diff --git a/docs/users/extension/introduction.md b/docs/users/extension/introduction.md index 5865af385f4..73f34dff480 100644 --- a/docs/users/extension/introduction.md +++ b/docs/users/extension/introduction.md @@ -155,6 +155,8 @@ Only scoped packages (`@scope/package-name`) are supported to avoid ambiguity wi #### From Git Repository +Public Git repository installs and update checks require Git 2.37 or newer. Qwen Code uses the `http.curloptResolve` setting introduced in Git 2.37 to pin public network connections to validated DNS results. If your distribution ships an older Git version, upgrade Git or install a local/archive release instead. + ```bash qwen extensions install https://github.com/github/github-mcp-server ``` diff --git a/packages/core/src/extension/github.test.ts b/packages/core/src/extension/github.test.ts index 589102d5cfb..eef3edff711 100644 --- a/packages/core/src/extension/github.test.ts +++ b/packages/core/src/extension/github.test.ts @@ -319,6 +319,55 @@ describe('git extension helpers', () => { ); }); + it('explains how to install public extensions when Git is too old for DNS pinning', async () => { + mockGit.version.mockResolvedValue({ major: 2, minor: 34, patch: 1 }); + + await expect( + cloneFromGit( + { + source: 'https://github.com/owner/repo.git', + type: 'git', + networkPolicy: 'public', + }, + '/dest', + ), + ).rejects.toThrow( + 'Public extension Git installs require Git 2.37 or newer for secure DNS pinning; found Git 2.34.1. Upgrade Git, or install the extension from a local path or archive instead.', + ); + expect(mockGit.clone).not.toHaveBeenCalled(); + }); + + it('accepts Git 2.37 while preserving public network pinning', async () => { + mockGit.version.mockResolvedValue({ major: 2, minor: 37, patch: 0 }); + vi.spyOn(dns, 'lookup').mockResolvedValue([ + { address: '8.8.8.8', family: 4 }, + ] as never); + const source = 'https://github.com/owner/repo.git'; + mockGit.getRemotes.mockResolvedValue([ + { name: 'origin', refs: { fetch: source } }, + ]); + + await cloneFromGit( + { source, type: 'git', networkPolicy: 'public' }, + '/dest', + ); + + expect(simpleGit).toHaveBeenLastCalledWith('/dest', { + config: [ + 'http.curloptResolve=github.com:443:8.8.8.8', + 'http.followRedirects=false', + 'http.proxy=', + 'protocol.allow=never', + 'protocol.https.allow=always', + ], + unsafe: { + allowUnsafeConfigPaths: true, + allowUnsafeProtocolOverride: true, + }, + }); + expect(mockGit.clone).toHaveBeenCalled(); + }); + it('passes explicit credentials through scoped Git config without changing the URL', async () => { vi.spyOn(dns, 'lookup').mockResolvedValue([ { address: '8.8.8.8', family: 4 }, diff --git a/packages/core/src/extension/github.ts b/packages/core/src/extension/github.ts index 869b318ae6d..2792d75418b 100644 --- a/packages/core/src/extension/github.ts +++ b/packages/core/src/extension/github.ts @@ -144,7 +144,12 @@ async function assertPinnedGitSupported(): Promise { (version.major === MINIMUM_PINNED_GIT_VERSION.major && version.minor < MINIMUM_PINNED_GIT_VERSION.minor) ) { - throw new Error('Public extension Git installs require Git 2.37 or newer.'); + const detectedVersion = [version.major, version.minor, version.patch] + .filter((component) => component !== undefined) + .join('.'); + throw new Error( + `Public extension Git installs require Git 2.37 or newer for secure DNS pinning; found Git ${detectedVersion}. Upgrade Git, or install the extension from a local path or archive instead.`, + ); } }