diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 51a6cd08cc..fad03313ef 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2589,6 +2589,7 @@ dependencies = [ "codex-app-server-protocol", "codex-apply-patch", "codex-async-utils", + "codex-backend-client", "codex-code-mode", "codex-config", "codex-connectors", diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 352e8051e4..fc66731b55 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -29,6 +29,7 @@ codex-api = { workspace = true } codex-app-server-protocol = { workspace = true } codex-apply-patch = { workspace = true } codex-async-utils = { workspace = true } +codex-backend-client = { workspace = true } codex-code-mode = { workspace = true } codex-connectors = { workspace = true } codex-context-fragments = { workspace = true } diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index cadb797212..58612f22ae 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -154,6 +154,7 @@ pub use codex_network_proxy::NetworkProxyAuditMetadata; use codex_sandboxing::compatibility_sandbox_policy_for_permission_profile; pub use codex_sandboxing::system_bwrap_warning; pub use managed_features::ManagedFeatures; +pub(crate) use network_proxy_spec::NetworkProxyRuntimeState; pub use network_proxy_spec::NetworkProxySpec; pub use network_proxy_spec::StartedNetworkProxy; pub(crate) use permissions::is_builtin_permission_profile_name; diff --git a/codex-rs/core/src/config/network_proxy_spec.rs b/codex-rs/core/src/config/network_proxy_spec.rs index 631a826ac7..5a62391de8 100644 --- a/codex-rs/core/src/config/network_proxy_spec.rs +++ b/codex-rs/core/src/config/network_proxy_spec.rs @@ -34,6 +34,11 @@ pub struct StartedNetworkProxy { _handle: NetworkProxyHandle, } +pub(crate) struct NetworkProxyRuntimeState { + pub(crate) state: ConfigState, + pub(crate) reloader: Arc, +} + impl StartedNetworkProxy { fn new(proxy: NetworkProxy, handle: NetworkProxyHandle) -> Self { Self { @@ -128,7 +133,33 @@ impl NetworkProxySpec { enable_network_approval_flow: bool, audit_metadata: NetworkProxyAuditMetadata, ) -> std::io::Result { - let state = self.build_state_with_audit_metadata(audit_metadata)?; + let state = self.build_config_state_for_spec()?; + self.start_proxy_with_runtime_state( + permission_profile, + policy_decider, + blocked_request_observer, + enable_network_approval_flow, + audit_metadata, + NetworkProxyRuntimeState { + state: state.clone(), + reloader: Arc::new(StaticNetworkProxyReloader::new(state)), + }, + ) + .await + } + + pub(crate) async fn start_proxy_with_runtime_state( + &self, + permission_profile: &PermissionProfile, + policy_decider: Option>, + blocked_request_observer: Option>, + enable_network_approval_flow: bool, + audit_metadata: NetworkProxyAuditMetadata, + runtime_state: NetworkProxyRuntimeState, + ) -> std::io::Result { + let NetworkProxyRuntimeState { state, reloader } = runtime_state; + let state = + NetworkProxyState::with_reloader_and_audit_metadata(state, reloader, audit_metadata); let mut builder = NetworkProxy::builder().state(Arc::new(state)); if enable_network_approval_flow && !self.hard_deny_allowlist_misses { if let Some(policy_decider) = policy_decider { @@ -191,6 +222,7 @@ impl NetworkProxySpec { }) } + #[cfg(test)] fn build_state_with_audit_metadata( &self, audit_metadata: NetworkProxyAuditMetadata, @@ -204,7 +236,7 @@ impl NetworkProxySpec { )) } - fn build_config_state_for_spec(&self) -> std::io::Result { + pub(crate) fn build_config_state_for_spec(&self) -> std::io::Result { build_config_state(self.config.clone(), self.constraints.clone()).map_err(|err| { std::io::Error::other(format!("failed to build network proxy state: {err}")) }) diff --git a/codex-rs/core/src/credentialed_routes.rs b/codex-rs/core/src/credentialed_routes.rs new file mode 100644 index 0000000000..d20b3308d0 --- /dev/null +++ b/codex-rs/core/src/credentialed_routes.rs @@ -0,0 +1,126 @@ +use anyhow::Result; +use codex_backend_client::Client as BackendClient; +use codex_login::AuthManager; +use codex_login::CodexAuth; +use codex_network_proxy::CredentialedRoute; +use codex_network_proxy::CredentialedRouteProxyHeader; +use codex_network_proxy::CredentialedRoutesConfig; +use codex_network_proxy::CredentialedRoutesSource; +use http::HeaderMap; +use std::collections::BTreeSet; +use std::sync::Arc; +use tracing::debug; +use tracing::warn; + +const MAX_CREDENTIALED_ROUTE_INSTRUCTION_CHARS: usize = 8_000; +const MAX_CREDENTIALED_ROUTE_INSTRUCTION_PREFIXES: usize = 100; +const OMITTED_CREDENTIALED_ROUTES_INSTRUCTION: &str = + "\n- [additional credentialed routes omitted]"; + +pub(crate) async fn load_for_session( + chatgpt_base_url: &str, + auth: Option<&CodexAuth>, +) -> CredentialedRoutesConfig { + match fetch(chatgpt_base_url, auth).await { + Ok(credentialed_routes) => credentialed_routes, + Err(err) => { + warn!(error = %err, "failed to load credentialed routes for session"); + CredentialedRoutesConfig::default() + } + } +} + +pub(crate) fn developer_instructions( + credentialed_routes: &CredentialedRoutesConfig, +) -> Option { + let route_prefixes = credentialed_routes + .routes + .iter() + .map(|route| route.base_url.clone()) + .collect::>(); + if route_prefixes.is_empty() { + return None; + } + + let header = "The managed network proxy automatically attaches stored credentials when you call these HTTPS URL prefixes directly:"; + let mut instructions = header.to_string(); + let mut omitted_prefixes = false; + let route_prefix_count = route_prefixes.len(); + for (index, route_prefix) in route_prefixes.into_iter().enumerate() { + let route_prefix = format!("\n- {route_prefix}"); + let omitted_suffix_len = if index + 1 < route_prefix_count { + OMITTED_CREDENTIALED_ROUTES_INSTRUCTION.len() + } else { + 0 + }; + if index == MAX_CREDENTIALED_ROUTE_INSTRUCTION_PREFIXES + || instructions.len() + route_prefix.len() + omitted_suffix_len + > MAX_CREDENTIALED_ROUTE_INSTRUCTION_CHARS + { + omitted_prefixes = true; + break; + } + instructions.push_str(&route_prefix); + } + if omitted_prefixes { + instructions.push_str(OMITTED_CREDENTIALED_ROUTES_INSTRUCTION); + } + Some(instructions) +} + +async fn fetch( + chatgpt_base_url: &str, + auth: Option<&CodexAuth>, +) -> Result { + let Some(auth) = auth.filter(|auth| auth.uses_codex_backend()) else { + return Ok(CredentialedRoutesConfig::default()); + }; + + let client = BackendClient::from_auth(chatgpt_base_url.to_string(), auth)?; + let response = client.list_credential_routes().await?; + debug!( + credentialed_routes = response.routes.len(), + "loaded credentialed routes for session" + ); + Ok(CredentialedRoutesConfig { + routes: response + .routes + .into_iter() + .map(|route| CredentialedRoute { + connector_id: route.connector_id, + link_id: route.link_id, + base_url: route.base_url, + }) + .collect(), + proxy_headers: credentialed_route_proxy_headers(client.credential_routes_proxy_headers()), + proxy_url: Some(client.credential_routes_proxy_url()), + }) +} + +pub(crate) fn source( + chatgpt_base_url: String, + auth_manager: Arc, +) -> Arc { + Arc::new(move || { + let chatgpt_base_url = chatgpt_base_url.clone(); + let auth_manager = Arc::clone(&auth_manager); + async move { + let auth = auth_manager.auth().await; + fetch(&chatgpt_base_url, auth.as_ref()).await + } + }) +} + +#[cfg(test)] +#[path = "credentialed_routes_tests.rs"] +mod tests; + +fn credentialed_route_proxy_headers(headers: HeaderMap) -> Vec { + headers + .iter() + .map(|(name, value)| CredentialedRouteProxyHeader { + name: name.clone(), + value: value.clone(), + }) + .collect() +} diff --git a/codex-rs/core/src/credentialed_routes_tests.rs b/codex-rs/core/src/credentialed_routes_tests.rs new file mode 100644 index 0000000000..20192e1c76 --- /dev/null +++ b/codex-rs/core/src/credentialed_routes_tests.rs @@ -0,0 +1,52 @@ +use super::*; + +#[test] +fn developer_instructions_deduplicate_and_sort_route_prefixes() { + let credentialed_routes = CredentialedRoutesConfig { + routes: vec![ + CredentialedRoute { + connector_id: "connector_b".to_string(), + link_id: "link_b".to_string(), + base_url: "https://b.example.com/v1".to_string(), + }, + CredentialedRoute { + connector_id: "connector_a".to_string(), + link_id: "link_a".to_string(), + base_url: "https://a.example.com/v1".to_string(), + }, + CredentialedRoute { + connector_id: "connector_a".to_string(), + link_id: "link_a".to_string(), + base_url: "https://a.example.com/v1".to_string(), + }, + ], + ..CredentialedRoutesConfig::default() + }; + + assert_eq!( + developer_instructions(&credentialed_routes), + Some( + "The managed network proxy automatically attaches stored credentials when you call these HTTPS URL prefixes directly:\n- https://a.example.com/v1\n- https://b.example.com/v1".to_string() + ) + ); +} + +#[test] +fn developer_instructions_cap_route_prefixes() { + let credentialed_routes = CredentialedRoutesConfig { + routes: (0..=MAX_CREDENTIALED_ROUTE_INSTRUCTION_PREFIXES) + .map(|index| CredentialedRoute { + connector_id: format!("connector_{index}"), + link_id: format!("link_{index}"), + base_url: format!("https://{index}.example.com/v1"), + }) + .collect(), + ..CredentialedRoutesConfig::default() + }; + + let instructions = + developer_instructions(&credentialed_routes).expect("routes should render instructions"); + + assert!(instructions.len() <= MAX_CREDENTIALED_ROUTE_INSTRUCTION_CHARS); + assert!(instructions.contains("[additional credentialed routes omitted]")); +} diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index a9aad7a1e8..a0a08b18a3 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -34,6 +34,7 @@ pub mod config; pub mod connectors; pub mod context; mod context_manager; +mod credentialed_routes; mod environment_selection; pub mod exec; pub mod exec_env; diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 8424ce216f..aa7a4dbb84 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -899,6 +899,18 @@ async fn thread_title_from_thread_store( (!title.is_empty() && thread.preview.trim() != title).then(|| title.to_string()) } +struct ManagedNetworkProxyStartRequest<'a> { + spec: &'a crate::config::NetworkProxySpec, + credentialed_routes: codex_network_proxy::CredentialedRoutesConfig, + credentialed_routes_source: Arc, + exec_policy: &'a codex_execpolicy::Policy, + permission_profile: &'a PermissionProfile, + network_policy_decider: Option>, + blocked_request_observer: Option>, + managed_network_requirements_enabled: bool, + audit_metadata: NetworkProxyAuditMetadata, +} + impl Session { pub(crate) async fn app_server_client_metadata(&self) -> AppServerClientMetadata { let state = self.state.lock().await; @@ -946,14 +958,23 @@ impl Session { } async fn start_managed_network_proxy( - spec: &crate::config::NetworkProxySpec, - exec_policy: &codex_execpolicy::Policy, - permission_profile: &PermissionProfile, - network_policy_decider: Option>, - blocked_request_observer: Option>, - managed_network_requirements_enabled: bool, - audit_metadata: NetworkProxyAuditMetadata, - ) -> anyhow::Result<(StartedNetworkProxy, SessionNetworkProxyRuntime)> { + request: ManagedNetworkProxyStartRequest<'_>, + ) -> anyhow::Result<( + StartedNetworkProxy, + SessionNetworkProxyRuntime, + Arc, + )> { + let ManagedNetworkProxyStartRequest { + spec, + credentialed_routes, + credentialed_routes_source, + exec_policy, + permission_profile, + network_policy_decider, + blocked_request_observer, + managed_network_requirements_enabled, + audit_metadata, + } = request; let spec = spec .with_exec_policy_network_rules(exec_policy) .map_err(|err| { @@ -963,13 +984,31 @@ impl Session { err }) .unwrap_or_else(|_| spec.clone()); + let credentialed_routes_reloader = + Arc::new(codex_network_proxy::CredentialedRoutesReloader::new( + spec.build_config_state_for_spec().map_err(|err| { + anyhow::anyhow!("failed to build managed proxy base state: {err}") + })?, + credentialed_routes, + credentialed_routes_source, + )); + let state = + codex_network_proxy::ConfigReloader::reload_now(credentialed_routes_reloader.as_ref()) + .await + .map_err(|err| { + anyhow::anyhow!("failed to build credentialed route proxy state: {err}") + })?; let network_proxy = spec - .start_proxy( + .start_proxy_with_runtime_state( permission_profile, network_policy_decider, blocked_request_observer, managed_network_requirements_enabled, audit_metadata, + crate::config::NetworkProxyRuntimeState { + state, + reloader: credentialed_routes_reloader.clone(), + }, ) .await .map_err(|err| anyhow::anyhow!("failed to start managed network proxy: {err}"))?; @@ -980,7 +1019,11 @@ impl Session { socks_addr: proxy.socks_addr().to_string(), } }; - Ok((network_proxy, session_network_proxy)) + Ok(( + network_proxy, + session_network_proxy, + credentialed_routes_reloader, + )) } async fn refresh_managed_network_proxy_for_current_permission_profile(&self) { @@ -1000,6 +1043,7 @@ impl Session { .cloned() else { self.services.network_proxy.store(None); + self.services.credentialed_routes_reloader.store(None); return; }; @@ -1023,31 +1067,71 @@ impl Session { } }; if let Some(started_proxy) = self.services.network_proxy.load_full() { - if let Err(err) = spec.apply_to_started_proxy(started_proxy.as_ref()).await { + let update_result = if let Some(credentialed_routes_reloader) = + self.services.credentialed_routes_reloader.load_full() + { + match spec.build_config_state_for_spec() { + Ok(base_state) => match credentialed_routes_reloader + .replace_base_state(base_state) + .await + { + Ok(state) => started_proxy + .proxy() + .replace_config_state(state) + .await + .map_err(std::io::Error::other), + Err(err) => Err(std::io::Error::other(err)), + }, + Err(err) => Err(err), + } + } else { + spec.apply_to_started_proxy(started_proxy.as_ref()).await + }; + if let Err(err) = update_result { warn!("failed to refresh managed network proxy for sandbox change: {err}"); } return; } - match Self::start_managed_network_proxy( - &spec, - current_exec_policy.as_ref(), - &session_configuration.permission_profile(), - /*network_policy_decider*/ None, - self.services - .managed_network_requirements_configured - .then(|| { - build_blocked_request_observer(Arc::clone(&self.services.network_approval)) - }), - self.services.managed_network_requirements_configured, - self.services.network_proxy_audit_metadata.clone(), + let auth = self.services.auth_manager.auth().await; + let credentialed_routes = crate::credentialed_routes::load_for_session( + &session_configuration + .original_config_do_not_use + .chatgpt_base_url, + auth.as_ref(), ) + .await; + let credentialed_routes_source = crate::credentialed_routes::source( + session_configuration + .original_config_do_not_use + .chatgpt_base_url + .clone(), + Arc::clone(&self.services.auth_manager), + ); + match Self::start_managed_network_proxy(ManagedNetworkProxyStartRequest { + spec: &spec, + credentialed_routes, + credentialed_routes_source, + exec_policy: current_exec_policy.as_ref(), + permission_profile: &session_configuration.permission_profile(), + network_policy_decider: None, + blocked_request_observer: self.services.managed_network_requirements_configured.then( + || build_blocked_request_observer(Arc::clone(&self.services.network_approval)), + ), + managed_network_requirements_enabled: self + .services + .managed_network_requirements_configured, + audit_metadata: self.services.network_proxy_audit_metadata.clone(), + }) .await { - Ok((started_proxy, _session_network_proxy)) => { + Ok((started_proxy, _session_network_proxy, credentialed_routes_reloader)) => { self.services .network_proxy .store(Some(Arc::new(started_proxy))); + self.services + .credentialed_routes_reloader + .store(Some(credentialed_routes_reloader)); } Err(err) => { warn!("failed to start managed network proxy for sandbox change: {err}"); @@ -2810,6 +2894,15 @@ impl Session { { developer_sections.push(developer_instructions.to_string()); } + if let Some(credentialed_routes_reloader) = + self.services.credentialed_routes_reloader.load_full() + && let Some(credentialed_route_instructions) = + crate::credentialed_routes::developer_instructions( + &credentialed_routes_reloader.current_routes().await, + ) + { + developer_sections.push(credentialed_route_instructions); + } // Add developer instructions from collaboration_mode if they exist and are non-empty if turn_context.config.include_collaboration_mode_instructions && let Some(collab_instructions) = diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index e42cf202e8..8e54bf9fa3 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -893,18 +893,31 @@ impl Session { Arc::clone(network_policy_decider_session), ) }); - let (network_proxy, session_network_proxy) = + let credentialed_routes = if config.permissions.network.is_some() { + crate::credentialed_routes::load_for_session(&config.chatgpt_base_url, auth).await + } else { + codex_network_proxy::CredentialedRoutesConfig::default() + }; + let credentialed_routes_source = crate::credentialed_routes::source( + config.chatgpt_base_url.clone(), + Arc::clone(&auth_manager), + ); + let (network_proxy, session_network_proxy, credentialed_routes_reloader) = if let Some(spec) = config.permissions.network.as_ref() { let current_exec_policy = exec_policy.current(); - let (network_proxy, session_network_proxy) = Self::start_managed_network_proxy( - spec, - current_exec_policy.as_ref(), - config.permissions.permission_profile(), - network_policy_decider.as_ref().map(Arc::clone), - blocked_request_observer.as_ref().map(Arc::clone), - managed_network_requirements_configured, - network_proxy_audit_metadata.clone(), - ) + let (network_proxy, session_network_proxy, credentialed_routes_reloader) = + Self::start_managed_network_proxy(ManagedNetworkProxyStartRequest { + spec, + credentialed_routes, + credentialed_routes_source: Arc::clone(&credentialed_routes_source), + exec_policy: current_exec_policy.as_ref(), + permission_profile: config.permissions.permission_profile(), + network_policy_decider: network_policy_decider.as_ref().map(Arc::clone), + blocked_request_observer: blocked_request_observer.as_ref().map(Arc::clone), + managed_network_requirements_enabled: + managed_network_requirements_configured, + audit_metadata: network_proxy_audit_metadata.clone(), + }) .instrument(info_span!( "session_init.network_proxy", otel.name = "session_init.network_proxy", @@ -912,9 +925,13 @@ impl Session { managed_network_requirements_enabled, )) .await?; - (Some(network_proxy), Some(session_network_proxy)) + ( + Some(network_proxy), + Some(session_network_proxy), + Some(credentialed_routes_reloader), + ) } else { - (None, None) + (None, None, None) }; let hooks = @@ -1006,6 +1023,9 @@ impl Session { thread_extension_data, agent_control, network_proxy: arc_swap::ArcSwapOption::from(network_proxy.map(Arc::new)), + credentialed_routes_reloader: arc_swap::ArcSwapOption::from( + credentialed_routes_reloader, + ), network_proxy_audit_metadata, managed_network_requirements_configured, network_approval: Arc::clone(&network_approval), diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 29de7ca662..cd704d529e 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -174,6 +174,28 @@ use std::time::Duration as StdDuration; mod guardian_tests; +fn empty_credentialed_routes_source() -> Arc { + Arc::new(|| async { Ok(codex_network_proxy::CredentialedRoutesConfig::default()) }) +} + +fn managed_network_proxy_start_request<'a>( + spec: &'a crate::config::NetworkProxySpec, + exec_policy: &'a Policy, + permission_profile: &'a PermissionProfile, +) -> ManagedNetworkProxyStartRequest<'a> { + ManagedNetworkProxyStartRequest { + spec, + credentialed_routes: codex_network_proxy::CredentialedRoutesConfig::default(), + credentialed_routes_source: empty_credentialed_routes_source(), + exec_policy, + permission_profile, + network_policy_decider: None, + blocked_request_observer: None, + managed_network_requirements_enabled: false, + audit_metadata: crate::config::NetworkProxyAuditMetadata::default(), + } +} + struct InstructionsTestCase { slug: &'static str, expects_apply_patch_description: bool, @@ -708,14 +730,8 @@ async fn start_managed_network_proxy_applies_execpolicy_network_rules() -> anyho /*justification*/ None, )?; - let (started_proxy, _) = Session::start_managed_network_proxy( - &spec, - &exec_policy, - &permission_profile, - /*network_policy_decider*/ None, - /*blocked_request_observer*/ None, - /*managed_network_requirements_enabled*/ false, - crate::config::NetworkProxyAuditMetadata::default(), + let (started_proxy, _, _) = Session::start_managed_network_proxy( + managed_network_proxy_start_request(&spec, &exec_policy, &permission_profile), ) .await?; @@ -753,14 +769,8 @@ async fn start_managed_network_proxy_ignores_invalid_execpolicy_network_rules() /*justification*/ None, )?; - let (started_proxy, _) = Session::start_managed_network_proxy( - &spec, - &exec_policy, - &permission_profile, - /*network_policy_decider*/ None, - /*blocked_request_observer*/ None, - /*managed_network_requirements_enabled*/ false, - crate::config::NetworkProxyAuditMetadata::default(), + let (started_proxy, _, _) = Session::start_managed_network_proxy( + managed_network_proxy_start_request(&spec, &exec_policy, &permission_profile), ) .await?; @@ -793,16 +803,17 @@ async fn managed_network_proxy_decider_survives_full_access_start() -> anyhow::R } }); - let (started_proxy, _) = Session::start_managed_network_proxy( - &spec, - &exec_policy, - &full_access_permission_profile, - Some(network_policy_decider), - /*blocked_request_observer*/ None, - /*managed_network_requirements_enabled*/ true, - crate::config::NetworkProxyAuditMetadata::default(), - ) - .await?; + let (started_proxy, _, _) = + Session::start_managed_network_proxy(ManagedNetworkProxyStartRequest { + network_policy_decider: Some(network_policy_decider), + managed_network_requirements_enabled: true, + ..managed_network_proxy_start_request( + &spec, + &exec_policy, + &full_access_permission_profile, + ) + }) + .await?; let spec = spec.recompute_for_permission_profile(&PermissionProfile::workspace_write())?; spec.apply_to_started_proxy(&started_proxy).await?; @@ -863,14 +874,9 @@ async fn new_turn_refreshes_managed_network_proxy_for_sandbox_change() -> anyhow Some(requirements), &initial_permission_profile, )?; - let (started_proxy, _) = Session::start_managed_network_proxy( - &spec, - &Policy::empty(), - &initial_permission_profile, - /*network_policy_decider*/ None, - /*blocked_request_observer*/ None, - /*managed_network_requirements_enabled*/ false, - crate::config::NetworkProxyAuditMetadata::default(), + let exec_policy = Policy::empty(); + let (started_proxy, _, _) = Session::start_managed_network_proxy( + managed_network_proxy_start_request(&spec, &exec_policy, &initial_permission_profile), ) .await?; assert_eq!( @@ -4868,6 +4874,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { thread_extension_data: codex_extension_api::ExtensionData::new(thread_id.to_string()), agent_control, network_proxy: arc_swap::ArcSwapOption::from(None), + credentialed_routes_reloader: arc_swap::ArcSwapOption::from(None), network_proxy_audit_metadata: crate::config::NetworkProxyAuditMetadata::default(), managed_network_requirements_configured: false, network_approval: Arc::clone(&network_approval), @@ -6946,6 +6953,7 @@ where thread_extension_data: codex_extension_api::ExtensionData::new(thread_id.to_string()), agent_control, network_proxy: arc_swap::ArcSwapOption::from(None), + credentialed_routes_reloader: arc_swap::ArcSwapOption::from(None), network_proxy_audit_metadata: crate::config::NetworkProxyAuditMetadata::default(), managed_network_requirements_configured: false, network_approval: Arc::clone(&network_approval), @@ -7513,6 +7521,43 @@ async fn build_initial_context_adds_multi_agent_v2_root_usage_hint_as_developer_ ); } +#[tokio::test] +async fn build_initial_context_adds_credentialed_route_instructions_as_developer_message() { + let (session, turn_context) = make_session_and_context().await; + let base_state = codex_network_proxy::build_config_state( + codex_network_proxy::NetworkProxyConfig::default(), + codex_network_proxy::NetworkProxyConstraints::default(), + ) + .expect("credentialed route test state should compile"); + let credentialed_routes_reloader = + Arc::new(codex_network_proxy::CredentialedRoutesReloader::new( + base_state, + codex_network_proxy::CredentialedRoutesConfig { + routes: vec![codex_network_proxy::CredentialedRoute { + connector_id: "connector_123".to_string(), + link_id: "link_123".to_string(), + base_url: "https://api.example.com/v1".to_string(), + }], + ..codex_network_proxy::CredentialedRoutesConfig::default() + }, + empty_credentialed_routes_source(), + )); + session + .services + .credentialed_routes_reloader + .store(Some(credentialed_routes_reloader)); + + let initial_context = session.build_initial_context(&turn_context).await; + + assert!( + developer_input_texts(&initial_context) + .iter() + .any(|text| text.contains("https://api.example.com/v1")), + "expected credentialed route developer instructions, got {:?}", + developer_message_texts(&initial_context) + ); +} + #[tokio::test] async fn build_initial_context_adds_multi_agent_v2_subagent_usage_hint_as_developer_message() { let (session, mut turn_context) = diff --git a/codex-rs/core/src/state/service.rs b/codex-rs/core/src/state/service.rs index b4c499118d..9509fac795 100644 --- a/codex-rs/core/src/state/service.rs +++ b/codex-rs/core/src/state/service.rs @@ -69,6 +69,8 @@ pub(crate) struct SessionServices { pub(crate) thread_extension_data: ExtensionData, pub(crate) agent_control: AgentControl, pub(crate) network_proxy: ArcSwapOption, + pub(crate) credentialed_routes_reloader: + ArcSwapOption, pub(crate) network_proxy_audit_metadata: NetworkProxyAuditMetadata, pub(crate) managed_network_requirements_configured: bool, pub(crate) network_approval: Arc,