diff --git a/codex-rs/backend-client/src/client.rs b/codex-rs/backend-client/src/client.rs index 43d6d146435e..ce28d3dad938 100644 --- a/codex-rs/backend-client/src/client.rs +++ b/codex-rs/backend-client/src/client.rs @@ -204,12 +204,17 @@ impl Client { } fn headers(&self) -> HeaderMap { - let mut h = HeaderMap::new(); + let mut h = self.auth_headers(); if let Some(ua) = &self.user_agent { h.insert(USER_AGENT, ua.clone()); } else { h.insert(USER_AGENT, HeaderValue::from_static("codex-cli")); } + h + } + + fn auth_headers(&self) -> HeaderMap { + let mut h = HeaderMap::new(); self.auth_provider.add_auth_headers(&mut h); if let Some(acc) = &self.chatgpt_account_id && let Ok(name) = HeaderName::from_bytes(b"ChatGPT-Account-Id") @@ -423,6 +428,10 @@ impl Client { } } + pub fn credential_routes_proxy_auth_headers(&self) -> HeaderMap { + self.auth_headers() + } + /// Create a new task (user turn) by POSTing to the appropriate backend path /// based on `path_style`. Returns the created task id. pub async fn create_task(&self, request_body: serde_json::Value) -> Result { diff --git a/codex-rs/core/src/config/network_proxy_spec.rs b/codex-rs/core/src/config/network_proxy_spec.rs index 631a826ac712..8cb0c5e6fa8c 100644 --- a/codex-rs/core/src/config/network_proxy_spec.rs +++ b/codex-rs/core/src/config/network_proxy_spec.rs @@ -177,6 +177,37 @@ impl NetworkProxySpec { Ok(spec) } + pub(crate) fn with_credentialed_routes( + &self, + credentialed_routes: &crate::credentialed_routes::CredentialedRoutesSessionConfig, + ) -> std::io::Result { + let mut spec = self.clone(); + let credentialed_route_hooks = credentialed_routes.mitm_hooks(); + let mut allowed_domains = spec.config.network.allowed_domains().unwrap_or_default(); + for hook in &credentialed_route_hooks { + if !allowed_domains + .iter() + .any(|allowed_domain| normalize_host(allowed_domain) == normalize_host(&hook.host)) + { + allowed_domains.push(hook.host.clone()); + } + } + spec.config.network.set_allowed_domains(allowed_domains); + let mut mitm_hooks = credentialed_route_hooks; + mitm_hooks.extend(spec.config.network.mitm_hooks); + spec.config.network.mitm_hooks = mitm_hooks; + spec.config.network.mitm = spec.config.network.mode + == codex_network_proxy::NetworkMode::Limited + || !spec.config.network.mitm_hooks.is_empty(); + validate_policy_against_constraints(&spec.config, &spec.constraints).map_err(|err| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("network proxy constraints are invalid: {err}"), + ) + })?; + Ok(spec) + } + pub(crate) async fn apply_to_started_proxy( &self, started_proxy: &StartedNetworkProxy, diff --git a/codex-rs/core/src/config/network_proxy_spec_tests.rs b/codex-rs/core/src/config/network_proxy_spec_tests.rs index 14b7c1c33059..b0d833f0a3ff 100644 --- a/codex-rs/core/src/config/network_proxy_spec_tests.rs +++ b/codex-rs/core/src/config/network_proxy_spec_tests.rs @@ -1,4 +1,6 @@ use super::*; +use codex_backend_client::CredentialRouteAuthType; +use codex_backend_client::ResolvedCredentialRoute; use codex_config::NetworkDomainPermissionToml; use codex_config::NetworkDomainPermissionsToml; use codex_network_proxy::NetworkDomainPermission; @@ -80,6 +82,38 @@ fn requirements_allowed_domains_are_a_baseline_for_user_allowlist() { assert_eq!(spec.constraints.allowlist_expansion_enabled, Some(true)); } +#[test] +fn credentialed_routes_add_runtime_allowlist_and_mitm_hooks() { + let spec = NetworkProxySpec::from_config_and_constraints( + NetworkProxyConfig::default(), + /*requirements*/ None, + &permission_profile_for_sandbox_policy(&SandboxPolicy::new_workspace_write_policy()), + ) + .expect("config should load"); + let credentialed_routes = crate::credentialed_routes::CredentialedRoutesSessionConfig { + routes: vec![ResolvedCredentialRoute { + connector_id: "connector_123".to_string(), + link_id: "link_123".to_string(), + auth_type: CredentialRouteAuthType::OAuth, + base_url: "https://api.example.com/v1".to_string(), + }], + proxy_headers: Vec::new(), + proxy_url: Some("https://chatgpt.com/backend-api/ps/credential_routes/proxy".to_string()), + }; + + let spec = spec + .with_credentialed_routes(&credentialed_routes) + .expect("credentialed routes should fit unconstrained config"); + + assert_eq!( + spec.config.network.allowed_domains(), + Some(vec!["api.example.com".to_string()]) + ); + assert!(spec.config.network.mitm); + assert_eq!(spec.config.network.mitm_hooks.len(), 1); + assert_eq!(spec.config.network.mitm_hooks[0].host, "api.example.com"); +} + #[test] fn requirements_allowed_domains_do_not_override_user_denies_for_same_pattern() { let mut config = NetworkProxyConfig::default(); diff --git a/codex-rs/core/src/credentialed_routes.rs b/codex-rs/core/src/credentialed_routes.rs index cf6ee30b766d..7313e6c66c5e 100644 --- a/codex-rs/core/src/credentialed_routes.rs +++ b/codex-rs/core/src/credentialed_routes.rs @@ -1,22 +1,36 @@ use codex_backend_client::Client as BackendClient; use codex_backend_client::ResolvedCredentialRoute; use codex_login::CodexAuth; +use codex_network_proxy::CredentialedRouteProxyActionConfig; +use codex_network_proxy::CredentialedRouteProxyHeader; +use codex_network_proxy::MitmHookActionsConfig; +use codex_network_proxy::MitmHookConfig; +use codex_network_proxy::MitmHookMatchConfig; +use http::HeaderMap; use tracing::debug; use tracing::warn; +use url::Url; + +#[derive(Debug, Clone, Default)] +pub(crate) struct CredentialedRoutesSessionConfig { + pub(crate) routes: Vec, + pub(crate) proxy_headers: Vec, + pub(crate) proxy_url: Option, +} pub(crate) async fn load_for_session( chatgpt_base_url: &str, auth: Option<&CodexAuth>, -) -> Vec { +) -> CredentialedRoutesSessionConfig { let Some(auth) = auth.filter(|auth| auth.uses_codex_backend()) else { - return Vec::new(); + return CredentialedRoutesSessionConfig::default(); }; let client = match BackendClient::from_auth(chatgpt_base_url.to_string(), auth) { Ok(client) => client, Err(err) => { warn!(error = %err, "failed to initialize credentialed routes client"); - return Vec::new(); + return CredentialedRoutesSessionConfig::default(); } }; @@ -26,11 +40,150 @@ pub(crate) async fn load_for_session( credentialed_routes = response.routes.len(), "loaded credentialed routes for session" ); - response.routes + CredentialedRoutesSessionConfig { + routes: response.routes, + proxy_headers: credentialed_route_proxy_headers( + client.credential_routes_proxy_auth_headers(), + ), + proxy_url: Some(client.credential_routes_proxy_url()), + } } Err(err) => { warn!(error = %err, "failed to load credentialed routes for session"); - Vec::new() + CredentialedRoutesSessionConfig::default() } } } + +impl CredentialedRoutesSessionConfig { + pub(crate) fn mitm_hooks(&self) -> Vec { + let Some(proxy_url) = self.proxy_url.as_ref() else { + return Vec::new(); + }; + + self.routes + .iter() + .filter_map( + |route| match route_mitm_hook(route, &self.proxy_headers, proxy_url) { + Ok(hook) => Some(hook), + Err(err) => { + warn!( + connector_id = %route.connector_id, + link_id = %route.link_id, + base_url = %route.base_url, + error = %err, + "skipping invalid credentialed route" + ); + None + } + }, + ) + .collect() + } +} + +fn route_mitm_hook( + route: &ResolvedCredentialRoute, + proxy_headers: &[CredentialedRouteProxyHeader], + proxy_url: &str, +) -> anyhow::Result { + let base_url = Url::parse(&route.base_url)?; + anyhow::ensure!( + base_url.scheme() == "https", + "credentialed route must use https" + ); + let host = base_url + .host_str() + .ok_or_else(|| anyhow::anyhow!("credentialed route must include a host"))?; + anyhow::ensure!( + base_url.username().is_empty() && base_url.password().is_none(), + "credentialed route must not include user info" + ); + anyhow::ensure!( + base_url.fragment().is_none() && base_url.query().is_none(), + "credentialed route must not include query or fragment" + ); + let path_prefix = match base_url.path() { + "" => "/", + path => path, + }; + + Ok(MitmHookConfig { + host: host.to_string(), + matcher: MitmHookMatchConfig { + methods: vec![ + "DELETE".to_string(), + "GET".to_string(), + "HEAD".to_string(), + "PATCH".to_string(), + "POST".to_string(), + "PUT".to_string(), + ], + path_prefixes: vec![path_prefix.to_string()], + ..MitmHookMatchConfig::default() + }, + actions: MitmHookActionsConfig { + credentialed_route_proxy: Some(CredentialedRouteProxyActionConfig { + connector_id: route.connector_id.clone(), + link_id: route.link_id.clone(), + proxy_headers: proxy_headers.to_vec(), + proxy_url: proxy_url.to_string(), + }), + ..MitmHookActionsConfig::default() + }, + }) +} + +fn credentialed_route_proxy_headers(headers: HeaderMap) -> Vec { + headers + .iter() + .map(|(name, value)| CredentialedRouteProxyHeader { + name: name.clone(), + value: value.clone(), + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use codex_backend_client::CredentialRouteAuthType; + use pretty_assertions::assert_eq; + + #[test] + fn credentialed_routes_compile_to_internal_mitm_hooks() { + let config = CredentialedRoutesSessionConfig { + routes: vec![ResolvedCredentialRoute { + connector_id: "connector_123".to_string(), + link_id: "link_123".to_string(), + auth_type: CredentialRouteAuthType::OAuth, + base_url: "https://api.example.com/v1".to_string(), + }], + proxy_headers: vec![CredentialedRouteProxyHeader { + name: http::header::AUTHORIZATION, + value: http::HeaderValue::from_static("Bearer codex-token"), + }], + proxy_url: Some( + "https://chatgpt.com/backend-api/ps/credential_routes/proxy".to_string(), + ), + }; + + let hooks = config.mitm_hooks(); + + assert_eq!(hooks.len(), 1); + assert_eq!(hooks[0].host, "api.example.com"); + assert_eq!(hooks[0].matcher.path_prefixes, vec!["/v1".to_string()]); + assert_eq!( + hooks[0].actions.credentialed_route_proxy, + Some(CredentialedRouteProxyActionConfig { + connector_id: "connector_123".to_string(), + link_id: "link_123".to_string(), + proxy_headers: vec![CredentialedRouteProxyHeader { + name: http::header::AUTHORIZATION, + value: http::HeaderValue::from_static("Bearer codex-token"), + }], + proxy_url: "https://chatgpt.com/backend-api/ps/credential_routes/proxy".to_string(), + }) + ); + } +} diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index db17f814329b..e6c1d0cc87a7 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -877,7 +877,7 @@ async fn thread_title_from_thread_store( struct ManagedNetworkProxyStartParams<'a> { spec: &'a crate::config::NetworkProxySpec, - credentialed_routes: &'a [codex_backend_client::ResolvedCredentialRoute], + credentialed_routes: &'a crate::credentialed_routes::CredentialedRoutesSessionConfig, exec_policy: &'a codex_execpolicy::Policy, permission_profile: &'a PermissionProfile, network_policy_decider: Option>, @@ -961,10 +961,18 @@ impl Session { }: ManagedNetworkProxyStartParams<'_>, ) -> anyhow::Result<(StartedNetworkProxy, SessionNetworkProxyRuntime)> { debug!( - credentialed_routes = credentialed_routes.len(), + credentialed_routes = credentialed_routes.routes.len(), "starting managed network proxy" ); let spec = spec + .with_credentialed_routes(credentialed_routes) + .map_err(|err| { + tracing::warn!( + "failed to apply credentialed routes to managed proxy; continuing without credentialed routes: {err}" + ); + err + }) + .unwrap_or_else(|_| spec.clone()) .with_exec_policy_network_rules(exec_policy) .map_err(|err| { tracing::warn!( @@ -1023,6 +1031,15 @@ impl Session { return; } }; + let spec = match spec.with_credentialed_routes(&self.services.credentialed_routes) { + Ok(spec) => spec, + Err(err) => { + warn!( + "failed to apply credentialed routes while refreshing managed network proxy: {err}" + ); + return; + } + }; let current_exec_policy = self.services.exec_policy.current(); let spec = match spec.with_exec_policy_network_rules(current_exec_policy.as_ref()) { Ok(spec) => spec, diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 2d9fda49b22c..a19a04b9429f 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -811,11 +811,14 @@ impl Session { Arc::clone(network_policy_decider_session), ) }); + let credentialed_routes = if config.permissions.network.is_some() { + crate::credentialed_routes::load_for_session(&config.chatgpt_base_url, auth) + .await + } else { + crate::credentialed_routes::CredentialedRoutesSessionConfig::default() + }; let (network_proxy, session_network_proxy) = if let Some(spec) = config.permissions.network.as_ref() { - let credentialed_routes = - crate::credentialed_routes::load_for_session(&config.chatgpt_base_url, auth) - .await; let current_exec_policy = exec_policy.current(); let (network_proxy, session_network_proxy) = Self::start_managed_network_proxy( ManagedNetworkProxyStartParams { @@ -923,6 +926,7 @@ impl Session { thread_extension_data, agent_control, network_proxy, + credentialed_routes, network_approval: Arc::clone(&network_approval), state_db: state_db_ctx.clone(), live_thread: live_thread_init.as_ref().cloned(), diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 03d4f7ac386d..a25e4e8e560e 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -685,7 +685,8 @@ async fn start_managed_network_proxy_applies_execpolicy_network_rules() -> anyho let (started_proxy, _) = Session::start_managed_network_proxy(ManagedNetworkProxyStartParams { spec: &spec, - credentialed_routes: &[], + credentialed_routes: &crate::credentialed_routes::CredentialedRoutesSessionConfig::default( + ), exec_policy: &exec_policy, permission_profile: &permission_profile_for_sandbox_policy( &SandboxPolicy::new_workspace_write_policy(), @@ -732,7 +733,8 @@ async fn start_managed_network_proxy_ignores_invalid_execpolicy_network_rules() let (started_proxy, _) = Session::start_managed_network_proxy(ManagedNetworkProxyStartParams { spec: &spec, - credentialed_routes: &[], + credentialed_routes: &crate::credentialed_routes::CredentialedRoutesSessionConfig::default( + ), exec_policy: &exec_policy, permission_profile: &permission_profile_for_sandbox_policy( &SandboxPolicy::new_workspace_write_policy(), @@ -774,7 +776,8 @@ async fn managed_network_proxy_decider_survives_full_access_start() -> anyhow::R let (started_proxy, _) = Session::start_managed_network_proxy(ManagedNetworkProxyStartParams { spec: &spec, - credentialed_routes: &[], + credentialed_routes: &crate::credentialed_routes::CredentialedRoutesSessionConfig::default( + ), exec_policy: &exec_policy, permission_profile: &permission_profile_for_sandbox_policy( &SandboxPolicy::DangerFullAccess, @@ -849,7 +852,8 @@ async fn new_turn_refreshes_managed_network_proxy_for_sandbox_change() -> anyhow )?; let (started_proxy, _) = Session::start_managed_network_proxy(ManagedNetworkProxyStartParams { spec: &spec, - credentialed_routes: &[], + credentialed_routes: &crate::credentialed_routes::CredentialedRoutesSessionConfig::default( + ), exec_policy: &Policy::empty(), permission_profile: &permission_profile_for_sandbox_policy(&initial_policy), network_policy_decider: None, @@ -4352,6 +4356,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: None, + credentialed_routes: crate::credentialed_routes::CredentialedRoutesSessionConfig::default(), network_approval: Arc::clone(&network_approval), state_db: None, live_thread: None, @@ -6208,6 +6213,7 @@ where thread_extension_data: codex_extension_api::ExtensionData::new(thread_id.to_string()), agent_control, network_proxy: None, + credentialed_routes: crate::credentialed_routes::CredentialedRoutesSessionConfig::default(), network_approval: Arc::clone(&network_approval), state_db: state_db.clone(), live_thread: None, diff --git a/codex-rs/core/src/state/service.rs b/codex-rs/core/src/state/service.rs index afba409a068e..fe0f77828e7c 100644 --- a/codex-rs/core/src/state/service.rs +++ b/codex-rs/core/src/state/service.rs @@ -66,6 +66,7 @@ pub(crate) struct SessionServices { pub(crate) thread_extension_data: ExtensionData, pub(crate) agent_control: AgentControl, pub(crate) network_proxy: Option, + pub(crate) credentialed_routes: crate::credentialed_routes::CredentialedRoutesSessionConfig, pub(crate) network_approval: Arc, pub(crate) state_db: Option, pub(crate) live_thread: Option, diff --git a/codex-rs/network-proxy/src/lib.rs b/codex-rs/network-proxy/src/lib.rs index 76316c8359c0..e918aee6ba03 100644 --- a/codex-rs/network-proxy/src/lib.rs +++ b/codex-rs/network-proxy/src/lib.rs @@ -24,6 +24,8 @@ pub use config::NetworkProxyConfig; pub use config::NetworkUnixSocketPermission; pub use config::NetworkUnixSocketPermissions; pub use config::host_and_port_from_network_addr; +pub use mitm_hook::CredentialedRouteProxyActionConfig; +pub use mitm_hook::CredentialedRouteProxyHeader; pub use mitm_hook::InjectedHeaderConfig; pub use mitm_hook::MitmHookActionsConfig; pub use mitm_hook::MitmHookBodyConfig; diff --git a/codex-rs/network-proxy/src/mitm.rs b/codex-rs/network-proxy/src/mitm.rs index b14b39ea5eea..f1aafddb2326 100644 --- a/codex-rs/network-proxy/src/mitm.rs +++ b/codex-rs/network-proxy/src/mitm.rs @@ -33,6 +33,7 @@ use rama_http::Response; use rama_http::StatusCode; use rama_http::Uri; use rama_http::header::HOST; +use rama_http::header::HeaderName; use rama_http::layer::remove_header::RemoveRequestHeaderLayer; use rama_http::layer::remove_header::RemoveResponseHeaderLayer; use rama_http_backend::server::HttpServer; @@ -47,6 +48,7 @@ use std::task::Context as TaskContext; use std::task::Poll; use tracing::info; use tracing::warn; +use url::Url; /// State needed to terminate a CONNECT tunnel and enforce policy on inner HTTPS requests. pub struct MitmState { @@ -84,6 +86,10 @@ enum MitmPolicyDecision { const MITM_INSPECT_BODIES: bool = false; const MITM_MAX_BODY_BYTES: usize = 4096; +const CREDENTIAL_ROUTE_CONNECTOR_ID_HEADER: &str = + "x-openai-internal-credential-route-connector-id"; +const CREDENTIAL_ROUTE_LINK_ID_HEADER: &str = "x-openai-internal-credential-route-link-id"; +const CREDENTIAL_ROUTE_REQUEST_URL_HEADER: &str = "x-openai-internal-credential-route-request-url"; impl std::fmt::Debug for MitmState { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { @@ -228,10 +234,27 @@ async fn forward_request(req: Request, request_ctx: &MitmRequestContext) -> Resu let (mut parts, body) = req.into_parts(); apply_mitm_hook_actions(&mut parts.headers, hook_actions.as_ref()); let authority = authority_header_value(&target_host, target_port); - parts.uri = build_https_uri(&authority, &path)?; - parts - .headers - .insert(HOST, HeaderValue::from_str(&authority)?); + let original_request_uri = build_https_uri(&authority, &path)?; + if let Some(actions) = hook_actions.as_ref() + && let Some(credentialed_route_proxy) = actions.credentialed_route_proxy.as_ref() + { + let (proxy_uri, proxy_authority) = + credentialed_route_proxy_request(credentialed_route_proxy)?; + apply_credentialed_route_proxy_headers( + &mut parts.headers, + credentialed_route_proxy, + &original_request_uri, + )?; + parts.uri = proxy_uri; + parts + .headers + .insert(HOST, HeaderValue::from_str(&proxy_authority)?); + } else { + parts.uri = original_request_uri; + parts + .headers + .insert(HOST, HeaderValue::from_str(&authority)?); + } let inspect = mitm.inspect_enabled(); let max_body_bytes = mitm.max_body_bytes(); @@ -547,6 +570,48 @@ fn build_https_uri(authority: &str, path: &str) -> Result { Ok(target.parse()?) } +fn credentialed_route_proxy_request( + action: &crate::mitm_hook::CredentialedRouteProxyAction, +) -> Result<(Uri, String)> { + let proxy_url = Url::parse(&action.proxy_url) + .with_context(|| format!("invalid credentialed route proxy URL {}", action.proxy_url))?; + let proxy_authority = url_authority_header_value(&proxy_url)?; + Ok((proxy_url.as_str().parse()?, proxy_authority)) +} + +fn apply_credentialed_route_proxy_headers( + headers: &mut HeaderMap, + action: &crate::mitm_hook::CredentialedRouteProxyAction, + request_uri: &Uri, +) -> Result<()> { + for proxy_header in &action.proxy_headers { + headers.insert(proxy_header.name.clone(), proxy_header.value.clone()); + } + headers.insert( + HeaderName::from_static(CREDENTIAL_ROUTE_CONNECTOR_ID_HEADER), + HeaderValue::from_str(&action.connector_id)?, + ); + headers.insert( + HeaderName::from_static(CREDENTIAL_ROUTE_LINK_ID_HEADER), + HeaderValue::from_str(&action.link_id)?, + ); + headers.insert( + HeaderName::from_static(CREDENTIAL_ROUTE_REQUEST_URL_HEADER), + HeaderValue::from_str(&request_uri.to_string())?, + ); + Ok(()) +} + +fn url_authority_header_value(url: &Url) -> Result { + let host = url + .host_str() + .ok_or_else(|| anyhow!("URL must include a host"))?; + let port = url + .port_or_known_default() + .ok_or_else(|| anyhow!("URL must include a known port"))?; + Ok(authority_header_value(host, port)) +} + fn path_and_query(uri: &Uri) -> String { uri.path_and_query() .map(rama_http::uri::PathAndQuery::as_str) diff --git a/codex-rs/network-proxy/src/mitm_hook.rs b/codex-rs/network-proxy/src/mitm_hook.rs index 6262dcd4ff7c..ac8d61c28d55 100644 --- a/codex-rs/network-proxy/src/mitm_hook.rs +++ b/codex-rs/network-proxy/src/mitm_hook.rs @@ -47,6 +47,8 @@ pub struct MitmHookMatchConfig { pub struct MitmHookActionsConfig { pub strip_request_headers: Vec, pub inject_request_headers: Vec, + #[serde(skip)] + pub credentialed_route_proxy: Option, } #[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] @@ -94,6 +96,7 @@ pub struct HeaderConstraint { pub struct MitmHookActions { pub strip_request_headers: Vec, pub inject_request_headers: Vec, + pub credentialed_route_proxy: Option, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -109,6 +112,37 @@ pub enum SecretSource { File(AbsolutePathBuf), } +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct CredentialedRouteProxyActionConfig { + pub connector_id: String, + pub link_id: String, + pub proxy_headers: Vec, + pub proxy_url: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CredentialedRouteProxyAction { + pub connector_id: String, + pub link_id: String, + pub proxy_headers: Vec, + pub proxy_url: String, +} + +#[derive(Clone, PartialEq, Eq)] +pub struct CredentialedRouteProxyHeader { + pub name: HeaderName, + pub value: HeaderValue, +} + +impl std::fmt::Debug for CredentialedRouteProxyHeader { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("CredentialedRouteProxyHeader") + .field("name", &self.name) + .field("value", &"") + .finish() + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct MitmHookBodyMatcher { pub raw: serde_json::Value, @@ -315,6 +349,16 @@ where .with_context(|| format!("failed to compile injected header {}", header.name)) }) .collect::>>()?; + let credentialed_route_proxy = + hook.actions + .credentialed_route_proxy + .as_ref() + .map(|action| CredentialedRouteProxyAction { + connector_id: action.connector_id.clone(), + link_id: action.link_id.clone(), + proxy_headers: action.proxy_headers.clone(), + proxy_url: action.proxy_url.clone(), + }); hooks_by_host .entry(host.clone()) @@ -331,6 +375,7 @@ where actions: MitmHookActions { strip_request_headers, inject_request_headers, + credentialed_route_proxy, }, }); } @@ -676,6 +721,7 @@ mod tests { secret_file: None, prefix: Some("Bearer ".to_string()), }], + credentialed_route_proxy: None, }, } } diff --git a/codex-rs/network-proxy/src/mitm_tests.rs b/codex-rs/network-proxy/src/mitm_tests.rs index 3db22fbd72b8..b68cc2d900dc 100644 --- a/codex-rs/network-proxy/src/mitm_tests.rs +++ b/codex-rs/network-proxy/src/mitm_tests.rs @@ -32,6 +32,7 @@ fn github_write_hook() -> crate::mitm_hook::MitmHookConfig { secret_file: None, prefix: Some("Bearer ".to_string()), }], + credentialed_route_proxy: None, }, } } @@ -261,6 +262,7 @@ fn apply_mitm_hook_actions_replaces_authorization_header() { AbsolutePathBuf::try_from("/tmp/github-token").unwrap(), ), }], + credentialed_route_proxy: None, }; apply_mitm_hook_actions(&mut headers, Some(&actions)); @@ -274,3 +276,53 @@ fn apply_mitm_hook_actions_replaces_authorization_header() { Some(&HeaderValue::from_static("req_123")) ); } + +#[test] +fn credentialed_route_proxy_request_rewrites_upstream_target() { + let action = crate::mitm_hook::CredentialedRouteProxyAction { + connector_id: "connector_123".to_string(), + link_id: "link_123".to_string(), + proxy_headers: vec![crate::mitm_hook::CredentialedRouteProxyHeader { + name: HeaderName::from_static("authorization"), + value: HeaderValue::from_static("Bearer codex-token"), + }], + proxy_url: "http://localhost:8080/api/codex/credential_routes/proxy".to_string(), + }; + let request_uri: Uri = "https://api.example.com/v1/items?limit=5".parse().unwrap(); + let mut headers = HeaderMap::new(); + headers.insert( + HeaderName::from_static("authorization"), + HeaderValue::from_static("Bearer provider-token"), + ); + headers.insert( + HeaderName::from_static(CREDENTIAL_ROUTE_REQUEST_URL_HEADER), + HeaderValue::from_static("https://spoofed.example.com"), + ); + + let (proxy_uri, proxy_authority) = credentialed_route_proxy_request(&action).unwrap(); + apply_credentialed_route_proxy_headers(&mut headers, &action, &request_uri).unwrap(); + + assert_eq!( + proxy_uri.to_string(), + "http://localhost:8080/api/codex/credential_routes/proxy" + ); + assert_eq!(proxy_authority, "localhost:8080"); + assert_eq!( + headers.get("authorization"), + Some(&HeaderValue::from_static("Bearer codex-token")) + ); + assert_eq!( + headers.get(CREDENTIAL_ROUTE_CONNECTOR_ID_HEADER), + Some(&HeaderValue::from_static("connector_123")) + ); + assert_eq!( + headers.get(CREDENTIAL_ROUTE_LINK_ID_HEADER), + Some(&HeaderValue::from_static("link_123")) + ); + assert_eq!( + headers.get(CREDENTIAL_ROUTE_REQUEST_URL_HEADER), + Some(&HeaderValue::from_static( + "https://api.example.com/v1/items?limit=5" + )) + ); +}