diff --git a/src/cliproxy/auth/oauth-process.ts b/src/cliproxy/auth/oauth-process.ts index ba157784..829d911c 100644 --- a/src/cliproxy/auth/oauth-process.ts +++ b/src/cliproxy/auth/oauth-process.ts @@ -217,13 +217,67 @@ function displayUrlFromStderr( } } +const ANSI_ESCAPE_REGEX = /\x1b\[[0-9;]*m/g; + +export function extractLikelyAuthFailureFromStderr( + provider: CLIProxyProvider, + stderrData: string +): string | null { + // Keep this scoped to ghcp to avoid over-classifying other providers. + if (provider !== 'ghcp') { + return null; + } + + if (!stderrData.trim()) { + return null; + } + + const normalizedLines = stderrData + .split('\n') + .map((line) => line.replace(ANSI_ESCAPE_REGEX, '').trim()) + .filter(Boolean) + .map((line) => { + const messageIndex = line.indexOf('msg="'); + if (messageIndex >= 0) { + const message = line + .slice(messageIndex + 5) + .replace(/"$/, '') + .trim(); + if (message) { + return message; + } + } + return line; + }); + + const prioritizedPatterns = [ + /github copilot authentication failed:\s*(.+)/i, + /authentication failed:\s*(.+)/i, + /failed to verify copilot access[^:]*:\s*(.+)/i, + /failed to save auth:\s*(.+)/i, + ]; + + for (let i = normalizedLines.length - 1; i >= 0; i--) { + const line = normalizedLines[i]; + for (const pattern of prioritizedPatterns) { + const match = line.match(pattern); + if (match?.[1]?.trim()) { + return match[1].trim().slice(0, 240); + } + } + } + + return null; +} + /** Handle token not found after successful process exit */ async function handleTokenNotFound( provider: CLIProxyProvider, callbackPort: number | null, tokenDir: string, nickname: string | undefined, - verbose: boolean + verbose: boolean, + failureReason?: string ): Promise { // Kiro-specific: Try auto-import from Kiro IDE if (provider === 'kiro') { @@ -248,6 +302,18 @@ async function handleTokenNotFound( // Default behavior for other providers console.log(''); + + if (failureReason) { + console.log(fail('Authentication completed but token was not persisted')); + console.log(` ${failureReason}`); + console.log(''); + console.log('This usually means provider-side authorization was accepted,'); + console.log('but CLIProxy failed a post-auth verification or token save step.'); + console.log(''); + console.log(`Try: ccs ${provider} --auth --verbose`); + return null; + } + console.log(fail('Token not found after authentication')); console.log(''); console.log('The browser showed success but callback was not received.'); @@ -470,11 +536,13 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise { + it('ignores non-ghcp providers', () => { + const stderr = + 'time="2026-03-03T10:00:00Z" level=error msg="GitHub Copilot authentication failed: example"'; + + expect(extractLikelyAuthFailureFromStderr('qwen', stderr)).toBeNull(); + }); + + it('extracts copilot verification failures from logrus lines', () => { + const stderr = + 'time="2026-03-03T10:00:00Z" level=error msg="GitHub Copilot authentication failed: github-copilot: failed to verify Copilot access - you may not have an active Copilot subscription: 403 Forbidden"'; + + expect(extractLikelyAuthFailureFromStderr('ghcp', stderr)).toBe( + 'github-copilot: failed to verify Copilot access - you may not have an active Copilot subscription: 403 Forbidden' + ); + }); + + it('extracts generic authentication failure lines', () => { + const stderr = 'level=error msg="Authentication failed: state mismatch"'; + + expect(extractLikelyAuthFailureFromStderr('ghcp', stderr)).toBe('state mismatch'); + }); + + it('caps extracted message length to prevent noisy broadcasts', () => { + const longSuffix = 'x'.repeat(400); + const stderr = `level=error msg="Authentication failed: ${longSuffix}"`; + + const parsed = extractLikelyAuthFailureFromStderr('ghcp', stderr); + expect(parsed).not.toBeNull(); + expect((parsed as string).length).toBe(240); + }); +}); diff --git a/tests/unit/web-server/cliproxy-auth-routes.test.ts b/tests/unit/web-server/cliproxy-auth-routes.test.ts index 9982c133..9314f32c 100644 --- a/tests/unit/web-server/cliproxy-auth-routes.test.ts +++ b/tests/unit/web-server/cliproxy-auth-routes.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'bun:test'; -import { getStartUrlUnsupportedReason } from '../../../src/web-server/routes/cliproxy-auth-routes'; +import { + getStartAuthFailureMessage, + getStartUrlUnsupportedReason, +} from '../../../src/web-server/routes/cliproxy-auth-routes'; describe('cliproxy-auth-routes start-url guard', () => { it('rejects device code providers', () => { @@ -27,3 +30,16 @@ describe('cliproxy-auth-routes start-url guard', () => { expect(getStartUrlUnsupportedReason('claude')).toBeNull(); }); }); + +describe('cliproxy-auth-routes start failure messaging', () => { + it('returns ghcp-specific guidance for Copilot verification failures', () => { + expect(getStartAuthFailureMessage('ghcp')).toContain( + 'GitHub Copilot verification did not complete' + ); + }); + + it('keeps generic failure text for other providers', () => { + expect(getStartAuthFailureMessage('gemini')).toBe('Authentication failed or was cancelled'); + expect(getStartAuthFailureMessage('kiro')).toBe('Authentication failed or was cancelled'); + }); +});