Skip to content

feat: add GOOGLE_WORKSPACE_CLI_API_BASE_URL for custom/mock endpoint support#100

Open
xdotli wants to merge 4 commits intogoogleworkspace:mainfrom
benchflow-ai:feat/custom-endpoint-override
Open

feat: add GOOGLE_WORKSPACE_CLI_API_BASE_URL for custom/mock endpoint support#100
xdotli wants to merge 4 commits intogoogleworkspace:mainfrom
benchflow-ai:feat/custom-endpoint-override

Conversation

@xdotli
Copy link

@xdotli xdotli commented Mar 5, 2026

Reproduce steps:

# Install from this branch
cargo install --git https://github.com/benchflow-ai/cli --branch feat/custom-endpoint-override

Without env var — request goes to Google APIs:

$ gws gmail users getProfile --params '{"userId":"me"}' --dry-run
{
  "method": "GET",
  "url": "https://gmail.googleapis.com/gmail/v1/users/me/profile"
}

With GWS_API_BASE_URL — request redirected to custom endpoint:

$ GWS_API_BASE_URL=http://localhost:8001 gws gmail users getProfile --params '{"userId":"me"}' --dry-run
[gws] Custom API endpoint active: http://localhost:8001
[gws] Authentication is disabled. Requests will NOT go to Google APIs.
{
  "method": "GET",
  "url": "http://localhost:8001/gmail/v1/users/me/profile"
}

--dry-run builds the request without sending it. Discovery Documents are still fetched from Google so the full command tree works — only the API request URLs are rewritten.

Summary

Adds a GWS_API_BASE_URL environment variable that redirects all API requests to a custom endpoint (e.g., a mock server). When set:

  • The real Discovery Document is still fetched so the CLI command tree works normally
  • root_url and base_url in the Discovery Document are rewritten to the custom endpoint
  • OAuth authentication is automatically skipped (mock servers don't need it)
  • Works in both CLI mode and MCP server mode

Motivation

Testing agents and skills against mock API servers is a common need for development and CI. The gog CLI already supports this via GOG_ENDPOINT. This PR brings the same capability to gws.

Example usage

# Point gws at a local mock Gmail server
export GWS_API_BASE_URL=http://localhost:8099

# All commands now hit the mock server, no auth needed
gws gmail users messages list --params '{"userId": "me", "q": "is:unread"}'

Changes

  • src/discovery.rs: Add custom_api_base_url() with LazyLock caching; rewrite Discovery Document URLs when env var is set
  • src/executor.rs: Add resolve_auth() that skips OAuth for custom endpoints
  • src/main.rs, src/mcp_server.rs: Use resolve_auth() for consistent behavior

@xdotli xdotli requested a review from jpoehnelt as a code owner March 5, 2026 04:23
@changeset-bot
Copy link

changeset-bot bot commented Mar 5, 2026

🦋 Changeset detected

Latest commit: 7cc5875

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@googleworkspace/cli Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@gemini-code-assist
Copy link
Contributor

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request enhances the application's testability and development workflow by allowing users to specify a custom API base URL via an environment variable. This enables seamless interaction with mock API servers, facilitating local development and continuous integration testing without requiring real Google API calls or OAuth authentication. The change ensures that the application's command structure remains intact while redirecting actual API requests to the specified endpoint.

Highlights

  • Custom API Endpoint Support: Introduced a GWS_API_BASE_URL environment variable to redirect all API requests to a custom endpoint, such as a mock server, for development and testing purposes.
  • Discovery Document URL Rewriting: Implemented logic to rewrite the root_url and base_url within the fetched Google Discovery Document to point to the custom API endpoint, ensuring the CLI command tree remains functional while hitting the mock server.
  • Automatic OAuth Skipping: Configured the system to automatically skip OAuth authentication when GWS_API_BASE_URL is set, as mock servers typically do not require or support Google OAuth.
  • Unified Authentication Logic: Centralized the authentication resolution logic into a new resolve_auth function, ensuring consistent behavior across both CLI and MCP server modes.
Changelog
  • src/discovery.rs
    • Introduced CUSTOM_API_BASE_URL using LazyLock to cache the GWS_API_BASE_URL environment variable.
    • Added custom_api_base_url() function to provide access to the cached custom URL.
    • Modified fetch_discovery_document to dynamically rewrite the root_url and base_url of the Discovery Document if a custom API base URL is present.
  • src/executor.rs
    • Added a new asynchronous function resolve_auth to encapsulate authentication logic.
    • Implemented conditional logic within resolve_auth to skip OAuth if a custom API base URL is detected.
  • src/main.rs
    • Updated the main application's authentication flow to utilize the new executor::resolve_auth function.
  • src/mcp_server.rs
    • Modified the MCP server's authentication process to call the new crate::executor::resolve_auth function.
Activity
  • The cargo check command has passed, indicating no compilation errors.
  • Manual testing against a mock Gmail API server is planned to verify functionality.
  • Verification of MCP server mode respecting the environment variable is planned.
  • Verification of normal operation (without the environment variable) being unaffected is planned.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

@codecov
Copy link

codecov bot commented Mar 5, 2026

Codecov Report

❌ Patch coverage is 48.38710% with 32 lines in your changes missing coverage. Please review.
✅ Project coverage is 54.89%. Comparing base (6ed836c) to head (30e0f85).
⚠️ Report is 8 commits behind head on main.

Files with missing lines Patch % Lines
src/discovery.rs 60.00% 20 Missing ⚠️
src/executor.rs 0.00% 8 Missing ⚠️
src/main.rs 0.00% 3 Missing ⚠️
src/mcp_server.rs 0.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #100      +/-   ##
==========================================
+ Coverage   54.88%   54.89%   +0.01%     
==========================================
  Files          38       38              
  Lines       13085    13137      +52     
==========================================
+ Hits         7182     7212      +30     
- Misses       5903     5925      +22     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copy link
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request introduces a valuable feature for testing against mock API servers by using the GWS_API_BASE_URL environment variable, with a well-designed implementation using LazyLock and a refactored resolve_auth function. However, a critical security vulnerability has been identified: the current implementation allows for silent redirection of sensitive API traffic and automatically disables authentication when GWS_API_BASE_URL is set. This, combined with the automatic loading of .env files, creates a risk where an attacker could hijack a user's API requests by placing a malicious .env file in their working directory. To address this, I recommend adding a mandatory warning message when a custom endpoint is active to ensure users are aware of the redirection and the disabled security state. Additionally, consider improving error handling for authentication failures to provide better feedback to the user.

src/executor.rs Outdated
}
match crate::auth::get_token(scopes).await {
Ok(t) => (Some(t), AuthMethod::OAuth),
Err(_) => (None, AuthMethod::None),
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

The current implementation swallows errors from auth::get_token, which can hide useful debugging information from users if their authentication setup is misconfigured (e.g., a corrupted credentials file). While the subsequent API call will likely fail, the original, more specific error is lost.

Consider logging the error to stderr to improve debuggability, while maintaining the current behavior of attempting an unauthenticated request.

        Err(e) => {
            // Not a fatal error; the request will be tried without auth.
            // But logging it helps debug credential issues.
            eprintln!("[gws] Warning: could not acquire authentication token: {e}. Proceeding unauthenticated.");
            (None, AuthMethod::None)
        }

@xdotli xdotli force-pushed the feat/custom-endpoint-override branch from ed54a90 to c22dc58 Compare March 5, 2026 04:32
@jpoehnelt
Copy link
Member

Review Feedback

Thanks for this PR — the design is clean and smart. A few items to address before merging:

Required

  1. Env var naming: GWS_API_BASE_URL should follow the existing convention → GOOGLE_WORKSPACE_CLI_API_BASE_URL (consistent with GOOGLE_WORKSPACE_CLI_TOKEN, GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE, etc.)

  2. Missing changeset: Please add a .changeset/ file (e.g. pnpx changeset).

  3. Needs rebase: The branch is behind main — get_token() signature has changed. Please rebase onto main.

Recommended

  1. Add a test for apply_base_url_override(): A simple unit test that constructs a RestDescription, calls the function, and asserts root_url/base_url were rewritten would be valuable.

  2. Security note: Consider adding a note in the README or AGENTS.md that this env var should never be set in production, since it silently disables authentication.

Otherwise the approach looks solid — rewriting the Discovery Document URLs while still fetching the real schema from Google is the right call.

Copy link
Member

@jpoehnelt jpoehnelt left a comment

Choose a reason for hiding this comment

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

.

…support

Add an environment variable that redirects all API requests to a custom
endpoint (e.g., a mock server). When set, OAuth authentication is
automatically skipped while the real Discovery Document is still fetched
so the CLI command tree remains fully functional.

Changes:
- src/discovery.rs: add custom_api_base_url() with LazyLock caching;
  rewrite Discovery Document URLs when env var is set
- src/executor.rs: add resolve_auth() that skips OAuth for custom endpoints
- src/main.rs, src/mcp_server.rs: use resolve_auth() for consistent behavior
- AGENTS.md: document env var with security note
- Add changeset and unit tests for URL rewriting
@xdotli xdotli force-pushed the feat/custom-endpoint-override branch from 8f9a36e to 30e0f85 Compare March 5, 2026 07:22
@xdotli
Copy link
Author

xdotli commented Mar 5, 2026

.

Review Feedback

Thanks for this PR — the design is clean and smart. A few items to address before merging:

Required

  1. Env var naming: GWS_API_BASE_URL should follow the existing convention → GOOGLE_WORKSPACE_CLI_API_BASE_URL (consistent with GOOGLE_WORKSPACE_CLI_TOKEN, GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE, etc.)
  2. Missing changeset: Please add a .changeset/ file (e.g. pnpx changeset).
  3. Needs rebase: The branch is behind main — get_token() signature has changed. Please rebase onto main.

Recommended

  1. Add a test for apply_base_url_override(): A simple unit test that constructs a RestDescription, calls the function, and asserts root_url/base_url were rewritten would be valuable.
  2. Security note: Consider adding a note in the README or AGENTS.md that this env var should never be set in production, since it silently disables authentication.

Otherwise the approach looks solid — rewriting the Discovery Document URLs while still fetching the real schema from Google is the right call.

hi @jpoehnelt thanks for the speedy and thorough review! I have addressed all the changes and let cursor go thru it + reran my reproduce steps and it work as expected. Would appreciate another review. Thanks!

@xdotli xdotli requested a review from jpoehnelt March 5, 2026 07:29
Copy link
Member

@jpoehnelt jpoehnelt left a comment

Choose a reason for hiding this comment

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

  1. base_url rewrite loses the service path: rewrite_base_url sets
    base_url to just the custom host, dropping the /gmail/v1/ prefix. This
    means final request URLs won't include the service path. Please preserve
    the path portion from the original base_url or verify that build_url()
    reconstructs it from root_url + service_path.
  2. Auth error logging regression: resolve_auth() silently discards auth
    errors. The old mcp_server.rs code logged them. Please add an
    eprintln! warning in the Err branch.

@jpoehnelt
Copy link
Member

/gemini review

Copy link
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request introduces a valuable feature for testing against mock API servers by adding the GOOGLE_WORKSPACE_CLI_API_BASE_URL environment variable. However, a critical vulnerability exists due to an inconsistency between the environment variable name implemented in the code (GOOGLE_WORKSPACE_CLI_API_BASE_URL) and the one documented in the PR description and examples (GWS_API_BASE_URL). This mismatch could lead to users accidentally performing actions on production Google Workspace accounts, posing a significant risk to data integrity. Additionally, the implementation of URL rewriting in rewrite_base_url has been identified as potentially leading to incorrect request URLs and may inadvertently strip the service path (e.g., /gmail/v1/) from API requests. Furthermore, a refactoring silently removed a useful warning on authentication failure in the MCP server, which should be restored.

Comment on lines +299 to +303
fn rewrite_base_url(doc: &mut RestDescription, base: &str) {
let base_trimmed = base.trim_end_matches('/');
doc.root_url = format!("{base_trimmed}/");
doc.base_url = Some(format!("{base_trimmed}/"));
}
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);
    }
}

Comment on lines +375 to +404
#[test]
fn test_rewrite_base_url() {
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/gmail/v1/".to_string()),
service_path: "gmail/v1/".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_trailing_slash() {
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()),
..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/"));
}
Copy link
Contributor

Choose a reason for hiding this comment

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

critical

These tests need to be updated to reflect the correct behavior of rewrite_base_url. The assertions should check that the service path is preserved in the rewritten base_url.

    #[test]
    fn test_rewrite_base_url() {
        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/gmail/v1/".to_string()),
            service_path: "gmail/v1/".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/gmail/v1/")
        );
    }

    #[test]
    fn test_rewrite_base_url_trailing_slash() {
        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()),
            ..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/")
        );
    }

src/executor.rs Outdated
Comment on lines +46 to +54
pub async fn resolve_auth(scopes: &[&str], account: Option<&str>) -> (Option<String>, AuthMethod) {
if crate::discovery::custom_api_base_url().is_some() {
return (None, AuthMethod::None);
}
match crate::auth::get_token(scopes, account).await {
Ok(t) => (Some(t), AuthMethod::OAuth),
Err(_) => (None, AuthMethod::None),
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

The resolve_auth function currently swallows errors from crate::auth::get_token, which can hide important authentication problems from the user (e.g., invalid account, missing default account). This also removes a useful warning message that was previously shown in the MCP server on auth failure.

To preserve the original behavior and provide better error feedback, I suggest changing resolve_auth to return a Result that propagates the error from get_token. The call sites can then decide whether to show a warning or ignore the error.

Suggested change
pub async fn resolve_auth(scopes: &[&str], account: Option<&str>) -> (Option<String>, AuthMethod) {
if crate::discovery::custom_api_base_url().is_some() {
return (None, AuthMethod::None);
}
match crate::auth::get_token(scopes, account).await {
Ok(t) => (Some(t), AuthMethod::OAuth),
Err(_) => (None, AuthMethod::None),
}
}
pub async fn resolve_auth(scopes: &[&str], account: Option<&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, account).await {
Ok(t) => Ok((Some(t), AuthMethod::OAuth)),
Err(e) => Err(e),
}
}

src/main.rs Outdated
Comment on lines +239 to +240
let (token, auth_method) =
executor::resolve_auth(&scopes, account.as_deref()).await;
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

Following the change to resolve_auth to return a Result, this call site should be updated to handle the error. To preserve the existing behavior of proceeding without authentication on failure, you can match on the result and provide a fallback.

Suggested change
let (token, auth_method) =
executor::resolve_auth(&scopes, account.as_deref()).await;
let (token, auth_method) =
match executor::resolve_auth(&scopes, account.as_deref()).await {
Ok(auth) => auth,
Err(_) => (None, executor::AuthMethod::None),
};

(None, crate::executor::AuthMethod::None)
}
};
let (token, auth_method) = crate::executor::resolve_auth(&scopes, None).await;
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

Following the change to resolve_auth to return a Result, this call site should be updated to handle the error. This will allow you to restore the warning message that was previously displayed on authentication failure, which is useful for debugging.

Suggested change
let (token, auth_method) = crate::executor::resolve_auth(&scopes, None).await;
let (token, auth_method) = match crate::executor::resolve_auth(&scopes, None).await {
Ok(auth) => auth,
Err(e) => {
eprintln!(
"[gws mcp] Warning: Authentication failed, proceeding without credentials: {e}"
);
(None, crate::executor::AuthMethod::None)
}
};

@xdotli xdotli changed the title feat: add GWS_API_BASE_URL for custom/mock endpoint support feat: add GOOGLE_WORKSPACE_CLI_API_BASE_URL for custom/mock endpoint support Mar 5, 2026
@xdotli
Copy link
Author

xdotli commented Mar 5, 2026

@jpoehnelt thanks for the thorough review! Here's what was done to address everything:

For the base_url rewrite losing the service path — this was a real bug. The original implementation set base_url to just the custom host, which broke any API that uses a non-empty servicePath. Gmail worked fine because its servicePath is empty and the method paths include the full path (e.g. gmail/v1/users/{userId}/profile), but Drive has servicePath "drive/v3/" with short method paths like "files", so the final URL became http://localhost:8001/files instead of http://localhost:8001/drive/v3/files. Fixed by using str::replace on the original root_url so only the host gets swapped and the path portion is preserved. Added three unit tests covering both styles plus the None base_url edge case.

For the auth error logging regression — resolve_auth now returns Result so each call site can handle errors with its own context. main.rs silently falls back to unauthenticated (same as before), and mcp_server.rs restores the original "[gws mcp] Warning: Authentication failed, proceeding without credentials" message that was there before the refactor.

Also fixed cargo fmt issues that were in the original commit.

Tested all of this against a mock Gmail API server running on localhost:8001 (a FastAPI/uvicorn server implementing the full Gmail REST surface — messages, threads, labels, drafts, settings, history, profile, attachments, watch/stop, plus admin endpoints for seeding/reset/snapshots). After seeding the server, verified the following endpoints all return correct data through gws: users.getProfile, users.messages.list, users.messages.get (with path param substitution for messageId), users.labels.list, users.threads.list, users.drafts.list. Also verified Drive files.list dry-run now produces the correct URL with the service path preserved. Without the env var set, everything still hits the real Google APIs as expected.

Also changed PR title, added logging as mentioned.

Lmk if I should start mock servers on other services and test them.

@xdotli xdotli requested a review from jpoehnelt March 5, 2026 09:55
@jpoehnelt jpoehnelt added area: http cla: yes This human has signed the Contributor License Agreement. complexity: medium Moderate change, some review needed labels Mar 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: http cla: yes This human has signed the Contributor License Agreement. complexity: medium Moderate change, some review needed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants