Merge pull request #50 from badri-s2001/feature/headless-server

feat: add --no-browser OAuth mode for headless servers
This commit is contained in:
Badri Narayanan S
2026-01-04 14:53:08 +05:30
committed by GitHub
7 changed files with 365 additions and 6 deletions

View File

@@ -29,6 +29,7 @@ npm run dev
# Account management # Account management
npm run accounts # Interactive account management npm run accounts # Interactive account management
npm run accounts:add # Add a new Google account via OAuth 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:list # List configured accounts
npm run accounts:verify # Verify account tokens are valid 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:images # Image processing
npm run test:caching # Prompt caching npm run test:caching # Prompt caching
npm run test:crossmodel # Cross-model thinking signatures npm run test:crossmodel # Cross-model thinking signatures
npm run test:oauth # OAuth no-browser mode
``` ```
## Architecture ## Architecture

View File

@@ -84,6 +84,21 @@ npm run accounts:add
This opens your browser for Google OAuth. Sign in and authorize access. Repeat for multiple accounts. 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: Manage accounts:
```bash ```bash

View File

@@ -26,7 +26,8 @@
"test:interleaved": "node tests/test-interleaved-thinking.cjs", "test:interleaved": "node tests/test-interleaved-thinking.cjs",
"test:images": "node tests/test-images.cjs", "test:images": "node tests/test-images.cjs",
"test:caching": "node tests/test-caching-streaming.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": [ "keywords": [
"claude", "claude",

View File

@@ -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 * Start a local server to receive the OAuth callback
* Returns a promise that resolves with the authorization code * Returns a promise that resolves with the authorization code
@@ -338,6 +388,7 @@ export async function completeOAuthFlow(code, verifier) {
export default { export default {
getAuthorizationUrl, getAuthorizationUrl,
extractCodeFromInput,
startCallbackServer, startCallbackServer,
exchangeCode, exchangeCode,
refreshAccessToken, refreshAccessToken,

View File

@@ -25,7 +25,8 @@ import {
startCallbackServer, startCallbackServer,
completeOAuthFlow, completeOAuthFlow,
refreshAccessToken, refreshAccessToken,
getUserEmail getUserEmail,
extractCodeFromInput
} from '../auth/oauth.js'; } from '../auth/oauth.js';
const SERVER_PORT = process.env.PORT || DEFAULT_PORT; 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, state: 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 * Interactive remove accounts flow
*/ */
@@ -275,8 +333,14 @@ async function interactiveRemove(rl) {
/** /**
* Interactive add accounts flow (Main Menu) * 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(); const accounts = loadAccounts();
if (accounts.length > 0) { if (accounts.length > 0) {
@@ -307,7 +371,11 @@ async function interactiveAdd(rl) {
return; 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) { if (newAccount) {
accounts.push(newAccount); accounts.push(newAccount);
saveAccounts(accounts); saveAccounts(accounts);
@@ -388,9 +456,11 @@ async function verifyAccounts() {
async function main() { async function main() {
const args = process.argv.slice(2); const args = process.argv.slice(2);
const command = args[0] || 'add'; const command = args[0] || 'add';
const noBrowser = args.includes('--no-browser');
console.log('╔════════════════════════════════════════╗'); console.log('╔════════════════════════════════════════╗');
console.log('║ Antigravity Proxy Account Manager ║'); console.log('║ Antigravity Proxy Account Manager ║');
console.log('║ Use --no-browser for headless mode ║');
console.log('╚════════════════════════════════════════╝'); console.log('╚════════════════════════════════════════╝');
const rl = createRL(); const rl = createRL();
@@ -399,7 +469,7 @@ async function main() {
switch (command) { switch (command) {
case 'add': case 'add':
await ensureServerStopped(); await ensureServerStopped();
await interactiveAdd(rl); await interactiveAdd(rl, noBrowser);
break; break;
case 'list': case 'list':
await listAccounts(); await listAccounts();
@@ -418,6 +488,8 @@ async function main() {
console.log(' node src/cli/accounts.js verify Verify account tokens'); 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 clear Remove all accounts');
console.log(' node src/cli/accounts.js help Show this help'); 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; break;
case 'remove': case 'remove':
await ensureServerStopped(); await ensureServerStopped();

View File

@@ -14,7 +14,8 @@ const tests = [
{ name: 'Multi-turn Tools (Streaming)', file: 'test-multiturn-thinking-tools-streaming.cjs' }, { name: 'Multi-turn Tools (Streaming)', file: 'test-multiturn-thinking-tools-streaming.cjs' },
{ name: 'Interleaved Thinking', file: 'test-interleaved-thinking.cjs' }, { name: 'Interleaved Thinking', file: 'test-interleaved-thinking.cjs' },
{ name: 'Image Support', file: 'test-images.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) { async function runTest(test) {

View File

@@ -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);
});