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
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

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.
**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

View File

@@ -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",

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
* 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,

View File

@@ -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, 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
*/
@@ -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,9 +456,11 @@ 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 ║');
console.log('║ Use --no-browser for headless mode ║');
console.log('╚════════════════════════════════════════╝');
const rl = createRL();
@@ -399,7 +469,7 @@ async function main() {
switch (command) {
case 'add':
await ensureServerStopped();
await interactiveAdd(rl);
await interactiveAdd(rl, noBrowser);
break;
case 'list':
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 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();

View File

@@ -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) {

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