Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/custom-api-endpoint.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
113 changes: 111 additions & 2 deletions src/discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,21 @@ pub struct JsonSchemaProperty {
pub additional_properties: Option<Box<JsonSchemaProperty>>,
}

static CUSTOM_API_BASE_URL: std::sync::LazyLock<Option<String>> = 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.");
}
Comment on lines +190 to +193
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The eprintln! macro can panic if it fails to write to stderr, for instance, due to a broken pipe when the output is redirected. This can cause the application to crash unexpectedly. To improve robustness, it's safer to use std::io::stderr() with writeln! and handle the Result, even if it's just by ignoring it.

    if let Some(ref u) = url {
        use std::io::Write;
        let mut stderr = std::io::stderr();
        let _ = writeln!(stderr, "[gws] Custom API endpoint active: {u}");
        let _ = writeln!(stderr, "[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,
Expand All @@ -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);
}
}
Expand Down Expand Up @@ -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();
}
}
}
Comment on lines +277 to +291
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

The current implementation of rewrite_base_url incorrectly rewrites base_url. It replaces the entire base_url with the custom endpoint, which causes the service path (e.g., /gmail/v1/) to be lost. This will result in incorrect API request URLs.

For example, a request to gmail.users.getProfile would be sent to http://localhost:8001/users/me/profile instead of the correct http://localhost:8001/gmail/v1/users/me/profile.

The fix is to replace only the host part of the base_url, preserving the path. Here is a suggested implementation:

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 {
        *base_url = base_url.replace(&original_root_url, &doc.root_url);
    }
}


#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -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());
}
}
13 changes: 13 additions & 0 deletions src/executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>, 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 {
Expand Down
10 changes: 7 additions & 3 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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"
Expand Down
4 changes: 2 additions & 2 deletions src/mcp_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down