feat(doctor): add OAuth diagnostics for Windows headless false positives

Add environment and OAuth port diagnostics to help troubleshoot
authentication issues reported by Windows users:

- Add ENVIRONMENT section showing platform, SSH, TTY, browser capability
- Add OAUTH READINESS section with pre-flight port availability checks
- Fix Windows headless false positive (isTTY undefined on npm wrappers)
- Add pre-flight OAuth check before auth attempt to fail fast

New files:
- src/management/environment-diagnostics.ts
- src/management/oauth-port-diagnostics.ts

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.
This commit is contained in:
kaitranntt
2025-12-09 12:50:38 -05:00
parent a7410bc104
commit 92007d7c04
4 changed files with 718 additions and 11 deletions
+45 -7
View File
@@ -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 {
+127 -4
View File
@@ -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<void> {
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)
*/
+310
View File
@@ -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<string, string> = {
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,
};
+236
View File
@@ -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<CLIProxyProvider, number | null> = {
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<CLIProxyProvider, OAuthFlowType> = {
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<OAuthPortDiagnostic> {
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<OAuthPortDiagnostic[]> {
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<OAuthPortDiagnostic[]> {
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<OAuthPortDiagnostic[]> {
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,
};