From 573ba57db6710d949e8b479f44a56947ef0d0271 Mon Sep 17 00:00:00 2001 From: Caixiaopig Date: Sun, 4 Jan 2026 16:53:30 +0800 Subject: [PATCH 1/2] feat: add --no-browser OAuth mode for headless servers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Feature Description Enables adding Google accounts on remote servers without a desktop environment (headless Linux, Docker containers, SSH sessions) where automatic browser opening is not possible. Users can manually copy the authorization URL to a device with a browser, complete authentication, and paste the authorization code back. ## Usage npm run accounts:add -- --no-browser ## Code Architecture ### New Modules 1. oauth.js - extractCodeFromInput() - Parses user input (full callback URL or raw authorization code) - Extracts code and state parameters - Handles OAuth error responses 2. accounts.js - addAccountNoBrowser() - Account addition flow for no-browser mode - Displays authorization URL for manual copying - Waits for user to paste authorization code - Calls extractCodeFromInput to parse input - Completes OAuth flow and saves account 3. tests/test-oauth-no-browser.cjs - 13 unit tests covering valid URLs, raw codes, error handling, edge cases ### Modified Modules 1. accounts.js - interactiveAdd() - Added noBrowser parameter - Selects addAccount or addAccountNoBrowser based on mode 2. accounts.js - main() - Parses --no-browser CLI argument - Updated help information ## User Flow ┌─────────────────────────────────────────────────────────────┐ │ Headless Server Terminal │ └─────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────┐ │ npm run accounts:add -- --no-browser │ └─────────────────────────────────┘ │ ▼ ┌─────────────────────────────────┐ │ Display Google OAuth URL │ │ (manual copy required) │ └─────────────────────────────────┘ │ ┌────────────────────┴────────────────────┐ │ │ ▼ ▼ ┌─────────────────┐ ┌─────────────────────┐ │ Local Browser │ │ Phone/Other Device │ │ Open URL │ │ Open URL │ │ Google Sign-in │ │ Google Sign-in │ │ Authorize App │ │ Authorize App │ └─────────────────┘ └─────────────────────┘ │ │ └────────────────────┬────────────────────┘ │ ▼ ┌─────────────────────────────────┐ │ Browser redirects to localhost │ │ (page won't load - this is OK) │ │ Copy full URL or code parameter │ └─────────────────────────────────┘ │ ▼ ┌─────────────────────────────────┐ │ Return to server terminal │ │ Paste URL or authorization code │ └─────────────────────────────────┘ │ ▼ ┌─────────────────────────────────┐ │ ✓ Account added successfully │ └─────────────────────────────────┘ ## Security Considerations - Supports state parameter validation (when user pastes full URL) - Warns on state mismatch but allows continuation (manual mode tolerance) - Authorization code length validation to prevent incorrect input ## Compatibility - Does not affect existing automatic browser OAuth flow - All changes are additive, no modifications to upstream logic - Easy to merge future upstream updates --- src/auth/oauth.js | 51 ++++++++ src/cli/accounts.js | 79 +++++++++++- tests/test-oauth-no-browser.cjs | 217 ++++++++++++++++++++++++++++++++ 3 files changed, 343 insertions(+), 4 deletions(-) create mode 100644 tests/test-oauth-no-browser.cjs diff --git a/src/auth/oauth.js b/src/auth/oauth.js index 0c40ff7..4978030 100644 --- a/src/auth/oauth.js +++ b/src/auth/oauth.js @@ -57,6 +57,56 @@ export function getAuthorizationUrl() { }; } +/** + * Extract authorization code and state from user input. + * User can paste either: + * - Full callback URL: http://localhost:51121/oauth-callback?code=xxx&state=xxx + * - Just the code parameter: 4/0xxx... + * + * @param {string} input - User input (URL or code) + * @returns {{code: string, state: string|null}} Extracted code and optional state + */ +export function extractCodeFromInput(input) { + if (!input || typeof input !== 'string') { + throw new Error('No input provided'); + } + + const trimmed = input.trim(); + + // Check if it looks like a URL + if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) { + try { + const url = new URL(trimmed); + const code = url.searchParams.get('code'); + const state = url.searchParams.get('state'); + const error = url.searchParams.get('error'); + + if (error) { + throw new Error(`OAuth error: ${error}`); + } + + if (!code) { + throw new Error('No authorization code found in URL'); + } + + return { code, state }; + } catch (e) { + if (e.message.includes('OAuth error') || e.message.includes('No authorization code')) { + throw e; + } + throw new Error('Invalid URL format'); + } + } + + // Assume it's a raw code + // Google auth codes typically start with "4/" and are long + if (trimmed.length < 10) { + throw new Error('Input is too short to be a valid authorization code'); + } + + return { code: trimmed, state: null }; +} + /** * Start a local server to receive the OAuth callback * Returns a promise that resolves with the authorization code @@ -338,6 +388,7 @@ export async function completeOAuthFlow(code, verifier) { export default { getAuthorizationUrl, + extractCodeFromInput, startCallbackServer, exchangeCode, refreshAccessToken, diff --git a/src/cli/accounts.js b/src/cli/accounts.js index 9a7e3db..533aec8 100644 --- a/src/cli/accounts.js +++ b/src/cli/accounts.js @@ -25,7 +25,8 @@ import { startCallbackServer, completeOAuthFlow, refreshAccessToken, - getUserEmail + getUserEmail, + extractCodeFromInput } from '../auth/oauth.js'; const SERVER_PORT = process.env.PORT || DEFAULT_PORT; @@ -229,6 +230,63 @@ async function addAccount(existingAccounts) { } } +/** + * Add a new account via OAuth with manual code input (no-browser mode) + * For headless servers without a desktop environment + */ +async function addAccountNoBrowser(existingAccounts, rl) { + console.log('\n=== Add Google Account (No-Browser Mode) ===\n'); + + // Generate authorization URL + const { url, verifier, state } = getAuthorizationUrl(); + + console.log('Copy the following URL and open it in a browser on another device:\n'); + console.log(` ${url}\n`); + console.log('After signing in, you will be redirected to a localhost URL.'); + console.log('Copy the ENTIRE redirect URL or just the authorization code.\n'); + + const input = await rl.question('Paste the callback URL or authorization code: '); + + try { + const { code, extractedState } = extractCodeFromInput(input); + + // Validate state if present + if (extractedState && extractedState !== state) { + console.log('\n⚠ State mismatch detected. This could indicate a security issue.'); + console.log('Proceeding anyway as this is manual mode...'); + } + + console.log('\nExchanging authorization code for tokens...'); + const result = await completeOAuthFlow(code, verifier); + + // Check if account already exists + const existing = existingAccounts.find(a => a.email === result.email); + if (existing) { + console.log(`\n⚠ Account ${result.email} already exists. Updating tokens.`); + existing.refreshToken = result.refreshToken; + existing.projectId = result.projectId; + existing.addedAt = new Date().toISOString(); + return null; // Don't add duplicate + } + + console.log(`\n✓ Successfully authenticated: ${result.email}`); + if (result.projectId) { + console.log(` Project ID: ${result.projectId}`); + } + + return { + email: result.email, + refreshToken: result.refreshToken, + projectId: result.projectId, + addedAt: new Date().toISOString(), + modelRateLimits: {} + }; + } catch (error) { + console.error(`\n✗ Authentication failed: ${error.message}`); + return null; + } +} + /** * Interactive remove accounts flow */ @@ -275,8 +333,14 @@ async function interactiveRemove(rl) { /** * Interactive add accounts flow (Main Menu) + * @param {Object} rl - readline interface + * @param {boolean} noBrowser - if true, use manual code input mode */ -async function interactiveAdd(rl) { +async function interactiveAdd(rl, noBrowser = false) { + if (noBrowser) { + console.log('\n📋 No-browser mode: You will manually paste the authorization code.\n'); + } + const accounts = loadAccounts(); if (accounts.length > 0) { @@ -307,7 +371,11 @@ async function interactiveAdd(rl) { return; } - const newAccount = await addAccount(accounts); + // Use appropriate add function based on mode + const newAccount = noBrowser + ? await addAccountNoBrowser(accounts, rl) + : await addAccount(accounts); + if (newAccount) { accounts.push(newAccount); saveAccounts(accounts); @@ -388,6 +456,7 @@ async function verifyAccounts() { async function main() { const args = process.argv.slice(2); const command = args[0] || 'add'; + const noBrowser = args.includes('--no-browser'); console.log('╔════════════════════════════════════════╗'); console.log('║ Antigravity Proxy Account Manager ║'); @@ -399,7 +468,7 @@ async function main() { switch (command) { case 'add': await ensureServerStopped(); - await interactiveAdd(rl); + await interactiveAdd(rl, noBrowser); break; case 'list': await listAccounts(); @@ -418,6 +487,8 @@ async function main() { console.log(' node src/cli/accounts.js verify Verify account tokens'); console.log(' node src/cli/accounts.js clear Remove all accounts'); console.log(' node src/cli/accounts.js help Show this help'); + console.log('\nOptions:'); + console.log(' --no-browser Manual authorization code input (for headless servers)'); break; case 'remove': await ensureServerStopped(); diff --git a/tests/test-oauth-no-browser.cjs b/tests/test-oauth-no-browser.cjs new file mode 100644 index 0000000..c67c6f5 --- /dev/null +++ b/tests/test-oauth-no-browser.cjs @@ -0,0 +1,217 @@ +/** + * OAuth No-Browser Mode Unit Tests + * + * Tests the extractCodeFromInput() function which enables OAuth authentication + * on headless servers without a desktop environment. + * + * ============================================================================ + * FEATURE: --no-browser OAuth Mode + * ============================================================================ + * + * PURPOSE: + * Allow users to add Google accounts on remote servers (headless Linux, + * Docker containers, SSH sessions) where automatic browser opening is + * not possible. + * + * USAGE: + * npm run accounts:add -- --no-browser + * + * USER FLOW: + * 1. User runs command on headless server + * 2. System displays Google OAuth URL + * 3. User opens URL on another device (phone/laptop) with a browser + * 4. User signs in to Google and authorizes the app + * 5. Browser redirects to localhost (page won't load - this is expected) + * 6. User copies the redirect URL or authorization code from address bar + * 7. User pastes into server terminal + * 8. System extracts code using extractCodeFromInput() (tested here) + * 9. Account is added successfully + * + * FUNCTION UNDER TEST: + * extractCodeFromInput(input: string) => { code: string, state: string|null } + * + * Accepts either: + * - Full callback URL: http://localhost:51121/callback?code=xxx&state=yyy + * - Raw authorization code: 4/0AQSTgQG... + * + * Throws on: + * - Empty/null input + * - Too short input (< 10 chars) + * - URL with OAuth error parameter + * - URL without code parameter + * + * ============================================================================ + * + * Run: node tests/test-oauth-no-browser.cjs + */ + +// Note: Using dynamic import because oauth.js is ESM +async function runTests() { + console.log('='.repeat(60)); + console.log('OAUTH NO-BROWSER MODE UNIT TESTS'); + console.log('Testing: extractCodeFromInput()'); + console.log('='.repeat(60)); + console.log(''); + + // Import the ESM module + const { extractCodeFromInput } = await import('../src/auth/oauth.js'); + + let allPassed = true; + const results = []; + + /** + * Helper to run a single test case + * @param {string} name - Test name + * @param {Function} testFn - Test function that returns { passed, message } + */ + async function test(name, testFn) { + try { + const { passed, message } = await testFn(); + results.push({ name, passed, message }); + const status = passed ? 'PASS' : 'FAIL'; + console.log(` [${status}] ${name}`); + if (message) console.log(` ${message}`); + if (!passed) allPassed = false; + } catch (error) { + results.push({ name, passed: false, message: error.message }); + console.log(` [FAIL] ${name}`); + console.log(` Error: ${error.message}`); + allPassed = false; + } + } + + // ===== Test Group 1: Valid URL Inputs ===== + console.log('\n--- Valid URL Inputs ---'); + + await test('Parse full callback URL with code and state', () => { + const input = 'http://localhost:51121/oauth-callback?code=4/0AQSTg123&state=abc123'; + const result = extractCodeFromInput(input); + const passed = result.code === '4/0AQSTg123' && result.state === 'abc123'; + return { passed, message: `code=${result.code}, state=${result.state}` }; + }); + + await test('Parse URL with only code (no state)', () => { + const input = 'http://localhost:51121/oauth-callback?code=4/0AQSTg456'; + const result = extractCodeFromInput(input); + const passed = result.code === '4/0AQSTg456' && result.state === null; + return { passed, message: `code=${result.code}, state=${result.state}` }; + }); + + await test('Parse HTTPS URL', () => { + const input = 'https://localhost:51121/callback?code=secureCode123&state=xyz'; + const result = extractCodeFromInput(input); + const passed = result.code === 'secureCode123'; + return { passed, message: `code=${result.code}` }; + }); + + await test('Parse URL with additional query params', () => { + const input = 'http://localhost:51121/?code=myCode&state=myState&scope=email'; + const result = extractCodeFromInput(input); + const passed = result.code === 'myCode' && result.state === 'myState'; + return { passed, message: `code=${result.code}, state=${result.state}` }; + }); + + // ===== Test Group 2: Raw Code Inputs ===== + console.log('\n--- Raw Authorization Code Inputs ---'); + + await test('Parse raw authorization code (Google format)', () => { + const input = '4/0AQSTgQGxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'; + const result = extractCodeFromInput(input); + const passed = result.code === input && result.state === null; + return { passed, message: `code length=${result.code.length}` }; + }); + + await test('Parse raw code with whitespace (should trim)', () => { + const input = ' 4/0AQSTgQGcode123 \n'; + const result = extractCodeFromInput(input); + const passed = result.code === '4/0AQSTgQGcode123' && result.state === null; + return { passed, message: `trimmed code=${result.code}` }; + }); + + // ===== Test Group 3: Error Cases ===== + console.log('\n--- Error Handling ---'); + + await test('Throw on empty input', () => { + try { + extractCodeFromInput(''); + return { passed: false, message: 'Should have thrown' }; + } catch (e) { + return { passed: e.message.includes('No input'), message: e.message }; + } + }); + + await test('Throw on null input', () => { + try { + extractCodeFromInput(null); + return { passed: false, message: 'Should have thrown' }; + } catch (e) { + return { passed: e.message.includes('No input'), message: e.message }; + } + }); + + await test('Throw on too short code', () => { + try { + extractCodeFromInput('abc'); + return { passed: false, message: 'Should have thrown' }; + } catch (e) { + return { passed: e.message.includes('too short'), message: e.message }; + } + }); + + await test('Throw on OAuth error in URL', () => { + try { + const input = 'http://localhost:51121/?error=access_denied&error_description=User%20denied'; + extractCodeFromInput(input); + return { passed: false, message: 'Should have thrown' }; + } catch (e) { + return { passed: e.message.includes('OAuth error'), message: e.message }; + } + }); + + await test('Throw on URL without code param', () => { + try { + extractCodeFromInput('http://localhost:51121/callback?state=onlyState'); + return { passed: false, message: 'Should have thrown' }; + } catch (e) { + return { passed: e.message.includes('No authorization code'), message: e.message }; + } + }); + + // ===== Test Group 4: Edge Cases ===== + console.log('\n--- Edge Cases ---'); + + await test('Handle URL-encoded characters in code', () => { + const input = 'http://localhost:51121/?code=4%2F0AQSTg%2B%2B&state=test'; + const result = extractCodeFromInput(input); + // URL class automatically decodes + const passed = result.code === '4/0AQSTg++'; + return { passed, message: `decoded code=${result.code}` }; + }); + + await test('Accept minimum valid code length (10 chars)', () => { + const input = '1234567890'; + const result = extractCodeFromInput(input); + const passed = result.code === input; + return { passed, message: `code=${result.code}` }; + }); + + // ===== Summary ===== + console.log('\n' + '='.repeat(60)); + console.log('SUMMARY'); + console.log('='.repeat(60)); + + const passed = results.filter(r => r.passed).length; + const failed = results.filter(r => !r.passed).length; + console.log(` Total: ${results.length} | Passed: ${passed} | Failed: ${failed}`); + + console.log('\n' + '='.repeat(60)); + console.log(allPassed ? 'ALL TESTS PASSED' : 'SOME TESTS FAILED'); + console.log('='.repeat(60)); + + process.exit(allPassed ? 0 : 1); +} + +runTests().catch(err => { + console.error('Test suite failed:', err); + process.exit(1); +}); From 1628696ca1ab941038e69dbf28beaf3dd76c159b Mon Sep 17 00:00:00 2001 From: Badri Narayanan S Date: Sun, 4 Jan 2026 14:51:05 +0530 Subject: [PATCH 2/2] fix: correct state destructuring and document headless mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix extractCodeFromInput destructuring: returns { code, state } not { code, extractedState }, so state validation was being bypassed - Add --no-browser hint to CLI banner for discoverability - Document --no-browser mode in README.md and CLAUDE.md - Add test:oauth script to package.json - Add OAuth test to run-all.cjs test suite 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- CLAUDE.md | 2 ++ README.md | 15 +++++++++++++++ package.json | 3 ++- src/cli/accounts.js | 3 ++- tests/run-all.cjs | 3 ++- 5 files changed, 23 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 70c7fb2..6aae1b3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,6 +29,7 @@ npm run dev # Account management npm run accounts # Interactive account management npm run accounts:add # Add a new Google account via OAuth +npm run accounts:add -- --no-browser # Add account on headless server (manual code input) npm run accounts:list # List configured accounts npm run accounts:verify # Verify account tokens are valid @@ -43,6 +44,7 @@ npm run test:interleaved # Interleaved thinking npm run test:images # Image processing npm run test:caching # Prompt caching npm run test:crossmodel # Cross-model thinking signatures +npm run test:oauth # OAuth no-browser mode ``` ## Architecture diff --git a/README.md b/README.md index e5aea5d..863d3b4 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,21 @@ npm run accounts:add This opens your browser for Google OAuth. Sign in and authorize access. Repeat for multiple accounts. +**Headless Servers (Docker, SSH, no desktop):** + +```bash +# Use --no-browser mode for servers without a browser +antigravity-claude-proxy accounts add --no-browser + +# Or with npx +npx antigravity-claude-proxy accounts add -- --no-browser + +# Or if cloned locally +npm run accounts:add -- --no-browser +``` + +This displays an OAuth URL you can open on another device (phone/laptop). After signing in, copy the redirect URL or authorization code and paste it back into the terminal. + Manage accounts: ```bash diff --git a/package.json b/package.json index 365945e..432dcfd 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,8 @@ "test:interleaved": "node tests/test-interleaved-thinking.cjs", "test:images": "node tests/test-images.cjs", "test:caching": "node tests/test-caching-streaming.cjs", - "test:crossmodel": "node tests/test-cross-model-thinking.cjs" + "test:crossmodel": "node tests/test-cross-model-thinking.cjs", + "test:oauth": "node tests/test-oauth-no-browser.cjs" }, "keywords": [ "claude", diff --git a/src/cli/accounts.js b/src/cli/accounts.js index 533aec8..7cb000e 100644 --- a/src/cli/accounts.js +++ b/src/cli/accounts.js @@ -248,7 +248,7 @@ async function addAccountNoBrowser(existingAccounts, rl) { const input = await rl.question('Paste the callback URL or authorization code: '); try { - const { code, extractedState } = extractCodeFromInput(input); + const { code, state: extractedState } = extractCodeFromInput(input); // Validate state if present if (extractedState && extractedState !== state) { @@ -460,6 +460,7 @@ async function main() { console.log('╔════════════════════════════════════════╗'); console.log('║ Antigravity Proxy Account Manager ║'); + console.log('║ Use --no-browser for headless mode ║'); console.log('╚════════════════════════════════════════╝'); const rl = createRL(); diff --git a/tests/run-all.cjs b/tests/run-all.cjs index 709a2ac..07fa56a 100644 --- a/tests/run-all.cjs +++ b/tests/run-all.cjs @@ -14,7 +14,8 @@ const tests = [ { name: 'Multi-turn Tools (Streaming)', file: 'test-multiturn-thinking-tools-streaming.cjs' }, { name: 'Interleaved Thinking', file: 'test-interleaved-thinking.cjs' }, { name: 'Image Support', file: 'test-images.cjs' }, - { name: 'Prompt Caching', file: 'test-caching-streaming.cjs' } + { name: 'Prompt Caching', file: 'test-caching-streaming.cjs' }, + { name: 'OAuth No-Browser Mode', file: 'test-oauth-no-browser.cjs' } ]; async function runTest(test) {