diff --git a/src/cliproxy/auth-session-manager.ts b/src/cliproxy/auth-session-manager.ts index ce718be9..0ad0dd7c 100644 --- a/src/cliproxy/auth-session-manager.ts +++ b/src/cliproxy/auth-session-manager.ts @@ -8,6 +8,9 @@ import { EventEmitter } from 'events'; import { ChildProcess } from 'child_process'; +// H8: TTL for stale session cleanup (10 minutes - generous for OAuth flows) +const SESSION_TTL_MS = 10 * 60 * 1000; + export interface ActiveAuthSession { sessionId: string; provider: string; @@ -19,6 +22,31 @@ export const authSessionEvents = new EventEmitter(); const activeSessions = new Map(); +// H8: Periodic cleanup of stale sessions (prevents memory leak from orphaned sessions) +let cleanupInterval: ReturnType | null = null; + +function startCleanupInterval(): void { + if (cleanupInterval) return; + cleanupInterval = setInterval(() => { + const now = Date.now(); + for (const [sessionId, session] of activeSessions.entries()) { + if (now - session.startedAt > SESSION_TTL_MS) { + // Stale session - kill process if still running, then remove + if (session.process && !session.process.killed) { + session.process.kill('SIGTERM'); + } + activeSessions.delete(sessionId); + authSessionEvents.emit('session:expired', sessionId); + } + } + // Stop interval if no active sessions + if (activeSessions.size === 0 && cleanupInterval) { + clearInterval(cleanupInterval); + cleanupInterval = null; + } + }, 60000); // Check every minute +} + /** * Register an active OAuth session */ @@ -33,6 +61,8 @@ export function registerAuthSession( startedAt: Date.now(), process, }); + // H8: Start TTL cleanup when first session registered + startCleanupInterval(); authSessionEvents.emit('session:started', sessionId, provider); } diff --git a/src/cliproxy/auth/auth-types.ts b/src/cliproxy/auth/auth-types.ts index 927c2e2a..42e3cc9e 100644 --- a/src/cliproxy/auth/auth-types.ts +++ b/src/cliproxy/auth/auth-types.ts @@ -15,17 +15,20 @@ import { AccountInfo } from '../account-manager'; * - Gemini: Authorization Code Flow with local callback server on port 8085 * - Codex: Authorization Code Flow with local callback server on port 1455 * - Agy: Authorization Code Flow with local callback server on port 51121 - * - Qwen: Device Code Flow (polling-based, NO callback port needed) * - Kiro: Authorization Code Flow with local callback server on port 9876 + * - iFlow: Authorization Code Flow with local callback server on port 11451 + * - Claude: Authorization Code Flow with local callback server on port 54545 (Anthropic OAuth) + * - Qwen: Device Code Flow (polling-based, NO callback port needed) * - GHCP: Device Code Flow (polling-based, NO callback port needed) */ export const OAUTH_CALLBACK_PORTS: Partial> = { gemini: 8085, kiro: 9876, - // codex uses 1455 - // agy uses 51121 - // qwen uses Device Code Flow - no callback port needed - // ghcp uses Device Code Flow - no callback port needed + codex: 1455, + agy: 51121, + iflow: 11451, + // qwen: Device Code Flow - no callback port + // ghcp: Device Code Flow - no callback port }; /** diff --git a/src/cliproxy/auth/oauth-process.ts b/src/cliproxy/auth/oauth-process.ts index c09a9536..6b5cb997 100644 --- a/src/cliproxy/auth/oauth-process.ts +++ b/src/cliproxy/auth/oauth-process.ts @@ -17,13 +17,18 @@ import { isProjectList, generateSessionId, requestProjectSelection, + cancelProjectSelection, type GCloudProject, type ProjectSelectionPrompt, } from '../project-selection-handler'; import { ProviderOAuthConfig } from './auth-types'; import { getTimeoutTroubleshooting, showStep } from './environment-detector'; import { isAuthenticated, registerAccountFromToken } from './token-manager'; -import { deviceCodeEvents, type DeviceCodePrompt } from '../device-code-handler'; +import { + deviceCodeEvents, + DEVICE_CODE_TIMEOUT_MS, + type DeviceCodePrompt, +} from '../device-code-handler'; import { OAUTH_FLOW_TYPES } from '../../management'; import { registerAuthSession, @@ -150,7 +155,7 @@ async function handleStdout( provider: options.provider, userCode: state.userCode, verificationUrl, - expiresAt: Date.now() + 900000, // 15 minutes + expiresAt: Date.now() + DEVICE_CODE_TIMEOUT_MS, }; deviceCodeEvents.emit('deviceCode:received', deviceCodePrompt); @@ -311,8 +316,13 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise | null = null; + // H5: Signal handling - properly kill child process on SIGINT/SIGTERM + // H8: Also clear stdinKeepalive interval to prevent memory leak const cleanup = () => { + if (stdinKeepalive) clearInterval(stdinKeepalive); if (authProcess && !authProcess.killed) { authProcess.kill('SIGTERM'); } @@ -347,6 +357,20 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise { + if (authProcess.stdin && !authProcess.stdin.destroyed) { + authProcess.stdin.write('\n'); + log('Sent stdin keepalive (skip manual URL prompt)'); + } + }, 16000); + } + authProcess.stdout?.on('data', async (data: Buffer) => { await handleStdout(data.toString(), state, options, authProcess, log); }); @@ -393,11 +417,14 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise { + // H7: Clear stdin keepalive interval + if (stdinKeepalive) clearInterval(stdinKeepalive); // H5: Remove signal handlers before killing process process.removeListener('SIGINT', cleanup); process.removeListener('SIGTERM', cleanup); authSessionEvents.removeListener('session:cancelled', handleCancel); unregisterAuthSession(state.sessionId); + cancelProjectSelection(state.sessionId); authProcess.kill(); console.log(''); console.log(fail(`OAuth timed out after ${headless ? 5 : 2} minutes`)); @@ -409,11 +436,14 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise { clearTimeout(timeout); + // H7: Clear stdin keepalive interval + if (stdinKeepalive) clearInterval(stdinKeepalive); // H5: Remove signal handlers to prevent memory leaks process.removeListener('SIGINT', cleanup); process.removeListener('SIGTERM', cleanup); authSessionEvents.removeListener('session:cancelled', handleCancel); unregisterAuthSession(state.sessionId); + cancelProjectSelection(state.sessionId); const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); if (code === 0) { @@ -462,11 +492,14 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise { clearTimeout(timeout); + // H7: Clear stdin keepalive interval + if (stdinKeepalive) clearInterval(stdinKeepalive); // H5: Remove signal handlers to prevent memory leaks process.removeListener('SIGINT', cleanup); process.removeListener('SIGTERM', cleanup); authSessionEvents.removeListener('session:cancelled', handleCancel); unregisterAuthSession(state.sessionId); + cancelProjectSelection(state.sessionId); console.log(''); console.log(fail(`Failed to start auth process: ${error.message}`)); resolve(null); diff --git a/src/management/oauth-port-diagnostics.ts b/src/management/oauth-port-diagnostics.ts index e3d28317..262d963d 100644 --- a/src/management/oauth-port-diagnostics.ts +++ b/src/management/oauth-port-diagnostics.ts @@ -31,7 +31,7 @@ export const OAUTH_CALLBACK_PORTS: Record = { codex: 1455, agy: 51121, qwen: null, // Device Code Flow - no callback port - iflow: null, // Device Code Flow - no callback port + iflow: 11451, // Authorization Code Flow kiro: 9876, // Authorization Code Flow ghcp: null, // Device Code Flow - no callback port }; @@ -49,7 +49,7 @@ export const OAUTH_FLOW_TYPES: Record = { codex: 'authorization_code', agy: 'authorization_code', qwen: 'device_code', - iflow: 'device_code', + iflow: 'authorization_code', kiro: 'authorization_code', ghcp: 'device_code', }; diff --git a/src/utils/port-utils.ts b/src/utils/port-utils.ts index 31f87208..4ddb9313 100644 --- a/src/utils/port-utils.ts +++ b/src/utils/port-utils.ts @@ -218,13 +218,16 @@ export async function testLocalhostBinding(port: number): Promise { - if (err.code === 'EADDRINUSE') { - resolve({ success: false, message: `Port ${port} is already in use` }); - } else if (err.code === 'EACCES') { - resolve({ success: false, message: `Permission denied for port ${port}` }); - } else { - resolve({ success: false, message: `Cannot bind to port ${port}: ${err.message}` }); - } + // H8: Close server to prevent fd leak on error path + server.close(() => { + if (err.code === 'EADDRINUSE') { + resolve({ success: false, message: `Port ${port} is already in use` }); + } else if (err.code === 'EACCES') { + resolve({ success: false, message: `Permission denied for port ${port}` }); + } else { + resolve({ success: false, message: `Cannot bind to port ${port}: ${err.message}` }); + } + }); }); server.once('listening', () => {