diff --git a/codex-rs/app-server/tests/suite/v2/web_search.rs b/codex-rs/app-server/tests/suite/v2/web_search.rs index d6de23ce35..d7875acd8e 100644 --- a/codex-rs/app-server/tests/suite/v2/web_search.rs +++ b/codex-rs/app-server/tests/suite/v2/web_search.rs @@ -7,11 +7,15 @@ use app_test_support::ChatGptAuthFixture; use app_test_support::MockResponsesConfig; use app_test_support::TestAppServer; use app_test_support::write_chatgpt_auth; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ItemCompletedNotification; use codex_app_server_protocol::ItemStartedNotification; +use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::ThreadItemPayload; use codex_app_server_protocol::ThreadReadParams; use codex_app_server_protocol::ThreadReadResponse; +use codex_app_server_protocol::ThreadSettingsUpdateParams; +use codex_app_server_protocol::ThreadSettingsUpdateResponse; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::Turn; @@ -23,6 +27,7 @@ use codex_app_server_protocol::WebSearchItem; use codex_config::types::AuthCredentialsStoreMode; use codex_features::Feature; use codex_models_manager::model_info::model_info_from_slug; +use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS; use core_test_support::responses; use core_test_support::responses::strip_response_item_ids_from_json; use pretty_assertions::assert_eq; @@ -79,6 +84,143 @@ enum WebSearchProvider { CustomResponses, } +#[tokio::test] +async fn standalone_web_search_uses_new_permissions_only_on_the_next_turn() -> Result<()> { + let server = responses::start_mock_server().await; + let search_path = "/v1/alpha/search"; + mount_search_response(&server, search_path, /*expected_calls*/ 3).await; + let search = |id| { + responses::sse(vec![ + responses::ev_function_call_with_namespace( + id, + "web", + "run", + r#"{"search_query":[{"q":"weather"}]}"#, + ), + responses::ev_completed(id), + ]) + }; + responses::mount_sse_sequence( + &server, + vec![ + search("before-update"), + responses::sse(vec![ + responses::ev_function_call( + "pause", + "request_user_input", + &json!({ + "questions": [{ + "id": "continue", "header": "Continue", "question": "Search again?", + "options": [ + {"label": "Yes", "description": "Search again"}, + {"label": "No", "description": "Finish"} + ] + }] + }) + .to_string(), + ), + responses::ev_completed("paused"), + ]), + search("after-update"), + responses::sse_completed("first-turn-done"), + search("next-turn"), + responses::sse_completed("second-turn-done"), + ], + ) + .await; + + let codex_home = TempDir::new()?; + MockResponsesConfig::new(&server.uri()) + .with_root_config(r#"web_search = "cached""#) + .enable_feature(Feature::StandaloneWebSearch) + .enable_feature(Feature::DefaultModeRequestUserInput) + .with_provider_config("supports_websockets = false") + .with_provider_config("supports_standalone_web_search = true") + .write(codex_home.path())?; + let mut app = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build_initialized_with_timeout(DEFAULT_READ_TIMEOUT) + .await?; + let thread_id = app + .start_thread(ThreadStartParams::default()) + .await? + .thread + .id; + let turn = TurnStartParams { + thread_id: thread_id.clone(), + input: vec![V2UserInput::Text { + text: "Search the web".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }; + let start = app.send_turn_start_request(turn.clone()).await?; + let _: TurnStartResponse = app.read_response(start).await?; + let ServerRequest::ToolRequestUserInput { request_id, .. } = timeout( + DEFAULT_READ_TIMEOUT, + app.read_stream_until_request_message(), + ) + .await?? + else { + anyhow::bail!("expected the first turn to pause for input"); + }; + let _: ThreadSettingsUpdateResponse = app + .request(|request_id| ClientRequest::ThreadSettingsUpdate { + request_id, + params: ThreadSettingsUpdateParams { + thread_id, + permissions: Some(BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS.to_string()), + ..Default::default() + }, + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + app.read_stream_until_matching_notification( + "thread settings updated to Full Access", + |notification| { + notification.method == "thread/settings/updated" + && notification + .params + .as_ref() + .and_then(|params| params.pointer("/threadSettings/sandboxPolicy/type")) + .and_then(Value::as_str) + == Some("dangerFullAccess") + }, + ), + ) + .await??; + app.send_response( + request_id, + json!({"answers":{"continue":{"answers":["Yes"]}}}), + ) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + app.read_stream_until_notification_message("turn/completed"), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + app.start_turn_and_wait_for_completion(turn), + ) + .await??; + + let mut observed = Vec::new(); + for request in server + .received_requests() + .await + .context("expected HTTP requests")? + { + if request.url.path() == search_path { + let body = request.body_json::()?; + observed.push(body["settings"]["external_web_access"].clone()); + } + } + assert_eq!(observed, vec![json!(false), json!(false), json!(true)]); + Ok(()) +} + async fn assert_standalone_web_search_round_trips_output( provider: WebSearchProvider, literature_search: bool, @@ -114,7 +256,7 @@ async fn assert_standalone_web_search_round_trips_output( WebSearchProvider::ChatGpt => "/api/codex/alpha/search", WebSearchProvider::CustomResponses => "/v1/alpha/search", }; - mount_search_response(&server, search_path).await; + mount_search_response(&server, search_path, /*expected_calls*/ 1).await; let response_mock = responses::mount_sse_sequence( &server, @@ -427,7 +569,7 @@ async fn wait_for_web_search_completed( } } -async fn mount_search_response(server: &MockServer, search_path: &str) { +async fn mount_search_response(server: &MockServer, search_path: &str, expected_calls: u64) { Mock::given(method("POST")) .and(path(search_path)) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ @@ -442,7 +584,7 @@ async fn mount_search_response(server: &MockServer, search_path: &str) { "future_field": {"preserved": true}, }], }))) - .expect(1) + .expect(expected_calls) .mount(server) .await; } diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index f43f76748f..9f4a4e3917 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -4147,6 +4147,8 @@ impl Session { let extension_data = codex_extension_api::ExtensionData::new(turn_context.sub_id.clone()); extension_data.insert_arc(Arc::clone(&settings.model_info)); + // Standalone and hosted search must use the same turn-scoped permission mode. + extension_data.insert(turn_context.config.web_search_mode.value()); if let Some(messages) = settings .model_info .model_messages diff --git a/codex-rs/ext/web-search/src/extension.rs b/codex-rs/ext/web-search/src/extension.rs index 531cc364f9..bebdf5ebf3 100644 --- a/codex-rs/ext/web-search/src/extension.rs +++ b/codex-rs/ext/web-search/src/extension.rs @@ -36,7 +36,7 @@ struct WebSearchExtension { #[derive(Clone)] struct WebSearchExtensionConfig { - available: bool, + provider_supports_search: bool, http_client_factory: HttpClientFactory, provider: ModelProviderInfo, settings: SearchSettings, @@ -44,21 +44,18 @@ struct WebSearchExtensionConfig { impl From<&Config> for WebSearchExtensionConfig { fn from(config: &Config) -> Self { - let web_search_mode = config.web_search_mode.value(); Self { - // Core selects this executor per turn using the feature flag or model metadata. - available: (config.model_provider.is_openai() + provider_supports_search: config.model_provider.is_openai() || config.model_provider.uses_openai_actor_authorization() - || config.model_provider.supports_standalone_web_search) - && web_search_mode != WebSearchMode::Disabled, + || config.model_provider.supports_standalone_web_search, http_client_factory: config.http_client_factory(), provider: config.model_provider.clone(), - settings: search_settings(config, web_search_mode), + settings: search_settings(config), } } } -fn search_settings(config: &Config, web_search_mode: WebSearchMode) -> SearchSettings { +fn search_settings(config: &Config) -> SearchSettings { let web_search_config = config.web_search_config.as_ref(); SearchSettings { user_location: web_search_config @@ -84,7 +81,6 @@ fn search_settings(config: &Config, web_search_mode: WebSearchMode) -> SearchSet blocked_domains: None, }), allowed_callers: Some(vec![AllowedCaller::Direct]), - external_web_access: Some(external_web_access_for_mode(web_search_mode)), ..Default::default() } } @@ -131,13 +127,14 @@ impl ToolContributor for WebSearchExtension { ) -> Vec< Arc codex_extension_api::ToolExecutor>>, > { - let (Some(config), Some(workspace_routing)) = ( + let (Some(config), Some(workspace_routing), Some(mode)) = ( thread_store.get::(), thread_store.get::(), + step_store.get::(), ) else { return Vec::new(); }; - if !config.available { + if !config.provider_supports_search || *mode == WebSearchMode::Disabled { return Vec::new(); } @@ -149,7 +146,10 @@ impl ToolContributor for WebSearchExtension { Some(self.auth_manager.clone()), ), workspace_routing, - settings: config.settings.clone(), + settings: SearchSettings { + external_web_access: Some(external_web_access_for_mode(*mode)), + ..config.settings.clone() + }, supports_literature_search: step_store.get::().is_some_and(|model| { model .experimental_supported_tools @@ -230,8 +230,9 @@ mod tests { thread_store.insert(WorkspaceRoutingContext::new( "https://chatgpt.com/backend-api".to_string(), )); + step_store.insert(WebSearchMode::Cached); thread_store.insert(WebSearchExtensionConfig { - available: true, + provider_supports_search: true, http_client_factory: HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault), provider: ModelProviderInfo::create_openai_provider(/*base_url*/ None), settings: Default::default(), diff --git a/codex-rs/ext/web-search/src/routing_tests.rs b/codex-rs/ext/web-search/src/routing_tests.rs index 01fc407752..079a24dcca 100644 --- a/codex-rs/ext/web-search/src/routing_tests.rs +++ b/codex-rs/ext/web-search/src/routing_tests.rs @@ -82,8 +82,9 @@ async fn config_changes_preserve_the_threads_established_routing() { let mut changed = config.clone(); changed.chatgpt_base_url = "https://changed.example/backend-api".into(); extension.on_config_changed(&session_store, &thread_store, &config, &changed); - let tools = - extension.tools_for_step(&session_store, &thread_store, &ExtensionData::new("step")); + let step_store = ExtensionData::new("step"); + step_store.insert(config.web_search_mode.value()); + let tools = extension.tools_for_step(&session_store, &thread_store, &step_store); let call = ToolCall { turn_id: "turn".into(), call_id: "search".into(),