Skip to content

Commit f00f488

Browse files
authored
Add extensionRegistryURI setting to change where the registry is read from (google-gemini#20463)
1 parent 6a9acdb commit f00f488

10 files changed

Lines changed: 134 additions & 16 deletions

File tree

docs/reference/configuration.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1003,6 +1003,12 @@ their corresponding top-level category object in your `settings.json` file.
10031003
- **Default:** `false`
10041004
- **Requires restart:** Yes
10051005

1006+
- **`experimental.extensionRegistryURI`** (string):
1007+
- **Description:** The URI (web URL or local file path) of the extension
1008+
registry.
1009+
- **Default:** `"https://geminicli.com/extensions.json"`
1010+
- **Requires restart:** Yes
1011+
10061012
- **`experimental.extensionReloading`** (boolean):
10071013
- **Description:** Enables extension loading/unloading within the CLI session.
10081014
- **Default:** `false`

packages/cli/src/config/config.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import yargs from 'yargs/yargs';
88
import { hideBin } from 'yargs/helpers';
99
import process from 'node:process';
10+
import * as path from 'node:path';
1011
import { mcpCommand } from '../commands/mcp.js';
1112
import { extensionsCommand } from '../commands/extensions.js';
1213
import { skillsCommand } from '../commands/skills.js';
@@ -33,6 +34,7 @@ import {
3334
getAdminErrorMessage,
3435
isHeadlessMode,
3536
Config,
37+
resolveToRealPath,
3638
applyAdminAllowlist,
3739
getAdminBlockedMcpServersMessage,
3840
type HookDefinition,
@@ -488,6 +490,15 @@ export async function loadCliConfig(
488490

489491
const experimentalJitContext = settings.experimental?.jitContext ?? false;
490492

493+
let extensionRegistryURI: string | undefined = trustedFolder
494+
? settings.experimental?.extensionRegistryURI
495+
: undefined;
496+
if (extensionRegistryURI && !extensionRegistryURI.startsWith('http')) {
497+
extensionRegistryURI = resolveToRealPath(
498+
path.resolve(cwd, resolvePath(extensionRegistryURI)),
499+
);
500+
}
501+
491502
let memoryContent: string | HierarchicalMemory = '';
492503
let fileCount = 0;
493504
let filePaths: string[] = [];
@@ -764,6 +775,7 @@ export async function loadCliConfig(
764775
deleteSession: argv.deleteSession,
765776
enabledExtensions: argv.extensions,
766777
extensionLoader: extensionManager,
778+
extensionRegistryURI,
767779
enableExtensionReloading: settings.experimental?.extensionReloading,
768780
enableAgents: settings.experimental?.enableAgents,
769781
plan: settings.experimental?.plan,

packages/cli/src/config/extensionRegistryClient.test.ts

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,24 @@ import {
1313
afterEach,
1414
type Mock,
1515
} from 'vitest';
16+
import * as fs from 'node:fs/promises';
1617
import {
1718
ExtensionRegistryClient,
1819
type RegistryExtension,
1920
} from './extensionRegistryClient.js';
20-
import { fetchWithTimeout } from '@google/gemini-cli-core';
21+
import { fetchWithTimeout, resolveToRealPath } from '@google/gemini-cli-core';
22+
23+
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
24+
const actual =
25+
await importOriginal<typeof import('@google/gemini-cli-core')>();
26+
return {
27+
...actual,
28+
fetchWithTimeout: vi.fn(),
29+
};
30+
});
2131

22-
vi.mock('@google/gemini-cli-core', () => ({
23-
fetchWithTimeout: vi.fn(),
32+
vi.mock('node:fs/promises', () => ({
33+
readFile: vi.fn(),
2434
}));
2535

2636
const mockExtensions: RegistryExtension[] = [
@@ -279,4 +289,32 @@ describe('ExtensionRegistryClient', () => {
279289
expect(ids).not.toContain('dataplex');
280290
expect(ids).toContain('conductor');
281291
});
292+
293+
it('should fetch extensions from a local file path', async () => {
294+
const filePath = '/path/to/extensions.json';
295+
const clientWithFile = new ExtensionRegistryClient(filePath);
296+
const mockReadFile = vi.mocked(fs.readFile);
297+
mockReadFile.mockResolvedValue(JSON.stringify(mockExtensions));
298+
299+
const result = await clientWithFile.getExtensions();
300+
expect(result.extensions).toHaveLength(3);
301+
expect(mockReadFile).toHaveBeenCalledWith(
302+
resolveToRealPath(filePath),
303+
'utf-8',
304+
);
305+
});
306+
307+
it('should fetch extensions from a file:// URL', async () => {
308+
const fileUrl = 'file:///path/to/extensions.json';
309+
const clientWithFileUrl = new ExtensionRegistryClient(fileUrl);
310+
const mockReadFile = vi.mocked(fs.readFile);
311+
mockReadFile.mockResolvedValue(JSON.stringify(mockExtensions));
312+
313+
const result = await clientWithFileUrl.getExtensions();
314+
expect(result.extensions).toHaveLength(3);
315+
expect(mockReadFile).toHaveBeenCalledWith(
316+
resolveToRealPath(fileUrl),
317+
'utf-8',
318+
);
319+
});
282320
});

packages/cli/src/config/extensionRegistryClient.ts

Lines changed: 39 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,12 @@
44
* SPDX-License-Identifier: Apache-2.0
55
*/
66

7-
import { fetchWithTimeout } from '@google/gemini-cli-core';
7+
import * as fs from 'node:fs/promises';
8+
import {
9+
fetchWithTimeout,
10+
resolveToRealPath,
11+
isPrivateIp,
12+
} from '@google/gemini-cli-core';
813
import { AsyncFzf } from 'fzf';
914

1015
export interface RegistryExtension {
@@ -29,12 +34,19 @@ export interface RegistryExtension {
2934
}
3035

3136
export class ExtensionRegistryClient {
32-
private static readonly REGISTRY_URL =
37+
static readonly DEFAULT_REGISTRY_URL =
3338
'https://geminicli.com/extensions.json';
3439
private static readonly FETCH_TIMEOUT_MS = 10000; // 10 seconds
3540

3641
private static fetchPromise: Promise<RegistryExtension[]> | null = null;
3742

43+
private readonly registryURI: string;
44+
45+
constructor(registryURI?: string) {
46+
this.registryURI =
47+
registryURI || ExtensionRegistryClient.DEFAULT_REGISTRY_URL;
48+
}
49+
3850
/** @internal */
3951
static resetCache() {
4052
ExtensionRegistryClient.fetchPromise = null;
@@ -97,18 +109,34 @@ export class ExtensionRegistryClient {
97109
return ExtensionRegistryClient.fetchPromise;
98110
}
99111

112+
const uri = this.registryURI;
100113
ExtensionRegistryClient.fetchPromise = (async () => {
101114
try {
102-
const response = await fetchWithTimeout(
103-
ExtensionRegistryClient.REGISTRY_URL,
104-
ExtensionRegistryClient.FETCH_TIMEOUT_MS,
105-
);
106-
if (!response.ok) {
107-
throw new Error(`Failed to fetch extensions: ${response.statusText}`);
108-
}
115+
if (uri.startsWith('http')) {
116+
if (isPrivateIp(uri)) {
117+
throw new Error(
118+
'Private IP addresses are not allowed for the extension registry.',
119+
);
120+
}
121+
const response = await fetchWithTimeout(
122+
uri,
123+
ExtensionRegistryClient.FETCH_TIMEOUT_MS,
124+
);
125+
if (!response.ok) {
126+
throw new Error(
127+
`Failed to fetch extensions: ${response.statusText}`,
128+
);
129+
}
109130

110-
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
111-
return (await response.json()) as RegistryExtension[];
131+
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
132+
return (await response.json()) as RegistryExtension[];
133+
} else {
134+
// Handle local file path
135+
const filePath = resolveToRealPath(uri);
136+
const content = await fs.readFile(filePath, 'utf-8');
137+
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
138+
return JSON.parse(content) as RegistryExtension[];
139+
}
112140
} catch (error) {
113141
ExtensionRegistryClient.fetchPromise = null;
114142
throw error;

packages/cli/src/config/settingsSchema.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1791,6 +1791,16 @@ const SETTINGS_SCHEMA = {
17911791
description: 'Enable extension registry explore UI.',
17921792
showInDialog: false,
17931793
},
1794+
extensionRegistryURI: {
1795+
type: 'string',
1796+
label: 'Extension Registry URI',
1797+
category: 'Experimental',
1798+
requiresRestart: true,
1799+
default: 'https://geminicli.com/extensions.json',
1800+
description:
1801+
'The URI (web URL or local file path) of the extension registry.',
1802+
showInDialog: false,
1803+
},
17941804
extensionReloading: {
17951805
type: 'boolean',
17961806
label: 'Extension Reloading',

packages/cli/src/ui/components/views/ExtensionRegistryView.test.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,9 @@ describe('ExtensionRegistryView', () => {
132132

133133
vi.mocked(useConfig).mockReturnValue({
134134
getEnableExtensionReloading: vi.fn().mockReturnValue(false),
135+
getExtensionRegistryURI: vi
136+
.fn()
137+
.mockReturnValue('https://geminicli.com/extensions.json'),
135138
} as unknown as ReturnType<typeof useConfig>);
136139
});
137140

packages/cli/src/ui/components/views/ExtensionRegistryView.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,11 @@ export function ExtensionRegistryView({
3939
onClose,
4040
extensionManager,
4141
}: ExtensionRegistryViewProps): React.JSX.Element {
42-
const { extensions, loading, error, search } = useExtensionRegistry();
4342
const config = useConfig();
43+
const { extensions, loading, error, search } = useExtensionRegistry(
44+
'',
45+
config.getExtensionRegistryURI(),
46+
);
4447
const { terminalHeight, staticExtraHeight } = useUIState();
4548

4649
const { extensionsUpdateState } = useExtensionUpdates(

packages/cli/src/ui/hooks/useExtensionRegistry.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,16 @@ export interface UseExtensionRegistryResult {
1919

2020
export function useExtensionRegistry(
2121
initialQuery = '',
22+
registryURI?: string,
2223
): UseExtensionRegistryResult {
2324
const [extensions, setExtensions] = useState<RegistryExtension[]>([]);
2425
const [loading, setLoading] = useState(true);
2526
const [error, setError] = useState<string | null>(null);
2627

27-
const client = useMemo(() => new ExtensionRegistryClient(), []);
28+
const client = useMemo(
29+
() => new ExtensionRegistryClient(registryURI),
30+
[registryURI],
31+
);
2832

2933
// Ref to track the latest query to avoid race conditions
3034
const latestQueryRef = useRef(initialQuery);

packages/core/src/config/config.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -550,6 +550,7 @@ export interface ConfigParameters {
550550
skipNextSpeakerCheck?: boolean;
551551
shellExecutionConfig?: ShellExecutionConfig;
552552
extensionManagement?: boolean;
553+
extensionRegistryURI?: string;
553554
truncateToolOutputThreshold?: number;
554555
eventEmitter?: EventEmitter;
555556
useWriteTodos?: boolean;
@@ -738,6 +739,7 @@ export class Config implements McpContext, AgentLoopContext {
738739
private readonly useAlternateBuffer: boolean;
739740
private shellExecutionConfig: ShellExecutionConfig;
740741
private readonly extensionManagement: boolean = true;
742+
private readonly extensionRegistryURI: string | undefined;
741743
private readonly truncateToolOutputThreshold: number;
742744
private compressionTruncationCounter = 0;
743745
private initialized = false;
@@ -969,6 +971,7 @@ export class Config implements McpContext, AgentLoopContext {
969971
this.shellToolInactivityTimeout =
970972
(params.shellToolInactivityTimeout ?? 300) * 1000; // 5 minutes
971973
this.extensionManagement = params.extensionManagement ?? true;
974+
this.extensionRegistryURI = params.extensionRegistryURI;
972975
this.enableExtensionReloading = params.enableExtensionReloading ?? false;
973976
this.storage = new Storage(this.targetDir, this._sessionId);
974977
this.storage.setCustomPlansDir(params.planSettings?.directory);
@@ -1840,6 +1843,10 @@ export class Config implements McpContext, AgentLoopContext {
18401843
return this.extensionsEnabled;
18411844
}
18421845

1846+
getExtensionRegistryURI(): string | undefined {
1847+
return this.extensionRegistryURI;
1848+
}
1849+
18431850
getMcpClientManager(): McpClientManager | undefined {
18441851
return this.mcpClientManager;
18451852
}

schemas/settings.schema.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1689,6 +1689,13 @@
16891689
"default": false,
16901690
"type": "boolean"
16911691
},
1692+
"extensionRegistryURI": {
1693+
"title": "Extension Registry URI",
1694+
"description": "The URI (web URL or local file path) of the extension registry.",
1695+
"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`",
1696+
"default": "https://geminicli.com/extensions.json",
1697+
"type": "string"
1698+
},
16921699
"extensionReloading": {
16931700
"title": "Extension Reloading",
16941701
"description": "Enables extension loading/unloading within the CLI session.",

0 commit comments

Comments
 (0)