From 582ae6690f3fd3669ac3245aa2ad05cd42df9a3d Mon Sep 17 00:00:00 2001 From: Winston Howes Date: Thu, 14 May 2026 12:24:28 -0700 Subject: [PATCH] Discover credentialed routes for managed proxy --- codex-rs/Cargo.lock | 1 + codex-rs/backend-client/src/client.rs | 61 ++++++++++++++++++++++++ codex-rs/backend-client/src/lib.rs | 3 ++ codex-rs/backend-client/src/types.rs | 23 +++++++++ codex-rs/core/Cargo.toml | 1 + codex-rs/core/src/credentialed_routes.rs | 36 ++++++++++++++ codex-rs/core/src/lib.rs | 1 + codex-rs/core/src/session/mod.rs | 5 ++ codex-rs/core/src/session/session.rs | 4 ++ codex-rs/core/src/session/tests.rs | 4 ++ 10 files changed, 139 insertions(+) create mode 100644 codex-rs/core/src/credentialed_routes.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 62d52591d208..1882d1ee977d 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2492,6 +2492,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/backend-client/src/client.rs b/codex-rs/backend-client/src/client.rs index 6365d527ed5b..05deb85a3cfd 100644 --- a/codex-rs/backend-client/src/client.rs +++ b/codex-rs/backend-client/src/client.rs @@ -1,5 +1,6 @@ use crate::types::CodeTaskDetailsResponse; use crate::types::ConfigFileResponse; +use crate::types::ListCredentialRoutesResponse; use crate::types::PaginatedListTaskListItem; use crate::types::RateLimitReachedKind as BackendRateLimitReachedKind; use crate::types::RateLimitStatusPayload; @@ -408,6 +409,20 @@ impl Client { .map_err(RequestError::from) } + pub async fn list_credential_routes(&self) -> Result { + let url = self.credential_routes_url(); + let req = self.http.get(&url).headers(self.headers()); + let (body, ct) = self.exec_request(req, "GET", &url).await?; + self.decode_json(&url, &ct, &body) + } + + pub fn credential_routes_proxy_url(&self) -> String { + match self.path_style { + PathStyle::CodexApi => format!("{}/api/codex/credential_routes/proxy", self.base_url), + PathStyle::ChatGptApi => format!("{}/wham/credential_routes/proxy", self.base_url), + } + } + /// 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 { @@ -539,6 +554,13 @@ impl Client { } } + fn credential_routes_url(&self) -> String { + match self.path_style { + PathStyle::CodexApi => format!("{}/api/codex/credential_routes", self.base_url), + PathStyle::ChatGptApi => format!("{}/wham/credential_routes", self.base_url), + } + } + fn map_rate_limit_window( window: Option>>, ) -> Option { @@ -862,4 +884,43 @@ mod tests { serde_json::json!({ "credit_type": "usage_limit" }) ); } + + #[test] + fn credential_routes_use_expected_paths() { + let codex_client = Client { + base_url: "https://example.test".to_string(), + http: reqwest::Client::new(), + auth_provider: codex_model_provider::unauthenticated_auth_provider(), + user_agent: None, + chatgpt_account_id: None, + chatgpt_account_is_fedramp: false, + path_style: PathStyle::CodexApi, + }; + assert_eq!( + codex_client.credential_routes_url(), + "https://example.test/api/codex/credential_routes" + ); + assert_eq!( + codex_client.credential_routes_proxy_url(), + "https://example.test/api/codex/credential_routes/proxy" + ); + + let chatgpt_client = Client { + base_url: "https://chatgpt.com/backend-api".to_string(), + http: reqwest::Client::new(), + auth_provider: codex_model_provider::unauthenticated_auth_provider(), + user_agent: None, + chatgpt_account_id: None, + chatgpt_account_is_fedramp: false, + path_style: PathStyle::ChatGptApi, + }; + assert_eq!( + chatgpt_client.credential_routes_url(), + "https://chatgpt.com/backend-api/wham/credential_routes" + ); + assert_eq!( + chatgpt_client.credential_routes_proxy_url(), + "https://chatgpt.com/backend-api/wham/credential_routes/proxy" + ); + } } diff --git a/codex-rs/backend-client/src/lib.rs b/codex-rs/backend-client/src/lib.rs index 300da8156828..4e85b992d7d1 100644 --- a/codex-rs/backend-client/src/lib.rs +++ b/codex-rs/backend-client/src/lib.rs @@ -7,6 +7,9 @@ pub use client::RequestError; pub use types::CodeTaskDetailsResponse; pub use types::CodeTaskDetailsResponseExt; pub use types::ConfigFileResponse; +pub use types::CredentialRouteAuthType; +pub use types::ListCredentialRoutesResponse; pub use types::PaginatedListTaskListItem; +pub use types::ResolvedCredentialRoute; pub use types::TaskListItem; pub use types::TurnAttemptsSiblingTurnsResponse; diff --git a/codex-rs/backend-client/src/types.rs b/codex-rs/backend-client/src/types.rs index d8d24ab9fce4..6ad6859859a1 100644 --- a/codex-rs/backend-client/src/types.rs +++ b/codex-rs/backend-client/src/types.rs @@ -13,6 +13,29 @@ use serde::de::Deserializer; use serde_json::Value; use std::collections::HashMap; +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +pub struct ListCredentialRoutesResponse { + pub routes: Vec, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +pub struct ResolvedCredentialRoute { + pub connector_id: String, + pub link_id: String, + pub auth_type: CredentialRouteAuthType, + pub base_url: String, +} + +#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)] +pub enum CredentialRouteAuthType { + #[serde(rename = "API_KEY")] + ApiKey, + #[serde(rename = "OAUTH")] + OAuth, + #[serde(rename = "NONE")] + None, +} + /// Hand-rolled models for the Cloud Tasks task-details response. /// The generated OpenAPI models are pretty bad. This is a half-step /// towards hand-rolling them. diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 8472918dcae9..e6a02baed60e 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-config = { workspace = true } diff --git a/codex-rs/core/src/credentialed_routes.rs b/codex-rs/core/src/credentialed_routes.rs new file mode 100644 index 000000000000..cf6ee30b766d --- /dev/null +++ b/codex-rs/core/src/credentialed_routes.rs @@ -0,0 +1,36 @@ +use codex_backend_client::Client as BackendClient; +use codex_backend_client::ResolvedCredentialRoute; +use codex_login::CodexAuth; +use tracing::debug; +use tracing::warn; + +pub(crate) async fn load_for_session( + chatgpt_base_url: &str, + auth: Option<&CodexAuth>, +) -> Vec { + let Some(auth) = auth.filter(|auth| auth.uses_codex_backend()) else { + return Vec::new(); + }; + + 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(); + } + }; + + match client.list_credential_routes().await { + Ok(response) => { + debug!( + credentialed_routes = response.routes.len(), + "loaded credentialed routes for session" + ); + response.routes + } + Err(err) => { + warn!(error = %err, "failed to load credentialed routes for session"); + Vec::new() + } + } +} diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 325d65745f36..203520ea8ca1 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -19,6 +19,7 @@ mod codex_thread; mod compact_remote; mod compact_remote_v2; mod config_lock; +mod credentialed_routes; pub use codex_thread::CodexThread; pub use codex_thread::CodexThreadTurnContextOverrides; pub use codex_thread::ThreadConfigSnapshot; diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index fd3f31771dc1..dbbd7f24893c 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -930,6 +930,7 @@ impl Session { async fn start_managed_network_proxy( spec: &crate::config::NetworkProxySpec, + credentialed_routes: &[codex_backend_client::ResolvedCredentialRoute], exec_policy: &codex_execpolicy::Policy, permission_profile: &PermissionProfile, network_policy_decider: Option>, @@ -937,6 +938,10 @@ impl Session { managed_network_requirements_enabled: bool, audit_metadata: NetworkProxyAuditMetadata, ) -> anyhow::Result<(StartedNetworkProxy, SessionNetworkProxyRuntime)> { + debug!( + credentialed_routes = credentialed_routes.len(), + "starting managed network proxy" + ); let spec = spec .with_exec_policy_network_rules(exec_policy) .map_err(|err| { diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 5b1b8c83e496..60b9bb7a3623 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -765,9 +765,13 @@ impl Session { }); 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( spec, + &credentialed_routes, current_exec_policy.as_ref(), config.permissions.permission_profile.get(), network_policy_decider.as_ref().map(Arc::clone), diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index a53a1e0fa107..4a4249374a01 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -682,6 +682,7 @@ async fn start_managed_network_proxy_applies_execpolicy_network_rules() -> anyho let (started_proxy, _) = Session::start_managed_network_proxy( &spec, + /*credentialed_routes*/ &[], &exec_policy, &permission_profile_for_sandbox_policy(&SandboxPolicy::new_workspace_write_policy()), /*network_policy_decider*/ None, @@ -726,6 +727,7 @@ async fn start_managed_network_proxy_ignores_invalid_execpolicy_network_rules() let (started_proxy, _) = Session::start_managed_network_proxy( &spec, + /*credentialed_routes*/ &[], &exec_policy, &permission_profile_for_sandbox_policy(&SandboxPolicy::new_workspace_write_policy()), /*network_policy_decider*/ None, @@ -765,6 +767,7 @@ async fn managed_network_proxy_decider_survives_full_access_start() -> anyhow::R let (started_proxy, _) = Session::start_managed_network_proxy( &spec, + /*credentialed_routes*/ &[], &exec_policy, &permission_profile_for_sandbox_policy(&SandboxPolicy::DangerFullAccess), Some(network_policy_decider), @@ -837,6 +840,7 @@ async fn new_turn_refreshes_managed_network_proxy_for_sandbox_change() -> anyhow )?; let (started_proxy, _) = Session::start_managed_network_proxy( &spec, + /*credentialed_routes*/ &[], &Policy::empty(), &permission_profile_for_sandbox_policy(&initial_policy), /*network_policy_decider*/ None,