diff --git a/.changeset/custom-api-endpoint.md b/.changeset/custom-api-endpoint.md new file mode 100644 index 00000000..1e2118b1 --- /dev/null +++ b/.changeset/custom-api-endpoint.md @@ -0,0 +1,5 @@ +--- +"@googleworkspace/cli": minor +--- + +Add `GOOGLE_WORKSPACE_CLI_API_BASE_URL` env var to redirect API requests to a custom endpoint (e.g., mock server). Authentication is automatically skipped when set. diff --git a/AGENTS.md b/AGENTS.md index 1fe2d658..85a9132e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -181,6 +181,7 @@ Use these labels to categorize pull requests and issues: | Variable | Description | |---|---| | `GOOGLE_WORKSPACE_CLI_CONFIG_DIR` | Override the config directory (default: `~/.config/gws`) | +| `GOOGLE_WORKSPACE_CLI_API_BASE_URL` | Redirects all API requests to a custom endpoint (e.g., `http://localhost:8099`). Authentication is automatically disabled. **Security: never set this in production** — it silently disables OAuth and sends all requests to an arbitrary endpoint. | ### OAuth Client diff --git a/src/discovery.rs b/src/discovery.rs index b4fa9ff1..4790db25 100644 --- a/src/discovery.rs +++ b/src/discovery.rs @@ -183,6 +183,21 @@ pub struct JsonSchemaProperty { pub additional_properties: Option>, } +static CUSTOM_API_BASE_URL: std::sync::LazyLock> = std::sync::LazyLock::new(|| { + let url = std::env::var("GOOGLE_WORKSPACE_CLI_API_BASE_URL") + .ok() + .filter(|s| !s.is_empty()); + if let Some(ref u) = url { + eprintln!("[gws] Custom API endpoint active: {u}"); + eprintln!("[gws] Authentication is disabled. Requests will NOT go to Google APIs."); + } + url +}); + +pub fn custom_api_base_url() -> Option<&'static str> { + CUSTOM_API_BASE_URL.as_deref() +} + /// Fetches and caches a Google Discovery Document. pub async fn fetch_discovery_document( service: &str, @@ -206,7 +221,8 @@ pub async fn fetch_discovery_document( if let Ok(modified) = metadata.modified() { if modified.elapsed().unwrap_or_default() < std::time::Duration::from_secs(86400) { let data = std::fs::read_to_string(&cache_file)?; - let doc: RestDescription = serde_json::from_str(&data)?; + let mut doc: RestDescription = serde_json::from_str(&data)?; + apply_base_url_override(&mut doc); return Ok(doc); } } @@ -247,10 +263,33 @@ pub async fn fetch_discovery_document( let _ = e; } - let doc: RestDescription = serde_json::from_str(&body)?; + let mut doc: RestDescription = serde_json::from_str(&body)?; + apply_base_url_override(&mut doc); Ok(doc) } +fn apply_base_url_override(doc: &mut RestDescription) { + if let Some(base) = custom_api_base_url() { + rewrite_base_url(doc, base); + } +} + +fn rewrite_base_url(doc: &mut RestDescription, base: &str) { + let base_trimmed = base.trim_end_matches('/'); + let new_root_url = format!("{base_trimmed}/"); + let original_root_url = std::mem::replace(&mut doc.root_url, new_root_url); + + if let Some(base_url) = &mut doc.base_url { + if let Some(stripped_path) = base_url.strip_prefix(&original_root_url) { + *base_url = format!("{}{}", &doc.root_url, stripped_path); + } else { + // Fallback: base_url has a different domain than root_url. + // Still rewrite to ensure requests go to the custom endpoint. + *base_url = doc.root_url.clone(); + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -320,4 +359,74 @@ mod tests { assert!(doc.resources.is_empty()); assert!(doc.schemas.is_empty()); } + + #[test] + fn test_rewrite_base_url_empty_service_path() { + let mut doc = RestDescription { + name: "gmail".to_string(), + version: "v1".to_string(), + root_url: "https://gmail.googleapis.com/".to_string(), + base_url: Some("https://gmail.googleapis.com/".to_string()), + service_path: "".to_string(), + ..Default::default() + }; + + rewrite_base_url(&mut doc, "http://localhost:8099"); + assert_eq!(doc.root_url, "http://localhost:8099/"); + assert_eq!(doc.base_url.as_deref(), Some("http://localhost:8099/")); + } + + #[test] + fn test_rewrite_base_url_preserves_service_path() { + let mut doc = RestDescription { + name: "drive".to_string(), + version: "v3".to_string(), + root_url: "https://www.googleapis.com/".to_string(), + base_url: Some("https://www.googleapis.com/drive/v3/".to_string()), + service_path: "drive/v3/".to_string(), + ..Default::default() + }; + + rewrite_base_url(&mut doc, "http://localhost:8099/"); + assert_eq!(doc.root_url, "http://localhost:8099/"); + assert_eq!( + doc.base_url.as_deref(), + Some("http://localhost:8099/drive/v3/") + ); + } + + #[test] + fn test_rewrite_base_url_different_domain_fallback() { + // Edge case: base_url has a different domain than root_url. + // The fallback should still rewrite base_url to the custom endpoint. + let mut doc = RestDescription { + name: "hypothetical".to_string(), + version: "v1".to_string(), + root_url: "https://hypothetical.googleapis.com/".to_string(), + base_url: Some("https://other-domain.googleapis.com/hypothetical/v1/".to_string()), + service_path: "hypothetical/v1/".to_string(), + ..Default::default() + }; + + rewrite_base_url(&mut doc, "http://localhost:8099"); + assert_eq!(doc.root_url, "http://localhost:8099/"); + // Service path is lost but requests still go to the custom endpoint + assert_eq!(doc.base_url.as_deref(), Some("http://localhost:8099/")); + } + + #[test] + fn test_rewrite_base_url_none() { + let mut doc = RestDescription { + name: "customsearch".to_string(), + version: "v1".to_string(), + root_url: "https://www.googleapis.com/".to_string(), + base_url: None, + service_path: "customsearch/v1/".to_string(), + ..Default::default() + }; + + rewrite_base_url(&mut doc, "http://localhost:8099"); + assert_eq!(doc.root_url, "http://localhost:8099/"); + assert!(doc.base_url.is_none()); + } } diff --git a/src/executor.rs b/src/executor.rs index 49101ece..90f4a07d 100644 --- a/src/executor.rs +++ b/src/executor.rs @@ -38,6 +38,19 @@ pub enum AuthMethod { None, } +/// Resolve authentication, skipping OAuth when a custom API endpoint is set. +pub async fn resolve_auth( + scopes: &[&str], +) -> anyhow::Result<(Option, AuthMethod)> { + if crate::discovery::custom_api_base_url().is_some() { + return Ok((None, AuthMethod::None)); + } + match crate::auth::get_token(scopes).await { + Ok(t) => Ok((Some(t), AuthMethod::OAuth)), + Err(e) => Err(e), + } +} + /// Configuration for auto-pagination. #[derive(Debug, Clone)] pub struct PaginationConfig { diff --git a/src/main.rs b/src/main.rs index a448fec1..05476121 100644 --- a/src/main.rs +++ b/src/main.rs @@ -233,9 +233,10 @@ async fn run() -> Result<(), GwsError> { // to avoid restrictive scopes like gmail.metadata that block query parameters. let scopes: Vec<&str> = select_scope(&method.scopes).into_iter().collect(); - // Authenticate: try OAuth, fail with error if credentials exist but are broken - let (token, auth_method) = match auth::get_token(&scopes).await { - Ok(t) => (Some(t), executor::AuthMethod::OAuth), + // Authenticate: try OAuth (resolve_auth skips auth when custom endpoint is set), + // fail with error if credentials exist but are broken. + let (token, auth_method) = match executor::resolve_auth(&scopes).await { + Ok(auth) => auth, Err(e) => { // If credentials were found but failed (e.g. decryption error, invalid token), // propagate the error instead of silently falling back to unauthenticated. @@ -455,6 +456,9 @@ fn print_usage() { println!( " GOOGLE_WORKSPACE_CLI_CONFIG_DIR Override config directory (default: ~/.config/gws)" ); + println!( + " GOOGLE_WORKSPACE_CLI_API_BASE_URL Custom API endpoint (e.g., mock server); disables auth" + ); println!(" GOOGLE_WORKSPACE_CLI_SANITIZE_TEMPLATE Default Model Armor template"); println!( " GOOGLE_WORKSPACE_CLI_SANITIZE_MODE Sanitization mode: warn (default) or block" diff --git a/src/mcp_server.rs b/src/mcp_server.rs index acc3c29c..8c803e46 100644 --- a/src/mcp_server.rs +++ b/src/mcp_server.rs @@ -814,8 +814,8 @@ async fn execute_mcp_method( }; let scopes: Vec<&str> = crate::select_scope(&method.scopes).into_iter().collect(); - let (token, auth_method) = match crate::auth::get_token(&scopes).await { - Ok(t) => (Some(t), crate::executor::AuthMethod::OAuth), + let (token, auth_method) = match crate::executor::resolve_auth(&scopes).await { + Ok(auth) => auth, Err(e) => { eprintln!( "[gws mcp] Warning: Authentication failed, proceeding without credentials: {e}"