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 87726d30c..f8f8db58b 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 @@ -20355,6 +20355,7 @@ "enum": [ "disabled", "cached", + "indexed", "live" ], "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 0c2f2f54b..81a79c0c9 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -18134,6 +18134,7 @@ "enum": [ "disabled", "cached", + "indexed", "live" ], "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json index c7fa35294..ef2411534 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json @@ -809,6 +809,7 @@ "enum": [ "disabled", "cached", + "indexed", "live" ], "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json index e57bd48b7..edbb7f0bc 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json @@ -503,6 +503,7 @@ "enum": [ "disabled", "cached", + "indexed", "live" ], "type": "string" diff --git a/codex-rs/app-server-protocol/schema/typescript/WebSearchMode.ts b/codex-rs/app-server-protocol/schema/typescript/WebSearchMode.ts index 695c13e3f..0544fd09c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/WebSearchMode.ts +++ b/codex-rs/app-server-protocol/schema/typescript/WebSearchMode.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 WebSearchMode = "disabled" | "cached" | "live"; +export type WebSearchMode = "disabled" | "cached" | "indexed" | "live"; diff --git a/codex-rs/codex-api/src/endpoint/search.rs b/codex-rs/codex-api/src/endpoint/search.rs index 864f7ec9b..93f6249f1 100644 --- a/codex-rs/codex-api/src/endpoint/search.rs +++ b/codex-rs/codex-api/src/endpoint/search.rs @@ -55,6 +55,7 @@ mod tests { use crate::provider::RetryConfig; use crate::search::AllowedCaller; use crate::search::ApproximateLocation; + use crate::search::ExternalWebAccess; use crate::search::LocationType; use crate::search::OpenOperation; use crate::search::SearchCommands; @@ -193,7 +194,7 @@ mod tests { caption: Some(true), }), allowed_callers: Some(vec![AllowedCaller::Direct]), - external_web_access: Some(true), + external_web_access: Some(ExternalWebAccess::Boolean(true)), }), max_output_tokens: Some(2500), }, diff --git a/codex-rs/codex-api/src/lib.rs b/codex-rs/codex-api/src/lib.rs index df6723f02..4881e77fb 100644 --- a/codex-rs/codex-api/src/lib.rs +++ b/codex-rs/codex-api/src/lib.rs @@ -81,6 +81,8 @@ pub use crate::requests::Compression; pub use crate::search::AllowedCaller; pub use crate::search::ApproximateLocation; pub use crate::search::ClickOperation; +pub use crate::search::ExternalWebAccess; +pub use crate::search::ExternalWebAccessMode; pub use crate::search::FinanceAssetType; pub use crate::search::FinanceOperation; pub use crate::search::FindOperation; diff --git a/codex-rs/codex-api/src/search.rs b/codex-rs/codex-api/src/search.rs index bae7c8a7d..b896186af 100644 --- a/codex-rs/codex-api/src/search.rs +++ b/codex-rs/codex-api/src/search.rs @@ -211,6 +211,21 @@ pub enum SearchResponseLength { Long, } +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum ExternalWebAccessMode { + Offline, + Indexed, + Online, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(untagged)] +pub enum ExternalWebAccess { + Boolean(bool), + Mode(ExternalWebAccessMode), +} + #[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] pub struct SearchSettings { #[serde(skip_serializing_if = "Option::is_none")] @@ -224,7 +239,7 @@ pub struct SearchSettings { #[serde(skip_serializing_if = "Option::is_none")] pub allowed_callers: Option>, #[serde(skip_serializing_if = "Option::is_none")] - pub external_web_access: Option, + pub external_web_access: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] diff --git a/codex-rs/config/src/config_requirements.rs b/codex-rs/config/src/config_requirements.rs index ca4407ce4..ac5173453 100644 --- a/codex-rs/config/src/config_requirements.rs +++ b/codex-rs/config/src/config_requirements.rs @@ -665,10 +665,11 @@ fn is_glob_metacharacter(ch: char) -> bool { } #[derive(Deserialize, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -#[serde(rename_all = "lowercase")] +#[serde(rename_all = "snake_case")] pub enum WebSearchModeRequirement { Disabled, Cached, + Indexed, Live, } @@ -677,6 +678,7 @@ impl From for WebSearchModeRequirement { match mode { WebSearchMode::Disabled => WebSearchModeRequirement::Disabled, WebSearchMode::Cached => WebSearchModeRequirement::Cached, + WebSearchMode::Indexed => WebSearchModeRequirement::Indexed, WebSearchMode::Live => WebSearchModeRequirement::Live, } } @@ -687,6 +689,7 @@ impl From for WebSearchMode { match mode { WebSearchModeRequirement::Disabled => WebSearchMode::Disabled, WebSearchModeRequirement::Cached => WebSearchMode::Cached, + WebSearchModeRequirement::Indexed => WebSearchMode::Indexed, WebSearchModeRequirement::Live => WebSearchMode::Live, } } @@ -697,6 +700,7 @@ impl fmt::Display for WebSearchModeRequirement { match self { WebSearchModeRequirement::Disabled => write!(f, "disabled"), WebSearchModeRequirement::Cached => write!(f, "cached"), + WebSearchModeRequirement::Indexed => write!(f, "indexed"), WebSearchModeRequirement::Live => write!(f, "live"), } } @@ -1345,6 +1349,8 @@ impl TryFrom for ConfigRequirements { let initial_value = if accepted.contains(&WebSearchModeRequirement::Cached) { WebSearchMode::Cached + } else if accepted.contains(&WebSearchModeRequirement::Indexed) { + WebSearchMode::Indexed } else if accepted.contains(&WebSearchModeRequirement::Live) { WebSearchMode::Live } else { @@ -2927,6 +2933,34 @@ allowed_approvals_reviewers = ["user"] Ok(()) } + #[test] + fn allowed_web_search_modes_supports_indexed() -> Result<()> { + let config: ConfigRequirementsToml = from_str( + r#" + allowed_web_search_modes = ["indexed"] + "#, + )?; + let requirements: ConfigRequirements = with_unknown_source(config).try_into()?; + + assert_eq!(requirements.web_search_mode.value(), WebSearchMode::Indexed); + for mode in [WebSearchMode::Disabled, WebSearchMode::Indexed] { + assert!(requirements.web_search_mode.can_set(&mode).is_ok()); + } + for mode in [WebSearchMode::Cached, WebSearchMode::Live] { + assert_eq!( + requirements.web_search_mode.can_set(&mode), + Err(ConstraintError::InvalidValue { + field_name: "web_search_mode", + candidate: format!("{mode:?}"), + allowed: "[Disabled, Indexed]".into(), + requirement_source: RequirementSource::Unknown, + }) + ); + } + + Ok(()) + } + #[test] fn allowed_web_search_modes_allows_disabled() -> Result<()> { let toml_str = r#" diff --git a/codex-rs/config/src/config_toml.rs b/codex-rs/config/src/config_toml.rs index f575aa27d..3e0ca82b3 100644 --- a/codex-rs/config/src/config_toml.rs +++ b/codex-rs/config/src/config_toml.rs @@ -409,7 +409,7 @@ pub struct ConfigToml { pub experimental_thread_store: Option, pub projects: Option>, - /// Controls the web search tool mode: disabled, cached, or live. + /// Controls the web search tool mode: disabled, cached, indexed, or live. pub web_search: Option, /// Nested tools section for feature toggles diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 7d21c9069..c507fc61b 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -4419,6 +4419,7 @@ "enum": [ "disabled", "cached", + "indexed", "live" ], "type": "string" @@ -5380,7 +5381,7 @@ "$ref": "#/definitions/WebSearchMode" } ], - "description": "Controls the web search tool mode: disabled, cached, or live." + "description": "Controls the web search tool mode: disabled, cached, indexed, or live." }, "windows": { "allOf": [ diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index a6a9ab636..744619478 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -5186,6 +5186,14 @@ fn web_search_mode_disabled_overrides_legacy_request() { ); } +#[test] +fn web_search_mode_for_turn_preserves_indexed_for_disabled_permissions() { + let web_search_mode = Constrained::allow_any(WebSearchMode::Indexed); + let mode = resolve_web_search_mode_for_turn(&web_search_mode, &PermissionProfile::Disabled); + + assert_eq!(mode, WebSearchMode::Indexed); +} + #[test] fn web_search_mode_for_turn_uses_preference_for_read_only() { let web_search_mode = Constrained::allow_any(WebSearchMode::Cached); @@ -5232,6 +5240,31 @@ fn web_search_mode_for_turn_falls_back_when_live_is_disallowed() -> anyhow::Resu Ok(()) } +#[test] +fn web_search_mode_for_turn_does_not_implicitly_select_indexed() -> anyhow::Result<()> { + let allowed = [ + WebSearchMode::Disabled, + WebSearchMode::Cached, + WebSearchMode::Indexed, + ]; + let web_search_mode = Constrained::new(WebSearchMode::Cached, move |candidate| { + if allowed.contains(candidate) { + Ok(()) + } else { + Err(ConstraintError::InvalidValue { + field_name: "web_search_mode", + candidate: format!("{candidate:?}"), + allowed: format!("{allowed:?}"), + requirement_source: RequirementSource::Unknown, + }) + } + })?; + let mode = resolve_web_search_mode_for_turn(&web_search_mode, &PermissionProfile::Disabled); + + assert_eq!(mode, WebSearchMode::Cached); + Ok(()) +} + #[tokio::test] async fn project_profiles_are_ignored() -> std::io::Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 4e0cb77fe..a1ecd8f8a 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -2667,7 +2667,7 @@ pub(crate) fn resolve_web_search_mode_for_turn( let preferred = web_search_mode.value(); if matches!(permission_profile, PermissionProfile::Disabled) - && preferred != WebSearchMode::Disabled + && !matches!(preferred, WebSearchMode::Disabled | WebSearchMode::Indexed) { for mode in [ WebSearchMode::Live, diff --git a/codex-rs/core/src/tools/hosted_spec.rs b/codex-rs/core/src/tools/hosted_spec.rs index ba26ba6b2..24a7905fa 100644 --- a/codex-rs/core/src/tools/hosted_spec.rs +++ b/codex-rs/core/src/tools/hosted_spec.rs @@ -18,11 +18,12 @@ pub fn create_image_generation_tool(output_format: &str) -> ToolSpec { } pub fn create_web_search_tool(options: WebSearchToolOptions<'_>) -> Option { - let external_web_access = match options.web_search_mode { - Some(WebSearchMode::Cached) => Some(false), - Some(WebSearchMode::Live) => Some(true), - Some(WebSearchMode::Disabled) | None => None, - }?; + let (external_web_access, index_gated_web_access) = match options.web_search_mode { + Some(WebSearchMode::Cached) => (false, None), + Some(WebSearchMode::Indexed) => (true, Some(true)), + Some(WebSearchMode::Live) => (true, None), + Some(WebSearchMode::Disabled) | None => return None, + }; let search_content_types = match options.web_search_tool_type { WebSearchToolType::Text => None, @@ -36,6 +37,7 @@ pub fn create_web_search_tool(options: WebSearchToolOptions<'_>) -> Option Result<()> { + assert_code_mode_standalone_web_search(WebSearchMode::Live, serde_json::json!(true)).await +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn code_mode_can_call_indexed_standalone_web_search() -> Result<()> { + assert_code_mode_standalone_web_search(WebSearchMode::Indexed, serde_json::json!("indexed")) + .await +} + +async fn assert_code_mode_standalone_web_search( + web_search_mode: WebSearchMode, + expected_external_web_access: Value, +) -> Result<()> { skip_if_no_network!(Ok(())); let server = responses::start_mock_server().await; @@ -268,7 +281,7 @@ text(result); .with_auth(auth) .with_extensions(Arc::new(extension_builder.build())) .with_model("test-gpt-5.1-codex") - .with_config(|config| { + .with_config(move |config| { config .features .enable(Feature::CodeMode) @@ -279,7 +292,7 @@ text(result); .expect("standalone web search should be enabled"); config .web_search_mode - .set(WebSearchMode::Live) + .set(web_search_mode) .expect("web search mode should be accepted"); }); let test = builder.build(&server).await?; @@ -310,7 +323,7 @@ text(result); search_body["settings"], serde_json::json!({ "allowed_callers": ["direct"], - "external_web_access": true, + "external_web_access": expected_external_web_access, }) ); assert_eq!( diff --git a/codex-rs/core/tests/suite/deprecation_notice.rs b/codex-rs/core/tests/suite/deprecation_notice.rs index c41ff47f2..9248486f5 100644 --- a/codex-rs/core/tests/suite/deprecation_notice.rs +++ b/codex-rs/core/tests/suite/deprecation_notice.rs @@ -92,7 +92,7 @@ async fn emits_deprecation_notice_for_web_search_feature_flag_values() -> anyhow 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." + "Set `web_search` to `\"live\"`, `\"indexed\"`, `\"cached\"`, or `\"disabled\"` at the top level (or under a profile) in config.toml if you want to override it." ), ); } diff --git a/codex-rs/core/tests/suite/web_search.rs b/codex-rs/core/tests/suite/web_search.rs index 529cee8b8..5728b97a7 100644 --- a/codex-rs/core/tests/suite/web_search.rs +++ b/codex-rs/core/tests/suite/web_search.rs @@ -274,3 +274,42 @@ location = { country = "US", city = "New York", timezone = "America/New_York" } }) ); } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn indexed_web_search_mode_sets_index_gate() { + skip_if_no_network!(); + + let server = start_mock_server().await; + let sse = responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_completed("resp-1"), + ]); + let resp_mock = responses::mount_sse_once(&server, sse).await; + + let home = Arc::new(tempfile::TempDir::new().expect("create codex home")); + std::fs::write(home.path().join("config.toml"), r#"web_search = "indexed""#) + .expect("write config.toml"); + + let mut builder = test_codex().with_model("gpt-5.3-codex").with_home(home); + let test = builder + .build(&server) + .await + .expect("create test Codex conversation"); + + test.submit_turn_with_permission_profile( + "hello indexed web search", + PermissionProfile::Disabled, + ) + .await + .expect("submit turn"); + + let body = resp_mock.single_request().body_json(); + let tool = find_web_search_tool(&body); + assert_eq!( + ( + tool.get("external_web_access").and_then(Value::as_bool), + tool.get("index_gated_web_access").and_then(Value::as_bool), + ), + (Some(true), Some(true)) + ); +} diff --git a/codex-rs/ext/web-search/src/extension.rs b/codex-rs/ext/web-search/src/extension.rs index 688b504de..7afdfe413 100644 --- a/codex-rs/ext/web-search/src/extension.rs +++ b/codex-rs/ext/web-search/src/extension.rs @@ -2,6 +2,8 @@ use std::sync::Arc; use codex_api::AllowedCaller; use codex_api::ApproximateLocation; +use codex_api::ExternalWebAccess; +use codex_api::ExternalWebAccessMode; use codex_api::LocationType; use codex_api::SearchContextSize; use codex_api::SearchFilters; @@ -73,14 +75,19 @@ fn search_settings(config: &Config, web_search_mode: WebSearchMode) -> SearchSet blocked_domains: None, }), allowed_callers: Some(vec![AllowedCaller::Direct]), - external_web_access: Some(match web_search_mode { - WebSearchMode::Live => true, - WebSearchMode::Cached | WebSearchMode::Disabled => false, - }), + external_web_access: Some(external_web_access_for_mode(web_search_mode)), ..Default::default() } } +fn external_web_access_for_mode(web_search_mode: WebSearchMode) -> ExternalWebAccess { + match web_search_mode { + WebSearchMode::Disabled | WebSearchMode::Cached => ExternalWebAccess::Boolean(false), + WebSearchMode::Indexed => ExternalWebAccess::Mode(ExternalWebAccessMode::Indexed), + WebSearchMode::Live => ExternalWebAccess::Boolean(true), + } +} + impl ThreadLifecycleContributor for WebSearchExtension { fn on_thread_start<'a>( &'a self, @@ -149,9 +156,32 @@ mod tests { use super::AuthManager; use super::Config; use super::WebSearchExtensionConfig; + use super::external_web_access_for_mode; use super::install; use crate::tool::RUN_TOOL_NAME; use crate::tool::WEB_NAMESPACE; + use codex_api::ExternalWebAccess; + use codex_api::ExternalWebAccessMode; + use codex_protocol::config_types::WebSearchMode; + + #[test] + fn external_web_access_preserves_legacy_values_until_indexed() { + assert_eq!( + [ + WebSearchMode::Disabled, + WebSearchMode::Cached, + WebSearchMode::Indexed, + WebSearchMode::Live, + ] + .map(external_web_access_for_mode), + [ + ExternalWebAccess::Boolean(false), + ExternalWebAccess::Boolean(false), + ExternalWebAccess::Mode(ExternalWebAccessMode::Indexed), + ExternalWebAccess::Boolean(true), + ] + ); + } #[test] fn installed_extension_contributes_web_run_when_enabled() { diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index 6dc8827fe..3bba6faf9 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -599,7 +599,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 if you want to override it." + "Set `web_search` to `\"live\"`, `\"indexed\"`, `\"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/protocol/src/config_types.rs b/codex-rs/protocol/src/config_types.rs index 64cfefeb7..5c250648a 100644 --- a/codex-rs/protocol/src/config_types.rs +++ b/codex-rs/protocol/src/config_types.rs @@ -313,12 +313,13 @@ pub enum MultiAgentMode { #[derive( Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Display, JsonSchema, TS, Default, )] -#[serde(rename_all = "lowercase")] -#[strum(serialize_all = "lowercase")] +#[serde(rename_all = "snake_case")] +#[strum(serialize_all = "snake_case")] pub enum WebSearchMode { Disabled, #[default] Cached, + Indexed, Live, } diff --git a/codex-rs/tools/src/tool_spec.rs b/codex-rs/tools/src/tool_spec.rs index 98f705363..146cef3b6 100644 --- a/codex-rs/tools/src/tool_spec.rs +++ b/codex-rs/tools/src/tool_spec.rs @@ -38,6 +38,8 @@ pub enum ToolSpec { #[serde(skip_serializing_if = "Option::is_none")] external_web_access: Option, #[serde(skip_serializing_if = "Option::is_none")] + index_gated_web_access: Option, + #[serde(skip_serializing_if = "Option::is_none")] filters: Option, #[serde(skip_serializing_if = "Option::is_none")] user_location: Option, diff --git a/codex-rs/tools/src/tool_spec_tests.rs b/codex-rs/tools/src/tool_spec_tests.rs index a181cd247..b6f599d5d 100644 --- a/codex-rs/tools/src/tool_spec_tests.rs +++ b/codex-rs/tools/src/tool_spec_tests.rs @@ -67,6 +67,7 @@ fn tool_spec_name_covers_all_variants() { assert_eq!( ToolSpec::WebSearch { external_web_access: Some(true), + index_gated_web_access: None, filters: None, user_location: None, search_context_size: None, @@ -199,6 +200,7 @@ fn web_search_tool_spec_serializes_expected_wire_shape() { assert_eq!( serde_json::to_value(ToolSpec::WebSearch { external_web_access: Some(true), + index_gated_web_access: None, filters: Some(ResponsesApiWebSearchFilters { allowed_domains: Some(vec!["example.com".to_string()]), }),