diff --git a/.gitignore b/.gitignore
index 3dd9aa5..6ad23df 100644
--- a/.gitignore
+++ b/.gitignore
@@ -40,4 +40,5 @@ release/
# VitePress
docs/.vitepress/dist
-docs/.vitepress/cache
\ No newline at end of file
+docs/.vitepress/cache
+
diff --git a/README.md b/README.md
index 8744edb..3372834 100644
--- a/README.md
+++ b/README.md
@@ -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.
diff --git a/cloud_function/index.js b/cloud_function/index.js
index 75d8787..a88509c 100644
--- a/cloud_function/index.js
+++ b/cloud_function/index.js
@@ -183,16 +183,40 @@ async function handleCallback(req, res) {
Keychain Storage Instructions:
-
- - Open your OS Keychain/Credential Manager.
- - Create a new secure entry (e.g., a "Generic Password" on macOS, a "Windows Credential", or similar on Linux).
- - Set the **Service** (or equivalent field) to:
${KEYCHAIN_SERVICE_NAME}
- - Set the **Account** (or username field) to:
${KEYCHAIN_ACCOUNT_NAME}
- - Paste the copied JSON into the **Password/Secret** field.
- - Save the entry.
-
-
Your local MCP server will now be able to find and use these credentials automatically.
-
(If keychain is unavailable, the server falls back to an encrypted file, but keychain is recommended.)
+
+
+ Linux (CLI / Remote VM)
+ Run this command in your terminal, then paste the JSON and press Ctrl+D:
+ secret-tool store --label="Gemini Workspace OAuth" service ${KEYCHAIN_SERVICE_NAME} username ${KEYCHAIN_ACCOUNT_NAME}
+ Note: Requires libsecret-tools. Install with: sudo apt install libsecret-tools
+
+
+
+ macOS
+
+ - Open Keychain Access (Applications → Utilities).
+ - Click File → New Password Item.
+ - Set Keychain Item Name to:
${KEYCHAIN_SERVICE_NAME}
+ - Set Account Name to:
${KEYCHAIN_ACCOUNT_NAME}
+ - Paste the JSON into the Password field.
+ - Click Add.
+
+
+
+
+ Windows
+
+ - Open Credential Manager (search in Start menu).
+ - Select Windows Credentials → Add a generic credential.
+ - Set Internet or network address to:
${KEYCHAIN_SERVICE_NAME}
+ - Set User name to:
${KEYCHAIN_ACCOUNT_NAME}
+ - Paste the JSON into the Password field.
+ - Click OK.
+
+
+
+
After storing, restart your MCP server. It will automatically find and use the credentials.
+
(If keychain is unavailable, set GEMINI_CLI_WORKSPACE_FORCE_FILE_STORAGE=true to use encrypted file storage.)
diff --git a/workspace-server/src/auth/AuthManager.ts b/workspace-server/src/auth/AuthManager.ts
index acd7f76..cb7c8e9 100644
--- a/workspace-server/src/auth/AuthManager.ts
+++ b/workspace-server/src/auth/AuthManager.ts
@@ -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';
+ 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
@@ -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');
diff --git a/workspace-server/src/utils/open-wrapper.ts b/workspace-server/src/utils/open-wrapper.ts
index 3575e86..a71c6aa 100644
--- a/workspace-server/src/utils/open-wrapper.ts
+++ b/workspace-server/src/utils/open-wrapper.ts
@@ -31,20 +31,21 @@ const createMockChildProcess = () => ({
});
const openWrapper = async (url: string): Promise => {
- // 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.
diff --git a/workspace-server/src/utils/secure-browser-launcher.ts b/workspace-server/src/utils/secure-browser-launcher.ts
index 6126d44..e721630 100644
--- a/workspace-server/src/utils/secure-browser-launcher.ts
+++ b/workspace-server/src/utils/secure-browser-launcher.ts
@@ -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'];