diff --git a/src/cliproxy/auth-handler.ts b/src/cliproxy/auth-handler.ts index b854312f..23b92fd2 100644 --- a/src/cliproxy/auth-handler.ts +++ b/src/cliproxy/auth-handler.ts @@ -27,6 +27,7 @@ import { registerAccount, touchAccount, } from './account-manager'; +import { preflightOAuthCheck } from '../management/oauth-port-diagnostics'; /** * OAuth callback ports used by CLIProxyAPI (hardcoded in binary) @@ -90,26 +91,50 @@ function killProcessOnPort(port: number, verbose: boolean): boolean { /** * Detect if running in a headless environment (no browser available) + * + * IMPROVED: Avoids false positives on Windows desktop environments + * where isTTY may be undefined due to terminal wrapper behavior. + * + * Case study: Vietnamese Windows users reported "command hangs" because + * their terminal (PowerShell via npm) didn't set isTTY correctly. */ function isHeadlessEnvironment(): boolean { - // SSH session + // SSH session - always headless if (process.env.SSH_TTY || process.env.SSH_CLIENT || process.env.SSH_CONNECTION) { return true; } - // No display (Linux/X11) + // No display on Linux (X11/Wayland) - headless if (process.platform === 'linux' && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) { return true; } - // Non-interactive (piped stdin) - skip on Windows - // Windows npm wrappers don't set isTTY correctly (returns undefined, not true) + // Windows desktop - NEVER headless unless SSH (already checked above) + // This fixes false positive where Windows npm wrappers don't set isTTY correctly // Windows desktop environments always have browser capability - if (process.platform !== 'win32' && !process.stdin.isTTY) { - return true; + if (process.platform === 'win32') { + return false; } - return false; + // macOS - check for proper terminal + if (process.platform === 'darwin') { + // Non-interactive stdin on macOS means likely piped/scripted + if (!process.stdin.isTTY) { + return true; + } + return false; + } + + // Linux with display - check TTY + if (process.platform === 'linux') { + if (!process.stdin.isTTY) { + return true; + } + return false; + } + + // Default fallback for unknown platforms + return !process.stdin.isTTY; } /** @@ -422,6 +447,19 @@ export async function triggerOAuth( } }; + // Pre-flight check: verify OAuth callback port is available + const preflight = await preflightOAuthCheck(provider); + if (!preflight.ready) { + console.log(''); + console.log('[!] OAuth pre-flight check failed:'); + for (const issue of preflight.issues) { + console.log(` ${issue}`); + } + console.log(''); + console.log('[i] Resolve the port conflict and try again.'); + return null; + } + // Ensure binary exists let binaryPath: string; try { diff --git a/src/management/doctor.ts b/src/management/doctor.ts index 6f4058a3..cfb101f9 100644 --- a/src/management/doctor.ts +++ b/src/management/doctor.ts @@ -19,6 +19,8 @@ import { CLIPROXY_DEFAULT_PORT, } from '../cliproxy'; import { getPortProcess, isCLIProxyProcess } from '../utils/port-utils'; +import { getEnvironmentDiagnostics } from './environment-diagnostics'; +import { checkAuthCodePorts } from './oauth-port-diagnostics'; // Make ora optional (might not be available during npm install postinstall) // ora v9+ is an ES module, need to use .default for CommonJS @@ -146,31 +148,41 @@ class Doctor { this.checkCcsDirectory(); console.log(''); - // Group 2: Configuration + // Group 2: Environment (NEW - OAuth readiness diagnostics) + console.log(header('ENVIRONMENT')); + this.checkEnvironment(); + console.log(''); + + // Group 3: Configuration console.log(header('CONFIGURATION')); this.checkConfigFiles(); this.checkClaudeSettings(); console.log(''); - // Group 3: Profiles & Delegation + // Group 4: Profiles & Delegation console.log(header('PROFILES & DELEGATION')); this.checkProfiles(); this.checkInstances(); this.checkDelegation(); console.log(''); - // Group 4: System Health + // Group 5: System Health console.log(header('SYSTEM HEALTH')); this.checkPermissions(); this.checkCcsSymlinks(); this.checkSettingsSymlinks(); console.log(''); - // Group 5: CLIProxy (OAuth profiles) + // Group 6: CLIProxy (OAuth profiles) console.log(header('CLIPROXY (OAUTH PROFILES)')); await this.checkCLIProxy(); console.log(''); + // Group 7: OAuth Readiness (NEW - port availability) + console.log(header('OAUTH READINESS')); + await this.checkOAuthPorts(); + console.log(''); + this.showReport(); return this.results; } @@ -766,6 +778,117 @@ class Doctor { } } + /** + * Check 10.1: Environment detection (OAuth readiness) + * Helps diagnose Windows headless false positives + */ + private checkEnvironment(): void { + const spinner = ora('Checking environment').start(); + const diag = getEnvironmentDiagnostics(); + + // Determine overall environment health + let envStatus: 'OK' | 'WARN' = 'OK'; + let envMessage = 'Browser available'; + + // Check for potential issues + if (diag.detectedHeadless) { + if (diag.platform === 'win32' && diag.ttyStatus === 'undefined') { + // Windows false positive - this is actually a warning + envStatus = 'WARN'; + envMessage = 'Headless detected (may be false positive on Windows)'; + } else if (diag.sshSession) { + envMessage = 'SSH session (headless mode)'; + } else { + envMessage = 'Headless environment'; + } + } + + if (envStatus === 'WARN') { + spinner.warn(); + console.log(` ${warn('Environment'.padEnd(22))} ${envMessage}`); + } else { + spinner.succeed(); + console.log(` ${ok('Environment'.padEnd(22))} ${envMessage}`); + } + + // Show key environment details + console.log(` ${''.padEnd(24)} Platform: ${diag.platformName}`); + if (diag.sshSession) { + console.log(` ${''.padEnd(24)} SSH: Yes (${diag.sshReason})`); + } + if (diag.ttyStatus === 'undefined') { + console.log(` ${''.padEnd(24)} TTY: undefined [!]`); + } + console.log(` ${''.padEnd(24)} Browser: ${diag.browserReason}`); + + this.results.addCheck( + 'Environment', + envStatus === 'OK' ? 'success' : 'warning', + envMessage, + envStatus === 'WARN' ? 'If browser opens correctly, this warning can be ignored' : undefined, + { + status: envStatus, + info: envMessage, + } + ); + } + + /** + * Check 10.2: OAuth callback ports availability + * Pre-flight check for OAuth authentication + */ + private async checkOAuthPorts(): Promise { + const spinner = ora('Checking OAuth callback ports').start(); + const portDiagnostics = await checkAuthCodePorts(); + + // Count issues + const conflicts = portDiagnostics.filter((d) => d.status === 'occupied'); + + if (conflicts.length === 0) { + spinner.succeed(); + console.log(` ${ok('OAuth Ports'.padEnd(22))} All callback ports available`); + this.results.addCheck('OAuth Ports', 'success', undefined, undefined, { + status: 'OK', + info: 'All callback ports available', + }); + } else { + spinner.warn(); + console.log(` ${warn('OAuth Ports'.padEnd(22))} ${conflicts.length} port conflict(s)`); + this.results.addCheck( + 'OAuth Ports', + 'warning', + `${conflicts.length} port conflict(s)`, + 'Close conflicting applications before OAuth', + { status: 'WARN', info: `${conflicts.length} conflict(s)` } + ); + } + + // Show individual port status + for (const diag of portDiagnostics) { + const providerName = diag.provider.charAt(0).toUpperCase() + diag.provider.slice(1); + const portStr = diag.port !== null ? `(${diag.port})` : ''; + + let statusIcon: string; + switch (diag.status) { + case 'free': + case 'cliproxy': + statusIcon = ok(`${providerName} ${portStr}`.padEnd(20)); + break; + case 'occupied': + statusIcon = warn(`${providerName} ${portStr}`.padEnd(20)); + break; + default: + statusIcon = info(`${providerName} ${portStr}`.padEnd(20)); + } + + console.log(` ${statusIcon} ${diag.message}`); + + if (diag.recommendation && diag.status === 'occupied') { + console.log(` ${''.padEnd(24)} Fix: ${diag.recommendation}`); + } + } + } + /** * Check 11: CLIProxy health (OAuth profiles: gemini, codex, agy, qwen) */ diff --git a/src/management/environment-diagnostics.ts b/src/management/environment-diagnostics.ts new file mode 100644 index 00000000..7083256a --- /dev/null +++ b/src/management/environment-diagnostics.ts @@ -0,0 +1,310 @@ +/** + * Environment Diagnostics Module + * + * Provides detailed environment detection diagnostics for troubleshooting + * OAuth authentication issues, particularly for Windows users experiencing + * false headless detection. + * + * Case study: Vietnamese Windows users reported "command hangs" because + * Windows terminal wrappers don't set isTTY correctly, triggering + * headless mode when browser should be available. + */ + +/** + * Detailed environment diagnostic information + */ +export interface EnvironmentDiagnostics { + /** Operating system platform */ + platform: NodeJS.Platform; + /** Platform display name */ + platformName: string; + /** Current shell (from SHELL env or detected) */ + shell: string; + /** Terminal program (TERM_PROGRAM env) */ + termProgram: string | null; + /** Whether running in SSH session */ + sshSession: boolean; + /** SSH detection reason */ + sshReason: string | null; + /** stdin TTY status */ + ttyStatus: boolean | 'undefined'; + /** stdout TTY status */ + stdoutTty: boolean | 'undefined'; + /** Display environment (Linux X11/Wayland) */ + display: string | null; + /** Windows Terminal detected (WT_SESSION env) */ + windowsTerminal: boolean; + /** VS Code integrated terminal detected */ + vsCodeTerminal: boolean; + /** CI environment detected */ + ciEnvironment: boolean; + /** Final headless detection result */ + detectedHeadless: boolean; + /** Reasons why headless was detected (or not) */ + headlessReasons: string[]; + /** Browser capability assessment */ + browserCapability: 'available' | 'unlikely' | 'unknown'; + /** Browser capability reason */ + browserReason: string; +} + +/** + * Detect current shell from environment + */ +function detectShell(): string { + if (process.env.SHELL) { + return process.env.SHELL; + } + if (process.platform === 'win32') { + if (process.env.PSModulePath) { + return 'PowerShell'; + } + if (process.env.COMSPEC) { + return process.env.COMSPEC; + } + return 'cmd.exe'; + } + return 'unknown'; +} + +/** + * Check if running in SSH session + */ +function checkSshSession(): { isSsh: boolean; reason: string | null } { + if (process.env.SSH_TTY) { + return { isSsh: true, reason: 'SSH_TTY set' }; + } + if (process.env.SSH_CLIENT) { + return { isSsh: true, reason: 'SSH_CLIENT set' }; + } + if (process.env.SSH_CONNECTION) { + return { isSsh: true, reason: 'SSH_CONNECTION set' }; + } + return { isSsh: false, reason: null }; +} + +/** + * Check browser capability based on environment + */ +function assessBrowserCapability( + platform: NodeJS.Platform, + sshSession: boolean, + display: string | null, + windowsTerminal: boolean, + vsCodeTerminal: boolean +): { capability: 'available' | 'unlikely' | 'unknown'; reason: string } { + // SSH session - no browser + if (sshSession) { + return { capability: 'unlikely', reason: 'SSH session detected' }; + } + + // Windows desktop - should always have browser + if (platform === 'win32') { + if (windowsTerminal || vsCodeTerminal) { + return { capability: 'available', reason: 'Windows desktop terminal' }; + } + // Even plain cmd.exe on Windows desktop should have browser access + return { capability: 'available', reason: 'Windows desktop environment' }; + } + + // macOS - always has browser unless SSH + if (platform === 'darwin') { + return { capability: 'available', reason: 'macOS desktop environment' }; + } + + // Linux - depends on DISPLAY/WAYLAND + if (platform === 'linux') { + if (display) { + return { capability: 'available', reason: `Display available: ${display}` }; + } + return { capability: 'unlikely', reason: 'No DISPLAY or WAYLAND_DISPLAY' }; + } + + return { capability: 'unknown', reason: 'Unknown platform' }; +} + +/** + * Determine if environment is headless (improved detection) + * Returns both result and reasons for transparency + */ +function detectHeadless(): { isHeadless: boolean; reasons: string[] } { + const reasons: string[] = []; + + // SSH session + if (process.env.SSH_TTY || process.env.SSH_CLIENT || process.env.SSH_CONNECTION) { + reasons.push('SSH session detected'); + return { isHeadless: true, reasons }; + } + + // Linux without display + if (process.platform === 'linux' && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) { + reasons.push('Linux without DISPLAY/WAYLAND_DISPLAY'); + return { isHeadless: true, reasons }; + } + + // Windows desktop - NEVER headless unless SSH (already checked above) + // This fixes false positive on Windows where isTTY is undefined + if (process.platform === 'win32') { + reasons.push('Windows desktop - browser available'); + return { isHeadless: false, reasons }; + } + + // macOS - check for proper terminal + if (process.platform === 'darwin') { + if (!process.stdin.isTTY) { + reasons.push('Non-interactive stdin on macOS'); + return { isHeadless: true, reasons }; + } + reasons.push('macOS with TTY - browser available'); + return { isHeadless: false, reasons }; + } + + // Linux with display - check TTY + if (process.platform === 'linux') { + if (!process.stdin.isTTY) { + reasons.push('Non-interactive stdin on Linux'); + return { isHeadless: true, reasons }; + } + reasons.push('Linux with display and TTY'); + return { isHeadless: false, reasons }; + } + + // Default fallback + reasons.push('Default: assuming headless'); + return { isHeadless: true, reasons }; +} + +/** + * Get comprehensive environment diagnostics + */ +export function getEnvironmentDiagnostics(): EnvironmentDiagnostics { + const platform = process.platform; + const ssh = checkSshSession(); + const display = + process.platform === 'linux' + ? process.env.DISPLAY || process.env.WAYLAND_DISPLAY || null + : null; + const windowsTerminal = !!process.env.WT_SESSION; + const vsCodeTerminal = !!( + process.env.VSCODE_GIT_IPC_HANDLE || + process.env.VSCODE_IPC_HOOK_CLI || + process.env.TERM_PROGRAM === 'vscode' + ); + const ciEnvironment = !!( + process.env.CI || + process.env.GITHUB_ACTIONS || + process.env.GITLAB_CI || + process.env.JENKINS_URL + ); + + const browser = assessBrowserCapability( + platform, + ssh.isSsh, + display, + windowsTerminal, + vsCodeTerminal + ); + const headless = detectHeadless(); + + // Platform display name + const platformNames: Record = { + win32: 'Windows', + darwin: 'macOS', + linux: 'Linux', + freebsd: 'FreeBSD', + openbsd: 'OpenBSD', + }; + + return { + platform, + platformName: platformNames[platform] || platform, + shell: detectShell(), + termProgram: process.env.TERM_PROGRAM || null, + sshSession: ssh.isSsh, + sshReason: ssh.reason, + ttyStatus: process.stdin.isTTY === undefined ? 'undefined' : process.stdin.isTTY, + stdoutTty: process.stdout.isTTY === undefined ? 'undefined' : process.stdout.isTTY, + display, + windowsTerminal, + vsCodeTerminal, + ciEnvironment, + detectedHeadless: headless.isHeadless, + headlessReasons: headless.reasons, + browserCapability: browser.capability, + browserReason: browser.reason, + }; +} + +/** + * Check if environment should use headless OAuth flow + * Uses improved detection that avoids Windows false positives + */ +export function shouldUseHeadlessAuth(): boolean { + return detectHeadless().isHeadless; +} + +/** + * Format diagnostics for display + */ +export function formatEnvironmentDiagnostics(diag: EnvironmentDiagnostics): string[] { + const lines: string[] = []; + + // Platform + lines.push(`Platform ${diag.platformName} (${diag.platform})`); + + // Shell + lines.push(`Shell ${diag.shell}`); + + // Terminal + if (diag.termProgram) { + lines.push(`Terminal Program ${diag.termProgram}`); + } + if (diag.windowsTerminal) { + lines.push(`Windows Terminal Yes`); + } + if (diag.vsCodeTerminal) { + lines.push(`VS Code Terminal Yes`); + } + + // SSH + lines.push(`SSH Session ${diag.sshSession ? `Yes (${diag.sshReason})` : 'No'}`); + + // TTY + const ttyDisplay = + diag.ttyStatus === 'undefined' ? 'undefined [!]' : diag.ttyStatus ? 'Yes' : 'No'; + lines.push(`stdin TTY ${ttyDisplay}`); + + // Display (Linux) + if (diag.platform === 'linux') { + lines.push(`Display ${diag.display || 'Not set'}`); + } + + // CI + if (diag.ciEnvironment) { + lines.push(`CI Environment Yes`); + } + + // Browser capability + const browserIcon = + diag.browserCapability === 'available' + ? '[OK]' + : diag.browserCapability === 'unlikely' + ? '[!]' + : '[?]'; + lines.push(`Browser Capability ${browserIcon} ${diag.browserReason}`); + + // Headless detection result + const headlessIcon = diag.detectedHeadless ? '[!]' : '[OK]'; + lines.push(`Headless Mode ${headlessIcon} ${diag.detectedHeadless ? 'Yes' : 'No'}`); + for (const reason of diag.headlessReasons) { + lines.push(` → ${reason}`); + } + + return lines; +} + +export default { + getEnvironmentDiagnostics, + shouldUseHeadlessAuth, + formatEnvironmentDiagnostics, +}; diff --git a/src/management/oauth-port-diagnostics.ts b/src/management/oauth-port-diagnostics.ts new file mode 100644 index 00000000..ccc255a0 --- /dev/null +++ b/src/management/oauth-port-diagnostics.ts @@ -0,0 +1,236 @@ +/** + * OAuth Port Diagnostics Module + * + * Pre-flight checks for OAuth callback ports to detect conflicts + * before users attempt authentication. + * + * OAuth flows require specific localhost ports for callbacks: + * - Gemini: 8085 + * - Codex: 1455 + * - Agy: 51121 + * - Qwen: Device Code Flow (no port needed) + */ + +import { getPortProcess, PortProcess, isCLIProxyProcess } from '../utils/port-utils'; +import { CLIProxyProvider } from '../cliproxy/types'; + +/** + * OAuth callback ports for each provider + * Extracted from CLIProxyAPI source + */ +export const OAUTH_CALLBACK_PORTS: Record = { + gemini: 8085, + codex: 1455, + agy: 51121, + qwen: null, // Device Code Flow - no callback port + iflow: null, // Device Code Flow - no callback port +}; + +/** + * OAuth flow types + */ +export type OAuthFlowType = 'authorization_code' | 'device_code'; + +/** + * OAuth flow type per provider + */ +export const OAUTH_FLOW_TYPES: Record = { + gemini: 'authorization_code', + codex: 'authorization_code', + agy: 'authorization_code', + qwen: 'device_code', + iflow: 'device_code', +}; + +/** + * Port diagnostic result + */ +export interface OAuthPortDiagnostic { + /** Provider name */ + provider: CLIProxyProvider; + /** OAuth flow type */ + flowType: OAuthFlowType; + /** Callback port (null for device code flow) */ + port: number | null; + /** Port status */ + status: 'free' | 'occupied' | 'cliproxy' | 'not_applicable'; + /** Process occupying the port (if any) */ + process: PortProcess | null; + /** Human-readable status message */ + message: string; + /** Recommendation for fixing (if issue detected) */ + recommendation: string | null; +} + +/** + * Check OAuth port availability for a single provider + */ +export async function checkOAuthPort(provider: CLIProxyProvider): Promise { + const port = OAUTH_CALLBACK_PORTS[provider]; + const flowType = OAUTH_FLOW_TYPES[provider]; + + // Device code flow doesn't need callback port + if (port === null) { + return { + provider, + flowType, + port: null, + status: 'not_applicable', + process: null, + message: 'Uses Device Code Flow (no callback port needed)', + recommendation: null, + }; + } + + // Check if port is in use + const portProcess = await getPortProcess(port); + + if (!portProcess) { + return { + provider, + flowType, + port, + status: 'free', + process: null, + message: `Port ${port} is available`, + recommendation: null, + }; + } + + // Check if it's CLIProxy (expected if proxy is running) + if (isCLIProxyProcess(portProcess)) { + return { + provider, + flowType, + port, + status: 'cliproxy', + process: portProcess, + message: `Port ${port} in use by CLIProxy (expected)`, + recommendation: null, + }; + } + + // Port is occupied by another process + return { + provider, + flowType, + port, + status: 'occupied', + process: portProcess, + message: `Port ${port} occupied by ${portProcess.processName}`, + recommendation: `Kill process: kill ${portProcess.pid} (or close ${portProcess.processName})`, + }; +} + +/** + * Check OAuth ports for all providers + */ +export async function checkAllOAuthPorts(): Promise { + const providers: CLIProxyProvider[] = ['gemini', 'codex', 'agy', 'qwen', 'iflow']; + const results: OAuthPortDiagnostic[] = []; + + for (const provider of providers) { + const diagnostic = await checkOAuthPort(provider); + results.push(diagnostic); + } + + return results; +} + +/** + * Check OAuth ports for providers that use Authorization Code flow only + */ +export async function checkAuthCodePorts(): Promise { + const providers: CLIProxyProvider[] = ['gemini', 'codex', 'agy']; + const results: OAuthPortDiagnostic[] = []; + + for (const provider of providers) { + const diagnostic = await checkOAuthPort(provider); + results.push(diagnostic); + } + + return results; +} + +/** + * Get providers with port conflicts + */ +export async function getPortConflicts(): Promise { + const allPorts = await checkAllOAuthPorts(); + return allPorts.filter((d) => d.status === 'occupied'); +} + +/** + * Format OAuth port diagnostics for display + */ +export function formatOAuthPortDiagnostics(diagnostics: OAuthPortDiagnostic[]): string[] { + const lines: string[] = []; + + for (const diag of diagnostics) { + const providerName = diag.provider.charAt(0).toUpperCase() + diag.provider.slice(1); + const portStr = diag.port !== null ? `(${diag.port})` : ''; + + let statusIcon: string; + switch (diag.status) { + case 'free': + statusIcon = '[OK]'; + break; + case 'cliproxy': + statusIcon = '[OK]'; + break; + case 'occupied': + statusIcon = '[!]'; + break; + case 'not_applicable': + statusIcon = '[i]'; + break; + default: + statusIcon = '[?]'; + } + + const label = `${providerName} ${portStr}`.padEnd(20); + lines.push(`${statusIcon} ${label} ${diag.message}`); + + if (diag.recommendation) { + lines.push(` → ${diag.recommendation}`); + } + } + + return lines; +} + +/** + * Pre-flight check before OAuth - returns issues or empty array if OK + */ +export async function preflightOAuthCheck(provider: CLIProxyProvider): Promise<{ + ready: boolean; + issues: string[]; +}> { + const diagnostic = await checkOAuthPort(provider); + const issues: string[] = []; + + if (diagnostic.status === 'occupied' && diagnostic.process) { + issues.push( + `OAuth callback port ${diagnostic.port} is blocked by ${diagnostic.process.processName} (PID ${diagnostic.process.pid})` + ); + if (diagnostic.recommendation) { + issues.push(`Fix: ${diagnostic.recommendation}`); + } + } + + return { + ready: issues.length === 0, + issues, + }; +} + +export default { + OAUTH_CALLBACK_PORTS, + OAUTH_FLOW_TYPES, + checkOAuthPort, + checkAllOAuthPorts, + checkAuthCodePorts, + getPortConflicts, + formatOAuthPortDiagnostics, + preflightOAuthCheck, +};