mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-02 12:19:35 +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 */
|
/** Handle token not found after successful process exit */
|
||||||
async function handleTokenNotFound(
|
async function handleTokenNotFound(
|
||||||
provider: CLIProxyProvider,
|
provider: CLIProxyProvider,
|
||||||
callbackPort: number | null,
|
callbackPort: number | null,
|
||||||
tokenDir: string,
|
tokenDir: string,
|
||||||
nickname: string | undefined,
|
nickname: string | undefined,
|
||||||
verbose: boolean
|
verbose: boolean,
|
||||||
|
failureReason?: string
|
||||||
): Promise<AccountInfo | null> {
|
): Promise<AccountInfo | null> {
|
||||||
// Kiro-specific: Try auto-import from Kiro IDE
|
// Kiro-specific: Try auto-import from Kiro IDE
|
||||||
if (provider === 'kiro') {
|
if (provider === 'kiro') {
|
||||||
@@ -248,6 +302,18 @@ async function handleTokenNotFound(
|
|||||||
|
|
||||||
// Default behavior for other providers
|
// Default behavior for other providers
|
||||||
console.log('');
|
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(fail('Token not found after authentication'));
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log('The browser showed success but callback was not received.');
|
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));
|
resolve(registerAccountFromToken(provider, tokenDir, nickname));
|
||||||
} else {
|
} else {
|
||||||
|
const failureReason = extractLikelyAuthFailureFromStderr(provider, state.stderrData);
|
||||||
|
|
||||||
// Emit device code failure event for UI
|
// Emit device code failure event for UI
|
||||||
if (isDeviceCodeFlow && state.deviceCodeDisplayed) {
|
if (isDeviceCodeFlow && state.deviceCodeDisplayed) {
|
||||||
deviceCodeEvents.emit('deviceCode:failed', {
|
deviceCodeEvents.emit('deviceCode:failed', {
|
||||||
sessionId: state.sessionId,
|
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,
|
callbackPort,
|
||||||
tokenDir,
|
tokenDir,
|
||||||
nickname,
|
nickname,
|
||||||
verbose
|
verbose,
|
||||||
|
failureReason || undefined
|
||||||
);
|
);
|
||||||
resolve(account);
|
resolve(account);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,6 +112,13 @@ export function getStartUrlUnsupportedReason(
|
|||||||
return null;
|
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
|
* GET /api/cliproxy/auth - Get auth status for built-in CLIProxy profiles
|
||||||
* Also fetches CLIProxyAPI stats to update lastUsedAt for active providers
|
* 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 {
|
} else {
|
||||||
res.status(400).json({ error: 'Authentication failed or was cancelled' });
|
res.status(400).json({
|
||||||
|
error: getStartAuthFailureMessage(provider as CLIProxyProvider),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
respondInternalError(res, error, 'Failed to start OAuth flow.');
|
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 { 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', () => {
|
describe('cliproxy-auth-routes start-url guard', () => {
|
||||||
it('rejects device code providers', () => {
|
it('rejects device code providers', () => {
|
||||||
@@ -27,3 +30,16 @@ describe('cliproxy-auth-routes start-url guard', () => {
|
|||||||
expect(getStartUrlUnsupportedReason('claude')).toBeNull();
|
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