mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 08:17:11 +00:00
fix(ghcp): improve oauth failure diagnostics in device flow
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user