From ada5848c699aa0925c8d8df6090d6a168485a296 Mon Sep 17 00:00:00 2001 From: davidapierce Date: Fri, 29 May 2026 01:33:01 +0000 Subject: [PATCH] Add BYOID experiment flag and skeleton for BYOID auth flow. --- packages/cli/src/config/auth.test.ts | 125 ++++++++++++++++-- packages/cli/src/config/auth.ts | 25 +++- packages/cli/src/config/config.ts | 7 + packages/cli/src/config/extension-manager.ts | 1 + packages/cli/src/config/settingsSchema.ts | 20 +++ packages/cli/src/gemini.tsx | 1 + packages/cli/src/ui/AppContainer.tsx | 6 +- packages/cli/src/ui/auth/useAuth.ts | 4 +- .../cli/src/validateNonInterActiveAuth.ts | 5 +- packages/core/src/code_assist/codeAssist.ts | 3 +- packages/core/src/config/config.ts | 18 +++ packages/core/src/core/contentGenerator.ts | 18 ++- 12 files changed, 216 insertions(+), 17 deletions(-) diff --git a/packages/cli/src/config/auth.test.ts b/packages/cli/src/config/auth.test.ts index 2360cf60e7..3b29be0104 100644 --- a/packages/cli/src/config/auth.test.ts +++ b/packages/cli/src/config/auth.test.ts @@ -7,6 +7,7 @@ import { AuthType } from '@google/gemini-cli-core'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { validateAuthMethod } from './auth.js'; +import fs from 'node:fs'; vi.mock('@google/gemini-cli-core', async (importOriginal) => { const actual = @@ -17,11 +18,30 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { }; }); +const { mockSettings } = vi.hoisted(() => ({ + mockSettings: { + merged: { + security: { + auth: { + byoidConfigPath: undefined as string | undefined, + }, + }, + experimental: { + byoid: false as boolean, + }, + }, + }, +})); + vi.mock('./settings.js', () => ({ loadEnvironment: vi.fn(), - loadSettings: vi.fn().mockReturnValue({ - merged: vi.fn().mockReturnValue({}), - }), + loadSettings: vi.fn(() => mockSettings), +})); + +vi.mock('node:fs', () => ({ + default: { + existsSync: vi.fn(), + }, })); describe('validateAuthMethod', () => { @@ -30,10 +50,13 @@ describe('validateAuthMethod', () => { vi.stubEnv('GOOGLE_CLOUD_PROJECT', undefined); vi.stubEnv('GOOGLE_CLOUD_LOCATION', undefined); vi.stubEnv('GOOGLE_API_KEY', undefined); + mockSettings.merged.security.auth.byoidConfigPath = undefined; + mockSettings.merged.experimental.byoid = false; }); afterEach(() => { vi.unstubAllEnvs(); + vi.clearAllMocks(); }); it.each([ @@ -92,17 +115,99 @@ describe('validateAuthMethod', () => { '• GOOGLE_API_KEY environment variable (if using express mode).\n' + 'Update your environment and try again (no reload needed if using .env)!', }, + { + description: + 'should return null for BYOID if experimental.byoid is true, and byoidConfigPath is set and exists', + authType: AuthType.BYOID, + envs: {}, + byoidEnabled: true, + byoidConfigPath: '/path/to/config', + fsExists: true, + expected: null, + }, + { + description: + 'should return error for BYOID if experimental.byoid is false', + authType: AuthType.BYOID, + envs: {}, + byoidEnabled: false, + expected: + 'BYOID authentication is experimental and must be enabled via experimental.byoid in settings.', + }, + { + description: + 'should return error for BYOID if byoidConfigPath is not set', + authType: AuthType.BYOID, + envs: {}, + byoidEnabled: true, + expected: + 'When using BYOID, you must specify the security.auth.byoidConfigPath setting.\n' + + 'Update your settings and try again!', + }, + { + description: + 'should return error for BYOID if byoidConfigPath does not exist', + authType: AuthType.BYOID, + envs: {}, + byoidEnabled: true, + byoidConfigPath: '/non/existent/path', + fsExists: false, + expected: 'BYOID configuration file not found at: /non/existent/path', + }, + { + description: + 'should return null for BYOID if experimentalByoid argument is true, even if settings are false', + authType: AuthType.BYOID, + envs: {}, + experimentalByoidArg: true, + byoidEnabled: false, + byoidConfigPath: '/path/to/config', + fsExists: true, + expected: null, + }, + { + description: + 'should return error for BYOID if experimentalByoid argument is false and settings are false', + authType: AuthType.BYOID, + envs: {}, + experimentalByoidArg: false, + byoidEnabled: false, + expected: + 'BYOID authentication is experimental and must be enabled via experimental.byoid in settings.', + }, { description: 'should return an error message for an invalid auth method', // eslint-disable-next-line @typescript-eslint/no-explicit-any - authType: 'invalid-method' as any, + authType: 'invalid' as any, envs: {}, expected: 'Invalid auth method selected.', }, - ])('$description', async ({ authType, envs, expected }) => { - for (const [key, value] of Object.entries(envs)) { - vi.stubEnv(key, value as string); - } - expect(await validateAuthMethod(authType)).toBe(expected); - }); + ])( + '$description', + async ({ + authType, + envs, + expected, + byoidConfigPath, + fsExists, + byoidEnabled, + experimentalByoidArg, + }) => { + for (const [key, value] of Object.entries(envs)) { + vi.stubEnv(key, value as string); + } + if (byoidConfigPath !== undefined) { + mockSettings.merged.security.auth.byoidConfigPath = byoidConfigPath; + } + if (byoidEnabled !== undefined) { + mockSettings.merged.experimental.byoid = byoidEnabled; + } + if (fsExists !== undefined) { + vi.mocked(fs.existsSync).mockReturnValue(fsExists); + } + expect(await validateAuthMethod(authType, experimentalByoidArg)).toBe( + expected, + ); + }, + ); }); diff --git a/packages/cli/src/config/auth.ts b/packages/cli/src/config/auth.ts index 1ca07f98eb..82b58c401f 100644 --- a/packages/cli/src/config/auth.ts +++ b/packages/cli/src/config/auth.ts @@ -6,11 +6,14 @@ import { AuthType, loadApiKey } from '@google/gemini-cli-core'; import { loadEnvironment, loadSettings } from './settings.js'; +import fs from 'node:fs'; export async function validateAuthMethod( authMethod: string, + experimentalByoid?: boolean, ): Promise { - loadEnvironment(loadSettings().merged, process.cwd()); + const settings = loadSettings(); + loadEnvironment(settings.merged, process.cwd()); if ( authMethod === AuthType.LOGIN_WITH_GOOGLE || authMethod === AuthType.COMPUTE_ADC @@ -45,5 +48,25 @@ export async function validateAuthMethod( return null; } + if (authMethod === AuthType.BYOID) { + const isByoidEnabled = + experimentalByoid || settings.merged.experimental?.byoid; + if (!isByoidEnabled) { + return 'BYOID authentication is experimental and must be enabled via experimental.byoid in settings.'; + } + const configPath = settings.merged.security.auth.byoidConfigPath; + if (!configPath) { + return ( + 'When using BYOID, you must specify the security.auth.byoidConfigPath setting.\n' + + 'Update your settings and try again!' + ); + } + + if (!fs.existsSync(configPath)) { + return `BYOID configuration file not found at: ${configPath}`; + } + return null; + } + return 'Invalid auth method selected.'; } diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 139ab5d0f1..b741cd4cf5 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -91,6 +91,7 @@ export interface CliArgs { allowedTools: string[] | undefined; acp?: boolean; experimentalAcp?: boolean; + experimentalByoid?: boolean; extensions: string[] | undefined; listExtensions: boolean | undefined; resume: string | typeof RESUME_LATEST | undefined; @@ -368,6 +369,10 @@ export async function parseArguments( description: 'Starts the agent in ACP mode (deprecated, use --acp instead)', }) + .option('experimental-byoid', { + type: 'boolean', + description: 'Enable experimental support for BYOID authentication', + }) .option('allowed-mcp-server-names', { type: 'array', string: true, @@ -1055,6 +1060,7 @@ export async function loadCliConfig( disabledSkills: settings.skills?.disabled, experimentalAutoMemory: settings.experimental?.autoMemory, experimentalGemma: settings.experimental?.gemma, + experimentalByoid: argv.experimentalByoid || settings.experimental?.byoid, contextManagement, modelSteering: settings.experimental?.modelSteering, topicUpdateNarration: @@ -1121,6 +1127,7 @@ export async function loadCliConfig( }; }, enableConseca: settings.security?.enableConseca, + byoidConfigPath: settings.security?.auth?.byoidConfigPath, }); } diff --git a/packages/cli/src/config/extension-manager.ts b/packages/cli/src/config/extension-manager.ts index ded72510fc..7aaa9463aa 100644 --- a/packages/cli/src/config/extension-manager.ts +++ b/packages/cli/src/config/extension-manager.ts @@ -131,6 +131,7 @@ export class ExtensionManager extends ExtensionLoader { cwd: options.workspaceDir, model: '', debugMode: false, + byoidConfigPath: options.settings.security?.auth?.byoidConfigPath, }); this.requestConsent = options.requestConsent; this.requestSetting = options.requestSetting ?? undefined; diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 3a87cba3ab..688979add5 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -2002,6 +2002,16 @@ const SETTINGS_SCHEMA = { description: 'Whether to use an external authentication flow.', showInDialog: false, }, + byoidConfigPath: { + type: 'string', + label: 'BYOID Configuration Path', + category: 'Security', + requiresRestart: true, + default: undefined as string | undefined, + description: + 'Path to the BYOID (Bring Your Own IDentifier) configuration file.', + showInDialog: true, + }, }, }, enableConseca: { @@ -2467,6 +2477,16 @@ const SETTINGS_SCHEMA = { description: 'Enable logic for context management.', showInDialog: true, }, + byoid: { + type: 'boolean', + label: 'Enable BYOID Auth', + category: 'Experimental', + requiresRestart: true, + default: false, + description: + 'Enable experimental support for BYOID (Bring Your Own IDentifier) authentication.', + showInDialog: true, + }, topicUpdateNarration: { type: 'boolean', label: 'Topic & Update Narration', diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index fb76a88068..d2404190c3 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -516,6 +516,7 @@ export async function main() { ) { const err = await validateAuthMethod( settings.merged.security.auth.selectedType, + partialConfig.isExperimentalByoidEnabled(), ); if (err) { throw new Error(err); diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index e11282788b..2abf47d5d2 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -912,7 +912,10 @@ Logging in with Google... Restarting Gemini CLI to continue. const authMethod = settings.merged.security.auth.selectedType; void (async () => { try { - const error = await validateAuthMethod(authMethod); + const error = await validateAuthMethod( + authMethod, + config.isExperimentalByoidEnabled(), + ); if ( error && authMethod === settings.merged.security.auth.selectedType @@ -931,6 +934,7 @@ Logging in with Google... Restarting Gemini CLI to continue. settings.merged.security.auth.enforcedType, settings.merged.security.auth.useExternal, onAuthError, + config, ]); const { isModelDialogOpen, openModelDialog, closeModelDialog } = diff --git a/packages/cli/src/ui/auth/useAuth.ts b/packages/cli/src/ui/auth/useAuth.ts index caa9ed2c4b..e6732376e1 100644 --- a/packages/cli/src/ui/auth/useAuth.ts +++ b/packages/cli/src/ui/auth/useAuth.ts @@ -21,6 +21,7 @@ import { validateAuthMethod } from '../../config/auth.js'; export async function validateAuthMethodWithSettings( authType: AuthType, settings: LoadedSettings, + config?: Config, ): Promise { const enforcedType = settings.merged.security.auth.enforcedType; if (enforcedType && enforcedType !== authType) { @@ -33,7 +34,7 @@ export async function validateAuthMethodWithSettings( if (authType === AuthType.USE_GEMINI) { return null; } - return validateAuthMethod(authType); + return validateAuthMethod(authType, config?.isExperimentalByoidEnabled()); } import type { AccountSuspensionInfo } from '../contexts/UIStateContext.js'; @@ -114,6 +115,7 @@ export const useAuthCommand = ( const error = await validateAuthMethodWithSettings( authType, settings, + config, ).catch((e: unknown) => getErrorMessage(e)); if (error) { diff --git a/packages/cli/src/validateNonInterActiveAuth.ts b/packages/cli/src/validateNonInterActiveAuth.ts index a15f4f83a2..87cd3967fe 100644 --- a/packages/cli/src/validateNonInterActiveAuth.ts +++ b/packages/cli/src/validateNonInterActiveAuth.ts @@ -42,7 +42,10 @@ export async function validateNonInteractiveAuth( const authType: AuthType = effectiveAuthType; if (!useExternalAuth) { - const err = await validateAuthMethod(String(authType)); + const err = await validateAuthMethod( + String(authType), + nonInteractiveConfig.isExperimentalByoidEnabled(), + ); if (err != null) { throw new Error(err); } diff --git a/packages/core/src/code_assist/codeAssist.ts b/packages/core/src/code_assist/codeAssist.ts index 4fcbea7853..a5e9fe4ef4 100644 --- a/packages/core/src/code_assist/codeAssist.ts +++ b/packages/core/src/code_assist/codeAssist.ts @@ -19,7 +19,8 @@ export async function createCodeAssistContentGenerator( ): Promise { if ( authType === AuthType.LOGIN_WITH_GOOGLE || - authType === AuthType.COMPUTE_ADC + authType === AuthType.COMPUTE_ADC || + authType === AuthType.BYOID ) { const authClient = await getOauthClient(authType, config); const userData = await setupUser(authClient, config, httpOptions); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index e8301e9e1f..976b8a3b32 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -715,6 +715,7 @@ export interface ConfigParameters { autoDistillation?: boolean; experimentalAutoMemory?: boolean; experimentalGemma?: boolean; + experimentalByoid?: boolean; experimentalContextManagementConfig?: string; experimentalAgentHistoryTruncation?: boolean; experimentalAgentHistoryTruncationThreshold?: number; @@ -744,6 +745,7 @@ export interface ConfigParameters { }; vertexAiRouting?: VertexAiRoutingConfig; logRagSnippets?: boolean; + byoidConfigPath?: string; } export class Config implements McpContext, AgentLoopContext { @@ -956,6 +958,7 @@ export class Config implements McpContext, AgentLoopContext { overageStrategy: OverageStrategy; }; private readonly vertexAiRouting: VertexAiRoutingConfig | undefined; + private readonly byoidConfigPath: string | undefined; private readonly enableAgents: boolean; private agents: AgentSettings; @@ -965,6 +968,7 @@ export class Config implements McpContext, AgentLoopContext { private readonly adminSkillsEnabled: boolean; private readonly experimentalAutoMemory: boolean; private readonly experimentalGemma: boolean; + private readonly experimentalByoid: boolean; private readonly experimentalContextManagementConfig?: string; private readonly memoryBoundaryMarkers: readonly string[]; private readonly topicUpdateNarration: boolean; @@ -1186,6 +1190,7 @@ export class Config implements McpContext, AgentLoopContext { this.experimentalAutoMemory = params.experimentalAutoMemory ?? false; this.experimentalGemma = params.experimentalGemma ?? true; + this.experimentalByoid = params.experimentalByoid ?? false; this.experimentalContextManagementConfig = params.experimentalContextManagementConfig; this.memoryBoundaryMarkers = params.memoryBoundaryMarkers ?? ['.git']; @@ -1598,6 +1603,7 @@ export class Config implements McpContext, AgentLoopContext { baseUrl, customHeaders, this.vertexAiRouting, + this.byoidConfigPath, ); this.contentGenerator = await createContentGenerator( newContentGeneratorConfig, @@ -2652,6 +2658,10 @@ export class Config implements McpContext, AgentLoopContext { return this.modelSteering; } + isExperimentalByoidEnabled(): boolean { + return this.experimentalByoid; + } + async getToolOutputMaskingConfig(): Promise { await this.ensureExperimentsLoaded(); @@ -4136,6 +4146,14 @@ export class Config implements McpContext, AgentLoopContext { await this.mcpClientManager.stop(); } } + + isExperimentalByoidEnabled(): boolean { + return this.experimentalByoid; + } + + getByoidConfigPath(): string | undefined { + return this.byoidConfigPath; + } } // Export model constants for use in CLI export { DEFAULT_GEMINI_FLASH_MODEL }; diff --git a/packages/core/src/core/contentGenerator.ts b/packages/core/src/core/contentGenerator.ts index bc311a9aea..436c5f892b 100644 --- a/packages/core/src/core/contentGenerator.ts +++ b/packages/core/src/core/contentGenerator.ts @@ -65,6 +65,7 @@ export enum AuthType { LEGACY_CLOUD_SHELL = 'cloud-shell', COMPUTE_ADC = 'compute-default-credentials', GATEWAY = 'gateway', + BYOID = 'byoid', } /** @@ -105,6 +106,7 @@ export type ContentGeneratorConfig = { baseUrl?: string; customHeaders?: Record; vertexAiRouting?: VertexAiRoutingConfig; + byoidConfigPath?: string; }; export type VertexAiRequestType = 'dedicated' | 'shared'; @@ -134,6 +136,7 @@ export async function createContentGeneratorConfig( baseUrl?: string, customHeaders?: Record, vertexAiRouting?: VertexAiRoutingConfig, + byoidConfigPath?: string, ): Promise { const contentGeneratorConfig: ContentGeneratorConfig = { authType, @@ -141,6 +144,7 @@ export async function createContentGeneratorConfig( baseUrl, customHeaders, vertexAiRouting, + byoidConfigPath, }; // If we are using Google auth or we are in Cloud Shell, there is nothing else to validate for now. @@ -148,7 +152,8 @@ export async function createContentGeneratorConfig( // (WSL/SSH/Docker/CI) keytar can block indefinitely on its functional probe. if ( authType === AuthType.LOGIN_WITH_GOOGLE || - authType === AuthType.COMPUTE_ADC + authType === AuthType.COMPUTE_ADC || + authType === AuthType.BYOID ) { return contentGeneratorConfig; } @@ -277,8 +282,17 @@ export async function createContentGenerator( } if ( config.authType === AuthType.LOGIN_WITH_GOOGLE || - config.authType === AuthType.COMPUTE_ADC + config.authType === AuthType.COMPUTE_ADC || + config.authType === AuthType.BYOID ) { + if ( + config.authType === AuthType.BYOID && + !gcConfig.isExperimentalByoidEnabled() + ) { + throw new Error( + 'BYOID authentication is experimental and must be enabled via the experimentalByoid flag.', + ); + } const httpOptions = { headers: baseHeaders }; return new LoggingContentGenerator( await createCodeAssistContentGenerator(