From 5b8ad9cd6550811530d5e43998ea5fb120e3196a Mon Sep 17 00:00:00 2001 From: kevinjwang1 Date: Tue, 10 Mar 2026 13:22:03 -0700 Subject: [PATCH] Add extensionRegistryURI setting to change where the registry is read from (#20463) --- docs/reference/configuration.md | 6 +++ packages/cli/src/config/config.ts | 12 +++++ .../config/extensionRegistryClient.test.ts | 44 ++++++++++++++-- .../cli/src/config/extensionRegistryClient.ts | 50 +++++++++++++++---- packages/cli/src/config/settingsSchema.ts | 10 ++++ .../views/ExtensionRegistryView.test.tsx | 3 ++ .../views/ExtensionRegistryView.tsx | 5 +- .../cli/src/ui/hooks/useExtensionRegistry.ts | 6 ++- packages/core/src/config/config.ts | 7 +++ schemas/settings.schema.json | 7 +++ 10 files changed, 134 insertions(+), 16 deletions(-) diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index c93d0c2e66..7d0febcf40 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -1003,6 +1003,12 @@ their corresponding top-level category object in your `settings.json` file. - **Default:** `false` - **Requires restart:** Yes +- **`experimental.extensionRegistryURI`** (string): + - **Description:** The URI (web URL or local file path) of the extension + registry. + - **Default:** `"https://geminicli.com/extensions.json"` + - **Requires restart:** Yes + - **`experimental.extensionReloading`** (boolean): - **Description:** Enables extension loading/unloading within the CLI session. - **Default:** `false` diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index a8c85975e9..320e47a380 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -7,6 +7,7 @@ import yargs from 'yargs/yargs'; import { hideBin } from 'yargs/helpers'; import process from 'node:process'; +import * as path from 'node:path'; import { mcpCommand } from '../commands/mcp.js'; import { extensionsCommand } from '../commands/extensions.js'; import { skillsCommand } from '../commands/skills.js'; @@ -33,6 +34,7 @@ import { getAdminErrorMessage, isHeadlessMode, Config, + resolveToRealPath, applyAdminAllowlist, getAdminBlockedMcpServersMessage, type HookDefinition, @@ -488,6 +490,15 @@ export async function loadCliConfig( const experimentalJitContext = settings.experimental?.jitContext ?? false; + let extensionRegistryURI: string | undefined = trustedFolder + ? settings.experimental?.extensionRegistryURI + : undefined; + if (extensionRegistryURI && !extensionRegistryURI.startsWith('http')) { + extensionRegistryURI = resolveToRealPath( + path.resolve(cwd, resolvePath(extensionRegistryURI)), + ); + } + let memoryContent: string | HierarchicalMemory = ''; let fileCount = 0; let filePaths: string[] = []; @@ -764,6 +775,7 @@ export async function loadCliConfig( deleteSession: argv.deleteSession, enabledExtensions: argv.extensions, extensionLoader: extensionManager, + extensionRegistryURI, enableExtensionReloading: settings.experimental?.extensionReloading, enableAgents: settings.experimental?.enableAgents, plan: settings.experimental?.plan, diff --git a/packages/cli/src/config/extensionRegistryClient.test.ts b/packages/cli/src/config/extensionRegistryClient.test.ts index 4b9699d5e3..66eaab914b 100644 --- a/packages/cli/src/config/extensionRegistryClient.test.ts +++ b/packages/cli/src/config/extensionRegistryClient.test.ts @@ -13,14 +13,24 @@ import { afterEach, type Mock, } from 'vitest'; +import * as fs from 'node:fs/promises'; import { ExtensionRegistryClient, type RegistryExtension, } from './extensionRegistryClient.js'; -import { fetchWithTimeout } from '@google/gemini-cli-core'; +import { fetchWithTimeout, resolveToRealPath } from '@google/gemini-cli-core'; -vi.mock('@google/gemini-cli-core', () => ({ - fetchWithTimeout: vi.fn(), +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + fetchWithTimeout: vi.fn(), + }; +}); + +vi.mock('node:fs/promises', () => ({ + readFile: vi.fn(), })); const mockExtensions: RegistryExtension[] = [ @@ -279,4 +289,32 @@ describe('ExtensionRegistryClient', () => { expect(ids).not.toContain('dataplex'); expect(ids).toContain('conductor'); }); + + it('should fetch extensions from a local file path', async () => { + const filePath = '/path/to/extensions.json'; + const clientWithFile = new ExtensionRegistryClient(filePath); + const mockReadFile = vi.mocked(fs.readFile); + mockReadFile.mockResolvedValue(JSON.stringify(mockExtensions)); + + const result = await clientWithFile.getExtensions(); + expect(result.extensions).toHaveLength(3); + expect(mockReadFile).toHaveBeenCalledWith( + resolveToRealPath(filePath), + 'utf-8', + ); + }); + + it('should fetch extensions from a file:// URL', async () => { + const fileUrl = 'file:///path/to/extensions.json'; + const clientWithFileUrl = new ExtensionRegistryClient(fileUrl); + const mockReadFile = vi.mocked(fs.readFile); + mockReadFile.mockResolvedValue(JSON.stringify(mockExtensions)); + + const result = await clientWithFileUrl.getExtensions(); + expect(result.extensions).toHaveLength(3); + expect(mockReadFile).toHaveBeenCalledWith( + resolveToRealPath(fileUrl), + 'utf-8', + ); + }); }); diff --git a/packages/cli/src/config/extensionRegistryClient.ts b/packages/cli/src/config/extensionRegistryClient.ts index bf09aabe77..4b47c215ec 100644 --- a/packages/cli/src/config/extensionRegistryClient.ts +++ b/packages/cli/src/config/extensionRegistryClient.ts @@ -4,7 +4,12 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { fetchWithTimeout } from '@google/gemini-cli-core'; +import * as fs from 'node:fs/promises'; +import { + fetchWithTimeout, + resolveToRealPath, + isPrivateIp, +} from '@google/gemini-cli-core'; import { AsyncFzf } from 'fzf'; export interface RegistryExtension { @@ -29,12 +34,19 @@ export interface RegistryExtension { } export class ExtensionRegistryClient { - private static readonly REGISTRY_URL = + static readonly DEFAULT_REGISTRY_URL = 'https://geminicli.com/extensions.json'; private static readonly FETCH_TIMEOUT_MS = 10000; // 10 seconds private static fetchPromise: Promise | null = null; + private readonly registryURI: string; + + constructor(registryURI?: string) { + this.registryURI = + registryURI || ExtensionRegistryClient.DEFAULT_REGISTRY_URL; + } + /** @internal */ static resetCache() { ExtensionRegistryClient.fetchPromise = null; @@ -97,18 +109,34 @@ export class ExtensionRegistryClient { return ExtensionRegistryClient.fetchPromise; } + const uri = this.registryURI; ExtensionRegistryClient.fetchPromise = (async () => { try { - const response = await fetchWithTimeout( - ExtensionRegistryClient.REGISTRY_URL, - ExtensionRegistryClient.FETCH_TIMEOUT_MS, - ); - if (!response.ok) { - throw new Error(`Failed to fetch extensions: ${response.statusText}`); - } + if (uri.startsWith('http')) { + if (isPrivateIp(uri)) { + throw new Error( + 'Private IP addresses are not allowed for the extension registry.', + ); + } + const response = await fetchWithTimeout( + uri, + ExtensionRegistryClient.FETCH_TIMEOUT_MS, + ); + if (!response.ok) { + throw new Error( + `Failed to fetch extensions: ${response.statusText}`, + ); + } - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - return (await response.json()) as RegistryExtension[]; + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + return (await response.json()) as RegistryExtension[]; + } else { + // Handle local file path + const filePath = resolveToRealPath(uri); + const content = await fs.readFile(filePath, 'utf-8'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + return JSON.parse(content) as RegistryExtension[]; + } } catch (error) { ExtensionRegistryClient.fetchPromise = null; throw error; diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 2b4685cf81..facded0e6f 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -1791,6 +1791,16 @@ const SETTINGS_SCHEMA = { description: 'Enable extension registry explore UI.', showInDialog: false, }, + extensionRegistryURI: { + type: 'string', + label: 'Extension Registry URI', + category: 'Experimental', + requiresRestart: true, + default: 'https://geminicli.com/extensions.json', + description: + 'The URI (web URL or local file path) of the extension registry.', + showInDialog: false, + }, extensionReloading: { type: 'boolean', label: 'Extension Reloading', diff --git a/packages/cli/src/ui/components/views/ExtensionRegistryView.test.tsx b/packages/cli/src/ui/components/views/ExtensionRegistryView.test.tsx index 954dff1f07..22ff1f6f5c 100644 --- a/packages/cli/src/ui/components/views/ExtensionRegistryView.test.tsx +++ b/packages/cli/src/ui/components/views/ExtensionRegistryView.test.tsx @@ -132,6 +132,9 @@ describe('ExtensionRegistryView', () => { vi.mocked(useConfig).mockReturnValue({ getEnableExtensionReloading: vi.fn().mockReturnValue(false), + getExtensionRegistryURI: vi + .fn() + .mockReturnValue('https://geminicli.com/extensions.json'), } as unknown as ReturnType); }); diff --git a/packages/cli/src/ui/components/views/ExtensionRegistryView.tsx b/packages/cli/src/ui/components/views/ExtensionRegistryView.tsx index 394eba3a2a..44568ad82f 100644 --- a/packages/cli/src/ui/components/views/ExtensionRegistryView.tsx +++ b/packages/cli/src/ui/components/views/ExtensionRegistryView.tsx @@ -39,8 +39,11 @@ export function ExtensionRegistryView({ onClose, extensionManager, }: ExtensionRegistryViewProps): React.JSX.Element { - const { extensions, loading, error, search } = useExtensionRegistry(); const config = useConfig(); + const { extensions, loading, error, search } = useExtensionRegistry( + '', + config.getExtensionRegistryURI(), + ); const { terminalHeight, staticExtraHeight } = useUIState(); const { extensionsUpdateState } = useExtensionUpdates( diff --git a/packages/cli/src/ui/hooks/useExtensionRegistry.ts b/packages/cli/src/ui/hooks/useExtensionRegistry.ts index cfd85ef229..96f4e68007 100644 --- a/packages/cli/src/ui/hooks/useExtensionRegistry.ts +++ b/packages/cli/src/ui/hooks/useExtensionRegistry.ts @@ -19,12 +19,16 @@ export interface UseExtensionRegistryResult { export function useExtensionRegistry( initialQuery = '', + registryURI?: string, ): UseExtensionRegistryResult { const [extensions, setExtensions] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const client = useMemo(() => new ExtensionRegistryClient(), []); + const client = useMemo( + () => new ExtensionRegistryClient(registryURI), + [registryURI], + ); // Ref to track the latest query to avoid race conditions const latestQueryRef = useRef(initialQuery); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 752ad25c4f..ae06808a5b 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -550,6 +550,7 @@ export interface ConfigParameters { skipNextSpeakerCheck?: boolean; shellExecutionConfig?: ShellExecutionConfig; extensionManagement?: boolean; + extensionRegistryURI?: string; truncateToolOutputThreshold?: number; eventEmitter?: EventEmitter; useWriteTodos?: boolean; @@ -738,6 +739,7 @@ export class Config implements McpContext, AgentLoopContext { private readonly useAlternateBuffer: boolean; private shellExecutionConfig: ShellExecutionConfig; private readonly extensionManagement: boolean = true; + private readonly extensionRegistryURI: string | undefined; private readonly truncateToolOutputThreshold: number; private compressionTruncationCounter = 0; private initialized = false; @@ -969,6 +971,7 @@ export class Config implements McpContext, AgentLoopContext { this.shellToolInactivityTimeout = (params.shellToolInactivityTimeout ?? 300) * 1000; // 5 minutes this.extensionManagement = params.extensionManagement ?? true; + this.extensionRegistryURI = params.extensionRegistryURI; this.enableExtensionReloading = params.enableExtensionReloading ?? false; this.storage = new Storage(this.targetDir, this._sessionId); this.storage.setCustomPlansDir(params.planSettings?.directory); @@ -1840,6 +1843,10 @@ export class Config implements McpContext, AgentLoopContext { return this.extensionsEnabled; } + getExtensionRegistryURI(): string | undefined { + return this.extensionRegistryURI; + } + getMcpClientManager(): McpClientManager | undefined { return this.mcpClientManager; } diff --git a/schemas/settings.schema.json b/schemas/settings.schema.json index d505a05838..779ac288db 100644 --- a/schemas/settings.schema.json +++ b/schemas/settings.schema.json @@ -1689,6 +1689,13 @@ "default": false, "type": "boolean" }, + "extensionRegistryURI": { + "title": "Extension Registry URI", + "description": "The URI (web URL or local file path) of the extension registry.", + "markdownDescription": "The URI (web URL or local file path) of the extension registry.\n\n- Category: `Experimental`\n- Requires restart: `yes`\n- Default: `https://geminicli.com/extensions.json`", + "default": "https://geminicli.com/extensions.json", + "type": "string" + }, "extensionReloading": { "title": "Extension Reloading", "description": "Enables extension loading/unloading within the CLI session.",