use std::collections::HashMap; use std::sync::Arc; use anyhow::{Context as _, Result}; use futures::StreamExt as _; use galaxy_graphql::client::RequestOptions; use instant::Duration; use parking_lot::{Mutex, RwLock}; use warp_server_auth::auth_state::AuthState; use warp_server_auth::credentials::AuthToken; use crate::auth::{AuthEvent, AuthSession, UserUid}; /// Header key for the ambient agent workload token attached to authenticated requests. pub const AMBIENT_WORKLOAD_TOKEN_HEADER: &str = "X-Warp-Ambient-Workload-Token"; /// Header key for the cloud agent task ID attached to ambient-agent requests. pub const CLOUD_AGENT_ID_HEADER: &str = "X-Warp-Cloud-Agent-ID"; /// Header used to communicate the source of an agent run. pub const AGENT_SOURCE_HEADER: &str = "X-Oz-Api-Source"; /// Header used to route agent-mode eval requests to a selected eval user. pub const EVAL_USER_ID_HEADER: &str = "X-Eval-User-ID"; /// IDs in the staging database that were created specifically for evals. /// /// Keep this list in sync with `script/populate_agent_mode_eval_user.sql` in warp-server. #[cfg(feature = "agent_mode_evals")] const EVAL_USER_IDS: [i32; 11] = [ 2162, 2164, 2165, 2166, 2167, 2168, 2169, 2172, 2173, 2174, 2175, ]; /// Duration for which an ambient agent workload token is valid. const AMBIENT_WORKLOAD_TOKEN_DURATION: Duration = Duration::from_secs(3 * 60 * 60); /// Selects whether a contextual header is inherited, set, or omitted for one request. #[derive(Clone, Debug, PartialEq, Eq)] pub enum HeaderOverride { Inherit, Set(T), Omit, } /// Describes the request-local ambient agent headers that are safe to vary by endpoint. #[derive(Clone, Debug, PartialEq, Eq)] pub struct AmbientHeaderPolicy { pub workload_token: HeaderOverride, pub cloud_agent_id: HeaderOverride, pub agent_source: HeaderOverride, } impl AmbientHeaderPolicy { /// Inherits every ambient agent contextual header configured on the client. pub fn inherit_all() -> Self { Self { workload_token: HeaderOverride::Inherit, cloud_agent_id: HeaderOverride::Inherit, agent_source: HeaderOverride::Inherit, } } /// Replaces only the cloud-agent task identifier for one task-scoped request. pub fn for_task(task_id: impl Into) -> Self { Self { cloud_agent_id: HeaderOverride::Set(task_id.into()), ..Self::inherit_all() } } /// Includes workload-token context without cloud-agent or source context. pub fn workload_only() -> Self { Self { workload_token: HeaderOverride::Inherit, cloud_agent_id: HeaderOverride::Omit, agent_source: HeaderOverride::Omit, } } /// Omits all ambient agent contextual headers for a request. pub fn omit_all() -> Self { Self { workload_token: HeaderOverride::Omit, cloud_agent_id: HeaderOverride::Omit, agent_source: HeaderOverride::Omit, } } } impl Default for AmbientHeaderPolicy { fn default() -> Self { Self::inherit_all() } } /// Provides GraphQL path routing that applies independently of authentication. #[derive(Clone, Debug, Default)] pub struct GraphqlRoutingConfig { pub path_prefix: Option, } /// Provides headers added only to session-authenticated GraphQL operations. #[derive(Clone, Debug, Default)] pub struct AuthenticatedGraphqlConfig { pub headers: HashMap, } /// Owns shared transport, authentication, and authenticated request decoration. pub struct BaseClient { client: Arc, auth_state: Arc, event_sender: async_channel::Sender, auth_session: Arc, ambient_workload_token: Arc>>, ambient_agent_task_id: Arc>>, agent_source: Option, graphql_routing: GraphqlRoutingConfig, authenticated_graphql: AuthenticatedGraphqlConfig, iap_token_provider: Option>, #[cfg(feature = "agent_mode_evals")] eval_user_id: Option, } impl BaseClient { pub fn new( client: Arc, auth_state: Arc, event_sender: async_channel::Sender, agent_source: Option, graphql_routing: GraphqlRoutingConfig, mut authenticated_graphql: AuthenticatedGraphqlConfig, iap_token_provider: Option>, ) -> Self { authenticated_graphql.headers.retain(|name, _| { if Self::is_reserved_authenticated_graphql_header(name) { log::warn!("Ignoring reserved authenticated GraphQL header configuration: {name}"); false } else { true } }); // We generate one random user ID per client so evals can run in parallel. #[cfg(feature = "agent_mode_evals")] let eval_user_id = { use rand::Rng as _; Some(EVAL_USER_IDS[rand::thread_rng().gen_range(0..EVAL_USER_IDS.len())]) }; #[cfg(feature = "agent_mode_evals")] if let Some(eval_user_id) = eval_user_id { authenticated_graphql .headers .insert(EVAL_USER_ID_HEADER.to_string(), eval_user_id.to_string()); } let auth_session = Arc::new(AuthSession::new( client.clone(), auth_state.clone(), event_sender.clone(), )); Self { client, auth_state, event_sender, auth_session, ambient_workload_token: Arc::new(Mutex::new(None)), ambient_agent_task_id: Arc::new(RwLock::new(None)), agent_source, graphql_routing, authenticated_graphql, iap_token_provider, #[cfg(feature = "agent_mode_evals")] eval_user_id, } } /// Returns whether authenticated GraphQL decoration would override BaseClient-owned headers. fn is_reserved_authenticated_graphql_header(name: &str) -> bool { #[cfg(feature = "agent_mode_evals")] if name.eq_ignore_ascii_case(EVAL_USER_ID_HEADER) { return true; } [ http::header::AUTHORIZATION.as_str(), http::header::CONTENT_TYPE.as_str(), http::header::CONTENT_LENGTH.as_str(), http_client::iap::IAP_PROXY_AUTH_HEADER, AMBIENT_WORKLOAD_TOKEN_HEADER, CLOUD_AGENT_ID_HEADER, AGENT_SOURCE_HEADER, ] .iter() .any(|reserved| name.eq_ignore_ascii_case(reserved)) } /// Returns the shared HTTP client for request construction. pub fn http_client(&self) -> &http_client::Client { self.client.as_ref() } /// Returns an owned handle to the shared HTTP client for GraphQL operations. pub fn owned_http_client(&self) -> Arc { self.client.clone() } pub fn auth_session(&self) -> Arc { self.auth_session.clone() } pub fn anonymous_id(&self) -> String { self.auth_state.anonymous_id() } pub fn user_id(&self) -> Option { self.auth_state.user_id() } /// Returns the eval user selected for this client, if eval routing is enabled. pub fn eval_user_id(&self) -> Option { #[cfg(feature = "agent_mode_evals")] { self.eval_user_id } #[cfg(not(feature = "agent_mode_evals"))] { None } } pub fn access_token_ignoring_validity(&self) -> Option { self.auth_state.get_access_token_ignoring_validity() } pub fn allowed_to_refresh_token(&self) -> bool { self.auth_session.allowed_to_refresh_token() } pub async fn get_or_refresh_access_token(&self) -> Result { self.auth_session.get_or_refresh_access_token().await } /// Returns a sender for asynchronous work that emits auth events without borrowing this client. pub fn event_sender(&self) -> async_channel::Sender { self.event_sender.clone() } /// Sends an auth event from synchronous client-owned response handling. pub fn send_auth_event( &self, event: AuthEvent, ) -> Result<(), async_channel::TrySendError> { self.event_sender.try_send(event) } pub fn is_auth_refresh_allowed(&self) -> bool { self.allowed_to_refresh_token() } /// Sets the default cloud-agent identifier inherited by subsequent requests. pub fn set_ambient_agent_task_id(&self, task_id: Option) { *self.ambient_agent_task_id.write() = task_id; } /// Returns an ambient agent workload token when the current runtime can issue one. pub async fn get_or_create_ambient_workload_token(&self) -> Result> { if cfg!(target_family = "wasm") { return Ok(None); } { let cached = self.ambient_workload_token.lock(); if let Some(token) = cached.as_ref() { let is_valid = token.expires_at.is_none_or(|expires_at| { chrono::Utc::now() + chrono::Duration::minutes(5) < expires_at }); if is_valid { return Ok(Some(token.token.clone())); } } } let workload_token = match galaxy_isolation_platform::issue_workload_token(Some( AMBIENT_WORKLOAD_TOKEN_DURATION, )) .await { Ok(token) => token, Err(galaxy_isolation_platform::IsolationPlatformError::NoIsolationPlatformDetected) => { return Ok(None); } Err(error) => return Err(error.into()), }; let token = workload_token.token.clone(); *self.ambient_workload_token.lock() = Some(workload_token); Ok(Some(token)) } /// Resolves request-local ambient agent policy into wire headers. pub async fn ambient_headers( &self, policy: AmbientHeaderPolicy, ) -> Result> { let workload_token = match policy.workload_token { HeaderOverride::Inherit => self .get_or_create_ambient_workload_token() .await .context("Failed to get ambient agent workload token")?, HeaderOverride::Set(token) => Some(token), HeaderOverride::Omit => None, }; let cloud_agent_id = match policy.cloud_agent_id { HeaderOverride::Inherit => self.ambient_agent_task_id.read().clone(), HeaderOverride::Set(task_id) => Some(task_id), HeaderOverride::Omit => None, }; let agent_source = match policy.agent_source { HeaderOverride::Inherit => self.agent_source.clone(), HeaderOverride::Set(source) => Some(source), HeaderOverride::Omit => None, }; Ok(workload_token .map(|token| (AMBIENT_WORKLOAD_TOKEN_HEADER.to_string(), token)) .into_iter() .chain(cloud_agent_id.map(|id| (CLOUD_AGENT_ID_HEADER.to_string(), id))) .chain(agent_source.map(|source| (AGENT_SOURCE_HEADER.to_string(), source))) .collect()) } /// Returns GraphQL options for bootstrap or explicit-token operations. pub fn graphql_request_options_with_token(&self, auth_token: Option) -> RequestOptions { RequestOptions { auth_token, path_prefix: self.graphql_routing.path_prefix.clone(), ..RequestOptions::default() } } /// Returns GraphQL options for a session-authenticated operation. pub async fn graphql_request_options( &self, timeout: Option, ) -> Result { let auth_token = self .get_or_refresh_access_token() .await .context("Failed to get access token for GraphQL request")?; let mut options = self.graphql_request_options_with_token(auth_token.bearer_token()); options.timeout = timeout; options.headers = self.authenticated_graphql.headers.clone(); options.headers.extend( self.ambient_headers(AmbientHeaderPolicy::inherit_all()) .await?, ); Ok(options) } /// Notifies the application when an enabled IAP-backed request receives an IAP challenge. pub fn observe_iap_challenge(&self, response: &http_client::Response) -> bool { if self.iap_token_provider.is_none() || !http_client::iap::is_iap_challenge(response.status(), response.headers()) { return false; } log::warn!( "Received IAP challenge (status {}); notifying IapManager", response.status() ); if let Err(error) = self.send_auth_event(AuthEvent::IapChallengeReceived) { log::warn!("Failed to enqueue IapChallengeReceived event: {error}"); } true } /// Wraps an eventsource stream so IAP challenges notify the application without changing the /// original stream result or reconnecting it. #[allow(clippy::result_large_err)] pub fn wrap_eventsource_with_iap_detection( &self, stream: http_client::EventSourceStream, ) -> http_client::EventSourceStream { if self.iap_token_provider.is_none() { return stream; } let event_sender = self.event_sender(); let wrapped = stream.map(move |event| { if let Err(reqwest_eventsource::Error::InvalidStatusCode(status, ref response)) = event && http_client::iap::is_iap_challenge(status, response.headers()) { log::warn!( "Received IAP challenge on eventsource (status {status}); notifying IapManager" ); if let Err(error) = event_sender.try_send(AuthEvent::IapChallengeReceived) { log::warn!( "Failed to enqueue IapChallengeReceived event from eventsource: {error}" ); } } event }); cfg_if::cfg_if! { if #[cfg(target_family = "wasm")] { wrapped.boxed_local() } else { wrapped.boxed() } } } /// Inspects a WebSocket handshake error for an IAP challenge and notifies the application. #[cfg(not(target_family = "wasm"))] pub fn report_ws_iap_challenge(&self, error: &anyhow::Error) { if self.iap_token_provider.is_none() || !crate::iap::ws_connect_is_iap_challenge(error) { return; } log::warn!("Received IAP challenge on websocket handshake; notifying IapManager"); if let Err(error) = self.send_auth_event(AuthEvent::IapChallengeReceived) { log::warn!("Failed to enqueue IapChallengeReceived: {error}"); } } #[cfg(target_family = "wasm")] pub fn report_ws_iap_challenge(&self, _error: &anyhow::Error) {} /// Returns the current IAP proxy authorization header for transports outside the HTTP client. pub fn iap_proxy_auth_header(&self) -> Option<(&'static str, String)> { self.iap_token_provider .as_ref()? .cached_token() .map(|token| http_client::iap::proxy_auth_header(&token)) } } #[cfg(test)] #[path = "base_client_tests.rs"] mod tests;