diff --git a/packages/cli/src/config/extension.test.ts b/packages/cli/src/config/extension.test.ts index eb3136924b..033fa872ae 100644 --- a/packages/cli/src/config/extension.test.ts +++ b/packages/cli/src/config/extension.test.ts @@ -8,6 +8,7 @@ import { vi } from 'vitest'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; +import { createHash } from 'node:crypto'; import { EXTENSIONS_CONFIG_FILENAME, ExtensionStorage, @@ -509,6 +510,163 @@ describe('extension tests', () => { ); consoleSpy.mockRestore(); }); + + describe('id generation', () => { + it('should generate id from source for non-github git urls', () => { + const extensionDir = createExtension({ + extensionsDir: userExtensionsDir, + name: 'my-ext', + version: '1.0.0', + installMetadata: { + type: 'git', + source: 'http://somehost.com/foo/bar', + }, + }); + + const extension = loadExtension({ + extensionDir, + workspaceDir: tempWorkspaceDir, + }); + + const expectedHash = createHash('sha256') + .update('http://somehost.com/foo/bar') + .digest('hex'); + expect(extension?.id).toBe(expectedHash); + }); + + it('should generate id from owner/repo for github http urls', () => { + const extensionDir = createExtension({ + extensionsDir: userExtensionsDir, + name: 'my-ext', + version: '1.0.0', + installMetadata: { + type: 'git', + source: 'http://github.com/foo/bar', + }, + }); + + const extension = loadExtension({ + extensionDir, + workspaceDir: tempWorkspaceDir, + }); + + const expectedHash = createHash('sha256') + .update('https://github.com/foo/bar') + .digest('hex'); + expect(extension?.id).toBe(expectedHash); + }); + + it('should generate id from owner/repo for github ssh urls', () => { + const extensionDir = createExtension({ + extensionsDir: userExtensionsDir, + name: 'my-ext', + version: '1.0.0', + installMetadata: { + type: 'git', + source: 'git@github.com:foo/bar', + }, + }); + + const extension = loadExtension({ + extensionDir, + workspaceDir: tempWorkspaceDir, + }); + + const expectedHash = createHash('sha256') + .update('https://github.com/foo/bar') + .digest('hex'); + expect(extension?.id).toBe(expectedHash); + }); + + it('should generate id from source for github-release extension', () => { + const extensionDir = createExtension({ + extensionsDir: userExtensionsDir, + name: 'my-ext', + version: '1.0.0', + installMetadata: { + type: 'github-release', + source: 'https://github.com/foo/bar', + }, + }); + + const extension = loadExtension({ + extensionDir, + workspaceDir: tempWorkspaceDir, + }); + + const expectedHash = createHash('sha256') + .update('https://github.com/foo/bar') + .digest('hex'); + expect(extension?.id).toBe(expectedHash); + }); + + it('should generate id from the original source for local extension', () => { + const extensionDir = createExtension({ + extensionsDir: userExtensionsDir, + name: 'local-ext-name', + version: '1.0.0', + installMetadata: { + type: 'local', + source: '/some/path', + }, + }); + + const extension = loadExtension({ + extensionDir, + workspaceDir: tempWorkspaceDir, + }); + + const expectedHash = createHash('sha256') + .update('/some/path') + .digest('hex'); + expect(extension?.id).toBe(expectedHash); + }); + + it('should generate id from the original source for linked extensions', async () => { + const extDevelopmentDir = path.join(tempHomeDir, 'local_extensions'); + const actualExtensionDir = createExtension({ + extensionsDir: extDevelopmentDir, + name: 'link-ext-name', + version: '1.0.0', + }); + const extensionName = await installOrUpdateExtension( + { + type: 'link', + source: actualExtensionDir, + }, + async () => true, + tempWorkspaceDir, + ); + + const extension = loadExtension({ + extensionDir: new ExtensionStorage(extensionName).getExtensionDir(), + workspaceDir: tempWorkspaceDir, + }); + + const expectedHash = createHash('sha256') + .update(actualExtensionDir) + .digest('hex'); + expect(extension?.id).toBe(expectedHash); + }); + + it('should generate id from name for extension with no install metadata', () => { + const extensionDir = createExtension({ + extensionsDir: userExtensionsDir, + name: 'no-meta-name', + version: '1.0.0', + }); + + const extension = loadExtension({ + extensionDir, + workspaceDir: tempWorkspaceDir, + }); + + const expectedHash = createHash('sha256') + .update('no-meta-name') + .digest('hex'); + expect(extension?.id).toBe(expectedHash); + }); + }); }); describe('annotateActiveExtensions', () => { diff --git a/packages/cli/src/config/extension.ts b/packages/cli/src/config/extension.ts index f3ecba81b9..8557341be1 100644 --- a/packages/cli/src/config/extension.ts +++ b/packages/cli/src/config/extension.ts @@ -35,10 +35,11 @@ import { } from './extensions/variables.js'; import { isWorkspaceTrusted } from './trustedFolders.js'; import { resolveEnvVarsInObject } from '../utils/envVarResolver.js'; -import { randomUUID } from 'node:crypto'; +import { randomUUID, createHash } from 'node:crypto'; import { cloneFromGit, downloadFromGitHubRelease, + tryParseGithubUrl, } from './extensions/github.js'; import type { LoadExtensionContext } from './extensions/variableSchema.js'; import { ExtensionEnablementManager } from './extensions/extensionEnablement.js'; @@ -253,6 +254,28 @@ export function loadExtension( ) .filter((contextFilePath) => fs.existsSync(contextFilePath)); + // IDs are created by hashing details of the installation source in order to + // deduplicate extensions with conflicting names and also obfuscate any + // potentially sensitive information such as private git urls, system paths, + // or project names. + const hash = createHash('sha256'); + const githubUrlParts = + installMetadata && + (installMetadata.type === 'git' || + installMetadata.type === 'github-release') + ? tryParseGithubUrl(installMetadata.source) + : null; + if (githubUrlParts) { + // For github repos, we use the https URI to the repo as the ID. + hash.update( + `https://github.com/${githubUrlParts.owner}/${githubUrlParts.repo}`, + ); + } else { + hash.update(installMetadata?.source ?? config.name); + } + + const id = hash.digest('hex'); + return { name: config.name, version: config.version, @@ -262,6 +285,7 @@ export function loadExtension( mcpServers: config.mcpServers, excludeTools: config.excludeTools, isActive: true, // Barring any other signals extensions should be considered Active. + id, }; } catch (e) { console.error( @@ -465,26 +489,36 @@ export async function installOrUpdateExtension( installMetadata.type === 'github-release' ) { tempDir = await ExtensionStorage.createTmpDir(); - const result = await downloadFromGitHubRelease(installMetadata, tempDir); - if (result.success) { - installMetadata.type = result.type; - installMetadata.releaseTag = result.tagName; - } else if ( - // This repo has no github releases, and wasn't explicitly installed - // from a github release, unconditionally just clone it. - (result.failureReason === 'no release data' && - installMetadata.type === 'git') || - // Otherwise ask the user if they would like to try a git clone. - (await requestConsent( - `Error downloading github release for ${installMetadata.source} with the following error: ${result.errorMessage}.\n\nWould you like to attempt to install via "git clone" instead?`, - )) - ) { + const parsedGithubParts = tryParseGithubUrl(installMetadata.source); + if (!parsedGithubParts) { await cloneFromGit(installMetadata, tempDir); installMetadata.type = 'git'; } else { - throw new Error( - `Failed to install extension ${installMetadata.source}: ${result.errorMessage}`, + const result = await downloadFromGitHubRelease( + installMetadata, + tempDir, + parsedGithubParts, ); + if (result.success) { + installMetadata.type = result.type; + installMetadata.releaseTag = result.tagName; + } else if ( + // This repo has no github releases, and wasn't explicitly installed + // from a github release, unconditionally just clone it. + (result.failureReason === 'no release data' && + installMetadata.type === 'git') || + // Otherwise ask the user if they would like to try a git clone. + (await requestConsent( + `Error downloading github release for ${installMetadata.source} with the following error: ${result.errorMessage}.\n\nWould you like to attempt to install via "git clone" instead?`, + )) + ) { + await cloneFromGit(installMetadata, tempDir); + installMetadata.type = 'git'; + } else { + throw new Error( + `Failed to install extension ${installMetadata.source}: ${result.errorMessage}`, + ); + } } localSourcePath = tempDir; } else if ( diff --git a/packages/cli/src/config/extensions/github.test.ts b/packages/cli/src/config/extensions/github.test.ts index 0bbd7fd856..c4874aa08b 100644 --- a/packages/cli/src/config/extensions/github.test.ts +++ b/packages/cli/src/config/extensions/github.test.ts @@ -11,7 +11,7 @@ import { extractFile, findReleaseAsset, fetchReleaseFromGithub, - parseGitHubRepoForReleases, + tryParseGithubUrl, } from './github.js'; import { simpleGit, type SimpleGit } from 'simple-git'; import { ExtensionUpdateState } from '../../ui/state/extensions.js'; @@ -348,61 +348,62 @@ describe('git extension helpers', () => { describe('parseGitHubRepoForReleases', () => { it('should parse owner and repo from a full GitHub URL', () => { const source = 'https://github.com/owner/repo.git'; - const { owner, repo } = parseGitHubRepoForReleases(source)!; + const { owner, repo } = tryParseGithubUrl(source)!; expect(owner).toBe('owner'); expect(repo).toBe('repo'); }); it('should parse owner and repo from a full GitHub URL without .git', () => { const source = 'https://github.com/owner/repo'; - const { owner, repo } = parseGitHubRepoForReleases(source)!; + const { owner, repo } = tryParseGithubUrl(source)!; expect(owner).toBe('owner'); expect(repo).toBe('repo'); }); it('should parse owner and repo from a full GitHub URL with a trailing slash', () => { const source = 'https://github.com/owner/repo/'; - const { owner, repo } = parseGitHubRepoForReleases(source)!; + const { owner, repo } = tryParseGithubUrl(source)!; expect(owner).toBe('owner'); expect(repo).toBe('repo'); }); - it('should fail on a GitHub SSH URL', () => { + it('should parse owner and repo from a GitHub SSH URL', () => { const source = 'git@github.com:owner/repo.git'; - expect(() => parseGitHubRepoForReleases(source)).toThrow( - 'GitHub release-based extensions are not supported for SSH. You must use an HTTPS URI with a personal access token to download releases from private repositories. You can set your personal access token in the GITHUB_TOKEN environment variable and install the extension via SSH.', - ); + + const { owner, repo } = tryParseGithubUrl(source)!; + expect(owner).toBe('owner'); + expect(repo).toBe('repo'); }); it('should return null on a non-GitHub URL', () => { const source = 'https://example.com/owner/repo.git'; - expect(parseGitHubRepoForReleases(source)).toBe(null); + expect(tryParseGithubUrl(source)).toBe(null); }); it('should parse owner and repo from a shorthand string', () => { const source = 'owner/repo'; - const { owner, repo } = parseGitHubRepoForReleases(source)!; + const { owner, repo } = tryParseGithubUrl(source)!; expect(owner).toBe('owner'); expect(repo).toBe('repo'); }); it('should handle .git suffix in repo name', () => { const source = 'owner/repo.git'; - const { owner, repo } = parseGitHubRepoForReleases(source)!; + const { owner, repo } = tryParseGithubUrl(source)!; expect(owner).toBe('owner'); expect(repo).toBe('repo'); }); it('should throw error for invalid source format', () => { const source = 'invalid-format'; - expect(() => parseGitHubRepoForReleases(source)).toThrow( + expect(() => tryParseGithubUrl(source)).toThrow( 'Invalid GitHub repository source: invalid-format. Expected "owner/repo" or a github repo uri.', ); }); it('should throw error for source with too many parts', () => { const source = 'https://github.com/owner/repo/extra'; - expect(() => parseGitHubRepoForReleases(source)).toThrow( + expect(() => tryParseGithubUrl(source)).toThrow( 'Invalid GitHub repository source: https://github.com/owner/repo/extra. Expected "owner/repo" or a github repo uri.', ); }); diff --git a/packages/cli/src/config/extensions/github.ts b/packages/cli/src/config/extensions/github.ts index ef95cb26fe..2b28e9eab4 100644 --- a/packages/cli/src/config/extensions/github.ts +++ b/packages/cli/src/config/extensions/github.ts @@ -76,24 +76,30 @@ export async function cloneFromGit( } } -export function parseGitHubRepoForReleases(source: string): { +export interface GithubRepoInfo { owner: string; repo: string; -} | null { +} + +export function tryParseGithubUrl(source: string): GithubRepoInfo | null { + // First step in normalizing a github ssh URI to the https form. + if (source.startsWith('git@github.com:')) { + source = source.replace('git@github.com:', ''); + } // Default to a github repo path, so `source` can be just an org/repo const parsedUrl = URL.parse(source, 'https://github.com'); if (!parsedUrl) { throw new Error(`Invalid repo URL: ${source}`); } - // The pathname should be "/owner/repo". - const parts = parsedUrl?.pathname - .substring(1) - .split('/') - // Remove the empty segments, fixes trailing slashes - .filter((part) => part !== ''); if (parsedUrl?.host !== 'github.com') { return null; } + // The pathname should be "/owner/repo". + const parts = parsedUrl?.pathname + .split('/') + // Remove the empty segments, fixes trailing and leading slashes + .filter((part) => part !== ''); + if (parts?.length !== 2) { throw new Error( `Invalid GitHub repository source: ${source}. Expected "owner/repo" or a github repo uri.`, @@ -102,13 +108,10 @@ export function parseGitHubRepoForReleases(source: string): { const owner = parts[0]; const repo = parts[1].replace('.git', ''); - if (owner.startsWith('git@github.com')) { - throw new Error( - `GitHub release-based extensions are not supported for SSH. You must use an HTTPS URI with a personal access token to download releases from private repositories. You can set your personal access token in the GITHUB_TOKEN environment variable and install the extension via SSH.`, - ); - } - - return { owner, repo }; + return { + owner, + repo, + }; } export async function fetchReleaseFromGithub( @@ -217,7 +220,7 @@ export async function checkForExtensionUpdate( console.error(`No "source" provided for extension.`); return ExtensionUpdateState.ERROR; } - const repoInfo = parseGitHubRepoForReleases(source); + const repoInfo = tryParseGithubUrl(source); if (!repoInfo) { console.error( `Source is not a valid GitHub repository for release checks: ${source}`, @@ -248,39 +251,35 @@ export async function checkForExtensionUpdate( } } -export interface GitHubDownloadResult { - tagName?: string; - type: 'git' | 'github-release'; - success: boolean; - failureReason?: - | 'failed to fetch release data' - | 'no release data' - | 'no release asset found' - | 'failed to download asset' - | 'failed to extract asset' - | 'unknown'; - errorMessage?: string; -} - +export type GitHubDownloadResult = + | { + tagName?: string; + type: 'git' | 'github-release'; + success: false; + failureReason: + | 'failed to fetch release data' + | 'no release data' + | 'no release asset found' + | 'failed to download asset' + | 'failed to extract asset' + | 'unknown'; + errorMessage: string; + } + | { + tagName?: string; + type: 'git' | 'github-release'; + success: true; + }; export async function downloadFromGitHubRelease( installMetadata: ExtensionInstallMetadata, destination: string, + githubRepoInfo: GithubRepoInfo, ): Promise { - const { source, ref, allowPreRelease: preRelease } = installMetadata; + const { ref, allowPreRelease: preRelease } = installMetadata; + const { owner, repo } = githubRepoInfo; let releaseData: GithubReleaseData | null = null; try { - const parts = parseGitHubRepoForReleases(source); - if (!parts) { - return { - failureReason: 'no release data', - success: false, - type: 'github-release', - errorMessage: `Not a github repo: ${source}`, - }; - } - const { owner, repo } = parts; - try { releaseData = await fetchReleaseFromGithub(owner, repo, ref, preRelease); if (!releaseData) { diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 05799a81a4..1b72f19e89 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -141,6 +141,7 @@ export interface GeminiCLIExtension { mcpServers?: Record; contextFiles: string[]; excludeTools?: string[]; + id?: string; } export interface ExtensionInstallMetadata {