Use turn-scoped permissions for standalone web search (#58189)

## Why

Standalone web search must use the same permission mode as hosted search. Permission changes during a turn should take effect on the next turn, without changing web access for searches in the active turn.

## What changed

Pass the turn's `WebSearchMode` to the web search extension and use it to determine tool availability and `external_web_access`, while retaining provider support checks and thread-level search settings.

## Testing

Add an app-server regression test that switches from cached search to Full Access while a turn is paused. Searches before and after the update keep `external_web_access` disabled in that turn; the next turn enables it.

GitOrigin-RevId: 9e3b8598f2e491388b7e7d4840c849155ae95e88
This commit is contained in:
sayan-oai
2026-10-29 23:24:28 +00:00
committed by copyberry
parent dfcb5b5994
commit 94d89c74d6
4 changed files with 164 additions and 18 deletions
@@ -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::<Value>()?;
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;
}
+2
View File
@@ -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
+14 -13
View File
@@ -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<dyn for<'call> codex_extension_api::ToolExecutor<codex_extension_api::ToolCall<'call>>>,
> {
let (Some(config), Some(workspace_routing)) = (
let (Some(config), Some(workspace_routing), Some(mode)) = (
thread_store.get::<WebSearchExtensionConfig>(),
thread_store.get::<WorkspaceRoutingContext>(),
step_store.get::<WebSearchMode>(),
) 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::<ModelInfo>().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(),
+3 -2
View File
@@ -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(),