From 235ef2dda375ac9c4e73ec3853cd0e00504a9690 Mon Sep 17 00:00:00 2001 From: Bryan Bednarski Date: Wed, 15 Jul 2026 19:11:22 -0600 Subject: [PATCH 1/4] fix(cli): bind transparent runs to gateway credentials Signed-off-by: Bryan Bednarski --- crates/cli/src/agents/claude/launch.rs | 14 + crates/cli/src/agents/codex/launch.rs | 6 +- crates/cli/src/agents/hermes/config.rs | 7 + crates/cli/src/agents/mod.rs | 9 +- crates/cli/src/agents/shared/alignment.rs | 3 +- crates/cli/src/error.rs | 3 + crates/cli/src/gateway/mod.rs | 78 +++-- crates/cli/src/gateway/request.rs | 16 +- crates/cli/src/gateway/response.rs | 1 + crates/cli/src/gateway/routes.rs | 13 +- crates/cli/src/lib.rs | 1 + crates/cli/src/process/launcher.rs | 26 +- crates/cli/src/process/prepared.rs | 15 + crates/cli/src/provider_auth.rs | 168 +++++++++++ crates/cli/src/server/mod.rs | 55 +++- .../cli/tests/coverage/agents/hermes_tests.rs | 8 + .../tests/coverage/agents/launcher_tests.rs | 83 +++++- .../tests/coverage/shared/gateway_tests.rs | 271 +++++++++++++++++- .../tests/coverage/shared/installer_tests.rs | 1 + crates/cli/tests/coverage/shared/mcp_tests.rs | 1 + .../cli/tests/coverage/shared/server_tests.rs | 140 ++++++++- 21 files changed, 847 insertions(+), 72 deletions(-) create mode 100644 crates/cli/src/provider_auth.rs diff --git a/crates/cli/src/agents/claude/launch.rs b/crates/cli/src/agents/claude/launch.rs index b86b3e61f..38e616a25 100644 --- a/crates/cli/src/agents/claude/launch.rs +++ b/crates/cli/src/agents/claude/launch.rs @@ -13,8 +13,22 @@ use crate::process::{PreparedAgentLaunch, insert_after_host}; pub(crate) fn prepare( launch: &mut PreparedAgentLaunch, gateway_url: &str, + proxy_credential: &crate::provider_auth::TransparentProxyCredential, dry_run: bool, ) -> Result<(), CliError> { + let proxy_header = format!( + "{}: {}", + crate::provider_auth::TRANSPARENT_PROXY_CREDENTIAL_HEADER, + proxy_credential.expose() + ); + let custom_headers = std::env::var("ANTHROPIC_CUSTOM_HEADERS") + .ok() + .filter(|value| !value.trim().is_empty()) + .map_or_else( + || proxy_header.clone(), + |value| format!("{value}\n{proxy_header}"), + ); + launch.set_secret_env("ANTHROPIC_CUSTOM_HEADERS", custom_headers); if dry_run { insert_after_host( &mut launch.argv, diff --git a/crates/cli/src/agents/codex/launch.rs b/crates/cli/src/agents/codex/launch.rs index 2bbd79927..ad20c5cc0 100644 --- a/crates/cli/src/agents/codex/launch.rs +++ b/crates/cli/src/agents/codex/launch.rs @@ -198,8 +198,10 @@ fn canonical_json(value: Value) -> Value { fn gateway_provider_config(gateway_url: &str) -> String { format!( - "model_providers.nemo-relay-openai={{name=\"NeMo Relay OpenAI\",base_url={},wire_api=\"responses\",requires_openai_auth=true,supports_websockets=false}}", - toml_string(gateway_url) + "model_providers.nemo-relay-openai={{name=\"NeMo Relay OpenAI\",base_url={},wire_api=\"responses\",requires_openai_auth=true,supports_websockets=false,env_http_headers={{{}={}}}}}", + toml_string(gateway_url), + toml_string(crate::provider_auth::TRANSPARENT_PROXY_CREDENTIAL_HEADER), + toml_string(crate::provider_auth::TRANSPARENT_PROXY_CREDENTIAL_ENV), ) } diff --git a/crates/cli/src/agents/hermes/config.rs b/crates/cli/src/agents/hermes/config.rs index f15c13efa..95d305fb3 100644 --- a/crates/cli/src/agents/hermes/config.rs +++ b/crates/cli/src/agents/hermes/config.rs @@ -64,6 +64,13 @@ pub(crate) fn transparent_config( "base_url".into(), Value::String(format!("{}/v1", gateway_url.trim_end_matches('/'))), ); + model.insert( + "api_key".into(), + Value::String(format!( + "${{{}}}", + crate::provider_auth::TRANSPARENT_PROXY_CREDENTIAL_ENV + )), + ); object.insert("model".into(), Value::Object(model)); serde_yaml::to_string(&root).map_err(|error| CliError::Install(error.to_string())) } diff --git a/crates/cli/src/agents/mod.rs b/crates/cli/src/agents/mod.rs index f8a769ee8..41cd5f72a 100644 --- a/crates/cli/src/agents/mod.rs +++ b/crates/cli/src/agents/mod.rs @@ -383,11 +383,18 @@ pub(crate) fn prepare_launch( launch: &mut crate::process::PreparedAgentLaunch, gateway_url: &str, resolved: &crate::configuration::ResolvedConfig, + proxy_credential: &crate::provider_auth::TransparentProxyCredential, dry_run: bool, ) -> Result<(), crate::error::CliError> { + launch.set_secret_env( + crate::provider_auth::TRANSPARENT_PROXY_CREDENTIAL_ENV, + proxy_credential.expose(), + ); match agent { CodingAgent::Codex => codex::launch::prepare(launch, gateway_url), - CodingAgent::ClaudeCode => claude::launch::prepare(launch, gateway_url, dry_run), + CodingAgent::ClaudeCode => { + claude::launch::prepare(launch, gateway_url, proxy_credential, dry_run) + } CodingAgent::Hermes => hermes::launch::prepare( launch, resolved.agents.hermes.hooks_path.as_deref(), diff --git a/crates/cli/src/agents/shared/alignment.rs b/crates/cli/src/agents/shared/alignment.rs index 5561ca495..6694d526d 100644 --- a/crates/cli/src/agents/shared/alignment.rs +++ b/crates/cli/src/agents/shared/alignment.rs @@ -546,8 +546,7 @@ pub(crate) fn gateway_upstream_url_override( /// Remove or preserve agent-native auth before generic provider auth injection. /// -/// Codex strips ChatGPT auth tokens only when an OpenAI API key is available to -/// replace them. +/// Codex strips ChatGPT auth tokens only when an OpenAI API key is available to replace them. pub(crate) fn gateway_forward_headers( headers: &HeaderMap, route: GatewayRouteKind, diff --git a/crates/cli/src/error.rs b/crates/cli/src/error.rs index 0d5dbb3b2..41865a537 100644 --- a/crates/cli/src/error.rs +++ b/crates/cli/src/error.rs @@ -34,6 +34,8 @@ pub(crate) enum CliError { InvalidPayload(String), #[error("payload too large: {0}")] PayloadTooLarge(String), + #[error("unauthorized gateway client: {0}")] + Unauthorized(String), #[error("gateway upstream error: {0}")] Upstream(#[from] reqwest::Error), #[error("{0}")] @@ -98,6 +100,7 @@ impl IntoResponse for CliError { let status = match (guardrail_reason.is_some(), &self) { (true, _) => StatusCode::FORBIDDEN, (false, Self::PayloadTooLarge(_)) => StatusCode::PAYLOAD_TOO_LARGE, + (false, Self::Unauthorized(_)) => StatusCode::UNAUTHORIZED, (false, Self::InvalidPayload(_)) => StatusCode::BAD_REQUEST, (false, Self::Upstream(_)) => StatusCode::BAD_GATEWAY, (false, Self::ProviderFailure(failure)) => failure diff --git a/crates/cli/src/gateway/mod.rs b/crates/cli/src/gateway/mod.rs index a9d39adbf..8495185cc 100644 --- a/crates/cli/src/gateway/mod.rs +++ b/crates/cli/src/gateway/mod.rs @@ -65,12 +65,11 @@ const MAX_UPSTREAM_ERROR_BODY_BYTES: usize = 64 * 1024; /// design that splits a raw byte stream between client and runtime. pub(crate) async fn passthrough( State(state): State, - request: Request, + mut request: Request, ) -> Result, CliError> { state.touch(); - let allow_environment_provider_auth = state.allows_environment_provider_auth(request.headers()); - let prepared = - prepare_gateway_request(&state.config, request, allow_environment_provider_auth).await?; + let authorization = state.authorize_provider_request(request.headers_mut())?; + let prepared = prepare_gateway_request(&state.config, request, authorization).await?; let prep = state .sessions .prepare_gateway_call(&prepared.headers, build_llm_gateway_start(&prepared)) @@ -131,7 +130,7 @@ async fn run_unmanaged_gateway( &prepared.body_bytes, &prepared.headers, None, - ProviderForwarding::new(prepared.provider, prepared.allow_environment_provider_auth), + ProviderForwarding::new(prepared.provider, prepared.authorization), ) .await?; let status = response.status(); @@ -253,8 +252,7 @@ fn build_buffered_func( let url = prepared.upstream_url.clone(); let body_bytes = prepared.body_bytes.clone(); let headers = prepared.headers.clone(); - let forwarding = - ProviderForwarding::new(prepared.provider, prepared.allow_environment_provider_auth); + let forwarding = ProviderForwarding::new(prepared.provider, prepared.authorization); Arc::new(move |request| { let http = http.clone(); let method = method.clone(); @@ -436,8 +434,7 @@ fn build_streaming_func( let url = prepared.upstream_url.clone(); let body_bytes = prepared.body_bytes.clone(); let headers = prepared.headers.clone(); - let forwarding = - ProviderForwarding::new(prepared.provider, prepared.allow_environment_provider_auth); + let forwarding = ProviderForwarding::new(prepared.provider, prepared.authorization); Arc::new(move |request| { let http = http.clone(); let method = method.clone(); @@ -688,7 +685,7 @@ fn encode_sse_frame(event_json: &Value, route: ProviderRoute) -> String { // Forwards the buffered request to the upstream provider with only the safe request headers. This // is shared by the buffered and streaming managed funcs so header filtering stays consistent. -// Agent-native credential quirks are normalized by alignment before provider auth injection runs. +// Source authentication is normalized at ingress, before interceptors can select a target. async fn forward_upstream_request( http: &reqwest::Client, method: &Method, @@ -698,31 +695,36 @@ async fn forward_upstream_request( effective_request: Option<&LlmRequest>, forwarding: ProviderForwarding, ) -> Result { + debug_assert_eq!( + forwarding + .authorization + .source_credential + .provider_credential_present(), + crate::provider_auth::has_provider_credential(headers) + ); let effective = effective_dispatch_request( body_bytes, headers, effective_request, url, - forwarding.route, - ); - let sanitized = strip_replaceable_agent_auth_headers( - &effective.headers, - effective.route, - forwarding.allow_environment_provider_auth, + forwarding.source_route, ); let mut upstream = http .request(method.clone(), &effective.url) .body(effective.body_bytes.clone()); - for (name, value) in &sanitized { - if should_forward_request_header(name, &sanitized) { + for (name, value) in &effective.headers { + if should_forward_request_header(name, &effective.headers) { upstream = upstream.header(name, value); } } upstream = inject_provider_auth( upstream, - effective.route, - &sanitized, - forwarding.allow_environment_provider_auth, + effective.target_route, + &effective.headers, + matches!( + effective.credential_policy, + TargetCredentialPolicy::SourceOrEnvironment + ) && forwarding.authorization.allow_environment_provider_auth, ); upstream.send().await } @@ -732,7 +734,14 @@ struct EffectiveUpstreamRequest { body_bytes: Bytes, headers: HeaderMap, url: String, - route: ProviderRoute, + target_route: ProviderRoute, + credential_policy: TargetCredentialPolicy, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum TargetCredentialPolicy { + SourceOrEnvironment, + ExplicitTarget, } #[cfg(test)] @@ -765,7 +774,8 @@ fn effective_dispatch_request( body_bytes: body_bytes.clone(), headers, url: url.to_string(), - route, + target_route: route, + credential_policy: TargetCredentialPolicy::SourceOrEnvironment, }; }; @@ -782,7 +792,8 @@ fn effective_dispatch_request( body_bytes: body_bytes.clone(), headers, url: url.to_string(), - route, + target_route: route, + credential_policy: TargetCredentialPolicy::SourceOrEnvironment, }; } } @@ -801,6 +812,17 @@ fn effective_dispatch_request( .and_then(|value| ProviderRoute::from_dispatch_override(&value)); continue; } + } + let credential_policy = if override_url.is_some() || dispatch_route_header_seen { + crate::provider_auth::remove_provider_credentials(&mut headers); + TargetCredentialPolicy::ExplicitTarget + } else { + TargetCredentialPolicy::SourceOrEnvironment + }; + // Observable source headers exclude credentials. Applying the rewritten map only after source + // credentials are removed lets an explicit target binding add its own authorization without + // inheriting credentials intended for the original provider. + for (name, value) in &request.headers { let Ok(name) = HeaderName::from_bytes(name.as_bytes()) else { continue; }; @@ -820,7 +842,8 @@ fn effective_dispatch_request( } else { override_url.unwrap_or_else(|| url.to_string()) }, - route: override_route.unwrap_or(route), + target_route: override_route.unwrap_or(route), + credential_policy, } } @@ -934,7 +957,7 @@ async fn passthrough_streaming( &prepared.body_bytes, &prepared.headers, None, - ProviderForwarding::new(prepared.provider, prepared.allow_environment_provider_auth), + ProviderForwarding::new(prepared.provider, prepared.authorization), ) .await?; let status = response.status(); @@ -1078,7 +1101,8 @@ pub(crate) async fn models( .path_and_query() .map(|p| p.as_str()) .unwrap_or(parts.uri.path()); - let allow_environment_provider_auth = state.allows_environment_provider_auth(&parts.headers); + let authorization = state.authorize_provider_request(&mut parts.headers)?; + let allow_environment_provider_auth = authorization.allow_environment_provider_auth; parts.headers.remove(BOOTSTRAP_CLIENT_TOKEN_HEADER); let upstream_url = gateway_upstream_url_override( provider, diff --git a/crates/cli/src/gateway/request.rs b/crates/cli/src/gateway/request.rs index a56fccaa8..16bb446f3 100644 --- a/crates/cli/src/gateway/request.rs +++ b/crates/cli/src/gateway/request.rs @@ -30,13 +30,13 @@ pub(super) struct PreparedGatewayRequest { pub(super) body_bytes: Bytes, pub(super) request_json: Value, pub(super) streaming: bool, - pub(super) allow_environment_provider_auth: bool, + pub(super) authorization: crate::provider_auth::ProviderRequestAuthorization, } pub(super) async fn prepare_gateway_request( config: &crate::configuration::GatewayConfig, request: Request, - allow_environment_provider_auth: bool, + mut authorization: crate::provider_auth::ProviderRequestAuthorization, ) -> Result { let (mut parts, body) = request.into_parts(); parts.headers.remove(BOOTSTRAP_CLIENT_TOKEN_HEADER); @@ -56,9 +56,17 @@ pub(super) async fn prepare_gateway_request( provider, &parts.headers, path_and_query, - allow_environment_provider_auth, + authorization.allow_environment_provider_auth, ) .unwrap_or_else(|| provider.upstream_url(config, path_and_query)); + parts.headers = super::routes::strip_replaceable_agent_auth_headers( + &parts.headers, + provider, + authorization.allow_environment_provider_auth, + ); + authorization.source_credential = authorization + .source_credential + .after_source_normalization(&parts.headers); let streaming = request_json .get("stream") .and_then(Value::as_bool) @@ -72,7 +80,7 @@ pub(super) async fn prepare_gateway_request( body_bytes, request_json, streaming, - allow_environment_provider_auth, + authorization, }) } diff --git a/crates/cli/src/gateway/response.rs b/crates/cli/src/gateway/response.rs index 29f1e11e3..4043ef200 100644 --- a/crates/cli/src/gateway/response.rs +++ b/crates/cli/src/gateway/response.rs @@ -62,6 +62,7 @@ pub(super) fn should_forward_request_header(name: &HeaderName, headers: &HeaderM && name != http::header::HOST && name != http::header::CONTENT_LENGTH && name.as_str() != BOOTSTRAP_CLIENT_TOKEN_HEADER + && name.as_str() != crate::provider_auth::TRANSPARENT_PROXY_CREDENTIAL_HEADER // Strip Accept-Encoding so upstreams return identity-encoded bodies; otherwise the // observability capture (`output.value` on LLM spans, ATIF trajectory bodies) records // gzip/br/zstd bytes that downstream consumers can't read. Bandwidth cost is paid only diff --git a/crates/cli/src/gateway/routes.rs b/crates/cli/src/gateway/routes.rs index 08fb44c70..b109ac386 100644 --- a/crates/cli/src/gateway/routes.rs +++ b/crates/cli/src/gateway/routes.rs @@ -16,15 +16,18 @@ pub(super) enum ProviderRoute { #[derive(Clone, Copy)] pub(super) struct ProviderForwarding { - pub(super) route: ProviderRoute, - pub(super) allow_environment_provider_auth: bool, + pub(super) source_route: ProviderRoute, + pub(super) authorization: crate::provider_auth::ProviderRequestAuthorization, } impl ProviderForwarding { - pub(super) fn new(route: ProviderRoute, allow_environment_provider_auth: bool) -> Self { + pub(super) fn new( + source_route: ProviderRoute, + authorization: crate::provider_auth::ProviderRequestAuthorization, + ) -> Self { Self { - route, - allow_environment_provider_auth, + source_route, + authorization, } } } diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index d292fb9de..1ce0fe721 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -19,6 +19,7 @@ mod mcp; mod mcp_environment; mod plugins; mod process; +mod provider_auth; mod server; mod sessions; diff --git a/crates/cli/src/process/launcher.rs b/crates/cli/src/process/launcher.rs index bb918d47b..62a8d70b0 100644 --- a/crates/cli/src/process/launcher.rs +++ b/crates/cli/src/process/launcher.rs @@ -145,11 +145,13 @@ async fn execute_live_run_with_dynamic( prepared: PreparedAgentLaunch, ) -> Result { let bootstrap_fingerprint = crate::configuration::transparent_gateway_fingerprint(gateway_url); + let proxy_credential = prepared.proxy_credential.clone(); let running_server = RunningGateway::start( listener, gateway_config, dynamic_plugins, bootstrap_fingerprint.clone(), + proxy_credential, ); if let Err(error) = wait_for_health(gateway_url, &bootstrap_fingerprint).await { let restore = prepared.restore(); @@ -321,6 +323,7 @@ impl RunningGateway { config: crate::configuration::GatewayConfig, dynamic_plugins: Vec, bootstrap_fingerprint: String, + proxy_credential: crate::provider_auth::TransparentProxyCredential, ) -> Self { let (shutdown_tx, shutdown_rx) = oneshot::channel(); let task = tokio::spawn(async move { @@ -329,6 +332,7 @@ impl RunningGateway { config, dynamic_plugins, bootstrap_fingerprint, + proxy_credential, Some(shutdown_rx), ) .await @@ -399,6 +403,7 @@ impl PreparedAgentLaunch { resolved: &ResolvedConfig, dry_run: bool, ) -> Result { + let proxy_credential = crate::provider_auth::TransparentProxyCredential::generate()?; let mut run = Self { argv, host_index, @@ -411,11 +416,21 @@ impl PreparedAgentLaunch { ], temp_dirs: Vec::new(), notes: Vec::new(), + proxy_credential, + secret_env_names: Vec::new(), }; if let Some(path) = path_with_transparent_hook_dir() { run.env.push(("PATH".into(), path)); } - crate::agents::prepare_launch(agent, &mut run, gateway_url, resolved, dry_run)?; + let proxy_credential = run.proxy_credential.clone(); + crate::agents::prepare_launch( + agent, + &mut run, + gateway_url, + resolved, + &proxy_credential, + dry_run, + )?; Ok(run) } @@ -529,7 +544,14 @@ impl PreparedAgentLaunch { } println!("argv = {}", self.argv.join(" ")); for (name, value) in &self.env { - println!("env.{name} = {value}"); + println!( + "env.{name} = {}", + if self.secret_env_names.contains(name) { + "" + } else { + value + } + ); } for note in &self.notes { println!("note = {note}"); diff --git a/crates/cli/src/process/prepared.rs b/crates/cli/src/process/prepared.rs index b365ae667..6989c5710 100644 --- a/crates/cli/src/process/prepared.rs +++ b/crates/cli/src/process/prepared.rs @@ -3,6 +3,8 @@ use std::path::PathBuf; +use crate::provider_auth::TransparentProxyCredential; + /// Fully resolved child-process launch plan produced by one agent integration. pub(crate) struct PreparedAgentLaunch { pub(crate) argv: Vec, @@ -10,6 +12,19 @@ pub(crate) struct PreparedAgentLaunch { pub(crate) env: Vec<(String, String)>, pub(crate) temp_dirs: Vec, pub(crate) notes: Vec, + pub(crate) proxy_credential: TransparentProxyCredential, + pub(crate) secret_env_names: Vec, +} + +impl PreparedAgentLaunch { + pub(crate) fn set_secret_env(&mut self, name: impl Into, value: impl Into) { + let name = name.into(); + self.env.retain(|(existing, _)| existing != &name); + self.env.push((name.clone(), value.into())); + if !self.secret_env_names.contains(&name) { + self.secret_env_names.push(name); + } + } } pub(crate) fn insert_after_host( diff --git a/crates/cli/src/provider_auth.rs b/crates/cli/src/provider_auth.rs new file mode 100644 index 000000000..8bffa95a7 --- /dev/null +++ b/crates/cli/src/provider_auth.rs @@ -0,0 +1,168 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Provider-credential provenance for CLI gateway requests. +//! +//! Transparent wrappers authenticate to their invocation-owned loopback gateway with a random +//! credential. The gateway consumes that credential before request intercepts run, preserving a +//! clear boundary between wrapper authentication and credentials intended for an upstream model. + +use std::sync::Arc; + +use axum::http::{HeaderMap, HeaderValue, header}; +use ring::rand::{SecureRandom, SystemRandom}; +use subtle::ConstantTimeEq; + +use crate::error::CliError; + +pub(crate) const TRANSPARENT_PROXY_CREDENTIAL_ENV: &str = "NEMO_RELAY_PROXY_CREDENTIAL"; +pub(crate) const TRANSPARENT_PROXY_CREDENTIAL_HEADER: &str = "x-nemo-relay-proxy-token"; + +const TOKEN_BYTES: usize = 32; +const PROVIDER_API_KEY_HEADERS: [&str; 3] = ["x-api-key", "api-key", "anthropic-api-key"]; + +#[derive(Clone)] +pub(crate) struct TransparentProxyCredential(Arc); + +impl TransparentProxyCredential { + pub(crate) fn generate() -> Result { + let mut bytes = [0_u8; TOKEN_BYTES]; + SystemRandom::new().fill(&mut bytes).map_err(|_| { + CliError::Launch("failed to generate transparent proxy credential".into()) + })?; + let encoded = bytes + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + Ok(Self(format!("nrp_{encoded}").into())) + } + + pub(crate) fn expose(&self) -> &str { + &self.0 + } + + /// Verify and consume this invocation's proxy credential without disturbing an independent + /// provider credential carried by a dedicated header. + pub(crate) fn consume( + &self, + headers: &mut HeaderMap, + ) -> Result { + let mut authenticated = false; + if let Some(value) = headers.get(TRANSPARENT_PROXY_CREDENTIAL_HEADER) { + if !self.matches_raw(value) { + return Err(CliError::Unauthorized( + "transparent proxy token did not match this Relay invocation".into(), + )); + } + authenticated = true; + headers.remove(TRANSPARENT_PROXY_CREDENTIAL_HEADER); + } + + if headers + .get(header::AUTHORIZATION) + .is_some_and(|value| self.matches_bearer(value)) + { + authenticated = true; + headers.remove(header::AUTHORIZATION); + } + for name in PROVIDER_API_KEY_HEADERS { + if headers + .get(name) + .is_some_and(|value| self.matches_raw(value)) + { + authenticated = true; + headers.remove(name); + } + } + + if !authenticated { + return Err(CliError::Unauthorized( + "request did not present this transparent Relay invocation's proxy token".into(), + )); + } + Ok(SourceCredentialDisposition::RelayProxyCredential { + provider_credential_present: has_provider_credential(headers), + }) + } + + fn matches_raw(&self, value: &HeaderValue) -> bool { + value + .to_str() + .ok() + .is_some_and(|value| constant_time_eq(value.as_bytes(), self.0.as_bytes())) + } + + fn matches_bearer(&self, value: &HeaderValue) -> bool { + value + .to_str() + .ok() + .and_then(|value| value.strip_prefix("Bearer ")) + .is_some_and(|value| constant_time_eq(value.as_bytes(), self.0.as_bytes())) + } + + #[cfg(test)] + pub(crate) fn from_static(value: &'static str) -> Self { + Self(value.into()) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum SourceCredentialDisposition { + RelayProxyCredential { provider_credential_present: bool }, + ProviderCredential, + Absent, +} + +impl SourceCredentialDisposition { + pub(crate) fn from_provider_headers(headers: &HeaderMap) -> Self { + if has_provider_credential(headers) { + Self::ProviderCredential + } else { + Self::Absent + } + } + + pub(crate) const fn provider_credential_present(self) -> bool { + match self { + Self::RelayProxyCredential { + provider_credential_present, + } => provider_credential_present, + Self::ProviderCredential => true, + Self::Absent => false, + } + } + + pub(crate) fn after_source_normalization(self, headers: &HeaderMap) -> Self { + let provider_credential_present = has_provider_credential(headers); + match self { + Self::RelayProxyCredential { .. } => Self::RelayProxyCredential { + provider_credential_present, + }, + Self::ProviderCredential | Self::Absent => Self::from_provider_headers(headers), + } + } +} + +#[derive(Debug, Clone, Copy)] +pub(crate) struct ProviderRequestAuthorization { + pub(crate) source_credential: SourceCredentialDisposition, + pub(crate) allow_environment_provider_auth: bool, +} + +pub(crate) fn has_provider_credential(headers: &HeaderMap) -> bool { + headers.contains_key(header::AUTHORIZATION) + || PROVIDER_API_KEY_HEADERS + .iter() + .any(|name| headers.contains_key(*name)) +} + +pub(crate) fn remove_provider_credentials(headers: &mut HeaderMap) { + headers.remove(header::AUTHORIZATION); + for name in PROVIDER_API_KEY_HEADERS { + headers.remove(name); + } +} + +fn constant_time_eq(left: &[u8], right: &[u8]) -> bool { + left.ct_eq(right).into() +} diff --git a/crates/cli/src/server/mod.rs b/crates/cli/src/server/mod.rs index 2303a2de1..2a3a36824 100644 --- a/crates/cli/src/server/mod.rs +++ b/crates/cli/src/server/mod.rs @@ -57,6 +57,8 @@ pub(crate) struct AppState { pub(crate) bootstrap_fingerprint: Option, pub(crate) bootstrap_challenge_key: Option, pub(crate) require_provider_client_token: bool, + pub(crate) transparent_proxy_credential: + Option, pub(crate) http: Client, pub(crate) sessions: SessionManager, pub(crate) last_activity: Arc>, @@ -78,6 +80,7 @@ struct BootstrapServeOptions<'a> { identity: Option, ready_file: Option<&'a Path>, shutdown_token: Option, + transparent_proxy_credential: Option, } /// Binds the configured address and activates enabled dynamic plugins before serving. @@ -103,6 +106,7 @@ pub(crate) async fn serve_with_dynamic( identity: managed_bootstrap, ready_file, shutdown_token: bootstrap_shutdown_token, + ..BootstrapServeOptions::default() }, ) .await @@ -219,6 +223,7 @@ pub(crate) async fn serve_transparent_listener_with_dynamic( config: GatewayConfig, dynamic_plugins: Vec, bootstrap_fingerprint: String, + transparent_proxy_credential: crate::provider_auth::TransparentProxyCredential, shutdown: Option>, ) -> Result<(), CliError> { serve_listener_with_dynamic_inner( @@ -228,6 +233,7 @@ pub(crate) async fn serve_transparent_listener_with_dynamic( shutdown.map(ShutdownMode::Receiver), BootstrapServeOptions { fingerprint: Some(bootstrap_fingerprint), + transparent_proxy_credential: Some(transparent_proxy_credential), ..BootstrapServeOptions::default() }, ) @@ -253,6 +259,7 @@ async fn serve_listener_with_dynamic_inner( identity: managed_bootstrap, ready_file, shutdown_token: bootstrap_shutdown_token, + transparent_proxy_credential, } = bootstrap; let bootstrap_challenge_key = bootstrap_fingerprint .as_ref() @@ -277,6 +284,7 @@ async fn serve_listener_with_dynamic_inner( bootstrap_challenge_key, require_provider_client_token, bootstrap_shutdown, + transparent_proxy_credential, ); state.bootstrap_tls = bootstrap_tls; state.local_address = Some(listener.local_addr()?); @@ -428,7 +436,7 @@ pub(crate) fn router(config: GatewayConfig) -> Router { impl AppState { #[cfg(test)] pub(crate) fn new(config: GatewayConfig) -> Self { - Self::new_with_bootstrap(config, None, None, false, None) + Self::new_with_bootstrap(config, None, None, false, None, None) } fn new_with_bootstrap( @@ -437,6 +445,7 @@ impl AppState { bootstrap_challenge_key: Option, require_provider_client_token: bool, bootstrap_shutdown: Option, + transparent_proxy_credential: Option, ) -> Self { let sessions = SessionManager::new(config.clone()); sessions.start_idle_sweeper(); @@ -451,6 +460,7 @@ impl AppState { bootstrap_fingerprint, bootstrap_challenge_key, require_provider_client_token, + transparent_proxy_credential, http, sessions, last_activity: Arc::new(Mutex::new(Instant::now())), @@ -467,21 +477,38 @@ impl AppState { } } - /// Foreground gateways may supply provider credentials from their own environment for simple - /// local proxy use. Managed plugin sidecars are long-lived loopback services, so callers must - /// present the private per-user proof installed into their provider configuration before Relay - /// can spend a forwarded credential on their behalf. - pub(crate) fn allows_environment_provider_auth(&self, headers: &HeaderMap) -> bool { - if !self.require_provider_client_token { - return true; + /// Authenticate an invocation-owned transparent client before interceptors can rewrite its + /// route. Foreground gateways retain their existing provider-credential behavior; managed + /// sidecars still require their stable client proof before ambient provider credentials may be + /// used. + pub(crate) fn authorize_provider_request( + &self, + headers: &mut HeaderMap, + ) -> Result { + if let Some(proxy) = &self.transparent_proxy_credential { + return Ok(crate::provider_auth::ProviderRequestAuthorization { + source_credential: proxy.consume(headers)?, + allow_environment_provider_auth: true, + }); } - let Some(key) = self.bootstrap_challenge_key.as_ref() else { - return false; + let allow_environment_provider_auth = if !self.require_provider_client_token { + true + } else { + self.bootstrap_challenge_key + .as_ref() + .and_then(|key| { + headers + .get(BOOTSTRAP_CLIENT_TOKEN_HEADER) + .and_then(|value| value.to_str().ok()) + .map(|token| key.verify_client_token(token)) + }) + .unwrap_or(false) }; - headers - .get(BOOTSTRAP_CLIENT_TOKEN_HEADER) - .and_then(|value| value.to_str().ok()) - .is_some_and(|token| key.verify_client_token(token)) + Ok(crate::provider_auth::ProviderRequestAuthorization { + source_credential: + crate::provider_auth::SourceCredentialDisposition::from_provider_headers(headers), + allow_environment_provider_auth, + }) } } diff --git a/crates/cli/tests/coverage/agents/hermes_tests.rs b/crates/cli/tests/coverage/agents/hermes_tests.rs index 2d8e59b3f..eae5ee906 100644 --- a/crates/cli/tests/coverage/agents/hermes_tests.rs +++ b/crates/cli/tests/coverage/agents/hermes_tests.rs @@ -1158,6 +1158,14 @@ fn transparent_config_suppresses_only_the_managed_mcp_and_uses_one_relay_hook() patched["mcp_servers"]["filesystem"]["command"], json!("fs-mcp") ); + assert_eq!(patched["model"]["provider"], json!("custom")); + assert_eq!( + patched["model"]["api_key"], + json!(format!( + "${{{}}}", + crate::provider_auth::TRANSPARENT_PROXY_CREDENTIAL_ENV + )) + ); for event in CodingAgent::Hermes.hook_events() { let groups = patched["hooks"][event].as_array().unwrap(); assert_eq!( diff --git a/crates/cli/tests/coverage/agents/launcher_tests.rs b/crates/cli/tests/coverage/agents/launcher_tests.rs index c3715e8d9..ab1f1c98f 100644 --- a/crates/cli/tests/coverage/agents/launcher_tests.rs +++ b/crates/cli/tests/coverage/agents/launcher_tests.rs @@ -287,7 +287,25 @@ fn prepares_codex_config_overrides() { // When OPENAI_API_KEY is in the environment the gateway substitutes it; // otherwise codex's own auth is forwarded as-is. && arg.contains("requires_openai_auth=true") - && arg.contains("supports_websockets=false")) + && arg.contains("supports_websockets=false") + && arg.contains("env_http_headers") + && arg.contains(crate::provider_auth::TRANSPARENT_PROXY_CREDENTIAL_HEADER) + && arg.contains(crate::provider_auth::TRANSPARENT_PROXY_CREDENTIAL_ENV)) + ); + assert!( + !prepared + .argv + .iter() + .any(|arg| arg.contains(prepared.proxy_credential.expose())) + ); + assert!(prepared.env.contains(&( + crate::provider_auth::TRANSPARENT_PROXY_CREDENTIAL_ENV.into(), + prepared.proxy_credential.expose().into() + ))); + assert!( + prepared + .secret_env_names + .contains(&crate::provider_auth::TRANSPARENT_PROXY_CREDENTIAL_ENV.into()) ); assert!( !prepared @@ -745,6 +763,57 @@ fn prepares_claude_dry_run_without_writing_plugin() { .contains(&("ANTHROPIC_BASE_URL".into(), "http://127.0.0.1:1234".into())) ); assert!(prepared.notes[0].contains("would generate")); + let custom_headers = prepared + .env + .iter() + .find_map(|(name, value)| (name == "ANTHROPIC_CUSTOM_HEADERS").then_some(value)) + .unwrap(); + assert!(custom_headers.starts_with(&format!( + "{}: ", + crate::provider_auth::TRANSPARENT_PROXY_CREDENTIAL_HEADER + ))); + assert!(custom_headers.ends_with(prepared.proxy_credential.expose())); + assert!( + prepared + .secret_env_names + .contains(&"ANTHROPIC_CUSTOM_HEADERS".into()) + ); +} + +#[test] +fn claude_transparent_proxy_header_preserves_existing_custom_headers() { + let _env = EnvScope::set(&[( + "ANTHROPIC_CUSTOM_HEADERS", + Some(std::ffi::OsStr::new("x-existing: preserved")), + )]); + let resolved = ResolvedConfig { + gateway: GatewayConfig::default(), + agents: AgentConfigs::default(), + ..ResolvedConfig::default() + }; + + let prepared = PreparedAgentLaunch::new( + CodingAgent::ClaudeCode, + vec!["claude".into()], + "http://127.0.0.1:1234", + &resolved, + true, + ) + .unwrap(); + let custom_headers = prepared + .env + .iter() + .find_map(|(name, value)| (name == "ANTHROPIC_CUSTOM_HEADERS").then_some(value)) + .unwrap(); + + assert_eq!( + custom_headers, + &format!( + "x-existing: preserved\n{}: {}", + crate::provider_auth::TRANSPARENT_PROXY_CREDENTIAL_HEADER, + prepared.proxy_credential.expose() + ) + ); } #[test] @@ -833,6 +902,14 @@ fn prepares_hermes_hook_environment() { .expect("Hermes overlay path"); let hooks = std::fs::read_to_string(overlay.join("config.yaml")).unwrap(); let hooks: serde_json::Value = serde_yaml::from_str(&hooks).unwrap(); + assert_eq!(hooks["model"]["provider"], json!("custom")); + assert_eq!( + hooks["model"]["api_key"], + json!(format!( + "${{{}}}", + crate::provider_auth::TRANSPARENT_PROXY_CREDENTIAL_ENV + )) + ); assert!(crate::hook_assertions::value_has_command_arguments( &hooks, &[ @@ -1662,6 +1739,10 @@ async fn gateway_failure_terminates_the_agent_and_restores_private_state() { env: Vec::new(), temp_dirs: vec![overlay.clone()], notes: Vec::new(), + proxy_credential: crate::provider_auth::TransparentProxyCredential::from_static( + "test-proxy-token", + ), + secret_env_names: Vec::new(), }; let observed_wrapper_pid_path = wrapper_pid_path.clone(); let observed_descendant_pid_path = descendant_pid_path.clone(); diff --git a/crates/cli/tests/coverage/shared/gateway_tests.rs b/crates/cli/tests/coverage/shared/gateway_tests.rs index a55a5f738..c78425198 100644 --- a/crates/cli/tests/coverage/shared/gateway_tests.rs +++ b/crates/cli/tests/coverage/shared/gateway_tests.rs @@ -91,11 +91,19 @@ async fn prepared_gateway_request_consumes_private_client_proof() { .body(Body::from(r#"{"model":"gpt-test"}"#)) .unwrap(); - let prepared = prepare_gateway_request(&GatewayConfig::default(), request, true) - .await - .unwrap(); + let prepared = prepare_gateway_request( + &GatewayConfig::default(), + request, + crate::provider_auth::ProviderRequestAuthorization { + source_credential: + crate::provider_auth::SourceCredentialDisposition::ProviderCredential, + allow_environment_provider_auth: true, + }, + ) + .await + .unwrap(); - assert!(prepared.allow_environment_provider_auth); + assert!(prepared.authorization.allow_environment_provider_auth); assert!( !prepared .headers @@ -395,7 +403,7 @@ fn internal_dispatch_controls_are_consumed_and_never_forwarded() { ProviderRoute::OpenAiChatCompletions, ); assert_eq!(effective.url, "http://127.0.0.1:9000/v1/responses"); - assert_eq!(effective.route, ProviderRoute::OpenAiResponses); + assert_eq!(effective.target_route, ProviderRoute::OpenAiResponses); assert_eq!(effective.headers.get("x-backend").unwrap(), "selected"); assert!( effective @@ -413,6 +421,128 @@ fn internal_dispatch_controls_are_consumed_and_never_forwarded() { assert!(retry_aware_dispatch(&request)); } +#[test] +fn explicit_keyless_target_drops_source_credentials() { + let mut source_headers = HeaderMap::new(); + source_headers.insert( + header::AUTHORIZATION, + HeaderValue::from_static("Bearer source-provider-key"), + ); + source_headers.insert("x-api-key", HeaderValue::from_static("source-api-key")); + let request = LlmRequest { + headers: Map::from_iter([ + ( + INTERNAL_DISPATCH_URL_HEADER.to_string(), + json!("http://keyless.invalid/v1/chat/completions"), + ), + ( + INTERNAL_DISPATCH_ROUTE_HEADER.to_string(), + json!("openai_chat"), + ), + ]), + content: Value::Null, + }; + + let effective = effective_dispatch_request( + &Bytes::from_static(br#"{"model":"source"}"#), + &source_headers, + Some(&request), + "http://source.invalid/v1/responses", + ProviderRoute::OpenAiResponses, + ); + + assert_eq!( + effective.credential_policy, + TargetCredentialPolicy::ExplicitTarget + ); + assert!(!crate::provider_auth::has_provider_credential( + &effective.headers + )); +} + +#[test] +fn cross_protocol_target_uses_only_binding_owned_authentication() { + let mut source_headers = HeaderMap::new(); + source_headers.insert( + header::AUTHORIZATION, + HeaderValue::from_static("Bearer openai-source-key"), + ); + let request = LlmRequest { + headers: Map::from_iter([ + ( + INTERNAL_DISPATCH_URL_HEADER.to_string(), + json!("http://anthropic-target.invalid/v1/messages"), + ), + ( + INTERNAL_DISPATCH_ROUTE_HEADER.to_string(), + json!("anthropic_messages"), + ), + ("x-api-key".to_string(), json!("anthropic-binding-key")), + ]), + content: Value::Null, + }; + + let effective = effective_dispatch_request( + &Bytes::from_static(br#"{"model":"source"}"#), + &source_headers, + Some(&request), + "http://source.invalid/v1/responses", + ProviderRoute::OpenAiResponses, + ); + + assert_eq!(effective.target_route, ProviderRoute::AnthropicMessages); + assert_eq!( + effective.credential_policy, + TargetCredentialPolicy::ExplicitTarget + ); + assert!(effective.headers.get(header::AUTHORIZATION).is_none()); + assert_eq!( + effective.headers.get("x-api-key").unwrap(), + "anthropic-binding-key" + ); + + let built = inject_provider_auth_with_env( + test_http_client().post("http://anthropic-target.invalid/v1/messages"), + effective.target_route, + &effective.headers, + false, + |_: &str| Some("ambient-key-must-not-win".into()), + ) + .build() + .unwrap(); + assert!(built.headers().get(header::AUTHORIZATION).is_none()); +} + +#[test] +fn same_route_rewrite_without_explicit_dispatch_preserves_source_auth_policy() { + let mut source_headers = HeaderMap::new(); + source_headers.insert( + header::AUTHORIZATION, + HeaderValue::from_static("Bearer source-provider-key"), + ); + let request = LlmRequest { + headers: Map::from_iter([("x-plugin-metadata".to_string(), json!("present"))]), + content: json!({"model": "rewritten"}), + }; + + let effective = effective_dispatch_request( + &Bytes::from_static(br#"{"model":"source"}"#), + &source_headers, + Some(&request), + "http://source.invalid/v1/responses", + ProviderRoute::OpenAiResponses, + ); + + assert_eq!( + effective.credential_policy, + TargetCredentialPolicy::SourceOrEnvironment + ); + assert_eq!( + effective.headers.get(header::AUTHORIZATION).unwrap(), + "Bearer source-provider-key" + ); +} + #[test] fn malformed_dispatch_route_discards_the_override_url() { let original_body = Bytes::from_static(br#"{"model":"original"}"#); @@ -439,7 +569,7 @@ fn malformed_dispatch_route_discards_the_override_url() { ); assert_eq!(effective.url, "http://default.invalid/v1/chat/completions"); - assert_eq!(effective.route, ProviderRoute::OpenAiChatCompletions); + assert_eq!(effective.target_route, ProviderRoute::OpenAiChatCompletions); assert!( effective .headers @@ -474,7 +604,7 @@ fn dispatch_url_without_a_route_remains_supported() { ); assert_eq!(effective.url, "http://127.0.0.1:9000/v1/chat/completions"); - assert_eq!(effective.route, ProviderRoute::OpenAiChatCompletions); + assert_eq!(effective.target_route, ProviderRoute::OpenAiChatCompletions); } #[test] @@ -587,7 +717,10 @@ async fn retry_aware_buffered_body_read_failure_stays_structured() { body_bytes: Bytes::from_static(b"{}"), request_json: json!({}), streaming: false, - allow_environment_provider_auth: false, + authorization: crate::provider_auth::ProviderRequestAuthorization { + source_credential: crate::provider_auth::SourceCredentialDisposition::Absent, + allow_environment_provider_auth: false, + }, }; let upstream_info = Arc::new(Mutex::new(None)); let upstream_error = Arc::new(Mutex::new(None)); @@ -778,7 +911,10 @@ fn build_llm_gateway_start_uses_alignment_identifiers_and_metadata() { body_bytes: axum::body::Bytes::new(), request_json: request_json.clone(), streaming: true, - allow_environment_provider_auth: true, + authorization: crate::provider_auth::ProviderRequestAuthorization { + source_credential: crate::provider_auth::SourceCredentialDisposition::Absent, + allow_environment_provider_auth: true, + }, }; let start = build_llm_gateway_start(&prepared); @@ -809,6 +945,10 @@ fn observable_headers_omit_secrets_and_transport_headers() { let mut headers = HeaderMap::new(); headers.insert("authorization", HeaderValue::from_static("Bearer secret")); headers.insert("x-api-key", HeaderValue::from_static("secret")); + headers.insert( + crate::provider_auth::TRANSPARENT_PROXY_CREDENTIAL_HEADER, + HeaderValue::from_static("proxy-secret"), + ); headers.insert("connection", HeaderValue::from_static("close")); headers.insert("x-request-id", HeaderValue::from_static("req-1")); @@ -817,6 +957,7 @@ fn observable_headers_omit_secrets_and_transport_headers() { assert_eq!(observed.get("x-request-id"), Some(&json!("req-1"))); assert!(!observed.contains_key("authorization")); assert!(!observed.contains_key("x-api-key")); + assert!(!observed.contains_key(crate::provider_auth::TRANSPARENT_PROXY_CREDENTIAL_HEADER)); assert!(!observed.contains_key("connection")); } @@ -894,6 +1035,116 @@ fn preserves_jwt_when_no_replacement_key_available() { assert!(sanitized.get("authorization").is_some()); } +#[test] +fn foreground_gateway_does_not_interpret_agent_specific_placeholder_credentials() { + let mut inbound = HeaderMap::new(); + inbound.insert( + "authorization", + HeaderValue::from_static("Bearer no-key-required"), + ); + let sanitized = strip_replaceable_agent_auth_headers_with_openai_key_state( + &inbound, + ProviderRoute::OpenAiChatCompletions, + true, + ); + assert_eq!( + sanitized.get("authorization").unwrap(), + "Bearer no-key-required" + ); +} + +#[test] +fn generated_transparent_proxy_credentials_are_random_and_high_entropy() { + let first = crate::provider_auth::TransparentProxyCredential::generate().unwrap(); + let second = crate::provider_auth::TransparentProxyCredential::generate().unwrap(); + + assert!(first.expose().starts_with("nrp_")); + assert_eq!(first.expose().len(), 68); + assert_ne!(first.expose(), second.expose()); +} + +#[test] +fn transparent_proxy_dedicated_header_is_consumed_before_plugins() { + let credential = + crate::provider_auth::TransparentProxyCredential::from_static("test-proxy-token"); + let mut inbound = HeaderMap::new(); + inbound.insert( + "authorization", + HeaderValue::from_static("Bearer real-provider-key"), + ); + inbound.insert( + crate::provider_auth::TRANSPARENT_PROXY_CREDENTIAL_HEADER, + HeaderValue::from_static("test-proxy-token"), + ); + + let disposition = credential.consume(&mut inbound).unwrap(); + + assert_eq!( + disposition, + crate::provider_auth::SourceCredentialDisposition::RelayProxyCredential { + provider_credential_present: true + } + ); + assert_eq!( + inbound.get("authorization").unwrap(), + "Bearer real-provider-key" + ); + assert!( + inbound + .get(crate::provider_auth::TRANSPARENT_PROXY_CREDENTIAL_HEADER) + .is_none() + ); +} + +#[test] +fn transparent_proxy_accepts_standard_openai_and_anthropic_auth_shapes() { + let credential = + crate::provider_auth::TransparentProxyCredential::from_static("test-proxy-token"); + for (name, value) in [ + ("authorization", "Bearer test-proxy-token"), + ("x-api-key", "test-proxy-token"), + ("api-key", "test-proxy-token"), + ("anthropic-api-key", "test-proxy-token"), + ] { + let mut headers = HeaderMap::new(); + headers.insert( + HeaderName::from_bytes(name.as_bytes()).unwrap(), + HeaderValue::from_str(value).unwrap(), + ); + + let disposition = credential.consume(&mut headers).unwrap(); + + assert_eq!( + disposition, + crate::provider_auth::SourceCredentialDisposition::RelayProxyCredential { + provider_credential_present: false + }, + "header={name}" + ); + assert!(headers.is_empty(), "header={name}"); + } +} + +#[test] +fn transparent_proxy_rejects_missing_or_foreign_credentials() { + let credential = + crate::provider_auth::TransparentProxyCredential::from_static("test-proxy-token"); + assert!(credential.consume(&mut HeaderMap::new()).is_err()); + + let mut foreign = HeaderMap::new(); + foreign.insert( + crate::provider_auth::TRANSPARENT_PROXY_CREDENTIAL_HEADER, + HeaderValue::from_static("different-run-token"), + ); + assert!(credential.consume(&mut foreign).is_err()); + assert_eq!( + foreign + .get(crate::provider_auth::TRANSPARENT_PROXY_CREDENTIAL_HEADER) + .unwrap(), + "different-run-token" + ); +} + #[test] fn injects_openai_bearer_when_inbound_has_no_auth() { // Foreground gateway mode retains the convenience of supplying its own provider key. @@ -1120,6 +1371,7 @@ async fn passthrough_rejects_unsupported_provider_path_directly() { bootstrap_fingerprint: None, bootstrap_challenge_key: None, require_provider_client_token: false, + transparent_proxy_credential: None, http: test_http_client(), sessions: SessionManager::new(config), last_activity: std::sync::Arc::new(std::sync::Mutex::new(std::time::Instant::now())), @@ -1156,6 +1408,7 @@ async fn models_rejects_non_get_requests_directly() { bootstrap_fingerprint: None, bootstrap_challenge_key: None, require_provider_client_token: false, + transparent_proxy_credential: None, http: test_http_client(), sessions: SessionManager::new(config), last_activity: std::sync::Arc::new(std::sync::Mutex::new(std::time::Instant::now())), diff --git a/crates/cli/tests/coverage/shared/installer_tests.rs b/crates/cli/tests/coverage/shared/installer_tests.rs index e3908c6c7..8ef4713e4 100644 --- a/crates/cli/tests/coverage/shared/installer_tests.rs +++ b/crates/cli/tests/coverage/shared/installer_tests.rs @@ -62,6 +62,7 @@ async fn transparent_hook_delivery_authenticates_the_wrapper_gateway() { config, Vec::new(), fingerprint.clone(), + crate::provider_auth::TransparentProxyCredential::generate().unwrap(), Some(shutdown_rx), )); tokio::time::timeout(Duration::from_secs(5), async { diff --git a/crates/cli/tests/coverage/shared/mcp_tests.rs b/crates/cli/tests/coverage/shared/mcp_tests.rs index bf84ab938..329f025c4 100644 --- a/crates/cli/tests/coverage/shared/mcp_tests.rs +++ b/crates/cli/tests/coverage/shared/mcp_tests.rs @@ -421,6 +421,7 @@ async fn borrowed_transparent_gateway_is_authenticated_and_monitored() { config, Vec::new(), fingerprint.clone(), + crate::provider_auth::TransparentProxyCredential::generate().unwrap(), Some(shutdown_rx), )); tokio::time::timeout(Duration::from_secs(5), async { diff --git a/crates/cli/tests/coverage/shared/server_tests.rs b/crates/cli/tests/coverage/shared/server_tests.rs index b13321ce1..d51117ee2 100644 --- a/crates/cli/tests/coverage/shared/server_tests.rs +++ b/crates/cli/tests/coverage/shared/server_tests.rs @@ -394,6 +394,7 @@ async fn healthz_rejects_a_different_persistent_gateway_fingerprint() { Some(BootstrapChallengeKey::from_bytes(b"test challenge key")), false, None, + None, )); let response = app .oneshot( @@ -426,22 +427,43 @@ async fn managed_sidecar_requires_private_client_proof_for_forwarded_credentials Some(key.clone()), true, None, + None, ); let mut headers = HeaderMap::new(); - assert!(!state.allows_environment_provider_auth(&headers)); + assert!( + !state + .authorize_provider_request(&mut headers) + .unwrap() + .allow_environment_provider_auth + ); headers.insert( crate::configuration::BOOTSTRAP_CLIENT_TOKEN_HEADER, HeaderValue::from_static("hmac-sha256:wrong"), ); - assert!(!state.allows_environment_provider_auth(&headers)); + assert!( + !state + .authorize_provider_request(&mut headers) + .unwrap() + .allow_environment_provider_auth + ); headers.insert( crate::configuration::BOOTSTRAP_CLIENT_TOKEN_HEADER, HeaderValue::from_str(&key.client_token()).unwrap(), ); - assert!(state.allows_environment_provider_auth(&headers)); + assert!( + state + .authorize_provider_request(&mut headers) + .unwrap() + .allow_environment_provider_auth + ); let foreground = AppState::new(test_config()); - assert!(foreground.allows_environment_provider_auth(&HeaderMap::new())); + assert!( + foreground + .authorize_provider_request(&mut HeaderMap::new()) + .unwrap() + .allow_environment_provider_auth + ); let transparent = AppState::new_with_bootstrap( test_config(), @@ -449,8 +471,26 @@ async fn managed_sidecar_requires_private_client_proof_for_forwarded_credentials Some(BootstrapChallengeKey::from_bytes(b"test challenge key")), false, None, + Some(crate::provider_auth::TransparentProxyCredential::from_static("test-proxy-token")), + ); + assert!( + transparent + .authorize_provider_request(&mut HeaderMap::new()) + .is_err() + ); + let mut transparent_headers = HeaderMap::new(); + transparent_headers.insert( + crate::provider_auth::TRANSPARENT_PROXY_CREDENTIAL_HEADER, + HeaderValue::from_static("test-proxy-token"), + ); + let authorization = transparent + .authorize_provider_request(&mut transparent_headers) + .unwrap(); + assert!(authorization.allow_environment_provider_auth); + assert!( + !transparent_headers + .contains_key(crate::provider_auth::TRANSPARENT_PROXY_CREDENTIAL_HEADER) ); - assert!(transparent.allows_environment_provider_auth(&HeaderMap::new())); } #[tokio::test] @@ -462,6 +502,7 @@ async fn healthz_only_refreshes_idle_activity_for_an_authenticated_heartbeat() { Some(challenge_key.clone()), true, None, + None, ); let activity = state.last_activity.clone(); let baseline = std::time::Instant::now() - Duration::from_secs(30); @@ -529,6 +570,7 @@ async fn bootstrap_shutdown_requires_the_private_owner_token() { token: "private-token".into(), sender: Arc::new(std::sync::Mutex::new(Some(sender))), }), + None, )); let rejected = app .clone() @@ -2363,6 +2405,91 @@ async fn gateway_forwards_openai_json_without_rewriting_payload() { assert_eq!(body["connection"], Value::Null); } +#[tokio::test] +async fn transparent_gateway_requires_and_consumes_its_invocation_token() { + let upstream = spawn_upstream(false).await; + let mut config = test_config(); + config.openai_base_url = upstream.url(); + let app = router_with_state(AppState::new_with_bootstrap( + config, + Some("transparent-fingerprint".into()), + None, + false, + None, + Some( + crate::provider_auth::TransparentProxyCredential::from_static( + "current-invocation-token", + ), + ), + )); + let body = || { + Body::from( + json!({ + "model": "gpt-test", + "messages": [{ "role": "user", "content": "hello" }] + }) + .to_string(), + ) + }; + + let missing = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/chat/completions") + .header("content-type", "application/json") + .body(body()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(missing.status(), StatusCode::UNAUTHORIZED); + + let foreign = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/chat/completions") + .header("content-type", "application/json") + .header( + crate::provider_auth::TRANSPARENT_PROXY_CREDENTIAL_HEADER, + "different-invocation-token", + ) + .body(body()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(foreign.status(), StatusCode::UNAUTHORIZED); + + let accepted = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/chat/completions") + .header("content-type", "application/json") + .header( + crate::provider_auth::TRANSPARENT_PROXY_CREDENTIAL_HEADER, + "current-invocation-token", + ) + .header("authorization", "Bearer upstream-provider-key") + .body(body()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(accepted.status(), StatusCode::OK); + let bytes = accepted.into_body().collect().await.unwrap().to_bytes(); + let payload: Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!( + payload["authorization"], + json!("Bearer upstream-provider-key") + ); + assert_eq!(payload["transparent_proxy_token"], Value::Null); +} + #[tokio::test] async fn gateway_accepts_codex_responses_path() { let upstream = spawn_upstream(false).await; @@ -2879,6 +3006,9 @@ async fn spawn_upstream(streaming: bool) -> TestServer { "x_test_intercept": headers .get("x-test-intercept") .and_then(|value| value.to_str().ok()), + "transparent_proxy_token": headers + .get(crate::provider_auth::TRANSPARENT_PROXY_CREDENTIAL_HEADER) + .and_then(|value| value.to_str().ok()), "connection": headers .get(header::CONNECTION) .and_then(|value| value.to_str().ok()) From ed1125b185ef35105e27e43b230ab9c296665bfb Mon Sep 17 00:00:00 2001 From: Bryan Bednarski Date: Thu, 16 Jul 2026 07:30:31 -0600 Subject: [PATCH 2/4] fix(cli): replace stale Claude proxy headers Signed-off-by: Bryan Bednarski --- crates/cli/src/agents/claude/launch.rs | 18 ++++++- .../tests/coverage/agents/launcher_tests.rs | 48 +++++++++++++++++++ 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/crates/cli/src/agents/claude/launch.rs b/crates/cli/src/agents/claude/launch.rs index 38e616a25..4147c7ccb 100644 --- a/crates/cli/src/agents/claude/launch.rs +++ b/crates/cli/src/agents/claude/launch.rs @@ -26,7 +26,7 @@ pub(crate) fn prepare( .filter(|value| !value.trim().is_empty()) .map_or_else( || proxy_header.clone(), - |value| format!("{value}\n{proxy_header}"), + |value| replace_custom_header(&value, &proxy_header), ); launch.set_secret_env("ANTHROPIC_CUSTOM_HEADERS", custom_headers); if dry_run { @@ -94,6 +94,22 @@ pub(crate) fn prepare( Ok(()) } +fn replace_custom_header(existing: &str, replacement: &str) -> String { + let replacement_name = replacement + .split_once(':') + .map_or(replacement, |(name, _)| name) + .trim(); + existing + .lines() + .filter(|line| { + line.split_once(':') + .is_none_or(|(name, _)| !name.trim().eq_ignore_ascii_case(replacement_name)) + }) + .chain(std::iter::once(replacement)) + .collect::>() + .join("\n") +} + pub(crate) fn settings_overlay( argv: &[String], host_index: usize, diff --git a/crates/cli/tests/coverage/agents/launcher_tests.rs b/crates/cli/tests/coverage/agents/launcher_tests.rs index ab1f1c98f..b4bc5a2ce 100644 --- a/crates/cli/tests/coverage/agents/launcher_tests.rs +++ b/crates/cli/tests/coverage/agents/launcher_tests.rs @@ -816,6 +816,54 @@ fn claude_transparent_proxy_header_preserves_existing_custom_headers() { ); } +#[test] +fn claude_transparent_proxy_header_replaces_case_insensitive_existing_entries() { + let _env = EnvScope::set(&[( + "ANTHROPIC_CUSTOM_HEADERS", + Some(std::ffi::OsStr::new( + "X-NEMO-RELAY-PROXY-TOKEN: stale-first\nx-existing: preserved\nx-NeMo-ReLaY-PrOxY-ToKeN : stale-second", + )), + )]); + let resolved = ResolvedConfig { + gateway: GatewayConfig::default(), + agents: AgentConfigs::default(), + ..ResolvedConfig::default() + }; + + let prepared = PreparedAgentLaunch::new( + CodingAgent::ClaudeCode, + vec!["claude".into()], + "http://127.0.0.1:1234", + &resolved, + true, + ) + .unwrap(); + let custom_headers = prepared + .env + .iter() + .find_map(|(name, value)| (name == "ANTHROPIC_CUSTOM_HEADERS").then_some(value)) + .unwrap(); + + assert_eq!( + custom_headers, + &format!( + "x-existing: preserved\n{}: {}", + crate::provider_auth::TRANSPARENT_PROXY_CREDENTIAL_HEADER, + prepared.proxy_credential.expose() + ) + ); + assert_eq!( + custom_headers + .lines() + .filter(|line| line.split_once(':').is_some_and(|(name, _)| { + name.trim() + .eq_ignore_ascii_case(crate::provider_auth::TRANSPARENT_PROXY_CREDENTIAL_HEADER) + })) + .count(), + 1 + ); +} + #[test] fn prepares_claude_dry_inserts_plugin_dir_after_authoritative_agent_executable() { let resolved = ResolvedConfig { From d2e5e1dc81c1045d56e89c472e1957f4171bde91 Mon Sep 17 00:00:00 2001 From: Bryan Bednarski Date: Thu, 16 Jul 2026 07:37:43 -0600 Subject: [PATCH 3/4] test(cli): isolate Claude custom header environment Signed-off-by: Bryan Bednarski --- crates/cli/tests/coverage/agents/launcher_tests.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/cli/tests/coverage/agents/launcher_tests.rs b/crates/cli/tests/coverage/agents/launcher_tests.rs index b4bc5a2ce..d678892a5 100644 --- a/crates/cli/tests/coverage/agents/launcher_tests.rs +++ b/crates/cli/tests/coverage/agents/launcher_tests.rs @@ -741,6 +741,7 @@ async fn wrapped_agent_version_probe_runs_through_the_wrapper() { #[test] fn prepares_claude_dry_run_without_writing_plugin() { + let _env = EnvScope::set(&[("ANTHROPIC_CUSTOM_HEADERS", None)]); let resolved = ResolvedConfig { gateway: GatewayConfig::default(), agents: AgentConfigs::default(), From 5f3780dd7cb8a4e8431ce16db1e41488b5458ff9 Mon Sep 17 00:00:00 2001 From: Bryan Bednarski Date: Thu, 16 Jul 2026 07:41:04 -0600 Subject: [PATCH 4/4] test(cli): assert transparent proxy unauthorized response Signed-off-by: Bryan Bednarski --- crates/cli/tests/coverage/shared/gateway_tests.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/crates/cli/tests/coverage/shared/gateway_tests.rs b/crates/cli/tests/coverage/shared/gateway_tests.rs index c78425198..ed8d4851c 100644 --- a/crates/cli/tests/coverage/shared/gateway_tests.rs +++ b/crates/cli/tests/coverage/shared/gateway_tests.rs @@ -9,6 +9,7 @@ use crate::sessions::{LlmGatewayStart, SessionManager}; use axum::body::Body; use axum::extract::State; use axum::http::{HeaderMap, HeaderValue, Method, Request, StatusCode, header}; +use axum::response::IntoResponse; use http_body_util::BodyExt; use reqwest::Client; use serde_json::Map; @@ -1129,14 +1130,21 @@ fn transparent_proxy_accepts_standard_openai_and_anthropic_auth_shapes() { fn transparent_proxy_rejects_missing_or_foreign_credentials() { let credential = crate::provider_auth::TransparentProxyCredential::from_static("test-proxy-token"); - assert!(credential.consume(&mut HeaderMap::new()).is_err()); + let missing = credential.consume(&mut HeaderMap::new()).unwrap_err(); + assert!(matches!(&missing, CliError::Unauthorized(_))); + assert_eq!(missing.into_response().status(), StatusCode::UNAUTHORIZED); let mut foreign = HeaderMap::new(); foreign.insert( crate::provider_auth::TRANSPARENT_PROXY_CREDENTIAL_HEADER, HeaderValue::from_static("different-run-token"), ); - assert!(credential.consume(&mut foreign).is_err()); + let foreign_error = credential.consume(&mut foreign).unwrap_err(); + assert!(matches!(&foreign_error, CliError::Unauthorized(_))); + assert_eq!( + foreign_error.into_response().status(), + StatusCode::UNAUTHORIZED + ); assert_eq!( foreign .get(crate::provider_auth::TRANSPARENT_PROXY_CREDENTIAL_HEADER)