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(); + } + }); +});