mirror of
https://github.com/openai/codex.git
synced 2026-10-30 19:18:49 +00:00
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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user