From cfe604a97c5ef79fbfb1f020579e0b5541d49b27 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Tue, 6 Jan 2026 10:30:12 -0500 Subject: [PATCH 1/3] fix(cliproxy): add missing OAuth callback ports for codex, agy, iflow Port cleanup before OAuth was skipped for providers whose ports were only in comments. This caused hanging when stale processes blocked the callback port from previous auth attempts. Changes: - auth-types.ts: Add codex (1455), agy (51121), iflow (11451) to map - oauth-port-diagnostics.ts: Update iflow from device_code to auth_code flow - Add Claude (54545) to doc comments for future reference --- src/cliproxy/auth/auth-types.ts | 13 ++++++++----- src/management/oauth-port-diagnostics.ts | 4 ++-- 2 files changed, 10 insertions(+), 7 deletions(-) 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/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', }; From 0557f93f2fdb17972324f05c9e216785f893ad16 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Tue, 6 Jan 2026 10:49:51 -0500 Subject: [PATCH 2/3] fix(oauth): add stdin keepalive to prevent blocking on manual URL prompt CLIProxyAPIPlus has a 15-second timer that prompts for manual URL paste. If user completes browser auth after this timer fires but before the non-blocking check, the prompt blocks forever on stdin since CCS pipes stdin but doesn't write to it. Workaround: Send newline every 16s for authorization code flows to skip the manual prompt and continue polling for callback. --- src/cliproxy/auth/oauth-process.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/cliproxy/auth/oauth-process.ts b/src/cliproxy/auth/oauth-process.ts index c09a9536..2eb1cbdb 100644 --- a/src/cliproxy/auth/oauth-process.ts +++ b/src/cliproxy/auth/oauth-process.ts @@ -347,6 +347,21 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise | null = null; + if (!isDeviceCodeFlow && stdinMode === 'pipe') { + stdinKeepalive = setInterval(() => { + 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,6 +408,8 @@ 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); @@ -409,6 +426,8 @@ 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); @@ -462,6 +481,8 @@ 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); From 472497fb0324a92993b2a7e7fd27c8f071a9e7c6 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Tue, 6 Jan 2026 11:31:40 -0500 Subject: [PATCH 3/3] fix(oauth): harden cleanup for edge cases in auth process - Clear stdinKeepalive interval on SIGINT/SIGTERM signal handlers - Add cancelProjectSelection() to timeout/exit/error handlers - Close server on error path in testLocalhostBinding to prevent fd leak - Add TTL-based cleanup for stale auth sessions (10 min expiry) - Use DEVICE_CODE_TIMEOUT_MS constant instead of hardcoded value (DRY) --- src/cliproxy/auth-session-manager.ts | 30 ++++++++++++++++++++++++++++ src/cliproxy/auth/oauth-process.ts | 18 ++++++++++++++--- src/utils/port-utils.ts | 17 +++++++++------- 3 files changed, 55 insertions(+), 10 deletions(-) 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/oauth-process.ts b/src/cliproxy/auth/oauth-process.ts index 2eb1cbdb..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'); } @@ -352,7 +362,6 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise | null = null; if (!isDeviceCodeFlow && stdinMode === 'pipe') { stdinKeepalive = setInterval(() => { if (authProcess.stdin && !authProcess.stdin.destroyed) { @@ -415,6 +424,7 @@ export function executeOAuthProcess(options: OAuthProcessOptions): 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', () => {