From 19c6c3f4577932a3b9c1eb4f8818d6fbec838bca Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 29 Jul 2026 14:08:48 -0400 Subject: [PATCH 1/3] fix(cliproxy): probe Gemini OAuth support --- src/cliproxy/auth/oauth-cli-args.ts | 93 +++++++++++ src/cliproxy/auth/oauth-cli-capabilities.ts | 108 +++++++++++++ src/cliproxy/auth/oauth-handler.ts | 165 ++++++++++++-------- src/cliproxy/binary-manager.ts | 3 +- 4 files changed, 307 insertions(+), 62 deletions(-) create mode 100644 src/cliproxy/auth/oauth-cli-args.ts create mode 100644 src/cliproxy/auth/oauth-cli-capabilities.ts diff --git a/src/cliproxy/auth/oauth-cli-args.ts b/src/cliproxy/auth/oauth-cli-args.ts new file mode 100644 index 00000000..fd643bd8 --- /dev/null +++ b/src/cliproxy/auth/oauth-cli-args.ts @@ -0,0 +1,93 @@ +import { ValidationError, AuthError } from '../../errors/error-types'; +import { getUnsupportedAuthStartReason } from '../provider-capabilities'; +import { CLIProxyBackend, CLIProxyProvider } from '../types'; +import { + getKiroCLIAuthFlag, + getOAuthConfig, + isKiroCLIAuthMethod, + normalizeKiroAuthMethod, + normalizeKiroIDCFlow, + type OAuthOptions, +} from './auth-types'; +import { + getOAuthFlagCandidatesForProvider, + resolveAdvertisedAuthFlag, +} from './oauth-cli-capabilities'; + +export function buildOAuthArgs( + provider: CLIProxyProvider, + configPath: string, + headless: boolean, + noIncognito: boolean, + options: { + advertisedFlags?: ReadonlySet; + backend?: CLIProxyBackend; + kiroMethod?: OAuthOptions['kiroMethod']; + kiroIDCStartUrl?: string; + kiroIDCRegion?: string; + kiroIDCFlow?: OAuthOptions['kiroIDCFlow']; + } = {} +): string[] { + const unsupportedReason = getUnsupportedAuthStartReason(provider); + if (unsupportedReason) { + throw new AuthError(unsupportedReason, provider); + } + + const args = ['--config', configPath]; + const advertisedFlags = options.advertisedFlags; + + if (provider === 'kiro') { + const method = normalizeKiroAuthMethod(options.kiroMethod); + if (!isKiroCLIAuthMethod(method)) { + throw new AuthError(`Kiro auth method '${method}' is not supported by CLI flow.`, 'kiro'); + } + + const selectedKiroFlag = advertisedFlags + ? resolveAdvertisedAuthFlag( + provider, + getOAuthFlagCandidatesForProvider(provider, method), + advertisedFlags, + { backend: options.backend } + ) + : getKiroCLIAuthFlag(method); + + if (method !== 'idc') { + args.push(selectedKiroFlag); + } else { + const startUrl = options.kiroIDCStartUrl?.trim(); + if (!startUrl) { + throw new ValidationError( + 'Kiro IDC login requires --kiro-idc-start-url', + 'kiroIDCStartUrl' + ); + } + + args.push(selectedKiroFlag, '--kiro-idc-start-url', startUrl); + const region = options.kiroIDCRegion?.trim(); + if (region) { + args.push('--kiro-idc-region', region); + } + args.push('--kiro-idc-flow', normalizeKiroIDCFlow(options.kiroIDCFlow)); + } + } else { + args.push( + advertisedFlags + ? resolveAdvertisedAuthFlag( + provider, + getOAuthFlagCandidatesForProvider(provider), + advertisedFlags, + { backend: options.backend } + ) + : getOAuthConfig(provider).authFlag + ); + } + + if (headless) { + args.push('--no-browser'); + } + if (provider === 'kiro' && noIncognito) { + args.push('--no-incognito'); + } + + return args; +} diff --git a/src/cliproxy/auth/oauth-cli-capabilities.ts b/src/cliproxy/auth/oauth-cli-capabilities.ts new file mode 100644 index 00000000..8bbd0af8 --- /dev/null +++ b/src/cliproxy/auth/oauth-cli-capabilities.ts @@ -0,0 +1,108 @@ +import * as childProcess from 'child_process'; +import { AuthError, BinaryError } from '../../errors/error-types'; +import type { CLIProxyBackend, CLIProxyProvider } from '../types'; +import { getKiroCLIAuthFlag, getOAuthConfig, type KiroCLIAuthMethod } from './auth-types'; + +const HELP_FLAG_PATTERN = /(?:^|\n)\s+-([a-z0-9][a-z0-9-]*)\b/gi; +export const OAUTH_HELP_PROBE_TIMEOUT_MS = 5000; + +export function extractAdvertisedCliFlags(helpText: string): Set { + const flags = new Set(); + + for (const match of helpText.matchAll(HELP_FLAG_PATTERN)) { + const flagName = match[1]?.trim(); + if (!flagName) { + continue; + } + flags.add(`--${flagName}`); + } + + return flags; +} + +export function getOAuthFlagCandidatesForProvider( + provider: CLIProxyProvider, + kiroMethod?: KiroCLIAuthMethod +): readonly string[] { + if (provider !== 'kiro') { + return [getOAuthConfig(provider).authFlag]; + } + + switch (kiroMethod) { + case 'google': + return ['--kiro-google-login', '--kiro-login']; + case 'aws': + case 'aws-authcode': + case 'idc': + return [getKiroCLIAuthFlag(kiroMethod)]; + default: + return [getKiroCLIAuthFlag('aws')]; + } +} + +export function selectAdvertisedAuthFlag( + candidates: readonly string[], + advertisedFlags: ReadonlySet +): string | null { + for (const candidate of candidates) { + if (advertisedFlags.has(candidate)) { + return candidate; + } + } + + return null; +} + +export function resolveAdvertisedAuthFlag( + provider: CLIProxyProvider, + candidates: readonly string[], + advertisedFlags: ReadonlySet, + options: { backend?: CLIProxyBackend } = {} +): string { + const selected = selectAdvertisedAuthFlag(candidates, advertisedFlags); + if (selected) { + return selected; + } + + const oauthConfig = getOAuthConfig(provider); + if (provider === 'gemini' && options.backend === 'original') { + throw new AuthError( + 'Installed CLIProxy binary does not advertise Google Gemini login support (--login). The active `cliproxy.backend: original` runtime cannot start Gemini OAuth from CCS. To use Gemini OAuth, switch `cliproxy.backend` to `plus`, set CLIPROXY_GEMINI_OAUTH_CLIENT_ID and CLIPROXY_GEMINI_OAUTH_CLIENT_SECRET before starting CLIProxy Plus, reinstall the maintained Plus fork, and retry auth.', + provider + ); + } + + throw new AuthError( + `Installed CLIProxy binary does not advertise a supported ${oauthConfig.displayName} login flag (${candidates.join(' or ')}). Run \`ccs cliproxy status\`, then reinstall the active backend binary before retrying auth.`, + provider + ); +} + +export function probeCliProxyAdvertisedFlags(binaryPath: string): Set { + const result = childProcess.spawnSync(binaryPath, ['--help'], { + encoding: 'utf8', + shell: false, + timeout: OAUTH_HELP_PROBE_TIMEOUT_MS, + windowsHide: true, + }); + + if (result.error) { + const errorCode = (result.error as NodeJS.ErrnoException).code; + if (errorCode === 'ETIMEDOUT') { + throw new BinaryError( + `Timed out after ${OAUTH_HELP_PROBE_TIMEOUT_MS}ms while inspecting CLIProxy login capabilities`, + binaryPath + ); + } + throw result.error; + } + + if (result.signal) { + throw new BinaryError( + `CLIProxy capability probe was interrupted while inspecting login capabilities (${result.signal})`, + binaryPath + ); + } + + return extractAdvertisedCliFlags(`${result.stdout ?? ''}\n${result.stderr ?? ''}`); +} diff --git a/src/cliproxy/auth/oauth-handler.ts b/src/cliproxy/auth/oauth-handler.ts index 14264460..8495503a 100644 --- a/src/cliproxy/auth/oauth-handler.ts +++ b/src/cliproxy/auth/oauth-handler.ts @@ -14,9 +14,9 @@ import * as fs from 'fs'; import * as path from 'path'; import { fail, info, warn, color, ok } from '../../utils/ui'; import { createLogger } from '../../services/logging'; -import { ensureCLIProxyBinary, getStoredConfiguredBackend } from '../binary-manager'; +import { ensureCLIProxyBinary, getConfiguredBackend } from '../binary-manager'; import { generateConfig } from '../config/config-generator'; -import { AuthError, ConfigError } from '../../errors/error-types'; +import { AuthError, BinaryError, ConfigError } from '../../errors/error-types'; import { CLIProxyBackend, CLIProxyProvider } from '../types'; import { AccountInfo, @@ -37,8 +37,6 @@ import { DEFAULT_KIRO_AUTH_METHOD, DEFAULT_KIRO_IDC_FLOW, getKiroCallbackPort, - getKiroCLIAuthArgs, - isKiroCLIAuthMethod, isKiroDeviceCodeMethod, getOAuthConfig, ProviderOAuthConfig, @@ -47,6 +45,7 @@ import { getManagementOAuthCallbackPath, normalizeKiroAuthMethod, normalizeKiroIDCFlow, + isKiroCLIAuthMethod, } from './auth-types'; import { isHeadlessEnvironment, killProcessOnPort, showStep } from './environment-detector'; import { @@ -60,6 +59,12 @@ import { import { executeOAuthProcess } from './oauth-process'; import { importKiroToken } from './kiro-import'; import { parseGitLabPatAuthResponse } from './gitlab-pat-response'; +import { + getOAuthFlagCandidatesForProvider, + probeCliProxyAdvertisedFlags, + selectAdvertisedAuthFlag, +} from './oauth-cli-capabilities'; +import { buildOAuthArgs } from './oauth-cli-args'; import { buildOAuthStartFailureGuidance, formatOAuthStartFailureForCli, @@ -144,14 +149,27 @@ function buildPlusOAuthCredentialMessage( displayName: string, idEnv: string, secretEnv: string, - missing?: string[] + missing?: string[], + options?: { originalFallbackSupported?: boolean } ): string { const missingText = missing?.length ? ` Missing: ${missing.join(', ')}.` : ''; + const fallbackText = + options?.originalFallbackSupported === false + ? ' Current `cliproxy.backend: original` releases do not advertise Gemini login, so switching back to original will not restore Gemini OAuth.' + : ` or switch \`cliproxy.backend\` to \`original\` for ${displayName}.`; return ( `${displayName} OAuth from CLIProxy Plus is missing Google OAuth client credentials.` + missingText + ` Set ${idEnv} and ${secretEnv} before starting CLIProxy Plus,` + - ` or switch \`cliproxy.backend\` to \`original\` for ${displayName}.` + fallbackText + ); +} + +function getGeminiOriginalBackendOAuthMessage(): string { + return ( + 'Installed CLIProxy binary does not advertise Google Gemini login support (--login). ' + + 'The active `cliproxy.backend: original` runtime cannot start Gemini OAuth from CCS. ' + + `To use Gemini OAuth, switch \`cliproxy.backend\` to \`plus\`, set ${GEMINI_PLUS_CLIENT_ID_ENV} and ${GEMINI_PLUS_CLIENT_SECRET_ENV} before starting CLIProxy Plus, reinstall the maintained Plus fork, and retry auth.` ); } @@ -177,7 +195,9 @@ export function getPlusOAuthCredentialError( const missing = [entry.idEnv, entry.secretEnv].filter((name) => !env[name]?.trim()); return missing.length > 0 - ? buildPlusOAuthCredentialMessage(entry.displayName, entry.idEnv, entry.secretEnv, missing) + ? buildPlusOAuthCredentialMessage(entry.displayName, entry.idEnv, entry.secretEnv, missing, { + originalFallbackSupported: provider !== 'gemini', + }) : null; } @@ -205,7 +225,15 @@ export function getPlusAuthUrlCredentialError( const clientId = parsed.searchParams.get('client_id')?.trim(); return clientId ? null - : buildPlusOAuthCredentialMessage(entry.displayName, entry.idEnv, entry.secretEnv); + : buildPlusOAuthCredentialMessage( + entry.displayName, + entry.idEnv, + entry.secretEnv, + undefined, + { + originalFallbackSupported: provider !== 'gemini', + } + ); } catch { return null; } @@ -579,12 +607,21 @@ async function runPreflightChecks( */ async function prepareBinary( provider: CLIProxyProvider, - verbose: boolean -): Promise<{ binaryPath: string; tokenDir: string; configPath: string } | null> { + verbose: boolean, + backend: CLIProxyBackend +): Promise<{ + binaryPath: string; + tokenDir: string; + configPath: string; + backend: CLIProxyBackend; +} | null> { showStep(1, 4, 'progress', 'Preparing CLIProxy binary...'); try { - const binaryPath = await ensureCLIProxyBinary(verbose, { skipAutoUpdate: true }); + const binaryPath = await ensureCLIProxyBinary(verbose, { + backend, + skipAutoUpdate: true, + }); process.stdout.write('\x1b[1A\x1b[2K'); showStep(1, 4, 'ok', 'CLIProxy binary ready'); @@ -596,7 +633,7 @@ async function prepareBinary( console.error(`[auth] Config generated: ${configPath}`); } - return { binaryPath, tokenDir, configPath }; + return { binaryPath, tokenDir, configPath, backend }; } catch (error) { process.stdout.write('\x1b[1A\x1b[2K'); showStep(1, 4, 'fail', 'Failed to prepare CLIProxy binary'); @@ -605,49 +642,31 @@ async function prepareBinary( } } -export function buildOAuthArgs( +async function prepareOAuthRuntime( provider: CLIProxyProvider, - configPath: string, - headless: boolean, - noIncognito: boolean, - options: { - kiroMethod?: OAuthOptions['kiroMethod']; - kiroIDCStartUrl?: string; - kiroIDCRegion?: string; - kiroIDCFlow?: OAuthOptions['kiroIDCFlow']; - } = {} -): string[] { - const unsupportedReason = getUnsupportedAuthStartReason(provider); - if (unsupportedReason) { - throw new AuthError(unsupportedReason, provider); + verbose: boolean, + backend: CLIProxyBackend +): Promise<{ + advertisedFlags: ReadonlySet; + backend: CLIProxyBackend; + binaryPath: string; + configPath: string; + tokenDir: string; +}> { + const prepared = await prepareBinary(provider, verbose, backend); + if (!prepared) { + throw new BinaryError('CLIProxy binary preparation returned no runtime'); } - const args = ['--config', configPath]; + return { + ...prepared, + advertisedFlags: probeCliProxyAdvertisedFlags(prepared.binaryPath), + }; +} - if (provider === 'kiro') { - const method = normalizeKiroAuthMethod(options.kiroMethod); - if (!isKiroCLIAuthMethod(method)) { - throw new AuthError(`Kiro auth method '${method}' is not supported by CLI flow.`, 'kiro'); - } - args.push( - ...getKiroCLIAuthArgs(method, { - idcStartUrl: options.kiroIDCStartUrl, - idcRegion: options.kiroIDCRegion, - idcFlow: options.kiroIDCFlow, - }) - ); - } else { - args.push(getOAuthConfig(provider).authFlag); - } - - if (headless) { - args.push('--no-browser'); - } - if (provider === 'kiro' && noIncognito) { - args.push('--no-incognito'); - } - - return args; +function formatOAuthRuntimePreparationError(error: unknown): string { + const message = error instanceof Error ? error.message : String(error); + return `Unable to prepare CLIProxy OAuth runtime: ${message}`; } export function usesKiroLocalCallbackReplay( @@ -1223,12 +1242,10 @@ export async function triggerOAuth( usesKiroLocalCallbackReplay(resolvedKiroMethod, resolvedKiroIDCFlow); const useSelectedKiroDirectCliFlow = provider === 'kiro' && (isDeviceCodeFlow || useSelectedKiroLocalPasteCallback); + const activeBackend = getConfiguredBackend(); if (!(selectedPasteCallback && !useSelectedKiroDirectCliFlow)) { - const credentialError = getGeminiPlusOAuthCredentialError( - provider, - getStoredConfiguredBackend() - ); + const credentialError = getGeminiPlusOAuthCredentialError(provider, activeBackend); if (credentialError) { console.log(fail(credentialError)); return null; @@ -1266,7 +1283,26 @@ export async function triggerOAuth( } if (selectedPasteCallback && !useSelectedKiroDirectCliFlow) { - const tokenDir = getProviderTokenDir(provider); + const target = getProxyTarget(); + let tokenDir = getProviderTokenDir(provider); + if (provider === 'gemini' && activeBackend === 'original' && !target.isRemote) { + try { + const runtime = await prepareOAuthRuntime(provider, verbose, activeBackend); + tokenDir = runtime.tokenDir; + const selectedFlag = selectAdvertisedAuthFlag( + getOAuthFlagCandidatesForProvider(provider), + runtime.advertisedFlags + ); + if (!selectedFlag) { + console.log(fail(getGeminiOriginalBackendOAuthMessage())); + return null; + } + } catch (error) { + console.log(fail(formatOAuthRuntimePreparationError(error))); + return null; + } + } + return handlePasteCallbackMode( provider, oauthConfig, @@ -1289,11 +1325,16 @@ export async function triggerOAuth( console.log(''); - // Prepare binary - const prepared = await prepareBinary(provider, verbose); - if (!prepared) return null; - - const { binaryPath, tokenDir, configPath } = prepared; + let runtime; + let advertisedFlags: ReadonlySet; + try { + runtime = await prepareOAuthRuntime(provider, verbose, activeBackend); + advertisedFlags = runtime.advertisedFlags; + } catch (error) { + console.log(fail(formatOAuthRuntimePreparationError(error))); + return null; + } + const { binaryPath, tokenDir, configPath } = runtime; // Free callback port if needed (only for authorization code flows) const localCallbackPort = callbackPort; @@ -1308,6 +1349,8 @@ export async function triggerOAuth( let args: string[]; try { args = buildOAuthArgs(provider, configPath, processHeadless, noIncognito, { + advertisedFlags, + backend: activeBackend, kiroMethod: provider === 'kiro' ? resolvedKiroMethod : undefined, kiroIDCStartUrl: options.kiroIDCStartUrl, kiroIDCRegion: options.kiroIDCRegion, diff --git a/src/cliproxy/binary-manager.ts b/src/cliproxy/binary-manager.ts index f778ff8c..e35753a9 100644 --- a/src/cliproxy/binary-manager.ts +++ b/src/cliproxy/binary-manager.ts @@ -244,6 +244,7 @@ export class BinaryManager { export interface EnsureCLIProxyBinaryOptions { allowInstall?: boolean; + backend?: CLIProxyBackend; skipAutoUpdate?: boolean; } @@ -252,7 +253,7 @@ export async function ensureCLIProxyBinary( verbose = false, options: EnsureCLIProxyBinaryOptions = {} ): Promise { - const configuredBackend = getConfiguredOrDefaultBackend(); + const configuredBackend = options.backend ?? getConfiguredOrDefaultBackend(); const backend = resolveLocalBackend(configuredBackend, { notifyOnPlus: true }); // Migrate old shared pin to backend-specific location (one-time migration) From c4fc5e89855f1a07b9d01a843c7874fc640e1371 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 29 Jul 2026 14:09:09 -0400 Subject: [PATCH 2/3] test(cliproxy): cover Gemini OAuth capabilities --- scripts/run-test-bucket.js | 1 + .../__tests__/oauth-cli-capabilities.test.ts | 85 +++++++ .../__tests__/oauth-handler-auth-args.test.ts | 45 ++++ ...th-handler-gemini-backend-guidance.test.ts | 40 +++ .../__tests__/oauth-handler-xai-args.test.ts | 4 +- .../gemini-backend-guidance-child.scenario.ts | 230 ++++++++++++++++++ 6 files changed, 402 insertions(+), 3 deletions(-) create mode 100644 src/cliproxy/auth/__tests__/oauth-cli-capabilities.test.ts create mode 100644 src/cliproxy/auth/__tests__/oauth-handler-auth-args.test.ts create mode 100644 src/cliproxy/auth/__tests__/oauth-handler-gemini-backend-guidance.test.ts create mode 100644 src/cliproxy/auth/test-fixtures/gemini-backend-guidance-child.scenario.ts diff --git a/scripts/run-test-bucket.js b/scripts/run-test-bucket.js index 1dd392c2..213028bf 100644 --- a/scripts/run-test-bucket.js +++ b/scripts/run-test-bucket.js @@ -40,6 +40,7 @@ const slowTests = [ 'tests/unit/targets/settings-profile-websearch-launch.test.ts', 'tests/unit/web-server/cursor-routes.test.ts', 'tests/unit/web-server/websearch-routes.test.ts', + 'src/cliproxy/auth/__tests__/oauth-handler-gemini-backend-guidance.test.ts', ]; // CommonJS-heavy JS suites stay slow by default because many of them mutate // module cache or process state. Opt them into `test:fast` only after they are diff --git a/src/cliproxy/auth/__tests__/oauth-cli-capabilities.test.ts b/src/cliproxy/auth/__tests__/oauth-cli-capabilities.test.ts new file mode 100644 index 00000000..9e532e98 --- /dev/null +++ b/src/cliproxy/auth/__tests__/oauth-cli-capabilities.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it, spyOn } from 'bun:test'; +import * as childProcess from 'child_process'; +import { + extractAdvertisedCliFlags, + getOAuthFlagCandidatesForProvider, + OAUTH_HELP_PROBE_TIMEOUT_MS, + probeCliProxyAdvertisedFlags, + resolveAdvertisedAuthFlag, +} from '../oauth-cli-capabilities'; + +describe('oauth CLI capability probing', () => { + it('extracts advertised flags from Go help output', () => { + const flags = extractAdvertisedCliFlags(` +Usage of cli-proxy-api-plus + -login + Login Google Account + -codex-login + Login to Codex using OAuth +`); + + expect(flags.has('--login')).toBe(true); + expect(flags.has('--codex-login')).toBe(true); + }); + + it('falls back to the legacy Kiro Google alias when advertised', () => { + const selected = resolveAdvertisedAuthFlag( + 'kiro', + getOAuthFlagCandidatesForProvider('kiro', 'google'), + new Set(['--kiro-login']) + ); + + expect(selected).toBe('--kiro-login'); + }); + + it('probes help with a raw argv array so paths with spaces stay intact', () => { + const spawnSpy = spyOn(childProcess, 'spawnSync').mockReturnValue({ + status: 0, + stdout: ' -login\n', + stderr: '', + pid: 0, + output: [], + signal: null, + error: undefined, + }); + + try { + const binaryPath = 'C:\\Program Files\\CCS\\cli-proxy-api-plus.exe'; + const flags = probeCliProxyAdvertisedFlags(binaryPath); + + expect(flags.has('--login')).toBe(true); + expect(spawnSpy).toHaveBeenCalledWith( + binaryPath, + ['--help'], + expect.objectContaining({ + encoding: 'utf8', + shell: false, + timeout: OAUTH_HELP_PROBE_TIMEOUT_MS, + windowsHide: true, + }) + ); + } finally { + spawnSpy.mockRestore(); + } + }); + + it('fails fast when help probing times out', () => { + const spawnSpy = spyOn(childProcess, 'spawnSync').mockReturnValue({ + status: null, + stdout: '', + stderr: '', + pid: 0, + output: [], + signal: 'SIGTERM', + error: Object.assign(new Error('spawnSync timed out'), { code: 'ETIMEDOUT' }), + }); + + try { + expect(() => probeCliProxyAdvertisedFlags('/tmp/cli-proxy-api')).toThrow( + `Timed out after ${OAUTH_HELP_PROBE_TIMEOUT_MS}ms` + ); + } finally { + spawnSpy.mockRestore(); + } + }); +}); diff --git a/src/cliproxy/auth/__tests__/oauth-handler-auth-args.test.ts b/src/cliproxy/auth/__tests__/oauth-handler-auth-args.test.ts new file mode 100644 index 00000000..728d2a92 --- /dev/null +++ b/src/cliproxy/auth/__tests__/oauth-handler-auth-args.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'bun:test'; +import { buildOAuthArgs } from '../oauth-cli-args'; + +describe('buildOAuthArgs capability negotiation', () => { + it('uses the advertised Gemini login flag when supported', () => { + expect( + buildOAuthArgs('gemini', '/tmp/cliproxy-config.yaml', false, false, { + advertisedFlags: new Set(['--login']), + }) + ).toEqual(['--config', '/tmp/cliproxy-config.yaml', '--login']); + }); + + it('uses the legacy Kiro Google alias when the binary only advertises it', () => { + expect( + buildOAuthArgs('kiro', '/tmp/path with spaces/cliproxy config.yaml', false, false, { + kiroMethod: 'google', + advertisedFlags: new Set(['--kiro-login']), + }) + ).toEqual(['--config', '/tmp/path with spaces/cliproxy config.yaml', '--kiro-login']); + }); + + it('fails early when Gemini login is unsupported by the installed binary', () => { + expect(() => + buildOAuthArgs('gemini', '/tmp/cliproxy-config.yaml', false, false, { + backend: 'original', + advertisedFlags: new Set(['--codex-login']), + }) + ).toThrow('cliproxy.backend: original'); + }); + + it('preserves Windows config paths as a separate argv entry', () => { + const args = buildOAuthArgs( + 'gemini', + 'C:\\Users\\Kai Tran\\AppData\\Roaming\\CCS\\cliproxy config.yaml', + true, + false, + { + advertisedFlags: new Set(['--login']), + } + ); + + expect(args[1]).toBe('C:\\Users\\Kai Tran\\AppData\\Roaming\\CCS\\cliproxy config.yaml'); + expect(args).toContain('--no-browser'); + }); +}); diff --git a/src/cliproxy/auth/__tests__/oauth-handler-gemini-backend-guidance.test.ts b/src/cliproxy/auth/__tests__/oauth-handler-gemini-backend-guidance.test.ts new file mode 100644 index 00000000..ec00d9e1 --- /dev/null +++ b/src/cliproxy/auth/__tests__/oauth-handler-gemini-backend-guidance.test.ts @@ -0,0 +1,40 @@ +import * as childProcess from 'child_process'; +import * as path from 'path'; +import { describe, expect, it } from 'bun:test'; + +const childTestPath = path.resolve( + import.meta.dir, + '../test-fixtures/gemini-backend-guidance-child.scenario.ts' +); + +function runScenario(scenario: string): void { + const result = childProcess.spawnSync(process.execPath, ['test', childTestPath], { + cwd: path.resolve(import.meta.dir, '../../..'), + encoding: 'utf8', + env: { + ...process.env, + GEMINI_GUIDANCE_SCENARIO: scenario, + }, + }); + + const output = `${result.stdout ?? ''}\n${result.stderr ?? ''}`.trim(); + expect(result.status, output || `child scenario ${scenario} failed`).toBe(0); +} + +describe('triggerOAuth Gemini backend guidance', () => { + it('fails early on plus when Gemini OAuth client env vars are missing', () => { + runScenario('plus-missing-env'); + }); + + it('reports original backend Gemini incompatibility after probing direct CLI auth', () => { + runScenario('original-direct-unsupported'); + }); + + it('reports original backend Gemini incompatibility after probing headless auto paste mode', () => { + runScenario('original-headless-auto-paste-unsupported'); + }); + + it('allows pinned original binaries that still advertise Gemini login to continue past the probe', () => { + runScenario('original-headless-auto-paste-supported'); + }); +}); diff --git a/src/cliproxy/auth/__tests__/oauth-handler-xai-args.test.ts b/src/cliproxy/auth/__tests__/oauth-handler-xai-args.test.ts index 6a91c889..6cf7a085 100644 --- a/src/cliproxy/auth/__tests__/oauth-handler-xai-args.test.ts +++ b/src/cliproxy/auth/__tests__/oauth-handler-xai-args.test.ts @@ -1,10 +1,8 @@ import { describe, expect, it } from 'bun:test'; -import { OAUTH_CALLBACK_PORTS } from '../../../management/oauth-port-diagnostics'; -import { buildOAuthArgs } from '../oauth-handler'; +import { buildOAuthArgs } from '../oauth-cli-args'; describe('xAI OAuth process arguments', () => { it('starts CLIProxy device auth without a callback port argument', () => { - expect(OAUTH_CALLBACK_PORTS.xai).toBeNull(); expect(buildOAuthArgs('xai', '/tmp/cliproxy-config.yaml', false, false)).toEqual([ '--config', '/tmp/cliproxy-config.yaml', diff --git a/src/cliproxy/auth/test-fixtures/gemini-backend-guidance-child.scenario.ts b/src/cliproxy/auth/test-fixtures/gemini-backend-guidance-child.scenario.ts new file mode 100644 index 00000000..f0c04e55 --- /dev/null +++ b/src/cliproxy/auth/test-fixtures/gemini-backend-guidance-child.scenario.ts @@ -0,0 +1,230 @@ +import { afterEach, describe, expect, it, mock, spyOn } from 'bun:test'; +import * as childProcess from 'child_process'; +import { ConfigError } from '../../../errors/error-types'; +import type { ProxyTarget } from '../../proxy/proxy-target-resolver'; + +const scenario = process.env['GEMINI_GUIDANCE_SCENARIO']; +if (!scenario) { + throw new ConfigError('GEMINI_GUIDANCE_SCENARIO is required'); +} + +let activeBackend: 'original' | 'plus' = 'original'; +let headlessEnvironment = false; +const proxyTarget: ProxyTarget = { + host: '127.0.0.1', + port: 8317, + protocol: 'http', + isRemote: false, +}; + +const ensureBinaryMock = mock(async () => '/tmp/fake-cli-proxy-api'); +const generateConfigMock = mock(() => '/tmp/cliproxy-config.yaml'); +const preflightCheckMock = mock(async () => ({ + ready: true, + checks: [], + firewallWarning: false, + firewallFixCommand: undefined, +})); + +const originalIsTTY = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY'); +const originalFetch = globalThis.fetch; + +function restoreIsTTY(): void { + if (originalIsTTY) { + Object.defineProperty(process.stdin, 'isTTY', originalIsTTY); + } +} + +function getAdvertisedHelpText(localScenario: string): string { + switch (localScenario) { + case 'original-headless-auto-paste-supported': + return ' -login\n'; + case 'original-direct-unsupported': + case 'original-headless-auto-paste-unsupported': + return ' -codex-login\n'; + default: + return ''; + } +} + +async function registerScenarioMocks(): Promise { + const [ + realConfigGenerator, + realAccountManager, + realTokenManager, + realEnvironmentDetector, + realProxyTargetResolver, + realAccountSafety, + realAccountSafetyCrossLane, + realPoolOptInPrompt, + realOAuthPortDiagnostics, + ] = await Promise.all([ + import('../../config/config-generator'), + import('../../accounts/account-manager'), + import('../token-manager'), + import('../environment-detector'), + import('../../proxy/proxy-target-resolver'), + import('../../accounts/account-safety'), + import('../../accounts/account-safety-cross-lane'), + import('../../routing/pool-opt-in-prompt'), + import('../../../management/oauth-port-diagnostics'), + ]); + + mock.module('../../binary-manager', () => ({ + ensureCLIProxyBinary: ensureBinaryMock, + getConfiguredBackend: () => activeBackend, + })); + + mock.module('../../config/config-generator', () => ({ + ...realConfigGenerator, + generateConfig: generateConfigMock, + })); + + mock.module('../../../management/oauth-port-diagnostics', () => ({ + ...realOAuthPortDiagnostics, + enhancedPreflightOAuthCheck: preflightCheckMock, + OAUTH_CALLBACK_PORTS: { gemini: 8085 }, + })); + + mock.module('../environment-detector', () => ({ + ...realEnvironmentDetector, + isHeadlessEnvironment: () => headlessEnvironment, + killProcessOnPort: () => false, + showStep: () => {}, + })); + + mock.module('../../proxy/proxy-target-resolver', () => ({ + ...realProxyTargetResolver, + getProxyTarget: () => proxyTarget, + buildProxyUrl: (target: ProxyTarget, endpointPath: string) => + `${target.protocol}://${target.host}:${target.port}${ + endpointPath.startsWith('/') ? endpointPath : `/${endpointPath}` + }`, + buildManagementHeaders: () => ({}), + })); + + mock.module('../../accounts/account-manager', () => ({ + ...realAccountManager, + getProviderAccounts: () => [], + getDefaultAccount: () => null, + touchAccount: () => undefined, + hasAccountNameConflict: () => false, + findAccountNameMatch: () => null, + PROVIDERS_WITHOUT_EMAIL: [], + validateNickname: () => null, + })); + + mock.module('../token-manager', () => ({ + ...realTokenManager, + getProviderTokenDir: () => '/tmp/ccs-gemini-auth-tests', + isAuthenticated: () => false, + listProviderTokenSnapshots: () => [], + findNewTokenSnapshotForAuthAttempt: () => null, + registerAccountFromToken: () => null, + })); + + mock.module('../../accounts/account-safety', () => ({ + ...realAccountSafety, + checkNewAccountConflict: () => null, + warnNewAccountConflict: () => undefined, + warnOAuthBanRisk: () => undefined, + warnPossible403Ban: () => undefined, + })); + + mock.module('../../accounts/account-safety-cross-lane', () => ({ + ...realAccountSafetyCrossLane, + checkCrossLaneEmailOverlap: () => null, + })); + + mock.module('../../routing/pool-opt-in-prompt', () => ({ + ...realPoolOptInPrompt, + maybeOfferPoolRouting: async () => undefined, + })); +} + +afterEach(() => { + delete process.env.CLIPROXY_GEMINI_OAUTH_CLIENT_ID; + delete process.env.CLIPROXY_GEMINI_OAUTH_CLIENT_SECRET; + restoreIsTTY(); + globalThis.fetch = originalFetch; +}); + +describe('child Gemini backend guidance scenario', () => { + it(`validates ${scenario}`, async () => { + if (scenario === 'plus-missing-env') { + activeBackend = 'plus'; + } else if ( + scenario === 'original-headless-auto-paste-unsupported' || + scenario === 'original-headless-auto-paste-supported' + ) { + headlessEnvironment = true; + Object.defineProperty(process.stdin, 'isTTY', { + value: false, + configurable: true, + }); + } + + if (scenario === 'original-headless-auto-paste-supported') { + globalThis.fetch = mock(async () => { + throw new ConfigError('fetch failed'); + }) as unknown as typeof fetch; + } + + const spawnSpy = spyOn(childProcess, 'spawnSync').mockReturnValue({ + status: 0, + stdout: getAdvertisedHelpText(scenario), + stderr: '', + pid: 0, + output: [], + signal: null, + error: undefined, + }); + const logSpy = spyOn(console, 'log').mockImplementation(() => {}); + + try { + await registerScenarioMocks(); + const { triggerOAuth } = await import(`../oauth-handler?gemini-guidance-child=${Date.now()}`); + const account = await triggerOAuth('gemini', { + headless: scenario === 'original-direct-unsupported' ? false : undefined, + }); + const output = logSpy.mock.calls.map(([message]) => String(message)).join('\n'); + + expect(account).toBeNull(); + + if (scenario === 'plus-missing-env') { + expect(ensureBinaryMock).not.toHaveBeenCalled(); + expect(output).toContain('CLIPROXY_GEMINI_OAUTH_CLIENT_ID'); + expect(output).toContain('CLIPROXY_GEMINI_OAUTH_CLIENT_SECRET'); + expect(output).toContain( + 'Current `cliproxy.backend: original` releases do not advertise Gemini login' + ); + expect(output).not.toContain('switch `cliproxy.backend` to `original` for Gemini'); + return; + } + + expect(ensureBinaryMock).toHaveBeenCalledTimes(1); + const firstEnsureBinaryCall = ensureBinaryMock.mock.calls.at(0) as unknown[] | undefined; + expect(firstEnsureBinaryCall?.[1]).toEqual( + expect.objectContaining({ backend: 'original', skipAutoUpdate: true }) + ); + + if (scenario === 'original-headless-auto-paste-supported') { + expect(output).not.toContain( + 'The active `cliproxy.backend: original` runtime cannot start Gemini OAuth from CCS' + ); + expect(output).toContain('Starting Google Gemini OAuth (paste-callback mode)...'); + expect(output).toContain('Failed to start OAuth flow'); + return; + } + + expect(output).toContain( + 'The active `cliproxy.backend: original` runtime cannot start Gemini OAuth from CCS' + ); + expect(output).toContain('CLIPROXY_GEMINI_OAUTH_CLIENT_ID'); + expect(output).toContain('CLIPROXY_GEMINI_OAUTH_CLIENT_SECRET'); + } finally { + spawnSpy.mockRestore(); + logSpy.mockRestore(); + } + }); +}); From 8d7446e3ffb8e73a2cb0c8f632503d89ad6e2bed Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 29 Jul 2026 14:09:24 -0400 Subject: [PATCH 3/3] chore(hardening): refresh Gemini auth inventory --- docs/reports/hardening-inventory.json | 44 +++++++++++++-------------- docs/reports/hardening-inventory.md | 12 ++++---- 2 files changed, 28 insertions(+), 28 deletions(-) diff --git a/docs/reports/hardening-inventory.json b/docs/reports/hardening-inventory.json index 41590117..ccbd41b6 100644 --- a/docs/reports/hardening-inventory.json +++ b/docs/reports/hardening-inventory.json @@ -300,8 +300,8 @@ ] }, "legacyShim": { - "totalMarkers": 456, - "filesAffected": 171, + "totalMarkers": 458, + "filesAffected": 173, "topFiles": [ { "file": "src/auth/profile-detector.ts", @@ -491,11 +491,11 @@ }, "maintainability": { "typedErrors": { - "totalThrows": 446, - "typedThrows": 74, + "totalThrows": 452, + "typedThrows": 80, "plainThrows": 312, "otherThrows": 60, - "adoptionRatio": 0.1659, + "adoptionRatio": 0.177, "topSubdomainsByThrows": [ { "subdomain": "web-server", @@ -539,6 +539,12 @@ "typed": 4, "plain": 17 }, + { + "subdomain": "cliproxy/auth", + "count": 16, + "typed": 16, + "plain": 0 + }, { "subdomain": "cliproxy/accounts", "count": 13, @@ -550,12 +556,6 @@ "count": 12, "typed": 12, "plain": 0 - }, - { - "subdomain": "cliproxy/ai-providers", - "count": 12, - "typed": 0, - "plain": 12 } ] }, @@ -566,15 +566,15 @@ "web-server/routes", "auth" ], - "numerator": 22, - "denominator": 24, - "ratio": 0.9167, + "numerator": 28, + "denominator": 30, + "ratio": 0.9333, "targetRatio": 0.4 }, "loggerCoverage": { "filesWithCreateLogger": 65, - "totalSourceFiles": 756, - "coverageRatio": 0.086, + "totalSourceFiles": 758, + "coverageRatio": 0.0858, "subdomainsWithZeroCreateLogger": [ "api", "bin", @@ -608,6 +608,11 @@ "count": 90, "withLogger": 1 }, + { + "subdomain": "cliproxy/auth", + "count": 32, + "withLogger": 1 + }, { "subdomain": "management", "count": 32, @@ -618,11 +623,6 @@ "count": 31, "withLogger": 5 }, - { - "subdomain": "cliproxy/auth", - "count": 30, - "withLogger": 1 - }, { "subdomain": "config", "count": 27, @@ -727,7 +727,7 @@ }, { "file": "src/cliproxy/auth/oauth-handler.ts", - "loc": 1467 + "loc": 1510 }, { "file": "src/cursor/cursor-executor.ts", diff --git a/docs/reports/hardening-inventory.md b/docs/reports/hardening-inventory.md index 9839ced0..79b13f92 100644 --- a/docs/reports/hardening-inventory.md +++ b/docs/reports/hardening-inventory.md @@ -10,8 +10,8 @@ Scope: `src/**/*.{ts,tsx,js,jsx,mjs,cjs}` | Sync fs files affected (all) | 257 | | Sync fs occurrences (runtime hotpaths) | 1103 | | Sync fs files affected (runtime hotpaths) | 151 | -| Legacy shim markers | 456 | -| Legacy shim files affected | 171 | +| Legacy shim markers | 458 | +| Legacy shim files affected | 173 | ## Top Runtime Hotpath Sync fs Files @@ -56,11 +56,11 @@ Scope: `src/**/*.{ts,tsx,js,jsx,mjs,cjs}` | Metric | Value | |---|---:| -| typed-error adoption (typed/total throws) | 16.6% (74/446) | -| typed-error adoption (P4 locked subdomains) | 91.7% (22/24), target 40% | +| typed-error adoption (typed/total throws) | 17.7% (80/452) | +| typed-error adoption (P4 locked subdomains) | 93.3% (28/30), target 40% | | hotpath console.error/warn occurrences | 266 (571 total, 305 CLI-UX exempt) | | hotpath console.error/warn files | 82 | -| files with createLogger | 65/756 | +| files with createLogger | 65/758 | | subdomains with zero createLogger | 15 (api, bin, channels, cliproxy, cliproxy/accounts, cliproxy/ai-providers, cliproxy/binary, cliproxy/config, cliproxy/management, cliproxy/sync, cliproxy/types, config, dispatcher, shared, types) | | files > 400 LOC | 91 | | files > 600 LOC | 42 | @@ -91,7 +91,7 @@ Scope: `src/**/*.{ts,tsx,js,jsx,mjs,cjs}` |---|---:| | `src/web-server/usage/native-quota-collector.ts` | 1662 | | `src/web-server/routes/cliproxy-auth-routes.ts` | 1531 | -| `src/cliproxy/auth/oauth-handler.ts` | 1467 | +| `src/cliproxy/auth/oauth-handler.ts` | 1510 | | `src/cursor/cursor-executor.ts` | 1234 | | `src/web-server/model-pricing.ts` | 1127 | | `src/cliproxy/config/generator.ts` | 1109 |