From b6ef4e5782f0dcd6dda3419c9b545dea6bfa1dcb Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Thu, 18 Jun 2026 21:34:27 -0400 Subject: [PATCH] fix(cliproxy): guard unsupported Qwen account auth --- README.md | 5 +- .../__tests__/provider-capabilities.test.ts | 11 +++++ .../oauth-handler-unsupported-auth.test.ts | 35 ++++++++++++++ src/cliproxy/auth/oauth-handler.ts | 12 +++++ .../auth/provider-refreshers/index.ts | 3 +- src/cliproxy/auth/token-manager.ts | 3 +- src/cliproxy/provider-capabilities.ts | 37 +++++++++++++- ...ool-sanitization-proxy-integration.test.ts | 48 ++++++++++++++++++- src/commands/command-catalog.ts | 2 +- src/web-server/routes/cliproxy-auth-routes.ts | 16 +++++++ .../web-server/cliproxy-auth-routes.test.ts | 14 +++++- 11 files changed, 177 insertions(+), 9 deletions(-) create mode 100644 src/cliproxy/auth/__tests__/oauth-handler-unsupported-auth.test.ts diff --git a/README.md b/README.md index dd086993..9eea7e95 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/src/cliproxy/__tests__/provider-capabilities.test.ts b/src/cliproxy/__tests__/provider-capabilities.test.ts index 16f15b21..b9665bd2 100644 --- a/src/cliproxy/__tests__/provider-capabilities.test.ts +++ b/src/cliproxy/__tests__/provider-capabilities.test.ts @@ -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, diff --git a/src/cliproxy/auth/__tests__/oauth-handler-unsupported-auth.test.ts b/src/cliproxy/auth/__tests__/oauth-handler-unsupported-auth.test.ts new file mode 100644 index 00000000..b692c0cb --- /dev/null +++ b/src/cliproxy/auth/__tests__/oauth-handler-unsupported-auth.test.ts @@ -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(); + } + }); +}); diff --git a/src/cliproxy/auth/oauth-handler.ts b/src/cliproxy/auth/oauth-handler.ts index 1ce30b65..2cde9b35 100644 --- a/src/cliproxy/auth/oauth-handler.ts +++ b/src/cliproxy/auth/oauth-handler.ts @@ -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 { 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', { diff --git a/src/cliproxy/auth/provider-refreshers/index.ts b/src/cliproxy/auth/provider-refreshers/index.ts index 23ed4916..aa9925f5 100644 --- a/src/cliproxy/auth/provider-refreshers/index.ts +++ b/src/cliproxy/auth/provider-refreshers/index.ts @@ -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 */ diff --git a/src/cliproxy/auth/token-manager.ts b/src/cliproxy/auth/token-manager.ts index 511aead1..1a9e059e 100644 --- a/src/cliproxy/auth/token-manager.ts +++ b/src/cliproxy/auth/token-manager.ts @@ -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 diff --git a/src/cliproxy/provider-capabilities.ts b/src/cliproxy/provider-capabilities.ts index eb942cd7..b72bf07f 100644 --- a/src/cliproxy/provider-capabilities.ts +++ b/src/cliproxy/provider-capabilities.ts @@ -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 { 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).model).toBe('gpt-5.5'); + expect( + ((lastRequest!.body as Record).reasoning as Record) + .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}`, diff --git a/src/commands/command-catalog.ts b/src/commands/command-catalog.ts index 4043a68e..e5e269b9 100644 --- a/src/commands/command-catalog.ts +++ b/src/commands/command-catalog.ts @@ -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', diff --git a/src/web-server/routes/cliproxy-auth-routes.ts b/src/web-server/routes/cliproxy-auth-routes.ts index b2e61991..c29fdb95 100644 --- a/src/web-server/routes/cliproxy-auth-routes.ts +++ b/src/web-server/routes/cliproxy-auth-routes.ts @@ -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 { "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'