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
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,5 @@ release/

# VitePress
docs/.vitepress/dist
docs/.vitepress/cache
docs/.vitepress/cache

18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,24 @@ Searches your Google Drive for files matching the given query.
- [Documentation](docs/index.md): Detailed documentation on all the available tools.
- [GitHub Issues](https://github.com/gemini-cli-extensions/workspace/issues): Report bugs or request features.

## Headless Mode / Remote VMs

If you're running the MCP server on a remote VM or headless environment where a browser cannot be opened, you can use headless mode with port-forwarding:

```python
env = {
"GEMINI_CLI_WORKSPACE_HEADLESS": "true",
"OAUTH_CALLBACK_PORT": "8585"
}
```

**Steps:**
1. Set up port-forwarding from your VM: `ssh -L 8585:localhost:8585 your-vm`
2. Run the MCP server with the environment variables above
3. Copy the authentication URL printed to stderr
4. Open the URL in your local browser and complete authentication
5. The OAuth callback will be forwarded to the VM, completing the flow

## Important security consideration: Indirect Prompt Injection Risk

When exposing any language model to untrusted data, there's a risk of an [indirect prompt injection attack](https://en.wikipedia.org/wiki/Prompt_injection). Agentic tools like Gemini CLI, connected to MCP servers, have access to a wide array of tools and APIs.
Expand Down
44 changes: 34 additions & 10 deletions cloud_function/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -183,16 +183,40 @@ async function handleCallback(req, res) {

<div class="instructions">
<h4>Keychain Storage Instructions:</h4>
<ol>
<li>Open your OS Keychain/Credential Manager.</li>
<li>Create a new secure entry (e.g., a "Generic Password" on macOS, a "Windows Credential", or similar on Linux).</li>
<li>Set the **Service** (or equivalent field) to: <code>${KEYCHAIN_SERVICE_NAME}</code></li>
<li>Set the **Account** (or username field) to: <code>${KEYCHAIN_ACCOUNT_NAME}</code></li>
<li>Paste the copied JSON into the **Password/Secret** field.</li>
<li>Save the entry.</li>
</ol>
<p>Your local MCP server will now be able to find and use these credentials automatically.</p>
<p><small>(If keychain is unavailable, the server falls back to an encrypted file, but keychain is recommended.)</small></p>

<details open>
<summary><strong>Linux (CLI / Remote VM)</strong></summary>
<p>Run this command in your terminal, then paste the JSON and press <code>Ctrl+D</code>:</p>
<pre>secret-tool store --label="Gemini Workspace OAuth" service ${KEYCHAIN_SERVICE_NAME} username ${KEYCHAIN_ACCOUNT_NAME}</pre>
<p><small>Note: Requires <code>libsecret-tools</code>. Install with: <code>sudo apt install libsecret-tools</code></small></p>
</details>

<details>
<summary><strong>macOS</strong></summary>
<ol>
<li>Open <strong>Keychain Access</strong> (Applications → Utilities).</li>
<li>Click <strong>File → New Password Item</strong>.</li>
<li>Set <strong>Keychain Item Name</strong> to: <code>${KEYCHAIN_SERVICE_NAME}</code></li>
<li>Set <strong>Account Name</strong> to: <code>${KEYCHAIN_ACCOUNT_NAME}</code></li>
<li>Paste the JSON into the <strong>Password</strong> field.</li>
<li>Click <strong>Add</strong>.</li>
</ol>
</details>

<details>
<summary><strong>Windows</strong></summary>
<ol>
<li>Open <strong>Credential Manager</strong> (search in Start menu).</li>
<li>Select <strong>Windows Credentials</strong> → <strong>Add a generic credential</strong>.</li>
<li>Set <strong>Internet or network address</strong> to: <code>${KEYCHAIN_SERVICE_NAME}</code></li>
<li>Set <strong>User name</strong> to: <code>${KEYCHAIN_ACCOUNT_NAME}</code></li>
<li>Paste the JSON into the <strong>Password</strong> field.</li>
<li>Click <strong>OK</strong>.</li>
</ol>
</details>

<p style="margin-top: 1rem;">After storing, <strong>restart your MCP server</strong>. It will automatically find and use the credentials.</p>
<p><small>(If keychain is unavailable, set <code>GEMINI_CLI_WORKSPACE_FORCE_FILE_STORAGE=true</code> to use encrypted file storage.)</small></p>
</div>
</div>

Expand Down
22 changes: 19 additions & 3 deletions workspace-server/src/auth/AuthManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,8 +155,18 @@ export class AuthManager {
}

const webLogin = await this.authWithWeb(oAuth2Client);

// Print port-forwarding instructions when in headless mode
const isHeadless = process.env['GEMINI_CLI_WORKSPACE_HEADLESS'] === 'true' ||
process.env['GEMINI_CLI_WORKSPACE_HEADLESS'] === '1';
Comment on lines +160 to +161
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

This check for headless mode is duplicated in AuthManager.ts (here and on line 290) and in secure-browser-launcher.ts. To improve maintainability and avoid potential inconsistencies, it would be better to extract this logic into a single, shared utility function.

For example:

// In a shared utility file
export function isHeadlessMode(): boolean {
  const headlessEnv = process.env['GEMINI_CLI_WORKSPACE_HEADLESS'];
  return headlessEnv === 'true' || headlessEnv === '1';
}

You could then call isHeadlessMode() in all three places.

References
  1. Avoid duplicating code. Duplicated code makes the software harder to maintain. When a bug is found in a piece of duplicated code, it must be fixed in every place it appears.

if (isHeadless) {
const port = process.env['OAUTH_CALLBACK_PORT'] || 'dynamically assigned';
console.error(`\n[Headless Mode] If you are on a remote instance, ensure port ${port} is forwarded to your local machine.`);
console.error(`Example: ssh -L ${port}:localhost:${port} your-remote-host\n`);
}

await open(webLogin.authUrl);
console.log('Waiting for authentication...');
console.error('Waiting for authentication...');

// Add timeout to prevent infinite waiting when browser tab gets stuck
const authTimeout = 5 * 60 * 1000; // 5 minutes timeout
Expand Down Expand Up @@ -275,13 +285,19 @@ export class AuthManager {

const isGuiAvailable = shouldLaunchBrowser();

// GEMINI_CLI_WORKSPACE_HEADLESS: Don't launch browser, but still use callback server for redirect
// This enables headless/VM environments with port-forwarding to work
const isMcpHeadless = process.env['GEMINI_CLI_WORKSPACE_HEADLESS'] === 'true' || process.env['GEMINI_CLI_WORKSPACE_HEADLESS'] === '1';
const useCallbackServer = isGuiAvailable || isMcpHeadless;

// SECURITY: Generate a random token for CSRF protection.
const csrfToken = crypto.randomBytes(32).toString('hex');

// The state now contains a JSON payload indicating the flow mode and CSRF token.
// When using callback server (GUI mode or MCP_HEADLESS), include the URI and set manual: false
const statePayload = {
uri: isGuiAvailable ? localRedirectUri : undefined,
manual: !isGuiAvailable,
uri: useCallbackServer ? localRedirectUri : undefined,
manual: !useCallbackServer,
csrf: csrfToken,
};
const state = Buffer.from(JSON.stringify(statePayload)).toString('base64');
Expand Down
13 changes: 7 additions & 6 deletions workspace-server/src/utils/open-wrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,20 +31,21 @@ const createMockChildProcess = () => ({
});

const openWrapper = async (url: string): Promise<any> => {
// Check if we should launch the browser
// Always print the URL to stderr first for headless/VM environments
console.error(`\nPlease open this URL in your browser to authenticate:\n${url}\n`);

// Check if we should also try to launch the browser
if (!shouldLaunchBrowser()) {
console.log(`Browser launch not supported. Please open this URL in your browser: ${url}`);
return createMockChildProcess();
}

// Try to open the browser securely
// Try to open the browser securely (best effort, don't fail if it doesn't work)
try {
await openBrowserSecurely(url);
return createMockChildProcess();
} catch {
console.log(`Failed to open browser. Please open this URL in your browser: ${url}`);
return createMockChildProcess();
// Browser launch failed, but URL is already printed above
}
return createMockChildProcess();
};

// Use standard ES Module export and let the compiler generate the CommonJS correct output.
Expand Down
6 changes: 6 additions & 0 deletions workspace-server/src/utils/secure-browser-launcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,12 @@ export async function openBrowserSecurely(
* @returns True if the tool should attempt to launch a browser
*/
export function shouldLaunchBrowser(): boolean {
// GEMINI_CLI_WORKSPACE_HEADLESS: Skip browser launch but still use callback server for OAuth redirect
// This is useful for headless/VM environments with port-forwarding
if (process.env.GEMINI_CLI_WORKSPACE_HEADLESS === 'true' || process.env.GEMINI_CLI_WORKSPACE_HEADLESS === '1') {
return false;
}

// A list of browser names that indicate we should not attempt to open a
// web browser for the user.
const browserBlocklist = ['www-browser'];
Expand Down