diff --git a/docs/extension.md b/docs/extension.md index 4d51ea774d..a82472bfb9 100644 --- a/docs/extension.md +++ b/docs/extension.md @@ -104,7 +104,7 @@ The `gemini-extension.json` file contains the configuration for the extension. T } ``` -- `name`: The name of the extension. This is used to uniquely identify the extension and for conflict resolution when extension commands have the same name as user or project commands. The name should be lowercase and use dashes instead of underscores or spaces. This is how users will refer to your extension in the CLI. Note that we expect this name to match the extension directory name. +- `name`: The name of the extension. This is used to uniquely identify the extension and for conflict resolution when extension commands have the same name as user or project commands. The name should be lowercase or numbers and use dashes instead of underscores or spaces. This is how users will refer to your extension in the CLI. Note that we expect this name to match the extension directory name. - `version`: The version of the extension. - `mcpServers`: A map of MCP servers to configure. The key is the name of the server, and the value is the server configuration. These servers will be loaded on startup just like MCP servers configured in a [`settings.json` file](./cli/configuration.md). If both an extension and a `settings.json` file configure an MCP server with the same name, the server defined in the `settings.json` file takes precedence. - Note that all MCP server configuration options are supported except for `trust`. diff --git a/packages/cli/src/config/extension.test.ts b/packages/cli/src/config/extension.test.ts index 7da34c2696..6f3e62c137 100644 --- a/packages/cli/src/config/extension.test.ts +++ b/packages/cli/src/config/extension.test.ts @@ -462,6 +462,28 @@ describe('extension tests', () => { const loadedConfig = extensions[0].config; expect(loadedConfig.mcpServers?.['test-server'].trust).toBeUndefined(); }); + + it('should throw an error for invalid extension names', () => { + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + const badExtDir = createExtension({ + extensionsDir: userExtensionsDir, + name: 'bad_name', + version: '1.0.0', + }); + + const extension = loadExtension({ + extensionDir: badExtDir, + workspaceDir: tempWorkspaceDir, + }); + + expect(extension).toBeNull(); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('Invalid extension name: "bad_name"'), + ); + consoleSpy.mockRestore(); + }); }); describe('annotateActiveExtensions', () => { @@ -951,6 +973,18 @@ This extension will run the following MCP servers: expect(mockRequestConsent).not.toHaveBeenCalled(); }); + + it('should throw an error for invalid extension names', async () => { + const sourceExtDir = createExtension({ + extensionsDir: tempHomeDir, + name: 'bad_name', + version: '1.0.0', + }); + + await expect( + installExtension({ source: sourceExtDir, type: 'local' }), + ).rejects.toThrow('Invalid extension name: "bad_name"'); + }); }); describe('uninstallExtension', () => { diff --git a/packages/cli/src/config/extension.ts b/packages/cli/src/config/extension.ts index c59ac93f89..3cf2dad33e 100644 --- a/packages/cli/src/config/extension.ts +++ b/packages/cli/src/config/extension.ts @@ -627,6 +627,14 @@ async function maybeRequestConsentOrFail( } } +export function validateName(name: string) { + if (!/^[a-zA-Z0-9-]+$/.test(name)) { + throw new Error( + `Invalid extension name: "${name}". Only letters (a-z, A-Z), numbers (0-9), and dashes (-) are allowed.`, + ); + } +} + export function loadExtensionConfig( context: LoadExtensionContext, ): ExtensionConfig { @@ -648,6 +656,7 @@ export function loadExtensionConfig( `Invalid configuration in ${configFilePath}: missing ${!config.name ? '"name"' : '"version"'}`, ); } + validateName(config.name); return config; } catch (e) { throw new Error(