fix(cliproxy): guard unsupported Qwen account auth

This commit is contained in:
Kai (Tam Nhu) Tran authored and GitHub committed 2026-06-18 21:34:27 -04:00
1 parent 5b6c3e87e9
commit b6ef4e5782
11 files changed
+177 -9

No files matched your search

+4 -1
View File
@@ -61,11 +61,14 @@ CCS gives you one stable command surface while letting you switch between:
- multiple runtimes such as Claude Code, Factory Droid, and Codex CLI - multiple runtimes such as Claude Code, Factory Droid, and Codex CLI
- multiple Claude subscriptions and isolated account contexts - multiple Claude subscriptions and isolated account contexts
- OAuth providers like Codex, Kiro, Claude, Qwen, Kimi, and more, with legacy - OAuth providers like Codex, Kiro, Claude, Kimi, and more, with legacy
Copilot compatibility for existing setups Copilot compatibility for existing setups
- API and local-model profiles like GLM, Kimi, OpenRouter, Ollama, llama.cpp, - API and local-model profiles like GLM, Kimi, OpenRouter, Ollama, llama.cpp,
Novita, Fireworks AI, and Alibaba Coding Plan Novita, Fireworks AI, and Alibaba Coding Plan
Qwen Code account linking is not available in the bundled CLIProxy runtime yet;
use an API-key Qwen profile such as Alibaba Coding Plan for Qwen models.
The goal is simple: stop rewriting config files, stop breaking active sessions, The goal is simple: stop rewriting config files, stop breaking active sessions,
and move between providers in seconds. and move between providers in seconds.
@@ -5,6 +5,7 @@ import {
getDeviceCodeVerificationProviders, getDeviceCodeVerificationProviders,
getOAuthCallbackPort, getOAuthCallbackPort,
getOAuthFlowType, getOAuthFlowType,
getUnsupportedAuthStartReason,
PROVIDER_CAPABILITIES, PROVIDER_CAPABILITIES,
getProviderDisplayName, getProviderDisplayName,
getProvidersByOAuthFlow, getProvidersByOAuthFlow,
@@ -134,10 +135,20 @@ describe('provider-capabilities', () => {
expect(getOAuthCallbackPort('gitlab')).toBe(17171); expect(getOAuthCallbackPort('gitlab')).toBe(17171);
expect(getOAuthCallbackPort('gemini')).toBe(8085); expect(getOAuthCallbackPort('gemini')).toBe(8085);
expect(PROVIDER_CAPABILITIES.gemini.refreshOwnership).toBe('cliproxy'); expect(PROVIDER_CAPABILITIES.gemini.refreshOwnership).toBe('cliproxy');
expect(PROVIDER_CAPABILITIES.qwen.refreshOwnership).toBe('unsupported');
expect(getProviderDisplayName('agy')).toBe('Antigravity'); expect(getProviderDisplayName('agy')).toBe('Antigravity');
expect(getProviderDisplayName('kilo')).toBe('Kilo AI'); expect(getProviderDisplayName('kilo')).toBe('Kilo AI');
}); });
it('exposes auth start support separately from OAuth flow type', () => {
expect(getOAuthFlowType('qwen')).toBe('device_code');
expect(getUnsupportedAuthStartReason('qwen')).toContain(
'Qwen account linking is not supported'
);
expect(getUnsupportedAuthStartReason('kiro')).toBeNull();
expect(getUnsupportedAuthStartReason('qoder')).toBeNull();
});
it('throws when provider aliases collide across providers', () => { it('throws when provider aliases collide across providers', () => {
const capabilitiesWithCollision = { const capabilitiesWithCollision = {
...PROVIDER_CAPABILITIES, ...PROVIDER_CAPABILITIES,
@@ -0,0 +1,35 @@
import { afterEach, describe, expect, it, spyOn } from 'bun:test';
import { triggerOAuth } from '../oauth-handler';
describe('triggerOAuth unsupported providers', () => {
const previousDisableBanWarnings = process.env.CCS_DISABLE_BAN_WARNINGS;
afterEach(() => {
if (previousDisableBanWarnings === undefined) {
delete process.env.CCS_DISABLE_BAN_WARNINGS;
} else {
process.env.CCS_DISABLE_BAN_WARNINGS = previousDisableBanWarnings;
}
});
it('fails Qwen account linking before preparing CLIProxy auth args', async () => {
process.env.CCS_DISABLE_BAN_WARNINGS = '1';
const logSpy = spyOn(console, 'log').mockImplementation(() => {});
try {
const account = await triggerOAuth('qwen');
expect(account).toBeNull();
expect(logSpy.mock.calls.some(([message]) => String(message).includes('--qwen-login'))).toBe(
false
);
expect(
logSpy.mock.calls.some(([message]) =>
String(message).includes('Qwen account linking is not supported')
)
).toBe(true);
} finally {
logSpy.mockRestore();
}
});
});
+12
View File
@@ -79,6 +79,7 @@ import {
import { maybeOfferPoolRouting } from '../routing/pool-opt-in-prompt'; import { maybeOfferPoolRouting } from '../routing/pool-opt-in-prompt';
import { checkCrossLaneEmailOverlap } from '../accounts/account-safety-cross-lane'; import { checkCrossLaneEmailOverlap } from '../accounts/account-safety-cross-lane';
import { ensureCliAntigravityResponsibility } from '../auth/antigravity-responsibility'; import { ensureCliAntigravityResponsibility } from '../auth/antigravity-responsibility';
import { getUnsupportedAuthStartReason } from '../provider-capabilities';
import { InteractivePrompt } from '../../utils/prompt'; import { InteractivePrompt } from '../../utils/prompt';
import { getCcsDir } from '../../utils/config-manager'; import { getCcsDir } from '../../utils/config-manager';
import { generateSessionId } from './project-selection-handler'; import { generateSessionId } from './project-selection-handler';
@@ -616,6 +617,11 @@ function buildOAuthArgs(
kiroIDCFlow?: OAuthOptions['kiroIDCFlow']; kiroIDCFlow?: OAuthOptions['kiroIDCFlow'];
} = {} } = {}
): string[] { ): string[] {
const unsupportedReason = getUnsupportedAuthStartReason(provider);
if (unsupportedReason) {
throw new AuthError(unsupportedReason, provider);
}
const args = ['--config', configPath]; const args = ['--config', configPath];
if (provider === 'kiro') { if (provider === 'kiro') {
@@ -1091,6 +1097,12 @@ export async function triggerOAuth(
options: OAuthOptions = {} options: OAuthOptions = {}
): Promise<AccountInfo | null> { ): Promise<AccountInfo | null> {
const oauthConfig = getOAuthConfig(provider); const oauthConfig = getOAuthConfig(provider);
const unsupportedReason = getUnsupportedAuthStartReason(provider);
if (unsupportedReason) {
console.log(fail(unsupportedReason));
return null;
}
warnOAuthBanRisk(provider); warnOAuthBanRisk(provider);
const oauthStartedAt = Date.now(); const oauthStartedAt = Date.now();
logger.stage('auth', 'cliproxy.oauth.start', 'Triggering OAuth flow', { logger.stage('auth', 'cliproxy.oauth.start', 'Triggering OAuth flow', {
@@ -4,8 +4,9 @@
* Exports refresh functions for each OAuth provider. * Exports refresh functions for each OAuth provider.
* *
* Refresh responsibility: * Refresh responsibility:
* - CLIProxy-delegated: gemini, codex, agy, kiro, ghcp, qwen, iflow, kimi * - CLIProxy-delegated: gemini, codex, agy, kiro, ghcp, iflow, kimi
* (CLIProxyAPIPlus handles refresh automatically in background) * (CLIProxyAPIPlus handles refresh automatically in background)
* - Unsupported account linking: qwen
* - Not implemented: claude * - Not implemented: claude
*/ */
+2 -1
View File
@@ -497,9 +497,10 @@ export function displayAuthStatus(): void {
* *
* Refresh responsibility: * Refresh responsibility:
* - gemini: CCS refreshes directly via Google OAuth * - gemini: CCS refreshes directly via Google OAuth
* - codex, agy, kiro, ghcp, qwen, iflow: CLIProxyAPIPlus handles refresh * - codex, agy, kiro, ghcp, iflow: CLIProxyAPIPlus handles refresh
* automatically in background (e.g. kiro refreshes every 1 min). * automatically in background (e.g. kiro refreshes every 1 min).
* CCS only checks if token file exists (authentication state). * CCS only checks if token file exists (authentication state).
* - qwen: account linking is unsupported by the bundled CLIProxy runtime
* - claude: not yet implemented * - claude: not yet implemented
* *
* @param provider The CLIProxy provider * @param provider The CLIProxy provider
+35 -2
View File
@@ -1,7 +1,9 @@
import type { CLIProxyProvider } from './types'; import type { CLIProxyProvider } from './types';
import { ConfigError } from '../errors/error-types';
export type OAuthFlowType = 'authorization_code' | 'device_code'; export type OAuthFlowType = 'authorization_code' | 'device_code';
export type TokenRefreshOwnership = 'ccs' | 'cliproxy' | 'unsupported'; export type TokenRefreshOwnership = 'ccs' | 'cliproxy' | 'unsupported';
export type AuthStartSupport = 'cliproxy-cli' | 'unsupported';
export interface ProviderCapabilities { export interface ProviderCapabilities {
displayName: string; displayName: string;
@@ -14,6 +16,10 @@ export interface ProviderCapabilities {
authUrlProviderName: string; authUrlProviderName: string;
/** Who owns token refresh logic for this provider. */ /** Who owns token refresh logic for this provider. */
refreshOwnership: TokenRefreshOwnership; refreshOwnership: TokenRefreshOwnership;
/** Whether CCS can start account linking through the bundled CLIProxy binary. */
authStartSupport: AuthStartSupport;
/** User-facing reason when account linking cannot be started. */
authStartUnsupportedReason?: string;
/** Filename prefixes used to identify auth tokens for this provider. */ /** Filename prefixes used to identify auth tokens for this provider. */
authFilePrefixes: readonly string[]; authFilePrefixes: readonly string[];
/** Token JSON "type" values accepted for this provider. */ /** Token JSON "type" values accepted for this provider. */
@@ -34,6 +40,7 @@ export const PROVIDER_CAPABILITIES: Record<CLIProxyProvider, ProviderCapabilitie
callbackProviderName: 'gemini', callbackProviderName: 'gemini',
authUrlProviderName: 'gemini-cli', authUrlProviderName: 'gemini-cli',
refreshOwnership: 'cliproxy', refreshOwnership: 'cliproxy',
authStartSupport: 'cliproxy-cli',
authFilePrefixes: ['gemini-', 'google-'], authFilePrefixes: ['gemini-', 'google-'],
tokenTypeValues: ['gemini'], tokenTypeValues: ['gemini'],
aliases: ['gemini-cli'], aliases: ['gemini-cli'],
@@ -46,6 +53,7 @@ export const PROVIDER_CAPABILITIES: Record<CLIProxyProvider, ProviderCapabilitie
callbackProviderName: 'codex', callbackProviderName: 'codex',
authUrlProviderName: 'codex', authUrlProviderName: 'codex',
refreshOwnership: 'cliproxy', refreshOwnership: 'cliproxy',
authStartSupport: 'cliproxy-cli',
authFilePrefixes: ['codex-', 'openai-'], authFilePrefixes: ['codex-', 'openai-'],
tokenTypeValues: ['codex'], tokenTypeValues: ['codex'],
aliases: [], aliases: [],
@@ -58,6 +66,7 @@ export const PROVIDER_CAPABILITIES: Record<CLIProxyProvider, ProviderCapabilitie
callbackProviderName: 'antigravity', callbackProviderName: 'antigravity',
authUrlProviderName: 'antigravity', authUrlProviderName: 'antigravity',
refreshOwnership: 'cliproxy', refreshOwnership: 'cliproxy',
authStartSupport: 'cliproxy-cli',
authFilePrefixes: ['antigravity-', 'agy-'], authFilePrefixes: ['antigravity-', 'agy-'],
tokenTypeValues: ['antigravity'], tokenTypeValues: ['antigravity'],
aliases: ['antigravity'], aliases: ['antigravity'],
@@ -69,7 +78,10 @@ export const PROVIDER_CAPABILITIES: Record<CLIProxyProvider, ProviderCapabilitie
callbackPort: null, callbackPort: null,
callbackProviderName: 'qwen', callbackProviderName: 'qwen',
authUrlProviderName: 'qwen', authUrlProviderName: 'qwen',
refreshOwnership: 'cliproxy', refreshOwnership: 'unsupported',
authStartSupport: 'unsupported',
authStartUnsupportedReason:
'Alibaba Qwen account linking is not supported by the bundled CLIProxy runtime. Use an API-key Qwen profile; CLIProxyAPI does not expose Qwen OAuth yet.',
authFilePrefixes: ['qwen-'], authFilePrefixes: ['qwen-'],
tokenTypeValues: ['qwen'], tokenTypeValues: ['qwen'],
aliases: [], aliases: [],
@@ -82,6 +94,7 @@ export const PROVIDER_CAPABILITIES: Record<CLIProxyProvider, ProviderCapabilitie
callbackProviderName: 'iflow', callbackProviderName: 'iflow',
authUrlProviderName: 'iflow', authUrlProviderName: 'iflow',
refreshOwnership: 'cliproxy', refreshOwnership: 'cliproxy',
authStartSupport: 'cliproxy-cli',
authFilePrefixes: ['iflow-'], authFilePrefixes: ['iflow-'],
tokenTypeValues: ['iflow'], tokenTypeValues: ['iflow'],
aliases: [], aliases: [],
@@ -94,6 +107,7 @@ export const PROVIDER_CAPABILITIES: Record<CLIProxyProvider, ProviderCapabilitie
callbackProviderName: 'kiro', callbackProviderName: 'kiro',
authUrlProviderName: 'kiro', authUrlProviderName: 'kiro',
refreshOwnership: 'cliproxy', refreshOwnership: 'cliproxy',
authStartSupport: 'cliproxy-cli',
authFilePrefixes: ['kiro-', 'aws-', 'codewhisperer-'], authFilePrefixes: ['kiro-', 'aws-', 'codewhisperer-'],
tokenTypeValues: ['kiro', 'codewhisperer'], tokenTypeValues: ['kiro', 'codewhisperer'],
aliases: ['codewhisperer'], aliases: ['codewhisperer'],
@@ -106,6 +120,7 @@ export const PROVIDER_CAPABILITIES: Record<CLIProxyProvider, ProviderCapabilitie
callbackProviderName: 'copilot', callbackProviderName: 'copilot',
authUrlProviderName: 'github', authUrlProviderName: 'github',
refreshOwnership: 'cliproxy', refreshOwnership: 'cliproxy',
authStartSupport: 'cliproxy-cli',
authFilePrefixes: ['github-copilot-', 'copilot-', 'gh-'], authFilePrefixes: ['github-copilot-', 'copilot-', 'gh-'],
tokenTypeValues: ['github-copilot', 'copilot'], tokenTypeValues: ['github-copilot', 'copilot'],
aliases: ['github-copilot', 'copilot'], aliases: ['github-copilot', 'copilot'],
@@ -118,6 +133,7 @@ export const PROVIDER_CAPABILITIES: Record<CLIProxyProvider, ProviderCapabilitie
callbackProviderName: 'anthropic', callbackProviderName: 'anthropic',
authUrlProviderName: 'anthropic', authUrlProviderName: 'anthropic',
refreshOwnership: 'unsupported', refreshOwnership: 'unsupported',
authStartSupport: 'cliproxy-cli',
authFilePrefixes: ['claude-', 'anthropic-'], authFilePrefixes: ['claude-', 'anthropic-'],
tokenTypeValues: ['claude', 'anthropic'], tokenTypeValues: ['claude', 'anthropic'],
aliases: ['anthropic'], aliases: ['anthropic'],
@@ -130,6 +146,7 @@ export const PROVIDER_CAPABILITIES: Record<CLIProxyProvider, ProviderCapabilitie
callbackProviderName: 'kimi', callbackProviderName: 'kimi',
authUrlProviderName: 'kimi', authUrlProviderName: 'kimi',
refreshOwnership: 'cliproxy', refreshOwnership: 'cliproxy',
authStartSupport: 'cliproxy-cli',
authFilePrefixes: ['kimi-'], authFilePrefixes: ['kimi-'],
tokenTypeValues: ['kimi'], tokenTypeValues: ['kimi'],
aliases: ['moonshot'], aliases: ['moonshot'],
@@ -142,6 +159,7 @@ export const PROVIDER_CAPABILITIES: Record<CLIProxyProvider, ProviderCapabilitie
callbackProviderName: 'cursor', callbackProviderName: 'cursor',
authUrlProviderName: 'cursor', authUrlProviderName: 'cursor',
refreshOwnership: 'cliproxy', refreshOwnership: 'cliproxy',
authStartSupport: 'cliproxy-cli',
authFilePrefixes: ['cursor.', 'cursor-'], authFilePrefixes: ['cursor.', 'cursor-'],
tokenTypeValues: ['cursor'], tokenTypeValues: ['cursor'],
aliases: [], aliases: [],
@@ -154,6 +172,7 @@ export const PROVIDER_CAPABILITIES: Record<CLIProxyProvider, ProviderCapabilitie
callbackProviderName: 'gitlab', callbackProviderName: 'gitlab',
authUrlProviderName: 'gitlab', authUrlProviderName: 'gitlab',
refreshOwnership: 'cliproxy', refreshOwnership: 'cliproxy',
authStartSupport: 'cliproxy-cli',
authFilePrefixes: ['gitlab-'], authFilePrefixes: ['gitlab-'],
tokenTypeValues: ['gitlab'], tokenTypeValues: ['gitlab'],
aliases: ['gitlab-duo'], aliases: ['gitlab-duo'],
@@ -166,6 +185,7 @@ export const PROVIDER_CAPABILITIES: Record<CLIProxyProvider, ProviderCapabilitie
callbackProviderName: 'codebuddy', callbackProviderName: 'codebuddy',
authUrlProviderName: 'codebuddy', authUrlProviderName: 'codebuddy',
refreshOwnership: 'cliproxy', refreshOwnership: 'cliproxy',
authStartSupport: 'cliproxy-cli',
authFilePrefixes: ['codebuddy-'], authFilePrefixes: ['codebuddy-'],
tokenTypeValues: ['codebuddy'], tokenTypeValues: ['codebuddy'],
aliases: ['tencent'], aliases: ['tencent'],
@@ -178,6 +198,7 @@ export const PROVIDER_CAPABILITIES: Record<CLIProxyProvider, ProviderCapabilitie
callbackProviderName: 'kilo', callbackProviderName: 'kilo',
authUrlProviderName: 'kilo', authUrlProviderName: 'kilo',
refreshOwnership: 'unsupported', refreshOwnership: 'unsupported',
authStartSupport: 'cliproxy-cli',
authFilePrefixes: ['kilo-'], authFilePrefixes: ['kilo-'],
tokenTypeValues: ['kilo'], tokenTypeValues: ['kilo'],
aliases: [], aliases: [],
@@ -190,6 +211,7 @@ export const PROVIDER_CAPABILITIES: Record<CLIProxyProvider, ProviderCapabilitie
callbackProviderName: 'qoder', callbackProviderName: 'qoder',
authUrlProviderName: 'qoder', authUrlProviderName: 'qoder',
refreshOwnership: 'unsupported', refreshOwnership: 'unsupported',
authStartSupport: 'cliproxy-cli',
authFilePrefixes: ['qoder-'], authFilePrefixes: ['qoder-'],
tokenTypeValues: ['qoder'], tokenTypeValues: ['qoder'],
aliases: [], aliases: [],
@@ -254,7 +276,7 @@ export function buildProviderAliasMap(
const existingProvider = aliasMap.get(normalized); const existingProvider = aliasMap.get(normalized);
if (existingProvider && existingProvider !== provider) { if (existingProvider && existingProvider !== provider) {
throw new Error( throw new ConfigError(
`Provider alias collision for "${normalized}": ${existingProvider} and ${provider}` `Provider alias collision for "${normalized}": ${existingProvider} and ${provider}`
); );
} }
@@ -330,6 +352,17 @@ export function isRefreshDelegatedToCLIProxy(provider: CLIProxyProvider): boolea
return PROVIDER_CAPABILITIES[provider].refreshOwnership === 'cliproxy'; return PROVIDER_CAPABILITIES[provider].refreshOwnership === 'cliproxy';
} }
export function getUnsupportedAuthStartReason(provider: CLIProxyProvider): string | null {
const capabilities = PROVIDER_CAPABILITIES[provider];
if (capabilities.authStartSupport !== 'unsupported') {
return null;
}
return (
capabilities.authStartUnsupportedReason ??
`${capabilities.displayName} account linking is not supported by the bundled CLIProxy runtime.`
);
}
export function getProviderAuthFilePrefixes(provider: CLIProxyProvider): readonly string[] { export function getProviderAuthFilePrefixes(provider: CLIProxyProvider): readonly string[] {
return PROVIDER_CAPABILITIES[provider].authFilePrefixes; return PROVIDER_CAPABILITIES[provider].authFilePrefixes;
} }
@@ -7,11 +7,12 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'bun:test'; import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
import * as http from 'http'; import * as http from 'http';
import { ToolSanitizationProxy } from '../tool-sanitization-proxy'; import { ToolSanitizationProxy } from '../tool-sanitization-proxy';
import { CodexReasoningProxy } from '../../ai-providers/codex-reasoning-proxy';
// Mock upstream server that echoes requests // Mock upstream server that echoes requests
let mockUpstream: http.Server; let mockUpstream: http.Server;
let mockUpstreamPort: number; let mockUpstreamPort: number;
let lastRequest: { body: unknown; headers: http.IncomingHttpHeaders } | null = null; let lastRequest: { path: string; body: unknown; headers: http.IncomingHttpHeaders } | null = null;
// Track response to send back // Track response to send back
let mockResponse: { status: number; body: unknown; stream?: boolean } = { let mockResponse: { status: number; body: unknown; stream?: boolean } = {
@@ -28,6 +29,7 @@ beforeAll(async () => {
req.on('end', () => { req.on('end', () => {
const body = Buffer.concat(chunks).toString('utf8'); const body = Buffer.concat(chunks).toString('utf8');
lastRequest = { lastRequest = {
path: req.url || '',
body: body ? JSON.parse(body) : null, body: body ? JSON.parse(body) : null,
headers: req.headers, headers: req.headers,
}; };
@@ -184,6 +186,50 @@ describe('ToolSanitizationProxy Integration', () => {
} }
}); });
it('normalizes codex effort aliases through the provider-scoped local proxy chain', async () => {
const toolProxy = new ToolSanitizationProxy({
upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`,
});
const toolPort = await toolProxy.start();
const reasoningProxy = new CodexReasoningProxy({
upstreamBaseUrl: `http://127.0.0.1:${toolPort}`,
modelMap: {
defaultModel: 'gpt-5.5-high',
opusModel: 'gpt-5.5-xhigh',
sonnetModel: 'gpt-5.5-high',
haikuModel: 'gpt-5.5-mini-medium',
},
defaultEffort: 'medium',
});
const reasoningPort = await reasoningProxy.start();
try {
const response = await fetch(
`http://127.0.0.1:${reasoningPort}/api/provider/codex/v1/messages`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'gpt-5.5-high',
messages: [{ role: 'user', content: 'hi' }],
}),
}
);
expect(response.ok).toBe(true);
expect(lastRequest).not.toBeNull();
expect(lastRequest!.path).toBe('/api/provider/codex/v1/messages');
expect((lastRequest!.body as Record<string, unknown>).model).toBe('gpt-5.5');
expect(
((lastRequest!.body as Record<string, unknown>).reasoning as Record<string, unknown>)
.effort
).toBe('high');
} finally {
reasoningProxy.stop();
toolProxy.stop();
}
});
it('normalizes dotted Claude thinking model IDs for root/composite routes', async () => { it('normalizes dotted Claude thinking model IDs for root/composite routes', async () => {
const proxy = new ToolSanitizationProxy({ const proxy = new ToolSanitizationProxy({
upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`, upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`,
+1 -1
View File
@@ -201,7 +201,7 @@ export const BUILTIN_PROVIDER_SHORTCUTS: readonly ShortcutEntry[] = CLIPROXY_PRO
gemini: 'Google Gemini via CLIProxy OAuth', gemini: 'Google Gemini via CLIProxy OAuth',
codex: 'OpenAI Codex via CLIProxy OAuth', codex: 'OpenAI Codex via CLIProxy OAuth',
agy: 'Antigravity via CLIProxy OAuth', agy: 'Antigravity via CLIProxy OAuth',
qwen: 'Qwen Code via CLIProxy OAuth', qwen: 'Qwen Code via CLIProxy; account linking unsupported',
iflow: 'iFlow via CLIProxy OAuth', iflow: 'iFlow via CLIProxy OAuth',
kiro: 'Kiro via CLIProxy OAuth', kiro: 'Kiro via CLIProxy OAuth',
ghcp: 'Deprecated GitHub Copilot via CLIProxy OAuth', ghcp: 'Deprecated GitHub Copilot via CLIProxy OAuth',
@@ -60,6 +60,7 @@ import {
} from '../../cliproxy/auth/auth-types'; } from '../../cliproxy/auth/auth-types';
import { import {
getOAuthFlowType, getOAuthFlowType,
getUnsupportedAuthStartReason,
isBrowserUrlAuthProvider, isBrowserUrlAuthProvider,
mapExternalProviderName, mapExternalProviderName,
} from '../../cliproxy/provider-capabilities'; } from '../../cliproxy/provider-capabilities';
@@ -285,6 +286,11 @@ export function getStartUrlUnsupportedReason(
provider: CLIProxyProvider, provider: CLIProxyProvider,
options?: { kiroMethod?: KiroAuthMethod } options?: { kiroMethod?: KiroAuthMethod }
): string | null { ): string | null {
const unsupportedAuthStartReason = getStartAuthUnsupportedReason(provider);
if (unsupportedAuthStartReason) {
return unsupportedAuthStartReason;
}
if (provider === 'kiro') { if (provider === 'kiro') {
const kiroMethod = options?.kiroMethod ?? normalizeKiroAuthMethod(); const kiroMethod = options?.kiroMethod ?? normalizeKiroAuthMethod();
if (kiroMethod === 'idc') { if (kiroMethod === 'idc') {
@@ -316,6 +322,10 @@ export function getStartAuthFailureMessage(provider: CLIProxyProvider): string {
return 'Authentication failed or was cancelled'; return 'Authentication failed or was cancelled';
} }
export function getStartAuthUnsupportedReason(provider: CLIProxyProvider): string | null {
return getUnsupportedAuthStartReason(provider);
}
function getManualCallbackRegistrationError(provider: CLIProxyProvider): string { function getManualCallbackRegistrationError(provider: CLIProxyProvider): string {
if (PROVIDERS_WITHOUT_EMAIL.includes(provider)) { if (PROVIDERS_WITHOUT_EMAIL.includes(provider)) {
return 'Authenticated token could not be matched to a new account. Retry the flow and choose a different nickname if needed.'; return 'Authenticated token could not be matched to a new account. Retry the flow and choose a different nickname if needed.';
@@ -683,6 +693,12 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise<voi
} }
const localProvider = provider as CLIProxyProvider; const localProvider = provider as CLIProxyProvider;
const unsupportedReason = getStartAuthUnsupportedReason(localProvider);
if (unsupportedReason) {
res.status(400).json({ error: unsupportedReason, code: 'AUTH_START_UNSUPPORTED' });
return;
}
const existingAccounts = getProviderAccounts(localProvider); const existingAccounts = getProviderAccounts(localProvider);
const reauthTarget = getReauthAccountTarget(accountId, existingAccounts); const reauthTarget = getReauthAccountTarget(accountId, existingAccounts);
if (reauthTarget.error) { if (reauthTarget.error) {
@@ -4,6 +4,7 @@ import {
getReauthAccountTarget, getReauthAccountTarget,
getStartAuthFailureMessage, getStartAuthFailureMessage,
getStartAuthNicknameError, getStartAuthNicknameError,
getStartAuthUnsupportedReason,
getStartUrlUnsupportedReason, getStartUrlUnsupportedReason,
} from '../../../src/web-server/routes/cliproxy-auth-routes'; } from '../../../src/web-server/routes/cliproxy-auth-routes';
@@ -13,12 +14,14 @@ describe('cliproxy-auth-routes start-url guard', () => {
"Kiro method 'aws' uses Device Code flow" "Kiro method 'aws' uses Device Code flow"
); );
expect(getStartUrlUnsupportedReason('ghcp')).toContain("Provider 'ghcp' uses Device Code flow"); expect(getStartUrlUnsupportedReason('ghcp')).toContain("Provider 'ghcp' uses Device Code flow");
expect(getStartUrlUnsupportedReason('qwen')).toContain("Provider 'qwen' uses Device Code flow"); expect(getStartUrlUnsupportedReason('qwen')).toContain('Qwen account linking is not supported');
expect(getStartUrlUnsupportedReason('codebuddy')).toContain( expect(getStartUrlUnsupportedReason('codebuddy')).toContain(
"Provider 'codebuddy' uses Device Code flow" "Provider 'codebuddy' uses Device Code flow"
); );
expect(getStartUrlUnsupportedReason('kilo')).toContain("Provider 'kilo' uses Device Code flow"); expect(getStartUrlUnsupportedReason('kilo')).toContain("Provider 'kilo' uses Device Code flow");
expect(getStartUrlUnsupportedReason('qoder')).toContain("Provider 'qoder' uses Device Code flow"); expect(getStartUrlUnsupportedReason('qoder')).toContain(
"Provider 'qoder' uses Device Code flow"
);
}); });
it('allows Cursor browser URL auth on start-url', () => { it('allows Cursor browser URL auth on start-url', () => {
@@ -89,6 +92,13 @@ describe('cliproxy-auth-routes Kiro IDC start validation', () => {
}); });
describe('cliproxy-auth-routes start failure messaging', () => { describe('cliproxy-auth-routes start failure messaging', () => {
it('returns a clear unsupported message for Qwen account linking', () => {
expect(getStartAuthUnsupportedReason('qwen')).toContain(
'Qwen account linking is not supported'
);
expect(getStartAuthUnsupportedReason('kiro')).toBeNull();
});
it('returns ghcp-specific guidance for Copilot verification failures', () => { it('returns ghcp-specific guidance for Copilot verification failures', () => {
expect(getStartAuthFailureMessage('ghcp')).toContain( expect(getStartAuthFailureMessage('ghcp')).toContain(
'GitHub Copilot verification did not complete' 'GitHub Copilot verification did not complete'