fix(ghcp): improve oauth failure diagnostics in device flow

This commit is contained in:
Tam Nhu Tran
2026-03-04 00:19:11 +07:00
parent a066654613
commit c2f564ac66
4 changed files with 134 additions and 5 deletions
+72 -3
View File
@@ -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<AccountInfo | null> {
// 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<Accou
resolve(registerAccountFromToken(provider, tokenDir, nickname));
} else {
const failureReason = extractLikelyAuthFailureFromStderr(provider, state.stderrData);
// Emit device code failure event for UI
if (isDeviceCodeFlow && state.deviceCodeDisplayed) {
deviceCodeEvents.emit('deviceCode:failed', {
sessionId: state.sessionId,
error: 'Token not found after authentication',
error: failureReason || 'Token not found after authentication',
});
}
@@ -484,7 +552,8 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise<Accou
callbackPort,
tokenDir,
nickname,
verbose
verbose,
failureReason || undefined
);
resolve(account);
}
+10 -1
View File
@@ -112,6 +112,13 @@ export function getStartUrlUnsupportedReason(
return null;
}
export function getStartAuthFailureMessage(provider: CLIProxyProvider): string {
if (provider === 'ghcp') {
return 'Authentication failed, was cancelled, or GitHub Copilot verification did not complete. Ensure the account has an active Copilot subscription and retry.';
}
return 'Authentication failed or was cancelled';
}
/**
* GET /api/cliproxy/auth - Get auth status for built-in CLIProxy profiles
* Also fetches CLIProxyAPI stats to update lastUsedAt for active providers
@@ -503,7 +510,9 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise<voi
},
});
} else {
res.status(400).json({ error: 'Authentication failed or was cancelled' });
res.status(400).json({
error: getStartAuthFailureMessage(provider as CLIProxyProvider),
});
}
} catch (error) {
respondInternalError(res, error, 'Failed to start OAuth flow.');
@@ -0,0 +1,35 @@
import { describe, expect, it } from 'bun:test';
import { extractLikelyAuthFailureFromStderr } from '../../../src/cliproxy/auth/oauth-process';
describe('oauth-process stderr parsing', () => {
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);
});
});
@@ -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');
});
});