mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-14 06:17:27 +00:00
Merge pull request #1679 from kaitranntt/kai/fix/1660-gemini-auth
fix(cliproxy): probe Gemini OAuth support
This commit is contained in:
12 files changed
+737
-93
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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<string>;
|
||||
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;
|
||||
}
|
||||
@@ -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<string> {
|
||||
const flags = new Set<string>();
|
||||
|
||||
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>
|
||||
): 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<string>,
|
||||
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<string> {
|
||||
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 ?? ''}`);
|
||||
}
|
||||
@@ -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<string>;
|
||||
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<string>;
|
||||
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,
|
||||
|
||||
@@ -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<void> {
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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<string> {
|
||||
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)
|
||||
|
||||
Reference in new issue
Block a user