From a5e8e69d181ef1c7258a4eafb913423b95a9e9f4 Mon Sep 17 00:00:00 2001 From: pash-openai Date: Fri, 13 Feb 2026 14:59:16 -0800 Subject: [PATCH 01/62] turn metadata followups (#11782) some trivial simplifications from #11677 --- codex-rs/core/src/codex.rs | 39 ++++++---------------------- codex-rs/core/src/compact.rs | 2 +- codex-rs/core/src/memories/phase1.rs | 2 +- codex-rs/core/src/tasks/mod.rs | 8 ++++-- codex-rs/core/src/turn_metadata.rs | 11 +++++++- 5 files changed, 26 insertions(+), 36 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 59cbf57dd5..94e6e1ceb8 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -31,7 +31,6 @@ use crate::models_manager::manager::ModelsManager; use crate::parse_command::parse_command; use crate::parse_turn_item; use crate::rollout::session_index; -use crate::sandbox_tags::sandbox_tag; use crate::stream_events_utils::HandleOutputCtx; use crate::stream_events_utils::handle_non_tool_response_item; use crate::stream_events_utils::handle_output_item_done; @@ -556,7 +555,7 @@ pub(crate) struct TurnContext { pub(crate) truncation_policy: TruncationPolicy, pub(crate) js_repl: Arc, pub(crate) dynamic_tools: Vec, - turn_metadata_state: Arc, + pub(crate) turn_metadata_state: Arc, } impl TurnContext { pub(crate) fn model_context_window(&self) -> Option { @@ -686,18 +685,6 @@ impl TurnContext { denied_domains: network.denied_domains.clone().unwrap_or_default(), }) } - - pub fn current_turn_metadata_header(&self) -> Option { - self.turn_metadata_state.current_header_value() - } - - pub fn spawn_turn_metadata_enrichment_task(self: &Arc) { - self.turn_metadata_state.spawn_git_enrichment_task(); - } - - pub fn cancel_turn_metadata_enrichment_task(&self) { - self.turn_metadata_state.cancel_git_enrichment_task(); - } } #[derive(Clone)] @@ -926,13 +913,8 @@ impl Session { let turn_metadata_state = Arc::new(TurnMetadataState::new( sub_id.clone(), cwd.clone(), - Some( - sandbox_tag( - session_configuration.sandbox_policy.get(), - session_configuration.windows_sandbox_level, - ) - .to_string(), - ), + session_configuration.sandbox_policy.get(), + session_configuration.windows_sandbox_level, )); TurnContext { sub_id, @@ -1758,7 +1740,7 @@ impl Session { turn_context.final_output_json_schema = final_schema; } let turn_context = Arc::new(turn_context); - turn_context.spawn_turn_metadata_enrichment_task(); + turn_context.turn_metadata_state.spawn_git_enrichment_task(); turn_context } @@ -4009,13 +3991,8 @@ async fn spawn_review_thread( let turn_metadata_state = Arc::new(TurnMetadataState::new( review_turn_id.clone(), parent_turn_context.cwd.clone(), - Some( - sandbox_tag( - &parent_turn_context.sandbox_policy, - parent_turn_context.windows_sandbox_level, - ) - .to_string(), - ), + &parent_turn_context.sandbox_policy, + parent_turn_context.windows_sandbox_level, )); let review_turn_context = TurnContext { @@ -4058,7 +4035,7 @@ async fn spawn_review_thread( text_elements: Vec::new(), }]; let tc = Arc::new(review_turn_context); - tc.spawn_turn_metadata_enrichment_task(); + tc.turn_metadata_state.spawn_git_enrichment_task(); sess.spawn_task(tc.clone(), input, ReviewTask::new()).await; // Announce entering review mode so UIs can switch modes. @@ -4341,7 +4318,7 @@ pub(crate) async fn run_turn( }) .map(|user_message| user_message.message()) .collect::>(); - let turn_metadata_header = turn_context.current_turn_metadata_header(); + let turn_metadata_header = turn_context.turn_metadata_state.current_header_value(); match run_sampling_request( Arc::clone(&sess), Arc::clone(&turn_context), diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 08932bf7a3..eaee299f07 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -109,7 +109,7 @@ async fn run_compact_task_inner( personality: turn_context.personality, ..Default::default() }; - let turn_metadata_header = turn_context.current_turn_metadata_header(); + let turn_metadata_header = turn_context.turn_metadata_state.current_header_value(); let attempt_result = drain_to_completed( &sess, turn_context.as_ref(), diff --git a/codex-rs/core/src/memories/phase1.rs b/codex-rs/core/src/memories/phase1.rs index 17e0359c98..146dc817ff 100644 --- a/codex-rs/core/src/memories/phase1.rs +++ b/codex-rs/core/src/memories/phase1.rs @@ -198,7 +198,7 @@ async fn build_request_context(session: &Arc, config: &Config) -> Reque let turn_context = session.new_default_turn().await; RequestContext::from_turn_context( turn_context.as_ref(), - turn_context.current_turn_metadata_header(), + turn_context.turn_metadata_state.current_header_value(), model, ) } diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index 17961d38bd..e42315719d 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -197,7 +197,9 @@ impl Session { turn_context: Arc, last_agent_message: Option, ) { - turn_context.cancel_turn_metadata_enrichment_task(); + turn_context + .turn_metadata_state + .cancel_git_enrichment_task(); let mut active = self.active_turn.lock().await; let mut pending_input = Vec::::new(); @@ -262,7 +264,9 @@ impl Session { trace!(task_kind = ?task.kind, sub_id, "aborting running task"); task.cancellation_token.cancel(); - task.turn_context.cancel_turn_metadata_enrichment_task(); + task.turn_context + .turn_metadata_state + .cancel_git_enrichment_task(); let session_task = task.task; select! { diff --git a/codex-rs/core/src/turn_metadata.rs b/codex-rs/core/src/turn_metadata.rs index dd4d717449..249fdeb60e 100644 --- a/codex-rs/core/src/turn_metadata.rs +++ b/codex-rs/core/src/turn_metadata.rs @@ -12,6 +12,9 @@ use crate::git_info::get_git_remote_urls_assume_git_repo; use crate::git_info::get_git_repo_root; use crate::git_info::get_has_changes; use crate::git_info::get_head_commit_hash; +use crate::sandbox_tags::sandbox_tag; +use codex_protocol::config_types::WindowsSandboxLevel; +use codex_protocol::protocol::SandboxPolicy; #[derive(Clone, Debug, Default)] struct WorkspaceGitMetadata { @@ -124,8 +127,14 @@ pub(crate) struct TurnMetadataState { } impl TurnMetadataState { - pub(crate) fn new(turn_id: String, cwd: PathBuf, sandbox: Option) -> Self { + pub(crate) fn new( + turn_id: String, + cwd: PathBuf, + sandbox_policy: &SandboxPolicy, + windows_sandbox_level: WindowsSandboxLevel, + ) -> Self { let repo_root = get_git_repo_root(&cwd).map(|root| root.to_string_lossy().into_owned()); + let sandbox = Some(sandbox_tag(sandbox_policy, windows_sandbox_level).to_string()); let base_metadata = build_turn_metadata_bag(Some(turn_id), sandbox, None, None); let base_header = base_metadata .to_header_value() From e4f8263798dcf97e585b5ba48bdc6a8f73a95727 Mon Sep 17 00:00:00 2001 From: Jeremy Rose <172423086+nornagon-openai@users.noreply.github.com> Date: Fri, 13 Feb 2026 15:08:14 -0800 Subject: [PATCH 02/62] [app-server] add fuzzyFileSearch/sessionCompleted (#11773) this is to allow the client to know when to stop showing a spinner. --- ...ileSearchSessionCompletedNotification.json | 13 +++++ .../schema/json/ServerNotification.json | 31 ++++++++++ .../codex_app_server_protocol.schemas.json | 33 +++++++++++ ...yFileSearchSessionCompletedNotification.ts | 5 ++ .../schema/typescript/ServerNotification.ts | 3 +- .../schema/typescript/index.ts | 1 + .../src/protocol/common.rs | 8 +++ codex-rs/app-server/README.md | 7 +++ codex-rs/app-server/src/fuzzy_file_search.rs | 19 ++++++- .../tests/suite/fuzzy_file_search.rs | 56 +++++++++++++++++++ 10 files changed, 174 insertions(+), 2 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/json/FuzzyFileSearchSessionCompletedNotification.json create mode 100644 codex-rs/app-server-protocol/schema/typescript/FuzzyFileSearchSessionCompletedNotification.ts diff --git a/codex-rs/app-server-protocol/schema/json/FuzzyFileSearchSessionCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/FuzzyFileSearchSessionCompletedNotification.json new file mode 100644 index 0000000000..c8924e77ca --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/FuzzyFileSearchSessionCompletedNotification.json @@ -0,0 +1,13 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "sessionId": { + "type": "string" + } + }, + "required": [ + "sessionId" + ], + "title": "FuzzyFileSearchSessionCompletedNotification", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 3277e5650f..c6b84ac5dc 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -3752,6 +3752,17 @@ ], "type": "object" }, + "FuzzyFileSearchSessionCompletedNotification": { + "properties": { + "sessionId": { + "type": "string" + } + }, + "required": [ + "sessionId" + ], + "type": "object" + }, "FuzzyFileSearchSessionUpdatedNotification": { "properties": { "files": { @@ -8290,6 +8301,26 @@ "title": "FuzzyFileSearch/sessionUpdatedNotification", "type": "object" }, + { + "properties": { + "method": { + "enum": [ + "fuzzyFileSearch/sessionCompleted" + ], + "title": "FuzzyFileSearch/sessionCompletedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/FuzzyFileSearchSessionCompletedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "FuzzyFileSearch/sessionCompletedNotification", + "type": "object" + }, { "description": "Notifies the user of world-writable directories on Windows, which cannot be protected by the sandbox.", "properties": { 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 d808219e40..f2c2ca55d5 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 @@ -5412,6 +5412,19 @@ ], "type": "object" }, + "FuzzyFileSearchSessionCompletedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "sessionId": { + "type": "string" + } + }, + "required": [ + "sessionId" + ], + "title": "FuzzyFileSearchSessionCompletedNotification", + "type": "object" + }, "FuzzyFileSearchSessionUpdatedNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -8503,6 +8516,26 @@ "title": "FuzzyFileSearch/sessionUpdatedNotification", "type": "object" }, + { + "properties": { + "method": { + "enum": [ + "fuzzyFileSearch/sessionCompleted" + ], + "title": "FuzzyFileSearch/sessionCompletedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/FuzzyFileSearchSessionCompletedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "FuzzyFileSearch/sessionCompletedNotification", + "type": "object" + }, { "description": "Notifies the user of world-writable directories on Windows, which cannot be protected by the sandbox.", "properties": { diff --git a/codex-rs/app-server-protocol/schema/typescript/FuzzyFileSearchSessionCompletedNotification.ts b/codex-rs/app-server-protocol/schema/typescript/FuzzyFileSearchSessionCompletedNotification.ts new file mode 100644 index 0000000000..f4dc7fac11 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/FuzzyFileSearchSessionCompletedNotification.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 FuzzyFileSearchSessionCompletedNotification = { sessionId: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts b/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts index 0e49c060c0..ffc39598b6 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts @@ -2,6 +2,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { AuthStatusChangeNotification } from "./AuthStatusChangeNotification"; +import type { FuzzyFileSearchSessionCompletedNotification } from "./FuzzyFileSearchSessionCompletedNotification"; import type { FuzzyFileSearchSessionUpdatedNotification } from "./FuzzyFileSearchSessionUpdatedNotification"; import type { LoginChatGptCompleteNotification } from "./LoginChatGptCompleteNotification"; import type { SessionConfiguredNotification } from "./SessionConfiguredNotification"; @@ -38,4 +39,4 @@ import type { WindowsWorldWritableWarningNotification } from "./v2/WindowsWorldW /** * Notification sent from the server to the client. */ -export type ServerNotification = { "method": "error", "params": ErrorNotification } | { "method": "thread/started", "params": ThreadStartedNotification } | { "method": "thread/name/updated", "params": ThreadNameUpdatedNotification } | { "method": "thread/tokenUsage/updated", "params": ThreadTokenUsageUpdatedNotification } | { "method": "turn/started", "params": TurnStartedNotification } | { "method": "turn/completed", "params": TurnCompletedNotification } | { "method": "turn/diff/updated", "params": TurnDiffUpdatedNotification } | { "method": "turn/plan/updated", "params": TurnPlanUpdatedNotification } | { "method": "item/started", "params": ItemStartedNotification } | { "method": "item/completed", "params": ItemCompletedNotification } | { "method": "rawResponseItem/completed", "params": RawResponseItemCompletedNotification } | { "method": "item/agentMessage/delta", "params": AgentMessageDeltaNotification } | { "method": "item/plan/delta", "params": PlanDeltaNotification } | { "method": "item/commandExecution/outputDelta", "params": CommandExecutionOutputDeltaNotification } | { "method": "item/commandExecution/terminalInteraction", "params": TerminalInteractionNotification } | { "method": "item/fileChange/outputDelta", "params": FileChangeOutputDeltaNotification } | { "method": "item/mcpToolCall/progress", "params": McpToolCallProgressNotification } | { "method": "mcpServer/oauthLogin/completed", "params": McpServerOauthLoginCompletedNotification } | { "method": "account/updated", "params": AccountUpdatedNotification } | { "method": "account/rateLimits/updated", "params": AccountRateLimitsUpdatedNotification } | { "method": "app/list/updated", "params": AppListUpdatedNotification } | { "method": "item/reasoning/summaryTextDelta", "params": ReasoningSummaryTextDeltaNotification } | { "method": "item/reasoning/summaryPartAdded", "params": ReasoningSummaryPartAddedNotification } | { "method": "item/reasoning/textDelta", "params": ReasoningTextDeltaNotification } | { "method": "thread/compacted", "params": ContextCompactedNotification } | { "method": "deprecationNotice", "params": DeprecationNoticeNotification } | { "method": "configWarning", "params": ConfigWarningNotification } | { "method": "fuzzyFileSearch/sessionUpdated", "params": FuzzyFileSearchSessionUpdatedNotification } | { "method": "windows/worldWritableWarning", "params": WindowsWorldWritableWarningNotification } | { "method": "account/login/completed", "params": AccountLoginCompletedNotification } | { "method": "authStatusChange", "params": AuthStatusChangeNotification } | { "method": "loginChatGptComplete", "params": LoginChatGptCompleteNotification } | { "method": "sessionConfigured", "params": SessionConfiguredNotification }; +export type ServerNotification = { "method": "error", "params": ErrorNotification } | { "method": "thread/started", "params": ThreadStartedNotification } | { "method": "thread/name/updated", "params": ThreadNameUpdatedNotification } | { "method": "thread/tokenUsage/updated", "params": ThreadTokenUsageUpdatedNotification } | { "method": "turn/started", "params": TurnStartedNotification } | { "method": "turn/completed", "params": TurnCompletedNotification } | { "method": "turn/diff/updated", "params": TurnDiffUpdatedNotification } | { "method": "turn/plan/updated", "params": TurnPlanUpdatedNotification } | { "method": "item/started", "params": ItemStartedNotification } | { "method": "item/completed", "params": ItemCompletedNotification } | { "method": "rawResponseItem/completed", "params": RawResponseItemCompletedNotification } | { "method": "item/agentMessage/delta", "params": AgentMessageDeltaNotification } | { "method": "item/plan/delta", "params": PlanDeltaNotification } | { "method": "item/commandExecution/outputDelta", "params": CommandExecutionOutputDeltaNotification } | { "method": "item/commandExecution/terminalInteraction", "params": TerminalInteractionNotification } | { "method": "item/fileChange/outputDelta", "params": FileChangeOutputDeltaNotification } | { "method": "item/mcpToolCall/progress", "params": McpToolCallProgressNotification } | { "method": "mcpServer/oauthLogin/completed", "params": McpServerOauthLoginCompletedNotification } | { "method": "account/updated", "params": AccountUpdatedNotification } | { "method": "account/rateLimits/updated", "params": AccountRateLimitsUpdatedNotification } | { "method": "app/list/updated", "params": AppListUpdatedNotification } | { "method": "item/reasoning/summaryTextDelta", "params": ReasoningSummaryTextDeltaNotification } | { "method": "item/reasoning/summaryPartAdded", "params": ReasoningSummaryPartAddedNotification } | { "method": "item/reasoning/textDelta", "params": ReasoningTextDeltaNotification } | { "method": "thread/compacted", "params": ContextCompactedNotification } | { "method": "deprecationNotice", "params": DeprecationNoticeNotification } | { "method": "configWarning", "params": ConfigWarningNotification } | { "method": "fuzzyFileSearch/sessionUpdated", "params": FuzzyFileSearchSessionUpdatedNotification } | { "method": "fuzzyFileSearch/sessionCompleted", "params": FuzzyFileSearchSessionCompletedNotification } | { "method": "windows/worldWritableWarning", "params": WindowsWorldWritableWarningNotification } | { "method": "account/login/completed", "params": AccountLoginCompletedNotification } | { "method": "authStatusChange", "params": AuthStatusChangeNotification } | { "method": "loginChatGptComplete", "params": LoginChatGptCompleteNotification } | { "method": "sessionConfigured", "params": SessionConfiguredNotification }; diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index 2e7446a3db..ef17362ffd 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -78,6 +78,7 @@ export type { FunctionCallOutputPayload } from "./FunctionCallOutputPayload"; export type { FuzzyFileSearchParams } from "./FuzzyFileSearchParams"; export type { FuzzyFileSearchResponse } from "./FuzzyFileSearchResponse"; export type { FuzzyFileSearchResult } from "./FuzzyFileSearchResult"; +export type { FuzzyFileSearchSessionCompletedNotification } from "./FuzzyFileSearchSessionCompletedNotification"; export type { FuzzyFileSearchSessionUpdatedNotification } from "./FuzzyFileSearchSessionUpdatedNotification"; export type { GetAuthStatusParams } from "./GetAuthStatusParams"; export type { GetAuthStatusResponse } from "./GetAuthStatusResponse"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index bc91341166..af56408c8f 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -758,6 +758,13 @@ pub struct FuzzyFileSearchSessionUpdatedNotification { pub files: Vec, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(rename_all = "camelCase")] +pub struct FuzzyFileSearchSessionCompletedNotification { + pub session_id: String, +} + server_notification_definitions! { /// NEW NOTIFICATIONS Error => "error" (v2::ErrorNotification), @@ -791,6 +798,7 @@ server_notification_definitions! { DeprecationNotice => "deprecationNotice" (v2::DeprecationNoticeNotification), ConfigWarning => "configWarning" (v2::ConfigWarningNotification), FuzzyFileSearchSessionUpdated => "fuzzyFileSearch/sessionUpdated" (FuzzyFileSearchSessionUpdatedNotification), + FuzzyFileSearchSessionCompleted => "fuzzyFileSearch/sessionCompleted" (FuzzyFileSearchSessionCompletedNotification), /// Notifies the user of world-writable directories on Windows, which cannot be protected by the sandbox. WindowsWorldWritableWarning => "windows/worldWritableWarning" (v2::WindowsWorldWritableWarningNotification), diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 39ddc73c65..2805c65030 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -535,6 +535,13 @@ Examples: - Opt out of legacy session setup event: `codex/event/session_configured` - Opt out of streamed agent text deltas: `item/agentMessage/delta` +### Fuzzy file search events (experimental) + +The fuzzy file search session API emits per-query notifications: + +- `fuzzyFileSearch/sessionUpdated` — `{ sessionId, query, files }` with the current matching files for the active query. +- `fuzzyFileSearch/sessionCompleted` — `{ sessionId, query }` once indexing/matching for that query has completed. + ### Turn events The app-server streams JSON-RPC notifications while a turn is running. Each turn starts with `turn/started` (initial `turn`) and ends with `turn/completed` (final `turn` status). Token usage events stream separately via `thread/tokenUsage/updated`. Clients subscribe to the events they care about, rendering each item incrementally as updates arrive. The per-item lifecycle is always: `item/started` → zero or more item-specific deltas → `item/completed`. diff --git a/codex-rs/app-server/src/fuzzy_file_search.rs b/codex-rs/app-server/src/fuzzy_file_search.rs index cb1f61c362..d40d3fc242 100644 --- a/codex-rs/app-server/src/fuzzy_file_search.rs +++ b/codex-rs/app-server/src/fuzzy_file_search.rs @@ -6,6 +6,7 @@ use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; use codex_app_server_protocol::FuzzyFileSearchResult; +use codex_app_server_protocol::FuzzyFileSearchSessionCompletedNotification; use codex_app_server_protocol::FuzzyFileSearchSessionUpdatedNotification; use codex_app_server_protocol::ServerNotification; use codex_file_search as file_search; @@ -195,6 +196,20 @@ impl SessionReporterImpl { outgoing.send_server_notification(notification).await; }); } + + fn send_complete(&self) { + if self.shared.canceled.load(Ordering::Relaxed) { + return; + } + let session_id = self.shared.session_id.clone(); + let outgoing = self.shared.outgoing.clone(); + self.shared.runtime.spawn(async move { + let notification = ServerNotification::FuzzyFileSearchSessionCompleted( + FuzzyFileSearchSessionCompletedNotification { session_id }, + ); + outgoing.send_server_notification(notification).await; + }); + } } impl file_search::SessionReporter for SessionReporterImpl { @@ -202,7 +217,9 @@ impl file_search::SessionReporter for SessionReporterImpl { self.send_snapshot(snapshot); } - fn on_complete(&self) {} + fn on_complete(&self) { + self.send_complete(); + } } fn collect_files(snapshot: &file_search::FileSearchSnapshot) -> Vec { diff --git a/codex-rs/app-server/tests/suite/fuzzy_file_search.rs b/codex-rs/app-server/tests/suite/fuzzy_file_search.rs index ffa4fa2cca..b6932560f0 100644 --- a/codex-rs/app-server/tests/suite/fuzzy_file_search.rs +++ b/codex-rs/app-server/tests/suite/fuzzy_file_search.rs @@ -1,6 +1,7 @@ use anyhow::Result; use anyhow::anyhow; use app_test_support::McpProcess; +use codex_app_server_protocol::FuzzyFileSearchSessionCompletedNotification; use codex_app_server_protocol::FuzzyFileSearchSessionUpdatedNotification; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; @@ -13,6 +14,7 @@ const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs const SHORT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(500); const STOP_GRACE_PERIOD: std::time::Duration = std::time::Duration::from_millis(250); const SESSION_UPDATED_METHOD: &str = "fuzzyFileSearch/sessionUpdated"; +const SESSION_COMPLETED_METHOD: &str = "fuzzyFileSearch/sessionCompleted"; #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum FileExpectation { @@ -60,6 +62,29 @@ async fn wait_for_session_updated( ); } +async fn wait_for_session_completed( + mcp: &mut McpProcess, + session_id: &str, +) -> Result { + for _ in 0..20 { + let notification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message(SESSION_COMPLETED_METHOD), + ) + .await??; + let params = notification + .params + .ok_or_else(|| anyhow!("missing notification params"))?; + let payload = + serde_json::from_value::(params)?; + if payload.session_id == session_id { + return Ok(payload); + } + } + + anyhow::bail!("did not receive expected session completion for sessionId={session_id}"); +} + async fn assert_update_request_fails_for_missing_session( mcp: &mut McpProcess, session_id: &str, @@ -271,12 +296,41 @@ async fn test_fuzzy_file_search_session_streams_updates() -> Result<()> { assert_eq!(payload.files.len(), 1); assert_eq!(payload.files[0].root, root_path); assert_eq!(payload.files[0].path, "alpha.txt"); + let completed = wait_for_session_completed(&mut mcp, session_id).await?; + assert_eq!(completed.session_id, session_id); mcp.stop_fuzzy_file_search_session(session_id).await?; Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_fuzzy_file_search_session_no_updates_after_complete_until_query_edited() -> Result<()> +{ + let codex_home = TempDir::new()?; + let root = TempDir::new()?; + std::fs::write(root.path().join("alpha.txt"), "contents")?; + let mut mcp = initialized_mcp(&codex_home).await?; + + let root_path = root.path().to_string_lossy().to_string(); + let session_id = "session-complete-invariant"; + mcp.start_fuzzy_file_search_session(session_id, vec![root_path]) + .await?; + + mcp.update_fuzzy_file_search_session(session_id, "alp") + .await?; + wait_for_session_updated(&mut mcp, session_id, "alp", FileExpectation::NonEmpty).await?; + wait_for_session_completed(&mut mcp, session_id).await?; + assert_no_session_updates_for(&mut mcp, session_id, STOP_GRACE_PERIOD, SHORT_READ_TIMEOUT) + .await?; + + mcp.update_fuzzy_file_search_session(session_id, "alpha") + .await?; + wait_for_session_updated(&mut mcp, session_id, "alpha", FileExpectation::NonEmpty).await?; + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_fuzzy_file_search_session_update_before_start_errors() -> Result<()> { let codex_home = TempDir::new()?; @@ -345,6 +399,7 @@ async fn test_fuzzy_file_search_session_multiple_query_updates_work() -> Result< alp_payload.files.iter().all(|file| file.root == root_path), true ); + wait_for_session_completed(&mut mcp, session_id).await?; mcp.update_fuzzy_file_search_session(session_id, "zzzz") .await?; @@ -352,6 +407,7 @@ async fn test_fuzzy_file_search_session_multiple_query_updates_work() -> Result< wait_for_session_updated(&mut mcp, session_id, "zzzz", FileExpectation::Any).await?; assert_eq!(zzzz_payload.query, "zzzz"); assert_eq!(zzzz_payload.files.is_empty(), true); + wait_for_session_completed(&mut mcp, session_id).await?; Ok(()) } From fb0aaf94de7db12e22a293f5b0dd26aa6c8d6a8e Mon Sep 17 00:00:00 2001 From: Max Johnson <162359438+maxj-oai@users.noreply.github.com> Date: Fri, 13 Feb 2026 15:09:58 -0800 Subject: [PATCH 03/62] codex-rs: fix thread resume rejoin semantics (#11756) ## Summary - always rejoin an in-memory running thread on `thread/resume`, even when overrides are present - reject `thread/resume` when `history` is provided for a running thread - reject `thread/resume` when `path` mismatches the running thread rollout path - warn (but do not fail) on override mismatches for running threads - add more `thread_resume` integration tests and fixes; including restart-based resume-with-overrides coverage ## Validation - `just fmt` - `cargo test -p codex-app-server --test all thread_resume` - manual test with app-server-test-client https://github.com/openai/codex/pull/11755 - manual test both stdio and websocket in app --- codex-rs/app-server-test-client/README.md | 30 + .../app-server/src/codex_message_processor.rs | 498 ++++++++++++---- .../tests/suite/v2/thread_resume.rs | 531 ++++++++++++++++-- 3 files changed, 917 insertions(+), 142 deletions(-) diff --git a/codex-rs/app-server-test-client/README.md b/codex-rs/app-server-test-client/README.md index 5ef6e2344c..ab11f12353 100644 --- a/codex-rs/app-server-test-client/README.md +++ b/codex-rs/app-server-test-client/README.md @@ -17,3 +17,33 @@ cargo run -p codex-app-server-test-client -- \ # 3) Call app-server (defaults to ws://127.0.0.1:4222) cargo run -p codex-app-server-test-client -- model-list ``` + +## Testing Thread Rejoin Behavior + +Build and start an app server using commands above. The app-server log is written to `/tmp/codex-app-server-test-client/app-server.log` + +### 1) Get a thread id + +Create at least one thread, then list threads: + +```bash +cargo run -p codex-app-server-test-client -- send-message-v2 "seed thread for rejoin test" +cargo run -p codex-app-server-test-client -- thread-list --limit 5 +``` + +Copy a thread id from the `thread-list` output. + +### 2) Rejoin while a turn is in progress (two terminals) + +Terminal A: + +```bash +cargo run --bin codex-app-server-test-client -- \ + resume-message-v2 "respond with thorough docs on the rust core" +``` + +Terminal B (while Terminal A is still streaming): + +```bash +cargo run --bin codex-app-server-test-client -- thread-resume +``` diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 198892d0a2..156a2e3138 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -2689,6 +2689,13 @@ impl CodexMessageProcessor { } async fn thread_resume(&mut self, request_id: ConnectionRequestId, params: ThreadResumeParams) { + if self + .resume_running_thread(request_id.clone(), ¶ms) + .await + { + return; + } + let ThreadResumeParams { thread_id, history, @@ -2706,77 +2713,21 @@ impl CodexMessageProcessor { } = params; let thread_history = if let Some(history) = history { - if history.is_empty() { - self.send_invalid_request_error( - request_id, - "history must not be empty".to_string(), - ) - .await; + let Some(thread_history) = self + .resume_thread_from_history(request_id.clone(), history.as_slice()) + .await + else { return; - } - InitialHistory::Forked(history.into_iter().map(RolloutItem::ResponseItem).collect()) - } else if let Some(path) = path { - match RolloutRecorder::get_rollout_history(&path).await { - Ok(initial_history) => initial_history, - Err(err) => { - self.send_invalid_request_error( - request_id, - format!("failed to load rollout `{}`: {err}", path.display()), - ) - .await; - return; - } - } + }; + thread_history } else { - let existing_thread_id = match ThreadId::from_string(&thread_id) { - Ok(id) => id, - Err(err) => { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("invalid thread id: {err}"), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - return; - } + let Some(thread_history) = self + .resume_thread_from_rollout(request_id.clone(), &thread_id, path.as_ref()) + .await + else { + return; }; - - let path = match find_thread_path_by_id_str( - &self.config.codex_home, - &existing_thread_id.to_string(), - ) - .await - { - Ok(Some(p)) => p, - Ok(None) => { - self.send_invalid_request_error( - request_id, - format!("no rollout found for thread id {existing_thread_id}"), - ) - .await; - return; - } - Err(err) => { - self.send_invalid_request_error( - request_id, - format!("failed to locate thread id {existing_thread_id}: {err}"), - ) - .await; - return; - } - }; - - match RolloutRecorder::get_rollout_history(&path).await { - Ok(initial_history) => initial_history, - Err(err) => { - self.send_invalid_request_error( - request_id, - format!("failed to load rollout `{}`: {err}", path.display()), - ) - .await; - return; - } - } + thread_history }; let history_cwd = thread_history.session_cwd(); @@ -2857,41 +2808,17 @@ impl CodexMessageProcessor { ); } - let mut thread = match read_summary_from_rollout( - rollout_path.as_path(), - fallback_model_provider.as_str(), - ) - .await - { - Ok(summary) => summary_to_thread(summary), - Err(err) => { - self.send_internal_error( - request_id, - format!( - "failed to load rollout `{}` for thread {thread_id}: {err}", - rollout_path.display() - ), - ) - .await; - return; - } + let Some(thread) = self + .load_thread_from_rollout_or_send_internal( + request_id.clone(), + thread_id, + rollout_path.as_path(), + fallback_model_provider.as_str(), + ) + .await + else { + return; }; - match read_rollout_items_from_rollout(rollout_path.as_path()).await { - Ok(items) => { - thread.turns = build_turns_from_rollout_items(&items); - } - Err(err) => { - self.send_internal_error( - request_id, - format!( - "failed to load rollout `{}` for thread {thread_id}: {err}", - rollout_path.display() - ), - ) - .await; - return; - } - } let response = ThreadResumeResponse { thread, @@ -2916,6 +2843,278 @@ impl CodexMessageProcessor { } } + async fn resume_running_thread( + &mut self, + request_id: ConnectionRequestId, + params: &ThreadResumeParams, + ) -> bool { + if let Ok(existing_thread_id) = ThreadId::from_string(¶ms.thread_id) + && let Ok(existing_thread) = self.thread_manager.get_thread(existing_thread_id).await + { + if params.history.is_some() { + self.send_invalid_request_error( + request_id, + format!( + "cannot resume thread {existing_thread_id} with history while it is already running" + ), + ) + .await; + return true; + } + + let rollout_path = if let Some(path) = existing_thread.rollout_path() { + if path.exists() { + path + } else { + match find_thread_path_by_id_str( + &self.config.codex_home, + &existing_thread_id.to_string(), + ) + .await + { + Ok(Some(path)) => path, + Ok(None) => { + self.send_invalid_request_error( + request_id, + format!("no rollout found for thread id {existing_thread_id}"), + ) + .await; + return true; + } + Err(err) => { + self.send_invalid_request_error( + request_id, + format!("failed to locate thread id {existing_thread_id}: {err}"), + ) + .await; + return true; + } + } + } + } else { + match find_thread_path_by_id_str( + &self.config.codex_home, + &existing_thread_id.to_string(), + ) + .await + { + Ok(Some(path)) => path, + Ok(None) => { + self.send_invalid_request_error( + request_id, + format!("no rollout found for thread id {existing_thread_id}"), + ) + .await; + return true; + } + Err(err) => { + self.send_invalid_request_error( + request_id, + format!("failed to locate thread id {existing_thread_id}: {err}"), + ) + .await; + return true; + } + } + }; + + if let Some(requested_path) = params.path.as_ref() + && requested_path != &rollout_path + { + self.send_invalid_request_error( + request_id, + format!( + "cannot resume running thread {existing_thread_id} with mismatched path: requested `{}`, active `{}`", + requested_path.display(), + rollout_path.display() + ), + ) + .await; + return true; + } + + if let Err(err) = self + .ensure_conversation_listener( + existing_thread_id, + request_id.connection_id, + false, + ApiVersion::V2, + ) + .await + { + tracing::warn!( + "failed to attach listener for thread {}: {}", + existing_thread_id, + err.message + ); + } + + 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 Some(thread) = self + .load_thread_from_rollout_or_send_internal( + request_id.clone(), + existing_thread_id, + rollout_path.as_path(), + config_snapshot.model_provider_id.as_str(), + ) + .await + else { + return true; + }; + + let ThreadConfigSnapshot { + model, + model_provider_id, + approval_policy, + sandbox_policy, + cwd, + reasoning_effort, + .. + } = config_snapshot; + let response = ThreadResumeResponse { + thread, + model, + model_provider: model_provider_id, + cwd, + approval_policy: approval_policy.into(), + sandbox: sandbox_policy.into(), + reasoning_effort, + }; + self.outgoing.send_response(request_id, response).await; + return true; + } + false + } + + async fn resume_thread_from_history( + &self, + request_id: ConnectionRequestId, + history: &[ResponseItem], + ) -> Option { + if history.is_empty() { + self.send_invalid_request_error(request_id, "history must not be empty".to_string()) + .await; + return None; + } + Some(InitialHistory::Forked( + history + .iter() + .cloned() + .map(RolloutItem::ResponseItem) + .collect(), + )) + } + + async fn resume_thread_from_rollout( + &self, + request_id: ConnectionRequestId, + thread_id: &str, + path: Option<&PathBuf>, + ) -> Option { + let rollout_path = if let Some(path) = path { + path.clone() + } else { + let existing_thread_id = match ThreadId::from_string(thread_id) { + Ok(id) => id, + Err(err) => { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!("invalid thread id: {err}"), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return None; + } + }; + + match find_thread_path_by_id_str( + &self.config.codex_home, + &existing_thread_id.to_string(), + ) + .await + { + Ok(Some(path)) => path, + Ok(None) => { + self.send_invalid_request_error( + request_id, + format!("no rollout found for thread id {existing_thread_id}"), + ) + .await; + return None; + } + Err(err) => { + self.send_invalid_request_error( + request_id, + format!("failed to locate thread id {existing_thread_id}: {err}"), + ) + .await; + return None; + } + } + }; + + match RolloutRecorder::get_rollout_history(&rollout_path).await { + Ok(initial_history) => Some(initial_history), + Err(err) => { + self.send_invalid_request_error( + request_id, + format!("failed to load rollout `{}`: {err}", rollout_path.display()), + ) + .await; + None + } + } + } + + async fn load_thread_from_rollout_or_send_internal( + &self, + request_id: ConnectionRequestId, + thread_id: ThreadId, + rollout_path: &Path, + fallback_provider: &str, + ) -> Option { + let mut thread = match read_summary_from_rollout(rollout_path, fallback_provider).await { + Ok(summary) => summary_to_thread(summary), + Err(err) => { + self.send_internal_error( + request_id, + format!( + "failed to load rollout `{}` for thread {thread_id}: {err}", + rollout_path.display() + ), + ) + .await; + return None; + } + }; + match read_rollout_items_from_rollout(rollout_path).await { + Ok(items) => { + thread.turns = build_turns_from_rollout_items(&items); + Some(thread) + } + Err(err) => { + self.send_internal_error( + request_id, + format!( + "failed to load rollout `{}` for thread {thread_id}: {err}", + rollout_path.display() + ), + ) + .await; + None + } + } + } + async fn thread_fork(&mut self, request_id: ConnectionRequestId, params: ThreadForkParams) { let ThreadForkParams { thread_id, @@ -5808,6 +6007,101 @@ impl CodexMessageProcessor { } } +fn collect_resume_override_mismatches( + request: &ThreadResumeParams, + config_snapshot: &ThreadConfigSnapshot, +) -> Vec { + let mut mismatch_details = Vec::new(); + + if let Some(requested_model) = request.model.as_deref() + && requested_model != config_snapshot.model + { + mismatch_details.push(format!( + "model requested={requested_model} active={}", + config_snapshot.model + )); + } + if let Some(requested_provider) = request.model_provider.as_deref() + && requested_provider != config_snapshot.model_provider_id + { + mismatch_details.push(format!( + "model_provider requested={requested_provider} active={}", + config_snapshot.model_provider_id + )); + } + if let Some(requested_cwd) = request.cwd.as_deref() { + let requested_cwd_path = std::path::PathBuf::from(requested_cwd); + if requested_cwd_path != config_snapshot.cwd { + mismatch_details.push(format!( + "cwd requested={} active={}", + requested_cwd_path.display(), + config_snapshot.cwd.display() + )); + } + } + if let Some(requested_approval) = request.approval_policy.as_ref() { + let active_approval: AskForApproval = config_snapshot.approval_policy.into(); + if requested_approval != &active_approval { + mismatch_details.push(format!( + "approval_policy requested={requested_approval:?} active={active_approval:?}" + )); + } + } + if let Some(requested_sandbox) = request.sandbox.as_ref() { + let sandbox_matches = matches!( + (requested_sandbox, &config_snapshot.sandbox_policy), + ( + SandboxMode::ReadOnly, + codex_protocol::protocol::SandboxPolicy::ReadOnly { .. } + ) | ( + SandboxMode::WorkspaceWrite, + codex_protocol::protocol::SandboxPolicy::WorkspaceWrite { .. } + ) | ( + SandboxMode::DangerFullAccess, + codex_protocol::protocol::SandboxPolicy::DangerFullAccess + ) | ( + SandboxMode::DangerFullAccess, + codex_protocol::protocol::SandboxPolicy::ExternalSandbox { .. } + ) + ); + if !sandbox_matches { + mismatch_details.push(format!( + "sandbox requested={requested_sandbox:?} active={:?}", + config_snapshot.sandbox_policy + )); + } + } + if let Some(requested_personality) = request.personality.as_ref() + && config_snapshot.personality.as_ref() != Some(requested_personality) + { + mismatch_details.push(format!( + "personality requested={requested_personality:?} active={:?}", + config_snapshot.personality + )); + } + + if request.config.is_some() { + mismatch_details + .push("config overrides were provided and ignored while running".to_string()); + } + if request.base_instructions.is_some() { + mismatch_details + .push("baseInstructions override was provided and ignored while running".to_string()); + } + if request.developer_instructions.is_some() { + mismatch_details.push( + "developerInstructions override was provided and ignored while running".to_string(), + ); + } + if request.persist_extended_history { + mismatch_details.push( + "persistExtendedHistory override was provided and ignored while running".to_string(), + ); + } + + mismatch_details +} + fn skills_to_info( skills: &[codex_core::skills::SkillMetadata], disabled_paths: &std::collections::HashSet, 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 a92d24ed08..e001d4a7fd 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -209,18 +209,412 @@ async fn thread_resume_without_overrides_does_not_change_updated_at_or_mtime() - Ok(()) } +#[tokio::test] +async fn thread_resume_keeps_in_flight_turn_streaming() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut primary = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, primary.initialize()).await??; + + let start_id = primary + .send_thread_start_request(ThreadStartParams { + model: Some("gpt-5.1-codex-max".to_string()), + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; + + let seed_turn_id = primary + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![UserInput::Text { + text: "seed history".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_response_message(RequestId::Integer(seed_turn_id)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_notification_message("turn/completed"), + ) + .await??; + primary.clear_message_buffer(); + + let mut secondary = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, secondary.initialize()).await??; + + let turn_id = primary + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![UserInput::Text { + text: "respond with docs".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_response_message(RequestId::Integer(turn_id)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_notification_message("turn/started"), + ) + .await??; + + let resume_id = secondary + .send_thread_resume_request(ThreadResumeParams { + thread_id: thread.id, + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + secondary.read_stream_until_response_message(RequestId::Integer(resume_id)), + ) + .await??; + + timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + Ok(()) +} + +#[tokio::test] +async fn thread_resume_rejects_history_when_thread_is_running() -> Result<()> { + let server = responses::start_mock_server().await; + let first_body = responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_assistant_message("msg-1", "Done"), + responses::ev_completed("resp-1"), + ]); + let second_body = responses::sse(vec![responses::ev_response_created("resp-2")]); + let _first_response_mock = responses::mount_sse_once(&server, first_body).await; + let _second_response_mock = responses::mount_sse_once(&server, second_body).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut primary = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, primary.initialize()).await??; + + let start_id = primary + .send_thread_start_request(ThreadStartParams { + model: Some("gpt-5.1-codex-max".to_string()), + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; + + let seed_turn_id = primary + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![UserInput::Text { + text: "seed history".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_response_message(RequestId::Integer(seed_turn_id)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_notification_message("turn/completed"), + ) + .await??; + primary.clear_message_buffer(); + + let running_turn_id = primary + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![UserInput::Text { + text: "keep running".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_response_message(RequestId::Integer(running_turn_id)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_notification_message("turn/started"), + ) + .await??; + + let resume_id = primary + .send_thread_resume_request(ThreadResumeParams { + thread_id: thread.id, + history: Some(vec![ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "history override".to_string(), + }], + end_turn: None, + phase: None, + }]), + ..Default::default() + }) + .await?; + let resume_err: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_error_message(RequestId::Integer(resume_id)), + ) + .await??; + assert!( + resume_err.error.message.contains("cannot resume thread") + && resume_err.error.message.contains("with history") + && resume_err.error.message.contains("running"), + "unexpected resume error: {}", + resume_err.error.message + ); + + Ok(()) +} + +#[tokio::test] +async fn thread_resume_rejects_mismatched_path_when_thread_is_running() -> Result<()> { + let server = responses::start_mock_server().await; + let first_body = responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_assistant_message("msg-1", "Done"), + responses::ev_completed("resp-1"), + ]); + let second_body = responses::sse(vec![responses::ev_response_created("resp-2")]); + let _first_response_mock = responses::mount_sse_once(&server, first_body).await; + let _second_response_mock = responses::mount_sse_once(&server, second_body).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut primary = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, primary.initialize()).await??; + + let start_id = primary + .send_thread_start_request(ThreadStartParams { + model: Some("gpt-5.1-codex-max".to_string()), + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; + + let seed_turn_id = primary + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![UserInput::Text { + text: "seed history".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_response_message(RequestId::Integer(seed_turn_id)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_notification_message("turn/completed"), + ) + .await??; + primary.clear_message_buffer(); + + let running_turn_id = primary + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![UserInput::Text { + text: "keep running".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_response_message(RequestId::Integer(running_turn_id)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_notification_message("turn/started"), + ) + .await??; + + let resume_id = primary + .send_thread_resume_request(ThreadResumeParams { + thread_id: thread.id, + path: Some(PathBuf::from("/tmp/does-not-match-running-rollout.jsonl")), + ..Default::default() + }) + .await?; + let resume_err: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_error_message(RequestId::Integer(resume_id)), + ) + .await??; + assert!( + resume_err.error.message.contains("mismatched path"), + "unexpected resume error: {}", + resume_err.error.message + ); + + Ok(()) +} + +#[tokio::test] +async fn thread_resume_rejoins_running_thread_even_with_override_mismatch() -> Result<()> { + let server = responses::start_mock_server().await; + let first_body = responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_assistant_message("msg-1", "Done"), + responses::ev_completed("resp-1"), + ]); + let second_body = responses::sse(vec![responses::ev_response_created("resp-2")]); + let _response_mock = + responses::mount_sse_sequence(&server, vec![first_body, second_body]).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut primary = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, primary.initialize()).await??; + + let start_id = primary + .send_thread_start_request(ThreadStartParams { + model: Some("gpt-5.1-codex-max".to_string()), + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; + + let seed_turn_id = primary + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![UserInput::Text { + text: "seed history".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_response_message(RequestId::Integer(seed_turn_id)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_notification_message("turn/completed"), + ) + .await??; + primary.clear_message_buffer(); + + let running_turn_id = primary + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![UserInput::Text { + text: "keep running".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_response_message(RequestId::Integer(running_turn_id)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_notification_message("turn/started"), + ) + .await??; + + let resume_id = primary + .send_thread_resume_request(ThreadResumeParams { + thread_id: thread.id.clone(), + model: Some("not-the-running-model".to_string()), + cwd: Some("/tmp".to_string()), + ..Default::default() + }) + .await?; + let resume_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_response_message(RequestId::Integer(resume_id)), + ) + .await??; + let ThreadResumeResponse { model, .. } = to_response::(resume_resp)?; + assert_eq!(model, "gpt-5.1-codex-max"); + + timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + Ok(()) +} + #[tokio::test] async fn thread_resume_with_overrides_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())?; + create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = McpProcess::new(codex_home.path()).await?; - timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + let RestartedThreadFixture { + mut mcp, + thread_id, + rollout_file_path, + } = start_materialized_thread_and_restart(codex_home.path(), "materialize").await?; + let expected_updated_at_rfc3339 = "2025-01-07T00:00:00Z"; + set_rollout_mtime(rollout_file_path.as_path(), expected_updated_at_rfc3339)?; + let before_modified = std::fs::metadata(&rollout_file_path)?.modified()?; + let expected_updated_at = chrono::DateTime::parse_from_rfc3339(expected_updated_at_rfc3339)? + .with_timezone(&Utc) + .timestamp(); let resume_id = mcp .send_thread_resume_request(ThreadResumeParams { - thread_id: rollout.conversation_id.clone(), + thread_id, model: Some("mock-model".to_string()), ..Default::default() }) @@ -230,16 +624,19 @@ async fn thread_resume_with_overrides_defers_updated_at_until_turn_start() -> Re mcp.read_stream_until_response_message(RequestId::Integer(resume_id)), ) .await??; - let ThreadResumeResponse { thread, .. } = to_response::(resume_resp)?; + let ThreadResumeResponse { + thread: resumed_thread, + .. + } = to_response::(resume_resp)?; - assert_eq!(thread.updated_at, rollout.expected_updated_at); + assert_eq!(resumed_thread.updated_at, expected_updated_at); - let after_resume_modified = std::fs::metadata(&rollout.rollout_file_path)?.modified()?; - assert_eq!(after_resume_modified, rollout.before_modified); + let after_resume_modified = std::fs::metadata(&rollout_file_path)?.modified()?; + assert_eq!(after_resume_modified, before_modified); let turn_id = mcp .send_turn_start_request(TurnStartParams { - thread_id: rollout.conversation_id, + thread_id: resumed_thread.id, input: vec![UserInput::Text { text: "Hello".to_string(), text_elements: Vec::new(), @@ -258,8 +655,8 @@ async fn thread_resume_with_overrides_defers_updated_at_until_turn_start() -> Re ) .await??; - let after_turn_modified = std::fs::metadata(&rollout.rollout_file_path)?.modified()?; - assert!(after_turn_modified > rollout.before_modified); + let after_turn_modified = std::fs::metadata(&rollout_file_path)?.modified()?; + assert!(after_turn_modified > before_modified); Ok(()) } @@ -374,22 +771,9 @@ async fn thread_resume_supports_history_and_overrides() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = McpProcess::new(codex_home.path()).await?; - timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; - - // Start a thread. - let start_id = mcp - .send_thread_start_request(ThreadStartParams { - model: Some("gpt-5.1-codex-max".to_string()), - ..Default::default() - }) - .await?; - let start_resp: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(start_id)), - ) - .await??; - let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; + let RestartedThreadFixture { + mut mcp, thread_id, .. + } = start_materialized_thread_and_restart(codex_home.path(), "seed history").await?; let history_text = "Hello from history"; let history = vec![ResponseItem::Message { @@ -405,7 +789,7 @@ async fn thread_resume_supports_history_and_overrides() -> Result<()> { // Resume with explicit history and override the model. let resume_id = mcp .send_thread_resume_request(ThreadResumeParams { - thread_id: thread.id, + thread_id, history: Some(history), model: Some("mock-model".to_string()), model_provider: Some("mock_provider".to_string()), @@ -429,6 +813,70 @@ async fn thread_resume_supports_history_and_overrides() -> Result<()> { Ok(()) } +struct RestartedThreadFixture { + mcp: McpProcess, + thread_id: String, + rollout_file_path: PathBuf, +} + +async fn start_materialized_thread_and_restart( + codex_home: &Path, + seed_text: &str, +) -> Result { + let mut first_mcp = McpProcess::new(codex_home).await?; + timeout(DEFAULT_READ_TIMEOUT, first_mcp.initialize()).await??; + + let start_id = first_mcp + .send_thread_start_request(ThreadStartParams { + model: Some("gpt-5.1-codex-max".to_string()), + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + first_mcp.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; + + let materialize_turn_id = first_mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![UserInput::Text { + text: seed_text.to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + first_mcp.read_stream_until_response_message(RequestId::Integer(materialize_turn_id)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + first_mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let thread_id = thread.id; + let rollout_file_path = thread + .path + .ok_or_else(|| anyhow::anyhow!("thread path missing from thread/start response"))?; + + drop(first_mcp); + + let mut second_mcp = McpProcess::new(codex_home).await?; + timeout(DEFAULT_READ_TIMEOUT, second_mcp.initialize()).await??; + + Ok(RestartedThreadFixture { + mcp: second_mcp, + thread_id, + rollout_file_path, + }) +} + #[tokio::test] async fn thread_resume_accepts_personality_override() -> Result<()> { skip_if_no_network!(Ok(())); @@ -449,10 +897,10 @@ async fn thread_resume_accepts_personality_override() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = McpProcess::new(codex_home.path()).await?; - timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + let mut primary = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, primary.initialize()).await??; - let start_id = mcp + let start_id = primary .send_thread_start_request(ThreadStartParams { model: Some("gpt-5.2-codex".to_string()), ..Default::default() @@ -460,12 +908,12 @@ async fn thread_resume_accepts_personality_override() -> Result<()> { .await?; let start_resp: JSONRPCResponse = timeout( DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(start_id)), + primary.read_stream_until_response_message(RequestId::Integer(start_id)), ) .await??; let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; - let materialize_id = mcp + let materialize_id = primary .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), input: vec![UserInput::Text { @@ -477,16 +925,19 @@ async fn thread_resume_accepts_personality_override() -> Result<()> { .await?; timeout( DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(materialize_id)), + primary.read_stream_until_response_message(RequestId::Integer(materialize_id)), ) .await??; timeout( DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_notification_message("turn/completed"), + primary.read_stream_until_notification_message("turn/completed"), ) .await??; - let resume_id = mcp + let mut secondary = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, secondary.initialize()).await??; + + let resume_id = secondary .send_thread_resume_request(ThreadResumeParams { thread_id: thread.id, model: Some("gpt-5.2-codex".to_string()), @@ -496,12 +947,12 @@ async fn thread_resume_accepts_personality_override() -> Result<()> { .await?; let resume_resp: JSONRPCResponse = timeout( DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(resume_id)), + secondary.read_stream_until_response_message(RequestId::Integer(resume_id)), ) .await??; let resume: ThreadResumeResponse = to_response::(resume_resp)?; - let turn_id = mcp + let turn_id = secondary .send_turn_start_request(TurnStartParams { thread_id: resume.thread.id, input: vec![UserInput::Text { @@ -513,13 +964,13 @@ async fn thread_resume_accepts_personality_override() -> Result<()> { .await?; timeout( DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(turn_id)), + secondary.read_stream_until_response_message(RequestId::Integer(turn_id)), ) .await??; timeout( DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_notification_message("turn/completed"), + secondary.read_stream_until_notification_message("turn/completed"), ) .await??; From 6b466df1469388a692a1b0a23cfc11ad0a3cb436 Mon Sep 17 00:00:00 2001 From: sayan-oai Date: Fri, 13 Feb 2026 16:26:32 -0800 Subject: [PATCH 04/62] fix: send unfiltered models over model/list (#11793) ### What to unblock filtering models in VSCE, change `model/list` app-server endpoint to send all models + visibility field `showInPicker` so filtering can be done in VSCE if desired. ### Tests Updated tests. --- .../schema/json/ClientRequest.json | 7 ++++ .../codex_app_server_protocol.schemas.json | 11 +++++ .../schema/json/v2/ModelListParams.json | 7 ++++ .../schema/json/v2/ModelListResponse.json | 4 ++ .../schema/typescript/v2/Model.ts | 2 +- .../schema/typescript/v2/ModelListParams.ts | 6 ++- .../src/protocol/common.rs | 3 +- .../app-server-protocol/src/protocol/v2.rs | 4 ++ codex-rs/app-server/README.md | 2 +- .../app-server/src/codex_message_processor.rs | 9 +++- codex-rs/app-server/src/models.rs | 9 +++- .../app-server/tests/suite/v2/model_list.rs | 42 +++++++++++++++++++ 12 files changed, 98 insertions(+), 8 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 4eec3e9d44..a8b66a202b 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -1128,6 +1128,13 @@ "null" ] }, + "includeHidden": { + "description": "When true, include models that are hidden from the default picker list.", + "type": [ + "boolean", + "null" + ] + }, "limit": { "description": "Optional page size; defaults to a reasonable server-side value.", "format": "uint32", 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 f2c2ca55d5..a3ef24452b 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 @@ -12611,6 +12611,9 @@ "displayName": { "type": "string" }, + "hidden": { + "type": "boolean" + }, "id": { "type": "string" }, @@ -12651,6 +12654,7 @@ "defaultReasoningEffort", "description", "displayName", + "hidden", "id", "isDefault", "model", @@ -12668,6 +12672,13 @@ "null" ] }, + "includeHidden": { + "description": "When true, include models that are hidden from the default picker list.", + "type": [ + "boolean", + "null" + ] + }, "limit": { "description": "Optional page size; defaults to a reasonable server-side value.", "format": "uint32", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ModelListParams.json b/codex-rs/app-server-protocol/schema/json/v2/ModelListParams.json index 13bb29977e..11a3476240 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ModelListParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ModelListParams.json @@ -8,6 +8,13 @@ "null" ] }, + "includeHidden": { + "description": "When true, include models that are hidden from the default picker list.", + "type": [ + "boolean", + "null" + ] + }, "limit": { "description": "Optional page size; defaults to a reasonable server-side value.", "format": "uint32", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ModelListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ModelListResponse.json index a1b3f7114c..2afa018dc5 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ModelListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ModelListResponse.json @@ -31,6 +31,9 @@ "displayName": { "type": "string" }, + "hidden": { + "type": "boolean" + }, "id": { "type": "string" }, @@ -71,6 +74,7 @@ "defaultReasoningEffort", "description", "displayName", + "hidden", "id", "isDefault", "model", diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/Model.ts b/codex-rs/app-server-protocol/schema/typescript/v2/Model.ts index 7528a8fad3..de7b240173 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/Model.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/Model.ts @@ -5,4 +5,4 @@ import type { InputModality } from "../InputModality"; import type { ReasoningEffort } from "../ReasoningEffort"; import type { ReasoningEffortOption } from "./ReasoningEffortOption"; -export type Model = { id: string, model: string, upgrade: string | null, displayName: string, description: string, supportedReasoningEfforts: Array, defaultReasoningEffort: ReasoningEffort, inputModalities: Array, supportsPersonality: boolean, isDefault: boolean, }; +export type Model = { id: string, model: string, upgrade: string | null, displayName: string, description: string, hidden: boolean, supportedReasoningEfforts: Array, defaultReasoningEffort: ReasoningEffort, inputModalities: Array, supportsPersonality: boolean, isDefault: boolean, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ModelListParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ModelListParams.ts index b0bc5326c1..a1e7b8fa74 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ModelListParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ModelListParams.ts @@ -10,4 +10,8 @@ cursor?: string | null, /** * Optional page size; defaults to a reasonable server-side value. */ -limit?: number | null, }; +limit?: number | null, +/** + * When true, include models that are hidden from the default picker list. + */ +includeHidden?: boolean | null, }; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index af56408c8f..6c58d3b90c 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -1235,7 +1235,8 @@ mod tests { "id": 6, "params": { "limit": null, - "cursor": null + "cursor": null, + "includeHidden": null } }), serde_json::to_value(&request)?, diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 9bbd8fe6f6..9a184d1754 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -1110,6 +1110,9 @@ pub struct ModelListParams { /// Optional page size; defaults to a reasonable server-side value. #[ts(optional = nullable)] pub limit: Option, + /// When true, include models that are hidden from the default picker list. + #[ts(optional = nullable)] + pub include_hidden: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] @@ -1121,6 +1124,7 @@ pub struct Model { pub upgrade: Option, pub display_name: String, pub description: String, + pub hidden: bool, pub supported_reasoning_efforts: Vec, pub default_reasoning_effort: ReasoningEffort, #[serde(default = "default_input_modalities")] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 2805c65030..aece5fa3cb 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -131,7 +131,7 @@ Example with notification opt-out: - `turn/interrupt` — request cancellation of an in-flight turn by `(thread_id, turn_id)`; success is an empty `{}` response and the turn finishes with `status: "interrupted"`. - `review/start` — kick off Codex’s automated reviewer for a thread; responds like `turn/start` and emits `item/started`/`item/completed` notifications with `enteredReviewMode` and `exitedReviewMode` items, plus a final assistant `agentMessage` containing the review. - `command/exec` — run a single command under the server sandbox without starting a thread/turn (handy for utilities and validation). -- `model/list` — list available models (with reasoning effort options and optional `upgrade` model ids). +- `model/list` — list available models (set `includeHidden: true` to include entries with `hidden: true`), with reasoning effort options and optional `upgrade` model ids. - `experimentalFeature/list` — list feature flags with stage metadata (`beta`, `underDevelopment`, `stable`, etc.), enabled/default-enabled state, and cursor pagination. For non-beta flags, `displayName`/`description`/`announcement` are `null`. - `collaborationMode/list` — list available collaboration mode presets (experimental, no pagination). - `skills/list` — list skills for one or more `cwd` values (optional `forceReload`). diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 156a2e3138..b7bffa70db 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -3592,10 +3592,15 @@ impl CodexMessageProcessor { request_id: ConnectionRequestId, params: ModelListParams, ) { - let ModelListParams { limit, cursor } = params; + let ModelListParams { + limit, + cursor, + include_hidden, + } = params; let mut config = (*config).clone(); config.features.enable(Feature::RemoteModels); - let models = supported_models(thread_manager, &config).await; + let models = + supported_models(thread_manager, &config, include_hidden.unwrap_or(false)).await; let total = models.len(); if total == 0 { diff --git a/codex-rs/app-server/src/models.rs b/codex-rs/app-server/src/models.rs index 350b86f92a..d31e005210 100644 --- a/codex-rs/app-server/src/models.rs +++ b/codex-rs/app-server/src/models.rs @@ -8,12 +8,16 @@ use codex_core::models_manager::manager::RefreshStrategy; use codex_protocol::openai_models::ModelPreset; use codex_protocol::openai_models::ReasoningEffortPreset; -pub async fn supported_models(thread_manager: Arc, config: &Config) -> Vec { +pub async fn supported_models( + thread_manager: Arc, + config: &Config, + include_hidden: bool, +) -> Vec { thread_manager .list_models(config, RefreshStrategy::OnlineIfUncached) .await .into_iter() - .filter(|preset| preset.show_in_picker) + .filter(|preset| include_hidden || preset.show_in_picker) .map(model_from_preset) .collect() } @@ -25,6 +29,7 @@ fn model_from_preset(preset: ModelPreset) -> Model { upgrade: preset.upgrade.map(|upgrade| upgrade.id), display_name: preset.display_name.to_string(), description: preset.description.to_string(), + hidden: !preset.show_in_picker, supported_reasoning_efforts: reasoning_efforts_from_preset( preset.supported_reasoning_efforts, ), diff --git a/codex-rs/app-server/tests/suite/v2/model_list.rs b/codex-rs/app-server/tests/suite/v2/model_list.rs index c2431d24d0..ebcbdf99e7 100644 --- a/codex-rs/app-server/tests/suite/v2/model_list.rs +++ b/codex-rs/app-server/tests/suite/v2/model_list.rs @@ -33,6 +33,7 @@ async fn list_models_returns_all_models_with_large_limit() -> Result<()> { .send_list_models_request(ModelListParams { limit: Some(100), cursor: None, + include_hidden: None, }) .await?; @@ -54,6 +55,7 @@ async fn list_models_returns_all_models_with_large_limit() -> Result<()> { upgrade: None, display_name: "gpt-5.2-codex".to_string(), description: "Latest frontier agentic coding model.".to_string(), + hidden: false, supported_reasoning_efforts: vec![ ReasoningEffortOption { reasoning_effort: ReasoningEffort::Low, @@ -84,6 +86,7 @@ async fn list_models_returns_all_models_with_large_limit() -> Result<()> { upgrade: Some("gpt-5.2-codex".to_string()), display_name: "gpt-5.1-codex-max".to_string(), description: "Codex-optimized flagship for deep and fast reasoning.".to_string(), + hidden: false, supported_reasoning_efforts: vec![ ReasoningEffortOption { reasoning_effort: ReasoningEffort::Low, @@ -114,6 +117,7 @@ async fn list_models_returns_all_models_with_large_limit() -> Result<()> { upgrade: Some("gpt-5.2-codex".to_string()), display_name: "gpt-5.1-codex-mini".to_string(), description: "Optimized for codex. Cheaper, faster, but less capable.".to_string(), + hidden: false, supported_reasoning_efforts: vec![ ReasoningEffortOption { reasoning_effort: ReasoningEffort::Medium, @@ -138,6 +142,7 @@ async fn list_models_returns_all_models_with_large_limit() -> Result<()> { description: "Latest frontier model with improvements across knowledge, reasoning and coding" .to_string(), + hidden: false, supported_reasoning_efforts: vec![ ReasoningEffortOption { reasoning_effort: ReasoningEffort::Low, @@ -173,6 +178,38 @@ async fn list_models_returns_all_models_with_large_limit() -> Result<()> { Ok(()) } +#[tokio::test] +async fn list_models_includes_hidden_models() -> Result<()> { + let codex_home = TempDir::new()?; + write_models_cache(codex_home.path())?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_list_models_request(ModelListParams { + limit: Some(100), + cursor: None, + include_hidden: Some(true), + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + + let ModelListResponse { + data: items, + next_cursor, + } = to_response::(response)?; + + assert!(items.iter().any(|item| item.hidden)); + assert!(next_cursor.is_none()); + Ok(()) +} + #[tokio::test] async fn list_models_pagination_works() -> Result<()> { let codex_home = TempDir::new()?; @@ -185,6 +222,7 @@ async fn list_models_pagination_works() -> Result<()> { .send_list_models_request(ModelListParams { limit: Some(1), cursor: None, + include_hidden: None, }) .await?; @@ -207,6 +245,7 @@ async fn list_models_pagination_works() -> Result<()> { .send_list_models_request(ModelListParams { limit: Some(1), cursor: Some(next_cursor.clone()), + include_hidden: None, }) .await?; @@ -229,6 +268,7 @@ async fn list_models_pagination_works() -> Result<()> { .send_list_models_request(ModelListParams { limit: Some(1), cursor: Some(third_cursor.clone()), + include_hidden: None, }) .await?; @@ -251,6 +291,7 @@ async fn list_models_pagination_works() -> Result<()> { .send_list_models_request(ModelListParams { limit: Some(1), cursor: Some(fourth_cursor.clone()), + include_hidden: None, }) .await?; @@ -283,6 +324,7 @@ async fn list_models_rejects_invalid_cursor() -> Result<()> { .send_list_models_request(ModelListParams { limit: None, cursor: Some("invalid".to_string()), + include_hidden: None, }) .await?; From 067f8b1be0d7e5fef65540b1539d70d9c0eb8b95 Mon Sep 17 00:00:00 2001 From: Josh McKinney Date: Fri, 13 Feb 2026 16:53:15 -0800 Subject: [PATCH 05/62] fix(protocol): make local image test Bazel-friendly (#11799) Fixes Bazel build failure in //codex-rs/protocol:protocol-unit-tests. The test used include_bytes! to read a PNG from codex-core assets; Cargo can read it, but Bazel sandboxing can't, so the crate fails to compile. This change inlines a tiny valid PNG in the test to keep it hermetic. Related regression: #10590 (cc: @charley-oai) --- codex-rs/protocol/src/models.rs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index 2f0a024fd0..eba748f2e4 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -1617,10 +1617,14 @@ mod tests { let image_url = "data:image/png;base64,abc".to_string(); let dir = tempdir()?; let local_path = dir.path().join("local.png"); - let png_bytes = include_bytes!( - "../../core/src/skills/assets/samples/skill-creator/assets/skill-creator.png" - ); - std::fs::write(&local_path, png_bytes.as_slice())?; + // A tiny valid PNG (1x1) so this test doesn't depend on cross-crate file paths, which + // break under Bazel sandboxing. + const TINY_PNG_BYTES: &[u8] = &[ + 137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 1, 0, 0, 0, 1, + 8, 6, 0, 0, 0, 31, 21, 196, 137, 0, 0, 0, 11, 73, 68, 65, 84, 120, 156, 99, 96, 0, 2, + 0, 0, 5, 0, 1, 122, 94, 171, 63, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130, + ]; + std::fs::write(&local_path, TINY_PNG_BYTES)?; let item = ResponseInputItem::from(vec![ UserInput::Image { From 6cbb489e6ed7a091392582b01aa40da4ada087aa Mon Sep 17 00:00:00 2001 From: Curtis 'Fjord' Hawthorne Date: Fri, 13 Feb 2026 17:11:13 -0800 Subject: [PATCH 06/62] Fix js_repl view_image test runtime panic (#11796) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Fixes a flaky/panicking `js_repl` image-path test by running it on a multi-thread Tokio runtime and tightening assertions to focus on real behavior. ## Problem `js_repl_can_attach_image_via_view_image_tool` in `/Users/fjord/code/codex-jsrepl-seq/codex-rs/core/src/tools/js_repl/mod.rs` can panic under single-thread test runtime with: `can call blocking only when running on the multi-threaded runtime` It also asserted a brittle user-facing text string. ## Changes 1. Updated the test runtime to: `#[tokio::test(flavor = "multi_thread", worker_threads = 2)]` 2. Removed the brittle `"attached local image path"` string assertion. 3. Kept the concrete side-effect assertions: - tool call succeeds - image is actually injected into pending input (`InputImage` with `data:image/png;base64,...`) ## Why this is safe This is test-only behavior. No production runtime code paths are changed. ## Validation - Ran: `cargo test -p codex-core tools::js_repl::tests::js_repl_can_attach_image_via_view_image_tool -- --nocapture` - Result: pass #### [git stack](https://github.com/magus/git-stack-cli) - 👉 `1` https://github.com/openai/codex/pull/11796 - ⏳ `2` https://github.com/openai/codex/pull/11800 - ⏳ `3` https://github.com/openai/codex/pull/10673 - ⏳ `4` https://github.com/openai/codex/pull/10670 --- codex-rs/core/src/tools/js_repl/mod.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/codex-rs/core/src/tools/js_repl/mod.rs b/codex-rs/core/src/tools/js_repl/mod.rs index 1aad8cfba5..fdd498b26e 100644 --- a/codex-rs/core/src/tools/js_repl/mod.rs +++ b/codex-rs/core/src/tools/js_repl/mod.rs @@ -1746,7 +1746,7 @@ console.log("cell-complete"); Ok(()) } - #[tokio::test] + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn js_repl_can_attach_image_via_view_image_tool() -> anyhow::Result<()> { if !can_run_js_repl_runtime_tests().await { return Ok(()); @@ -1795,7 +1795,6 @@ console.log(out.output?.body?.text ?? ""); ) .await?; assert!(result.output.contains("function_call_output")); - assert!(result.output.contains("attached local image path")); let pending_input = session.get_pending_input().await; let image_url = pending_input From 0d76d029b7703b0089b261610664368c29ed09e1 Mon Sep 17 00:00:00 2001 From: Curtis 'Fjord' Hawthorne Date: Fri, 13 Feb 2026 17:24:52 -0800 Subject: [PATCH 07/62] Fix js_repl in-flight tool-call waiter race (#11800) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary This PR fixes a race in `js_repl` tool-call draining that could leave an exec waiting indefinitely for in-flight tool calls to finish. The fix is in: - `/Users/fjord/code/codex-jsrepl-seq/codex-rs/core/src/tools/js_repl/mod.rs` ## Problem `js_repl` tracks in-flight tool calls per exec and waits for them to drain on completion/timeout/cancel paths. The previous wait logic used a check-then-wait pattern with `Notify` that could miss a wakeup: 1. Observe `in_flight > 0` 2. Drop lock 3. Register wait (`notified().await`) If `notify_waiters()` happened between (2) and (3), the waiter could sleep until another notification that never comes. ## What changed - Updated all exec-tool-call wait loops to create an owned notification future while holding the lock: - use `Arc::notified_owned()` instead of cloning notify and awaiting later. - Applied this consistently to: - `wait_for_exec_tool_calls` - `wait_for_all_exec_tool_calls` - `wait_for_exec_tool_calls_map` This preserves existing behavior while eliminating the lost-wakeup window. ## Test coverage Added a regression test: - `wait_for_exec_tool_calls_map_drains_inflight_calls_without_hanging` The test repeatedly races waiter/finisher tasks and asserts bounded completion to catch hangs. ## Impact - No API changes. - No user-facing behavior changes intended. - Improves reliability of exec lifecycle boundaries when tool calls are still in flight. #### [git stack](https://github.com/magus/git-stack-cli) - ✅ `1` https://github.com/openai/codex/pull/11796 - 👉 `2` https://github.com/openai/codex/pull/11800 - ⏳ `3` https://github.com/openai/codex/pull/10673 - ⏳ `4` https://github.com/openai/codex/pull/10670 --- codex-rs/core/src/tools/js_repl/mod.rs | 58 ++++++++++++++++++++------ 1 file changed, 46 insertions(+), 12 deletions(-) diff --git a/codex-rs/core/src/tools/js_repl/mod.rs b/codex-rs/core/src/tools/js_repl/mod.rs index fdd498b26e..23efb0c67e 100644 --- a/codex-rs/core/src/tools/js_repl/mod.rs +++ b/codex-rs/core/src/tools/js_repl/mod.rs @@ -306,15 +306,15 @@ impl JsReplManager { async fn wait_for_exec_tool_calls(&self, exec_id: &str) { loop { - let notify = { + let notified = { let calls = self.exec_tool_calls.lock().await; calls .get(exec_id) .filter(|state| state.in_flight > 0) - .map(|state| Arc::clone(&state.notify)) + .map(|state| Arc::clone(&state.notify).notified_owned()) }; - match notify { - Some(notify) => notify.notified().await, + match notified { + Some(notified) => notified.await, None => return, } } @@ -322,15 +322,15 @@ impl JsReplManager { async fn wait_for_all_exec_tool_calls(&self) { loop { - let notify = { + let notified = { let calls = self.exec_tool_calls.lock().await; calls .values() .find(|state| state.in_flight > 0) - .map(|state| Arc::clone(&state.notify)) + .map(|state| Arc::clone(&state.notify).notified_owned()) }; - match notify { - Some(notify) => notify.notified().await, + match notified { + Some(notified) => notified.await, None => return, } } @@ -377,15 +377,15 @@ impl JsReplManager { exec_id: &str, ) { loop { - let notify = { + let notified = { let calls = exec_tool_calls.lock().await; calls .get(exec_id) .filter(|state| state.in_flight > 0) - .map(|state| Arc::clone(&state.notify)) + .map(|state| Arc::clone(&state.notify).notified_owned()) }; - match notify { - Some(notify) => notify.notified().await, + match notified { + Some(notified) => notified.await, None => return, } } @@ -1407,6 +1407,40 @@ mod tests { assert!(!is_js_repl_internal_tool("list_mcp_resources")); } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn wait_for_exec_tool_calls_map_drains_inflight_calls_without_hanging() { + let exec_tool_calls = Arc::new(Mutex::new(HashMap::new())); + + for _ in 0..128 { + let exec_id = Uuid::new_v4().to_string(); + exec_tool_calls + .lock() + .await + .insert(exec_id.clone(), ExecToolCalls::default()); + assert!(JsReplManager::begin_exec_tool_call(&exec_tool_calls, &exec_id).await); + + let wait_map = Arc::clone(&exec_tool_calls); + let wait_exec_id = exec_id.clone(); + let waiter = tokio::spawn(async move { + JsReplManager::wait_for_exec_tool_calls_map(&wait_map, &wait_exec_id).await; + }); + + let finish_map = Arc::clone(&exec_tool_calls); + let finish_exec_id = exec_id.clone(); + let finisher = tokio::spawn(async move { + tokio::task::yield_now().await; + JsReplManager::finish_exec_tool_call(&finish_map, &finish_exec_id).await; + }); + + tokio::time::timeout(Duration::from_secs(1), waiter) + .await + .expect("wait_for_exec_tool_calls_map should not hang") + .expect("wait task should not panic"); + finisher.await.expect("finish task should not panic"); + + JsReplManager::clear_exec_tool_calls_map(&exec_tool_calls, &exec_id).await; + } + } async fn can_run_js_repl_runtime_tests() -> bool { if std::env::var_os("CODEX_SANDBOX").is_some() { return false; From 5b6911cb1beef5da474e6c55e9304e42e10febcf Mon Sep 17 00:00:00 2001 From: Celia Chen Date: Fri, 13 Feb 2026 17:43:44 -0800 Subject: [PATCH 08/62] feat(skills): add permission profiles from openai.yaml metadata (#11658) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary This PR adds support for skill-level permissions in .codex/openai.yaml and wires that through the skill loading pipeline. ## What’s included 1. Added a new permissions section for skills (network, filesystem, and macOS-related access). 2. Implemented permission parsing/normalization and translation into runtime permission profiles. 3. Threaded the new permission profile through SkillMetadata and loader flow. ## Follow-up A follow-up PR will connect these permission profiles to actual sandbox enforcement and add user approval prompts for executing binaries/scripts from skill directories. ## Example `openai.yaml` snippet: ``` permissions: network: true fs_read: - "./data" - "./data" fs_write: - "./output" macos_preferences: "readwrite" macos_automation: - "com.apple.Notes" macos_accessibility: true macos_calendar: true ``` compiled skill permission profile metadata (macOS): ``` SkillPermissionProfile { sandbox_policy: SandboxPolicy::WorkspaceWrite { writable_roots: vec![ AbsolutePathBuf::try_from("/ABS/PATH/TO/SKILL/output").unwrap(), ], read_only_access: ReadOnlyAccess::Restricted { include_platform_defaults: true, readable_roots: vec![ AbsolutePathBuf::try_from("/ABS/PATH/TO/SKILL/data").unwrap(), ], }, network_access: true, exclude_tmpdir_env_var: false, exclude_slash_tmp: false, }, // Truncated for readability; actual generated profile is longer. macos_seatbelt_permission_file: r#" (allow user-preference-write) (allow appleevent-send (appleevent-destination "com.apple.Notes")) (allow mach-lookup (global-name "com.apple.axserver")) (allow mach-lookup (global-name "com.apple.CalendarAgent")) ... "#.to_string(), ``` --- codex-rs/core/src/config/mod.rs | 12 + codex-rs/core/src/mcp/skill_dependencies.rs | 1 + codex-rs/core/src/seatbelt.rs | 10 +- codex-rs/core/src/seatbelt_permissions.rs | 10 +- codex-rs/core/src/skills/injection.rs | 1 + codex-rs/core/src/skills/loader.rs | 260 +++++++++- codex-rs/core/src/skills/mod.rs | 1 + codex-rs/core/src/skills/model.rs | 5 +- codex-rs/core/src/skills/permissions.rs | 510 ++++++++++++++++++++ codex-rs/tui/src/bottom_pane/mod.rs | 1 + codex-rs/tui/src/chatwidget/skills.rs | 1 + codex-rs/tui/src/chatwidget/tests.rs | 2 + 12 files changed, 797 insertions(+), 17 deletions(-) create mode 100644 codex-rs/core/src/skills/permissions.rs diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 5cfdcf0441..91ab7e5b76 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -49,6 +49,8 @@ use crate::project_doc::LOCAL_PROJECT_DOC_FILENAME; use crate::protocol::AskForApproval; use crate::protocol::ReadOnlyAccess; use crate::protocol::SandboxPolicy; +#[cfg(target_os = "macos")] +use crate::seatbelt_permissions::MacOsSeatbeltProfileExtensions; use crate::windows_sandbox::WindowsSandboxLevelExt; use crate::windows_sandbox::resolve_windows_sandbox_mode; use codex_app_server_protocol::Tools; @@ -78,6 +80,8 @@ use std::path::Path; use std::path::PathBuf; #[cfg(test)] use tempfile::tempdir; +#[cfg(not(target_os = "macos"))] +type MacOsSeatbeltProfileExtensions = (); use crate::config::profile::ConfigProfile; use toml::Value as TomlValue; @@ -133,6 +137,9 @@ pub struct Permissions { /// Effective Windows sandbox mode derived from `[windows].sandbox` or /// legacy feature keys. pub windows_sandbox_mode: Option, + /// Optional macOS seatbelt extension profile used to extend default + /// seatbelt permissions when running under seatbelt. + pub macos_seatbelt_profile_extensions: Option, } /// Application configuration loaded from disk and merged with overrides. @@ -1745,6 +1752,7 @@ impl Config { network, shell_environment_policy, windows_sandbox_mode, + macos_seatbelt_profile_extensions: None, }, enforce_residency: enforce_residency.value, did_user_set_custom_approval_policy_or_sandbox_mode, @@ -4084,6 +4092,7 @@ model_verbosity = "high" network: None, shell_environment_policy: ShellEnvironmentPolicy::default(), windows_sandbox_mode: None, + macos_seatbelt_profile_extensions: None, }, enforce_residency: Constrained::allow_any(None), did_user_set_custom_approval_policy_or_sandbox_mode: true, @@ -4194,6 +4203,7 @@ model_verbosity = "high" network: None, shell_environment_policy: ShellEnvironmentPolicy::default(), windows_sandbox_mode: None, + macos_seatbelt_profile_extensions: None, }, enforce_residency: Constrained::allow_any(None), did_user_set_custom_approval_policy_or_sandbox_mode: true, @@ -4302,6 +4312,7 @@ model_verbosity = "high" network: None, shell_environment_policy: ShellEnvironmentPolicy::default(), windows_sandbox_mode: None, + macos_seatbelt_profile_extensions: None, }, enforce_residency: Constrained::allow_any(None), did_user_set_custom_approval_policy_or_sandbox_mode: true, @@ -4396,6 +4407,7 @@ model_verbosity = "high" network: None, shell_environment_policy: ShellEnvironmentPolicy::default(), windows_sandbox_mode: None, + macos_seatbelt_profile_extensions: None, }, enforce_residency: Constrained::allow_any(None), did_user_set_custom_approval_policy_or_sandbox_mode: true, diff --git a/codex-rs/core/src/mcp/skill_dependencies.rs b/codex-rs/core/src/mcp/skill_dependencies.rs index e2817d5a4d..0e6d61055e 100644 --- a/codex-rs/core/src/mcp/skill_dependencies.rs +++ b/codex-rs/core/src/mcp/skill_dependencies.rs @@ -432,6 +432,7 @@ mod tests { interface: None, dependencies: Some(SkillDependencies { tools }), policy: None, + permissions: None, path: PathBuf::from("skill"), scope: SkillScope::User, } diff --git a/codex-rs/core/src/seatbelt.rs b/codex-rs/core/src/seatbelt.rs index 29c05e0183..7f738797b8 100644 --- a/codex-rs/core/src/seatbelt.rs +++ b/codex-rs/core/src/seatbelt.rs @@ -14,7 +14,6 @@ use tokio::process::Child; use url::Url; use crate::protocol::SandboxPolicy; -use crate::seatbelt_permissions::MacOsPreferencesPermission; use crate::seatbelt_permissions::MacOsSeatbeltProfileExtensions; use crate::seatbelt_permissions::build_seatbelt_extensions; use crate::spawn::CODEX_SANDBOX_ENV_VAR; @@ -299,10 +298,7 @@ pub(crate) fn create_seatbelt_command_args_with_extensions( let seatbelt_extensions = extensions.map_or_else( || { // Backward-compatibility default when no extension profile is provided. - build_seatbelt_extensions(&MacOsSeatbeltProfileExtensions { - macos_preferences: MacOsPreferencesPermission::ReadOnly, - ..Default::default() - }) + build_seatbelt_extensions(&MacOsSeatbeltProfileExtensions::default()) }, build_seatbelt_extensions, ); @@ -480,7 +476,7 @@ mod tests { } #[test] - fn seatbelt_args_omit_macos_extensions_when_profile_is_empty() { + fn seatbelt_args_default_extension_profile_keeps_preferences_read_access() { let cwd = std::env::temp_dir(); let args = create_seatbelt_command_args_with_extensions( vec!["echo".to_string(), "ok".to_string()], @@ -494,7 +490,7 @@ mod tests { assert!(!policy.contains("appleevent-send")); assert!(!policy.contains("com.apple.axserver")); assert!(!policy.contains("com.apple.CalendarAgent")); - assert!(!policy.contains("user-preference-read")); + assert!(policy.contains("(allow user-preference-read)")); assert!(!policy.contains("user-preference-write")); } diff --git a/codex-rs/core/src/seatbelt_permissions.rs b/codex-rs/core/src/seatbelt_permissions.rs index 29313d7a3c..aacc3a7784 100644 --- a/codex-rs/core/src/seatbelt_permissions.rs +++ b/codex-rs/core/src/seatbelt_permissions.rs @@ -6,10 +6,12 @@ use std::path::PathBuf; #[allow(dead_code)] #[derive(Debug, Clone, PartialEq, Eq, Default)] pub enum MacOsPreferencesPermission { + // IMPORTANT: ReadOnly needs to be the default because it's the security-sensitive default. + // it's important for allowing cf prefs to work. #[default] - None, ReadOnly, ReadWrite, + None, } #[allow(dead_code)] @@ -164,7 +166,6 @@ mod tests { use super::MacOsPreferencesPermission; use super::MacOsSeatbeltProfileExtensions; use super::build_seatbelt_extensions; - use pretty_assertions::assert_eq; #[test] fn preferences_read_only_emits_read_clauses_only() { @@ -239,8 +240,9 @@ mod tests { } #[test] - fn empty_extensions_emit_empty_policy() { + fn default_extensions_emit_preferences_read_only_policy() { let policy = build_seatbelt_extensions(&MacOsSeatbeltProfileExtensions::default()); - assert_eq!(policy.policy, ""); + assert!(policy.policy.contains("(allow user-preference-read)")); + assert!(!policy.policy.contains("(allow user-preference-write)")); } } diff --git a/codex-rs/core/src/skills/injection.rs b/codex-rs/core/src/skills/injection.rs index 660785d712..4dec3fe399 100644 --- a/codex-rs/core/src/skills/injection.rs +++ b/codex-rs/core/src/skills/injection.rs @@ -476,6 +476,7 @@ mod tests { interface: None, dependencies: None, policy: None, + permissions: None, path: PathBuf::from(path), scope: codex_protocol::protocol::SkillScope::User, } diff --git a/codex-rs/core/src/skills/loader.rs b/codex-rs/core/src/skills/loader.rs index 6821d1b8fe..e00bd42976 100644 --- a/codex-rs/core/src/skills/loader.rs +++ b/codex-rs/core/src/skills/loader.rs @@ -1,4 +1,5 @@ use crate::config::Config; +use crate::config::Permissions; use crate::config_loader::ConfigLayerStack; use crate::config_loader::ConfigLayerStackOrdering; use crate::config_loader::default_project_root_markers; @@ -11,6 +12,8 @@ use crate::skills::model::SkillLoadOutcome; use crate::skills::model::SkillMetadata; use crate::skills::model::SkillPolicy; use crate::skills::model::SkillToolDependency; +use crate::skills::permissions::SkillManifestPermissions; +use crate::skills::permissions::compile_permission_profile; use crate::skills::system::system_cache_root_dir; use codex_app_server_protocol::ConfigLayerSource; use codex_protocol::protocol::SkillScope; @@ -50,6 +53,8 @@ struct SkillMetadataFile { dependencies: Option, #[serde(default)] policy: Option, + #[serde(default)] + permissions: Option, } #[derive(Debug, Default, Deserialize)] @@ -490,7 +495,7 @@ fn parse_skill_file(path: &Path, scope: SkillScope) -> Result Result, Option, Option, + Option, ) { // Fail open: optional metadata should not block loading SKILL.md. let Some(skill_dir) = skill_path.parent() else { - return (None, None, None); + return (None, None, None, None); }; let metadata_path = skill_dir .join(SKILLS_METADATA_DIR) .join(SKILLS_METADATA_FILENAME); if !metadata_path.exists() { - return (None, None, None); + return (None, None, None, None); } let contents = match fs::read_to_string(&metadata_path) { @@ -542,7 +549,7 @@ fn load_skill_metadata( path = metadata_path.display(), label = SKILLS_METADATA_FILENAME ); - return (None, None, None); + return (None, None, None, None); } }; @@ -554,7 +561,7 @@ fn load_skill_metadata( path = metadata_path.display(), label = SKILLS_METADATA_FILENAME ); - return (None, None, None); + return (None, None, None, None); } }; @@ -562,12 +569,14 @@ fn load_skill_metadata( interface, dependencies, policy, + permissions, } = parsed; ( resolve_interface(interface, skill_dir), resolve_dependencies(dependencies), resolve_policy(policy), + compile_permission_profile(skill_dir, permissions), ) } @@ -801,7 +810,9 @@ mod tests { use crate::config::ConfigBuilder; use crate::config::ConfigOverrides; use crate::config::ConfigToml; + use crate::config::Constrained; use crate::config::ProjectConfig; + use crate::config::types::ShellEnvironmentPolicy; use crate::config_loader::ConfigLayerEntry; use crate::config_loader::ConfigLayerStack; use crate::config_loader::ConfigRequirements; @@ -1021,6 +1032,7 @@ mod tests { interface: None, dependencies: None, policy: None, + permissions: None, path: normalized(&skill_path), scope: SkillScope::User, }] @@ -1168,6 +1180,7 @@ mod tests { ], }), policy: None, + permissions: None, path: normalized(&skill_path), scope: SkillScope::User, }] @@ -1223,6 +1236,7 @@ interface: }), dependencies: None, policy: None, + permissions: None, path: normalized(skill_path.as_path()), scope: SkillScope::User, }] @@ -1295,6 +1309,219 @@ policy: {} ); } + #[tokio::test] + async fn loads_skill_permissions_from_yaml() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let skill_path = write_skill(&codex_home, "demo", "permissions-skill", "from yaml"); + let skill_dir = skill_path.parent().expect("skill dir"); + fs::create_dir_all(skill_dir.join("data")).expect("create read path"); + fs::create_dir_all(skill_dir.join("output")).expect("create write path"); + + write_skill_metadata_at( + skill_dir, + r#" +permissions: + network: true + file_system: + read: + - "./data" + - "./data" + write: + - "./output" +"#, + ); + + let cfg = make_config(&codex_home).await; + let outcome = load_skills(&cfg); + + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!(outcome.skills.len(), 1); + #[cfg(target_os = "macos")] + let macos_seatbelt_profile_extensions = + Some(crate::seatbelt_permissions::MacOsSeatbeltProfileExtensions::default()); + #[cfg(not(target_os = "macos"))] + let macos_seatbelt_profile_extensions = None; + assert_eq!( + outcome.skills[0].permissions, + Some(Permissions { + approval_policy: Constrained::allow_any(crate::protocol::AskForApproval::Never), + sandbox_policy: Constrained::allow_any( + crate::protocol::SandboxPolicy::WorkspaceWrite { + writable_roots: vec![ + AbsolutePathBuf::try_from(normalized( + skill_dir.join("output").as_path(), + )) + .expect("absolute output path") + ], + read_only_access: crate::protocol::ReadOnlyAccess::Restricted { + include_platform_defaults: true, + readable_roots: vec![ + AbsolutePathBuf::try_from(normalized( + skill_dir.join("data").as_path(), + )) + .expect("absolute data path") + ], + }, + network_access: true, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + } + ), + network: None, + shell_environment_policy: ShellEnvironmentPolicy::default(), + windows_sandbox_mode: None, + macos_seatbelt_profile_extensions, + }) + ); + } + + #[tokio::test] + async fn empty_skill_permissions_do_not_create_profile() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let skill_path = write_skill(&codex_home, "demo", "permissions-empty", "from yaml"); + let skill_dir = skill_path.parent().expect("skill dir"); + + write_skill_metadata_at( + skill_dir, + r#" +permissions: {} +"#, + ); + + let cfg = make_config(&codex_home).await; + let outcome = load_skills(&cfg); + + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!(outcome.skills.len(), 1); + #[cfg(target_os = "macos")] + let expected = Some(Permissions { + approval_policy: Constrained::allow_any(crate::protocol::AskForApproval::Never), + sandbox_policy: Constrained::allow_any( + crate::protocol::SandboxPolicy::new_read_only_policy(), + ), + network: None, + shell_environment_policy: ShellEnvironmentPolicy::default(), + windows_sandbox_mode: None, + macos_seatbelt_profile_extensions: Some( + crate::seatbelt_permissions::MacOsSeatbeltProfileExtensions::default(), + ), + }); + #[cfg(not(target_os = "macos"))] + let expected = Some(Permissions { + approval_policy: Constrained::allow_any(crate::protocol::AskForApproval::Never), + sandbox_policy: Constrained::allow_any( + crate::protocol::SandboxPolicy::new_read_only_policy(), + ), + network: None, + shell_environment_policy: ShellEnvironmentPolicy::default(), + windows_sandbox_mode: None, + macos_seatbelt_profile_extensions: None, + }); + assert_eq!(outcome.skills[0].permissions, expected); + } + + #[cfg(target_os = "macos")] + #[tokio::test] + async fn loads_skill_macos_permissions_from_yaml() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let skill_path = write_skill(&codex_home, "demo", "permissions-macos", "from yaml"); + let skill_dir = skill_path.parent().expect("skill dir"); + + write_skill_metadata_at( + skill_dir, + r#" +permissions: + macos: + preferences: "readwrite" + automations: + - "com.apple.Notes" + accessibility: true + calendar: true +"#, + ); + + let cfg = make_config(&codex_home).await; + let outcome = load_skills(&cfg); + + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!(outcome.skills.len(), 1); + let profile = outcome.skills[0] + .permissions + .as_ref() + .expect("permission profile"); + assert_eq!( + profile.macos_seatbelt_profile_extensions, + Some( + crate::seatbelt_permissions::MacOsSeatbeltProfileExtensions { + macos_preferences: + crate::seatbelt_permissions::MacOsPreferencesPermission::ReadWrite, + macos_automation: + crate::seatbelt_permissions::MacOsAutomationPermission::BundleIds(vec![ + "com.apple.Notes".to_string() + ],), + macos_accessibility: true, + macos_calendar: true, + } + ) + ); + } + + #[cfg(not(target_os = "macos"))] + #[tokio::test] + async fn loads_skill_macos_permissions_from_yaml_non_macos_does_not_create_profile() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let skill_path = write_skill(&codex_home, "demo", "permissions-macos", "from yaml"); + let skill_dir = skill_path.parent().expect("skill dir"); + + write_skill_metadata_at( + skill_dir, + r#" +permissions: + macos: + preferences: "readwrite" + automations: + - "com.apple.Notes" + accessibility: true + calendar: true +"#, + ); + + let cfg = make_config(&codex_home).await; + let outcome = load_skills(&cfg); + + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!(outcome.skills.len(), 1); + assert_eq!( + outcome.skills[0].permissions, + Some(Permissions { + approval_policy: Constrained::allow_any(crate::protocol::AskForApproval::Never), + sandbox_policy: Constrained::allow_any( + crate::protocol::SandboxPolicy::new_read_only_policy(), + ), + network: None, + shell_environment_policy: ShellEnvironmentPolicy::default(), + windows_sandbox_mode: None, + macos_seatbelt_profile_extensions: None, + }) + ); + } + #[tokio::test] async fn accepts_icon_paths_under_assets_dir() { let codex_home = tempfile::tempdir().expect("tempdir"); @@ -1339,6 +1566,7 @@ policy: {} }), dependencies: None, policy: None, + permissions: None, path: normalized(&skill_path), scope: SkillScope::User, }] @@ -1379,6 +1607,7 @@ policy: {} interface: None, dependencies: None, policy: None, + permissions: None, path: normalized(&skill_path), scope: SkillScope::User, }] @@ -1432,6 +1661,7 @@ policy: {} }), dependencies: None, policy: None, + permissions: None, path: normalized(&skill_path), scope: SkillScope::User, }] @@ -1473,6 +1703,7 @@ policy: {} interface: None, dependencies: None, policy: None, + permissions: None, path: normalized(&skill_path), scope: SkillScope::User, }] @@ -1517,6 +1748,7 @@ policy: {} interface: None, dependencies: None, policy: None, + permissions: None, path: normalized(&shared_skill_path), scope: SkillScope::User, }] @@ -1577,6 +1809,7 @@ policy: {} interface: None, dependencies: None, policy: None, + permissions: None, path: normalized(&skill_path), scope: SkillScope::User, }] @@ -1613,6 +1846,7 @@ policy: {} interface: None, dependencies: None, policy: None, + permissions: None, path: normalized(&shared_skill_path), scope: SkillScope::Admin, }] @@ -1653,6 +1887,7 @@ policy: {} interface: None, dependencies: None, policy: None, + permissions: None, path: normalized(&linked_skill_path), scope: SkillScope::Repo, }] @@ -1720,6 +1955,7 @@ policy: {} interface: None, dependencies: None, policy: None, + permissions: None, path: normalized(&within_depth_path), scope: SkillScope::User, }] @@ -1747,6 +1983,7 @@ policy: {} interface: None, dependencies: None, policy: None, + permissions: None, path: normalized(&skill_path), scope: SkillScope::User, }] @@ -1778,6 +2015,7 @@ policy: {} interface: None, dependencies: None, policy: None, + permissions: None, path: normalized(&skill_path), scope: SkillScope::User, }] @@ -1890,6 +2128,7 @@ policy: {} interface: None, dependencies: None, policy: None, + permissions: None, path: normalized(&skill_path), scope: SkillScope::Repo, }] @@ -1925,6 +2164,7 @@ policy: {} interface: None, dependencies: None, policy: None, + permissions: None, path: normalized(&skill_path), scope: SkillScope::Repo, }] @@ -1978,6 +2218,7 @@ policy: {} interface: None, dependencies: None, policy: None, + permissions: None, path: normalized(&nested_skill_path), scope: SkillScope::Repo, }, @@ -1988,6 +2229,7 @@ policy: {} interface: None, dependencies: None, policy: None, + permissions: None, path: normalized(&root_skill_path), scope: SkillScope::Repo, }, @@ -2027,6 +2269,7 @@ policy: {} interface: None, dependencies: None, policy: None, + permissions: None, path: normalized(&skill_path), scope: SkillScope::Repo, }] @@ -2064,6 +2307,7 @@ policy: {} interface: None, dependencies: None, policy: None, + permissions: None, path: normalized(&skill_path), scope: SkillScope::Repo, }] @@ -2105,6 +2349,7 @@ policy: {} interface: None, dependencies: None, policy: None, + permissions: None, path: normalized(&repo_skill_path), scope: SkillScope::Repo, }, @@ -2115,6 +2360,7 @@ policy: {} interface: None, dependencies: None, policy: None, + permissions: None, path: normalized(&user_skill_path), scope: SkillScope::User, }, @@ -2179,6 +2425,7 @@ policy: {} interface: None, dependencies: None, policy: None, + permissions: None, path: first_path, scope: SkillScope::Repo, }, @@ -2189,6 +2436,7 @@ policy: {} interface: None, dependencies: None, policy: None, + permissions: None, path: second_path, scope: SkillScope::Repo, }, @@ -2260,6 +2508,7 @@ policy: {} interface: None, dependencies: None, policy: None, + permissions: None, path: normalized(&skill_path), scope: SkillScope::Repo, }] @@ -2318,6 +2567,7 @@ policy: {} interface: None, dependencies: None, policy: None, + permissions: None, path: normalized(&skill_path), scope: SkillScope::System, }] diff --git a/codex-rs/core/src/skills/mod.rs b/codex-rs/core/src/skills/mod.rs index 17405129ec..18f7180f99 100644 --- a/codex-rs/core/src/skills/mod.rs +++ b/codex-rs/core/src/skills/mod.rs @@ -3,6 +3,7 @@ pub mod injection; pub mod loader; pub mod manager; pub mod model; +pub mod permissions; pub mod remote; pub mod render; pub mod system; diff --git a/codex-rs/core/src/skills/model.rs b/codex-rs/core/src/skills/model.rs index bc75b7e365..ac642db43d 100644 --- a/codex-rs/core/src/skills/model.rs +++ b/codex-rs/core/src/skills/model.rs @@ -1,9 +1,10 @@ use std::collections::HashSet; use std::path::PathBuf; +use crate::config::Permissions; use codex_protocol::protocol::SkillScope; -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq)] pub struct SkillMetadata { pub name: String, pub description: String, @@ -11,6 +12,8 @@ pub struct SkillMetadata { pub interface: Option, pub dependencies: Option, pub policy: Option, + // This is an experimental field. + pub permissions: Option, pub path: PathBuf, pub scope: SkillScope, } diff --git a/codex-rs/core/src/skills/permissions.rs b/codex-rs/core/src/skills/permissions.rs new file mode 100644 index 0000000000..1cf18e1052 --- /dev/null +++ b/codex-rs/core/src/skills/permissions.rs @@ -0,0 +1,510 @@ +use std::collections::HashSet; +use std::path::Component; +use std::path::Path; +use std::path::PathBuf; + +use codex_utils_absolute_path::AbsolutePathBuf; +use dirs::home_dir; +use dunce::canonicalize as canonicalize_path; +use serde::Deserialize; +use tracing::warn; + +use crate::config::Constrained; +use crate::config::Permissions; +use crate::config::types::ShellEnvironmentPolicy; +use crate::protocol::AskForApproval; +use crate::protocol::ReadOnlyAccess; +use crate::protocol::SandboxPolicy; +#[cfg(target_os = "macos")] +use crate::seatbelt_permissions::MacOsSeatbeltProfileExtensions; +#[cfg(not(target_os = "macos"))] +type MacOsSeatbeltProfileExtensions = (); + +#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize)] +pub(crate) struct SkillManifestPermissions { + #[serde(default)] + pub(crate) network: bool, + #[serde(default)] + pub(crate) file_system: SkillManifestFileSystemPermissions, + #[serde(default)] + pub(crate) macos: SkillManifestMacOsPermissions, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize)] +pub(crate) struct SkillManifestFileSystemPermissions { + #[serde(default)] + pub(crate) read: Vec, + #[serde(default)] + pub(crate) write: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize)] +pub(crate) struct SkillManifestMacOsPermissions { + #[serde(default)] + pub(crate) preferences: Option, + #[serde(default)] + pub(crate) automations: Option, + #[serde(default)] + pub(crate) accessibility: bool, + #[serde(default)] + pub(crate) calendar: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(untagged)] +pub(crate) enum MacOsPreferencesValue { + Bool(bool), + Mode(String), +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(untagged)] +pub(crate) enum MacOsAutomationValue { + Bool(bool), + BundleIds(Vec), +} + +pub(crate) fn compile_permission_profile( + skill_dir: &Path, + permissions: Option, +) -> Option { + let permissions = permissions?; + let fs_read = normalize_permission_paths( + skill_dir, + &permissions.file_system.read, + "permissions.file_system.read", + ); + let fs_write = normalize_permission_paths( + skill_dir, + &permissions.file_system.write, + "permissions.file_system.write", + ); + let sandbox_policy = if !fs_write.is_empty() { + SandboxPolicy::WorkspaceWrite { + writable_roots: fs_write, + read_only_access: if fs_read.is_empty() { + ReadOnlyAccess::FullAccess + } else { + ReadOnlyAccess::Restricted { + include_platform_defaults: true, + readable_roots: fs_read, + } + }, + network_access: permissions.network, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + } + } else if !fs_read.is_empty() { + SandboxPolicy::ReadOnly { + access: ReadOnlyAccess::Restricted { + include_platform_defaults: true, + readable_roots: fs_read, + }, + } + } else { + // Default sandbox policy + SandboxPolicy::new_read_only_policy() + }; + let macos_seatbelt_profile_extensions = + build_macos_seatbelt_profile_extensions(&permissions.macos); + + Some(Permissions { + approval_policy: Constrained::allow_any(AskForApproval::Never), + sandbox_policy: Constrained::allow_any(sandbox_policy), + network: None, + shell_environment_policy: ShellEnvironmentPolicy::default(), + windows_sandbox_mode: None, + macos_seatbelt_profile_extensions, + }) +} + +fn normalize_permission_paths( + skill_dir: &Path, + values: &[String], + field: &str, +) -> Vec { + let mut paths = Vec::new(); + let mut seen = HashSet::new(); + + for value in values { + let Some(path) = normalize_permission_path(skill_dir, value, field) else { + continue; + }; + if seen.insert(path.clone()) { + paths.push(path); + } + } + + paths +} + +fn normalize_permission_path( + skill_dir: &Path, + value: &str, + field: &str, +) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() { + warn!("ignoring {field}: value is empty"); + return None; + } + + let expanded = expand_home(trimmed); + let path = PathBuf::from(expanded); + let absolute = if path.is_absolute() { + path + } else { + skill_dir.join(path) + }; + let normalized = normalize_lexically(&absolute); + let canonicalized = canonicalize_path(&normalized).unwrap_or(normalized); + match AbsolutePathBuf::from_absolute_path(&canonicalized) { + Ok(path) => Some(path), + Err(error) => { + warn!("ignoring {field}: expected absolute path, got {canonicalized:?}: {error}"); + None + } + } +} + +fn expand_home(path: &str) -> String { + if path == "~" { + if let Some(home) = home_dir() { + return home.to_string_lossy().to_string(); + } + return path.to_string(); + } + if let Some(rest) = path.strip_prefix("~/") + && let Some(home) = home_dir() + { + return home.join(rest).to_string_lossy().to_string(); + } + path.to_string() +} + +#[cfg(target_os = "macos")] +fn build_macos_seatbelt_profile_extensions( + permissions: &SkillManifestMacOsPermissions, +) -> Option { + let defaults = MacOsSeatbeltProfileExtensions::default(); + + let extensions = MacOsSeatbeltProfileExtensions { + macos_preferences: resolve_macos_preferences_permission( + permissions.preferences.as_ref(), + defaults.macos_preferences, + ), + macos_automation: resolve_macos_automation_permission( + permissions.automations.as_ref(), + defaults.macos_automation, + ), + macos_accessibility: permissions.accessibility, + macos_calendar: permissions.calendar, + }; + Some(extensions) +} + +#[cfg(target_os = "macos")] +fn resolve_macos_preferences_permission( + value: Option<&MacOsPreferencesValue>, + default: crate::seatbelt_permissions::MacOsPreferencesPermission, +) -> crate::seatbelt_permissions::MacOsPreferencesPermission { + use crate::seatbelt_permissions::MacOsPreferencesPermission; + + match value { + Some(MacOsPreferencesValue::Bool(true)) => MacOsPreferencesPermission::ReadOnly, + Some(MacOsPreferencesValue::Bool(false)) => MacOsPreferencesPermission::None, + Some(MacOsPreferencesValue::Mode(mode)) => { + let mode = mode.trim(); + if mode.eq_ignore_ascii_case("readonly") || mode.eq_ignore_ascii_case("read-only") { + MacOsPreferencesPermission::ReadOnly + } else if mode.eq_ignore_ascii_case("readwrite") + || mode.eq_ignore_ascii_case("read-write") + { + MacOsPreferencesPermission::ReadWrite + } else { + warn!( + "ignoring permissions.macos.preferences: expected true/false, readonly, or readwrite" + ); + default + } + } + None => default, + } +} + +#[cfg(target_os = "macos")] +fn resolve_macos_automation_permission( + value: Option<&MacOsAutomationValue>, + default: crate::seatbelt_permissions::MacOsAutomationPermission, +) -> crate::seatbelt_permissions::MacOsAutomationPermission { + use crate::seatbelt_permissions::MacOsAutomationPermission; + + match value { + Some(MacOsAutomationValue::Bool(true)) => MacOsAutomationPermission::All, + Some(MacOsAutomationValue::Bool(false)) => MacOsAutomationPermission::None, + Some(MacOsAutomationValue::BundleIds(bundle_ids)) => { + let bundle_ids = bundle_ids + .iter() + .map(|bundle_id| bundle_id.trim()) + .filter(|bundle_id| !bundle_id.is_empty()) + .map(ToOwned::to_owned) + .collect::>(); + if bundle_ids.is_empty() { + MacOsAutomationPermission::None + } else { + MacOsAutomationPermission::BundleIds(bundle_ids) + } + } + None => default, + } +} + +#[cfg(not(target_os = "macos"))] +fn build_macos_seatbelt_profile_extensions( + _: &SkillManifestMacOsPermissions, +) -> Option { + None +} + +fn normalize_lexically(path: &Path) -> PathBuf { + let mut normalized = PathBuf::new(); + for component in path.components() { + match component { + Component::CurDir => {} + Component::ParentDir => { + normalized.pop(); + } + Component::RootDir | Component::Prefix(_) | Component::Normal(_) => { + normalized.push(component.as_os_str()); + } + } + } + normalized +} + +#[cfg(test)] +mod tests { + use super::SkillManifestFileSystemPermissions; + #[cfg(target_os = "macos")] + use super::SkillManifestMacOsPermissions; + use super::SkillManifestPermissions; + use super::compile_permission_profile; + use crate::config::Constrained; + use crate::config::Permissions; + use crate::config::types::ShellEnvironmentPolicy; + use crate::protocol::AskForApproval; + use crate::protocol::ReadOnlyAccess; + use crate::protocol::SandboxPolicy; + use codex_utils_absolute_path::AbsolutePathBuf; + use pretty_assertions::assert_eq; + use std::fs; + + #[test] + fn compile_permission_profile_normalizes_paths() { + let tempdir = tempfile::tempdir().expect("tempdir"); + let skill_dir = tempdir.path().join("skill"); + fs::create_dir_all(skill_dir.join("scripts")).expect("skill dir"); + let read_dir = skill_dir.join("data"); + fs::create_dir_all(&read_dir).expect("read dir"); + + let profile = compile_permission_profile( + &skill_dir, + Some(SkillManifestPermissions { + network: true, + file_system: SkillManifestFileSystemPermissions { + read: vec![ + "./data".to_string(), + "./data".to_string(), + "scripts/../data".to_string(), + ], + write: vec!["./output".to_string()], + }, + ..Default::default() + }), + ) + .expect("profile"); + + assert_eq!( + profile, + Permissions { + approval_policy: Constrained::allow_any(AskForApproval::Never), + sandbox_policy: Constrained::allow_any(SandboxPolicy::WorkspaceWrite { + writable_roots: vec![ + AbsolutePathBuf::try_from(skill_dir.join("output")) + .expect("absolute output path") + ], + read_only_access: ReadOnlyAccess::Restricted { + include_platform_defaults: true, + readable_roots: vec![ + AbsolutePathBuf::try_from( + dunce::canonicalize(&read_dir).unwrap_or(read_dir) + ) + .expect("absolute read path") + ], + }, + network_access: true, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + }), + network: None, + shell_environment_policy: ShellEnvironmentPolicy::default(), + windows_sandbox_mode: None, + #[cfg(target_os = "macos")] + macos_seatbelt_profile_extensions: Some( + crate::seatbelt_permissions::MacOsSeatbeltProfileExtensions::default(), + ), + #[cfg(not(target_os = "macos"))] + macos_seatbelt_profile_extensions: None, + } + ); + } + + #[test] + fn compile_permission_profile_without_permissions_has_empty_profile() { + let tempdir = tempfile::tempdir().expect("tempdir"); + let skill_dir = tempdir.path().join("skill"); + fs::create_dir_all(&skill_dir).expect("skill dir"); + + let profile = compile_permission_profile(&skill_dir, None); + + assert_eq!(profile, None); + } + + #[test] + fn compile_permission_profile_with_network_only_uses_read_only_policy() { + let tempdir = tempfile::tempdir().expect("tempdir"); + let skill_dir = tempdir.path().join("skill"); + fs::create_dir_all(&skill_dir).expect("skill dir"); + + let profile = compile_permission_profile( + &skill_dir, + Some(SkillManifestPermissions { + network: true, + ..Default::default() + }), + ) + .expect("profile"); + + assert_eq!( + profile, + Permissions { + approval_policy: Constrained::allow_any(AskForApproval::Never), + sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()), + network: None, + shell_environment_policy: ShellEnvironmentPolicy::default(), + windows_sandbox_mode: None, + #[cfg(target_os = "macos")] + macos_seatbelt_profile_extensions: Some( + crate::seatbelt_permissions::MacOsSeatbeltProfileExtensions::default(), + ), + #[cfg(not(target_os = "macos"))] + macos_seatbelt_profile_extensions: None, + } + ); + } + + #[test] + fn compile_permission_profile_with_network_and_read_only_paths_uses_read_only_policy() { + let tempdir = tempfile::tempdir().expect("tempdir"); + let skill_dir = tempdir.path().join("skill"); + let read_dir = skill_dir.join("data"); + fs::create_dir_all(&read_dir).expect("read dir"); + + let profile = compile_permission_profile( + &skill_dir, + Some(SkillManifestPermissions { + network: true, + file_system: SkillManifestFileSystemPermissions { + read: vec!["./data".to_string()], + write: Vec::new(), + }, + ..Default::default() + }), + ) + .expect("profile"); + + assert_eq!( + profile, + Permissions { + approval_policy: Constrained::allow_any(AskForApproval::Never), + sandbox_policy: Constrained::allow_any(SandboxPolicy::ReadOnly { + access: ReadOnlyAccess::Restricted { + include_platform_defaults: true, + readable_roots: vec![ + AbsolutePathBuf::try_from( + dunce::canonicalize(&read_dir).unwrap_or(read_dir) + ) + .expect("absolute read path") + ], + }, + }), + network: None, + shell_environment_policy: ShellEnvironmentPolicy::default(), + windows_sandbox_mode: None, + #[cfg(target_os = "macos")] + macos_seatbelt_profile_extensions: Some( + crate::seatbelt_permissions::MacOsSeatbeltProfileExtensions::default(), + ), + #[cfg(not(target_os = "macos"))] + macos_seatbelt_profile_extensions: None, + } + ); + } + + #[cfg(target_os = "macos")] + #[test] + fn compile_permission_profile_builds_macos_permission_file() { + let tempdir = tempfile::tempdir().expect("tempdir"); + let skill_dir = tempdir.path().join("skill"); + fs::create_dir_all(&skill_dir).expect("skill dir"); + + let profile = compile_permission_profile( + &skill_dir, + Some(SkillManifestPermissions { + macos: SkillManifestMacOsPermissions { + preferences: Some(super::MacOsPreferencesValue::Mode("readwrite".to_string())), + automations: Some(super::MacOsAutomationValue::BundleIds(vec![ + "com.apple.Notes".to_string(), + ])), + accessibility: true, + calendar: true, + }, + ..Default::default() + }), + ) + .expect("profile"); + + assert_eq!( + profile.macos_seatbelt_profile_extensions, + Some( + crate::seatbelt_permissions::MacOsSeatbeltProfileExtensions { + macos_preferences: + crate::seatbelt_permissions::MacOsPreferencesPermission::ReadWrite, + macos_automation: + crate::seatbelt_permissions::MacOsAutomationPermission::BundleIds(vec![ + "com.apple.Notes".to_string() + ],), + macos_accessibility: true, + macos_calendar: true, + } + ) + ); + } + + #[cfg(target_os = "macos")] + #[test] + fn compile_permission_profile_uses_macos_defaults_when_values_missing() { + let tempdir = tempfile::tempdir().expect("tempdir"); + let skill_dir = tempdir.path().join("skill"); + fs::create_dir_all(&skill_dir).expect("skill dir"); + + let profile = + compile_permission_profile(&skill_dir, Some(SkillManifestPermissions::default())) + .expect("profile"); + + assert_eq!( + profile.macos_seatbelt_profile_extensions, + Some(crate::seatbelt_permissions::MacOsSeatbeltProfileExtensions::default()) + ); + } +} diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 4e4dabe5c6..e36c2f3c40 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -1402,6 +1402,7 @@ mod tests { interface: None, dependencies: None, policy: None, + permissions: None, path: PathBuf::from("test-skill"), scope: SkillScope::User, }]), diff --git a/codex-rs/tui/src/chatwidget/skills.rs b/codex-rs/tui/src/chatwidget/skills.rs index 6ec4aefa6d..e7fadde1af 100644 --- a/codex-rs/tui/src/chatwidget/skills.rs +++ b/codex-rs/tui/src/chatwidget/skills.rs @@ -190,6 +190,7 @@ fn protocol_skill_to_core(skill: &ProtocolSkillMetadata) -> SkillMetadata { .collect(), }), policy: None, + permissions: None, path: skill.path.clone(), scope: skill.scope, } diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index cd03b9899b..5435913bbe 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -874,6 +874,7 @@ async fn submission_prefers_selected_duplicate_skill_path() { interface: None, dependencies: None, policy: None, + permissions: None, path: repo_skill_path, scope: SkillScope::Repo, }, @@ -884,6 +885,7 @@ async fn submission_prefers_selected_duplicate_skill_path() { interface: None, dependencies: None, policy: None, + permissions: None, path: user_skill_path.clone(), scope: SkillScope::User, }, From de93cef5b716216a8d7c7d77fb621977a6ae923a Mon Sep 17 00:00:00 2001 From: Josh McKinney Date: Fri, 13 Feb 2026 18:11:19 -0800 Subject: [PATCH 09/62] bazel: enforce MODULE.bazel.lock sync with Cargo.lock (#11790) ## Why this change When Cargo dependencies change, it is easy to end up with an unexpected local diff in `MODULE.bazel.lock` after running Bazel. That creates noisy working copies and pushes lockfile fixes later in the cycle. This change addresses that pain point directly. ## What this change enforces The expected invariant is: after dependency updates, `MODULE.bazel.lock` is already in sync with Cargo resolution. In practice, running `bazel mod deps` should not mutate the lockfile in a clean state. If it does, the dependency update is incomplete. ## How this is enforced This change adds a single lockfile check script that snapshots `MODULE.bazel.lock`, runs `bazel mod deps`, and fails if the file changes. The same check is wired into local workflow commands (`just bazel-lock-update` and `just bazel-lock-check`) and into Bazel CI (Linux x86_64 job) so drift is caught early and consistently. The developer documentation is updated in `codex-rs/docs/bazel.md` and `AGENTS.md` to make the expected flow explicit. `MODULE.bazel.lock` is also refreshed in this PR to match the current Cargo dependency resolution. ## Expected developer workflow After changing `Cargo.toml` or `Cargo.lock`, run `just bazel-lock-update`, then run `just bazel-lock-check`, and include any resulting `MODULE.bazel.lock` update in the same change. ## Testing Ran `just bazel-lock-check` locally. --- .github/workflows/bazel.yml | 5 +++++ AGENTS.md | 4 ++++ MODULE.bazel.lock | 4 ++-- codex-rs/docs/bazel.md | 15 ++++++++++++++- justfile | 8 ++++++++ scripts/check-module-bazel-lock.sh | 8 ++++++++ 6 files changed, 41 insertions(+), 3 deletions(-) create mode 100755 scripts/check-module-bazel-lock.sh diff --git a/.github/workflows/bazel.yml b/.github/workflows/bazel.yml index 292e549b41..59a1e07fa7 100644 --- a/.github/workflows/bazel.yml +++ b/.github/workflows/bazel.yml @@ -65,6 +65,11 @@ jobs: - name: Set up Bazel uses: bazelbuild/setup-bazelisk@v3 + - name: Check MODULE.bazel.lock is up to date + if: matrix.os == 'ubuntu-24.04' && matrix.target == 'x86_64-unknown-linux-gnu' + shell: bash + run: ./scripts/check-module-bazel-lock.sh + # TODO(mbolin): Bring this back once we have caching working. Currently, # we never seem to get a cache hit but we still end up paying the cost of # uploading at the end of the build, which takes over a minute! diff --git a/AGENTS.md b/AGENTS.md index fc545c5080..fa2a4bbaba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,6 +15,10 @@ In the codex-rs folder where the rust code lives: - When writing tests, prefer comparing the equality of entire objects over fields one by one. - When making a change that adds or changes an API, ensure that the documentation in the `docs/` folder is up to date if applicable. - If you change `ConfigToml` or nested config types, run `just write-config-schema` to update `codex-rs/core/config.schema.json`. +- If you change Rust dependencies (`Cargo.toml` or `Cargo.lock`), run `just bazel-lock-update` from the + repo root to refresh `MODULE.bazel.lock`, and include that lockfile update in the same change. +- After dependency changes, run `just bazel-lock-check` from the repo root so lockfile drift is caught + locally before CI. - Do not create small helper methods that are referenced only once. Run `just fmt` (in `codex-rs` directory) automatically after you have finished making Rust code changes; do not ask for approval to run it. Additionally, run the tests: diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index 98efd3cede..6f15063691 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -1154,8 +1154,8 @@ "reqwest_0.12.28": "{\"dependencies\":[{\"name\":\"base64\",\"req\":\"^0.22\"},{\"kind\":\"dev\",\"name\":\"brotli_crate\",\"package\":\"brotli\",\"req\":\"^8\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"bytes\",\"req\":\"^1.2\"},{\"name\":\"cookie_crate\",\"optional\":true,\"package\":\"cookie\",\"req\":\"^0.18.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"cookie_store\",\"optional\":true,\"req\":\"^0.22.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"encoding_rs\",\"optional\":true,\"req\":\"^0.8\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.10\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"kind\":\"dev\",\"name\":\"flate2\",\"req\":\"^1.0.13\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"futures-channel\",\"optional\":true,\"req\":\"^0.3\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3.28\"},{\"default_features\":false,\"name\":\"futures-util\",\"optional\":true,\"req\":\"^0.3.28\"},{\"default_features\":false,\"features\":[\"std\",\"alloc\"],\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3.28\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"h2\",\"optional\":true,\"req\":\"^0.4\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"h3\",\"optional\":true,\"req\":\"^0.0.8\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"h3-quinn\",\"optional\":true,\"req\":\"^0.0.10\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"features\":[\"tokio\"],\"name\":\"hickory-resolver\",\"optional\":true,\"req\":\"^0.25\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"http\",\"req\":\"^1.1\"},{\"name\":\"http-body\",\"req\":\"^1\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"http-body-util\",\"req\":\"^0.1.2\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"features\":[\"http1\",\"client\"],\"name\":\"hyper\",\"req\":\"^1.1\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"http1\",\"http2\",\"client\",\"server\"],\"kind\":\"dev\",\"name\":\"hyper\",\"req\":\"^1.1.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"http1\",\"tls12\"],\"name\":\"hyper-rustls\",\"optional\":true,\"req\":\"^0.27.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"hyper-tls\",\"optional\":true,\"req\":\"^0.6\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"features\":[\"http1\",\"client\",\"client-legacy\",\"client-proxy\",\"tokio\"],\"name\":\"hyper-util\",\"req\":\"^0.1.12\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"features\":[\"http1\",\"http2\",\"client\",\"client-legacy\",\"server-auto\",\"server-graceful\",\"tokio\"],\"kind\":\"dev\",\"name\":\"hyper-util\",\"req\":\"^0.1.12\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"js-sys\",\"req\":\"^0.3.77\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0\"},{\"name\":\"log\",\"req\":\"^0.4.17\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"mime\",\"optional\":true,\"req\":\"^0.3.16\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"name\":\"mime_guess\",\"optional\":true,\"req\":\"^2.0\"},{\"name\":\"native-tls-crate\",\"optional\":true,\"package\":\"native-tls\",\"req\":\"^0.2.10\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"kind\":\"dev\",\"name\":\"num_cpus\",\"req\":\"^1.0\"},{\"name\":\"once_cell\",\"optional\":true,\"req\":\"^1.18\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"percent-encoding\",\"req\":\"^2.3\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.11\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"rustls\",\"runtime-tokio\"],\"name\":\"quinn\",\"optional\":true,\"req\":\"^0.11.1\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"std\",\"tls12\"],\"name\":\"rustls\",\"optional\":true,\"req\":\"^0.23.4\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"rustls-native-certs\",\"optional\":true,\"req\":\"^0.8.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"features\":[\"std\"],\"name\":\"rustls-pki-types\",\"optional\":true,\"req\":\"^1.9.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"serde\",\"req\":\"^1.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"serde_json\",\"req\":\"^1.0\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"serde_urlencoded\",\"req\":\"^0.7.1\"},{\"features\":[\"futures\"],\"name\":\"sync_wrapper\",\"req\":\"^1.0\"},{\"default_features\":false,\"features\":[\"net\",\"time\"],\"name\":\"tokio\",\"req\":\"^1.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"macros\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"tokio-native-tls\",\"optional\":true,\"req\":\"^0.3.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"tls12\"],\"name\":\"tokio-rustls\",\"optional\":true,\"req\":\"^0.26\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"io\"],\"name\":\"tokio-util\",\"optional\":true,\"req\":\"^0.7.9\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"retry\",\"timeout\",\"util\"],\"name\":\"tower\",\"req\":\"^0.5.2\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"limit\"],\"kind\":\"dev\",\"name\":\"tower\",\"req\":\"^0.5.2\"},{\"default_features\":false,\"features\":[\"follow-redirect\"],\"name\":\"tower-http\",\"req\":\"^0.6.8\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"tower-service\",\"req\":\"^0.3\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"url\",\"req\":\"^2.4\"},{\"name\":\"wasm-bindgen\",\"req\":\"^0.2.89\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"features\":[\"serde-serialize\"],\"kind\":\"dev\",\"name\":\"wasm-bindgen\",\"req\":\"^0.2.89\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"name\":\"wasm-bindgen-futures\",\"req\":\"^0.4.18\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"name\":\"wasm-streams\",\"optional\":true,\"req\":\"^0.4\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"features\":[\"AbortController\",\"AbortSignal\",\"Headers\",\"Request\",\"RequestInit\",\"RequestMode\",\"Response\",\"Window\",\"FormData\",\"Blob\",\"BlobPropertyBag\",\"ServiceWorkerGlobalScope\",\"RequestCredentials\",\"File\",\"ReadableStream\",\"RequestCache\"],\"name\":\"web-sys\",\"req\":\"^0.3.28\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"name\":\"webpki-roots\",\"optional\":true,\"req\":\"^1\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"kind\":\"dev\",\"name\":\"zstd_crate\",\"package\":\"zstd\",\"req\":\"^0.13\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"}],\"features\":{\"__rustls\":[\"dep:hyper-rustls\",\"dep:tokio-rustls\",\"dep:rustls\",\"__tls\"],\"__rustls-ring\":[\"hyper-rustls?/ring\",\"tokio-rustls?/ring\",\"rustls?/ring\",\"quinn?/ring\"],\"__tls\":[\"dep:rustls-pki-types\",\"tokio/io-util\"],\"blocking\":[\"dep:futures-channel\",\"futures-channel?/sink\",\"dep:futures-util\",\"futures-util?/io\",\"futures-util?/sink\",\"tokio/sync\"],\"brotli\":[\"tower-http/decompression-br\"],\"charset\":[\"dep:encoding_rs\",\"dep:mime\"],\"cookies\":[\"dep:cookie_crate\",\"dep:cookie_store\"],\"default\":[\"default-tls\",\"charset\",\"http2\",\"system-proxy\"],\"default-tls\":[\"dep:hyper-tls\",\"dep:native-tls-crate\",\"__tls\",\"dep:tokio-native-tls\"],\"deflate\":[\"tower-http/decompression-deflate\"],\"gzip\":[\"tower-http/decompression-gzip\"],\"hickory-dns\":[\"dep:hickory-resolver\",\"dep:once_cell\"],\"http2\":[\"h2\",\"hyper/http2\",\"hyper-util/http2\",\"hyper-rustls?/http2\"],\"http3\":[\"rustls-tls-manual-roots\",\"dep:h3\",\"dep:h3-quinn\",\"dep:quinn\",\"tokio/macros\"],\"json\":[\"dep:serde_json\"],\"macos-system-configuration\":[\"system-proxy\"],\"multipart\":[\"dep:mime_guess\",\"dep:futures-util\"],\"native-tls\":[\"default-tls\"],\"native-tls-alpn\":[\"native-tls\",\"native-tls-crate?/alpn\",\"hyper-tls?/alpn\"],\"native-tls-vendored\":[\"native-tls\",\"native-tls-crate?/vendored\"],\"rustls-tls\":[\"rustls-tls-webpki-roots\"],\"rustls-tls-manual-roots\":[\"rustls-tls-manual-roots-no-provider\",\"__rustls-ring\"],\"rustls-tls-manual-roots-no-provider\":[\"__rustls\"],\"rustls-tls-native-roots\":[\"rustls-tls-native-roots-no-provider\",\"__rustls-ring\"],\"rustls-tls-native-roots-no-provider\":[\"dep:rustls-native-certs\",\"hyper-rustls?/native-tokio\",\"__rustls\"],\"rustls-tls-no-provider\":[\"rustls-tls-manual-roots-no-provider\"],\"rustls-tls-webpki-roots\":[\"rustls-tls-webpki-roots-no-provider\",\"__rustls-ring\"],\"rustls-tls-webpki-roots-no-provider\":[\"dep:webpki-roots\",\"hyper-rustls?/webpki-tokio\",\"__rustls\"],\"socks\":[],\"stream\":[\"tokio/fs\",\"dep:futures-util\",\"dep:tokio-util\",\"dep:wasm-streams\"],\"system-proxy\":[\"hyper-util/client-proxy-system\"],\"trust-dns\":[],\"zstd\":[\"tower-http/decompression-zstd\"]}}", "resolv-conf_0.7.6": "{\"dependencies\":[],\"features\":{\"system\":[]}}", "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.14.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.14.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.14.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\":[],\"macros\":[\"dep:rmcp-macros\",\"dep:pastey\"],\"reqwest\":[\"__reqwest\",\"reqwest?/rustls-tls\"],\"reqwest-tls-no-provider\":[\"__reqwest\",\"reqwest?/rustls-tls-no-provider\"],\"schemars\":[\"dep:schemars\"],\"server\":[\"transport-async-rw\",\"dep:schemars\"],\"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\"]}}", + "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\"]}}", "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\"]}}", diff --git a/codex-rs/docs/bazel.md b/codex-rs/docs/bazel.md index 52c5d523cb..a124688a24 100644 --- a/codex-rs/docs/bazel.md +++ b/codex-rs/docs/bazel.md @@ -23,7 +23,20 @@ As of 1/9/2026, this setup is still experimental as we stabilize it. ## Evolving the setup When you add or change Rust dependencies, update the Cargo.toml/Cargo.lock as normal. -The Bazel build should automatically pick things up without any manual action needed. +Then refresh the Bzlmod lockfile from the repo root: + +```bash +just bazel-lock-update +``` + +This runs `bazel mod deps --lockfile_mode=update` and updates `MODULE.bazel.lock` if needed. +Commit the lockfile changes along with your Cargo lockfile update. + +To verify lockfile alignment locally (the same check CI runs), use: + +```bash +just bazel-lock-check +``` In some cases, an upstream crate may need a patch or a `crate.annotation` in `../MODULE.bzl` to have it build in Bazel's sandbox or make it cross-compilation-friendly. If you see issues, diff --git a/justfile b/justfile index 024a31ba2a..33d62d515b 100644 --- a/justfile +++ b/justfile @@ -51,6 +51,14 @@ test: bazel-codex *args: bazel run //codex-rs/cli:codex --run_under="cd $PWD &&" -- "$@" +[no-cd] +bazel-lock-update: + bazel mod deps --lockfile_mode=update + +[no-cd] +bazel-lock-check: + ./scripts/check-module-bazel-lock.sh + bazel-test: bazel test //... --keep_going diff --git a/scripts/check-module-bazel-lock.sh b/scripts/check-module-bazel-lock.sh new file mode 100755 index 0000000000..1a14889643 --- /dev/null +++ b/scripts/check-module-bazel-lock.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail + +if ! bazel mod deps --lockfile_mode=error; then + echo "MODULE.bazel.lock is out of date." + echo "Run 'just bazel-lock-update' and commit the updated lockfile." + exit 1 +fi From 8156c572340464fe549de5fdb63ba23783e641c8 Mon Sep 17 00:00:00 2001 From: alexsong-oai Date: Fri, 13 Feb 2026 18:15:07 -0800 Subject: [PATCH 10/62] add perf metrics for connectors load (#11803) --- codex-rs/core/src/mcp_connection_manager.rs | 36 +++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/codex-rs/core/src/mcp_connection_manager.rs b/codex-rs/core/src/mcp_connection_manager.rs index af2003375a..36f0cbb6cb 100644 --- a/codex-rs/core/src/mcp_connection_manager.rs +++ b/codex-rs/core/src/mcp_connection_manager.rs @@ -89,6 +89,9 @@ pub const DEFAULT_STARTUP_TIMEOUT: Duration = Duration::from_secs(10); const DEFAULT_TOOL_TIMEOUT: Duration = Duration::from_secs(60); const CODEX_APPS_TOOLS_CACHE_TTL: Duration = Duration::from_secs(3600); +const MCP_TOOLS_LIST_DURATION_METRIC: &str = "codex.mcp.tools.list.duration_ms"; +const MCP_TOOLS_FETCH_UNCACHED_DURATION_METRIC: &str = "codex.mcp.tools.fetch_uncached.duration_ms"; +const MCP_TOOLS_CACHE_WRITE_DURATION_METRIC: &str = "codex.mcp.tools.cache_write.duration_ms"; /// The Responses API requires tool names to match `^[a-zA-Z0-9_-]+$`. /// MCP server/tool names are user-controlled, so sanitize the fully-qualified @@ -1104,15 +1107,42 @@ async fn list_tools_for_client( client: &Arc, timeout: Option, ) -> Result> { + let total_start = Instant::now(); if server_name == CODEX_APPS_MCP_SERVER_NAME && let Some(cached_tools) = read_cached_codex_apps_tools() { + emit_duration( + MCP_TOOLS_LIST_DURATION_METRIC, + total_start.elapsed(), + &[("cache", "hit")], + ); return Ok(cached_tools); } + let fetch_start = Instant::now(); let tools = list_tools_for_client_uncached(server_name, client, timeout).await?; + emit_duration( + MCP_TOOLS_FETCH_UNCACHED_DURATION_METRIC, + fetch_start.elapsed(), + &[], + ); + if server_name == CODEX_APPS_MCP_SERVER_NAME { + let cache_write_start = Instant::now(); write_cached_codex_apps_tools(&tools); + emit_duration( + MCP_TOOLS_CACHE_WRITE_DURATION_METRIC, + cache_write_start.elapsed(), + &[], + ); + } + + if server_name == CODEX_APPS_MCP_SERVER_NAME { + emit_duration( + MCP_TOOLS_LIST_DURATION_METRIC, + total_start.elapsed(), + &[("cache", "miss")], + ); } Ok(tools) } @@ -1148,6 +1178,12 @@ fn write_cached_codex_apps_tools(tools: &[ToolInfo]) { }); } +fn emit_duration(metric: &str, duration: Duration, tags: &[(&str, &str)]) { + if let Some(metrics) = codex_otel::metrics::global() { + let _ = metrics.record_duration(metric, duration, tags); + } +} + async fn list_tools_for_client_uncached( server_name: &str, client: &Arc, From 67e577da533d9fb5fbfdc2a904321f789e63736e Mon Sep 17 00:00:00 2001 From: Charley Cunningham Date: Fri, 13 Feb 2026 19:02:53 -0800 Subject: [PATCH 11/62] Handle model-switch base instructions after compaction (#11659) Strip trailing during model-switch compaction request, and append after model switch compaction --- codex-rs/core/src/codex.rs | 19 ++++ codex-rs/core/src/compact.rs | 136 +++++++++++++++++++++++++++ codex-rs/core/src/compact_remote.rs | 10 ++ codex-rs/core/tests/suite/compact.rs | 9 ++ 4 files changed, 174 insertions(+) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 94e6e1ceb8..05cb42e9e8 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -1932,6 +1932,19 @@ impl Session { Some(DeveloperInstructions::model_switch_message(model_instructions).into()) } + pub(crate) fn is_model_switch_developer_message(item: &ResponseItem) -> bool { + let ResponseItem::Message { role, content, .. } = item else { + return false; + }; + role == "developer" + && content.iter().any(|content_item| { + matches!( + content_item, + ContentItem::InputText { text } if text.starts_with("") + ) + }) + } + fn build_settings_update_items( &self, previous_context: Option<&Arc>, @@ -4438,6 +4451,12 @@ async fn run_pre_sampling_compact( Ok(()) } +/// Runs pre-sampling compaction against the previous model when switching to a smaller +/// context-window model. +/// +/// Returns `Ok(())` when compaction either completed successfully or was skipped because the +/// model/context-window preconditions were not met. Returns `Err(_)` only when compaction was +/// attempted and failed. async fn maybe_run_previous_model_inline_compact( sess: &Arc, turn_context: &Arc, diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index eaee299f07..a40f1a0b8e 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -7,6 +7,7 @@ use crate::client_common::ResponseEvent; use crate::codex::Session; use crate::codex::TurnContext; use crate::codex::get_last_assistant_message_from_turn; +use crate::context_manager::ContextManager; use crate::error::CodexErr; use crate::error::Result as CodexResult; use crate::protocol::CompactedItem; @@ -35,6 +36,31 @@ pub(crate) fn should_use_remote_compact_task(provider: &ModelProviderInfo) -> bo provider.is_openai() } +pub(crate) fn extract_trailing_model_switch_update_for_compaction_request( + history: &mut ContextManager, +) -> Option { + let history_items = history.raw_items(); + let last_user_turn_boundary_index = history_items + .iter() + .rposition(crate::context_manager::is_user_turn_boundary); + let model_switch_index = history_items + .iter() + .enumerate() + .rev() + .find_map(|(i, item)| { + let is_trailing = last_user_turn_boundary_index.is_none_or(|boundary| i > boundary); + if is_trailing && Session::is_model_switch_developer_message(item) { + Some(i) + } else { + None + } + })?; + let mut replacement = history_items.to_vec(); + let model_switch_item = replacement.remove(model_switch_index); + history.replace(replacement); + Some(model_switch_item) +} + pub(crate) async fn run_inline_auto_compact_task( sess: Arc, turn_context: Arc, @@ -75,6 +101,10 @@ async fn run_compact_task_inner( let initial_input_for_turn: ResponseInputItem = ResponseInputItem::from(input); let mut history = sess.clone_history().await; + // Keep compaction prompts in-distribution: if a model-switch update was injected at the + // tail of history (between turns), exclude it from the compaction request payload. + let stripped_model_switch_item = + extract_trailing_model_switch_update_for_compaction_request(&mut history); history.record_items( &[initial_input_for_turn.into()], turn_context.truncation_policy, @@ -180,6 +210,11 @@ async fn run_compact_task_inner( let initial_context = sess.build_initial_context(turn_context.as_ref()).await; let mut new_history = build_compacted_history(initial_context, &user_messages, &summary_text); + // Reattach the stripped model-switch update only after successful compaction so the model + // still sees the switch instructions on the next real sampling request. + if let Some(model_switch_item) = stripped_model_switch_item { + new_history.push(model_switch_item); + } let ghost_snapshots: Vec = history_items .iter() .filter(|item| matches!(item, ResponseItem::GhostSnapshot { .. })) @@ -443,6 +478,107 @@ mod tests { assert_eq!(None, joined); } + #[test] + fn extract_trailing_model_switch_update_for_compaction_request_removes_trailing_item() { + let mut history = ContextManager::new(); + history.replace(vec![ + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "USER_MESSAGE".to_string(), + }], + end_turn: None, + phase: None, + }, + ResponseItem::Message { + id: None, + role: "assistant".to_string(), + content: vec![ContentItem::OutputText { + text: "ASSISTANT_REPLY".to_string(), + }], + end_turn: None, + phase: None, + }, + ResponseItem::Message { + id: None, + role: "developer".to_string(), + content: vec![ContentItem::InputText { + text: "\nNEW_MODEL_INSTRUCTIONS".to_string(), + }], + end_turn: None, + phase: None, + }, + ]); + + let model_switch_item = + extract_trailing_model_switch_update_for_compaction_request(&mut history); + + assert_eq!(history.raw_items().len(), 2); + assert!(model_switch_item.is_some()); + assert!( + history + .raw_items() + .iter() + .all(|item| !Session::is_model_switch_developer_message(item)) + ); + } + + #[test] + fn extract_trailing_model_switch_update_for_compaction_request_keeps_historical_item() { + let mut history = ContextManager::new(); + history.replace(vec![ + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "FIRST_USER_MESSAGE".to_string(), + }], + end_turn: None, + phase: None, + }, + ResponseItem::Message { + id: None, + role: "developer".to_string(), + content: vec![ContentItem::InputText { + text: "\nOLDER_MODEL_INSTRUCTIONS".to_string(), + }], + end_turn: None, + phase: None, + }, + ResponseItem::Message { + id: None, + role: "assistant".to_string(), + content: vec![ContentItem::OutputText { + text: "ASSISTANT_REPLY".to_string(), + }], + end_turn: None, + phase: None, + }, + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "SECOND_USER_MESSAGE".to_string(), + }], + end_turn: None, + phase: None, + }, + ]); + + let model_switch_item = + extract_trailing_model_switch_update_for_compaction_request(&mut history); + + assert_eq!(history.raw_items().len(), 4); + assert!(model_switch_item.is_none()); + assert!( + history + .raw_items() + .iter() + .any(Session::is_model_switch_developer_message) + ); + } + #[test] fn collect_user_messages_extracts_user_text_only() { let items = vec![ diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index 4fe07560b9..3d181f8855 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use crate::Prompt; use crate::codex::Session; use crate::codex::TurnContext; +use crate::compact::extract_trailing_model_switch_update_for_compaction_request; use crate::context_manager::ContextManager; use crate::context_manager::TotalTokenUsageBreakdown; use crate::context_manager::estimate_response_item_model_visible_bytes; @@ -65,6 +66,10 @@ async fn run_remote_compact_task_inner_impl( sess.emit_turn_item_started(turn_context, &compaction_item) .await; let mut history = sess.clone_history().await; + // Keep compaction prompts in-distribution: if a model-switch update was injected at the + // tail of history (between turns), exclude it from the compaction request payload. + let stripped_model_switch_item = + extract_trailing_model_switch_update_for_compaction_request(&mut history); let base_instructions = sess.get_base_instructions().await; let deleted_items = trim_function_call_history_to_fit_context_window( &mut history, @@ -120,6 +125,11 @@ async fn run_remote_compact_task_inner_impl( new_history = sess .process_compacted_history(turn_context, new_history) .await; + // Reattach the stripped model-switch update only after successful compaction so the model + // still sees the switch instructions on the next real sampling request. + if let Some(model_switch_item) = stripped_model_switch_item { + new_history.push(model_switch_item); + } if !ghost_snapshots.is_empty() { new_history.extend(ghost_snapshots); diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index 914673bdb3..888d5946c0 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -142,6 +142,15 @@ fn assert_pre_sampling_switch_compaction_requests( body_contains_text(&compact_body, SUMMARIZATION_PROMPT), "pre-sampling compact request should include summarization prompt" ); + assert!( + !compact_body.contains(""), + "pre-sampling compact request should strip trailing model-switch update item" + ); + let follow_up_body = follow_up.to_string(); + assert!( + follow_up_body.contains(""), + "follow-up request after successful model-switch compaction should include model-switch update item" + ); } async fn assert_compaction_uses_turn_lifecycle_id(codex: &std::sync::Arc) { From 854e91e422b85fde31f26441908938116eea7c38 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Fri, 13 Feb 2026 20:16:22 -0800 Subject: [PATCH 12/62] Fixed help text for `mcp` and `mcp-server` CLI commands (#11813) Also removed the "[experimental]" tag since these have been stable for many months This addresses #11812 --- codex-rs/cli/src/main.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 494f8d8935..cae322c77e 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -93,10 +93,10 @@ enum Subcommand { /// Remove stored authentication credentials. Logout(LogoutCommand), - /// [experimental] Run Codex as an MCP server and manage MCP servers. + /// Manage external MCP servers for Codex. Mcp(McpCli), - /// [experimental] Run the Codex MCP server (stdio transport). + /// Start Codex as an MCP server (stdio). McpServer, /// [experimental] Run the app server or related tooling. From b527ee28907a7dcc68185f256c0441d90a07fe9d Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Fri, 13 Feb 2026 20:18:12 -0800 Subject: [PATCH 13/62] feat(core): add structured network approval plumbing and policy decision model (#11672) ### Description #### Summary Introduces the core plumbing required for structured network approvals #### What changed - Added structured network policy decision modeling in core. - Added approval payload/context types needed for network approval semantics. - Wired shell/unified-exec runtime plumbing to consume structured decisions. - Updated related core error/event surfaces for structured handling. - Updated protocol plumbing used by core approval flow. - Included small CLI debug sandbox compatibility updates needed by this layer. #### Why establishes the minimal backend foundation for network approvals without yet changing high-level orchestration or TUI behavior. #### Notes - Behavior remains constrained by existing requirements/config gating. - Follow-up PRs in the stack handle orchestration, UX, and app-server integration. --------- Co-authored-by: Codex <199175422+chatgpt-codex-connector[bot]@users.noreply.github.com> --- .../schema/json/EventMsg.json | 46 ++ .../schema/json/ServerNotification.json | 35 + .../codex_app_server_protocol.schemas.json | 35 + .../json/v1/ForkConversationResponse.json | 35 + .../json/v1/ResumeConversationResponse.json | 35 + .../v1/SessionConfiguredNotification.json | 35 + .../typescript/ExecApprovalRequestEvent.ts | 5 + .../typescript/NetworkApprovalContext.ts | 6 + .../typescript/NetworkApprovalProtocol.ts | 5 + .../schema/typescript/index.ts | 2 + .../app-server/src/bespoke_event_handling.rs | 1 + .../app-server/src/codex_message_processor.rs | 13 +- codex-rs/cli/src/debug_sandbox.rs | 13 +- codex-rs/core/src/codex.rs | 104 ++- codex-rs/core/src/codex_delegate.rs | 2 + codex-rs/core/src/config/mod.rs | 7 + .../core/src/config/network_proxy_spec.rs | 41 +- codex-rs/core/src/error.rs | 12 +- codex-rs/core/src/exec.rs | 61 +- codex-rs/core/src/lib.rs | 1 + codex-rs/core/src/network_policy_decision.rs | 254 +++++++ codex-rs/core/src/sandboxing/mod.rs | 2 + codex-rs/core/src/state/service.rs | 2 + codex-rs/core/src/tasks/user_shell.rs | 1 + codex-rs/core/src/tools/events.rs | 2 +- .../core/src/tools/handlers/apply_patch.rs | 8 +- codex-rs/core/src/tools/handlers/shell.rs | 6 +- codex-rs/core/src/tools/mod.rs | 1 + codex-rs/core/src/tools/network_approval.rs | 675 ++++++++++++++++++ codex-rs/core/src/tools/orchestrator.rs | 154 +++- codex-rs/core/src/tools/runtimes/shell.rs | 20 +- .../core/src/tools/runtimes/unified_exec.rs | 20 +- codex-rs/core/src/tools/sandboxing.rs | 8 + .../core/src/unified_exec/async_watcher.rs | 37 + codex-rs/core/src/unified_exec/mod.rs | 3 + .../core/src/unified_exec/process_manager.rs | 159 ++++- codex-rs/core/tests/suite/exec.rs | 1 + .../exec-server/src/posix/escalate_server.rs | 1 + .../linux-sandbox/tests/suite/landlock.rs | 8 +- codex-rs/mcp-server/src/codex_tool_runner.rs | 1 + codex-rs/network-proxy/src/lib.rs | 4 + codex-rs/network-proxy/src/network_policy.rs | 6 +- codex-rs/network-proxy/src/proxy.rs | 30 +- codex-rs/network-proxy/src/runtime.rs | 122 ++-- codex-rs/protocol/src/approvals.rs | 22 + codex-rs/protocol/src/protocol.rs | 2 + codex-rs/tui/src/chatwidget/tests.rs | 7 + 47 files changed, 1874 insertions(+), 176 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/NetworkApprovalContext.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/NetworkApprovalProtocol.ts create mode 100644 codex-rs/core/src/network_policy_decision.rs create mode 100644 codex-rs/core/src/tools/network_approval.rs diff --git a/codex-rs/app-server-protocol/schema/json/EventMsg.json b/codex-rs/app-server-protocol/schema/json/EventMsg.json index 6af66294c8..21d8784c34 100644 --- a/codex-rs/app-server-protocol/schema/json/EventMsg.json +++ b/codex-rs/app-server-protocol/schema/json/EventMsg.json @@ -1438,6 +1438,17 @@ "description": "The command's working directory.", "type": "string" }, + "network_approval_context": { + "anyOf": [ + { + "$ref": "#/definitions/NetworkApprovalContext" + }, + { + "type": "null" + } + ], + "description": "Optional network context for a blocked request that can be approved." + }, "parsed_cmd": { "items": { "$ref": "#/definitions/ParsedCommand" @@ -3315,6 +3326,30 @@ ], "type": "string" }, + "NetworkApprovalContext": { + "properties": { + "host": { + "type": "string" + }, + "protocol": { + "$ref": "#/definitions/NetworkApprovalProtocol" + } + }, + "required": [ + "host", + "protocol" + ], + "type": "object" + }, + "NetworkApprovalProtocol": { + "enum": [ + "http", + "https", + "socks5_tcp", + "socks5_udp" + ], + "type": "string" + }, "ParsedCommand": { "oneOf": [ { @@ -6308,6 +6343,17 @@ "description": "The command's working directory.", "type": "string" }, + "network_approval_context": { + "anyOf": [ + { + "$ref": "#/definitions/NetworkApprovalContext" + }, + { + "type": "null" + } + ], + "description": "Optional network context for a blocked request that can be approved." + }, "parsed_cmd": { "items": { "$ref": "#/definitions/ParsedCommand" diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index c6b84ac5dc..6b12cee9cf 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -2059,6 +2059,17 @@ "description": "The command's working directory.", "type": "string" }, + "network_approval_context": { + "anyOf": [ + { + "$ref": "#/definitions/NetworkApprovalContext" + }, + { + "type": "null" + } + ], + "description": "Optional network context for a blocked request that can be approved." + }, "parsed_cmd": { "items": { "$ref": "#/definitions/ParsedCommand" @@ -4207,6 +4218,30 @@ ], "type": "string" }, + "NetworkApprovalContext": { + "properties": { + "host": { + "type": "string" + }, + "protocol": { + "$ref": "#/definitions/NetworkApprovalProtocol" + } + }, + "required": [ + "host", + "protocol" + ], + "type": "object" + }, + "NetworkApprovalProtocol": { + "enum": [ + "http", + "https", + "socks5_tcp", + "socks5_udp" + ], + "type": "string" + }, "ParsedCommand": { "oneOf": [ { 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 a3ef24452b..5f352004cb 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 @@ -3451,6 +3451,17 @@ "description": "The command's working directory.", "type": "string" }, + "network_approval_context": { + "anyOf": [ + { + "$ref": "#/definitions/NetworkApprovalContext" + }, + { + "type": "null" + } + ], + "description": "Optional network context for a blocked request that can be approved." + }, "parsed_cmd": { "items": { "$ref": "#/definitions/ParsedCommand" @@ -6263,6 +6274,30 @@ ], "type": "string" }, + "NetworkApprovalContext": { + "properties": { + "host": { + "type": "string" + }, + "protocol": { + "$ref": "#/definitions/NetworkApprovalProtocol" + } + }, + "required": [ + "host", + "protocol" + ], + "type": "object" + }, + "NetworkApprovalProtocol": { + "enum": [ + "http", + "https", + "socks5_tcp", + "socks5_udp" + ], + "type": "string" + }, "NewConversationParams": { "properties": { "approvalPolicy": { diff --git a/codex-rs/app-server-protocol/schema/json/v1/ForkConversationResponse.json b/codex-rs/app-server-protocol/schema/json/v1/ForkConversationResponse.json index 12db241e1a..225ea3f42a 100644 --- a/codex-rs/app-server-protocol/schema/json/v1/ForkConversationResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v1/ForkConversationResponse.json @@ -1438,6 +1438,17 @@ "description": "The command's working directory.", "type": "string" }, + "network_approval_context": { + "anyOf": [ + { + "$ref": "#/definitions/NetworkApprovalContext" + }, + { + "type": "null" + } + ], + "description": "Optional network context for a blocked request that can be approved." + }, "parsed_cmd": { "items": { "$ref": "#/definitions/ParsedCommand" @@ -3315,6 +3326,30 @@ ], "type": "string" }, + "NetworkApprovalContext": { + "properties": { + "host": { + "type": "string" + }, + "protocol": { + "$ref": "#/definitions/NetworkApprovalProtocol" + } + }, + "required": [ + "host", + "protocol" + ], + "type": "object" + }, + "NetworkApprovalProtocol": { + "enum": [ + "http", + "https", + "socks5_tcp", + "socks5_udp" + ], + "type": "string" + }, "ParsedCommand": { "oneOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v1/ResumeConversationResponse.json b/codex-rs/app-server-protocol/schema/json/v1/ResumeConversationResponse.json index dd7606b5e0..2b305281c8 100644 --- a/codex-rs/app-server-protocol/schema/json/v1/ResumeConversationResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v1/ResumeConversationResponse.json @@ -1438,6 +1438,17 @@ "description": "The command's working directory.", "type": "string" }, + "network_approval_context": { + "anyOf": [ + { + "$ref": "#/definitions/NetworkApprovalContext" + }, + { + "type": "null" + } + ], + "description": "Optional network context for a blocked request that can be approved." + }, "parsed_cmd": { "items": { "$ref": "#/definitions/ParsedCommand" @@ -3315,6 +3326,30 @@ ], "type": "string" }, + "NetworkApprovalContext": { + "properties": { + "host": { + "type": "string" + }, + "protocol": { + "$ref": "#/definitions/NetworkApprovalProtocol" + } + }, + "required": [ + "host", + "protocol" + ], + "type": "object" + }, + "NetworkApprovalProtocol": { + "enum": [ + "http", + "https", + "socks5_tcp", + "socks5_udp" + ], + "type": "string" + }, "ParsedCommand": { "oneOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v1/SessionConfiguredNotification.json b/codex-rs/app-server-protocol/schema/json/v1/SessionConfiguredNotification.json index 89ab825d32..d4bbf908d5 100644 --- a/codex-rs/app-server-protocol/schema/json/v1/SessionConfiguredNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v1/SessionConfiguredNotification.json @@ -1438,6 +1438,17 @@ "description": "The command's working directory.", "type": "string" }, + "network_approval_context": { + "anyOf": [ + { + "$ref": "#/definitions/NetworkApprovalContext" + }, + { + "type": "null" + } + ], + "description": "Optional network context for a blocked request that can be approved." + }, "parsed_cmd": { "items": { "$ref": "#/definitions/ParsedCommand" @@ -3315,6 +3326,30 @@ ], "type": "string" }, + "NetworkApprovalContext": { + "properties": { + "host": { + "type": "string" + }, + "protocol": { + "$ref": "#/definitions/NetworkApprovalProtocol" + } + }, + "required": [ + "host", + "protocol" + ], + "type": "object" + }, + "NetworkApprovalProtocol": { + "enum": [ + "http", + "https", + "socks5_tcp", + "socks5_udp" + ], + "type": "string" + }, "ParsedCommand": { "oneOf": [ { diff --git a/codex-rs/app-server-protocol/schema/typescript/ExecApprovalRequestEvent.ts b/codex-rs/app-server-protocol/schema/typescript/ExecApprovalRequestEvent.ts index 66db8fd40a..542ed879b0 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ExecApprovalRequestEvent.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ExecApprovalRequestEvent.ts @@ -2,6 +2,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { ExecPolicyAmendment } from "./ExecPolicyAmendment"; +import type { NetworkApprovalContext } from "./NetworkApprovalContext"; import type { ParsedCommand } from "./ParsedCommand"; export type ExecApprovalRequestEvent = { @@ -26,6 +27,10 @@ cwd: string, * Optional human-readable reason for the approval (e.g. retry without sandbox). */ reason: string | null, +/** + * Optional network context for a blocked request that can be approved. + */ +network_approval_context?: NetworkApprovalContext, /** * Proposed execpolicy amendment that can be applied to allow future runs. */ diff --git a/codex-rs/app-server-protocol/schema/typescript/NetworkApprovalContext.ts b/codex-rs/app-server-protocol/schema/typescript/NetworkApprovalContext.ts new file mode 100644 index 0000000000..b4b78e473c --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/NetworkApprovalContext.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 { NetworkApprovalProtocol } from "./NetworkApprovalProtocol"; + +export type NetworkApprovalContext = { host: string, protocol: NetworkApprovalProtocol, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/NetworkApprovalProtocol.ts b/codex-rs/app-server-protocol/schema/typescript/NetworkApprovalProtocol.ts new file mode 100644 index 0000000000..a33eab566f --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/NetworkApprovalProtocol.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 NetworkApprovalProtocol = "http" | "https" | "socks5_tcp" | "socks5_udp"; diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index ef17362ffd..3c0826712a 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -126,6 +126,8 @@ export type { McpToolCallEndEvent } from "./McpToolCallEndEvent"; export type { MessagePhase } from "./MessagePhase"; export type { ModeKind } from "./ModeKind"; export type { NetworkAccess } from "./NetworkAccess"; +export type { NetworkApprovalContext } from "./NetworkApprovalContext"; +export type { NetworkApprovalProtocol } from "./NetworkApprovalProtocol"; export type { NewConversationParams } from "./NewConversationParams"; export type { NewConversationResponse } from "./NewConversationResponse"; export type { ParsedCommand } from "./ParsedCommand"; diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index c1f36e1ef3..da7abe0593 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -206,6 +206,7 @@ pub(crate) async fn apply_bespoke_event_handling( reason, proposed_execpolicy_amendment, parsed_cmd, + .. }) => match api_version { ApiVersion::V1 => { let params = ExecCommandApprovalParams { diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index b7bffa70db..0439068db9 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -1688,8 +1688,18 @@ impl CodexMessageProcessor { let timeout_ms = params .timeout_ms .and_then(|timeout_ms| u64::try_from(timeout_ms).ok()); + let managed_network_requirements_enabled = + self.config.managed_network_requirements_enabled(); let started_network_proxy = match self.config.permissions.network.as_ref() { - Some(spec) => match spec.start_proxy().await { + Some(spec) => match spec + .start_proxy( + self.config.permissions.sandbox_policy.get(), + None, + None, + managed_network_requirements_enabled, + ) + .await + { Ok(started) => Some(started), Err(err) => { let error = JSONRPCErrorError { @@ -1712,6 +1722,7 @@ impl CodexMessageProcessor { network: started_network_proxy .as_ref() .map(codex_core::config::StartedNetworkProxy::proxy), + network_attempt_id: None, sandbox_permissions: SandboxPermissions::UseDefault, windows_sandbox_level, justification: None, diff --git a/codex-rs/cli/src/debug_sandbox.rs b/codex-rs/cli/src/debug_sandbox.rs index c19d58189f..a65fa486e4 100644 --- a/codex-rs/cli/src/debug_sandbox.rs +++ b/codex-rs/cli/src/debug_sandbox.rs @@ -213,12 +213,19 @@ async fn run_command_under_sandbox( #[cfg(not(target_os = "macos"))] let _ = log_denials; + let managed_network_requirements_enabled = config.managed_network_requirements_enabled(); + // This proxy should only live for the lifetime of the child process. let network_proxy = match config.permissions.network.as_ref() { Some(spec) => Some( - spec.start_proxy() - .await - .map_err(|err| anyhow::anyhow!("failed to start managed network proxy: {err}"))?, + spec.start_proxy( + config.permissions.sandbox_policy.get(), + None, + None, + managed_network_requirements_enabled, + ) + .await + .map_err(|err| anyhow::anyhow!("failed to start managed network proxy: {err}"))?, ), None => None, }; diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 05cb42e9e8..753337eb60 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -170,6 +170,7 @@ use crate::protocol::Event; use crate::protocol::EventMsg; use crate::protocol::ExecApprovalRequestEvent; use crate::protocol::McpServerRefreshConfig; +use crate::protocol::NetworkApprovalContext; use crate::protocol::Op; use crate::protocol::PlanDeltaEvent; use crate::protocol::RateLimitSnapshot; @@ -223,6 +224,9 @@ use crate::tasks::SessionTaskContext; use crate::tools::ToolRouter; use crate::tools::context::SharedTurnDiffTracker; use crate::tools::js_repl::JsReplHandle; +use crate::tools::network_approval::NetworkApprovalService; +use crate::tools::network_approval::build_blocked_request_observer; +use crate::tools::network_approval::build_network_policy_decider; use crate::tools::parallel::ToolCallRuntime; use crate::tools::sandboxing::ApprovalStore; use crate::tools::spec::ToolsConfig; @@ -519,7 +523,6 @@ pub(crate) struct Session { js_repl: Arc, next_internal_sub_id: AtomicU64, } - /// The context needed for a single turn of the thread. #[derive(Debug)] pub(crate) struct TurnContext { @@ -822,6 +825,33 @@ impl Session { } } + async fn start_managed_network_proxy( + spec: &crate::config::NetworkProxySpec, + sandbox_policy: &SandboxPolicy, + network_policy_decider: Option>, + blocked_request_observer: Option>, + managed_network_requirements_enabled: bool, + ) -> anyhow::Result<(StartedNetworkProxy, SessionNetworkProxyRuntime)> { + let network_proxy = spec + .start_proxy( + sandbox_policy, + network_policy_decider, + blocked_request_observer, + managed_network_requirements_enabled, + ) + .await + .map_err(|err| anyhow::anyhow!("failed to start managed network proxy: {err}"))?; + let session_network_proxy = { + let proxy = network_proxy.proxy(); + SessionNetworkProxyRuntime { + http_addr: proxy.http_addr().to_string(), + socks_addr: proxy.socks_addr().to_string(), + admin_addr: proxy.admin_addr().to_string(), + } + }; + Ok((network_proxy, session_network_proxy)) + } + /// Don't expand the number of mutated arguments on config. We are in the process of getting rid of it. pub(crate) fn build_per_turn_config(session_configuration: &SessionConfiguration) -> Config { // todo(aibrahim): store this state somewhere else so we don't need to mut config @@ -1178,21 +1208,50 @@ impl Session { }; session_configuration.thread_name = thread_name.clone(); let mut state = SessionState::new(session_configuration.clone()); - let network_proxy = - match config.permissions.network.as_ref() { - Some(spec) => Some(spec.start_proxy().await.map_err(|err| { - anyhow::anyhow!("failed to start managed network proxy: {err}") - })?), - None => None, + let managed_network_requirements_enabled = config.managed_network_requirements_enabled(); + let network_approval = Arc::new(NetworkApprovalService::default()); + // The managed proxy can call back into core for allowlist-miss decisions. + let network_policy_decider_session = if managed_network_requirements_enabled { + config + .permissions + .network + .as_ref() + .map(|_| Arc::new(RwLock::new(std::sync::Weak::::new()))) + } else { + None + }; + let blocked_request_observer = if managed_network_requirements_enabled { + config + .permissions + .network + .as_ref() + .map(|_| build_blocked_request_observer(Arc::clone(&network_approval))) + } else { + None + }; + let network_policy_decider = + network_policy_decider_session + .as_ref() + .map(|network_policy_decider_session| { + build_network_policy_decider( + Arc::clone(&network_approval), + Arc::clone(network_policy_decider_session), + ) + }); + let (network_proxy, session_network_proxy) = + if let Some(spec) = config.permissions.network.as_ref() { + let (network_proxy, session_network_proxy) = Self::start_managed_network_proxy( + spec, + config.permissions.sandbox_policy.get(), + network_policy_decider.as_ref().map(Arc::clone), + blocked_request_observer.as_ref().map(Arc::clone), + managed_network_requirements_enabled, + ) + .await?; + (Some(network_proxy), Some(session_network_proxy)) + } else { + (None, None) }; - let session_network_proxy = network_proxy.as_ref().map(|started| { - let proxy = started.proxy(); - SessionNetworkProxyRuntime { - http_addr: proxy.http_addr().to_string(), - socks_addr: proxy.socks_addr().to_string(), - admin_addr: proxy.admin_addr().to_string(), - } - }); let services = SessionServices { mcp_connection_manager: Arc::new(RwLock::new(McpConnectionManager::default())), @@ -1218,6 +1277,7 @@ impl Session { file_watcher, agent_control, network_proxy, + network_approval: Arc::clone(&network_approval), state_db: state_db_ctx.clone(), model_client: ModelClient::new( Some(Arc::clone(&auth_manager)), @@ -1260,6 +1320,10 @@ impl Session { js_repl, next_internal_sub_id: AtomicU64::new(0), }); + if let Some(network_policy_decider_session) = network_policy_decider_session { + let mut guard = network_policy_decider_session.write().await; + *guard = Arc::downgrade(&sess); + } // Dispatch the SessionConfiguredEvent first and then report any errors. // If resuming, include converted initial messages in the payload so UIs can render them immediately. @@ -2082,7 +2146,7 @@ impl Session { Ok(()) } - async fn turn_context_for_sub_id(&self, sub_id: &str) -> Option> { + pub(crate) async fn turn_context_for_sub_id(&self, sub_id: &str) -> Option> { let active = self.active_turn.lock().await; active .as_ref() @@ -2144,6 +2208,7 @@ impl Session { command: Vec, cwd: PathBuf, reason: Option, + network_approval_context: Option, proposed_execpolicy_amendment: Option, ) -> ReviewDecision { // Add the tx_approve callback to the map before sending the request. @@ -2170,6 +2235,7 @@ impl Session { command, cwd, reason, + network_approval_context, proposed_execpolicy_amendment, parsed_cmd, }); @@ -6948,6 +7014,7 @@ mod tests { let mut state = SessionState::new(session_configuration.clone()); mark_state_initial_context_seeded(&mut state); let skills_manager = Arc::new(SkillsManager::new(config.codex_home.clone())); + let network_approval = Arc::new(NetworkApprovalService::default()); let file_watcher = Arc::new(FileWatcher::noop()); let services = SessionServices { @@ -6974,6 +7041,7 @@ mod tests { file_watcher, agent_control, network_proxy: None, + network_approval: Arc::clone(&network_approval), state_db: None, model_client: ModelClient::new( Some(auth_manager.clone()), @@ -7094,6 +7162,7 @@ mod tests { let mut state = SessionState::new(session_configuration.clone()); mark_state_initial_context_seeded(&mut state); let skills_manager = Arc::new(SkillsManager::new(config.codex_home.clone())); + let network_approval = Arc::new(NetworkApprovalService::default()); let file_watcher = Arc::new(FileWatcher::noop()); let services = SessionServices { @@ -7120,6 +7189,7 @@ mod tests { file_watcher, agent_control, network_proxy: None, + network_approval: Arc::clone(&network_approval), state_db: None, model_client: ModelClient::new( Some(Arc::clone(&auth_manager)), @@ -7882,6 +7952,7 @@ mod tests { expiration: timeout_ms.into(), env: HashMap::new(), network: None, + network_attempt_id: None, sandbox_permissions, windows_sandbox_level: turn_context.windows_sandbox_level, justification: Some("test".to_string()), @@ -7895,6 +7966,7 @@ mod tests { expiration: timeout_ms.into(), env: HashMap::new(), network: None, + network_attempt_id: None, windows_sandbox_level: turn_context.windows_sandbox_level, justification: params.justification.clone(), arg0: None, diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 1f4240f683..69efeefeca 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -316,6 +316,7 @@ async fn handle_exec_approval( command, cwd, reason, + network_approval_context, proposed_execpolicy_amendment, .. } = event; @@ -327,6 +328,7 @@ async fn handle_exec_approval( command, cwd, reason, + network_approval_context, proposed_execpolicy_amendment, ); let decision = diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 91ab7e5b76..5d887a6a59 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -1950,6 +1950,13 @@ impl Config { self.permissions.windows_sandbox_mode }; } + + pub fn managed_network_requirements_enabled(&self) -> bool { + self.config_layer_stack + .requirements_toml() + .network + .is_some() + } } pub(crate) fn uses_deprecated_instructions_file(config_layer_stack: &ConfigLayerStack) -> bool { diff --git a/codex-rs/core/src/config/network_proxy_spec.rs b/codex-rs/core/src/config/network_proxy_spec.rs index ee3e8ebed1..183eafcb17 100644 --- a/codex-rs/core/src/config/network_proxy_spec.rs +++ b/codex-rs/core/src/config/network_proxy_spec.rs @@ -1,8 +1,11 @@ use crate::config; use crate::config_loader::NetworkConstraints; use async_trait::async_trait; +use codex_network_proxy::BlockedRequestObserver; use codex_network_proxy::ConfigReloader; use codex_network_proxy::ConfigState; +use codex_network_proxy::NetworkDecision; +use codex_network_proxy::NetworkPolicyDecider; use codex_network_proxy::NetworkProxy; use codex_network_proxy::NetworkProxyConfig; use codex_network_proxy::NetworkProxyConstraints; @@ -11,6 +14,7 @@ use codex_network_proxy::NetworkProxyState; use codex_network_proxy::build_config_state; use codex_network_proxy::host_and_port_from_network_addr; use codex_network_proxy::validate_policy_against_constraints; +use codex_protocol::protocol::SandboxPolicy; use std::sync::Arc; #[derive(Debug, Clone, PartialEq, Eq)] @@ -92,20 +96,41 @@ impl NetworkProxySpec { }) } - pub async fn start_proxy(&self) -> std::io::Result { + pub async fn start_proxy( + &self, + sandbox_policy: &SandboxPolicy, + policy_decider: Option>, + blocked_request_observer: Option>, + enable_network_approval_flow: bool, + ) -> std::io::Result { let state = build_config_state(self.config.clone(), self.constraints.clone()).map_err(|err| { std::io::Error::other(format!("failed to build network proxy state: {err}")) })?; let reloader = Arc::new(StaticNetworkProxyReloader::new(state.clone())); let state = NetworkProxyState::with_reloader(state, reloader); - let proxy = NetworkProxy::builder() - .state(Arc::new(state)) - .build() - .await - .map_err(|err| { - std::io::Error::other(format!("failed to build network proxy: {err}")) - })?; + let mut builder = NetworkProxy::builder().state(Arc::new(state)); + if enable_network_approval_flow + && matches!( + sandbox_policy, + SandboxPolicy::ReadOnly { .. } | SandboxPolicy::WorkspaceWrite { .. } + ) + { + builder = match policy_decider { + Some(policy_decider) => builder.policy_decider_arc(policy_decider), + None => builder.policy_decider(|_request| async { + // In restricted sandbox modes, allowlist misses should ask for + // explicit network approval instead of hard-denying. + NetworkDecision::ask("not_allowed") + }), + }; + } + if let Some(blocked_request_observer) = blocked_request_observer { + builder = builder.blocked_request_observer_arc(blocked_request_observer); + } + let proxy = builder.build().await.map_err(|err| { + std::io::Error::other(format!("failed to build network proxy: {err}")) + })?; let handle = proxy .run() .await diff --git a/codex-rs/core/src/error.rs b/codex-rs/core/src/error.rs index 404fbc5a7a..ad49c611fc 100644 --- a/codex-rs/core/src/error.rs +++ b/codex-rs/core/src/error.rs @@ -1,4 +1,5 @@ use crate::exec::ExecToolCallOutput; +use crate::network_policy_decision::NetworkPolicyDecisionPayload; use crate::token_data::KnownPlan; use crate::token_data::PlanType; use crate::truncate::TruncationPolicy; @@ -31,7 +32,10 @@ pub enum SandboxErr { "sandbox denied exec error, exit code: {}, stdout: {}, stderr: {}", .output.exit_code, .output.stdout.text, .output.stderr.text )] - Denied { output: Box }, + Denied { + output: Box, + network_policy_decision: Option, + }, /// Error from linux seccomp filter setup #[cfg(target_os = "linux")] @@ -616,7 +620,7 @@ impl CodexErr { pub fn get_error_message_ui(e: &CodexErr) -> String { let message = match e { - CodexErr::Sandbox(SandboxErr::Denied { output }) => { + CodexErr::Sandbox(SandboxErr::Denied { output, .. }) => { let aggregated = output.aggregated_output.text.trim(); if !aggregated.is_empty() { output.aggregated_output.text.clone() @@ -736,6 +740,7 @@ mod tests { }; let err = CodexErr::Sandbox(SandboxErr::Denied { output: Box::new(output), + network_policy_decision: None, }); assert_eq!(get_error_message_ui(&err), "aggregate detail"); } @@ -752,6 +757,7 @@ mod tests { }; let err = CodexErr::Sandbox(SandboxErr::Denied { output: Box::new(output), + network_policy_decision: None, }); assert_eq!(get_error_message_ui(&err), "stderr detail\nstdout detail"); } @@ -768,6 +774,7 @@ mod tests { }; let err = CodexErr::Sandbox(SandboxErr::Denied { output: Box::new(output), + network_policy_decision: None, }); assert_eq!(get_error_message_ui(&err), "stdout only"); } @@ -811,6 +818,7 @@ mod tests { }; let err = CodexErr::Sandbox(SandboxErr::Denied { output: Box::new(output), + network_policy_decision: None, }); assert_eq!( get_error_message_ui(&err), diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs index 398a767ce0..0f0f0609a2 100644 --- a/codex-rs/core/src/exec.rs +++ b/codex-rs/core/src/exec.rs @@ -15,6 +15,7 @@ use tokio::io::AsyncReadExt; use tokio::io::BufReader; use tokio::process::Child; use tokio_util::sync::CancellationToken; +use uuid::Uuid; use crate::error::CodexErr; use crate::error::Result; @@ -66,6 +67,7 @@ pub struct ExecParams { pub expiration: ExecExpiration, pub env: HashMap, pub network: Option, + pub network_attempt_id: Option, pub sandbox_permissions: SandboxPermissions, pub windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel, pub justification: Option, @@ -184,13 +186,15 @@ pub async fn process_exec_tool_call( mut env, expiration, network, + network_attempt_id, sandbox_permissions, windows_sandbox_level, justification, arg0: _, } = params; + let network_attempt_id = network_attempt_id.map(|attempt_id| attempt_id.to_string()); if let Some(network) = network.as_ref() { - network.apply_to_env(&mut env); + network.apply_to_env_for_attempt(&mut env, network_attempt_id.as_deref()); } let (program, args) = command.split_first().ok_or_else(|| { CodexErr::Io(io::Error::new( @@ -238,6 +242,7 @@ pub(crate) async fn execute_exec_env( cwd, env, network, + network_attempt_id, expiration, sandbox, windows_sandbox_level, @@ -246,12 +251,18 @@ pub(crate) async fn execute_exec_env( arg0, } = env; + let network_attempt_id = match network_attempt_id.as_deref() { + Some(attempt_id) => Uuid::parse_str(attempt_id).ok(), + None => network.as_ref().map(|_| Uuid::new_v4()), + }; + let params = ExecParams { command, cwd, expiration, env, - network, + network: network.clone(), + network_attempt_id, sandbox_permissions, windows_sandbox_level, justification, @@ -345,12 +356,14 @@ async fn exec_windows_sandbox( cwd, mut env, network, + network_attempt_id, expiration, windows_sandbox_level, .. } = params; + let network_attempt_id = network_attempt_id.map(|attempt_id| attempt_id.to_string()); if let Some(network) = network.as_ref() { - network.apply_to_env(&mut env); + network.apply_to_env_for_attempt(&mut env, network_attempt_id.as_deref()); } // TODO(iceweasel-oai): run_windows_sandbox_capture should support all @@ -490,6 +503,7 @@ fn finalize_exec_result( if is_likely_sandbox_denied(sandbox_type, &exec_output) { return Err(CodexErr::Sandbox(SandboxErr::Denied { output: Box::new(exec_output), + network_policy_decision: None, })); } @@ -701,13 +715,19 @@ async fn exec( let ExecParams { command, cwd, - env, + mut env, network, + network_attempt_id, arg0, expiration, windows_sandbox_level: _, .. } = params; + let network_attempt_id = network_attempt_id.map(|attempt_id| attempt_id.to_string()); + + if let Some(network) = network.as_ref() { + network.apply_to_env_for_attempt(&mut env, network_attempt_id.as_deref()); + } let (program, args) = command.split_first().ok_or_else(|| { CodexErr::Io(io::Error::new( @@ -722,7 +742,10 @@ async fn exec( arg0: arg0_ref, cwd, sandbox_policy, - network: network.as_ref(), + // The environment already has attempt-scoped proxy settings from + // apply_to_env_for_attempt above. Passing network here would reapply + // non-attempt proxy vars and drop attempt correlation metadata. + network: None, stdio_policy: StdioPolicy::RedirectForShellTool, env, }) @@ -951,6 +974,17 @@ mod tests { assert!(!is_likely_sandbox_denied(SandboxType::None, &output)); } + #[test] + fn sandbox_detection_ignores_network_policy_text_in_non_sandbox_mode() { + let output = make_exec_output( + 0, + "", + "", + r#"CODEX_NETWORK_POLICY_DECISION {"decision":"ask","reason":"not_allowed","source":"decider","protocol":"http","host":"google.com","port":80}"#, + ); + assert!(!is_likely_sandbox_denied(SandboxType::None, &output)); + } + #[test] fn sandbox_detection_uses_aggregated_output() { let output = make_exec_output( @@ -965,6 +999,21 @@ mod tests { )); } + #[test] + fn sandbox_detection_ignores_network_policy_text_with_zero_exit_code() { + let output = make_exec_output( + 0, + "", + "", + r#"CODEX_NETWORK_POLICY_DECISION {"decision":"ask","source":"decider","protocol":"http","host":"google.com","port":80}"#, + ); + + assert!(!is_likely_sandbox_denied( + SandboxType::LinuxSeccomp, + &output + )); + } + #[tokio::test] async fn read_capped_limits_retained_bytes() { let (mut writer, reader) = tokio::io::duplex(1024); @@ -1088,6 +1137,7 @@ mod tests { expiration: 500.into(), env, network: None, + network_attempt_id: None, sandbox_permissions: SandboxPermissions::UseDefault, windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel::Disabled, justification: None, @@ -1141,6 +1191,7 @@ mod tests { expiration: ExecExpiration::Cancellation(cancel_token), env, network: None, + network_attempt_id: None, sandbox_permissions: SandboxPermissions::UseDefault, windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel::Disabled, justification: None, diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 8b396517e2..91bbe042ef 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -41,6 +41,7 @@ pub mod landlock; pub mod mcp; mod mcp_connection_manager; pub mod models_manager; +mod network_policy_decision; pub mod network_proxy_loader; pub use mcp_connection_manager::MCP_SANDBOX_STATE_CAPABILITY; pub use mcp_connection_manager::MCP_SANDBOX_STATE_METHOD; diff --git a/codex-rs/core/src/network_policy_decision.rs b/codex-rs/core/src/network_policy_decision.rs new file mode 100644 index 0000000000..3da6d34d6c --- /dev/null +++ b/codex-rs/core/src/network_policy_decision.rs @@ -0,0 +1,254 @@ +use codex_network_proxy::BlockedRequest; +use codex_network_proxy::NetworkDecisionSource; +use codex_network_proxy::NetworkPolicyDecision; +use codex_protocol::approvals::NetworkApprovalContext; +use codex_protocol::approvals::NetworkApprovalProtocol; +use serde::Deserialize; + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct NetworkPolicyDecisionPayload { + pub decision: NetworkPolicyDecision, + pub source: NetworkDecisionSource, + #[serde(default)] + pub protocol: Option, + pub host: Option, + pub reason: Option, + pub port: Option, +} + +impl NetworkPolicyDecisionPayload { + pub(crate) fn is_ask_from_decider(&self) -> bool { + self.decision == NetworkPolicyDecision::Ask && self.source == NetworkDecisionSource::Decider + } +} + +fn parse_network_policy_decision(value: &str) -> Option { + match value { + "deny" => Some(NetworkPolicyDecision::Deny), + "ask" => Some(NetworkPolicyDecision::Ask), + _ => None, + } +} + +pub(crate) fn network_approval_context_from_payload( + payload: &NetworkPolicyDecisionPayload, +) -> Option { + if !payload.is_ask_from_decider() { + return None; + } + + let protocol = payload.protocol?; + + let host = payload.host.as_deref()?.trim(); + if host.is_empty() { + return None; + } + + Some(NetworkApprovalContext { + host: host.to_string(), + protocol, + }) +} + +pub(crate) fn denied_network_policy_message(blocked: &BlockedRequest) -> Option { + let decision = blocked + .decision + .as_deref() + .and_then(parse_network_policy_decision); + if decision != Some(NetworkPolicyDecision::Deny) { + return None; + } + + let host = blocked.host.trim(); + if host.is_empty() { + return Some("Network access was blocked by policy.".to_string()); + } + + let detail = match blocked.reason.as_str() { + "denied" => "domain is explicitly denied by policy and cannot be approved from this prompt", + "not_allowed" => "domain is not on the allowlist for the current sandbox mode", + "not_allowed_local" => "local/private network addresses are blocked by policy", + "method_not_allowed" => "request method is blocked by the current network mode", + "proxy_disabled" => "managed network proxy is disabled", + _ => "request is blocked by network policy", + }; + + Some(format!( + "Network access to \"{host}\" was blocked: {detail}." + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use codex_network_proxy::BlockedRequest; + use pretty_assertions::assert_eq; + + #[test] + fn network_approval_context_requires_ask_from_decider() { + let payload = NetworkPolicyDecisionPayload { + decision: NetworkPolicyDecision::Deny, + source: NetworkDecisionSource::Decider, + protocol: Some(NetworkApprovalProtocol::Https), + host: Some("example.com".to_string()), + reason: Some("not_allowed".to_string()), + port: Some(443), + }; + + assert_eq!(network_approval_context_from_payload(&payload), None); + } + + #[test] + fn network_approval_context_maps_http_https_and_socks_protocols() { + let http_payload = NetworkPolicyDecisionPayload { + decision: NetworkPolicyDecision::Ask, + source: NetworkDecisionSource::Decider, + protocol: Some(NetworkApprovalProtocol::Http), + host: Some("example.com".to_string()), + reason: Some("not_allowed".to_string()), + port: Some(80), + }; + assert_eq!( + network_approval_context_from_payload(&http_payload), + Some(NetworkApprovalContext { + host: "example.com".to_string(), + protocol: NetworkApprovalProtocol::Http, + }) + ); + + let https_payload = NetworkPolicyDecisionPayload { + decision: NetworkPolicyDecision::Ask, + source: NetworkDecisionSource::Decider, + protocol: Some(NetworkApprovalProtocol::Https), + host: Some("example.com".to_string()), + reason: Some("not_allowed".to_string()), + port: Some(443), + }; + assert_eq!( + network_approval_context_from_payload(&https_payload), + Some(NetworkApprovalContext { + host: "example.com".to_string(), + protocol: NetworkApprovalProtocol::Https, + }) + ); + + let http_connect_payload = NetworkPolicyDecisionPayload { + decision: NetworkPolicyDecision::Ask, + source: NetworkDecisionSource::Decider, + protocol: Some(NetworkApprovalProtocol::Https), + host: Some("example.com".to_string()), + reason: Some("not_allowed".to_string()), + port: Some(443), + }; + assert_eq!( + network_approval_context_from_payload(&http_connect_payload), + Some(NetworkApprovalContext { + host: "example.com".to_string(), + protocol: NetworkApprovalProtocol::Https, + }) + ); + + let socks5_tcp_payload = NetworkPolicyDecisionPayload { + decision: NetworkPolicyDecision::Ask, + source: NetworkDecisionSource::Decider, + protocol: Some(NetworkApprovalProtocol::Socks5Tcp), + host: Some("example.com".to_string()), + reason: Some("not_allowed".to_string()), + port: Some(443), + }; + assert_eq!( + network_approval_context_from_payload(&socks5_tcp_payload), + Some(NetworkApprovalContext { + host: "example.com".to_string(), + protocol: NetworkApprovalProtocol::Socks5Tcp, + }) + ); + + let socks5_udp_payload = NetworkPolicyDecisionPayload { + decision: NetworkPolicyDecision::Ask, + source: NetworkDecisionSource::Decider, + protocol: Some(NetworkApprovalProtocol::Socks5Udp), + host: Some("example.com".to_string()), + reason: Some("not_allowed".to_string()), + port: Some(443), + }; + assert_eq!( + network_approval_context_from_payload(&socks5_udp_payload), + Some(NetworkApprovalContext { + host: "example.com".to_string(), + protocol: NetworkApprovalProtocol::Socks5Udp, + }) + ); + } + + #[test] + fn network_policy_decision_payload_deserializes_proxy_protocol_aliases() { + let payload: NetworkPolicyDecisionPayload = serde_json::from_str( + r#"{ + "decision":"ask", + "source":"decider", + "protocol":"https_connect", + "host":"example.com", + "reason":"not_allowed", + "port":443 + }"#, + ) + .expect("payload should deserialize"); + assert_eq!(payload.protocol, Some(NetworkApprovalProtocol::Https)); + + let payload: NetworkPolicyDecisionPayload = serde_json::from_str( + r#"{ + "decision":"ask", + "source":"decider", + "protocol":"http-connect", + "host":"example.com", + "reason":"not_allowed", + "port":443 + }"#, + ) + .expect("payload should deserialize"); + assert_eq!(payload.protocol, Some(NetworkApprovalProtocol::Https)); + } + + #[test] + fn denied_network_policy_message_requires_deny_decision() { + let blocked = BlockedRequest { + host: "example.com".to_string(), + reason: "not_allowed".to_string(), + client: None, + method: Some("GET".to_string()), + mode: None, + protocol: "http".to_string(), + attempt_id: Some("attempt-1".to_string()), + decision: Some("ask".to_string()), + source: Some("decider".to_string()), + port: Some(80), + timestamp: 0, + }; + assert_eq!(denied_network_policy_message(&blocked), None); + } + + #[test] + fn denied_network_policy_message_for_denylist_block_is_explicit() { + let blocked = BlockedRequest { + host: "example.com".to_string(), + reason: "denied".to_string(), + client: None, + method: Some("GET".to_string()), + mode: None, + protocol: "http".to_string(), + attempt_id: Some("attempt-1".to_string()), + decision: Some("deny".to_string()), + source: Some("baseline_policy".to_string()), + port: Some(80), + timestamp: 0, + }; + assert_eq!( + denied_network_policy_message(&blocked), + Some( + "Network access to \"example.com\" was blocked: domain is explicitly denied by policy and cannot be approved from this prompt.".to_string() + ) + ); + } +} diff --git a/codex-rs/core/src/sandboxing/mod.rs b/codex-rs/core/src/sandboxing/mod.rs index 9a72b6e249..487278b619 100644 --- a/codex-rs/core/src/sandboxing/mod.rs +++ b/codex-rs/core/src/sandboxing/mod.rs @@ -46,6 +46,7 @@ pub struct ExecRequest { pub cwd: PathBuf, pub env: HashMap, pub network: Option, + pub network_attempt_id: Option, pub expiration: ExecExpiration, pub sandbox: SandboxType, pub windows_sandbox_level: WindowsSandboxLevel, @@ -214,6 +215,7 @@ impl SandboxManager { cwd: spec.cwd, env, network: network.cloned(), + network_attempt_id: None, expiration: spec.expiration, sandbox, windows_sandbox_level, diff --git a/codex-rs/core/src/state/service.rs b/codex-rs/core/src/state/service.rs index 3aec0e5350..6f640131f6 100644 --- a/codex-rs/core/src/state/service.rs +++ b/codex-rs/core/src/state/service.rs @@ -12,6 +12,7 @@ use crate::mcp_connection_manager::McpConnectionManager; use crate::models_manager::manager::ModelsManager; use crate::skills::SkillsManager; use crate::state_db::StateDbHandle; +use crate::tools::network_approval::NetworkApprovalService; use crate::tools::sandboxing::ApprovalStore; use crate::unified_exec::UnifiedExecProcessManager; use codex_hooks::Hooks; @@ -40,6 +41,7 @@ pub(crate) struct SessionServices { pub(crate) file_watcher: Arc, pub(crate) agent_control: AgentControl, pub(crate) network_proxy: Option, + pub(crate) network_approval: Arc, pub(crate) state_db: Option, /// Session-scoped model client shared across turns. pub(crate) model_client: ModelClient, diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index 43950a8da0..35088efce8 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -150,6 +150,7 @@ pub(crate) async fn execute_user_shell_command( Some(session.conversation_id), ), network: turn_context.network.clone(), + network_attempt_id: None, // TODO(zhao-oai): Now that we have ExecExpiration::Cancellation, we // should use that instead of an "arbitrarily large" timeout here. expiration: USER_SHELL_TIMEOUT_MS.into(), diff --git a/codex-rs/core/src/tools/events.rs b/codex-rs/core/src/tools/events.rs index 65972f5a5a..2bacd188d5 100644 --- a/codex-rs/core/src/tools/events.rs +++ b/codex-rs/core/src/tools/events.rs @@ -314,7 +314,7 @@ impl ToolEmitter { (event, result) } Err(ToolError::Codex(CodexErr::Sandbox(SandboxErr::Timeout { output }))) - | Err(ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied { output }))) => { + | Err(ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied { output, .. }))) => { let response = self.format_exec_output_for_model(&output, ctx); let event = ToolEventStage::Failure(ToolEventFailure::Output(*output)); let result = Err(FunctionCallError::RespondToModel(response)); diff --git a/codex-rs/core/src/tools/handlers/apply_patch.rs b/codex-rs/core/src/tools/handlers/apply_patch.rs index aced569ce4..49c679e520 100644 --- a/codex-rs/core/src/tools/handlers/apply_patch.rs +++ b/codex-rs/core/src/tools/handlers/apply_patch.rs @@ -143,10 +143,12 @@ impl ToolHandler for ApplyPatchHandler { turn: turn.as_ref(), call_id: call_id.clone(), tool_name: tool_name.to_string(), + network_attempt_id: None, }; let out = orchestrator .run(&mut runtime, &req, &tool_ctx, &turn, turn.approval_policy) - .await; + .await + .map(|result| result.output); let event_ctx = ToolEventCtx::new( session.as_ref(), turn.as_ref(), @@ -232,10 +234,12 @@ pub(crate) async fn intercept_apply_patch( turn, call_id: call_id.to_string(), tool_name: tool_name.to_string(), + network_attempt_id: None, }; let out = orchestrator .run(&mut runtime, &req, &tool_ctx, turn, turn.approval_policy) - .await; + .await + .map(|result| result.output); let event_ctx = ToolEventCtx::new(session, turn, call_id, tracker.as_ref().copied()); let content = emitter.finish(event_ctx, out).await?; diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index b9e2a97d6d..67f4c04166 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -54,6 +54,7 @@ impl ShellHandler { expiration: params.timeout_ms.into(), env: create_env(&turn_context.shell_environment_policy, Some(thread_id)), network: turn_context.network.clone(), + network_attempt_id: None, sandbox_permissions: params.sandbox_permissions.unwrap_or_default(), windows_sandbox_level: turn_context.windows_sandbox_level, justification: params.justification.clone(), @@ -83,6 +84,7 @@ impl ShellCommandHandler { expiration: params.timeout_ms.into(), env: create_env(&turn_context.shell_environment_policy, Some(thread_id)), network: turn_context.network.clone(), + network_attempt_id: None, sandbox_permissions: params.sandbox_permissions.unwrap_or_default(), windows_sandbox_level: turn_context.windows_sandbox_level, justification: params.justification.clone(), @@ -325,10 +327,12 @@ impl ShellHandler { turn: turn.as_ref(), call_id: call_id.clone(), tool_name, + network_attempt_id: None, }; let out = orchestrator .run(&mut runtime, &req, &tool_ctx, &turn, turn.approval_policy) - .await; + .await + .map(|result| result.output); let event_ctx = ToolEventCtx::new(session.as_ref(), turn.as_ref(), &call_id, None); let content = emitter.finish(event_ctx, out).await?; Ok(ToolOutput::Function { diff --git a/codex-rs/core/src/tools/mod.rs b/codex-rs/core/src/tools/mod.rs index 3307d65313..27fe515126 100644 --- a/codex-rs/core/src/tools/mod.rs +++ b/codex-rs/core/src/tools/mod.rs @@ -2,6 +2,7 @@ pub mod context; pub mod events; pub(crate) mod handlers; pub mod js_repl; +pub(crate) mod network_approval; pub mod orchestrator; pub mod parallel; pub mod registry; diff --git a/codex-rs/core/src/tools/network_approval.rs b/codex-rs/core/src/tools/network_approval.rs new file mode 100644 index 0000000000..51e6e3c98e --- /dev/null +++ b/codex-rs/core/src/tools/network_approval.rs @@ -0,0 +1,675 @@ +use crate::codex::Session; +use crate::network_policy_decision::denied_network_policy_message; +use crate::tools::sandboxing::ToolError; +use codex_network_proxy::BlockedRequest; +use codex_network_proxy::BlockedRequestObserver; +use codex_network_proxy::NetworkDecision; +use codex_network_proxy::NetworkPolicyDecider; +use codex_network_proxy::NetworkPolicyRequest; +use codex_network_proxy::NetworkProtocol; +use codex_network_proxy::NetworkProxy; +use codex_protocol::approvals::NetworkApprovalContext; +use codex_protocol::approvals::NetworkApprovalProtocol; +use codex_protocol::protocol::ReviewDecision; +use std::collections::HashMap; +use std::collections::HashSet; +use std::path::PathBuf; +use std::sync::Arc; +use tokio::sync::Mutex; +use tokio::sync::RwLock; +use uuid::Uuid; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum NetworkApprovalMode { + Immediate, + Deferred, +} + +#[derive(Clone, Debug)] +pub(crate) struct NetworkApprovalSpec { + pub command: Vec, + pub cwd: PathBuf, + pub network: Option, + pub mode: NetworkApprovalMode, +} + +#[derive(Clone, Debug)] +pub(crate) struct DeferredNetworkApproval { + attempt_id: String, +} + +impl DeferredNetworkApproval { + pub(crate) fn attempt_id(&self) -> &str { + &self.attempt_id + } +} + +#[derive(Debug)] +pub(crate) struct ActiveNetworkApproval { + attempt_id: Option, + mode: NetworkApprovalMode, +} + +impl ActiveNetworkApproval { + pub(crate) fn attempt_id(&self) -> Option<&str> { + self.attempt_id.as_deref() + } + + pub(crate) fn mode(&self) -> NetworkApprovalMode { + self.mode + } + + pub(crate) fn into_deferred(self) -> Option { + match (self.mode, self.attempt_id) { + (NetworkApprovalMode::Deferred, Some(attempt_id)) => { + Some(DeferredNetworkApproval { attempt_id }) + } + _ => None, + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum NetworkApprovalOutcome { + DeniedByUser, + DeniedByPolicy(String), +} + +struct NetworkApprovalAttempt { + turn_id: String, + call_id: String, + command: Vec, + cwd: PathBuf, + approved_hosts: Mutex>, + outcome: Mutex>, +} + +pub(crate) struct NetworkApprovalService { + attempts: Mutex>>, + session_approved_hosts: Mutex>, +} + +impl Default for NetworkApprovalService { + fn default() -> Self { + Self { + attempts: Mutex::new(HashMap::new()), + session_approved_hosts: Mutex::new(HashSet::new()), + } + } +} + +impl NetworkApprovalService { + pub(crate) async fn register_attempt( + &self, + attempt_id: String, + turn_id: String, + call_id: String, + command: Vec, + cwd: PathBuf, + ) { + let mut attempts = self.attempts.lock().await; + attempts.insert( + attempt_id, + Arc::new(NetworkApprovalAttempt { + turn_id, + call_id, + command, + cwd, + approved_hosts: Mutex::new(HashSet::new()), + outcome: Mutex::new(None), + }), + ); + } + + pub(crate) async fn unregister_attempt(&self, attempt_id: &str) { + let mut attempts = self.attempts.lock().await; + attempts.remove(attempt_id); + } + + pub(crate) async fn take_outcome(&self, attempt_id: &str) -> Option { + let attempt = { + let attempts = self.attempts.lock().await; + attempts.get(attempt_id).cloned() + }?; + let mut outcome = attempt.outcome.lock().await; + outcome.take() + } + + pub(crate) async fn take_user_denial_outcome(&self, attempt_id: &str) -> bool { + let attempt = { + let attempts = self.attempts.lock().await; + attempts.get(attempt_id).cloned() + }; + let Some(attempt) = attempt else { + return false; + }; + let mut outcome = attempt.outcome.lock().await; + if matches!(outcome.as_ref(), Some(NetworkApprovalOutcome::DeniedByUser)) { + outcome.take(); + return true; + } + false + } + + async fn resolve_attempt_for_request( + &self, + request: &NetworkPolicyRequest, + ) -> Option> { + let attempts = self.attempts.lock().await; + + if let Some(attempt_id) = request.attempt_id.as_deref() { + if let Some(attempt) = attempts.get(attempt_id).cloned() { + return Some(attempt); + } + return None; + } + + if attempts.len() == 1 { + return attempts.values().next().cloned(); + } + + None + } + + async fn resolve_attempt_for_blocked_request( + &self, + blocked: &BlockedRequest, + ) -> Option> { + let attempts = self.attempts.lock().await; + + if let Some(attempt_id) = blocked.attempt_id.as_deref() { + if let Some(attempt) = attempts.get(attempt_id).cloned() { + return Some(attempt); + } + return None; + } + + if attempts.len() == 1 { + return attempts.values().next().cloned(); + } + + None + } + + pub(crate) async fn record_blocked_request(&self, blocked: BlockedRequest) { + let Some(message) = denied_network_policy_message(&blocked) else { + return; + }; + + let Some(attempt) = self.resolve_attempt_for_blocked_request(&blocked).await else { + return; + }; + + let mut outcome = attempt.outcome.lock().await; + if matches!(outcome.as_ref(), Some(NetworkApprovalOutcome::DeniedByUser)) { + return; + } + *outcome = Some(NetworkApprovalOutcome::DeniedByPolicy(message)); + } + + pub(crate) async fn handle_inline_policy_request( + &self, + session: &Session, + request: NetworkPolicyRequest, + ) -> NetworkDecision { + const REASON_NOT_ALLOWED: &str = "not_allowed"; + + { + let approved_hosts = self.session_approved_hosts.lock().await; + if approved_hosts.contains(request.host.as_str()) { + return NetworkDecision::Allow; + } + } + + let Some(attempt) = self.resolve_attempt_for_request(&request).await else { + return NetworkDecision::deny(REASON_NOT_ALLOWED); + }; + + { + let approved_hosts = attempt.approved_hosts.lock().await; + if approved_hosts.contains(request.host.as_str()) { + return NetworkDecision::Allow; + } + } + + let protocol = match request.protocol { + NetworkProtocol::Http => NetworkApprovalProtocol::Http, + NetworkProtocol::HttpsConnect => NetworkApprovalProtocol::Https, + NetworkProtocol::Socks5Tcp => NetworkApprovalProtocol::Socks5Tcp, + NetworkProtocol::Socks5Udp => NetworkApprovalProtocol::Socks5Udp, + }; + + let Some(turn_context) = session.turn_context_for_sub_id(&attempt.turn_id).await else { + return NetworkDecision::deny(REASON_NOT_ALLOWED); + }; + + let approval_decision = session + .request_command_approval( + turn_context.as_ref(), + attempt.call_id.clone(), + attempt.command.clone(), + attempt.cwd.clone(), + Some(format!( + "Network access to \"{}\" is blocked by policy.", + request.host + )), + Some(NetworkApprovalContext { + host: request.host.clone(), + protocol, + }), + None, + ) + .await; + + match approval_decision { + ReviewDecision::Approved | ReviewDecision::ApprovedExecpolicyAmendment { .. } => { + let mut approved_hosts = attempt.approved_hosts.lock().await; + approved_hosts.insert(request.host); + NetworkDecision::Allow + } + ReviewDecision::ApprovedForSession => { + let mut approved_hosts = self.session_approved_hosts.lock().await; + approved_hosts.insert(request.host); + NetworkDecision::Allow + } + ReviewDecision::Denied | ReviewDecision::Abort => { + let mut outcome = attempt.outcome.lock().await; + *outcome = Some(NetworkApprovalOutcome::DeniedByUser); + NetworkDecision::deny(REASON_NOT_ALLOWED) + } + } + } +} + +pub(crate) fn build_blocked_request_observer( + network_approval: Arc, +) -> Arc { + Arc::new(move |blocked: BlockedRequest| { + let network_approval = Arc::clone(&network_approval); + async move { + network_approval.record_blocked_request(blocked).await; + } + }) +} + +pub(crate) fn build_network_policy_decider( + network_approval: Arc, + network_policy_decider_session: Arc>>, +) -> Arc { + Arc::new(move |request: NetworkPolicyRequest| { + let network_approval = Arc::clone(&network_approval); + let network_policy_decider_session = Arc::clone(&network_policy_decider_session); + async move { + let Some(session) = network_policy_decider_session.read().await.upgrade() else { + return NetworkDecision::ask("not_allowed"); + }; + network_approval + .handle_inline_policy_request(session.as_ref(), request) + .await + } + }) +} + +pub(crate) async fn begin_network_approval( + session: &Session, + turn_id: &str, + call_id: &str, + has_managed_network_requirements: bool, + spec: Option, +) -> Option { + let spec = spec?; + if !has_managed_network_requirements || spec.network.is_none() { + return None; + } + + let attempt_id = Uuid::new_v4().to_string(); + session + .services + .network_approval + .register_attempt( + attempt_id.clone(), + turn_id.to_string(), + call_id.to_string(), + spec.command, + spec.cwd, + ) + .await; + + Some(ActiveNetworkApproval { + attempt_id: Some(attempt_id), + mode: spec.mode, + }) +} + +pub(crate) async fn finish_immediate_network_approval( + session: &Session, + active: ActiveNetworkApproval, +) -> Result<(), ToolError> { + let Some(attempt_id) = active.attempt_id.as_deref() else { + return Ok(()); + }; + + let approval_outcome = session + .services + .network_approval + .take_outcome(attempt_id) + .await; + + session + .services + .network_approval + .unregister_attempt(attempt_id) + .await; + + match approval_outcome { + Some(NetworkApprovalOutcome::DeniedByUser) => { + Err(ToolError::Rejected("rejected by user".to_string())) + } + Some(NetworkApprovalOutcome::DeniedByPolicy(message)) => Err(ToolError::Rejected(message)), + None => Ok(()), + } +} + +pub(crate) async fn deferred_rejection_message( + session: &Session, + deferred: &DeferredNetworkApproval, +) -> Option { + match session + .services + .network_approval + .take_outcome(deferred.attempt_id()) + .await + { + Some(NetworkApprovalOutcome::DeniedByUser) => Some("rejected by user".to_string()), + Some(NetworkApprovalOutcome::DeniedByPolicy(message)) => Some(message), + None => None, + } +} + +pub(crate) async fn finish_deferred_network_approval( + session: &Session, + deferred: Option, +) { + let Some(deferred) = deferred else { + return; + }; + session + .services + .network_approval + .unregister_attempt(deferred.attempt_id()) + .await; +} + +#[cfg(test)] +mod tests { + use super::*; + use codex_network_proxy::BlockedRequestArgs; + use codex_network_proxy::NetworkPolicyRequestArgs; + use pretty_assertions::assert_eq; + + fn http_request(host: &str, attempt_id: Option<&str>) -> NetworkPolicyRequest { + NetworkPolicyRequest::new(NetworkPolicyRequestArgs { + protocol: NetworkProtocol::Http, + host: host.to_string(), + port: 80, + client_addr: None, + method: Some("GET".to_string()), + command: None, + exec_policy_hint: None, + attempt_id: attempt_id.map(ToString::to_string), + }) + } + + #[tokio::test] + async fn resolve_attempt_for_request_falls_back_to_single_active_attempt() { + let service = NetworkApprovalService::default(); + service + .register_attempt( + "attempt-1".to_string(), + "turn-1".to_string(), + "call-1".to_string(), + vec!["curl".to_string(), "example.com".to_string()], + std::env::temp_dir(), + ) + .await; + + let resolved = service + .resolve_attempt_for_request(&http_request("example.com", None)) + .await + .expect("single active attempt should be used as fallback"); + assert_eq!(resolved.call_id, "call-1"); + } + + #[tokio::test] + async fn resolve_attempt_for_request_returns_exact_attempt_match() { + let service = NetworkApprovalService::default(); + service + .register_attempt( + "attempt-1".to_string(), + "turn-1".to_string(), + "call-1".to_string(), + vec!["curl".to_string(), "example.com".to_string()], + std::env::temp_dir(), + ) + .await; + service + .register_attempt( + "attempt-2".to_string(), + "turn-2".to_string(), + "call-2".to_string(), + vec!["curl".to_string(), "openai.com".to_string()], + std::env::temp_dir(), + ) + .await; + + let resolved = service + .resolve_attempt_for_request(&http_request("openai.com", Some("attempt-2"))) + .await + .expect("attempt-2 should resolve"); + assert_eq!(resolved.call_id, "call-2"); + } + + #[tokio::test] + async fn resolve_attempt_for_request_returns_none_for_unknown_attempt_id() { + let service = NetworkApprovalService::default(); + service + .register_attempt( + "attempt-1".to_string(), + "turn-1".to_string(), + "call-1".to_string(), + vec!["curl".to_string(), "example.com".to_string()], + std::env::temp_dir(), + ) + .await; + + let resolved = service + .resolve_attempt_for_request(&http_request("example.com", Some("attempt-unknown"))) + .await; + assert!(resolved.is_none()); + } + + #[tokio::test] + async fn resolve_attempt_for_request_returns_none_when_ambiguous() { + let service = NetworkApprovalService::default(); + service + .register_attempt( + "attempt-1".to_string(), + "turn-1".to_string(), + "call-1".to_string(), + vec!["curl".to_string(), "example.com".to_string()], + std::env::temp_dir(), + ) + .await; + service + .register_attempt( + "attempt-2".to_string(), + "turn-2".to_string(), + "call-2".to_string(), + vec!["curl".to_string(), "robinhood.com".to_string()], + std::env::temp_dir(), + ) + .await; + + let resolved = service + .resolve_attempt_for_request(&http_request("example.com", None)) + .await; + assert!(resolved.is_none()); + } + + #[tokio::test] + async fn take_outcome_clears_stored_value() { + let service = NetworkApprovalService::default(); + service + .register_attempt( + "attempt-1".to_string(), + "turn-1".to_string(), + "call-1".to_string(), + vec!["curl".to_string(), "example.com".to_string()], + std::env::temp_dir(), + ) + .await; + + let attempt = { + let attempts = service.attempts.lock().await; + attempts + .get("attempt-1") + .cloned() + .expect("attempt should exist") + }; + { + let mut outcome = attempt.outcome.lock().await; + *outcome = Some(NetworkApprovalOutcome::DeniedByUser); + } + + assert_eq!( + service.take_outcome("attempt-1").await, + Some(NetworkApprovalOutcome::DeniedByUser) + ); + assert_eq!(service.take_outcome("attempt-1").await, None); + } + + #[tokio::test] + async fn take_user_denial_outcome_preserves_policy_denial() { + let service = NetworkApprovalService::default(); + service + .register_attempt( + "attempt-1".to_string(), + "turn-1".to_string(), + "call-1".to_string(), + vec!["curl".to_string(), "example.com".to_string()], + std::env::temp_dir(), + ) + .await; + + let attempt = { + let attempts = service.attempts.lock().await; + attempts + .get("attempt-1") + .cloned() + .expect("attempt should exist") + }; + { + let mut outcome = attempt.outcome.lock().await; + *outcome = Some(NetworkApprovalOutcome::DeniedByPolicy( + "policy denied".to_string(), + )); + } + + assert!(!service.take_user_denial_outcome("attempt-1").await); + assert_eq!( + service.take_outcome("attempt-1").await, + Some(NetworkApprovalOutcome::DeniedByPolicy( + "policy denied".to_string(), + )) + ); + } + + #[tokio::test] + async fn record_blocked_request_stores_policy_denial_outcome() { + let service = NetworkApprovalService::default(); + service + .register_attempt( + "attempt-1".to_string(), + "turn-1".to_string(), + "call-1".to_string(), + vec!["curl".to_string(), "example.com".to_string()], + std::env::temp_dir(), + ) + .await; + + service + .record_blocked_request(BlockedRequest::new(BlockedRequestArgs { + host: "example.com".to_string(), + reason: "denied".to_string(), + client: None, + method: Some("GET".to_string()), + mode: None, + protocol: "http".to_string(), + attempt_id: Some("attempt-1".to_string()), + decision: Some("deny".to_string()), + source: Some("baseline_policy".to_string()), + port: Some(80), + })) + .await; + + let outcome = service + .take_outcome("attempt-1") + .await + .expect("outcome should be recorded"); + match outcome { + NetworkApprovalOutcome::DeniedByPolicy(message) => { + assert_eq!( + message, + "Network access to \"example.com\" was blocked: domain is explicitly denied by policy and cannot be approved from this prompt.".to_string() + ); + } + NetworkApprovalOutcome::DeniedByUser => panic!("expected policy denial"), + } + } + + #[tokio::test] + async fn record_blocked_request_does_not_override_user_denial() { + let service = NetworkApprovalService::default(); + service + .register_attempt( + "attempt-1".to_string(), + "turn-1".to_string(), + "call-1".to_string(), + vec!["curl".to_string(), "example.com".to_string()], + std::env::temp_dir(), + ) + .await; + + let attempt = { + let attempts = service.attempts.lock().await; + attempts + .get("attempt-1") + .cloned() + .expect("attempt should exist") + }; + { + let mut outcome = attempt.outcome.lock().await; + *outcome = Some(NetworkApprovalOutcome::DeniedByUser); + } + + service + .record_blocked_request(BlockedRequest::new(BlockedRequestArgs { + host: "example.com".to_string(), + reason: "denied".to_string(), + client: None, + method: Some("GET".to_string()), + mode: None, + protocol: "http".to_string(), + attempt_id: Some("attempt-1".to_string()), + decision: Some("deny".to_string()), + source: Some("baseline_policy".to_string()), + port: Some(80), + })) + .await; + + assert_eq!( + service.take_outcome("attempt-1").await, + Some(NetworkApprovalOutcome::DeniedByUser) + ); + } +} diff --git a/codex-rs/core/src/tools/orchestrator.rs b/codex-rs/core/src/tools/orchestrator.rs index 3f410e1b26..cdbaac0254 100644 --- a/codex-rs/core/src/tools/orchestrator.rs +++ b/codex-rs/core/src/tools/orchestrator.rs @@ -10,7 +10,13 @@ use crate::error::CodexErr; use crate::error::SandboxErr; use crate::exec::ExecToolCallOutput; use crate::features::Feature; +use crate::network_policy_decision::network_approval_context_from_payload; use crate::sandboxing::SandboxManager; +use crate::tools::network_approval::DeferredNetworkApproval; +use crate::tools::network_approval::NetworkApprovalMode; +use crate::tools::network_approval::begin_network_approval; +use crate::tools::network_approval::finish_deferred_network_approval; +use crate::tools::network_approval::finish_immediate_network_approval; use crate::tools::sandboxing::ApprovalCtx; use crate::tools::sandboxing::ExecApprovalRequirement; use crate::tools::sandboxing::SandboxAttempt; @@ -27,6 +33,11 @@ pub(crate) struct ToolOrchestrator { sandbox: SandboxManager, } +pub(crate) struct OrchestratorRunResult { + pub output: Out, + pub deferred_network_approval: Option, +} + impl ToolOrchestrator { pub fn new() -> Self { Self { @@ -34,6 +45,60 @@ impl ToolOrchestrator { } } + async fn run_attempt( + tool: &mut T, + req: &Rq, + tool_ctx: &ToolCtx<'_>, + attempt: &SandboxAttempt<'_>, + has_managed_network_requirements: bool, + ) -> (Result, Option) + where + T: ToolRuntime, + { + let network_approval = begin_network_approval( + tool_ctx.session, + &tool_ctx.turn.sub_id, + &tool_ctx.call_id, + has_managed_network_requirements, + tool.network_approval_spec(req, tool_ctx), + ) + .await; + + let attempt_tool_ctx = ToolCtx { + session: tool_ctx.session, + turn: tool_ctx.turn, + call_id: tool_ctx.call_id.clone(), + tool_name: tool_ctx.tool_name.clone(), + network_attempt_id: network_approval.as_ref().and_then(|network_approval| { + network_approval.attempt_id().map(ToString::to_string) + }), + }; + let run_result = tool.run(req, attempt, &attempt_tool_ctx).await; + + let Some(network_approval) = network_approval else { + return (run_result, None); + }; + + match network_approval.mode() { + NetworkApprovalMode::Immediate => { + let finalize_result = + finish_immediate_network_approval(tool_ctx.session, network_approval).await; + if let Err(err) = finalize_result { + return (Err(err), None); + } + (run_result, None) + } + NetworkApprovalMode::Deferred => { + let deferred = network_approval.into_deferred(); + if run_result.is_err() { + finish_deferred_network_approval(tool_ctx.session, deferred).await; + return (run_result, None); + } + (run_result, deferred) + } + } + } + pub async fn run( &mut self, tool: &mut T, @@ -41,7 +106,7 @@ impl ToolOrchestrator { tool_ctx: &ToolCtx<'_>, turn_ctx: &crate::codex::TurnContext, approval_policy: AskForApproval, - ) -> Result + ) -> Result, ToolError> where T: ToolRuntime, { @@ -70,6 +135,7 @@ impl ToolOrchestrator { turn: turn_ctx, call_id: &tool_ctx.call_id, retry_reason: reason, + network_approval_context: None, }; let decision = tool.start_approval_async(req, approval_ctx).await; @@ -118,33 +184,86 @@ impl ToolOrchestrator { windows_sandbox_level: turn_ctx.windows_sandbox_level, }; - match tool.run(req, &initial_attempt, tool_ctx).await { + let (first_result, first_deferred_network_approval) = Self::run_attempt( + tool, + req, + tool_ctx, + &initial_attempt, + has_managed_network_requirements, + ) + .await; + match first_result { Ok(out) => { // We have a successful initial result - Ok(out) + Ok(OrchestratorRunResult { + output: out, + deferred_network_approval: first_deferred_network_approval, + }) } - Err(ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied { output }))) => { + Err(ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied { + output, + network_policy_decision, + }))) => { + let network_approval_context = if has_managed_network_requirements { + network_policy_decision + .as_ref() + .and_then(network_approval_context_from_payload) + } else { + None + }; + if network_policy_decision.is_some() && network_approval_context.is_none() { + return Err(ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied { + output, + network_policy_decision, + }))); + } if !tool.escalate_on_failure() { return Err(ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied { output, + network_policy_decision, }))); } // Under `Never` or `OnRequest`, do not retry without sandbox; surface a concise // sandbox denial that preserves the original output. if !tool.wants_no_sandbox_approval(approval_policy) { - return Err(ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied { - output, - }))); + let allow_on_request_network_prompt = + matches!(approval_policy, AskForApproval::OnRequest) + && network_approval_context.is_some() + && matches!( + default_exec_approval_requirement( + approval_policy, + &turn_ctx.sandbox_policy + ), + ExecApprovalRequirement::NeedsApproval { .. } + ); + if !allow_on_request_network_prompt { + return Err(ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied { + output, + network_policy_decision, + }))); + } } + let retry_reason = + if let Some(network_approval_context) = network_approval_context.as_ref() { + format!( + "Network access to \"{}\" is blocked by policy.", + network_approval_context.host + ) + } else { + build_denial_reason_from_output(output.as_ref()) + }; // Ask for approval before retrying with the escalated sandbox. - if !tool.should_bypass_approval(approval_policy, already_approved) { - let reason_msg = build_denial_reason_from_output(output.as_ref()); + let bypass_retry_approval = tool + .should_bypass_approval(approval_policy, already_approved) + && network_approval_context.is_none(); + if !bypass_retry_approval { let approval_ctx = ApprovalCtx { session: tool_ctx.session, turn: turn_ctx, call_id: &tool_ctx.call_id, - retry_reason: Some(reason_msg), + retry_reason: Some(retry_reason), + network_approval_context: network_approval_context.clone(), }; let decision = tool.start_approval_async(req, approval_ctx).await; @@ -172,9 +291,20 @@ impl ToolOrchestrator { }; // Second attempt. - (*tool).run(req, &escalated_attempt, tool_ctx).await + let (retry_result, retry_deferred_network_approval) = Self::run_attempt( + tool, + req, + tool_ctx, + &escalated_attempt, + has_managed_network_requirements, + ) + .await; + retry_result.map(|output| OrchestratorRunResult { + output, + deferred_network_approval: retry_deferred_network_approval, + }) } - other => other, + Err(err) => Err(err), } } } diff --git a/codex-rs/core/src/tools/runtimes/shell.rs b/codex-rs/core/src/tools/runtimes/shell.rs index fd076f005e..3b7f970d77 100644 --- a/codex-rs/core/src/tools/runtimes/shell.rs +++ b/codex-rs/core/src/tools/runtimes/shell.rs @@ -11,6 +11,8 @@ use crate::powershell::prefix_powershell_script_with_utf8; use crate::sandboxing::SandboxPermissions; use crate::sandboxing::execute_env; use crate::shell::ShellType; +use crate::tools::network_approval::NetworkApprovalMode; +use crate::tools::network_approval::NetworkApprovalSpec; use crate::tools::runtimes::build_command_spec; use crate::tools::runtimes::maybe_wrap_shell_lc_with_snapshot; use crate::tools::sandboxing::Approvable; @@ -109,6 +111,7 @@ impl Approvable for ShellRuntime { command, cwd, reason, + ctx.network_approval_context.clone(), req.exec_approval_requirement .proposed_execpolicy_amendment() .cloned(), @@ -141,6 +144,20 @@ impl Approvable for ShellRuntime { } impl ToolRuntime for ShellRuntime { + fn network_approval_spec( + &self, + req: &ShellRequest, + _ctx: &ToolCtx<'_>, + ) -> Option { + req.network.as_ref()?; + Some(NetworkApprovalSpec { + command: req.command.clone(), + cwd: req.cwd.clone(), + network: req.network.clone(), + mode: NetworkApprovalMode::Immediate, + }) + } + async fn run( &mut self, req: &ShellRequest, @@ -167,9 +184,10 @@ impl ToolRuntime for ShellRuntime { req.sandbox_permissions, req.justification.clone(), )?; - let env = attempt + let mut env = attempt .env_for(spec, req.network.as_ref()) .map_err(|err| ToolError::Codex(err.into()))?; + env.network_attempt_id = ctx.network_attempt_id.clone(); let out = execute_env(env, attempt.policy, Self::stdout_stream(ctx)) .await .map_err(ToolError::Codex)?; diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index f3bd2e64b1..4c6c611075 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -12,6 +12,8 @@ use crate::features::Feature; use crate::powershell::prefix_powershell_script_with_utf8; use crate::sandboxing::SandboxPermissions; use crate::shell::ShellType; +use crate::tools::network_approval::NetworkApprovalMode; +use crate::tools::network_approval::NetworkApprovalSpec; use crate::tools::runtimes::build_command_spec; use crate::tools::runtimes::maybe_wrap_shell_lc_with_snapshot; use crate::tools::sandboxing::Approvable; @@ -110,6 +112,7 @@ impl Approvable for UnifiedExecRuntime<'_> { command, cwd, reason, + ctx.network_approval_context.clone(), req.exec_approval_requirement .proposed_execpolicy_amendment() .cloned(), @@ -145,6 +148,20 @@ impl Approvable for UnifiedExecRuntime<'_> { } impl<'a> ToolRuntime for UnifiedExecRuntime<'a> { + fn network_approval_spec( + &self, + req: &UnifiedExecRequest, + _ctx: &ToolCtx<'_>, + ) -> Option { + req.network.as_ref()?; + Some(NetworkApprovalSpec { + command: req.command.clone(), + cwd: req.cwd.clone(), + network: req.network.clone(), + mode: NetworkApprovalMode::Deferred, + }) + } + async fn run( &mut self, req: &UnifiedExecRequest, @@ -165,7 +182,7 @@ impl<'a> ToolRuntime for UnifiedExecRunt let mut env = req.env.clone(); if let Some(network) = req.network.as_ref() { - network.apply_to_env(&mut env); + network.apply_to_env_for_attempt(&mut env, ctx.network_attempt_id.as_deref()); } let spec = build_command_spec( &command, @@ -186,6 +203,7 @@ impl<'a> ToolRuntime for UnifiedExecRunt UnifiedExecError::SandboxDenied { output, .. } => { ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied { output: Box::new(output), + network_policy_decision: None, })) } other => ToolError::Rejected(other.to_string()), diff --git a/codex-rs/core/src/tools/sandboxing.rs b/codex-rs/core/src/tools/sandboxing.rs index 3671760844..04dc10c3e1 100644 --- a/codex-rs/core/src/tools/sandboxing.rs +++ b/codex-rs/core/src/tools/sandboxing.rs @@ -12,8 +12,10 @@ use crate::sandboxing::CommandSpec; use crate::sandboxing::SandboxManager; use crate::sandboxing::SandboxTransformError; use crate::state::SessionServices; +use crate::tools::network_approval::NetworkApprovalSpec; use codex_network_proxy::NetworkProxy; use codex_protocol::approvals::ExecPolicyAmendment; +use codex_protocol::approvals::NetworkApprovalContext; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::ReviewDecision; use std::collections::HashMap; @@ -110,6 +112,7 @@ pub(crate) struct ApprovalCtx<'a> { pub turn: &'a TurnContext, pub call_id: &'a str, pub retry_reason: Option, + pub network_approval_context: Option, } // Specifies what tool orchestrator should do with a given tool call. @@ -252,6 +255,7 @@ pub(crate) struct ToolCtx<'a> { pub turn: &'a TurnContext, pub call_id: String, pub tool_name: String, + pub network_attempt_id: Option, } #[derive(Debug)] @@ -261,6 +265,10 @@ pub(crate) enum ToolError { } pub(crate) trait ToolRuntime: Approvable + Sandboxable { + fn network_approval_spec(&self, _req: &Req, _ctx: &ToolCtx<'_>) -> Option { + None + } + async fn run( &mut self, req: &Req, diff --git a/codex-rs/core/src/unified_exec/async_watcher.rs b/codex-rs/core/src/unified_exec/async_watcher.rs index 1fbb1e8f6d..772baab00b 100644 --- a/codex-rs/core/src/unified_exec/async_watcher.rs +++ b/codex-rs/core/src/unified_exec/async_watcher.rs @@ -139,6 +139,43 @@ pub(crate) fn spawn_exit_watcher( }); } +pub(crate) fn spawn_network_denial_watcher( + process: Arc, + session: Arc, + process_id: String, + network_attempt_id: String, +) { + let exit_token = process.cancellation_token(); + tokio::spawn(async move { + let mut poll = tokio::time::interval(Duration::from_millis(100)); + poll.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + + loop { + tokio::select! { + _ = exit_token.cancelled() => { + break; + } + _ = poll.tick() => { + if session + .services + .network_approval + .take_user_denial_outcome(&network_attempt_id) + .await + { + process.terminate(); + session + .services + .unified_exec_manager + .release_process_id(&process_id) + .await; + break; + } + } + } + } + }); +} + async fn process_chunk( pending: &mut Vec, transcript: &Arc>, diff --git a/codex-rs/core/src/unified_exec/mod.rs b/codex-rs/core/src/unified_exec/mod.rs index 84785270f8..a907f348f2 100644 --- a/codex-rs/core/src/unified_exec/mod.rs +++ b/codex-rs/core/src/unified_exec/mod.rs @@ -25,6 +25,7 @@ use std::collections::HashMap; use std::collections::HashSet; use std::path::PathBuf; use std::sync::Arc; +use std::sync::Weak; use std::time::Duration; use codex_network_proxy::NetworkProxy; @@ -144,6 +145,8 @@ struct ProcessEntry { process_id: String, command: Vec, tty: bool, + network_attempt_id: Option, + session: Weak, last_used: tokio::time::Instant, } diff --git a/codex-rs/core/src/unified_exec/process_manager.rs b/codex-rs/core/src/unified_exec/process_manager.rs index 251a4910ba..197cda479f 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -19,6 +19,9 @@ use crate::sandboxing::ExecRequest; use crate::tools::events::ToolEmitter; use crate::tools::events::ToolEventCtx; use crate::tools::events::ToolEventStage; +use crate::tools::network_approval::DeferredNetworkApproval; +use crate::tools::network_approval::deferred_rejection_message; +use crate::tools::network_approval::finish_deferred_network_approval; use crate::tools::orchestrator::ToolOrchestrator; use crate::tools::runtimes::unified_exec::UnifiedExecRequest as UnifiedExecToolRequest; use crate::tools::runtimes::unified_exec::UnifiedExecRuntime; @@ -40,6 +43,7 @@ use crate::unified_exec::WARNING_UNIFIED_EXEC_PROCESSES; use crate::unified_exec::WriteStdinRequest; use crate::unified_exec::async_watcher::emit_exec_end_for_unified_exec; use crate::unified_exec::async_watcher::spawn_exit_watcher; +use crate::unified_exec::async_watcher::spawn_network_denial_watcher; use crate::unified_exec::async_watcher::start_streaming_output; use crate::unified_exec::clamp_yield_time; use crate::unified_exec::generate_chunk_id; @@ -130,8 +134,25 @@ impl UnifiedExecProcessManager { } pub(crate) async fn release_process_id(&self, process_id: &str) { - let mut store = self.process_store.lock().await; - store.remove(process_id); + let removed = { + let mut store = self.process_store.lock().await; + store.remove(process_id) + }; + if let Some(entry) = removed { + Self::unregister_network_attempt_for_entry(&entry).await; + } + } + + async fn unregister_network_attempt_for_entry(entry: &ProcessEntry) { + if let Some(attempt_id) = entry.network_attempt_id.as_deref() + && let Some(session) = entry.session.upgrade() + { + session + .services + .network_approval + .unregister_attempt(attempt_id) + .await; + } } pub(crate) async fn exec_command( @@ -143,13 +164,14 @@ impl UnifiedExecProcessManager { .workdir .clone() .unwrap_or_else(|| context.turn.cwd.clone()); - let process = self .open_session_with_sandbox(&request, cwd.clone(), context) .await; - let process = match process { - Ok(process) => Arc::new(process), + let (process, mut deferred_network_approval) = match process { + Ok((process, deferred_network_approval)) => { + (Arc::new(process), deferred_network_approval) + } Err(err) => { self.release_process_id(&request.process_id).await; return Err(err); @@ -172,7 +194,6 @@ impl UnifiedExecProcessManager { emitter.emit(event_ctx, ToolEventStage::Begin).await; start_streaming_output(&process, context, Arc::clone(&transcript)); - let max_tokens = resolve_max_tokens(request.max_output_tokens); let yield_time_ms = clamp_yield_time(request.yield_time_ms); @@ -205,6 +226,7 @@ impl UnifiedExecProcessManager { let has_exited = process.has_exited() || exit_code.is_some(); let chunk_id = generate_chunk_id(); let process_id = request.process_id.clone(); + if has_exited { // Short‑lived command: emit ExecCommandEnd immediately using the // same helper as the background watcher, so all end events share @@ -215,7 +237,7 @@ impl UnifiedExecProcessManager { Arc::clone(&context.turn), context.call_id.clone(), request.command.clone(), - cwd, + cwd.clone(), Some(process_id), Arc::clone(&transcript), output.clone(), @@ -225,12 +247,45 @@ impl UnifiedExecProcessManager { .await; self.release_process_id(&request.process_id).await; + if let Some(deferred) = deferred_network_approval.as_ref() + && let Some(message) = + deferred_rejection_message(context.session.as_ref(), deferred).await + { + finish_deferred_network_approval( + context.session.as_ref(), + deferred_network_approval.take(), + ) + .await; + return Err(UnifiedExecError::create_process(message)); + } + finish_deferred_network_approval( + context.session.as_ref(), + deferred_network_approval.take(), + ) + .await; process.check_for_sandbox_denial_with_text(&text).await?; } else { + if let Some(deferred) = deferred_network_approval.as_ref() + && let Some(message) = + deferred_rejection_message(context.session.as_ref(), deferred).await + { + process.terminate(); + finish_deferred_network_approval( + context.session.as_ref(), + deferred_network_approval.take(), + ) + .await; + self.release_process_id(&request.process_id).await; + return Err(UnifiedExecError::create_process(message)); + } + // Long‑lived command: persist the process so write_stdin can reuse // it, and register a background watcher that will emit // ExecCommandEnd when the PTY eventually exits (even if no further // tool calls are made). + let network_attempt_id = deferred_network_approval + .as_ref() + .map(|deferred| deferred.attempt_id().to_string()); self.store_process( Arc::clone(&process), context, @@ -239,6 +294,7 @@ impl UnifiedExecProcessManager { start, process_id, request.tty, + network_attempt_id, Arc::clone(&transcript), ) .await; @@ -358,29 +414,35 @@ impl UnifiedExecProcessManager { } async fn refresh_process_state(&self, process_id: &str) -> ProcessStatus { - let mut store = self.process_store.lock().await; - let Some(entry) = store.processes.get(process_id) else { - return ProcessStatus::Unknown; - }; - - let exit_code = entry.process.exit_code(); - let process_id = entry.process_id.clone(); - - if entry.process.has_exited() { - let Some(entry) = store.remove(&process_id) else { + let status = { + let mut store = self.process_store.lock().await; + let Some(entry) = store.processes.get(process_id) else { return ProcessStatus::Unknown; }; - ProcessStatus::Exited { - exit_code, - entry: Box::new(entry), - } - } else { - ProcessStatus::Alive { - exit_code, - call_id: entry.call_id.clone(), - process_id, + + let exit_code = entry.process.exit_code(); + let process_id = entry.process_id.clone(); + + if entry.process.has_exited() { + let Some(entry) = store.remove(&process_id) else { + return ProcessStatus::Unknown; + }; + ProcessStatus::Exited { + exit_code, + entry: Box::new(entry), + } + } else { + ProcessStatus::Alive { + exit_code, + call_id: entry.call_id.clone(), + process_id, + } } + }; + if let ProcessStatus::Exited { entry, .. } = &status { + Self::unregister_network_attempt_for_entry(entry).await; } + status } async fn prepare_process_handles( @@ -437,22 +499,32 @@ impl UnifiedExecProcessManager { started_at: Instant, process_id: String, tty: bool, + network_attempt_id: Option, transcript: Arc>, ) { + let network_attempt_id_for_watcher = network_attempt_id.clone(); let entry = ProcessEntry { process: Arc::clone(&process), call_id: context.call_id.clone(), process_id: process_id.clone(), command: command.to_vec(), tty, + network_attempt_id, + session: Arc::downgrade(&context.session), last_used: started_at, }; - let number_processes = { + let (number_processes, pruned_entry) = { let mut store = self.process_store.lock().await; - Self::prune_processes_if_needed(&mut store); + let pruned_entry = Self::prune_processes_if_needed(&mut store); store.processes.insert(process_id.clone(), entry); - store.processes.len() + (store.processes.len(), pruned_entry) }; + // prune_processes_if_needed runs while holding process_store; do async + // network-approval cleanup only after dropping that lock. + if let Some(pruned_entry) = pruned_entry { + Self::unregister_network_attempt_for_entry(&pruned_entry).await; + pruned_entry.process.terminate(); + } if number_processes >= WARNING_UNIFIED_EXEC_PROCESSES { context @@ -471,10 +543,21 @@ impl UnifiedExecProcessManager { context.call_id.clone(), command.to_vec(), cwd, - process_id, + process_id.clone(), transcript, started_at, ); + + if context.turn.config.managed_network_requirements_enabled() + && let Some(network_attempt_id) = network_attempt_id_for_watcher + { + spawn_network_denial_watcher( + Arc::clone(&process), + Arc::clone(&context.session), + process_id, + network_attempt_id, + ); + } } pub(crate) async fn open_session_with_exec_env( @@ -516,7 +599,7 @@ impl UnifiedExecProcessManager { request: &ExecCommandRequest, cwd: PathBuf, context: &UnifiedExecContext, - ) -> Result { + ) -> Result<(UnifiedExecProcess, Option), UnifiedExecError> { let env = apply_unified_exec_env(create_env( &context.turn.shell_environment_policy, Some(context.session.conversation_id), @@ -550,6 +633,7 @@ impl UnifiedExecProcessManager { turn: context.turn.as_ref(), call_id: context.call_id.clone(), tool_name: "exec_command".to_string(), + network_attempt_id: None, }; orchestrator .run( @@ -560,6 +644,7 @@ impl UnifiedExecProcessManager { context.turn.approval_policy, ) .await + .map(|result| (result.output, result.deferred_network_approval)) .map_err(|e| UnifiedExecError::create_process(format!("{e:?}"))) } @@ -643,9 +728,9 @@ impl UnifiedExecProcessManager { collected } - fn prune_processes_if_needed(store: &mut ProcessStore) -> bool { + fn prune_processes_if_needed(store: &mut ProcessStore) -> Option { if store.processes.len() < MAX_UNIFIED_EXEC_PROCESSES { - return false; + return None; } let meta: Vec<(String, Instant, bool)> = store @@ -655,13 +740,10 @@ impl UnifiedExecProcessManager { .collect(); if let Some(process_id) = Self::process_id_to_prune_from_meta(&meta) { - if let Some(entry) = store.remove(&process_id) { - entry.process.terminate(); - } - return true; + return store.remove(&process_id); } - false + None } // Centralized pruning policy so we can easily swap strategies later. @@ -706,6 +788,7 @@ impl UnifiedExecProcessManager { }; for entry in entries { + Self::unregister_network_attempt_for_entry(&entry).await; entry.process.terminate(); } } diff --git a/codex-rs/core/tests/suite/exec.rs b/codex-rs/core/tests/suite/exec.rs index c3cb15875b..c857affd2b 100644 --- a/codex-rs/core/tests/suite/exec.rs +++ b/codex-rs/core/tests/suite/exec.rs @@ -37,6 +37,7 @@ async fn run_test_cmd(tmp: TempDir, cmd: Vec<&str>) -> Result bool { .await { Ok(output) => is_bwrap_unavailable_output(&output), - Err(CodexErr::Sandbox(SandboxErr::Denied { output })) => { + Err(CodexErr::Sandbox(SandboxErr::Denied { output, .. })) => { is_bwrap_unavailable_output(&output) } // Probe timeouts are not actionable for the bwrap-specific assertions below; @@ -142,7 +143,7 @@ fn expect_denied( assert_ne!(output.exit_code, 0, "{context}: expected nonzero exit code"); output } - Err(CodexErr::Sandbox(SandboxErr::Denied { output })) => *output, + Err(CodexErr::Sandbox(SandboxErr::Denied { output, .. })) => *output, Err(err) => panic!("{context}: {err:?}"), } } @@ -236,6 +237,7 @@ async fn assert_network_blocked(cmd: &[&str]) { expiration: NETWORK_TIMEOUT_MS.into(), env: create_env_from_core_vars(), network: None, + network_attempt_id: None, sandbox_permissions: SandboxPermissions::UseDefault, windows_sandbox_level: WindowsSandboxLevel::Disabled, justification: None, @@ -257,7 +259,7 @@ async fn assert_network_blocked(cmd: &[&str]) { let output = match result { Ok(output) => output, - Err(CodexErr::Sandbox(SandboxErr::Denied { output })) => *output, + Err(CodexErr::Sandbox(SandboxErr::Denied { output, .. })) => *output, _ => { panic!("expected sandbox denied error, got: {result:?}"); } diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index 6c5b3c25a2..5af2191fcc 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -221,6 +221,7 @@ async fn run_codex_tool_session_inner( reason: _, proposed_execpolicy_amendment: _, parsed_cmd, + network_approval_context: _, }) => { handle_exec_approval_request( command, diff --git a/codex-rs/network-proxy/src/lib.rs b/codex-rs/network-proxy/src/lib.rs index 0983b07dc5..536dd4bf6f 100644 --- a/codex-rs/network-proxy/src/lib.rs +++ b/codex-rs/network-proxy/src/lib.rs @@ -18,7 +18,9 @@ pub use config::NetworkMode; pub use config::NetworkProxyConfig; pub use config::host_and_port_from_network_addr; pub use network_policy::NetworkDecision; +pub use network_policy::NetworkDecisionSource; pub use network_policy::NetworkPolicyDecider; +pub use network_policy::NetworkPolicyDecision; pub use network_policy::NetworkPolicyRequest; pub use network_policy::NetworkPolicyRequestArgs; pub use network_policy::NetworkProtocol; @@ -34,6 +36,8 @@ pub use proxy::PROXY_URL_ENV_KEYS; pub use proxy::has_proxy_url_env_vars; pub use proxy::proxy_url_env_value; pub use runtime::BlockedRequest; +pub use runtime::BlockedRequestArgs; +pub use runtime::BlockedRequestObserver; pub use runtime::ConfigReloader; pub use runtime::ConfigState; pub use runtime::NetworkProxyState; diff --git a/codex-rs/network-proxy/src/network_policy.rs b/codex-rs/network-proxy/src/network_policy.rs index 201a62cda4..fdbbe92819 100644 --- a/codex-rs/network-proxy/src/network_policy.rs +++ b/codex-rs/network-proxy/src/network_policy.rs @@ -26,7 +26,8 @@ impl NetworkProtocol { } } -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, serde::Deserialize, serde::Serialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] pub enum NetworkPolicyDecision { Deny, Ask, @@ -41,7 +42,8 @@ impl NetworkPolicyDecision { } } -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, serde::Deserialize, serde::Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] pub enum NetworkDecisionSource { BaselinePolicy, ModeGuard, diff --git a/codex-rs/network-proxy/src/proxy.rs b/codex-rs/network-proxy/src/proxy.rs index 42f1637c2d..55a0eaa165 100644 --- a/codex-rs/network-proxy/src/proxy.rs +++ b/codex-rs/network-proxy/src/proxy.rs @@ -3,7 +3,7 @@ use crate::config; use crate::http_proxy; use crate::metadata::proxy_username_for_attempt_id; use crate::network_policy::NetworkPolicyDecider; -use crate::runtime::BlockedRequest; +use crate::runtime::BlockedRequestObserver; use crate::runtime::unix_socket_permissions_supported; use crate::socks5; use crate::state::NetworkProxyState; @@ -72,6 +72,7 @@ pub struct NetworkProxyBuilder { admin_addr: Option, managed_by_codex: bool, policy_decider: Option>, + blocked_request_observer: Option>, } impl Default for NetworkProxyBuilder { @@ -83,6 +84,7 @@ impl Default for NetworkProxyBuilder { admin_addr: None, managed_by_codex: true, policy_decider: None, + blocked_request_observer: None, } } } @@ -126,12 +128,31 @@ impl NetworkProxyBuilder { self } + pub fn blocked_request_observer(mut self, observer: O) -> Self + where + O: BlockedRequestObserver, + { + self.blocked_request_observer = Some(Arc::new(observer)); + self + } + + pub fn blocked_request_observer_arc( + mut self, + observer: Arc, + ) -> Self { + self.blocked_request_observer = Some(observer); + self + } + pub async fn build(self) -> Result { let state = self.state.ok_or_else(|| { anyhow::anyhow!( "NetworkProxyBuilder requires a state; supply one via builder.state(...)" ) })?; + state + .set_blocked_request_observer(self.blocked_request_observer.clone()) + .await; let current_cfg = state.current_cfg().await?; let (requested_http_addr, requested_socks_addr, requested_admin_addr, reserved_listeners) = if self.managed_by_codex { @@ -399,13 +420,6 @@ impl NetworkProxy { self.admin_addr } - pub async fn latest_blocked_request_for_attempt( - &self, - attempt_id: &str, - ) -> Result> { - self.state.latest_blocked_for_attempt(attempt_id).await - } - pub fn apply_to_env(&self, env: &mut HashMap) { self.apply_to_env_for_attempt(env, None); } diff --git a/codex-rs/network-proxy/src/runtime.rs b/codex-rs/network-proxy/src/runtime.rs index 737a8dd1de..7917747a8f 100644 --- a/codex-rs/network-proxy/src/runtime.rs +++ b/codex-rs/network-proxy/src/runtime.rs @@ -20,6 +20,7 @@ use globset::GlobSet; use serde::Serialize; use std::collections::HashSet; use std::collections::VecDeque; +use std::future::Future; use std::net::IpAddr; use std::path::Path; use std::sync::Arc; @@ -162,9 +163,33 @@ pub trait ConfigReloader: Send + Sync { async fn reload_now(&self) -> Result; } +#[async_trait] +pub trait BlockedRequestObserver: Send + Sync + 'static { + async fn on_blocked_request(&self, request: BlockedRequest); +} + +#[async_trait] +impl BlockedRequestObserver for Arc { + async fn on_blocked_request(&self, request: BlockedRequest) { + (**self).on_blocked_request(request).await + } +} + +#[async_trait] +impl BlockedRequestObserver for F +where + F: Fn(BlockedRequest) -> Fut + Send + Sync + 'static, + Fut: Future + Send, +{ + async fn on_blocked_request(&self, request: BlockedRequest) { + (self)(request).await + } +} + pub struct NetworkProxyState { state: Arc>, reloader: Arc, + blocked_request_observer: Arc>>>, } impl std::fmt::Debug for NetworkProxyState { @@ -180,18 +205,36 @@ impl Clone for NetworkProxyState { Self { state: self.state.clone(), reloader: self.reloader.clone(), + blocked_request_observer: self.blocked_request_observer.clone(), } } } impl NetworkProxyState { pub fn with_reloader(state: ConfigState, reloader: Arc) -> Self { + Self::with_reloader_and_blocked_observer(state, reloader, None) + } + + pub fn with_reloader_and_blocked_observer( + state: ConfigState, + reloader: Arc, + blocked_request_observer: Option>, + ) -> Self { Self { state: Arc::new(RwLock::new(state)), reloader, + blocked_request_observer: Arc::new(RwLock::new(blocked_request_observer)), } } + pub async fn set_blocked_request_observer( + &self, + blocked_request_observer: Option>, + ) { + let mut observer = self.blocked_request_observer.write().await; + *observer = blocked_request_observer; + } + pub async fn current_cfg(&self) -> Result { // Callers treat `NetworkProxyState` as a live view of policy. We reload-on-demand so edits to // `config.toml` (including Codex-managed writes) take effect without a restart. @@ -312,6 +355,8 @@ impl NetworkProxyState { pub async fn record_blocked(&self, entry: BlockedRequest) -> Result<()> { self.reload_if_needed().await?; + let blocked_for_observer = entry.clone(); + let blocked_request_observer = self.blocked_request_observer.read().await.clone(); let violation_line = blocked_request_violation_log_line(&entry); let mut guard = self.state.write().await; let host = entry.host.clone(); @@ -340,6 +385,11 @@ impl NetworkProxyState { guard.blocked.len() ); debug!("{violation_line}"); + drop(guard); + + if let Some(observer) = blocked_request_observer { + observer.on_blocked_request(blocked_for_observer).await; + } Ok(()) } @@ -351,20 +401,6 @@ impl NetworkProxyState { Ok(guard.blocked.iter().cloned().collect()) } - pub async fn latest_blocked_for_attempt( - &self, - attempt_id: &str, - ) -> Result> { - self.reload_if_needed().await?; - let guard = self.state.read().await; - Ok(guard - .blocked - .iter() - .rev() - .find(|entry| entry.attempt_id.as_deref() == Some(attempt_id)) - .cloned()) - } - /// Drain and return the buffered blocked-request entries in FIFO order. pub async fn drain_blocked(&self) -> Result> { self.reload_if_needed().await?; @@ -692,64 +728,6 @@ mod tests { assert_eq!(drained[0].port, snapshot[0].port); } - #[tokio::test] - async fn latest_blocked_for_attempt_returns_latest_matching_entry() { - let state = network_proxy_state_for_policy(NetworkProxySettings::default()); - - state - .record_blocked(BlockedRequest::new(BlockedRequestArgs { - host: "one.example.com".to_string(), - reason: "not_allowed".to_string(), - client: None, - method: Some("GET".to_string()), - mode: None, - protocol: "http".to_string(), - attempt_id: Some("attempt-1".to_string()), - decision: Some("ask".to_string()), - source: Some("decider".to_string()), - port: Some(80), - })) - .await - .expect("entry should be recorded"); - state - .record_blocked(BlockedRequest::new(BlockedRequestArgs { - host: "two.example.com".to_string(), - reason: "not_allowed".to_string(), - client: None, - method: Some("GET".to_string()), - mode: None, - protocol: "http".to_string(), - attempt_id: Some("attempt-2".to_string()), - decision: Some("ask".to_string()), - source: Some("decider".to_string()), - port: Some(80), - })) - .await - .expect("entry should be recorded"); - state - .record_blocked(BlockedRequest::new(BlockedRequestArgs { - host: "three.example.com".to_string(), - reason: "not_allowed".to_string(), - client: None, - method: Some("GET".to_string()), - mode: None, - protocol: "http".to_string(), - attempt_id: Some("attempt-1".to_string()), - decision: Some("ask".to_string()), - source: Some("decider".to_string()), - port: Some(80), - })) - .await - .expect("entry should be recorded"); - - let latest = state - .latest_blocked_for_attempt("attempt-1") - .await - .expect("lookup should succeed") - .expect("attempt should have a blocked entry"); - assert_eq!(latest.host, "three.example.com"); - } - #[tokio::test] async fn drain_blocked_returns_buffered_window() { let state = network_proxy_state_for_policy(NetworkProxySettings::default()); diff --git a/codex-rs/protocol/src/approvals.rs b/codex-rs/protocol/src/approvals.rs index 635cd5223d..4ee7bd8b30 100644 --- a/codex-rs/protocol/src/approvals.rs +++ b/codex-rs/protocol/src/approvals.rs @@ -37,6 +37,24 @@ impl From> for ExecPolicyAmendment { } } +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +pub enum NetworkApprovalProtocol { + // TODO(viyatb): Add websocket protocol variants when managed proxy policy + // decisions expose websocket traffic as a distinct approval context. + Http, + #[serde(alias = "https_connect", alias = "http-connect")] + Https, + Socks5Tcp, + Socks5Udp, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +pub struct NetworkApprovalContext { + pub host: String, + pub protocol: NetworkApprovalProtocol, +} + #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct ExecApprovalRequestEvent { /// Identifier for the associated exec call, if available. @@ -52,6 +70,10 @@ pub struct ExecApprovalRequestEvent { /// Optional human-readable reason for the approval (e.g. retry without sandbox). #[serde(skip_serializing_if = "Option::is_none")] pub reason: Option, + /// Optional network context for a blocked request that can be approved. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub network_approval_context: Option, /// Proposed execpolicy amendment that can be applied to allow future runs. #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index e44bf4c1d8..8a7fa9c382 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -54,6 +54,8 @@ pub use crate::approvals::ApplyPatchApprovalRequestEvent; pub use crate::approvals::ElicitationAction; pub use crate::approvals::ExecApprovalRequestEvent; pub use crate::approvals::ExecPolicyAmendment; +pub use crate::approvals::NetworkApprovalContext; +pub use crate::approvals::NetworkApprovalProtocol; pub use crate::request_user_input::RequestUserInputEvent; /// Open/close tags for special user-input blocks. Used across crates to avoid diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 5435913bbe..6881459b9a 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -2476,6 +2476,7 @@ async fn exec_approval_emits_proposed_command_and_decision_history() { reason: Some( "this is a test reason such as one that would be produced by the model".into(), ), + network_approval_context: None, proposed_execpolicy_amendment: None, parsed_cmd: vec![], }; @@ -2520,6 +2521,7 @@ async fn exec_approval_decision_truncates_multiline_and_long_commands() { reason: Some( "this is a test reason such as one that would be produced by the model".into(), ), + network_approval_context: None, proposed_execpolicy_amendment: None, parsed_cmd: vec![], }; @@ -2570,6 +2572,7 @@ async fn exec_approval_decision_truncates_multiline_and_long_commands() { command: vec!["bash".into(), "-lc".into(), long], cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), reason: None, + network_approval_context: None, proposed_execpolicy_amendment: None, parsed_cmd: vec![], }; @@ -5450,6 +5453,7 @@ async fn approval_modal_exec_snapshot() -> anyhow::Result<()> { reason: Some( "this is a test reason such as one that would be produced by the model".into(), ), + network_approval_context: None, proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ "echo".into(), "hello".into(), @@ -5506,6 +5510,7 @@ async fn approval_modal_exec_without_reason_snapshot() -> anyhow::Result<()> { command: vec!["bash".into(), "-lc".into(), "echo hello world".into()], cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), reason: None, + network_approval_context: None, proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ "echo".into(), "hello".into(), @@ -5553,6 +5558,7 @@ async fn approval_modal_exec_multiline_prefix_hides_execpolicy_option_snapshot() command: command.clone(), cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), reason: None, + network_approval_context: None, proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(command)), parsed_cmd: vec![], }; @@ -5912,6 +5918,7 @@ async fn status_widget_and_approval_modal_snapshot() { reason: Some( "this is a test reason such as one that would be produced by the model".into(), ), + network_approval_context: None, proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ "echo".into(), "hello world".into(), From 3164670101fa873923dcc78a2d7e707c1a32fd1e Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Fri, 13 Feb 2026 22:38:36 -0800 Subject: [PATCH 14/62] feat(tui): render structured network approval prompts in approval overlay (#11674) ### Description #### Summary Adds the TUI UX layer for structured network approvals #### What changed - Updated approval overlay to display network-specific approval context (host/protocol). - Added/updated TUI wiring so approval prompts show correct network messaging. - Added tests covering the new approval overlay behavior. #### Why Core orchestration can now request structured network approvals; this ensures users see clear, contextual prompts in the TUI. #### Notes - UX behavior activates only when network approval context is present. --------- Co-authored-by: Codex <199175422+chatgpt-codex-connector[bot]@users.noreply.github.com> --- .../tui/src/bottom_pane/approval_overlay.rs | 114 +++++++++++++++++- codex-rs/tui/src/bottom_pane/mod.rs | 1 + codex-rs/tui/src/chatwidget.rs | 1 + 3 files changed, 113 insertions(+), 3 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/approval_overlay.rs b/codex-rs/tui/src/bottom_pane/approval_overlay.rs index 42abe174d4..567f18f332 100644 --- a/codex-rs/tui/src/bottom_pane/approval_overlay.rs +++ b/codex-rs/tui/src/bottom_pane/approval_overlay.rs @@ -20,6 +20,7 @@ use codex_core::features::Features; use codex_core::protocol::ElicitationAction; use codex_core::protocol::ExecPolicyAmendment; use codex_core::protocol::FileChange; +use codex_core::protocol::NetworkApprovalContext; use codex_core::protocol::Op; use codex_core::protocol::ReviewDecision; use codex_protocol::mcp::RequestId; @@ -42,6 +43,7 @@ pub(crate) enum ApprovalRequest { id: String, command: Vec, reason: Option, + network_approval_context: Option, proposed_execpolicy_amendment: Option, }, ApplyPatch { @@ -108,11 +110,23 @@ impl ApprovalOverlay { ) -> (Vec, SelectionViewParams) { let (options, title) = match &variant { ApprovalVariant::Exec { + network_approval_context, proposed_execpolicy_amendment, .. } => ( - exec_options(proposed_execpolicy_amendment.clone()), - "Would you like to run the following command?".to_string(), + exec_options( + proposed_execpolicy_amendment.clone(), + network_approval_context.as_ref(), + ), + network_approval_context.as_ref().map_or_else( + || "Would you like to run the following command?".to_string(), + |network_approval_context| { + format!( + "Do you want to approve access to \"{}\"?", + network_approval_context.host + ) + }, + ), ), ApprovalVariant::ApplyPatch { .. } => ( patch_options(), @@ -342,6 +356,7 @@ impl From for ApprovalRequestState { id, command, reason, + network_approval_context, proposed_execpolicy_amendment, } => { let mut header: Vec> = Vec::new(); @@ -359,6 +374,7 @@ impl From for ApprovalRequestState { variant: ApprovalVariant::Exec { id, command, + network_approval_context, proposed_execpolicy_amendment, }, header: Box::new(Paragraph::new(header).wrap(Wrap { trim: false })), @@ -414,6 +430,7 @@ enum ApprovalVariant { Exec { id: String, command: Vec, + network_approval_context: Option, proposed_execpolicy_amendment: Option, }, ApplyPatch { @@ -447,7 +464,33 @@ impl ApprovalOption { } } -fn exec_options(proposed_execpolicy_amendment: Option) -> Vec { +fn exec_options( + proposed_execpolicy_amendment: Option, + network_approval_context: Option<&NetworkApprovalContext>, +) -> Vec { + if network_approval_context.is_some() { + return vec![ + ApprovalOption { + label: "Yes, just this once".to_string(), + decision: ApprovalDecision::Review(ReviewDecision::Approved), + display_shortcut: None, + additional_shortcuts: vec![key_hint::plain(KeyCode::Char('y'))], + }, + ApprovalOption { + label: "Yes, and allow this host for this session".to_string(), + decision: ApprovalDecision::Review(ReviewDecision::ApprovedForSession), + display_shortcut: None, + additional_shortcuts: vec![key_hint::plain(KeyCode::Char('a'))], + }, + ApprovalOption { + label: "No, and tell Codex what to do differently".to_string(), + decision: ApprovalDecision::Review(ReviewDecision::Abort), + display_shortcut: Some(key_hint::plain(KeyCode::Esc)), + additional_shortcuts: vec![key_hint::plain(KeyCode::Char('n'))], + }, + ]; + } + vec![ApprovalOption { label: "Yes, proceed".to_string(), decision: ApprovalDecision::Review(ReviewDecision::Approved), @@ -531,6 +574,7 @@ fn elicitation_options() -> Vec { mod tests { use super::*; use crate::app_event::AppEvent; + use codex_core::protocol::NetworkApprovalProtocol; use pretty_assertions::assert_eq; use tokio::sync::mpsc::unbounded_channel; @@ -539,6 +583,7 @@ mod tests { id: "test".to_string(), command: vec!["echo".to_string(), "hi".to_string()], reason: Some("reason".to_string()), + network_approval_context: None, proposed_execpolicy_amendment: None, } } @@ -581,6 +626,7 @@ mod tests { id: "test".to_string(), command: vec!["echo".to_string()], reason: None, + network_approval_context: None, proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ "echo".to_string(), ])), @@ -619,6 +665,7 @@ mod tests { id: "test".into(), command, reason: None, + network_approval_context: None, proposed_execpolicy_amendment: None, }; @@ -641,6 +688,67 @@ mod tests { ); } + #[test] + fn network_exec_options_use_expected_labels_and_hide_execpolicy_amendment() { + let network_context = NetworkApprovalContext { + host: "example.com".to_string(), + protocol: NetworkApprovalProtocol::Https, + }; + let options = exec_options( + Some(ExecPolicyAmendment::new(vec!["curl".to_string()])), + Some(&network_context), + ); + + let labels: Vec = options.into_iter().map(|option| option.label).collect(); + assert_eq!( + labels, + vec![ + "Yes, just this once".to_string(), + "Yes, and allow this host for this session".to_string(), + "No, and tell Codex what to do differently".to_string(), + ] + ); + } + + #[test] + fn network_exec_prompt_title_includes_host() { + let (tx, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx); + let exec_request = ApprovalRequest::Exec { + id: "test".into(), + command: vec!["curl".into(), "https://example.com".into()], + reason: Some("network request blocked".into()), + network_approval_context: Some(NetworkApprovalContext { + host: "example.com".to_string(), + protocol: NetworkApprovalProtocol::Https, + }), + proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec!["curl".into()])), + }; + + let view = ApprovalOverlay::new(exec_request, tx, Features::with_defaults()); + let mut buf = Buffer::empty(Rect::new(0, 0, 100, view.desired_height(100))); + view.render(Rect::new(0, 0, 100, view.desired_height(100)), &mut buf); + + let rendered: Vec = (0..buf.area.height) + .map(|row| { + (0..buf.area.width) + .map(|col| buf[(col, row)].symbol().to_string()) + .collect() + }) + .collect(); + + assert!( + rendered + .iter() + .any(|line| line.contains("Do you want to approve access to \"example.com\"?")), + "expected network title to include host, got {rendered:?}" + ); + assert!( + !rendered.iter().any(|line| line.contains("don't ask again")), + "network prompt should not show execpolicy option, got {rendered:?}" + ); + } + #[test] fn exec_history_cell_wraps_with_two_space_indent() { let command = vec![ diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index e36c2f3c40..43aecb4940 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -1006,6 +1006,7 @@ mod tests { id: "1".to_string(), command: vec!["echo".into(), "ok".into()], reason: None, + network_approval_context: None, proposed_execpolicy_amendment: None, } } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 36251c4482..59873cbfd2 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -2394,6 +2394,7 @@ impl ChatWidget { id: ev.call_id, command: ev.command, reason: ev.reason, + network_approval_context: ev.network_approval_context, proposed_execpolicy_amendment: ev.proposed_execpolicy_amendment, }; self.bottom_pane From ebceb71db6ba3cca89d06e64e82a8bcf8c286724 Mon Sep 17 00:00:00 2001 From: Dylan Hurd Date: Fri, 13 Feb 2026 23:44:27 -0800 Subject: [PATCH 15/62] feat(tui) Permissions update history item (#11550) ## Summary We should document in the tui when you switch permissions! ## Testing - [x] Added unit tests - [x] Tested locally --- codex-rs/tui/src/chatwidget.rs | 37 +++- ...s_selection_history_after_mode_switch.snap | 5 + ...ection_history_full_access_to_default.snap | 5 + ...istory_full_access_to_default@windows.snap | 5 + codex-rs/tui/src/chatwidget/tests.rs | 192 ++++++++++++++++++ 5 files changed, 237 insertions(+), 7 deletions(-) create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__permissions_selection_history_after_mode_switch.snap create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__permissions_selection_history_full_access_to_default.snap create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__permissions_selection_history_full_access_to_default@windows.snap diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 59873cbfd2..f72255b22b 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -5423,15 +5423,23 @@ impl ChatWidget { }); })] } else { - Self::approval_preset_actions(preset.approval, preset.sandbox.clone()) + Self::approval_preset_actions( + preset.approval, + preset.sandbox.clone(), + name.clone(), + ) } } #[cfg(not(target_os = "windows"))] { - Self::approval_preset_actions(preset.approval, preset.sandbox.clone()) + Self::approval_preset_actions( + preset.approval, + preset.sandbox.clone(), + name.clone(), + ) } } else { - Self::approval_preset_actions(preset.approval, preset.sandbox.clone()) + Self::approval_preset_actions(preset.approval, preset.sandbox.clone(), name.clone()) }; items.push(SelectionItem { name, @@ -5485,6 +5493,7 @@ impl ChatWidget { fn approval_preset_actions( approval: AskForApproval, sandbox: SandboxPolicy, + label: String, ) -> Vec { vec![Box::new(move |tx| { let sandbox_clone = sandbox.clone(); @@ -5501,6 +5510,9 @@ impl ChatWidget { })); tx.send(AppEvent::UpdateAskForApprovalPolicy(approval)); tx.send(AppEvent::UpdateSandboxPolicy(sandbox_clone)); + tx.send(AppEvent::InsertHistoryCell(Box::new( + history_cell::new_info_event(format!("Permissions updated to {label}"), None), + ))); })] } @@ -5547,6 +5559,7 @@ impl ChatWidget { preset: ApprovalPreset, return_to_permissions: bool, ) { + let selected_name = preset.label.to_string(); let approval = preset.approval; let sandbox = preset.sandbox; let mut header_children: Vec> = Vec::new(); @@ -5563,12 +5576,14 @@ impl ChatWidget { )); let header = ColumnRenderable::with(header_children); - let mut accept_actions = Self::approval_preset_actions(approval, sandbox.clone()); + let mut accept_actions = + Self::approval_preset_actions(approval, sandbox.clone(), selected_name.clone()); accept_actions.push(Box::new(|tx| { tx.send(AppEvent::UpdateFullAccessWarningAcknowledged(true)); })); - let mut accept_and_remember_actions = Self::approval_preset_actions(approval, sandbox); + let mut accept_and_remember_actions = + Self::approval_preset_actions(approval, sandbox, selected_name); accept_and_remember_actions.push(Box::new(|tx| { tx.send(AppEvent::UpdateFullAccessWarningAcknowledged(true)); tx.send(AppEvent::PersistFullAccessWarningAcknowledged); @@ -5678,7 +5693,11 @@ impl ChatWidget { })); } if let (Some(approval), Some(sandbox)) = (approval, sandbox.clone()) { - accept_actions.extend(Self::approval_preset_actions(approval, sandbox)); + accept_actions.extend(Self::approval_preset_actions( + approval, + sandbox, + mode_label.to_string(), + )); } let mut accept_and_remember_actions: Vec = Vec::new(); @@ -5687,7 +5706,11 @@ impl ChatWidget { tx.send(AppEvent::PersistWorldWritableWarningAcknowledged); })); if let (Some(approval), Some(sandbox)) = (approval, sandbox) { - accept_and_remember_actions.extend(Self::approval_preset_actions(approval, sandbox)); + accept_and_remember_actions.extend(Self::approval_preset_actions( + approval, + sandbox, + mode_label.to_string(), + )); } let items = vec![ diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__permissions_selection_history_after_mode_switch.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__permissions_selection_history_after_mode_switch.snap new file mode 100644 index 0000000000..9cc7afa1f5 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__permissions_selection_history_after_mode_switch.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: "lines_to_single_string(&cells[0])" +--- +• Permissions updated to Full Access diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__permissions_selection_history_full_access_to_default.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__permissions_selection_history_full_access_to_default.snap new file mode 100644 index 0000000000..135e5b1bfa --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__permissions_selection_history_full_access_to_default.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: "lines_to_single_string(&cells[0])" +--- +• Permissions updated to Default diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__permissions_selection_history_full_access_to_default@windows.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__permissions_selection_history_full_access_to_default@windows.snap new file mode 100644 index 0000000000..eb08108564 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__permissions_selection_history_full_access_to_default@windows.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: "lines_to_single_string(&cells[0])" +--- +• Permissions updated to Default (non-admin sandbox) diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 6881459b9a..f069db9160 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -20,6 +20,8 @@ use codex_core::config::Config; use codex_core::config::ConfigBuilder; use codex_core::config::Constrained; use codex_core::config::ConstraintError; +#[cfg(target_os = "windows")] +use codex_core::config::types::WindowsSandboxModeToml; use codex_core::config_loader::RequirementSource; use codex_core::features::Feature; use codex_core::models_manager::manager::ModelsManager; @@ -5430,6 +5432,196 @@ async fn approvals_popup_navigation_skips_disabled() { ); } +#[tokio::test] +async fn permissions_selection_emits_history_cell_when_selection_changes() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + #[cfg(target_os = "windows")] + { + chat.config.notices.hide_world_writable_warning = Some(true); + chat.set_windows_sandbox_mode(Some(WindowsSandboxModeToml::Unelevated)); + } + chat.config.notices.hide_full_access_warning = Some(true); + + chat.open_permissions_popup(); + chat.handle_key_event(KeyEvent::from(KeyCode::Down)); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + + let cells = drain_insert_history(&mut rx); + assert_eq!( + cells.len(), + 1, + "expected one permissions selection history cell" + ); + let rendered = lines_to_single_string(&cells[0]); + assert!( + rendered.contains("Permissions updated to"), + "expected permissions selection history message, got: {rendered}" + ); +} + +#[tokio::test] +async fn permissions_selection_history_snapshot_after_mode_switch() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + #[cfg(target_os = "windows")] + { + chat.config.notices.hide_world_writable_warning = Some(true); + chat.set_windows_sandbox_mode(Some(WindowsSandboxModeToml::Unelevated)); + } + chat.config.notices.hide_full_access_warning = Some(true); + + chat.open_permissions_popup(); + chat.handle_key_event(KeyEvent::from(KeyCode::Down)); + #[cfg(target_os = "windows")] + chat.handle_key_event(KeyEvent::from(KeyCode::Down)); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + + let cells = drain_insert_history(&mut rx); + assert_eq!(cells.len(), 1, "expected one mode-switch history cell"); + assert_snapshot!( + "permissions_selection_history_after_mode_switch", + lines_to_single_string(&cells[0]) + ); +} + +#[tokio::test] +async fn permissions_selection_history_snapshot_full_access_to_default() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + #[cfg(target_os = "windows")] + { + chat.config.notices.hide_world_writable_warning = Some(true); + chat.set_windows_sandbox_mode(Some(WindowsSandboxModeToml::Unelevated)); + } + chat.config.notices.hide_full_access_warning = Some(true); + chat.config + .permissions + .approval_policy + .set(AskForApproval::Never) + .expect("set approval policy"); + chat.config + .permissions + .sandbox_policy + .set(SandboxPolicy::DangerFullAccess) + .expect("set sandbox policy"); + + chat.open_permissions_popup(); + chat.handle_key_event(KeyEvent::from(KeyCode::Up)); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + + let cells = drain_insert_history(&mut rx); + assert_eq!(cells.len(), 1, "expected one mode-switch history cell"); + let rendered = lines_to_single_string(&cells[0]); + #[cfg(target_os = "windows")] + insta::with_settings!({ snapshot_suffix => "windows" }, { + assert_snapshot!("permissions_selection_history_full_access_to_default", rendered); + }); + #[cfg(not(target_os = "windows"))] + assert_snapshot!( + "permissions_selection_history_full_access_to_default", + rendered + ); +} + +#[tokio::test] +async fn permissions_selection_emits_history_cell_when_current_is_selected() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + #[cfg(target_os = "windows")] + { + chat.config.notices.hide_world_writable_warning = Some(true); + chat.set_windows_sandbox_mode(Some(WindowsSandboxModeToml::Unelevated)); + } + chat.config + .permissions + .approval_policy + .set(AskForApproval::OnRequest) + .expect("set approval policy"); + chat.config + .permissions + .sandbox_policy + .set(SandboxPolicy::new_workspace_write_policy()) + .expect("set sandbox policy"); + + chat.open_permissions_popup(); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + + let cells = drain_insert_history(&mut rx); + assert_eq!( + cells.len(), + 1, + "expected history cell even when selecting current permissions" + ); + let rendered = lines_to_single_string(&cells[0]); + assert!( + rendered.contains("Permissions updated to"), + "expected permissions update history message, got: {rendered}" + ); +} + +#[tokio::test] +async fn permissions_full_access_history_cell_emitted_only_after_confirmation() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + #[cfg(target_os = "windows")] + { + chat.config.notices.hide_world_writable_warning = Some(true); + chat.set_windows_sandbox_mode(Some(WindowsSandboxModeToml::Unelevated)); + } + chat.config.notices.hide_full_access_warning = None; + + chat.open_permissions_popup(); + chat.handle_key_event(KeyEvent::from(KeyCode::Down)); + #[cfg(target_os = "windows")] + chat.handle_key_event(KeyEvent::from(KeyCode::Down)); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + + let mut open_confirmation_event = None; + let mut cells_before_confirmation = Vec::new(); + while let Ok(event) = rx.try_recv() { + match event { + AppEvent::InsertHistoryCell(cell) => { + cells_before_confirmation.push(cell.display_lines(80)); + } + AppEvent::OpenFullAccessConfirmation { + preset, + return_to_permissions, + } => { + open_confirmation_event = Some((preset, return_to_permissions)); + } + _ => {} + } + } + if cfg!(not(target_os = "windows")) { + assert!( + cells_before_confirmation.is_empty(), + "did not expect history cell before confirming full access" + ); + } + let (preset, return_to_permissions) = + open_confirmation_event.expect("expected full access confirmation event"); + chat.open_full_access_confirmation(preset, return_to_permissions); + + let popup = render_bottom_popup(&chat, 80); + assert!( + popup.contains("Enable full access?"), + "expected full access confirmation popup, got: {popup}" + ); + + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + let cells_after_confirmation = drain_insert_history(&mut rx); + let total_history_cells = cells_before_confirmation.len() + cells_after_confirmation.len(); + assert_eq!( + total_history_cells, 1, + "expected one full access history cell total" + ); + let rendered = if !cells_before_confirmation.is_empty() { + lines_to_single_string(&cells_before_confirmation[0]) + } else { + lines_to_single_string(&cells_after_confirmation[0]) + }; + assert!( + rendered.contains("Permissions updated to Full Access"), + "expected full access update history message, got: {rendered}" + ); +} + // // Snapshot test: command approval modal // From db6aa801953fbb924cb1cf28ea93c33dd15cfa0e Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Sat, 14 Feb 2026 11:00:01 -0800 Subject: [PATCH 16/62] fix(core): add linux bubblewrap sandbox tag (#11767) ## Summary - add a distinct `linux_bubblewrap` sandbox tag when the Linux bubblewrap pipeline feature is enabled - thread the bubblewrap feature flag into sandbox tag generation for: - turn metadata header emission - tool telemetry metric tags and after-tool-use hooks - add focused unit tests for `sandbox_tag` precedence and Linux bubblewrap behavior ## Validation - `just fmt` - `cargo clippy -p codex-core --all-targets` - `cargo test -p codex-core sandbox_tags::tests` - started `cargo test -p codex-core` and stopped it per request Co-authored-by: Codex <199175422+chatgpt-codex-connector[bot]@users.noreply.github.com> --- codex-rs/core/src/codex.rs | 6 +++ codex-rs/core/src/sandbox_tags.rs | 54 +++++++++++++++++++++++++++ codex-rs/core/src/tools/registry.rs | 13 ++++++- codex-rs/core/src/turn_metadata.rs | 58 ++++++++++++++++++++++++++++- 4 files changed, 128 insertions(+), 3 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 753337eb60..0024a678a6 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -945,6 +945,9 @@ impl Session { cwd.clone(), session_configuration.sandbox_policy.get(), session_configuration.windows_sandbox_level, + per_turn_config + .features + .enabled(Feature::UseLinuxSandboxBwrap), )); TurnContext { sub_id, @@ -4072,6 +4075,9 @@ async fn spawn_review_thread( parent_turn_context.cwd.clone(), &parent_turn_context.sandbox_policy, parent_turn_context.windows_sandbox_level, + parent_turn_context + .features + .enabled(Feature::UseLinuxSandboxBwrap), )); let review_turn_context = TurnContext { diff --git a/codex-rs/core/src/sandbox_tags.rs b/codex-rs/core/src/sandbox_tags.rs index d81647e711..56c9e0f184 100644 --- a/codex-rs/core/src/sandbox_tags.rs +++ b/codex-rs/core/src/sandbox_tags.rs @@ -6,6 +6,7 @@ use codex_protocol::config_types::WindowsSandboxLevel; pub(crate) fn sandbox_tag( policy: &SandboxPolicy, windows_sandbox_level: WindowsSandboxLevel, + use_linux_sandbox_bwrap: bool, ) -> &'static str { if matches!(policy, SandboxPolicy::DangerFullAccess) { return "none"; @@ -17,8 +18,61 @@ pub(crate) fn sandbox_tag( { return "windows_elevated"; } + if cfg!(target_os = "linux") && use_linux_sandbox_bwrap { + return "linux_bubblewrap"; + } get_platform_sandbox(windows_sandbox_level != WindowsSandboxLevel::Disabled) .map(SandboxType::as_metric_tag) .unwrap_or("none") } + +#[cfg(test)] +mod tests { + use super::sandbox_tag; + use crate::exec::SandboxType; + use crate::protocol::SandboxPolicy; + use crate::safety::get_platform_sandbox; + use codex_protocol::config_types::WindowsSandboxLevel; + use codex_protocol::protocol::NetworkAccess; + use pretty_assertions::assert_eq; + + #[test] + fn danger_full_access_is_untagged_even_when_bubblewrap_is_enabled() { + let actual = sandbox_tag( + &SandboxPolicy::DangerFullAccess, + WindowsSandboxLevel::Disabled, + true, + ); + assert_eq!(actual, "none"); + } + + #[test] + fn external_sandbox_keeps_external_tag_when_bubblewrap_is_enabled() { + let actual = sandbox_tag( + &SandboxPolicy::ExternalSandbox { + network_access: NetworkAccess::Enabled, + }, + WindowsSandboxLevel::Disabled, + true, + ); + assert_eq!(actual, "external"); + } + + #[test] + fn bubblewrap_feature_sets_distinct_linux_tag() { + let actual = sandbox_tag( + &SandboxPolicy::new_read_only_policy(), + WindowsSandboxLevel::Disabled, + true, + ); + let expected = if cfg!(target_os = "linux") { + "linux_bubblewrap" + } else { + get_platform_sandbox(false) + .map(SandboxType::as_metric_tag) + .unwrap_or("none") + }; + assert_eq!(actual, expected); + } +} diff --git a/codex-rs/core/src/tools/registry.rs b/codex-rs/core/src/tools/registry.rs index 8ab702c0c2..ffde584b19 100644 --- a/codex-rs/core/src/tools/registry.rs +++ b/codex-rs/core/src/tools/registry.rs @@ -4,6 +4,7 @@ use std::time::Duration; use std::time::Instant; use crate::client_common::tools::ToolSpec; +use crate::features::Feature; use crate::function_tool::FunctionCallError; use crate::protocol::SandboxPolicy; use crate::sandbox_tags::sandbox_tag; @@ -88,6 +89,10 @@ impl ToolRegistry { sandbox_tag( &invocation.turn.sandbox_policy, invocation.turn.windows_sandbox_level, + invocation + .turn + .features + .enabled(Feature::UseLinuxSandboxBwrap), ), ), ( @@ -356,8 +361,12 @@ async fn dispatch_after_tool_use_hook(dispatch: AfterToolUseHookDispatch<'_>) { success: dispatch.success, duration_ms: u64::try_from(dispatch.duration.as_millis()).unwrap_or(u64::MAX), mutating: dispatch.mutating, - sandbox: sandbox_tag(&turn.sandbox_policy, turn.windows_sandbox_level) - .to_string(), + sandbox: sandbox_tag( + &turn.sandbox_policy, + turn.windows_sandbox_level, + turn.features.enabled(Feature::UseLinuxSandboxBwrap), + ) + .to_string(), sandbox_policy: sandbox_policy_tag(&turn.sandbox_policy).to_string(), output_preview: dispatch.output_preview.clone(), }, diff --git a/codex-rs/core/src/turn_metadata.rs b/codex-rs/core/src/turn_metadata.rs index 249fdeb60e..0814897d91 100644 --- a/codex-rs/core/src/turn_metadata.rs +++ b/codex-rs/core/src/turn_metadata.rs @@ -132,9 +132,17 @@ impl TurnMetadataState { cwd: PathBuf, sandbox_policy: &SandboxPolicy, windows_sandbox_level: WindowsSandboxLevel, + use_linux_sandbox_bwrap: bool, ) -> Self { let repo_root = get_git_repo_root(&cwd).map(|root| root.to_string_lossy().into_owned()); - let sandbox = Some(sandbox_tag(sandbox_policy, windows_sandbox_level).to_string()); + let sandbox = Some( + sandbox_tag( + sandbox_policy, + windows_sandbox_level, + use_linux_sandbox_bwrap, + ) + .to_string(), + ); let base_metadata = build_turn_metadata_bag(Some(turn_id), sandbox, None, None); let base_header = base_metadata .to_header_value() @@ -290,4 +298,52 @@ mod tests { Some(false) ); } + + #[test] + fn turn_metadata_state_respects_linux_bubblewrap_toggle() { + let temp_dir = TempDir::new().expect("temp dir"); + let cwd = temp_dir.path().to_path_buf(); + let sandbox_policy = SandboxPolicy::new_read_only_policy(); + + let without_bubblewrap = TurnMetadataState::new( + "turn-a".to_string(), + cwd.clone(), + &sandbox_policy, + WindowsSandboxLevel::Disabled, + false, + ); + let with_bubblewrap = TurnMetadataState::new( + "turn-b".to_string(), + cwd, + &sandbox_policy, + WindowsSandboxLevel::Disabled, + true, + ); + + let without_bubblewrap_header = without_bubblewrap + .current_header_value() + .expect("without_bubblewrap_header"); + let with_bubblewrap_header = with_bubblewrap + .current_header_value() + .expect("with_bubblewrap_header"); + + let without_bubblewrap_json: Value = + serde_json::from_str(&without_bubblewrap_header).expect("without_bubblewrap_json"); + let with_bubblewrap_json: Value = + serde_json::from_str(&with_bubblewrap_header).expect("with_bubblewrap_json"); + + let without_bubblewrap_sandbox = without_bubblewrap_json + .get("sandbox") + .and_then(Value::as_str); + let with_bubblewrap_sandbox = with_bubblewrap_json.get("sandbox").and_then(Value::as_str); + + let expected_with_bubblewrap = + sandbox_tag(&sandbox_policy, WindowsSandboxLevel::Disabled, true); + assert_eq!(with_bubblewrap_sandbox, Some(expected_with_bubblewrap)); + + if cfg!(target_os = "linux") { + assert_eq!(with_bubblewrap_sandbox, Some("linux_bubblewrap")); + assert_ne!(with_bubblewrap_sandbox, without_bubblewrap_sandbox); + } + } } From fce4ad9cf42592f825e3bc8eca40db5b069a6ccf Mon Sep 17 00:00:00 2001 From: Charley Cunningham Date: Sat, 14 Feb 2026 17:27:22 -0800 Subject: [PATCH 17/62] Add process_uuid to sqlite logs (#11534) ## Summary This PR is the first slice of the per-session `/feedback` logging work: it adds a process-unique identifier to SQLite log rows. It does **not** change `/feedback` sourcing behavior yet. ## Changes - Add migration `0009_logs_process_id.sql` to extend `logs` with: - `process_uuid TEXT` - `idx_logs_process_uuid` index - Extend state log models: - `LogEntry.process_uuid: Option` - `LogRow.process_uuid: Option` - Stamp each log row with a stable per-process UUID in the sqlite log layer: - generated once per process as `pid::` - Update sqlite log insert/query paths to persist and read `process_uuid`: - `INSERT INTO logs (..., process_uuid, ...)` - `SELECT ..., process_uuid, ... FROM logs` ## Why App-server runs many sessions in one process. This change provides a process-scoping primitive we need for follow-up `/feedback` work, so threadless/process-level logs can be associated with the emitting process without mixing across processes. ## Non-goals in this PR - No `/feedback` transport/source changes - No attachment size changes - No sqlite retention/trim policy changes ## Testing - `just fmt` - CI will run the full checks --- .../state/migrations/0010_logs_process_id.sql | 3 +++ codex-rs/state/src/log_db.rs | 19 ++++++++++++++++++- codex-rs/state/src/model/log.rs | 2 ++ codex-rs/state/src/runtime.rs | 5 +++-- 4 files changed, 26 insertions(+), 3 deletions(-) create mode 100644 codex-rs/state/migrations/0010_logs_process_id.sql diff --git a/codex-rs/state/migrations/0010_logs_process_id.sql b/codex-rs/state/migrations/0010_logs_process_id.sql new file mode 100644 index 0000000000..fdefcd53a8 --- /dev/null +++ b/codex-rs/state/migrations/0010_logs_process_id.sql @@ -0,0 +1,3 @@ +ALTER TABLE logs ADD COLUMN process_uuid TEXT; + +CREATE INDEX idx_logs_process_uuid ON logs(process_uuid); diff --git a/codex-rs/state/src/log_db.rs b/codex-rs/state/src/log_db.rs index 345e90c6a8..64deeef2ad 100644 --- a/codex-rs/state/src/log_db.rs +++ b/codex-rs/state/src/log_db.rs @@ -20,6 +20,7 @@ use chrono::Duration as ChronoDuration; use chrono::Utc; +use std::sync::OnceLock; use std::time::Duration; use std::time::SystemTime; use std::time::UNIX_EPOCH; @@ -33,6 +34,7 @@ use tracing::span::Id; use tracing::span::Record; use tracing_subscriber::Layer; use tracing_subscriber::registry::LookupSpan; +use uuid::Uuid; use crate::LogEntry; use crate::StateRuntime; @@ -44,14 +46,19 @@ const LOG_RETENTION_DAYS: i64 = 90; pub struct LogDbLayer { sender: mpsc::Sender, + process_uuid: String, } pub fn start(state_db: std::sync::Arc) -> LogDbLayer { + let process_uuid = current_process_log_uuid().to_string(); let (sender, receiver) = mpsc::channel(LOG_QUEUE_CAPACITY); tokio::spawn(run_inserter(std::sync::Arc::clone(&state_db), receiver)); tokio::spawn(run_retention_cleanup(state_db)); - LogDbLayer { sender } + LogDbLayer { + sender, + process_uuid, + } } impl Layer for LogDbLayer @@ -118,6 +125,7 @@ where target: metadata.target().to_string(), message: visitor.message, thread_id, + process_uuid: Some(self.process_uuid.clone()), module_path: metadata.module_path().map(ToString::to_string), file: metadata.file().map(ToString::to_string), line: metadata.line().map(|line| line as i64), @@ -196,6 +204,15 @@ where thread_id } +fn current_process_log_uuid() -> &'static str { + static PROCESS_LOG_UUID: OnceLock = OnceLock::new(); + PROCESS_LOG_UUID.get_or_init(|| { + let pid = std::process::id(); + let process_uuid = Uuid::new_v4(); + format!("pid:{pid}:{process_uuid}") + }) +} + async fn run_inserter( state_db: std::sync::Arc, mut receiver: mpsc::Receiver, diff --git a/codex-rs/state/src/model/log.rs b/codex-rs/state/src/model/log.rs index 819abb5d22..2aeaf2d5c8 100644 --- a/codex-rs/state/src/model/log.rs +++ b/codex-rs/state/src/model/log.rs @@ -9,6 +9,7 @@ pub struct LogEntry { pub target: String, pub message: Option, pub thread_id: Option, + pub process_uuid: Option, pub module_path: Option, pub file: Option, pub line: Option, @@ -23,6 +24,7 @@ pub struct LogRow { pub target: String, pub message: Option, pub thread_id: Option, + pub process_uuid: Option, pub file: Option, pub line: Option, } diff --git a/codex-rs/state/src/runtime.rs b/codex-rs/state/src/runtime.rs index 4cc9c63b17..3a743a9c70 100644 --- a/codex-rs/state/src/runtime.rs +++ b/codex-rs/state/src/runtime.rs @@ -360,7 +360,7 @@ FROM threads } let mut builder = QueryBuilder::::new( - "INSERT INTO logs (ts, ts_nanos, level, target, message, thread_id, module_path, file, line) ", + "INSERT INTO logs (ts, ts_nanos, level, target, message, thread_id, process_uuid, module_path, file, line) ", ); builder.push_values(entries, |mut row, entry| { row.push_bind(entry.ts) @@ -369,6 +369,7 @@ FROM threads .push_bind(&entry.target) .push_bind(&entry.message) .push_bind(&entry.thread_id) + .push_bind(&entry.process_uuid) .push_bind(&entry.module_path) .push_bind(&entry.file) .push_bind(entry.line); @@ -388,7 +389,7 @@ FROM threads /// Query logs with optional filters. pub async fn query_logs(&self, query: &LogQuery) -> anyhow::Result> { let mut builder = QueryBuilder::::new( - "SELECT id, ts, ts_nanos, level, target, message, thread_id, file, line FROM logs WHERE 1 = 1", + "SELECT id, ts, ts_nanos, level, target, message, thread_id, process_uuid, file, line FROM logs WHERE 1 = 1", ); push_log_filters(&mut builder, query); if query.descending { From 85034b189e6cb6f90489b65150a903117b194a94 Mon Sep 17 00:00:00 2001 From: Charley Cunningham Date: Sat, 14 Feb 2026 19:57:10 -0800 Subject: [PATCH 18/62] core: snapshot tests for compaction requests, post-compaction layout, some additional compaction tests (#11487) This PR keeps compaction context-layout test coverage separate from runtime compaction behavior changes, so runtime logic review can stay focused. ## Included - Adds reusable context snapshot helpers in `core/tests/common/context_snapshot.rs` for rendering model-visible request/history shapes. - Standardizes helper naming for readability: - `format_request_input_snapshot` - `format_response_items_snapshot` - `format_labeled_requests_snapshot` - `format_labeled_items_snapshot` - Expands snapshot coverage for both local and remote compaction flows: - pre-turn auto-compaction - pre-turn failure/context-window-exceeded paths - mid-turn continuation compaction - manual `/compact` with and without prior user turns - Captures both sides where relevant: - compaction request shape - post-compaction history layout shape - Adds/uses shared request-inspection helpers so assertions target structured request content instead of ad-hoc JSON string parsing. - Aligns snapshots/assertions to current behavior and leaves explicit `TODO(ccunningham)` notes where behavior is known and intentionally deferred. ## Not Included - No runtime compaction logic changes. - No model-visible context/state behavior changes. --- codex-rs/Cargo.lock | 1 + codex-rs/core/Cargo.toml | 1 + .../core/tests/common/context_snapshot.rs | 339 ++++++++++ codex-rs/core/tests/common/lib.rs | 1 + codex-rs/core/tests/common/responses.rs | 59 +- codex-rs/core/tests/suite/compact.rs | 608 ++++++++++++++---- codex-rs/core/tests/suite/compact_remote.rs | 548 ++++++++++++++-- codex-rs/core/tests/suite/model_switching.rs | 23 +- ...t__manual_compact_with_history_shapes.snap | 21 + ...nual_compact_without_prev_user_shapes.snap | 18 + ...__compact__mid_turn_compaction_shapes.snap | 22 + ...mpling_model_switch_compaction_shapes.snap | 31 + ...action_context_window_exceeded_shapes.snap | 13 + ..._compaction_including_incoming_shapes.snap | 25 + ...te_manual_compact_with_history_shapes.snap | 21 + ...nual_compact_without_prev_user_shapes.snap | 16 + ...te__remote_mid_turn_compaction_shapes.snap | 20 + ...action_context_window_exceeded_shapes.snap | 12 + ...te_pre_turn_compaction_failure_shapes.snap | 12 + ..._compaction_including_incoming_shapes.snap | 24 + 20 files changed, 1594 insertions(+), 221 deletions(-) create mode 100644 codex-rs/core/tests/common/context_snapshot.rs create mode 100644 codex-rs/core/tests/suite/snapshots/all__suite__compact__manual_compact_with_history_shapes.snap create mode 100644 codex-rs/core/tests/suite/snapshots/all__suite__compact__manual_compact_without_prev_user_shapes.snap create mode 100644 codex-rs/core/tests/suite/snapshots/all__suite__compact__mid_turn_compaction_shapes.snap create mode 100644 codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_sampling_model_switch_compaction_shapes.snap create mode 100644 codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_context_window_exceeded_shapes.snap create mode 100644 codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_including_incoming_shapes.snap create mode 100644 codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_with_history_shapes.snap create mode 100644 codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_without_prev_user_shapes.snap create mode 100644 codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_shapes.snap create mode 100644 codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_context_window_exceeded_shapes.snap create mode 100644 codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_failure_shapes.snap create mode 100644 codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_including_incoming_shapes.snap diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 224502c38a..3456f9d731 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1712,6 +1712,7 @@ dependencies = [ "include_dir", "indexmap 2.13.0", "indoc", + "insta", "keyring", "landlock", "libc", diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 1b3f62c5aa..208ba1c3a6 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -152,6 +152,7 @@ codex-utils-cargo-bin = { workspace = true } core_test_support = { workspace = true } ctor = { workspace = true } image = { workspace = true, features = ["jpeg", "png"] } +insta = { workspace = true } maplit = { workspace = true } predicates = { workspace = true } pretty_assertions = { workspace = true } diff --git a/codex-rs/core/tests/common/context_snapshot.rs b/codex-rs/core/tests/common/context_snapshot.rs new file mode 100644 index 0000000000..f9362ee30c --- /dev/null +++ b/codex-rs/core/tests/common/context_snapshot.rs @@ -0,0 +1,339 @@ +use serde_json::Value; + +use crate::responses::ResponsesRequest; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum ContextSnapshotRenderMode { + #[default] + RedactedText, + FullText, + KindOnly, + KindWithTextPrefix { + max_chars: usize, + }, +} + +#[derive(Debug, Clone)] +pub struct ContextSnapshotOptions { + render_mode: ContextSnapshotRenderMode, +} + +impl Default for ContextSnapshotOptions { + fn default() -> Self { + Self { + render_mode: ContextSnapshotRenderMode::RedactedText, + } + } +} + +impl ContextSnapshotOptions { + pub fn render_mode(mut self, render_mode: ContextSnapshotRenderMode) -> Self { + self.render_mode = render_mode; + self + } +} + +pub fn format_request_input_snapshot( + request: &ResponsesRequest, + options: &ContextSnapshotOptions, +) -> String { + let items = request.input(); + format_response_items_snapshot(items.as_slice(), options) +} + +pub fn format_response_items_snapshot(items: &[Value], options: &ContextSnapshotOptions) -> String { + items + .iter() + .enumerate() + .map(|(idx, item)| { + let Some(item_type) = item.get("type").and_then(Value::as_str) else { + return format!("{idx:02}:"); + }; + + if options.render_mode == ContextSnapshotRenderMode::KindOnly { + return if item_type == "message" { + let role = item.get("role").and_then(Value::as_str).unwrap_or("unknown"); + format!("{idx:02}:message/{role}") + } else { + format!("{idx:02}:{item_type}") + }; + } + + match item_type { + "message" => { + let role = item.get("role").and_then(Value::as_str).unwrap_or("unknown"); + let text = item + .get("content") + .and_then(Value::as_array) + .map(|content| { + content + .iter() + .map(|entry| { + if let Some(text) = entry.get("text").and_then(Value::as_str) { + return format_snapshot_text(text, options); + } + let Some(content_type) = + entry.get("type").and_then(Value::as_str) + else { + return "".to_string(); + }; + let Some(content_object) = entry.as_object() else { + return format!("<{content_type}>"); + }; + let mut extra_keys = content_object + .keys() + .filter(|key| *key != "type" && *key != "text") + .cloned() + .collect::>(); + extra_keys.sort(); + if extra_keys.is_empty() { + format!("<{content_type}>") + } else { + format!("<{content_type}:{}>", extra_keys.join(",")) + } + }) + .collect::>() + .join(" | ") + }) + .filter(|text| !text.is_empty()) + .unwrap_or_else(|| "".to_string()); + format!("{idx:02}:message/{role}:{text}") + } + "function_call" => { + let name = item.get("name").and_then(Value::as_str).unwrap_or("unknown"); + format!("{idx:02}:function_call/{name}") + } + "function_call_output" => { + let output = item + .get("output") + .and_then(Value::as_str) + .map(|output| format_snapshot_text(output, options)) + .unwrap_or_else(|| "".to_string()); + format!("{idx:02}:function_call_output:{output}") + } + "local_shell_call" => { + let command = item + .get("action") + .and_then(|action| action.get("command")) + .and_then(Value::as_array) + .map(|parts| { + parts + .iter() + .filter_map(Value::as_str) + .collect::>() + .join(" ") + }) + .map(|command| format_snapshot_text(&command, options)) + .filter(|cmd| !cmd.is_empty()) + .unwrap_or_else(|| "".to_string()); + format!("{idx:02}:local_shell_call:{command}") + } + "reasoning" => { + let summary_text = item + .get("summary") + .and_then(Value::as_array) + .and_then(|summary| summary.first()) + .and_then(|entry| entry.get("text")) + .and_then(Value::as_str) + .map(|text| format_snapshot_text(text, options)) + .unwrap_or_else(|| "".to_string()); + let has_encrypted_content = item + .get("encrypted_content") + .and_then(Value::as_str) + .is_some_and(|value| !value.is_empty()); + format!( + "{idx:02}:reasoning:summary={summary_text}:encrypted={has_encrypted_content}" + ) + } + "compaction" => { + let has_encrypted_content = item + .get("encrypted_content") + .and_then(Value::as_str) + .is_some_and(|value| !value.is_empty()); + format!("{idx:02}:compaction:encrypted={has_encrypted_content}") + } + other => format!("{idx:02}:{other}"), + } + }) + .collect::>() + .join("\n") +} + +pub fn format_labeled_requests_snapshot( + scenario: &str, + sections: &[(&str, &ResponsesRequest)], + options: &ContextSnapshotOptions, +) -> String { + let sections = sections + .iter() + .map(|(title, request)| { + format!( + "## {title}\n{}", + format_request_input_snapshot(request, options) + ) + }) + .collect::>() + .join("\n\n"); + format!("Scenario: {scenario}\n\n{sections}") +} + +pub fn format_labeled_items_snapshot( + scenario: &str, + sections: &[(&str, &[Value])], + options: &ContextSnapshotOptions, +) -> String { + let sections = sections + .iter() + .map(|(title, items)| { + format!( + "## {title}\n{}", + format_response_items_snapshot(items, options) + ) + }) + .collect::>() + .join("\n\n"); + format!("Scenario: {scenario}\n\n{sections}") +} + +fn format_snapshot_text(text: &str, options: &ContextSnapshotOptions) -> String { + match options.render_mode { + ContextSnapshotRenderMode::RedactedText => { + canonicalize_snapshot_text(text).replace('\n', "\\n") + } + ContextSnapshotRenderMode::FullText => text.replace('\n', "\\n"), + ContextSnapshotRenderMode::KindWithTextPrefix { max_chars } => { + let normalized = canonicalize_snapshot_text(text).replace('\n', "\\n"); + if normalized.chars().count() <= max_chars { + normalized + } else { + let prefix = normalized.chars().take(max_chars).collect::(); + format!("{prefix}...") + } + } + ContextSnapshotRenderMode::KindOnly => unreachable!(), + } +} + +fn canonicalize_snapshot_text(text: &str) -> String { + if text.starts_with("") { + return "".to_string(); + } + if text.starts_with("# AGENTS.md instructions for ") { + return "".to_string(); + } + if text.starts_with("") { + if let (Some(cwd_start), Some(cwd_end)) = (text.find(""), text.find("")) { + let cwd = &text[cwd_start + "".len()..cwd_end]; + return if cwd.ends_with("PRETURN_CONTEXT_DIFF_CWD") { + "".to_string() + } else { + ">".to_string() + }; + } + return "".to_string(); + } + if text.starts_with("You are performing a CONTEXT CHECKPOINT COMPACTION.") { + return "".to_string(); + } + if text.starts_with("Another language model started to solve this problem") + && let Some((_, summary)) = text.split_once('\n') + { + return format!("\n{summary}"); + } + text.to_string() +} + +#[cfg(test)] +mod tests { + use super::ContextSnapshotOptions; + use super::ContextSnapshotRenderMode; + use super::format_response_items_snapshot; + use pretty_assertions::assert_eq; + use serde_json::json; + + #[test] + fn full_text_mode_preserves_unredacted_text() { + let items = vec![json!({ + "type": "message", + "role": "user", + "content": [{ + "type": "input_text", + "text": "# AGENTS.md instructions for /tmp/example" + }] + })]; + + let rendered = format_response_items_snapshot( + &items, + &ContextSnapshotOptions::default().render_mode(ContextSnapshotRenderMode::FullText), + ); + + assert_eq!( + rendered, + "00:message/user:# AGENTS.md instructions for /tmp/example" + ); + } + + #[test] + fn redacted_text_mode_keeps_canonical_placeholders() { + let items = vec![json!({ + "type": "message", + "role": "user", + "content": [{ + "type": "input_text", + "text": "# AGENTS.md instructions for /tmp/example" + }] + })]; + + let rendered = format_response_items_snapshot( + &items, + &ContextSnapshotOptions::default().render_mode(ContextSnapshotRenderMode::RedactedText), + ); + + assert_eq!(rendered, "00:message/user:"); + } + + #[test] + fn image_only_message_is_rendered_as_non_text_span() { + let items = vec![json!({ + "type": "message", + "role": "user", + "content": [{ + "type": "input_image", + "image_url": "data:image/png;base64,AAAA" + }] + })]; + + let rendered = format_response_items_snapshot(&items, &ContextSnapshotOptions::default()); + + assert_eq!(rendered, "00:message/user:"); + } + + #[test] + fn mixed_text_and_image_message_keeps_image_span() { + let items = vec![json!({ + "type": "message", + "role": "user", + "content": [ + { + "type": "input_text", + "text": "" + }, + { + "type": "input_image", + "image_url": "data:image/png;base64,AAAA" + }, + { + "type": "input_text", + "text": "" + } + ] + })]; + + let rendered = format_response_items_snapshot(&items, &ContextSnapshotOptions::default()); + + assert_eq!( + rendered, + "00:message/user: | | " + ); + } +} diff --git a/codex-rs/core/tests/common/lib.rs b/codex-rs/core/tests/common/lib.rs index 87a8e1adb2..79bf9369e0 100644 --- a/codex-rs/core/tests/common/lib.rs +++ b/codex-rs/core/tests/common/lib.rs @@ -12,6 +12,7 @@ use codex_utils_absolute_path::AbsolutePathBuf; use regex_lite::Regex; use std::path::PathBuf; +pub mod context_snapshot; pub mod process; pub mod responses; pub mod streaming_sse; diff --git a/codex-rs/core/tests/common/responses.rs b/codex-rs/core/tests/common/responses.rs index 9faca23888..c434c89717 100644 --- a/codex-rs/core/tests/common/responses.rs +++ b/codex-rs/core/tests/common/responses.rs @@ -5,6 +5,8 @@ use std::time::Duration; use anyhow::Result; use base64::Engine; +use codex_protocol::models::ContentItem; +use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ModelsResponse; use futures::SinkExt; use futures::StreamExt; @@ -112,6 +114,14 @@ impl ResponsesRequest { self.0.body.clone() } + pub fn body_contains_text(&self, text: &str) -> bool { + let json_fragment = serde_json::to_string(text) + .expect("serialize text to JSON") + .trim_matches('"') + .to_string(); + self.body_json().to_string().contains(&json_fragment) + } + pub fn instructions_text(&self) -> String { self.body_json()["instructions"] .as_str() @@ -131,6 +141,22 @@ impl ResponsesRequest { .collect() } + /// Returns all `input_image` `image_url` spans from `message` inputs for the provided role. + pub fn message_input_image_urls(&self, role: &str) -> Vec { + self.inputs_of_type("message") + .into_iter() + .filter(|item| item.get("role").and_then(Value::as_str) == Some(role)) + .filter_map(|item| item.get("content").and_then(Value::as_array).cloned()) + .flatten() + .filter(|span| span.get("type").and_then(Value::as_str) == Some("input_image")) + .filter_map(|span| { + span.get("image_url") + .and_then(Value::as_str) + .map(str::to_owned) + }) + .collect() + } + pub fn input(&self) -> Vec { self.body_json()["input"] .as_array() @@ -480,6 +506,18 @@ pub fn ev_assistant_message(id: &str, text: &str) -> Value { }) } +pub fn user_message_item(text: &str) -> ResponseItem { + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: text.to_string(), + }], + end_turn: None, + phase: None, + } +} + pub fn ev_message_item_added(id: &str, text: &str) -> Value { serde_json::json!({ "type": "response.output_item.added", @@ -818,15 +856,24 @@ where } pub async fn mount_compact_json_once(server: &MockServer, body: serde_json::Value) -> ResponseMock { - let (mock, response_mock) = compact_mock(); - mock.respond_with( + mount_compact_response_once( + server, ResponseTemplate::new(200) .insert_header("content-type", "application/json") - .set_body_json(body.clone()), + .set_body_json(body), ) - .up_to_n_times(1) - .mount(server) - .await; + .await +} + +pub async fn mount_compact_response_once( + server: &MockServer, + response: ResponseTemplate, +) -> ResponseMock { + let (mock, response_mock) = compact_mock(); + mock.respond_with(response) + .up_to_n_times(1) + .mount(server) + .await; response_mock } diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index 888d5946c0..25278073ec 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -20,6 +20,9 @@ use codex_protocol::items::TurnItem; use codex_protocol::openai_models::ModelInfo; use codex_protocol::openai_models::ModelsResponse; 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_local_shell_call; use core_test_support::responses::ev_reasoning_item; use core_test_support::responses::mount_models_once; @@ -27,7 +30,7 @@ use core_test_support::skip_if_no_network; use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; use core_test_support::wait_for_event_match; -use std::collections::VecDeque; +use std::path::PathBuf; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; @@ -60,10 +63,11 @@ const SECOND_AUTO_SUMMARY: &str = "SECOND_AUTO_SUMMARY"; const FINAL_REPLY: &str = "FINAL_REPLY"; const CONTEXT_LIMIT_MESSAGE: &str = "Your input exceeds the context window of this model. Please adjust your input and try again."; -const DUMMY_FUNCTION_NAME: &str = "unsupported_tool"; +const DUMMY_FUNCTION_NAME: &str = "test_tool"; const DUMMY_CALL_ID: &str = "call-multi-auto"; const FUNCTION_CALL_LIMIT_MSG: &str = "function call limit push"; const POST_AUTO_USER_MSG: &str = "post auto follow-up"; +const PRETURN_CONTEXT_DIFF_CWD: &str = "/tmp/PRETURN_CONTEXT_DIFF_CWD"; pub(super) const COMPACT_WARNING_MESSAGE: &str = "Heads up: Long threads and multiple compactions can cause the model to be less accurate. Start a new thread when possible to keep threads small and targeted."; @@ -75,23 +79,6 @@ fn summary_with_prefix(summary: &str) -> String { format!("{SUMMARY_PREFIX}\n{summary}") } -fn drop_call_id(value: &mut serde_json::Value) { - match value { - serde_json::Value::Object(obj) => { - obj.retain(|k, _| k != "call_id"); - for v in obj.values_mut() { - drop_call_id(v); - } - } - serde_json::Value::Array(arr) => { - for v in arr { - drop_call_id(v); - } - } - _ => {} - } -} - fn set_test_compact_prompt(config: &mut Config) { config.compact_prompt = Some(SUMMARIZATION_PROMPT.to_string()); } @@ -194,6 +181,21 @@ async fn assert_compaction_uses_turn_lifecycle_id(codex: &std::sync::Arc ContextSnapshotOptions { + ContextSnapshotOptions::default() + .render_mode(ContextSnapshotRenderMode::KindWithTextPrefix { max_chars: 64 }) +} + +fn format_labeled_requests_snapshot( + scenario: &str, + sections: &[(&str, &core_test_support::responses::ResponsesRequest)], +) -> String { + context_snapshot::format_labeled_requests_snapshot( + scenario, + sections, + &context_snapshot_options(), + ) +} #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn summarize_context_three_requests_and_instructions() { @@ -408,8 +410,15 @@ async fn manual_compact_uses_custom_prompt() { skip_if_no_network!(); let server = start_mock_server().await; - let sse_stream = sse(vec![ev_completed("r1")]); - let response_mock = mount_sse_once(&server, sse_stream).await; + let first_turn = sse(vec![ + ev_assistant_message("m0", FIRST_REPLY), + ev_completed_with_tokens("r0", 80), + ]); + let compact_turn = sse(vec![ + ev_assistant_message("m1", SUMMARY_TEXT), + ev_completed_with_tokens("r1", 100), + ]); + let request_log = mount_sse_sequence(&server, vec![first_turn, compact_turn]).await; let custom_prompt = "Use this compact prompt instead"; @@ -424,6 +433,18 @@ async fn manual_compact_uses_custom_prompt() { .expect("create conversation") .codex; + codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "USER_ONE".to_string(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }) + .await + .expect("submit first user turn"); + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + codex.submit(Op::Compact).await.expect("trigger compact"); let warning_event = wait_for_event(&codex, |ev| matches!(ev, EventMsg::Warning(_))).await; let EventMsg::Warning(WarningEvent { message }) = warning_event else { @@ -432,7 +453,13 @@ async fn manual_compact_uses_custom_prompt() { assert_eq!(message, COMPACT_WARNING_MESSAGE); wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; - let body = response_mock.single_request().body_json(); + let requests = request_log.requests(); + assert_eq!( + requests.len(), + 2, + "expected first turn and compact requests" + ); + let body = requests[1].body_json(); let input = body .get("input") @@ -1742,6 +1769,21 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { previous_model, next_model, ); + + insta::assert_snapshot!( + "pre_sampling_model_switch_compaction_shapes", + format_labeled_requests_snapshot( + "Pre-sampling compaction on model switch to a smaller context window: current behavior compacts using prior-turn history only (incoming user message excluded), and the follow-up request carries compacted history plus the new user message.", + &[ + ("Initial Request (Previous Model)", &requests[0]), + ("Pre-sampling Compaction Request", &requests[1]), + ( + "Post-Compaction Follow-up Request (Next Model)", + &requests[2] + ), + ] + ) + ); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -2133,6 +2175,82 @@ async fn manual_compact_retries_after_context_window_error() { } } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +// TODO(ccunningham): Re-enable after the follow-up compaction behavior PR lands. +// Current main behavior around non-context manual /compact failures is known-incorrect. +#[ignore = "behavior change covered in follow-up compaction PR"] +async fn manual_compact_non_context_failure_retries_then_emits_task_error() { + skip_if_no_network!(); + + let server = start_mock_server().await; + + let user_turn = sse(vec![ + ev_assistant_message("m1", FIRST_REPLY), + ev_completed("r1"), + ]); + let compact_failed_1 = sse_failed( + "resp-fail-1", + "server_error", + "temporary compact failure one", + ); + let compact_failed_2 = sse_failed( + "resp-fail-2", + "server_error", + "temporary compact failure two", + ); + + mount_sse_sequence(&server, vec![user_turn, compact_failed_1, compact_failed_2]).await; + + let mut model_provider = non_openai_model_provider(&server); + model_provider.stream_max_retries = Some(1); + + let codex = test_codex() + .with_config(move |config| { + config.model_provider = model_provider; + set_test_compact_prompt(config); + config.model_auto_compact_token_limit = Some(200_000); + }) + .build(&server) + .await + .expect("build codex") + .codex; + + codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "first turn".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }) + .await + .expect("submit user input"); + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + codex.submit(Op::Compact).await.expect("trigger compact"); + + let reconnect_message = wait_for_event_match(&codex, |event| match event { + EventMsg::StreamError(stream_error) => Some(stream_error.message.clone()), + _ => None, + }) + .await; + assert!( + reconnect_message.contains("Reconnecting... 1/1"), + "expected reconnect stream error message, got {reconnect_message}" + ); + + let task_error_message = wait_for_event_match(&codex, |event| match event { + EventMsg::Error(err) => Some(err.message.clone()), + _ => None, + }) + .await; + assert!( + task_error_message.contains("Error running local compact task"), + "expected local compact task error prefix, got {task_error_message}" + ); + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn manual_compact_twice_preserves_latest_user_messages() { skip_if_no_network!(); @@ -2237,114 +2355,89 @@ async fn manual_compact_twice_preserves_latest_user_messages() { 5, "expected exactly 5 requests (user turn, compact, user turn, compact, final turn)" ); - let contains_user_text = |input: &[serde_json::Value], expected: &str| -> bool { - input.iter().any(|item| { - item.get("type").and_then(|v| v.as_str()) == Some("message") - && item.get("role").and_then(|v| v.as_str()) == Some("user") - && item - .get("content") - .and_then(|v| v.as_array()) - .is_some_and(|arr| { - arr.iter().any(|entry| { - entry.get("text").and_then(|v| v.as_str()) == Some(expected) - }) - }) - }) + let contains_user_text = |request: &core_test_support::responses::ResponsesRequest, + expected: &str| { + request + .message_input_texts("user") + .iter() + .any(|text| text == expected) }; - let first_turn_input = requests[0].input(); assert!( - contains_user_text(&first_turn_input, first_user_message), + contains_user_text(&requests[0], first_user_message), "first turn request missing first user message" ); assert!( - !contains_user_text(&first_turn_input, SUMMARIZATION_PROMPT), + !contains_user_text(&requests[0], SUMMARIZATION_PROMPT), "first turn request should not include summarization prompt" ); - let first_compact_input = requests[1].input(); assert!( - contains_user_text(&first_compact_input, first_user_message), + contains_user_text(&requests[1], first_user_message), "first compact request should include history before compaction" ); - let second_turn_input = requests[2].input(); assert!( - contains_user_text(&second_turn_input, second_user_message), + contains_user_text(&requests[2], second_user_message), "second turn request missing second user message" ); assert!( - contains_user_text(&second_turn_input, first_user_message), + contains_user_text(&requests[2], first_user_message), "second turn request should include the compacted user history" ); - let second_compact_input = requests[3].input(); assert!( - contains_user_text(&second_compact_input, second_user_message), + contains_user_text(&requests[3], second_user_message), "second compact request should include latest history" ); - let first_compact_has_prompt = contains_user_text(&first_compact_input, SUMMARIZATION_PROMPT); - let second_compact_has_prompt = contains_user_text(&second_compact_input, SUMMARIZATION_PROMPT); + insta::assert_snapshot!( + "manual_compact_with_history_shapes", + format_labeled_requests_snapshot( + "Manual /compact with prior user history compacts existing history and the follow-up turn includes the compact summary plus new user message.", + &[ + ("Local Compaction Request", &requests[1]), + ("Local Post-Compaction History Layout", &requests[2]), + ] + ) + ); + + let first_compact_has_prompt = contains_user_text(&requests[1], SUMMARIZATION_PROMPT); + let second_compact_has_prompt = contains_user_text(&requests[3], SUMMARIZATION_PROMPT); assert_eq!( first_compact_has_prompt, second_compact_has_prompt, "compact requests should consistently include or omit the summarization prompt" ); - let mut final_output = requests + let first_request_user_texts = requests[0].message_input_texts("user"); + let first_turn_user_index = first_request_user_texts + .len() + .checked_sub(1) + .unwrap_or_else(|| panic!("first turn request missing user messages")); + assert_eq!( + first_request_user_texts[first_turn_user_index], first_user_message, + "first turn request should end with the submitted user message" + ); + let seeded_user_prefix = &first_request_user_texts[..first_turn_user_index]; + + let final_request_user_texts = requests .last() .unwrap_or_else(|| panic!("final turn request missing for {final_user_message}")) - .input() - .into_iter() - .collect::>(); - - // Permissions developer message - final_output.pop_front(); - // User instructions (project docs/skills) - final_output.pop_front(); - // Environment context - final_output.pop_front(); - - let _ = final_output - .iter_mut() - .map(drop_call_id) - .collect::>(); - + .message_input_texts("user"); + assert!( + final_request_user_texts + .as_slice() + .starts_with(seeded_user_prefix), + "final request should start with seeded user prefix from first request: {seeded_user_prefix:?}" + ); + let final_output = &final_request_user_texts[seeded_user_prefix.len()..]; let expected = vec![ - json!({ - "content": vec![json!({ - "text": first_user_message, - "type": "input_text", - })], - "role": "user", - "type": "message", - }), - json!({ - "content": vec![json!({ - "text": second_user_message, - "type": "input_text", - })], - "role": "user", - "type": "message", - }), - json!({ - "content": vec![json!({ - "text": expected_second_summary, - "type": "input_text", - })], - "role": "user", - "type": "message", - }), - json!({ - "content": vec![json!({ - "text": final_user_message, - "type": "input_text", - })], - "role": "user", - "type": "message", - }), + first_user_message.to_string(), + second_user_message.to_string(), + expected_second_summary, + final_user_message.to_string(), ]; - assert_eq!(final_output, expected); + assert_eq!(final_output, expected.as_slice()); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -2459,7 +2552,7 @@ async fn auto_compact_allows_multiple_attempts_when_interleaved_with_other_turn_ } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn auto_compact_triggers_after_function_call_over_95_percent_usage() { +async fn snapshot_request_shape_mid_turn_continuation_compaction() { skip_if_no_network!(); let server = start_mock_server().await; @@ -2467,29 +2560,25 @@ async fn auto_compact_triggers_after_function_call_over_95_percent_usage() { let context_window = 100; let limit = context_window * 90 / 100; let over_limit_tokens = context_window * 95 / 100 + 1; - let follow_up_user = "FOLLOW_UP_AFTER_LIMIT"; let first_turn = sse(vec![ ev_function_call(DUMMY_CALL_ID, DUMMY_FUNCTION_NAME, "{}"), - ev_completed_with_tokens("r1", 50), - ]); - let function_call_follow_up = sse(vec![ - ev_assistant_message("m2", FINAL_REPLY), - ev_completed_with_tokens("r2", over_limit_tokens), + ev_completed_with_tokens("r1", over_limit_tokens), ]); let auto_summary_payload = auto_summary(AUTO_SUMMARY_TEXT); let auto_compact_turn = sse(vec![ - ev_assistant_message("m3", &auto_summary_payload), + ev_assistant_message("m2", &auto_summary_payload), ev_completed_with_tokens("r3", 10), ]); - let post_auto_compact_turn = sse(vec![ev_completed_with_tokens("r4", 10)]); + let post_auto_compact_turn = sse(vec![ + ev_assistant_message("m3", FINAL_REPLY), + ev_completed_with_tokens("r4", 10), + ]); // Mount responses in order and keep mocks only for the ones we assert on. let first_turn_mock = mount_sse_once(&server, first_turn).await; - let follow_up_mock = mount_sse_once(&server, function_call_follow_up).await; let auto_compact_mock = mount_sse_once(&server, auto_compact_turn).await; - // We don't assert on the post-compact request, so no need to keep its mock. - mount_sse_once(&server, post_auto_compact_turn).await; + let post_auto_compact_mock = mount_sse_once(&server, post_auto_compact_turn).await; let model_provider = non_openai_model_provider(&server); @@ -2514,19 +2603,6 @@ async fn auto_compact_triggers_after_function_call_over_95_percent_usage() { wait_for_event(&codex, |msg| matches!(msg, EventMsg::TurnComplete(_))).await; - codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: follow_up_user.into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - }) - .await - .unwrap(); - - wait_for_event(&codex, |msg| matches!(msg, EventMsg::TurnComplete(_))).await; - // Assert first request captured expected user message that triggers function call. let first_request = first_turn_mock.single_request().input(); assert!( @@ -2543,7 +2619,7 @@ async fn auto_compact_triggers_after_function_call_over_95_percent_usage() { "first request should include the user message that triggers the function call" ); - let function_call_output = follow_up_mock + let function_call_output = auto_compact_mock .single_request() .function_call_output(DUMMY_CALL_ID); let output_text = function_call_output @@ -2558,7 +2634,24 @@ async fn auto_compact_triggers_after_function_call_over_95_percent_usage() { let auto_compact_body = auto_compact_mock.single_request().body_json().to_string(); assert!( body_contains_text(&auto_compact_body, SUMMARIZATION_PROMPT), - "auto compact request should include the summarization prompt after exceeding 95% (limit {limit})" + "mid-turn auto compact request should include the summarization prompt after exceeding 95% (limit {limit})" + ); + + insta::assert_snapshot!( + "mid_turn_compaction_shapes", + format_labeled_requests_snapshot( + "True mid-turn continuation compaction after tool output: compact request includes tool artifacts, and the continuation request includes the summary in the same turn.", + &[ + ( + "Local Compaction Request", + &auto_compact_mock.single_request() + ), + ( + "Local Post-Compaction History Layout", + &post_auto_compact_mock.single_request() + ), + ] + ) ); } @@ -2828,3 +2921,270 @@ async fn auto_compact_runs_when_reasoning_header_clears_between_turns() { "remote compaction should run once after the reasoning header clears" ); } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +// TODO(ccunningham): Update once pre-turn compaction includes incoming user input. +async fn snapshot_request_shape_pre_turn_compaction_including_incoming_user_message() { + skip_if_no_network!(); + + let server = start_mock_server().await; + + let sse1 = sse(vec![ + ev_assistant_message("m1", FIRST_REPLY), + ev_completed_with_tokens("r1", 60), + ]); + let sse2 = sse(vec![ + ev_assistant_message("m2", "SECOND_REPLY"), + ev_completed_with_tokens("r2", 500), + ]); + let sse3 = sse(vec![ + ev_assistant_message("m3", "PRE_TURN_SUMMARY"), + ev_completed_with_tokens("r3", 100), + ]); + let sse4 = sse(vec![ + ev_assistant_message("m4", FINAL_REPLY), + ev_completed_with_tokens("r4", 80), + ]); + let request_log = mount_sse_sequence(&server, vec![sse1, sse2, sse3, sse4]).await; + + let model_provider = non_openai_model_provider(&server); + let codex = test_codex() + .with_config(move |config| { + config.model_provider = model_provider; + set_test_compact_prompt(config); + config.model_auto_compact_token_limit = Some(200); + }) + .build(&server) + .await + .expect("build codex") + .codex; + + for user in ["USER_ONE", "USER_TWO"] { + codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: user.to_string(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }) + .await + .expect("submit user input"); + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + } + codex + .submit(Op::OverrideTurnContext { + cwd: Some(PathBuf::from(PRETURN_CONTEXT_DIFF_CWD)), + approval_policy: None, + sandbox_policy: None, + windows_sandbox_level: None, + model: None, + effort: None, + summary: None, + collaboration_mode: None, + personality: None, + }) + .await + .expect("override turn context"); + let image_url = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGNgYAAAAAMAASsJTYQAAAAASUVORK5CYII=" + .to_string(); + codex + .submit(Op::UserInput { + items: vec![ + UserInput::Image { + image_url: image_url.clone(), + }, + UserInput::Text { + text: "USER_THREE".to_string(), + text_elements: Vec::new(), + }, + ], + final_output_json_schema: None, + }) + .await + .expect("submit user input"); + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + let requests = request_log.requests(); + assert_eq!(requests.len(), 4, "expected user, user, compact, follow-up"); + + insta::assert_snapshot!( + "pre_turn_compaction_including_incoming_shapes", + format_labeled_requests_snapshot( + "Pre-turn auto-compaction with a context override emits the context diff in the compact request while the incoming user message is still excluded.", + &[ + ("Local Compaction Request", &requests[2]), + ("Local Post-Compaction History Layout", &requests[3]), + ] + ) + ); + let compact_request_user_texts = requests[2].message_input_texts("user"); + assert!( + !compact_request_user_texts + .iter() + .any(|text| text == "USER_THREE"), + "current behavior excludes incoming user message from pre-turn compaction input" + ); + let follow_up_user_texts = requests[3].message_input_texts("user"); + assert!( + follow_up_user_texts.iter().any(|text| text == "USER_THREE"), + "expected post-compaction follow-up request to keep incoming user text" + ); + let follow_up_user_images = requests[3].message_input_image_urls("user"); + assert!( + follow_up_user_images + .iter() + .any(|url| url == image_url.as_str()), + "expected post-compaction follow-up request to keep incoming user image content" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +// TODO(ccunningham): Update once pre-turn compaction context-overflow handling includes incoming +// user input and emits richer oversized-input messaging. +async fn snapshot_request_shape_pre_turn_compaction_context_window_exceeded() { + skip_if_no_network!(); + + let server = start_mock_server().await; + + let first_turn = sse(vec![ + ev_assistant_message("m1", FIRST_REPLY), + ev_completed_with_tokens("r1", 500), + ]); + let mut responses = vec![first_turn]; + responses.extend( + (0..6).map(|_| { + sse_failed( + "compact-failed", + "context_length_exceeded", + "Your input exceeds the context window of this model. Please adjust your input and try again.", + ) + }), + ); + let request_log = mount_sse_sequence(&server, responses).await; + + let mut model_provider = non_openai_model_provider(&server); + model_provider.stream_max_retries = Some(0); + let codex = test_codex() + .with_config(move |config| { + config.model_provider = model_provider; + set_test_compact_prompt(config); + config.model_auto_compact_token_limit = Some(200); + }) + .build(&server) + .await + .expect("build codex") + .codex; + + codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "USER_ONE".to_string(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }) + .await + .expect("submit first user"); + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "USER_TWO".to_string(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }) + .await + .expect("submit second user"); + let error_message = wait_for_event_match(&codex, |event| match event { + EventMsg::Error(err) => Some(err.message.clone()), + _ => None, + }) + .await; + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + let requests = request_log.requests(); + assert!( + requests.len() >= 2, + "expected first turn and at least one compaction request" + ); + + insta::assert_snapshot!( + "pre_turn_compaction_context_window_exceeded_shapes", + format_labeled_requests_snapshot( + "Pre-turn auto-compaction context-window failure: compaction request excludes the incoming user message and the turn errors.", + &[( + "Local Compaction Request (Incoming User Excluded)", + &requests[1] + ),] + ) + ); + + assert!( + error_message.contains("ran out of room in the model's context window"), + "expected context window exceeded message, got {error_message}" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn snapshot_request_shape_manual_compact_without_previous_user_messages() { + skip_if_no_network!(); + + let server = start_mock_server().await; + + let compact_turn = sse(vec![ + ev_assistant_message("m1", "MANUAL_EMPTY_SUMMARY"), + ev_completed_with_tokens("r1", 90), + ]); + let follow_up_turn = sse(vec![ + ev_assistant_message("m2", FINAL_REPLY), + ev_completed_with_tokens("r2", 80), + ]); + let request_log = mount_sse_sequence(&server, vec![compact_turn, follow_up_turn]).await; + + let model_provider = non_openai_model_provider(&server); + let codex = test_codex() + .with_config(move |config| { + config.model_provider = model_provider; + set_test_compact_prompt(config); + }) + .build(&server) + .await + .expect("build codex") + .codex; + + codex.submit(Op::Compact).await.expect("run /compact"); + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "AFTER_MANUAL_EMPTY_COMPACT".to_string(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }) + .await + .expect("submit follow-up user input"); + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + let requests = request_log.requests(); + assert_eq!( + requests.len(), + 2, + "expected manual /compact request and follow-up turn request" + ); + + insta::assert_snapshot!( + "manual_compact_without_prev_user_shapes", + format_labeled_requests_snapshot( + "Manual /compact with no prior user turn currently still issues a compaction request; follow-up turn carries canonical context and the new user message.", + &[ + ("Local Compaction Request", &requests[0]), + ("Local Post-Compaction History Layout", &requests[1]), + ] + ) + ); +} diff --git a/codex-rs/core/tests/suite/compact_remote.rs b/codex-rs/core/tests/suite/compact_remote.rs index 3311427d4b..6c31b8dbeb 100644 --- a/codex-rs/core/tests/suite/compact_remote.rs +++ b/codex-rs/core/tests/suite/compact_remote.rs @@ -1,9 +1,11 @@ #![allow(clippy::expect_used)] use std::fs; +use std::path::PathBuf; use anyhow::Result; use codex_core::CodexAuth; +use codex_core::compact::SUMMARY_PREFIX; use codex_core::protocol::EventMsg; use codex_core::protocol::ItemCompletedEvent; use codex_core::protocol::ItemStartedEvent; @@ -14,6 +16,9 @@ use codex_protocol::items::TurnItem; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; 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; use core_test_support::responses::mount_sse_once; use core_test_support::responses::sse; @@ -23,6 +28,7 @@ use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; use core_test_support::wait_for_event_match; use pretty_assertions::assert_eq; +use wiremock::ResponseTemplate; fn approx_token_count(text: &str) -> i64 { i64::try_from(text.len().saturating_add(3) / 4).unwrap_or(i64::MAX) @@ -39,6 +45,29 @@ fn estimate_compact_payload_tokens(request: &responses::ResponsesRequest) -> i64 .saturating_add(approx_token_count(&request.instructions_text())) } +const PRETURN_CONTEXT_DIFF_CWD: &str = "/tmp/PRETURN_CONTEXT_DIFF_CWD"; +const DUMMY_FUNCTION_NAME: &str = "test_tool"; + +fn summary_with_prefix(summary: &str) -> String { + format!("{SUMMARY_PREFIX}\n{summary}") +} + +fn context_snapshot_options() -> ContextSnapshotOptions { + ContextSnapshotOptions::default() + .render_mode(ContextSnapshotRenderMode::KindWithTextPrefix { max_chars: 64 }) +} + +fn format_labeled_requests_snapshot( + scenario: &str, + sections: &[(&str, &responses::ResponsesRequest)], +) -> String { + context_snapshot::format_labeled_requests_snapshot( + scenario, + sections, + &context_snapshot_options(), + ) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn remote_compact_replaces_history_for_followups() -> Result<()> { skip_if_no_network!(Ok(())); @@ -65,15 +94,7 @@ async fn remote_compact_replaces_history_for_followups() -> Result<()> { .await; let compacted_history = vec![ - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "REMOTE_COMPACTED_SUMMARY".to_string(), - }], - end_turn: None, - phase: None, - }, + responses::user_message_item("REMOTE_COMPACTED_SUMMARY"), ResponseItem::Compaction { encrypted_content: "ENCRYPTED_COMPACTION_SUMMARY".to_string(), }, @@ -134,12 +155,9 @@ async fn remote_compact_replaces_history_for_followups() -> Result<()> { "expected compact request to include assistant history" ); - let follow_up_body = responses_mock - .requests() - .last() - .expect("follow-up request missing") - .body_json() - .to_string(); + let response_requests = responses_mock.requests(); + let follow_up_request = response_requests.last().expect("follow-up request missing"); + let follow_up_body = follow_up_request.body_json().to_string(); assert!( follow_up_body.contains("REMOTE_COMPACTED_SUMMARY"), "expected follow-up request to use compacted history" @@ -152,6 +170,21 @@ async fn remote_compact_replaces_history_for_followups() -> Result<()> { !follow_up_body.contains("FIRST_REMOTE_REPLY"), "expected follow-up request to drop pre-compaction assistant messages" ); + assert!( + !follow_up_body.contains("hello remote compact"), + "expected follow-up request to drop compacted-away user turns when remote output omits them" + ); + + insta::assert_snapshot!( + "remote_manual_compact_with_history_shapes", + format_labeled_requests_snapshot( + "Remote manual /compact where remote compact output is summary-only: follow-up layout uses returned summary plus new user message.", + &[ + ("Remote Compaction Request", &compact_request), + ("Remote Post-Compaction History Layout", follow_up_request), + ] + ) + ); Ok(()) } @@ -184,15 +217,7 @@ async fn remote_compact_runs_automatically() -> Result<()> { .await; let compacted_history = vec![ - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "REMOTE_COMPACTED_SUMMARY".to_string(), - }], - end_turn: None, - phase: None, - }, + responses::user_message_item("REMOTE_COMPACTED_SUMMARY"), ResponseItem::Compaction { encrypted_content: "ENCRYPTED_COMPACTION_SUMMARY".to_string(), }, @@ -221,7 +246,8 @@ async fn remote_compact_runs_automatically() -> Result<()> { wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; assert!(message); assert_eq!(compact_mock.requests().len(), 1); - let follow_up_body = responses_mock.single_request().body_json().to_string(); + let follow_up_request = responses_mock.single_request(); + let follow_up_body = follow_up_request.body_json().to_string(); assert!(follow_up_body.contains("REMOTE_COMPACTED_SUMMARY")); assert!(follow_up_body.contains("ENCRYPTED_COMPACTION_SUMMARY")); @@ -497,7 +523,7 @@ async fn auto_remote_compact_failure_stops_agent_loop() -> Result<()> { ) .await; - let compact_mock = responses::mount_compact_json_once( + let first_compact_mock = responses::mount_compact_json_once( harness.server(), serde_json::json!({ "output": "invalid compact payload shape" }), ) @@ -541,18 +567,29 @@ async fn auto_remote_compact_failure_stops_agent_loop() -> Result<()> { assert!( error_message.contains("Error running remote compact task"), - "expected compact failure error, got {error_message}" + "expected remote compact task error prefix, got {error_message}" ); assert_eq!( - compact_mock.requests().len(), + first_compact_mock.requests().len(), 1, - "expected exactly one remote compact attempt" + "expected first remote compact attempt with incoming items" ); assert!( post_compact_turn_mock.requests().is_empty(), "expected agent loop to stop after compaction failure" ); + insta::assert_snapshot!( + "remote_pre_turn_compaction_failure_shapes", + format_labeled_requests_snapshot( + "Remote pre-turn auto-compaction parse failure: compaction request excludes the incoming user message and the turn stops.", + &[( + "Remote Compaction Request (Incoming User Excluded)", + &first_compact_mock.single_request() + ),] + ) + ); + Ok(()) } @@ -778,15 +815,7 @@ async fn remote_manual_compact_emits_context_compaction_items() -> Result<()> { .await; let compacted_history = vec![ - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "REMOTE_COMPACTED_SUMMARY".to_string(), - }], - end_turn: None, - phase: None, - }, + responses::user_message_item("REMOTE_COMPACTED_SUMMARY"), ResponseItem::Compaction { encrypted_content: "ENCRYPTED_COMPACTION_SUMMARY".to_string(), }, @@ -851,6 +880,68 @@ async fn remote_manual_compact_emits_context_compaction_items() -> Result<()> { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn remote_manual_compact_failure_emits_task_error_event() -> Result<()> { + skip_if_no_network!(Ok(())); + + let harness = TestCodexHarness::with_builder( + test_codex().with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()), + ) + .await?; + let codex = harness.test().codex.clone(); + + mount_sse_once( + harness.server(), + sse(vec![ + responses::ev_assistant_message("m1", "REMOTE_REPLY"), + responses::ev_completed("resp-1"), + ]), + ) + .await; + + let compact_mock = responses::mount_compact_json_once( + harness.server(), + serde_json::json!({ "output": "invalid compact payload shape" }), + ) + .await; + + codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "manual remote compact".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }) + .await?; + wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; + + codex.submit(Op::Compact).await?; + + let error_message = wait_for_event_match(&codex, |event| match event { + EventMsg::Error(err) => Some(err.message.clone()), + _ => None, + }) + .await; + assert!( + error_message.contains("Error running remote compact task"), + "expected remote compact task error prefix, got {error_message}" + ); + assert!( + error_message.contains("invalid compact payload shape") + || error_message.contains("invalid type: string"), + "expected invalid compact payload details, got {error_message}" + ); + wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; + + assert_eq!(compact_mock.requests().len(), 1); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +// TODO(ccunningham): Re-enable after the follow-up compaction behavior PR lands. +// Current main behavior for rollout replacement-history persistence is known-incorrect. +#[ignore = "behavior change covered in follow-up compaction PR"] async fn remote_compact_persists_replacement_history_in_rollout() -> Result<()> { skip_if_no_network!(Ok(())); @@ -876,15 +967,7 @@ async fn remote_compact_persists_replacement_history_in_rollout() -> Result<()> .await; let compacted_history = vec![ - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "COMPACTED_USER_SUMMARY".to_string(), - }], - end_turn: None, - phase: None, - }, + responses::user_message_item("COMPACTED_USER_SUMMARY"), ResponseItem::Compaction { encrypted_content: "ENCRYPTED_COMPACTION_SUMMARY".to_string(), }, @@ -980,11 +1063,11 @@ async fn remote_compact_persists_replacement_history_in_rollout() -> Result<()> ) }); - if has_compacted_user_summary - && has_compaction_item - && has_compacted_assistant_note - && has_permissions_developer_message - { + if has_compacted_user_summary && has_compaction_item && has_compacted_assistant_note { + assert!( + !has_permissions_developer_message, + "manual remote compact rollout replacement history should not inject permissions context" + ); saw_compacted_history = true; break; } @@ -1036,15 +1119,7 @@ async fn remote_compact_and_resume_refresh_stale_developer_instructions() -> Res .await; let compacted_history = vec![ - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "REMOTE_COMPACTED_SUMMARY".to_string(), - }], - end_turn: None, - phase: None, - }, + responses::user_message_item("REMOTE_COMPACTED_SUMMARY"), ResponseItem::Message { id: None, role: "developer".to_string(), @@ -1177,15 +1252,7 @@ async fn remote_compact_refreshes_stale_developer_instructions_without_resume() .await; let compacted_history = vec![ - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "REMOTE_COMPACTED_SUMMARY".to_string(), - }], - end_turn: None, - phase: None, - }, + responses::user_message_item("REMOTE_COMPACTED_SUMMARY"), ResponseItem::Message { id: None, role: "developer".to_string(), @@ -1250,3 +1317,344 @@ async fn remote_compact_refreshes_stale_developer_instructions_without_resume() Ok(()) } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +// TODO(ccunningham): Update once remote pre-turn compaction includes incoming user input. +async fn snapshot_request_shape_remote_pre_turn_compaction_including_incoming_user_message() +-> Result<()> { + skip_if_no_network!(Ok(())); + + let harness = TestCodexHarness::with_builder( + test_codex() + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_config(|config| { + config.model_auto_compact_token_limit = Some(200); + }), + ) + .await?; + let codex = harness.test().codex.clone(); + + let responses_mock = responses::mount_sse_sequence( + harness.server(), + vec![ + responses::sse(vec![ + responses::ev_assistant_message("m1", "REMOTE_FIRST_REPLY"), + responses::ev_completed_with_tokens("r1", 60), + ]), + responses::sse(vec![ + responses::ev_assistant_message("m2", "REMOTE_SECOND_REPLY"), + responses::ev_completed_with_tokens("r2", 500), + ]), + responses::sse(vec![ + responses::ev_assistant_message("m3", "REMOTE_FINAL_REPLY"), + responses::ev_completed_with_tokens("r3", 80), + ]), + ], + ) + .await; + + let compacted_history = vec![ + responses::user_message_item("USER_ONE"), + responses::user_message_item("USER_TWO"), + responses::user_message_item(&summary_with_prefix("REMOTE_PRE_TURN_SUMMARY")), + ]; + let compact_mock = responses::mount_compact_json_once( + harness.server(), + serde_json::json!({ "output": compacted_history }), + ) + .await; + + for user in ["USER_ONE", "USER_TWO", "USER_THREE"] { + if user == "USER_THREE" { + codex + .submit(Op::OverrideTurnContext { + cwd: Some(PathBuf::from(PRETURN_CONTEXT_DIFF_CWD)), + approval_policy: None, + sandbox_policy: None, + windows_sandbox_level: None, + model: None, + effort: None, + summary: None, + collaboration_mode: None, + personality: None, + }) + .await?; + } + codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: user.to_string(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }) + .await?; + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + } + + assert_eq!(compact_mock.requests().len(), 1); + let requests = responses_mock.requests(); + assert_eq!( + requests.len(), + 3, + "expected user, user, and post-compact turn" + ); + + let compact_request = compact_mock.single_request(); + insta::assert_snapshot!( + "remote_pre_turn_compaction_including_incoming_shapes", + format_labeled_requests_snapshot( + "Remote pre-turn auto-compaction with a context override emits the context diff in the compact request while excluding the incoming user message.", + &[ + ("Remote Compaction Request", &compact_request), + ("Remote Post-Compaction History Layout", &requests[2]), + ] + ) + ); + assert_eq!( + requests[2] + .message_input_texts("user") + .iter() + .filter(|text| text.as_str() == "USER_THREE") + .count(), + 1, + "post-compaction request should contain incoming user exactly once from runtime append" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +// TODO(ccunningham): Update once remote pre-turn compaction context-overflow handling includes +// incoming user input and emits richer oversized-input messaging. +async fn snapshot_request_shape_remote_pre_turn_compaction_context_window_exceeded() -> Result<()> { + skip_if_no_network!(Ok(())); + + let harness = TestCodexHarness::with_builder( + test_codex() + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_config(|config| { + config.model_auto_compact_token_limit = Some(200); + }), + ) + .await?; + let codex = harness.test().codex.clone(); + + let responses_mock = responses::mount_sse_sequence( + harness.server(), + vec![responses::sse(vec![ + responses::ev_assistant_message("m1", "REMOTE_FIRST_REPLY"), + responses::ev_completed_with_tokens("r1", 500), + ])], + ) + .await; + + let compact_mock = responses::mount_compact_response_once( + harness.server(), + ResponseTemplate::new(400).set_body_json(serde_json::json!({ + "error": { + "code": "context_length_exceeded", + "message": "Your input exceeds the context window of this model. Please adjust your input and try again." + } + })), + ) + .await; + let post_compact_turn_mock = responses::mount_sse_once( + harness.server(), + responses::sse(vec![ + responses::ev_assistant_message("m2", "REMOTE_POST_COMPACT_SHOULD_NOT_RUN"), + responses::ev_completed_with_tokens("r2", 80), + ]), + ) + .await; + + codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "USER_ONE".to_string(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }) + .await?; + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "USER_TWO".to_string(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }) + .await?; + let error_message = wait_for_event_match(&codex, |event| match event { + EventMsg::Error(err) => Some(err.message.clone()), + _ => None, + }) + .await; + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + assert_eq!(compact_mock.requests().len(), 1); + let requests = responses_mock.requests(); + assert_eq!( + requests.len(), + 1, + "expected no post-compaction follow-up turn request after compact failure" + ); + assert!( + post_compact_turn_mock.requests().is_empty(), + "expected turn to stop after compaction failure" + ); + + let include_attempt_request = compact_mock.single_request(); + insta::assert_snapshot!( + "remote_pre_turn_compaction_context_window_exceeded_shapes", + format_labeled_requests_snapshot( + "Remote pre-turn auto-compaction context-window failure: compaction request excludes the incoming user message and the turn errors.", + &[( + "Remote Compaction Request (Incoming User Excluded)", + &include_attempt_request + ),] + ) + ); + assert!( + error_message.to_lowercase().contains("context window"), + "expected context window failure to surface, got {error_message}" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn snapshot_request_shape_remote_mid_turn_continuation_compaction() -> Result<()> { + skip_if_no_network!(Ok(())); + + let harness = TestCodexHarness::with_builder( + test_codex() + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_config(|config| { + config.model_auto_compact_token_limit = Some(200); + }), + ) + .await?; + let codex = harness.test().codex.clone(); + + let responses_mock = responses::mount_sse_sequence( + harness.server(), + vec![ + responses::sse(vec![ + responses::ev_function_call("call-remote-mid-turn", DUMMY_FUNCTION_NAME, "{}"), + responses::ev_completed_with_tokens("r1", 500), + ]), + responses::sse(vec![ + responses::ev_assistant_message("m2", "REMOTE_MID_TURN_FINAL_REPLY"), + responses::ev_completed_with_tokens("r2", 80), + ]), + ], + ) + .await; + + let compacted_history = vec![ + responses::user_message_item("USER_ONE"), + responses::user_message_item(&summary_with_prefix("REMOTE_MID_TURN_SUMMARY")), + ]; + let compact_mock = responses::mount_compact_json_once( + harness.server(), + serde_json::json!({ "output": compacted_history }), + ) + .await; + + codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "USER_ONE".to_string(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }) + .await?; + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + assert_eq!(compact_mock.requests().len(), 1); + let requests = responses_mock.requests(); + assert_eq!( + requests.len(), + 2, + "expected initial and post-compact requests" + ); + + let compact_request = compact_mock.single_request(); + insta::assert_snapshot!( + "remote_mid_turn_compaction_shapes", + format_labeled_requests_snapshot( + "Remote mid-turn continuation compaction after tool output: compact request includes tool artifacts and follow-up request includes the summary.", + &[ + ("Remote Compaction Request", &compact_request), + ("Remote Post-Compaction History Layout", &requests[1]), + ] + ) + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +// TODO(ccunningham): Update once manual remote /compact with no prior user turn becomes a no-op. +async fn snapshot_request_shape_remote_manual_compact_without_previous_user_messages() -> Result<()> +{ + skip_if_no_network!(Ok(())); + + let harness = TestCodexHarness::with_builder( + test_codex().with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()), + ) + .await?; + let codex = harness.test().codex.clone(); + + let responses_mock = responses::mount_sse_once( + harness.server(), + responses::sse(vec![ + responses::ev_assistant_message("m1", "REMOTE_MANUAL_EMPTY_FOLLOW_UP_REPLY"), + responses::ev_completed_with_tokens("r1", 80), + ]), + ) + .await; + + let compact_mock = + responses::mount_compact_json_once(harness.server(), serde_json::json!({ "output": [] })) + .await; + + codex.submit(Op::Compact).await?; + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "USER_ONE".to_string(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }) + .await?; + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + assert_eq!( + compact_mock.requests().len(), + 1, + "current behavior still issues remote compaction for manual /compact without prior user" + ); + let compact_request = compact_mock.single_request(); + let follow_up_request = responses_mock.single_request(); + insta::assert_snapshot!( + "remote_manual_compact_without_prev_user_shapes", + format_labeled_requests_snapshot( + "Remote manual /compact with no prior user turn still issues a compact request; follow-up turn carries canonical context and new user message.", + &[ + ("Remote Compaction Request", &compact_request), + ("Remote Post-Compaction History Layout", &follow_up_request), + ] + ) + ); + + Ok(()) +} diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index 8edeef065a..edbb42f70c 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -29,7 +29,6 @@ use core_test_support::skip_if_no_network; 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 wiremock::MockServer; #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -324,32 +323,14 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< assert_eq!(requests.len(), 2, "expected two model requests"); let first_request = requests.first().expect("expected first request"); - let first_has_input_image = first_request.inputs_of_type("message").iter().any(|item| { - item.get("content") - .and_then(Value::as_array) - .is_some_and(|content| { - content - .iter() - .any(|span| span.get("type").and_then(Value::as_str) == Some("input_image")) - }) - }); assert!( - first_has_input_image, + !first_request.message_input_image_urls("user").is_empty(), "first request should include the uploaded image" ); let second_request = requests.last().expect("expected second request"); - let second_has_input_image = second_request.inputs_of_type("message").iter().any(|item| { - item.get("content") - .and_then(Value::as_array) - .is_some_and(|content| { - content - .iter() - .any(|span| span.get("type").and_then(Value::as_str) == Some("input_image")) - }) - }); assert!( - !second_has_input_image, + second_request.message_input_image_urls("user").is_empty(), "second request should strip unsupported image content" ); let second_user_texts = second_request.message_input_texts("user"); diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact__manual_compact_with_history_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact__manual_compact_with_history_shapes.snap new file mode 100644 index 0000000000..bcca5d3575 --- /dev/null +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact__manual_compact_with_history_shapes.snap @@ -0,0 +1,21 @@ +--- +source: core/tests/suite/compact.rs +expression: "format_labeled_requests_snapshot(\"Manual /compact with prior user history compacts existing history and the follow-up turn includes the compact summary plus new user message.\",\n&[(\"Local Compaction Request\", &requests[1]),\n(\"Local Post-Compaction History Layout\", &requests[2]),])" +--- +Scenario: Manual /compact with prior user history compacts existing history and the follow-up turn includes the compact summary plus new user message. + +## Local Compaction Request +00:message/developer: +01:message/user: +02:message/user:> +03:message/user:first manual turn +04:message/assistant:FIRST_REPLY +05:message/user: + +## Local Post-Compaction History Layout +00:message/developer: +01:message/user: +02:message/user:> +03:message/user:first manual turn +04:message/user:\nFIRST_MANUAL_SUMMARY +05:message/user:second manual turn diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact__manual_compact_without_prev_user_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact__manual_compact_without_prev_user_shapes.snap new file mode 100644 index 0000000000..bdb4fe9bae --- /dev/null +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact__manual_compact_without_prev_user_shapes.snap @@ -0,0 +1,18 @@ +--- +source: core/tests/suite/compact.rs +expression: "format_labeled_requests_snapshot(\"Manual /compact with no prior user turn currently still issues a compaction request; follow-up turn carries canonical context and the new user message.\",\n&[(\"Local Compaction Request\", &requests[0]),\n(\"Local Post-Compaction History Layout\", &requests[1]),])" +--- +Scenario: Manual /compact with no prior user turn currently still issues a compaction request; follow-up turn carries canonical context and the new user message. + +## Local Compaction Request +00:message/developer: +01:message/user: +02:message/user:> +03:message/user: + +## Local Post-Compaction History Layout +00:message/developer: +01:message/user: +02:message/user:> +03:message/user:\nMANUAL_EMPTY_SUMMARY +04:message/user:AFTER_MANUAL_EMPTY_COMPACT diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact__mid_turn_compaction_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact__mid_turn_compaction_shapes.snap new file mode 100644 index 0000000000..c13f78aff9 --- /dev/null +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact__mid_turn_compaction_shapes.snap @@ -0,0 +1,22 @@ +--- +source: core/tests/suite/compact.rs +assertion_line: 2646 +expression: "format_labeled_requests_snapshot(\"True mid-turn continuation compaction after tool output: compact request includes tool artifacts, and the continuation request includes the summary in the same turn.\",\n&[(\"Local Compaction Request\", &auto_compact_mock.single_request()),\n(\"Local Post-Compaction History Layout\",\n&post_auto_compact_mock.single_request()),])" +--- +Scenario: True mid-turn continuation compaction after tool output: compact request includes tool artifacts, and the continuation request includes the summary in the same turn. + +## Local Compaction Request +00:message/developer: +01:message/user: +02:message/user:> +03:message/user:function call limit push +04:function_call/test_tool +05:function_call_output:unsupported call: test_tool +06:message/user: + +## Local Post-Compaction History Layout +00:message/developer: +01:message/user: +02:message/user:> +03:message/user:function call limit push +04:message/user:\nAUTO_SUMMARY diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_sampling_model_switch_compaction_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_sampling_model_switch_compaction_shapes.snap new file mode 100644 index 0000000000..8d6c4d9b1a --- /dev/null +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_sampling_model_switch_compaction_shapes.snap @@ -0,0 +1,31 @@ +--- +source: core/tests/suite/compact.rs +assertion_line: 1773 +expression: "format_labeled_requests_snapshot(\"Pre-sampling compaction on model switch to a smaller context window: current behavior compacts using prior-turn history only (incoming user message excluded), and the follow-up request carries compacted history plus the new user message.\",\n&[(\"Initial Request (Previous Model)\", &requests[0]),\n(\"Pre-sampling Compaction Request\", &requests[1]),\n(\"Post-Compaction Follow-up Request (Next Model)\", &requests[2]),])" +--- +Scenario: Pre-sampling compaction on model switch to a smaller context window: current behavior compacts using prior-turn history only (incoming user message excluded), and the follow-up request carries compacted history plus the new user message. + +## Initial Request (Previous Model) +00:message/developer: +01:message/user: +02:message/user:> +03:message/developer: +04:message/user:before switch + +## Pre-sampling Compaction Request +00:message/developer: +01:message/user: +02:message/user:> +03:message/developer: +04:message/user:before switch +05:message/assistant:before switch +06:message/user: + +## Post-Compaction Follow-up Request (Next Model) +00:message/developer: +01:message/user: +02:message/user:> +03:message/user:before switch +04:message/user:\nPRE_SAMPLING_SUMMARY +05:message/developer:\nThe user was previously using a different model.... +06:message/user:after switch diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_context_window_exceeded_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_context_window_exceeded_shapes.snap new file mode 100644 index 0000000000..da30b01bd7 --- /dev/null +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_context_window_exceeded_shapes.snap @@ -0,0 +1,13 @@ +--- +source: core/tests/suite/compact.rs +expression: "format_labeled_requests_snapshot(\"Pre-turn auto-compaction context-window failure: compaction request excludes the incoming user message and the turn errors.\",\n&[(\"Local Compaction Request (Incoming User Excluded)\", &requests[1]),])" +--- +Scenario: Pre-turn auto-compaction context-window failure: compaction request excludes the incoming user message and the turn errors. + +## Local Compaction Request (Incoming User Excluded) +00:message/developer: +01:message/user: +02:message/user:> +03:message/user:USER_ONE +04:message/assistant:FIRST_REPLY +05:message/user: 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 new file mode 100644 index 0000000000..e586c8521a --- /dev/null +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_including_incoming_shapes.snap @@ -0,0 +1,25 @@ +--- +source: core/tests/suite/compact.rs +expression: "format_labeled_requests_snapshot(\"Pre-turn auto-compaction with a context override emits the context diff in the compact request while the incoming user message is still excluded.\",\n&[(\"Local Compaction Request\", &requests[2]),\n(\"Local Post-Compaction History Layout\", &requests[3]),])" +--- +Scenario: Pre-turn auto-compaction with a context override emits the context diff in the compact request while the incoming user message is still excluded. + +## Local Compaction Request +00:message/developer: +01:message/user: +02:message/user:> +03:message/user:USER_ONE +04:message/assistant:FIRST_REPLY +05:message/user:USER_TWO +06:message/assistant:SECOND_REPLY +07:message/user: +08:message/user: + +## Local Post-Compaction History Layout +00:message/developer: +01:message/user: +02:message/user: +03:message/user:USER_ONE +04:message/user:USER_TWO +05:message/user:\nPRE_TURN_SUMMARY +06:message/user: | | | USER_THREE diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_with_history_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_with_history_shapes.snap new file mode 100644 index 0000000000..bc795a19f4 --- /dev/null +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_with_history_shapes.snap @@ -0,0 +1,21 @@ +--- +source: core/tests/suite/compact_remote.rs +assertion_line: 178 +expression: "format_labeled_requests_snapshot(\"Remote manual /compact where remote compact output is summary-only: follow-up layout uses returned summary plus new user message.\",\n&[(\"Remote Compaction Request\", &compact_request),\n(\"Remote Post-Compaction History Layout\", follow_up_request),])" +--- +Scenario: Remote manual /compact where remote compact output is summary-only: follow-up layout uses returned summary plus new user message. + +## Remote Compaction Request +00:message/developer: +01:message/user: +02:message/user:> +03:message/user:hello remote compact +04:message/assistant:FIRST_REMOTE_REPLY + +## Remote Post-Compaction History Layout +00:message/developer: +01:message/user: +02:message/user:> +03:message/user:REMOTE_COMPACTED_SUMMARY +04:compaction:encrypted=true +05:message/user:after compact diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_without_prev_user_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_without_prev_user_shapes.snap new file mode 100644 index 0000000000..ce62806d99 --- /dev/null +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_without_prev_user_shapes.snap @@ -0,0 +1,16 @@ +--- +source: core/tests/suite/compact_remote.rs +expression: "format_labeled_requests_snapshot(\"Remote manual /compact with no prior user turn still issues a compact request; follow-up turn carries canonical context and new user message.\",\n&[(\"Remote Compaction Request\", &compact_request),\n(\"Remote Post-Compaction History Layout\", &follow_up_request),])" +--- +Scenario: Remote manual /compact with no prior user turn still issues a compact request; follow-up turn carries canonical context and new user message. + +## Remote Compaction Request +00:message/developer: +01:message/user: +02:message/user:> + +## Remote Post-Compaction History Layout +00:message/developer: +01:message/user: +02:message/user:> +03:message/user:USER_ONE diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_shapes.snap new file mode 100644 index 0000000000..8ef8701673 --- /dev/null +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_shapes.snap @@ -0,0 +1,20 @@ +--- +source: core/tests/suite/compact_remote.rs +expression: "format_labeled_requests_snapshot(\"Remote mid-turn continuation compaction after tool output: compact request includes tool artifacts and follow-up request includes the summary.\",\n&[(\"Remote Compaction Request\", &compact_request),\n(\"Remote Post-Compaction History Layout\", &requests[1]),])" +--- +Scenario: Remote mid-turn continuation compaction after tool output: compact request includes tool artifacts and follow-up request includes the summary. + +## Remote Compaction Request +00:message/developer: +01:message/user: +02:message/user:> +03:message/user:USER_ONE +04:function_call/test_tool +05:function_call_output:unsupported call: test_tool + +## Remote Post-Compaction History Layout +00:message/user:USER_ONE +01:message/developer: +02:message/user: +03:message/user:> +04:message/user:\nREMOTE_MID_TURN_SUMMARY diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_context_window_exceeded_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_context_window_exceeded_shapes.snap new file mode 100644 index 0000000000..e718bfda88 --- /dev/null +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_context_window_exceeded_shapes.snap @@ -0,0 +1,12 @@ +--- +source: core/tests/suite/compact_remote.rs +expression: "format_labeled_requests_snapshot(\"Remote pre-turn auto-compaction context-window failure: compaction request excludes the incoming user message and the turn errors.\",\n&[(\"Remote Compaction Request (Incoming User Excluded)\",\n&include_attempt_request),])" +--- +Scenario: Remote pre-turn auto-compaction context-window failure: compaction request excludes the incoming user message and the turn errors. + +## Remote Compaction Request (Incoming User Excluded) +00:message/developer: +01:message/user: +02:message/user:> +03:message/user:USER_ONE +04:message/assistant:REMOTE_FIRST_REPLY diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_failure_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_failure_shapes.snap new file mode 100644 index 0000000000..f8aa74e9a1 --- /dev/null +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_failure_shapes.snap @@ -0,0 +1,12 @@ +--- +source: core/tests/suite/compact_remote.rs +expression: "format_labeled_requests_snapshot(\"Remote pre-turn auto-compaction parse failure: compaction request excludes the incoming user message and the turn stops.\",\n&[(\"Remote Compaction Request (Incoming User Excluded)\",\n&first_compact_mock.single_request()),])" +--- +Scenario: Remote pre-turn auto-compaction parse failure: compaction request excludes the incoming user message and the turn stops. + +## Remote Compaction Request (Incoming User Excluded) +00:message/developer: +01:message/user: +02:message/user:> +03:message/user:turn that exceeds token threshold +04:message/assistant:initial turn complete diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_including_incoming_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_including_incoming_shapes.snap new file mode 100644 index 0000000000..00aaaeaa92 --- /dev/null +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_including_incoming_shapes.snap @@ -0,0 +1,24 @@ +--- +source: core/tests/suite/compact_remote.rs +expression: "format_labeled_requests_snapshot(\"Remote pre-turn auto-compaction with a context override emits the context diff in the compact request while excluding the incoming user message.\",\n&[(\"Remote Compaction Request\", &compact_request),\n(\"Remote Post-Compaction History Layout\", &requests[2]),])" +--- +Scenario: Remote pre-turn auto-compaction with a context override emits the context diff in the compact request while excluding the incoming user message. + +## Remote Compaction Request +00:message/developer: +01:message/user: +02:message/user:> +03:message/user:USER_ONE +04:message/assistant:REMOTE_FIRST_REPLY +05:message/user:USER_TWO +06:message/assistant:REMOTE_SECOND_REPLY +07:message/user: + +## Remote Post-Compaction History Layout +00:message/user:USER_ONE +01:message/user:USER_TWO +02:message/developer: +03:message/user: +04:message/user: +05:message/user:\nREMOTE_PRE_TURN_SUMMARY +06:message/user:USER_THREE From 060a320e7dc2f64f5f8ef444a0b9c29a2e10f053 Mon Sep 17 00:00:00 2001 From: sayan-oai Date: Sun, 15 Feb 2026 18:46:05 -0800 Subject: [PATCH 19/62] fix: show user warning when using default fallback metadata (#11690) ### What It's currently unclear when the harness falls back to the default, generic `ModelInfo`. This happens when the `remote_models` feature is disabled or the model is truly unknown, and can lead to bad performance and issues in the harness. Add a user-facing warning when this happens so they are aware when their setup is broken. ### Tests Added tests, tested locally. --- .../app-server/tests/common/models_cache.rs | 1 + .../codex-api/tests/models_integration.rs | 1 + codex-rs/core/src/codex.rs | 19 +++++++--- codex-rs/core/src/models_manager/manager.rs | 36 +++++++++++++++++-- .../core/src/models_manager/model_info.rs | 1 + codex-rs/core/tests/suite/model_switching.rs | 2 ++ codex-rs/core/tests/suite/models_cache_ttl.rs | 1 + codex-rs/core/tests/suite/personality.rs | 3 ++ codex-rs/core/tests/suite/remote_models.rs | 3 ++ codex-rs/core/tests/suite/resume_warning.rs | 9 ++++- codex-rs/core/tests/suite/rmcp_client.rs | 1 + codex-rs/core/tests/suite/view_image.rs | 1 + codex-rs/protocol/src/openai_models.rs | 6 ++++ 13 files changed, 76 insertions(+), 8 deletions(-) diff --git a/codex-rs/app-server/tests/common/models_cache.rs b/codex-rs/app-server/tests/common/models_cache.rs index 242a71f98d..5095bf0f3d 100644 --- a/codex-rs/app-server/tests/common/models_cache.rs +++ b/codex-rs/app-server/tests/common/models_cache.rs @@ -41,6 +41,7 @@ fn preset_to_info(preset: &ModelPreset, priority: i32) -> ModelInfo { experimental_supported_tools: Vec::new(), input_modalities: default_input_modalities(), prefer_websockets: false, + used_fallback_model_metadata: false, } } diff --git a/codex-rs/codex-api/tests/models_integration.rs b/codex-rs/codex-api/tests/models_integration.rs index b33f8b308b..9f7e380665 100644 --- a/codex-rs/codex-api/tests/models_integration.rs +++ b/codex-rs/codex-api/tests/models_integration.rs @@ -89,6 +89,7 @@ async fn models_client_hits_models_endpoint() { experimental_supported_tools: Vec::new(), input_modalities: default_input_modalities(), prefer_websockets: false, + used_fallback_model_metadata: false, }], }; diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 0024a678a6..10b070cf01 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -1780,13 +1780,11 @@ impl Session { } } + let resolved_model_slug = session_configuration.collaboration_mode.model().to_string(); let model_info = self .services .models_manager - .get_model_info( - session_configuration.collaboration_mode.model(), - &per_turn_config, - ) + .get_model_info(resolved_model_slug.as_str(), &per_turn_config) .await; let mut turn_context: TurnContext = Self::make_turn_context( Some(Arc::clone(&self.services.auth_manager)), @@ -1794,7 +1792,7 @@ impl Session { session_configuration.provider.clone(), &session_configuration, per_turn_config, - model_info, + model_info.clone(), self.services .network_proxy .as_ref() @@ -1807,6 +1805,17 @@ impl Session { turn_context.final_output_json_schema = final_schema; } let turn_context = Arc::new(turn_context); + if model_info.used_fallback_model_metadata { + self.send_event( + turn_context.as_ref(), + EventMsg::Warning(WarningEvent { + message: format!( + "Model metadata for `{resolved_model_slug}` not found. Defaulting to fallback metadata; this can degrade performance and cause issues." + ), + }), + ) + .await; + } turn_context.turn_metadata_state.spawn_git_enrichment_task(); turn_context } diff --git a/codex-rs/core/src/models_manager/manager.rs b/codex-rs/core/src/models_manager/manager.rs index 15b3dfe0d0..dd769da2e8 100644 --- a/codex-rs/core/src/models_manager/manager.rs +++ b/codex-rs/core/src/models_manager/manager.rs @@ -140,15 +140,16 @@ impl ModelsManager { let remote = self .find_remote_model_by_longest_prefix(model, config) .await; - let model = if let Some(remote) = remote { + let model_info = if let Some(remote) = remote { ModelInfo { slug: model.to_string(), + used_fallback_model_metadata: false, ..remote } } else { model_info::model_info_from_slug(model) }; - model_info::with_config_overrides(model, config) + model_info::with_config_overrides(model_info, config) } async fn find_remote_model_by_longest_prefix( @@ -472,6 +473,37 @@ mod tests { } } + #[tokio::test] + async fn get_model_info_tracks_fallback_usage() { + let codex_home = tempdir().expect("temp dir"); + let mut config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .build() + .await + .expect("load default test config"); + config.features.enable(Feature::RemoteModels); + let auth_manager = + AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); + let manager = ModelsManager::new(codex_home.path().to_path_buf(), auth_manager); + let known_slug = manager + .get_remote_models(&config) + .await + .first() + .expect("bundled models should include at least one model") + .slug + .clone(); + + let known = manager.get_model_info(known_slug.as_str(), &config).await; + assert!(!known.used_fallback_model_metadata); + assert_eq!(known.slug, known_slug); + + let unknown = manager + .get_model_info("model-that-does-not-exist", &config) + .await; + assert!(unknown.used_fallback_model_metadata); + assert_eq!(unknown.slug, "model-that-does-not-exist"); + } + #[tokio::test] async fn refresh_available_models_sorts_by_priority() { let server = MockServer::start().await; diff --git a/codex-rs/core/src/models_manager/model_info.rs b/codex-rs/core/src/models_manager/model_info.rs index 6a29cd96f9..c5ad8899a1 100644 --- a/codex-rs/core/src/models_manager/model_info.rs +++ b/codex-rs/core/src/models_manager/model_info.rs @@ -81,6 +81,7 @@ pub(crate) fn model_info_from_slug(slug: &str) -> ModelInfo { experimental_supported_tools: Vec::new(), input_modalities: default_input_modalities(), prefer_websockets: false, + used_fallback_model_metadata: true, // this is the fallback model metadata } } diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index edbb42f70c..44d0d8a2fc 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -228,6 +228,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< supported_in_api: true, input_modalities: default_input_modalities(), prefer_websockets: false, + used_fallback_model_metadata: false, priority: 1, upgrade: None, base_instructions: "base instructions".to_string(), @@ -385,6 +386,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< supported_in_api: true, input_modalities: default_input_modalities(), prefer_websockets: false, + used_fallback_model_metadata: false, priority: 1, upgrade: None, base_instructions: "base instructions".to_string(), diff --git a/codex-rs/core/tests/suite/models_cache_ttl.rs b/codex-rs/core/tests/suite/models_cache_ttl.rs index 59c7dde7d7..780937c111 100644 --- a/codex-rs/core/tests/suite/models_cache_ttl.rs +++ b/codex-rs/core/tests/suite/models_cache_ttl.rs @@ -352,5 +352,6 @@ fn test_remote_model(slug: &str, priority: i32) -> ModelInfo { experimental_supported_tools: Vec::new(), input_modalities: default_input_modalities(), prefer_websockets: false, + used_fallback_model_metadata: false, } } diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs index 9397765c90..51f99720aa 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -616,6 +616,7 @@ async fn ignores_remote_personality_if_remote_models_disabled() -> anyhow::Resul experimental_supported_tools: Vec::new(), input_modalities: default_input_modalities(), prefer_websockets: false, + used_fallback_model_metadata: false, }; let _models_mock = mount_models_once( @@ -733,6 +734,7 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow experimental_supported_tools: Vec::new(), input_modalities: default_input_modalities(), prefer_websockets: false, + used_fallback_model_metadata: false, }; let _models_mock = mount_models_once( @@ -845,6 +847,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - experimental_supported_tools: Vec::new(), input_modalities: default_input_modalities(), prefer_websockets: false, + used_fallback_model_metadata: false, }; let _models_mock = mount_models_once( diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index 3af3a5952b..29ba047d01 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -225,6 +225,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { supported_in_api: true, input_modalities: default_input_modalities(), prefer_websockets: false, + used_fallback_model_metadata: false, priority: 1, upgrade: None, base_instructions: "base instructions".to_string(), @@ -464,6 +465,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { supported_in_api: true, input_modalities: default_input_modalities(), prefer_websockets: false, + used_fallback_model_metadata: false, priority: 1, upgrade: None, base_instructions: remote_base.to_string(), @@ -948,6 +950,7 @@ fn test_remote_model_with_policy( supported_in_api: true, input_modalities: default_input_modalities(), prefer_websockets: false, + used_fallback_model_metadata: false, priority, upgrade: None, base_instructions: "base instructions".to_string(), diff --git a/codex-rs/core/tests/suite/resume_warning.rs b/codex-rs/core/tests/suite/resume_warning.rs index f2e9c202b6..61726f913c 100644 --- a/codex-rs/core/tests/suite/resume_warning.rs +++ b/codex-rs/core/tests/suite/resume_warning.rs @@ -74,7 +74,14 @@ async fn emits_warning_when_resumed_model_differs() { .expect("resume conversation"); // Assert: a Warning event is emitted describing the model mismatch. - let warning = wait_for_event(&conversation, |ev| matches!(ev, EventMsg::Warning(_))).await; + let warning = wait_for_event(&conversation, |ev| { + matches!( + ev, + EventMsg::Warning(WarningEvent { message }) + if message.contains("previous-model") && message.contains("current-model") + ) + }) + .await; let EventMsg::Warning(WarningEvent { message }) = warning else { panic!("expected warning event"); }; diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 46df9ca17e..467f4e354d 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -410,6 +410,7 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re experimental_supported_tools: Vec::new(), input_modalities: vec![InputModality::Text], prefer_websockets: false, + used_fallback_model_metadata: false, }], }, ) diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index e81592cc5c..bd5bd6a037 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -671,6 +671,7 @@ async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> an supported_in_api: true, input_modalities: vec![InputModality::Text], prefer_websockets: false, + used_fallback_model_metadata: false, priority: 1, upgrade: None, base_instructions: "base instructions".to_string(), diff --git a/codex-rs/protocol/src/openai_models.rs b/codex-rs/protocol/src/openai_models.rs index d9275e3830..e61860698a 100644 --- a/codex-rs/protocol/src/openai_models.rs +++ b/codex-rs/protocol/src/openai_models.rs @@ -253,6 +253,11 @@ pub struct ModelInfo { /// When true, this model should use websocket transport even when websocket features are off. #[serde(default)] pub prefer_websockets: bool, + /// Internal-only marker set by core when a model slug resolved to fallback metadata. + #[serde(default, skip_serializing, skip_deserializing)] + #[schemars(skip)] + #[ts(skip)] + pub used_fallback_model_metadata: bool, } impl ModelInfo { @@ -517,6 +522,7 @@ mod tests { experimental_supported_tools: vec![], input_modalities: default_input_modalities(), prefer_websockets: false, + used_fallback_model_metadata: false, } } From 02abd9a8eab953f6936453c69bb567660ce91045 Mon Sep 17 00:00:00 2001 From: Anton Panasenko Date: Sun, 15 Feb 2026 19:18:41 -0800 Subject: [PATCH 20/62] feat: persist and restore codex app's tools after search (#11780) ### What changed 1. Removed per-turn MCP selection reset in `core/src/tasks/mod.rs`. 2. Added `SessionState::set_mcp_tool_selection(Vec)` in `core/src/state/session.rs` for authoritative restore behavior (deduped, order-preserving, empty clears). 3. Added rollout parsing in `core/src/codex.rs` to recover `active_selected_tools` from prior `search_tool_bm25` outputs: - tracks matching `call_id`s - parses function output text JSON - extracts `active_selected_tools` - latest valid payload wins - malformed/non-matching payloads are ignored 4. Applied restore logic to resumed and forked startup paths in `core/src/codex.rs`. 5. Updated instruction text to session/thread scope in `core/templates/search_tool/tool_description.md`. 6. Expanded tests in `core/tests/suite/search_tool.rs`, plus unit coverage in: - `core/src/codex.rs` - `core/src/state/session.rs` ### Behavior after change 1. Search activates matched tools. 2. Additional searches union into active selection. 3. Selection survives new turns in the same thread. 4. Resume/fork restores selection from rollout history. 5. Separate threads do not inherit selection unless forked. --- codex-rs/core/src/codex.rs | 248 ++++- codex-rs/core/src/mcp_connection_manager.rs | 10 + codex-rs/core/src/state/session.rs | 55 ++ codex-rs/core/src/tasks/mod.rs | 1 - codex-rs/core/src/tools/handlers/mod.rs | 1 + .../src/tools/handlers/search_tool_bm25.rs | 32 +- codex-rs/core/src/tools/spec.rs | 7 +- .../templates/search_tool/tool_description.md | 10 +- .../core/tests/common/apps_test_server.rs | 158 ++++ codex-rs/core/tests/common/lib.rs | 1 + codex-rs/core/tests/suite/client.rs | 9 +- codex-rs/core/tests/suite/search_tool.rs | 870 ++++++++++++++---- 12 files changed, 1201 insertions(+), 201 deletions(-) create mode 100644 codex-rs/core/tests/common/apps_test_server.rs diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 10b070cf01..437fd9e803 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -150,6 +150,7 @@ use crate::mcp::with_codex_apps_mcp; use crate::mcp_connection_manager::McpConnectionManager; use crate::mcp_connection_manager::filter_codex_apps_mcp_tools_only; use crate::mcp_connection_manager::filter_mcp_tools_by_name; +use crate::mcp_connection_manager::filter_non_codex_apps_mcp_tools_only; use crate::memories; use crate::mentions::build_connector_slug_counts; use crate::mentions::build_skill_name_counts; @@ -223,6 +224,7 @@ use crate::tasks::SessionTask; use crate::tasks::SessionTaskContext; use crate::tools::ToolRouter; use crate::tools::context::SharedTurnDiffTracker; +use crate::tools::handlers::SEARCH_TOOL_BM25_TOOL_NAME; use crate::tools::js_repl::JsReplHandle; use crate::tools::network_approval::NetworkApprovalService; use crate::tools::network_approval::build_blocked_request_observer; @@ -1489,6 +1491,11 @@ impl Session { state.merge_mcp_tool_selection(tool_names) } + pub(crate) async fn set_mcp_tool_selection(&self, tool_names: Vec) { + let mut state = self.state.lock().await; + state.set_mcp_tool_selection(tool_names); + } + pub(crate) async fn get_mcp_tool_selection(&self) -> Option> { let state = self.state.lock().await; state.get_mcp_tool_selection() @@ -1522,6 +1529,7 @@ impl Session { async fn record_initial_history(&self, conversation_history: InitialHistory) { let turn_context = self.new_default_turn().await; + self.clear_mcp_tool_selection().await; match conversation_history { InitialHistory::New => { // Build and record initial items (user instructions + environment context) @@ -1537,6 +1545,8 @@ impl Session { } InitialHistory::Resumed(resumed_history) => { let rollout_items = resumed_history.history; + let restored_tool_selection = + Self::extract_mcp_tool_selection_from_rollout(&rollout_items); let previous_model = Self::last_rollout_model_name(&rollout_items) .map(std::string::ToString::to_string); { @@ -1578,12 +1588,17 @@ impl Session { let mut state = self.state.lock().await; state.set_token_info(Some(info)); } + if let Some(selected_tools) = restored_tool_selection { + self.set_mcp_tool_selection(selected_tools).await; + } // Defer seeding the session's initial context until the first turn starts so // turn/start overrides can be merged before we write to the rollout. self.flush_rollout().await; } InitialHistory::Forked(rollout_items) => { + let restored_tool_selection = + Self::extract_mcp_tool_selection_from_rollout(&rollout_items); let previous_model = Self::last_rollout_model_name(&rollout_items) .map(std::string::ToString::to_string); self.set_previous_model(previous_model).await; @@ -1603,6 +1618,9 @@ impl Session { let mut state = self.state.lock().await; state.set_token_info(Some(info)); } + if let Some(selected_tools) = restored_tool_selection { + self.set_mcp_tool_selection(selected_tools).await; + } // If persisting, persist all rollout items as-is (recorder filters) if !rollout_items.is_empty() { @@ -1644,6 +1662,54 @@ impl Session { }) } + fn extract_mcp_tool_selection_from_rollout( + rollout_items: &[RolloutItem], + ) -> Option> { + let mut search_call_ids = HashSet::new(); + let mut active_selected_tools: Option> = None; + + for item in rollout_items { + let RolloutItem::ResponseItem(response_item) = item else { + continue; + }; + match response_item { + ResponseItem::FunctionCall { name, call_id, .. } => { + if name == SEARCH_TOOL_BM25_TOOL_NAME { + search_call_ids.insert(call_id.clone()); + } + } + ResponseItem::FunctionCallOutput { call_id, output } => { + if !search_call_ids.contains(call_id) { + continue; + } + let Some(content) = output.body.to_text() else { + continue; + }; + let Ok(payload) = serde_json::from_str::(&content) else { + continue; + }; + let Some(selected_tools) = payload + .get("active_selected_tools") + .and_then(Value::as_array) + else { + continue; + }; + let Some(selected_tools) = selected_tools + .iter() + .map(|value| value.as_str().map(str::to_string)) + .collect::>>() + else { + continue; + }; + active_selected_tools = Some(selected_tools); + } + _ => {} + } + } + + active_selected_tools + } + async fn previous_model(&self) -> Option { let state = self.state.lock().await; state.previous_model() @@ -4899,6 +4965,10 @@ async fn built_tools( None }; + let app_tools = connectors + .as_ref() + .map(|connectors| filter_codex_apps_mcp_tools(&mcp_tools, connectors)); + if let Some(connectors) = connectors.as_ref() { let skill_name_counts_lower = skills_outcome.map_or_else(HashMap::new, |outcome| { build_skill_name_counts(&outcome.skills, &outcome.disabled_paths).1 @@ -4911,24 +4981,20 @@ async fn built_tools( &skill_name_counts_lower, ); - let mut selected_mcp_tools = - if let Some(selected_tools) = sess.get_mcp_tool_selection().await { - filter_mcp_tools_by_name(&mcp_tools, &selected_tools) - } else { - HashMap::new() - }; + let mut selected_mcp_tools = filter_non_codex_apps_mcp_tools_only(&mcp_tools); - let apps_mcp_tools = - filter_codex_apps_mcp_tools_only(&mcp_tools, explicitly_enabled.as_ref()); - selected_mcp_tools.extend(apps_mcp_tools); + if let Some(selected_tools) = sess.get_mcp_tool_selection().await { + selected_mcp_tools.extend(filter_mcp_tools_by_name(&mcp_tools, &selected_tools)); + } + + selected_mcp_tools.extend(filter_codex_apps_mcp_tools_only( + &mcp_tools, + explicitly_enabled.as_ref(), + )); mcp_tools = selected_mcp_tools; } - let app_tools = connectors - .as_ref() - .map(|connectors| filter_codex_apps_mcp_tools(&mcp_tools, connectors)); - Ok(Arc::new(ToolRouter::from_config( &turn_context.tools_config, Some( @@ -5789,6 +5855,22 @@ mod tests { } } + fn function_call_rollout_item(name: &str, call_id: &str) -> RolloutItem { + RolloutItem::ResponseItem(ResponseItem::FunctionCall { + id: None, + name: name.to_string(), + arguments: "{}".to_string(), + call_id: call_id.to_string(), + }) + } + + fn function_call_output_rollout_item(call_id: &str, output: &str) -> RolloutItem { + RolloutItem::ResponseItem(ResponseItem::FunctionCallOutput { + call_id: call_id.to_string(), + output: FunctionCallOutputPayload::from_text(output.to_string()), + }) + } + #[tokio::test] async fn get_base_instructions_no_user_content() { let prompt_with_apply_patch_instructions = @@ -5978,6 +6060,46 @@ mod tests { assert_eq!(connector_ids, HashSet::::new()); } + #[test] + fn non_app_mcp_tools_remain_visible_without_search_selection() { + let mcp_tools = HashMap::from([ + ( + "mcp__codex_apps__calendar_create_event".to_string(), + make_mcp_tool( + CODEX_APPS_MCP_SERVER_NAME, + "calendar_create_event", + Some("calendar"), + Some("Calendar"), + ), + ), + ( + "mcp__rmcp__echo".to_string(), + make_mcp_tool("rmcp", "echo", None, None), + ), + ]); + + let mut selected_mcp_tools = mcp_tools + .iter() + .filter(|(_, tool)| tool.server_name != CODEX_APPS_MCP_SERVER_NAME) + .map(|(name, tool)| (name.clone(), tool.clone())) + .collect::>(); + + let connectors = connectors::accessible_connectors_from_mcp_tools(&mcp_tools); + let explicitly_enabled_connectors = HashSet::new(); + let connectors = filter_connectors_for_input( + &connectors, + &[user_message("run echo")], + &explicitly_enabled_connectors, + &HashMap::new(), + ); + let apps_mcp_tools = filter_codex_apps_mcp_tools_only(&mcp_tools, &connectors); + selected_mcp_tools.extend(apps_mcp_tools); + + let mut tool_names: Vec = selected_mcp_tools.into_keys().collect(); + tool_names.sort(); + assert_eq!(tool_names, vec!["mcp__rmcp__echo".to_string()]); + } + #[test] fn search_tool_selection_keeps_codex_apps_tools_without_mentions() { let selected_tool_names = vec![ @@ -6065,6 +6187,106 @@ mod tests { ); } + #[test] + fn extract_mcp_tool_selection_from_rollout_reads_search_tool_output() { + let rollout_items = vec![ + function_call_rollout_item(SEARCH_TOOL_BM25_TOOL_NAME, "search-1"), + function_call_output_rollout_item( + "search-1", + &json!({ + "active_selected_tools": [ + "mcp__codex_apps__calendar_create_event", + "mcp__codex_apps__calendar_list_events", + ], + }) + .to_string(), + ), + ]; + + let selected = Session::extract_mcp_tool_selection_from_rollout(&rollout_items); + assert_eq!( + selected, + Some(vec![ + "mcp__codex_apps__calendar_create_event".to_string(), + "mcp__codex_apps__calendar_list_events".to_string(), + ]) + ); + } + + #[test] + fn extract_mcp_tool_selection_from_rollout_latest_valid_payload_wins() { + let rollout_items = vec![ + function_call_rollout_item(SEARCH_TOOL_BM25_TOOL_NAME, "search-1"), + function_call_output_rollout_item( + "search-1", + &json!({ + "active_selected_tools": ["mcp__codex_apps__calendar_create_event"], + }) + .to_string(), + ), + function_call_rollout_item(SEARCH_TOOL_BM25_TOOL_NAME, "search-2"), + function_call_output_rollout_item( + "search-2", + &json!({ + "active_selected_tools": ["mcp__codex_apps__calendar_delete_event"], + }) + .to_string(), + ), + ]; + + let selected = Session::extract_mcp_tool_selection_from_rollout(&rollout_items); + assert_eq!( + selected, + Some(vec!["mcp__codex_apps__calendar_delete_event".to_string(),]) + ); + } + + #[test] + fn extract_mcp_tool_selection_from_rollout_ignores_non_search_and_malformed_payloads() { + let rollout_items = vec![ + function_call_rollout_item("shell", "shell-1"), + function_call_output_rollout_item( + "shell-1", + &json!({ + "active_selected_tools": ["mcp__codex_apps__should_be_ignored"], + }) + .to_string(), + ), + function_call_rollout_item(SEARCH_TOOL_BM25_TOOL_NAME, "search-1"), + function_call_output_rollout_item("search-1", "{not-json"), + function_call_output_rollout_item( + "unknown-search-call", + &json!({ + "active_selected_tools": ["mcp__codex_apps__also_ignored"], + }) + .to_string(), + ), + function_call_output_rollout_item( + "search-1", + &json!({ + "active_selected_tools": ["mcp__codex_apps__calendar_list_events"], + }) + .to_string(), + ), + ]; + + let selected = Session::extract_mcp_tool_selection_from_rollout(&rollout_items); + assert_eq!( + selected, + Some(vec!["mcp__codex_apps__calendar_list_events".to_string(),]) + ); + } + + #[test] + fn extract_mcp_tool_selection_from_rollout_returns_none_without_valid_search_output() { + let rollout_items = vec![function_call_rollout_item( + SEARCH_TOOL_BM25_TOOL_NAME, + "search-1", + )]; + let selected = Session::extract_mcp_tool_selection_from_rollout(&rollout_items); + assert_eq!(selected, None); + } + #[tokio::test] async fn reconstruct_history_matches_live_compactions() { let (session, turn_context) = make_session_and_context().await; diff --git a/codex-rs/core/src/mcp_connection_manager.rs b/codex-rs/core/src/mcp_connection_manager.rs index 36f0cbb6cb..5a3c4d6e5c 100644 --- a/codex-rs/core/src/mcp_connection_manager.rs +++ b/codex-rs/core/src/mcp_connection_manager.rs @@ -908,6 +908,16 @@ pub(crate) fn filter_codex_apps_mcp_tools_only( .collect() } +pub(crate) fn filter_non_codex_apps_mcp_tools_only( + mcp_tools: &HashMap, +) -> HashMap { + mcp_tools + .iter() + .filter(|(_, tool)| tool.server_name != CODEX_APPS_MCP_SERVER_NAME) + .map(|(name, tool)| (name.clone(), tool.clone())) + .collect() +} + pub(crate) fn filter_mcp_tools_by_name( mcp_tools: &HashMap, selected_tools: &[String], diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index 70fa78a280..c86f0cdfd2 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -170,6 +170,27 @@ impl SessionState { merged } + pub(crate) fn set_mcp_tool_selection(&mut self, tool_names: Vec) { + if tool_names.is_empty() { + self.active_mcp_tool_selection = None; + return; + } + + let mut selected = Vec::new(); + let mut seen = HashSet::new(); + for tool_name in tool_names { + if seen.insert(tool_name.clone()) { + selected.push(tool_name); + } + } + + self.active_mcp_tool_selection = if selected.is_empty() { + None + } else { + Some(selected) + }; + } + pub(crate) fn get_mcp_tool_selection(&self) -> Option> { self.active_mcp_tool_selection.clone() } @@ -293,6 +314,40 @@ mod tests { assert_eq!(state.get_mcp_tool_selection(), None); } + #[tokio::test] + async fn set_mcp_tool_selection_deduplicates_and_preserves_order() { + let session_configuration = make_session_configuration_for_tests().await; + let mut state = SessionState::new(session_configuration); + state.merge_mcp_tool_selection(vec!["mcp__rmcp__old".to_string()]); + + state.set_mcp_tool_selection(vec![ + "mcp__rmcp__echo".to_string(), + "mcp__rmcp__image".to_string(), + "mcp__rmcp__echo".to_string(), + "mcp__rmcp__search".to_string(), + ]); + + assert_eq!( + state.get_mcp_tool_selection(), + Some(vec![ + "mcp__rmcp__echo".to_string(), + "mcp__rmcp__image".to_string(), + "mcp__rmcp__search".to_string(), + ]) + ); + } + + #[tokio::test] + async fn set_mcp_tool_selection_empty_input_clears_selection() { + let session_configuration = make_session_configuration_for_tests().await; + let mut state = SessionState::new(session_configuration); + state.merge_mcp_tool_selection(vec!["mcp__rmcp__echo".to_string()]); + + state.set_mcp_tool_selection(Vec::new()); + + assert_eq!(state.get_mcp_tool_selection(), None); + } + #[tokio::test] // Verifies connector merging deduplicates repeated IDs. async fn merge_connector_selection_deduplicates_entries() { diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index e42315719d..eb04619ee9 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -120,7 +120,6 @@ impl Session { task: T, ) { self.abort_all_tasks(TurnAbortReason::Replaced).await; - self.clear_mcp_tool_selection().await; self.clear_connector_selection().await; self.seed_initial_context_if_needed(turn_context.as_ref()) .await; diff --git a/codex-rs/core/src/tools/handlers/mod.rs b/codex-rs/core/src/tools/handlers/mod.rs index d07d2e68bf..9f4a1a16ce 100644 --- a/codex-rs/core/src/tools/handlers/mod.rs +++ b/codex-rs/core/src/tools/handlers/mod.rs @@ -33,6 +33,7 @@ pub use read_file::ReadFileHandler; pub use request_user_input::RequestUserInputHandler; pub(crate) use request_user_input::request_user_input_tool_description; pub(crate) use search_tool_bm25::DEFAULT_LIMIT as SEARCH_TOOL_BM25_DEFAULT_LIMIT; +pub(crate) use search_tool_bm25::SEARCH_TOOL_BM25_TOOL_NAME; pub use search_tool_bm25::SearchToolBm25Handler; pub use shell::ShellCommandHandler; pub use shell::ShellHandler; diff --git a/codex-rs/core/src/tools/handlers/search_tool_bm25.rs b/codex-rs/core/src/tools/handlers/search_tool_bm25.rs index 37fb34267a..e535abac91 100644 --- a/codex-rs/core/src/tools/handlers/search_tool_bm25.rs +++ b/codex-rs/core/src/tools/handlers/search_tool_bm25.rs @@ -10,7 +10,6 @@ use std::collections::HashMap; use std::collections::HashSet; use crate::connectors; -use crate::features::Feature; use crate::function_tool::FunctionCallError; use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; use crate::mcp_connection_manager::ToolInfo; @@ -23,6 +22,7 @@ use crate::tools::registry::ToolKind; pub struct SearchToolBm25Handler; +pub(crate) const SEARCH_TOOL_BM25_TOOL_NAME: &str = "search_tool_bm25"; pub(crate) const DEFAULT_LIMIT: usize = 8; fn default_limit() -> usize { @@ -42,7 +42,6 @@ struct ToolEntry { server_name: String, title: Option, description: Option, - connector_id: Option, connector_name: Option, input_keys: Vec, search_text: String, @@ -66,7 +65,6 @@ impl ToolEntry { .tool .description .map(|description| description.to_string()), - connector_id: info.connector_id, connector_name: info.connector_name, input_keys, search_text, @@ -91,9 +89,9 @@ impl ToolHandler for SearchToolBm25Handler { let arguments = match payload { ToolPayload::Function { arguments } => arguments, _ => { - return Err(FunctionCallError::Fatal( - "search_tool_bm25 handler received unsupported payload".to_string(), - )); + return Err(FunctionCallError::Fatal(format!( + "{SEARCH_TOOL_BM25_TOOL_NAME} handler received unsupported payload" + ))); } }; @@ -120,15 +118,12 @@ impl ToolHandler for SearchToolBm25Handler { .await .list_all_tools() .await; - let mcp_tools = if turn.config.features.enabled(Feature::Apps) { - let connectors = connectors::with_app_enabled_state( - connectors::accessible_connectors_from_mcp_tools(&mcp_tools), - &turn.config, - ); - filter_codex_apps_mcp_tools(mcp_tools, &connectors) - } else { - mcp_tools - }; + + let connectors = connectors::with_app_enabled_state( + connectors::accessible_connectors_from_mcp_tools(&mcp_tools), + &turn.config, + ); + let mcp_tools = filter_codex_apps_mcp_tools(mcp_tools, &connectors); let mut entries: Vec = mcp_tools .into_iter() @@ -172,7 +167,6 @@ impl ToolHandler for SearchToolBm25Handler { "server": entry.server_name.clone(), "title": entry.title.clone(), "description": entry.description.clone(), - "connector_id": entry.connector_id.clone(), "connector_name": entry.connector_name.clone(), "input_keys": entry.input_keys.clone(), "score": result.score, @@ -243,12 +237,6 @@ fn build_search_text(name: &str, info: &ToolInfo, input_keys: &[String]) -> Stri parts.push(connector_name.to_string()); } - if let Some(connector_id) = info.connector_id.as_deref() - && !connector_id.trim().is_empty() - { - parts.push(connector_id.to_string()); - } - if !input_keys.is_empty() { parts.extend(input_keys.iter().cloned()); } diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index 06181ae0fb..20b27ccce7 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -8,6 +8,7 @@ use crate::features::Features; use crate::mcp_connection_manager::ToolInfo; use crate::tools::handlers::PLAN_TOOL; use crate::tools::handlers::SEARCH_TOOL_BM25_DEFAULT_LIMIT; +use crate::tools::handlers::SEARCH_TOOL_BM25_TOOL_NAME; use crate::tools::handlers::apply_patch::create_apply_patch_freeform_tool; use crate::tools::handlers::apply_patch::create_apply_patch_json_tool; use crate::tools::handlers::collab::DEFAULT_WAIT_TIMEOUT_MS; @@ -913,7 +914,7 @@ fn create_search_tool_bm25_tool(app_tools: &HashMap) -> ToolSp SEARCH_TOOL_BM25_DESCRIPTION_TEMPLATE.replace("{{app_names}}", app_names.as_str()); ToolSpec::Function(ResponsesApiTool { - name: "search_tool_bm25".to_string(), + name: SEARCH_TOOL_BM25_TOOL_NAME.to_string(), description, strict: false, parameters: JsonSchema::Object { @@ -1507,7 +1508,7 @@ pub(crate) fn build_specs( && let Some(app_tools) = app_tools { builder.push_spec_with_parallel_support(create_search_tool_bm25_tool(&app_tools), true); - builder.register_handler("search_tool_bm25", search_tool_handler); + builder.register_handler(SEARCH_TOOL_BM25_TOOL_NAME, search_tool_handler); } if let Some(apply_patch_tool_type) = &config.apply_patch_tool_type { @@ -2579,7 +2580,7 @@ mod tests { ) .build(); - let search_tool = find_tool(&tools, "search_tool_bm25"); + let search_tool = find_tool(&tools, SEARCH_TOOL_BM25_TOOL_NAME); let ToolSpec::Function(ResponsesApiTool { description, .. }) = &search_tool.spec else { panic!("expected function tool"); }; diff --git a/codex-rs/core/templates/search_tool/tool_description.md b/codex-rs/core/templates/search_tool/tool_description.md index ce4268177d..0c84a6f52e 100644 --- a/codex-rs/core/templates/search_tool/tool_description.md +++ b/codex-rs/core/templates/search_tool/tool_description.md @@ -2,7 +2,7 @@ Searches over apps tool metadata with BM25 and exposes matching tools for the next model call. -When `search_tool_bm25` is available, apps tools (`mcp__codex_apps__...`) are hidden until you search for them. +MCP tools of the apps ({{app_names}}) are hidden until you search for them with this tool (`search_tool_bm25`). Follow this workflow: @@ -10,8 +10,8 @@ Follow this workflow: - `query` (required): focused terms that describe the capability you need. - `limit` (optional): maximum number of tools to return (default `8`). 2. Use the returned `tools` list to decide which Apps tools are relevant. -3. Matching tools are added to available `tools` and available for the remainder of the current turn. -4. Repeated searches in the same turn are additive: new matches are unioned into `tools`. +3. Matching tools are added to available `tools` and available for the remainder of the current session/thread. +4. Repeated searches in the same session/thread are additive: new matches are unioned into `tools`. Notes: - Core tools remain available without searching. @@ -23,8 +23,6 @@ Notes: - `title` - `description` - `connector_name` - - `connector_id` - input schema property keys (`input_keys`) -- Use `search_tool_bm25` when the user asks to work with one of apps ({{app_names}}) and the exact tool name is not already known. - If the needed app is already explicit in the prompt (for example an `apps://...` mention) or already present in the current `tools` list, you can call that tool directly. -- Do not use `search_tool_bm25` for non-apps/local tasks (filesystem, repo search, or shell-only workflows). +- Do not use `search_tool_bm25` for non-apps/local tasks (filesystem, repo search, or shell-only workflows) or anything not related to {{app_names}}. diff --git a/codex-rs/core/tests/common/apps_test_server.rs b/codex-rs/core/tests/common/apps_test_server.rs new file mode 100644 index 0000000000..c562e390e1 --- /dev/null +++ b/codex-rs/core/tests/common/apps_test_server.rs @@ -0,0 +1,158 @@ +use anyhow::Result; +use serde_json::Value; +use serde_json::json; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::Request; +use wiremock::Respond; +use wiremock::ResponseTemplate; +use wiremock::matchers::method; +use wiremock::matchers::path; +use wiremock::matchers::path_regex; + +const CONNECTOR_ID: &str = "calendar"; +const CONNECTOR_NAME: &str = "Calendar"; +const PROTOCOL_VERSION: &str = "2025-11-25"; +const SERVER_NAME: &str = "codex-apps-test"; +const SERVER_VERSION: &str = "1.0.0"; + +#[derive(Clone)] +pub struct AppsTestServer { + pub chatgpt_base_url: String, +} + +impl AppsTestServer { + pub async fn mount(server: &MockServer) -> Result { + mount_oauth_metadata(server).await; + mount_streamable_http_json_rpc(server).await; + Ok(Self { + chatgpt_base_url: server.uri(), + }) + } +} + +async fn mount_oauth_metadata(server: &MockServer) { + Mock::given(method("GET")) + .and(path("/.well-known/oauth-authorization-server/mcp")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "authorization_endpoint": format!("{}/oauth/authorize", server.uri()), + "token_endpoint": format!("{}/oauth/token", server.uri()), + "scopes_supported": [""], + }))) + .mount(server) + .await; +} + +async fn mount_streamable_http_json_rpc(server: &MockServer) { + Mock::given(method("POST")) + .and(path_regex("^/api/codex/apps/?$")) + .respond_with(CodexAppsJsonRpcResponder) + .mount(server) + .await; +} + +struct CodexAppsJsonRpcResponder; + +impl Respond for CodexAppsJsonRpcResponder { + fn respond(&self, request: &Request) -> ResponseTemplate { + let body: Value = match serde_json::from_slice(&request.body) { + Ok(body) => body, + Err(error) => { + return ResponseTemplate::new(400).set_body_json(json!({ + "error": format!("invalid JSON-RPC body: {error}"), + })); + } + }; + + let Some(method) = body.get("method").and_then(Value::as_str) else { + return ResponseTemplate::new(400).set_body_json(json!({ + "error": "missing method in JSON-RPC request", + })); + }; + + match method { + "initialize" => { + let id = body.get("id").cloned().unwrap_or(Value::Null); + let protocol_version = body + .pointer("/params/protocolVersion") + .and_then(Value::as_str) + .unwrap_or(PROTOCOL_VERSION); + ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", + "id": id, + "result": { + "protocolVersion": protocol_version, + "capabilities": { + "tools": { + "listChanged": true + } + }, + "serverInfo": { + "name": SERVER_NAME, + "version": SERVER_VERSION + } + } + })) + } + "notifications/initialized" => ResponseTemplate::new(202), + "tools/list" => { + let id = body.get("id").cloned().unwrap_or(Value::Null); + ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", + "id": id, + "result": { + "tools": [ + { + "name": "calendar_create_event", + "description": "Create a calendar event.", + "inputSchema": { + "type": "object", + "properties": { + "title": { "type": "string" }, + "starts_at": { "type": "string" }, + "timezone": { "type": "string" } + }, + "required": ["title", "starts_at"], + "additionalProperties": false + }, + "_meta": { + "connector_id": CONNECTOR_ID, + "connector_name": CONNECTOR_NAME + } + }, + { + "name": "calendar_list_events", + "description": "List calendar events.", + "inputSchema": { + "type": "object", + "properties": { + "query": { "type": "string" }, + "limit": { "type": "integer" } + }, + "additionalProperties": false + }, + "_meta": { + "connector_id": CONNECTOR_ID, + "connector_name": CONNECTOR_NAME + } + } + ], + "nextCursor": null + } + })) + } + method if method.starts_with("notifications/") => ResponseTemplate::new(202), + _ => { + let id = body.get("id").cloned().unwrap_or(Value::Null); + ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", + "id": id, + "error": { + "code": -32601, + "message": format!("method not found: {method}") + } + })) + } + } + } +} diff --git a/codex-rs/core/tests/common/lib.rs b/codex-rs/core/tests/common/lib.rs index 79bf9369e0..d2f9c9ac59 100644 --- a/codex-rs/core/tests/common/lib.rs +++ b/codex-rs/core/tests/common/lib.rs @@ -12,6 +12,7 @@ use codex_utils_absolute_path::AbsolutePathBuf; use regex_lite::Regex; use std::path::PathBuf; +pub mod apps_test_server; pub mod context_snapshot; pub mod process; pub mod responses; diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 7c6799df92..bbdbc73233 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -34,6 +34,7 @@ use codex_protocol::models::ReasoningItemReasoningSummary; use codex_protocol::models::WebSearchAction; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::user_input::UserInput; +use core_test_support::apps_test_server::AppsTestServer; use core_test_support::load_default_config_for_test; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_completed_with_tokens; @@ -671,6 +672,10 @@ async fn includes_user_instructions_message_in_request() { async fn includes_apps_guidance_as_developer_message_when_enabled() { skip_if_no_network!(); let server = MockServer::start().await; + let apps_server = AppsTestServer::mount(&server) + .await + .expect("mount apps MCP mock"); + let apps_base_url = apps_server.chatgpt_base_url.clone(); let resp_mock = mount_sse_once( &server, @@ -680,8 +685,10 @@ async fn includes_apps_guidance_as_developer_message_when_enabled() { let mut builder = test_codex() .with_auth(CodexAuth::from_api_key("Test API Key")) - .with_config(|config| { + .with_config(move |config| { config.features.enable(Feature::Apps); + config.features.disable(Feature::AppsMcpGateway); + config.chatgpt_base_url = apps_base_url; }); let codex = builder .build(&server) diff --git a/codex-rs/core/tests/suite/search_tool.rs b/codex-rs/core/tests/suite/search_tool.rs index d40c12a427..7d2de4eb9a 100644 --- a/codex-rs/core/tests/suite/search_tool.rs +++ b/codex-rs/core/tests/suite/search_tool.rs @@ -1,14 +1,22 @@ #![cfg(not(target_os = "windows"))] #![allow(clippy::unwrap_used, clippy::expect_used)] +use std::sync::Arc; use std::time::Duration; use anyhow::Result; +use codex_core::CodexThread; +use codex_core::NewThread; +use codex_core::config::Config; use codex_core::config::types::McpServerConfig; use codex_core::config::types::McpServerTransportConfig; use codex_core::features::Feature; use codex_core::protocol::AskForApproval; +use codex_core::protocol::EventMsg; +use codex_core::protocol::Op; use codex_core::protocol::SandboxPolicy; +use codex_protocol::user_input::UserInput; +use core_test_support::apps_test_server::AppsTestServer; use core_test_support::responses::ResponsesRequest; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; @@ -19,15 +27,24 @@ 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::stdio_server_bin; +use core_test_support::test_codex::TestCodexBuilder; 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; const SEARCH_TOOL_INSTRUCTION_SNIPPETS: [&str; 2] = [ - "apps tools (`mcp__codex_apps__...`) are hidden until you search for them.", - "Matching tools are added to available `tools` and available for the remainder of the current turn.", + "MCP tools of the apps (Calendar) are hidden until you search for them with this tool", + "Matching tools are added to available `tools` and available for the remainder of the current session/thread.", ]; +const SEARCH_TOOL_BM25_TOOL_NAME: &str = "search_tool_bm25"; +const CALENDAR_CREATE_TOOL: &str = "mcp__codex_apps__calendar_create_event"; +const CALENDAR_LIST_TOOL: &str = "mcp__codex_apps__calendar_list_events"; +const RMCP_ECHO_TOOL: &str = "mcp__rmcp__echo"; +const RMCP_IMAGE_TOOL: &str = "mcp__rmcp__image"; +const CALENDAR_CREATE_QUERY: &str = "create calendar event"; +const CALENDAR_LIST_QUERY: &str = "list calendar events"; fn tool_names(body: &Value) -> Vec { body.get("tools") @@ -51,7 +68,7 @@ fn search_tool_description(body: &Value) -> Option { .and_then(Value::as_array) .and_then(|tools| { tools.iter().find_map(|tool| { - if tool.get("name").and_then(Value::as_str) == Some("search_tool_bm25") { + if tool.get("name").and_then(Value::as_str) == Some(SEARCH_TOOL_BM25_TOOL_NAME) { tool.get("description") .and_then(Value::as_str) .map(str::to_string) @@ -65,9 +82,13 @@ fn search_tool_description(body: &Value) -> Option { fn search_tool_output_payload(request: &ResponsesRequest, call_id: &str) -> Value { let (content, _success) = request .function_call_output_content_and_success(call_id) - .expect("search_tool_bm25 function_call_output should be present"); - let content = content.expect("search_tool_bm25 output should include content"); - serde_json::from_str(&content).expect("search_tool_bm25 content should be valid JSON") + .unwrap_or_else(|| { + panic!("{SEARCH_TOOL_BM25_TOOL_NAME} function_call_output should be present") + }); + let content = content + .unwrap_or_else(|| panic!("{SEARCH_TOOL_BM25_TOOL_NAME} output should include content")); + serde_json::from_str(&content) + .unwrap_or_else(|_| panic!("{SEARCH_TOOL_BM25_TOOL_NAME} content should be valid JSON")) } fn active_selected_tools(payload: &Value) -> Vec { @@ -95,11 +116,70 @@ fn search_result_tools(payload: &Value) -> Vec<&Value> { .collect() } +fn rmcp_server_config(command: String) -> McpServerConfig { + McpServerConfig { + transport: McpServerTransportConfig::Stdio { + command, + args: Vec::new(), + env: None, + env_vars: Vec::new(), + cwd: None, + }, + enabled: true, + required: false, + disabled_reason: None, + startup_timeout_sec: Some(Duration::from_secs(10)), + tool_timeout_sec: None, + enabled_tools: None, + disabled_tools: None, + scopes: None, + } +} + +fn configure_apps_with_optional_rmcp( + config: &mut Config, + apps_base_url: &str, + rmcp_server_bin: Option, +) { + config.features.enable(Feature::Apps); + config.features.disable(Feature::AppsMcpGateway); + config.chatgpt_base_url = apps_base_url.to_string(); + if let Some(command) = rmcp_server_bin { + let mut servers = config.mcp_servers.get().clone(); + servers.insert("rmcp".to_string(), rmcp_server_config(command)); + config + .mcp_servers + .set(servers) + .expect("test mcp servers should accept any configuration"); + } +} + +fn configured_builder(apps_base_url: String, rmcp_server_bin: Option) -> TestCodexBuilder { + test_codex().with_config(move |config| { + configure_apps_with_optional_rmcp(config, apps_base_url.as_str(), rmcp_server_bin); + }) +} + +async fn submit_user_input(thread: &Arc, text: &str) -> Result<()> { + thread + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: text.to_string(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }) + .await?; + wait_for_event(thread, |event| matches!(event, EventMsg::TurnComplete(_))).await; + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn search_tool_flag_adds_tool() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; + let apps_server = AppsTestServer::mount(&server).await?; let mock = mount_sse_sequence( &server, vec![sse(vec![ @@ -110,9 +190,7 @@ async fn search_tool_flag_adds_tool() -> Result<()> { ) .await; - let mut builder = test_codex().with_config(|config| { - config.features.enable(Feature::Apps); - }); + let mut builder = configured_builder(apps_server.chatgpt_base_url.clone(), None); let test = builder.build(&server).await?; test.submit_turn_with_policies( @@ -125,8 +203,8 @@ async fn search_tool_flag_adds_tool() -> Result<()> { let body = mock.single_request().body_json(); let tools = tool_names(&body); assert!( - tools.iter().any(|name| name == "search_tool_bm25"), - "tools list should include search_tool_bm25 when enabled: {tools:?}" + tools.iter().any(|name| name == SEARCH_TOOL_BM25_TOOL_NAME), + "tools list should include {SEARCH_TOOL_BM25_TOOL_NAME} when enabled: {tools:?}" ); Ok(()) @@ -137,6 +215,7 @@ async fn search_tool_adds_discovery_instructions_to_tool_description() -> Result skip_if_no_network!(Ok(())); let server = start_mock_server().await; + let apps_server = AppsTestServer::mount(&server).await?; let mock = mount_sse_sequence( &server, vec![sse(vec![ @@ -147,9 +226,7 @@ async fn search_tool_adds_discovery_instructions_to_tool_description() -> Result ) .await; - let mut builder = test_codex().with_config(|config| { - config.features.enable(Feature::Apps); - }); + let mut builder = configured_builder(apps_server.chatgpt_base_url.clone(), None); let test = builder.build(&server).await?; test.submit_turn_with_policies( @@ -160,23 +237,24 @@ async fn search_tool_adds_discovery_instructions_to_tool_description() -> Result .await?; let body = mock.single_request().body_json(); - let search_tool_description = - search_tool_description(&body).expect("search tool description should be present"); + let description = search_tool_description(&body).expect("search tool description should exist"); assert!( SEARCH_TOOL_INSTRUCTION_SNIPPETS .iter() - .all(|snippet| search_tool_description.contains(snippet)), - "search tool description should include search tool workflow: {search_tool_description:?}" + .all(|snippet| description.contains(snippet)), + "search tool description should include search tool workflow: {description:?}" ); Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn search_tool_hides_mcp_tools_without_search() -> Result<()> { +async fn search_tool_hides_apps_tools_without_search_but_keeps_non_app_tools_visible() -> Result<()> +{ skip_if_no_network!(Ok(())); let server = start_mock_server().await; + let apps_server = AppsTestServer::mount(&server).await?; let mock = mount_sse_sequence( &server, vec![sse(vec![ @@ -188,34 +266,10 @@ async fn search_tool_hides_mcp_tools_without_search() -> Result<()> { .await; let rmcp_test_server_bin = stdio_server_bin()?; - let mut builder = test_codex().with_config(move |config| { - config.features.enable(Feature::Apps); - let mut servers = config.mcp_servers.get().clone(); - servers.insert( - "rmcp".to_string(), - McpServerConfig { - transport: McpServerTransportConfig::Stdio { - command: rmcp_test_server_bin, - args: Vec::new(), - env: None, - env_vars: Vec::new(), - cwd: None, - }, - enabled: true, - required: false, - disabled_reason: None, - startup_timeout_sec: Some(Duration::from_secs(10)), - tool_timeout_sec: None, - enabled_tools: None, - disabled_tools: None, - scopes: None, - }, - ); - config - .mcp_servers - .set(servers) - .expect("test mcp servers should accept any configuration"); - }); + let mut builder = configured_builder( + apps_server.chatgpt_base_url.clone(), + Some(rmcp_test_server_bin), + ); let test = builder.build(&server).await?; test.submit_turn_with_policies( @@ -228,35 +282,96 @@ async fn search_tool_hides_mcp_tools_without_search() -> Result<()> { let body = mock.single_request().body_json(); let tools = tool_names(&body); assert!( - tools.iter().any(|name| name == "search_tool_bm25"), - "tools list should include search_tool_bm25 when enabled: {tools:?}" + tools.iter().any(|name| name == SEARCH_TOOL_BM25_TOOL_NAME), + "tools list should include {SEARCH_TOOL_BM25_TOOL_NAME}: {tools:?}" ); assert!( - !tools.iter().any(|name| name == "mcp__rmcp__echo"), - "tools list should not include MCP tools before search: {tools:?}" + tools.iter().any(|name| name == RMCP_ECHO_TOOL), + "non-app MCP tools should remain visible in Apps mode: {tools:?}" ); assert!( - !tools.iter().any(|name| name == "mcp__rmcp__image"), - "tools list should not include MCP tools before search: {tools:?}" + tools.iter().any(|name| name == RMCP_IMAGE_TOOL), + "non-app MCP tools should remain visible in Apps mode: {tools:?}" + ); + assert!( + !tools.iter().any(|name| name == CALENDAR_CREATE_TOOL), + "apps tools should stay hidden before search/mention: {tools:?}" + ); + assert!( + !tools.iter().any(|name| name == CALENDAR_LIST_TOOL), + "apps tools should stay hidden before search/mention: {tools:?}" ); Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn search_tool_selection_persists_within_turn_and_resets_next_turn() -> Result<()> { +async fn explicit_app_mentions_expose_apps_tools_without_search() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; + let apps_server = AppsTestServer::mount(&server).await?; + let mock = mount_sse_sequence( + &server, + vec![sse(vec![ + ev_response_created("resp-1"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-1"), + ])], + ) + .await; + + let rmcp_test_server_bin = stdio_server_bin()?; + let mut builder = configured_builder( + apps_server.chatgpt_base_url.clone(), + Some(rmcp_test_server_bin), + ); + let test = builder.build(&server).await?; + + test.submit_turn_with_policies( + "Use [$calendar](app://calendar) and then call tools.", + AskForApproval::Never, + SandboxPolicy::DangerFullAccess, + ) + .await?; + + let body = mock.single_request().body_json(); + let tools = tool_names(&body); + assert!( + tools.iter().any(|name| name == RMCP_ECHO_TOOL), + "non-app tools should remain visible: {tools:?}" + ); + assert!( + tools.iter().any(|name| name == CALENDAR_CREATE_TOOL), + "apps tools should be available after explicit app mention: {tools:?}" + ); + assert!( + tools.iter().any(|name| name == CALENDAR_LIST_TOOL), + "apps tools should be available after explicit app mention: {tools:?}" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn search_tool_selection_persists_across_turns() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let apps_server = AppsTestServer::mount(&server).await?; let call_id = "tool-search"; let args = json!({ - "query": "echo", + "query": CALENDAR_CREATE_QUERY, "limit": 1, }); let responses = vec![ sse(vec![ ev_response_created("resp-1"), - ev_function_call(call_id, "search_tool_bm25", &serde_json::to_string(&args)?), + ev_function_call( + call_id, + SEARCH_TOOL_BM25_TOOL_NAME, + &serde_json::to_string(&args)?, + ), ev_completed("resp-1"), ]), sse(vec![ @@ -271,38 +386,14 @@ async fn search_tool_selection_persists_within_turn_and_resets_next_turn() -> Re let mock = mount_sse_sequence(&server, responses).await; let rmcp_test_server_bin = stdio_server_bin()?; - let mut builder = test_codex().with_config(move |config| { - config.features.enable(Feature::Apps); - let mut servers = config.mcp_servers.get().clone(); - servers.insert( - "rmcp".to_string(), - McpServerConfig { - transport: McpServerTransportConfig::Stdio { - command: rmcp_test_server_bin, - args: Vec::new(), - env: None, - env_vars: Vec::new(), - cwd: None, - }, - enabled: true, - required: false, - disabled_reason: None, - startup_timeout_sec: Some(Duration::from_secs(10)), - tool_timeout_sec: None, - enabled_tools: None, - disabled_tools: None, - scopes: None, - }, - ); - config - .mcp_servers - .set(servers) - .expect("test mcp servers should accept any configuration"); - }); + let mut builder = configured_builder( + apps_server.chatgpt_base_url.clone(), + Some(rmcp_test_server_bin), + ); let test = builder.build(&server).await?; test.submit_turn_with_policies( - "find the echo tool", + "find calendar create tool", AskForApproval::Never, SandboxPolicy::DangerFullAccess, ) @@ -324,18 +415,12 @@ async fn search_tool_selection_persists_within_turn_and_resets_next_turn() -> Re let first_tools = tool_names(&requests[0].body_json()); assert!( - !first_tools.iter().any(|name| name == "mcp__rmcp__echo"), - "first request should not include MCP tools before search: {first_tools:?}" - ); - - let second_tools = tool_names(&requests[1].body_json()); - assert!( - !second_tools.iter().any(|name| name == "mcp__rmcp__echo"), - "second request should not include rmcp MCP tools after search: {second_tools:?}" + first_tools.iter().any(|name| name == RMCP_ECHO_TOOL), + "non-app MCP tools should be available before search: {first_tools:?}" ); assert!( - !second_tools.iter().any(|name| name == "mcp__rmcp__image"), - "second request should not include rmcp MCP tools after search: {second_tools:?}" + !first_tools.iter().any(|name| name == CALENDAR_CREATE_TOOL), + "apps tools should not be visible before search: {first_tools:?}" ); let search_output_payload = search_tool_output_payload(&requests[1], call_id); @@ -350,18 +435,44 @@ async fn search_tool_selection_persists_within_turn_and_resets_next_turn() -> Re "search results should only include codex_apps tools: {search_output_payload:?}" ); } + + let selected_tools = active_selected_tools(&search_output_payload); assert!( - !active_selected_tools(&search_output_payload) + selected_tools + .iter() + .any(|tool| tool == CALENDAR_CREATE_TOOL), + "calendar create tool should be selected: {search_output_payload:?}" + ); + assert!( + !selected_tools .iter() .any(|tool_name| tool_name.starts_with("mcp__rmcp__")), "search should not add rmcp tools to active selection: {search_output_payload:?}" ); + let second_tools = tool_names(&requests[1].body_json()); + assert!( + second_tools.iter().any(|name| name == RMCP_ECHO_TOOL), + "non-app MCP tools should remain visible after search: {second_tools:?}" + ); + for selected_tool in &selected_tools { + assert!( + second_tools.iter().any(|name| name == selected_tool), + "follow-up request should include selected tool {selected_tool:?}: {second_tools:?}" + ); + } + let third_tools = tool_names(&requests[2].body_json()); assert!( - !third_tools.iter().any(|name| name == "mcp__rmcp__echo"), - "third request should not include MCP tools after turn reset: {third_tools:?}" + third_tools.iter().any(|name| name == RMCP_ECHO_TOOL), + "non-app MCP tools should remain visible on later turns: {third_tools:?}" ); + for selected_tool in &selected_tools { + assert!( + third_tools.iter().any(|name| name == selected_tool), + "subsequent turn should include selected tool {selected_tool:?}: {third_tools:?}" + ); + } Ok(()) } @@ -371,14 +482,15 @@ async fn search_tool_selection_unions_results_within_turn() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; - let first_call_id = "tool-search-echo"; - let second_call_id = "tool-search-image"; + let apps_server = AppsTestServer::mount(&server).await?; + let first_call_id = "tool-search-create"; + let second_call_id = "tool-search-list"; let first_args = json!({ - "query": "echo", + "query": CALENDAR_CREATE_QUERY, "limit": 1, }); let second_args = json!({ - "query": "image", + "query": CALENDAR_LIST_QUERY, "limit": 1, }); let responses = vec![ @@ -386,7 +498,7 @@ async fn search_tool_selection_unions_results_within_turn() -> Result<()> { ev_response_created("resp-1"), ev_function_call( first_call_id, - "search_tool_bm25", + SEARCH_TOOL_BM25_TOOL_NAME, &serde_json::to_string(&first_args)?, ), ev_completed("resp-1"), @@ -395,7 +507,7 @@ async fn search_tool_selection_unions_results_within_turn() -> Result<()> { ev_response_created("resp-2"), ev_function_call( second_call_id, - "search_tool_bm25", + SEARCH_TOOL_BM25_TOOL_NAME, &serde_json::to_string(&second_args)?, ), ev_completed("resp-2"), @@ -408,38 +520,14 @@ async fn search_tool_selection_unions_results_within_turn() -> Result<()> { let mock = mount_sse_sequence(&server, responses).await; let rmcp_test_server_bin = stdio_server_bin()?; - let mut builder = test_codex().with_config(move |config| { - config.features.enable(Feature::Apps); - let mut servers = config.mcp_servers.get().clone(); - servers.insert( - "rmcp".to_string(), - McpServerConfig { - transport: McpServerTransportConfig::Stdio { - command: rmcp_test_server_bin, - args: Vec::new(), - env: None, - env_vars: Vec::new(), - cwd: None, - }, - enabled: true, - required: false, - disabled_reason: None, - startup_timeout_sec: Some(Duration::from_secs(10)), - tool_timeout_sec: None, - enabled_tools: None, - disabled_tools: None, - scopes: None, - }, - ); - config - .mcp_servers - .set(servers) - .expect("test mcp servers should accept any configuration"); - }); + let mut builder = configured_builder( + apps_server.chatgpt_base_url.clone(), + Some(rmcp_test_server_bin), + ); let test = builder.build(&server).await?; test.submit_turn_with_policies( - "find echo and image tools", + "find create and list calendar tools", AskForApproval::Never, SandboxPolicy::DangerFullAccess, ) @@ -455,28 +543,16 @@ async fn search_tool_selection_unions_results_within_turn() -> Result<()> { let first_tools = tool_names(&requests[0].body_json()); assert!( - !first_tools.iter().any(|name| name == "mcp__rmcp__echo"), - "first request should not include MCP tools before search: {first_tools:?}" - ); - - let second_tools = tool_names(&requests[1].body_json()); - assert!( - !second_tools.iter().any(|name| name == "mcp__rmcp__echo"), - "second request should not include rmcp tools after first search: {second_tools:?}" + first_tools.iter().any(|name| name == RMCP_ECHO_TOOL), + "non-app MCP tools should be visible before search: {first_tools:?}" ); assert!( - !second_tools.iter().any(|name| name == "mcp__rmcp__image"), - "second request should not include rmcp tools after first search: {second_tools:?}" - ); - - let third_tools = tool_names(&requests[2].body_json()); - assert!( - !third_tools.iter().any(|name| name == "mcp__rmcp__echo"), - "third request should not include rmcp tools: {third_tools:?}" + !first_tools.iter().any(|name| name == CALENDAR_CREATE_TOOL), + "apps tools should be hidden before search: {first_tools:?}" ); assert!( - !third_tools.iter().any(|name| name == "mcp__rmcp__image"), - "third request should not include rmcp tools: {third_tools:?}" + !first_tools.iter().any(|name| name == CALENDAR_LIST_TOOL), + "apps tools should be hidden before search: {first_tools:?}" ); let second_search_payload = search_tool_output_payload(&requests[2], second_call_id); @@ -491,11 +567,495 @@ async fn search_tool_selection_unions_results_within_turn() -> Result<()> { "search results should only include codex_apps tools: {second_search_payload:?}" ); } + + let selected_tools = active_selected_tools(&second_search_payload); + assert_eq!( + selected_tools, + vec![ + CALENDAR_CREATE_TOOL.to_string(), + CALENDAR_LIST_TOOL.to_string(), + ], + "two searches in one turn should union selected apps tools" + ); + + let third_tools = tool_names(&requests[2].body_json()); assert!( - !active_selected_tools(&second_search_payload) - .iter() - .any(|tool_name| tool_name.starts_with("mcp__rmcp__")), - "search should not add rmcp tools to active selection: {second_search_payload:?}" + third_tools.iter().any(|name| name == RMCP_ECHO_TOOL), + "non-app MCP tools should remain visible after repeated search: {third_tools:?}" + ); + assert!( + third_tools.iter().any(|name| name == CALENDAR_CREATE_TOOL), + "calendar create should be available after repeated search: {third_tools:?}" + ); + assert!( + third_tools.iter().any(|name| name == CALENDAR_LIST_TOOL), + "calendar list should be available after repeated search: {third_tools:?}" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn search_tool_selection_restores_when_resumed() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let apps_server = AppsTestServer::mount(&server).await?; + let call_id = "tool-search"; + let args = json!({ + "query": CALENDAR_CREATE_QUERY, + "limit": 1, + }); + let responses = vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call( + call_id, + SEARCH_TOOL_BM25_TOOL_NAME, + &serde_json::to_string(&args)?, + ), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + sse(vec![ + ev_response_created("resp-3"), + ev_assistant_message("msg-2", "resumed done"), + ev_completed("resp-3"), + ]), + ]; + let mock = mount_sse_sequence(&server, responses).await; + + let rmcp_test_server_bin = stdio_server_bin()?; + let mut builder = configured_builder( + apps_server.chatgpt_base_url.clone(), + Some(rmcp_test_server_bin.clone()), + ); + let test = builder.build(&server).await?; + + let home = test.home.clone(); + let rollout_path = test + .session_configured + .rollout_path + .clone() + .expect("rollout path should be available for resume"); + + test.submit_turn_with_policies( + "find calendar tools", + AskForApproval::Never, + SandboxPolicy::DangerFullAccess, + ) + .await?; + + let requests = mock.requests(); + assert_eq!( + requests.len(), + 2, + "expected 2 requests after initial turn, got {}", + requests.len() + ); + let search_output_payload = search_tool_output_payload(&requests[1], call_id); + let selected_tools = active_selected_tools(&search_output_payload); + assert!( + selected_tools + .iter() + .any(|name| name == CALENDAR_CREATE_TOOL), + "search should select calendar create before resume: {search_output_payload:?}" + ); + + let mut resume_builder = configured_builder( + apps_server.chatgpt_base_url.clone(), + Some(rmcp_test_server_bin), + ); + let resumed = resume_builder.resume(&server, home, rollout_path).await?; + resumed + .submit_turn_with_policies( + "hello after resume", + AskForApproval::Never, + SandboxPolicy::DangerFullAccess, + ) + .await?; + + let requests = mock.requests(); + assert_eq!( + requests.len(), + 3, + "expected 3 requests after resumed turn, got {}", + requests.len() + ); + let resumed_tools = tool_names(&requests[2].body_json()); + assert!( + resumed_tools.iter().any(|name| name == RMCP_ECHO_TOOL), + "non-app tools should remain visible after resume: {resumed_tools:?}" + ); + for selected_tool in &selected_tools { + assert!( + resumed_tools.iter().any(|name| name == selected_tool), + "resumed request should include restored selected tool {selected_tool:?}: {resumed_tools:?}" + ); + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn search_tool_selection_union_restores_when_resumed_after_multiple_search_calls() +-> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let apps_server = AppsTestServer::mount(&server).await?; + let first_call_id = "tool-search-create"; + let second_call_id = "tool-search-list"; + let first_args = json!({ + "query": CALENDAR_CREATE_QUERY, + "limit": 1, + }); + let second_args = json!({ + "query": CALENDAR_LIST_QUERY, + "limit": 1, + }); + let responses = vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call( + first_call_id, + SEARCH_TOOL_BM25_TOOL_NAME, + &serde_json::to_string(&first_args)?, + ), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-1", "first search done"), + ev_completed("resp-2"), + ]), + sse(vec![ + ev_response_created("resp-3"), + ev_function_call( + second_call_id, + SEARCH_TOOL_BM25_TOOL_NAME, + &serde_json::to_string(&second_args)?, + ), + ev_completed("resp-3"), + ]), + sse(vec![ + ev_response_created("resp-4"), + ev_assistant_message("msg-2", "second search done"), + ev_completed("resp-4"), + ]), + sse(vec![ + ev_response_created("resp-5"), + ev_assistant_message("msg-3", "resumed done"), + ev_completed("resp-5"), + ]), + ]; + let mock = mount_sse_sequence(&server, responses).await; + + let rmcp_test_server_bin = stdio_server_bin()?; + let mut builder = configured_builder( + apps_server.chatgpt_base_url.clone(), + Some(rmcp_test_server_bin.clone()), + ); + let test = builder.build(&server).await?; + + let home = test.home.clone(); + let rollout_path = test + .session_configured + .rollout_path + .clone() + .expect("rollout path should be available for resume"); + + test.submit_turn_with_policies( + "find create calendar tool", + AskForApproval::Never, + SandboxPolicy::DangerFullAccess, + ) + .await?; + test.submit_turn_with_policies( + "find list calendar tool", + AskForApproval::Never, + SandboxPolicy::DangerFullAccess, + ) + .await?; + + let requests = mock.requests(); + assert_eq!( + requests.len(), + 4, + "expected 4 requests before resume, got {}", + requests.len() + ); + + let first_search_payload = search_tool_output_payload(&requests[1], first_call_id); + let first_result_tools = search_result_tools(&first_search_payload); + assert_eq!( + first_result_tools.len(), + 1, + "first search should return exactly one tool: {first_search_payload:?}" + ); + assert_eq!( + first_result_tools[0].get("name").and_then(Value::as_str), + Some(CALENDAR_CREATE_TOOL), + "first search should return calendar create tool: {first_search_payload:?}" + ); + let first_selected_tools = active_selected_tools(&first_search_payload); + assert_eq!( + first_selected_tools, + vec![CALENDAR_CREATE_TOOL.to_string()], + "first search should only select create tool: {first_search_payload:?}" + ); + + let second_search_payload = search_tool_output_payload(&requests[3], second_call_id); + let second_result_tools = search_result_tools(&second_search_payload); + assert_eq!( + second_result_tools.len(), + 1, + "second search should return exactly one tool: {second_search_payload:?}" + ); + assert_eq!( + second_result_tools[0].get("name").and_then(Value::as_str), + Some(CALENDAR_LIST_TOOL), + "second search should return calendar list tool: {second_search_payload:?}" + ); + let second_selected_tools = active_selected_tools(&second_search_payload); + assert_eq!( + second_selected_tools, + vec![ + CALENDAR_CREATE_TOOL.to_string(), + CALENDAR_LIST_TOOL.to_string(), + ], + "multiple searches should persist union before resume: {second_search_payload:?}" + ); + + let mut resume_builder = configured_builder( + apps_server.chatgpt_base_url.clone(), + Some(rmcp_test_server_bin), + ); + let resumed = resume_builder.resume(&server, home, rollout_path).await?; + resumed + .submit_turn_with_policies( + "hello after resume with union", + AskForApproval::Never, + SandboxPolicy::DangerFullAccess, + ) + .await?; + + let requests = mock.requests(); + assert_eq!( + requests.len(), + 5, + "expected 5 requests after resumed turn, got {}", + requests.len() + ); + + let resumed_tools = tool_names(&requests[4].body_json()); + assert!( + resumed_tools.iter().any(|name| name == RMCP_ECHO_TOOL), + "non-app tools should remain visible after resume: {resumed_tools:?}" + ); + assert!( + resumed_tools + .iter() + .any(|name| name == CALENDAR_CREATE_TOOL), + "resumed turn should restore calendar create tool: {resumed_tools:?}" + ); + assert!( + resumed_tools.iter().any(|name| name == CALENDAR_LIST_TOOL), + "resumed turn should restore calendar list tool: {resumed_tools:?}" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn search_tool_selection_restores_when_forked_with_full_history() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let apps_server = AppsTestServer::mount(&server).await?; + let call_id = "tool-search"; + let args = json!({ + "query": CALENDAR_CREATE_QUERY, + "limit": 1, + }); + let responses = vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call( + call_id, + SEARCH_TOOL_BM25_TOOL_NAME, + &serde_json::to_string(&args)?, + ), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + sse(vec![ + ev_response_created("resp-3"), + ev_assistant_message("msg-2", "forked done"), + ev_completed("resp-3"), + ]), + ]; + let mock = mount_sse_sequence(&server, responses).await; + + let rmcp_test_server_bin = stdio_server_bin()?; + let mut builder = configured_builder( + apps_server.chatgpt_base_url.clone(), + Some(rmcp_test_server_bin), + ); + let test = builder.build(&server).await?; + + test.submit_turn_with_policies( + "find calendar tools", + AskForApproval::Never, + SandboxPolicy::DangerFullAccess, + ) + .await?; + + let requests = mock.requests(); + assert_eq!( + requests.len(), + 2, + "expected 2 requests after initial turn, got {}", + requests.len() + ); + let search_output_payload = search_tool_output_payload(&requests[1], call_id); + let selected_tools = active_selected_tools(&search_output_payload); + assert!( + selected_tools + .iter() + .any(|name| name == CALENDAR_CREATE_TOOL), + "search should select calendar create: {search_output_payload:?}" + ); + + let rollout_path = test + .codex + .rollout_path() + .expect("rollout path should exist for fork"); + let NewThread { thread: forked, .. } = test + .thread_manager + .fork_thread(usize::MAX, test.config.clone(), rollout_path, false) + .await?; + submit_user_input(&forked, "hello after fork").await?; + + let requests = mock.requests(); + assert_eq!( + requests.len(), + 3, + "expected 3 requests after forked turn, got {}", + requests.len() + ); + let forked_tools = tool_names(&requests[2].body_json()); + assert!( + forked_tools.iter().any(|name| name == RMCP_ECHO_TOOL), + "non-app tools should remain visible in forked thread: {forked_tools:?}" + ); + for selected_tool in &selected_tools { + assert!( + forked_tools.iter().any(|name| name == selected_tool), + "forked request should include restored selected tool {selected_tool:?}: {forked_tools:?}" + ); + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn search_tool_selection_drops_when_fork_excludes_search_turn() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let apps_server = AppsTestServer::mount(&server).await?; + let call_id = "tool-search"; + let args = json!({ + "query": CALENDAR_CREATE_QUERY, + "limit": 1, + }); + let responses = vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call( + call_id, + SEARCH_TOOL_BM25_TOOL_NAME, + &serde_json::to_string(&args)?, + ), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + sse(vec![ + ev_response_created("resp-3"), + ev_assistant_message("msg-2", "forked done"), + ev_completed("resp-3"), + ]), + ]; + let mock = mount_sse_sequence(&server, responses).await; + + let rmcp_test_server_bin = stdio_server_bin()?; + let mut builder = configured_builder( + apps_server.chatgpt_base_url.clone(), + Some(rmcp_test_server_bin), + ); + let test = builder.build(&server).await?; + + test.submit_turn_with_policies( + "find calendar tools", + AskForApproval::Never, + SandboxPolicy::DangerFullAccess, + ) + .await?; + + let requests = mock.requests(); + assert_eq!( + requests.len(), + 2, + "expected 2 requests after initial turn, got {}", + requests.len() + ); + let search_output_payload = search_tool_output_payload(&requests[1], call_id); + let selected_tools = active_selected_tools(&search_output_payload); + assert!( + !selected_tools.is_empty(), + "search turn should produce selected tools: {search_output_payload:?}" + ); + + let rollout_path = test + .codex + .rollout_path() + .expect("rollout path should exist for fork"); + let NewThread { thread: forked, .. } = test + .thread_manager + .fork_thread(0, test.config.clone(), rollout_path, false) + .await?; + submit_user_input(&forked, "hello after fork").await?; + + let requests = mock.requests(); + assert_eq!( + requests.len(), + 3, + "expected 3 requests after forked turn, got {}", + requests.len() + ); + let forked_tools = tool_names(&requests[2].body_json()); + assert!( + forked_tools.iter().any(|name| name == RMCP_ECHO_TOOL), + "non-app tools should remain visible in forked thread: {forked_tools:?}" + ); + assert!( + !forked_tools + .iter() + .any(|name| name.starts_with("mcp__codex_apps__")), + "forked history without search turn should not restore apps tools: {forked_tools:?}" ); Ok(()) From bdea9974d9f41bc295defc1b7c4c0b4739913444 Mon Sep 17 00:00:00 2001 From: sayan-oai Date: Sun, 15 Feb 2026 21:18:35 -0800 Subject: [PATCH 21/62] fix: only emit unknown model warning on user turns (#11884) ###### Context unknown model warning added in #11690 has [issues](https://github.com/openai/codex/actions/runs/22047424710/job/63700733887) on ubuntu runners because we potentially emit it on all new turns, including ones with intentionally fake models (i.e., `mock-model` in a test). ###### Fix change the warning to only emit on user turns/review turns. ###### Tests CI now passes on ubuntu, still passes locally --- codex-rs/core/src/codex.rs | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 437fd9e803..502d307487 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -1846,11 +1846,13 @@ impl Session { } } - let resolved_model_slug = session_configuration.collaboration_mode.model().to_string(); let model_info = self .services .models_manager - .get_model_info(resolved_model_slug.as_str(), &per_turn_config) + .get_model_info( + session_configuration.collaboration_mode.model(), + &per_turn_config, + ) .await; let mut turn_context: TurnContext = Self::make_turn_context( Some(Arc::clone(&self.services.auth_manager)), @@ -1858,7 +1860,7 @@ impl Session { session_configuration.provider.clone(), &session_configuration, per_turn_config, - model_info.clone(), + model_info, self.services .network_proxy .as_ref() @@ -1871,19 +1873,23 @@ impl Session { turn_context.final_output_json_schema = final_schema; } let turn_context = Arc::new(turn_context); - if model_info.used_fallback_model_metadata { + turn_context.turn_metadata_state.spawn_git_enrichment_task(); + turn_context + } + + pub(crate) async fn maybe_emit_unknown_model_warning_for_turn(&self, tc: &TurnContext) { + if tc.model_info.used_fallback_model_metadata { self.send_event( - turn_context.as_ref(), + tc, EventMsg::Warning(WarningEvent { message: format!( - "Model metadata for `{resolved_model_slug}` not found. Defaulting to fallback metadata; this can degrade performance and cause issues." + "Model metadata for `{}` not found. Defaulting to fallback metadata; this can degrade performance and cause issues.", + tc.model_info.slug ), }), ) .await; } - turn_context.turn_metadata_state.spawn_git_enrichment_task(); - turn_context } pub(crate) async fn new_default_turn(&self) -> Arc { @@ -3454,6 +3460,8 @@ mod handlers { // new_turn_with_sub_id already emits the error event. return; }; + sess.maybe_emit_unknown_model_warning_for_turn(current_context.as_ref()) + .await; current_context.otel_manager.user_prompt(&items); // Attempt to inject input into current task. @@ -4058,6 +4066,8 @@ mod handlers { review_request: ReviewRequest, ) { let turn_context = sess.new_default_turn_with_sub_id(sub_id.clone()).await; + sess.maybe_emit_unknown_model_warning_for_turn(turn_context.as_ref()) + .await; sess.refresh_mcp_servers_if_requested(&turn_context).await; match resolve_review_request(review_request, turn_context.cwd.as_path()) { Ok(resolved) => { From 1d956561490f4e5d87687b49126339676f417cd8 Mon Sep 17 00:00:00 2001 From: Anton Panasenko Date: Sun, 15 Feb 2026 23:11:59 -0800 Subject: [PATCH 22/62] bazel: fix snapshot parity for tests/*.rs rust_test targets (#11893) ## Summary - make `rust_test` targets generated from `tests/*.rs` use Cargo-style crate names (file stem) so snapshot names match Cargo (`all__...` instead of Bazel-derived names) - split lib vs `tests/*.rs` test env wiring in `codex_rust_crate` to keep existing lib snapshot behavior while applying Bazel runfiles-compatible workspace root for `tests/*.rs` - compute the `tests/*.rs` snapshot workspace root from package depth so `insta` resolves committed snapshots under Bazel `--noenable_runfiles` ## Validation - `bazelisk test //codex-rs/core:core-all-test --test_arg=suite::compact:: --cache_test_results=no` - `bazelisk test //codex-rs/core:core-all-test --test_arg=suite::compact_remote:: --cache_test_results=no` --- codex-rs/core/tests/common/lib.rs | 20 ++++++++++++++++++++ defs.bzl | 14 +++++++++++--- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/codex-rs/core/tests/common/lib.rs b/codex-rs/core/tests/common/lib.rs index d2f9c9ac59..a7fda23cd0 100644 --- a/codex-rs/core/tests/common/lib.rs +++ b/codex-rs/core/tests/common/lib.rs @@ -26,6 +26,26 @@ fn enable_deterministic_unified_exec_process_ids_for_tests() { codex_core::test_support::set_deterministic_process_ids(true); } +#[ctor] +fn configure_insta_workspace_root_for_snapshot_tests() { + if std::env::var_os("INSTA_WORKSPACE_ROOT").is_some() { + return; + } + + let workspace_root = codex_utils_cargo_bin::repo_root() + .ok() + .map(|root| root.join("codex-rs")); + + if let Some(workspace_root) = workspace_root + && let Ok(workspace_root) = workspace_root.canonicalize() + { + // Safety: this ctor runs at process startup before test threads begin. + unsafe { + std::env::set_var("INSTA_WORKSPACE_ROOT", workspace_root); + } + } +} + #[track_caller] pub fn assert_regex_match<'s>(pattern: &str, actual: &'s str) -> regex_lite::Captures<'s> { let regex = Regex::new(pattern).unwrap_or_else(|err| { diff --git a/defs.bzl b/defs.bzl index db09ef0133..b3fe9903ea 100644 --- a/defs.bzl +++ b/defs.bzl @@ -170,20 +170,28 @@ def codex_rust_crate( cargo_env["CARGO_BIN_EXE_" + binary] = "$(rlocationpath %s)" % binary_label for test in native.glob(["tests/*.rs"], allow_empty = True): - test_name = name + "-" + test.removeprefix("tests/").removesuffix(".rs").replace("/", "-") + test_file_stem = test.removeprefix("tests/").removesuffix(".rs") + test_crate_name = test_file_stem.replace("-", "_") + test_name = name + "-" + test_file_stem.replace("/", "-") if not test_name.endswith("-test"): test_name += "-test" rust_test( name = test_name, + crate_name = test_crate_name, crate_root = test, srcs = [test], data = native.glob(["tests/**"], allow_empty = True) + sanitized_binaries + test_data_extra, compile_data = native.glob(["tests/**"], allow_empty = True) + integration_compile_data_extra, deps = maybe_lib + deps + dev_deps + integration_deps_extra, proc_macro_deps = proc_macro_deps + proc_macro_dev_deps, - rustc_flags = rustc_flags_extra, + # Keep `file!()` paths Cargo-like (`core/tests/...`) instead of + # Bazel workspace-prefixed (`codex-rs/core/tests/...`) for snapshot parity. + rustc_flags = rustc_flags_extra + ["--remap-path-prefix=codex-rs="], rustc_env = rustc_env, - env = test_env | cargo_env, + # Important: do not merge `test_env` here. Its unit-test-only + # `INSTA_WORKSPACE_ROOT="."` can point integration tests at the + # runfiles cwd and cause false `.snap.new` churn on Linux. + env = cargo_env, tags = test_tags, ) From 825a4af42feef5ceacfe97f1b1eb838c18004e3b Mon Sep 17 00:00:00 2001 From: jif-oai Date: Mon, 16 Feb 2026 09:11:00 +0000 Subject: [PATCH 23/62] feat: use shell policy in shell snapshot (#11759) Honor `shell_environment_policy.set` even after a shell snapshot --- codex-rs/core/src/tasks/user_shell.rs | 1 + codex-rs/core/src/tools/handlers/shell.rs | 10 +- codex-rs/core/src/tools/runtimes/mod.rs | 314 +++++++++++++++++- codex-rs/core/src/tools/runtimes/shell.rs | 9 +- .../core/src/tools/runtimes/unified_exec.rs | 9 +- .../core/src/unified_exec/process_manager.rs | 1 + codex-rs/core/tests/suite/shell_snapshot.rs | 230 ++++++++++++- 7 files changed, 555 insertions(+), 19 deletions(-) diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index 35088efce8..80112c61c7 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -119,6 +119,7 @@ pub(crate) async fn execute_user_shell_command( &display_command, session_shell.as_ref(), turn_context.cwd.as_path(), + &turn_context.shell_environment_policy.r#set, ); let call_id = Uuid::new_v4().to_string(); diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index 67f4c04166..9093dfba4c 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -254,7 +254,14 @@ impl ShellHandler { let mut exec_params = exec_params; let dependency_env = session.dependency_env().await; if !dependency_env.is_empty() { - exec_params.env.extend(dependency_env); + exec_params.env.extend(dependency_env.clone()); + } + + let mut explicit_env_overrides = turn.shell_environment_policy.r#set.clone(); + for key in dependency_env.keys() { + if let Some(value) = exec_params.env.get(key) { + explicit_env_overrides.insert(key.clone(), value.clone()); + } } // Approval policy guard for explicit escalation in non-OnRequest modes. @@ -315,6 +322,7 @@ impl ShellHandler { cwd: exec_params.cwd.clone(), timeout_ms: exec_params.expiration.timeout_ms(), env: exec_params.env.clone(), + explicit_env_overrides, network: exec_params.network.clone(), sandbox_permissions: exec_params.sandbox_permissions, justification: exec_params.justification.clone(), diff --git a/codex-rs/core/src/tools/runtimes/mod.rs b/codex-rs/core/src/tools/runtimes/mod.rs index b3d7b32921..92c8f39223 100644 --- a/codex-rs/core/src/tools/runtimes/mod.rs +++ b/codex-rs/core/src/tools/runtimes/mod.rs @@ -57,7 +57,12 @@ pub(crate) fn maybe_wrap_shell_lc_with_snapshot( command: &[String], session_shell: &Shell, cwd: &Path, + explicit_env_overrides: &HashMap, ) -> Vec { + if cfg!(windows) { + return command.to_vec(); + } + let Some(snapshot) = session_shell.shell_snapshot() else { return command.to_vec(); }; @@ -95,24 +100,78 @@ pub(crate) fn maybe_wrap_shell_lc_with_snapshot( .iter() .map(|arg| format!(" '{}'", shell_single_quote(arg))) .collect::(); - let rewritten_script = format!( - "if . '{snapshot_path}' >/dev/null 2>&1; then :; fi; exec '{original_shell}' -c '{original_script}'{trailing_args}" - ); + let (override_captures, override_exports) = build_override_exports(explicit_env_overrides); + let rewritten_script = if override_exports.is_empty() { + format!( + "if . '{snapshot_path}' >/dev/null 2>&1; then :; fi\n\nexec '{original_shell}' -c '{original_script}'{trailing_args}" + ) + } else { + format!( + "{override_captures}\n\nif . '{snapshot_path}' >/dev/null 2>&1; then :; fi\n\n{override_exports}\n\nexec '{original_shell}' -c '{original_script}'{trailing_args}" + ) + }; vec![shell_path.to_string(), "-c".to_string(), rewritten_script] } +fn build_override_exports(explicit_env_overrides: &HashMap) -> (String, String) { + let mut keys = explicit_env_overrides + .keys() + .filter(|key| is_valid_shell_variable_name(key)) + .collect::>(); + keys.sort_unstable(); + + if keys.is_empty() { + return (String::new(), String::new()); + } + + let captures = keys + .iter() + .enumerate() + .map(|(idx, key)| { + format!( + "__CODEX_SNAPSHOT_OVERRIDE_SET_{idx}=\"${{{key}+x}}\"\n__CODEX_SNAPSHOT_OVERRIDE_{idx}=\"${{{key}-}}\"" + ) + }) + .collect::>() + .join("\n"); + let restores = keys + .iter() + .enumerate() + .map(|(idx, key)| { + format!( + "if [ -n \"${{__CODEX_SNAPSHOT_OVERRIDE_SET_{idx}}}\" ]; then export {key}=\"${{__CODEX_SNAPSHOT_OVERRIDE_{idx}}}\"; else unset {key}; fi" + ) + }) + .collect::>() + .join("\n"); + + (captures, restores) +} + +fn is_valid_shell_variable_name(name: &str) -> bool { + let mut chars = name.chars(); + let Some(first) = chars.next() else { + return false; + }; + if !(first == '_' || first.is_ascii_alphabetic()) { + return false; + } + chars.all(|c| c == '_' || c.is_ascii_alphanumeric()) +} + fn shell_single_quote(input: &str) -> String { input.replace('\'', r#"'"'"'"#) } -#[cfg(test)] +#[cfg(all(test, unix))] mod tests { use super::*; use crate::shell::ShellType; use crate::shell_snapshot::ShellSnapshot; use pretty_assertions::assert_eq; use std::path::PathBuf; + use std::process::Command; use std::sync::Arc; use tempfile::tempdir; use tokio::sync::watch; @@ -151,7 +210,12 @@ mod tests { "echo hello".to_string(), ]; - let rewritten = maybe_wrap_shell_lc_with_snapshot(&command, &session_shell, dir.path()); + let rewritten = maybe_wrap_shell_lc_with_snapshot( + &command, + &session_shell, + dir.path(), + &HashMap::new(), + ); assert_eq!(rewritten[0], "/bin/zsh"); assert_eq!(rewritten[1], "-c"); @@ -176,7 +240,12 @@ mod tests { "echo 'hello'".to_string(), ]; - let rewritten = maybe_wrap_shell_lc_with_snapshot(&command, &session_shell, dir.path()); + let rewritten = maybe_wrap_shell_lc_with_snapshot( + &command, + &session_shell, + dir.path(), + &HashMap::new(), + ); assert!(rewritten[2].contains(r#"exec '/bin/bash' -c 'echo '"'"'hello'"'"''"#)); } @@ -198,7 +267,12 @@ mod tests { "echo hello".to_string(), ]; - let rewritten = maybe_wrap_shell_lc_with_snapshot(&command, &session_shell, dir.path()); + let rewritten = maybe_wrap_shell_lc_with_snapshot( + &command, + &session_shell, + dir.path(), + &HashMap::new(), + ); assert_eq!(rewritten[0], "/bin/bash"); assert_eq!(rewritten[1], "-c"); @@ -223,7 +297,12 @@ mod tests { "echo hello".to_string(), ]; - let rewritten = maybe_wrap_shell_lc_with_snapshot(&command, &session_shell, dir.path()); + let rewritten = maybe_wrap_shell_lc_with_snapshot( + &command, + &session_shell, + dir.path(), + &HashMap::new(), + ); assert_eq!(rewritten[0], "/bin/sh"); assert_eq!(rewritten[1], "-c"); @@ -250,7 +329,12 @@ mod tests { "arg1".to_string(), ]; - let rewritten = maybe_wrap_shell_lc_with_snapshot(&command, &session_shell, dir.path()); + let rewritten = maybe_wrap_shell_lc_with_snapshot( + &command, + &session_shell, + dir.path(), + &HashMap::new(), + ); assert!( rewritten[2].contains( @@ -276,7 +360,12 @@ mod tests { "echo hello".to_string(), ]; - let rewritten = maybe_wrap_shell_lc_with_snapshot(&command, &session_shell, &command_cwd); + let rewritten = maybe_wrap_shell_lc_with_snapshot( + &command, + &session_shell, + &command_cwd, + &HashMap::new(), + ); assert_eq!(rewritten, command); } @@ -299,11 +388,214 @@ mod tests { ]; let command_cwd = dir.path().join("."); - let rewritten = maybe_wrap_shell_lc_with_snapshot(&command, &session_shell, &command_cwd); + let rewritten = maybe_wrap_shell_lc_with_snapshot( + &command, + &session_shell, + &command_cwd, + &HashMap::new(), + ); assert_eq!(rewritten[0], "/bin/zsh"); assert_eq!(rewritten[1], "-c"); assert!(rewritten[2].contains("if . '")); assert!(rewritten[2].contains("exec '/bin/bash' -c 'echo hello'")); } + + #[test] + fn maybe_wrap_shell_lc_with_snapshot_restores_explicit_override_precedence() { + let dir = tempdir().expect("create temp dir"); + let snapshot_path = dir.path().join("snapshot.sh"); + std::fs::write( + &snapshot_path, + "# Snapshot file\nexport TEST_ENV_SNAPSHOT=global\nexport SNAPSHOT_ONLY=from_snapshot\n", + ) + .expect("write snapshot"); + let session_shell = shell_with_snapshot( + ShellType::Bash, + "/bin/bash", + snapshot_path, + dir.path().to_path_buf(), + ); + let command = vec![ + "/bin/bash".to_string(), + "-lc".to_string(), + "printf '%s|%s' \"$TEST_ENV_SNAPSHOT\" \"${SNAPSHOT_ONLY-unset}\"".to_string(), + ]; + let explicit_env_overrides = + HashMap::from([("TEST_ENV_SNAPSHOT".to_string(), "worktree".to_string())]); + let rewritten = maybe_wrap_shell_lc_with_snapshot( + &command, + &session_shell, + dir.path(), + &explicit_env_overrides, + ); + let output = Command::new(&rewritten[0]) + .args(&rewritten[1..]) + .env("TEST_ENV_SNAPSHOT", "worktree") + .output() + .expect("run rewritten command"); + + assert!(output.status.success(), "command failed: {output:?}"); + assert_eq!( + String::from_utf8_lossy(&output.stdout), + "worktree|from_snapshot" + ); + } + + #[test] + fn maybe_wrap_shell_lc_with_snapshot_keeps_snapshot_path_without_override() { + let dir = tempdir().expect("create temp dir"); + let snapshot_path = dir.path().join("snapshot.sh"); + std::fs::write( + &snapshot_path, + "# Snapshot file\nexport PATH='/snapshot/bin'\n", + ) + .expect("write snapshot"); + let session_shell = shell_with_snapshot( + ShellType::Bash, + "/bin/bash", + snapshot_path, + dir.path().to_path_buf(), + ); + let command = vec![ + "/bin/bash".to_string(), + "-lc".to_string(), + "printf '%s' \"$PATH\"".to_string(), + ]; + let rewritten = maybe_wrap_shell_lc_with_snapshot( + &command, + &session_shell, + dir.path(), + &HashMap::new(), + ); + let output = Command::new(&rewritten[0]) + .args(&rewritten[1..]) + .output() + .expect("run rewritten command"); + + assert!(output.status.success(), "command failed: {output:?}"); + assert_eq!(String::from_utf8_lossy(&output.stdout), "/snapshot/bin"); + } + + #[test] + fn maybe_wrap_shell_lc_with_snapshot_applies_explicit_path_override() { + let dir = tempdir().expect("create temp dir"); + let snapshot_path = dir.path().join("snapshot.sh"); + std::fs::write( + &snapshot_path, + "# Snapshot file\nexport PATH='/snapshot/bin'\n", + ) + .expect("write snapshot"); + let session_shell = shell_with_snapshot( + ShellType::Bash, + "/bin/bash", + snapshot_path, + dir.path().to_path_buf(), + ); + let command = vec![ + "/bin/bash".to_string(), + "-lc".to_string(), + "printf '%s' \"$PATH\"".to_string(), + ]; + let explicit_env_overrides = + HashMap::from([("PATH".to_string(), "/worktree/bin".to_string())]); + let rewritten = maybe_wrap_shell_lc_with_snapshot( + &command, + &session_shell, + dir.path(), + &explicit_env_overrides, + ); + let output = Command::new(&rewritten[0]) + .args(&rewritten[1..]) + .env("PATH", "/worktree/bin") + .output() + .expect("run rewritten command"); + + assert!(output.status.success(), "command failed: {output:?}"); + assert_eq!(String::from_utf8_lossy(&output.stdout), "/worktree/bin"); + } + + #[test] + fn maybe_wrap_shell_lc_with_snapshot_does_not_embed_override_values_in_argv() { + let dir = tempdir().expect("create temp dir"); + let snapshot_path = dir.path().join("snapshot.sh"); + std::fs::write( + &snapshot_path, + "# Snapshot file\nexport OPENAI_API_KEY='snapshot-value'\n", + ) + .expect("write snapshot"); + let session_shell = shell_with_snapshot( + ShellType::Bash, + "/bin/bash", + snapshot_path, + dir.path().to_path_buf(), + ); + let command = vec![ + "/bin/bash".to_string(), + "-lc".to_string(), + "printf '%s' \"$OPENAI_API_KEY\"".to_string(), + ]; + let explicit_env_overrides = HashMap::from([( + "OPENAI_API_KEY".to_string(), + "super-secret-value".to_string(), + )]); + let rewritten = maybe_wrap_shell_lc_with_snapshot( + &command, + &session_shell, + dir.path(), + &explicit_env_overrides, + ); + + assert!(!rewritten[2].contains("super-secret-value")); + let output = Command::new(&rewritten[0]) + .args(&rewritten[1..]) + .env("OPENAI_API_KEY", "super-secret-value") + .output() + .expect("run rewritten command"); + assert!(output.status.success(), "command failed: {output:?}"); + assert_eq!( + String::from_utf8_lossy(&output.stdout), + "super-secret-value" + ); + } + + #[test] + fn maybe_wrap_shell_lc_with_snapshot_preserves_unset_override_variables() { + let dir = tempdir().expect("create temp dir"); + let snapshot_path = dir.path().join("snapshot.sh"); + std::fs::write( + &snapshot_path, + "# Snapshot file\nexport CODEX_TEST_UNSET_OVERRIDE='snapshot-value'\n", + ) + .expect("write snapshot"); + let session_shell = shell_with_snapshot( + ShellType::Bash, + "/bin/bash", + snapshot_path, + dir.path().to_path_buf(), + ); + let command = vec![ + "/bin/bash".to_string(), + "-lc".to_string(), + "if [ \"${CODEX_TEST_UNSET_OVERRIDE+x}\" = x ]; then printf 'set:%s' \"$CODEX_TEST_UNSET_OVERRIDE\"; else printf 'unset'; fi".to_string(), + ]; + let explicit_env_overrides = HashMap::from([( + "CODEX_TEST_UNSET_OVERRIDE".to_string(), + "worktree-value".to_string(), + )]); + let rewritten = maybe_wrap_shell_lc_with_snapshot( + &command, + &session_shell, + dir.path(), + &explicit_env_overrides, + ); + + let output = Command::new(&rewritten[0]) + .args(&rewritten[1..]) + .env_remove("CODEX_TEST_UNSET_OVERRIDE") + .output() + .expect("run rewritten command"); + assert!(output.status.success(), "command failed: {output:?}"); + assert_eq!(String::from_utf8_lossy(&output.stdout), "unset"); + } } diff --git a/codex-rs/core/src/tools/runtimes/shell.rs b/codex-rs/core/src/tools/runtimes/shell.rs index 3b7f970d77..3d7a61ef77 100644 --- a/codex-rs/core/src/tools/runtimes/shell.rs +++ b/codex-rs/core/src/tools/runtimes/shell.rs @@ -37,6 +37,7 @@ pub struct ShellRequest { pub cwd: PathBuf, pub timeout_ms: Option, pub env: std::collections::HashMap, + pub explicit_env_overrides: std::collections::HashMap, pub network: Option, pub sandbox_permissions: SandboxPermissions, pub justification: Option, @@ -166,8 +167,12 @@ impl ToolRuntime for ShellRuntime { ) -> Result { let base_command = &req.command; let session_shell = ctx.session.user_shell(); - let command = - maybe_wrap_shell_lc_with_snapshot(base_command, session_shell.as_ref(), &req.cwd); + let command = maybe_wrap_shell_lc_with_snapshot( + base_command, + session_shell.as_ref(), + &req.cwd, + &req.explicit_env_overrides, + ); let command = if matches!(session_shell.shell_type, ShellType::PowerShell) && ctx.session.features().enabled(Feature::PowershellUtf8) { diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index 4c6c611075..89b159c5b8 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -41,6 +41,7 @@ pub struct UnifiedExecRequest { pub command: Vec, pub cwd: PathBuf, pub env: HashMap, + pub explicit_env_overrides: HashMap, pub network: Option, pub tty: bool, pub sandbox_permissions: SandboxPermissions, @@ -170,8 +171,12 @@ impl<'a> ToolRuntime for UnifiedExecRunt ) -> Result { let base_command = &req.command; let session_shell = ctx.session.user_shell(); - let command = - maybe_wrap_shell_lc_with_snapshot(base_command, session_shell.as_ref(), &req.cwd); + let command = maybe_wrap_shell_lc_with_snapshot( + base_command, + session_shell.as_ref(), + &req.cwd, + &req.explicit_env_overrides, + ); let command = if matches!(session_shell.shell_type, ShellType::PowerShell) && ctx.session.features().enabled(Feature::PowershellUtf8) { diff --git a/codex-rs/core/src/unified_exec/process_manager.rs b/codex-rs/core/src/unified_exec/process_manager.rs index 197cda479f..6896df14b4 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -622,6 +622,7 @@ impl UnifiedExecProcessManager { command: request.command.clone(), cwd, env, + explicit_env_overrides: context.turn.shell_environment_policy.r#set.clone(), network: request.network.clone(), tty: request.tty, sandbox_permissions: request.sandbox_permissions, diff --git a/codex-rs/core/tests/suite/shell_snapshot.rs b/codex-rs/core/tests/suite/shell_snapshot.rs index 42a2107cd1..b5007fd589 100644 --- a/codex-rs/core/tests/suite/shell_snapshot.rs +++ b/codex-rs/core/tests/suite/shell_snapshot.rs @@ -20,6 +20,7 @@ use core_test_support::wait_for_event; use core_test_support::wait_for_event_match; use pretty_assertions::assert_eq; use serde_json::json; +use std::collections::HashMap; use std::path::Path; use std::path::PathBuf; use tokio::fs; @@ -36,6 +37,17 @@ struct SnapshotRun { codex_home: PathBuf, } +const POLICY_PATH_FOR_TEST: &str = "/codex/policy/path"; +const SNAPSHOT_PATH_FOR_TEST: &str = "/codex/snapshot/path"; +const SNAPSHOT_MARKER_VAR: &str = "CODEX_SNAPSHOT_POLICY_MARKER"; +const SNAPSHOT_MARKER_VALUE: &str = "from_snapshot"; +const POLICY_SUCCESS_OUTPUT: &str = "policy-after-snapshot"; + +#[derive(Debug, Default)] +struct SnapshotRunOptions { + shell_environment_set: HashMap, +} + async fn wait_for_snapshot(codex_home: &Path) -> Result { let snapshot_dir = codex_home.join("shell_snapshots"); let deadline = Instant::now() + Duration::from_secs(5); @@ -54,12 +66,57 @@ async fn wait_for_snapshot(codex_home: &Path) -> Result { } } +async fn wait_for_file_contents(path: &Path) -> Result { + let deadline = Instant::now() + Duration::from_secs(5); + loop { + match fs::read_to_string(path).await { + Ok(contents) => return Ok(contents), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(err) => return Err(err.into()), + } + + if Instant::now() >= deadline { + anyhow::bail!("timed out waiting for file {}", path.display()); + } + + sleep(Duration::from_millis(25)).await; + } +} + +fn policy_set_path_for_test() -> HashMap { + HashMap::from([("PATH".to_string(), POLICY_PATH_FOR_TEST.to_string())]) +} + +fn snapshot_override_content_for_policy_test() -> String { + format!( + "# Snapshot file\nexport PATH='{SNAPSHOT_PATH_FOR_TEST}'\nexport {SNAPSHOT_MARKER_VAR}='{SNAPSHOT_MARKER_VALUE}'\n" + ) +} + +fn command_asserting_policy_after_snapshot() -> String { + format!( + "if [ \"${{{SNAPSHOT_MARKER_VAR}:-}}\" = \"{SNAPSHOT_MARKER_VALUE}\" ] && [ \"$PATH\" != \"{SNAPSHOT_PATH_FOR_TEST}\" ]; then case \":$PATH:\" in *\":{POLICY_PATH_FOR_TEST}:\"*) printf \"{POLICY_SUCCESS_OUTPUT}\" ;; *) printf \"path=%s marker=%s\" \"$PATH\" \"${{{SNAPSHOT_MARKER_VAR}:-missing}}\" ;; esac; else printf \"path=%s marker=%s\" \"$PATH\" \"${{{SNAPSHOT_MARKER_VAR}:-missing}}\"; fi" + ) +} + #[allow(clippy::expect_used)] async fn run_snapshot_command(command: &str) -> Result { - let builder = test_codex().with_config(|config| { + run_snapshot_command_with_options(command, SnapshotRunOptions::default()).await +} + +#[allow(clippy::expect_used)] +async fn run_snapshot_command_with_options( + command: &str, + options: SnapshotRunOptions, +) -> Result { + let SnapshotRunOptions { + shell_environment_set, + } = options; + let builder = test_codex().with_config(move |config| { config.use_experimental_unified_exec_tool = true; config.features.enable(Feature::UnifiedExec); config.features.enable(Feature::ShellSnapshot); + config.permissions.shell_environment_policy.r#set = shell_environment_set; }); let harness = TestCodexHarness::with_builder(builder).await?; let args = json!({ @@ -132,8 +189,20 @@ async fn run_snapshot_command(command: &str) -> Result { #[allow(clippy::expect_used)] async fn run_shell_command_snapshot(command: &str) -> Result { - let builder = test_codex().with_config(|config| { + run_shell_command_snapshot_with_options(command, SnapshotRunOptions::default()).await +} + +#[allow(clippy::expect_used)] +async fn run_shell_command_snapshot_with_options( + command: &str, + options: SnapshotRunOptions, +) -> Result { + let SnapshotRunOptions { + shell_environment_set, + } = options; + let builder = test_codex().with_config(move |config| { config.features.enable(Feature::ShellSnapshot); + config.permissions.shell_environment_policy.r#set = shell_environment_set; }); let harness = TestCodexHarness::with_builder(builder).await?; let args = json!({ @@ -204,6 +273,64 @@ async fn run_shell_command_snapshot(command: &str) -> Result { }) } +#[allow(clippy::expect_used)] +async fn run_tool_turn_on_harness( + harness: &TestCodexHarness, + prompt: &str, + call_id: &str, + tool_name: &str, + args: serde_json::Value, +) -> Result { + let responses = vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call(call_id, tool_name, &serde_json::to_string(&args)?), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + ]; + mount_sse_sequence(harness.server(), responses).await; + + let test = harness.test(); + let codex = test.codex.clone(); + let session_model = test.session_configured.model.clone(); + let cwd = test.cwd_path().to_path_buf(); + codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: prompt.into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + cwd, + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::DangerFullAccess, + model: session_model, + effort: None, + summary: ReasoningSummary::Auto, + collaboration_mode: None, + personality: None, + }) + .await?; + + wait_for_event_match(&codex, |ev| match ev { + EventMsg::ExecCommandBegin(ev) if ev.call_id == call_id => Some(ev.clone()), + _ => None, + }) + .await; + let end = wait_for_event_match(&codex, |ev| match ev { + EventMsg::ExecCommandEnd(ev) if ev.call_id == call_id => Some(ev.clone()), + _ => None, + }) + .await; + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + Ok(end) +} + fn normalize_newlines(text: &str) -> String { text.replace("\r\n", "\n") } @@ -260,6 +387,100 @@ async fn linux_shell_command_uses_shell_snapshot() -> Result<()> { Ok(()) } +#[cfg_attr(target_os = "windows", ignore)] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn shell_command_snapshot_preserves_shell_environment_policy_set() -> Result<()> { + let builder = test_codex().with_config(|config| { + config.features.enable(Feature::ShellSnapshot); + config.permissions.shell_environment_policy.r#set = policy_set_path_for_test(); + }); + let harness = TestCodexHarness::with_builder(builder).await?; + let codex_home = harness.test().home.path().to_path_buf(); + run_tool_turn_on_harness( + &harness, + "warm up shell snapshot", + "shell-snapshot-policy-warmup", + "shell_command", + json!({ + "command": "printf warmup", + "timeout_ms": 1_000, + }), + ) + .await?; + let snapshot_path = wait_for_snapshot(&codex_home).await?; + fs::write(&snapshot_path, snapshot_override_content_for_policy_test()).await?; + + let command = command_asserting_policy_after_snapshot(); + let end = run_tool_turn_on_harness( + &harness, + "verify shell policy after snapshot", + "shell-snapshot-policy-assert", + "shell_command", + json!({ + "command": command, + "timeout_ms": 1_000, + }), + ) + .await?; + + assert_eq!( + normalize_newlines(&end.stdout).trim(), + POLICY_SUCCESS_OUTPUT + ); + assert_eq!(end.exit_code, 0); + assert!(snapshot_path.starts_with(codex_home)); + + Ok(()) +} + +#[cfg_attr(not(target_os = "linux"), ignore)] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn linux_unified_exec_snapshot_preserves_shell_environment_policy_set() -> Result<()> { + let builder = test_codex().with_config(|config| { + config.use_experimental_unified_exec_tool = true; + config.features.enable(Feature::UnifiedExec); + config.features.enable(Feature::ShellSnapshot); + config.permissions.shell_environment_policy.r#set = policy_set_path_for_test(); + }); + let harness = TestCodexHarness::with_builder(builder).await?; + let codex_home = harness.test().home.path().to_path_buf(); + run_tool_turn_on_harness( + &harness, + "warm up unified exec shell snapshot", + "shell-snapshot-policy-warmup-exec", + "exec_command", + json!({ + "cmd": "printf warmup", + "yield_time_ms": 1_000, + }), + ) + .await?; + let snapshot_path = wait_for_snapshot(&codex_home).await?; + fs::write(&snapshot_path, snapshot_override_content_for_policy_test()).await?; + + let command = command_asserting_policy_after_snapshot(); + let end = run_tool_turn_on_harness( + &harness, + "verify unified exec policy after snapshot", + "shell-snapshot-policy-assert-exec", + "exec_command", + json!({ + "cmd": command, + "yield_time_ms": 1_000, + }), + ) + .await?; + + assert_eq!( + normalize_newlines(&end.stdout).trim(), + POLICY_SUCCESS_OUTPUT + ); + assert_eq!(end.exit_code, 0); + assert!(snapshot_path.starts_with(codex_home)); + + Ok(()) +} + #[cfg_attr(target_os = "windows", ignore)] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn shell_command_snapshot_still_intercepts_apply_patch() -> Result<()> { @@ -316,7 +537,10 @@ async fn shell_command_snapshot_still_intercepts_apply_patch() -> Result<()> { wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; - assert_eq!(fs::read_to_string(&target).await?, "hello from snapshot\n"); + assert_eq!( + wait_for_file_contents(&target).await?, + "hello from snapshot\n" + ); let snapshot_path = wait_for_snapshot(&codex_home).await?; let snapshot_content = fs::read_to_string(&snapshot_path).await?; From b3095679ed135cd514783896fec50d86c9b9c98c Mon Sep 17 00:00:00 2001 From: gt-oai Date: Mon, 16 Feb 2026 14:11:05 +0000 Subject: [PATCH 24/62] Allow hooks to error (#11615) Allow hooks to return errors. We should do this before introducing more hook types, or we'll have to migrate them all. --- codex-rs/core/src/codex.rs | 45 ++++++- codex-rs/core/src/tools/registry.rs | 43 ++++++- codex-rs/hooks/src/lib.rs | 3 +- codex-rs/hooks/src/registry.rs | 148 ++++++++++++++++++++---- codex-rs/hooks/src/types.rs | 44 +++++-- codex-rs/hooks/src/user_notification.rs | 11 +- 6 files changed, 251 insertions(+), 43 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 502d307487..7dd8eeae64 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -44,6 +44,7 @@ use async_channel::Sender; use codex_hooks::HookEvent; use codex_hooks::HookEventAfterAgent; use codex_hooks::HookPayload; +use codex_hooks::HookResult; use codex_hooks::Hooks; use codex_hooks::HooksConfig; use codex_network_proxy::NetworkProxy; @@ -4533,7 +4534,8 @@ pub(crate) async fn run_turn( if !needs_follow_up { last_agent_message = sampling_request_last_agent_message; - sess.hooks() + let hook_outcomes = sess + .hooks() .dispatch(HookPayload { session_id: sess.conversation_id, cwd: turn_context.cwd.clone(), @@ -4548,6 +4550,47 @@ pub(crate) async fn run_turn( }, }) .await; + + let mut abort_message = None; + for hook_outcome in hook_outcomes { + let hook_name = hook_outcome.hook_name; + match hook_outcome.result { + HookResult::Success => {} + HookResult::FailedContinue(error) => { + warn!( + turn_id = %turn_context.sub_id, + hook_name = %hook_name, + error = %error, + "after_agent hook failed; continuing" + ); + } + HookResult::FailedAbort(error) => { + let message = format!( + "after_agent hook '{hook_name}' failed and aborted turn completion: {error}" + ); + warn!( + turn_id = %turn_context.sub_id, + hook_name = %hook_name, + error = %error, + "after_agent hook failed; aborting operation" + ); + if abort_message.is_none() { + abort_message = Some(message); + } + } + } + } + if let Some(message) = abort_message { + sess.send_event( + &turn_context, + EventMsg::Error(ErrorEvent { + message, + codex_error_info: None, + }), + ) + .await; + return None; + } break; } continue; diff --git a/codex-rs/core/src/tools/registry.rs b/codex-rs/core/src/tools/registry.rs index ffde584b19..8006c80722 100644 --- a/codex-rs/core/src/tools/registry.rs +++ b/codex-rs/core/src/tools/registry.rs @@ -15,6 +15,7 @@ use async_trait::async_trait; use codex_hooks::HookEvent; use codex_hooks::HookEventAfterToolUse; use codex_hooks::HookPayload; +use codex_hooks::HookResult; use codex_hooks::HookToolInput; use codex_hooks::HookToolInputLocalShell; use codex_hooks::HookToolKind; @@ -172,7 +173,7 @@ impl ToolRegistry { Ok((preview, success)) => (preview.clone(), *success), Err(err) => (err.to_string(), false), }; - dispatch_after_tool_use_hook(AfterToolUseHookDispatch { + let hook_abort_error = dispatch_after_tool_use_hook(AfterToolUseHookDispatch { invocation: &invocation, output_preview, success, @@ -182,6 +183,10 @@ impl ToolRegistry { }) .await; + if let Some(err) = hook_abort_error { + return Err(err); + } + match result { Ok(_) => { let mut guard = output_cell.lock().await; @@ -339,12 +344,14 @@ struct AfterToolUseHookDispatch<'a> { mutating: bool, } -async fn dispatch_after_tool_use_hook(dispatch: AfterToolUseHookDispatch<'_>) { +async fn dispatch_after_tool_use_hook( + dispatch: AfterToolUseHookDispatch<'_>, +) -> Option { let AfterToolUseHookDispatch { invocation, .. } = dispatch; let session = invocation.session.as_ref(); let turn = invocation.turn.as_ref(); let tool_input = HookToolInput::from(&invocation.payload); - session + let hook_outcomes = session .hooks() .dispatch(HookPayload { session_id: session.conversation_id, @@ -373,4 +380,34 @@ async fn dispatch_after_tool_use_hook(dispatch: AfterToolUseHookDispatch<'_>) { }, }) .await; + + for hook_outcome in hook_outcomes { + let hook_name = hook_outcome.hook_name; + match hook_outcome.result { + HookResult::Success => {} + HookResult::FailedContinue(error) => { + warn!( + call_id = %invocation.call_id, + tool_name = %invocation.tool_name, + hook_name = %hook_name, + error = %error, + "after_tool_use hook failed; continuing" + ); + } + HookResult::FailedAbort(error) => { + warn!( + call_id = %invocation.call_id, + tool_name = %invocation.tool_name, + hook_name = %hook_name, + error = %error, + "after_tool_use hook failed; aborting operation" + ); + return Some(FunctionCallError::Fatal(format!( + "after_tool_use hook '{hook_name}' failed and aborted operation: {error}" + ))); + } + } + } + + None } diff --git a/codex-rs/hooks/src/lib.rs b/codex-rs/hooks/src/lib.rs index aa7dd58acd..f21fe469e9 100644 --- a/codex-rs/hooks/src/lib.rs +++ b/codex-rs/hooks/src/lib.rs @@ -9,8 +9,9 @@ pub use types::Hook; pub use types::HookEvent; pub use types::HookEventAfterAgent; pub use types::HookEventAfterToolUse; -pub use types::HookOutcome; pub use types::HookPayload; +pub use types::HookResponse; +pub use types::HookResult; pub use types::HookToolInput; pub use types::HookToolInputLocalShell; pub use types::HookToolKind; diff --git a/codex-rs/hooks/src/registry.rs b/codex-rs/hooks/src/registry.rs index 9228bdddd4..6568f5374f 100644 --- a/codex-rs/hooks/src/registry.rs +++ b/codex-rs/hooks/src/registry.rs @@ -2,8 +2,8 @@ use tokio::process::Command; use crate::types::Hook; use crate::types::HookEvent; -use crate::types::HookOutcome; use crate::types::HookPayload; +use crate::types::HookResponse; #[derive(Default, Clone)] pub struct HooksConfig { @@ -45,14 +45,19 @@ impl Hooks { } } - pub async fn dispatch(&self, hook_payload: HookPayload) { - // TODO(gt): support interrupting program execution by returning a result here. - for hook in self.hooks_for_event(&hook_payload.hook_event) { + pub async fn dispatch(&self, hook_payload: HookPayload) -> Vec { + let hooks = self.hooks_for_event(&hook_payload.hook_event); + let mut outcomes = Vec::with_capacity(hooks.len()); + for hook in hooks { let outcome = hook.execute(&hook_payload).await; - if matches!(outcome, HookOutcome::Stop) { + let should_abort_operation = outcome.result.should_abort_operation(); + outcomes.push(outcome); + if should_abort_operation { break; } } + + outcomes } } @@ -88,6 +93,7 @@ mod tests { use super::*; use crate::types::HookEventAfterAgent; use crate::types::HookEventAfterToolUse; + use crate::types::HookResult; use crate::types::HookToolInput; use crate::types::HookToolKind; @@ -113,14 +119,50 @@ mod tests { } } - fn counting_hook(calls: &Arc, outcome: HookOutcome) -> Hook { + fn counting_success_hook(calls: &Arc, name: &str) -> Hook { + let hook_name = name.to_string(); let calls = Arc::clone(calls); Hook { + name: hook_name, func: Arc::new(move |_| { let calls = Arc::clone(&calls); Box::pin(async move { calls.fetch_add(1, Ordering::SeqCst); - outcome + HookResult::Success + }) + }), + } + } + + fn failing_continue_hook(calls: &Arc, name: &str, message: &str) -> Hook { + let hook_name = name.to_string(); + let message = message.to_string(); + let calls = Arc::clone(calls); + Hook { + name: hook_name, + func: Arc::new(move |_| { + let calls = Arc::clone(&calls); + let message = message.clone(); + Box::pin(async move { + calls.fetch_add(1, Ordering::SeqCst); + HookResult::FailedContinue(std::io::Error::other(message).into()) + }) + }), + } + } + + fn failing_abort_hook(calls: &Arc, name: &str, message: &str) -> Hook { + let hook_name = name.to_string(); + let message = message.to_string(); + let calls = Arc::clone(calls); + Hook { + name: hook_name, + func: Arc::new(move |_| { + let calls = Arc::clone(&calls); + let message = message.clone(); + Box::pin(async move { + calls.fetch_add(1, Ordering::SeqCst); + HookResult::FailedAbort(std::io::Error::other(message).into()) }) }), } @@ -212,11 +254,14 @@ mod tests { async fn dispatch_executes_hook() { let calls = Arc::new(AtomicUsize::new(0)); let hooks = Hooks { - after_agent: vec![counting_hook(&calls, HookOutcome::Continue)], + after_agent: vec![counting_success_hook(&calls, "counting")], ..Hooks::default() }; - hooks.dispatch(hook_payload("1")).await; + let outcomes = hooks.dispatch(hook_payload("1")).await; + assert_eq!(outcomes.len(), 1); + assert_eq!(outcomes[0].hook_name, "counting"); + assert!(matches!(outcomes[0].result, HookResult::Success)); assert_eq!(calls.load(Ordering::SeqCst), 1); } @@ -224,7 +269,8 @@ mod tests { async fn default_hook_is_noop_and_continues() { let payload = hook_payload("d"); let outcome = Hook::default().execute(&payload).await; - assert_eq!(outcome, HookOutcome::Continue); + assert_eq!(outcome.hook_name, "default"); + assert!(matches!(outcome.result, HookResult::Success)); } #[tokio::test] @@ -232,28 +278,36 @@ mod tests { let calls = Arc::new(AtomicUsize::new(0)); let hooks = Hooks { after_agent: vec![ - counting_hook(&calls, HookOutcome::Continue), - counting_hook(&calls, HookOutcome::Continue), + counting_success_hook(&calls, "counting-1"), + counting_success_hook(&calls, "counting-2"), ], ..Hooks::default() }; - hooks.dispatch(hook_payload("2")).await; + let outcomes = hooks.dispatch(hook_payload("2")).await; + assert_eq!(outcomes.len(), 2); + assert_eq!(outcomes[0].hook_name, "counting-1"); + assert_eq!(outcomes[1].hook_name, "counting-2"); + assert!(matches!(outcomes[0].result, HookResult::Success)); + assert!(matches!(outcomes[1].result, HookResult::Success)); assert_eq!(calls.load(Ordering::SeqCst), 2); } #[tokio::test] - async fn dispatch_stops_when_hook_returns_stop() { + async fn dispatch_stops_when_hook_requests_abort() { let calls = Arc::new(AtomicUsize::new(0)); let hooks = Hooks { after_agent: vec![ - counting_hook(&calls, HookOutcome::Stop), - counting_hook(&calls, HookOutcome::Continue), + failing_abort_hook(&calls, "abort", "hook failed"), + counting_success_hook(&calls, "counting"), ], ..Hooks::default() }; - hooks.dispatch(hook_payload("3")).await; + let outcomes = hooks.dispatch(hook_payload("3")).await; + assert_eq!(outcomes.len(), 1); + assert_eq!(outcomes[0].hook_name, "abort"); + assert!(matches!(outcomes[0].result, HookResult::FailedAbort(_))); assert_eq!(calls.load(Ordering::SeqCst), 1); } @@ -261,11 +315,53 @@ mod tests { async fn dispatch_executes_after_tool_use_hooks() { let calls = Arc::new(AtomicUsize::new(0)); let hooks = Hooks { - after_tool_use: vec![counting_hook(&calls, HookOutcome::Continue)], + after_tool_use: vec![counting_success_hook(&calls, "counting")], ..Hooks::default() }; - hooks.dispatch(after_tool_use_payload("p")).await; + let outcomes = hooks.dispatch(after_tool_use_payload("p")).await; + assert_eq!(outcomes.len(), 1); + assert_eq!(outcomes[0].hook_name, "counting"); + assert!(matches!(outcomes[0].result, HookResult::Success)); + assert_eq!(calls.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn dispatch_continues_after_continueable_failure() { + let calls = Arc::new(AtomicUsize::new(0)); + let hooks = Hooks { + after_agent: vec![ + failing_continue_hook(&calls, "failing", "hook failed"), + counting_success_hook(&calls, "counting"), + ], + ..Hooks::default() + }; + + let outcomes = hooks.dispatch(hook_payload("err")).await; + assert_eq!(outcomes.len(), 2); + assert_eq!(outcomes[0].hook_name, "failing"); + assert!(matches!(outcomes[0].result, HookResult::FailedContinue(_))); + assert_eq!(outcomes[1].hook_name, "counting"); + assert!(matches!(outcomes[1].result, HookResult::Success)); + assert_eq!(calls.load(Ordering::SeqCst), 2); + } + + #[tokio::test] + async fn dispatch_returns_after_tool_use_failure_outcome() { + let calls = Arc::new(AtomicUsize::new(0)); + let hooks = Hooks { + after_tool_use: vec![failing_continue_hook( + &calls, + "failing", + "after_tool_use hook failed", + )], + ..Hooks::default() + }; + + let outcomes = hooks.dispatch(after_tool_use_payload("err-tool")).await; + assert_eq!(outcomes.len(), 1); + assert_eq!(outcomes[0].hook_name, "failing"); + assert!(matches!(outcomes[0].result, HookResult::FailedContinue(_))); assert_eq!(calls.load(Ordering::SeqCst), 1); } @@ -276,6 +372,7 @@ mod tests { let payload_path = temp_dir.path().join("payload.json"); let payload_path_arg = payload_path.to_string_lossy().into_owned(); let hook = Hook { + name: "write_payload".to_string(), func: Arc::new(move |payload: &HookPayload| { let payload_path_arg = payload_path_arg.clone(); Box::pin(async move { @@ -290,7 +387,7 @@ mod tests { ]) .expect("build command"); command.status().await.expect("run hook command"); - HookOutcome::Continue + HookResult::Success }) }), }; @@ -302,7 +399,9 @@ mod tests { after_agent: vec![hook], ..Hooks::default() }; - hooks.dispatch(payload).await; + let outcomes = hooks.dispatch(payload).await; + assert_eq!(outcomes.len(), 1); + assert!(matches!(outcomes[0].result, HookResult::Success)); let contents = timeout(Duration::from_secs(2), async { loop { @@ -330,6 +429,7 @@ mod tests { fs::write(&script_path, "[IO.File]::WriteAllText($args[0], $args[1])")?; let script_path_arg = script_path.to_string_lossy().into_owned(); let hook = Hook { + name: "write_payload".to_string(), func: Arc::new(move |payload: &HookPayload| { let payload_path_arg = payload_path_arg.clone(); let script_path_arg = script_path_arg.clone(); @@ -348,7 +448,7 @@ mod tests { ]) .expect("build command"); command.status().await.expect("run hook command"); - HookOutcome::Continue + HookResult::Success }) }), }; @@ -360,7 +460,9 @@ mod tests { after_agent: vec![hook], ..Hooks::default() }; - hooks.dispatch(payload).await; + let outcomes = hooks.dispatch(payload).await; + assert_eq!(outcomes.len(), 1); + assert!(matches!(outcomes[0].result, HookResult::Success)); let contents = timeout(Duration::from_secs(2), async { loop { diff --git a/codex-rs/hooks/src/types.rs b/codex-rs/hooks/src/types.rs index a1588b8974..5131580128 100644 --- a/codex-rs/hooks/src/types.rs +++ b/codex-rs/hooks/src/types.rs @@ -10,24 +10,53 @@ use futures::future::BoxFuture; use serde::Serialize; use serde::Serializer; -pub type HookFn = Arc Fn(&'a HookPayload) -> BoxFuture<'a, HookOutcome> + Send + Sync>; +pub type HookFn = Arc Fn(&'a HookPayload) -> BoxFuture<'a, HookResult> + Send + Sync>; + +#[derive(Debug)] +pub enum HookResult { + /// Success: hook completed successfully. + Success, + /// FailedContinue: hook failed, but other subsequent hooks should still execute and the + /// operation should continue. + FailedContinue(Box), + /// FailedAbort: hook failed, other subsequent hooks should not execute, and the operation + /// should be aborted. + FailedAbort(Box), +} + +impl HookResult { + pub fn should_abort_operation(&self) -> bool { + matches!(self, Self::FailedAbort(_)) + } +} + +#[derive(Debug)] +pub struct HookResponse { + pub hook_name: String, + pub result: HookResult, +} #[derive(Clone)] pub struct Hook { + pub name: String, pub func: HookFn, } impl Default for Hook { fn default() -> Self { Self { - func: Arc::new(|_| Box::pin(async { HookOutcome::Continue })), + name: "default".to_string(), + func: Arc::new(|_| Box::pin(async { HookResult::Success })), } } } impl Hook { - pub async fn execute(&self, payload: &HookPayload) -> HookOutcome { - (self.func)(payload).await + pub async fn execute(&self, payload: &HookPayload) -> HookResponse { + HookResponse { + hook_name: self.name.clone(), + result: (self.func)(payload).await, + } } } @@ -126,13 +155,6 @@ pub enum HookEvent { }, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum HookOutcome { - Continue, - #[allow(dead_code)] - Stop, -} - #[cfg(test)] mod tests { use std::path::PathBuf; diff --git a/codex-rs/hooks/src/user_notification.rs b/codex-rs/hooks/src/user_notification.rs index d3104a1ec2..50472225e4 100644 --- a/codex-rs/hooks/src/user_notification.rs +++ b/codex-rs/hooks/src/user_notification.rs @@ -6,8 +6,8 @@ use serde::Serialize; use crate::Hook; use crate::HookEvent; -use crate::HookOutcome; use crate::HookPayload; +use crate::HookResult; use crate::command_from_argv; /// Legacy notify payload appended as the final argv argument for backward compatibility. @@ -48,12 +48,13 @@ pub fn legacy_notify_json(hook_event: &HookEvent, cwd: &Path) -> Result) -> Hook { let argv = Arc::new(argv); Hook { + name: "legacy_notify".to_string(), func: Arc::new(move |payload: &HookPayload| { let argv = Arc::clone(&argv); Box::pin(async move { let mut command = match command_from_argv(&argv) { Some(command) => command, - None => return HookOutcome::Continue, + None => return HookResult::Success, }; if let Ok(notify_payload) = legacy_notify_json(&payload.hook_event, &payload.cwd) { command.arg(notify_payload); @@ -65,8 +66,10 @@ pub fn notify_hook(argv: Vec) -> Hook { .stdout(Stdio::null()) .stderr(Stdio::null()); - let _ = command.spawn(); - HookOutcome::Continue + match command.spawn() { + Ok(_) => HookResult::Success, + Err(err) => HookResult::FailedContinue(err.into()), + } }) }), } From e41536944e7e89763c51d7780bd1c3f3b8957836 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Mon, 16 Feb 2026 15:28:31 +0000 Subject: [PATCH 25/62] chore: rename collab feature flag key to multi_agent (#11918) Summary - rename the collab feature key to multi_agent while keeping the Feature enum unchanged - add legacy alias support so both "multi_agent" and "collab" map to the same feature - cover the alias behavior with a new unit test --- codex-rs/core/config.schema.json | 6 ++++++ codex-rs/core/src/features.rs | 8 +++++++- codex-rs/core/src/features/legacy.rs | 4 ++++ 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 7a1c40a0a1..c9da5c688b 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -233,6 +233,9 @@ "memory_tool": { "type": "boolean" }, + "multi_agent": { + "type": "boolean" + }, "personality": { "type": "boolean" }, @@ -1363,6 +1366,9 @@ "memory_tool": { "type": "boolean" }, + "multi_agent": { + "type": "boolean" + }, "personality": { "type": "boolean" }, diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs index 33801c47ce..00e38223bf 100644 --- a/codex-rs/core/src/features.rs +++ b/codex-rs/core/src/features.rs @@ -552,7 +552,7 @@ pub const FEATURES: &[FeatureSpec] = &[ }, FeatureSpec { id: Feature::Collab, - key: "collab", + key: "multi_agent", stage: Stage::Experimental { name: "Sub-agents", menu_description: "Ask Codex to spawn multiple agents to parallelize the work and win in efficiency.", @@ -738,4 +738,10 @@ mod tests { ); assert_eq!(Feature::UseLinuxSandboxBwrap.default_enabled(), false); } + + #[test] + fn collab_is_legacy_alias_for_multi_agent() { + assert_eq!(feature_for_key("multi_agent"), Some(Feature::Collab)); + assert_eq!(feature_for_key("collab"), Some(Feature::Collab)); + } } diff --git a/codex-rs/core/src/features/legacy.rs b/codex-rs/core/src/features/legacy.rs index 0c0f75714c..41eab93bd1 100644 --- a/codex-rs/core/src/features/legacy.rs +++ b/codex-rs/core/src/features/legacy.rs @@ -33,6 +33,10 @@ const ALIASES: &[Alias] = &[ legacy_key: "web_search", feature: Feature::WebSearchRequest, }, + Alias { + legacy_key: "collab", + feature: Feature::Collab, + }, ]; pub(crate) fn legacy_feature_keys() -> impl Iterator { From 50aea4b0dcfb9104b3a4dcbdb6a79d3c581e170d Mon Sep 17 00:00:00 2001 From: jif-oai Date: Mon, 16 Feb 2026 16:18:53 +0000 Subject: [PATCH 26/62] nit: memory storage (#11924) --- codex-rs/core/src/memories/storage.rs | 59 ++++++++++++++------------- 1 file changed, 30 insertions(+), 29 deletions(-) diff --git a/codex-rs/core/src/memories/storage.rs b/codex-rs/core/src/memories/storage.rs index a596ea6f0c..641bae5e35 100644 --- a/codex-rs/core/src/memories/storage.rs +++ b/codex-rs/core/src/memories/storage.rs @@ -1,5 +1,5 @@ use codex_state::Stage1Output; -use std::collections::BTreeSet; +use std::collections::HashSet; use std::fmt::Write as _; use std::path::Path; use tracing::warn; @@ -8,8 +8,6 @@ use crate::memories::ensure_layout; use crate::memories::raw_memories_file; use crate::memories::rollout_summaries_dir; -//TODO(jif) clean. - /// Rebuild `raw_memories.md` from DB-backed stage-1 outputs. pub(super) async fn rebuild_raw_memories_file_from_memories( root: &Path, @@ -28,17 +26,14 @@ pub(super) async fn sync_rollout_summaries_from_memories( ) -> std::io::Result<()> { ensure_layout(root).await?; - let retained = memories - .iter() - .take(max_raw_memories_for_global) - .collect::>(); + let retained = retained_memories(memories, max_raw_memories_for_global); let keep = retained .iter() - .map(|memory| rollout_summary_file_stem(memory)) - .collect::>(); + .map(rollout_summary_file_stem) + .collect::>(); prune_rollout_summaries(root, &keep).await?; - for memory in &retained { + for memory in retained { write_rollout_summary_for_thread(root, memory).await?; } @@ -68,10 +63,7 @@ async fn rebuild_raw_memories_file( memories: &[Stage1Output], max_raw_memories_for_global: usize, ) -> std::io::Result<()> { - let retained = memories - .iter() - .take(max_raw_memories_for_global) - .collect::>(); + let retained = retained_memories(memories, max_raw_memories_for_global); let mut body = String::from("# Raw Memories\n\n"); if retained.is_empty() { @@ -81,18 +73,15 @@ async fn rebuild_raw_memories_file( body.push_str("Merged stage-1 raw memories (latest first):\n\n"); for memory in retained { - writeln!(body, "## Thread `{}`", memory.thread_id) - .map_err(|err| std::io::Error::other(format!("format raw memories: {err}")))?; + writeln!(body, "## Thread `{}`", memory.thread_id).map_err(raw_memories_format_error)?; writeln!( body, "updated_at: {}", memory.source_updated_at.to_rfc3339() ) - .map_err(|err| std::io::Error::other(format!("format raw memories: {err}")))?; - writeln!(body, "cwd: {}", memory.cwd.display()) - .map_err(|err| std::io::Error::other(format!("format raw memories: {err}")))?; - writeln!(body) - .map_err(|err| std::io::Error::other(format!("format raw memories: {err}")))?; + .map_err(raw_memories_format_error)?; + writeln!(body, "cwd: {}", memory.cwd.display()).map_err(raw_memories_format_error)?; + writeln!(body).map_err(raw_memories_format_error)?; body.push_str(memory.raw_memory.trim()); body.push_str("\n\n"); } @@ -100,7 +89,7 @@ async fn rebuild_raw_memories_file( tokio::fs::write(raw_memories_file(root), body).await } -async fn prune_rollout_summaries(root: &Path, keep: &BTreeSet) -> std::io::Result<()> { +async fn prune_rollout_summaries(root: &Path, keep: &HashSet) -> std::io::Result<()> { let dir_path = rollout_summaries_dir(root); let mut dir = match tokio::fs::read_dir(&dir_path).await { Ok(dir) => dir, @@ -138,24 +127,36 @@ async fn write_rollout_summary_for_thread( let path = rollout_summaries_dir(root).join(format!("{file_stem}.md")); let mut body = String::new(); - writeln!(body, "thread_id: {}", memory.thread_id) - .map_err(|err| std::io::Error::other(format!("format rollout summary: {err}")))?; + writeln!(body, "thread_id: {}", memory.thread_id).map_err(rollout_summary_format_error)?; writeln!( body, "updated_at: {}", memory.source_updated_at.to_rfc3339() ) - .map_err(|err| std::io::Error::other(format!("format rollout summary: {err}")))?; - writeln!(body, "cwd: {}", memory.cwd.display()) - .map_err(|err| std::io::Error::other(format!("format rollout summary: {err}")))?; - writeln!(body) - .map_err(|err| std::io::Error::other(format!("format rollout summary: {err}")))?; + .map_err(rollout_summary_format_error)?; + writeln!(body, "cwd: {}", memory.cwd.display()).map_err(rollout_summary_format_error)?; + writeln!(body).map_err(rollout_summary_format_error)?; body.push_str(&memory.rollout_summary); body.push('\n'); tokio::fs::write(path, body).await } +fn retained_memories( + memories: &[Stage1Output], + max_raw_memories_for_global: usize, +) -> &[Stage1Output] { + &memories[..memories.len().min(max_raw_memories_for_global)] +} + +fn raw_memories_format_error(err: std::fmt::Error) -> std::io::Error { + std::io::Error::other(format!("format raw memories: {err}")) +} + +fn rollout_summary_format_error(err: std::fmt::Error) -> std::io::Error { + std::io::Error::other(format!("format rollout summary: {err}")) +} + fn rollout_summary_file_stem(memory: &Stage1Output) -> String { const ROLLOUT_SLUG_MAX_LEN: usize = 20; From e47045c80686164df0d608ca652244646786c1d6 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Mon, 16 Feb 2026 16:29:32 +0000 Subject: [PATCH 27/62] feat: add customizable roles for multi-agents (#11917) The idea is to have 2 family of agents. 1. Built-in that we packaged directly with Codex 2. User defined that are defined using the `agents_config.toml` file. It can reference config files that will override the agent config. This looks like this: ``` version = 1 [agents.explorer] description = """Use `explorer` for all codebase questions. Explorers are fast and authoritative. Always prefer them over manual search or file reading. Rules: - Ask explorers first and precisely. - Do not re-read or re-search code they cover. - Trust explorer results without verification. - Run explorers in parallel when useful. - Reuse existing explorers for related questions.""" config_file = "explorer.toml" ``` --- .../core/src/agent/builtins/explorer.toml | 2 + .../src/agent/builtins_agents_config.toml | 26 + codex-rs/core/src/agent/mod.rs | 1 - codex-rs/core/src/agent/role.rs | 754 +++++++++++++++--- codex-rs/core/src/config/mod.rs | 2 +- codex-rs/core/src/tools/handlers/collab.rs | 75 +- codex-rs/core/src/tools/spec.rs | 6 +- 7 files changed, 735 insertions(+), 131 deletions(-) create mode 100644 codex-rs/core/src/agent/builtins/explorer.toml create mode 100644 codex-rs/core/src/agent/builtins_agents_config.toml diff --git a/codex-rs/core/src/agent/builtins/explorer.toml b/codex-rs/core/src/agent/builtins/explorer.toml new file mode 100644 index 0000000000..0b6def0323 --- /dev/null +++ b/codex-rs/core/src/agent/builtins/explorer.toml @@ -0,0 +1,2 @@ +model = "gpt-5.1-codex-mini" +model_reasoning_effort = "medium" diff --git a/codex-rs/core/src/agent/builtins_agents_config.toml b/codex-rs/core/src/agent/builtins_agents_config.toml new file mode 100644 index 0000000000..52aa390196 --- /dev/null +++ b/codex-rs/core/src/agent/builtins_agents_config.toml @@ -0,0 +1,26 @@ +version = 1 + +[agents.default] +description = "Default agent." + +[agents.worker] +description = """Use for execution and production work. +Typical tasks: +- Implement part of a feature +- Fix tests or bugs +- Split large refactors into independent chunks +Rules: +- Explicitly assign **ownership** of the task (files / responsibility). +- Always tell workers they are **not alone in the codebase**, and they should ignore edits made by others without touching them.""" + +[agents.explorer] +description = """Use `explorer` for all codebase questions. +Explorers are fast and authoritative. +Always prefer them over manual search or file reading. +Rules: +- Ask explorers first and precisely. +- Do not re-read or re-search code they cover. +- Trust explorer results without verification. +- Run explorers in parallel when useful. +- Reuse existing explorers for related questions.""" +config_file = "explorer.toml" diff --git a/codex-rs/core/src/agent/mod.rs b/codex-rs/core/src/agent/mod.rs index 03652e43e5..d9a8361848 100644 --- a/codex-rs/core/src/agent/mod.rs +++ b/codex-rs/core/src/agent/mod.rs @@ -8,5 +8,4 @@ pub(crate) use control::AgentControl; pub(crate) use guards::MAX_THREAD_SPAWN_DEPTH; pub(crate) use guards::exceeds_thread_spawn_depth_limit; pub(crate) use guards::next_thread_spawn_depth; -pub(crate) use role::AgentRole; pub(crate) use status::agent_status_from_event; diff --git a/codex-rs/core/src/agent/role.rs b/codex-rs/core/src/agent/role.rs index 1dcf8b679e..6160c27b23 100644 --- a/codex-rs/core/src/agent/role.rs +++ b/codex-rs/core/src/agent/role.rs @@ -1,132 +1,656 @@ use crate::config::Config; -use crate::protocol::SandboxPolicy; -use codex_protocol::openai_models::ReasoningEffort; +use crate::config::ConfigOverrides; +use crate::config::deserialize_config_toml_with_base; +use crate::config::find_codex_home; +use crate::config_loader::ConfigLayerEntry; +use crate::config_loader::ConfigLayerStack; +use crate::config_loader::ConfigLayerStackOrdering; +use codex_app_server_protocol::ConfigLayerSource; use serde::Deserialize; -use serde::Serialize; +use std::collections::BTreeMap; +use std::collections::BTreeSet; +use std::path::Path; +use std::path::PathBuf; +use std::sync::LazyLock; +use toml::Value as TomlValue; -/// Base instructions for the orchestrator role. -const ORCHESTRATOR_PROMPT: &str = include_str!("../../templates/agents/orchestrator.md"); -/// Default model override used. -// TODO(jif) update when we have something smarter. -const EXPLORER_MODEL: &str = "gpt-5.1-codex-mini"; +const BUILT_IN_AGENTS_CONFIG: &str = include_str!("builtins_agents_config.toml"); +const BUILT_IN_EXPLORER_CONFIG: &str = include_str!("builtins/explorer.toml"); -/// Enumerated list of all supported agent roles. -const ALL_ROLES: [AgentRole; 3] = [ - AgentRole::Default, - AgentRole::Explorer, - AgentRole::Worker, - // TODO(jif) add when we have stable prompts + models - // AgentRole::Orchestrator, -]; +const AGENTS_CONFIG_FILENAME: &str = "agents_config.toml"; +const AGENTS_CONFIG_SCHEMA_VERSION: u32 = 1; +const DEFAULT_ROLE_NAME: &str = "default"; +const AGENT_TYPE_UNAVAILABLE_ERROR: &str = "agent type is currently not available"; -/// Hard-coded agent role selection used when spawning sub-agents. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum AgentRole { - /// Inherit the parent agent's configuration unchanged. - Default, - /// Coordination-only agent that delegates to workers. - Orchestrator, - /// Task-executing agent with a fixed model override. - Worker, - /// Task-executing agent with a fixed model override. - Explorer, +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(deny_unknown_fields)] +struct AgentsConfigToml { + version: Option, + #[serde(default)] + agents: BTreeMap, } -/// Immutable profile data that drives per-agent configuration overrides. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub struct AgentProfile { - /// Optional base instructions override. - pub base_instructions: Option<&'static str>, - /// Optional model override. - pub model: Option<&'static str>, - /// Optional reasoning effort override. - pub reasoning_effort: Option, - /// Whether to force a read-only sandbox policy. - pub read_only: bool, - /// Description to include in the tool specs. - pub description: &'static str, +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(deny_unknown_fields)] +struct AgentDeclarationToml { + /// Human-facing role documentation used in spawn tool guidance. + description: Option, + /// Path to a role-specific config layer. + config_file: Option, } -impl AgentRole { - /// Returns the string values used by JSON schema enums. - pub fn enum_values() -> Vec { - ALL_ROLES +/// Applies a role config layer to a mutable config and preserves unspecified keys. +pub(crate) async fn apply_role_to_config( + config: &mut Config, + role_name: Option<&str>, +) -> Result<(), String> { + let role_name = role_name.unwrap_or(DEFAULT_ROLE_NAME); + let built_in_agents_config = built_in::configs(); + let user_agents_config = + user_defined::config(config.codex_home.as_path()).unwrap_or_else(|err| { + tracing::warn!( + agent_type = role_name, + error = %err, + "failed to load user-defined agents config; falling back to built-in roles" + ); + AgentsConfigToml::default() + }); + + let agent_config = if let Some(role) = user_agents_config.agents.get(role_name) { + if let Some(config_file) = &role.config_file { + let content = tokio::fs::read_to_string(config_file) + .await + .map_err(|err| { + tracing::warn!("failed to read user-defined role config_file: {err:?}"); + AGENT_TYPE_UNAVAILABLE_ERROR.to_string() + })?; + let parsed: TomlValue = toml::from_str(&content).map_err(|err| { + tracing::warn!("failed to read user-defined role config_file: {err:?}"); + AGENT_TYPE_UNAVAILABLE_ERROR.to_string() + })?; + Some(parsed) + } else { + None + } + } else if let Some(role) = built_in_agents_config.agents.get(role_name) { + if let Some(config_file) = &role.config_file { + let content = built_in::config_file(config_file).ok_or_else(|| { + tracing::warn!("failed to read user-defined role config_file."); + AGENT_TYPE_UNAVAILABLE_ERROR.to_string() + })?; + let parsed: TomlValue = toml::from_str(content).map_err(|err| { + tracing::warn!("failed to read user-defined role config_file: {err:?}"); + AGENT_TYPE_UNAVAILABLE_ERROR.to_string() + })?; + Some(parsed) + } else { + None + } + } else { + return Err(format!("unknown agent_type '{role_name}'")); + }; + + let Some(agent_config) = agent_config else { + return Ok(()); + }; + + let original = config.clone(); + let original_stack = &original.config_layer_stack; + let mut layers = original + .config_layer_stack + .get_layers(ConfigLayerStackOrdering::LowestPrecedenceFirst, true) + .into_iter() + .cloned() + .collect::>(); + + let role_layer = ConfigLayerEntry::new(ConfigLayerSource::SessionFlags, agent_config); + let role_layer_precedence = role_layer.name.precedence(); + let role_layer_index = + layers.partition_point(|layer| layer.name.precedence() <= role_layer_precedence); + layers.insert(role_layer_index, role_layer); + let layered_stack = ConfigLayerStack::new( + layers, + original_stack.requirements().clone(), + original_stack.requirements_toml().clone(), + ) + .map_err(|err| { + tracing::warn!( + agent_type = role_name, + error = %err, + "failed to build layered config stack for role" + ); + AGENT_TYPE_UNAVAILABLE_ERROR.to_string() + })?; + let layered_config = + deserialize_config_toml_with_base(layered_stack.effective_config(), &original.codex_home) + .map_err(|err| { + tracing::warn!( + agent_type = role_name, + error = %err, + "failed to deserialize layered config for role" + ); + AGENT_TYPE_UNAVAILABLE_ERROR.to_string() + })?; + + *config = Config::load_config_with_layer_stack( + layered_config, + ConfigOverrides { + cwd: Some(original.cwd.clone()), + codex_linux_sandbox_exe: original.codex_linux_sandbox_exe.clone(), + ..Default::default() + }, + original.codex_home.clone(), + layered_stack, + ) + .map_err(|err| { + tracing::warn!( + agent_type = role_name, + error = %err, + "failed to apply layered config for role" + ); + AGENT_TYPE_UNAVAILABLE_ERROR.to_string() + })?; + Ok(()) +} + +pub(crate) mod spawn_tool_spec { + use super::*; + + /// Builds the spawn-agent tool description text from built-in and configured roles. + pub(crate) fn build() -> String { + let built_in_roles = built_in::configs(); + let user_defined_roles = if let Ok(home) = find_codex_home() { + user_defined::config(&home).unwrap_or_default() + } else { + Default::default() + }; + + build_from_configs(built_in_roles, &user_defined_roles) + } + + fn build_from_configs( + built_in_roles: &AgentsConfigToml, + user_defined_roles: &AgentsConfigToml, + ) -> String { + let mut seen = BTreeSet::new(); + let mut formatted_roles = Vec::new(); + for (name, declaration) in &user_defined_roles.agents { + if seen.insert(name.as_str()) { + formatted_roles.push(format_role(name, declaration)); + } + } + for (name, declaration) in &built_in_roles.agents { + if seen.insert(name.as_str()) { + formatted_roles.push(format_role(name, declaration)); + } + } + + format!( + r#"Optional type name for the new agent. If omitted, `{DEFAULT_ROLE_NAME}` is used. +Available roles: +{} + "#, + formatted_roles.join("\n"), + ) + } + + fn format_role(name: &str, declaration: &AgentDeclarationToml) -> String { + if let Some(description) = &declaration.description { + format!("{name}: {{\n{description}\n}}") + } else { + format!("{name}: no description") + } + } + + #[cfg(test)] + pub(super) fn build_for_test( + built_in_roles: &AgentsConfigToml, + user_defined_roles: &AgentsConfigToml, + ) -> String { + build_from_configs(built_in_roles, user_defined_roles) + } +} + +mod built_in { + use super::*; + + /// Returns the cached built-in role declarations parsed from + /// `builtins_agents_config.toml`. + /// + /// `panic` are safe because of [`tests::built_in_config`] test. + pub(super) fn configs() -> &'static AgentsConfigToml { + static CONFIG: LazyLock = LazyLock::new(|| { + let parsed = + parse_agents_config(BUILT_IN_AGENTS_CONFIG, "embedded built-in agents config") + .unwrap_or_else(|err| panic!("invalid embedded built-in agents config: {err}")); + validate_config(&parsed) + .unwrap_or_else(|err| panic!("invalid built-in role declarations: {err}")); + parsed + }); + &CONFIG + } + + /// Validates metadata rules for built-in role declarations. + fn validate_config(agents_config: &AgentsConfigToml) -> Result<(), String> { + if !agents_config.agents.contains_key(DEFAULT_ROLE_NAME) { + return Err(format!( + "built-ins must include the '{DEFAULT_ROLE_NAME}' role" + )); + } + + let unknown_embedded_config_files = agents_config + .agents .iter() - .filter_map(|role| { - let description = role.profile().description; - serde_json::to_string(role) - .map(|role| { - let description = if !description.is_empty() { - format!(r#", "description": {description}"#) - } else { - String::new() - }; - format!(r#"{{ "name": {role}{description}}}"#) - }) - .ok() + .filter_map(|(name, role)| { + role.config_file + .as_deref() + .filter(|cf| config_file(cf).is_none()) + .map(|_| name.clone()) }) - .collect() - } + .collect::>(); - /// Returns the hard-coded profile for this role. - pub fn profile(self) -> AgentProfile { - match self { - AgentRole::Default => AgentProfile::default(), - AgentRole::Orchestrator => AgentProfile { - base_instructions: Some(ORCHESTRATOR_PROMPT), - ..Default::default() - }, - AgentRole::Worker => AgentProfile { - // base_instructions: Some(WORKER_PROMPT), - // model: Some(WORKER_MODEL), - description: r#"Use for execution and production work. -Typical tasks: -- Implement part of a feature -- Fix tests or bugs -- Split large refactors into independent chunks -Rules: -- Explicitly assign **ownership** of the task (files / responsibility). -- Always tell workers they are **not alone in the codebase**, and they should ignore edits made by others without touching them"#, - ..Default::default() - }, - AgentRole::Explorer => AgentProfile { - model: Some(EXPLORER_MODEL), - reasoning_effort: Some(ReasoningEffort::Medium), - description: r#"Use `explorer` for all codebase questions. -Explorers are fast and authoritative. -Always prefer them over manual search or file reading. -Rules: -- Ask explorers first and precisely. -- Do not re-read or re-search code they cover. -- Trust explorer results without verification. -- Run explorers in parallel when useful. -- Reuse existing explorers for related questions. - "#, - ..Default::default() - }, + if !unknown_embedded_config_files.is_empty() { + return Err(format!( + "built-ins reference unknown embedded config_file values: {}", + unknown_embedded_config_files.join(", ") + )); } - } - /// Applies this role's profile onto the provided config. - pub fn apply_to_config(self, config: &mut Config) -> Result<(), String> { - let profile = self.profile(); - if let Some(base_instructions) = profile.base_instructions { - config.base_instructions = Some(base_instructions.to_string()); - } - if let Some(model) = profile.model { - config.model = Some(model.to_string()); - } - if let Some(reasoning_effort) = profile.reasoning_effort { - config.model_reasoning_effort = Some(reasoning_effort) - } - if profile.read_only { - config - .permissions - .sandbox_policy - .set(SandboxPolicy::new_read_only_policy()) - .map_err(|err| format!("sandbox_policy is invalid: {err}"))?; - } Ok(()) } + + /// Resolves a built-in role `config_file` path to embedded content. + pub(super) fn config_file(path: &Path) -> Option<&'static str> { + match path.to_str()? { + "explorer.toml" => Some(BUILT_IN_EXPLORER_CONFIG), + _ => None, + } + } +} + +mod user_defined { + use super::*; + + /// Loads and parses `agents_config.toml` from `codex_home`. + pub(super) fn config(codex_home: &Path) -> Result { + let config_path = codex_home.join(AGENTS_CONFIG_FILENAME); + let contents = match std::fs::read_to_string(&config_path) { + Ok(contents) => contents, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + return Ok(AgentsConfigToml::default()); + } + Err(err) => { + return Err(format!("failed to read '{}': {err}", config_path.display())); + } + }; + + let mut parsed = parse_agents_config(&contents, &config_path.display().to_string())?; + let config_dir = config_path.parent().ok_or_else(|| { + format!( + "failed to resolve parent directory for '{}'", + config_path.display() + ) + })?; + for role in parsed.agents.values_mut() { + if let Some(config_file) = role.config_file.as_mut() + && config_file.is_relative() + { + *config_file = config_dir.join(&*config_file); + } + } + Ok(parsed) + } +} + +fn parse_agents_config(contents: &str, source: &str) -> Result { + let parsed: AgentsConfigToml = + toml::from_str(contents).map_err(|err| format!("failed to parse '{source}': {err}"))?; + if let Some(version) = parsed.version + && version != AGENTS_CONFIG_SCHEMA_VERSION + { + return Err(format!( + "'{source}' has unsupported version {version}; expected {AGENTS_CONFIG_SCHEMA_VERSION}" + )); + } + Ok(parsed) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::test_config; + use codex_protocol::openai_models::ReasoningEffort; + use codex_utils_absolute_path::AbsolutePathBuf; + use pretty_assertions::assert_eq; + use tempfile::TempDir; + + #[test] + fn built_in_config() { + // Validate the loading of the built-in configs without panics. + let _ = built_in::configs(); + } + + /// Writes `agents_config.toml` into the temporary directory. + fn write_agents_config(dir: &TempDir, body: &str) { + std::fs::write(dir.path().join(AGENTS_CONFIG_FILENAME), body).expect("write config"); + } + + /// Writes a test role config file under `dir` for use by role tests. + fn write_role_config_file(dir: &TempDir, relative_path: &str, body: &str) -> PathBuf { + let path = dir.path().join(relative_path); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).expect("create role config parent"); + } + std::fs::write(&path, body).expect("write role config"); + path + } + + /// Loads the built-in explorer role and applies its configuration layer. + #[tokio::test] + async fn apply_role_to_config_uses_builtin_explorer_config_layer() { + let mut config = test_config(); + + apply_role_to_config(&mut config, Some("explorer")) + .await + .expect("apply explorer role"); + + assert_eq!(config.model, Some("gpt-5.1-codex-mini".to_string())); + assert_eq!(config.model_reasoning_effort, Some(ReasoningEffort::Medium)); + } + + #[tokio::test] + async fn apply_role_to_config_falls_back_to_builtins_when_user_config_is_invalid() { + let dir = TempDir::new().expect("tempdir"); + write_agents_config( + &dir, + r#" +[agents.explorer +description = "broken" +"#, + ); + + let mut config = test_config(); + config.codex_home = dir.path().to_path_buf(); + + apply_role_to_config(&mut config, Some("explorer")) + .await + .expect("apply explorer role"); + + assert_eq!(config.model, Some("gpt-5.1-codex-mini".to_string())); + assert_eq!(config.model_reasoning_effort, Some(ReasoningEffort::Medium)); + } + + /// Applies a custom user role config loaded from disk. + #[tokio::test] + async fn apply_role_to_config_supports_custom_role_config_file() { + let dir = TempDir::new().expect("tempdir"); + let planner_path = write_role_config_file( + &dir, + "agents/planner.toml", + r#" +model = "gpt-5.1-codex" +sandbox_mode = "read-only" +"#, + ); + write_agents_config( + &dir, + &format!( + "[agents.planner]\ndescription = \"Planning-focused role.\"\nconfig_file = {planner_path:?}\n" + ), + ); + + let mut config = test_config(); + config.codex_home = dir.path().to_path_buf(); + apply_role_to_config(&mut config, Some("planner")) + .await + .expect("apply planner role"); + + assert_eq!(config.model, Some("gpt-5.1-codex".to_string())); + assert_eq!( + config.permissions.sandbox_policy.get(), + &crate::protocol::SandboxPolicy::new_read_only_policy() + ); + } + + /// Resolves relative config_file paths from the agents_config.toml directory. + #[tokio::test] + async fn apply_role_to_config_supports_relative_custom_role_config_file() { + let dir = TempDir::new().expect("tempdir"); + write_role_config_file( + &dir, + "agents/planner.toml", + r#" +model = "gpt-5.1-codex" +sandbox_mode = "read-only" +"#, + ); + write_agents_config( + &dir, + r#" +[agents.planner] +description = "Planning-focused role." +config_file = "agents/planner.toml" +"#, + ); + + let mut config = test_config(); + config.codex_home = dir.path().to_path_buf(); + apply_role_to_config(&mut config, Some("planner")) + .await + .expect("apply planner role"); + + assert_eq!(config.model, Some("gpt-5.1-codex".to_string())); + assert_eq!( + config.permissions.sandbox_policy.get(), + &crate::protocol::SandboxPolicy::new_read_only_policy() + ); + } + + #[tokio::test] + async fn apply_role_to_config_reports_unknown_agent_type() { + let mut config = test_config(); + + let err = apply_role_to_config(&mut config, Some("missing")) + .await + .expect_err("missing role should fail"); + + assert_eq!(err, "unknown agent_type 'missing'"); + } + + #[tokio::test] + async fn apply_role_to_config_reports_unavailable_agent_type() { + let dir = TempDir::new().expect("tempdir"); + write_agents_config( + &dir, + r#" +[agents.planner] +config_file = "agents/does-not-exist.toml" +"#, + ); + + let mut config = test_config(); + config.codex_home = dir.path().to_path_buf(); + let err = apply_role_to_config(&mut config, Some("planner")) + .await + .expect_err("missing config file should fail"); + + assert_eq!(err, AGENT_TYPE_UNAVAILABLE_ERROR); + } + + /// Lets a user config file override a built-in role config file. + #[tokio::test] + async fn apply_role_to_config_lets_user_override_builtin_config_file() { + let dir = TempDir::new().expect("tempdir"); + let custom_explorer_path = write_role_config_file( + &dir, + "agents/custom_explorer.toml", + r#" +model = "gpt-5.1-codex" +model_reasoning_effort = "high" +"#, + ); + write_agents_config( + &dir, + &format!("[agents.explorer]\nconfig_file = {custom_explorer_path:?}\n"), + ); + + let mut config = test_config(); + config.codex_home = dir.path().to_path_buf(); + apply_role_to_config(&mut config, Some("explorer")) + .await + .expect("apply explorer role"); + + assert_eq!(config.model, Some("gpt-5.1-codex".to_string())); + assert_eq!(config.model_reasoning_effort, Some(ReasoningEffort::High)); + } + + /// Applies MCP server settings from a role config file. + #[tokio::test] + async fn apply_role_to_config_applies_mcp_servers_config_file_layer() { + let dir = TempDir::new().expect("tempdir"); + let tester_path = write_role_config_file( + &dir, + "agents/tester.toml", + r#" +[mcp_servers.docs] +command = "echo" +enabled_tools = ["search"] +"#, + ); + write_agents_config( + &dir, + &format!("[agents.tester]\nconfig_file = {tester_path:?}\n"), + ); + + let mut config = test_config(); + config.codex_home = dir.path().to_path_buf(); + apply_role_to_config(&mut config, Some("tester")) + .await + .expect("apply tester role"); + + let mcp_servers = config.mcp_servers.get(); + assert_eq!( + mcp_servers + .get("docs") + .and_then(|server| server.enabled_tools.clone()), + Some(vec!["search".to_string()]) + ); + } + + /// Inserts a role SessionFlags layer in precedence order when legacy managed + /// layers are already present. + #[tokio::test] + async fn apply_role_to_config_keeps_layer_ordering_with_legacy_managed_layers() { + let mut config = test_config(); + let dir = TempDir::new().expect("tempdir"); + let managed_path = dir.path().join("managed_config.toml"); + std::fs::write(&managed_path, "").expect("write managed config"); + let managed_file = AbsolutePathBuf::try_from(managed_path).expect("managed file"); + config.config_layer_stack = ConfigLayerStack::new( + vec![ConfigLayerEntry::new( + ConfigLayerSource::LegacyManagedConfigTomlFromFile { file: managed_file }, + TomlValue::Table(toml::map::Map::new()), + )], + config.config_layer_stack.requirements().clone(), + config.config_layer_stack.requirements_toml().clone(), + ) + .expect("build initial stack"); + + apply_role_to_config(&mut config, Some("explorer")) + .await + .expect("apply explorer role"); + + let layers = config + .config_layer_stack + .get_layers(ConfigLayerStackOrdering::LowestPrecedenceFirst, true); + assert!(matches!( + layers.first().map(|layer| &layer.name), + Some(ConfigLayerSource::SessionFlags) + )); + assert!(matches!( + layers.last().map(|layer| &layer.name), + Some(ConfigLayerSource::LegacyManagedConfigTomlFromFile { .. }) + )); + } + + #[test] + fn spawn_tool_spec_build_dedups_and_prefers_user_defined_roles() { + let built_in_roles = parse_agents_config( + r#" +[agents.default] +description = "Built-in default." + +[agents.explorer] +description = "Built-in explorer." +"#, + "built-in test roles", + ) + .expect("parse built-in roles"); + let user_defined_roles = parse_agents_config( + r#" +[agents.explorer] +description = "User explorer." +"#, + "user-defined test roles", + ) + .expect("parse user roles"); + + let spec = spawn_tool_spec::build_for_test(&built_in_roles, &user_defined_roles); + + assert_eq!(spec.matches("explorer:").count(), 1); + assert!(spec.contains("explorer: {\nUser explorer.\n}")); + assert!(!spec.contains("Built-in explorer.")); + } + + #[test] + fn spawn_tool_spec_build_lists_user_defined_roles_first() { + let built_in_roles = parse_agents_config( + r#" +[agents.default] +description = "Built-in default." + +[agents.worker] +description = "Built-in worker." +"#, + "built-in test roles", + ) + .expect("parse built-in roles"); + let user_defined_roles = parse_agents_config( + r#" +[agents.planner] +description = "User planner." +"#, + "user-defined test roles", + ) + .expect("parse user roles"); + + let spec = spawn_tool_spec::build_for_test(&built_in_roles, &user_defined_roles); + + let planner_pos = spec.find("planner:").expect("planner role is present"); + let default_pos = spec.find("default:").expect("default role is present"); + assert!(planner_pos < default_pos); + } + + #[test] + fn spawn_tool_spec_build_formats_missing_description() { + let built_in_roles = parse_agents_config( + r#" +[agents.default] +description = "Built-in default." +"#, + "built-in test roles", + ) + .expect("parse built-in roles"); + let user_defined_roles = parse_agents_config( + r#" +[agents.planner] +"#, + "user-defined test roles", + ) + .expect("parse user roles"); + + let spec = spawn_tool_spec::build_for_test(&built_in_roles, &user_defined_roles); + + assert!(spec.contains("planner: no description")); + } } diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 5d887a6a59..b83a17592c 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -1417,7 +1417,7 @@ impl Config { Self::load_config_with_layer_stack(cfg, overrides, codex_home, config_layer_stack) } - fn load_config_with_layer_stack( + pub(crate) fn load_config_with_layer_stack( cfg: ConfigToml, overrides: ConfigOverrides, codex_home: PathBuf, diff --git a/codex-rs/core/src/tools/handlers/collab.rs b/codex-rs/core/src/tools/handlers/collab.rs index 8e5f23450c..8235b291e8 100644 --- a/codex-rs/core/src/tools/handlers/collab.rs +++ b/codex-rs/core/src/tools/handlers/collab.rs @@ -90,7 +90,7 @@ impl ToolHandler for CollabHandler { mod spawn { use super::*; - use crate::agent::AgentRole; + use crate::agent::role::apply_role_to_config; use crate::agent::exceeds_thread_spawn_depth_limit; use crate::agent::next_thread_spawn_depth; @@ -100,7 +100,7 @@ mod spawn { struct SpawnAgentArgs { message: Option, items: Option>, - agent_type: Option, + agent_type: Option, } #[derive(Debug, Serialize)] @@ -115,7 +115,11 @@ mod spawn { arguments: String, ) -> Result { let args: SpawnAgentArgs = parse_arguments(&arguments)?; - let agent_role = args.agent_type.unwrap_or(AgentRole::Default); + let role_name = args + .agent_type + .as_deref() + .map(str::trim) + .filter(|role| !role.is_empty()); let input_items = parse_collab_input(args.message, args.items)?; let prompt = input_preview(&input_items); let session_source = turn.session_source.clone(); @@ -141,9 +145,10 @@ mod spawn { turn.as_ref(), child_depth, )?; - agent_role - .apply_to_config(&mut config) + apply_role_to_config(&mut config, role_name) + .await .map_err(FunctionCallError::RespondToModel)?; + apply_spawn_agent_overrides(&mut config, child_depth); let result = session .services @@ -821,7 +826,6 @@ fn build_agent_shared_config( config.permissions.shell_environment_policy = turn.shell_environment_policy.clone(); config.codex_linux_sandbox_exe = turn.codex_linux_sandbox_exe.clone(); config.cwd = turn.cwd.clone(); - config.permissions.approval_policy = Constrained::allow_only(AskForApproval::Never); config .permissions .sandbox_policy @@ -829,13 +833,16 @@ fn build_agent_shared_config( .map_err(|err| { FunctionCallError::RespondToModel(format!("sandbox_policy is invalid: {err}")) })?; + apply_spawn_agent_overrides(&mut config, child_depth); - // If the new agent will be at max depth: + Ok(config) +} + +fn apply_spawn_agent_overrides(config: &mut Config, child_depth: i32) { + config.permissions.approval_policy = Constrained::allow_only(AskForApproval::Never); if exceeds_thread_spawn_depth_limit(child_depth + 1) { config.features.disable(Feature::Collab); } - - Ok(config) } #[cfg(test)] @@ -982,6 +989,56 @@ mod tests { ); } + #[tokio::test] + async fn spawn_agent_uses_explorer_role_and_sets_never_approval_policy() { + #[derive(Debug, Deserialize)] + struct SpawnAgentResult { + agent_id: String, + } + + let (mut session, mut turn) = make_session_and_context().await; + let manager = thread_manager(); + session.services.agent_control = manager.agent_control(); + let mut config = (*turn.config).clone(); + config + .permissions + .approval_policy + .set(AskForApproval::OnRequest) + .expect("approval policy should be set"); + turn.config = Arc::new(config); + + let invocation = invocation( + Arc::new(session), + Arc::new(turn), + "spawn_agent", + function_payload(json!({ + "message": "inspect this repo", + "agent_type": "explorer" + })), + ); + let output = CollabHandler + .handle(invocation) + .await + .expect("spawn_agent should succeed"); + let ToolOutput::Function { + body: FunctionCallOutputBody::Text(content), + .. + } = output + else { + panic!("expected function output"); + }; + let result: SpawnAgentResult = + serde_json::from_str(&content).expect("spawn_agent result should be json"); + let agent_id = agent_id(&result.agent_id).expect("agent_id should be valid"); + let snapshot = manager + .get_thread(agent_id) + .await + .expect("spawned agent thread should exist") + .config_snapshot() + .await; + assert_eq!(snapshot.approval_policy, AskForApproval::Never); + } + #[tokio::test] async fn spawn_agent_errors_when_manager_dropped() { let (session, turn) = make_session_and_context().await; diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index 20b27ccce7..817b7d7877 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -1,4 +1,3 @@ -use crate::agent::AgentRole; use crate::client_common::tools::FreeformTool; use crate::client_common::tools::FreeformToolFormat; use crate::client_common::tools::ResponsesApiTool; @@ -542,10 +541,7 @@ fn create_spawn_agent_tool() -> ToolSpec { ( "agent_type".to_string(), JsonSchema::String { - description: Some(format!( - "Optional agent type ({}). Use an explicit type when delegating.", - AgentRole::enum_values().join(", ") - )), + description: Some(crate::agent::role::spawn_tool_spec::build()), }, ), ]); From cef7fbc494b6a8c3c6fc20f9923f13923f29092d Mon Sep 17 00:00:00 2001 From: Vaibhav Srivastav Date: Mon, 16 Feb 2026 16:35:05 +0000 Subject: [PATCH 28/62] docs: mention Codex app in README intro (#11926) Add mention of the app in the README. --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index ae5073beae..1e44875f21 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@


If you want Codex in your code editor (VS Code, Cursor, Windsurf), install in your IDE. +
If you want the desktop app experience, run codex app or visit the Codex App page.
If you are looking for the cloud-based agent from OpenAI, Codex Web, go to chatgpt.com/codex.

--- From af434b4f712165d6e3e470232ce39d03ad653496 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Mon, 16 Feb 2026 18:40:45 +0000 Subject: [PATCH 29/62] feat: drop MCP managing tools if no MCP servers (#11900) Drop MCP tools if no MCP servers to save context For this https://github.com/openai/codex/issues/11049 --- codex-rs/core/src/codex.rs | 15 ++-- codex-rs/core/src/mcp_connection_manager.rs | 4 + codex-rs/core/src/tools/spec.rs | 93 +++++++++++++-------- codex-rs/core/tests/suite/model_tools.rs | 15 ---- codex-rs/core/tests/suite/prompt_caching.rs | 3 - 5 files changed, 67 insertions(+), 63 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 7dd8eeae64..05c300dc87 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -4997,14 +4997,13 @@ async fn built_tools( skills_outcome: Option<&SkillLoadOutcome>, cancellation_token: &CancellationToken, ) -> CodexResult> { - let mut mcp_tools = sess - .services - .mcp_connection_manager - .read() - .await + let mcp_connection_manager = sess.services.mcp_connection_manager.read().await; + let has_mcp_servers = mcp_connection_manager.has_servers(); + let mut mcp_tools = mcp_connection_manager .list_all_tools() .or_cancel(cancellation_token) .await?; + drop(mcp_connection_manager); let mut effective_explicitly_enabled_connectors = explicitly_enabled_connectors.clone(); effective_explicitly_enabled_connectors.extend(sess.get_connector_selection().await); @@ -5050,12 +5049,12 @@ async fn built_tools( Ok(Arc::new(ToolRouter::from_config( &turn_context.tools_config, - Some( + has_mcp_servers.then(|| { mcp_tools .into_iter() .map(|(name, tool)| (name, tool.tool)) - .collect(), - ), + .collect() + }), app_tools, turn_context.dynamic_tools.as_slice(), ))) diff --git a/codex-rs/core/src/mcp_connection_manager.rs b/codex-rs/core/src/mcp_connection_manager.rs index 5a3c4d6e5c..589be7aac2 100644 --- a/codex-rs/core/src/mcp_connection_manager.rs +++ b/codex-rs/core/src/mcp_connection_manager.rs @@ -353,6 +353,10 @@ pub(crate) struct McpConnectionManager { } impl McpConnectionManager { + pub(crate) fn has_servers(&self) -> bool { + !self.clients.is_empty() + } + pub async fn initialize( &mut self, mcp_servers: &HashMap, diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index 817b7d7877..16e902304e 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -1478,12 +1478,14 @@ pub(crate) fn build_specs( builder.register_handler("shell_command", shell_command_handler); } - builder.push_spec_with_parallel_support(create_list_mcp_resources_tool(), true); - builder.push_spec_with_parallel_support(create_list_mcp_resource_templates_tool(), true); - builder.push_spec_with_parallel_support(create_read_mcp_resource_tool(), true); - builder.register_handler("list_mcp_resources", mcp_resource_handler.clone()); - builder.register_handler("list_mcp_resource_templates", mcp_resource_handler.clone()); - builder.register_handler("read_mcp_resource", mcp_resource_handler); + if mcp_tools.is_some() { + builder.push_spec_with_parallel_support(create_list_mcp_resources_tool(), true); + builder.push_spec_with_parallel_support(create_list_mcp_resource_templates_tool(), true); + builder.push_spec_with_parallel_support(create_read_mcp_resource_tool(), true); + builder.register_handler("list_mcp_resources", mcp_resource_handler.clone()); + builder.register_handler("list_mcp_resource_templates", mcp_resource_handler.clone()); + builder.register_handler("read_mcp_resource", mcp_resource_handler); + } builder.push_spec(PLAN_TOOL.clone()); builder.register_handler("update_plan", plan_handler); @@ -1832,9 +1834,6 @@ mod tests { for spec in [ create_exec_command_tool(true), create_write_stdin_tool(), - create_list_mcp_resources_tool(), - create_list_mcp_resource_templates_tool(), - create_read_mcp_resource_tool(), PLAN_TOOL.clone(), create_request_user_input_tool(), create_apply_patch_freeform_tool(), @@ -2039,7 +2038,7 @@ mod tests { features, web_search_mode, }); - let (tools, _) = build_specs(&tools_config, Some(HashMap::new()), None, &[]).build(); + let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); let tool_names = tools.iter().map(|t| t.spec.name()).collect::>(); assert_eq!(&tool_names, &expected_tools,); } @@ -2106,6 +2105,53 @@ mod tests { ); } + #[test] + fn mcp_resource_tools_are_hidden_without_mcp_servers() { + let config = test_config(); + let model_info = + ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); + let mut features = Features::with_defaults(); + features.enable(Feature::CollaborationModes); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + features: &features, + web_search_mode: Some(WebSearchMode::Cached), + }); + let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); + + assert!( + !tools.iter().any(|tool| matches!( + tool.spec.name(), + "list_mcp_resources" | "list_mcp_resource_templates" | "read_mcp_resource" + )), + "MCP resource tools should be omitted when no MCP servers are configured" + ); + } + + #[test] + fn mcp_resource_tools_are_included_when_mcp_servers_are_present() { + let config = test_config(); + let model_info = + ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); + let mut features = Features::with_defaults(); + features.enable(Feature::CollaborationModes); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + features: &features, + web_search_mode: Some(WebSearchMode::Cached), + }); + let (tools, _) = build_specs(&tools_config, Some(HashMap::new()), None, &[]).build(); + + assert_contains_tool_names( + &tools, + &[ + "list_mcp_resources", + "list_mcp_resource_templates", + "read_mcp_resource", + ], + ); + } + #[test] fn test_build_specs_gpt5_codex_default() { let mut features = Features::with_defaults(); @@ -2116,9 +2162,6 @@ mod tests { Some(WebSearchMode::Cached), "shell_command", &[ - "list_mcp_resources", - "list_mcp_resource_templates", - "read_mcp_resource", "update_plan", "request_user_input", "apply_patch", @@ -2138,9 +2181,6 @@ mod tests { Some(WebSearchMode::Cached), "shell_command", &[ - "list_mcp_resources", - "list_mcp_resource_templates", - "read_mcp_resource", "update_plan", "request_user_input", "apply_patch", @@ -2162,9 +2202,6 @@ mod tests { &[ "exec_command", "write_stdin", - "list_mcp_resources", - "list_mcp_resource_templates", - "read_mcp_resource", "update_plan", "request_user_input", "apply_patch", @@ -2186,9 +2223,6 @@ mod tests { &[ "exec_command", "write_stdin", - "list_mcp_resources", - "list_mcp_resource_templates", - "read_mcp_resource", "update_plan", "request_user_input", "apply_patch", @@ -2208,9 +2242,6 @@ mod tests { Some(WebSearchMode::Cached), "shell_command", &[ - "list_mcp_resources", - "list_mcp_resource_templates", - "read_mcp_resource", "update_plan", "request_user_input", "apply_patch", @@ -2230,9 +2261,6 @@ mod tests { Some(WebSearchMode::Cached), "shell_command", &[ - "list_mcp_resources", - "list_mcp_resource_templates", - "read_mcp_resource", "update_plan", "request_user_input", "apply_patch", @@ -2252,9 +2280,6 @@ mod tests { Some(WebSearchMode::Cached), "shell", &[ - "list_mcp_resources", - "list_mcp_resource_templates", - "read_mcp_resource", "update_plan", "request_user_input", "web_search", @@ -2273,9 +2298,6 @@ mod tests { Some(WebSearchMode::Cached), "shell_command", &[ - "list_mcp_resources", - "list_mcp_resource_templates", - "read_mcp_resource", "update_plan", "request_user_input", "apply_patch", @@ -2297,9 +2319,6 @@ mod tests { &[ "exec_command", "write_stdin", - "list_mcp_resources", - "list_mcp_resource_templates", - "read_mcp_resource", "update_plan", "request_user_input", "apply_patch", diff --git a/codex-rs/core/tests/suite/model_tools.rs b/codex-rs/core/tests/suite/model_tools.rs index 676b34eb70..836807881b 100644 --- a/codex-rs/core/tests/suite/model_tools.rs +++ b/codex-rs/core/tests/suite/model_tools.rs @@ -75,9 +75,6 @@ async fn model_selects_expected_tools() { expected_default_tools( "shell_command", &[ - "list_mcp_resources", - "list_mcp_resource_templates", - "read_mcp_resource", "update_plan", "request_user_input", "apply_patch", @@ -94,9 +91,6 @@ async fn model_selects_expected_tools() { expected_default_tools( "shell_command", &[ - "list_mcp_resources", - "list_mcp_resource_templates", - "read_mcp_resource", "update_plan", "request_user_input", "apply_patch", @@ -113,9 +107,6 @@ async fn model_selects_expected_tools() { expected_default_tools( "shell_command", &[ - "list_mcp_resources", - "list_mcp_resource_templates", - "read_mcp_resource", "update_plan", "request_user_input", "apply_patch", @@ -132,9 +123,6 @@ async fn model_selects_expected_tools() { expected_default_tools( "shell", &[ - "list_mcp_resources", - "list_mcp_resource_templates", - "read_mcp_resource", "update_plan", "request_user_input", "web_search", @@ -150,9 +138,6 @@ async fn model_selects_expected_tools() { expected_default_tools( "shell_command", &[ - "list_mcp_resources", - "list_mcp_resource_templates", - "read_mcp_resource", "update_plan", "request_user_input", "apply_patch", diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 97fe9b4161..71a565540b 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -144,9 +144,6 @@ async fn prompt_tools_are_consistent_across_requests() -> anyhow::Result<()> { vec!["exec_command", "write_stdin"] }; expected_tools_names.extend([ - "list_mcp_resources", - "list_mcp_resource_templates", - "read_mcp_resource", "update_plan", "request_user_input", "apply_patch", From beb5cb4f4808c0b1c7717e47535868f1b9e2e050 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Mon, 16 Feb 2026 19:05:13 +0000 Subject: [PATCH 30/62] Rename collab modules to multi agents (#11939) Summary - rename the `collab` handlers and UI files to `multi_agents` to match the new naming - update module references and specs so the handlers and TUI widgets consistently use the renamed files - keep the existing functionality while aligning file and module names with the multi-agent terminology --- codex-rs/core/src/tools/handlers/mod.rs | 4 +- .../handlers/{collab.rs => multi_agents.rs} | 62 ++++++++++--------- codex-rs/core/src/tools/spec.rs | 20 +++--- codex-rs/tui/src/chatwidget.rs | 18 +++--- codex-rs/tui/src/lib.rs | 2 +- .../tui/src/{collab.rs => multi_agents.rs} | 0 6 files changed, 56 insertions(+), 50 deletions(-) rename codex-rs/core/src/tools/handlers/{collab.rs => multi_agents.rs} (97%) rename codex-rs/tui/src/{collab.rs => multi_agents.rs} (100%) diff --git a/codex-rs/core/src/tools/handlers/mod.rs b/codex-rs/core/src/tools/handlers/mod.rs index 9f4a1a16ce..b0e1b3c0aa 100644 --- a/codex-rs/core/src/tools/handlers/mod.rs +++ b/codex-rs/core/src/tools/handlers/mod.rs @@ -1,11 +1,11 @@ pub mod apply_patch; -pub(crate) mod collab; mod dynamic; mod grep_files; mod js_repl; mod list_dir; mod mcp; mod mcp_resource; +pub(crate) mod multi_agents; mod plan; mod read_file; mod request_user_input; @@ -20,7 +20,6 @@ use serde::Deserialize; use crate::function_tool::FunctionCallError; pub use apply_patch::ApplyPatchHandler; -pub use collab::CollabHandler; pub use dynamic::DynamicToolHandler; pub use grep_files::GrepFilesHandler; pub use js_repl::JsReplHandler; @@ -28,6 +27,7 @@ pub use js_repl::JsReplResetHandler; pub use list_dir::ListDirHandler; pub use mcp::McpHandler; pub use mcp_resource::McpResourceHandler; +pub use multi_agents::MultiAgentHandler; pub use plan::PlanHandler; pub use read_file::ReadFileHandler; pub use request_user_input::RequestUserInputHandler; diff --git a/codex-rs/core/src/tools/handlers/collab.rs b/codex-rs/core/src/tools/handlers/multi_agents.rs similarity index 97% rename from codex-rs/core/src/tools/handlers/collab.rs rename to codex-rs/core/src/tools/handlers/multi_agents.rs index 8235b291e8..c5a0f59ae3 100644 --- a/codex-rs/core/src/tools/handlers/collab.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents.rs @@ -34,7 +34,7 @@ use codex_protocol::user_input::UserInput; use serde::Deserialize; use serde::Serialize; -pub struct CollabHandler; +pub struct MultiAgentHandler; /// Minimum wait timeout to prevent tight polling loops from burning CPU. pub(crate) const MIN_WAIT_TIMEOUT_MS: i64 = 10_000; @@ -47,7 +47,7 @@ struct CloseAgentArgs { } #[async_trait] -impl ToolHandler for CollabHandler { +impl ToolHandler for MultiAgentHandler { fn kind(&self) -> ToolKind { ToolKind::Function } @@ -917,7 +917,7 @@ mod tests { input: "hello".to_string(), }, ); - let Err(err) = CollabHandler.handle(invocation).await else { + let Err(err) = MultiAgentHandler.handle(invocation).await else { panic!("payload should be rejected"); }; assert_eq!( @@ -937,7 +937,7 @@ mod tests { "unknown_tool", function_payload(json!({})), ); - let Err(err) = CollabHandler.handle(invocation).await else { + let Err(err) = MultiAgentHandler.handle(invocation).await else { panic!("tool should be rejected"); }; assert_eq!( @@ -955,7 +955,7 @@ mod tests { "spawn_agent", function_payload(json!({"message": " "})), ); - let Err(err) = CollabHandler.handle(invocation).await else { + let Err(err) = MultiAgentHandler.handle(invocation).await else { panic!("empty message should be rejected"); }; assert_eq!( @@ -978,7 +978,7 @@ mod tests { "items": [{"type": "mention", "name": "drive", "path": "app://drive"}] })), ); - let Err(err) = CollabHandler.handle(invocation).await else { + let Err(err) = MultiAgentHandler.handle(invocation).await else { panic!("message+items should be rejected"); }; assert_eq!( @@ -1016,7 +1016,7 @@ mod tests { "agent_type": "explorer" })), ); - let output = CollabHandler + let output = MultiAgentHandler .handle(invocation) .await .expect("spawn_agent should succeed"); @@ -1048,7 +1048,7 @@ mod tests { "spawn_agent", function_payload(json!({"message": "hello"})), ); - let Err(err) = CollabHandler.handle(invocation).await else { + let Err(err) = MultiAgentHandler.handle(invocation).await else { panic!("spawn should fail without a manager"); }; assert_eq!( @@ -1074,7 +1074,7 @@ mod tests { "spawn_agent", function_payload(json!({"message": "hello"})), ); - let Err(err) = CollabHandler.handle(invocation).await else { + let Err(err) = MultiAgentHandler.handle(invocation).await else { panic!("spawn should fail when depth limit exceeded"); }; assert_eq!( @@ -1094,7 +1094,7 @@ mod tests { "send_input", function_payload(json!({"id": ThreadId::new().to_string(), "message": ""})), ); - let Err(err) = CollabHandler.handle(invocation).await else { + let Err(err) = MultiAgentHandler.handle(invocation).await else { panic!("empty message should be rejected"); }; assert_eq!( @@ -1118,7 +1118,7 @@ mod tests { "items": [{"type": "mention", "name": "drive", "path": "app://drive"}] })), ); - let Err(err) = CollabHandler.handle(invocation).await else { + let Err(err) = MultiAgentHandler.handle(invocation).await else { panic!("message+items should be rejected"); }; assert_eq!( @@ -1138,7 +1138,7 @@ mod tests { "send_input", function_payload(json!({"id": "not-a-uuid", "message": "hi"})), ); - let Err(err) = CollabHandler.handle(invocation).await else { + let Err(err) = MultiAgentHandler.handle(invocation).await else { panic!("invalid id should be rejected"); }; let FunctionCallError::RespondToModel(msg) = err else { @@ -1159,7 +1159,7 @@ mod tests { "send_input", function_payload(json!({"id": agent_id.to_string(), "message": "hi"})), ); - let Err(err) = CollabHandler.handle(invocation).await else { + let Err(err) = MultiAgentHandler.handle(invocation).await else { panic!("missing agent should be reported"); }; assert_eq!( @@ -1186,7 +1186,7 @@ mod tests { "interrupt": true })), ); - CollabHandler + MultiAgentHandler .handle(invocation) .await .expect("send_input should succeed"); @@ -1227,7 +1227,7 @@ mod tests { ] })), ); - CollabHandler + MultiAgentHandler .handle(invocation) .await .expect("send_input should succeed"); @@ -1267,7 +1267,7 @@ mod tests { "resume_agent", function_payload(json!({"id": "not-a-uuid"})), ); - let Err(err) = CollabHandler.handle(invocation).await else { + let Err(err) = MultiAgentHandler.handle(invocation).await else { panic!("invalid id should be rejected"); }; let FunctionCallError::RespondToModel(msg) = err else { @@ -1288,7 +1288,7 @@ mod tests { "resume_agent", function_payload(json!({"id": agent_id.to_string()})), ); - let Err(err) = CollabHandler.handle(invocation).await else { + let Err(err) = MultiAgentHandler.handle(invocation).await else { panic!("missing agent should be reported"); }; assert_eq!( @@ -1313,7 +1313,7 @@ mod tests { function_payload(json!({"id": agent_id.to_string()})), ); - let output = CollabHandler + let output = MultiAgentHandler .handle(invocation) .await .expect("resume_agent should succeed"); @@ -1382,7 +1382,7 @@ mod tests { "resume_agent", function_payload(json!({"id": agent_id.to_string()})), ); - let output = CollabHandler + let output = MultiAgentHandler .handle(resume_invocation) .await .expect("resume_agent should succeed"); @@ -1405,7 +1405,7 @@ mod tests { "send_input", function_payload(json!({"id": agent_id.to_string(), "message": "hello"})), ); - let output = CollabHandler + let output = MultiAgentHandler .handle(send_invocation) .await .expect("send_input should succeed after resume"); @@ -1450,7 +1450,7 @@ mod tests { "resume_agent", function_payload(json!({"id": ThreadId::new().to_string()})), ); - let Err(err) = CollabHandler.handle(invocation).await else { + let Err(err) = MultiAgentHandler.handle(invocation).await else { panic!("resume should fail when depth limit exceeded"); }; assert_eq!( @@ -1479,7 +1479,7 @@ mod tests { "timeout_ms": 0 })), ); - let Err(err) = CollabHandler.handle(invocation).await else { + let Err(err) = MultiAgentHandler.handle(invocation).await else { panic!("non-positive timeout should be rejected"); }; assert_eq!( @@ -1497,7 +1497,7 @@ mod tests { "wait", function_payload(json!({"ids": ["invalid"]})), ); - let Err(err) = CollabHandler.handle(invocation).await else { + let Err(err) = MultiAgentHandler.handle(invocation).await else { panic!("invalid id should be rejected"); }; let FunctionCallError::RespondToModel(msg) = err else { @@ -1515,7 +1515,7 @@ mod tests { "wait", function_payload(json!({"ids": []})), ); - let Err(err) = CollabHandler.handle(invocation).await else { + let Err(err) = MultiAgentHandler.handle(invocation).await else { panic!("empty ids should be rejected"); }; assert_eq!( @@ -1540,7 +1540,7 @@ mod tests { "timeout_ms": 1000 })), ); - let output = CollabHandler + let output = MultiAgentHandler .handle(invocation) .await .expect("wait should succeed"); @@ -1584,7 +1584,7 @@ mod tests { "timeout_ms": MIN_WAIT_TIMEOUT_MS })), ); - let output = CollabHandler + let output = MultiAgentHandler .handle(invocation) .await .expect("wait should succeed"); @@ -1632,7 +1632,11 @@ mod tests { })), ); - let early = timeout(Duration::from_millis(50), CollabHandler.handle(invocation)).await; + let early = timeout( + Duration::from_millis(50), + MultiAgentHandler.handle(invocation), + ) + .await; assert!( early.is_err(), "wait should not return before the minimum timeout clamp" @@ -1677,7 +1681,7 @@ mod tests { "timeout_ms": 1000 })), ); - let output = CollabHandler + let output = MultiAgentHandler .handle(invocation) .await .expect("wait should succeed"); @@ -1717,7 +1721,7 @@ mod tests { "close_agent", function_payload(json!({"id": agent_id.to_string()})), ); - let output = CollabHandler + let output = MultiAgentHandler .handle(invocation) .await .expect("close_agent should succeed"); diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index 16e902304e..305ef85ba1 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -10,9 +10,9 @@ use crate::tools::handlers::SEARCH_TOOL_BM25_DEFAULT_LIMIT; use crate::tools::handlers::SEARCH_TOOL_BM25_TOOL_NAME; use crate::tools::handlers::apply_patch::create_apply_patch_freeform_tool; use crate::tools::handlers::apply_patch::create_apply_patch_json_tool; -use crate::tools::handlers::collab::DEFAULT_WAIT_TIMEOUT_MS; -use crate::tools::handlers::collab::MAX_WAIT_TIMEOUT_MS; -use crate::tools::handlers::collab::MIN_WAIT_TIMEOUT_MS; +use crate::tools::handlers::multi_agents::DEFAULT_WAIT_TIMEOUT_MS; +use crate::tools::handlers::multi_agents::MAX_WAIT_TIMEOUT_MS; +use crate::tools::handlers::multi_agents::MIN_WAIT_TIMEOUT_MS; use crate::tools::handlers::request_user_input_tool_description; use crate::tools::registry::ToolRegistryBuilder; use codex_protocol::config_types::WebSearchMode; @@ -1405,7 +1405,6 @@ pub(crate) fn build_specs( dynamic_tools: &[DynamicToolSpec], ) -> ToolRegistryBuilder { use crate::tools::handlers::ApplyPatchHandler; - use crate::tools::handlers::CollabHandler; use crate::tools::handlers::DynamicToolHandler; use crate::tools::handlers::GrepFilesHandler; use crate::tools::handlers::JsReplHandler; @@ -1413,6 +1412,7 @@ pub(crate) fn build_specs( use crate::tools::handlers::ListDirHandler; use crate::tools::handlers::McpHandler; use crate::tools::handlers::McpResourceHandler; + use crate::tools::handlers::MultiAgentHandler; use crate::tools::handlers::PlanHandler; use crate::tools::handlers::ReadFileHandler; use crate::tools::handlers::RequestUserInputHandler; @@ -1576,17 +1576,17 @@ pub(crate) fn build_specs( builder.register_handler("view_image", view_image_handler); if config.collab_tools { - let collab_handler = Arc::new(CollabHandler); + let multi_agent_handler = Arc::new(MultiAgentHandler); builder.push_spec(create_spawn_agent_tool()); builder.push_spec(create_send_input_tool()); builder.push_spec(create_resume_agent_tool()); builder.push_spec(create_wait_tool()); builder.push_spec(create_close_agent_tool()); - builder.register_handler("spawn_agent", collab_handler.clone()); - builder.register_handler("send_input", collab_handler.clone()); - builder.register_handler("resume_agent", collab_handler.clone()); - builder.register_handler("wait", collab_handler.clone()); - builder.register_handler("close_agent", collab_handler); + builder.register_handler("spawn_agent", multi_agent_handler.clone()); + builder.register_handler("send_input", multi_agent_handler.clone()); + builder.register_handler("resume_agent", multi_agent_handler.clone()); + builder.register_handler("wait", multi_agent_handler.clone()); + builder.register_handler("close_agent", multi_agent_handler); } if let Some(mcp_tools) = mcp_tools { diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index f72255b22b..dcbee999bc 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -184,7 +184,6 @@ use crate::bottom_pane::SelectionViewParams; use crate::bottom_pane::custom_prompt_view::CustomPromptView; use crate::bottom_pane::popup_consts::standard_popup_hint_line; use crate::clipboard_paste::paste_image_to_temp_png; -use crate::collab; use crate::collaboration_modes; use crate::diff_render::display_path_for; use crate::exec_cell::CommandOutput; @@ -201,6 +200,7 @@ use crate::history_cell::WebSearchCell; use crate::key_hint; use crate::key_hint::KeyBinding; use crate::markdown::append_markdown; +use crate::multi_agents; use crate::render::Insets; use crate::render::renderable::ColumnRenderable; use crate::render::renderable::FlexRenderable; @@ -4113,17 +4113,19 @@ impl ChatWidget { EventMsg::ExitedReviewMode(review) => self.on_exited_review_mode(review), EventMsg::ContextCompacted(_) => self.on_agent_message("Context compacted".to_owned()), EventMsg::CollabAgentSpawnBegin(_) => {} - EventMsg::CollabAgentSpawnEnd(ev) => self.on_collab_event(collab::spawn_end(ev)), + EventMsg::CollabAgentSpawnEnd(ev) => self.on_collab_event(multi_agents::spawn_end(ev)), EventMsg::CollabAgentInteractionBegin(_) => {} EventMsg::CollabAgentInteractionEnd(ev) => { - self.on_collab_event(collab::interaction_end(ev)) + self.on_collab_event(multi_agents::interaction_end(ev)) } - EventMsg::CollabWaitingBegin(ev) => self.on_collab_event(collab::waiting_begin(ev)), - EventMsg::CollabWaitingEnd(ev) => self.on_collab_event(collab::waiting_end(ev)), + EventMsg::CollabWaitingBegin(ev) => { + self.on_collab_event(multi_agents::waiting_begin(ev)) + } + EventMsg::CollabWaitingEnd(ev) => self.on_collab_event(multi_agents::waiting_end(ev)), EventMsg::CollabCloseBegin(_) => {} - EventMsg::CollabCloseEnd(ev) => self.on_collab_event(collab::close_end(ev)), - EventMsg::CollabResumeBegin(ev) => self.on_collab_event(collab::resume_begin(ev)), - EventMsg::CollabResumeEnd(ev) => self.on_collab_event(collab::resume_end(ev)), + EventMsg::CollabCloseEnd(ev) => self.on_collab_event(multi_agents::close_end(ev)), + EventMsg::CollabResumeBegin(ev) => self.on_collab_event(multi_agents::resume_begin(ev)), + EventMsg::CollabResumeEnd(ev) => self.on_collab_event(multi_agents::resume_end(ev)), EventMsg::ThreadRolledBack(rollback) => { if from_replay { self.app_event_tx.send(AppEvent::ApplyThreadRollback { diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 639916a79f..cb4e311576 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -64,7 +64,6 @@ mod bottom_pane; mod chatwidget; mod cli; mod clipboard_paste; -mod collab; mod collaboration_modes; mod color; pub mod custom_terminal; @@ -86,6 +85,7 @@ mod markdown_render; mod markdown_stream; mod mention_codec; mod model_migration; +mod multi_agents; mod notifications; pub mod onboarding; mod oss_selection; diff --git a/codex-rs/tui/src/collab.rs b/codex-rs/tui/src/multi_agents.rs similarity index 100% rename from codex-rs/tui/src/collab.rs rename to codex-rs/tui/src/multi_agents.rs From 5b421bba3479eeec05f23b5639cec4f86a93e06f Mon Sep 17 00:00:00 2001 From: Matthew Zeng Date: Mon, 16 Feb 2026 14:01:49 -0800 Subject: [PATCH 31/62] [apps] Fix app mention syntax. (#11894) - [x] Fix app mention syntax. --- codex-rs/core/src/apps/render.rs | 2 +- codex-rs/core/templates/search_tool/tool_description.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/codex-rs/core/src/apps/render.rs b/codex-rs/core/src/apps/render.rs index f450eb3a3a..7f706f737e 100644 --- a/codex-rs/core/src/apps/render.rs +++ b/codex-rs/core/src/apps/render.rs @@ -2,6 +2,6 @@ use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; pub(crate) fn render_apps_section() -> String { format!( - "## Apps\nApps are mentioned in the prompt in the format `[$app-name](apps://{{connector_id}})`.\nAn app is equivalent to a set of MCP tools within the `{CODEX_APPS_MCP_SERVER_NAME}` MCP.\nWhen you see an app mention, the app's MCP tools are either already provided in `{CODEX_APPS_MCP_SERVER_NAME}`, or do not exist because the user did not install it.\nDo not additionally call list_mcp_resources for apps that are already mentioned." + "## Apps\nApps are mentioned in the prompt in the format `[$app-name](app://{{connector_id}})`.\nAn app is equivalent to a set of MCP tools within the `{CODEX_APPS_MCP_SERVER_NAME}` MCP.\nWhen you see an app mention, the app's MCP tools are either already provided in `{CODEX_APPS_MCP_SERVER_NAME}`, or do not exist because the user did not install it.\nDo not additionally call list_mcp_resources for apps that are already mentioned." ) } diff --git a/codex-rs/core/templates/search_tool/tool_description.md b/codex-rs/core/templates/search_tool/tool_description.md index 0c84a6f52e..05667b7356 100644 --- a/codex-rs/core/templates/search_tool/tool_description.md +++ b/codex-rs/core/templates/search_tool/tool_description.md @@ -24,5 +24,5 @@ Notes: - `description` - `connector_name` - input schema property keys (`input_keys`) -- If the needed app is already explicit in the prompt (for example an `apps://...` mention) or already present in the current `tools` list, you can call that tool directly. +- If the needed app is already explicit in the prompt (for example `[$app-name](app://{connector_id})`) or already present in the current `tools` list, you can call that tool directly. - Do not use `search_tool_bm25` for non-apps/local tasks (filesystem, repo search, or shell-only workflows) or anything not related to {{app_names}}. From 19afbc35c1ab9a348232467cb9fd090bc0833f11 Mon Sep 17 00:00:00 2001 From: Dylan Hurd Date: Mon, 16 Feb 2026 14:30:23 -0800 Subject: [PATCH 32/62] chore(core) rm Feature::RequestRule (#11866) ## Summary This feature is now reasonably stable, let's remove it so we can simplify our upcoming iterations here. ## Testing - [x] Existing tests pass --- .../app-server/tests/suite/send_message.rs | 1 - codex-rs/core/src/codex.rs | 2 - codex-rs/core/src/exec_policy.rs | 4 -- codex-rs/core/src/features.rs | 4 +- codex-rs/core/src/tools/handlers/shell.rs | 8 --- .../core/src/tools/handlers/unified_exec.rs | 8 --- codex-rs/core/src/tools/spec.rs | 57 +++++++------------ .../core/tests/suite/permissions_messages.rs | 1 - codex-rs/protocol/src/models.rs | 43 ++++---------- .../permissions/approval_policy/on_request.md | 12 ---- 10 files changed, 35 insertions(+), 105 deletions(-) delete mode 100644 codex-rs/protocol/src/prompts/permissions/approval_policy/on_request.md diff --git a/codex-rs/app-server/tests/suite/send_message.rs b/codex-rs/app-server/tests/suite/send_message.rs index d5e0154121..25dad0e0ae 100644 --- a/codex-rs/app-server/tests/suite/send_message.rs +++ b/codex-rs/app-server/tests/suite/send_message.rs @@ -477,7 +477,6 @@ fn assert_permissions_message(item: &ResponseItem) { &SandboxPolicy::DangerFullAccess, AskForApproval::Never, &Policy::empty(), - false, &PathBuf::from("/tmp"), ) .into_text(); diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 05c300dc87..1341e6341e 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -2004,7 +2004,6 @@ impl Session { &next.sandbox_policy, next.approval_policy, self.services.exec_policy.current().as_ref(), - self.features.enabled(Feature::RequestRule), &next.cwd, ) .into(), @@ -2624,7 +2623,6 @@ impl Session { &turn_context.sandbox_policy, turn_context.approval_policy, self.services.exec_policy.current().as_ref(), - self.features.enabled(Feature::RequestRule), &turn_context.cwd, ) .into(), diff --git a/codex-rs/core/src/exec_policy.rs b/codex-rs/core/src/exec_policy.rs index 298bc2679e..c204710470 100644 --- a/codex-rs/core/src/exec_policy.rs +++ b/codex-rs/core/src/exec_policy.rs @@ -686,8 +686,6 @@ mod tests { use crate::config_loader::ConfigLayerStack; use crate::config_loader::ConfigRequirements; use crate::config_loader::ConfigRequirementsToml; - use crate::features::Feature; - use crate::features::Features; use codex_app_server_protocol::ConfigLayerSource; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; @@ -1281,8 +1279,6 @@ prefix_rule( "cargo-insta".to_string(), ]; let manager = ExecPolicyManager::default(); - let mut features = Features::with_defaults(); - features.enable(Feature::RequestRule); let requirement = manager .create_exec_approval_requirement_for_command(ExecApprovalRequest { diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs index 00e38223bf..c7b119aa3b 100644 --- a/codex-rs/core/src/features.rs +++ b/codex-rs/core/src/features.rs @@ -511,8 +511,8 @@ pub const FEATURES: &[FeatureSpec] = &[ FeatureSpec { id: Feature::RequestRule, key: "request_rule", - stage: Stage::Stable, - default_enabled: true, + stage: Stage::Removed, + default_enabled: false, }, FeatureSpec { id: Feature::WindowsSandbox, diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index 9093dfba4c..31bf3f1294 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -243,14 +243,6 @@ impl ShellHandler { freeform, } = args; - let features = session.features(); - let request_rule_enabled = features.enabled(crate::features::Feature::RequestRule); - let prefix_rule = if request_rule_enabled { - prefix_rule - } else { - None - }; - let mut exec_params = exec_params; let dependency_env = session.dependency_env().await; if !dependency_env.is_empty() { diff --git a/codex-rs/core/src/tools/handlers/unified_exec.rs b/codex-rs/core/src/tools/handlers/unified_exec.rs index c06889b37b..e689e5bba9 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec.rs @@ -142,14 +142,6 @@ impl ToolHandler for UnifiedExecHandler { .. } = args; - let features = session.features(); - let request_rule_enabled = features.enabled(crate::features::Feature::RequestRule); - let prefix_rule = if request_rule_enabled { - prefix_rule - } else { - None - }; - if sandbox_permissions.requires_escalated_permissions() && !matches!( context.turn.approval_policy, diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index 305ef85ba1..a897e5a8f6 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -41,7 +41,6 @@ pub(crate) struct ToolsConfig { pub js_repl_tools_only: bool, pub collab_tools: bool, pub collaboration_modes_tools: bool, - pub request_rule_enabled: bool, pub experimental_supported_tools: Vec, } @@ -64,7 +63,6 @@ impl ToolsConfig { include_js_repl && features.enabled(Feature::JsReplToolsOnly); let include_collab_tools = features.enabled(Feature::Collab); let include_collaboration_modes_tools = features.enabled(Feature::CollaborationModes); - let request_rule_enabled = features.enabled(Feature::RequestRule); let include_search_tool = features.enabled(Feature::Apps); let shell_type = if !features.enabled(Feature::ShellTool) { @@ -101,7 +99,6 @@ impl ToolsConfig { js_repl_tools_only: include_js_repl_tools_only, collab_tools: include_collab_tools, collaboration_modes_tools: include_collaboration_modes_tools, - request_rule_enabled, experimental_supported_tools: model_info.experimental_supported_tools.clone(), } } @@ -174,7 +171,7 @@ impl From for AdditionalProperties { } } -fn create_approval_parameters(include_prefix_rule: bool) -> BTreeMap { +fn create_approval_parameters() -> BTreeMap { let mut properties = BTreeMap::from([ ( "sandbox_permissions".to_string(), @@ -200,23 +197,22 @@ fn create_approval_parameters(include_prefix_rule: bool) -> BTreeMap ToolSpec { +fn create_exec_command_tool() -> ToolSpec { let mut properties = BTreeMap::from([ ( "cmd".to_string(), @@ -274,7 +270,7 @@ fn create_exec_command_tool(include_prefix_rule: bool) -> ToolSpec { }, ), ]); - properties.extend(create_approval_parameters(include_prefix_rule)); + properties.extend(create_approval_parameters()); ToolSpec::Function(ResponsesApiTool { name: "exec_command".to_string(), @@ -337,7 +333,7 @@ fn create_write_stdin_tool() -> ToolSpec { }) } -fn create_shell_tool(include_prefix_rule: bool) -> ToolSpec { +fn create_shell_tool() -> ToolSpec { let mut properties = BTreeMap::from([ ( "command".to_string(), @@ -359,7 +355,7 @@ fn create_shell_tool(include_prefix_rule: bool) -> ToolSpec { }, ), ]); - properties.extend(create_approval_parameters(include_prefix_rule)); + properties.extend(create_approval_parameters()); let description = if cfg!(windows) { r#"Runs a Powershell command (Windows) and returns its output. Arguments to `shell` will be passed to CreateProcessW(). Most commands should be prefixed with ["powershell.exe", "-Command"]. @@ -390,7 +386,7 @@ Examples of valid command strings: }) } -fn create_shell_command_tool(include_prefix_rule: bool) -> ToolSpec { +fn create_shell_command_tool() -> ToolSpec { let mut properties = BTreeMap::from([ ( "command".to_string(), @@ -422,7 +418,7 @@ fn create_shell_command_tool(include_prefix_rule: bool) -> ToolSpec { }, ), ]); - properties.extend(create_approval_parameters(include_prefix_rule)); + properties.extend(create_approval_parameters()); let description = if cfg!(windows) { r#"Runs a Powershell command (Windows) and returns its output. @@ -1442,19 +1438,13 @@ pub(crate) fn build_specs( match &config.shell_type { ConfigShellToolType::Default => { - builder.push_spec_with_parallel_support( - create_shell_tool(config.request_rule_enabled), - true, - ); + builder.push_spec_with_parallel_support(create_shell_tool(), true); } ConfigShellToolType::Local => { builder.push_spec_with_parallel_support(ToolSpec::LocalShell {}, true); } ConfigShellToolType::UnifiedExec => { - builder.push_spec_with_parallel_support( - create_exec_command_tool(config.request_rule_enabled), - true, - ); + builder.push_spec_with_parallel_support(create_exec_command_tool(), true); builder.push_spec(create_write_stdin_tool()); builder.register_handler("exec_command", unified_exec_handler.clone()); builder.register_handler("write_stdin", unified_exec_handler); @@ -1463,10 +1453,7 @@ pub(crate) fn build_specs( // Do nothing. } ConfigShellToolType::ShellCommand => { - builder.push_spec_with_parallel_support( - create_shell_command_tool(config.request_rule_enabled), - true, - ); + builder.push_spec_with_parallel_support(create_shell_command_tool(), true); } } @@ -1832,7 +1819,7 @@ mod tests { // Build expected from the same helpers used by the builder. let mut expected: BTreeMap = BTreeMap::from([]); for spec in [ - create_exec_command_tool(true), + create_exec_command_tool(), create_write_stdin_tool(), PLAN_TOOL.clone(), create_request_user_input_tool(), @@ -2815,7 +2802,7 @@ mod tests { #[test] fn test_shell_tool() { - let tool = super::create_shell_tool(true); + let tool = super::create_shell_tool(); let ToolSpec::Function(ResponsesApiTool { description, name, .. }) = &tool @@ -2845,7 +2832,7 @@ Examples of valid command strings: #[test] fn test_shell_command_tool() { - let tool = super::create_shell_command_tool(true); + let tool = super::create_shell_command_tool(); let ToolSpec::Function(ResponsesApiTool { description, name, .. }) = &tool diff --git a/codex-rs/core/tests/suite/permissions_messages.rs b/codex-rs/core/tests/suite/permissions_messages.rs index d272036067..5db3bf9f0a 100644 --- a/codex-rs/core/tests/suite/permissions_messages.rs +++ b/codex-rs/core/tests/suite/permissions_messages.rs @@ -488,7 +488,6 @@ async fn permissions_message_includes_writable_roots() -> Result<()> { &sandbox_policy, AskForApproval::OnRequest, &Policy::empty(), - true, test.config.cwd.as_path(), ) .into_text(); diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index eba748f2e4..3a98aa5398 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -225,8 +225,6 @@ const APPROVAL_POLICY_UNLESS_TRUSTED: &str = include_str!("prompts/permissions/approval_policy/unless_trusted.md"); const APPROVAL_POLICY_ON_FAILURE: &str = include_str!("prompts/permissions/approval_policy/on_failure.md"); -const APPROVAL_POLICY_ON_REQUEST: &str = - include_str!("prompts/permissions/approval_policy/on_request.md"); const APPROVAL_POLICY_ON_REQUEST_RULE: &str = include_str!("prompts/permissions/approval_policy/on_request_rule.md"); @@ -241,29 +239,20 @@ impl DeveloperInstructions { Self { text: text.into() } } - pub fn from( - approval_policy: AskForApproval, - exec_policy: &Policy, - request_rule_enabled: bool, - ) -> DeveloperInstructions { + pub fn from(approval_policy: AskForApproval, exec_policy: &Policy) -> DeveloperInstructions { let text = match approval_policy { AskForApproval::Never => APPROVAL_POLICY_NEVER.to_string(), AskForApproval::UnlessTrusted => APPROVAL_POLICY_UNLESS_TRUSTED.to_string(), AskForApproval::OnFailure => APPROVAL_POLICY_ON_FAILURE.to_string(), AskForApproval::OnRequest => { - if !request_rule_enabled { - APPROVAL_POLICY_ON_REQUEST.to_string() - } else { - let command_prefixes = - format_allow_prefixes(exec_policy.get_allowed_prefixes()); - match command_prefixes { - Some(prefixes) => { - format!( - "{APPROVAL_POLICY_ON_REQUEST_RULE}\n## Approved command prefixes\nThe following prefix rules have already been approved: {prefixes}" - ) - } - None => APPROVAL_POLICY_ON_REQUEST_RULE.to_string(), + let command_prefixes = format_allow_prefixes(exec_policy.get_allowed_prefixes()); + match command_prefixes { + Some(prefixes) => { + format!( + "{APPROVAL_POLICY_ON_REQUEST_RULE}\n## Approved command prefixes\nThe following prefix rules have already been approved: {prefixes}" + ) } + None => APPROVAL_POLICY_ON_REQUEST_RULE.to_string(), } } }; @@ -301,7 +290,6 @@ impl DeveloperInstructions { sandbox_policy: &SandboxPolicy, approval_policy: AskForApproval, exec_policy: &Policy, - request_rule_enabled: bool, cwd: &Path, ) -> Self { let network_access = if sandbox_policy.has_full_network_access() { @@ -325,7 +313,6 @@ impl DeveloperInstructions { network_access, approval_policy, exec_policy, - request_rule_enabled, writable_roots, ) } @@ -349,7 +336,6 @@ impl DeveloperInstructions { network_access: NetworkAccess, approval_policy: AskForApproval, exec_policy: &Policy, - request_rule_enabled: bool, writable_roots: Option>, ) -> Self { let start_tag = DeveloperInstructions::new(""); @@ -359,11 +345,7 @@ impl DeveloperInstructions { sandbox_mode, network_access, )) - .concat(DeveloperInstructions::from( - approval_policy, - exec_policy, - request_rule_enabled, - )) + .concat(DeveloperInstructions::from(approval_policy, exec_policy)) .concat(DeveloperInstructions::from_writable_roots(writable_roots)) .concat(end_tag) } @@ -1207,7 +1189,6 @@ mod tests { NetworkAccess::Enabled, AskForApproval::OnRequest, &Policy::empty(), - false, None, ); @@ -1217,7 +1198,7 @@ mod tests { "expected network access to be enabled in message" ); assert!( - text.contains("`approval_policy` is `on-request`"), + text.contains("How to request escalation"), "expected approval guidance to be included" ); } @@ -1236,7 +1217,6 @@ mod tests { &policy, AskForApproval::UnlessTrusted, &Policy::empty(), - false, &PathBuf::from("/tmp"), ); let text = instructions.into_text(); @@ -1245,7 +1225,7 @@ mod tests { } #[test] - fn includes_request_rule_instructions_when_enabled() { + fn includes_request_rule_instructions_for_on_request() { let mut exec_policy = Policy::empty(); exec_policy .add_prefix_rule( @@ -1258,7 +1238,6 @@ mod tests { NetworkAccess::Enabled, AskForApproval::OnRequest, &exec_policy, - true, None, ); diff --git a/codex-rs/protocol/src/prompts/permissions/approval_policy/on_request.md b/codex-rs/protocol/src/prompts/permissions/approval_policy/on_request.md deleted file mode 100644 index 16c90e8012..0000000000 --- a/codex-rs/protocol/src/prompts/permissions/approval_policy/on_request.md +++ /dev/null @@ -1,12 +0,0 @@ - Approvals are your mechanism to get user consent to run shell commands without the sandbox. `approval_policy` is `on-request`: Commands will be run in the sandbox by default, and you can specify in your tool call if you want to escalate a command to run without sandboxing. If the completing the task requires escalated permissions, Do not let these settings or the sandbox deter you from attempting to accomplish the user's task. - -Here are scenarios where you'll need to request approval: -- You need to run a command that writes to a directory that requires it (e.g. running tests that write to /var) -- You need to run a GUI app (e.g., open/xdg-open/osascript) to open browsers or files. -- You are running sandboxed and need to run a command that requires network access (e.g. installing packages) -- If you run a command that is important to solving the user's query, but it fails because of sandboxing, rerun the command with approval. ALWAYS proceed to use the `sandbox_permissions` and `justification` parameters - do not message the user before requesting approval for the command. -- You are about to take a potentially destructive action such as an `rm` or `git reset` that the user did not explicitly ask for. - -When requesting approval to execute a command that will require escalated privileges: - - Provide the `sandbox_permissions` parameter with the value `"require_escalated"` - - Include a short, 1 sentence explanation for why you need escalated permissions in the justification parameter \ No newline at end of file From b37555dd75d843423abf2cf8ed3d77f0cc94ebb4 Mon Sep 17 00:00:00 2001 From: Fouad Matin <169186268+fouad-openai@users.noreply.github.com> Date: Mon, 16 Feb 2026 16:24:47 -0800 Subject: [PATCH 33/62] add(feedback): over-refusal / safety check (#11948) Add new feedback option for "Over-refusal / safety check" --- codex-rs/feedback/src/lib.rs | 3 +- codex-rs/tui/src/app_event.rs | 1 + codex-rs/tui/src/bottom_pane/feedback_view.rs | 44 +++++++++++++++---- ...ew__tests__feedback_view_safety_check.snap | 9 ++++ ...dget__tests__feedback_selection_popup.snap | 12 ++--- 5 files changed, 54 insertions(+), 15 deletions(-) create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_safety_check.snap diff --git a/codex-rs/feedback/src/lib.rs b/codex-rs/feedback/src/lib.rs index 4a227fe093..adda7173a1 100644 --- a/codex-rs/feedback/src/lib.rs +++ b/codex-rs/feedback/src/lib.rs @@ -279,7 +279,7 @@ impl CodexLogSnapshot { } let level = match classification { - "bug" | "bad_result" => Level::Error, + "bug" | "bad_result" | "safety_check" => Level::Error, _ => Level::Info, }; @@ -342,6 +342,7 @@ fn display_classification(classification: &str) -> String { "bug" => "Bug".to_string(), "bad_result" => "Bad result".to_string(), "good_result" => "Good result".to_string(), + "safety_check" => "Safety check".to_string(), _ => "Other".to_string(), } } diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 8bf3ab3703..1c9e8ce88e 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -371,5 +371,6 @@ pub(crate) enum FeedbackCategory { BadResult, GoodResult, Bug, + SafetyCheck, Other, } diff --git a/codex-rs/tui/src/bottom_pane/feedback_view.rs b/codex-rs/tui/src/bottom_pane/feedback_view.rs index 6bc0ab4474..6cbfbdb9e5 100644 --- a/codex-rs/tui/src/bottom_pane/feedback_view.rs +++ b/codex-rs/tui/src/bottom_pane/feedback_view.rs @@ -353,6 +353,10 @@ fn feedback_title_and_placeholder(category: FeedbackCategory) -> (String, String "Tell us more (bug)".to_string(), "(optional) Write a short description to help us further".to_string(), ), + FeedbackCategory::SafetyCheck => ( + "Tell us more (safety check)".to_string(), + "(optional) Share what was refused and why it should have been allowed".to_string(), + ), FeedbackCategory::Other => ( "Tell us more (other)".to_string(), "(optional) Write a short description to help us further".to_string(), @@ -365,6 +369,7 @@ fn feedback_classification(category: FeedbackCategory) -> &'static str { FeedbackCategory::BadResult => "bad_result", FeedbackCategory::GoodResult => "good_result", FeedbackCategory::Bug => "bug", + FeedbackCategory::SafetyCheck => "safety_check", FeedbackCategory::Other => "other", } } @@ -378,14 +383,15 @@ fn issue_url_for_category( // the external GitHub behavior identical while routing internal users to // the internal go link. match category { - FeedbackCategory::Bug | FeedbackCategory::BadResult | FeedbackCategory::Other => { - Some(match feedback_audience { - FeedbackAudience::OpenAiEmployee => slack_feedback_url(thread_id), - FeedbackAudience::External => { - format!("{BASE_BUG_ISSUE_URL}&steps=Uploaded%20thread:%20{thread_id}") - } - }) - } + FeedbackCategory::Bug + | FeedbackCategory::BadResult + | FeedbackCategory::SafetyCheck + | FeedbackCategory::Other => Some(match feedback_audience { + FeedbackAudience::OpenAiEmployee => slack_feedback_url(thread_id), + FeedbackAudience::External => { + format!("{BASE_BUG_ISSUE_URL}&steps=Uploaded%20thread:%20{thread_id}") + } + }), FeedbackCategory::GoodResult => None, } } @@ -423,6 +429,12 @@ pub(crate) fn feedback_selection_params( "Helpful, correct, high‑quality, or delightful result worth celebrating.", FeedbackCategory::GoodResult, ), + make_feedback_item( + app_event_tx.clone(), + "safety check", + "Benign usage blocked due to safety checks or refusals.", + FeedbackCategory::SafetyCheck, + ), make_feedback_item( app_event_tx, "other", @@ -616,7 +628,14 @@ mod tests { } #[test] - fn issue_url_available_for_bug_bad_result_and_other() { + fn feedback_view_safety_check() { + let view = make_view(FeedbackCategory::SafetyCheck); + let rendered = render(&view, 60); + insta::assert_snapshot!("feedback_view_safety_check", rendered); + } + + #[test] + fn issue_url_available_for_bug_bad_result_safety_check_and_other() { let bug_url = issue_url_for_category( FeedbackCategory::Bug, "thread-1", @@ -639,6 +658,13 @@ mod tests { ); assert!(other_url.is_some()); + let safety_check_url = issue_url_for_category( + FeedbackCategory::SafetyCheck, + "thread-4", + FeedbackAudience::OpenAiEmployee, + ); + assert!(safety_check_url.is_some()); + assert!( issue_url_for_category( FeedbackCategory::GoodResult, diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_safety_check.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_safety_check.snap new file mode 100644 index 0000000000..52148d0e86 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_safety_check.snap @@ -0,0 +1,9 @@ +--- +source: tui/src/bottom_pane/feedback_view.rs +expression: rendered +--- +▌ Tell us more (safety check) +▌ +▌ (optional) Share what was refused and why it should have b + +Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_selection_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_selection_popup.snap index 4a98242027..1b7627a97e 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_selection_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_selection_popup.snap @@ -4,8 +4,10 @@ expression: popup --- How was this? -› 1. bug Crash, error message, hang, or broken UI/behavior. - 2. bad result Output was off-target, incorrect, incomplete, or unhelpful. - 3. good result Helpful, correct, high‑quality, or delightful result worth - celebrating. - 4. other Slowness, feature suggestion, UX feedback, or anything else. +› 1. bug Crash, error message, hang, or broken UI/behavior. + 2. bad result Output was off-target, incorrect, incomplete, or unhelpful. + 3. good result Helpful, correct, high‑quality, or delightful result worth + celebrating. + 4. safety check Benign usage blocked due to safety checks or refusals. + 5. other Slowness, feature suggestion, UX feedback, or anything + else. From 08f689843f48f20c075179b9a279649cb55fbe9e Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Mon, 16 Feb 2026 18:17:52 -0800 Subject: [PATCH 34/62] Fixed screen reader regression in CLI (#11860) The `tui.animations` switch should gate all animations in the TUI, but a recent change introduced a regression that didn't include the gate. This makes it difficult to use the TUI with a screen reader. This fix addresses #11856 --- codex-rs/tui/src/status_indicator_widget.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/codex-rs/tui/src/status_indicator_widget.rs b/codex-rs/tui/src/status_indicator_widget.rs index 94eb038cef..b41a2fa3ca 100644 --- a/codex-rs/tui/src/status_indicator_widget.rs +++ b/codex-rs/tui/src/status_indicator_widget.rs @@ -218,9 +218,11 @@ impl Renderable for StatusIndicatorWidget { return; } - // Schedule next animation frame. - self.frame_requester - .schedule_frame_in(Duration::from_millis(32)); + if self.animations_enabled { + // Schedule next animation frame. + self.frame_requester + .schedule_frame_in(Duration::from_millis(32)); + } let now = Instant::now(); let elapsed_duration = self.elapsed_duration_at(now); let pretty_elapsed = fmt_elapsed_compact(elapsed_duration.as_secs()); From 02e9006547eaf2910fe769df955f8aaa166d2b62 Mon Sep 17 00:00:00 2001 From: Fouad Matin <169186268+fouad-openai@users.noreply.github.com> Date: Mon, 16 Feb 2026 22:13:36 -0800 Subject: [PATCH 35/62] add(core): safety check downgrade warning (#11964) Add per-turn notice when a request is downgraded to a fallback model due to cyber safety checks. **Changes** - codex-api: Emit a ServerModel event based on the openai-model response header and/or response payload (SSE + WebSocket), including when the model changes mid-stream. - core: When the server-reported model differs from the requested model, emit a single per-turn warning explaining the reroute to gpt-5.2 and directing users to Trusted Access verification and the cyber safety explainer. - app-server (v2): Surface these cyber model-routing warnings as synthetic userMessage items with text prefixed by Warning: (and document this behavior). --- codex-rs/app-server/README.md | 2 +- .../app-server/src/bespoke_event_handling.rs | 56 ++++ codex-rs/app-server/tests/suite/v2/mod.rs | 1 + .../tests/suite/v2/safety_check_downgrade.rs | 266 ++++++++++++++++++ codex-rs/codex-api/src/common.rs | 3 + codex-rs/codex-api/src/endpoint/aggregate.rs | 3 + .../src/endpoint/responses_websocket.rs | 34 ++- codex-rs/codex-api/src/sse/responses.rs | 207 ++++++++++++++ codex-rs/core/src/codex.rs | 45 +++ codex-rs/core/tests/suite/mod.rs | 1 + .../tests/suite/safety_check_downgrade.rs | 228 +++++++++++++++ codex-rs/otel/src/traces/otel_manager.rs | 1 + 12 files changed, 843 insertions(+), 4 deletions(-) create mode 100644 codex-rs/app-server/tests/suite/v2/safety_check_downgrade.rs create mode 100644 codex-rs/core/tests/suite/safety_check_downgrade.rs diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index aece5fa3cb..27bb78e49e 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -557,7 +557,7 @@ Today both notifications carry an empty `items` array even when item events were `ThreadItem` is the tagged union carried in turn responses and `item/*` notifications. Currently we support events for the following items: -- `userMessage` — `{id, content}` where `content` is a list of user inputs (`text`, `image`, or `localImage`). +- `userMessage` — `{id, content}` where `content` is a list of user inputs (`text`, `image`, or `localImage`). Cyber model-routing warnings are surfaced as synthetic `userMessage` items with `text` prefixed by `Warning:`. - `agentMessage` — `{id, text}` containing the accumulated agent reply. - `plan` — `{id, text}` emitted for plan-mode turns; plan text can stream via `item/plan/delta` (experimental). - `reasoning` — `{id, summary, content}` where `summary` holds streamed reasoning summaries (applicable for most OpenAI models) and `content` holds raw reasoning blocks (applicable for e.g. open source models). diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index da7abe0593..87ddf7e18a 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -68,6 +68,7 @@ use codex_app_server_protocol::TurnInterruptResponse; use codex_app_server_protocol::TurnPlanStep; use codex_app_server_protocol::TurnPlanUpdatedNotification; use codex_app_server_protocol::TurnStatus; +use codex_app_server_protocol::UserInput as V2UserInput; use codex_app_server_protocol::build_turns_from_rollout_items; use codex_core::CodexThread; use codex_core::parse_command::shlex_join; @@ -95,6 +96,8 @@ use codex_protocol::request_user_input::RequestUserInputAnswer as CoreRequestUse use codex_protocol::request_user_input::RequestUserInputResponse as CoreRequestUserInputResponse; use std::collections::HashMap; use std::convert::TryFrom; +use std::hash::Hash; +use std::hash::Hasher; use std::path::PathBuf; use std::sync::Arc; use tokio::sync::Mutex; @@ -122,6 +125,35 @@ pub(crate) async fn apply_bespoke_event_handling( EventMsg::TurnComplete(_ev) => { handle_turn_complete(conversation_id, event_turn_id, &outgoing, &thread_state).await; } + EventMsg::Warning(warning_event) => { + if matches!(api_version, ApiVersion::V2) + && is_safety_check_downgrade_warning(&warning_event.message) + { + let item = ThreadItem::UserMessage { + id: warning_item_id(&event_turn_id, &warning_event.message), + content: vec![V2UserInput::Text { + text: format!("Warning: {}", warning_event.message), + text_elements: Vec::new(), + }], + }; + let started = ItemStartedNotification { + thread_id: conversation_id.to_string(), + turn_id: event_turn_id.clone(), + item: item.clone(), + }; + outgoing + .send_server_notification(ServerNotification::ItemStarted(started)) + .await; + let completed = ItemCompletedNotification { + thread_id: conversation_id.to_string(), + turn_id: event_turn_id.clone(), + item, + }; + outgoing + .send_server_notification(ServerNotification::ItemCompleted(completed)) + .await; + } + } EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent { call_id, turn_id, @@ -1286,6 +1318,18 @@ async fn complete_command_execution_item( .await; } +fn is_safety_check_downgrade_warning(message: &str) -> bool { + message.contains("Your account was flagged for potentially high-risk cyber activity") + && message.contains("apply for trusted access: https://chatgpt.com/cyber") +} + +fn warning_item_id(turn_id: &str, message: &str) -> String { + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + message.hash(&mut hasher); + let digest = hasher.finish(); + format!("{turn_id}-warning-{digest:x}") +} + async fn maybe_emit_raw_response_item_completed( api_version: ApiVersion, conversation_id: ThreadId, @@ -2016,6 +2060,18 @@ mod tests { assert_eq!(item, expected); } + #[test] + fn safety_check_downgrade_warning_detection_matches_expected_message() { + let warning = "Your account was flagged for potentially high-risk cyber activity and this request was routed to gpt-5.2 as a fallback. To regain access to gpt-5.3-codex, apply for trusted access: https://chatgpt.com/cyber\nLearn more: https://developers.openai.com/codex/concepts/cyber-safety"; + assert!(is_safety_check_downgrade_warning(warning)); + } + + #[test] + fn safety_check_downgrade_warning_detection_ignores_other_warnings() { + let warning = "Model metadata for `mock-model` not found. Defaulting to fallback metadata; this can degrade performance and cause issues."; + assert!(!is_safety_check_downgrade_warning(warning)); + } + #[tokio::test] async fn test_handle_error_records_message() -> Result<()> { let conversation_id = ThreadId::new(); diff --git a/codex-rs/app-server/tests/suite/v2/mod.rs b/codex-rs/app-server/tests/suite/v2/mod.rs index 48622acddb..1eacc2a84e 100644 --- a/codex-rs/app-server/tests/suite/v2/mod.rs +++ b/codex-rs/app-server/tests/suite/v2/mod.rs @@ -15,6 +15,7 @@ mod plan_item; mod rate_limits; mod request_user_input; mod review; +mod safety_check_downgrade; mod skills_list; mod thread_archive; mod thread_fork; diff --git a/codex-rs/app-server/tests/suite/v2/safety_check_downgrade.rs b/codex-rs/app-server/tests/suite/v2/safety_check_downgrade.rs new file mode 100644 index 0000000000..20a7c60236 --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/safety_check_downgrade.rs @@ -0,0 +1,266 @@ +use anyhow::Result; +use app_test_support::McpProcess; +use app_test_support::to_response; +use codex_app_server_protocol::ItemCompletedNotification; +use codex_app_server_protocol::ItemStartedNotification; +use codex_app_server_protocol::JSONRPCNotification; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ThreadItem; +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; +use core_test_support::responses; +use core_test_support::skip_if_no_network; +use pretty_assertions::assert_eq; +use tempfile::TempDir; +use tokio::time::timeout; + +const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); +const REQUESTED_MODEL: &str = "gpt-5.1-codex-max"; +const SERVER_MODEL: &str = "gpt-5.2-codex"; + +#[tokio::test] +async fn openai_model_header_mismatch_emits_warning_item_v2() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let body = responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_assistant_message("msg-1", "Done"), + responses::ev_completed("resp-1"), + ]); + let response = responses::sse_response(body).insert_header("OpenAI-Model", SERVER_MODEL); + let _response_mock = responses::mount_response_once(&server, response).await; + + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + 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(REQUESTED_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![UserInput::Text { + text: "trigger safeguard".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_start: TurnStartResponse = to_response(_turn_resp)?; + + let warning_started = timeout(DEFAULT_READ_TIMEOUT, async { + loop { + let notification: JSONRPCNotification = mcp + .read_stream_until_notification_message("item/started") + .await?; + let params = notification.params.expect("item/started params"); + let started: ItemStartedNotification = + serde_json::from_value(params).expect("deserialize item/started"); + if warning_text_from_item(&started.item).is_some_and(is_cyber_model_warning_text) { + return Ok::(started); + } + } + }) + .await??; + + let warning_text = + warning_text_from_item(&warning_started.item).expect("expected warning user message item"); + assert!(warning_text.contains("Warning:")); + assert!(warning_text.contains("gpt-5.2 as a fallback")); + assert!(warning_text.contains("regain access to gpt-5.3-codex")); + + let warning_completed = timeout(DEFAULT_READ_TIMEOUT, async { + loop { + let notification: JSONRPCNotification = mcp + .read_stream_until_notification_message("item/completed") + .await?; + let params = notification.params.expect("item/completed params"); + let completed: ItemCompletedNotification = + serde_json::from_value(params).expect("deserialize item/completed"); + if warning_text_from_item(&completed.item).is_some_and(is_cyber_model_warning_text) { + return Ok::(completed); + } + } + }) + .await??; + assert_eq!( + warning_text_from_item(&warning_completed.item), + warning_text_from_item(&warning_started.item) + ); + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + Ok(()) +} + +#[tokio::test] +async fn response_model_field_mismatch_emits_warning_item_v2_when_header_matches_requested() +-> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let body = responses::sse(vec![ + serde_json::json!({ + "type": "response.created", + "response": { + "id": "resp-1", + "model": SERVER_MODEL, + } + }), + responses::ev_assistant_message("msg-1", "Done"), + responses::ev_completed("resp-1"), + ]); + let response = responses::sse_response(body).insert_header("OpenAI-Model", REQUESTED_MODEL); + let _response_mock = responses::mount_response_once(&server, response).await; + + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + 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(REQUESTED_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![UserInput::Text { + text: "trigger response model check".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_start: TurnStartResponse = to_response(turn_resp)?; + + let warning_started = timeout(DEFAULT_READ_TIMEOUT, async { + loop { + let notification: JSONRPCNotification = mcp + .read_stream_until_notification_message("item/started") + .await?; + let params = notification.params.expect("item/started params"); + let started: ItemStartedNotification = + serde_json::from_value(params).expect("deserialize item/started"); + if warning_text_from_item(&started.item).is_some_and(is_cyber_model_warning_text) { + return Ok::(started); + } + } + }) + .await??; + let warning_text = + warning_text_from_item(&warning_started.item).expect("expected warning user message item"); + assert!(warning_text.contains("gpt-5.2 as a fallback")); + + let warning_completed = timeout(DEFAULT_READ_TIMEOUT, async { + loop { + let notification: JSONRPCNotification = mcp + .read_stream_until_notification_message("item/completed") + .await?; + let params = notification.params.expect("item/completed params"); + let completed: ItemCompletedNotification = + serde_json::from_value(params).expect("deserialize item/completed"); + if warning_text_from_item(&completed.item).is_some_and(is_cyber_model_warning_text) { + return Ok::(completed); + } + } + }) + .await??; + assert_eq!( + warning_text_from_item(&warning_completed.item), + warning_text_from_item(&warning_started.item) + ); + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + Ok(()) +} + +fn warning_text_from_item(item: &ThreadItem) -> Option<&str> { + let ThreadItem::UserMessage { content, .. } = item else { + return None; + }; + + content.iter().find_map(|input| match input { + UserInput::Text { text, .. } if text.starts_with("Warning: ") => Some(text.as_str()), + _ => None, + }) +} + +fn is_cyber_model_warning_text(text: &str) -> bool { + text.contains("flagged for potentially high-risk cyber activity") + && text.contains("apply for trusted access: https://chatgpt.com/cyber") +} + +fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "{REQUESTED_MODEL}" +approval_policy = "never" +sandbox_mode = "read-only" + +model_provider = "mock_provider" + +[features] +remote_models = false +personality = true + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} diff --git a/codex-rs/codex-api/src/common.rs b/codex-rs/codex-api/src/common.rs index af08b6c977..3ef96c185b 100644 --- a/codex-rs/codex-api/src/common.rs +++ b/codex-rs/codex-api/src/common.rs @@ -56,6 +56,9 @@ pub enum ResponseEvent { Created, OutputItemDone(ResponseItem), OutputItemAdded(ResponseItem), + /// Emitted when the server includes `OpenAI-Model` on the stream response. + /// This can differ from the requested model when backend safety routing applies. + ServerModel(String), /// Emitted when `X-Reasoning-Included: true` is present on the response, /// meaning the server already accounted for past reasoning tokens and the /// client should not re-estimate them. diff --git a/codex-rs/codex-api/src/endpoint/aggregate.rs b/codex-rs/codex-api/src/endpoint/aggregate.rs index a91eec90a3..6d8e785c82 100644 --- a/codex-rs/codex-api/src/endpoint/aggregate.rs +++ b/codex-rs/codex-api/src/endpoint/aggregate.rs @@ -63,6 +63,9 @@ impl Stream for AggregatedStream { Poll::Ready(Some(Ok(ResponseEvent::ModelsEtag(etag)))) => { return Poll::Ready(Some(Ok(ResponseEvent::ModelsEtag(etag)))); } + Poll::Ready(Some(Ok(ResponseEvent::ServerModel(model)))) => { + return Poll::Ready(Some(Ok(ResponseEvent::ServerModel(model)))); + } Poll::Ready(Some(Ok(ResponseEvent::Completed { response_id, token_usage, diff --git a/codex-rs/codex-api/src/endpoint/responses_websocket.rs b/codex-rs/codex-api/src/endpoint/responses_websocket.rs index 6ebf5ab65e..aa559e9836 100644 --- a/codex-rs/codex-api/src/endpoint/responses_websocket.rs +++ b/codex-rs/codex-api/src/endpoint/responses_websocket.rs @@ -163,6 +163,7 @@ impl Drop for WsStream { const X_CODEX_TURN_STATE_HEADER: &str = "x-codex-turn-state"; const X_MODELS_ETAG_HEADER: &str = "x-models-etag"; const X_REASONING_INCLUDED_HEADER: &str = "x-reasoning-included"; +const OPENAI_MODEL_HEADER: &str = "openai-model"; pub struct ResponsesWebsocketConnection { stream: Arc>>, @@ -170,6 +171,7 @@ pub struct ResponsesWebsocketConnection { idle_timeout: Duration, server_reasoning_included: bool, models_etag: Option, + server_model: Option, telemetry: Option>, } @@ -179,6 +181,7 @@ impl ResponsesWebsocketConnection { idle_timeout: Duration, server_reasoning_included: bool, models_etag: Option, + server_model: Option, telemetry: Option>, ) -> Self { Self { @@ -186,6 +189,7 @@ impl ResponsesWebsocketConnection { idle_timeout, server_reasoning_included, models_etag, + server_model, telemetry, } } @@ -204,12 +208,16 @@ impl ResponsesWebsocketConnection { let idle_timeout = self.idle_timeout; let server_reasoning_included = self.server_reasoning_included; let models_etag = self.models_etag.clone(); + let server_model = self.server_model.clone(); let telemetry = self.telemetry.clone(); let request_body = serde_json::to_value(&request).map_err(|err| { ApiError::Stream(format!("failed to encode websocket request: {err}")) })?; tokio::spawn(async move { + if let Some(model) = server_model { + let _ = tx_event.send(Ok(ResponseEvent::ServerModel(model))).await; + } if let Some(etag) = models_etag { let _ = tx_event.send(Ok(ResponseEvent::ModelsEtag(etag))).await; } @@ -273,13 +281,14 @@ impl ResponsesWebsocketClient { merge_request_headers(&self.provider.headers, extra_headers, default_headers); add_auth_headers_to_header_map(&self.auth, &mut headers); - let (stream, server_reasoning_included, models_etag) = + let (stream, server_reasoning_included, models_etag, server_model) = connect_websocket(ws_url, headers, turn_state.clone()).await?; Ok(ResponsesWebsocketConnection::new( stream, self.provider.stream_idle_timeout, server_reasoning_included, models_etag, + server_model, telemetry, )) } @@ -304,7 +313,7 @@ async fn connect_websocket( url: Url, headers: HeaderMap, turn_state: Option>>, -) -> Result<(WsStream, bool, Option), ApiError> { +) -> Result<(WsStream, bool, Option, Option), ApiError> { ensure_rustls_crypto_provider(); info!("connecting to websocket: {url}"); @@ -341,6 +350,11 @@ async fn connect_websocket( .get(X_MODELS_ETAG_HEADER) .and_then(|value| value.to_str().ok()) .map(ToString::to_string); + let server_model = response + .headers() + .get(OPENAI_MODEL_HEADER) + .and_then(|value| value.to_str().ok()) + .map(ToString::to_string); if let Some(turn_state) = turn_state && let Some(header_value) = response .headers() @@ -349,7 +363,12 @@ async fn connect_websocket( { let _ = turn_state.set(header_value.to_string()); } - Ok((WsStream::new(stream), reasoning_included, models_etag)) + Ok(( + WsStream::new(stream), + reasoning_included, + models_etag, + server_model, + )) } fn websocket_config() -> WebSocketConfig { @@ -469,6 +488,7 @@ async fn run_websocket_response_stream( idle_timeout: Duration, telemetry: Option>, ) -> Result<(), ApiError> { + let mut last_server_model: Option = None; let request_text = match serde_json::to_string(&request_body) { Ok(text) => text, Err(err) => { @@ -536,6 +556,14 @@ async fn run_websocket_response_stream( } continue; } + if let Some(model) = event.response_model() + && last_server_model.as_deref() != Some(model.as_str()) + { + let _ = tx_event + .send(Ok(ResponseEvent::ServerModel(model.clone()))) + .await; + last_server_model = Some(model); + } match process_responses_event(event) { Ok(Some(event)) => { let is_completed = matches!(event, ResponseEvent::Completed { .. }); diff --git a/codex-rs/codex-api/src/sse/responses.rs b/codex-rs/codex-api/src/sse/responses.rs index 11e4f9de34..75c79ba5c1 100644 --- a/codex-rs/codex-api/src/sse/responses.rs +++ b/codex-rs/codex-api/src/sse/responses.rs @@ -26,6 +26,7 @@ use tracing::debug; use tracing::trace; const X_REASONING_INCLUDED_HEADER: &str = "x-reasoning-included"; +const OPENAI_MODEL_HEADER: &str = "openai-model"; /// Streams SSE events from an on-disk fixture for tests. pub fn stream_from_fixture( @@ -60,6 +61,11 @@ pub fn spawn_response_stream( .get("X-Models-Etag") .and_then(|v| v.to_str().ok()) .map(ToString::to_string); + let server_model = stream_response + .headers + .get(OPENAI_MODEL_HEADER) + .and_then(|v| v.to_str().ok()) + .map(ToString::to_string); let reasoning_included = stream_response .headers .get(X_REASONING_INCLUDED_HEADER) @@ -74,6 +80,9 @@ pub fn spawn_response_stream( } let (tx_event, rx_event) = mpsc::channel::>(1600); tokio::spawn(async move { + if let Some(model) = server_model { + let _ = tx_event.send(Ok(ResponseEvent::ServerModel(model))).await; + } for snapshot in rate_limit_snapshots { let _ = tx_event.send(Ok(ResponseEvent::RateLimits(snapshot))).await; } @@ -169,6 +178,41 @@ impl ResponsesStreamEvent { pub fn kind(&self) -> &str { &self.kind } + + pub fn response_model(&self) -> Option { + self.response.as_ref().and_then(extract_server_model) + } +} + +fn extract_server_model(value: &Value) -> Option { + value + .get("model") + .and_then(json_value_as_string) + .or_else(|| { + value + .get("headers") + .and_then(header_openai_model_value_from_json) + }) +} + +fn header_openai_model_value_from_json(value: &Value) -> Option { + let headers = value.as_object()?; + headers.iter().find_map(|(name, value)| { + if name.eq_ignore_ascii_case("openai-model") || name.eq_ignore_ascii_case("x-openai-model") + { + json_value_as_string(value) + } else { + None + } + }) +} + +fn json_value_as_string(value: &Value) -> Option { + match value { + Value::String(value) => Some(value.clone()), + Value::Array(items) => items.first().and_then(json_value_as_string), + _ => None, + } } #[derive(Debug)] @@ -339,6 +383,7 @@ pub async fn process_sse( ) { let mut stream = stream.eventsource(); let mut response_error: Option = None; + let mut last_server_model: Option = None; loop { let start = Instant::now(); @@ -378,6 +423,19 @@ pub async fn process_sse( } }; + if let Some(model) = event.response_model() + && last_server_model.as_deref() != Some(model.as_str()) + { + if tx_event + .send(Ok(ResponseEvent::ServerModel(model.clone()))) + .await + .is_err() + { + return; + } + last_server_model = Some(model); + } + match process_responses_event(event) { Ok(Some(event)) => { let is_completed = matches!(event, ResponseEvent::Completed { .. }); @@ -456,9 +514,13 @@ mod tests { use super::*; use assert_matches::assert_matches; use bytes::Bytes; + use codex_client::StreamResponse; use codex_protocol::models::MessagePhase; use codex_protocol::models::ResponseItem; use futures::stream; + use http::HeaderMap; + use http::HeaderValue; + use http::StatusCode; use pretty_assertions::assert_eq; use serde_json::json; use tokio::sync::mpsc; @@ -870,6 +932,149 @@ mod tests { } } + #[tokio::test] + async fn spawn_response_stream_emits_server_model_header() { + let mut headers = HeaderMap::new(); + headers.insert( + OPENAI_MODEL_HEADER, + HeaderValue::from_static(CYBER_RESTRICTED_MODEL_FOR_TESTS), + ); + let bytes = stream::iter(Vec::>::new()); + let stream_response = StreamResponse { + status: StatusCode::OK, + headers, + bytes: Box::pin(bytes), + }; + + let mut stream = spawn_response_stream(stream_response, idle_timeout(), None, None); + let event = stream + .rx_event + .recv() + .await + .expect("expected server model event") + .expect("expected ok event"); + + match event { + ResponseEvent::ServerModel(model) => { + assert_eq!(model, CYBER_RESTRICTED_MODEL_FOR_TESTS); + } + other => panic!("expected server model event, got {other:?}"), + } + } + + #[tokio::test] + async fn process_sse_emits_server_model_from_response_payload() { + let events = run_sse(vec![ + json!({ + "type": "response.created", + "response": { + "id": "resp-1", + "model": CYBER_RESTRICTED_MODEL_FOR_TESTS + } + }), + json!({ + "type": "response.completed", + "response": { + "id": "resp-1", + "model": CYBER_RESTRICTED_MODEL_FOR_TESTS + } + }), + ]) + .await; + + assert_eq!(events.len(), 3); + assert_matches!( + &events[0], + ResponseEvent::ServerModel(model) if model == CYBER_RESTRICTED_MODEL_FOR_TESTS + ); + assert_matches!(&events[1], ResponseEvent::Created); + assert_matches!( + &events[2], + ResponseEvent::Completed { + response_id, + token_usage: None, + can_append: false + } if response_id == "resp-1" + ); + } + + #[tokio::test] + async fn process_sse_emits_server_model_from_response_headers_payload() { + let events = run_sse(vec![ + json!({ + "type": "response.created", + "response": { + "id": "resp-1", + "headers": { + "OpenAI-Model": CYBER_RESTRICTED_MODEL_FOR_TESTS + } + } + }), + json!({ + "type": "response.completed", + "response": { + "id": "resp-1" + } + }), + ]) + .await; + + assert_eq!(events.len(), 3); + assert_matches!( + &events[0], + ResponseEvent::ServerModel(model) if model == CYBER_RESTRICTED_MODEL_FOR_TESTS + ); + assert_matches!(&events[1], ResponseEvent::Created); + assert_matches!( + &events[2], + ResponseEvent::Completed { + response_id, + token_usage: None, + can_append: false + } if response_id == "resp-1" + ); + } + + #[tokio::test] + async fn process_sse_emits_server_model_again_when_response_model_changes() { + let events = run_sse(vec![ + json!({ + "type": "response.created", + "response": { + "id": "resp-1", + "model": "gpt-5.2-codex" + } + }), + json!({ + "type": "response.completed", + "response": { + "id": "resp-1", + "model": "gpt-5.3-codex" + } + }), + ]) + .await; + + assert_eq!(events.len(), 4); + assert_matches!( + &events[0], + ResponseEvent::ServerModel(model) if model == "gpt-5.2-codex" + ); + assert_matches!(&events[1], ResponseEvent::Created); + assert_matches!( + &events[2], + ResponseEvent::ServerModel(model) if model == "gpt-5.3-codex" + ); + assert_matches!( + &events[3], + ResponseEvent::Completed { + response_id, + token_usage: None, + can_append: false + } if response_id == "resp-1" + ); + } + #[test] fn test_try_parse_retry_after() { let err = Error { @@ -909,4 +1114,6 @@ mod tests { let delay = try_parse_retry_after(&err); assert_eq!(delay, Some(Duration::from_secs(35))); } + + const CYBER_RESTRICTED_MODEL_FOR_TESTS: &str = "gpt-5.3-codex"; } diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 1341e6341e..2b0a768093 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -279,6 +279,8 @@ pub struct CodexSpawnOk { pub(crate) const INITIAL_SUBMIT_ID: &str = ""; pub(crate) const SUBMISSION_CHANNEL_CAPACITY: usize = 64; +const CYBER_VERIFY_URL: &str = "https://chatgpt.com/cyber"; +const CYBER_SAFETY_URL: &str = "https://developers.openai.com/codex/concepts/cyber-safety"; impl Codex { /// Spawn a new [`Codex`] and initialize the session. @@ -2560,6 +2562,35 @@ impl Session { self.record_conversation_items(ctx, &[item]).await; } + async fn maybe_warn_on_server_model_mismatch( + self: &Arc, + turn_context: &Arc, + server_model: String, + ) -> bool { + let requested_model = turn_context.model_info.slug.as_str(); + if server_model == requested_model { + info!("server reported model {server_model} (matches requested model)"); + return false; + } + + warn!("server reported model {server_model} while requested model was {requested_model}"); + + let warning_message = format!( + "Your account was flagged for potentially high-risk cyber activity and this request was routed to gpt-5.2 as a fallback. To regain access to gpt-5.3-codex, apply for trusted access: {CYBER_VERIFY_URL} or learn more: {CYBER_SAFETY_URL}" + ); + + self.send_event( + turn_context, + EventMsg::Warning(WarningEvent { + message: warning_message.clone(), + }), + ) + .await; + self.record_model_warning(warning_message, turn_context) + .await; + true + } + pub(crate) async fn replace_history(&self, items: Vec) { let mut state = self.state.lock().await; state.replace_history(items); @@ -4435,6 +4466,7 @@ pub(crate) async fn run_turn( // Although from the perspective of codex.rs, TurnDiffTracker has the lifecycle of a Task which contains // many turns, from the perspective of the user, it is a single turn. let turn_diff_tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())); + let mut server_model_warning_emitted_for_turn = false; // `ModelClientSession` is turn-scoped and caches WebSocket + sticky routing state, so we reuse // one instance across retries within this turn. @@ -4497,6 +4529,7 @@ pub(crate) async fn run_turn( sampling_request_input, &explicitly_enabled_connectors, skills_outcome.as_ref(), + &mut server_model_warning_emitted_for_turn, cancellation_token.child_token(), ) .await @@ -4870,6 +4903,7 @@ async fn run_sampling_request( input: Vec, explicitly_enabled_connectors: &HashSet, skills_outcome: Option<&SkillLoadOutcome>, + server_model_warning_emitted_for_turn: &mut bool, cancellation_token: CancellationToken, ) -> CodexResult { let router = built_tools( @@ -4906,6 +4940,7 @@ async fn run_sampling_request( client_session, turn_metadata_header, Arc::clone(&turn_diff_tracker), + server_model_warning_emitted_for_turn, &prompt, cancellation_token.child_token(), ) @@ -5474,6 +5509,7 @@ async fn try_run_sampling_request( client_session: &mut ModelClientSession, turn_metadata_header: Option<&str>, turn_diff_tracker: SharedTurnDiffTracker, + server_model_warning_emitted_for_turn: &mut bool, prompt: &Prompt, cancellation_token: CancellationToken, ) -> CodexResult { @@ -5616,6 +5652,15 @@ async fn try_run_sampling_request( active_item = Some(turn_item); } } + ResponseEvent::ServerModel(server_model) => { + if !*server_model_warning_emitted_for_turn + && sess + .maybe_warn_on_server_model_mismatch(&turn_context, server_model) + .await + { + *server_model_warning_emitted_for_turn = true; + } + } ResponseEvent::ServerReasoningIncluded(included) => { sess.set_server_reasoning_included(included).await; } diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index 113cf9f674..03eec5bde2 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -103,6 +103,7 @@ mod resume_warning; mod review; mod rmcp_client; mod rollout_list_find; +mod safety_check_downgrade; mod search_tool; mod seatbelt; mod shell_command; diff --git a/codex-rs/core/tests/suite/safety_check_downgrade.rs b/codex-rs/core/tests/suite/safety_check_downgrade.rs new file mode 100644 index 0000000000..a22fead90f --- /dev/null +++ b/codex-rs/core/tests/suite/safety_check_downgrade.rs @@ -0,0 +1,228 @@ +use anyhow::Result; +use codex_core::protocol::AskForApproval; +use codex_core::protocol::EventMsg; +use codex_core::protocol::Op; +use codex_core::protocol::SandboxPolicy; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::models::ContentItem; +use codex_protocol::models::ResponseItem; +use codex_protocol::user_input::UserInput; +use core_test_support::responses::ev_assistant_message; +use core_test_support::responses::ev_function_call; +use core_test_support::responses::ev_response_created; +use core_test_support::responses::mount_response_once; +use core_test_support::responses::mount_response_sequence; +use core_test_support::responses::sse; +use core_test_support::responses::sse_completed; +use core_test_support::responses::sse_response; +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; +use pretty_assertions::assert_eq; + +const SERVER_MODEL: &str = "gpt-5.2"; +const REQUESTED_MODEL: &str = "gpt-5.3-codex"; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn openai_model_header_mismatch_emits_warning_event_and_warning_item() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let response = + sse_response(sse_completed("resp-1")).insert_header("OpenAI-Model", SERVER_MODEL); + let _mock = mount_response_once(&server, response).await; + + let mut builder = test_codex().with_model(REQUESTED_MODEL); + let test = builder.build(&server).await?; + + test.codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "trigger safety check".to_string(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + cwd: test.cwd_path().to_path_buf(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::DangerFullAccess, + model: REQUESTED_MODEL.to_string(), + effort: test.config.model_reasoning_effort, + summary: ReasoningSummary::Auto, + collaboration_mode: None, + personality: None, + }) + .await?; + + let warning = wait_for_event(&test.codex, |event| matches!(event, EventMsg::Warning(_))).await; + let EventMsg::Warning(warning) = warning else { + panic!("expected warning event"); + }; + assert!(warning.message.contains(REQUESTED_MODEL)); + assert!(warning.message.contains(SERVER_MODEL)); + + let warning_item = wait_for_event(&test.codex, |event| { + matches!( + event, + EventMsg::RawResponseItem(raw) + if matches!( + &raw.item, + ResponseItem::Message { content, .. } + if content.iter().any(|item| matches!( + item, + ContentItem::InputText { text } if text.starts_with("Warning: ") + )) + ) + ) + }) + .await; + let EventMsg::RawResponseItem(raw) = warning_item else { + panic!("expected raw response item event"); + }; + let ResponseItem::Message { role, content, .. } = raw.item else { + panic!("expected warning to be recorded as a message item"); + }; + assert_eq!(role, "user"); + let warning_text = content.iter().find_map(|item| match item { + ContentItem::InputText { text } => Some(text.as_str()), + _ => None, + }); + let warning_text = warning_text.expect("warning message should include input_text content"); + assert!(warning_text.contains(REQUESTED_MODEL)); + assert!(warning_text.contains(SERVER_MODEL)); + + let _ = wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn response_model_field_mismatch_emits_warning_when_header_matches_requested() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let response = sse_response(sse(vec![ + serde_json::json!({ + "type": "response.created", + "response": { + "id": "resp-1", + "model": SERVER_MODEL, + } + }), + core_test_support::responses::ev_completed("resp-1"), + ])) + .insert_header("OpenAI-Model", REQUESTED_MODEL); + let _mock = mount_response_once(&server, response).await; + + let mut builder = test_codex().with_model(REQUESTED_MODEL); + let test = builder.build(&server).await?; + + test.codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "trigger response model check".to_string(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + cwd: test.cwd_path().to_path_buf(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::DangerFullAccess, + model: REQUESTED_MODEL.to_string(), + effort: test.config.model_reasoning_effort, + summary: ReasoningSummary::Auto, + collaboration_mode: None, + personality: None, + }) + .await?; + + let warning = wait_for_event(&test.codex, |event| { + matches!( + event, + EventMsg::Warning(warning) + if warning + .message + .contains("flagged for potentially high-risk cyber activity") + ) + }) + .await; + let EventMsg::Warning(warning) = warning else { + panic!("expected warning event"); + }; + assert!(warning.message.contains("gpt-5.2 as a fallback")); + + let _ = wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn openai_model_header_mismatch_only_emits_one_warning_per_turn() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let tool_args = serde_json::json!({ + "command": "echo hello", + "timeout_ms": 1_000 + }); + + let first_response = sse_response(sse(vec![ + ev_response_created("resp-1"), + ev_function_call( + "call-1", + "shell_command", + &serde_json::to_string(&tool_args)?, + ), + core_test_support::responses::ev_completed("resp-1"), + ])) + .insert_header("OpenAI-Model", SERVER_MODEL); + let second_response = sse_response(sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-1", "done"), + core_test_support::responses::ev_completed("resp-2"), + ])) + .insert_header("OpenAI-Model", SERVER_MODEL); + let _mock = mount_response_sequence(&server, vec![first_response, second_response]).await; + + let mut builder = test_codex().with_model(REQUESTED_MODEL); + let test = builder.build(&server).await?; + + test.codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "trigger follow-up turn".to_string(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + cwd: test.cwd_path().to_path_buf(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::DangerFullAccess, + model: REQUESTED_MODEL.to_string(), + effort: test.config.model_reasoning_effort, + summary: ReasoningSummary::Auto, + collaboration_mode: None, + personality: None, + }) + .await?; + + let mut warning_count = 0; + loop { + let event = wait_for_event(&test.codex, |_| true).await; + match event { + EventMsg::Warning(warning) if warning.message.contains(REQUESTED_MODEL) => { + warning_count += 1; + } + EventMsg::TurnComplete(_) => break, + _ => {} + } + } + + assert_eq!(warning_count, 1); + + Ok(()) +} diff --git a/codex-rs/otel/src/traces/otel_manager.rs b/codex-rs/otel/src/traces/otel_manager.rs index f50eb5dfac..e3ba85eab1 100644 --- a/codex-rs/otel/src/traces/otel_manager.rs +++ b/codex-rs/otel/src/traces/otel_manager.rs @@ -748,6 +748,7 @@ impl OtelManager { ResponseEvent::ReasoningSummaryPartAdded { .. } => { "reasoning_summary_part_added".into() } + ResponseEvent::ServerModel(_) => "server_model".into(), ResponseEvent::ServerReasoningIncluded(_) => "server_reasoning_included".into(), ResponseEvent::RateLimits(_) => "rate_limits".into(), ResponseEvent::ModelsEtag(_) => "models_etag".into(), From 0fbe10a807c435c16c587958d22f145bcccb78b4 Mon Sep 17 00:00:00 2001 From: Dylan Hurd Date: Mon, 16 Feb 2026 23:11:59 -0800 Subject: [PATCH 36/62] fix(core) exec_policy parsing fixes (#11951) ## Summary Fixes a few things in our exec_policy handling of prefix_rules: 1. Correctly match redirects specifically for exec_policy parsing. i.e. if you have `prefix_rule(["echo"], decision="allow")` then `echo hello > output.txt` should match - this should fix #10321 2. If there already exists any rule that would match our prefix rule (not just a prompt), then drop it, since it won't do anything. ## Testing - [x] Updated unit tests, added approvals ScenarioSpecs --- codex-rs/core/src/exec_policy.rs | 75 +++++++++++++++++++------- codex-rs/core/tests/suite/approvals.rs | 49 +++++++++++++++-- codex-rs/shell-command/src/bash.rs | 32 +++++------ 3 files changed, 113 insertions(+), 43 deletions(-) diff --git a/codex-rs/core/src/exec_policy.rs b/codex-rs/core/src/exec_policy.rs index c204710470..62ebdde43d 100644 --- a/codex-rs/core/src/exec_policy.rs +++ b/codex-rs/core/src/exec_policy.rs @@ -170,23 +170,26 @@ impl ExecPolicyManager { prefix_rule, } = req; let exec_policy = self.current(); - let (commands, used_heredoc_fallback) = commands_for_exec_policy(command); + let (commands, used_complex_parsing) = commands_for_exec_policy(command); // Keep heredoc prefix parsing for rule evaluation so existing // allow/prompt/forbidden rules still apply, but avoid auto-derived // amendments when only the heredoc fallback parser matched. - let auto_amendment_allowed = !used_heredoc_fallback; + let auto_amendment_allowed = !used_complex_parsing; let exec_policy_fallback = |cmd: &[String]| { render_decision_for_unmatched_command( approval_policy, sandbox_policy, cmd, sandbox_permissions, + used_complex_parsing, ) }; let evaluation = exec_policy.check_multiple(commands.iter(), &exec_policy_fallback); - let requested_amendment = - derive_requested_execpolicy_amendment(prefix_rule.as_ref(), &evaluation.matched_rules); + let requested_amendment = derive_requested_execpolicy_amendment_from_prefix_rule( + prefix_rule.as_ref(), + &evaluation.matched_rules, + ); match evaluation.decision { Decision::Forbidden => ExecApprovalRequirement::Forbidden { @@ -406,8 +409,9 @@ pub fn render_decision_for_unmatched_command( sandbox_policy: &SandboxPolicy, command: &[String], sandbox_permissions: SandboxPermissions, + used_complex_parsing: bool, ) -> Decision { - if is_known_safe_command(command) { + if is_known_safe_command(command) && !used_complex_parsing { return Decision::Allow; } @@ -534,7 +538,7 @@ fn try_derive_execpolicy_amendment_for_allow_rules( }) } -fn derive_requested_execpolicy_amendment( +fn derive_requested_execpolicy_amendment_from_prefix_rule( prefix_rule: Option<&Vec>, matched_rules: &[RuleMatch], ) -> Option { @@ -552,10 +556,8 @@ fn derive_requested_execpolicy_amendment( return None; } - if matched_rules - .iter() - .any(|rule_match| is_policy_match(rule_match) && rule_match.decision() == Decision::Prompt) - { + // if any policy rule already matches, don't suggest an additional rule that might conflict or not apply + if matched_rules.iter().any(is_policy_match) { return None; } @@ -1560,14 +1562,17 @@ prefix_rule( #[test] fn derive_requested_execpolicy_amendment_returns_none_for_missing_prefix_rule() { - assert_eq!(None, derive_requested_execpolicy_amendment(None, &[])); + assert_eq!( + None, + derive_requested_execpolicy_amendment_from_prefix_rule(None, &[]) + ); } #[test] fn derive_requested_execpolicy_amendment_returns_none_for_empty_prefix_rule() { assert_eq!( None, - derive_requested_execpolicy_amendment(Some(&Vec::new()), &[]) + derive_requested_execpolicy_amendment_from_prefix_rule(Some(&Vec::new()), &[]) ); } @@ -1575,7 +1580,7 @@ prefix_rule( fn derive_requested_execpolicy_amendment_returns_none_for_exact_banned_prefix_rule() { assert_eq!( None, - derive_requested_execpolicy_amendment( + derive_requested_execpolicy_amendment_from_prefix_rule( Some(&vec!["python".to_string(), "-c".to_string()]), &[], ) @@ -1594,7 +1599,7 @@ prefix_rule( ] { assert_eq!( None, - derive_requested_execpolicy_amendment(Some(&prefix_rule), &[]) + derive_requested_execpolicy_amendment_from_prefix_rule(Some(&prefix_rule), &[]) ); } } @@ -1620,7 +1625,7 @@ prefix_rule( ] { assert_eq!( None, - derive_requested_execpolicy_amendment(Some(&prefix_rule), &[]) + derive_requested_execpolicy_amendment_from_prefix_rule(Some(&prefix_rule), &[]) ); } } @@ -1635,22 +1640,52 @@ prefix_rule( assert_eq!( Some(ExecPolicyAmendment::new(prefix_rule.clone())), - derive_requested_execpolicy_amendment(Some(&prefix_rule), &[]) + derive_requested_execpolicy_amendment_from_prefix_rule(Some(&prefix_rule), &[]) ); } #[test] - fn derive_requested_execpolicy_amendment_returns_none_when_policy_prompt_matches() { + fn derive_requested_execpolicy_amendment_returns_none_when_policy_matches() { let prefix_rule = vec!["cargo".to_string(), "build".to_string()]; - let matched_rules = vec![RuleMatch::PrefixRuleMatch { + + let matched_rules_prompt = vec![RuleMatch::PrefixRuleMatch { matched_prefix: vec!["cargo".to_string()], decision: Decision::Prompt, justification: None, }]; - assert_eq!( None, - derive_requested_execpolicy_amendment(Some(&prefix_rule), &matched_rules) + derive_requested_execpolicy_amendment_from_prefix_rule( + Some(&prefix_rule), + &matched_rules_prompt + ), + "should return none when prompt policy matches" + ); + let matched_rules_allow = vec![RuleMatch::PrefixRuleMatch { + matched_prefix: vec!["cargo".to_string()], + decision: Decision::Allow, + justification: None, + }]; + assert_eq!( + None, + derive_requested_execpolicy_amendment_from_prefix_rule( + Some(&prefix_rule), + &matched_rules_allow + ), + "should return none when prompt policy matches" + ); + let matched_rules_forbidden = vec![RuleMatch::PrefixRuleMatch { + matched_prefix: vec!["cargo".to_string()], + decision: Decision::Forbidden, + justification: None, + }]; + assert_eq!( + None, + derive_requested_execpolicy_amendment_from_prefix_rule( + Some(&prefix_rule), + &matched_rules_forbidden + ), + "should return none when prompt policy matches" ); } diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index 72de76bab8..09d6755d3d 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -1479,6 +1479,44 @@ fn scenarios() -> Vec { output_contains: "rejected by user", }, }, + ScenarioSpec { + name: "safe command with heredoc and redirect still requires approval", + approval_policy: AskForApproval::OnRequest, + sandbox_policy: workspace_write(false), + action: ActionKind::RunUnifiedExecCommand { + command: "cat <<'EOF' > /tmp/out.txt \nhello\nEOF", + justification: None, + }, + sandbox_permissions: SandboxPermissions::RequireEscalated, + features: vec![Feature::UnifiedExec], + model_override: None, + outcome: Outcome::ExecApproval { + decision: ReviewDecision::Denied, + expected_reason: None, + }, + expectation: Expectation::CommandFailure { + output_contains: "rejected by user", + }, + }, + ScenarioSpec { + name: "compound command with one safe command still requires approval", + approval_policy: AskForApproval::OnRequest, + sandbox_policy: workspace_write(false), + action: ActionKind::RunUnifiedExecCommand { + command: "cat ./one.txt && touch ./two.txt", + justification: None, + }, + sandbox_permissions: SandboxPermissions::RequireEscalated, + features: vec![Feature::UnifiedExec], + model_override: None, + outcome: Outcome::ExecApproval { + decision: ReviewDecision::Denied, + expected_reason: None, + }, + expectation: Expectation::CommandFailure { + output_contains: "rejected by user", + }, + }, ] } @@ -1890,14 +1928,15 @@ async fn approving_execpolicy_amendment_persists_policy_and_skips_future_prompts Ok(()) } +// todo(dylan) add ScenarioSpec support for rules #[tokio::test(flavor = "current_thread")] #[cfg(unix)] -async fn heredoc_with_chained_allowed_prefix_still_requires_approval() -> Result<()> { +async fn compound_command_with_one_safe_command_still_requires_approval() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; let approval_policy = AskForApproval::UnlessTrusted; - let sandbox_policy = SandboxPolicy::new_read_only_policy(); + let sandbox_policy = SandboxPolicy::new_workspace_write_policy(); let sandbox_policy_for_config = sandbox_policy.clone(); let mut builder = test_codex().with_config(move |config| { config.permissions.approval_policy = Constrained::allow_any(approval_policy); @@ -1913,12 +1952,12 @@ async fn heredoc_with_chained_allowed_prefix_still_requires_approval() -> Result )?; let call_id = "heredoc-with-chained-prefix"; - let command = "cat <<'EOF' > /tmp/test.txt && touch allow-prefix.txt\nhello\nEOF"; + let command = "touch ./test.txt && rm ./test.txt"; let (event, expected_command) = ActionKind::RunCommand { command } .prepare(&test, &server, call_id, SandboxPermissions::UseDefault) .await?; let expected_command = - expected_command.expect("heredoc chained command scenario should produce a shell command"); + expected_command.expect("compound command should produce a shell command"); let _ = mount_sse_once( &server, @@ -1940,7 +1979,7 @@ async fn heredoc_with_chained_allowed_prefix_still_requires_approval() -> Result submit_turn( &test, - "heredoc chained prefix", + "compound command", approval_policy, sandbox_policy.clone(), ) diff --git a/codex-rs/shell-command/src/bash.rs b/codex-rs/shell-command/src/bash.rs index 47573621a8..770ff4795b 100644 --- a/codex-rs/shell-command/src/bash.rs +++ b/codex-rs/shell-command/src/bash.rs @@ -128,9 +128,6 @@ pub fn parse_shell_lc_single_command_prefix(command: &[String]) -> Option) -> bool { node.named_children(&mut cursor).next().is_none() } -fn has_named_descendant_kind(node: Node<'_>, kind: &str) -> bool { - let mut stack = vec![node]; - while let Some(current) = stack.pop() { - if current.kind() == kind { - return true; - } - let mut cursor = current.walk(); - for child in current.named_children(&mut cursor) { - stack.push(child); - } - } - false -} - fn is_allowed_heredoc_attachment_kind(kind: &str) -> bool { matches!( kind, @@ -532,7 +515,10 @@ mod tests { "-lc".to_string(), "echo hello > /tmp/out.txt".to_string(), ]; - assert_eq!(parse_shell_lc_single_command_prefix(&command), None); + assert_eq!( + parse_shell_lc_single_command_prefix(&command), + Some(vec!["echo".to_string(), "hello".to_string()]) + ); } #[test] @@ -548,6 +534,16 @@ mod tests { ); } + #[test] + fn parse_shell_lc_single_command_prefix_rejects_herestring_with_chaining() { + let command = vec![ + "bash".to_string(), + "-lc".to_string(), + r#"echo hello > /tmp/out.txt && cat /tmp/out.txt"#.to_string(), + ]; + assert_eq!(parse_shell_lc_single_command_prefix(&command), None); + } + #[test] fn parse_shell_lc_single_command_prefix_rejects_herestring_with_substitution() { let command = vec![ From 846464e86999c10ac5fdd42540b0d4d36ba9a0b7 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Tue, 17 Feb 2026 08:40:54 +0000 Subject: [PATCH 37/62] fix: js_repl reset hang by clearing exec tool calls without waiting (#11932) Remove the waiting loop in `reset` so it no longer blocks on potentially hanging exec tool calls + add `clear_all_exec_tool_calls_map` to drain the map and notify waiters so `reset` completes immediately --- codex-rs/core/src/tools/js_repl/mod.rs | 138 +++++++++++++++++++------ 1 file changed, 108 insertions(+), 30 deletions(-) diff --git a/codex-rs/core/src/tools/js_repl/mod.rs b/codex-rs/core/src/tools/js_repl/mod.rs index 23efb0c67e..c726491c0b 100644 --- a/codex-rs/core/src/tools/js_repl/mod.rs +++ b/codex-rs/core/src/tools/js_repl/mod.rs @@ -114,6 +114,7 @@ struct ExecContext { struct ExecToolCalls { in_flight: usize, notify: Arc, + cancel: CancellationToken, } enum KernelStreamEnd { @@ -300,6 +301,7 @@ impl JsReplManager { async fn clear_exec_tool_calls(&self, exec_id: &str) { if let Some(state) = self.exec_tool_calls.lock().await.remove(exec_id) { + state.cancel.cancel(); state.notify.notify_waiters(); } } @@ -320,32 +322,14 @@ impl JsReplManager { } } - async fn wait_for_all_exec_tool_calls(&self) { - loop { - let notified = { - let calls = self.exec_tool_calls.lock().await; - calls - .values() - .find(|state| state.in_flight > 0) - .map(|state| Arc::clone(&state.notify).notified_owned()) - }; - match notified { - Some(notified) => notified.await, - None => return, - } - } - } - async fn begin_exec_tool_call( exec_tool_calls: &Arc>>, exec_id: &str, - ) -> bool { + ) -> Option { let mut calls = exec_tool_calls.lock().await; - let Some(state) = calls.get_mut(exec_id) else { - return false; - }; + let state = calls.get_mut(exec_id)?; state.in_flight += 1; - true + Some(state.cancel.clone()) } async fn finish_exec_tool_call( @@ -396,14 +380,27 @@ impl JsReplManager { exec_id: &str, ) { if let Some(state) = exec_tool_calls.lock().await.remove(exec_id) { + state.cancel.cancel(); + state.notify.notify_waiters(); + } + } + + async fn clear_all_exec_tool_calls_map( + exec_tool_calls: &Arc>>, + ) { + let states = { + let mut calls = exec_tool_calls.lock().await; + calls.drain().map(|(_, state)| state).collect::>() + }; + for state in states { + state.cancel.cancel(); state.notify.notify_waiters(); } } pub async fn reset(&self) -> Result<(), FunctionCallError> { self.reset_kernel().await; - self.wait_for_all_exec_tool_calls().await; - self.exec_tool_calls.lock().await.clear(); + Self::clear_all_exec_tool_calls_map(&self.exec_tool_calls).await; Ok(()) } @@ -854,7 +851,9 @@ impl JsReplManager { JsReplManager::clear_exec_tool_calls_map(&exec_tool_calls, &id).await; } KernelToHost::RunTool(req) => { - if !JsReplManager::begin_exec_tool_call(&exec_tool_calls, &req.exec_id).await { + let Some(reset_cancel) = + JsReplManager::begin_exec_tool_call(&exec_tool_calls, &req.exec_id).await + else { let exec_id = req.exec_id.clone(); let tool_call_id = req.id.clone(); let payload = HostToKernel::RunToolResult(RunToolResult { @@ -877,10 +876,10 @@ impl JsReplManager { ); } continue; - } + }; let stdin_clone = Arc::clone(&stdin); let exec_contexts = Arc::clone(&exec_contexts); - let exec_tool_calls = Arc::clone(&exec_tool_calls); + let exec_tool_calls_for_task = Arc::clone(&exec_tool_calls); let recent_stderr = Arc::clone(&recent_stderr); tokio::spawn(async move { let exec_id = req.exec_id.clone(); @@ -888,15 +887,26 @@ impl JsReplManager { let tool_name = req.tool_name.clone(); let context = { exec_contexts.lock().await.get(&exec_id).cloned() }; let result = match context { - Some(ctx) => JsReplManager::run_tool_request(ctx, req).await, + Some(ctx) => { + tokio::select! { + _ = reset_cancel.cancelled() => RunToolResult { + id: tool_call_id.clone(), + ok: false, + response: None, + error: Some("js_repl execution reset".to_string()), + }, + result = JsReplManager::run_tool_request(ctx, req) => result, + } + } None => RunToolResult { - id: req.id.clone(), + id: tool_call_id.clone(), ok: false, response: None, error: Some("js_repl exec context not found".to_string()), }, }; - JsReplManager::finish_exec_tool_call(&exec_tool_calls, &exec_id).await; + JsReplManager::finish_exec_tool_call(&exec_tool_calls_for_task, &exec_id) + .await; let payload = HostToKernel::RunToolResult(result); if let Err(err) = JsReplManager::write_message(&stdin_clone, &payload).await { @@ -1417,7 +1427,11 @@ mod tests { .lock() .await .insert(exec_id.clone(), ExecToolCalls::default()); - assert!(JsReplManager::begin_exec_tool_call(&exec_tool_calls, &exec_id).await); + assert!( + JsReplManager::begin_exec_tool_call(&exec_tool_calls, &exec_id) + .await + .is_some() + ); let wait_map = Arc::clone(&exec_tool_calls); let wait_exec_id = exec_id.clone(); @@ -1441,6 +1455,70 @@ mod tests { JsReplManager::clear_exec_tool_calls_map(&exec_tool_calls, &exec_id).await; } } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn reset_clears_inflight_exec_tool_calls_without_waiting() { + let manager = JsReplManager::new(None, std::env::temp_dir()) + .await + .expect("manager should initialize"); + let exec_id = Uuid::new_v4().to_string(); + manager.register_exec_tool_calls(&exec_id).await; + assert!( + JsReplManager::begin_exec_tool_call(&manager.exec_tool_calls, &exec_id) + .await + .is_some() + ); + + let wait_manager = Arc::clone(&manager); + let wait_exec_id = exec_id.clone(); + let waiter = tokio::spawn(async move { + wait_manager.wait_for_exec_tool_calls(&wait_exec_id).await; + }); + tokio::task::yield_now().await; + + tokio::time::timeout(Duration::from_secs(1), manager.reset()) + .await + .expect("reset should not hang") + .expect("reset should succeed"); + + tokio::time::timeout(Duration::from_secs(1), waiter) + .await + .expect("waiter should be released") + .expect("wait task should not panic"); + + assert!(manager.exec_tool_calls.lock().await.is_empty()); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn reset_aborts_inflight_exec_tool_tasks() { + let manager = JsReplManager::new(None, std::env::temp_dir()) + .await + .expect("manager should initialize"); + let exec_id = Uuid::new_v4().to_string(); + manager.register_exec_tool_calls(&exec_id).await; + let reset_cancel = JsReplManager::begin_exec_tool_call(&manager.exec_tool_calls, &exec_id) + .await + .expect("exec should be registered"); + + let task = tokio::spawn(async move { + tokio::select! { + _ = reset_cancel.cancelled() => "cancelled", + _ = tokio::time::sleep(Duration::from_secs(60)) => "timed_out", + } + }); + + tokio::time::timeout(Duration::from_secs(1), manager.reset()) + .await + .expect("reset should not hang") + .expect("reset should succeed"); + + let outcome = tokio::time::timeout(Duration::from_secs(1), task) + .await + .expect("cancelled task should resolve promptly") + .expect("task should not panic"); + assert_eq!(outcome, "cancelled"); + } + async fn can_run_js_repl_runtime_tests() -> bool { if std::env::var_os("CODEX_SANDBOX").is_some() { return false; From b994b52994122cad64e43189f293726edb8ff337 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Tue, 17 Feb 2026 10:30:17 +0000 Subject: [PATCH 38/62] Hide /debug slash commands from popup menu (#11974) Summary - filter command popup builtins to remove any `/debug*` entries so they stay usable but are not listed - added regression tests to ensure the popup hides debug commands while dispatch still resolves them --- codex-rs/tui/src/bottom_pane/command_popup.rs | 25 +++++++++++++++++-- .../tui/src/bottom_pane/slash_commands.rs | 12 +++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/command_popup.rs b/codex-rs/tui/src/bottom_pane/command_popup.rs index 48cf255ee0..1c2291465e 100644 --- a/codex-rs/tui/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui/src/bottom_pane/command_popup.rs @@ -45,12 +45,15 @@ pub(crate) struct CommandPopupFlags { impl CommandPopup { pub(crate) fn new(mut prompts: Vec, flags: CommandPopupFlags) -> Self { // Keep built-in availability in sync with the composer. - let builtins = slash_commands::builtins_for_input( + let builtins: Vec<(&'static str, SlashCommand)> = slash_commands::builtins_for_input( flags.collaboration_modes_enabled, flags.connectors_enabled, flags.personality_command_enabled, flags.windows_degraded_sandbox_active, - ); + ) + .into_iter() + .filter(|(name, _)| !name.starts_with("debug")) + .collect(); // Exclude prompts that collide with builtin command names and sort by name. let exclude: HashSet = builtins.iter().map(|(n, _)| (*n).to_string()).collect(); prompts.retain(|p| !exclude.contains(&p.name)); @@ -566,4 +569,22 @@ mod tests { other => panic!("expected personality to be selected for exact match, got {other:?}"), } } + + #[test] + fn debug_commands_are_hidden_from_popup() { + let popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default()); + let cmds: Vec<&str> = popup + .filtered_items() + .into_iter() + .filter_map(|item| match item { + CommandItem::Builtin(cmd) => Some(cmd.command()), + CommandItem::UserPrompt(_) => None, + }) + .collect(); + + assert!( + !cmds.iter().any(|name| name.starts_with("debug")), + "expected no /debug* command in popup menu, got {cmds:?}" + ); + } } diff --git a/codex-rs/tui/src/bottom_pane/slash_commands.rs b/codex-rs/tui/src/bottom_pane/slash_commands.rs index cdd0ba8f7d..4613161f2f 100644 --- a/codex-rs/tui/src/bottom_pane/slash_commands.rs +++ b/codex-rs/tui/src/bottom_pane/slash_commands.rs @@ -63,3 +63,15 @@ pub(crate) fn has_builtin_prefix( .into_iter() .any(|(command_name, _)| fuzzy_match(command_name, name).is_some()) } + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn debug_command_still_resolves_for_dispatch() { + let cmd = find_builtin_command("debug-config", true, true, true, false); + assert_eq!(cmd, Some(SlashCommand::DebugConfig)); + } +} From 77f74a5c173635c987121d73a65852426b274d72 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Tue, 17 Feb 2026 11:06:14 +0000 Subject: [PATCH 39/62] fix: race in js repl (#11922) js_repl_reset previously raced with in-flight/new js_repl executions because reset() could clear exec_tool_calls without synchronizing with execute(). In that window, a running exec could lose its per-exec tool-call context, and subsequent kernel RunTool messages would fail with js_repl exec context not found. The fix serializes reset and execute on the same exec_lock, so reset cannot run concurrently with exec setup/teardown. We also keep the timeout path safe by performing reset steps inline while execute() already holds the lock, avoiding re-entrant lock acquisition. A regression test now verifies that reset waits for the exec lock and does not clear tool-call state early. --- codex-rs/core/src/tools/js_repl/mod.rs | 47 +++++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/codex-rs/core/src/tools/js_repl/mod.rs b/codex-rs/core/src/tools/js_repl/mod.rs index c726491c0b..ef0cdad004 100644 --- a/codex-rs/core/src/tools/js_repl/mod.rs +++ b/codex-rs/core/src/tools/js_repl/mod.rs @@ -399,6 +399,9 @@ impl JsReplManager { } pub async fn reset(&self) -> Result<(), FunctionCallError> { + let _permit = self.exec_lock.clone().acquire_owned().await.map_err(|_| { + FunctionCallError::RespondToModel("js_repl execution unavailable".to_string()) + })?; self.reset_kernel().await; Self::clear_all_exec_tool_calls_map(&self.exec_tool_calls).await; Ok(()) @@ -527,7 +530,9 @@ impl JsReplManager { return Err(FunctionCallError::RespondToModel(message)); } Err(_) => { - self.reset().await?; + self.reset_kernel().await; + self.wait_for_exec_tool_calls(&req_id).await; + self.exec_tool_calls.lock().await.clear(); return Err(FunctionCallError::RespondToModel( "js_repl execution timed out; kernel reset, rerun your request".to_string(), )); @@ -1456,6 +1461,46 @@ mod tests { } } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn reset_waits_for_exec_lock_before_clearing_exec_tool_calls() { + let manager = JsReplManager::new(None, PathBuf::from(".")) + .await + .expect("manager should initialize"); + let permit = manager + .exec_lock + .clone() + .acquire_owned() + .await + .expect("lock should be acquirable"); + let exec_id = Uuid::new_v4().to_string(); + manager.register_exec_tool_calls(&exec_id).await; + + let reset_manager = Arc::clone(&manager); + let mut reset_task = tokio::spawn(async move { reset_manager.reset().await }); + tokio::time::sleep(Duration::from_millis(50)).await; + + assert!( + !reset_task.is_finished(), + "reset should wait until execute lock is released" + ); + assert!( + manager.exec_tool_calls.lock().await.contains_key(&exec_id), + "reset must not clear tool-call contexts while execute lock is held" + ); + + drop(permit); + + tokio::time::timeout(Duration::from_secs(1), &mut reset_task) + .await + .expect("reset should complete after execute lock release") + .expect("reset task should not panic") + .expect("reset should succeed"); + assert!( + !manager.exec_tool_calls.lock().await.contains_key(&exec_id), + "reset should clear tool-call contexts after lock acquisition" + ); + } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn reset_clears_inflight_exec_tool_calls_without_waiting() { let manager = JsReplManager::new(None, std::env::temp_dir()) From fcf16e97a6c96e5945a143f0264c1646eb12a1e4 Mon Sep 17 00:00:00 2001 From: Dylan Hurd Date: Tue, 17 Feb 2026 03:13:18 -0800 Subject: [PATCH 40/62] fix(ci) Fix shell-tool-mcp.yml (#11969) ## Summary We're seeing failures for shell-tool-mcp.yml during git checkouts. This is a quick attempt to unblock releases - we should revisit this build pipeline since we've hit a number of errors. --- .github/workflows/shell-tool-mcp.yml | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/workflows/shell-tool-mcp.yml b/.github/workflows/shell-tool-mcp.yml index 04e127a785..609c933bd9 100644 --- a/.github/workflows/shell-tool-mcp.yml +++ b/.github/workflows/shell-tool-mcp.yml @@ -408,9 +408,8 @@ jobs: shell: bash run: | set -euo pipefail - git clone --depth 1 https://git.code.sf.net/p/zsh/code /tmp/zsh + git clone https://git.code.sf.net/p/zsh/code /tmp/zsh cd /tmp/zsh - git fetch --depth 1 origin 77045ef899e53b9598bebc5a41db93a548a40ca6 git checkout 77045ef899e53b9598bebc5a41db93a548a40ca6 git apply "${GITHUB_WORKSPACE}/shell-tool-mcp/patches/zsh-exec-wrapper.patch" ./Util/preconfig @@ -487,9 +486,8 @@ jobs: shell: bash run: | set -euo pipefail - git clone --depth 1 https://git.code.sf.net/p/zsh/code /tmp/zsh + git clone https://git.code.sf.net/p/zsh/code /tmp/zsh cd /tmp/zsh - git fetch --depth 1 origin 77045ef899e53b9598bebc5a41db93a548a40ca6 git checkout 77045ef899e53b9598bebc5a41db93a548a40ca6 git apply "${GITHUB_WORKSPACE}/shell-tool-mcp/patches/zsh-exec-wrapper.patch" ./Util/preconfig From 5ae84197b2cc8c2219029874c2867d87687ce4dc Mon Sep 17 00:00:00 2001 From: jif-oai Date: Tue, 17 Feb 2026 11:22:30 +0000 Subject: [PATCH 41/62] Exit early when session initialization fails (#11908) Summary - wait for the initial session startup loop to finish and handle exit before waiting for the first message in fresh sessions - propagate AppRunControl::Exit to return immediately when initialization fails --- codex-rs/tui/src/app.rs | 105 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 104 insertions(+), 1 deletion(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 7bd105695d..b23b5f1a13 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -978,6 +978,27 @@ impl App { self.refresh_status_line(); } + fn should_wait_for_initial_session(session_selection: &SessionSelection) -> bool { + matches!( + session_selection, + SessionSelection::StartFresh | SessionSelection::Exit + ) + } + + fn should_handle_active_thread_events( + waiting_for_initial_session_configured: bool, + has_active_thread_receiver: bool, + ) -> bool { + has_active_thread_receiver && !waiting_for_initial_session_configured + } + + fn should_stop_waiting_for_initial_session( + waiting_for_initial_session_configured: bool, + primary_thread_id: Option, + ) -> bool { + waiting_for_initial_session_configured && primary_thread_id.is_some() + } + #[allow(clippy::too_many_arguments)] pub async fn run( tui: &mut tui::Tui, @@ -1068,6 +1089,8 @@ impl App { let status_line_invalid_items_warned = Arc::new(AtomicBool::new(false)); let enhanced_keys_supported = tui.enhanced_keys_supported(); + let wait_for_initial_session_configured = + Self::should_wait_for_initial_session(&session_selection); let mut chat_widget = match session_selection { SessionSelection::StartFresh | SessionSelection::Exit => { let init = crate::chatwidget::ChatWidgetInit { @@ -1252,6 +1275,7 @@ impl App { let mut thread_created_rx = thread_manager.subscribe_thread_created(); let mut listen_for_threads = true; + let mut waiting_for_initial_session_configured = wait_for_initial_session_configured; let exit_reason = loop { let control = select! { @@ -1264,7 +1288,10 @@ impl App { } else { None } - }, if app.active_thread_rx.is_some() => { + }, if App::should_handle_active_thread_events( + waiting_for_initial_session_configured, + app.active_thread_rx.is_some() + ) => { if let Some(event) = active { app.handle_active_thread_event(tui, event).await?; } else { @@ -1291,6 +1318,12 @@ impl App { AppRunControl::Continue } }; + if App::should_stop_waiting_for_initial_session( + waiting_for_initial_session_configured, + app.primary_thread_id, + ) { + waiting_for_initial_session_configured = false; + } match control { AppRunControl::Continue => {} AppRunControl::Exit(reason) => break reason, @@ -2915,6 +2948,76 @@ mod tests { Ok(()) } + #[test] + fn startup_waiting_gate_is_only_for_fresh_or_exit_session_selection() { + assert_eq!( + App::should_wait_for_initial_session(&SessionSelection::StartFresh), + true + ); + assert_eq!( + App::should_wait_for_initial_session(&SessionSelection::Exit), + true + ); + assert_eq!( + App::should_wait_for_initial_session(&SessionSelection::Resume(PathBuf::from( + "/tmp/restore" + ))), + false + ); + assert_eq!( + App::should_wait_for_initial_session(&SessionSelection::Fork(PathBuf::from( + "/tmp/fork" + ))), + false + ); + } + + #[test] + fn startup_waiting_gate_holds_active_thread_events_until_primary_thread_configured() { + let mut wait_for_initial_session = + App::should_wait_for_initial_session(&SessionSelection::StartFresh); + assert_eq!(wait_for_initial_session, true); + assert_eq!( + App::should_handle_active_thread_events(wait_for_initial_session, true), + false + ); + + assert_eq!( + App::should_stop_waiting_for_initial_session(wait_for_initial_session, None), + false + ); + if App::should_stop_waiting_for_initial_session( + wait_for_initial_session, + Some(ThreadId::new()), + ) { + wait_for_initial_session = false; + } + assert_eq!(wait_for_initial_session, false); + + assert_eq!( + App::should_handle_active_thread_events(wait_for_initial_session, true), + true + ); + } + + #[test] + fn startup_waiting_gate_not_applied_for_resume_or_fork_session_selection() { + let wait_for_resume = App::should_wait_for_initial_session(&SessionSelection::Resume( + PathBuf::from("/tmp/restore"), + )); + assert_eq!( + App::should_handle_active_thread_events(wait_for_resume, true), + true + ); + let wait_for_fork = App::should_wait_for_initial_session(&SessionSelection::Fork( + PathBuf::from("/tmp/fork"), + )); + assert_eq!( + App::should_handle_active_thread_events(wait_for_fork, true), + true + ); + } + #[tokio::test] async fn enqueue_thread_event_does_not_block_when_channel_full() -> Result<()> { let mut app = make_test_app().await; From 56cd85cd4bc9d736de1319dd741c2089674ef476 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Tue, 17 Feb 2026 11:45:59 +0000 Subject: [PATCH 42/62] nit: wording multi-agent (#11986) --- codex-rs/core/src/features.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs index c7b119aa3b..3cab14d1fa 100644 --- a/codex-rs/core/src/features.rs +++ b/codex-rs/core/src/features.rs @@ -554,9 +554,9 @@ pub const FEATURES: &[FeatureSpec] = &[ id: Feature::Collab, key: "multi_agent", stage: Stage::Experimental { - name: "Sub-agents", + name: "Multi-agents", menu_description: "Ask Codex to spawn multiple agents to parallelize the work and win in efficiency.", - announcement: "NEW: Sub-agents can now be spawned by Codex. Enable in /experimental and restart Codex!", + announcement: "NEW: Multi-agents can now be spawned by Codex. Enable in /experimental and restart Codex!", }, default_enabled: false, }, From 31d4bfdde029214bfc49e9d50498c540074dc789 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Tue, 17 Feb 2026 14:19:52 +0000 Subject: [PATCH 43/62] feat: add `--search` to `just log` (#11995) Summary - extend the log client to accept an optional `--search` substring filter when querying codex-state logs - propagate the filter through `LogQuery` and apply it in `push_log_filters` via `INSTR(message, ...)` - add an integration test that exercises the new search filtering behavior Testing - Not run (not requested) --- codex-rs/state/src/bin/logs_client.rs | 7 ++++ codex-rs/state/src/model/log.rs | 1 + codex-rs/state/src/runtime.rs | 58 +++++++++++++++++++++++++++ 3 files changed, 66 insertions(+) diff --git a/codex-rs/state/src/bin/logs_client.rs b/codex-rs/state/src/bin/logs_client.rs index 796a968cb8..099b06164e 100644 --- a/codex-rs/state/src/bin/logs_client.rs +++ b/codex-rs/state/src/bin/logs_client.rs @@ -46,6 +46,10 @@ struct Args { #[arg(long = "thread-id")] thread_id: Vec, + /// Substring match against the log message. + #[arg(long)] + search: Option, + /// Include logs that do not have a thread id. #[arg(long)] threadless: bool, @@ -67,6 +71,7 @@ struct LogFilter { module_like: Vec, file_like: Vec, thread_ids: Vec, + search: Option, include_threadless: bool, } @@ -154,6 +159,7 @@ fn build_filter(args: &Args) -> anyhow::Result { module_like, file_like, thread_ids, + search: args.search.clone(), include_threadless: args.threadless, }) } @@ -233,6 +239,7 @@ fn to_log_query( module_like: filter.module_like.clone(), file_like: filter.file_like.clone(), thread_ids: filter.thread_ids.clone(), + search: filter.search.clone(), include_threadless: filter.include_threadless, after_id, limit, diff --git a/codex-rs/state/src/model/log.rs b/codex-rs/state/src/model/log.rs index 2aeaf2d5c8..cf973bceeb 100644 --- a/codex-rs/state/src/model/log.rs +++ b/codex-rs/state/src/model/log.rs @@ -37,6 +37,7 @@ pub struct LogQuery { pub module_like: Vec, pub file_like: Vec, pub thread_ids: Vec, + pub search: Option, pub include_threadless: bool, pub after_id: Option, pub limit: Option, diff --git a/codex-rs/state/src/runtime.rs b/codex-rs/state/src/runtime.rs index 3a743a9c70..f9beacdae8 100644 --- a/codex-rs/state/src/runtime.rs +++ b/codex-rs/state/src/runtime.rs @@ -711,6 +711,11 @@ fn push_log_filters<'a>(builder: &mut QueryBuilder<'a, Sqlite>, query: &'a LogQu if let Some(after_id) = query.after_id { builder.push(" AND id > ").push_bind(after_id); } + if let Some(search) = query.search.as_ref() { + builder.push(" AND INSTR(message, "); + builder.push_bind(search.as_str()); + builder.push(") > 0"); + } } fn push_like_filters<'a>( @@ -906,6 +911,8 @@ mod tests { use super::StateRuntime; use super::ThreadMetadata; use super::state_db_filename; + use crate::LogEntry; + use crate::LogQuery; use crate::STATE_DB_FILENAME; use crate::STATE_DB_VERSION; use crate::model::Phase2JobClaimOutcome; @@ -2495,6 +2502,57 @@ VALUES (?, ?, ?, ?, ?) let _ = tokio::fs::remove_dir_all(codex_home).await; } + #[tokio::test] + async fn query_logs_with_search_matches_substring() { + let codex_home = unique_temp_dir(); + let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string(), None) + .await + .expect("initialize runtime"); + + runtime + .insert_logs(&[ + LogEntry { + ts: 1_700_000_001, + ts_nanos: 0, + level: "INFO".to_string(), + target: "cli".to_string(), + message: Some("alpha".to_string()), + thread_id: Some("thread-1".to_string()), + process_uuid: None, + file: Some("main.rs".to_string()), + line: Some(42), + module_path: None, + }, + LogEntry { + ts: 1_700_000_002, + ts_nanos: 0, + level: "INFO".to_string(), + target: "cli".to_string(), + message: Some("alphabet".to_string()), + thread_id: Some("thread-1".to_string()), + process_uuid: None, + file: Some("main.rs".to_string()), + line: Some(43), + module_path: None, + }, + ]) + .await + .expect("insert test logs"); + + let rows = runtime + .query_logs(&LogQuery { + search: Some("alphab".to_string()), + ..Default::default() + }) + .await + .expect("query matching logs"); + + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].message.as_deref(), Some("alphabet")); + + let _ = tokio::fs::remove_dir_all(codex_home).await; + } + fn test_thread_metadata( codex_home: &Path, thread_id: ThreadId, From 4ab44e2c5cc54ed47e47a6729dfd8aa5a3dc2476 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Tue, 17 Feb 2026 14:21:26 +0000 Subject: [PATCH 44/62] feat: add `--compact` mode to `just log` (#11994) Summary: - add a `--compact` flag to the logs client to suppress thread/target info - format rows and timestamps differently when compact mode is enabled so only hour time, level, and message remain --- codex-rs/state/src/bin/logs_client.rs | 29 ++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/codex-rs/state/src/bin/logs_client.rs b/codex-rs/state/src/bin/logs_client.rs index 099b06164e..6b0039ee97 100644 --- a/codex-rs/state/src/bin/logs_client.rs +++ b/codex-rs/state/src/bin/logs_client.rs @@ -61,6 +61,10 @@ struct Args { /// Poll interval in milliseconds. #[arg(long, default_value_t = 500)] poll_ms: u64, + + /// Show compact output with only time, level, and message. + #[arg(long)] + compact: bool, } #[derive(Debug, Clone)] @@ -86,7 +90,8 @@ async fn main() -> anyhow::Result<()> { .unwrap_or_else(|| PathBuf::from(".")); let runtime = StateRuntime::init(codex_home, "logs-client".to_string(), None).await?; - let mut last_id = print_backfill(runtime.as_ref(), &filter, args.backfill).await?; + let mut last_id = + print_backfill(runtime.as_ref(), &filter, args.backfill, args.compact).await?; if last_id == 0 { last_id = fetch_max_id(runtime.as_ref(), &filter).await?; } @@ -96,7 +101,7 @@ async fn main() -> anyhow::Result<()> { let rows = fetch_new_rows(runtime.as_ref(), &filter, last_id).await?; for row in rows { last_id = last_id.max(row.id); - println!("{}", format_row(&row)); + println!("{}", format_row(&row, args.compact)); } tokio::time::sleep(poll_interval).await; } @@ -178,6 +183,7 @@ async fn print_backfill( runtime: &StateRuntime, filter: &LogFilter, backfill: usize, + compact: bool, ) -> anyhow::Result { if backfill == 0 { return Ok(0); @@ -189,7 +195,7 @@ async fn print_backfill( let mut last_id = 0; for row in rows { last_id = last_id.max(row.id); - println!("{}", format_row(&row)); + println!("{}", format_row(&row, compact)); } Ok(last_id) } @@ -247,8 +253,8 @@ fn to_log_query( } } -fn format_row(row: &LogRow) -> String { - let timestamp = formatter::ts(row.ts, row.ts_nanos); +fn format_row(row: &LogRow, compact: bool) -> String { + let timestamp = formatter::ts(row.ts, row.ts_nanos, compact); let level = row.level.as_str(); let target = row.target.as_str(); let message = row.message.as_deref().unwrap_or(""); @@ -258,9 +264,13 @@ fn format_row(row: &LogRow) -> String { let thread_id_colored = thread_id.blue().dimmed().to_string(); let target_colored = target.dimmed().to_string(); let message_colored = heuristic_formatting(message); - format!( - "{timestamp_colored} {level_colored} [{thread_id_colored}] {target_colored} - {message_colored}" - ) + if compact { + format!("{timestamp_colored} {level_colored} {message_colored}") + } else { + format!( + "{timestamp_colored} {level_colored} [{thread_id_colored}] {target_colored} - {message_colored}" + ) + } } fn heuristic_formatting(message: &str) -> String { @@ -299,9 +309,10 @@ mod formatter { .join("\n") } - pub(super) fn ts(ts: i64, ts_nanos: i64) -> String { + pub(super) fn ts(ts: i64, ts_nanos: i64, compact: bool) -> String { let nanos = u32::try_from(ts_nanos).unwrap_or(0); match DateTime::::from_timestamp(ts, nanos) { + Some(dt) if compact => dt.format("%H:%M:%S").to_string(), Some(dt) => dt.to_rfc3339_opts(SecondsFormat::Millis, true), None => format!("{ts}.{ts_nanos:09}Z"), } From 281b0eae8b5d45844aceab5e70c68c819a0b7514 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Tue, 17 Feb 2026 07:19:28 -0800 Subject: [PATCH 45/62] Don't allow model_supports_reasoning_summaries to disable reasoning (#11833) The `model_supports_reasoning_summaries` config option was originally added so users could enable reasoning for custom models (models that codex doesn't know about). This is how it was documented in the source, but its implementation didn't match. It was implemented such that it can also be used to disable reasoning for models that otherwise support reasoning. This leads to bad behavior for some reasoning models like `gpt-5.3-codex`. Diagnosing this is difficult, and it has led to many support issues. This PR changes the handling of `model_supports_reasoning_summaries` so it matches its original documented behavior. If it is set to false, it is a no-op. That is, it never disables reasoning for models that are known to support reasoning. It can still be used for its intended purpose -- to enable reasoning for unknown models. --- .../core/src/models_manager/model_info.rs | 49 ++++++++++++++++++- 1 file changed, 47 insertions(+), 2 deletions(-) diff --git a/codex-rs/core/src/models_manager/model_info.rs b/codex-rs/core/src/models_manager/model_info.rs index c5ad8899a1..19a945f951 100644 --- a/codex-rs/core/src/models_manager/model_info.rs +++ b/codex-rs/core/src/models_manager/model_info.rs @@ -20,8 +20,10 @@ const LOCAL_PRAGMATIC_TEMPLATE: &str = "You are a deeply pragmatic, effective so const PERSONALITY_PLACEHOLDER: &str = "{{ personality }}"; pub(crate) fn with_config_overrides(mut model: ModelInfo, config: &Config) -> ModelInfo { - if let Some(supports_reasoning_summaries) = config.model_supports_reasoning_summaries { - model.supports_reasoning_summaries = supports_reasoning_summaries; + if let Some(supports_reasoning_summaries) = config.model_supports_reasoning_summaries + && supports_reasoning_summaries + { + model.supports_reasoning_summaries = true; } if let Some(context_window) = config.model_context_window { model.context_window = Some(context_window); @@ -100,3 +102,46 @@ fn local_personality_messages_for_slug(slug: &str) -> Option { _ => None, } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::test_config; + use pretty_assertions::assert_eq; + + #[test] + fn reasoning_summaries_override_true_enables_support() { + let model = model_info_from_slug("unknown-model"); + let mut config = test_config(); + config.model_supports_reasoning_summaries = Some(true); + + let updated = with_config_overrides(model.clone(), &config); + let mut expected = model; + expected.supports_reasoning_summaries = true; + + assert_eq!(updated, expected); + } + + #[test] + fn reasoning_summaries_override_false_does_not_disable_support() { + let mut model = model_info_from_slug("unknown-model"); + model.supports_reasoning_summaries = true; + let mut config = test_config(); + config.model_supports_reasoning_summaries = Some(false); + + let updated = with_config_overrides(model.clone(), &config); + + assert_eq!(updated, model); + } + + #[test] + fn reasoning_summaries_override_false_is_noop_when_model_is_false() { + let model = model_info_from_slug("unknown-model"); + let mut config = test_config(); + config.model_supports_reasoning_summaries = Some(false); + + let updated = with_config_overrides(model.clone(), &config); + + assert_eq!(updated, model); + } +} From cab607befb2f613854961c0988b0821879bc27f5 Mon Sep 17 00:00:00 2001 From: Charley Cunningham Date: Tue, 17 Feb 2026 09:21:44 -0800 Subject: [PATCH 46/62] Centralize context update diffing logic (#11807) ## Summary This PR centralizes model-visible state diffing for turn context updates into one module, while keeping existing behavior and call sites stable. ### What changed - Added `core/src/context_updates.rs` with the consolidated diffing logic for: - environment context updates - permissions/policy updates - collaboration mode updates - model-instruction switch updates - personality updates - Added `BuildSettingsUpdateItemsParams` so required dependencies are passed explicitly. - Updated `Session::build_settings_update_items` in `core/src/codex.rs` to delegate to the centralized module. - Reused the same centralized `personality_message_for` helper from initial-context assembly to avoid duplicated logic. - Registered the new module in `core/src/lib.rs`. ## Why This is a minimal, shippable step toward the model-visible-state design: all state diff decisions for turn-context update items now live in one place, improving reviewability and reducing drift risk without expanding scope. ## Behavior - Intended to be behavior-preserving. - No protocol/schema changes. - No call-site behavior changes beyond routing through the new centralized logic. ## Testing Ran targeted tests in this worktree: - `cargo test -p codex-core build_settings_update_items_emits_environment_item_for_network_changes` - `cargo test -p codex-core collaboration_instructions --test all` Both passed. ## Codex author `codex resume 019c540f-3951-7352-a3fa-6f07b834d4ce` --- codex-rs/core/src/codex.rs | 158 ++----------------- codex-rs/core/src/context_manager/mod.rs | 1 + codex-rs/core/src/context_manager/updates.rs | 148 +++++++++++++++++ 3 files changed, 165 insertions(+), 142 deletions(-) create mode 100644 codex-rs/core/src/context_manager/updates.rs diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 2b0a768093..34f0d9f873 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -1969,119 +1969,6 @@ impl Session { state.session_configuration.collaboration_mode.clone() } - fn build_environment_update_item( - &self, - previous: Option<&Arc>, - next: &TurnContext, - ) -> Option { - let prev = previous?; - - let shell = self.user_shell(); - let prev_context = EnvironmentContext::from_turn_context(prev.as_ref(), shell.as_ref()); - let next_context = EnvironmentContext::from_turn_context(next, shell.as_ref()); - if prev_context.equals_except_shell(&next_context) { - return None; - } - Some(ResponseItem::from(EnvironmentContext::diff( - prev.as_ref(), - next, - shell.as_ref(), - ))) - } - - fn build_permissions_update_item( - &self, - previous: Option<&Arc>, - next: &TurnContext, - ) -> Option { - let prev = previous?; - if prev.sandbox_policy == next.sandbox_policy - && prev.approval_policy == next.approval_policy - { - return None; - } - - Some( - DeveloperInstructions::from_policy( - &next.sandbox_policy, - next.approval_policy, - self.services.exec_policy.current().as_ref(), - &next.cwd, - ) - .into(), - ) - } - - fn build_personality_update_item( - &self, - previous: Option<&Arc>, - next: &TurnContext, - ) -> Option { - if !self.features.enabled(Feature::Personality) { - return None; - } - let previous = previous?; - if next.model_info.slug != previous.model_info.slug { - return None; - } - - // if a personality is specified and it's different from the previous one, build a personality update item - if let Some(personality) = next.personality - && next.personality != previous.personality - { - let model_info = &next.model_info; - let personality_message = Self::personality_message_for(model_info, personality); - personality_message.map(|personality_message| { - DeveloperInstructions::personality_spec_message(personality_message).into() - }) - } else { - None - } - } - - fn personality_message_for(model_info: &ModelInfo, personality: Personality) -> Option { - model_info - .model_messages - .as_ref() - .and_then(|spec| spec.get_personality_message(Some(personality))) - .filter(|message| !message.is_empty()) - } - - fn build_collaboration_mode_update_item( - &self, - previous: Option<&Arc>, - next: &TurnContext, - ) -> Option { - let prev = previous?; - if prev.collaboration_mode != next.collaboration_mode { - // If the next mode has empty developer instructions, this returns None and we emit no - // update, so prior collaboration instructions remain in the prompt history. - Some(DeveloperInstructions::from_collaboration_mode(&next.collaboration_mode)?.into()) - } else { - None - } - } - - fn build_model_instructions_update_item( - &self, - previous: Option<&Arc>, - resumed_model: Option<&str>, - next: &TurnContext, - ) -> Option { - let previous_model = - resumed_model.or_else(|| previous.map(|prev| prev.model_info.slug.as_str()))?; - if previous_model == next.model_info.slug { - return None; - } - - let model_instructions = next.model_info.get_model_instructions(next.personality); - if model_instructions.is_empty() { - return None; - } - - Some(DeveloperInstructions::model_switch_message(model_instructions).into()) - } - pub(crate) fn is_model_switch_developer_message(item: &ResponseItem) -> bool { let ResponseItem::Message { role, content, .. } = item else { return false; @@ -2094,42 +1981,26 @@ impl Session { ) }) } - fn build_settings_update_items( &self, previous_context: Option<&Arc>, resumed_model: Option<&str>, current_context: &TurnContext, ) -> Vec { - let mut update_items = Vec::new(); - if let Some(env_item) = - self.build_environment_update_item(previous_context, current_context) - { - update_items.push(env_item); - } - if let Some(permissions_item) = - self.build_permissions_update_item(previous_context, current_context) - { - update_items.push(permissions_item); - } - if let Some(collaboration_mode_item) = - self.build_collaboration_mode_update_item(previous_context, current_context) - { - update_items.push(collaboration_mode_item); - } - if let Some(model_instructions_item) = self.build_model_instructions_update_item( - previous_context, + // TODO: Make context updates a pure diff of persisted previous/current TurnContextItem + // state so replay/backtracking is deterministic. Runtime inputs that affect model-visible + // context (shell, exec policy, feature gates, resumed model bridge) should be persisted + // state or explicit non-state replay events. + let shell = self.user_shell(); + let exec_policy = self.services.exec_policy.current(); + crate::context_manager::updates::build_settings_update_items( + previous_context.map(Arc::as_ref), resumed_model, current_context, - ) { - update_items.push(model_instructions_item); - } - if let Some(personality_item) = - self.build_personality_update_item(previous_context, current_context) - { - update_items.push(personality_item); - } - update_items + shell.as_ref(), + exec_policy.as_ref(), + self.features.enabled(Feature::Personality), + ) } /// Persist the event to rollout and send it to clients. @@ -2689,7 +2560,10 @@ impl Session { && base_instructions == model_info.get_model_instructions(Some(personality)); if !has_baked_personality && let Some(personality_message) = - Self::personality_message_for(&model_info, personality) + crate::context_manager::updates::personality_message_for( + &model_info, + personality, + ) { items.push( DeveloperInstructions::personality_spec_message(personality_message).into(), diff --git a/codex-rs/core/src/context_manager/mod.rs b/codex-rs/core/src/context_manager/mod.rs index dcaf794c49..853f8af5ac 100644 --- a/codex-rs/core/src/context_manager/mod.rs +++ b/codex-rs/core/src/context_manager/mod.rs @@ -1,5 +1,6 @@ mod history; mod normalize; +pub(crate) mod updates; pub(crate) use history::ContextManager; pub(crate) use history::TotalTokenUsageBreakdown; diff --git a/codex-rs/core/src/context_manager/updates.rs b/codex-rs/core/src/context_manager/updates.rs new file mode 100644 index 0000000000..4a3f0c15e5 --- /dev/null +++ b/codex-rs/core/src/context_manager/updates.rs @@ -0,0 +1,148 @@ +use crate::codex::TurnContext; +use crate::environment_context::EnvironmentContext; +use crate::shell::Shell; +use codex_execpolicy::Policy; +use codex_protocol::config_types::Personality; +use codex_protocol::models::DeveloperInstructions; +use codex_protocol::models::ResponseItem; +use codex_protocol::openai_models::ModelInfo; + +fn build_environment_update_item( + previous: Option<&TurnContext>, + next: &TurnContext, + shell: &Shell, +) -> Option { + let prev = previous?; + let prev_context = EnvironmentContext::from_turn_context(prev, shell); + let next_context = EnvironmentContext::from_turn_context(next, shell); + if prev_context.equals_except_shell(&next_context) { + return None; + } + + Some(ResponseItem::from(EnvironmentContext::diff( + prev, next, shell, + ))) +} + +fn build_permissions_update_item( + previous: Option<&TurnContext>, + next: &TurnContext, + exec_policy: &Policy, +) -> Option { + let prev = previous?; + if prev.sandbox_policy == next.sandbox_policy && prev.approval_policy == next.approval_policy { + return None; + } + + Some( + DeveloperInstructions::from_policy( + &next.sandbox_policy, + next.approval_policy, + exec_policy, + &next.cwd, + ) + .into(), + ) +} + +fn build_collaboration_mode_update_item( + previous: Option<&TurnContext>, + next: &TurnContext, +) -> Option { + let prev = previous?; + if prev.collaboration_mode != next.collaboration_mode { + // If the next mode has empty developer instructions, this returns None and we emit no + // update, so prior collaboration instructions remain in the prompt history. + Some(DeveloperInstructions::from_collaboration_mode(&next.collaboration_mode)?.into()) + } else { + None + } +} + +fn build_personality_update_item( + previous: Option<&TurnContext>, + next: &TurnContext, + personality_feature_enabled: bool, +) -> Option { + if !personality_feature_enabled { + return None; + } + let previous = previous?; + if next.model_info.slug != previous.model_info.slug { + return None; + } + + if let Some(personality) = next.personality + && next.personality != previous.personality + { + let model_info = &next.model_info; + let personality_message = personality_message_for(model_info, personality); + personality_message + .map(|message| DeveloperInstructions::personality_spec_message(message).into()) + } else { + None + } +} + +pub(crate) fn personality_message_for( + model_info: &ModelInfo, + personality: Personality, +) -> Option { + model_info + .model_messages + .as_ref() + .and_then(|spec| spec.get_personality_message(Some(personality))) + .filter(|message| !message.is_empty()) +} + +pub(crate) fn build_model_instructions_update_item( + previous: Option<&TurnContext>, + resumed_model: Option<&str>, + next: &TurnContext, +) -> Option { + let previous_model = + resumed_model.or_else(|| previous.map(|prev| prev.model_info.slug.as_str()))?; + if previous_model == next.model_info.slug { + return None; + } + + let model_instructions = next.model_info.get_model_instructions(next.personality); + if model_instructions.is_empty() { + return None; + } + + Some(DeveloperInstructions::model_switch_message(model_instructions).into()) +} + +pub(crate) fn build_settings_update_items( + previous: Option<&TurnContext>, + resumed_model: Option<&str>, + next: &TurnContext, + shell: &Shell, + exec_policy: &Policy, + personality_feature_enabled: bool, +) -> Vec { + let mut update_items = Vec::new(); + + if let Some(env_item) = build_environment_update_item(previous, next, shell) { + update_items.push(env_item); + } + if let Some(permissions_item) = build_permissions_update_item(previous, next, exec_policy) { + update_items.push(permissions_item); + } + if let Some(collaboration_mode_item) = build_collaboration_mode_update_item(previous, next) { + update_items.push(collaboration_mode_item); + } + if let Some(model_instructions_item) = + build_model_instructions_update_item(previous, resumed_model, next) + { + update_items.push(model_instructions_item); + } + if let Some(personality_item) = + build_personality_update_item(previous, next, personality_feature_enabled) + { + update_items.push(personality_item); + } + + update_items +} From 31906cdb4d9f432ed6f0ca6cb82e53563d4c0b6f Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Tue, 17 Feb 2026 09:40:10 -0800 Subject: [PATCH 47/62] Update vendored rg to the latest stable version (15.1) (#12007) Addresses #12002 --- codex-cli/bin/rg | 60 ++++++++++++++++++++++++------------------------ 1 file changed, 30 insertions(+), 30 deletions(-) diff --git a/codex-cli/bin/rg b/codex-cli/bin/rg index 5a992570a9..426df75d71 100755 --- a/codex-cli/bin/rg +++ b/codex-cli/bin/rg @@ -4,74 +4,74 @@ "name": "rg", "platforms": { "macos-aarch64": { - "size": 1787248, - "hash": "blake3", - "digest": "8d9942032585ea8ee805937634238d9aee7b210069f4703c88fbe568e26fb78a", + "size": 1777930, + "hash": "sha256", + "digest": "378e973289176ca0c6054054ee7f631a065874a352bf43f0fa60ef079b6ba715", "format": "tar.gz", - "path": "ripgrep-14.1.1-aarch64-apple-darwin/rg", + "path": "ripgrep-15.1.0-aarch64-apple-darwin/rg", "providers": [ { - "url": "https://github.com/BurntSushi/ripgrep/releases/download/14.1.1/ripgrep-14.1.1-aarch64-apple-darwin.tar.gz" + "url": "https://github.com/BurntSushi/ripgrep/releases/download/15.1.0/ripgrep-15.1.0-aarch64-apple-darwin.tar.gz" } ] }, "linux-aarch64": { - "size": 2047405, - "hash": "blake3", - "digest": "0b670b8fa0a3df2762af2fc82cc4932f684ca4c02dbd1260d4f3133fd4b2a515", + "size": 1869959, + "hash": "sha256", + "digest": "2b661c6ef508e902f388e9098d9c4c5aca72c87b55922d94abdba830b4dc885e", "format": "tar.gz", - "path": "ripgrep-14.1.1-aarch64-unknown-linux-gnu/rg", + "path": "ripgrep-15.1.0-aarch64-unknown-linux-gnu/rg", "providers": [ { - "url": "https://github.com/BurntSushi/ripgrep/releases/download/14.1.1/ripgrep-14.1.1-aarch64-unknown-linux-gnu.tar.gz" + "url": "https://github.com/BurntSushi/ripgrep/releases/download/15.1.0/ripgrep-15.1.0-aarch64-unknown-linux-gnu.tar.gz" } ] }, "macos-x86_64": { - "size": 2082672, - "hash": "blake3", - "digest": "e9b862fc8da3127f92791f0ff6a799504154ca9d36c98bf3e60a81c6b1f7289e", + "size": 1894127, + "hash": "sha256", + "digest": "64811cb24e77cac3057d6c40b63ac9becf9082eedd54ca411b475b755d334882", "format": "tar.gz", - "path": "ripgrep-14.1.1-x86_64-apple-darwin/rg", + "path": "ripgrep-15.1.0-x86_64-apple-darwin/rg", "providers": [ { - "url": "https://github.com/BurntSushi/ripgrep/releases/download/14.1.1/ripgrep-14.1.1-x86_64-apple-darwin.tar.gz" + "url": "https://github.com/BurntSushi/ripgrep/releases/download/15.1.0/ripgrep-15.1.0-x86_64-apple-darwin.tar.gz" } ] }, "linux-x86_64": { - "size": 2566310, - "hash": "blake3", - "digest": "f73cca4e54d78c31f832c7f6e2c0b4db8b04fa3eaa747915727d570893dbee76", + "size": 2263077, + "hash": "sha256", + "digest": "1c9297be4a084eea7ecaedf93eb03d058d6faae29bbc57ecdaf5063921491599", "format": "tar.gz", - "path": "ripgrep-14.1.1-x86_64-unknown-linux-musl/rg", + "path": "ripgrep-15.1.0-x86_64-unknown-linux-musl/rg", "providers": [ { - "url": "https://github.com/BurntSushi/ripgrep/releases/download/14.1.1/ripgrep-14.1.1-x86_64-unknown-linux-musl.tar.gz" + "url": "https://github.com/BurntSushi/ripgrep/releases/download/15.1.0/ripgrep-15.1.0-x86_64-unknown-linux-musl.tar.gz" } ] }, "windows-x86_64": { - "size": 2058893, - "hash": "blake3", - "digest": "a8ce1a6fed4f8093ee997e57f33254e94b2cd18e26358b09db599c89882eadbd", + "size": 1810687, + "hash": "sha256", + "digest": "124510b94b6baa3380d051fdf4650eaa80a302c876d611e9dba0b2e18d87493a", "format": "zip", - "path": "ripgrep-14.1.1-x86_64-pc-windows-msvc/rg.exe", + "path": "ripgrep-15.1.0-x86_64-pc-windows-msvc/rg.exe", "providers": [ { - "url": "https://github.com/BurntSushi/ripgrep/releases/download/14.1.1/ripgrep-14.1.1-x86_64-pc-windows-msvc.zip" + "url": "https://github.com/BurntSushi/ripgrep/releases/download/15.1.0/ripgrep-15.1.0-x86_64-pc-windows-msvc.zip" } ] }, "windows-aarch64": { - "size": 1667740, - "hash": "blake3", - "digest": "47b971a8c4fca1d23a4e7c19bd4d88465ebc395598458133139406d3bf85f3fa", + "size": 1675460, + "hash": "sha256", + "digest": "00d931fb5237c9696ca49308818edb76d8eb6fc132761cb2a1bd616b2df02f8e", "format": "zip", - "path": "rg.exe", + "path": "ripgrep-15.1.0-aarch64-pc-windows-msvc/rg.exe", "providers": [ { - "url": "https://github.com/microsoft/ripgrep-prebuilt/releases/download/v13.0.0-13/ripgrep-v13.0.0-13-aarch64-pc-windows-msvc.zip" + "url": "https://github.com/BurntSushi/ripgrep/releases/download/15.1.0/ripgrep-15.1.0-aarch64-pc-windows-msvc.zip" } ] } From 5296e06b61505233165cbdf115c8bf5c031d75d7 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Tue, 17 Feb 2026 09:40:46 -0800 Subject: [PATCH 48/62] Protect workspace .agents directory in Windows sandbox (#11970) The Mac and Linux implementations of the sandbox recently added write protections for `.codex` and `.agents` subdirectories in all writable roots. When adding documentation for this, I noticed that this change was never made for the Windows sandbox. Summary - make compute_allow_paths treat .codex/.agents as protected alongside .git, and cover their behavior in new tests - wire protect_workspace_agents_dir through the sandbox lib and setup path to apply deny ACEs when `.agents` exists - factor shared ACL logic for workspace subdirectories --- codex-rs/windows-sandbox-rs/src/allow.rs | 44 ++++++++++++++++--- codex-rs/windows-sandbox-rs/src/lib.rs | 5 +++ .../windows-sandbox-rs/src/setup_main_win.rs | 31 +++++++++++-- .../windows-sandbox-rs/src/workspace_acl.rs | 16 +++++-- 4 files changed, 85 insertions(+), 11 deletions(-) diff --git a/codex-rs/windows-sandbox-rs/src/allow.rs b/codex-rs/windows-sandbox-rs/src/allow.rs index d2a2f94915..b40532cda8 100644 --- a/codex-rs/windows-sandbox-rs/src/allow.rs +++ b/codex-rs/windows-sandbox-rs/src/allow.rs @@ -52,9 +52,11 @@ pub fn compute_allow_paths( let canonical = canonicalize(&candidate).unwrap_or(candidate); add_allow(canonical.clone()); - let git_entry = canonical.join(".git"); - if git_entry.exists() { - add_deny(git_entry); + for protected_subdir in [".git", ".codex", ".agents"] { + let protected_entry = canonical.join(protected_subdir); + if protected_entry.exists() { + add_deny(protected_entry); + } } }; @@ -210,7 +212,39 @@ mod tests { } #[test] - fn skips_git_dir_when_missing() { + fn denies_codex_and_agents_inside_writable_root() { + let tmp = TempDir::new().expect("tempdir"); + let command_cwd = tmp.path().join("workspace"); + let codex_dir = command_cwd.join(".codex"); + let agents_dir = command_cwd.join(".agents"); + let _ = fs::create_dir_all(&codex_dir); + let _ = fs::create_dir_all(&agents_dir); + + let policy = SandboxPolicy::WorkspaceWrite { + writable_roots: vec![], + read_only_access: Default::default(), + network_access: false, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: false, + }; + + let paths = compute_allow_paths(&policy, &command_cwd, &command_cwd, &HashMap::new()); + let expected_allow: HashSet = [dunce::canonicalize(&command_cwd).unwrap()] + .into_iter() + .collect(); + let expected_deny: HashSet = [ + dunce::canonicalize(&codex_dir).unwrap(), + dunce::canonicalize(&agents_dir).unwrap(), + ] + .into_iter() + .collect(); + + assert_eq!(expected_allow, paths.allow); + assert_eq!(expected_deny, paths.deny); + } + + #[test] + fn skips_protected_subdirs_when_missing() { let tmp = TempDir::new().expect("tempdir"); let command_cwd = tmp.path().join("workspace"); let _ = fs::create_dir_all(&command_cwd); @@ -225,6 +259,6 @@ mod tests { let paths = compute_allow_paths(&policy, &command_cwd, &command_cwd, &HashMap::new()); assert_eq!(paths.allow.len(), 1); - assert!(paths.deny.is_empty(), "no deny when .git is absent"); + assert!(paths.deny.is_empty(), "no deny when protected dirs are absent"); } } diff --git a/codex-rs/windows-sandbox-rs/src/lib.rs b/codex-rs/windows-sandbox-rs/src/lib.rs index 61077342dc..fc96e5186b 100644 --- a/codex-rs/windows-sandbox-rs/src/lib.rs +++ b/codex-rs/windows-sandbox-rs/src/lib.rs @@ -127,6 +127,8 @@ pub use winutil::to_wide; #[cfg(target_os = "windows")] pub use workspace_acl::is_command_cwd_root; #[cfg(target_os = "windows")] +pub use workspace_acl::protect_workspace_agents_dir; +#[cfg(target_os = "windows")] pub use workspace_acl::protect_workspace_codex_dir; #[cfg(not(target_os = "windows"))] @@ -165,6 +167,7 @@ mod windows_impl { use super::winutil::quote_windows_arg; use super::winutil::to_wide; use super::workspace_acl::is_command_cwd_root; + use super::workspace_acl::protect_workspace_agents_dir; use super::workspace_acl::protect_workspace_codex_dir; use anyhow::Result; use std::collections::HashMap; @@ -344,6 +347,7 @@ mod windows_impl { if let Some(psid) = psid_workspace { allow_null_device(psid); let _ = protect_workspace_codex_dir(¤t_dir, psid); + let _ = protect_workspace_agents_dir(¤t_dir, psid); } } @@ -552,6 +556,7 @@ mod windows_impl { allow_null_device(psid_generic); allow_null_device(psid_workspace); let _ = protect_workspace_codex_dir(¤t_dir, psid_workspace); + let _ = protect_workspace_agents_dir(¤t_dir, psid_workspace); } Ok(()) diff --git a/codex-rs/windows-sandbox-rs/src/setup_main_win.rs b/codex-rs/windows-sandbox-rs/src/setup_main_win.rs index 28c94a769c..f3de7c8564 100644 --- a/codex-rs/windows-sandbox-rs/src/setup_main_win.rs +++ b/codex-rs/windows-sandbox-rs/src/setup_main_win.rs @@ -16,6 +16,7 @@ use codex_windows_sandbox::is_command_cwd_root; use codex_windows_sandbox::load_or_create_cap_sids; use codex_windows_sandbox::log_note; use codex_windows_sandbox::path_mask_allows; +use codex_windows_sandbox::protect_workspace_agents_dir; use codex_windows_sandbox::protect_workspace_codex_dir; use codex_windows_sandbox::sandbox_dir; use codex_windows_sandbox::sandbox_secrets_dir; @@ -788,9 +789,9 @@ fn run_setup_full(payload: &Payload, log: &mut File, sbx_dir: &Path) -> Result<( } } - // Protect the current workspace's `.codex` directory from tampering (write/delete) by using a - // workspace-specific capability SID. If `.codex` doesn't exist yet, skip it (it will be picked - // up on the next refresh). + // Protect the current workspace's `.codex` and `.agents` directories from tampering + // (write/delete) by using a workspace-specific capability SID. If a directory doesn't exist + // yet, skip it (it will be picked up on the next refresh). match unsafe { protect_workspace_codex_dir(&payload.command_cwd, workspace_psid) } { Ok(true) => { let cwd_codex = payload.command_cwd.join(".codex"); @@ -812,6 +813,30 @@ fn run_setup_full(payload: &Payload, log: &mut File, sbx_dir: &Path) -> Result<( )?; } } + match unsafe { protect_workspace_agents_dir(&payload.command_cwd, workspace_psid) } { + Ok(true) => { + let cwd_agents = payload.command_cwd.join(".agents"); + log_line( + log, + &format!( + "applied deny ACE to protect workspace .agents {}", + cwd_agents.display() + ), + )?; + } + Ok(false) => {} + Err(err) => { + let cwd_agents = payload.command_cwd.join(".agents"); + refresh_errors.push(format!( + "deny ACE failed on {}: {err}", + cwd_agents.display() + )); + log_line( + log, + &format!("deny ACE failed on {}: {err}", cwd_agents.display()), + )?; + } + } unsafe { if !sandbox_group_psid.is_null() { LocalFree(sandbox_group_psid as HLOCAL); diff --git a/codex-rs/windows-sandbox-rs/src/workspace_acl.rs b/codex-rs/windows-sandbox-rs/src/workspace_acl.rs index b34a7191ad..7143212b54 100644 --- a/codex-rs/windows-sandbox-rs/src/workspace_acl.rs +++ b/codex-rs/windows-sandbox-rs/src/workspace_acl.rs @@ -11,9 +11,19 @@ pub fn is_command_cwd_root(root: &Path, canonical_command_cwd: &Path) -> bool { /// # Safety /// Caller must ensure `psid` is a valid SID pointer. pub unsafe fn protect_workspace_codex_dir(cwd: &Path, psid: *mut c_void) -> Result { - let cwd_codex = cwd.join(".codex"); - if cwd_codex.is_dir() { - add_deny_write_ace(&cwd_codex, psid) + protect_workspace_subdir(cwd, psid, ".codex") +} + +/// # Safety +/// Caller must ensure `psid` is a valid SID pointer. +pub unsafe fn protect_workspace_agents_dir(cwd: &Path, psid: *mut c_void) -> Result { + protect_workspace_subdir(cwd, psid, ".agents") +} + +unsafe fn protect_workspace_subdir(cwd: &Path, psid: *mut c_void, subdir: &str) -> Result { + let path = cwd.join(subdir); + if path.is_dir() { + add_deny_write_ace(&path, psid) } else { Ok(false) } From 05e9c2cd759dbaa56e82c5bd60272a14ef61c18f Mon Sep 17 00:00:00 2001 From: jif-oai Date: Tue, 17 Feb 2026 18:04:33 +0000 Subject: [PATCH 49/62] Add /statusline tooltip entry (#12005) Summary - Add a brief tooltip pointing users to `/statusline` for configuring the status line content. Testing - Not run (not requested) --- codex-rs/tui/tooltips.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/codex-rs/tui/tooltips.txt b/codex-rs/tui/tooltips.txt index 22f6b37546..88f9de01cd 100644 --- a/codex-rs/tui/tooltips.txt +++ b/codex-rs/tui/tooltips.txt @@ -6,6 +6,7 @@ Use /permissions to control when Codex asks for confirmation. Run /review to get a code review of your current changes. Use /skills to list available skills or ask Codex to use one. Use /status to see the current model, approvals, and token usage. +Use /statusline to configure which items appear in the status line. Use /fork to branch the current chat into a new thread. Use /init to create an AGENTS.md with project-specific guidance. Use /mcp to list configured MCP tools. From 76283e6b4e0fae570344b221b4bbb152d969e850 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Tue, 17 Feb 2026 18:17:19 +0000 Subject: [PATCH 50/62] feat: move agents config to main config (#11982) --- codex-rs/core/config.schema.json | 22 +- .../src/agent/builtins_agents_config.toml | 26 - codex-rs/core/src/agent/role.rs | 845 +++++++----------- codex-rs/core/src/codex.rs | 9 +- codex-rs/core/src/config/mod.rs | 59 ++ codex-rs/core/src/config_loader/mod.rs | 2 +- .../core/src/tools/handlers/multi_agents.rs | 1 + codex-rs/core/src/tools/spec.rs | 16 +- 8 files changed, 442 insertions(+), 538 deletions(-) delete mode 100644 codex-rs/core/src/agent/builtins_agents_config.toml diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index c9da5c688b..b05d11edc9 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -6,8 +6,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" }, - "AgentsToml": { + "AgentRoleToml": { "additionalProperties": false, + "properties": { + "config_file": { + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "Path to a role-specific config layer." + }, + "description": { + "description": "Human-facing role documentation used in spawn tool guidance.", + "type": "string" + } + }, + "type": "object" + }, + "AgentsToml": { + "additionalProperties": { + "$ref": "#/definitions/AgentRoleToml" + }, "properties": { "max_threads": { "description": "Maximum number of agent threads that can be open concurrently. When unset, no limit is enforced.", diff --git a/codex-rs/core/src/agent/builtins_agents_config.toml b/codex-rs/core/src/agent/builtins_agents_config.toml deleted file mode 100644 index 52aa390196..0000000000 --- a/codex-rs/core/src/agent/builtins_agents_config.toml +++ /dev/null @@ -1,26 +0,0 @@ -version = 1 - -[agents.default] -description = "Default agent." - -[agents.worker] -description = """Use for execution and production work. -Typical tasks: -- Implement part of a feature -- Fix tests or bugs -- Split large refactors into independent chunks -Rules: -- Explicitly assign **ownership** of the task (files / responsibility). -- Always tell workers they are **not alone in the codebase**, and they should ignore edits made by others without touching them.""" - -[agents.explorer] -description = """Use `explorer` for all codebase questions. -Explorers are fast and authoritative. -Always prefer them over manual search or file reading. -Rules: -- Ask explorers first and precisely. -- Do not re-read or re-search code they cover. -- Trust explorer results without verification. -- Run explorers in parallel when useful. -- Reuse existing explorers for related questions.""" -config_file = "explorer.toml" diff --git a/codex-rs/core/src/agent/role.rs b/codex-rs/core/src/agent/role.rs index 6160c27b23..97cde2c8a3 100644 --- a/codex-rs/core/src/agent/role.rs +++ b/codex-rs/core/src/agent/role.rs @@ -1,155 +1,102 @@ +use crate::config::AgentRoleConfig; use crate::config::Config; use crate::config::ConfigOverrides; use crate::config::deserialize_config_toml_with_base; -use crate::config::find_codex_home; use crate::config_loader::ConfigLayerEntry; use crate::config_loader::ConfigLayerStack; use crate::config_loader::ConfigLayerStackOrdering; +use crate::config_loader::resolve_relative_paths_in_config_toml; use codex_app_server_protocol::ConfigLayerSource; -use serde::Deserialize; use std::collections::BTreeMap; use std::collections::BTreeSet; use std::path::Path; -use std::path::PathBuf; use std::sync::LazyLock; use toml::Value as TomlValue; -const BUILT_IN_AGENTS_CONFIG: &str = include_str!("builtins_agents_config.toml"); const BUILT_IN_EXPLORER_CONFIG: &str = include_str!("builtins/explorer.toml"); - -const AGENTS_CONFIG_FILENAME: &str = "agents_config.toml"; -const AGENTS_CONFIG_SCHEMA_VERSION: u32 = 1; const DEFAULT_ROLE_NAME: &str = "default"; const AGENT_TYPE_UNAVAILABLE_ERROR: &str = "agent type is currently not available"; -#[derive(Debug, Clone, Default, Deserialize)] -#[serde(deny_unknown_fields)] -struct AgentsConfigToml { - version: Option, - #[serde(default)] - agents: BTreeMap, -} - -#[derive(Debug, Clone, Default, Deserialize)] -#[serde(deny_unknown_fields)] -struct AgentDeclarationToml { - /// Human-facing role documentation used in spawn tool guidance. - description: Option, - /// Path to a role-specific config layer. - config_file: Option, -} - /// Applies a role config layer to a mutable config and preserves unspecified keys. pub(crate) async fn apply_role_to_config( config: &mut Config, role_name: Option<&str>, ) -> Result<(), String> { let role_name = role_name.unwrap_or(DEFAULT_ROLE_NAME); - let built_in_agents_config = built_in::configs(); - let user_agents_config = - user_defined::config(config.codex_home.as_path()).unwrap_or_else(|err| { - tracing::warn!( - agent_type = role_name, - error = %err, - "failed to load user-defined agents config; falling back to built-in roles" - ); - AgentsConfigToml::default() - }); - - let agent_config = if let Some(role) = user_agents_config.agents.get(role_name) { - if let Some(config_file) = &role.config_file { - let content = tokio::fs::read_to_string(config_file) - .await - .map_err(|err| { - tracing::warn!("failed to read user-defined role config_file: {err:?}"); - AGENT_TYPE_UNAVAILABLE_ERROR.to_string() - })?; - let parsed: TomlValue = toml::from_str(&content).map_err(|err| { - tracing::warn!("failed to read user-defined role config_file: {err:?}"); - AGENT_TYPE_UNAVAILABLE_ERROR.to_string() - })?; - Some(parsed) - } else { - None - } - } else if let Some(role) = built_in_agents_config.agents.get(role_name) { - if let Some(config_file) = &role.config_file { - let content = built_in::config_file(config_file).ok_or_else(|| { - tracing::warn!("failed to read user-defined role config_file."); - AGENT_TYPE_UNAVAILABLE_ERROR.to_string() - })?; - let parsed: TomlValue = toml::from_str(content).map_err(|err| { - tracing::warn!("failed to read user-defined role config_file: {err:?}"); - AGENT_TYPE_UNAVAILABLE_ERROR.to_string() - })?; - Some(parsed) - } else { - None - } - } else { - return Err(format!("unknown agent_type '{role_name}'")); - }; - - let Some(agent_config) = agent_config else { + let (config_file, is_built_in) = config + .agent_roles + .get(role_name) + .map(|role| (&role.config_file, false)) + .or_else(|| { + built_in::configs() + .get(role_name) + .map(|role| (&role.config_file, true)) + }) + .ok_or_else(|| format!("unknown agent_type '{role_name}'"))?; + let Some(config_file) = config_file.as_ref() else { return Ok(()); }; - let original = config.clone(); - let original_stack = &original.config_layer_stack; - let mut layers = original + let (role_config_contents, role_config_base) = if is_built_in { + ( + built_in::config_file_contents(config_file) + .map(str::to_owned) + .ok_or_else(|| AGENT_TYPE_UNAVAILABLE_ERROR.to_string())?, + config.codex_home.as_path(), + ) + } else { + ( + tokio::fs::read_to_string(config_file) + .await + .map_err(|_| AGENT_TYPE_UNAVAILABLE_ERROR.to_string())?, + config_file + .parent() + .ok_or_else(|| AGENT_TYPE_UNAVAILABLE_ERROR.to_string())?, + ) + }; + + let role_config_toml: TomlValue = toml::from_str(&role_config_contents) + .map_err(|_| AGENT_TYPE_UNAVAILABLE_ERROR.to_string())?; + deserialize_config_toml_with_base(role_config_toml.clone(), role_config_base) + .map_err(|_| AGENT_TYPE_UNAVAILABLE_ERROR.to_string())?; + let role_layer_toml = resolve_relative_paths_in_config_toml(role_config_toml, role_config_base) + .map_err(|_| AGENT_TYPE_UNAVAILABLE_ERROR.to_string())?; + + let mut layers: Vec = config .config_layer_stack .get_layers(ConfigLayerStackOrdering::LowestPrecedenceFirst, true) .into_iter() .cloned() - .collect::>(); + .collect(); + let layer = ConfigLayerEntry::new(ConfigLayerSource::SessionFlags, role_layer_toml); + let insertion_index = + layers.partition_point(|existing_layer| existing_layer.name <= layer.name); + layers.insert(insertion_index, layer); - let role_layer = ConfigLayerEntry::new(ConfigLayerSource::SessionFlags, agent_config); - let role_layer_precedence = role_layer.name.precedence(); - let role_layer_index = - layers.partition_point(|layer| layer.name.precedence() <= role_layer_precedence); - layers.insert(role_layer_index, role_layer); - let layered_stack = ConfigLayerStack::new( + let config_layer_stack = ConfigLayerStack::new( layers, - original_stack.requirements().clone(), - original_stack.requirements_toml().clone(), + config.config_layer_stack.requirements().clone(), + config.config_layer_stack.requirements_toml().clone(), ) - .map_err(|err| { - tracing::warn!( - agent_type = role_name, - error = %err, - "failed to build layered config stack for role" - ); - AGENT_TYPE_UNAVAILABLE_ERROR.to_string() - })?; - let layered_config = - deserialize_config_toml_with_base(layered_stack.effective_config(), &original.codex_home) - .map_err(|err| { - tracing::warn!( - agent_type = role_name, - error = %err, - "failed to deserialize layered config for role" - ); - AGENT_TYPE_UNAVAILABLE_ERROR.to_string() - })?; + .map_err(|_| AGENT_TYPE_UNAVAILABLE_ERROR.to_string())?; - *config = Config::load_config_with_layer_stack( - layered_config, + let merged_toml = config_layer_stack.effective_config(); + let merged_config = deserialize_config_toml_with_base(merged_toml, &config.codex_home) + .map_err(|_| AGENT_TYPE_UNAVAILABLE_ERROR.to_string())?; + let next_config = Config::load_config_with_layer_stack( + merged_config, ConfigOverrides { - cwd: Some(original.cwd.clone()), - codex_linux_sandbox_exe: original.codex_linux_sandbox_exe.clone(), + cwd: Some(config.cwd.clone()), + codex_linux_sandbox_exe: config.codex_linux_sandbox_exe.clone(), + js_repl_node_path: config.js_repl_node_path.clone(), ..Default::default() }, - original.codex_home.clone(), - layered_stack, + config.codex_home.clone(), + config_layer_stack, ) - .map_err(|err| { - tracing::warn!( - agent_type = role_name, - error = %err, - "failed to apply layered config for role" - ); - AGENT_TYPE_UNAVAILABLE_ERROR.to_string() - })?; + .map_err(|_| AGENT_TYPE_UNAVAILABLE_ERROR.to_string())?; + *config = next_config; + Ok(()) } @@ -157,29 +104,24 @@ pub(crate) mod spawn_tool_spec { use super::*; /// Builds the spawn-agent tool description text from built-in and configured roles. - pub(crate) fn build() -> String { + pub(crate) fn build(user_defined_agent_roles: &BTreeMap) -> String { let built_in_roles = built_in::configs(); - let user_defined_roles = if let Ok(home) = find_codex_home() { - user_defined::config(&home).unwrap_or_default() - } else { - Default::default() - }; - - build_from_configs(built_in_roles, &user_defined_roles) + build_from_configs(built_in_roles, user_defined_agent_roles) } + // This function is not inlined for testing purpose. fn build_from_configs( - built_in_roles: &AgentsConfigToml, - user_defined_roles: &AgentsConfigToml, + built_in_roles: &BTreeMap, + user_defined_roles: &BTreeMap, ) -> String { let mut seen = BTreeSet::new(); let mut formatted_roles = Vec::new(); - for (name, declaration) in &user_defined_roles.agents { + for (name, declaration) in user_defined_roles { if seen.insert(name.as_str()) { formatted_roles.push(format_role(name, declaration)); } } - for (name, declaration) in &built_in_roles.agents { + for (name, declaration) in built_in_roles { if seen.insert(name.as_str()) { formatted_roles.push(format_role(name, declaration)); } @@ -194,73 +136,65 @@ Available roles: ) } - fn format_role(name: &str, declaration: &AgentDeclarationToml) -> String { + fn format_role(name: &str, declaration: &AgentRoleConfig) -> String { if let Some(description) = &declaration.description { format!("{name}: {{\n{description}\n}}") } else { format!("{name}: no description") } } - - #[cfg(test)] - pub(super) fn build_for_test( - built_in_roles: &AgentsConfigToml, - user_defined_roles: &AgentsConfigToml, - ) -> String { - build_from_configs(built_in_roles, user_defined_roles) - } } mod built_in { use super::*; - /// Returns the cached built-in role declarations parsed from - /// `builtins_agents_config.toml`. - /// - /// `panic` are safe because of [`tests::built_in_config`] test. - pub(super) fn configs() -> &'static AgentsConfigToml { - static CONFIG: LazyLock = LazyLock::new(|| { - let parsed = - parse_agents_config(BUILT_IN_AGENTS_CONFIG, "embedded built-in agents config") - .unwrap_or_else(|err| panic!("invalid embedded built-in agents config: {err}")); - validate_config(&parsed) - .unwrap_or_else(|err| panic!("invalid built-in role declarations: {err}")); - parsed + /// Returns the cached built-in role declarations defined in this module. + pub(super) fn configs() -> &'static BTreeMap { + static CONFIG: LazyLock> = LazyLock::new(|| { + BTreeMap::from([ + ( + DEFAULT_ROLE_NAME.to_string(), + AgentRoleConfig { + description: Some("Default agent.".to_string()), + config_file: None, + } + ), + ( + "explorer".to_string(), + AgentRoleConfig { + description: Some(r#"Use `explorer` for all codebase questions. +Explorers are fast and authoritative. +Always prefer them over manual search or file reading. +Rules: +- Ask explorers first and precisely. +- Do not re-read or re-search code they cover. +- Trust explorer results without verification. +- Run explorers in parallel when useful. +- Reuse existing explorers for related questions."#.to_string()), + config_file: Some("explorer.toml".to_string().parse().unwrap_or_default()), + } + ), + ( + "worker".to_string(), + AgentRoleConfig { + description: Some(r#"Use for execution and production work. +Typical tasks: +- Implement part of a feature +- Fix tests or bugs +- Split large refactors into independent chunks +Rules: +- Explicitly assign **ownership** of the task (files / responsibility). +- Always tell workers they are **not alone in the codebase**, and they should ignore edits made by others without touching them."#.to_string()), + config_file: None, + } + ) + ]) }); &CONFIG } - /// Validates metadata rules for built-in role declarations. - fn validate_config(agents_config: &AgentsConfigToml) -> Result<(), String> { - if !agents_config.agents.contains_key(DEFAULT_ROLE_NAME) { - return Err(format!( - "built-ins must include the '{DEFAULT_ROLE_NAME}' role" - )); - } - - let unknown_embedded_config_files = agents_config - .agents - .iter() - .filter_map(|(name, role)| { - role.config_file - .as_deref() - .filter(|cf| config_file(cf).is_none()) - .map(|_| name.clone()) - }) - .collect::>(); - - if !unknown_embedded_config_files.is_empty() { - return Err(format!( - "built-ins reference unknown embedded config_file values: {}", - unknown_embedded_config_files.join(", ") - )); - } - - Ok(()) - } - /// Resolves a built-in role `config_file` path to embedded content. - pub(super) fn config_file(path: &Path) -> Option<&'static str> { + pub(super) fn config_file_contents(path: &Path) -> Option<&'static str> { match path.to_str()? { "explorer.toml" => Some(BUILT_IN_EXPLORER_CONFIG), _ => None, @@ -268,389 +202,292 @@ mod built_in { } } -mod user_defined { - use super::*; - - /// Loads and parses `agents_config.toml` from `codex_home`. - pub(super) fn config(codex_home: &Path) -> Result { - let config_path = codex_home.join(AGENTS_CONFIG_FILENAME); - let contents = match std::fs::read_to_string(&config_path) { - Ok(contents) => contents, - Err(err) if err.kind() == std::io::ErrorKind::NotFound => { - return Ok(AgentsConfigToml::default()); - } - Err(err) => { - return Err(format!("failed to read '{}': {err}", config_path.display())); - } - }; - - let mut parsed = parse_agents_config(&contents, &config_path.display().to_string())?; - let config_dir = config_path.parent().ok_or_else(|| { - format!( - "failed to resolve parent directory for '{}'", - config_path.display() - ) - })?; - for role in parsed.agents.values_mut() { - if let Some(config_file) = role.config_file.as_mut() - && config_file.is_relative() - { - *config_file = config_dir.join(&*config_file); - } - } - Ok(parsed) - } -} - -fn parse_agents_config(contents: &str, source: &str) -> Result { - let parsed: AgentsConfigToml = - toml::from_str(contents).map_err(|err| format!("failed to parse '{source}': {err}"))?; - if let Some(version) = parsed.version - && version != AGENTS_CONFIG_SCHEMA_VERSION - { - return Err(format!( - "'{source}' has unsupported version {version}; expected {AGENTS_CONFIG_SCHEMA_VERSION}" - )); - } - Ok(parsed) -} - #[cfg(test)] mod tests { use super::*; - use crate::config::test_config; + use crate::config::ConfigBuilder; + use crate::config_loader::ConfigLayerStackOrdering; use codex_protocol::openai_models::ReasoningEffort; - use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; + use std::path::PathBuf; use tempfile::TempDir; - #[test] - fn built_in_config() { - // Validate the loading of the built-in configs without panics. - let _ = built_in::configs(); + async fn test_config_with_cli_overrides( + cli_overrides: Vec<(String, TomlValue)>, + ) -> (TempDir, Config) { + let home = TempDir::new().expect("create temp dir"); + let home_path = home.path().to_path_buf(); + let config = ConfigBuilder::default() + .codex_home(home_path.clone()) + .cli_overrides(cli_overrides) + .fallback_cwd(Some(home_path)) + .build() + .await + .expect("load test config"); + (home, config) } - /// Writes `agents_config.toml` into the temporary directory. - fn write_agents_config(dir: &TempDir, body: &str) { - std::fs::write(dir.path().join(AGENTS_CONFIG_FILENAME), body).expect("write config"); + async fn write_role_config(home: &TempDir, name: &str, contents: &str) -> PathBuf { + let role_path = home.path().join(name); + tokio::fs::write(&role_path, contents) + .await + .expect("write role config"); + role_path } - /// Writes a test role config file under `dir` for use by role tests. - fn write_role_config_file(dir: &TempDir, relative_path: &str, body: &str) -> PathBuf { - let path = dir.path().join(relative_path); - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent).expect("create role config parent"); - } - std::fs::write(&path, body).expect("write role config"); - path + fn session_flags_layer_count(config: &Config) -> usize { + config + .config_layer_stack + .get_layers(ConfigLayerStackOrdering::LowestPrecedenceFirst, true) + .into_iter() + .filter(|layer| layer.name == ConfigLayerSource::SessionFlags) + .count() } - /// Loads the built-in explorer role and applies its configuration layer. #[tokio::test] - async fn apply_role_to_config_uses_builtin_explorer_config_layer() { - let mut config = test_config(); + async fn apply_role_defaults_to_default_and_leaves_config_unchanged() { + let (_home, mut config) = test_config_with_cli_overrides(Vec::new()).await; + let before = config.clone(); + + apply_role_to_config(&mut config, None) + .await + .expect("default role should apply"); + + assert_eq!(before, config); + } + + #[tokio::test] + async fn apply_role_returns_error_for_unknown_role() { + let (_home, mut config) = test_config_with_cli_overrides(Vec::new()).await; + + let err = apply_role_to_config(&mut config, Some("missing-role")) + .await + .expect_err("unknown role should fail"); + + assert_eq!(err, "unknown agent_type 'missing-role'"); + } + + #[tokio::test] + async fn apply_explorer_role_sets_model_and_adds_session_flags_layer() { + let (_home, mut config) = test_config_with_cli_overrides(Vec::new()).await; + let before_layers = session_flags_layer_count(&config); apply_role_to_config(&mut config, Some("explorer")) .await - .expect("apply explorer role"); + .expect("explorer role should apply"); - assert_eq!(config.model, Some("gpt-5.1-codex-mini".to_string())); + assert_eq!(config.model.as_deref(), Some("gpt-5.1-codex-mini")); assert_eq!(config.model_reasoning_effort, Some(ReasoningEffort::Medium)); + assert_eq!(session_flags_layer_count(&config), before_layers + 1); } #[tokio::test] - async fn apply_role_to_config_falls_back_to_builtins_when_user_config_is_invalid() { - let dir = TempDir::new().expect("tempdir"); - write_agents_config( - &dir, - r#" -[agents.explorer -description = "broken" -"#, + async fn apply_role_returns_unavailable_for_missing_user_role_file() { + let (_home, mut config) = test_config_with_cli_overrides(Vec::new()).await; + config.agent_roles.insert( + "custom".to_string(), + AgentRoleConfig { + description: None, + config_file: Some(PathBuf::from("/path/does/not/exist.toml")), + }, ); - let mut config = test_config(); - config.codex_home = dir.path().to_path_buf(); - - apply_role_to_config(&mut config, Some("explorer")) + let err = apply_role_to_config(&mut config, Some("custom")) .await - .expect("apply explorer role"); - - assert_eq!(config.model, Some("gpt-5.1-codex-mini".to_string())); - assert_eq!(config.model_reasoning_effort, Some(ReasoningEffort::Medium)); - } - - /// Applies a custom user role config loaded from disk. - #[tokio::test] - async fn apply_role_to_config_supports_custom_role_config_file() { - let dir = TempDir::new().expect("tempdir"); - let planner_path = write_role_config_file( - &dir, - "agents/planner.toml", - r#" -model = "gpt-5.1-codex" -sandbox_mode = "read-only" -"#, - ); - write_agents_config( - &dir, - &format!( - "[agents.planner]\ndescription = \"Planning-focused role.\"\nconfig_file = {planner_path:?}\n" - ), - ); - - let mut config = test_config(); - config.codex_home = dir.path().to_path_buf(); - apply_role_to_config(&mut config, Some("planner")) - .await - .expect("apply planner role"); - - assert_eq!(config.model, Some("gpt-5.1-codex".to_string())); - assert_eq!( - config.permissions.sandbox_policy.get(), - &crate::protocol::SandboxPolicy::new_read_only_policy() - ); - } - - /// Resolves relative config_file paths from the agents_config.toml directory. - #[tokio::test] - async fn apply_role_to_config_supports_relative_custom_role_config_file() { - let dir = TempDir::new().expect("tempdir"); - write_role_config_file( - &dir, - "agents/planner.toml", - r#" -model = "gpt-5.1-codex" -sandbox_mode = "read-only" -"#, - ); - write_agents_config( - &dir, - r#" -[agents.planner] -description = "Planning-focused role." -config_file = "agents/planner.toml" -"#, - ); - - let mut config = test_config(); - config.codex_home = dir.path().to_path_buf(); - apply_role_to_config(&mut config, Some("planner")) - .await - .expect("apply planner role"); - - assert_eq!(config.model, Some("gpt-5.1-codex".to_string())); - assert_eq!( - config.permissions.sandbox_policy.get(), - &crate::protocol::SandboxPolicy::new_read_only_policy() - ); - } - - #[tokio::test] - async fn apply_role_to_config_reports_unknown_agent_type() { - let mut config = test_config(); - - let err = apply_role_to_config(&mut config, Some("missing")) - .await - .expect_err("missing role should fail"); - - assert_eq!(err, "unknown agent_type 'missing'"); - } - - #[tokio::test] - async fn apply_role_to_config_reports_unavailable_agent_type() { - let dir = TempDir::new().expect("tempdir"); - write_agents_config( - &dir, - r#" -[agents.planner] -config_file = "agents/does-not-exist.toml" -"#, - ); - - let mut config = test_config(); - config.codex_home = dir.path().to_path_buf(); - let err = apply_role_to_config(&mut config, Some("planner")) - .await - .expect_err("missing config file should fail"); + .expect_err("missing role file should fail"); assert_eq!(err, AGENT_TYPE_UNAVAILABLE_ERROR); } - /// Lets a user config file override a built-in role config file. #[tokio::test] - async fn apply_role_to_config_lets_user_override_builtin_config_file() { - let dir = TempDir::new().expect("tempdir"); - let custom_explorer_path = write_role_config_file( - &dir, - "agents/custom_explorer.toml", - r#" -model = "gpt-5.1-codex" -model_reasoning_effort = "high" -"#, - ); - write_agents_config( - &dir, - &format!("[agents.explorer]\nconfig_file = {custom_explorer_path:?}\n"), + async fn apply_role_returns_unavailable_for_invalid_user_role_toml() { + let (home, mut config) = test_config_with_cli_overrides(Vec::new()).await; + let role_path = write_role_config(&home, "invalid-role.toml", "model = [").await; + config.agent_roles.insert( + "custom".to_string(), + AgentRoleConfig { + description: None, + config_file: Some(role_path), + }, ); - let mut config = test_config(); - config.codex_home = dir.path().to_path_buf(); - apply_role_to_config(&mut config, Some("explorer")) + let err = apply_role_to_config(&mut config, Some("custom")) .await - .expect("apply explorer role"); + .expect_err("invalid role file should fail"); - assert_eq!(config.model, Some("gpt-5.1-codex".to_string())); + assert_eq!(err, AGENT_TYPE_UNAVAILABLE_ERROR); + } + + #[tokio::test] + async fn apply_role_preserves_unspecified_keys() { + let (home, mut config) = test_config_with_cli_overrides(vec![( + "model".to_string(), + TomlValue::String("base-model".to_string()), + )]) + .await; + let role_path = write_role_config( + &home, + "effort-only.toml", + "model_reasoning_effort = \"high\"", + ) + .await; + config.agent_roles.insert( + "custom".to_string(), + AgentRoleConfig { + description: None, + config_file: Some(role_path), + }, + ); + + apply_role_to_config(&mut config, Some("custom")) + .await + .expect("custom role should apply"); + + assert_eq!(config.model.as_deref(), Some("base-model")); assert_eq!(config.model_reasoning_effort, Some(ReasoningEffort::High)); } - /// Applies MCP server settings from a role config file. #[tokio::test] - async fn apply_role_to_config_applies_mcp_servers_config_file_layer() { - let dir = TempDir::new().expect("tempdir"); - let tester_path = write_role_config_file( - &dir, - "agents/tester.toml", - r#" -[mcp_servers.docs] -command = "echo" -enabled_tools = ["search"] + #[cfg(not(windows))] + async fn apply_role_does_not_materialize_default_sandbox_workspace_write_fields() { + use codex_protocol::protocol::SandboxPolicy; + let (home, mut config) = test_config_with_cli_overrides(vec![ + ( + "sandbox_mode".to_string(), + TomlValue::String("workspace-write".to_string()), + ), + ( + "sandbox_workspace_write.network_access".to_string(), + TomlValue::Boolean(true), + ), + ]) + .await; + let role_path = write_role_config( + &home, + "sandbox-role.toml", + r#"[sandbox_workspace_write] +writable_roots = ["./sandbox-root"] "#, - ); - write_agents_config( - &dir, - &format!("[agents.tester]\nconfig_file = {tester_path:?}\n"), - ); - - let mut config = test_config(); - config.codex_home = dir.path().to_path_buf(); - apply_role_to_config(&mut config, Some("tester")) - .await - .expect("apply tester role"); - - let mcp_servers = config.mcp_servers.get(); - assert_eq!( - mcp_servers - .get("docs") - .and_then(|server| server.enabled_tools.clone()), - Some(vec!["search".to_string()]) - ); - } - - /// Inserts a role SessionFlags layer in precedence order when legacy managed - /// layers are already present. - #[tokio::test] - async fn apply_role_to_config_keeps_layer_ordering_with_legacy_managed_layers() { - let mut config = test_config(); - let dir = TempDir::new().expect("tempdir"); - let managed_path = dir.path().join("managed_config.toml"); - std::fs::write(&managed_path, "").expect("write managed config"); - let managed_file = AbsolutePathBuf::try_from(managed_path).expect("managed file"); - config.config_layer_stack = ConfigLayerStack::new( - vec![ConfigLayerEntry::new( - ConfigLayerSource::LegacyManagedConfigTomlFromFile { file: managed_file }, - TomlValue::Table(toml::map::Map::new()), - )], - config.config_layer_stack.requirements().clone(), - config.config_layer_stack.requirements_toml().clone(), ) - .expect("build initial stack"); + .await; + config.agent_roles.insert( + "custom".to_string(), + AgentRoleConfig { + description: None, + config_file: Some(role_path), + }, + ); - apply_role_to_config(&mut config, Some("explorer")) + apply_role_to_config(&mut config, Some("custom")) .await - .expect("apply explorer role"); + .expect("custom role should apply"); - let layers = config + let role_layer = config .config_layer_stack - .get_layers(ConfigLayerStackOrdering::LowestPrecedenceFirst, true); - assert!(matches!( - layers.first().map(|layer| &layer.name), - Some(ConfigLayerSource::SessionFlags) - )); - assert!(matches!( - layers.last().map(|layer| &layer.name), - Some(ConfigLayerSource::LegacyManagedConfigTomlFromFile { .. }) - )); + .get_layers(ConfigLayerStackOrdering::LowestPrecedenceFirst, true) + .into_iter() + .rfind(|layer| layer.name == ConfigLayerSource::SessionFlags) + .expect("expected a session flags layer"); + let sandbox_workspace_write = role_layer + .config + .get("sandbox_workspace_write") + .and_then(TomlValue::as_table) + .expect("role layer should include sandbox_workspace_write"); + assert_eq!( + sandbox_workspace_write.contains_key("network_access"), + false + ); + assert_eq!( + sandbox_workspace_write.contains_key("exclude_tmpdir_env_var"), + false + ); + assert_eq!( + sandbox_workspace_write.contains_key("exclude_slash_tmp"), + false + ); + + match &*config.permissions.sandbox_policy { + SandboxPolicy::WorkspaceWrite { network_access, .. } => { + assert_eq!(*network_access, true); + } + other => panic!("expected workspace-write sandbox policy, got {other:?}"), + } + } + + #[tokio::test] + async fn apply_role_takes_precedence_over_existing_session_flags_for_same_key() { + let (home, mut config) = test_config_with_cli_overrides(vec![( + "model".to_string(), + TomlValue::String("cli-model".to_string()), + )]) + .await; + let before_layers = session_flags_layer_count(&config); + let role_path = write_role_config(&home, "model-role.toml", "model = \"role-model\"").await; + config.agent_roles.insert( + "custom".to_string(), + AgentRoleConfig { + description: None, + config_file: Some(role_path), + }, + ); + + apply_role_to_config(&mut config, Some("custom")) + .await + .expect("custom role should apply"); + + assert_eq!(config.model.as_deref(), Some("role-model")); + assert_eq!(session_flags_layer_count(&config), before_layers + 1); } #[test] - fn spawn_tool_spec_build_dedups_and_prefers_user_defined_roles() { - let built_in_roles = parse_agents_config( - r#" -[agents.default] -description = "Built-in default." + fn spawn_tool_spec_build_deduplicates_user_defined_built_in_roles() { + let user_defined_roles = BTreeMap::from([ + ( + "explorer".to_string(), + AgentRoleConfig { + description: Some("user override".to_string()), + config_file: None, + }, + ), + ("researcher".to_string(), AgentRoleConfig::default()), + ]); -[agents.explorer] -description = "Built-in explorer." -"#, - "built-in test roles", - ) - .expect("parse built-in roles"); - let user_defined_roles = parse_agents_config( - r#" -[agents.explorer] -description = "User explorer." -"#, - "user-defined test roles", - ) - .expect("parse user roles"); + let spec = spawn_tool_spec::build(&user_defined_roles); - let spec = spawn_tool_spec::build_for_test(&built_in_roles, &user_defined_roles); - - assert_eq!(spec.matches("explorer:").count(), 1); - assert!(spec.contains("explorer: {\nUser explorer.\n}")); - assert!(!spec.contains("Built-in explorer.")); + assert!(spec.contains("researcher: no description")); + assert!(spec.contains("explorer: {\nuser override\n}")); + assert!(spec.contains("default: {\nDefault agent.\n}")); + assert!(!spec.contains("Explorers are fast and authoritative.")); } #[test] - fn spawn_tool_spec_build_lists_user_defined_roles_first() { - let built_in_roles = parse_agents_config( - r#" -[agents.default] -description = "Built-in default." + fn spawn_tool_spec_lists_user_defined_roles_before_built_ins() { + let user_defined_roles = BTreeMap::from([( + "aaa".to_string(), + AgentRoleConfig { + description: Some("first".to_string()), + config_file: None, + }, + )]); -[agents.worker] -description = "Built-in worker." -"#, - "built-in test roles", - ) - .expect("parse built-in roles"); - let user_defined_roles = parse_agents_config( - r#" -[agents.planner] -description = "User planner." -"#, - "user-defined test roles", - ) - .expect("parse user roles"); + let spec = spawn_tool_spec::build(&user_defined_roles); + let user_index = spec.find("aaa: {\nfirst\n}").expect("find user role"); + let built_in_index = spec + .find("default: {\nDefault agent.\n}") + .expect("find built-in role"); - let spec = spawn_tool_spec::build_for_test(&built_in_roles, &user_defined_roles); - - let planner_pos = spec.find("planner:").expect("planner role is present"); - let default_pos = spec.find("default:").expect("default role is present"); - assert!(planner_pos < default_pos); + assert!(user_index < built_in_index); } #[test] - fn spawn_tool_spec_build_formats_missing_description() { - let built_in_roles = parse_agents_config( - r#" -[agents.default] -description = "Built-in default." -"#, - "built-in test roles", - ) - .expect("parse built-in roles"); - let user_defined_roles = parse_agents_config( - r#" -[agents.planner] -"#, - "user-defined test roles", - ) - .expect("parse user roles"); - - let spec = spawn_tool_spec::build_for_test(&built_in_roles, &user_defined_roles); - - assert!(spec.contains("planner: no description")); + fn built_in_config_file_contents_resolves_explorer_only() { + assert_eq!( + built_in::config_file_contents(Path::new("explorer.toml")), + Some(BUILT_IN_EXPLORER_CONFIG) + ); + assert_eq!( + built_in::config_file_contents(Path::new("missing.toml")), + None + ); } } diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 34f0d9f873..5eb80de2ec 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -608,7 +608,8 @@ impl TurnContext { model_info: &model_info, features: &features, web_search_mode: self.tools_config.web_search_mode, - }); + }) + .with_agent_roles(config.agent_roles.clone()); Self { sub_id: self.sub_id.clone(), @@ -942,7 +943,8 @@ impl Session { model_info: &model_info, features: &per_turn_config.features, web_search_mode: Some(per_turn_config.web_search_mode.value()), - }); + }) + .with_agent_roles(per_turn_config.agent_roles.clone()); let cwd = session_configuration.cwd.clone(); let turn_metadata_state = Arc::new(TurnMetadataState::new( @@ -4025,7 +4027,8 @@ async fn spawn_review_thread( model_info: &review_model_info, features: &review_features, web_search_mode: Some(review_web_search_mode), - }); + }) + .with_agent_roles(config.agent_roles.clone()); let review_prompt = resolved.prompt.clone(); let provider = parent_turn_context.provider.clone(); diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index b83a17592c..e69f57737e 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -298,6 +298,9 @@ pub struct Config { /// Maximum number of agent threads that can be open concurrently. pub agent_max_threads: Option, + /// User-defined role declarations keyed by role name. + pub agent_roles: BTreeMap, + /// Memories subsystem settings. pub memories: MemoriesConfig, @@ -1148,6 +1151,35 @@ pub struct AgentsToml { /// When unset, no limit is enforced. #[schemars(range(min = 1))] pub max_threads: Option, + + /// User-defined role declarations keyed by role name. + /// + /// Example: + /// ```toml + /// [agents.researcher] + /// description = "Research-focused role." + /// config_file = "./agents/researcher.toml" + /// ``` + #[serde(default, flatten)] + pub roles: BTreeMap, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct AgentRoleConfig { + /// Human-facing role documentation used in spawn tool guidance. + pub description: Option, + /// Path to a role-specific config layer. + pub config_file: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct AgentRoleToml { + /// Human-facing role documentation used in spawn tool guidance. + pub description: Option, + + /// Path to a role-specific config layer. + pub config_file: Option, } impl From for Tools { @@ -1590,6 +1622,28 @@ impl Config { "agents.max_threads must be at least 1", )); } + let agent_roles = cfg + .agents + .as_ref() + .map(|agents| { + agents + .roles + .iter() + .map(|(name, role)| { + ( + name.clone(), + AgentRoleConfig { + description: role.description.clone(), + config_file: role + .config_file + .as_ref() + .map(AbsolutePathBuf::to_path_buf), + }, + ) + }) + .collect() + }) + .unwrap_or_default(); let ghost_snapshot = { let mut config = GhostSnapshotConfig::default(); @@ -1787,6 +1841,7 @@ impl Config { .collect(), tool_output_token_limit: cfg.tool_output_token_limit, agent_max_threads, + agent_roles, memories: cfg.memories.unwrap_or_default().into(), codex_home, log_dir, @@ -4115,6 +4170,7 @@ model_verbosity = "high" project_doc_fallback_filenames: Vec::new(), tool_output_token_limit: None, agent_max_threads: DEFAULT_AGENT_MAX_THREADS, + agent_roles: BTreeMap::new(), memories: MemoriesConfig::default(), codex_home: fixture.codex_home(), log_dir: fixture.codex_home().join("log"), @@ -4226,6 +4282,7 @@ model_verbosity = "high" project_doc_fallback_filenames: Vec::new(), tool_output_token_limit: None, agent_max_threads: DEFAULT_AGENT_MAX_THREADS, + agent_roles: BTreeMap::new(), memories: MemoriesConfig::default(), codex_home: fixture.codex_home(), log_dir: fixture.codex_home().join("log"), @@ -4335,6 +4392,7 @@ model_verbosity = "high" project_doc_fallback_filenames: Vec::new(), tool_output_token_limit: None, agent_max_threads: DEFAULT_AGENT_MAX_THREADS, + agent_roles: BTreeMap::new(), memories: MemoriesConfig::default(), codex_home: fixture.codex_home(), log_dir: fixture.codex_home().join("log"), @@ -4430,6 +4488,7 @@ model_verbosity = "high" project_doc_fallback_filenames: Vec::new(), tool_output_token_limit: None, agent_max_threads: DEFAULT_AGENT_MAX_THREADS, + agent_roles: BTreeMap::new(), memories: MemoriesConfig::default(), codex_home: fixture.codex_home(), log_dir: fixture.codex_home().join("log"), diff --git a/codex-rs/core/src/config_loader/mod.rs b/codex-rs/core/src/config_loader/mod.rs index 841ef7004d..f30a627c40 100644 --- a/codex-rs/core/src/config_loader/mod.rs +++ b/codex-rs/core/src/config_loader/mod.rs @@ -688,7 +688,7 @@ async fn project_trust_context( /// /// This ensures that multiple config layers can be merged together correctly /// even if they were loaded from different directories. -fn resolve_relative_paths_in_config_toml( +pub(crate) fn resolve_relative_paths_in_config_toml( value_from_config_toml: TomlValue, base_dir: &Path, ) -> io::Result { diff --git a/codex-rs/core/src/tools/handlers/multi_agents.rs b/codex-rs/core/src/tools/handlers/multi_agents.rs index c5a0f59ae3..f005c3f3cb 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents.rs @@ -1036,6 +1036,7 @@ mod tests { .expect("spawned agent thread should exist") .config_snapshot() .await; + assert_eq!(snapshot.model, "gpt-5.1-codex-mini"); assert_eq!(snapshot.approval_policy, AskForApproval::Never); } diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index a897e5a8f6..6ea7edd00f 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -2,6 +2,7 @@ use crate::client_common::tools::FreeformTool; use crate::client_common::tools::FreeformToolFormat; use crate::client_common::tools::ResponsesApiTool; use crate::client_common::tools::ToolSpec; +use crate::config::AgentRoleConfig; use crate::features::Feature; use crate::features::Features; use crate::mcp_connection_manager::ToolInfo; @@ -36,6 +37,7 @@ pub(crate) struct ToolsConfig { pub shell_type: ConfigShellToolType, pub apply_patch_tool_type: Option, pub web_search_mode: Option, + pub agent_roles: BTreeMap, pub search_tool: bool, pub js_repl_enabled: bool, pub js_repl_tools_only: bool, @@ -94,6 +96,7 @@ impl ToolsConfig { shell_type, apply_patch_tool_type, web_search_mode: *web_search_mode, + agent_roles: BTreeMap::new(), search_tool: include_search_tool, js_repl_enabled: include_js_repl, js_repl_tools_only: include_js_repl_tools_only, @@ -102,6 +105,11 @@ impl ToolsConfig { experimental_supported_tools: model_info.experimental_supported_tools.clone(), } } + + pub fn with_agent_roles(mut self, agent_roles: BTreeMap) -> Self { + self.agent_roles = agent_roles; + self + } } pub(crate) fn filter_tools_for_model(tools: Vec, config: &ToolsConfig) -> Vec { @@ -522,7 +530,7 @@ fn create_collab_input_items_schema() -> JsonSchema { } } -fn create_spawn_agent_tool() -> ToolSpec { +fn create_spawn_agent_tool(config: &ToolsConfig) -> ToolSpec { let properties = BTreeMap::from([ ( "message".to_string(), @@ -537,7 +545,9 @@ fn create_spawn_agent_tool() -> ToolSpec { ( "agent_type".to_string(), JsonSchema::String { - description: Some(crate::agent::role::spawn_tool_spec::build()), + description: Some(crate::agent::role::spawn_tool_spec::build( + &config.agent_roles, + )), }, ), ]); @@ -1564,7 +1574,7 @@ pub(crate) fn build_specs( if config.collab_tools { let multi_agent_handler = Arc::new(MultiAgentHandler); - builder.push_spec(create_spawn_agent_tool()); + builder.push_spec(create_spawn_agent_tool(config)); builder.push_spec(create_send_input_tool()); builder.push_spec(create_resume_agent_tool()); builder.push_spec(create_wait_tool()); From a1b8e34938a26b3910de2abdb2e984aa12624354 Mon Sep 17 00:00:00 2001 From: sayan-oai Date: Tue, 17 Feb 2026 10:20:24 -0800 Subject: [PATCH 51/62] chore: clarify web_search deprecation notices and consolidate tests (#11224) follow up to #10406, clarify default-enablement of web_search. also consolidate pseudo-redundant tests Tests pass --- codex-rs/core/src/features.rs | 5 +- .../core/tests/suite/deprecation_notice.rs | 94 ++++++------------- 2 files changed, 34 insertions(+), 65 deletions(-) diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs index 3cab14d1fa..7ef7485ace 100644 --- a/codex-rs/core/src/features.rs +++ b/codex-rs/core/src/features.rs @@ -356,7 +356,8 @@ fn legacy_usage_notice(alias: &str, feature: Feature) -> (String, Option } _ => alias, }; - let summary = format!("`{label}` is deprecated. Use `web_search` instead."); + let summary = + format!("`{label}` is deprecated because web search is enabled by default."); (summary, Some(web_search_details().to_string())) } _ => { @@ -374,7 +375,7 @@ fn legacy_usage_notice(alias: &str, feature: Feature) -> (String, Option } fn web_search_details() -> &'static str { - "Set `web_search` to `\"live\"`, `\"cached\"`, or `\"disabled\"` at the top level (or under a profile) in config.toml." + "Set `web_search` to `\"live\"`, `\"cached\"`, or `\"disabled\"` at the top level (or under a profile) in config.toml if you want to override it." } /// Keys accepted in `[features]` tables. diff --git a/codex-rs/core/tests/suite/deprecation_notice.rs b/codex-rs/core/tests/suite/deprecation_notice.rs index a8323fcd71..0e4ffd88b7 100644 --- a/codex-rs/core/tests/suite/deprecation_notice.rs +++ b/codex-rs/core/tests/suite/deprecation_notice.rs @@ -113,75 +113,43 @@ async fn emits_deprecation_notice_for_experimental_instructions_file() -> anyhow } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn emits_deprecation_notice_for_web_search_feature_flags() -> anyhow::Result<()> { +async fn emits_deprecation_notice_for_web_search_feature_flag_values() -> anyhow::Result<()> { skip_if_no_network!(Ok(())); - let server = start_mock_server().await; + for enabled in [true, false] { + let server = start_mock_server().await; - let mut builder = test_codex().with_config(|config| { - let mut entries = BTreeMap::new(); - entries.insert("web_search_request".to_string(), true); - config.features.apply_map(&entries); - }); + let mut builder = test_codex().with_config(move |config| { + let mut entries = BTreeMap::new(); + entries.insert("web_search_request".to_string(), enabled); + config.features.apply_map(&entries); + }); - let TestCodex { codex, .. } = builder.build(&server).await?; + let TestCodex { codex, .. } = builder.build(&server).await?; - let notice = wait_for_event_match(&codex, |event| match event { - EventMsg::DeprecationNotice(ev) if ev.summary.contains("[features].web_search_request") => { - Some(ev.clone()) - } - _ => None, - }) - .await; + let notice = wait_for_event_match(&codex, |event| match event { + EventMsg::DeprecationNotice(ev) + if ev.summary.contains("[features].web_search_request") => + { + Some(ev.clone()) + } + _ => None, + }) + .await; - let DeprecationNoticeEvent { summary, details } = notice; - assert_eq!( - summary, - "`[features].web_search_request` is deprecated. Use `web_search` instead.".to_string(), - ); - assert_eq!( - details.as_deref(), - Some( - "Set `web_search` to `\"live\"`, `\"cached\"`, or `\"disabled\"` at the top level (or under a profile) in config.toml." - ), - ); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn emits_deprecation_notice_for_disabled_web_search_feature_flag() -> anyhow::Result<()> { - skip_if_no_network!(Ok(())); - - let server = start_mock_server().await; - - let mut builder = test_codex().with_config(|config| { - let mut entries = BTreeMap::new(); - entries.insert("web_search_request".to_string(), false); - config.features.apply_map(&entries); - }); - - let TestCodex { codex, .. } = builder.build(&server).await?; - - let notice = wait_for_event_match(&codex, |event| match event { - EventMsg::DeprecationNotice(ev) if ev.summary.contains("[features].web_search_request") => { - Some(ev.clone()) - } - _ => None, - }) - .await; - - let DeprecationNoticeEvent { summary, details } = notice; - assert_eq!( - summary, - "`[features].web_search_request` is deprecated. Use `web_search` instead.".to_string(), - ); - assert_eq!( - details.as_deref(), - Some( - "Set `web_search` to `\"live\"`, `\"cached\"`, or `\"disabled\"` at the top level (or under a profile) in config.toml." - ), - ); + let DeprecationNoticeEvent { summary, details } = notice; + assert_eq!( + summary, + "`[features].web_search_request` is deprecated because web search is enabled by default." + .to_string(), + ); + assert_eq!( + details.as_deref(), + Some( + "Set `web_search` to `\"live\"`, `\"cached\"`, or `\"disabled\"` at the top level (or under a profile) in config.toml if you want to override it." + ), + ); + } Ok(()) } From 48018e9eacc13c3999fc89de4e011611283a009e Mon Sep 17 00:00:00 2001 From: Shijie Rao Date: Tue, 17 Feb 2026 11:02:23 -0800 Subject: [PATCH 52/62] Feat: add model reroute notification (#12001) ### Summary Builiding off https://github.com/openai/codex/pull/11964/files/5c75aa7b89a70bc2cc410a6fd238749306ec4c5e#diff-058ae8f109a8b84b4b79bbfa45f522c2233b9d9e139696044ae374d50b6196e0, we have created a `model/rerouted` notification that captures the event so that consumers can render as expected. Keep the `EventMsg::Warning` path in core so that this does not affect TUI rendering. `model/rerouted` is meant to be generic to account for future usage including capacity planning etc. --- .../schema/json/EventMsg.json | 64 +++++++ .../schema/json/ServerNotification.json | 88 ++++++++++ .../codex_app_server_protocol.schemas.json | 90 ++++++++++ .../json/v1/ForkConversationResponse.json | 35 ++++ .../json/v1/ResumeConversationResponse.json | 35 ++++ .../v1/SessionConfiguredNotification.json | 35 ++++ .../json/v2/ModelReroutedNotification.json | 37 ++++ .../schema/typescript/EventMsg.ts | 3 +- .../schema/typescript/ModelRerouteEvent.ts | 6 + .../schema/typescript/ModelRerouteReason.ts | 5 + .../schema/typescript/ServerNotification.ts | 3 +- .../schema/typescript/index.ts | 2 + .../typescript/v2/ModelRerouteReason.ts | 5 + .../v2/ModelReroutedNotification.ts | 6 + .../schema/typescript/v2/index.ts | 2 + .../src/protocol/common.rs | 1 + .../app-server-protocol/src/protocol/v2.rs | 18 ++ codex-rs/app-server/README.md | 3 +- .../app-server/src/bespoke_event_handling.rs | 58 +------ codex-rs/app-server/src/outgoing_message.rs | 30 ++++ .../tests/suite/v2/safety_check_downgrade.rs | 162 ++++++++---------- codex-rs/core/src/codex.rs | 14 +- codex-rs/core/src/rollout/policy.rs | 1 + .../tests/suite/safety_check_downgrade.rs | 26 ++- .../src/event_processor_with_human_output.rs | 1 + codex-rs/mcp-server/src/codex_tool_runner.rs | 3 + codex-rs/protocol/src/protocol.rs | 17 ++ codex-rs/tui/src/chatwidget.rs | 1 + 28 files changed, 605 insertions(+), 146 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/json/v2/ModelReroutedNotification.json create mode 100644 codex-rs/app-server-protocol/schema/typescript/ModelRerouteEvent.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/ModelRerouteReason.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/ModelRerouteReason.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/ModelReroutedNotification.ts diff --git a/codex-rs/app-server-protocol/schema/json/EventMsg.json b/codex-rs/app-server-protocol/schema/json/EventMsg.json index 21d8784c34..5ec61285ca 100644 --- a/codex-rs/app-server-protocol/schema/json/EventMsg.json +++ b/codex-rs/app-server-protocol/schema/json/EventMsg.json @@ -473,6 +473,35 @@ "title": "WarningEventMsg", "type": "object" }, + { + "description": "Model routing changed from the requested model to a different model.", + "properties": { + "from_model": { + "type": "string" + }, + "reason": { + "$ref": "#/definitions/ModelRerouteReason" + }, + "to_model": { + "type": "string" + }, + "type": { + "enum": [ + "model_reroute" + ], + "title": "ModelRerouteEventMsgType", + "type": "string" + } + }, + "required": [ + "from_model", + "reason", + "to_model", + "type" + ], + "title": "ModelRerouteEventMsg", + "type": "object" + }, { "description": "Conversation history was compacted (either automatically or manually).", "properties": { @@ -3318,6 +3347,12 @@ ], "type": "string" }, + "ModelRerouteReason": { + "enum": [ + "high_risk_cyber_activity" + ], + "type": "string" + }, "NetworkAccess": { "description": "Represents whether outbound network access is available to the agent.", "enum": [ @@ -5378,6 +5413,35 @@ "title": "WarningEventMsg", "type": "object" }, + { + "description": "Model routing changed from the requested model to a different model.", + "properties": { + "from_model": { + "type": "string" + }, + "reason": { + "$ref": "#/definitions/ModelRerouteReason" + }, + "to_model": { + "type": "string" + }, + "type": { + "enum": [ + "model_reroute" + ], + "title": "ModelRerouteEventMsgType", + "type": "string" + } + }, + "required": [ + "from_model", + "reason", + "to_model", + "type" + ], + "title": "ModelRerouteEventMsg", + "type": "object" + }, { "description": "Conversation history was compacted (either automatically or manually).", "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 6b12cee9cf..8cec894992 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -1094,6 +1094,35 @@ "title": "WarningEventMsg", "type": "object" }, + { + "description": "Model routing changed from the requested model to a different model.", + "properties": { + "from_model": { + "type": "string" + }, + "reason": { + "$ref": "#/definitions/ModelRerouteReason2" + }, + "to_model": { + "type": "string" + }, + "type": { + "enum": [ + "model_reroute" + ], + "title": "ModelRerouteEventMsgType", + "type": "string" + } + }, + "required": [ + "from_model", + "reason", + "to_model", + "type" + ], + "title": "ModelRerouteEventMsg", + "type": "object" + }, { "description": "Conversation history was compacted (either automatically or manually).", "properties": { @@ -4210,6 +4239,45 @@ ], "type": "string" }, + "ModelRerouteReason": { + "enum": [ + "highRiskCyberActivity" + ], + "type": "string" + }, + "ModelRerouteReason2": { + "enum": [ + "high_risk_cyber_activity" + ], + "type": "string" + }, + "ModelReroutedNotification": { + "properties": { + "fromModel": { + "type": "string" + }, + "reason": { + "$ref": "#/definitions/ModelRerouteReason" + }, + "threadId": { + "type": "string" + }, + "toModel": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "fromModel", + "reason", + "threadId", + "toModel", + "turnId" + ], + "type": "object" + }, "NetworkAccess": { "description": "Represents whether outbound network access is available to the agent.", "enum": [ @@ -8276,6 +8344,26 @@ "title": "Thread/compactedNotification", "type": "object" }, + { + "properties": { + "method": { + "enum": [ + "model/rerouted" + ], + "title": "Model/reroutedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ModelReroutedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Model/reroutedNotification", + "type": "object" + }, { "properties": { "method": { 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 5f352004cb..09375c5ed1 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 @@ -2486,6 +2486,35 @@ "title": "WarningEventMsg", "type": "object" }, + { + "description": "Model routing changed from the requested model to a different model.", + "properties": { + "from_model": { + "type": "string" + }, + "reason": { + "$ref": "#/definitions/v2/ModelRerouteReason" + }, + "to_model": { + "type": "string" + }, + "type": { + "enum": [ + "model_reroute" + ], + "title": "ModelRerouteEventMsgType", + "type": "string" + } + }, + "required": [ + "from_model", + "reason", + "to_model", + "type" + ], + "title": "ModelRerouteEventMsg", + "type": "object" + }, { "description": "Conversation history was compacted (either automatically or manually).", "properties": { @@ -6266,6 +6295,12 @@ ], "type": "string" }, + "ModelRerouteReason": { + "enum": [ + "high_risk_cyber_activity" + ], + "type": "string" + }, "NetworkAccess": { "description": "Represents whether outbound network access is available to the agent.", "enum": [ @@ -8491,6 +8526,26 @@ "title": "Thread/compactedNotification", "type": "object" }, + { + "properties": { + "method": { + "enum": [ + "model/rerouted" + ], + "title": "Model/reroutedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/ModelReroutedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Model/reroutedNotification", + "type": "object" + }, { "properties": { "method": { @@ -12750,6 +12805,41 @@ "title": "ModelListResponse", "type": "object" }, + "ModelRerouteReason": { + "enum": [ + "highRiskCyberActivity" + ], + "type": "string" + }, + "ModelReroutedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "fromModel": { + "type": "string" + }, + "reason": { + "$ref": "#/definitions/v2/ModelRerouteReason" + }, + "threadId": { + "type": "string" + }, + "toModel": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "fromModel", + "reason", + "threadId", + "toModel", + "turnId" + ], + "title": "ModelReroutedNotification", + "type": "object" + }, "NetworkAccess": { "enum": [ "restricted", diff --git a/codex-rs/app-server-protocol/schema/json/v1/ForkConversationResponse.json b/codex-rs/app-server-protocol/schema/json/v1/ForkConversationResponse.json index 225ea3f42a..a73c550f69 100644 --- a/codex-rs/app-server-protocol/schema/json/v1/ForkConversationResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v1/ForkConversationResponse.json @@ -473,6 +473,35 @@ "title": "WarningEventMsg", "type": "object" }, + { + "description": "Model routing changed from the requested model to a different model.", + "properties": { + "from_model": { + "type": "string" + }, + "reason": { + "$ref": "#/definitions/ModelRerouteReason" + }, + "to_model": { + "type": "string" + }, + "type": { + "enum": [ + "model_reroute" + ], + "title": "ModelRerouteEventMsgType", + "type": "string" + } + }, + "required": [ + "from_model", + "reason", + "to_model", + "type" + ], + "title": "ModelRerouteEventMsg", + "type": "object" + }, { "description": "Conversation history was compacted (either automatically or manually).", "properties": { @@ -3318,6 +3347,12 @@ ], "type": "string" }, + "ModelRerouteReason": { + "enum": [ + "high_risk_cyber_activity" + ], + "type": "string" + }, "NetworkAccess": { "description": "Represents whether outbound network access is available to the agent.", "enum": [ diff --git a/codex-rs/app-server-protocol/schema/json/v1/ResumeConversationResponse.json b/codex-rs/app-server-protocol/schema/json/v1/ResumeConversationResponse.json index 2b305281c8..6dc02934d3 100644 --- a/codex-rs/app-server-protocol/schema/json/v1/ResumeConversationResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v1/ResumeConversationResponse.json @@ -473,6 +473,35 @@ "title": "WarningEventMsg", "type": "object" }, + { + "description": "Model routing changed from the requested model to a different model.", + "properties": { + "from_model": { + "type": "string" + }, + "reason": { + "$ref": "#/definitions/ModelRerouteReason" + }, + "to_model": { + "type": "string" + }, + "type": { + "enum": [ + "model_reroute" + ], + "title": "ModelRerouteEventMsgType", + "type": "string" + } + }, + "required": [ + "from_model", + "reason", + "to_model", + "type" + ], + "title": "ModelRerouteEventMsg", + "type": "object" + }, { "description": "Conversation history was compacted (either automatically or manually).", "properties": { @@ -3318,6 +3347,12 @@ ], "type": "string" }, + "ModelRerouteReason": { + "enum": [ + "high_risk_cyber_activity" + ], + "type": "string" + }, "NetworkAccess": { "description": "Represents whether outbound network access is available to the agent.", "enum": [ diff --git a/codex-rs/app-server-protocol/schema/json/v1/SessionConfiguredNotification.json b/codex-rs/app-server-protocol/schema/json/v1/SessionConfiguredNotification.json index d4bbf908d5..69967e6853 100644 --- a/codex-rs/app-server-protocol/schema/json/v1/SessionConfiguredNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v1/SessionConfiguredNotification.json @@ -473,6 +473,35 @@ "title": "WarningEventMsg", "type": "object" }, + { + "description": "Model routing changed from the requested model to a different model.", + "properties": { + "from_model": { + "type": "string" + }, + "reason": { + "$ref": "#/definitions/ModelRerouteReason" + }, + "to_model": { + "type": "string" + }, + "type": { + "enum": [ + "model_reroute" + ], + "title": "ModelRerouteEventMsgType", + "type": "string" + } + }, + "required": [ + "from_model", + "reason", + "to_model", + "type" + ], + "title": "ModelRerouteEventMsg", + "type": "object" + }, { "description": "Conversation history was compacted (either automatically or manually).", "properties": { @@ -3318,6 +3347,12 @@ ], "type": "string" }, + "ModelRerouteReason": { + "enum": [ + "high_risk_cyber_activity" + ], + "type": "string" + }, "NetworkAccess": { "description": "Represents whether outbound network access is available to the agent.", "enum": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ModelReroutedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ModelReroutedNotification.json new file mode 100644 index 0000000000..b9bcc491b0 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/ModelReroutedNotification.json @@ -0,0 +1,37 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "ModelRerouteReason": { + "enum": [ + "highRiskCyberActivity" + ], + "type": "string" + } + }, + "properties": { + "fromModel": { + "type": "string" + }, + "reason": { + "$ref": "#/definitions/ModelRerouteReason" + }, + "threadId": { + "type": "string" + }, + "toModel": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "fromModel", + "reason", + "threadId", + "toModel", + "turnId" + ], + "title": "ModelReroutedNotification", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/typescript/EventMsg.ts b/codex-rs/app-server-protocol/schema/typescript/EventMsg.ts index 2c0514870f..5ed3ca2529 100644 --- a/codex-rs/app-server-protocol/schema/typescript/EventMsg.ts +++ b/codex-rs/app-server-protocol/schema/typescript/EventMsg.ts @@ -42,6 +42,7 @@ import type { McpStartupCompleteEvent } from "./McpStartupCompleteEvent"; import type { McpStartupUpdateEvent } from "./McpStartupUpdateEvent"; import type { McpToolCallBeginEvent } from "./McpToolCallBeginEvent"; import type { McpToolCallEndEvent } from "./McpToolCallEndEvent"; +import type { ModelRerouteEvent } from "./ModelRerouteEvent"; import type { PatchApplyBeginEvent } from "./PatchApplyBeginEvent"; import type { PatchApplyEndEvent } from "./PatchApplyEndEvent"; import type { PlanDeltaEvent } from "./PlanDeltaEvent"; @@ -74,4 +75,4 @@ import type { WebSearchEndEvent } from "./WebSearchEndEvent"; * Response event from the agent * NOTE: Make sure none of these values have optional types, as it will mess up the extension code-gen. */ -export type EventMsg = { "type": "error" } & ErrorEvent | { "type": "warning" } & WarningEvent | { "type": "context_compacted" } & ContextCompactedEvent | { "type": "thread_rolled_back" } & ThreadRolledBackEvent | { "type": "task_started" } & TurnStartedEvent | { "type": "task_complete" } & TurnCompleteEvent | { "type": "token_count" } & TokenCountEvent | { "type": "agent_message" } & AgentMessageEvent | { "type": "user_message" } & UserMessageEvent | { "type": "agent_message_delta" } & AgentMessageDeltaEvent | { "type": "agent_reasoning" } & AgentReasoningEvent | { "type": "agent_reasoning_delta" } & AgentReasoningDeltaEvent | { "type": "agent_reasoning_raw_content" } & AgentReasoningRawContentEvent | { "type": "agent_reasoning_raw_content_delta" } & AgentReasoningRawContentDeltaEvent | { "type": "agent_reasoning_section_break" } & AgentReasoningSectionBreakEvent | { "type": "session_configured" } & SessionConfiguredEvent | { "type": "thread_name_updated" } & ThreadNameUpdatedEvent | { "type": "mcp_startup_update" } & McpStartupUpdateEvent | { "type": "mcp_startup_complete" } & McpStartupCompleteEvent | { "type": "mcp_tool_call_begin" } & McpToolCallBeginEvent | { "type": "mcp_tool_call_end" } & McpToolCallEndEvent | { "type": "web_search_begin" } & WebSearchBeginEvent | { "type": "web_search_end" } & WebSearchEndEvent | { "type": "exec_command_begin" } & ExecCommandBeginEvent | { "type": "exec_command_output_delta" } & ExecCommandOutputDeltaEvent | { "type": "terminal_interaction" } & TerminalInteractionEvent | { "type": "exec_command_end" } & ExecCommandEndEvent | { "type": "view_image_tool_call" } & ViewImageToolCallEvent | { "type": "exec_approval_request" } & ExecApprovalRequestEvent | { "type": "request_user_input" } & RequestUserInputEvent | { "type": "dynamic_tool_call_request" } & DynamicToolCallRequest | { "type": "elicitation_request" } & ElicitationRequestEvent | { "type": "apply_patch_approval_request" } & ApplyPatchApprovalRequestEvent | { "type": "deprecation_notice" } & DeprecationNoticeEvent | { "type": "background_event" } & BackgroundEventEvent | { "type": "undo_started" } & UndoStartedEvent | { "type": "undo_completed" } & UndoCompletedEvent | { "type": "stream_error" } & StreamErrorEvent | { "type": "patch_apply_begin" } & PatchApplyBeginEvent | { "type": "patch_apply_end" } & PatchApplyEndEvent | { "type": "turn_diff" } & TurnDiffEvent | { "type": "get_history_entry_response" } & GetHistoryEntryResponseEvent | { "type": "mcp_list_tools_response" } & McpListToolsResponseEvent | { "type": "list_custom_prompts_response" } & ListCustomPromptsResponseEvent | { "type": "list_skills_response" } & ListSkillsResponseEvent | { "type": "list_remote_skills_response" } & ListRemoteSkillsResponseEvent | { "type": "remote_skill_downloaded" } & RemoteSkillDownloadedEvent | { "type": "skills_update_available" } | { "type": "plan_update" } & UpdatePlanArgs | { "type": "turn_aborted" } & TurnAbortedEvent | { "type": "shutdown_complete" } | { "type": "entered_review_mode" } & ReviewRequest | { "type": "exited_review_mode" } & ExitedReviewModeEvent | { "type": "raw_response_item" } & RawResponseItemEvent | { "type": "item_started" } & ItemStartedEvent | { "type": "item_completed" } & ItemCompletedEvent | { "type": "agent_message_content_delta" } & AgentMessageContentDeltaEvent | { "type": "plan_delta" } & PlanDeltaEvent | { "type": "reasoning_content_delta" } & ReasoningContentDeltaEvent | { "type": "reasoning_raw_content_delta" } & ReasoningRawContentDeltaEvent | { "type": "collab_agent_spawn_begin" } & CollabAgentSpawnBeginEvent | { "type": "collab_agent_spawn_end" } & CollabAgentSpawnEndEvent | { "type": "collab_agent_interaction_begin" } & CollabAgentInteractionBeginEvent | { "type": "collab_agent_interaction_end" } & CollabAgentInteractionEndEvent | { "type": "collab_waiting_begin" } & CollabWaitingBeginEvent | { "type": "collab_waiting_end" } & CollabWaitingEndEvent | { "type": "collab_close_begin" } & CollabCloseBeginEvent | { "type": "collab_close_end" } & CollabCloseEndEvent | { "type": "collab_resume_begin" } & CollabResumeBeginEvent | { "type": "collab_resume_end" } & CollabResumeEndEvent; +export type EventMsg = { "type": "error" } & ErrorEvent | { "type": "warning" } & WarningEvent | { "type": "model_reroute" } & ModelRerouteEvent | { "type": "context_compacted" } & ContextCompactedEvent | { "type": "thread_rolled_back" } & ThreadRolledBackEvent | { "type": "task_started" } & TurnStartedEvent | { "type": "task_complete" } & TurnCompleteEvent | { "type": "token_count" } & TokenCountEvent | { "type": "agent_message" } & AgentMessageEvent | { "type": "user_message" } & UserMessageEvent | { "type": "agent_message_delta" } & AgentMessageDeltaEvent | { "type": "agent_reasoning" } & AgentReasoningEvent | { "type": "agent_reasoning_delta" } & AgentReasoningDeltaEvent | { "type": "agent_reasoning_raw_content" } & AgentReasoningRawContentEvent | { "type": "agent_reasoning_raw_content_delta" } & AgentReasoningRawContentDeltaEvent | { "type": "agent_reasoning_section_break" } & AgentReasoningSectionBreakEvent | { "type": "session_configured" } & SessionConfiguredEvent | { "type": "thread_name_updated" } & ThreadNameUpdatedEvent | { "type": "mcp_startup_update" } & McpStartupUpdateEvent | { "type": "mcp_startup_complete" } & McpStartupCompleteEvent | { "type": "mcp_tool_call_begin" } & McpToolCallBeginEvent | { "type": "mcp_tool_call_end" } & McpToolCallEndEvent | { "type": "web_search_begin" } & WebSearchBeginEvent | { "type": "web_search_end" } & WebSearchEndEvent | { "type": "exec_command_begin" } & ExecCommandBeginEvent | { "type": "exec_command_output_delta" } & ExecCommandOutputDeltaEvent | { "type": "terminal_interaction" } & TerminalInteractionEvent | { "type": "exec_command_end" } & ExecCommandEndEvent | { "type": "view_image_tool_call" } & ViewImageToolCallEvent | { "type": "exec_approval_request" } & ExecApprovalRequestEvent | { "type": "request_user_input" } & RequestUserInputEvent | { "type": "dynamic_tool_call_request" } & DynamicToolCallRequest | { "type": "elicitation_request" } & ElicitationRequestEvent | { "type": "apply_patch_approval_request" } & ApplyPatchApprovalRequestEvent | { "type": "deprecation_notice" } & DeprecationNoticeEvent | { "type": "background_event" } & BackgroundEventEvent | { "type": "undo_started" } & UndoStartedEvent | { "type": "undo_completed" } & UndoCompletedEvent | { "type": "stream_error" } & StreamErrorEvent | { "type": "patch_apply_begin" } & PatchApplyBeginEvent | { "type": "patch_apply_end" } & PatchApplyEndEvent | { "type": "turn_diff" } & TurnDiffEvent | { "type": "get_history_entry_response" } & GetHistoryEntryResponseEvent | { "type": "mcp_list_tools_response" } & McpListToolsResponseEvent | { "type": "list_custom_prompts_response" } & ListCustomPromptsResponseEvent | { "type": "list_skills_response" } & ListSkillsResponseEvent | { "type": "list_remote_skills_response" } & ListRemoteSkillsResponseEvent | { "type": "remote_skill_downloaded" } & RemoteSkillDownloadedEvent | { "type": "skills_update_available" } | { "type": "plan_update" } & UpdatePlanArgs | { "type": "turn_aborted" } & TurnAbortedEvent | { "type": "shutdown_complete" } | { "type": "entered_review_mode" } & ReviewRequest | { "type": "exited_review_mode" } & ExitedReviewModeEvent | { "type": "raw_response_item" } & RawResponseItemEvent | { "type": "item_started" } & ItemStartedEvent | { "type": "item_completed" } & ItemCompletedEvent | { "type": "agent_message_content_delta" } & AgentMessageContentDeltaEvent | { "type": "plan_delta" } & PlanDeltaEvent | { "type": "reasoning_content_delta" } & ReasoningContentDeltaEvent | { "type": "reasoning_raw_content_delta" } & ReasoningRawContentDeltaEvent | { "type": "collab_agent_spawn_begin" } & CollabAgentSpawnBeginEvent | { "type": "collab_agent_spawn_end" } & CollabAgentSpawnEndEvent | { "type": "collab_agent_interaction_begin" } & CollabAgentInteractionBeginEvent | { "type": "collab_agent_interaction_end" } & CollabAgentInteractionEndEvent | { "type": "collab_waiting_begin" } & CollabWaitingBeginEvent | { "type": "collab_waiting_end" } & CollabWaitingEndEvent | { "type": "collab_close_begin" } & CollabCloseBeginEvent | { "type": "collab_close_end" } & CollabCloseEndEvent | { "type": "collab_resume_begin" } & CollabResumeBeginEvent | { "type": "collab_resume_end" } & CollabResumeEndEvent; diff --git a/codex-rs/app-server-protocol/schema/typescript/ModelRerouteEvent.ts b/codex-rs/app-server-protocol/schema/typescript/ModelRerouteEvent.ts new file mode 100644 index 0000000000..23a4e1efb6 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/ModelRerouteEvent.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 { ModelRerouteReason } from "./ModelRerouteReason"; + +export type ModelRerouteEvent = { from_model: string, to_model: string, reason: ModelRerouteReason, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/ModelRerouteReason.ts b/codex-rs/app-server-protocol/schema/typescript/ModelRerouteReason.ts new file mode 100644 index 0000000000..f5e1abf1e3 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/ModelRerouteReason.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 ModelRerouteReason = "high_risk_cyber_activity"; diff --git a/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts b/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts index ffc39598b6..171da74257 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts @@ -21,6 +21,7 @@ import type { ItemCompletedNotification } from "./v2/ItemCompletedNotification"; import type { ItemStartedNotification } from "./v2/ItemStartedNotification"; import type { McpServerOauthLoginCompletedNotification } from "./v2/McpServerOauthLoginCompletedNotification"; import type { McpToolCallProgressNotification } from "./v2/McpToolCallProgressNotification"; +import type { ModelReroutedNotification } from "./v2/ModelReroutedNotification"; import type { PlanDeltaNotification } from "./v2/PlanDeltaNotification"; import type { RawResponseItemCompletedNotification } from "./v2/RawResponseItemCompletedNotification"; import type { ReasoningSummaryPartAddedNotification } from "./v2/ReasoningSummaryPartAddedNotification"; @@ -39,4 +40,4 @@ import type { WindowsWorldWritableWarningNotification } from "./v2/WindowsWorldW /** * Notification sent from the server to the client. */ -export type ServerNotification = { "method": "error", "params": ErrorNotification } | { "method": "thread/started", "params": ThreadStartedNotification } | { "method": "thread/name/updated", "params": ThreadNameUpdatedNotification } | { "method": "thread/tokenUsage/updated", "params": ThreadTokenUsageUpdatedNotification } | { "method": "turn/started", "params": TurnStartedNotification } | { "method": "turn/completed", "params": TurnCompletedNotification } | { "method": "turn/diff/updated", "params": TurnDiffUpdatedNotification } | { "method": "turn/plan/updated", "params": TurnPlanUpdatedNotification } | { "method": "item/started", "params": ItemStartedNotification } | { "method": "item/completed", "params": ItemCompletedNotification } | { "method": "rawResponseItem/completed", "params": RawResponseItemCompletedNotification } | { "method": "item/agentMessage/delta", "params": AgentMessageDeltaNotification } | { "method": "item/plan/delta", "params": PlanDeltaNotification } | { "method": "item/commandExecution/outputDelta", "params": CommandExecutionOutputDeltaNotification } | { "method": "item/commandExecution/terminalInteraction", "params": TerminalInteractionNotification } | { "method": "item/fileChange/outputDelta", "params": FileChangeOutputDeltaNotification } | { "method": "item/mcpToolCall/progress", "params": McpToolCallProgressNotification } | { "method": "mcpServer/oauthLogin/completed", "params": McpServerOauthLoginCompletedNotification } | { "method": "account/updated", "params": AccountUpdatedNotification } | { "method": "account/rateLimits/updated", "params": AccountRateLimitsUpdatedNotification } | { "method": "app/list/updated", "params": AppListUpdatedNotification } | { "method": "item/reasoning/summaryTextDelta", "params": ReasoningSummaryTextDeltaNotification } | { "method": "item/reasoning/summaryPartAdded", "params": ReasoningSummaryPartAddedNotification } | { "method": "item/reasoning/textDelta", "params": ReasoningTextDeltaNotification } | { "method": "thread/compacted", "params": ContextCompactedNotification } | { "method": "deprecationNotice", "params": DeprecationNoticeNotification } | { "method": "configWarning", "params": ConfigWarningNotification } | { "method": "fuzzyFileSearch/sessionUpdated", "params": FuzzyFileSearchSessionUpdatedNotification } | { "method": "fuzzyFileSearch/sessionCompleted", "params": FuzzyFileSearchSessionCompletedNotification } | { "method": "windows/worldWritableWarning", "params": WindowsWorldWritableWarningNotification } | { "method": "account/login/completed", "params": AccountLoginCompletedNotification } | { "method": "authStatusChange", "params": AuthStatusChangeNotification } | { "method": "loginChatGptComplete", "params": LoginChatGptCompleteNotification } | { "method": "sessionConfigured", "params": SessionConfiguredNotification }; +export type ServerNotification = { "method": "error", "params": ErrorNotification } | { "method": "thread/started", "params": ThreadStartedNotification } | { "method": "thread/name/updated", "params": ThreadNameUpdatedNotification } | { "method": "thread/tokenUsage/updated", "params": ThreadTokenUsageUpdatedNotification } | { "method": "turn/started", "params": TurnStartedNotification } | { "method": "turn/completed", "params": TurnCompletedNotification } | { "method": "turn/diff/updated", "params": TurnDiffUpdatedNotification } | { "method": "turn/plan/updated", "params": TurnPlanUpdatedNotification } | { "method": "item/started", "params": ItemStartedNotification } | { "method": "item/completed", "params": ItemCompletedNotification } | { "method": "rawResponseItem/completed", "params": RawResponseItemCompletedNotification } | { "method": "item/agentMessage/delta", "params": AgentMessageDeltaNotification } | { "method": "item/plan/delta", "params": PlanDeltaNotification } | { "method": "item/commandExecution/outputDelta", "params": CommandExecutionOutputDeltaNotification } | { "method": "item/commandExecution/terminalInteraction", "params": TerminalInteractionNotification } | { "method": "item/fileChange/outputDelta", "params": FileChangeOutputDeltaNotification } | { "method": "item/mcpToolCall/progress", "params": McpToolCallProgressNotification } | { "method": "mcpServer/oauthLogin/completed", "params": McpServerOauthLoginCompletedNotification } | { "method": "account/updated", "params": AccountUpdatedNotification } | { "method": "account/rateLimits/updated", "params": AccountRateLimitsUpdatedNotification } | { "method": "app/list/updated", "params": AppListUpdatedNotification } | { "method": "item/reasoning/summaryTextDelta", "params": ReasoningSummaryTextDeltaNotification } | { "method": "item/reasoning/summaryPartAdded", "params": ReasoningSummaryPartAddedNotification } | { "method": "item/reasoning/textDelta", "params": ReasoningTextDeltaNotification } | { "method": "thread/compacted", "params": ContextCompactedNotification } | { "method": "model/rerouted", "params": ModelReroutedNotification } | { "method": "deprecationNotice", "params": DeprecationNoticeNotification } | { "method": "configWarning", "params": ConfigWarningNotification } | { "method": "fuzzyFileSearch/sessionUpdated", "params": FuzzyFileSearchSessionUpdatedNotification } | { "method": "fuzzyFileSearch/sessionCompleted", "params": FuzzyFileSearchSessionCompletedNotification } | { "method": "windows/worldWritableWarning", "params": WindowsWorldWritableWarningNotification } | { "method": "account/login/completed", "params": AccountLoginCompletedNotification } | { "method": "authStatusChange", "params": AuthStatusChangeNotification } | { "method": "loginChatGptComplete", "params": LoginChatGptCompleteNotification } | { "method": "sessionConfigured", "params": SessionConfiguredNotification }; diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index 3c0826712a..2a626d3638 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -125,6 +125,8 @@ export type { McpToolCallBeginEvent } from "./McpToolCallBeginEvent"; export type { McpToolCallEndEvent } from "./McpToolCallEndEvent"; export type { MessagePhase } from "./MessagePhase"; export type { ModeKind } from "./ModeKind"; +export type { ModelRerouteEvent } from "./ModelRerouteEvent"; +export type { ModelRerouteReason } from "./ModelRerouteReason"; export type { NetworkAccess } from "./NetworkAccess"; export type { NetworkApprovalContext } from "./NetworkApprovalContext"; export type { NetworkApprovalProtocol } from "./NetworkApprovalProtocol"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ModelRerouteReason.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ModelRerouteReason.ts new file mode 100644 index 0000000000..e780e7f95d --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ModelRerouteReason.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 ModelRerouteReason = "highRiskCyberActivity"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ModelReroutedNotification.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ModelReroutedNotification.ts new file mode 100644 index 0000000000..9b6b2e524a --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ModelReroutedNotification.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 { ModelRerouteReason } from "./ModelRerouteReason"; + +export type ModelReroutedNotification = { threadId: string, turnId: string, fromModel: string, toModel: string, reason: ModelRerouteReason, }; 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 500b1d930f..423ee9dd1b 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -91,6 +91,8 @@ export type { MergeStrategy } from "./MergeStrategy"; export type { Model } from "./Model"; export type { ModelListParams } from "./ModelListParams"; export type { ModelListResponse } from "./ModelListResponse"; +export type { ModelRerouteReason } from "./ModelRerouteReason"; +export type { ModelReroutedNotification } from "./ModelReroutedNotification"; export type { NetworkAccess } from "./NetworkAccess"; export type { NetworkRequirements } from "./NetworkRequirements"; export type { OverriddenMetadata } from "./OverriddenMetadata"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 6c58d3b90c..e6b285d9d6 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -795,6 +795,7 @@ server_notification_definitions! { ReasoningTextDelta => "item/reasoning/textDelta" (v2::ReasoningTextDeltaNotification), /// Deprecated: Use `ContextCompaction` item type instead. ContextCompacted => "thread/compacted" (v2::ContextCompactedNotification), + ModelRerouted => "model/rerouted" (v2::ModelReroutedNotification), DeprecationNotice => "deprecationNotice" (v2::DeprecationNoticeNotification), ConfigWarning => "configWarning" (v2::ConfigWarningNotification), FuzzyFileSearchSessionUpdated => "fuzzyFileSearch/sessionUpdated" (FuzzyFileSearchSessionUpdatedNotification), diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 9a184d1754..fc4a7742f5 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -30,6 +30,7 @@ use codex_protocol::protocol::AskForApproval as CoreAskForApproval; use codex_protocol::protocol::CodexErrorInfo as CoreCodexErrorInfo; use codex_protocol::protocol::CreditsSnapshot as CoreCreditsSnapshot; use codex_protocol::protocol::ExecCommandStatus as CoreExecCommandStatus; +use codex_protocol::protocol::ModelRerouteReason as CoreModelRerouteReason; use codex_protocol::protocol::NetworkAccess as CoreNetworkAccess; use codex_protocol::protocol::PatchApplyStatus as CorePatchApplyStatus; use codex_protocol::protocol::RateLimitSnapshot as CoreRateLimitSnapshot; @@ -229,6 +230,12 @@ v2_enum_from_core!( } ); +v2_enum_from_core!( + pub enum ModelRerouteReason from CoreModelRerouteReason { + HighRiskCyberActivity + } +); + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[serde(tag = "type", rename_all = "camelCase")] #[ts(tag = "type")] @@ -3305,6 +3312,17 @@ pub struct AccountLoginCompletedNotification { pub error: Option, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ModelReroutedNotification { + pub thread_id: String, + pub turn_id: String, + pub from_model: String, + pub to_model: String, + pub reason: ModelRerouteReason, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 27bb78e49e..669e8a1579 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -550,6 +550,7 @@ The app-server streams JSON-RPC notifications while a turn is running. Each turn - `turn/completed` — `{ turn }` where `turn.status` is `completed`, `interrupted`, or `failed`; failures carry `{ error: { message, codexErrorInfo?, additionalDetails? } }`. - `turn/diff/updated` — `{ threadId, turnId, diff }` represents the up-to-date snapshot of the turn-level unified diff, emitted after every FileChange item. `diff` is the latest aggregated unified diff across every file change in the turn. UIs can render this to show the full "what changed" view without stitching individual `fileChange` items. - `turn/plan/updated` — `{ turnId, explanation?, plan }` whenever the agent shares or changes its plan; each `plan` entry is `{ step, status }` with `status` in `pending`, `inProgress`, or `completed`. +- `model/rerouted` — `{ threadId, turnId, fromModel, toModel, reason }` when the backend reroutes a request to a different model (for example, due to high-risk cyber safety checks). Today both notifications carry an empty `items` array even when item events were streamed; rely on `item/*` notifications for the canonical item list until this is fixed. @@ -557,7 +558,7 @@ Today both notifications carry an empty `items` array even when item events were `ThreadItem` is the tagged union carried in turn responses and `item/*` notifications. Currently we support events for the following items: -- `userMessage` — `{id, content}` where `content` is a list of user inputs (`text`, `image`, or `localImage`). Cyber model-routing warnings are surfaced as synthetic `userMessage` items with `text` prefixed by `Warning:`. +- `userMessage` — `{id, content}` where `content` is a list of user inputs (`text`, `image`, or `localImage`). - `agentMessage` — `{id, text}` containing the accumulated agent reply. - `plan` — `{id, text}` emitted for plan-mode turns; plan text can stream via `item/plan/delta` (experimental). - `reasoning` — `{id, summary, content}` where `summary` holds streamed reasoning summaries (applicable for most OpenAI models) and `content` holds raw reasoning blocks (applicable for e.g. open source models). diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 87ddf7e18a..3acdefc731 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -41,6 +41,7 @@ use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::McpToolCallError; use codex_app_server_protocol::McpToolCallResult; use codex_app_server_protocol::McpToolCallStatus; +use codex_app_server_protocol::ModelReroutedNotification; use codex_app_server_protocol::PatchApplyStatus; use codex_app_server_protocol::PatchChangeKind as V2PatchChangeKind; use codex_app_server_protocol::PlanDeltaNotification; @@ -68,7 +69,6 @@ use codex_app_server_protocol::TurnInterruptResponse; use codex_app_server_protocol::TurnPlanStep; use codex_app_server_protocol::TurnPlanUpdatedNotification; use codex_app_server_protocol::TurnStatus; -use codex_app_server_protocol::UserInput as V2UserInput; use codex_app_server_protocol::build_turns_from_rollout_items; use codex_core::CodexThread; use codex_core::parse_command::shlex_join; @@ -96,8 +96,6 @@ use codex_protocol::request_user_input::RequestUserInputAnswer as CoreRequestUse use codex_protocol::request_user_input::RequestUserInputResponse as CoreRequestUserInputResponse; use std::collections::HashMap; use std::convert::TryFrom; -use std::hash::Hash; -use std::hash::Hasher; use std::path::PathBuf; use std::sync::Arc; use tokio::sync::Mutex; @@ -125,32 +123,18 @@ pub(crate) async fn apply_bespoke_event_handling( EventMsg::TurnComplete(_ev) => { handle_turn_complete(conversation_id, event_turn_id, &outgoing, &thread_state).await; } - EventMsg::Warning(warning_event) => { - if matches!(api_version, ApiVersion::V2) - && is_safety_check_downgrade_warning(&warning_event.message) - { - let item = ThreadItem::UserMessage { - id: warning_item_id(&event_turn_id, &warning_event.message), - content: vec![V2UserInput::Text { - text: format!("Warning: {}", warning_event.message), - text_elements: Vec::new(), - }], - }; - let started = ItemStartedNotification { + EventMsg::Warning(_warning_event) => {} + EventMsg::ModelReroute(event) => { + if let ApiVersion::V2 = api_version { + let notification = ModelReroutedNotification { thread_id: conversation_id.to_string(), turn_id: event_turn_id.clone(), - item: item.clone(), + from_model: event.from_model, + to_model: event.to_model, + reason: event.reason.into(), }; outgoing - .send_server_notification(ServerNotification::ItemStarted(started)) - .await; - let completed = ItemCompletedNotification { - thread_id: conversation_id.to_string(), - turn_id: event_turn_id.clone(), - item, - }; - outgoing - .send_server_notification(ServerNotification::ItemCompleted(completed)) + .send_server_notification(ServerNotification::ModelRerouted(notification)) .await; } } @@ -1318,18 +1302,6 @@ async fn complete_command_execution_item( .await; } -fn is_safety_check_downgrade_warning(message: &str) -> bool { - message.contains("Your account was flagged for potentially high-risk cyber activity") - && message.contains("apply for trusted access: https://chatgpt.com/cyber") -} - -fn warning_item_id(turn_id: &str, message: &str) -> String { - let mut hasher = std::collections::hash_map::DefaultHasher::new(); - message.hash(&mut hasher); - let digest = hasher.finish(); - format!("{turn_id}-warning-{digest:x}") -} - async fn maybe_emit_raw_response_item_completed( api_version: ApiVersion, conversation_id: ThreadId, @@ -2060,18 +2032,6 @@ mod tests { assert_eq!(item, expected); } - #[test] - fn safety_check_downgrade_warning_detection_matches_expected_message() { - let warning = "Your account was flagged for potentially high-risk cyber activity and this request was routed to gpt-5.2 as a fallback. To regain access to gpt-5.3-codex, apply for trusted access: https://chatgpt.com/cyber\nLearn more: https://developers.openai.com/codex/concepts/cyber-safety"; - assert!(is_safety_check_downgrade_warning(warning)); - } - - #[test] - fn safety_check_downgrade_warning_detection_ignores_other_warnings() { - let warning = "Model metadata for `mock-model` not found. Defaulting to fallback metadata; this can degrade performance and cause issues."; - assert!(!is_safety_check_downgrade_warning(warning)); - } - #[tokio::test] async fn test_handle_error_records_message() -> Result<()> { let conversation_id = ThreadId::new(); diff --git a/codex-rs/app-server/src/outgoing_message.rs b/codex-rs/app-server/src/outgoing_message.rs index 2d8b748c90..810182db0a 100644 --- a/codex-rs/app-server/src/outgoing_message.rs +++ b/codex-rs/app-server/src/outgoing_message.rs @@ -399,6 +399,8 @@ mod tests { use codex_app_server_protocol::AuthMode; use codex_app_server_protocol::ConfigWarningNotification; use codex_app_server_protocol::LoginChatGptCompleteNotification; + use codex_app_server_protocol::ModelRerouteReason; + use codex_app_server_protocol::ModelReroutedNotification; use codex_app_server_protocol::RateLimitSnapshot; use codex_app_server_protocol::RateLimitWindow; use codex_protocol::ThreadId; @@ -546,6 +548,34 @@ mod tests { ); } + #[test] + fn verify_model_rerouted_notification_serialization() { + let notification = ServerNotification::ModelRerouted(ModelReroutedNotification { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + from_model: "gpt-5.3-codex".to_string(), + to_model: "gpt-5.2".to_string(), + reason: ModelRerouteReason::HighRiskCyberActivity, + }); + + let jsonrpc_notification = OutgoingMessage::AppServerNotification(notification); + assert_eq!( + json!({ + "method": "model/rerouted", + "params": { + "threadId": "thread-1", + "turnId": "turn-1", + "fromModel": "gpt-5.3-codex", + "toModel": "gpt-5.2", + "reason": "highRiskCyberActivity", + }, + }), + serde_json::to_value(jsonrpc_notification) + .expect("ensure the notification serializes correctly"), + "ensure the notification serializes correctly" + ); + } + #[tokio::test] async fn send_response_routes_to_target_connection() { let (tx, mut rx) = mpsc::channel::(4); diff --git a/codex-rs/app-server/tests/suite/v2/safety_check_downgrade.rs b/codex-rs/app-server/tests/suite/v2/safety_check_downgrade.rs index 20a7c60236..53acd492b6 100644 --- a/codex-rs/app-server/tests/suite/v2/safety_check_downgrade.rs +++ b/codex-rs/app-server/tests/suite/v2/safety_check_downgrade.rs @@ -3,8 +3,10 @@ use app_test_support::McpProcess; use app_test_support::to_response; use codex_app_server_protocol::ItemCompletedNotification; use codex_app_server_protocol::ItemStartedNotification; -use codex_app_server_protocol::JSONRPCNotification; +use codex_app_server_protocol::JSONRPCMessage; use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::ModelRerouteReason; +use codex_app_server_protocol::ModelReroutedNotification; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ThreadItem; use codex_app_server_protocol::ThreadStartParams; @@ -23,7 +25,7 @@ const REQUESTED_MODEL: &str = "gpt-5.1-codex-max"; const SERVER_MODEL: &str = "gpt-5.2-codex"; #[tokio::test] -async fn openai_model_header_mismatch_emits_warning_item_v2() -> Result<()> { +async fn openai_model_header_mismatch_emits_model_rerouted_notification_v2() -> Result<()> { skip_if_no_network!(Ok(())); let server = responses::start_mock_server().await; @@ -64,64 +66,30 @@ async fn openai_model_header_mismatch_emits_warning_item_v2() -> Result<()> { ..Default::default() }) .await?; - let _turn_resp: JSONRPCResponse = timeout( + let turn_resp: JSONRPCResponse = timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), ) .await??; - let _turn_start: TurnStartResponse = to_response(_turn_resp)?; + let turn_start: TurnStartResponse = to_response(turn_resp)?; - let warning_started = timeout(DEFAULT_READ_TIMEOUT, async { - loop { - let notification: JSONRPCNotification = mcp - .read_stream_until_notification_message("item/started") - .await?; - let params = notification.params.expect("item/started params"); - let started: ItemStartedNotification = - serde_json::from_value(params).expect("deserialize item/started"); - if warning_text_from_item(&started.item).is_some_and(is_cyber_model_warning_text) { - return Ok::(started); - } - } - }) - .await??; - - let warning_text = - warning_text_from_item(&warning_started.item).expect("expected warning user message item"); - assert!(warning_text.contains("Warning:")); - assert!(warning_text.contains("gpt-5.2 as a fallback")); - assert!(warning_text.contains("regain access to gpt-5.3-codex")); - - let warning_completed = timeout(DEFAULT_READ_TIMEOUT, async { - loop { - let notification: JSONRPCNotification = mcp - .read_stream_until_notification_message("item/completed") - .await?; - let params = notification.params.expect("item/completed params"); - let completed: ItemCompletedNotification = - serde_json::from_value(params).expect("deserialize item/completed"); - if warning_text_from_item(&completed.item).is_some_and(is_cyber_model_warning_text) { - return Ok::(completed); - } - } - }) - .await??; + let rerouted = collect_turn_notifications_and_validate_no_warning_item(&mut mcp).await?; assert_eq!( - warning_text_from_item(&warning_completed.item), - warning_text_from_item(&warning_started.item) + rerouted, + ModelReroutedNotification { + thread_id: thread.id, + turn_id: turn_start.turn.id, + from_model: REQUESTED_MODEL.to_string(), + to_model: SERVER_MODEL.to_string(), + reason: ModelRerouteReason::HighRiskCyberActivity, + } ); - timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_notification_message("turn/completed"), - ) - .await??; - Ok(()) } #[tokio::test] -async fn response_model_field_mismatch_emits_warning_item_v2_when_header_matches_requested() +async fn response_model_field_mismatch_emits_model_rerouted_notification_v2_when_header_matches_requested() -> Result<()> { skip_if_no_network!(Ok(())); @@ -174,54 +142,65 @@ async fn response_model_field_mismatch_emits_warning_item_v2_when_header_matches mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), ) .await??; - let _turn_start: TurnStartResponse = to_response(turn_resp)?; + let turn_start: TurnStartResponse = to_response(turn_resp)?; - let warning_started = timeout(DEFAULT_READ_TIMEOUT, async { - loop { - let notification: JSONRPCNotification = mcp - .read_stream_until_notification_message("item/started") - .await?; - let params = notification.params.expect("item/started params"); - let started: ItemStartedNotification = - serde_json::from_value(params).expect("deserialize item/started"); - if warning_text_from_item(&started.item).is_some_and(is_cyber_model_warning_text) { - return Ok::(started); - } - } - }) - .await??; - let warning_text = - warning_text_from_item(&warning_started.item).expect("expected warning user message item"); - assert!(warning_text.contains("gpt-5.2 as a fallback")); - - let warning_completed = timeout(DEFAULT_READ_TIMEOUT, async { - loop { - let notification: JSONRPCNotification = mcp - .read_stream_until_notification_message("item/completed") - .await?; - let params = notification.params.expect("item/completed params"); - let completed: ItemCompletedNotification = - serde_json::from_value(params).expect("deserialize item/completed"); - if warning_text_from_item(&completed.item).is_some_and(is_cyber_model_warning_text) { - return Ok::(completed); - } - } - }) - .await??; + let rerouted = collect_turn_notifications_and_validate_no_warning_item(&mut mcp).await?; assert_eq!( - warning_text_from_item(&warning_completed.item), - warning_text_from_item(&warning_started.item) + rerouted, + ModelReroutedNotification { + thread_id: thread.id, + turn_id: turn_start.turn.id, + from_model: REQUESTED_MODEL.to_string(), + to_model: SERVER_MODEL.to_string(), + reason: ModelRerouteReason::HighRiskCyberActivity, + } ); - timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_notification_message("turn/completed"), - ) - .await??; - Ok(()) } +async fn collect_turn_notifications_and_validate_no_warning_item( + mcp: &mut McpProcess, +) -> Result { + let mut rerouted = None; + + loop { + let message = timeout(DEFAULT_READ_TIMEOUT, mcp.read_next_message()).await??; + let JSONRPCMessage::Notification(notification) = message else { + continue; + }; + match notification.method.as_str() { + "model/rerouted" => { + let params = notification.params.ok_or_else(|| { + anyhow::anyhow!("model/rerouted notifications must include params") + })?; + let payload: ModelReroutedNotification = serde_json::from_value(params)?; + rerouted = Some(payload); + } + "item/started" => { + let params = notification.params.ok_or_else(|| { + anyhow::anyhow!("item/started notifications must include params") + })?; + let payload: ItemStartedNotification = serde_json::from_value(params)?; + assert!(!is_warning_user_message_item(&payload.item)); + } + "item/completed" => { + let params = notification.params.ok_or_else(|| { + anyhow::anyhow!("item/completed notifications must include params") + })?; + let payload: ItemCompletedNotification = serde_json::from_value(params)?; + assert!(!is_warning_user_message_item(&payload.item)); + } + "turn/completed" => { + return rerouted.ok_or_else(|| { + anyhow::anyhow!("expected model/rerouted notification before turn/completed") + }); + } + _ => {} + } + } +} + fn warning_text_from_item(item: &ThreadItem) -> Option<&str> { let ThreadItem::UserMessage { content, .. } = item else { return None; @@ -233,9 +212,8 @@ fn warning_text_from_item(item: &ThreadItem) -> Option<&str> { }) } -fn is_cyber_model_warning_text(text: &str) -> bool { - text.contains("flagged for potentially high-risk cyber activity") - && text.contains("apply for trusted access: https://chatgpt.com/cyber") +fn is_warning_user_message_item(item: &ThreadItem) -> bool { + warning_text_from_item(item).is_some() } fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> { diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 5eb80de2ec..f73cef0eb6 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -172,6 +172,8 @@ use crate::protocol::Event; use crate::protocol::EventMsg; use crate::protocol::ExecApprovalRequestEvent; use crate::protocol::McpServerRefreshConfig; +use crate::protocol::ModelRerouteEvent; +use crate::protocol::ModelRerouteReason; use crate::protocol::NetworkApprovalContext; use crate::protocol::Op; use crate::protocol::PlanDeltaEvent; @@ -2440,7 +2442,7 @@ impl Session { turn_context: &Arc, server_model: String, ) -> bool { - let requested_model = turn_context.model_info.slug.as_str(); + let requested_model = turn_context.model_info.slug.clone(); if server_model == requested_model { info!("server reported model {server_model} (matches requested model)"); return false; @@ -2452,6 +2454,16 @@ impl Session { "Your account was flagged for potentially high-risk cyber activity and this request was routed to gpt-5.2 as a fallback. To regain access to gpt-5.3-codex, apply for trusted access: {CYBER_VERIFY_URL} or learn more: {CYBER_SAFETY_URL}" ); + self.send_event( + turn_context, + EventMsg::ModelReroute(ModelRerouteEvent { + from_model: requested_model.clone(), + to_model: server_model.clone(), + reason: ModelRerouteReason::HighRiskCyberActivity, + }), + ) + .await; + self.send_event( turn_context, EventMsg::Warning(WarningEvent { diff --git a/codex-rs/core/src/rollout/policy.rs b/codex-rs/core/src/rollout/policy.rs index dc3e111a67..f83433efb2 100644 --- a/codex-rs/core/src/rollout/policy.rs +++ b/codex-rs/core/src/rollout/policy.rs @@ -122,6 +122,7 @@ fn event_msg_persistence_mode(ev: &EventMsg) -> Option { | EventMsg::CollabCloseEnd(_) | EventMsg::CollabResumeEnd(_) => Some(EventPersistenceMode::Extended), EventMsg::Warning(_) + | EventMsg::ModelReroute(_) | EventMsg::AgentMessageDelta(_) | EventMsg::AgentReasoningDelta(_) | EventMsg::AgentReasoningRawContentDelta(_) diff --git a/codex-rs/core/tests/suite/safety_check_downgrade.rs b/codex-rs/core/tests/suite/safety_check_downgrade.rs index a22fead90f..62a46bf1de 100644 --- a/codex-rs/core/tests/suite/safety_check_downgrade.rs +++ b/codex-rs/core/tests/suite/safety_check_downgrade.rs @@ -1,6 +1,7 @@ use anyhow::Result; use codex_core::protocol::AskForApproval; use codex_core::protocol::EventMsg; +use codex_core::protocol::ModelRerouteReason; use codex_core::protocol::Op; use codex_core::protocol::SandboxPolicy; use codex_protocol::config_types::ReasoningSummary; @@ -54,6 +55,17 @@ async fn openai_model_header_mismatch_emits_warning_event_and_warning_item() -> }) .await?; + let reroute = wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::ModelReroute(_)) + }) + .await; + let EventMsg::ModelReroute(reroute) = reroute else { + panic!("expected model reroute event"); + }; + assert_eq!(reroute.from_model, REQUESTED_MODEL); + assert_eq!(reroute.to_model, SERVER_MODEL); + assert_eq!(reroute.reason, ModelRerouteReason::HighRiskCyberActivity); + let warning = wait_for_event(&test.codex, |event| matches!(event, EventMsg::Warning(_))).await; let EventMsg::Warning(warning) = warning else { panic!("expected warning event"); @@ -138,6 +150,17 @@ async fn response_model_field_mismatch_emits_warning_when_header_matches_request }) .await?; + let reroute = wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::ModelReroute(_)) + }) + .await; + let EventMsg::ModelReroute(reroute) = reroute else { + panic!("expected model reroute event"); + }; + assert_eq!(reroute.from_model, REQUESTED_MODEL); + assert_eq!(reroute.to_model, SERVER_MODEL); + assert_eq!(reroute.reason, ModelRerouteReason::HighRiskCyberActivity); + let warning = wait_for_event(&test.codex, |event| { matches!( event, @@ -151,7 +174,8 @@ async fn response_model_field_mismatch_emits_warning_when_header_matches_request let EventMsg::Warning(warning) = warning else { panic!("expected warning event"); }; - assert!(warning.message.contains("gpt-5.2 as a fallback")); + assert!(warning.message.contains(REQUESTED_MODEL)); + assert!(warning.message.contains(SERVER_MODEL)); let _ = wait_for_event(&test.codex, |event| { matches!(event, EventMsg::TurnComplete(_)) diff --git a/codex-rs/exec/src/event_processor_with_human_output.rs b/codex-rs/exec/src/event_processor_with_human_output.rs index d7b95e2882..26861af7c8 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -188,6 +188,7 @@ impl EventProcessor for EventProcessorWithHumanOutput { "warning:".style(self.yellow).style(self.bold) ); } + EventMsg::ModelReroute(_) => {} EventMsg::DeprecationNotice(DeprecationNoticeEvent { summary, details }) => { ts_msg!( self, diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index 5af2191fcc..04fa658017 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -254,6 +254,9 @@ async fn run_codex_tool_session_inner( EventMsg::Warning(_) => { continue; } + EventMsg::ModelReroute(_) => { + continue; + } EventMsg::ElicitationRequest(_) => { // TODO: forward elicitation requests to the client? continue; diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 8a7fa9c382..bf489059bb 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -883,6 +883,9 @@ pub enum EventMsg { /// indicates the turn continued but the user should still be notified. Warning(WarningEvent), + /// Model routing changed from the requested model to a different model. + ModelReroute(ModelRerouteEvent), + /// Conversation history was compacted (either automatically or manually). ContextCompacted(ContextCompactedEvent), @@ -1342,6 +1345,20 @@ pub struct WarningEvent { pub message: String, } +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(rename_all = "snake_case")] +pub enum ModelRerouteReason { + HighRiskCyberActivity, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +pub struct ModelRerouteEvent { + pub from_model: String, + pub to_model: String, + pub reason: ModelRerouteReason, +} + #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct ContextCompactedEvent; diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index dcbee999bc..9da2e6f9c4 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -4021,6 +4021,7 @@ impl ChatWidget { self.on_rate_limit_snapshot(ev.rate_limits); } EventMsg::Warning(WarningEvent { message }) => self.on_warning(message), + EventMsg::ModelReroute(_) => {} EventMsg::Error(ErrorEvent { message, codex_error_info, From 314029ffa3521718193e31e73f7ce52d2c403bac Mon Sep 17 00:00:00 2001 From: xl-openai Date: Tue, 17 Feb 2026 11:05:22 -0800 Subject: [PATCH 53/62] Add remote skill scope/product_surface/enabled params and cleanup (#11801) skills/remote/list: params=hazelnutScope, productSurface, enabled; returns=data: { id, name, description }[] skills/remote/export: params=hazelnutId; returns={ id, path } --- .../schema/json/ClientRequest.json | 60 ++++- .../codex_app_server_protocol.schemas.json | 64 +++-- .../json/v2/SkillsRemoteReadParams.json | 44 +++- .../json/v2/SkillsRemoteWriteParams.json | 6 +- .../json/v2/SkillsRemoteWriteResponse.json | 6 +- .../schema/typescript/ClientRequest.ts | 2 +- .../schema/typescript/v2/HazelnutScope.ts | 5 + .../schema/typescript/v2/ProductSurface.ts | 5 + .../typescript/v2/SkillsRemoteReadParams.ts | 4 +- .../typescript/v2/SkillsRemoteWriteParams.ts | 2 +- .../v2/SkillsRemoteWriteResponse.ts | 2 +- .../schema/typescript/v2/index.ts | 2 + .../src/protocol/common.rs | 4 +- .../app-server-protocol/src/protocol/v2.rs | 35 ++- codex-rs/app-server/README.md | 4 +- .../app-server/src/codex_message_processor.rs | 64 +++-- codex-rs/core/src/codex.rs | 81 ++++--- codex-rs/core/src/skills/remote.rs | 224 +++++++----------- codex-rs/protocol/src/protocol.rs | 31 ++- 19 files changed, 402 insertions(+), 243 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/HazelnutScope.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/ProductSurface.ts diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index a8b66a202b..d5ce1e2f87 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -711,6 +711,15 @@ ], "type": "object" }, + "HazelnutScope": { + "enum": [ + "example", + "workspace-shared", + "all-shared", + "personal" + ], + "type": "string" + }, "InitializeCapabilities": { "description": "Client-declared capabilities negotiated during initialize.", "properties": { @@ -1250,6 +1259,15 @@ ], "type": "string" }, + "ProductSurface": { + "enum": [ + "chatgpt", + "codex", + "api", + "atlas" + ], + "type": "string" + }, "ReadOnlyAccess": { "oneOf": [ { @@ -2421,20 +2439,38 @@ "type": "object" }, "SkillsRemoteReadParams": { + "properties": { + "enabled": { + "default": false, + "type": "boolean" + }, + "hazelnutScope": { + "allOf": [ + { + "$ref": "#/definitions/HazelnutScope" + } + ], + "default": "example" + }, + "productSurface": { + "allOf": [ + { + "$ref": "#/definitions/ProductSurface" + } + ], + "default": "codex" + } + }, "type": "object" }, "SkillsRemoteWriteParams": { "properties": { "hazelnutId": { "type": "string" - }, - "isPreload": { - "type": "boolean" } }, "required": [ - "hazelnutId", - "isPreload" + "hazelnutId" ], "type": "object" }, @@ -3599,9 +3635,9 @@ }, "method": { "enum": [ - "skills/remote/read" + "skills/remote/list" ], - "title": "Skills/remote/readRequestMethod", + "title": "Skills/remote/listRequestMethod", "type": "string" }, "params": { @@ -3613,7 +3649,7 @@ "method", "params" ], - "title": "Skills/remote/readRequest", + "title": "Skills/remote/listRequest", "type": "object" }, { @@ -3623,9 +3659,9 @@ }, "method": { "enum": [ - "skills/remote/write" + "skills/remote/export" ], - "title": "Skills/remote/writeRequestMethod", + "title": "Skills/remote/exportRequestMethod", "type": "string" }, "params": { @@ -3637,7 +3673,7 @@ "method", "params" ], - "title": "Skills/remote/writeRequest", + "title": "Skills/remote/exportRequest", "type": "object" }, { @@ -4720,4 +4756,4 @@ } ], "title": "ClientRequest" -} \ No newline at end of file +} 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 09375c5ed1..ad1342d128 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 @@ -755,9 +755,9 @@ }, "method": { "enum": [ - "skills/remote/read" + "skills/remote/list" ], - "title": "Skills/remote/readRequestMethod", + "title": "Skills/remote/listRequestMethod", "type": "string" }, "params": { @@ -769,7 +769,7 @@ "method", "params" ], - "title": "Skills/remote/readRequest", + "title": "Skills/remote/listRequest", "type": "object" }, { @@ -779,9 +779,9 @@ }, "method": { "enum": [ - "skills/remote/write" + "skills/remote/export" ], - "title": "Skills/remote/writeRequestMethod", + "title": "Skills/remote/exportRequestMethod", "type": "string" }, "params": { @@ -793,7 +793,7 @@ "method", "params" ], - "title": "Skills/remote/writeRequest", + "title": "Skills/remote/exportRequest", "type": "object" }, { @@ -12169,6 +12169,15 @@ }, "type": "object" }, + "HazelnutScope": { + "enum": [ + "example", + "workspace-shared", + "all-shared", + "personal" + ], + "type": "string" + }, "InputModality": { "description": "Canonical user-input modality tags advertised by a model.", "oneOf": [ @@ -13057,6 +13066,15 @@ ], "type": "string" }, + "ProductSurface": { + "enum": [ + "chatgpt", + "codex", + "api", + "atlas" + ], + "type": "string" + }, "ProfileV2": { "additionalProperties": true, "properties": { @@ -14588,6 +14606,28 @@ }, "SkillsRemoteReadParams": { "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "enabled": { + "default": false, + "type": "boolean" + }, + "hazelnutScope": { + "allOf": [ + { + "$ref": "#/definitions/v2/HazelnutScope" + } + ], + "default": "example" + }, + "productSurface": { + "allOf": [ + { + "$ref": "#/definitions/v2/ProductSurface" + } + ], + "default": "codex" + } + }, "title": "SkillsRemoteReadParams", "type": "object" }, @@ -14612,14 +14652,10 @@ "properties": { "hazelnutId": { "type": "string" - }, - "isPreload": { - "type": "boolean" } }, "required": [ - "hazelnutId", - "isPreload" + "hazelnutId" ], "title": "SkillsRemoteWriteParams", "type": "object" @@ -14630,16 +14666,12 @@ "id": { "type": "string" }, - "name": { - "type": "string" - }, "path": { "type": "string" } }, "required": [ "id", - "name", "path" ], "title": "SkillsRemoteWriteResponse", @@ -16793,4 +16825,4 @@ }, "title": "CodexAppServerProtocol", "type": "object" -} \ No newline at end of file +} diff --git a/codex-rs/app-server-protocol/schema/json/v2/SkillsRemoteReadParams.json b/codex-rs/app-server-protocol/schema/json/v2/SkillsRemoteReadParams.json index ace2fb5ba7..3fdf78f92b 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/SkillsRemoteReadParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/SkillsRemoteReadParams.json @@ -1,5 +1,47 @@ { "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "HazelnutScope": { + "enum": [ + "example", + "workspace-shared", + "all-shared", + "personal" + ], + "type": "string" + }, + "ProductSurface": { + "enum": [ + "chatgpt", + "codex", + "api", + "atlas" + ], + "type": "string" + } + }, + "properties": { + "enabled": { + "default": false, + "type": "boolean" + }, + "hazelnutScope": { + "allOf": [ + { + "$ref": "#/definitions/HazelnutScope" + } + ], + "default": "example" + }, + "productSurface": { + "allOf": [ + { + "$ref": "#/definitions/ProductSurface" + } + ], + "default": "codex" + } + }, "title": "SkillsRemoteReadParams", "type": "object" -} \ No newline at end of file +} diff --git a/codex-rs/app-server-protocol/schema/json/v2/SkillsRemoteWriteParams.json b/codex-rs/app-server-protocol/schema/json/v2/SkillsRemoteWriteParams.json index 871a5a428a..f1a70eeeb0 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/SkillsRemoteWriteParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/SkillsRemoteWriteParams.json @@ -3,14 +3,10 @@ "properties": { "hazelnutId": { "type": "string" - }, - "isPreload": { - "type": "boolean" } }, "required": [ - "hazelnutId", - "isPreload" + "hazelnutId" ], "title": "SkillsRemoteWriteParams", "type": "object" diff --git a/codex-rs/app-server-protocol/schema/json/v2/SkillsRemoteWriteResponse.json b/codex-rs/app-server-protocol/schema/json/v2/SkillsRemoteWriteResponse.json index 1a9473d054..d1e85bc5ba 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/SkillsRemoteWriteResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/SkillsRemoteWriteResponse.json @@ -4,18 +4,14 @@ "id": { "type": "string" }, - "name": { - "type": "string" - }, "path": { "type": "string" } }, "required": [ "id", - "name", "path" ], "title": "SkillsRemoteWriteResponse", "type": "object" -} \ No newline at end of file +} diff --git a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts index 3b5f6787b1..40757d9d95 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts @@ -57,4 +57,4 @@ import type { TurnSteerParams } from "./v2/TurnSteerParams"; /** * Request from the client to the server. */ -export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "skills/remote/read", id: RequestId, params: SkillsRemoteReadParams, } | { "method": "skills/remote/write", id: RequestId, params: SkillsRemoteWriteParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "newConversation", id: RequestId, params: NewConversationParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "listConversations", id: RequestId, params: ListConversationsParams, } | { "method": "resumeConversation", id: RequestId, params: ResumeConversationParams, } | { "method": "forkConversation", id: RequestId, params: ForkConversationParams, } | { "method": "archiveConversation", id: RequestId, params: ArchiveConversationParams, } | { "method": "sendUserMessage", id: RequestId, params: SendUserMessageParams, } | { "method": "sendUserTurn", id: RequestId, params: SendUserTurnParams, } | { "method": "interruptConversation", id: RequestId, params: InterruptConversationParams, } | { "method": "addConversationListener", id: RequestId, params: AddConversationListenerParams, } | { "method": "removeConversationListener", id: RequestId, params: RemoveConversationListenerParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "loginApiKey", id: RequestId, params: LoginApiKeyParams, } | { "method": "loginChatGpt", id: RequestId, params: undefined, } | { "method": "cancelLoginChatGpt", id: RequestId, params: CancelLoginChatGptParams, } | { "method": "logoutChatGpt", id: RequestId, params: undefined, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "getUserSavedConfig", id: RequestId, params: undefined, } | { "method": "setDefaultModel", id: RequestId, params: SetDefaultModelParams, } | { "method": "getUserAgent", id: RequestId, params: undefined, } | { "method": "userInfo", id: RequestId, params: undefined, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, } | { "method": "execOneOffCommand", id: RequestId, params: ExecOneOffCommandParams, }; +export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "skills/remote/list", id: RequestId, params: SkillsRemoteReadParams, } | { "method": "skills/remote/export", id: RequestId, params: SkillsRemoteWriteParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "newConversation", id: RequestId, params: NewConversationParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "listConversations", id: RequestId, params: ListConversationsParams, } | { "method": "resumeConversation", id: RequestId, params: ResumeConversationParams, } | { "method": "forkConversation", id: RequestId, params: ForkConversationParams, } | { "method": "archiveConversation", id: RequestId, params: ArchiveConversationParams, } | { "method": "sendUserMessage", id: RequestId, params: SendUserMessageParams, } | { "method": "sendUserTurn", id: RequestId, params: SendUserTurnParams, } | { "method": "interruptConversation", id: RequestId, params: InterruptConversationParams, } | { "method": "addConversationListener", id: RequestId, params: AddConversationListenerParams, } | { "method": "removeConversationListener", id: RequestId, params: RemoveConversationListenerParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "loginApiKey", id: RequestId, params: LoginApiKeyParams, } | { "method": "loginChatGpt", id: RequestId, params: undefined, } | { "method": "cancelLoginChatGpt", id: RequestId, params: CancelLoginChatGptParams, } | { "method": "logoutChatGpt", id: RequestId, params: undefined, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "getUserSavedConfig", id: RequestId, params: undefined, } | { "method": "setDefaultModel", id: RequestId, params: SetDefaultModelParams, } | { "method": "getUserAgent", id: RequestId, params: undefined, } | { "method": "userInfo", id: RequestId, params: undefined, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, } | { "method": "execOneOffCommand", id: RequestId, params: ExecOneOffCommandParams, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/HazelnutScope.ts b/codex-rs/app-server-protocol/schema/typescript/v2/HazelnutScope.ts new file mode 100644 index 0000000000..e623f1860b --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/HazelnutScope.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 HazelnutScope = "example" | "workspace-shared" | "all-shared" | "personal"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ProductSurface.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ProductSurface.ts new file mode 100644 index 0000000000..9998c727a8 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ProductSurface.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 ProductSurface = "chatgpt" | "codex" | "api" | "atlas"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/SkillsRemoteReadParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/SkillsRemoteReadParams.ts index 9f91787686..1257f0d791 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/SkillsRemoteReadParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/SkillsRemoteReadParams.ts @@ -1,5 +1,7 @@ // 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 { HazelnutScope } from "./HazelnutScope"; +import type { ProductSurface } from "./ProductSurface"; -export type SkillsRemoteReadParams = Record; +export type SkillsRemoteReadParams = { hazelnutScope: HazelnutScope, productSurface: ProductSurface, enabled: boolean, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/SkillsRemoteWriteParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/SkillsRemoteWriteParams.ts index 857b609ef1..ea42595bfd 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/SkillsRemoteWriteParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/SkillsRemoteWriteParams.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 SkillsRemoteWriteParams = { hazelnutId: string, isPreload: boolean, }; +export type SkillsRemoteWriteParams = { hazelnutId: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/SkillsRemoteWriteResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/SkillsRemoteWriteResponse.ts index cf1665ab97..228b723b19 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/SkillsRemoteWriteResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/SkillsRemoteWriteResponse.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 SkillsRemoteWriteResponse = { id: string, name: string, path: string, }; +export type SkillsRemoteWriteResponse = { id: string, path: string, }; 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 423ee9dd1b..8a1456f420 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -70,6 +70,7 @@ export type { GetAccountParams } from "./GetAccountParams"; export type { GetAccountRateLimitsResponse } from "./GetAccountRateLimitsResponse"; export type { GetAccountResponse } from "./GetAccountResponse"; export type { GitInfo } from "./GitInfo"; +export type { HazelnutScope } from "./HazelnutScope"; export type { ItemCompletedNotification } from "./ItemCompletedNotification"; export type { ItemStartedNotification } from "./ItemStartedNotification"; export type { ListMcpServerStatusParams } from "./ListMcpServerStatusParams"; @@ -99,6 +100,7 @@ export type { OverriddenMetadata } from "./OverriddenMetadata"; export type { PatchApplyStatus } from "./PatchApplyStatus"; export type { PatchChangeKind } from "./PatchChangeKind"; export type { PlanDeltaNotification } from "./PlanDeltaNotification"; +export type { ProductSurface } from "./ProductSurface"; export type { ProfileV2 } from "./ProfileV2"; export type { RateLimitSnapshot } from "./RateLimitSnapshot"; export type { RateLimitWindow } from "./RateLimitWindow"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index e6b285d9d6..88a17390de 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -239,11 +239,11 @@ client_request_definitions! { params: v2::SkillsListParams, response: v2::SkillsListResponse, }, - SkillsRemoteRead => "skills/remote/read" { + SkillsRemoteList => "skills/remote/list" { params: v2::SkillsRemoteReadParams, response: v2::SkillsRemoteReadResponse, }, - SkillsRemoteWrite => "skills/remote/write" { + SkillsRemoteExport => "skills/remote/export" { params: v2::SkillsRemoteWriteParams, response: v2::SkillsRemoteWriteResponse, }, diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index fc4a7742f5..4d4b15127e 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -1837,7 +1837,38 @@ pub struct SkillsListResponse { #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] -pub struct SkillsRemoteReadParams {} +pub struct SkillsRemoteReadParams { + #[serde(default)] + pub hazelnut_scope: HazelnutScope, + #[serde(default)] + pub product_surface: ProductSurface, + #[serde(default)] + pub enabled: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS, Default)] +#[serde(rename_all = "kebab-case")] +#[ts(rename_all = "kebab-case")] +#[ts(export_to = "v2/")] +pub enum HazelnutScope { + #[default] + Example, + WorkspaceShared, + AllShared, + Personal, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS, Default)] +#[serde(rename_all = "lowercase")] +#[ts(rename_all = "lowercase")] +#[ts(export_to = "v2/")] +pub enum ProductSurface { + Chatgpt, + #[default] + Codex, + Api, + Atlas, +} #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] @@ -1860,7 +1891,6 @@ pub struct SkillsRemoteReadResponse { #[ts(export_to = "v2/")] pub struct SkillsRemoteWriteParams { pub hazelnut_id: String, - pub is_preload: bool, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] @@ -1868,7 +1898,6 @@ pub struct SkillsRemoteWriteParams { #[ts(export_to = "v2/")] pub struct SkillsRemoteWriteResponse { pub id: String, - pub name: String, pub path: PathBuf, } diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 669e8a1579..c65793d240 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -135,8 +135,8 @@ Example with notification opt-out: - `experimentalFeature/list` — list feature flags with stage metadata (`beta`, `underDevelopment`, `stable`, etc.), enabled/default-enabled state, and cursor pagination. For non-beta flags, `displayName`/`description`/`announcement` are `null`. - `collaborationMode/list` — list available collaboration mode presets (experimental, no pagination). - `skills/list` — list skills for one or more `cwd` values (optional `forceReload`). -- `skills/remote/read` — list public remote skills (**under development; do not call from production clients yet**). -- `skills/remote/write` — download a public remote skill by `hazelnutId`; `isPreload=true` writes to `.codex/vendor_imports/skills` under `codex_home` (**under development; do not call from production clients yet**). +- `skills/remote/list` — list public remote skills (**under development; do not call from production clients yet**). +- `skills/remote/export` — download a remote skill by `hazelnutId` into `skills` under `codex_home` (**under development; do not call from production clients yet**). - `app/list` — list available apps. - `skills/config/write` — write user-level skill config by path. - `mcpServer/oauth/login` — start an OAuth login for a configured MCP server; returns an `authorization_url` and later emits `mcpServer/oauthLogin/completed` once the browser flow finishes. diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 0439068db9..b966942cbf 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -66,6 +66,7 @@ use codex_app_server_protocol::GetUserAgentResponse; use codex_app_server_protocol::GetUserSavedConfigResponse; use codex_app_server_protocol::GitDiffToRemoteResponse; use codex_app_server_protocol::GitInfo as ApiGitInfo; +use codex_app_server_protocol::HazelnutScope as ApiHazelnutScope; use codex_app_server_protocol::InputItem as WireInputItem; use codex_app_server_protocol::InterruptConversationParams; use codex_app_server_protocol::JSONRPCErrorError; @@ -92,6 +93,7 @@ use codex_app_server_protocol::ModelListParams; use codex_app_server_protocol::ModelListResponse; use codex_app_server_protocol::NewConversationParams; use codex_app_server_protocol::NewConversationResponse; +use codex_app_server_protocol::ProductSurface as ApiProductSurface; use codex_app_server_protocol::RemoveConversationListenerParams; use codex_app_server_protocol::RemoveConversationSubscriptionResponse; use codex_app_server_protocol::ResumeConversationParams; @@ -207,7 +209,7 @@ use codex_core::read_head_for_summary; use codex_core::read_session_meta_line; use codex_core::rollout_date_parts; use codex_core::sandboxing::SandboxPermissions; -use codex_core::skills::remote::download_remote_skill; +use codex_core::skills::remote::export_remote_skill; use codex_core::skills::remote::list_remote_skills; use codex_core::state_db::StateDbHandle; use codex_core::state_db::get_state_db; @@ -229,6 +231,8 @@ use codex_protocol::protocol::GitInfo as CoreGitInfo; use codex_protocol::protocol::McpAuthStatus as CoreMcpAuthStatus; use codex_protocol::protocol::McpServerRefreshConfig; use codex_protocol::protocol::RateLimitSnapshot as CoreRateLimitSnapshot; +use codex_protocol::protocol::RemoteSkillHazelnutScope; +use codex_protocol::protocol::RemoteSkillProductSurface; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionMetaLine; use codex_protocol::protocol::USER_MESSAGE_BEGIN; @@ -291,6 +295,24 @@ enum AppListLoadResult { Directory(Result, String>), } +fn convert_remote_scope(scope: ApiHazelnutScope) -> RemoteSkillHazelnutScope { + match scope { + ApiHazelnutScope::WorkspaceShared => RemoteSkillHazelnutScope::WorkspaceShared, + ApiHazelnutScope::AllShared => RemoteSkillHazelnutScope::AllShared, + ApiHazelnutScope::Personal => RemoteSkillHazelnutScope::Personal, + ApiHazelnutScope::Example => RemoteSkillHazelnutScope::Example, + } +} + +fn convert_remote_product_surface(product_surface: ApiProductSurface) -> RemoteSkillProductSurface { + match product_surface { + ApiProductSurface::Chatgpt => RemoteSkillProductSurface::Chatgpt, + ApiProductSurface::Codex => RemoteSkillProductSurface::Codex, + ApiProductSurface::Api => RemoteSkillProductSurface::Api, + ApiProductSurface::Atlas => RemoteSkillProductSurface::Atlas, + } +} + impl Drop for ActiveLogin { fn drop(&mut self) { self.shutdown_handle.shutdown(); @@ -549,12 +571,12 @@ impl CodexMessageProcessor { self.skills_list(to_connection_request_id(request_id), params) .await; } - ClientRequest::SkillsRemoteRead { request_id, params } => { - self.skills_remote_read(to_connection_request_id(request_id), params) + ClientRequest::SkillsRemoteList { request_id, params } => { + self.skills_remote_list(to_connection_request_id(request_id), params) .await; } - ClientRequest::SkillsRemoteWrite { request_id, params } => { - self.skills_remote_write(to_connection_request_id(request_id), params) + ClientRequest::SkillsRemoteExport { request_id, params } => { + self.skills_remote_export(to_connection_request_id(request_id), params) .await; } ClientRequest::AppsList { request_id, params } => { @@ -5048,12 +5070,25 @@ impl CodexMessageProcessor { .await; } - async fn skills_remote_read( + async fn skills_remote_list( &self, request_id: ConnectionRequestId, - _params: SkillsRemoteReadParams, + params: SkillsRemoteReadParams, ) { - match list_remote_skills(&self.config).await { + let hazelnut_scope = convert_remote_scope(params.hazelnut_scope); + let product_surface = convert_remote_product_surface(params.product_surface); + let enabled = if params.enabled { Some(true) } else { None }; + + let auth = self.auth_manager.auth().await; + match list_remote_skills( + &self.config, + auth.as_ref(), + hazelnut_scope, + product_surface, + enabled, + ) + .await + { Ok(skills) => { let data = skills .into_iter() @@ -5070,23 +5105,21 @@ impl CodexMessageProcessor { Err(err) => { self.send_internal_error( request_id, - format!("failed to read remote skills: {err}"), + format!("failed to list remote skills: {err}"), ) .await; } } } - async fn skills_remote_write( + async fn skills_remote_export( &self, request_id: ConnectionRequestId, params: SkillsRemoteWriteParams, ) { - let SkillsRemoteWriteParams { - hazelnut_id, - is_preload, - } = params; - let response = download_remote_skill(&self.config, hazelnut_id.as_str(), is_preload).await; + let SkillsRemoteWriteParams { hazelnut_id } = params; + let auth = self.auth_manager.auth().await; + let response = export_remote_skill(&self.config, auth.as_ref(), hazelnut_id.as_str()).await; match response { Ok(downloaded) => { @@ -5095,7 +5128,6 @@ impl CodexMessageProcessor { request_id, SkillsRemoteWriteResponse { id: downloaded.id, - name: downloaded.name, path: downloaded.path, }, ) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index f73cef0eb6..b96af69f03 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -3174,22 +3174,24 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv Op::ListSkills { cwds, force_reload } => { handlers::list_skills(&sess, sub.id.clone(), cwds, force_reload).await; } - Op::ListRemoteSkills => { - handlers::list_remote_skills(&sess, &config, sub.id.clone()).await; - } - Op::DownloadRemoteSkill { - hazelnut_id, - is_preload, + Op::ListRemoteSkills { + hazelnut_scope, + product_surface, + enabled, } => { - handlers::download_remote_skill( + handlers::list_remote_skills( &sess, &config, sub.id.clone(), - hazelnut_id, - is_preload, + hazelnut_scope, + product_surface, + enabled, ) .await; } + Op::DownloadRemoteSkill { hazelnut_id } => { + handlers::export_remote_skill(&sess, &config, sub.id.clone(), hazelnut_id).await; + } Op::Undo => { handlers::undo(&sess, sub.id.clone()).await; } @@ -3269,6 +3271,8 @@ mod handlers { use codex_protocol::protocol::McpServerRefreshConfig; use codex_protocol::protocol::Op; use codex_protocol::protocol::RemoteSkillDownloadedEvent; + use codex_protocol::protocol::RemoteSkillHazelnutScope; + use codex_protocol::protocol::RemoteSkillProductSurface; use codex_protocol::protocol::RemoteSkillSummary; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewRequest; @@ -3665,19 +3669,33 @@ mod handlers { sess.send_event_raw(event).await; } - pub async fn list_remote_skills(sess: &Session, config: &Arc, sub_id: String) { - let response = crate::skills::remote::list_remote_skills(config) - .await - .map(|skills| { - skills - .into_iter() - .map(|skill| RemoteSkillSummary { - id: skill.id, - name: skill.name, - description: skill.description, - }) - .collect::>() - }); + pub async fn list_remote_skills( + sess: &Session, + config: &Arc, + sub_id: String, + hazelnut_scope: RemoteSkillHazelnutScope, + product_surface: RemoteSkillProductSurface, + enabled: Option, + ) { + let auth = sess.services.auth_manager.auth().await; + let response = crate::skills::remote::list_remote_skills( + config, + auth.as_ref(), + hazelnut_scope, + product_surface, + enabled, + ) + .await + .map(|skills| { + skills + .into_iter() + .map(|skill| RemoteSkillSummary { + id: skill.id, + name: skill.name, + description: skill.description, + }) + .collect::>() + }); match response { Ok(skills) => { @@ -3702,22 +3720,27 @@ mod handlers { } } - pub async fn download_remote_skill( + pub async fn export_remote_skill( sess: &Session, config: &Arc, sub_id: String, hazelnut_id: String, - is_preload: bool, ) { - match crate::skills::remote::download_remote_skill(config, hazelnut_id.as_str(), is_preload) - .await + let auth = sess.services.auth_manager.auth().await; + match crate::skills::remote::export_remote_skill( + config, + auth.as_ref(), + hazelnut_id.as_str(), + ) + .await { Ok(result) => { + let id = result.id; let event = Event { id: sub_id, msg: EventMsg::RemoteSkillDownloaded(RemoteSkillDownloadedEvent { - id: result.id, - name: result.name, + id: id.clone(), + name: id, path: result.path, }), }; @@ -3727,7 +3750,7 @@ mod handlers { let event = Event { id: sub_id, msg: EventMsg::Error(ErrorEvent { - message: format!("failed to download remote skill {hazelnut_id}: {err}"), + message: format!("failed to export remote skill {hazelnut_id}: {err}"), codex_error_info: Some(CodexErrorInfo::Other), }), }; diff --git a/codex-rs/core/src/skills/remote.rs b/codex-rs/core/src/skills/remote.rs index af6dd5593a..6abd402584 100644 --- a/codex-rs/core/src/skills/remote.rs +++ b/codex-rs/core/src/skills/remote.rs @@ -1,18 +1,49 @@ use anyhow::Context; use anyhow::Result; use serde::Deserialize; -use std::collections::HashMap; -use std::collections::HashSet; use std::path::Component; use std::path::Path; use std::path::PathBuf; use std::time::Duration; +use crate::auth::CodexAuth; use crate::config::Config; use crate::default_client::build_reqwest_client; +use codex_protocol::protocol::RemoteSkillHazelnutScope; +use codex_protocol::protocol::RemoteSkillProductSurface; const REMOTE_SKILLS_API_TIMEOUT: Duration = Duration::from_secs(30); +fn as_query_hazelnut_scope(scope: RemoteSkillHazelnutScope) -> Option<&'static str> { + match scope { + RemoteSkillHazelnutScope::WorkspaceShared => Some("workspace-shared"), + RemoteSkillHazelnutScope::AllShared => Some("all-shared"), + RemoteSkillHazelnutScope::Personal => Some("personal"), + RemoteSkillHazelnutScope::Example => Some("example"), + } +} + +fn as_query_product_surface(product_surface: RemoteSkillProductSurface) -> &'static str { + match product_surface { + RemoteSkillProductSurface::Chatgpt => "chatgpt", + RemoteSkillProductSurface::Codex => "codex", + RemoteSkillProductSurface::Api => "api", + RemoteSkillProductSurface::Atlas => "atlas", + } +} + +fn ensure_chatgpt_auth(auth: Option<&CodexAuth>) -> Result<&CodexAuth> { + let Some(auth) = auth else { + anyhow::bail!("chatgpt authentication required for hazelnut scopes"); + }; + if !auth.is_chatgpt_auth() { + anyhow::bail!( + "chatgpt authentication required for hazelnut scopes; api key auth is not supported" + ); + } + Ok(auth) +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct RemoteSkillSummary { pub id: String, @@ -20,27 +51,12 @@ pub struct RemoteSkillSummary { pub description: String, } -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct RemoteSkillDownload { - pub id: String, - pub name: String, - pub base_sediment_id: String, - pub files: HashMap, -} - #[derive(Debug, Clone, PartialEq, Eq)] pub struct RemoteSkillDownloadResult { pub id: String, - pub name: String, pub path: PathBuf, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct RemoteSkillFileRange { - pub start: u64, - pub length: u64, -} - #[derive(Debug, Deserialize)] struct RemoteSkillsResponse { hazelnuts: Vec, @@ -53,36 +69,40 @@ struct RemoteSkill { description: String, } -#[derive(Debug, Deserialize)] -struct RemoteSkillsDownloadResponse { - hazelnuts: Vec, -} - -#[derive(Debug, Deserialize)] -struct RemoteSkillDownloadPayload { - id: String, - name: String, - #[serde(rename = "base_sediment_id")] - base_sediment_id: String, - files: HashMap, -} - -#[derive(Debug, Deserialize)] -struct RemoteSkillFileRangePayload { - start: u64, - length: u64, -} - -pub async fn list_remote_skills(config: &Config) -> Result> { +pub async fn list_remote_skills( + config: &Config, + auth: Option<&CodexAuth>, + hazelnut_scope: RemoteSkillHazelnutScope, + product_surface: RemoteSkillProductSurface, + enabled: Option, +) -> Result> { let base_url = config.chatgpt_base_url.trim_end_matches('/'); - let base_url = base_url.strip_suffix("/backend-api").unwrap_or(base_url); - let url = format!("{base_url}/public-api/hazelnuts/"); + let auth = ensure_chatgpt_auth(auth)?; + + let url = format!("{base_url}/hazelnuts"); + let product_surface = as_query_product_surface(product_surface); + let mut query_params = vec![("product_surface", product_surface)]; + if let Some(scope) = as_query_hazelnut_scope(hazelnut_scope) { + query_params.push(("scope", scope)); + } + if let Some(enabled) = enabled { + let enabled = if enabled { "true" } else { "false" }; + query_params.push(("enabled", enabled)); + } let client = build_reqwest_client(); - let response = client + let mut request = client .get(&url) .timeout(REMOTE_SKILLS_API_TIMEOUT) - .query(&[("product_surface", "codex")]) + .query(&query_params); + let token = auth + .get_token() + .context("Failed to read auth token for remote skills")?; + request = request.bearer_auth(token); + if let Some(account_id) = auth.get_account_id() { + request = request.header("chatgpt-account-id", account_id); + } + let response = request .send() .await .with_context(|| format!("Failed to send request to {url}"))?; @@ -107,20 +127,27 @@ pub async fn list_remote_skills(config: &Config) -> Result, hazelnut_id: &str, - is_preload: bool, ) -> Result { - let hazelnut = fetch_remote_skill(config, hazelnut_id).await?; + let auth = ensure_chatgpt_auth(auth)?; let client = build_reqwest_client(); let base_url = config.chatgpt_base_url.trim_end_matches('/'); - let base_url = base_url.strip_suffix("/backend-api").unwrap_or(base_url); - let url = format!("{base_url}/public-api/hazelnuts/{hazelnut_id}/export"); - let response = client - .get(&url) - .timeout(REMOTE_SKILLS_API_TIMEOUT) + let url = format!("{base_url}/hazelnuts/{hazelnut_id}/export"); + let mut request = client.get(&url).timeout(REMOTE_SKILLS_API_TIMEOUT); + + let token = auth + .get_token() + .context("Failed to read auth token for remote skills")?; + request = request.bearer_auth(token); + if let Some(account_id) = auth.get_account_id() { + request = request.header("chatgpt-account-id", account_id); + } + + let response = request .send() .await .with_context(|| format!("Failed to send download request to {url}"))?; @@ -136,48 +163,22 @@ pub async fn download_remote_skill( anyhow::bail!("Downloaded remote skill payload is not a zip archive"); } - let preferred_dir_name = if hazelnut.name.trim().is_empty() { - None - } else { - Some(hazelnut.name.as_str()) - }; - let dir_name = preferred_dir_name - .and_then(validate_dir_name_format) - .or_else(|| validate_dir_name_format(&hazelnut.id)) - .ok_or_else(|| anyhow::anyhow!("Remote skill has no valid directory name"))?; - let output_root = if is_preload { - config - .codex_home - .join("vendor_imports") - .join("skills") - .join("skills") - .join(".curated") - } else { - config.codex_home.join("skills").join("downloaded") - }; - let output_dir = output_root.join(dir_name); + let output_dir = config.codex_home.join("skills").join(hazelnut_id); tokio::fs::create_dir_all(&output_dir) .await .context("Failed to create downloaded skills directory")?; - let allowed_files = hazelnut.files.keys().cloned().collect::>(); let zip_bytes = body.to_vec(); let output_dir_clone = output_dir.clone(); - let prefix_candidates = vec![hazelnut.name.clone(), hazelnut.id.clone()]; + let prefix_candidates = vec![hazelnut_id.to_string()]; tokio::task::spawn_blocking(move || { - extract_zip_to_dir( - zip_bytes, - &output_dir_clone, - &allowed_files, - &prefix_candidates, - ) + extract_zip_to_dir(zip_bytes, &output_dir_clone, &prefix_candidates) }) .await .context("Zip extraction task failed")??; Ok(RemoteSkillDownloadResult { - id: hazelnut.id, - name: hazelnut.name, + id: hazelnut_id.to_string(), path: output_dir, }) } @@ -195,17 +196,6 @@ fn safe_join(base: &Path, name: &str) -> Result { Ok(base.join(path)) } -fn validate_dir_name_format(name: &str) -> Option { - let mut components = Path::new(name).components(); - match (components.next(), components.next()) { - (Some(Component::Normal(component)), None) => { - let value = component.to_string_lossy().to_string(); - if value.is_empty() { None } else { Some(value) } - } - _ => None, - } -} - fn is_zip_payload(bytes: &[u8]) -> bool { bytes.starts_with(b"PK\x03\x04") || bytes.starts_with(b"PK\x05\x06") @@ -215,7 +205,6 @@ fn is_zip_payload(bytes: &[u8]) -> bool { fn extract_zip_to_dir( bytes: Vec, output_dir: &Path, - allowed_files: &HashSet, prefix_candidates: &[String], ) -> Result<()> { let cursor = std::io::Cursor::new(bytes); @@ -230,9 +219,6 @@ fn extract_zip_to_dir( let Some(normalized) = normalized else { continue; }; - if !allowed_files.contains(&normalized) { - continue; - } let file_path = safe_join(output_dir, &normalized)?; if let Some(parent) = file_path.parent() { std::fs::create_dir_all(parent) @@ -264,51 +250,3 @@ fn normalize_zip_name(name: &str, prefix_candidates: &[String]) -> Option Result { - let base_url = config.chatgpt_base_url.trim_end_matches('/'); - let base_url = base_url.strip_suffix("/backend-api").unwrap_or(base_url); - let url = format!("{base_url}/public-api/hazelnuts/"); - - let client = build_reqwest_client(); - let response = client - .get(&url) - .timeout(REMOTE_SKILLS_API_TIMEOUT) - .query(&[("product_surface", "codex")]) - .send() - .await - .with_context(|| format!("Failed to send request to {url}"))?; - - let status = response.status(); - let body = response.text().await.unwrap_or_default(); - if !status.is_success() { - anyhow::bail!("Request failed with status {status} from {url}: {body}"); - } - - let parsed: RemoteSkillsDownloadResponse = - serde_json::from_str(&body).context("Failed to parse skills response")?; - let hazelnut = parsed - .hazelnuts - .into_iter() - .find(|hazelnut| hazelnut.id == hazelnut_id) - .ok_or_else(|| anyhow::anyhow!("Remote skill {hazelnut_id} not found"))?; - - Ok(RemoteSkillDownload { - id: hazelnut.id, - name: hazelnut.name, - base_sediment_id: hazelnut.base_sediment_id, - files: hazelnut - .files - .into_iter() - .map(|(name, range)| { - ( - name, - RemoteSkillFileRange { - start: range.start, - length: range.length, - }, - ) - }) - .collect(), - }) -} diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index bf489059bb..f9c56c5bc4 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -285,13 +285,14 @@ pub enum Op { }, /// Request the list of remote skills available via ChatGPT sharing. - ListRemoteSkills, + ListRemoteSkills { + hazelnut_scope: RemoteSkillHazelnutScope, + product_surface: RemoteSkillProductSurface, + enabled: Option, + }, /// Download a remote skill by id into the local skills cache. - DownloadRemoteSkill { - hazelnut_id: String, - is_preload: bool, - }, + DownloadRemoteSkill { hazelnut_id: String }, /// Request the agent to summarize the current conversation context. /// The agent will use its existing context (either conversation history or previous response id) @@ -2422,6 +2423,26 @@ pub struct RemoteSkillSummary { pub description: String, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "kebab-case")] +#[ts(rename_all = "kebab-case")] +pub enum RemoteSkillHazelnutScope { + WorkspaceShared, + AllShared, + Personal, + Example, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "lowercase")] +#[ts(rename_all = "lowercase")] +pub enum RemoteSkillProductSurface { + Chatgpt, + Codex, + Api, + Atlas, +} + /// Response payload for `Op::ListRemoteSkills`. #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct ListRemoteSkillsResponseEvent { From 41800fc87618591cecedd28f8778b038a22906d9 Mon Sep 17 00:00:00 2001 From: sayan-oai Date: Tue, 17 Feb 2026 11:43:16 -0800 Subject: [PATCH 54/62] chore: rm remote models fflag (#11699) rm `remote_models` feature flag. We see issues like #11527 when a user has `remote_models` disabled, as we always use the default fallback `ModelInfo`. This causes issues with model performance. Builds on #11690, which helps by warning the user when they are using the default fallback. This PR will make that happen much less frequently as an accidental consequence of disabling `remote_models`. --- .../app-server/src/codex_message_processor.rs | 9 +- codex-rs/app-server/src/models.rs | 4 +- codex-rs/app-server/tests/common/config.rs | 2 +- .../app-server/tests/suite/v2/plan_item.rs | 5 +- codex-rs/app-server/tests/suite/v2/review.rs | 1 - .../tests/suite/v2/thread_resume.rs | 2 - .../app-server/tests/suite/v2/turn_start.rs | 2 +- codex-rs/core/src/codex.rs | 12 +- codex-rs/core/src/config/service.rs | 4 +- codex-rs/core/src/features.rs | 6 +- codex-rs/core/src/models_manager/manager.rs | 150 +++++------------- codex-rs/core/src/thread_manager.rs | 3 +- codex-rs/core/tests/suite/compact.rs | 4 - .../core/tests/suite/model_info_overrides.rs | 5 +- codex-rs/core/tests/suite/model_switching.rs | 8 +- codex-rs/core/tests/suite/model_tools.rs | 1 - codex-rs/core/tests/suite/models_cache_ttl.rs | 15 +- .../core/tests/suite/models_etag_responses.rs | 2 - codex-rs/core/tests/suite/personality.rs | 150 +----------------- codex-rs/core/tests/suite/prompt_caching.rs | 1 - codex-rs/core/tests/suite/remote_models.rs | 69 ++------ codex-rs/core/tests/suite/rmcp_client.rs | 5 +- codex-rs/core/tests/suite/view_image.rs | 1 - codex-rs/exec/src/lib.rs | 2 +- codex-rs/mcp-server/tests/suite/codex_tool.rs | 1 - codex-rs/tui/src/app.rs | 4 +- codex-rs/tui/src/chatwidget.rs | 8 +- codex-rs/tui/src/chatwidget/tests.rs | 2 +- 28 files changed, 87 insertions(+), 391 deletions(-) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index b966942cbf..45d8af7746 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -621,11 +621,10 @@ impl CodexMessageProcessor { ClientRequest::ModelList { request_id, params } => { let outgoing = self.outgoing.clone(); let thread_manager = self.thread_manager.clone(); - let config = self.config.clone(); let request_id = to_connection_request_id(request_id); tokio::spawn(async move { - Self::list_models(outgoing, thread_manager, config, request_id, params).await; + Self::list_models(outgoing, thread_manager, request_id, params).await; }); } ClientRequest::ExperimentalFeatureList { request_id, params } => { @@ -3621,7 +3620,6 @@ impl CodexMessageProcessor { async fn list_models( outgoing: Arc, thread_manager: Arc, - config: Arc, request_id: ConnectionRequestId, params: ModelListParams, ) { @@ -3630,10 +3628,7 @@ impl CodexMessageProcessor { cursor, include_hidden, } = params; - let mut config = (*config).clone(); - config.features.enable(Feature::RemoteModels); - let models = - supported_models(thread_manager, &config, include_hidden.unwrap_or(false)).await; + let models = supported_models(thread_manager, include_hidden.unwrap_or(false)).await; let total = models.len(); if total == 0 { diff --git a/codex-rs/app-server/src/models.rs b/codex-rs/app-server/src/models.rs index d31e005210..1594c66229 100644 --- a/codex-rs/app-server/src/models.rs +++ b/codex-rs/app-server/src/models.rs @@ -3,18 +3,16 @@ use std::sync::Arc; use codex_app_server_protocol::Model; use codex_app_server_protocol::ReasoningEffortOption; use codex_core::ThreadManager; -use codex_core::config::Config; use codex_core::models_manager::manager::RefreshStrategy; use codex_protocol::openai_models::ModelPreset; use codex_protocol::openai_models::ReasoningEffortPreset; pub async fn supported_models( thread_manager: Arc, - config: &Config, include_hidden: bool, ) -> Vec { thread_manager - .list_models(config, RefreshStrategy::OnlineIfUncached) + .list_models(RefreshStrategy::OnlineIfUncached) .await .into_iter() .filter(|preset| include_hidden || preset.show_in_picker) diff --git a/codex-rs/app-server/tests/common/config.rs b/codex-rs/app-server/tests/common/config.rs index 09471b4a69..bffb35aa02 100644 --- a/codex-rs/app-server/tests/common/config.rs +++ b/codex-rs/app-server/tests/common/config.rs @@ -13,7 +13,7 @@ pub fn write_mock_responses_config_toml( compact_prompt: &str, ) -> std::io::Result<()> { // Phase 1: build the features block for config.toml. - let mut features = BTreeMap::from([(Feature::RemoteModels, false)]); + let mut features = BTreeMap::new(); for (feature, enabled) in feature_flags { features.insert(*feature, *enabled); } diff --git a/codex-rs/app-server/tests/suite/v2/plan_item.rs b/codex-rs/app-server/tests/suite/v2/plan_item.rs index d138954ac4..881bbb54c8 100644 --- a/codex-rs/app-server/tests/suite/v2/plan_item.rs +++ b/codex-rs/app-server/tests/suite/v2/plan_item.rs @@ -215,10 +215,7 @@ async fn collect_turn_notifications( } fn create_config_toml(codex_home: &Path, server_uri: &str) -> std::io::Result<()> { - let features = BTreeMap::from([ - (Feature::RemoteModels, false), - (Feature::CollaborationModes, true), - ]); + let features = BTreeMap::from([(Feature::CollaborationModes, true)]); let feature_entries = features .into_iter() .map(|(feature, enabled)| { diff --git a/codex-rs/app-server/tests/suite/v2/review.rs b/codex-rs/app-server/tests/suite/v2/review.rs index 05397d485d..219812d6b1 100644 --- a/codex-rs/app-server/tests/suite/v2/review.rs +++ b/codex-rs/app-server/tests/suite/v2/review.rs @@ -437,7 +437,6 @@ sandbox_mode = "read-only" model_provider = "mock_provider" [features] -remote_models = false shell_snapshot = false [model_providers.mock_provider] 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 e001d4a7fd..3976d3eea6 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -1008,7 +1008,6 @@ sandbox_mode = "read-only" model_provider = "mock_provider" [features] -remote_models = false personality = true [model_providers.mock_provider] @@ -1038,7 +1037,6 @@ sandbox_mode = "read-only" model_provider = "mock_provider" [features] -remote_models = false personality = true [model_providers.mock_provider] 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 97cb14f445..2eecfc5590 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -1871,7 +1871,7 @@ fn create_config_toml_with_sandbox( feature_flags: &BTreeMap, sandbox_mode: &str, ) -> std::io::Result<()> { - let mut features = BTreeMap::from([(Feature::RemoteModels, false)]); + let mut features = BTreeMap::new(); for (feature, enabled) in feature_flags { features.insert(*feature, *enabled); } diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index b96af69f03..cf362d4ba5 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -329,15 +329,11 @@ impl Codex { let config = Arc::new(config); let _ = models_manager - .list_models( - &config, - crate::models_manager::manager::RefreshStrategy::OnlineIfUncached, - ) + .list_models(crate::models_manager::manager::RefreshStrategy::OnlineIfUncached) .await; let model = models_manager .get_default_model( &config.model, - &config, crate::models_manager::manager::RefreshStrategy::OnlineIfUncached, ) .await; @@ -5583,11 +5579,7 @@ async fn try_run_sampling_request( } ResponseEvent::ModelsEtag(etag) => { // Update internal state with latest models etag - let config = sess.get_config().await; - sess.services - .models_manager - .refresh_if_new_etag(etag, &config) - .await; + sess.services.models_manager.refresh_if_new_etag(etag).await; } ResponseEvent::Completed { response_id: _, diff --git a/codex-rs/core/src/config/service.rs b/codex-rs/core/src/config/service.rs index db6963a2f8..02a9da23fd 100644 --- a/codex-rs/core/src/config/service.rs +++ b/codex-rs/core/src/config/service.rs @@ -777,7 +777,7 @@ unified_exec = true service .write_value(ConfigValueWriteParams { file_path: Some(tmp.path().join(CONFIG_TOML_FILE).display().to_string()), - key_path: "features.remote_models".to_string(), + key_path: "features.personality".to_string(), value: serde_json::json!(true), merge_strategy: MergeStrategy::Replace, expected_version: None, @@ -797,7 +797,7 @@ hide_full_access_warning = true [features] unified_exec = true -remote_models = true +personality = true "#; assert_eq!(updated, expected); Ok(()) diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs index 7ef7485ace..b55d8f260c 100644 --- a/codex-rs/core/src/features.rs +++ b/codex-rs/core/src/features.rs @@ -101,7 +101,7 @@ pub enum Feature { WindowsSandbox, /// Use the elevated Windows sandbox pipeline (setup + runner). WindowsSandboxElevated, - /// Refresh remote models and emit AppReady once the list is available. + /// Legacy remote models flag kept for backward compatibility. RemoteModels, /// Experimental shell snapshotting. ShellSnapshot, @@ -530,8 +530,8 @@ pub const FEATURES: &[FeatureSpec] = &[ FeatureSpec { id: Feature::RemoteModels, key: "remote_models", - stage: Stage::Stable, - default_enabled: true, + stage: Stage::Removed, + default_enabled: false, }, FeatureSpec { id: Feature::PowershellUtf8, diff --git a/codex-rs/core/src/models_manager/manager.rs b/codex-rs/core/src/models_manager/manager.rs index dd769da2e8..457351995f 100644 --- a/codex-rs/core/src/models_manager/manager.rs +++ b/codex-rs/core/src/models_manager/manager.rs @@ -7,7 +7,6 @@ use crate::config::Config; use crate::default_client::build_reqwest_client; use crate::error::CodexErr; use crate::error::Result as CoreResult; -use crate::features::Feature; use crate::model_provider_info::ModelProviderInfo; use crate::models_manager::collaboration_mode_presets::builtin_collaboration_mode_presets; use crate::models_manager::model_info; @@ -74,18 +73,11 @@ impl ModelsManager { /// List all available models, refreshing according to the specified strategy. /// /// Returns model presets sorted by priority and filtered by auth mode and visibility. - pub async fn list_models( - &self, - config: &Config, - refresh_strategy: RefreshStrategy, - ) -> Vec { - if let Err(err) = self - .refresh_available_models(config, refresh_strategy) - .await - { + pub async fn list_models(&self, refresh_strategy: RefreshStrategy) -> Vec { + if let Err(err) = self.refresh_available_models(refresh_strategy).await { error!("failed to refresh available models: {err}"); } - let remote_models = self.get_remote_models(config).await; + let remote_models = self.get_remote_models().await; self.build_available_models(remote_models) } @@ -99,8 +91,8 @@ impl ModelsManager { /// Attempt to list models without blocking, using the current cached state. /// /// Returns an error if the internal lock cannot be acquired. - pub fn try_list_models(&self, config: &Config) -> Result, TryLockError> { - let remote_models = self.try_get_remote_models(config)?; + pub fn try_list_models(&self) -> Result, TryLockError> { + let remote_models = self.try_get_remote_models()?; Ok(self.build_available_models(remote_models)) } @@ -112,19 +104,15 @@ impl ModelsManager { pub async fn get_default_model( &self, model: &Option, - config: &Config, refresh_strategy: RefreshStrategy, ) -> String { if let Some(model) = model.as_ref() { return model.to_string(); } - if let Err(err) = self - .refresh_available_models(config, refresh_strategy) - .await - { + if let Err(err) = self.refresh_available_models(refresh_strategy).await { error!("failed to refresh available models: {err}"); } - let remote_models = self.get_remote_models(config).await; + let remote_models = self.get_remote_models().await; let available = self.build_available_models(remote_models); available .iter() @@ -137,9 +125,7 @@ impl ModelsManager { // todo(aibrahim): look if we can tighten it to pub(crate) /// Look up model metadata, applying remote overrides and config adjustments. pub async fn get_model_info(&self, model: &str, config: &Config) -> ModelInfo { - let remote = self - .find_remote_model_by_longest_prefix(model, config) - .await; + let remote = self.find_remote_model_by_longest_prefix(model).await; let model_info = if let Some(remote) = remote { ModelInfo { slug: model.to_string(), @@ -152,13 +138,9 @@ impl ModelsManager { model_info::with_config_overrides(model_info, config) } - async fn find_remote_model_by_longest_prefix( - &self, - model: &str, - config: &Config, - ) -> Option { + async fn find_remote_model_by_longest_prefix(&self, model: &str) -> Option { let mut best: Option = None; - for candidate in self.get_remote_models(config).await { + for candidate in self.get_remote_models().await { if !model.starts_with(&candidate.slug) { continue; } @@ -177,7 +159,7 @@ impl ModelsManager { /// Refresh models if the provided ETag differs from the cached ETag. /// /// Uses `Online` strategy to fetch latest models when ETags differ. - pub(crate) async fn refresh_if_new_etag(&self, etag: String, config: &Config) { + pub(crate) async fn refresh_if_new_etag(&self, etag: String) { let current_etag = self.get_etag().await; if current_etag.clone().is_some() && current_etag.as_deref() == Some(etag.as_str()) { if let Err(err) = self.cache_manager.renew_cache_ttl().await { @@ -185,23 +167,13 @@ impl ModelsManager { } return; } - if let Err(err) = self - .refresh_available_models(config, RefreshStrategy::Online) - .await - { + if let Err(err) = self.refresh_available_models(RefreshStrategy::Online).await { error!("failed to refresh available models: {err}"); } } /// Refresh available models according to the specified strategy. - async fn refresh_available_models( - &self, - config: &Config, - refresh_strategy: RefreshStrategy, - ) -> CoreResult<()> { - if !config.features.enabled(Feature::RemoteModels) { - return Ok(()); - } + async fn refresh_available_models(&self, refresh_strategy: RefreshStrategy) -> CoreResult<()> { if self.auth_manager.auth_mode() != Some(AuthMode::Chatgpt) { if matches!( refresh_strategy, @@ -336,20 +308,12 @@ impl ModelsManager { merged_presets } - async fn get_remote_models(&self, config: &Config) -> Vec { - if config.features.enabled(Feature::RemoteModels) { - self.remote_models.read().await.clone() - } else { - Vec::new() - } + async fn get_remote_models(&self) -> Vec { + self.remote_models.read().await.clone() } - fn try_get_remote_models(&self, config: &Config) -> Result, TryLockError> { - if config.features.enabled(Feature::RemoteModels) { - Ok(self.remote_models.try_read()?.clone()) - } else { - Ok(Vec::new()) - } + fn try_get_remote_models(&self) -> Result, TryLockError> { + Ok(self.remote_models.try_read()?.clone()) } /// Construct a manager with a specific provider for testing. @@ -399,7 +363,6 @@ mod tests { use crate::CodexAuth; use crate::auth::AuthCredentialsStoreMode; use crate::config::ConfigBuilder; - use crate::features::Feature; use crate::model_provider_info::WireApi; use chrono::Utc; use codex_protocol::openai_models::ModelsResponse; @@ -476,17 +439,16 @@ mod tests { #[tokio::test] async fn get_model_info_tracks_fallback_usage() { let codex_home = tempdir().expect("temp dir"); - let mut config = ConfigBuilder::default() + let config = ConfigBuilder::default() .codex_home(codex_home.path().to_path_buf()) .build() .await .expect("load default test config"); - config.features.enable(Feature::RemoteModels); let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); let manager = ModelsManager::new(codex_home.path().to_path_buf(), auth_manager); let known_slug = manager - .get_remote_models(&config) + .get_remote_models() .await .first() .expect("bundled models should include at least one model") @@ -520,12 +482,6 @@ mod tests { .await; let codex_home = tempdir().expect("temp dir"); - let mut config = ConfigBuilder::default() - .codex_home(codex_home.path().to_path_buf()) - .build() - .await - .expect("load default test config"); - config.features.enable(Feature::RemoteModels); let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); let provider = provider_for(server.uri()); @@ -536,15 +492,13 @@ mod tests { ); manager - .refresh_available_models(&config, RefreshStrategy::OnlineIfUncached) + .refresh_available_models(RefreshStrategy::OnlineIfUncached) .await .expect("refresh succeeds"); - let cached_remote = manager.get_remote_models(&config).await; + let cached_remote = manager.get_remote_models().await; assert_models_contain(&cached_remote, &remote_models); - let available = manager - .list_models(&config, RefreshStrategy::OnlineIfUncached) - .await; + let available = manager.list_models(RefreshStrategy::OnlineIfUncached).await; let high_idx = available .iter() .position(|model| model.model == "priority-high") @@ -577,12 +531,6 @@ mod tests { .await; let codex_home = tempdir().expect("temp dir"); - let mut config = ConfigBuilder::default() - .codex_home(codex_home.path().to_path_buf()) - .build() - .await - .expect("load default test config"); - config.features.enable(Feature::RemoteModels); let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); let provider = provider_for(server.uri()); @@ -593,17 +541,17 @@ mod tests { ); manager - .refresh_available_models(&config, RefreshStrategy::OnlineIfUncached) + .refresh_available_models(RefreshStrategy::OnlineIfUncached) .await .expect("first refresh succeeds"); - assert_models_contain(&manager.get_remote_models(&config).await, &remote_models); + assert_models_contain(&manager.get_remote_models().await, &remote_models); // Second call should read from cache and avoid the network. manager - .refresh_available_models(&config, RefreshStrategy::OnlineIfUncached) + .refresh_available_models(RefreshStrategy::OnlineIfUncached) .await .expect("cached refresh succeeds"); - assert_models_contain(&manager.get_remote_models(&config).await, &remote_models); + assert_models_contain(&manager.get_remote_models().await, &remote_models); assert_eq!( models_mock.requests().len(), 1, @@ -624,12 +572,6 @@ mod tests { .await; let codex_home = tempdir().expect("temp dir"); - let mut config = ConfigBuilder::default() - .codex_home(codex_home.path().to_path_buf()) - .build() - .await - .expect("load default test config"); - config.features.enable(Feature::RemoteModels); let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); let provider = provider_for(server.uri()); @@ -640,7 +582,7 @@ mod tests { ); manager - .refresh_available_models(&config, RefreshStrategy::OnlineIfUncached) + .refresh_available_models(RefreshStrategy::OnlineIfUncached) .await .expect("initial refresh succeeds"); @@ -664,10 +606,10 @@ mod tests { .await; manager - .refresh_available_models(&config, RefreshStrategy::OnlineIfUncached) + .refresh_available_models(RefreshStrategy::OnlineIfUncached) .await .expect("second refresh succeeds"); - assert_models_contain(&manager.get_remote_models(&config).await, &updated_models); + assert_models_contain(&manager.get_remote_models().await, &updated_models); assert_eq!( initial_mock.requests().len(), 1, @@ -693,12 +635,6 @@ mod tests { .await; let codex_home = tempdir().expect("temp dir"); - let mut config = ConfigBuilder::default() - .codex_home(codex_home.path().to_path_buf()) - .build() - .await - .expect("load default test config"); - config.features.enable(Feature::RemoteModels); let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); let provider = provider_for(server.uri()); @@ -709,7 +645,7 @@ mod tests { ); manager - .refresh_available_models(&config, RefreshStrategy::OnlineIfUncached) + .refresh_available_models(RefreshStrategy::OnlineIfUncached) .await .expect("initial refresh succeeds"); @@ -733,10 +669,10 @@ mod tests { .await; manager - .refresh_available_models(&config, RefreshStrategy::OnlineIfUncached) + .refresh_available_models(RefreshStrategy::OnlineIfUncached) .await .expect("second refresh succeeds"); - assert_models_contain(&manager.get_remote_models(&config).await, &updated_models); + assert_models_contain(&manager.get_remote_models().await, &updated_models); assert_eq!( initial_mock.requests().len(), 1, @@ -762,12 +698,6 @@ mod tests { .await; let codex_home = tempdir().expect("temp dir"); - let mut config = ConfigBuilder::default() - .codex_home(codex_home.path().to_path_buf()) - .build() - .await - .expect("load default test config"); - config.features.enable(Feature::RemoteModels); let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); let provider = provider_for(server.uri()); @@ -779,7 +709,7 @@ mod tests { manager.cache_manager.set_ttl(Duration::ZERO); manager - .refresh_available_models(&config, RefreshStrategy::OnlineIfUncached) + .refresh_available_models(RefreshStrategy::OnlineIfUncached) .await .expect("initial refresh succeeds"); @@ -794,12 +724,12 @@ mod tests { .await; manager - .refresh_available_models(&config, RefreshStrategy::OnlineIfUncached) + .refresh_available_models(RefreshStrategy::OnlineIfUncached) .await .expect("second refresh succeeds"); let available = manager - .try_list_models(&config) + .try_list_models() .expect("models should be available"); assert!( available.iter().any(|preset| preset.model == "remote-new"), @@ -834,12 +764,6 @@ mod tests { .await; let codex_home = tempdir().expect("temp dir"); - let mut config = ConfigBuilder::default() - .codex_home(codex_home.path().to_path_buf()) - .build() - .await - .expect("load default test config"); - config.features.enable(Feature::RemoteModels); let auth_manager = Arc::new(AuthManager::new( codex_home.path().to_path_buf(), false, @@ -853,10 +777,10 @@ mod tests { ); manager - .refresh_available_models(&config, RefreshStrategy::Online) + .refresh_available_models(RefreshStrategy::Online) .await .expect("refresh should no-op without chatgpt auth"); - let cached_remote = manager.get_remote_models(&config).await; + let cached_remote = manager.get_remote_models().await; assert!( !cached_remote .iter() diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index b13ef8c61a..c16a883852 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -230,12 +230,11 @@ impl ThreadManager { pub async fn list_models( &self, - config: &Config, refresh_strategy: crate::models_manager::manager::RefreshStrategy, ) -> Vec { self.state .models_manager - .list_models(config, refresh_strategy) + .list_models(refresh_strategy) .await } diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index 25278073ec..22a6c72076 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -5,7 +5,6 @@ use codex_core::built_in_model_providers; use codex_core::compact::SUMMARIZATION_PROMPT; use codex_core::compact::SUMMARY_PREFIX; use codex_core::config::Config; -use codex_core::features::Feature; use codex_core::protocol::AskForApproval; use codex_core::protocol::EventMsg; use codex_core::protocol::ItemCompletedEvent; @@ -1708,7 +1707,6 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { .with_config(move |config| { config.model_provider = model_provider; set_test_compact_prompt(config); - config.features.enable(Feature::RemoteModels); }); let test = builder.build(&server).await.expect("build test codex"); @@ -1831,7 +1829,6 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { .with_config(move |config| { config.model_provider = model_provider; set_test_compact_prompt(config); - config.features.enable(Feature::RemoteModels); }); let initial = initial_builder .build(&server) @@ -1885,7 +1882,6 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { .with_config(move |config| { config.model_provider = model_provider; set_test_compact_prompt(config); - config.features.enable(Feature::RemoteModels); }); let resumed = resumed_builder .resume(&server, home, rollout_path) diff --git a/codex-rs/core/tests/suite/model_info_overrides.rs b/codex-rs/core/tests/suite/model_info_overrides.rs index a9e18f9605..2b085a004f 100644 --- a/codex-rs/core/tests/suite/model_info_overrides.rs +++ b/codex-rs/core/tests/suite/model_info_overrides.rs @@ -1,5 +1,4 @@ use codex_core::CodexAuth; -use codex_core::features::Feature; use codex_core::models_manager::manager::ModelsManager; use codex_protocol::openai_models::TruncationPolicyConfig; use core_test_support::load_default_config_for_test; @@ -9,8 +8,7 @@ use tempfile::TempDir; #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn offline_model_info_without_tool_output_override() { let codex_home = TempDir::new().expect("create temp dir"); - let mut config = load_default_config_for_test(&codex_home).await; - config.features.enable(Feature::RemoteModels); + let config = load_default_config_for_test(&codex_home).await; let auth_manager = codex_core::test_support::auth_manager_from_auth( CodexAuth::create_dummy_chatgpt_auth_for_testing(), ); @@ -28,7 +26,6 @@ async fn offline_model_info_without_tool_output_override() { async fn offline_model_info_with_tool_output_override() { let codex_home = TempDir::new().expect("create temp dir"); let mut config = load_default_config_for_test(&codex_home).await; - config.features.enable(Feature::RemoteModels); config.tool_output_token_limit = Some(123); let auth_manager = codex_core::test_support::auth_manager_from_auth( CodexAuth::create_dummy_chatgpt_auth_for_testing(), diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index 44d0d8a2fc..4d4abafeb6 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -266,13 +266,12 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< let mut builder = test_codex() .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) .with_config(move |config| { - config.features.enable(Feature::RemoteModels); config.model = Some(image_model_slug.to_string()); }); let test = builder.build(&server).await?; let models_manager = test.thread_manager.get_models_manager(); let _ = models_manager - .list_models(&test.config, RefreshStrategy::OnlineIfUncached) + .list_models(RefreshStrategy::OnlineIfUncached) .await; let image_url = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGNgYAAAAAMAASsJTYQAAAAASUVORK5CYII=" .to_string(); @@ -434,15 +433,12 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< let mut builder = test_codex() .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) .with_config(|config| { - config.features.enable(Feature::RemoteModels); config.model = Some(large_model_slug.to_string()); }); let test = builder.build(&server).await?; let models_manager = test.thread_manager.get_models_manager(); - let available_models = models_manager - .list_models(&test.config, RefreshStrategy::Online) - .await; + let available_models = models_manager.list_models(RefreshStrategy::Online).await; assert!( available_models .iter() diff --git a/codex-rs/core/tests/suite/model_tools.rs b/codex-rs/core/tests/suite/model_tools.rs index 836807881b..026be98dab 100644 --- a/codex-rs/core/tests/suite/model_tools.rs +++ b/codex-rs/core/tests/suite/model_tools.rs @@ -36,7 +36,6 @@ async fn collect_tool_identifiers_for_model(model: &str) -> Vec { .with_model(model) // Keep tool expectations stable when the default web_search mode changes. .with_config(|config| { - config.features.enable(Feature::RemoteModels); config .web_search_mode .set(WebSearchMode::Cached) diff --git a/codex-rs/core/tests/suite/models_cache_ttl.rs b/codex-rs/core/tests/suite/models_cache_ttl.rs index 780937c111..e9f0a73f98 100644 --- a/codex-rs/core/tests/suite/models_cache_ttl.rs +++ b/codex-rs/core/tests/suite/models_cache_ttl.rs @@ -6,7 +6,6 @@ use chrono::DateTime; use chrono::TimeZone; use chrono::Utc; use codex_core::CodexAuth; -use codex_core::features::Feature; use codex_core::models_manager::manager::RefreshStrategy; use codex_core::protocol::EventMsg; use codex_core::protocol::Op; @@ -57,7 +56,6 @@ async fn renews_cache_ttl_on_matching_models_etag() -> Result<()> { let mut builder = test_codex().with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()); builder = builder.with_config(|config| { - config.features.enable(Feature::RemoteModels); config.model = Some("gpt-5".to_string()); config.model_provider.request_max_retries = Some(0); config.model_provider.stream_max_retries = Some(1); @@ -70,7 +68,7 @@ async fn renews_cache_ttl_on_matching_models_etag() -> Result<()> { // Populate cache via initial refresh. let models_manager = test.thread_manager.get_models_manager(); let _ = models_manager - .list_models(&config, RefreshStrategy::OnlineIfUncached) + .list_models(RefreshStrategy::OnlineIfUncached) .await; let cache_path = config.codex_home.join(CACHE_FILE); @@ -123,7 +121,7 @@ async fn renews_cache_ttl_on_matching_models_etag() -> Result<()> { // Cached models remain usable offline. let offline_models = test .thread_manager - .list_models(&config, RefreshStrategy::Offline) + .list_models(RefreshStrategy::Offline) .await; assert!( offline_models @@ -160,14 +158,13 @@ async fn uses_cache_when_version_matches() -> Result<()> { write_cache_sync(&cache_path, &cache).expect("write cache"); }) .with_config(|config| { - config.features.enable(Feature::RemoteModels); config.model_provider.request_max_retries = Some(0); }); let test = builder.build(&server).await?; let models_manager = test.thread_manager.get_models_manager(); let models = models_manager - .list_models(&test.config, RefreshStrategy::OnlineIfUncached) + .list_models(RefreshStrategy::OnlineIfUncached) .await; assert!( @@ -208,14 +205,13 @@ async fn refreshes_when_cache_version_missing() -> Result<()> { write_cache_sync(&cache_path, &cache).expect("write cache"); }) .with_config(|config| { - config.features.enable(Feature::RemoteModels); config.model_provider.request_max_retries = Some(0); }); let test = builder.build(&server).await?; let models_manager = test.thread_manager.get_models_manager(); let models = models_manager - .list_models(&test.config, RefreshStrategy::OnlineIfUncached) + .list_models(RefreshStrategy::OnlineIfUncached) .await; assert!( @@ -257,14 +253,13 @@ async fn refreshes_when_cache_version_differs() -> Result<()> { write_cache_sync(&cache_path, &cache).expect("write cache"); }) .with_config(|config| { - config.features.enable(Feature::RemoteModels); config.model_provider.request_max_retries = Some(0); }); let test = builder.build(&server).await?; let models_manager = test.thread_manager.get_models_manager(); let models = models_manager - .list_models(&test.config, RefreshStrategy::OnlineIfUncached) + .list_models(RefreshStrategy::OnlineIfUncached) .await; assert!( diff --git a/codex-rs/core/tests/suite/models_etag_responses.rs b/codex-rs/core/tests/suite/models_etag_responses.rs index 413616b9dc..13c31cd7f9 100644 --- a/codex-rs/core/tests/suite/models_etag_responses.rs +++ b/codex-rs/core/tests/suite/models_etag_responses.rs @@ -4,7 +4,6 @@ use std::sync::Arc; use anyhow::Result; use codex_core::CodexAuth; -use codex_core::features::Feature; use codex_core::protocol::AskForApproval; use codex_core::protocol::EventMsg; use codex_core::protocol::Op; @@ -48,7 +47,6 @@ async fn refresh_models_on_models_etag_mismatch_and_avoid_duplicate_models_fetch .with_auth(auth) .with_model("gpt-5") .with_config(|config| { - config.features.enable(Feature::RemoteModels); // Keep this test deterministic: no request retries, and a small stream retry budget. config.model_provider.request_max_retries = Some(0); config.model_provider.stream_max_retries = Some(1); diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs index 51f99720aa..d15147ed36 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -81,7 +81,6 @@ async fn user_turn_personality_none_does_not_add_update_message() -> anyhow::Res let mut builder = test_codex() .with_model("gpt-5.2-codex") .with_config(|config| { - config.features.disable(Feature::RemoteModels); config.features.enable(Feature::Personality); }); let test = builder.build(&server).await?; @@ -127,7 +126,6 @@ async fn config_personality_some_sets_instructions_template() -> anyhow::Result< let mut builder = test_codex() .with_model("gpt-5.2-codex") .with_config(|config| { - config.features.disable(Feature::RemoteModels); config.features.enable(Feature::Personality); config.personality = Some(Personality::Friendly); }); @@ -181,7 +179,6 @@ async fn config_personality_none_sends_no_personality() -> anyhow::Result<()> { let mut builder = test_codex() .with_model("gpt-5.2-codex") .with_config(|config| { - config.features.disable(Feature::RemoteModels); config.features.enable(Feature::Personality); config.personality = Some(Personality::None); }); @@ -242,7 +239,6 @@ async fn default_personality_is_pragmatic_without_config_toml() -> anyhow::Resul let mut builder = test_codex() .with_model("gpt-5.2-codex") .with_config(|config| { - config.features.disable(Feature::RemoteModels); config.features.enable(Feature::Personality); }); let test = builder.build(&server).await?; @@ -290,7 +286,6 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> let mut builder = test_codex() .with_model("exp-codex-personality") .with_config(|config| { - config.features.disable(Feature::RemoteModels); config.features.enable(Feature::Personality); }); let test = builder.build(&server).await?; @@ -386,7 +381,6 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho let mut builder = test_codex() .with_model("exp-codex-personality") .with_config(|config| { - config.features.disable(Feature::RemoteModels); config.features.enable(Feature::Personality); config.personality = Some(Personality::Pragmatic); }); @@ -494,7 +488,6 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> let mut builder = test_codex() .with_model("exp-codex-personality") .with_config(|config| { - config.features.disable(Feature::RemoteModels); config.features.disable(Feature::Personality); }); let test = builder.build(&server).await?; @@ -570,123 +563,6 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> Ok(()) } -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn ignores_remote_personality_if_remote_models_disabled() -> anyhow::Result<()> { - skip_if_no_network!(Ok(())); - - let server = MockServer::builder() - .body_print_limit(BodyPrintLimit::Limited(80_000)) - .start() - .await; - - let remote_slug = "gpt-5.2-codex"; - let remote_personality_message = "Friendly from remote template"; - let remote_model = ModelInfo { - slug: remote_slug.to_string(), - display_name: "Remote personality test".to_string(), - description: Some("Remote model with personality template".to_string()), - default_reasoning_level: Some(ReasoningEffort::Medium), - supported_reasoning_levels: vec![ReasoningEffortPreset { - effort: ReasoningEffort::Medium, - description: ReasoningEffort::Medium.to_string(), - }], - shell_type: ConfigShellToolType::UnifiedExec, - visibility: ModelVisibility::List, - supported_in_api: true, - priority: 1, - upgrade: None, - base_instructions: "base instructions".to_string(), - model_messages: Some(ModelMessages { - instructions_template: Some("Base instructions\n{{ personality }}\n".to_string()), - instructions_variables: Some(ModelInstructionsVariables { - personality_default: None, - personality_friendly: Some(remote_personality_message.to_string()), - personality_pragmatic: None, - }), - }), - supports_reasoning_summaries: false, - support_verbosity: false, - default_verbosity: None, - apply_patch_tool_type: None, - truncation_policy: TruncationPolicyConfig::bytes(10_000), - supports_parallel_tool_calls: false, - context_window: Some(128_000), - auto_compact_token_limit: None, - effective_context_window_percent: 95, - experimental_supported_tools: Vec::new(), - input_modalities: default_input_modalities(), - prefer_websockets: false, - used_fallback_model_metadata: false, - }; - - let _models_mock = mount_models_once( - &server, - ModelsResponse { - models: vec![remote_model], - }, - ) - .await; - - let resp_mock = mount_sse_once(&server, sse_completed("resp-1")).await; - - let mut builder = test_codex() - .with_auth(codex_core::CodexAuth::create_dummy_chatgpt_auth_for_testing()) - .with_config(|config| { - config.features.disable(Feature::RemoteModels); - config.features.enable(Feature::Personality); - config.model = Some(remote_slug.to_string()); - config.personality = Some(Personality::Friendly); - }); - let test = builder.build(&server).await?; - - wait_for_model_available( - &test.thread_manager.get_models_manager(), - remote_slug, - &test.config, - ) - .await; - - test.codex - .submit(Op::UserTurn { - items: vec![UserInput::Text { - text: "hello".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - cwd: test.cwd_path().to_path_buf(), - approval_policy: AskForApproval::Never, - sandbox_policy: SandboxPolicy::new_read_only_policy(), - model: remote_slug.to_string(), - effort: test.config.model_reasoning_effort, - summary: ReasoningSummary::Auto, - collaboration_mode: None, - personality: None, - }) - .await?; - - wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; - - let request = resp_mock.single_request(); - let instructions_text = request.instructions_text(); - - assert!( - instructions_text.contains("You are Codex, a coding agent based on GPT-5. You and the user share the same workspace and collaborate to achieve the user's goals."), - "expected instructions to use the template instructions, got: {instructions_text:?}" - ); - assert!( - instructions_text.contains( - "You optimize for team morale and being a supportive teammate as much as code quality." - ), - "expected instructions to include the local friendly personality template, got: {instructions_text:?}" - ); - assert!( - !instructions_text.contains("{{ personality }}"), - "expected legacy personality placeholder to be replaced, got: {instructions_text:?}" - ); - - Ok(()) -} - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow::Result<()> { skip_if_no_network!(Ok(())); @@ -750,19 +626,13 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow let mut builder = test_codex() .with_auth(codex_core::CodexAuth::create_dummy_chatgpt_auth_for_testing()) .with_config(|config| { - config.features.enable(Feature::RemoteModels); config.features.enable(Feature::Personality); config.model = Some(remote_slug.to_string()); config.personality = Some(Personality::Friendly); }); let test = builder.build(&server).await?; - wait_for_model_available( - &test.thread_manager.get_models_manager(), - remote_slug, - &test.config, - ) - .await; + wait_for_model_available(&test.thread_manager.get_models_manager(), remote_slug).await; test.codex .submit(Op::UserTurn { @@ -867,18 +737,12 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - let mut builder = test_codex() .with_auth(codex_core::CodexAuth::create_dummy_chatgpt_auth_for_testing()) .with_config(|config| { - config.features.enable(Feature::RemoteModels); config.features.enable(Feature::Personality); config.model = Some("gpt-5.2-codex".to_string()); }); let test = builder.build(&server).await?; - wait_for_model_available( - &test.thread_manager.get_models_manager(), - remote_slug, - &test.config, - ) - .await; + wait_for_model_available(&test.thread_manager.get_models_manager(), remote_slug).await; test.codex .submit(Op::UserTurn { @@ -957,16 +821,10 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - Ok(()) } -async fn wait_for_model_available( - manager: &Arc, - slug: &str, - config: &codex_core::config::Config, -) { +async fn wait_for_model_available(manager: &Arc, slug: &str) { let deadline = Instant::now() + Duration::from_secs(2); loop { - let models = manager - .list_models(config, RefreshStrategy::OnlineIfUncached) - .await; + let models = manager.list_models(RefreshStrategy::OnlineIfUncached).await; if models.iter().any(|model| model.model == slug) { return; } diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 71a565540b..cd3b4272a0 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -194,7 +194,6 @@ async fn gpt_5_tools_without_apply_patch_append_apply_patch_instructions() -> an let TestCodex { codex, .. } = test_codex() .with_config(|config| { config.user_instructions = Some("be consistent and helpful".to_string()); - config.features.enable(Feature::RemoteModels); config.features.disable(Feature::ApplyPatchFreeform); config.features.enable(Feature::CollaborationModes); config.model = Some("gpt-5".to_string()); diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index 29ba047d01..110150d447 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -7,8 +7,6 @@ use anyhow::Result; use codex_core::CodexAuth; use codex_core::ModelProviderInfo; use codex_core::built_in_model_providers; -use codex_core::config::Config; -use codex_core::features::Feature; use codex_core::models_manager::manager::ModelsManager; use codex_core::models_manager::manager::RefreshStrategy; use codex_core::protocol::AskForApproval; @@ -92,8 +90,7 @@ async fn remote_models_get_model_info_uses_longest_matching_prefix() -> Result<( .await; let codex_home = TempDir::new()?; - let mut config = load_default_config_for_test(&codex_home).await; - config.features.enable(Feature::RemoteModels); + let config = load_default_config_for_test(&codex_home).await; let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); let provider = ModelProviderInfo { @@ -106,9 +103,7 @@ async fn remote_models_get_model_info_uses_longest_matching_prefix() -> Result<( provider, ); - manager - .list_models(&config, RefreshStrategy::OnlineIfUncached) - .await; + manager.list_models(RefreshStrategy::OnlineIfUncached).await; let model_info = manager.get_model_info("gpt-5.3-codex-test", &config).await; @@ -163,7 +158,6 @@ async fn remote_models_long_model_slug_is_sent_with_high_reasoning() -> Result<( } = test_codex() .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) .with_config(|config| { - config.features.enable(Feature::RemoteModels); config.model = Some(requested_model.to_string()); }) .build(&server) @@ -253,7 +247,6 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { let mut builder = test_codex() .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) .with_config(|config| { - config.features.enable(Feature::RemoteModels); config.model = Some("gpt-5.1".to_string()); }); let TestCodex { @@ -265,8 +258,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { } = builder.build(&server).await?; let models_manager = thread_manager.get_models_manager(); - let available_model = - wait_for_model_available(&models_manager, REMOTE_MODEL_SLUG, &config).await; + let available_model = wait_for_model_available(&models_manager, REMOTE_MODEL_SLUG).await; assert_eq!(available_model.model, REMOTE_MODEL_SLUG); @@ -375,13 +367,12 @@ async fn remote_models_truncation_policy_without_override_preserves_remote() -> let mut builder = test_codex() .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) .with_config(|config| { - config.features.enable(Feature::RemoteModels); config.model = Some("gpt-5.1".to_string()); }); let test = builder.build(&server).await?; let models_manager = test.thread_manager.get_models_manager(); - wait_for_model_available(&models_manager, slug, &test.config).await; + wait_for_model_available(&models_manager, slug).await; let model_info = models_manager.get_model_info(slug, &test.config).await; assert_eq!( @@ -420,14 +411,13 @@ async fn remote_models_truncation_policy_with_tool_output_override() -> Result<( let mut builder = test_codex() .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) .with_config(|config| { - config.features.enable(Feature::RemoteModels); config.model = Some("gpt-5.1".to_string()); config.tool_output_token_limit = Some(50); }); let test = builder.build(&server).await?; let models_manager = test.thread_manager.get_models_manager(); - wait_for_model_available(&models_manager, slug, &test.config).await; + wait_for_model_available(&models_manager, slug).await; let model_info = models_manager.get_model_info(slug, &test.config).await; assert_eq!( @@ -502,7 +492,6 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { let mut builder = test_codex() .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) .with_config(|config| { - config.features.enable(Feature::RemoteModels); config.model = Some("gpt-5.1".to_string()); }); let TestCodex { @@ -514,7 +503,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { } = builder.build(&server).await?; let models_manager = thread_manager.get_models_manager(); - wait_for_model_available(&models_manager, model, &config).await; + wait_for_model_available(&models_manager, model).await; codex .submit(Op::OverrideTurnContext { @@ -574,8 +563,6 @@ async fn remote_models_preserve_builtin_presets() -> Result<()> { .await; let codex_home = TempDir::new()?; - let mut config = load_default_config_for_test(&codex_home).await; - config.features.enable(Feature::RemoteModels); let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); let provider = ModelProviderInfo { @@ -588,9 +575,7 @@ async fn remote_models_preserve_builtin_presets() -> Result<()> { provider, ); - let available = manager - .list_models(&config, RefreshStrategy::OnlineIfUncached) - .await; + let available = manager.list_models(RefreshStrategy::OnlineIfUncached).await; let remote = available .iter() .find(|model| model.model == "remote-alpha") @@ -639,8 +624,6 @@ async fn remote_models_merge_adds_new_high_priority_first() -> Result<()> { .await; let codex_home = TempDir::new()?; - let mut config = load_default_config_for_test(&codex_home).await; - config.features.enable(Feature::RemoteModels); let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); let provider = ModelProviderInfo { @@ -653,9 +636,7 @@ async fn remote_models_merge_adds_new_high_priority_first() -> Result<()> { provider, ); - let available = manager - .list_models(&config, RefreshStrategy::OnlineIfUncached) - .await; + let available = manager.list_models(RefreshStrategy::OnlineIfUncached).await; assert_eq!( available.first().map(|model| model.model.as_str()), Some("remote-top") @@ -690,8 +671,6 @@ async fn remote_models_merge_replaces_overlapping_model() -> Result<()> { .await; let codex_home = TempDir::new()?; - let mut config = load_default_config_for_test(&codex_home).await; - config.features.enable(Feature::RemoteModels); let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); let provider = ModelProviderInfo { @@ -704,9 +683,7 @@ async fn remote_models_merge_replaces_overlapping_model() -> Result<()> { provider, ); - let available = manager - .list_models(&config, RefreshStrategy::OnlineIfUncached) - .await; + let available = manager.list_models(RefreshStrategy::OnlineIfUncached).await; let overridden = available .iter() .find(|model| model.model == slug) @@ -738,8 +715,6 @@ async fn remote_models_merge_preserves_bundled_models_on_empty_response() -> Res let _models_mock = mount_models_once(&server, ModelsResponse { models: Vec::new() }).await; let codex_home = TempDir::new()?; - let mut config = load_default_config_for_test(&codex_home).await; - config.features.enable(Feature::RemoteModels); let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); let provider = ModelProviderInfo { @@ -752,9 +727,7 @@ async fn remote_models_merge_preserves_bundled_models_on_empty_response() -> Res provider, ); - let available = manager - .list_models(&config, RefreshStrategy::OnlineIfUncached) - .await; + let available = manager.list_models(RefreshStrategy::OnlineIfUncached).await; let bundled_slug = bundled_model_slug(); assert!( available.iter().any(|model| model.model == bundled_slug), @@ -783,8 +756,6 @@ async fn remote_models_request_times_out_after_5s() -> Result<()> { .await; let codex_home = TempDir::new()?; - let mut config = load_default_config_for_test(&codex_home).await; - config.features.enable(Feature::RemoteModels); let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); let provider = ModelProviderInfo { @@ -800,7 +771,7 @@ async fn remote_models_request_times_out_after_5s() -> Result<()> { let start = Instant::now(); let model = timeout( Duration::from_secs(7), - manager.get_default_model(&None, &config, RefreshStrategy::OnlineIfUncached), + manager.get_default_model(&None, RefreshStrategy::OnlineIfUncached), ) .await; let elapsed = start.elapsed(); @@ -850,8 +821,6 @@ async fn remote_models_hide_picker_only_models() -> Result<()> { .await; let codex_home = TempDir::new()?; - let mut config = load_default_config_for_test(&codex_home).await; - config.features.enable(Feature::RemoteModels); let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); let provider = ModelProviderInfo { @@ -865,13 +834,11 @@ async fn remote_models_hide_picker_only_models() -> Result<()> { ); let selected = manager - .get_default_model(&None, &config, RefreshStrategy::OnlineIfUncached) + .get_default_model(&None, RefreshStrategy::OnlineIfUncached) .await; assert_eq!(selected, "gpt-5.2-codex"); - let available = manager - .list_models(&config, RefreshStrategy::OnlineIfUncached) - .await; + let available = manager.list_models(RefreshStrategy::OnlineIfUncached).await; let hidden = available .iter() .find(|model| model.model == "codex-auto-balanced") @@ -888,17 +855,11 @@ async fn remote_models_hide_picker_only_models() -> Result<()> { Ok(()) } -async fn wait_for_model_available( - manager: &Arc, - slug: &str, - config: &Config, -) -> ModelPreset { +async fn wait_for_model_available(manager: &Arc, slug: &str) -> ModelPreset { let deadline = Instant::now() + Duration::from_secs(2); loop { if let Some(model) = { - let guard = manager - .list_models(config, RefreshStrategy::OnlineIfUncached) - .await; + let guard = manager.list_models(RefreshStrategy::OnlineIfUncached).await; guard.iter().find(|model| model.model == slug).cloned() } { return model; diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 467f4e354d..d1394f7a74 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -11,7 +11,6 @@ use std::time::UNIX_EPOCH; use codex_core::CodexAuth; use codex_core::config::types::McpServerConfig; use codex_core::config::types::McpServerTransportConfig; -use codex_core::features::Feature; use codex_core::models_manager::manager::RefreshStrategy; use codex_core::protocol::AskForApproval; @@ -441,8 +440,6 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re let fixture = test_codex() .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) .with_config(move |config| { - config.features.enable(Feature::RemoteModels); - let mut servers = config.mcp_servers.get().clone(); servers.insert( server_name.to_string(), @@ -478,7 +475,7 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re fixture .thread_manager .get_models_manager() - .list_models(&fixture.config, RefreshStrategy::Online) + .list_models(RefreshStrategy::Online) .await; assert_eq!(models_mock.requests().len(), 1); diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index bd5bd6a037..bad9efa2d0 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -698,7 +698,6 @@ async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> an let TestCodex { codex, cwd, .. } = test_codex() .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) .with_config(|config| { - config.features.enable(Feature::RemoteModels); config.model = Some(model_slug.to_string()); }) .build(&server) diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 68c88f0000..49163c900b 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -374,7 +374,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any )); let default_model = thread_manager .get_models_manager() - .get_default_model(&config.model, &config, RefreshStrategy::OnlineIfUncached) + .get_default_model(&config.model, RefreshStrategy::OnlineIfUncached) .await; // Handle resume subcommand by resolving a rollout path and using explicit resume API. diff --git a/codex-rs/mcp-server/tests/suite/codex_tool.rs b/codex-rs/mcp-server/tests/suite/codex_tool.rs index c898b8f0d7..de8a9306d4 100644 --- a/codex-rs/mcp-server/tests/suite/codex_tool.rs +++ b/codex-rs/mcp-server/tests/suite/codex_tool.rs @@ -507,7 +507,6 @@ request_max_retries = 0 stream_max_retries = 0 [features] -remote_models = false "# ), ) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index b23b5f1a13..108f85ff8a 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -1029,11 +1029,11 @@ impl App { )); let mut model = thread_manager .get_models_manager() - .get_default_model(&config.model, &config, RefreshStrategy::Offline) + .get_default_model(&config.model, RefreshStrategy::Offline) .await; let available_models = thread_manager .get_models_manager() - .list_models(&config, RefreshStrategy::Offline) + .list_models(RefreshStrategy::Offline) .await; let exit_info = handle_model_migration_prompt_if_needed( tui, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 9da2e6f9c4..0a6da6a5ae 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -4699,7 +4699,7 @@ impl ChatWidget { } fn lower_cost_preset(&self) -> Option { - let models = self.models_manager.try_list_models(&self.config).ok()?; + let models = self.models_manager.try_list_models().ok()?; models .iter() .find(|preset| preset.show_in_picker && preset.model == NUDGE_MODEL_SLUG) @@ -4816,7 +4816,7 @@ impl ChatWidget { return; } - let presets: Vec = match self.models_manager.try_list_models(&self.config) { + let presets: Vec = match self.models_manager.try_list_models() { Ok(models) => models, Err(_) => { self.add_info_message( @@ -6138,7 +6138,7 @@ impl ChatWidget { fn current_model_supports_personality(&self) -> bool { let model = self.current_model(); self.models_manager - .try_list_models(&self.config) + .try_list_models() .ok() .and_then(|models| { models @@ -6156,7 +6156,7 @@ impl ChatWidget { fn current_model_supports_images(&self) -> bool { let model = self.current_model(); self.models_manager - .try_list_models(&self.config) + .try_list_models() .ok() .and_then(|models| { models diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index f069db9160..f49d318274 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -2749,7 +2749,7 @@ fn active_blob(chat: &ChatWidget) -> String { fn get_available_model(chat: &ChatWidget, model: &str) -> ModelPreset { let models = chat .models_manager - .try_list_models(&chat.config) + .try_list_models() .expect("models lock available"); models .iter() From 16fa195fce651123908060a975fb30ee2fd26fb9 Mon Sep 17 00:00:00 2001 From: Matthew Zeng Date: Tue, 17 Feb 2026 11:45:04 -0800 Subject: [PATCH 55/62] [apps] Expose more fields from apps listing endpoints. (#11706) - [x] Expose app_metadata, branding, and labels in AppInfo. --- .../schema/json/ServerNotification.json | 195 ++++++++++++++++++ .../codex_app_server_protocol.schemas.json | 195 ++++++++++++++++++ .../json/v2/AppListUpdatedNotification.json | 195 ++++++++++++++++++ .../schema/json/v2/AppsListResponse.json | 195 ++++++++++++++++++ .../schema/typescript/v2/AppBranding.ts | 8 + .../schema/typescript/v2/AppInfo.ts | 4 +- .../schema/typescript/v2/AppMetadata.ts | 7 + .../schema/typescript/v2/AppReview.ts | 5 + .../schema/typescript/v2/AppScreenshot.ts | 5 + .../schema/typescript/v2/index.ts | 4 + .../app-server-protocol/src/protocol/v2.rs | 52 +++++ codex-rs/app-server/README.md | 8 +- .../app-server/tests/suite/v2/app_list.rs | 119 +++++++++++ codex-rs/chatgpt/src/connectors.rs | 122 +++++++++++ codex-rs/core/src/codex.rs | 3 + codex-rs/core/src/connectors.rs | 5 + .../src/tools/handlers/search_tool_bm25.rs | 3 + codex-rs/tui/src/bottom_pane/chat_composer.rs | 6 + codex-rs/tui/src/chatwidget/tests.rs | 45 ++++ 19 files changed, 1174 insertions(+), 2 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/AppBranding.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/AppMetadata.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/AppReview.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/AppScreenshot.ts diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 8cec894992..085bbee050 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -165,9 +165,71 @@ } ] }, + "AppBranding": { + "description": "EXPERIMENTAL - app metadata returned by app-list APIs.", + "properties": { + "category": { + "type": [ + "string", + "null" + ] + }, + "developer": { + "type": [ + "string", + "null" + ] + }, + "isDiscoverableApp": { + "type": "boolean" + }, + "privacyPolicy": { + "type": [ + "string", + "null" + ] + }, + "termsOfService": { + "type": [ + "string", + "null" + ] + }, + "website": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "isDiscoverableApp" + ], + "type": "object" + }, "AppInfo": { "description": "EXPERIMENTAL - app metadata returned by app-list APIs.", "properties": { + "appMetadata": { + "anyOf": [ + { + "$ref": "#/definitions/AppMetadata" + }, + { + "type": "null" + } + ] + }, + "branding": { + "anyOf": [ + { + "$ref": "#/definitions/AppBranding" + }, + { + "type": "null" + } + ] + }, "description": { "type": [ "string", @@ -198,6 +260,15 @@ "description": "Whether this app is enabled in config.toml. Example: ```toml [apps.bad_app] enabled = false ```", "type": "boolean" }, + "labels": { + "additionalProperties": { + "type": "string" + }, + "type": [ + "object", + "null" + ] + }, "logoUrl": { "type": [ "string", @@ -235,6 +306,130 @@ ], "type": "object" }, + "AppMetadata": { + "properties": { + "categories": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "developer": { + "type": [ + "string", + "null" + ] + }, + "firstPartyRequiresInstall": { + "type": [ + "boolean", + "null" + ] + }, + "firstPartyType": { + "type": [ + "string", + "null" + ] + }, + "review": { + "anyOf": [ + { + "$ref": "#/definitions/AppReview" + }, + { + "type": "null" + } + ] + }, + "screenshots": { + "items": { + "$ref": "#/definitions/AppScreenshot" + }, + "type": [ + "array", + "null" + ] + }, + "seoDescription": { + "type": [ + "string", + "null" + ] + }, + "showInComposerWhenUnlinked": { + "type": [ + "boolean", + "null" + ] + }, + "subCategories": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "version": { + "type": [ + "string", + "null" + ] + }, + "versionId": { + "type": [ + "string", + "null" + ] + }, + "versionNotes": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "AppReview": { + "properties": { + "status": { + "type": "string" + } + }, + "required": [ + "status" + ], + "type": "object" + }, + "AppScreenshot": { + "properties": { + "fileId": { + "type": [ + "string", + "null" + ] + }, + "url": { + "type": [ + "string", + "null" + ] + }, + "userPrompt": { + "type": "string" + } + }, + "required": [ + "userPrompt" + ], + "type": "object" + }, "AskForApproval": { "description": "Determines the conditions under which the user is consulted to approve running the command proposed by Codex.", "oneOf": [ 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 ad1342d128..102baf2ffb 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 @@ -10271,6 +10271,48 @@ }, "type": "object" }, + "AppBranding": { + "description": "EXPERIMENTAL - app metadata returned by app-list APIs.", + "properties": { + "category": { + "type": [ + "string", + "null" + ] + }, + "developer": { + "type": [ + "string", + "null" + ] + }, + "isDiscoverableApp": { + "type": "boolean" + }, + "privacyPolicy": { + "type": [ + "string", + "null" + ] + }, + "termsOfService": { + "type": [ + "string", + "null" + ] + }, + "website": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "isDiscoverableApp" + ], + "type": "object" + }, "AppConfig": { "properties": { "disabled_reason": { @@ -10300,6 +10342,26 @@ "AppInfo": { "description": "EXPERIMENTAL - app metadata returned by app-list APIs.", "properties": { + "appMetadata": { + "anyOf": [ + { + "$ref": "#/definitions/v2/AppMetadata" + }, + { + "type": "null" + } + ] + }, + "branding": { + "anyOf": [ + { + "$ref": "#/definitions/v2/AppBranding" + }, + { + "type": "null" + } + ] + }, "description": { "type": [ "string", @@ -10330,6 +10392,15 @@ "description": "Whether this app is enabled in config.toml. Example: ```toml [apps.bad_app] enabled = false ```", "type": "boolean" }, + "labels": { + "additionalProperties": { + "type": "string" + }, + "type": [ + "object", + "null" + ] + }, "logoUrl": { "type": [ "string", @@ -10369,6 +10440,130 @@ "title": "AppListUpdatedNotification", "type": "object" }, + "AppMetadata": { + "properties": { + "categories": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "developer": { + "type": [ + "string", + "null" + ] + }, + "firstPartyRequiresInstall": { + "type": [ + "boolean", + "null" + ] + }, + "firstPartyType": { + "type": [ + "string", + "null" + ] + }, + "review": { + "anyOf": [ + { + "$ref": "#/definitions/v2/AppReview" + }, + { + "type": "null" + } + ] + }, + "screenshots": { + "items": { + "$ref": "#/definitions/v2/AppScreenshot" + }, + "type": [ + "array", + "null" + ] + }, + "seoDescription": { + "type": [ + "string", + "null" + ] + }, + "showInComposerWhenUnlinked": { + "type": [ + "boolean", + "null" + ] + }, + "subCategories": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "version": { + "type": [ + "string", + "null" + ] + }, + "versionId": { + "type": [ + "string", + "null" + ] + }, + "versionNotes": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "AppReview": { + "properties": { + "status": { + "type": "string" + } + }, + "required": [ + "status" + ], + "type": "object" + }, + "AppScreenshot": { + "properties": { + "fileId": { + "type": [ + "string", + "null" + ] + }, + "url": { + "type": [ + "string", + "null" + ] + }, + "userPrompt": { + "type": "string" + } + }, + "required": [ + "userPrompt" + ], + "type": "object" + }, "AppsConfig": { "type": "object" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/AppListUpdatedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/AppListUpdatedNotification.json index 1638472c4e..0813ed6f56 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/AppListUpdatedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/AppListUpdatedNotification.json @@ -1,9 +1,71 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "definitions": { + "AppBranding": { + "description": "EXPERIMENTAL - app metadata returned by app-list APIs.", + "properties": { + "category": { + "type": [ + "string", + "null" + ] + }, + "developer": { + "type": [ + "string", + "null" + ] + }, + "isDiscoverableApp": { + "type": "boolean" + }, + "privacyPolicy": { + "type": [ + "string", + "null" + ] + }, + "termsOfService": { + "type": [ + "string", + "null" + ] + }, + "website": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "isDiscoverableApp" + ], + "type": "object" + }, "AppInfo": { "description": "EXPERIMENTAL - app metadata returned by app-list APIs.", "properties": { + "appMetadata": { + "anyOf": [ + { + "$ref": "#/definitions/AppMetadata" + }, + { + "type": "null" + } + ] + }, + "branding": { + "anyOf": [ + { + "$ref": "#/definitions/AppBranding" + }, + { + "type": "null" + } + ] + }, "description": { "type": [ "string", @@ -34,6 +96,15 @@ "description": "Whether this app is enabled in config.toml. Example: ```toml [apps.bad_app] enabled = false ```", "type": "boolean" }, + "labels": { + "additionalProperties": { + "type": "string" + }, + "type": [ + "object", + "null" + ] + }, "logoUrl": { "type": [ "string", @@ -55,6 +126,130 @@ "name" ], "type": "object" + }, + "AppMetadata": { + "properties": { + "categories": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "developer": { + "type": [ + "string", + "null" + ] + }, + "firstPartyRequiresInstall": { + "type": [ + "boolean", + "null" + ] + }, + "firstPartyType": { + "type": [ + "string", + "null" + ] + }, + "review": { + "anyOf": [ + { + "$ref": "#/definitions/AppReview" + }, + { + "type": "null" + } + ] + }, + "screenshots": { + "items": { + "$ref": "#/definitions/AppScreenshot" + }, + "type": [ + "array", + "null" + ] + }, + "seoDescription": { + "type": [ + "string", + "null" + ] + }, + "showInComposerWhenUnlinked": { + "type": [ + "boolean", + "null" + ] + }, + "subCategories": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "version": { + "type": [ + "string", + "null" + ] + }, + "versionId": { + "type": [ + "string", + "null" + ] + }, + "versionNotes": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "AppReview": { + "properties": { + "status": { + "type": "string" + } + }, + "required": [ + "status" + ], + "type": "object" + }, + "AppScreenshot": { + "properties": { + "fileId": { + "type": [ + "string", + "null" + ] + }, + "url": { + "type": [ + "string", + "null" + ] + }, + "userPrompt": { + "type": "string" + } + }, + "required": [ + "userPrompt" + ], + "type": "object" } }, "description": "EXPERIMENTAL - notification emitted when the app list changes.", diff --git a/codex-rs/app-server-protocol/schema/json/v2/AppsListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/AppsListResponse.json index cec3da5ccc..4697b34e12 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/AppsListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/AppsListResponse.json @@ -1,9 +1,71 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "definitions": { + "AppBranding": { + "description": "EXPERIMENTAL - app metadata returned by app-list APIs.", + "properties": { + "category": { + "type": [ + "string", + "null" + ] + }, + "developer": { + "type": [ + "string", + "null" + ] + }, + "isDiscoverableApp": { + "type": "boolean" + }, + "privacyPolicy": { + "type": [ + "string", + "null" + ] + }, + "termsOfService": { + "type": [ + "string", + "null" + ] + }, + "website": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "isDiscoverableApp" + ], + "type": "object" + }, "AppInfo": { "description": "EXPERIMENTAL - app metadata returned by app-list APIs.", "properties": { + "appMetadata": { + "anyOf": [ + { + "$ref": "#/definitions/AppMetadata" + }, + { + "type": "null" + } + ] + }, + "branding": { + "anyOf": [ + { + "$ref": "#/definitions/AppBranding" + }, + { + "type": "null" + } + ] + }, "description": { "type": [ "string", @@ -34,6 +96,15 @@ "description": "Whether this app is enabled in config.toml. Example: ```toml [apps.bad_app] enabled = false ```", "type": "boolean" }, + "labels": { + "additionalProperties": { + "type": "string" + }, + "type": [ + "object", + "null" + ] + }, "logoUrl": { "type": [ "string", @@ -55,6 +126,130 @@ "name" ], "type": "object" + }, + "AppMetadata": { + "properties": { + "categories": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "developer": { + "type": [ + "string", + "null" + ] + }, + "firstPartyRequiresInstall": { + "type": [ + "boolean", + "null" + ] + }, + "firstPartyType": { + "type": [ + "string", + "null" + ] + }, + "review": { + "anyOf": [ + { + "$ref": "#/definitions/AppReview" + }, + { + "type": "null" + } + ] + }, + "screenshots": { + "items": { + "$ref": "#/definitions/AppScreenshot" + }, + "type": [ + "array", + "null" + ] + }, + "seoDescription": { + "type": [ + "string", + "null" + ] + }, + "showInComposerWhenUnlinked": { + "type": [ + "boolean", + "null" + ] + }, + "subCategories": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "version": { + "type": [ + "string", + "null" + ] + }, + "versionId": { + "type": [ + "string", + "null" + ] + }, + "versionNotes": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "AppReview": { + "properties": { + "status": { + "type": "string" + } + }, + "required": [ + "status" + ], + "type": "object" + }, + "AppScreenshot": { + "properties": { + "fileId": { + "type": [ + "string", + "null" + ] + }, + "url": { + "type": [ + "string", + "null" + ] + }, + "userPrompt": { + "type": "string" + } + }, + "required": [ + "userPrompt" + ], + "type": "object" } }, "description": "EXPERIMENTAL - app list response.", diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/AppBranding.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AppBranding.ts new file mode 100644 index 0000000000..873398db67 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AppBranding.ts @@ -0,0 +1,8 @@ +// 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. + +/** + * EXPERIMENTAL - app metadata returned by app-list APIs. + */ +export type AppBranding = { category: string | null, developer: string | null, website: string | null, privacyPolicy: string | null, termsOfService: string | null, isDiscoverableApp: boolean, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/AppInfo.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AppInfo.ts index 2f65c18327..0c9a13b124 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/AppInfo.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AppInfo.ts @@ -1,11 +1,13 @@ // 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 { AppBranding } from "./AppBranding"; +import type { AppMetadata } from "./AppMetadata"; /** * EXPERIMENTAL - app metadata returned by app-list APIs. */ -export type AppInfo = { id: string, name: string, description: string | null, logoUrl: string | null, logoUrlDark: string | null, distributionChannel: string | null, installUrl: string | null, isAccessible: boolean, +export type AppInfo = { id: string, name: string, description: string | null, logoUrl: string | null, logoUrlDark: string | null, distributionChannel: string | null, branding: AppBranding | null, appMetadata: AppMetadata | null, labels: { [key in string]?: string } | null, installUrl: string | null, isAccessible: boolean, /** * Whether this app is enabled in config.toml. * Example: diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/AppMetadata.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AppMetadata.ts new file mode 100644 index 0000000000..f1a5001eb1 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AppMetadata.ts @@ -0,0 +1,7 @@ +// 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 { AppReview } from "./AppReview"; +import type { AppScreenshot } from "./AppScreenshot"; + +export type AppMetadata = { review: AppReview | null, categories: Array | null, subCategories: Array | null, seoDescription: string | null, screenshots: Array | null, developer: string | null, version: string | null, versionId: string | null, versionNotes: string | null, firstPartyType: string | null, firstPartyRequiresInstall: boolean | null, showInComposerWhenUnlinked: boolean | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/AppReview.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AppReview.ts new file mode 100644 index 0000000000..10fd95f09d --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AppReview.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 AppReview = { status: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/AppScreenshot.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AppScreenshot.ts new file mode 100644 index 0000000000..0d264246f3 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AppScreenshot.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 AppScreenshot = { url: string | null, fileId: string | null, userPrompt: string, }; 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 8a1456f420..14a09f2f5c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -6,9 +6,13 @@ export type { AccountRateLimitsUpdatedNotification } from "./AccountRateLimitsUp export type { AccountUpdatedNotification } from "./AccountUpdatedNotification"; export type { AgentMessageDeltaNotification } from "./AgentMessageDeltaNotification"; export type { AnalyticsConfig } from "./AnalyticsConfig"; +export type { AppBranding } from "./AppBranding"; export type { AppDisabledReason } from "./AppDisabledReason"; export type { AppInfo } from "./AppInfo"; export type { AppListUpdatedNotification } from "./AppListUpdatedNotification"; +export type { AppMetadata } from "./AppMetadata"; +export type { AppReview } from "./AppReview"; +export type { AppScreenshot } from "./AppScreenshot"; export type { AppsConfig } from "./AppsConfig"; export type { AppsListParams } from "./AppsListParams"; export type { AppsListResponse } from "./AppsListResponse"; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 4d4b15127e..72732af852 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -1287,6 +1287,55 @@ pub struct AppsListParams { pub force_refetch: bool, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// EXPERIMENTAL - app metadata returned by app-list APIs. +pub struct AppBranding { + pub category: Option, + pub developer: Option, + pub website: Option, + pub privacy_policy: Option, + pub terms_of_service: Option, + pub is_discoverable_app: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct AppReview { + pub status: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct AppScreenshot { + pub url: Option, + #[serde(alias = "file_id")] + pub file_id: Option, + #[serde(alias = "user_prompt")] + pub user_prompt: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct AppMetadata { + pub review: Option, + pub categories: Option>, + pub sub_categories: Option>, + pub seo_description: Option, + pub screenshots: Option>, + pub developer: Option, + pub version: Option, + pub version_id: Option, + pub version_notes: Option, + pub first_party_type: Option, + pub first_party_requires_install: Option, + pub show_in_composer_when_unlinked: Option, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -1298,6 +1347,9 @@ pub struct AppInfo { pub logo_url: Option, pub logo_url_dark: Option, pub distribution_channel: Option, + pub branding: Option, + pub app_metadata: Option, + pub labels: Option>, pub install_url: Option, #[serde(default)] pub is_accessible: bool, diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index c65793d240..823e693876 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -772,7 +772,7 @@ To enable or disable a skill by path: ## Apps -Use `app/list` to fetch available apps (connectors). Each entry includes metadata like the app `id`, display `name`, `installUrl`, whether it is currently accessible, and whether it is enabled in config. +Use `app/list` to fetch available apps (connectors). Each entry includes metadata like the app `id`, display `name`, `installUrl`, `branding`, `appMetadata`, `labels`, whether it is currently accessible, and whether it is enabled in config. ```json { "method": "app/list", "id": 50, "params": { @@ -790,6 +790,9 @@ Use `app/list` to fetch available apps (connectors). Each entry includes metadat "logoUrl": "https://example.com/demo-app.png", "logoUrlDark": null, "distributionChannel": null, + "branding": null, + "appMetadata": null, + "labels": null, "installUrl": "https://chatgpt.com/apps/demo-app/demo-app", "isAccessible": true, "isEnabled": true @@ -817,6 +820,9 @@ The server also emits `app/list/updated` notifications whenever either source (a "logoUrl": "https://example.com/demo-app.png", "logoUrlDark": null, "distributionChannel": null, + "branding": null, + "appMetadata": null, + "labels": null, "installUrl": "https://chatgpt.com/apps/demo-app/demo-app", "isAccessible": true, "isEnabled": true diff --git a/codex-rs/app-server/tests/suite/v2/app_list.rs b/codex-rs/app-server/tests/suite/v2/app_list.rs index 079eba4794..1fd3f5a447 100644 --- a/codex-rs/app-server/tests/suite/v2/app_list.rs +++ b/codex-rs/app-server/tests/suite/v2/app_list.rs @@ -1,4 +1,5 @@ use std::borrow::Cow; +use std::collections::HashMap; use std::sync::Arc; use std::sync::Mutex as StdMutex; use std::time::Duration; @@ -16,8 +17,12 @@ use axum::http::HeaderMap; use axum::http::StatusCode; use axum::http::header::AUTHORIZATION; use axum::routing::get; +use codex_app_server_protocol::AppBranding; use codex_app_server_protocol::AppInfo; use codex_app_server_protocol::AppListUpdatedNotification; +use codex_app_server_protocol::AppMetadata; +use codex_app_server_protocol::AppReview; +use codex_app_server_protocol::AppScreenshot; use codex_app_server_protocol::AppsListParams; use codex_app_server_protocol::AppsListResponse; use codex_app_server_protocol::JSONRPCError; @@ -85,6 +90,9 @@ async fn list_apps_uses_thread_feature_flag_when_thread_id_is_provided() -> Resu logo_url: None, logo_url_dark: None, distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, install_url: None, is_accessible: false, is_enabled: true, @@ -184,6 +192,9 @@ async fn list_apps_reports_is_enabled_from_config() -> Result<()> { logo_url: None, logo_url_dark: None, distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, install_url: None, is_accessible: false, is_enabled: true, @@ -249,6 +260,39 @@ enabled = false #[tokio::test] async fn list_apps_emits_updates_and_returns_after_both_lists_load() -> Result<()> { + let alpha_branding = Some(AppBranding { + category: Some("PRODUCTIVITY".to_string()), + developer: Some("Acme".to_string()), + website: Some("https://acme.example".to_string()), + privacy_policy: Some("https://acme.example/privacy".to_string()), + terms_of_service: Some("https://acme.example/terms".to_string()), + is_discoverable_app: true, + }); + let alpha_app_metadata = Some(AppMetadata { + review: Some(AppReview { + status: "APPROVED".to_string(), + }), + categories: Some(vec!["PRODUCTIVITY".to_string()]), + sub_categories: Some(vec!["WRITING".to_string()]), + seo_description: Some("Alpha connector".to_string()), + screenshots: Some(vec![AppScreenshot { + url: Some("https://example.com/alpha-screenshot.png".to_string()), + file_id: Some("file_123".to_string()), + user_prompt: "Summarize this draft".to_string(), + }]), + developer: Some("Acme".to_string()), + version: Some("1.2.3".to_string()), + version_id: Some("version_123".to_string()), + version_notes: Some("Fixes and improvements".to_string()), + first_party_type: Some("internal".to_string()), + first_party_requires_install: Some(true), + show_in_composer_when_unlinked: Some(true), + }); + let alpha_labels = Some(HashMap::from([ + ("feature".to_string(), "beta".to_string()), + ("source".to_string(), "directory".to_string()), + ])); + let connectors = vec![ AppInfo { id: "alpha".to_string(), @@ -257,6 +301,9 @@ async fn list_apps_emits_updates_and_returns_after_both_lists_load() -> Result<( logo_url: Some("https://example.com/alpha.png".to_string()), logo_url_dark: None, distribution_channel: None, + branding: alpha_branding.clone(), + app_metadata: alpha_app_metadata.clone(), + labels: alpha_labels.clone(), install_url: None, is_accessible: false, is_enabled: true, @@ -268,6 +315,9 @@ async fn list_apps_emits_updates_and_returns_after_both_lists_load() -> Result<( logo_url: None, logo_url_dark: None, distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, install_url: None, is_accessible: false, is_enabled: true, @@ -313,6 +363,9 @@ async fn list_apps_emits_updates_and_returns_after_both_lists_load() -> Result<( logo_url: None, logo_url_dark: None, distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, install_url: Some("https://chatgpt.com/apps/beta-app/beta".to_string()), is_accessible: true, is_enabled: true, @@ -329,6 +382,9 @@ async fn list_apps_emits_updates_and_returns_after_both_lists_load() -> Result<( logo_url: None, logo_url_dark: None, distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, install_url: Some("https://chatgpt.com/apps/beta/beta".to_string()), is_accessible: true, is_enabled: true, @@ -340,6 +396,9 @@ async fn list_apps_emits_updates_and_returns_after_both_lists_load() -> Result<( logo_url: Some("https://example.com/alpha.png".to_string()), logo_url_dark: None, distribution_channel: None, + branding: alpha_branding, + app_metadata: alpha_app_metadata, + labels: alpha_labels, install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()), is_accessible: false, is_enabled: true, @@ -377,6 +436,9 @@ async fn list_apps_returns_connectors_with_accessible_flags() -> Result<()> { logo_url: Some("https://example.com/alpha.png".to_string()), logo_url_dark: None, distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, install_url: None, is_accessible: false, is_enabled: true, @@ -388,6 +450,9 @@ async fn list_apps_returns_connectors_with_accessible_flags() -> Result<()> { logo_url: None, logo_url_dark: None, distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, install_url: None, is_accessible: false, is_enabled: true, @@ -437,6 +502,9 @@ async fn list_apps_returns_connectors_with_accessible_flags() -> Result<()> { logo_url: Some("https://example.com/alpha.png".to_string()), logo_url_dark: None, distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()), is_accessible: false, is_enabled: true, @@ -448,6 +516,9 @@ async fn list_apps_returns_connectors_with_accessible_flags() -> Result<()> { logo_url: None, logo_url_dark: None, distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, install_url: Some("https://chatgpt.com/apps/beta/beta".to_string()), is_accessible: false, is_enabled: true, @@ -463,6 +534,9 @@ async fn list_apps_returns_connectors_with_accessible_flags() -> Result<()> { logo_url: None, logo_url_dark: None, distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, install_url: Some("https://chatgpt.com/apps/beta/beta".to_string()), is_accessible: true, is_enabled: true, @@ -474,6 +548,9 @@ async fn list_apps_returns_connectors_with_accessible_flags() -> Result<()> { logo_url: Some("https://example.com/alpha.png".to_string()), logo_url_dark: None, distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()), is_accessible: false, is_enabled: true, @@ -506,6 +583,9 @@ async fn list_apps_paginates_results() -> Result<()> { logo_url: None, logo_url_dark: None, distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, install_url: None, is_accessible: false, is_enabled: true, @@ -517,6 +597,9 @@ async fn list_apps_paginates_results() -> Result<()> { logo_url: None, logo_url_dark: None, distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, install_url: None, is_accessible: false, is_enabled: true, @@ -571,6 +654,9 @@ async fn list_apps_paginates_results() -> Result<()> { logo_url: None, logo_url_dark: None, distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, install_url: Some("https://chatgpt.com/apps/beta/beta".to_string()), is_accessible: true, is_enabled: true, @@ -611,6 +697,9 @@ async fn list_apps_paginates_results() -> Result<()> { logo_url: None, logo_url_dark: None, distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()), is_accessible: false, is_enabled: true, @@ -632,6 +721,9 @@ async fn list_apps_force_refetch_preserves_previous_cache_on_failure() -> Result logo_url: None, logo_url_dark: None, distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, install_url: None, is_accessible: false, is_enabled: true, @@ -733,6 +825,9 @@ async fn list_apps_force_refetch_patches_updates_from_cached_snapshots() -> Resu logo_url: None, logo_url_dark: None, distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, install_url: None, is_accessible: false, is_enabled: true, @@ -744,6 +839,9 @@ async fn list_apps_force_refetch_patches_updates_from_cached_snapshots() -> Resu logo_url: None, logo_url_dark: None, distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, install_url: None, is_accessible: false, is_enabled: true, @@ -790,6 +888,9 @@ async fn list_apps_force_refetch_patches_updates_from_cached_snapshots() -> Resu logo_url: None, logo_url_dark: None, distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, install_url: Some("https://chatgpt.com/apps/beta-app/beta".to_string()), is_accessible: true, is_enabled: true, @@ -807,6 +908,9 @@ async fn list_apps_force_refetch_patches_updates_from_cached_snapshots() -> Resu logo_url: None, logo_url_dark: None, distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, install_url: Some("https://chatgpt.com/apps/beta-app/beta".to_string()), is_accessible: true, is_enabled: true, @@ -818,6 +922,9 @@ async fn list_apps_force_refetch_patches_updates_from_cached_snapshots() -> Resu logo_url: None, logo_url_dark: None, distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()), is_accessible: false, is_enabled: true, @@ -844,6 +951,9 @@ async fn list_apps_force_refetch_patches_updates_from_cached_snapshots() -> Resu logo_url: None, logo_url_dark: None, distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, install_url: None, is_accessible: false, is_enabled: true, @@ -870,6 +980,9 @@ async fn list_apps_force_refetch_patches_updates_from_cached_snapshots() -> Resu logo_url: None, logo_url_dark: None, distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()), is_accessible: false, is_enabled: true, @@ -881,6 +994,9 @@ async fn list_apps_force_refetch_patches_updates_from_cached_snapshots() -> Resu logo_url: None, logo_url_dark: None, distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, install_url: Some("https://chatgpt.com/apps/beta-app/beta".to_string()), is_accessible: false, is_enabled: true, @@ -895,6 +1011,9 @@ async fn list_apps_force_refetch_patches_updates_from_cached_snapshots() -> Resu logo_url: None, logo_url_dark: None, distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()), is_accessible: false, is_enabled: true, diff --git a/codex-rs/chatgpt/src/connectors.rs b/codex-rs/chatgpt/src/connectors.rs index bafb0273a4..8955fbf84a 100644 --- a/codex-rs/chatgpt/src/connectors.rs +++ b/codex-rs/chatgpt/src/connectors.rs @@ -14,7 +14,9 @@ use crate::chatgpt_client::chatgpt_get_request_with_timeout; use crate::chatgpt_token::get_chatgpt_token_data; use crate::chatgpt_token::init_chatgpt_token_from_auth; +use codex_core::connectors::AppBranding; pub use codex_core::connectors::AppInfo; +use codex_core::connectors::AppMetadata; use codex_core::connectors::CONNECTORS_CACHE_TTL; pub use codex_core::connectors::connector_display_label; use codex_core::connectors::connector_install_url; @@ -36,6 +38,10 @@ struct DirectoryApp { id: String, name: String, description: Option, + #[serde(alias = "appMetadata")] + app_metadata: Option, + branding: Option, + labels: Option>, #[serde(alias = "logoUrl")] logo_url: Option, #[serde(alias = "logoUrlDark")] @@ -269,6 +275,9 @@ fn merge_directory_app(existing: &mut DirectoryApp, incoming: DirectoryApp) { id: _, name, description, + app_metadata, + branding, + labels, logo_url, logo_url_dark, distribution_channel, @@ -302,6 +311,108 @@ fn merge_directory_app(existing: &mut DirectoryApp, incoming: DirectoryApp) { if existing.distribution_channel.is_none() && distribution_channel.is_some() { existing.distribution_channel = distribution_channel; } + + if let Some(incoming_branding) = branding { + if let Some(existing_branding) = existing.branding.as_mut() { + if existing_branding.category.is_none() && incoming_branding.category.is_some() { + existing_branding.category = incoming_branding.category; + } + if existing_branding.developer.is_none() && incoming_branding.developer.is_some() { + existing_branding.developer = incoming_branding.developer; + } + if existing_branding.website.is_none() && incoming_branding.website.is_some() { + existing_branding.website = incoming_branding.website; + } + if existing_branding.privacy_policy.is_none() + && incoming_branding.privacy_policy.is_some() + { + existing_branding.privacy_policy = incoming_branding.privacy_policy; + } + if existing_branding.terms_of_service.is_none() + && incoming_branding.terms_of_service.is_some() + { + existing_branding.terms_of_service = incoming_branding.terms_of_service; + } + if !existing_branding.is_discoverable_app && incoming_branding.is_discoverable_app { + existing_branding.is_discoverable_app = true; + } + } else { + existing.branding = Some(incoming_branding); + } + } + + if let Some(incoming_app_metadata) = app_metadata { + if let Some(existing_app_metadata) = existing.app_metadata.as_mut() { + if existing_app_metadata.review.is_none() && incoming_app_metadata.review.is_some() { + existing_app_metadata.review = incoming_app_metadata.review; + } + if existing_app_metadata.categories.is_none() + && incoming_app_metadata.categories.is_some() + { + existing_app_metadata.categories = incoming_app_metadata.categories; + } + if existing_app_metadata.sub_categories.is_none() + && incoming_app_metadata.sub_categories.is_some() + { + existing_app_metadata.sub_categories = incoming_app_metadata.sub_categories; + } + if existing_app_metadata.seo_description.is_none() + && incoming_app_metadata.seo_description.is_some() + { + existing_app_metadata.seo_description = incoming_app_metadata.seo_description; + } + if existing_app_metadata.screenshots.is_none() + && incoming_app_metadata.screenshots.is_some() + { + existing_app_metadata.screenshots = incoming_app_metadata.screenshots; + } + if existing_app_metadata.developer.is_none() + && incoming_app_metadata.developer.is_some() + { + existing_app_metadata.developer = incoming_app_metadata.developer; + } + if existing_app_metadata.version.is_none() && incoming_app_metadata.version.is_some() { + existing_app_metadata.version = incoming_app_metadata.version; + } + if existing_app_metadata.version_id.is_none() + && incoming_app_metadata.version_id.is_some() + { + existing_app_metadata.version_id = incoming_app_metadata.version_id; + } + if existing_app_metadata.version_notes.is_none() + && incoming_app_metadata.version_notes.is_some() + { + existing_app_metadata.version_notes = incoming_app_metadata.version_notes; + } + if existing_app_metadata.first_party_type.is_none() + && incoming_app_metadata.first_party_type.is_some() + { + existing_app_metadata.first_party_type = incoming_app_metadata.first_party_type; + } + if existing_app_metadata.first_party_requires_install.is_none() + && incoming_app_metadata.first_party_requires_install.is_some() + { + existing_app_metadata.first_party_requires_install = + incoming_app_metadata.first_party_requires_install; + } + if existing_app_metadata + .show_in_composer_when_unlinked + .is_none() + && incoming_app_metadata + .show_in_composer_when_unlinked + .is_some() + { + existing_app_metadata.show_in_composer_when_unlinked = + incoming_app_metadata.show_in_composer_when_unlinked; + } + } else { + existing.app_metadata = Some(incoming_app_metadata); + } + } + + if existing.labels.is_none() && labels.is_some() { + existing.labels = labels; + } } fn is_hidden_directory_app(app: &DirectoryApp) -> bool { @@ -316,6 +427,9 @@ fn directory_app_to_app_info(app: DirectoryApp) -> AppInfo { logo_url: app.logo_url, logo_url_dark: app.logo_url_dark, distribution_channel: app.distribution_channel, + branding: app.branding, + app_metadata: app.app_metadata, + labels: app.labels, install_url: None, is_accessible: false, is_enabled: true, @@ -342,6 +456,8 @@ const DISALLOWED_CONNECTOR_IDS: &[&str] = &[ "asdk_app_6938a94a61d881918ef32cb999ff937c", "connector_2b0a9009c9c64bf9933a3dae3f2b1254", "connector_68de829bf7648191acd70a907364c67c", + "connector_68e004f14af881919eb50893d3d9f523", + "connector_69272cb413a081919685ec3c88d1744e", ]; const DISALLOWED_CONNECTOR_PREFIX: &str = "connector_openai_"; @@ -375,6 +491,9 @@ mod tests { logo_url: None, logo_url_dark: None, distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, install_url: None, is_accessible: false, is_enabled: true, @@ -429,6 +548,9 @@ mod tests { logo_url: None, logo_url_dark: None, distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, install_url: Some(connector_install_url(id, id)), is_accessible, is_enabled: true, diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index cf362d4ba5..c0eb259756 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -5823,6 +5823,9 @@ mod tests { 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, diff --git a/codex-rs/core/src/connectors.rs b/codex-rs/core/src/connectors.rs index 35c2dc5ceb..5a8930231b 100644 --- a/codex-rs/core/src/connectors.rs +++ b/codex-rs/core/src/connectors.rs @@ -7,7 +7,9 @@ use std::time::Duration; use std::time::Instant; use async_channel::unbounded; +pub use codex_app_server_protocol::AppBranding; pub use codex_app_server_protocol::AppInfo; +pub use codex_app_server_protocol::AppMetadata; use codex_protocol::protocol::SandboxPolicy; use serde::Deserialize; use tokio_util::sync::CancellationToken; @@ -320,6 +322,9 @@ where logo_url: None, logo_url_dark: None, distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, install_url: Some(connector_install_url(&connector_name, &connector_id)), is_accessible: true, is_enabled: true, diff --git a/codex-rs/core/src/tools/handlers/search_tool_bm25.rs b/codex-rs/core/src/tools/handlers/search_tool_bm25.rs index e535abac91..c5e267cb49 100644 --- a/codex-rs/core/src/tools/handlers/search_tool_bm25.rs +++ b/codex-rs/core/src/tools/handlers/search_tool_bm25.rs @@ -261,6 +261,9 @@ mod tests { 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: enabled, diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 36142cbaed..91dfa00a52 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -4596,6 +4596,9 @@ mod tests { logo_url: None, logo_url_dark: None, distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, install_url: Some("https://example.test/notion".to_string()), is_accessible: true, is_enabled: true, @@ -4633,6 +4636,9 @@ mod tests { logo_url: None, logo_url_dark: None, distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, install_url: Some("https://example.test/notion".to_string()), is_accessible: true, is_enabled: false, diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index f49d318274..bfae9581b2 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -4424,6 +4424,9 @@ async fn apps_popup_refreshes_when_connectors_snapshot_updates() { logo_url: None, logo_url_dark: None, distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, install_url: Some("https://example.test/notion".to_string()), is_accessible: true, is_enabled: true, @@ -4457,6 +4460,9 @@ async fn apps_popup_refreshes_when_connectors_snapshot_updates() { logo_url: None, logo_url_dark: None, distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, install_url: Some("https://example.test/notion".to_string()), is_accessible: true, is_enabled: true, @@ -4468,6 +4474,9 @@ async fn apps_popup_refreshes_when_connectors_snapshot_updates() { logo_url: None, logo_url_dark: None, distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, install_url: Some("https://example.test/linear".to_string()), is_accessible: true, is_enabled: true, @@ -4504,6 +4513,9 @@ async fn apps_refresh_failure_keeps_existing_full_snapshot() { logo_url: None, logo_url_dark: None, distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, install_url: Some("https://example.test/notion".to_string()), is_accessible: true, is_enabled: true, @@ -4515,6 +4527,9 @@ async fn apps_refresh_failure_keeps_existing_full_snapshot() { logo_url: None, logo_url_dark: None, distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, install_url: Some("https://example.test/linear".to_string()), is_accessible: false, is_enabled: true, @@ -4536,6 +4551,9 @@ async fn apps_refresh_failure_keeps_existing_full_snapshot() { logo_url: None, logo_url_dark: None, distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, install_url: Some("https://example.test/notion".to_string()), is_accessible: true, is_enabled: true, @@ -4572,6 +4590,9 @@ async fn apps_partial_refresh_uses_same_filtering_as_full_refresh() { logo_url: None, logo_url_dark: None, distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, install_url: Some("https://example.test/notion".to_string()), is_accessible: true, is_enabled: true, @@ -4583,6 +4604,9 @@ async fn apps_partial_refresh_uses_same_filtering_as_full_refresh() { logo_url: None, logo_url_dark: None, distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, install_url: Some("https://example.test/linear".to_string()), is_accessible: false, is_enabled: true, @@ -4606,6 +4630,9 @@ async fn apps_partial_refresh_uses_same_filtering_as_full_refresh() { logo_url: None, logo_url_dark: None, distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, install_url: Some("https://example.test/notion".to_string()), is_accessible: true, is_enabled: true, @@ -4617,6 +4644,9 @@ async fn apps_partial_refresh_uses_same_filtering_as_full_refresh() { logo_url: None, logo_url_dark: None, distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, install_url: Some("https://example.test/hidden-openai".to_string()), is_accessible: true, is_enabled: true, @@ -4657,6 +4687,9 @@ async fn apps_popup_shows_disabled_status_for_installed_but_disabled_apps() { logo_url: None, logo_url_dark: None, distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, install_url: Some("https://example.test/notion".to_string()), is_accessible: true, is_enabled: false, @@ -4704,6 +4737,9 @@ async fn apps_initial_load_applies_enabled_state_from_config() { logo_url: None, logo_url_dark: None, distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, install_url: Some("https://example.test/notion".to_string()), is_accessible: true, is_enabled: true, @@ -4738,6 +4774,9 @@ async fn apps_refresh_preserves_toggled_enabled_state() { logo_url: None, logo_url_dark: None, distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, install_url: Some("https://example.test/notion".to_string()), is_accessible: true, is_enabled: true, @@ -4756,6 +4795,9 @@ async fn apps_refresh_preserves_toggled_enabled_state() { logo_url: None, logo_url_dark: None, distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, install_url: Some("https://example.test/notion".to_string()), is_accessible: true, is_enabled: true, @@ -4797,6 +4839,9 @@ async fn apps_popup_for_not_installed_app_uses_install_only_selected_description logo_url: None, logo_url_dark: None, distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, install_url: Some("https://example.test/linear".to_string()), is_accessible: false, is_enabled: true, From 15cd79674938f3254d0b63720f1465b66e1039aa Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 17 Feb 2026 11:45:45 -0800 Subject: [PATCH 56/62] chore(deps): bump arc-swap from 1.8.0 to 1.8.2 in /codex-rs (#11890) Bumps [arc-swap](https://github.com/vorner/arc-swap) from 1.8.0 to 1.8.2.
Changelog

Sourced from arc-swap's changelog.

1.8.2

  • Proper gate of Pin (since 1.39 - we are not using only Pin, but also Pin::into_inner, #197).

1.8.1

  • Some more careful orderings (#195).
Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=arc-swap&package-manager=cargo&previous-version=1.8.0&new-version=1.8.2)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- codex-rs/Cargo.lock | 4 ++-- codex-rs/core/Cargo.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 3456f9d731..378b0dc681 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -430,9 +430,9 @@ dependencies = [ [[package]] name = "arc-swap" -version = "1.8.0" +version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d03449bb8ca2cc2ef70869af31463d1ae5ccc8fa3e334b307203fbf815207e" +checksum = "f9f3647c145568cec02c42054e07bdf9a5a698e15b466fb2341bfc393cd24aa5" dependencies = [ "rustversion", ] diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 208ba1c3a6..64b7330c3a 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -19,7 +19,7 @@ workspace = true [dependencies] anyhow = { workspace = true } -arc-swap = "1.8.0" +arc-swap = "1.8.2" async-channel = { workspace = true } async-trait = { workspace = true } askama = { workspace = true } From af3b1ae6cb1d31b8985e8f87e18bc98cd524d4b5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 17 Feb 2026 11:46:28 -0800 Subject: [PATCH 57/62] chore(deps): bump rust-toolchain from 1.93.0 to 1.93.1 in /codex-rs (#11886) Bumps [rust-toolchain](https://github.com/rust-lang/rust) from 1.93.0 to 1.93.1.
Release notes

Sourced from rust-toolchain's releases.

Rust 1.93.1

Changelog

Sourced from rust-toolchain's changelog.

Version 1.93.1 (2026-02-12)

Commits
  • 01f6ddf Auto merge of #152450 - cuviper:stable-next, r=cuviper
  • 674ccdd Release 1.93.1
  • f0867bf Sync release note changes from main
  • b8cc170 Remove the 4 failing tests from rustdoc-gui
  • 128b1c9 Remove rustdoc GUI flaky test
  • f8cf317 Revert "Update wasm-related dependencies in CI"
  • 9c13ace fix: panicking_unwrap FP on field access with implicit deref
  • feb759b Don't try to recover keyword as non-keyword identifier
  • f691f9a Add regression tests for keyword-in-identifier-position recovery ICE
  • See full diff in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=rust-toolchain&package-manager=rust_toolchain&previous-version=1.93.0&new-version=1.93.1)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Eric Traut --- codex-rs/rust-toolchain.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/rust-toolchain.toml b/codex-rs/rust-toolchain.toml index 954b684895..dc50bc36d3 100644 --- a/codex-rs/rust-toolchain.toml +++ b/codex-rs/rust-toolchain.toml @@ -1,3 +1,3 @@ [toolchain] -channel = "1.93.0" +channel = "1.93.1" components = ["clippy", "rustfmt", "rust-src"] From 6398e9a2ecb4b38fe2cd03d65cad041ae6f162b7 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Tue, 17 Feb 2026 12:04:09 -0800 Subject: [PATCH 58/62] chore: just bazel-lock-update (#12032) --- MODULE.bazel.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index 6f15063691..80e889a031 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -608,7 +608,7 @@ "anyhow_1.0.101": "{\"dependencies\":[{\"name\":\"backtrace\",\"optional\":true,\"req\":\"^0.3.51\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0.6\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"syn\",\"req\":\"^2.0\"},{\"kind\":\"dev\",\"name\":\"thiserror\",\"req\":\"^2\"},{\"features\":[\"diff\"],\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.108\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", "arbitrary_1.4.2": "{\"dependencies\":[{\"name\":\"derive_arbitrary\",\"optional\":true,\"req\":\"~1.4.0\"},{\"kind\":\"dev\",\"name\":\"exhaustigen\",\"req\":\"^0.1.0\"}],\"features\":{\"derive\":[\"derive_arbitrary\"]}}", "arboard_3.6.1": "{\"dependencies\":[{\"features\":[\"std\"],\"name\":\"clipboard-win\",\"req\":\"^5.3.1\",\"target\":\"cfg(windows)\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.10.2\"},{\"default_features\":false,\"features\":[\"png\"],\"name\":\"image\",\"optional\":true,\"req\":\"^0.25\",\"target\":\"cfg(all(unix, not(any(target_os=\\\"macos\\\", target_os=\\\"android\\\", target_os=\\\"emscripten\\\"))))\"},{\"default_features\":false,\"features\":[\"tiff\"],\"name\":\"image\",\"optional\":true,\"req\":\"^0.25\",\"target\":\"cfg(target_os = \\\"macos\\\")\"},{\"default_features\":false,\"features\":[\"png\",\"bmp\"],\"name\":\"image\",\"optional\":true,\"req\":\"^0.25\",\"target\":\"cfg(windows)\"},{\"name\":\"log\",\"req\":\"^0.4\",\"target\":\"cfg(all(unix, not(any(target_os=\\\"macos\\\", target_os=\\\"android\\\", target_os=\\\"emscripten\\\"))))\"},{\"name\":\"log\",\"req\":\"^0.4\",\"target\":\"cfg(windows)\"},{\"name\":\"objc2\",\"req\":\"^0.6.0\",\"target\":\"cfg(target_os = \\\"macos\\\")\"},{\"default_features\":false,\"features\":[\"std\",\"objc2-core-graphics\",\"NSPasteboard\",\"NSPasteboardItem\",\"NSImage\"],\"name\":\"objc2-app-kit\",\"req\":\"^0.3.0\",\"target\":\"cfg(target_os = \\\"macos\\\")\"},{\"default_features\":false,\"features\":[\"std\",\"CFCGTypes\"],\"name\":\"objc2-core-foundation\",\"optional\":true,\"req\":\"^0.3.0\",\"target\":\"cfg(target_os = \\\"macos\\\")\"},{\"default_features\":false,\"features\":[\"std\",\"CGImage\",\"CGColorSpace\",\"CGDataProvider\"],\"name\":\"objc2-core-graphics\",\"optional\":true,\"req\":\"^0.3.0\",\"target\":\"cfg(target_os = \\\"macos\\\")\"},{\"default_features\":false,\"features\":[\"std\",\"NSArray\",\"NSString\",\"NSEnumerator\",\"NSGeometry\",\"NSValue\"],\"name\":\"objc2-foundation\",\"req\":\"^0.3.0\",\"target\":\"cfg(target_os = \\\"macos\\\")\"},{\"name\":\"parking_lot\",\"req\":\"^0.12\",\"target\":\"cfg(all(unix, not(any(target_os=\\\"macos\\\", target_os=\\\"android\\\", target_os=\\\"emscripten\\\"))))\"},{\"name\":\"percent-encoding\",\"req\":\"^2.3.1\",\"target\":\"cfg(all(unix, not(any(target_os=\\\"macos\\\", target_os=\\\"android\\\", target_os=\\\"emscripten\\\"))))\"},{\"features\":[\"Win32_Foundation\",\"Win32_Storage_FileSystem\",\"Win32_System_DataExchange\",\"Win32_System_Memory\",\"Win32_System_Ole\",\"Win32_UI_Shell\"],\"name\":\"windows-sys\",\"req\":\">=0.52.0, <0.61.0\",\"target\":\"cfg(windows)\"},{\"name\":\"wl-clipboard-rs\",\"optional\":true,\"req\":\"^0.9.0\",\"target\":\"cfg(all(unix, not(any(target_os=\\\"macos\\\", target_os=\\\"android\\\", target_os=\\\"emscripten\\\"))))\"},{\"name\":\"x11rb\",\"req\":\"^0.13\",\"target\":\"cfg(all(unix, not(any(target_os=\\\"macos\\\", target_os=\\\"android\\\", target_os=\\\"emscripten\\\"))))\"}],\"features\":{\"core-graphics\":[\"dep:objc2-core-graphics\"],\"default\":[\"image-data\"],\"image\":[\"dep:image\"],\"image-data\":[\"dep:objc2-core-graphics\",\"dep:objc2-core-foundation\",\"image\",\"windows-sys\",\"core-graphics\"],\"wayland-data-control\":[\"wl-clipboard-rs\"],\"windows-sys\":[\"windows-sys/Win32_Graphics_Gdi\"],\"wl-clipboard-rs\":[\"dep:wl-clipboard-rs\"]}}", - "arc-swap_1.8.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"adaptive-barrier\",\"req\":\"~1\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"~0.7\"},{\"kind\":\"dev\",\"name\":\"crossbeam-utils\",\"req\":\"~0.8\"},{\"kind\":\"dev\",\"name\":\"itertools\",\"req\":\"^0.14\"},{\"kind\":\"dev\",\"name\":\"num_cpus\",\"req\":\"~1\"},{\"kind\":\"dev\",\"name\":\"once_cell\",\"req\":\"~1\"},{\"kind\":\"dev\",\"name\":\"parking_lot\",\"req\":\"~0.12\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"name\":\"rustversion\",\"req\":\"^1\"},{\"features\":[\"rc\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1.0.130\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0.177\"}],\"features\":{\"experimental-strategies\":[],\"experimental-thread-local\":[],\"internal-test-strategies\":[],\"weak\":[]}}", + "arc-swap_1.8.2": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"adaptive-barrier\",\"req\":\"~1\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"~0.7\"},{\"kind\":\"dev\",\"name\":\"crossbeam-utils\",\"req\":\"~0.8\"},{\"kind\":\"dev\",\"name\":\"itertools\",\"req\":\"^0.14\"},{\"kind\":\"dev\",\"name\":\"num_cpus\",\"req\":\"~1\"},{\"kind\":\"dev\",\"name\":\"once_cell\",\"req\":\"~1\"},{\"kind\":\"dev\",\"name\":\"parking_lot\",\"req\":\"~0.12\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"name\":\"rustversion\",\"req\":\"^1\"},{\"features\":[\"rc\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1.0.130\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0.177\"}],\"features\":{\"experimental-strategies\":[],\"experimental-thread-local\":[],\"internal-test-strategies\":[],\"weak\":[]}}", "arrayvec_0.7.6": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1.4\"},{\"default_features\":false,\"name\":\"borsh\",\"optional\":true,\"req\":\"^1.2.0\"},{\"kind\":\"dev\",\"name\":\"matches\",\"req\":\"^0.1\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1.4\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", "ascii-canvas_3.0.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"diff\",\"req\":\"^0.1\"},{\"name\":\"term\",\"req\":\"^0.7\"}],\"features\":{}}", "ascii_1.1.0": "{\"dependencies\":[{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.25\"},{\"name\":\"serde_test\",\"optional\":true,\"req\":\"^1.0\"}],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"std\":[\"alloc\"]}}", From c5b513ba9816fcac27ae6489cc8e997fb172f2e0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 17 Feb 2026 12:08:16 -0800 Subject: [PATCH 59/62] chore(deps): bump clap from 4.5.56 to 4.5.58 in /codex-rs (#11888) Bumps [clap](https://github.com/clap-rs/clap) from 4.5.56 to 4.5.58.
Release notes

Sourced from clap's releases.

v4.5.58

[4.5.58] - 2026-02-11

v4.5.57

[4.5.57] - 2026-02-03

Fixes

  • Regression from 4.5.55 where having an argument with .value_terminator("--") caused problems with an argument with .last(true)
Changelog

Sourced from clap's changelog.

[4.5.58] - 2026-02-11

[4.5.57] - 2026-02-03

Fixes

  • Regression from 4.5.55 where having an argument with .value_terminator("--") caused problems with an argument with .last(true)
Commits
  • 88f13cb chore: Release
  • fe2d731 docs: Update changelog
  • b256739 Merge pull request #6131 from mernen/do-not-suggest-opts-after-escape
  • 8aaf704 fix(complete): Do not suggest options after "--"
  • 4a86fee test(complete): Illustrate current behavior
  • 281f8ae Merge pull request #6126 from epage/p
  • 3cbce42 docs(cookbook): Make typed-derive easier to maintain
  • 9fd4dc9 docs(cookbook): Provide a custom TypedValueParser
  • 8f8e861 docs(cookbook): Add local enum to typed-derive
  • 926bafe docs(cookbook): Hint at overriding value_name
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=clap&package-manager=cargo&previous-version=4.5.56&new-version=4.5.58)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Eric Traut --- codex-rs/Cargo.lock | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 378b0dc681..18da829dcb 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -423,7 +423,7 @@ dependencies = [ "objc2-foundation", "parking_lot", "percent-encoding", - "windows-sys 0.60.2", + "windows-sys 0.52.0", "wl-clipboard-rs", "x11rb", ] @@ -1227,9 +1227,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.56" +version = "4.5.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75ca66430e33a14957acc24c5077b503e7d374151b2b4b3a10c83b4ceb4be0e" +checksum = "63be97961acde393029492ce0be7a1af7e323e6bae9511ebfac33751be5e6806" dependencies = [ "clap_builder", "clap_derive", @@ -1237,9 +1237,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.56" +version = "4.5.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "793207c7fa6300a0608d1080b858e5fdbe713cdc1c8db9fb17777d8a13e63df0" +checksum = "7f13174bda5dfd69d7e947827e5af4b0f2f94a4a3ee92912fba07a66150f21e2" dependencies = [ "anstream", "anstyle", @@ -1271,9 +1271,9 @@ dependencies = [ [[package]] name = "clap_lex" -version = "0.7.7" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" +checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" [[package]] name = "clipboard-win" @@ -3293,7 +3293,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3538,7 +3538,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -4931,7 +4931,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -5639,7 +5639,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -6205,7 +6205,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.45.0", ] [[package]] @@ -6766,7 +6766,7 @@ dependencies = [ "once_cell", "socket2 0.6.2", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.52.0", ] [[package]] @@ -7592,7 +7592,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.11.0", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -8969,7 +8969,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix 1.1.3", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -10329,7 +10329,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] From 4c4255fcfc5bf676ea2e5a09dc551b8f7384f249 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 17 Feb 2026 12:08:28 -0800 Subject: [PATCH 60/62] chore(deps): bump env_logger from 0.11.8 to 0.11.9 in /codex-rs (#11889) Bumps [env_logger](https://github.com/rust-cli/env_logger) from 0.11.8 to 0.11.9.
Release notes

Sourced from env_logger's releases.

v0.11.9

[0.11.9] - 2026-02-11

Changelog

Sourced from env_logger's changelog.

[0.11.9] - 2026-02-11

Commits
  • 2f06b4c chore: Release
  • 57e13ac chore: Release
  • 4f9066d Merge pull request #393 from rust-cli/renovate/crate-ci-typos-1.x
  • 3e4709a chore(deps): Update Rust crate snapbox to v0.6.24 (#394)
  • 80ff83a chore(deps): Update pre-commit hook crate-ci/typos to v1.42.3
  • 76891b9 Merge pull request #392 from epage/template
  • 14cda4a chore: Update from _rust template
  • e4f2b35 chore(ci): Update action
  • 6d0d36b chore(ci): Clean up previous branch in case it was leaked
  • 30b3b14 chore(ci): Fix how rustfmt jobs run
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=env_logger&package-manager=cargo&previous-version=0.11.8&new-version=0.11.9)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Eric Traut --- codex-rs/Cargo.lock | 8 ++++---- codex-rs/Cargo.toml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 18da829dcb..189d9468d2 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -3489,9 +3489,9 @@ checksum = "dbfd0e7fc632dec5e6c9396a27bc9f9975b4e039720e1fd3e34021d3ce28c415" [[package]] name = "env_filter" -version = "0.1.4" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" +checksum = "7a1c3cc8e57274ec99de65301228b537f1e4eedc1b8e0f9411c6caac8ae7308f" dependencies = [ "log", "regex", @@ -3505,9 +3505,9 @@ checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe" [[package]] name = "env_logger" -version = "0.11.8" +version = "0.11.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f" +checksum = "b2daee4ea451f429a58296525ddf28b45a3b64f1acf6587e2067437bb11e218d" dependencies = [ "anstream", "anstyle", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 75b3d18d3e..7ad0566efd 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -170,7 +170,7 @@ dotenvy = "0.15.7" dunce = "1.0.4" encoding_rs = "0.8.35" env-flags = "0.1.1" -env_logger = "0.11.5" +env_logger = "0.11.9" eventsource-stream = "0.2.3" futures = { version = "0.3", default-features = false } globset = "0.4" From 5341ad08f8b40ca2d47c9b90301e907cb90a5c6b Mon Sep 17 00:00:00 2001 From: gabec-openai Date: Tue, 17 Feb 2026 12:15:54 -0800 Subject: [PATCH 61/62] Use prompt-based co-author attribution with config override (#11617) --- codex-rs/core/config.schema.json | 10 ++++ codex-rs/core/src/codex.rs | 8 +++ codex-rs/core/src/commit_attribution.rs | 76 +++++++++++++++++++++++++ codex-rs/core/src/config/mod.rs | 19 +++++++ codex-rs/core/src/features.rs | 8 +++ codex-rs/core/src/lib.rs | 1 + 6 files changed, 122 insertions(+) create mode 100644 codex-rs/core/src/commit_attribution.rs diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index b05d11edc9..e3f7456c95 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -214,6 +214,9 @@ "child_agents_md": { "type": "boolean" }, + "codex_git_commit": { + "type": "boolean" + }, "collab": { "type": "boolean" }, @@ -1308,6 +1311,10 @@ "default": null, "description": "Preferred backend for storing CLI auth credentials. file (default): Use a file in the Codex home directory. keyring: Use an OS-specific keyring service. auto: Use the keyring if available, otherwise use a file." }, + "commit_attribution": { + "description": "Optional commit attribution text for commit message co-author trailers.\n\nSet to an empty string to disable automatic commit attribution.", + "type": "string" + }, "compact_prompt": { "description": "Compact prompt used for history compaction.", "type": "string" @@ -1347,6 +1354,9 @@ "child_agents_md": { "type": "boolean" }, + "codex_git_commit": { + "type": "boolean" + }, "collab": { "type": "boolean" }, diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index c0eb259756..6fa5ae3b80 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -17,6 +17,7 @@ use crate::analytics_client::AnalyticsEventsClient; use crate::analytics_client::AppInvocation; use crate::analytics_client::build_track_events_context; use crate::apps::render_apps_section; +use crate::commit_attribution::commit_message_trailer_instruction; use crate::compact; use crate::compact::run_inline_auto_compact_task; use crate::compact::should_use_remote_compact_task; @@ -2583,6 +2584,13 @@ impl Session { if turn_context.features.enabled(Feature::Apps) { items.push(DeveloperInstructions::new(render_apps_section()).into()); } + if turn_context.features.enabled(Feature::CodexGitCommit) + && let Some(commit_message_instruction) = commit_message_trailer_instruction( + turn_context.config.commit_attribution.as_deref(), + ) + { + items.push(DeveloperInstructions::new(commit_message_instruction).into()); + } if let Some(user_instructions) = turn_context.user_instructions.as_deref() { items.push( UserInstructions { diff --git a/codex-rs/core/src/commit_attribution.rs b/codex-rs/core/src/commit_attribution.rs new file mode 100644 index 0000000000..a2c9104b25 --- /dev/null +++ b/codex-rs/core/src/commit_attribution.rs @@ -0,0 +1,76 @@ +const DEFAULT_ATTRIBUTION_VALUE: &str = "Codex "; + +fn build_commit_message_trailer(config_attribution: Option<&str>) -> Option { + let value = resolve_attribution_value(config_attribution)?; + Some(format!("Co-authored-by: {value}")) +} + +pub(crate) fn commit_message_trailer_instruction( + config_attribution: Option<&str>, +) -> Option { + let trailer = build_commit_message_trailer(config_attribution)?; + Some(format!( + "When you write or edit a git commit message, ensure the message ends with this trailer exactly once:\n{trailer}\n\nRules:\n- Keep existing trailers and append this trailer at the end if missing.\n- Do not duplicate this trailer if it already exists.\n- Keep one blank line between the commit body and trailer block." + )) +} + +fn resolve_attribution_value(config_attribution: Option<&str>) -> Option { + match config_attribution { + Some(value) => { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + } + None => Some(DEFAULT_ATTRIBUTION_VALUE.to_string()), + } +} + +#[cfg(test)] +mod tests { + use super::build_commit_message_trailer; + use super::commit_message_trailer_instruction; + use super::resolve_attribution_value; + + #[test] + fn blank_attribution_disables_trailer_prompt() { + assert_eq!(build_commit_message_trailer(Some("")), None); + assert_eq!(commit_message_trailer_instruction(Some(" ")), None); + } + + #[test] + fn default_attribution_uses_codex_trailer() { + assert_eq!( + build_commit_message_trailer(None).as_deref(), + Some("Co-authored-by: Codex ") + ); + } + + #[test] + fn resolve_value_handles_default_custom_and_blank() { + assert_eq!( + resolve_attribution_value(None), + Some("Codex ".to_string()) + ); + assert_eq!( + resolve_attribution_value(Some("MyAgent ")), + Some("MyAgent ".to_string()) + ); + assert_eq!( + resolve_attribution_value(Some("MyAgent")), + Some("MyAgent".to_string()) + ); + assert_eq!(resolve_attribution_value(Some(" ")), None); + } + + #[test] + fn instruction_mentions_trailer_and_omits_generated_with() { + let instruction = commit_message_trailer_instruction(Some("AgentX ")) + .expect("instruction expected"); + assert!(instruction.contains("Co-authored-by: AgentX ")); + assert!(instruction.contains("exactly once")); + assert!(!instruction.contains("Generated-with")); + } +} diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index e69f57737e..9874cc2774 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -206,6 +206,13 @@ pub struct Config { /// Compact prompt override. pub compact_prompt: Option, + /// Optional commit attribution text for commit message co-author trailers. + /// + /// - `None`: use default attribution (`Codex `) + /// - `Some("")` or whitespace-only: disable commit attribution + /// - `Some("...")`: use the provided attribution text verbatim + pub commit_attribution: Option, + /// Optional external notifier command. When set, Codex will spawn this /// program after each completed *turn* (i.e. when the agent finishes /// processing a user submission). The value must be the full command @@ -917,6 +924,11 @@ pub struct ConfigToml { /// Compact prompt used for history compaction. pub compact_prompt: Option, + /// Optional commit attribution text for commit message co-author trailers. + /// + /// Set to an empty string to disable automatic commit attribution. + pub commit_attribution: Option, + /// When set, restricts ChatGPT login to a specific workspace identifier. #[serde(default)] pub forced_chatgpt_workspace_id: Option, @@ -1696,6 +1708,8 @@ impl Config { } }); + let commit_attribution = cfg.commit_attribution; + // Load base instructions override from a file if specified. If the // path is relative, resolve it against the effective cwd so the // behaviour matches other path-like config values. @@ -1816,6 +1830,7 @@ impl Config { personality, developer_instructions, compact_prompt, + commit_attribution, // The config.toml omits "_mode" because it's a config file. However, "_mode" // is important in code to differentiate the mode from the store implementation. cli_auth_credentials_store_mode: cfg.cli_auth_credentials_store.unwrap_or_default(), @@ -4192,6 +4207,7 @@ model_verbosity = "high" base_instructions: None, developer_instructions: None, compact_prompt: None, + commit_attribution: None, forced_chatgpt_workspace_id: None, forced_login_method: None, include_apply_patch_tool: false, @@ -4304,6 +4320,7 @@ model_verbosity = "high" base_instructions: None, developer_instructions: None, compact_prompt: None, + commit_attribution: None, forced_chatgpt_workspace_id: None, forced_login_method: None, include_apply_patch_tool: false, @@ -4414,6 +4431,7 @@ model_verbosity = "high" base_instructions: None, developer_instructions: None, compact_prompt: None, + commit_attribution: None, forced_chatgpt_workspace_id: None, forced_login_method: None, include_apply_patch_tool: false, @@ -4510,6 +4528,7 @@ model_verbosity = "high" base_instructions: None, developer_instructions: None, compact_prompt: None, + commit_attribution: None, forced_chatgpt_workspace_id: None, forced_login_method: None, include_apply_patch_tool: false, diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs index b55d8f260c..49363641bb 100644 --- a/codex-rs/core/src/features.rs +++ b/codex-rs/core/src/features.rs @@ -105,6 +105,8 @@ pub enum Feature { RemoteModels, /// Experimental shell snapshotting. ShellSnapshot, + /// Enable git commit attribution guidance via model instructions. + CodexGitCommit, /// Enable runtime metrics snapshots via a manual reader. RuntimeMetrics, /// Persist rollout metadata to a local SQLite database. @@ -466,6 +468,12 @@ pub const FEATURES: &[FeatureSpec] = &[ default_enabled: false, }, // Experimental program. Rendered in the `/experimental` menu for users. + FeatureSpec { + id: Feature::CodexGitCommit, + key: "codex_git_commit", + stage: Stage::UnderDevelopment, + default_enabled: false, + }, FeatureSpec { id: Feature::RuntimeMetrics, key: "runtime_metrics", diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 91bbe042ef..bbfef7f8fa 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -21,6 +21,7 @@ pub use codex_thread::ThreadConfigSnapshot; mod agent; mod codex_delegate; mod command_canonicalization; +mod commit_attribution; pub mod config; pub mod config_loader; pub mod connectors; From ad53574d5862dc79e0d4c4a294397effb46ed3c3 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Tue, 17 Feb 2026 12:29:03 -0800 Subject: [PATCH 62/62] =?UTF-8?q?Revert=20"chore(deps):=20bump=20rust-tool?= =?UTF-8?q?chain=20from=201.93.0=20to=201.93.1=20in=20/co=E2=80=A6dex-rs?= =?UTF-8?q?=20(#11886)"=20(#12035)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit af3b1ae6cb1d31b8985e8f87e18bc98cd524d4b5 which is breaking CI. --- codex-rs/rust-toolchain.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/rust-toolchain.toml b/codex-rs/rust-toolchain.toml index dc50bc36d3..954b684895 100644 --- a/codex-rs/rust-toolchain.toml +++ b/codex-rs/rust-toolchain.toml @@ -1,3 +1,3 @@ [toolchain] -channel = "1.93.1" +channel = "1.93.0" components = ["clippy", "rustfmt", "rust-src"]