mirror of
https://github.com/tiennm99/ccs.git
synced 2026-08-06 18:22:08 +00:00
Merge pull request #281 from kaitranntt/fix/codex-auth-port-map
fix(cliproxy): add missing OAuth callback ports for codex, agy, iflow
This commit is contained in:
@@ -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<string, ActiveAuthSession>();
|
||||
|
||||
// H8: Periodic cleanup of stale sessions (prevents memory leak from orphaned sessions)
|
||||
let cleanupInterval: ReturnType<typeof setInterval> | 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Record<CLIProxyProvider, number>> = {
|
||||
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
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<Accou
|
||||
env: { ...process.env, CLI_PROXY_AUTH_DIR: tokenDir },
|
||||
});
|
||||
|
||||
// H7: Mutable ref for stdin keepalive interval (set later, needed in cleanup)
|
||||
let stdinKeepalive: ReturnType<typeof setInterval> | 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<Accou
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
// H7: Stdin keepalive for Authorization Code flows
|
||||
// CLIProxyAPIPlus has a 15-second timer that prompts for manual URL paste.
|
||||
// If the user completes browser auth after this timer fires but before the
|
||||
// non-blocking check, the prompt blocks forever on stdin.
|
||||
// Workaround: Send newline every 16s to skip the manual prompt and continue polling.
|
||||
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,11 +417,14 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise<Accou
|
||||
// Timeout handling
|
||||
const timeoutMs = headless ? 300000 : 120000;
|
||||
const timeout = setTimeout(() => {
|
||||
// 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<Accou
|
||||
|
||||
authProcess.on('exit', async (code) => {
|
||||
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<Accou
|
||||
|
||||
authProcess.on('error', (error) => {
|
||||
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);
|
||||
|
||||
@@ -31,7 +31,7 @@ export const OAUTH_CALLBACK_PORTS: Record<CLIProxyProvider, number | null> = {
|
||||
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<CLIProxyProvider, OAuthFlowType> = {
|
||||
codex: 'authorization_code',
|
||||
agy: 'authorization_code',
|
||||
qwen: 'device_code',
|
||||
iflow: 'device_code',
|
||||
iflow: 'authorization_code',
|
||||
kiro: 'authorization_code',
|
||||
ghcp: 'device_code',
|
||||
};
|
||||
|
||||
+10
-7
@@ -218,13 +218,16 @@ export async function testLocalhostBinding(port: number): Promise<BindingTestRes
|
||||
const server = net.createServer();
|
||||
|
||||
server.once('error', (err: NodeJS.ErrnoException) => {
|
||||
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', () => {
|
||||
|
||||
Reference in New Issue
Block a user