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 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
- API and local-model profiles like GLM, Kimi, OpenRouter, Ollama, llama.cpp,
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,
and move between providers in seconds.
@@ -5,6 +5,7 @@ import {
getDeviceCodeVerificationProviders,
getOAuthCallbackPort,
getOAuthFlowType,
getUnsupportedAuthStartReason,
PROVIDER_CAPABILITIES,
getProviderDisplayName,
getProvidersByOAuthFlow,
@@ -134,10 +135,20 @@ describe('provider-capabilities', () => {
expect(getOAuthCallbackPort('gitlab')).toBe(17171);
expect(getOAuthCallbackPort('gemini')).toBe(8085);
expect(PROVIDER_CAPABILITIES.gemini.refreshOwnership).toBe('cliproxy');
expect(PROVIDER_CAPABILITIES.qwen.refreshOwnership).toBe('unsupported');
expect(getProviderDisplayName('agy')).toBe('Antigravity');
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', () => {
const capabilitiesWithCollision = {
...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 { checkCrossLaneEmailOverlap } from '../accounts/account-safety-cross-lane';
import { ensureCliAntigravityResponsibility } from '../auth/antigravity-responsibility';
import { getUnsupportedAuthStartReason } from '../provider-capabilities';
import { InteractivePrompt } from '../../utils/prompt';
import { getCcsDir } from '../../utils/config-manager';
import { generateSessionId } from './project-selection-handler';
@@ -616,6 +617,11 @@ function buildOAuthArgs(
kiroIDCFlow?: OAuthOptions['kiroIDCFlow'];
} = {}
): string[] {
const unsupportedReason = getUnsupportedAuthStartReason(provider);
if (unsupportedReason) {
throw new AuthError(unsupportedReason, provider);
}
const args = ['--config', configPath];
if (provider === 'kiro') {
@@ -1091,6 +1097,12 @@ export async function triggerOAuth(
options: OAuthOptions = {}
): Promise<AccountInfo | null> {
const oauthConfig = getOAuthConfig(provider);
const unsupportedReason = getUnsupportedAuthStartReason(provider);
if (unsupportedReason) {
console.log(fail(unsupportedReason));
return null;
}
warnOAuthBanRisk(provider);
const oauthStartedAt = Date.now();
logger.stage('auth', 'cliproxy.oauth.start', 'Triggering OAuth flow', {
@@ -4,8 +4,9 @@
* Exports refresh functions for each OAuth provider.
*
* 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)
* - Unsupported account linking: qwen
* - Not implemented: claude
*/
+2 -1
View File
@@ -497,9 +497,10 @@ export function displayAuthStatus(): void {
*
* Refresh responsibility:
* - 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).
* CCS only checks if token file exists (authentication state).
* - qwen: account linking is unsupported by the bundled CLIProxy runtime
* - claude: not yet implemented
*
* @param provider The CLIProxy provider
+35 -2
View File
@@ -1,7 +1,9 @@
import type { CLIProxyProvider } from './types';
import { ConfigError } from '../errors/error-types';
export type OAuthFlowType = 'authorization_code' | 'device_code';
export type TokenRefreshOwnership = 'ccs' | 'cliproxy' | 'unsupported';
export type AuthStartSupport = 'cliproxy-cli' | 'unsupported';
export interface ProviderCapabilities {
displayName: string;
@@ -14,6 +16,10 @@ export interface ProviderCapabilities {
authUrlProviderName: string;
/** Who owns token refresh logic for this provider. */
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. */
authFilePrefixes: readonly string[];
/** Token JSON "type" values accepted for this provider. */
@@ -34,6 +40,7 @@ export const PROVIDER_CAPABILITIES: Record<CLIProxyProvider, ProviderCapabilitie
callbackProviderName: 'gemini',
authUrlProviderName: 'gemini-cli',
refreshOwnership: 'cliproxy',
authStartSupport: 'cliproxy-cli',
authFilePrefixes: ['gemini-', 'google-'],
tokenTypeValues: ['gemini'],
aliases: ['gemini-cli'],
@@ -46,6 +53,7 @@ export const PROVIDER_CAPABILITIES: Record<CLIProxyProvider, ProviderCapabilitie
callbackProviderName: 'codex',
authUrlProviderName: 'codex',
refreshOwnership: 'cliproxy',
authStartSupport: 'cliproxy-cli',
authFilePrefixes: ['codex-', 'openai-'],
tokenTypeValues: ['codex'],
aliases: [],
@@ -58,6 +66,7 @@ export const PROVIDER_CAPABILITIES: Record<CLIProxyProvider, ProviderCapabilitie
callbackProviderName: 'antigravity',
authUrlProviderName: 'antigravity',
refreshOwnership: 'cliproxy',
authStartSupport: 'cliproxy-cli',
authFilePrefixes: ['antigravity-', 'agy-'],
tokenTypeValues: ['antigravity'],
aliases: ['antigravity'],
@@ -69,7 +78,10 @@ export const PROVIDER_CAPABILITIES: Record<CLIProxyProvider, ProviderCapabilitie
callbackPort: null,
callbackProviderName: '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-'],
tokenTypeValues: ['qwen'],
aliases: [],
@@ -82,6 +94,7 @@ export const PROVIDER_CAPABILITIES: Record<CLIProxyProvider, ProviderCapabilitie
callbackProviderName: 'iflow',
authUrlProviderName: 'iflow',
refreshOwnership: 'cliproxy',
authStartSupport: 'cliproxy-cli',
authFilePrefixes: ['iflow-'],
tokenTypeValues: ['iflow'],
aliases: [],
@@ -94,6 +107,7 @@ export const PROVIDER_CAPABILITIES: Record<CLIProxyProvider, ProviderCapabilitie
callbackProviderName: 'kiro',
authUrlProviderName: 'kiro',
refreshOwnership: 'cliproxy',
authStartSupport: 'cliproxy-cli',
authFilePrefixes: ['kiro-', 'aws-', 'codewhisperer-'],
tokenTypeValues: ['kiro', 'codewhisperer'],
aliases: ['codewhisperer'],
@@ -106,6 +120,7 @@ export const PROVIDER_CAPABILITIES: Record<CLIProxyProvider, ProviderCapabilitie
callbackProviderName: 'copilot',
authUrlProviderName: 'github',
refreshOwnership: 'cliproxy',
authStartSupport: 'cliproxy-cli',
authFilePrefixes: ['github-copilot-', 'copilot-', 'gh-'],
tokenTypeValues: ['github-copilot', 'copilot'],
aliases: ['github-copilot', 'copilot'],
@@ -118,6 +133,7 @@ export const PROVIDER_CAPABILITIES: Record<CLIProxyProvider, ProviderCapabilitie
callbackProviderName: 'anthropic',
authUrlProviderName: 'anthropic',
refreshOwnership: 'unsupported',
authStartSupport: 'cliproxy-cli',
authFilePrefixes: ['claude-', 'anthropic-'],
tokenTypeValues: ['claude', 'anthropic'],
aliases: ['anthropic'],
@@ -130,6 +146,7 @@ export const PROVIDER_CAPABILITIES: Record<CLIProxyProvider, ProviderCapabilitie
callbackProviderName: 'kimi',
authUrlProviderName: 'kimi',
refreshOwnership: 'cliproxy',
authStartSupport: 'cliproxy-cli',
authFilePrefixes: ['kimi-'],
tokenTypeValues: ['kimi'],
aliases: ['moonshot'],
@@ -142,6 +159,7 @@ export const PROVIDER_CAPABILITIES: Record<CLIProxyProvider, ProviderCapabilitie
callbackProviderName: 'cursor',
authUrlProviderName: 'cursor',
refreshOwnership: 'cliproxy',
authStartSupport: 'cliproxy-cli',
authFilePrefixes: ['cursor.', 'cursor-'],
tokenTypeValues: ['cursor'],
aliases: [],
@@ -154,6 +172,7 @@ export const PROVIDER_CAPABILITIES: Record<CLIProxyProvider, ProviderCapabilitie
callbackProviderName: 'gitlab',
authUrlProviderName: 'gitlab',
refreshOwnership: 'cliproxy',
authStartSupport: 'cliproxy-cli',
authFilePrefixes: ['gitlab-'],
tokenTypeValues: ['gitlab'],
aliases: ['gitlab-duo'],
@@ -166,6 +185,7 @@ export const PROVIDER_CAPABILITIES: Record<CLIProxyProvider, ProviderCapabilitie
callbackProviderName: 'codebuddy',
authUrlProviderName: 'codebuddy',
refreshOwnership: 'cliproxy',
authStartSupport: 'cliproxy-cli',
authFilePrefixes: ['codebuddy-'],
tokenTypeValues: ['codebuddy'],
aliases: ['tencent'],
@@ -178,6 +198,7 @@ export const PROVIDER_CAPABILITIES: Record<CLIProxyProvider, ProviderCapabilitie
callbackProviderName: 'kilo',
authUrlProviderName: 'kilo',
refreshOwnership: 'unsupported',
authStartSupport: 'cliproxy-cli',
authFilePrefixes: ['kilo-'],
tokenTypeValues: ['kilo'],
aliases: [],
@@ -190,6 +211,7 @@ export const PROVIDER_CAPABILITIES: Record<CLIProxyProvider, ProviderCapabilitie
callbackProviderName: 'qoder',
authUrlProviderName: 'qoder',
refreshOwnership: 'unsupported',
authStartSupport: 'cliproxy-cli',
authFilePrefixes: ['qoder-'],
tokenTypeValues: ['qoder'],
aliases: [],
@@ -254,7 +276,7 @@ export function buildProviderAliasMap(
const existingProvider = aliasMap.get(normalized);
if (existingProvider && existingProvider !== provider) {
throw new Error(
throw new ConfigError(
`Provider alias collision for "${normalized}": ${existingProvider} and ${provider}`
);
}
@@ -330,6 +352,17 @@ export function isRefreshDelegatedToCLIProxy(provider: CLIProxyProvider): boolea
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[] {
return PROVIDER_CAPABILITIES[provider].authFilePrefixes;
}
@@ -7,11 +7,12 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
import * as http from 'http';
import { ToolSanitizationProxy } from '../tool-sanitization-proxy';
import { CodexReasoningProxy } from '../../ai-providers/codex-reasoning-proxy';
// Mock upstream server that echoes requests
let mockUpstream: http.Server;
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
let mockResponse: { status: number; body: unknown; stream?: boolean } = {
@@ -28,6 +29,7 @@ beforeAll(async () => {
req.on('end', () => {
const body = Buffer.concat(chunks).toString('utf8');
lastRequest = {
path: req.url || '',
body: body ? JSON.parse(body) : null,
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 () => {
const proxy = new ToolSanitizationProxy({
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',
codex: 'OpenAI Codex 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',
kiro: 'Kiro via CLIProxy OAuth',
ghcp: 'Deprecated GitHub Copilot via CLIProxy OAuth',
@@ -60,6 +60,7 @@ import {
} from '../../cliproxy/auth/auth-types';
import {
getOAuthFlowType,
getUnsupportedAuthStartReason,
isBrowserUrlAuthProvider,
mapExternalProviderName,
} from '../../cliproxy/provider-capabilities';
@@ -285,6 +286,11 @@ export function getStartUrlUnsupportedReason(
provider: CLIProxyProvider,
options?: { kiroMethod?: KiroAuthMethod }
): string | null {
const unsupportedAuthStartReason = getStartAuthUnsupportedReason(provider);
if (unsupportedAuthStartReason) {
return unsupportedAuthStartReason;
}
if (provider === 'kiro') {
const kiroMethod = options?.kiroMethod ?? normalizeKiroAuthMethod();
if (kiroMethod === 'idc') {
@@ -316,6 +322,10 @@ export function getStartAuthFailureMessage(provider: CLIProxyProvider): string {
return 'Authentication failed or was cancelled';
}
export function getStartAuthUnsupportedReason(provider: CLIProxyProvider): string | null {
return getUnsupportedAuthStartReason(provider);
}
function getManualCallbackRegistrationError(provider: CLIProxyProvider): string {
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.';
@@ -683,6 +693,12 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise<voi
}
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 reauthTarget = getReauthAccountTarget(accountId, existingAccounts);
if (reauthTarget.error) {
@@ -4,6 +4,7 @@ import {
getReauthAccountTarget,
getStartAuthFailureMessage,
getStartAuthNicknameError,
getStartAuthUnsupportedReason,
getStartUrlUnsupportedReason,
} 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"
);
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(
"Provider 'codebuddy' 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', () => {
@@ -89,6 +92,13 @@ describe('cliproxy-auth-routes Kiro IDC start validation', () => {
});
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', () => {
expect(getStartAuthFailureMessage('ghcp')).toContain(
'GitHub Copilot verification did not complete'