fix(cliproxy): probe Gemini OAuth support

This commit is contained in:
Tam Nhu Tran committed 2026-07-29 14:24:12 -04:00
1 parent af2cb4b9b3
commit 19c6c3f457
4 files changed
+307 -62

No files matched your search

+93
View File
@@ -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;
}
+108
View File
@@ -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 ?? ''}`);
}
+104 -61
View File
@@ -14,9 +14,9 @@ import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import { fail, info, warn, color, ok } from '../../utils/ui'; import { fail, info, warn, color, ok } from '../../utils/ui';
import { createLogger } from '../../services/logging'; import { createLogger } from '../../services/logging';
import { ensureCLIProxyBinary, getStoredConfiguredBackend } from '../binary-manager'; import { ensureCLIProxyBinary, getConfiguredBackend } from '../binary-manager';
import { generateConfig } from '../config/config-generator'; 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 { CLIProxyBackend, CLIProxyProvider } from '../types';
import { import {
AccountInfo, AccountInfo,
@@ -37,8 +37,6 @@ import {
DEFAULT_KIRO_AUTH_METHOD, DEFAULT_KIRO_AUTH_METHOD,
DEFAULT_KIRO_IDC_FLOW, DEFAULT_KIRO_IDC_FLOW,
getKiroCallbackPort, getKiroCallbackPort,
getKiroCLIAuthArgs,
isKiroCLIAuthMethod,
isKiroDeviceCodeMethod, isKiroDeviceCodeMethod,
getOAuthConfig, getOAuthConfig,
ProviderOAuthConfig, ProviderOAuthConfig,
@@ -47,6 +45,7 @@ import {
getManagementOAuthCallbackPath, getManagementOAuthCallbackPath,
normalizeKiroAuthMethod, normalizeKiroAuthMethod,
normalizeKiroIDCFlow, normalizeKiroIDCFlow,
isKiroCLIAuthMethod,
} from './auth-types'; } from './auth-types';
import { isHeadlessEnvironment, killProcessOnPort, showStep } from './environment-detector'; import { isHeadlessEnvironment, killProcessOnPort, showStep } from './environment-detector';
import { import {
@@ -60,6 +59,12 @@ import {
import { executeOAuthProcess } from './oauth-process'; import { executeOAuthProcess } from './oauth-process';
import { importKiroToken } from './kiro-import'; import { importKiroToken } from './kiro-import';
import { parseGitLabPatAuthResponse } from './gitlab-pat-response'; import { parseGitLabPatAuthResponse } from './gitlab-pat-response';
import {
getOAuthFlagCandidatesForProvider,
probeCliProxyAdvertisedFlags,
selectAdvertisedAuthFlag,
} from './oauth-cli-capabilities';
import { buildOAuthArgs } from './oauth-cli-args';
import { import {
buildOAuthStartFailureGuidance, buildOAuthStartFailureGuidance,
formatOAuthStartFailureForCli, formatOAuthStartFailureForCli,
@@ -144,14 +149,27 @@ function buildPlusOAuthCredentialMessage(
displayName: string, displayName: string,
idEnv: string, idEnv: string,
secretEnv: string, secretEnv: string,
missing?: string[] missing?: string[],
options?: { originalFallbackSupported?: boolean }
): string { ): string {
const missingText = missing?.length ? ` Missing: ${missing.join(', ')}.` : ''; 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 ( return (
`${displayName} OAuth from CLIProxy Plus is missing Google OAuth client credentials.` + `${displayName} OAuth from CLIProxy Plus is missing Google OAuth client credentials.` +
missingText + missingText +
` Set ${idEnv} and ${secretEnv} before starting CLIProxy Plus,` + ` 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()); const missing = [entry.idEnv, entry.secretEnv].filter((name) => !env[name]?.trim());
return missing.length > 0 return missing.length > 0
? buildPlusOAuthCredentialMessage(entry.displayName, entry.idEnv, entry.secretEnv, missing) ? buildPlusOAuthCredentialMessage(entry.displayName, entry.idEnv, entry.secretEnv, missing, {
originalFallbackSupported: provider !== 'gemini',
})
: null; : null;
} }
@@ -205,7 +225,15 @@ export function getPlusAuthUrlCredentialError(
const clientId = parsed.searchParams.get('client_id')?.trim(); const clientId = parsed.searchParams.get('client_id')?.trim();
return clientId return clientId
? null ? null
: buildPlusOAuthCredentialMessage(entry.displayName, entry.idEnv, entry.secretEnv); : buildPlusOAuthCredentialMessage(
entry.displayName,
entry.idEnv,
entry.secretEnv,
undefined,
{
originalFallbackSupported: provider !== 'gemini',
}
);
} catch { } catch {
return null; return null;
} }
@@ -579,12 +607,21 @@ async function runPreflightChecks(
*/ */
async function prepareBinary( async function prepareBinary(
provider: CLIProxyProvider, provider: CLIProxyProvider,
verbose: boolean verbose: boolean,
): Promise<{ binaryPath: string; tokenDir: string; configPath: string } | null> { backend: CLIProxyBackend
): Promise<{
binaryPath: string;
tokenDir: string;
configPath: string;
backend: CLIProxyBackend;
} | null> {
showStep(1, 4, 'progress', 'Preparing CLIProxy binary...'); showStep(1, 4, 'progress', 'Preparing CLIProxy binary...');
try { try {
const binaryPath = await ensureCLIProxyBinary(verbose, { skipAutoUpdate: true }); const binaryPath = await ensureCLIProxyBinary(verbose, {
backend,
skipAutoUpdate: true,
});
process.stdout.write('\x1b[1A\x1b[2K'); process.stdout.write('\x1b[1A\x1b[2K');
showStep(1, 4, 'ok', 'CLIProxy binary ready'); showStep(1, 4, 'ok', 'CLIProxy binary ready');
@@ -596,7 +633,7 @@ async function prepareBinary(
console.error(`[auth] Config generated: ${configPath}`); console.error(`[auth] Config generated: ${configPath}`);
} }
return { binaryPath, tokenDir, configPath }; return { binaryPath, tokenDir, configPath, backend };
} catch (error) { } catch (error) {
process.stdout.write('\x1b[1A\x1b[2K'); process.stdout.write('\x1b[1A\x1b[2K');
showStep(1, 4, 'fail', 'Failed to prepare CLIProxy binary'); showStep(1, 4, 'fail', 'Failed to prepare CLIProxy binary');
@@ -605,49 +642,31 @@ async function prepareBinary(
} }
} }
export function buildOAuthArgs( async function prepareOAuthRuntime(
provider: CLIProxyProvider, provider: CLIProxyProvider,
configPath: string, verbose: boolean,
headless: boolean, backend: CLIProxyBackend
noIncognito: boolean, ): Promise<{
options: { advertisedFlags: ReadonlySet<string>;
kiroMethod?: OAuthOptions['kiroMethod']; backend: CLIProxyBackend;
kiroIDCStartUrl?: string; binaryPath: string;
kiroIDCRegion?: string; configPath: string;
kiroIDCFlow?: OAuthOptions['kiroIDCFlow']; tokenDir: string;
} = {} }> {
): string[] { const prepared = await prepareBinary(provider, verbose, backend);
const unsupportedReason = getUnsupportedAuthStartReason(provider); if (!prepared) {
if (unsupportedReason) { throw new BinaryError('CLIProxy binary preparation returned no runtime');
throw new AuthError(unsupportedReason, provider);
} }
const args = ['--config', configPath]; return {
...prepared,
advertisedFlags: probeCliProxyAdvertisedFlags(prepared.binaryPath),
};
}
if (provider === 'kiro') { function formatOAuthRuntimePreparationError(error: unknown): string {
const method = normalizeKiroAuthMethod(options.kiroMethod); const message = error instanceof Error ? error.message : String(error);
if (!isKiroCLIAuthMethod(method)) { return `Unable to prepare CLIProxy OAuth runtime: ${message}`;
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;
} }
export function usesKiroLocalCallbackReplay( export function usesKiroLocalCallbackReplay(
@@ -1223,12 +1242,10 @@ export async function triggerOAuth(
usesKiroLocalCallbackReplay(resolvedKiroMethod, resolvedKiroIDCFlow); usesKiroLocalCallbackReplay(resolvedKiroMethod, resolvedKiroIDCFlow);
const useSelectedKiroDirectCliFlow = const useSelectedKiroDirectCliFlow =
provider === 'kiro' && (isDeviceCodeFlow || useSelectedKiroLocalPasteCallback); provider === 'kiro' && (isDeviceCodeFlow || useSelectedKiroLocalPasteCallback);
const activeBackend = getConfiguredBackend();
if (!(selectedPasteCallback && !useSelectedKiroDirectCliFlow)) { if (!(selectedPasteCallback && !useSelectedKiroDirectCliFlow)) {
const credentialError = getGeminiPlusOAuthCredentialError( const credentialError = getGeminiPlusOAuthCredentialError(provider, activeBackend);
provider,
getStoredConfiguredBackend()
);
if (credentialError) { if (credentialError) {
console.log(fail(credentialError)); console.log(fail(credentialError));
return null; return null;
@@ -1266,7 +1283,26 @@ export async function triggerOAuth(
} }
if (selectedPasteCallback && !useSelectedKiroDirectCliFlow) { 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( return handlePasteCallbackMode(
provider, provider,
oauthConfig, oauthConfig,
@@ -1289,11 +1325,16 @@ export async function triggerOAuth(
console.log(''); console.log('');
// Prepare binary let runtime;
const prepared = await prepareBinary(provider, verbose); let advertisedFlags: ReadonlySet<string>;
if (!prepared) return null; try {
runtime = await prepareOAuthRuntime(provider, verbose, activeBackend);
const { binaryPath, tokenDir, configPath } = prepared; 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) // Free callback port if needed (only for authorization code flows)
const localCallbackPort = callbackPort; const localCallbackPort = callbackPort;
@@ -1308,6 +1349,8 @@ export async function triggerOAuth(
let args: string[]; let args: string[];
try { try {
args = buildOAuthArgs(provider, configPath, processHeadless, noIncognito, { args = buildOAuthArgs(provider, configPath, processHeadless, noIncognito, {
advertisedFlags,
backend: activeBackend,
kiroMethod: provider === 'kiro' ? resolvedKiroMethod : undefined, kiroMethod: provider === 'kiro' ? resolvedKiroMethod : undefined,
kiroIDCStartUrl: options.kiroIDCStartUrl, kiroIDCStartUrl: options.kiroIDCStartUrl,
kiroIDCRegion: options.kiroIDCRegion, kiroIDCRegion: options.kiroIDCRegion,
+2 -1
View File
@@ -244,6 +244,7 @@ export class BinaryManager {
export interface EnsureCLIProxyBinaryOptions { export interface EnsureCLIProxyBinaryOptions {
allowInstall?: boolean; allowInstall?: boolean;
backend?: CLIProxyBackend;
skipAutoUpdate?: boolean; skipAutoUpdate?: boolean;
} }
@@ -252,7 +253,7 @@ export async function ensureCLIProxyBinary(
verbose = false, verbose = false,
options: EnsureCLIProxyBinaryOptions = {} options: EnsureCLIProxyBinaryOptions = {}
): Promise<string> { ): Promise<string> {
const configuredBackend = getConfiguredOrDefaultBackend(); const configuredBackend = options.backend ?? getConfiguredOrDefaultBackend();
const backend = resolveLocalBackend(configuredBackend, { notifyOnPlus: true }); const backend = resolveLocalBackend(configuredBackend, { notifyOnPlus: true });
// Migrate old shared pin to backend-specific location (one-time migration) // Migrate old shared pin to backend-specific location (one-time migration)