diff --git a/README.md b/README.md index 5658dff..0a8e514 100644 --- a/README.md +++ b/README.md @@ -435,6 +435,64 @@ npm run test:cache-control # Cache control field stripping ## Troubleshooting +### Windows: OAuth Port Error (EACCES) + +On Windows, the default OAuth callback port (51121) may be reserved by Hyper-V, WSL2, or Docker. If you see: + +``` +Error: listen EACCES: permission denied 0.0.0.0:51121 +``` + +The proxy will automatically try fallback ports (51122-51126). If all ports fail, try these solutions: + +#### Option 1: Use a Custom Port (Recommended) + +Set a custom port outside the reserved range: + +```bash +# Windows PowerShell +$env:OAUTH_CALLBACK_PORT = "3456" +antigravity-claude-proxy start + +# Windows CMD +set OAUTH_CALLBACK_PORT=3456 +antigravity-claude-proxy start + +# Or add to your .env file +OAUTH_CALLBACK_PORT=3456 +``` + +#### Option 2: Reset Windows NAT + +Run as Administrator: + +```powershell +net stop winnat +net start winnat +``` + +#### Option 3: Check Reserved Ports + +See which ports are reserved: + +```powershell +netsh interface ipv4 show excludedportrange protocol=tcp +``` + +If 51121 is in a reserved range, use Option 1 with a port outside those ranges. + +#### Option 4: Permanently Exclude Port (Admin) + +Reserve the port before Hyper-V claims it (run as Administrator): + +```powershell +netsh int ipv4 add excludedportrange protocol=tcp startport=51121 numberofports=1 +``` + +> **Note:** The server automatically tries fallback ports (51122-51126) if the primary port fails. + +--- + ### "Could not extract token from Antigravity" If using single-account mode with Antigravity: diff --git a/config.example.json b/config.example.json index bfcd8cf..17c5d67 100644 --- a/config.example.json +++ b/config.example.json @@ -36,6 +36,9 @@ "maxAccounts": 10, "_maxAccounts_comment": "Maximum number of Google accounts allowed (1-100). Default: 10.", + "_oauthCallbackPort_comment": "OAuth callback server port. Change if you get EACCES errors on Windows. Can also use OAUTH_CALLBACK_PORT env var. Default: 51121.", + "_oauthCallbackPort_env": "OAUTH_CALLBACK_PORT=3456", + "_profiles": { "development": { "debug": true, diff --git a/src/auth/oauth.js b/src/auth/oauth.js index 502d75d..b60f6f9 100644 --- a/src/auth/oauth.js +++ b/src/auth/oauth.js @@ -137,22 +137,50 @@ export function extractCodeFromInput(input) { return { code: trimmed, state: null }; } +/** + * Attempt to bind server to a specific port + * @param {http.Server} server - HTTP server instance + * @param {number} port - Port to bind to + * @returns {Promise} Resolves with port on success, rejects on error + */ +function tryBindPort(server, port) { + return new Promise((resolve, reject) => { + const onError = (err) => { + server.removeListener('listening', onSuccess); + reject(err); + }; + const onSuccess = () => { + server.removeListener('error', onError); + resolve(port); + }; + server.once('error', onError); + server.once('listening', onSuccess); + server.listen(port); + }); +} + /** * Start a local server to receive the OAuth callback + * Implements automatic port fallback for Windows compatibility (issue #176) * Returns an object with a promise and an abort function * * @param {string} expectedState - Expected state parameter for CSRF protection * @param {number} timeoutMs - Timeout in milliseconds (default 120000) - * @returns {{promise: Promise, abort: Function}} Object with promise and abort function + * @returns {{promise: Promise, abort: Function, getPort: Function}} Object with promise, abort, and getPort functions */ export function startCallbackServer(expectedState, timeoutMs = 120000) { let server = null; let timeoutId = null; let isAborted = false; + let actualPort = OAUTH_CONFIG.callbackPort; + + const promise = new Promise(async (resolve, reject) => { + // Build list of ports to try: primary + fallbacks + const portsToTry = [OAUTH_CONFIG.callbackPort, ...(OAUTH_CONFIG.callbackFallbackPorts || [])]; + const errors = []; - const promise = new Promise((resolve, reject) => { server = http.createServer((req, res) => { - const url = new URL(req.url, `http://localhost:${OAUTH_CONFIG.callbackPort}`); + const url = new URL(req.url, `http://localhost:${actualPort}`); if (url.pathname !== '/oauth-callback') { res.writeHead(404); @@ -232,17 +260,60 @@ export function startCallbackServer(expectedState, timeoutMs = 120000) { resolve(code); }); - server.on('error', (err) => { - if (err.code === 'EADDRINUSE') { - reject(new Error(`Port ${OAUTH_CONFIG.callbackPort} is already in use. Close any other OAuth flows and try again.`)); - } else { - reject(err); - } - }); + // Try ports with fallback logic (issue #176 - Windows EACCES fix) + let boundSuccessfully = false; + for (const port of portsToTry) { + try { + await tryBindPort(server, port); + actualPort = port; + boundSuccessfully = true; - server.listen(OAUTH_CONFIG.callbackPort, () => { - logger.info(`[OAuth] Callback server listening on port ${OAUTH_CONFIG.callbackPort}`); - }); + if (port !== OAUTH_CONFIG.callbackPort) { + logger.warn(`[OAuth] Primary port ${OAUTH_CONFIG.callbackPort} unavailable, using fallback port ${port}`); + } else { + logger.info(`[OAuth] Callback server listening on port ${port}`); + } + break; + } catch (err) { + const errMsg = err.code === 'EACCES' + ? `Permission denied on port ${port}` + : err.code === 'EADDRINUSE' + ? `Port ${port} already in use` + : `Failed to bind port ${port}: ${err.message}`; + errors.push(errMsg); + logger.warn(`[OAuth] ${errMsg}`); + } + } + + if (!boundSuccessfully) { + // All ports failed - provide helpful error message + const isWindows = process.platform === 'win32'; + let errorMsg = `Failed to start OAuth callback server.\nTried ports: ${portsToTry.join(', ')}\n\nErrors:\n${errors.join('\n')}`; + + if (isWindows) { + errorMsg += `\n +================== WINDOWS TROUBLESHOOTING ================== +The default port range may be reserved by Hyper-V/WSL2/Docker. + +Option 1: Use a custom port + Set OAUTH_CALLBACK_PORT=3456 in your environment or .env file + +Option 2: Reset Windows NAT (run as Administrator) + net stop winnat && net start winnat + +Option 3: Check reserved port ranges + netsh interface ipv4 show excludedportrange protocol=tcp + +Option 4: Exclude port from reservation (run as Administrator) + netsh int ipv4 add excludedportrange protocol=tcp startport=51121 numberofports=1 +==============================================================`; + } else { + errorMsg += `\n\nTry setting a custom port: OAUTH_CALLBACK_PORT=3456`; + } + + reject(new Error(errorMsg)); + return; + } // Timeout after specified duration timeoutId = setTimeout(() => { @@ -266,7 +337,10 @@ export function startCallbackServer(expectedState, timeoutMs = 120000) { } }; - return { promise, abort }; + // Get actual port (useful when fallback is used) + const getPort = () => actualPort; + + return { promise, abort, getPort }; } /** diff --git a/src/constants.js b/src/constants.js index 1bf8a2d..5433d1c 100644 --- a/src/constants.js +++ b/src/constants.js @@ -188,13 +188,19 @@ export function isThinkingModel(modelName) { } // Google OAuth configuration (from opencode-antigravity-auth) +// OAuth callback port - configurable via environment variable for Windows compatibility (issue #176) +// Windows may reserve ports in range 49152-65535 for Hyper-V/WSL2/Docker, causing EACCES errors +const OAUTH_CALLBACK_PORT = parseInt(process.env.OAUTH_CALLBACK_PORT || '51121', 10); +const OAUTH_CALLBACK_FALLBACK_PORTS = [51122, 51123, 51124, 51125, 51126]; + export const OAUTH_CONFIG = { clientId: '1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com', clientSecret: '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', authUrl: 'https://accounts.google.com/o/oauth2/v2/auth', tokenUrl: 'https://oauth2.googleapis.com/token', userInfoUrl: 'https://www.googleapis.com/oauth2/v1/userinfo', - callbackPort: 51121, + callbackPort: OAUTH_CALLBACK_PORT, + callbackFallbackPorts: OAUTH_CALLBACK_FALLBACK_PORTS, scopes: [ 'https://www.googleapis.com/auth/cloud-platform', 'https://www.googleapis.com/auth/userinfo.email',