From 28ce16562d71adcb0ceb07801178d8ff7189049f Mon Sep 17 00:00:00 2001 From: Winston Howes Date: Thu, 14 May 2026 12:34:42 -0700 Subject: [PATCH 1/6] Route credentialed traffic through MITM proxy --- .../core/src/config/network_proxy_spec.rs | 31 ++++ .../src/config/network_proxy_spec_tests.rs | 33 +++++ codex-rs/core/src/credentialed_routes.rs | 136 +++++++++++++++++- codex-rs/core/src/session/mod.rs | 21 ++- codex-rs/core/src/session/session.rs | 10 +- codex-rs/core/src/session/tests.rs | 10 +- codex-rs/core/src/state/service.rs | 1 + codex-rs/network-proxy/src/lib.rs | 1 + codex-rs/network-proxy/src/mitm.rs | 46 +++++- codex-rs/network-proxy/src/mitm_hook.rs | 28 ++++ codex-rs/network-proxy/src/mitm_tests.rs | 21 +++ 11 files changed, 320 insertions(+), 18 deletions(-) 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..c0be217b361d 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,37 @@ 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(), + 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_url: Some("https://chatgpt.com/backend-api/wham/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..70e07de49213 100644 --- a/codex-rs/core/src/credentialed_routes.rs +++ b/codex-rs/core/src/credentialed_routes.rs @@ -1,22 +1,33 @@ 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::MitmHookActionsConfig; +use codex_network_proxy::MitmHookConfig; +use codex_network_proxy::MitmHookMatchConfig; use tracing::debug; use tracing::warn; +use url::Url; + +#[derive(Debug, Clone, Default)] +pub(crate) struct CredentialedRoutesSessionConfig { + pub(crate) routes: 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 +37,126 @@ pub(crate) async fn load_for_session( credentialed_routes = response.routes.len(), "loaded credentialed routes for session" ); - response.routes + CredentialedRoutesSessionConfig { + routes: response.routes, + 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, 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_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_url: proxy_url.to_string(), + }), + ..MitmHookActionsConfig::default() + }, + }) +} + +#[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_url: Some( + "https://chatgpt.com/backend-api/wham/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_url: "https://chatgpt.com/backend-api/wham/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..ad5d02d22a6f 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -685,7 +685,7 @@ 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 +732,7 @@ 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 +774,7 @@ 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 +849,7 @@ 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 +4352,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 +6209,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..637f6a4682dc 100644 --- a/codex-rs/network-proxy/src/lib.rs +++ b/codex-rs/network-proxy/src/lib.rs @@ -24,6 +24,7 @@ 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::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..9056e48f50dc 100644 --- a/codex-rs/network-proxy/src/mitm.rs +++ b/codex-rs/network-proxy/src/mitm.rs @@ -47,6 +47,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 { @@ -228,10 +229,22 @@ 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, &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 +560,31 @@ fn build_https_uri(authority: &str, path: &str) -> Result { Ok(target.parse()?) } +fn credentialed_route_proxy_request( + action: &crate::mitm_hook::CredentialedRouteProxyAction, + request_uri: &Uri, +) -> Result<(Uri, String)> { + let mut proxy_url = Url::parse(&action.proxy_url) + .with_context(|| format!("invalid credentialed route proxy URL {}", action.proxy_url))?; + proxy_url + .query_pairs_mut() + .append_pair("link_id", &action.link_id) + .append_pair("connector_id", &action.connector_id) + .append_pair("request_url", &request_uri.to_string()); + let proxy_authority = url_authority_header_value(&proxy_url)?; + Ok((proxy_url.as_str().parse()?, proxy_authority)) +} + +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..7fe4d81766b6 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,20 @@ pub enum SecretSource { File(AbsolutePathBuf), } +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct CredentialedRouteProxyActionConfig { + pub connector_id: String, + pub link_id: String, + pub proxy_url: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CredentialedRouteProxyAction { + pub connector_id: String, + pub link_id: String, + pub proxy_url: String, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct MitmHookBodyMatcher { pub raw: serde_json::Value, @@ -315,6 +332,15 @@ 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_url: action.proxy_url.clone(), + }); hooks_by_host .entry(host.clone()) @@ -331,6 +357,7 @@ where actions: MitmHookActions { strip_request_headers, inject_request_headers, + credentialed_route_proxy, }, }); } @@ -676,6 +703,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..b069cafbb508 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,22 @@ 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_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 (proxy_uri, proxy_authority) = + credentialed_route_proxy_request(&action, &request_uri).unwrap(); + + assert_eq!( + proxy_uri.to_string(), + "http://localhost:8080/api/codex/credential_routes/proxy?link_id=link_123&connector_id=connector_123&request_url=https%3A%2F%2Fapi.example.com%2Fv1%2Fitems%3Flimit%3D5" + ); + assert_eq!(proxy_authority, "localhost:8080"); +} From dba0f036dc9a628e06f89292b7c068b6d618bc10 Mon Sep 17 00:00:00 2001 From: Winston Howes Date: Thu, 14 May 2026 21:38:56 -0700 Subject: [PATCH 2/6] Move credential route proxy metadata to headers --- codex-rs/network-proxy/src/mitm.rs | 40 +++++++++++++++++++----- codex-rs/network-proxy/src/mitm_tests.rs | 25 +++++++++++++-- 2 files changed, 54 insertions(+), 11 deletions(-) diff --git a/codex-rs/network-proxy/src/mitm.rs b/codex-rs/network-proxy/src/mitm.rs index 9056e48f50dc..af2e0ac06607 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; @@ -85,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 { @@ -234,7 +239,12 @@ async fn forward_request(req: Request, request_ctx: &MitmRequestContext) -> Resu && let Some(credentialed_route_proxy) = actions.credentialed_route_proxy.as_ref() { let (proxy_uri, proxy_authority) = - credentialed_route_proxy_request(credentialed_route_proxy, &original_request_uri)?; + 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 @@ -562,19 +572,33 @@ fn build_https_uri(authority: &str, path: &str) -> Result { fn credentialed_route_proxy_request( action: &crate::mitm_hook::CredentialedRouteProxyAction, - request_uri: &Uri, ) -> Result<(Uri, String)> { - let mut proxy_url = Url::parse(&action.proxy_url) + let proxy_url = Url::parse(&action.proxy_url) .with_context(|| format!("invalid credentialed route proxy URL {}", action.proxy_url))?; - proxy_url - .query_pairs_mut() - .append_pair("link_id", &action.link_id) - .append_pair("connector_id", &action.connector_id) - .append_pair("request_url", &request_uri.to_string()); 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<()> { + 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() diff --git a/codex-rs/network-proxy/src/mitm_tests.rs b/codex-rs/network-proxy/src/mitm_tests.rs index b069cafbb508..e0e9d0bb2db3 100644 --- a/codex-rs/network-proxy/src/mitm_tests.rs +++ b/codex-rs/network-proxy/src/mitm_tests.rs @@ -285,13 +285,32 @@ fn credentialed_route_proxy_request_rewrites_upstream_target() { 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(CREDENTIAL_ROUTE_REQUEST_URL_HEADER), + HeaderValue::from_static("https://spoofed.example.com"), + ); - let (proxy_uri, proxy_authority) = - credentialed_route_proxy_request(&action, &request_uri).unwrap(); + 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?link_id=link_123&connector_id=connector_123&request_url=https%3A%2F%2Fapi.example.com%2Fv1%2Fitems%3Flimit%3D5" + "http://localhost:8080/api/codex/credential_routes/proxy" ); assert_eq!(proxy_authority, "localhost:8080"); + 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" + )) + ); } From e552ffb17629c6a35873580c0c3dc429fe390d6c Mon Sep 17 00:00:00 2001 From: Winston Howes Date: Thu, 14 May 2026 22:15:06 -0700 Subject: [PATCH 3/6] Inject Codex auth into credential route proxy --- codex-rs/backend-client/src/client.rs | 11 +++- .../src/config/network_proxy_spec_tests.rs | 1 + codex-rs/core/src/credentialed_routes.rs | 54 ++++++++++++++----- codex-rs/network-proxy/src/lib.rs | 1 + codex-rs/network-proxy/src/mitm.rs | 3 ++ codex-rs/network-proxy/src/mitm_hook.rs | 18 +++++++ codex-rs/network-proxy/src/mitm_tests.rs | 12 +++++ 7 files changed, 86 insertions(+), 14 deletions(-) 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_tests.rs b/codex-rs/core/src/config/network_proxy_spec_tests.rs index c0be217b361d..1d6dedc7ec21 100644 --- a/codex-rs/core/src/config/network_proxy_spec_tests.rs +++ b/codex-rs/core/src/config/network_proxy_spec_tests.rs @@ -97,6 +97,7 @@ fn credentialed_routes_add_runtime_allowlist_and_mitm_hooks() { 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/wham/credential_routes/proxy".to_string()), }; diff --git a/codex-rs/core/src/credentialed_routes.rs b/codex-rs/core/src/credentialed_routes.rs index 70e07de49213..c6e8c484b25d 100644 --- a/codex-rs/core/src/credentialed_routes.rs +++ b/codex-rs/core/src/credentialed_routes.rs @@ -2,9 +2,11 @@ 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; @@ -12,6 +14,7 @@ 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, } @@ -39,6 +42,9 @@ pub(crate) async fn load_for_session( ); CredentialedRoutesSessionConfig { routes: response.routes, + proxy_headers: credentialed_route_proxy_headers( + client.credential_routes_proxy_auth_headers(), + ), proxy_url: Some(client.credential_routes_proxy_url()), } } @@ -57,25 +63,28 @@ impl CredentialedRoutesSessionConfig { self.routes .iter() - .filter_map(|route| match route_mitm_hook(route, 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 - } - }) + .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)?; @@ -117,6 +126,7 @@ fn route_mitm_hook( 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() @@ -124,6 +134,16 @@ fn route_mitm_hook( }) } +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::*; @@ -139,6 +159,10 @@ mod tests { 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/wham/credential_routes/proxy".to_string(), ), @@ -154,6 +178,10 @@ mod tests { 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/wham/credential_routes/proxy" .to_string(), }) diff --git a/codex-rs/network-proxy/src/lib.rs b/codex-rs/network-proxy/src/lib.rs index 637f6a4682dc..e918aee6ba03 100644 --- a/codex-rs/network-proxy/src/lib.rs +++ b/codex-rs/network-proxy/src/lib.rs @@ -25,6 +25,7 @@ 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 af2e0ac06607..f1aafddb2326 100644 --- a/codex-rs/network-proxy/src/mitm.rs +++ b/codex-rs/network-proxy/src/mitm.rs @@ -584,6 +584,9 @@ fn apply_credentialed_route_proxy_headers( 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)?, diff --git a/codex-rs/network-proxy/src/mitm_hook.rs b/codex-rs/network-proxy/src/mitm_hook.rs index 7fe4d81766b6..ac8d61c28d55 100644 --- a/codex-rs/network-proxy/src/mitm_hook.rs +++ b/codex-rs/network-proxy/src/mitm_hook.rs @@ -116,6 +116,7 @@ pub enum SecretSource { pub struct CredentialedRouteProxyActionConfig { pub connector_id: String, pub link_id: String, + pub proxy_headers: Vec, pub proxy_url: String, } @@ -123,9 +124,25 @@ pub struct CredentialedRouteProxyActionConfig { 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, @@ -339,6 +356,7 @@ where .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(), }); diff --git a/codex-rs/network-proxy/src/mitm_tests.rs b/codex-rs/network-proxy/src/mitm_tests.rs index e0e9d0bb2db3..b68cc2d900dc 100644 --- a/codex-rs/network-proxy/src/mitm_tests.rs +++ b/codex-rs/network-proxy/src/mitm_tests.rs @@ -282,10 +282,18 @@ 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"), @@ -299,6 +307,10 @@ fn credentialed_route_proxy_request_rewrites_upstream_target() { "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")) From c735bffdf83f9064b663f792c1325ac39358e4d2 Mon Sep 17 00:00:00 2001 From: Winston Howes Date: Fri, 15 May 2026 05:50:08 -0700 Subject: [PATCH 4/6] Fix credential route lint comment --- codex-rs/core/src/config/network_proxy_spec_tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 1d6dedc7ec21..3d064d685dd3 100644 --- a/codex-rs/core/src/config/network_proxy_spec_tests.rs +++ b/codex-rs/core/src/config/network_proxy_spec_tests.rs @@ -86,7 +86,7 @@ fn requirements_allowed_domains_are_a_baseline_for_user_allowlist() { fn credentialed_routes_add_runtime_allowlist_and_mitm_hooks() { let spec = NetworkProxySpec::from_config_and_constraints( NetworkProxyConfig::default(), - None, + /*requirements*/ None, &permission_profile_for_sandbox_policy(&SandboxPolicy::new_workspace_write_policy()), ) .expect("config should load"); From c4d737c2f8f98f29806cb45bdfcd9b36f55ae836 Mon Sep 17 00:00:00 2001 From: Winston Howes Date: Fri, 15 May 2026 10:33:32 -0700 Subject: [PATCH 5/6] Format managed proxy route tests --- codex-rs/core/src/session/tests.rs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index ad5d02d22a6f..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: &crate::credentialed_routes::CredentialedRoutesSessionConfig::default(), + 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: &crate::credentialed_routes::CredentialedRoutesSessionConfig::default(), + 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: &crate::credentialed_routes::CredentialedRoutesSessionConfig::default(), + 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: &crate::credentialed_routes::CredentialedRoutesSessionConfig::default(), + credentialed_routes: &crate::credentialed_routes::CredentialedRoutesSessionConfig::default( + ), exec_policy: &Policy::empty(), permission_profile: &permission_profile_for_sandbox_policy(&initial_policy), network_policy_decider: None, From 52957cf34a6de3c9d51c268b23dbabd04b3afd13 Mon Sep 17 00:00:00 2001 From: Winston Howes Date: Fri, 15 May 2026 19:24:01 -0700 Subject: [PATCH 6/6] Use public credential route proxy path --- codex-rs/core/src/config/network_proxy_spec_tests.rs | 2 +- codex-rs/core/src/credentialed_routes.rs | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) 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 3d064d685dd3..b0d833f0a3ff 100644 --- a/codex-rs/core/src/config/network_proxy_spec_tests.rs +++ b/codex-rs/core/src/config/network_proxy_spec_tests.rs @@ -98,7 +98,7 @@ fn credentialed_routes_add_runtime_allowlist_and_mitm_hooks() { base_url: "https://api.example.com/v1".to_string(), }], proxy_headers: Vec::new(), - proxy_url: Some("https://chatgpt.com/backend-api/wham/credential_routes/proxy".to_string()), + proxy_url: Some("https://chatgpt.com/backend-api/ps/credential_routes/proxy".to_string()), }; let spec = spec diff --git a/codex-rs/core/src/credentialed_routes.rs b/codex-rs/core/src/credentialed_routes.rs index c6e8c484b25d..7313e6c66c5e 100644 --- a/codex-rs/core/src/credentialed_routes.rs +++ b/codex-rs/core/src/credentialed_routes.rs @@ -164,7 +164,7 @@ mod tests { value: http::HeaderValue::from_static("Bearer codex-token"), }], proxy_url: Some( - "https://chatgpt.com/backend-api/wham/credential_routes/proxy".to_string(), + "https://chatgpt.com/backend-api/ps/credential_routes/proxy".to_string(), ), }; @@ -182,8 +182,7 @@ mod tests { name: http::header::AUTHORIZATION, value: http::HeaderValue::from_static("Bearer codex-token"), }], - proxy_url: "https://chatgpt.com/backend-api/wham/credential_routes/proxy" - .to_string(), + proxy_url: "https://chatgpt.com/backend-api/ps/credential_routes/proxy".to_string(), }) ); }