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:

-
    -
  1. Open your OS Keychain/Credential Manager.
  2. -
  3. Create a new secure entry (e.g., a "Generic Password" on macOS, a "Windows Credential", or similar on Linux).
  4. -
  5. Set the **Service** (or equivalent field) to: ${KEYCHAIN_SERVICE_NAME}
  6. -
  7. Set the **Account** (or username field) to: ${KEYCHAIN_ACCOUNT_NAME}
  8. -
  9. Paste the copied JSON into the **Password/Secret** field.
  10. -
  11. Save the entry.
  12. -
-

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 +
    +
  1. Open Keychain Access (Applications → Utilities).
  2. +
  3. Click File → New Password Item.
  4. +
  5. Set Keychain Item Name to: ${KEYCHAIN_SERVICE_NAME}
  6. +
  7. Set Account Name to: ${KEYCHAIN_ACCOUNT_NAME}
  8. +
  9. Paste the JSON into the Password field.
  10. +
  11. Click Add.
  12. +
+
+ +
+ Windows +
    +
  1. Open Credential Manager (search in Start menu).
  2. +
  3. Select Windows CredentialsAdd a generic credential.
  4. +
  5. Set Internet or network address to: ${KEYCHAIN_SERVICE_NAME}
  6. +
  7. Set User name to: ${KEYCHAIN_ACCOUNT_NAME}
  8. +
  9. Paste the JSON into the Password field.
  10. +
  11. Click OK.
  12. +
+
+ +

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'];