Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion codex-rs/backend-client/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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<String> {
Expand Down
31 changes: 31 additions & 0 deletions codex-rs/core/src/config/network_proxy_spec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,37 @@ impl NetworkProxySpec {
Ok(spec)
}

pub(crate) fn with_credentialed_routes(
&self,
credentialed_routes: &crate::credentialed_routes::CredentialedRoutesSessionConfig,
) -> std::io::Result<Self> {
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,
Expand Down
34 changes: 34 additions & 0 deletions codex-rs/core/src/config/network_proxy_spec_tests.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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();
Expand Down
163 changes: 158 additions & 5 deletions codex-rs/core/src/credentialed_routes.rs
Original file line number Diff line number Diff line change
@@ -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<ResolvedCredentialRoute>,
pub(crate) proxy_headers: Vec<CredentialedRouteProxyHeader>,
pub(crate) proxy_url: Option<String>,
}

pub(crate) async fn load_for_session(
chatgpt_base_url: &str,
auth: Option<&CodexAuth>,
) -> Vec<ResolvedCredentialRoute> {
) -> 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();
}
};

Expand All @@ -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<MitmHookConfig> {
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<MitmHookConfig> {
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<CredentialedRouteProxyHeader> {
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(),
})
);
}
}
21 changes: 19 additions & 2 deletions codex-rs/core/src/session/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Arc<dyn codex_network_proxy::NetworkPolicyDecider>>,
Expand Down Expand Up @@ -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!(
Expand Down Expand Up @@ -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,
Expand Down
10 changes: 7 additions & 3 deletions codex-rs/core/src/session/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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(),
Expand Down
Loading
Loading