From 0a6c50c7a7241b42ddce0acd0fde0a6f70bcdf9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=B6=E5=85=AC?= Date: Fri, 7 Aug 2026 11:38:21 +0800 Subject: [PATCH 1/8] feat(core): support Qoder plugin extensions --- docs/design/qoder-plugin-compatibility.md | 21 ++ docs/users/extension/introduction.md | 16 +- .../cli/extensions-install.test.ts | 35 ++- packages/acp-bridge/src/status.ts | 6 +- packages/core/src/config/config.ts | 3 +- .../src/extension/claude-converter.test.ts | 3 + .../core/src/extension/claude-converter.ts | 2 +- .../core/src/extension/extension-converter.ts | 10 +- .../src/extension/extensionManager.test.ts | 154 ++++++++++++ .../core/src/extension/extensionManager.ts | 24 +- packages/core/src/extension/github.test.ts | 114 ++++++++- packages/core/src/extension/github.ts | 113 +++++---- .../src/extension/qoder-converter.test.ts | 238 ++++++++++++++++++ .../core/src/extension/qoder-converter.ts | 163 ++++++++++++ packages/sdk-typescript/src/daemon/types.ts | 6 +- 15 files changed, 842 insertions(+), 66 deletions(-) create mode 100644 docs/design/qoder-plugin-compatibility.md create mode 100644 packages/core/src/extension/qoder-converter.test.ts create mode 100644 packages/core/src/extension/qoder-converter.ts diff --git a/docs/design/qoder-plugin-compatibility.md b/docs/design/qoder-plugin-compatibility.md new file mode 100644 index 00000000000..03994d808a0 --- /dev/null +++ b/docs/design/qoder-plugin-compatibility.md @@ -0,0 +1,21 @@ +# Qoder Plugin Compatibility + +## Context + +Qwen Code installs extensions from directories, archives, Git repositories, archive URLs, and scoped npm packages. Each source is normalized to a local directory before its manifest is loaded. The [Qoder plugin layout](https://docs.qoder.com/en/cli/sdk/plugins) uses `.qoder-plugin/plugin.json` with standard commands, agents, skills, and a root `.mcp.json` file. + +## Design + +The existing compatible-extension conversion step recognizes the Qoder manifest after native Qwen, Gemini, and Claude manifests. It copies the plugin into a temporary extension directory, writes a generated `qwen-extension.json`, and records `Qoder` as the install origin. Converted Git installs record the checked-out commit in install metadata so update checks remain available after Git metadata is removed. + +The generated manifest preserves `name`, `version`, `displayName`, and `description`. A missing version defaults to `1.0.0`. Standard resource directories remain in place, while existing resource path declarations use the same confined collection logic as other compatible plugin formats. Root `.mcp.json` servers are normalized to Qwen transports unless the manifest already defines MCP servers. + +A safe root `system-prompt.md` is added to the extension context list. An existing `QWEN.md` and explicitly configured context files remain active alongside it, with duplicates removed. + +## Security + +The manifest must resolve within the plugin directory and parse as a JSON object with a valid name. Referenced resources and context files must remain inside the plugin. Bulk copying skips symlinks that escape the source root and omits Git metadata. Archive validation accepts the Qoder manifest at the archive root or inside one supported top-level wrapper directory. + +## Compatibility + +Adding `Qoder` to the shared extension-origin union lets CLI, daemon, SDK, ACP, and Web Shell consumers identify converted plugins without changing install commands or route shapes. diff --git a/docs/users/extension/introduction.md b/docs/users/extension/introduction.md index 098db12fffe..4c6ca5ffe70 100644 --- a/docs/users/extension/introduction.md +++ b/docs/users/extension/introduction.md @@ -2,7 +2,7 @@ Qwen Code extensions package prompts, MCP servers, subagents, skills and custom commands into a familiar and user-friendly format. With extensions, you can expand the capabilities of Qwen Code and share those capabilities with others. They are designed to be easily installable and shareable. -Extensions and plugins from [Gemini CLI Extensions Gallery](https://geminicli.com/extensions/) and [Claude Code Marketplace](https://claudemarketplaces.com/) can be directly installed into Qwen Code. This cross-platform compatibility gives you access to a rich ecosystem of extensions and plugins, dramatically expanding Qwen Code's capabilities without requiring extension authors to maintain separate versions. +Extensions and plugins from [Gemini CLI Extensions Gallery](https://geminicli.com/extensions/), [Claude Code Marketplace](https://claudemarketplaces.com/), and Qoder can be directly installed into Qwen Code. This cross-platform compatibility gives you access to a rich ecosystem of extensions and plugins, dramatically expanding Qwen Code's capabilities without requiring extension authors to maintain separate versions. ## Extension management @@ -99,6 +99,20 @@ Gemini extensions are automatically converted to Qwen Code format during install - TOML command files are automatically migrated to Markdown format - MCP servers, context files, and settings are preserved +#### From Qoder Plugins + +Qwen Code supports [Qoder plugins](https://docs.qoder.com/en/cli/sdk/plugins) that contain a `.qoder-plugin/plugin.json` manifest. Install a local directory, archive, Git repository, archive URL, or scoped npm package with the existing `qwen extensions install` command: + +```bash +qwen extensions install ./sample-qoder-plugin +qwen extensions install ./sample-qoder-plugin.zip +qwen extensions install owner/sample-qoder-plugin +``` + +The installer converts the Qoder manifest to `qwen-extension.json` and preserves standard `commands/`, `agents/`, and `skills/` directories. MCP servers declared in a root `.mcp.json` file are included as extension MCP servers. + +When a Qoder plugin contains `system-prompt.md` at its root, Qwen Code loads it as extension context. If the plugin also contains `QWEN.md` or declares other context files, all context files are retained and deduplicated. + #### From npm Registry Qwen Code supports installing extensions from npm registries using scoped package names. This is ideal for teams with private registries that already have auth, versioning, and publishing infrastructure in place. diff --git a/integration-tests/cli/extensions-install.test.ts b/integration-tests/cli/extensions-install.test.ts index 58afd4024a0..0af26a9d1e9 100644 --- a/integration-tests/cli/extensions-install.test.ts +++ b/integration-tests/cli/extensions-install.test.ts @@ -6,7 +6,7 @@ import { expect, test } from 'vitest'; import { TestRig } from '../test-helper.js'; -import { writeFileSync } from 'node:fs'; +import { mkdirSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; const extension = `{ @@ -50,3 +50,36 @@ test('installs a local extension, verifies a command, and updates it', async () await rig.cleanup(); }); + +test('installs a local Qoder plugin', async () => { + const rig = new TestRig(); + rig.setup('qoder plugin install test'); + const manifestDir = join(rig.testDir!, '.qoder-plugin'); + mkdirSync(manifestDir, { recursive: true }); + writeFileSync( + join(manifestDir, 'plugin.json'), + JSON.stringify({ name: 'sample-qoder-plugin', version: '1.0.0' }), + ); + writeFileSync(join(rig.testDir!, 'system-prompt.md'), '# System context'); + const skillDir = join(rig.testDir!, 'skills', 'sample-skill'); + mkdirSync(skillDir, { recursive: true }); + writeFileSync( + join(skillDir, 'SKILL.md'), + '---\nname: sample-skill\ndescription: Synthetic skill\n---\n', + ); + + try { + const result = await rig.runCommand( + ['extensions', 'install', rig.testDir!], + { stdin: 'y\n' }, + ); + expect(result).toContain('sample-qoder-plugin'); + + const listResult = await rig.runCommand(['extensions', 'list']); + expect(listResult).toContain('sample-qoder-plugin'); + + await rig.runCommand(['extensions', 'uninstall', 'sample-qoder-plugin']); + } finally { + await rig.cleanup(); + } +}); diff --git a/packages/acp-bridge/src/status.ts b/packages/acp-bridge/src/status.ts index 4bd6c5e815b..a8059a4d48a 100644 --- a/packages/acp-bridge/src/status.ts +++ b/packages/acp-bridge/src/status.ts @@ -1065,7 +1065,11 @@ export type ServeExtensionInstallType = | 'npm' | 'archive-url'; -export type ServeExtensionOriginSource = 'QwenCode' | 'Claude' | 'Gemini'; +export type ServeExtensionOriginSource = + | 'QwenCode' + | 'Claude' + | 'Gemini' + | 'Qoder'; export interface ServeExtensionCapabilities { mcpServerCount: number; diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index ad186d82428..4a2a1aa9221 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -672,7 +672,7 @@ function normalizeGitCoAuthor(value: GitCoAuthorParam | undefined): { }; } -export type ExtensionOriginSource = 'QwenCode' | 'Claude' | 'Gemini'; +export type ExtensionOriginSource = 'QwenCode' | 'Claude' | 'Gemini' | 'Qoder'; export type ExtensionNetworkPolicy = 'public'; export interface ExtensionInstallMetadata { @@ -680,6 +680,7 @@ export interface ExtensionInstallMetadata { type: 'git' | 'local' | 'link' | 'github-release' | 'npm' | 'archive-url'; originSource?: ExtensionOriginSource; releaseTag?: string; // Only present for github-release and npm installs. + gitCommit?: string; // Only present when a converted Git install cannot retain .git. registryUrl?: string; // Only present for npm installs. ref?: string; autoUpdate?: boolean; diff --git a/packages/core/src/extension/claude-converter.test.ts b/packages/core/src/extension/claude-converter.test.ts index 04f3494fa03..1d2f499d9a2 100644 --- a/packages/core/src/extension/claude-converter.test.ts +++ b/packages/core/src/extension/claude-converter.test.ts @@ -1371,6 +1371,7 @@ describe('convertClaudePluginPackage — git-subdir source', () => { JSON.stringify({ name: 'p', version: '1.0.0' }), 'utf-8', ); + return 'test-commit'; }); writeMarketplace({ @@ -1422,6 +1423,7 @@ describe('convertClaudePluginPackage — git-subdir source', () => { vi.mocked(cloneFromGit).mockImplementation(async (_meta, dir) => { // The clone succeeded but does not contain the requested subdir. fs.mkdirSync(path.join(dir as string, 'other'), { recursive: true }); + return 'test-commit'; }); writeMarketplace({ source: 'git-subdir', @@ -1440,6 +1442,7 @@ describe('convertClaudePluginPackage — git-subdir source', () => { // A hostile repo commits the subdir as a symlink whose name stays inside // the clone but whose target escapes it. fs.symlinkSync(secretDir, path.join(dir as string, 'sub')); + return 'test-commit'; }); writeMarketplace({ source: 'git-subdir', diff --git a/packages/core/src/extension/claude-converter.ts b/packages/core/src/extension/claude-converter.ts index 0c47a7c04a4..384b433a340 100644 --- a/packages/core/src/extension/claude-converter.ts +++ b/packages/core/src/extension/claude-converter.ts @@ -591,7 +591,7 @@ function resolvePluginRelativeFile( * (`convertClaudePluginPackage`) and standalone (`convertClaudePluginStandalone`) * conversion paths. */ -async function buildQwenExtensionFromPlugin( +export async function buildQwenExtensionFromPlugin( pluginSource: string, mergedConfig: ClaudePluginConfig, ): Promise<{ config: ExtensionConfig; convertedDir: string }> { diff --git a/packages/core/src/extension/extension-converter.ts b/packages/core/src/extension/extension-converter.ts index d3fa57a9abd..900990fb425 100644 --- a/packages/core/src/extension/extension-converter.ts +++ b/packages/core/src/extension/extension-converter.ts @@ -15,6 +15,10 @@ import { convertClaudePluginPackage, convertClaudePluginStandalone, } from './claude-converter.js'; +import { + convertQoderPlugin, + QODER_PLUGIN_MANIFEST, +} from './qoder-converter.js'; import type { ExtensionNetworkPolicy, ExtensionOriginSource, @@ -25,9 +29,10 @@ export const SUPPORTED_EXTENSION_MANIFESTS = [ 'gemini-extension.json', '.claude-plugin/marketplace.json', '.claude-plugin/plugin.json', + QODER_PLUGIN_MANIFEST, ] as const; -export async function convertGeminiOrClaudeExtension( +export async function convertCompatibleExtension( extensionDir: string, pluginName?: string, networkPolicy?: ExtensionNetworkPolicy, @@ -62,6 +67,9 @@ export async function convertGeminiOrClaudeExtension( newExtensionDir = (await convertClaudePluginStandalone(extensionDir)) .convertedDir; originSource = 'Claude'; + } else if (fs.existsSync(path.join(extensionDir, QODER_PLUGIN_MANIFEST))) { + newExtensionDir = (await convertQoderPlugin(extensionDir)).convertedDir; + originSource = 'Qoder'; } signal?.throwIfAborted(); return { extensionDir: newExtensionDir, originSource }; diff --git a/packages/core/src/extension/extensionManager.test.ts b/packages/core/src/extension/extensionManager.test.ts index 9e64ffe0005..d3477b130d2 100644 --- a/packages/core/src/extension/extensionManager.test.ts +++ b/packages/core/src/extension/extensionManager.test.ts @@ -44,6 +44,7 @@ const mockGit = { }; const mockDownloadFromArchiveUrl = vi.hoisted(() => vi.fn()); const mockExtractArchiveFile = vi.hoisted(() => vi.fn()); +const mockDownloadFromNpmRegistry = vi.hoisted(() => vi.fn()); vi.mock('simple-git', () => ({ CheckRepoActions: { IS_REPO_ROOT: 'is-repo-root' }, @@ -65,6 +66,14 @@ vi.mock('./github.js', async (importOriginal) => { }; }); +vi.mock('./npm.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + downloadFromNpmRegistry: mockDownloadFromNpmRegistry, + }; +}); + const mockHomedir = vi.hoisted(() => vi.fn()); vi.mock('os', async (importOriginal) => { const mockedOs = await importOriginal(); @@ -153,6 +162,8 @@ describe('extension tests', () => { Object.values(mockGit).forEach((fn) => fn.mockReset()); mockDownloadFromArchiveUrl.mockReset(); mockExtractArchiveFile.mockReset(); + mockDownloadFromNpmRegistry.mockReset(); + mockGit.revparse.mockResolvedValue('sample-commit'); }); afterEach(() => { @@ -187,6 +198,20 @@ describe('extension tests', () => { ); } + function writeQoderPlugin(destination: string) { + fs.mkdirSync(path.join(destination, '.qoder-plugin'), { + recursive: true, + }); + fs.writeFileSync( + path.join(destination, '.qoder-plugin', 'plugin.json'), + JSON.stringify({ name: 'sample-qoder-plugin', version: '1.0.0' }), + ); + fs.writeFileSync( + path.join(destination, 'system-prompt.md'), + '# System context', + ); + } + it('installs and uninstalls within an injected extension store root', async () => { const archivePath = path.join(tempWorkspaceDir, 'custom-root.zip'); fs.writeFileSync(archivePath, 'archive'); @@ -676,6 +701,135 @@ describe('extension tests', () => { }); }); + it('should install a Qoder plugin with skills and system context', async () => { + const sourcePath = path.join(tempWorkspaceDir, 'sample-qoder-plugin'); + fs.mkdirSync(path.join(sourcePath, '.qoder-plugin'), { + recursive: true, + }); + fs.writeFileSync( + path.join(sourcePath, '.qoder-plugin', 'plugin.json'), + JSON.stringify({ name: 'sample-qoder-plugin', version: '1.0.0' }), + ); + fs.writeFileSync( + path.join(sourcePath, 'system-prompt.md'), + '# System context', + ); + const skillPath = path.join(sourcePath, 'skills', 'sample-skill'); + fs.mkdirSync(skillPath, { recursive: true }); + fs.writeFileSync( + path.join(skillPath, 'SKILL.md'), + '---\nname: sample-skill\ndescription: Synthetic skill\n---\n', + ); + const requestConsent = vi.fn(async () => {}); + const manager = createExtensionManager(); + await manager.refreshCache(); + + const extension = await manager.installExtension( + { source: sourcePath, type: 'local' }, + requestConsent, + ); + + expect(extension.installMetadata).toMatchObject({ + source: sourcePath, + type: 'local', + originSource: 'Qoder', + }); + expect(extension.contextFiles).toEqual([ + path.join(extension.path, 'system-prompt.md'), + ]); + expect(extension.skills?.map((skill) => skill.name)).toEqual([ + 'sample-skill', + ]); + expect(requestConsent).toHaveBeenCalledWith( + expect.objectContaining({ originSource: 'Qoder' }), + ); + }); + + it.each([ + { + type: 'local' as const, + source: 'sample-qoder-plugin.zip', + }, + { + type: 'archive-url' as const, + source: 'https://example.com/sample-qoder-plugin.zip', + }, + { + type: 'npm' as const, + source: '@example/sample-qoder-plugin', + }, + ])('should install a Qoder plugin from $type', async (installMetadata) => { + const resolvedInstallMetadata = + installMetadata.type === 'local' + ? { + ...installMetadata, + source: path.join(tempWorkspaceDir, installMetadata.source), + } + : installMetadata; + if (resolvedInstallMetadata.type === 'local') { + fs.writeFileSync(resolvedInstallMetadata.source, 'synthetic archive'); + mockExtractArchiveFile.mockImplementation( + async (_source: string, destination: string) => { + writeQoderPlugin(destination); + }, + ); + } else if (installMetadata.type === 'archive-url') { + mockDownloadFromArchiveUrl.mockImplementation( + async (_metadata: ExtensionInstallMetadata, destination: string) => { + writeQoderPlugin(destination); + }, + ); + } else { + mockDownloadFromNpmRegistry.mockImplementation( + async (_metadata: ExtensionInstallMetadata, destination: string) => { + writeQoderPlugin(destination); + return { version: '1.0.0', type: 'npm' }; + }, + ); + } + const manager = createExtensionManager(); + await manager.refreshCache(); + + const extension = await manager.installExtension( + resolvedInstallMetadata, + async () => {}, + ); + + expect(extension.name).toBe('sample-qoder-plugin'); + expect(extension.installMetadata?.originSource).toBe('Qoder'); + expect(extension.contextFiles).toEqual([ + path.join(extension.path, 'system-prompt.md'), + ]); + }); + + it('should install a Qoder plugin from Git', async () => { + mockGit.clone.mockImplementation(async () => { + writeQoderPlugin(mockGit.path()); + }); + mockGit.getRemotes.mockResolvedValue([ + { + name: 'origin', + refs: { fetch: 'https://github.com/example/sample-qoder-plugin' }, + }, + ]); + mockGit.fetch.mockResolvedValue(undefined); + mockGit.checkout.mockResolvedValue(undefined); + const manager = createExtensionManager(); + await manager.refreshCache(); + + const extension = await manager.installExtension( + { + type: 'git', + source: 'https://github.com/example/sample-qoder-plugin', + }, + async () => {}, + ); + + expect(extension.name).toBe('sample-qoder-plugin'); + expect(extension.installMetadata?.originSource).toBe('Qoder'); + expect(extension.installMetadata?.gitCommit).toBe('sample-commit'); + }); + it('should emit mutation lifecycle events around install', async () => { const archivePath = path.join(tempWorkspaceDir, 'local-extension.zip'); fs.writeFileSync(archivePath, 'not used by mocked extractor'); diff --git a/packages/core/src/extension/extensionManager.ts b/packages/core/src/extension/extensionManager.ts index 31f308b5ab6..4c7d8eed5b8 100644 --- a/packages/core/src/extension/extensionManager.ts +++ b/packages/core/src/extension/extensionManager.ts @@ -63,7 +63,7 @@ import { loadMarketplaceConfigFromSource, parseInstallSource, } from './marketplace.js'; -import { convertGeminiOrClaudeExtension } from './extension-converter.js'; +import { convertCompatibleExtension } from './extension-converter.js'; import { glob } from 'glob'; import { createHash } from 'node:crypto'; import { ExtensionStorage } from './storage.js'; @@ -1768,7 +1768,11 @@ export class ExtensionManager { // See #6334. await fs.promises.rm(tempDir, { recursive: true, force: true }); await fs.promises.mkdir(tempDir, { recursive: true }); - await cloneFromGit(installMetadata, tempDir, signal); + installMetadata.gitCommit = await cloneFromGit( + installMetadata, + tempDir, + signal, + ); if (installMetadata.type === 'github-release') { installMetadata.type = 'git'; } @@ -1806,13 +1810,12 @@ export class ExtensionManager { signal?.throwIfAborted(); try { const sourceBeforeConversion = localSourcePath; - const { extensionDir, originSource } = - await convertGeminiOrClaudeExtension( - sourceBeforeConversion, - installMetadata.pluginName, - installMetadata.networkPolicy, - signal, - ); + const { extensionDir, originSource } = await convertCompatibleExtension( + sourceBeforeConversion, + installMetadata.pluginName, + installMetadata.networkPolicy, + signal, + ); signal?.throwIfAborted(); if (extensionDir !== sourceBeforeConversion) { @@ -1820,6 +1823,9 @@ export class ExtensionManager { } localSourcePath = extensionDir; installMetadata.originSource = originSource; + if (originSource !== 'Qoder') { + delete installMetadata.gitCommit; + } newExtensionConfig = this.loadExtensionConfig({ extensionDir: localSourcePath, diff --git a/packages/core/src/extension/github.test.ts b/packages/core/src/extension/github.test.ts index 8cdf5643c58..8e58a8c083b 100644 --- a/packages/core/src/extension/github.test.ts +++ b/packages/core/src/extension/github.test.ts @@ -36,6 +36,7 @@ import { } from './extensionManager.js'; import { getErrorMessage } from '../utils/errors.js'; import { EXTENSIONS_CONFIG_FILENAME } from './variables.js'; +import { QODER_PLUGIN_MANIFEST } from './qoder-converter.js'; import { ExtensionStorage } from './storage.js'; import { assertTarArchiveHasNoLinks } from './archive-safety.js'; @@ -147,6 +148,7 @@ describe('git extension helpers', () => { getRemotes: vi.fn(), fetch: vi.fn(), checkout: vi.fn(), + revparse: vi.fn(), version: vi.fn(), env: vi.fn(), }; @@ -155,6 +157,7 @@ describe('git extension helpers', () => { vi.mocked(simpleGit).mockReturnValue(mockGit as unknown as SimpleGit); mockGit.env.mockReturnValue(mockGit); mockGit.version.mockResolvedValue({ major: 2, minor: 52 }); + mockGit.revparse.mockResolvedValue('local-hash'); }); it('should clone, fetch and checkout a repo', async () => { @@ -170,7 +173,11 @@ describe('git extension helpers', () => { ]); const controller = new AbortController(); - await cloneFromGit(installMetadata, destination, controller.signal); + const commit = await cloneFromGit( + installMetadata, + destination, + controller.signal, + ); expect(simpleGit).toHaveBeenCalledWith(destination, { abort: controller.signal, @@ -187,6 +194,7 @@ describe('git extension helpers', () => { 'my-ref', ); expect(mockGit.checkout).toHaveBeenCalledWith('FETCH_HEAD'); + expect(commit).toBe('local-hash'); }); it('should use core.symlinks=false on Windows to avoid permission errors', async () => { @@ -561,6 +569,48 @@ describe('git extension helpers', () => { expect(result).toBe(ExtensionUpdateState.UPDATE_AVAILABLE); }); + it('checks a converted Qoder Git extension using its recorded commit', async () => { + const extension = createExtension({ + installMetadata: { + type: 'git', + source: 'https://github.com/example/sample-qoder-plugin', + originSource: 'Qoder', + gitCommit: 'local-hash', + }, + }); + mockGit.listRemote.mockResolvedValue('remote-hash\tHEAD'); + + const result = await checkForExtensionUpdate( + extension, + mockExtensionManager, + ); + + expect(result).toBe(ExtensionUpdateState.UPDATE_AVAILABLE); + expect(mockGit.getRemotes).not.toHaveBeenCalled(); + expect(mockGit.listRemote).toHaveBeenCalledWith([ + 'https://github.com/example/sample-qoder-plugin', + 'HEAD', + ]); + }); + + it('does not update-check legacy Qoder Git installs without a recorded commit', async () => { + const extension = createExtension({ + installMetadata: { + type: 'git', + source: 'https://github.com/example/sample-qoder-plugin', + originSource: 'Qoder', + }, + }); + + const result = await checkForExtensionUpdate( + extension, + mockExtensionManager, + ); + + expect(result).toBe(ExtensionUpdateState.NOT_UPDATABLE); + expect(mockGit.listRemote).not.toHaveBeenCalled(); + }); + it('pins public Git update checks and disables redirects and proxies', async () => { vi.spyOn(dns, 'lookup').mockResolvedValue([ { address: '8.8.8.8', family: 4 }, @@ -702,6 +752,44 @@ describe('git extension helpers', () => { expect(result).toBe(ExtensionUpdateState.UP_TO_DATE); }); + it('should convert a local Qoder plugin before checking for updates', async () => { + const tempDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'local-qoder-update-test-'), + ); + try { + await fs.mkdir(path.join(tempDir, '.qoder-plugin')); + await fs.writeFile( + path.join(tempDir, QODER_PLUGIN_MANIFEST), + JSON.stringify({ name: 'sample-qoder-plugin', version: '2.0.0' }), + ); + const extension = createExtension({ + version: '1.0.0', + installMetadata: { + type: 'local', + source: tempDir, + originSource: 'Qoder', + }, + }); + const mockManager = { + loadExtensionConfig: vi.fn( + ({ extensionDir }: { extensionDir: string }) => + JSON.parse( + fsSync.readFileSync( + path.join(extensionDir, EXTENSIONS_CONFIG_FILENAME), + 'utf-8', + ), + ), + ), + } as unknown as ExtensionManager; + + const result = await checkForExtensionUpdate(extension, mockManager); + + expect(result).toBe(ExtensionUpdateState.UPDATE_AVAILABLE); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + it('should return NOT_UPDATABLE for local extension when source cannot be loaded', async () => { const extension = createExtension({ version: '1.0.0', @@ -1755,6 +1843,30 @@ describe('git extension helpers', () => { ).resolves.toContain('tar-wrapped-extension'); }); + it('should extract and flatten a wrapped Qoder plugin archive', async () => { + const archivePath = path.join(tempDir, 'wrapped-qoder-plugin.zip'); + const archive = await createZipBuffer(tempDir, [ + { + name: `wrapped/${QODER_PLUGIN_MANIFEST}`, + content: JSON.stringify({ name: 'sample-qoder-plugin' }), + }, + { + name: 'wrapped/system-prompt.md', + content: '# System context', + }, + ]); + await fs.writeFile(archivePath, archive); + + await extractArchiveFile(archivePath, tempDir); + + await expect( + fs.readFile(path.join(tempDir, QODER_PLUGIN_MANIFEST), 'utf-8'), + ).resolves.toContain('sample-qoder-plugin'); + await expect( + fs.readFile(path.join(tempDir, 'system-prompt.md'), 'utf-8'), + ).resolves.toBe('# System context'); + }); + it('should flatten wrapped archives when the archive file is in the destination', async () => { const archivePath = path.join(tempDir, 'downloaded-extension.zip'); const archiveBuildDir = path.join(tempDir, 'archive-build'); diff --git a/packages/core/src/extension/github.ts b/packages/core/src/extension/github.ts index d9f23be5f66..9a51716a2e9 100644 --- a/packages/core/src/extension/github.ts +++ b/packages/core/src/extension/github.ts @@ -23,7 +23,7 @@ import type { ExtensionInstallMetadata } from '../config/config.js'; import { checkNpmUpdate } from './npm.js'; import { redactUrlCredentials } from './redaction.js'; import { - convertGeminiOrClaudeExtension, + convertCompatibleExtension, SUPPORTED_EXTENSION_MANIFESTS, } from './extension-converter.js'; import { assertTarArchiveHasNoLinks } from './archive-safety.js'; @@ -109,6 +109,25 @@ function getGitHubToken(): string | undefined { return process.env['GITHUB_TOKEN']; } +function addGitHubToken(source: string): string { + const token = getGitHubToken(); + if (!token) return source; + try { + const parsedUrl = new URL(source); + if ( + parsedUrl.protocol === 'https:' && + parsedUrl.hostname === 'github.com' && + !parsedUrl.username + ) { + parsedUrl.username = token; + return parsedUrl.toString(); + } + } catch { + return source; + } + return source; +} + async function assertPinnedGitSupported(): Promise { const { simpleGit } = await loadSimpleGit(); const version = await simpleGit().version(); @@ -165,7 +184,7 @@ export async function cloneFromGit( installMetadata: ExtensionInstallMetadata, destination: string, signal?: AbortSignal, -): Promise { +): Promise { const redactedSource = redactUrlCredentials(installMetadata.source); try { const { simpleGit } = await loadSimpleGit(); @@ -200,25 +219,7 @@ export async function cloneFromGit( installMetadata.networkPolicy, ); signal?.throwIfAborted(); - let sourceUrl = installMetadata.source; - const token = getGitHubToken(); - if (token) { - try { - const parsedUrl = new URL(sourceUrl); - if ( - parsedUrl.protocol === 'https:' && - parsedUrl.hostname === 'github.com' - ) { - if (!parsedUrl.username) { - parsedUrl.username = token; - } - sourceUrl = parsedUrl.toString(); - } - } catch { - // If source is not a valid URL, we don't inject the token. - // We let git handle the source as is. - } - } + const sourceUrl = addGitHubToken(installMetadata.source); // On Windows, symlinks require elevated privileges by default, so we // disable them to avoid "Permission denied" errors during checkout. const symlinkValue = os.platform() === 'win32' ? 'false' : 'true'; @@ -247,6 +248,7 @@ export async function cloneFromGit( // Detached HEAD is expected here — we only need the fetched content. await git.checkout('FETCH_HEAD'); signal?.throwIfAborted(); + return (await git.revparse(['HEAD'])).trim(); } catch (error) { if ( signal?.aborted && @@ -325,18 +327,20 @@ export async function checkForExtensionUpdate( signal?.throwIfAborted(); await extractArchiveFile(installMetadata.source, tempDir, signal); signal?.throwIfAborted(); - const converted = await convertGeminiOrClaudeExtension( - tempDir, - installMetadata.pluginName, - installMetadata.networkPolicy, - signal, - ); - extensionDir = converted.extensionDir; - if (extensionDir !== tempDir) { - convertedDir = extensionDir; - } - signal?.throwIfAborted(); + extensionDir = tempDir; } + const sourceBeforeConversion = extensionDir; + const converted = await convertCompatibleExtension( + sourceBeforeConversion, + installMetadata.pluginName, + installMetadata.networkPolicy, + signal, + ); + extensionDir = converted.extensionDir; + if (extensionDir !== sourceBeforeConversion) { + convertedDir = extensionDir; + } + signal?.throwIfAborted(); latestConfig = extensionManager.loadExtensionConfig({ extensionDir, }); @@ -377,7 +381,7 @@ export async function checkForExtensionUpdate( path.join(os.tmpdir(), 'extension-archive-update-'), ); await downloadFromArchiveUrl(installMetadata, tempDir, signal); - const converted = await convertGeminiOrClaudeExtension( + const converted = await convertCompatibleExtension( tempDir, installMetadata.pluginName, installMetadata.networkPolicy, @@ -429,22 +433,34 @@ export async function checkForExtensionUpdate( if (installMetadata.networkPolicy === 'public') { await assertPinnedGitSupported(); } - const localGit = simpleGit( - extension.path, - signal ? { abort: signal } : undefined, - ); - const remotes = await localGit.getRemotes(true); - signal?.throwIfAborted(); - if (remotes.length === 0) { - debugLogger.error('No git remotes found.'); - return ExtensionUpdateState.ERROR; - } - const remoteUrl = remotes[0].refs.fetch; - if (!remoteUrl) { - debugLogger.error( - `No fetch URL found for git remote ${remotes[0].name}.`, + let remoteUrl: string; + let localHash: string; + if (installMetadata.originSource === 'Qoder') { + if (!installMetadata.gitCommit) { + return ExtensionUpdateState.NOT_UPDATABLE; + } + remoteUrl = addGitHubToken(installMetadata.source); + localHash = installMetadata.gitCommit; + } else { + const localGit = simpleGit( + extension.path, + signal ? { abort: signal } : undefined, ); - return ExtensionUpdateState.ERROR; + const remotes = await localGit.getRemotes(true); + signal?.throwIfAborted(); + if (remotes.length === 0) { + debugLogger.error('No git remotes found.'); + return ExtensionUpdateState.ERROR; + } + const fetchedRemoteUrl = remotes[0].refs.fetch; + if (!fetchedRemoteUrl) { + debugLogger.error( + `No fetch URL found for git remote ${remotes[0].name}.`, + ); + return ExtensionUpdateState.ERROR; + } + remoteUrl = fetchedRemoteUrl; + localHash = await localGit.revparse(['HEAD']); } let networkConfig: string[] = []; if (installMetadata.networkPolicy === 'public') { @@ -487,7 +503,6 @@ export async function checkForExtensionUpdate( } const remoteHash = lsRemoteOutput.split('\t')[0]; - const localHash = await git.revparse(['HEAD']); signal?.throwIfAborted(); if (!remoteHash) { diff --git a/packages/core/src/extension/qoder-converter.test.ts b/packages/core/src/extension/qoder-converter.test.ts new file mode 100644 index 00000000000..18f3b740496 --- /dev/null +++ b/packages/core/src/extension/qoder-converter.test.ts @@ -0,0 +1,238 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { convertCompatibleExtension } from './extension-converter.js'; +import { + convertQoderPlugin, + QODER_PLUGIN_MANIFEST, +} from './qoder-converter.js'; + +describe('convertQoderPlugin', () => { + let root: string; + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'qoder-plugin-')); + fs.mkdirSync(path.join(root, '.qoder-plugin'), { recursive: true }); + }); + + afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + function writeManifest(config: Record): void { + fs.writeFileSync( + path.join(root, QODER_PLUGIN_MANIFEST), + JSON.stringify(config), + 'utf-8', + ); + } + + it('converts metadata, resources, MCP, and root context files', async () => { + writeManifest({ + name: 'sample-qoder-plugin', + version: '2.0.0', + displayName: 'Sample plugin', + description: 'A synthetic Qoder plugin', + }); + fs.writeFileSync(path.join(root, 'QWEN.md'), '# Qwen context', 'utf-8'); + fs.writeFileSync( + path.join(root, 'system-prompt.md'), + '# System context', + 'utf-8', + ); + fs.writeFileSync( + path.join(root, '.mcp.json'), + JSON.stringify({ + mcpServers: { + sample: { type: 'http', url: 'https://example.com/mcp' }, + }, + }), + 'utf-8', + ); + const skillDir = path.join(root, 'skills', 'sample-skill'); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync( + path.join(skillDir, 'SKILL.md'), + '---\nname: sample-skill\ndescription: Synthetic skill\n---\n', + 'utf-8', + ); + fs.mkdirSync(path.join(root, 'commands'), { recursive: true }); + fs.writeFileSync( + path.join(root, 'commands', 'sample.md'), + '# Sample command', + 'utf-8', + ); + fs.mkdirSync(path.join(root, 'agents'), { recursive: true }); + fs.writeFileSync( + path.join(root, 'agents', 'sample.md'), + '---\nname: sample\ndescription: Synthetic agent\n---\nPrompt', + 'utf-8', + ); + fs.writeFileSync( + path.join(root, 'NOTICE.txt'), + 'Synthetic resource', + 'utf-8', + ); + fs.mkdirSync(path.join(root, '.git'), { recursive: true }); + + const result = await convertQoderPlugin(root); + + expect(result.config).toMatchObject({ + name: 'sample-qoder-plugin', + version: '2.0.0', + displayName: 'Sample plugin', + description: 'A synthetic Qoder plugin', + contextFileName: ['QWEN.md', 'system-prompt.md'], + }); + expect(result.config.mcpServers?.['sample']).toMatchObject({ + httpUrl: 'https://example.com/mcp', + }); + expect( + fs.existsSync( + path.join(result.convertedDir, 'skills', 'sample-skill', 'SKILL.md'), + ), + ).toBe(true); + expect( + fs.existsSync(path.join(result.convertedDir, 'commands', 'sample.md')), + ).toBe(true); + expect( + fs.existsSync(path.join(result.convertedDir, 'agents', 'sample.md')), + ).toBe(true); + expect(fs.existsSync(path.join(result.convertedDir, 'NOTICE.txt'))).toBe( + true, + ); + expect(fs.existsSync(path.join(result.convertedDir, '.git'))).toBe(false); + + fs.rmSync(result.convertedDir, { recursive: true, force: true }); + }); + + it('defaults the version and reports Qoder as the origin', async () => { + writeManifest({ name: 'sample-qoder-plugin' }); + fs.writeFileSync( + path.join(root, 'system-prompt.md'), + '# System context', + 'utf-8', + ); + + const result = await convertCompatibleExtension(root); + + expect(result.originSource).toBe('Qoder'); + const converted = JSON.parse( + fs.readFileSync( + path.join(result.extensionDir, 'qwen-extension.json'), + 'utf-8', + ), + ) as Record; + expect(converted['version']).toBe('1.0.0'); + expect(converted['contextFileName']).toEqual(['system-prompt.md']); + + fs.rmSync(result.extensionDir, { recursive: true, force: true }); + }); + + it('merges explicit context with system-prompt.md without duplicates', async () => { + writeManifest({ + name: 'sample-qoder-plugin', + contextFileName: ['custom.md', 'custom.md', 'system-prompt.md'], + }); + fs.writeFileSync(path.join(root, 'custom.md'), '# Custom', 'utf-8'); + fs.writeFileSync( + path.join(root, 'system-prompt.md'), + '# System context', + 'utf-8', + ); + + const result = await convertQoderPlugin(root); + + expect(result.config.contextFileName).toEqual([ + 'custom.md', + 'system-prompt.md', + ]); + fs.rmSync(result.convertedDir, { recursive: true, force: true }); + }); + + it('loads QWEN.md with system-prompt.md when context is not configured', async () => { + writeManifest({ name: 'sample-qoder-plugin', contextFileName: [] }); + fs.writeFileSync(path.join(root, 'QWEN.md'), '# Qwen context', 'utf-8'); + fs.writeFileSync( + path.join(root, 'system-prompt.md'), + '# System context', + 'utf-8', + ); + + const result = await convertQoderPlugin(root); + + expect(result.config.contextFileName).toEqual([ + 'QWEN.md', + 'system-prompt.md', + ]); + fs.rmSync(result.convertedDir, { recursive: true, force: true }); + }); + + it('rejects invalid manifests and escaping manifest symlinks', async () => { + fs.writeFileSync(path.join(root, QODER_PLUGIN_MANIFEST), 'null', 'utf-8'); + await expect(convertQoderPlugin(root)).rejects.toThrow( + /expected a JSON object/, + ); + + const external = fs.mkdtempSync(path.join(os.tmpdir(), 'qoder-external-')); + const externalManifest = path.join(external, 'plugin.json'); + fs.writeFileSync( + externalManifest, + JSON.stringify({ name: 'external-plugin' }), + 'utf-8', + ); + fs.rmSync(path.join(root, QODER_PLUGIN_MANIFEST)); + fs.symlinkSync(externalManifest, path.join(root, QODER_PLUGIN_MANIFEST)); + + await expect(convertQoderPlugin(root)).rejects.toThrow( + /resolves through a symlink outside/, + ); + fs.rmSync(external, { recursive: true, force: true }); + }); + + it('does not copy escaping symlinks or load unsafe context paths', async () => { + const external = fs.mkdtempSync(path.join(os.tmpdir(), 'qoder-external-')); + const externalFile = path.join(external, 'private.txt'); + fs.writeFileSync(externalFile, 'private', 'utf-8'); + writeManifest({ + name: 'sample-qoder-plugin', + contextFileName: '../private.txt', + }); + fs.mkdirSync(path.join(root, 'skills'), { recursive: true }); + fs.symlinkSync(externalFile, path.join(root, 'skills', 'leak.txt')); + + const result = await convertQoderPlugin(root); + + expect(result.config.contextFileName).toBeUndefined(); + expect( + fs.existsSync(path.join(result.convertedDir, 'skills', 'leak.txt')), + ).toBe(false); + + fs.rmSync(result.convertedDir, { recursive: true, force: true }); + fs.rmSync(external, { recursive: true, force: true }); + }); + + it('does not load an escaping default QWEN.md symlink', async () => { + const external = fs.mkdtempSync(path.join(os.tmpdir(), 'qoder-external-')); + const externalFile = path.join(external, 'QWEN.md'); + fs.writeFileSync(externalFile, 'External context', 'utf-8'); + writeManifest({ name: 'sample-qoder-plugin' }); + fs.symlinkSync(externalFile, path.join(root, 'QWEN.md')); + + const result = await convertQoderPlugin(root); + + expect(result.config.contextFileName).toBeUndefined(); + expect(fs.existsSync(path.join(result.convertedDir, 'QWEN.md'))).toBe( + false, + ); + fs.rmSync(result.convertedDir, { recursive: true, force: true }); + fs.rmSync(external, { recursive: true, force: true }); + }); +}); diff --git a/packages/core/src/extension/qoder-converter.ts b/packages/core/src/extension/qoder-converter.ts new file mode 100644 index 00000000000..69f7ab105aa --- /dev/null +++ b/packages/core/src/extension/qoder-converter.ts @@ -0,0 +1,163 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import type { MCPServerConfig } from '../config/config.js'; +import type { ExtensionConfig } from './extensionManager.js'; +import { + buildQwenExtensionFromPlugin, + normalizeClaudeMcpServer, + type ClaudePluginConfig, +} from './claude-converter.js'; +import { isPathWithin, realPathWithin } from './gemini-converter.js'; +import { createDebugLogger } from '../utils/debugLogger.js'; +import { EXTENSIONS_CONFIG_FILENAME } from './variables.js'; + +export const QODER_PLUGIN_MANIFEST = '.qoder-plugin/plugin.json'; +const debugLogger = createDebugLogger('QODER_CONVERTER'); + +type QoderPluginConfig = Omit & { + version?: string; + displayName?: string; + contextFileName?: string | string[]; +}; + +function loadQoderConfig(extensionDir: string): QoderPluginConfig { + const configPath = path.join(extensionDir, QODER_PLUGIN_MANIFEST); + if (!fs.existsSync(configPath)) { + throw new Error(`Qoder plugin configuration not found at ${configPath}`); + } + if (!realPathWithin(configPath, extensionDir)) { + throw new Error( + `Qoder plugin configuration at ${configPath} resolves through a symlink outside the plugin`, + ); + } + + const parsed: unknown = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new Error( + `Invalid Qoder plugin configuration at ${configPath}: expected a JSON object`, + ); + } + + const config = parsed as QoderPluginConfig; + if (typeof config.name !== 'string' || config.name.length === 0) { + throw new Error('Qoder plugin config must have name field'); + } + return { + ...config, + version: + typeof config.version === 'string' && config.version.length > 0 + ? config.version + : '1.0.0', + }; +} + +function loadRootMcpServers( + extensionDir: string, +): Record | undefined { + const mcpPath = path.join(extensionDir, '.mcp.json'); + if (!fs.existsSync(mcpPath) || !realPathWithin(mcpPath, extensionDir)) { + return undefined; + } + + let parsed: unknown; + try { + parsed = JSON.parse(fs.readFileSync(mcpPath, 'utf-8')); + } catch (error) { + debugLogger.warn( + `Failed to parse .mcp.json at ${mcpPath}: ${error instanceof Error ? error.message : String(error)}`, + ); + return undefined; + } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + return undefined; + } + const servers = (parsed as { mcpServers?: unknown }).mcpServers; + if ( + typeof servers !== 'object' || + servers === null || + Array.isArray(servers) + ) { + return undefined; + } + + return Object.fromEntries( + Object.entries(servers).map(([name, server]) => [ + name, + normalizeClaudeMcpServer(server as MCPServerConfig), + ]), + ); +} + +function resolveContextFiles( + extensionDir: string, + configured: string | string[] | undefined, +): string[] | undefined { + const configuredFiles = configured + ? Array.isArray(configured) + ? configured + : [configured] + : []; + const hasConfiguredFiles = configuredFiles.length > 0; + const root = path.resolve(extensionDir); + const contextFiles = hasConfiguredFiles + ? [ + ...new Set( + configuredFiles.filter((file) => { + if (typeof file !== 'string' || path.isAbsolute(file)) return false; + const resolved = path.resolve(extensionDir, file); + return ( + isPathWithin(resolved, root) && + fs.existsSync(resolved) && + realPathWithin(resolved, extensionDir) + ); + }), + ), + ] + : fs.existsSync(path.join(extensionDir, 'QWEN.md')) && + realPathWithin(path.join(extensionDir, 'QWEN.md'), extensionDir) + ? ['QWEN.md'] + : []; + const systemPromptPath = path.join(extensionDir, 'system-prompt.md'); + if ( + fs.existsSync(systemPromptPath) && + realPathWithin(systemPromptPath, extensionDir) && + !contextFiles.includes('system-prompt.md') + ) { + contextFiles.push('system-prompt.md'); + } + return contextFiles.length > 0 ? contextFiles : undefined; +} + +export async function convertQoderPlugin( + extensionDir: string, +): Promise<{ config: ExtensionConfig; convertedDir: string }> { + const config = loadQoderConfig(extensionDir); + if (!config.mcpServers) { + config.mcpServers = loadRootMcpServers(extensionDir); + } + const contextFileName = resolveContextFiles( + extensionDir, + config.contextFileName, + ); + const converted = await buildQwenExtensionFromPlugin( + extensionDir, + config as ClaudePluginConfig, + ); + const qwenConfig: ExtensionConfig = { + ...converted.config, + displayName: config.displayName, + contextFileName, + }; + fs.writeFileSync( + path.join(converted.convertedDir, EXTENSIONS_CONFIG_FILENAME), + JSON.stringify(qwenConfig, null, 2), + 'utf-8', + ); + return { ...converted, config: qwenConfig }; +} diff --git a/packages/sdk-typescript/src/daemon/types.ts b/packages/sdk-typescript/src/daemon/types.ts index d2ec3803580..721dee1bc3d 100644 --- a/packages/sdk-typescript/src/daemon/types.ts +++ b/packages/sdk-typescript/src/daemon/types.ts @@ -3736,7 +3736,11 @@ export type DaemonExtensionInstallType = | 'github-release' | 'npm'; -export type DaemonExtensionOriginSource = 'QwenCode' | 'Claude' | 'Gemini'; +export type DaemonExtensionOriginSource = + | 'QwenCode' + | 'Claude' + | 'Gemini' + | 'Qoder'; export interface DaemonExtensionCapabilities { mcpServerCount: number; From ddd2cdc0ddf8d977204c51828f6a8737baa2d1e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=B6=E5=85=AC?= Date: Fri, 7 Aug 2026 16:17:41 +0800 Subject: [PATCH 2/8] fix(core): address Qoder extension review feedback --- docs/design/qoder-plugin-compatibility.md | 2 +- packages/core/src/config/config.ts | 2 +- .../core/src/extension/claude-converter.ts | 2 +- .../core/src/extension/extension-converter.ts | 6 +- .../src/extension/extensionManager.test.ts | 49 +++++++++ .../core/src/extension/extensionManager.ts | 12 +-- packages/core/src/extension/github.test.ts | 78 ++++++++++---- packages/core/src/extension/github.ts | 34 +++--- .../src/extension/qoder-converter.test.ts | 58 +++++++++- .../core/src/extension/qoder-converter.ts | 101 ++++++++++-------- 10 files changed, 251 insertions(+), 93 deletions(-) diff --git a/docs/design/qoder-plugin-compatibility.md b/docs/design/qoder-plugin-compatibility.md index 03994d808a0..a09fa0152db 100644 --- a/docs/design/qoder-plugin-compatibility.md +++ b/docs/design/qoder-plugin-compatibility.md @@ -6,7 +6,7 @@ Qwen Code installs extensions from directories, archives, Git repositories, arch ## Design -The existing compatible-extension conversion step recognizes the Qoder manifest after native Qwen, Gemini, and Claude manifests. It copies the plugin into a temporary extension directory, writes a generated `qwen-extension.json`, and records `Qoder` as the install origin. Converted Git installs record the checked-out commit in install metadata so update checks remain available after Git metadata is removed. +The existing compatible-extension conversion step recognizes the Qoder manifest alongside native Qwen, Gemini, and Claude manifests. It copies the plugin into a temporary extension directory, writes a generated `qwen-extension.json`, and records `Qoder` as the install origin. Converted Git installs record the checked-out commit in install metadata so update checks remain available after Git metadata is removed. The generated manifest preserves `name`, `version`, `displayName`, and `description`. A missing version defaults to `1.0.0`. Standard resource directories remain in place, while existing resource path declarations use the same confined collection logic as other compatible plugin formats. Root `.mcp.json` servers are normalized to Qwen transports unless the manifest already defines MCP servers. diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 4a2a1aa9221..9c6f06a92ec 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -680,7 +680,7 @@ export interface ExtensionInstallMetadata { type: 'git' | 'local' | 'link' | 'github-release' | 'npm' | 'archive-url'; originSource?: ExtensionOriginSource; releaseTag?: string; // Only present for github-release and npm installs. - gitCommit?: string; // Only present when a converted Git install cannot retain .git. + gitCommit?: string; // Commit recorded when the installation source was cloned. registryUrl?: string; // Only present for npm installs. ref?: string; autoUpdate?: boolean; diff --git a/packages/core/src/extension/claude-converter.ts b/packages/core/src/extension/claude-converter.ts index 384b433a340..9d7278f0392 100644 --- a/packages/core/src/extension/claude-converter.ts +++ b/packages/core/src/extension/claude-converter.ts @@ -554,7 +554,7 @@ export async function convertClaudePluginPackage( * could otherwise make the converter read sensitive files outside the plugin. * Returns the confined absolute path, or null when the reference is unsafe. */ -function resolvePluginRelativeFile( +export function resolvePluginRelativeFile( pluginSource: string, relativePath: string, ): string | null { diff --git a/packages/core/src/extension/extension-converter.ts b/packages/core/src/extension/extension-converter.ts index 900990fb425..be9212057bb 100644 --- a/packages/core/src/extension/extension-converter.ts +++ b/packages/core/src/extension/extension-converter.ts @@ -51,6 +51,9 @@ export async function convertCompatibleExtension( newExtensionDir = (await convertGeminiExtensionPackage(extensionDir)) .convertedDir; originSource = 'Gemini'; + } else if (fs.existsSync(path.join(extensionDir, QODER_PLUGIN_MANIFEST))) { + newExtensionDir = (await convertQoderPlugin(extensionDir)).convertedDir; + originSource = 'Qoder'; } else if (pluginName) { newExtensionDir = ( await convertClaudePluginPackage( @@ -67,9 +70,6 @@ export async function convertCompatibleExtension( newExtensionDir = (await convertClaudePluginStandalone(extensionDir)) .convertedDir; originSource = 'Claude'; - } else if (fs.existsSync(path.join(extensionDir, QODER_PLUGIN_MANIFEST))) { - newExtensionDir = (await convertQoderPlugin(extensionDir)).convertedDir; - originSource = 'Qoder'; } signal?.throwIfAborted(); return { extensionDir: newExtensionDir, originSource }; diff --git a/packages/core/src/extension/extensionManager.test.ts b/packages/core/src/extension/extensionManager.test.ts index d3477b130d2..5d6ff90b287 100644 --- a/packages/core/src/extension/extensionManager.test.ts +++ b/packages/core/src/extension/extensionManager.test.ts @@ -720,6 +720,15 @@ describe('extension tests', () => { path.join(skillPath, 'SKILL.md'), '---\nname: sample-skill\ndescription: Synthetic skill\n---\n', ); + const commandsPath = path.join(sourcePath, 'commands'); + fs.mkdirSync(commandsPath, { recursive: true }); + fs.writeFileSync( + path.join(commandsPath, 'sample.md'), + '# Command\n${CLAUDE_PLUGIN_ROOT}/scripts/run.sh', + ); + const hooksPath = path.join(sourcePath, 'hooks'); + fs.mkdirSync(hooksPath, { recursive: true }); + fs.writeFileSync(path.join(hooksPath, 'hooks.json'), '{}'); const requestConsent = vi.fn(async () => {}); const manager = createExtensionManager(); await manager.refreshCache(); @@ -740,6 +749,12 @@ describe('extension tests', () => { expect(extension.skills?.map((skill) => skill.name)).toEqual([ 'sample-skill', ]); + expect( + fs.readFileSync( + path.join(extension.path, 'commands', 'sample.md'), + 'utf-8', + ), + ).toContain(`${extension.path}/scripts/run.sh`); expect(requestConsent).toHaveBeenCalledWith( expect.objectContaining({ originSource: 'Qoder' }), ); @@ -830,6 +845,40 @@ describe('extension tests', () => { expect(extension.installMetadata?.gitCommit).toBe('sample-commit'); }); + it('should retain the recorded commit for a converted Claude Git plugin', async () => { + mockGit.clone.mockImplementation(async () => { + const sourcePath = mockGit.path(); + fs.mkdirSync(path.join(sourcePath, '.claude-plugin'), { + recursive: true, + }); + fs.writeFileSync( + path.join(sourcePath, '.claude-plugin', 'plugin.json'), + JSON.stringify({ name: 'sample-claude-plugin', version: '1.0.0' }), + ); + }); + mockGit.getRemotes.mockResolvedValue([ + { + name: 'origin', + refs: { fetch: 'https://github.com/example/sample-claude-plugin' }, + }, + ]); + mockGit.fetch.mockResolvedValue(undefined); + mockGit.checkout.mockResolvedValue(undefined); + const manager = createExtensionManager(); + await manager.refreshCache(); + + const extension = await manager.installExtension( + { + type: 'git', + source: 'https://github.com/example/sample-claude-plugin', + }, + async () => {}, + ); + + expect(extension.installMetadata?.originSource).toBe('Claude'); + expect(extension.installMetadata?.gitCommit).toBe('sample-commit'); + }); + it('should emit mutation lifecycle events around install', async () => { const archivePath = path.join(tempWorkspaceDir, 'local-extension.zip'); fs.writeFileSync(archivePath, 'not used by mocked extractor'); diff --git a/packages/core/src/extension/extensionManager.ts b/packages/core/src/extension/extensionManager.ts index 4c7d8eed5b8..9f91d5ed671 100644 --- a/packages/core/src/extension/extensionManager.ts +++ b/packages/core/src/extension/extensionManager.ts @@ -1823,9 +1823,6 @@ export class ExtensionManager { } localSourcePath = extensionDir; installMetadata.originSource = originSource; - if (originSource !== 'Qoder') { - delete installMetadata.gitCommit; - } newExtensionConfig = this.loadExtensionConfig({ extensionDir: localSourcePath, @@ -1956,11 +1953,12 @@ export class ExtensionManager { : path.join(stagingPath, newExtensionConfig.hooks) : null; + const usesPluginVariables = + originSource === 'Claude' || originSource === 'Qoder'; if ( - (originSource === 'Claude' && fs.existsSync(hooksDir)) || - (originSource === 'Claude' && - configHooksPath && - fs.existsSync(configHooksPath)) + usesPluginVariables && + (fs.existsSync(hooksDir) || + (configHooksPath && fs.existsSync(configHooksPath))) ) { try { await performVariableReplacement(stagingPath, destinationPath); diff --git a/packages/core/src/extension/github.test.ts b/packages/core/src/extension/github.test.ts index 8e58a8c083b..b1c2343ea17 100644 --- a/packages/core/src/extension/github.test.ts +++ b/packages/core/src/extension/github.test.ts @@ -569,29 +569,32 @@ describe('git extension helpers', () => { expect(result).toBe(ExtensionUpdateState.UPDATE_AVAILABLE); }); - it('checks a converted Qoder Git extension using its recorded commit', async () => { - const extension = createExtension({ - installMetadata: { - type: 'git', - source: 'https://github.com/example/sample-qoder-plugin', - originSource: 'Qoder', - gitCommit: 'local-hash', - }, - }); - mockGit.listRemote.mockResolvedValue('remote-hash\tHEAD'); + it.each(['Qoder', 'Claude'] as const)( + 'checks a converted %s Git extension using its recorded commit', + async (originSource) => { + const extension = createExtension({ + installMetadata: { + type: 'git', + source: 'https://github.com/example/sample-qoder-plugin', + originSource, + gitCommit: 'local-hash', + }, + }); + mockGit.listRemote.mockResolvedValue('remote-hash\tHEAD'); - const result = await checkForExtensionUpdate( - extension, - mockExtensionManager, - ); + const result = await checkForExtensionUpdate( + extension, + mockExtensionManager, + ); - expect(result).toBe(ExtensionUpdateState.UPDATE_AVAILABLE); - expect(mockGit.getRemotes).not.toHaveBeenCalled(); - expect(mockGit.listRemote).toHaveBeenCalledWith([ - 'https://github.com/example/sample-qoder-plugin', - 'HEAD', - ]); - }); + expect(result).toBe(ExtensionUpdateState.UPDATE_AVAILABLE); + expect(mockGit.getRemotes).not.toHaveBeenCalled(); + expect(mockGit.listRemote).toHaveBeenCalledWith([ + 'https://github.com/example/sample-qoder-plugin', + 'HEAD', + ]); + }, + ); it('does not update-check legacy Qoder Git installs without a recorded commit', async () => { const extension = createExtension({ @@ -790,6 +793,39 @@ describe('git extension helpers', () => { } }); + it('does not convert a local marketplace checkout during update checks', async () => { + const tempDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'local-marketplace-update-test-'), + ); + try { + const extension = createExtension({ + version: '1.0.0', + installMetadata: { + type: 'local', + source: tempDir, + originSource: 'Claude', + pluginName: 'sample-plugin', + }, + }); + const mockManager = { + loadExtensionConfig: vi.fn().mockReturnValue({ + name: 'sample-plugin', + version: '1.0.0', + }), + } as unknown as ExtensionManager; + + const result = await checkForExtensionUpdate(extension, mockManager); + + expect(result).toBe(ExtensionUpdateState.UP_TO_DATE); + expect(mockManager.loadExtensionConfig).toHaveBeenCalledWith({ + extensionDir: tempDir, + }); + expect(await fs.readdir(tempDir)).toEqual([]); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + it('should return NOT_UPDATABLE for local extension when source cannot be loaded', async () => { const extension = createExtension({ version: '1.0.0', diff --git a/packages/core/src/extension/github.ts b/packages/core/src/extension/github.ts index 9a51716a2e9..83518b163c2 100644 --- a/packages/core/src/extension/github.ts +++ b/packages/core/src/extension/github.ts @@ -329,16 +329,18 @@ export async function checkForExtensionUpdate( signal?.throwIfAborted(); extensionDir = tempDir; } - const sourceBeforeConversion = extensionDir; - const converted = await convertCompatibleExtension( - sourceBeforeConversion, - installMetadata.pluginName, - installMetadata.networkPolicy, - signal, - ); - extensionDir = converted.extensionDir; - if (extensionDir !== sourceBeforeConversion) { - convertedDir = extensionDir; + if (tempDir !== undefined || installMetadata.originSource === 'Qoder') { + const sourceBeforeConversion = extensionDir; + const converted = await convertCompatibleExtension( + sourceBeforeConversion, + installMetadata.pluginName, + installMetadata.networkPolicy, + signal, + ); + extensionDir = converted.extensionDir; + if (extensionDir !== sourceBeforeConversion) { + convertedDir = extensionDir; + } } signal?.throwIfAborted(); latestConfig = extensionManager.loadExtensionConfig({ @@ -421,7 +423,6 @@ export async function checkForExtensionUpdate( } if ( !installMetadata || - installMetadata.originSource === 'Claude' || (installMetadata.type !== 'git' && installMetadata.type !== 'github-release') ) { @@ -435,13 +436,16 @@ export async function checkForExtensionUpdate( } let remoteUrl: string; let localHash: string; - if (installMetadata.originSource === 'Qoder') { - if (!installMetadata.gitCommit) { - return ExtensionUpdateState.NOT_UPDATABLE; - } + if (installMetadata.gitCommit) { remoteUrl = addGitHubToken(installMetadata.source); localHash = installMetadata.gitCommit; } else { + if ( + installMetadata.originSource === 'Claude' || + installMetadata.originSource === 'Qoder' + ) { + return ExtensionUpdateState.NOT_UPDATABLE; + } const localGit = simpleGit( extension.path, signal ? { abort: signal } : undefined, diff --git a/packages/core/src/extension/qoder-converter.test.ts b/packages/core/src/extension/qoder-converter.test.ts index 18f3b740496..adac019dcdf 100644 --- a/packages/core/src/extension/qoder-converter.test.ts +++ b/packages/core/src/extension/qoder-converter.test.ts @@ -121,7 +121,10 @@ describe('convertQoderPlugin', () => { 'utf-8', ); - const result = await convertCompatibleExtension(root); + const result = await convertCompatibleExtension( + root, + 'ignored-plugin-name', + ); expect(result.originSource).toBe('Qoder'); const converted = JSON.parse( @@ -139,8 +142,9 @@ describe('convertQoderPlugin', () => { it('merges explicit context with system-prompt.md without duplicates', async () => { writeManifest({ name: 'sample-qoder-plugin', - contextFileName: ['custom.md', 'custom.md', 'system-prompt.md'], + contextFileName: ['custom.md', 'custom.md', './system-prompt.md'], }); + fs.writeFileSync(path.join(root, 'QWEN.md'), '# Qwen context', 'utf-8'); fs.writeFileSync(path.join(root, 'custom.md'), '# Custom', 'utf-8'); fs.writeFileSync( path.join(root, 'system-prompt.md'), @@ -151,12 +155,62 @@ describe('convertQoderPlugin', () => { const result = await convertQoderPlugin(root); expect(result.config.contextFileName).toEqual([ + 'QWEN.md', 'custom.md', 'system-prompt.md', ]); fs.rmSync(result.convertedDir, { recursive: true, force: true }); }); + it('loads path-valued MCP config from the standard wrapper', async () => { + writeManifest({ + name: 'sample-qoder-plugin', + mcpServers: '.mcp.json', + }); + fs.writeFileSync( + path.join(root, '.mcp.json'), + JSON.stringify({ + mcpServers: { + sample: { type: 'http', url: 'https://example.com/mcp' }, + }, + }), + 'utf-8', + ); + + const result = await convertQoderPlugin(root); + + expect(Object.keys(result.config.mcpServers ?? {})).toEqual(['sample']); + expect(result.config.mcpServers?.['sample']).toMatchObject({ + httpUrl: 'https://example.com/mcp', + }); + fs.rmSync(result.convertedDir, { recursive: true, force: true }); + }); + + it('rejects malformed root MCP config', async () => { + writeManifest({ name: 'sample-qoder-plugin' }); + fs.writeFileSync(path.join(root, '.mcp.json'), '{', 'utf-8'); + + await expect(convertQoderPlugin(root)).rejects.toThrow( + /Invalid Qoder MCP configuration/, + ); + }); + + it('rejects an invalid MCP wrapper from a configured path', async () => { + writeManifest({ + name: 'sample-qoder-plugin', + mcpServers: '.mcp.json', + }); + fs.writeFileSync( + path.join(root, '.mcp.json'), + JSON.stringify({ mcpServers: null }), + 'utf-8', + ); + + await expect(convertQoderPlugin(root)).rejects.toThrow( + /expected an "mcpServers" object/, + ); + }); + it('loads QWEN.md with system-prompt.md when context is not configured', async () => { writeManifest({ name: 'sample-qoder-plugin', contextFileName: [] }); fs.writeFileSync(path.join(root, 'QWEN.md'), '# Qwen context', 'utf-8'); diff --git a/packages/core/src/extension/qoder-converter.ts b/packages/core/src/extension/qoder-converter.ts index 69f7ab105aa..e256e64883b 100644 --- a/packages/core/src/extension/qoder-converter.ts +++ b/packages/core/src/extension/qoder-converter.ts @@ -11,14 +11,13 @@ import type { ExtensionConfig } from './extensionManager.js'; import { buildQwenExtensionFromPlugin, normalizeClaudeMcpServer, + resolvePluginRelativeFile, type ClaudePluginConfig, } from './claude-converter.js'; -import { isPathWithin, realPathWithin } from './gemini-converter.js'; -import { createDebugLogger } from '../utils/debugLogger.js'; +import { realPathWithin } from './gemini-converter.js'; import { EXTENSIONS_CONFIG_FILENAME } from './variables.js'; export const QODER_PLUGIN_MANIFEST = '.qoder-plugin/plugin.json'; -const debugLogger = createDebugLogger('QODER_CONVERTER'); type QoderPluginConfig = Omit & { version?: string; @@ -57,11 +56,13 @@ function loadQoderConfig(extensionDir: string): QoderPluginConfig { }; } -function loadRootMcpServers( +function loadMcpServersFile( extensionDir: string, + relativePath: string, + requireWrapper: boolean, ): Record | undefined { - const mcpPath = path.join(extensionDir, '.mcp.json'); - if (!fs.existsSync(mcpPath) || !realPathWithin(mcpPath, extensionDir)) { + const mcpPath = resolvePluginRelativeFile(extensionDir, relativePath); + if (!mcpPath || !fs.existsSync(mcpPath)) { return undefined; } @@ -69,21 +70,29 @@ function loadRootMcpServers( try { parsed = JSON.parse(fs.readFileSync(mcpPath, 'utf-8')); } catch (error) { - debugLogger.warn( - `Failed to parse .mcp.json at ${mcpPath}: ${error instanceof Error ? error.message : String(error)}`, + throw new Error( + `Invalid Qoder MCP configuration at ${mcpPath}: ${error instanceof Error ? error.message : String(error)}`, ); - return undefined; } if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { - return undefined; + throw new Error( + `Invalid Qoder MCP configuration at ${mcpPath}: expected a JSON object`, + ); } - const servers = (parsed as { mcpServers?: unknown }).mcpServers; + const hasWrapper = Object.prototype.hasOwnProperty.call(parsed, 'mcpServers'); + const servers = hasWrapper + ? (parsed as { mcpServers?: unknown }).mcpServers + : requireWrapper + ? undefined + : parsed; if ( typeof servers !== 'object' || servers === null || Array.isArray(servers) ) { - return undefined; + throw new Error( + `Invalid Qoder MCP configuration at ${mcpPath}: expected an "mcpServers" object`, + ); } return Object.fromEntries( @@ -94,6 +103,19 @@ function loadRootMcpServers( ); } +function resolveMcpServers( + extensionDir: string, + configured: QoderPluginConfig['mcpServers'], +): Record | undefined { + if (typeof configured === 'string') { + return loadMcpServersFile(extensionDir, configured, false); + } + if (configured) { + return configured; + } + return loadMcpServersFile(extensionDir, '.mcp.json', true); +} + function resolveContextFiles( extensionDir: string, configured: string | string[] | undefined, @@ -103,33 +125,30 @@ function resolveContextFiles( ? configured : [configured] : []; - const hasConfiguredFiles = configuredFiles.length > 0; - const root = path.resolve(extensionDir); - const contextFiles = hasConfiguredFiles - ? [ - ...new Set( - configuredFiles.filter((file) => { - if (typeof file !== 'string' || path.isAbsolute(file)) return false; - const resolved = path.resolve(extensionDir, file); - return ( - isPathWithin(resolved, root) && - fs.existsSync(resolved) && - realPathWithin(resolved, extensionDir) - ); - }), - ), - ] - : fs.existsSync(path.join(extensionDir, 'QWEN.md')) && - realPathWithin(path.join(extensionDir, 'QWEN.md'), extensionDir) - ? ['QWEN.md'] - : []; - const systemPromptPath = path.join(extensionDir, 'system-prompt.md'); - if ( - fs.existsSync(systemPromptPath) && - realPathWithin(systemPromptPath, extensionDir) && - !contextFiles.includes('system-prompt.md') - ) { - contextFiles.push('system-prompt.md'); + const contextFiles: string[] = []; + const seen = new Set(); + const addContextFile = (relativePath: string): void => { + const resolved = resolvePluginRelativeFile(extensionDir, relativePath); + if (!resolved || !fs.existsSync(resolved)) return; + const normalized = path.relative(path.resolve(extensionDir), resolved); + if (normalized && !seen.has(normalized)) { + seen.add(normalized); + contextFiles.push(normalized); + } + }; + + for (const file of configuredFiles) { + if (typeof file === 'string') addContextFile(file); + } + addContextFile('system-prompt.md'); + if (contextFiles.length > 0) { + const qwenPath = resolvePluginRelativeFile(extensionDir, 'QWEN.md'); + if (qwenPath && fs.existsSync(qwenPath)) { + const normalized = path.relative(path.resolve(extensionDir), qwenPath); + if (normalized && !seen.has(normalized)) { + contextFiles.unshift(normalized); + } + } } return contextFiles.length > 0 ? contextFiles : undefined; } @@ -138,9 +157,7 @@ export async function convertQoderPlugin( extensionDir: string, ): Promise<{ config: ExtensionConfig; convertedDir: string }> { const config = loadQoderConfig(extensionDir); - if (!config.mcpServers) { - config.mcpServers = loadRootMcpServers(extensionDir); - } + config.mcpServers = resolveMcpServers(extensionDir, config.mcpServers); const contextFileName = resolveContextFiles( extensionDir, config.contextFileName, From a193b2208a7cb23c80e1e4f2a89d49fc5c5576cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=B6=E5=85=AC?= Date: Fri, 7 Aug 2026 16:24:39 +0800 Subject: [PATCH 3/8] fix(core): handle annotated tags and unsafe parse errors --- packages/core/src/extension/github.test.ts | 27 ++++++++++++++++++ packages/core/src/extension/github.ts | 12 ++++++-- .../src/extension/qoder-converter.test.ts | 28 +++++++++++++++++-- .../core/src/extension/qoder-converter.ts | 16 +++++++++-- 4 files changed, 76 insertions(+), 7 deletions(-) diff --git a/packages/core/src/extension/github.test.ts b/packages/core/src/extension/github.test.ts index b1c2343ea17..f7b80e52f89 100644 --- a/packages/core/src/extension/github.test.ts +++ b/packages/core/src/extension/github.test.ts @@ -596,6 +596,33 @@ describe('git extension helpers', () => { }, ); + it('uses the peeled commit when checking a recorded annotated tag', async () => { + const extension = createExtension({ + installMetadata: { + type: 'git', + source: 'https://github.com/example/sample-qoder-plugin', + originSource: 'Qoder', + gitCommit: 'local-hash', + ref: 'v1.0.0', + }, + }); + mockGit.listRemote.mockResolvedValue( + 'tag-hash\trefs/tags/v1.0.0\nlocal-hash\trefs/tags/v1.0.0^{}', + ); + + const result = await checkForExtensionUpdate( + extension, + mockExtensionManager, + ); + + expect(result).toBe(ExtensionUpdateState.UP_TO_DATE); + expect(mockGit.listRemote).toHaveBeenCalledWith([ + 'https://github.com/example/sample-qoder-plugin', + 'v1.0.0', + 'v1.0.0^{}', + ]); + }); + it('does not update-check legacy Qoder Git installs without a recorded commit', async () => { const extension = createExtension({ installMetadata: { diff --git a/packages/core/src/extension/github.ts b/packages/core/src/extension/github.ts index 83518b163c2..4e2a4a9395d 100644 --- a/packages/core/src/extension/github.ts +++ b/packages/core/src/extension/github.ts @@ -497,8 +497,11 @@ export async function checkForExtensionUpdate( installMetadata.networkPolicy, ); const refToCheck = installMetadata.ref || 'HEAD'; + const refPatterns = installMetadata.ref + ? [refToCheck, `${refToCheck}^{}`] + : [refToCheck]; - const lsRemoteOutput = await git.listRemote([remoteUrl, refToCheck]); + const lsRemoteOutput = await git.listRemote([remoteUrl, ...refPatterns]); signal?.throwIfAborted(); if (typeof lsRemoteOutput !== 'string' || lsRemoteOutput.trim() === '') { @@ -506,7 +509,12 @@ export async function checkForExtensionUpdate( return ExtensionUpdateState.ERROR; } - const remoteHash = lsRemoteOutput.split('\t')[0]; + const remoteLines = lsRemoteOutput.trim().split('\n'); + const peeledLine = remoteLines.find((line) => + line.split('\t')[1]?.endsWith('^{}'), + ); + const remoteLine = peeledLine ?? remoteLines[0]; + const remoteHash = remoteLine?.split('\t')[0]; signal?.throwIfAborted(); if (!remoteHash) { diff --git a/packages/core/src/extension/qoder-converter.test.ts b/packages/core/src/extension/qoder-converter.test.ts index adac019dcdf..80d93eeb223 100644 --- a/packages/core/src/extension/qoder-converter.test.ts +++ b/packages/core/src/extension/qoder-converter.test.ts @@ -188,11 +188,15 @@ describe('convertQoderPlugin', () => { it('rejects malformed root MCP config', async () => { writeManifest({ name: 'sample-qoder-plugin' }); - fs.writeFileSync(path.join(root, '.mcp.json'), '{', 'utf-8'); + fs.writeFileSync(path.join(root, '.mcp.json'), '\u001b[31m{', 'utf-8'); - await expect(convertQoderPlugin(root)).rejects.toThrow( - /Invalid Qoder MCP configuration/, + const error = await convertQoderPlugin(root).catch( + (caught: unknown) => caught, ); + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).not.toContain('\u001b'); + expect((error as Error).message).toMatch(/Invalid Qoder MCP configuration/); }); it('rejects an invalid MCP wrapper from a configured path', async () => { @@ -251,6 +255,24 @@ describe('convertQoderPlugin', () => { fs.rmSync(external, { recursive: true, force: true }); }); + it('sanitizes control sequences from manifest parse errors', async () => { + fs.writeFileSync( + path.join(root, QODER_PLUGIN_MANIFEST), + '\u001b[31minvalid', + 'utf-8', + ); + + const error = await convertQoderPlugin(root).catch( + (caught: unknown) => caught, + ); + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).not.toContain('\u001b'); + expect((error as Error).message).toMatch( + /Invalid Qoder plugin configuration/, + ); + }); + it('does not copy escaping symlinks or load unsafe context paths', async () => { const external = fs.mkdtempSync(path.join(os.tmpdir(), 'qoder-external-')); const externalFile = path.join(external, 'private.txt'); diff --git a/packages/core/src/extension/qoder-converter.ts b/packages/core/src/extension/qoder-converter.ts index e256e64883b..c0c8b5183bf 100644 --- a/packages/core/src/extension/qoder-converter.ts +++ b/packages/core/src/extension/qoder-converter.ts @@ -16,6 +16,7 @@ import { } from './claude-converter.js'; import { realPathWithin } from './gemini-converter.js'; import { EXTENSIONS_CONFIG_FILENAME } from './variables.js'; +import { stripAnsiAndControl } from '../utils/textUtils.js'; export const QODER_PLUGIN_MANIFEST = '.qoder-plugin/plugin.json'; @@ -36,7 +37,16 @@ function loadQoderConfig(extensionDir: string): QoderPluginConfig { ); } - const parsed: unknown = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + let parsed: unknown; + try { + parsed = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + } catch (error) { + throw new Error( + stripAnsiAndControl( + `Invalid Qoder plugin configuration at ${configPath}: ${error instanceof Error ? error.message : String(error)}`, + ), + ); + } if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { throw new Error( `Invalid Qoder plugin configuration at ${configPath}: expected a JSON object`, @@ -71,7 +81,9 @@ function loadMcpServersFile( parsed = JSON.parse(fs.readFileSync(mcpPath, 'utf-8')); } catch (error) { throw new Error( - `Invalid Qoder MCP configuration at ${mcpPath}: ${error instanceof Error ? error.message : String(error)}`, + stripAnsiAndControl( + `Invalid Qoder MCP configuration at ${mcpPath}: ${error instanceof Error ? error.message : String(error)}`, + ), ); } if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { From a032fdb98bf61ec2d7c0e89e036d2d2d3640ce2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=B6=E5=85=AC?= Date: Fri, 7 Aug 2026 16:34:29 +0800 Subject: [PATCH 4/8] fix(core): harden Qoder conversion edge cases --- .../cli/extensions-install.test.ts | 12 +- packages/core/src/extension/github.test.ts | 5 + .../src/extension/qoder-converter.test.ts | 146 ++++++++++++++++-- .../core/src/extension/qoder-converter.ts | 85 ++++++---- 4 files changed, 208 insertions(+), 40 deletions(-) diff --git a/integration-tests/cli/extensions-install.test.ts b/integration-tests/cli/extensions-install.test.ts index 0af26a9d1e9..49c1975dcd9 100644 --- a/integration-tests/cli/extensions-install.test.ts +++ b/integration-tests/cli/extensions-install.test.ts @@ -68,6 +68,11 @@ test('installs a local Qoder plugin', async () => { '---\nname: sample-skill\ndescription: Synthetic skill\n---\n', ); + try { + await rig.runCommand(['extensions', 'uninstall', 'sample-qoder-plugin']); + } catch { + // The extension is not installed yet. + } try { const result = await rig.runCommand( ['extensions', 'install', rig.testDir!], @@ -77,9 +82,12 @@ test('installs a local Qoder plugin', async () => { const listResult = await rig.runCommand(['extensions', 'list']); expect(listResult).toContain('sample-qoder-plugin'); - - await rig.runCommand(['extensions', 'uninstall', 'sample-qoder-plugin']); } finally { + try { + await rig.runCommand(['extensions', 'uninstall', 'sample-qoder-plugin']); + } catch { + // Installation may have failed before the extension was registered. + } await rig.cleanup(); } }); diff --git a/packages/core/src/extension/github.test.ts b/packages/core/src/extension/github.test.ts index f7b80e52f89..87e5b6a72d1 100644 --- a/packages/core/src/extension/github.test.ts +++ b/packages/core/src/extension/github.test.ts @@ -61,7 +61,12 @@ vi.mock('node:https', async (importOriginal) => { vi.mock('simple-git'); describe('git extension helpers', () => { + beforeEach(() => { + vi.stubEnv('GITHUB_TOKEN', ''); + }); + afterEach(() => { + vi.unstubAllEnvs(); vi.restoreAllMocks(); mockHttpsGet.mockReset(); }); diff --git a/packages/core/src/extension/qoder-converter.test.ts b/packages/core/src/extension/qoder-converter.test.ts index 80d93eeb223..55250432eaf 100644 --- a/packages/core/src/extension/qoder-converter.test.ts +++ b/packages/core/src/extension/qoder-converter.test.ts @@ -142,7 +142,7 @@ describe('convertQoderPlugin', () => { it('merges explicit context with system-prompt.md without duplicates', async () => { writeManifest({ name: 'sample-qoder-plugin', - contextFileName: ['custom.md', 'custom.md', './system-prompt.md'], + contextFileName: ['custom.md', 42, 'custom.md', './system-prompt.md'], }); fs.writeFileSync(path.join(root, 'QWEN.md'), '# Qwen context', 'utf-8'); fs.writeFileSync(path.join(root, 'custom.md'), '# Custom', 'utf-8'); @@ -186,6 +186,29 @@ describe('convertQoderPlugin', () => { fs.rmSync(result.convertedDir, { recursive: true, force: true }); }); + it('prefers inline MCP config over the root MCP file', async () => { + writeManifest({ + name: 'sample-qoder-plugin', + mcpServers: { + inline: { type: 'http', url: 'https://example.com/inline' }, + }, + }); + fs.writeFileSync( + path.join(root, '.mcp.json'), + JSON.stringify({ + mcpServers: { + root: { type: 'http', url: 'https://example.com/root' }, + }, + }), + 'utf-8', + ); + + const result = await convertQoderPlugin(root); + + expect(Object.keys(result.config.mcpServers ?? {})).toEqual(['inline']); + fs.rmSync(result.convertedDir, { recursive: true, force: true }); + }); + it('rejects malformed root MCP config', async () => { writeManifest({ name: 'sample-qoder-plugin' }); fs.writeFileSync(path.join(root, '.mcp.json'), '\u001b[31m{', 'utf-8'); @@ -215,6 +238,54 @@ describe('convertQoderPlugin', () => { ); }); + it.each(['inline', 'root'] as const)( + 'rejects non-object MCP server entries from %s config', + async (source) => { + writeManifest({ + name: 'sample-qoder-plugin', + ...(source === 'inline' + ? { mcpServers: { invalid: null } } + : undefined), + }); + if (source === 'root') { + fs.writeFileSync( + path.join(root, '.mcp.json'), + JSON.stringify({ mcpServers: { invalid: null } }), + 'utf-8', + ); + } + + await expect(convertQoderPlugin(root)).rejects.toThrow( + /server entries must be JSON objects/, + ); + }, + ); + + it('does not load an escaping root MCP symlink', async () => { + const external = fs.mkdtempSync(path.join(os.tmpdir(), 'qoder-external-')); + const externalMcp = path.join(external, '.mcp.json'); + fs.writeFileSync( + externalMcp, + JSON.stringify({ + mcpServers: { + sample: { type: 'http', url: 'https://example.com/mcp' }, + }, + }), + 'utf-8', + ); + writeManifest({ name: 'sample-qoder-plugin' }); + fs.symlinkSync(externalMcp, path.join(root, '.mcp.json')); + + const result = await convertQoderPlugin(root); + + expect(result.config.mcpServers).toBeUndefined(); + expect(fs.existsSync(path.join(result.convertedDir, '.mcp.json'))).toBe( + false, + ); + fs.rmSync(result.convertedDir, { recursive: true, force: true }); + fs.rmSync(external, { recursive: true, force: true }); + }); + it('loads QWEN.md with system-prompt.md when context is not configured', async () => { writeManifest({ name: 'sample-qoder-plugin', contextFileName: [] }); fs.writeFileSync(path.join(root, 'QWEN.md'), '# Qwen context', 'utf-8'); @@ -239,6 +310,16 @@ describe('convertQoderPlugin', () => { /expected a JSON object/, ); + writeManifest({}); + await expect(convertQoderPlugin(root)).rejects.toThrow( + /must have name field/, + ); + + writeManifest({ name: 123 }); + await expect(convertQoderPlugin(root)).rejects.toThrow( + /must have name field/, + ); + const external = fs.mkdtempSync(path.join(os.tmpdir(), 'qoder-external-')); const externalManifest = path.join(external, 'plugin.json'); fs.writeFileSync( @@ -279,8 +360,9 @@ describe('convertQoderPlugin', () => { fs.writeFileSync(externalFile, 'private', 'utf-8'); writeManifest({ name: 'sample-qoder-plugin', - contextFileName: '../private.txt', + contextFileName: 'leak.md', }); + fs.symlinkSync(externalFile, path.join(root, 'leak.md')); fs.mkdirSync(path.join(root, 'skills'), { recursive: true }); fs.symlinkSync(externalFile, path.join(root, 'skills', 'leak.txt')); @@ -295,20 +377,60 @@ describe('convertQoderPlugin', () => { fs.rmSync(external, { recursive: true, force: true }); }); - it('does not load an escaping default QWEN.md symlink', async () => { - const external = fs.mkdtempSync(path.join(os.tmpdir(), 'qoder-external-')); - const externalFile = path.join(external, 'QWEN.md'); - fs.writeFileSync(externalFile, 'External context', 'utf-8'); - writeManifest({ name: 'sample-qoder-plugin' }); - fs.symlinkSync(externalFile, path.join(root, 'QWEN.md')); + it.each(['QWEN.md', 'system-prompt.md'])( + 'does not load an escaping default %s symlink', + async (contextFile) => { + const external = fs.mkdtempSync( + path.join(os.tmpdir(), 'qoder-external-'), + ); + const externalFile = path.join(external, contextFile); + fs.writeFileSync(externalFile, 'External context', 'utf-8'); + writeManifest({ name: 'sample-qoder-plugin' }); + fs.symlinkSync(externalFile, path.join(root, contextFile)); + + const result = await convertQoderPlugin(root); + + expect(result.config.contextFileName).toBeUndefined(); + expect(fs.existsSync(path.join(result.convertedDir, contextFile))).toBe( + false, + ); + fs.rmSync(result.convertedDir, { recursive: true, force: true }); + fs.rmSync(external, { recursive: true, force: true }); + }, + ); + + it('drops context files removed during selective resource collection', async () => { + writeManifest({ + name: 'sample-qoder-plugin', + commands: 'commands/kept.md', + contextFileName: 'commands/removed.md', + }); + fs.mkdirSync(path.join(root, 'commands'), { recursive: true }); + fs.writeFileSync(path.join(root, 'commands', 'kept.md'), '# Kept'); + fs.writeFileSync(path.join(root, 'commands', 'removed.md'), '# Removed'); + + const result = await convertQoderPlugin(root); + + expect(result.config.contextFileName).toBeUndefined(); + expect( + fs.existsSync(path.join(result.convertedDir, 'commands', 'kept.md')), + ).toBe(true); + expect( + fs.existsSync(path.join(result.convertedDir, 'commands', 'removed.md')), + ).toBe(false); + fs.rmSync(result.convertedDir, { recursive: true, force: true }); + }); + + it('ignores context paths that resolve to directories', async () => { + writeManifest({ + name: 'sample-qoder-plugin', + contextFileName: 'docs', + }); + fs.mkdirSync(path.join(root, 'docs')); const result = await convertQoderPlugin(root); expect(result.config.contextFileName).toBeUndefined(); - expect(fs.existsSync(path.join(result.convertedDir, 'QWEN.md'))).toBe( - false, - ); fs.rmSync(result.convertedDir, { recursive: true, force: true }); - fs.rmSync(external, { recursive: true, force: true }); }); }); diff --git a/packages/core/src/extension/qoder-converter.ts b/packages/core/src/extension/qoder-converter.ts index c0c8b5183bf..ca9619e6865 100644 --- a/packages/core/src/extension/qoder-converter.ts +++ b/packages/core/src/extension/qoder-converter.ts @@ -26,6 +26,26 @@ type QoderPluginConfig = Omit & { contextFileName?: string | string[]; }; +function normalizeMcpServers( + servers: Record, + configPath: string, +): Record { + return Object.fromEntries( + Object.entries(servers).map(([name, server]) => { + if ( + typeof server !== 'object' || + server === null || + Array.isArray(server) + ) { + throw new Error( + `Invalid Qoder MCP configuration at ${configPath}: server entries must be JSON objects`, + ); + } + return [name, normalizeClaudeMcpServer(server)]; + }), + ); +} + function loadQoderConfig(extensionDir: string): QoderPluginConfig { const configPath = path.join(extensionDir, QODER_PLUGIN_MANIFEST); if (!fs.existsSync(configPath)) { @@ -107,11 +127,9 @@ function loadMcpServersFile( ); } - return Object.fromEntries( - Object.entries(servers).map(([name, server]) => [ - name, - normalizeClaudeMcpServer(server as MCPServerConfig), - ]), + return normalizeMcpServers( + servers as Record, + mcpPath, ); } @@ -122,8 +140,18 @@ function resolveMcpServers( if (typeof configured === 'string') { return loadMcpServersFile(extensionDir, configured, false); } - if (configured) { - return configured; + if (configured !== undefined) { + if ( + typeof configured !== 'object' || + configured === null || + Array.isArray(configured) + ) { + throw new Error('Qoder plugin mcpServers must be an object or file path'); + } + return normalizeMcpServers( + configured, + path.join(extensionDir, QODER_PLUGIN_MANIFEST), + ); } return loadMcpServersFile(extensionDir, '.mcp.json', true); } @@ -139,13 +167,19 @@ function resolveContextFiles( : []; const contextFiles: string[] = []; const seen = new Set(); - const addContextFile = (relativePath: string): void => { + const addContextFile = (relativePath: string, prepend = false): void => { const resolved = resolvePluginRelativeFile(extensionDir, relativePath); - if (!resolved || !fs.existsSync(resolved)) return; + if (!resolved) return; + try { + if (!fs.statSync(resolved).isFile()) return; + } catch { + return; + } const normalized = path.relative(path.resolve(extensionDir), resolved); if (normalized && !seen.has(normalized)) { seen.add(normalized); - contextFiles.push(normalized); + if (prepend) contextFiles.unshift(normalized); + else contextFiles.push(normalized); } }; @@ -154,13 +188,7 @@ function resolveContextFiles( } addContextFile('system-prompt.md'); if (contextFiles.length > 0) { - const qwenPath = resolvePluginRelativeFile(extensionDir, 'QWEN.md'); - if (qwenPath && fs.existsSync(qwenPath)) { - const normalized = path.relative(path.resolve(extensionDir), qwenPath); - if (normalized && !seen.has(normalized)) { - contextFiles.unshift(normalized); - } - } + addContextFile('QWEN.md', true); } return contextFiles.length > 0 ? contextFiles : undefined; } @@ -170,23 +198,28 @@ export async function convertQoderPlugin( ): Promise<{ config: ExtensionConfig; convertedDir: string }> { const config = loadQoderConfig(extensionDir); config.mcpServers = resolveMcpServers(extensionDir, config.mcpServers); - const contextFileName = resolveContextFiles( - extensionDir, - config.contextFileName, - ); const converted = await buildQwenExtensionFromPlugin( extensionDir, config as ClaudePluginConfig, ); + const contextFileName = resolveContextFiles( + converted.convertedDir, + config.contextFileName, + ); const qwenConfig: ExtensionConfig = { ...converted.config, displayName: config.displayName, contextFileName, }; - fs.writeFileSync( - path.join(converted.convertedDir, EXTENSIONS_CONFIG_FILENAME), - JSON.stringify(qwenConfig, null, 2), - 'utf-8', - ); + try { + fs.writeFileSync( + path.join(converted.convertedDir, EXTENSIONS_CONFIG_FILENAME), + JSON.stringify(qwenConfig, null, 2), + 'utf-8', + ); + } catch (error) { + fs.rmSync(converted.convertedDir, { recursive: true, force: true }); + throw error; + } return { ...converted, config: qwenConfig }; } From 99faf6acac410459b42e9e953173a6f6fb840095 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=B6=E5=85=AC?= Date: Fri, 7 Aug 2026 21:42:29 +0800 Subject: [PATCH 5/8] fix(core): sanitize Qoder conversion inputs --- .../src/extension/qoder-converter.test.ts | 51 +++++++++++++++++++ .../core/src/extension/qoder-converter.ts | 13 +++-- 2 files changed, 60 insertions(+), 4 deletions(-) diff --git a/packages/core/src/extension/qoder-converter.test.ts b/packages/core/src/extension/qoder-converter.test.ts index 55250432eaf..df79e1fa527 100644 --- a/packages/core/src/extension/qoder-converter.test.ts +++ b/packages/core/src/extension/qoder-converter.test.ts @@ -139,6 +139,28 @@ describe('convertQoderPlugin', () => { fs.rmSync(result.extensionDir, { recursive: true, force: true }); }); + it('omits null optional metadata from the generated config', async () => { + writeManifest({ + name: 'sample-qoder-plugin', + displayName: null, + description: null, + }); + + const result = await convertQoderPlugin(root); + const generated = JSON.parse( + fs.readFileSync( + path.join(result.convertedDir, 'qwen-extension.json'), + 'utf-8', + ), + ) as Record; + + expect(result.config.displayName).toBeUndefined(); + expect(result.config.description).toBeUndefined(); + expect(generated).not.toHaveProperty('displayName'); + expect(generated).not.toHaveProperty('description'); + fs.rmSync(result.convertedDir, { recursive: true, force: true }); + }); + it('merges explicit context with system-prompt.md without duplicates', async () => { writeManifest({ name: 'sample-qoder-plugin', @@ -222,6 +244,35 @@ describe('convertQoderPlugin', () => { expect((error as Error).message).toMatch(/Invalid Qoder MCP configuration/); }); + it.skipIf(process.platform === 'win32').each([ + ['JSON value', 'null', /expected a JSON object/], + [ + 'wrapper', + JSON.stringify({ mcpServers: null }), + /expected an "mcpServers" object/, + ], + [ + 'server entry', + JSON.stringify({ mcpServers: { invalid: null } }), + /server entries must be JSON objects/, + ], + ])( + 'sanitizes control sequences in MCP %s errors', + async (_case, body, errorPattern) => { + const mcpFile = 'mcp\u001b[31m.json'; + writeManifest({ name: 'sample-qoder-plugin', mcpServers: mcpFile }); + fs.writeFileSync(path.join(root, mcpFile), body, 'utf-8'); + + const error = await convertQoderPlugin(root).catch( + (caught: unknown) => caught, + ); + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).not.toContain('\u001b'); + expect((error as Error).message).toMatch(errorPattern); + }, + ); + it('rejects an invalid MCP wrapper from a configured path', async () => { writeManifest({ name: 'sample-qoder-plugin', diff --git a/packages/core/src/extension/qoder-converter.ts b/packages/core/src/extension/qoder-converter.ts index ca9619e6865..b2b9829bae9 100644 --- a/packages/core/src/extension/qoder-converter.ts +++ b/packages/core/src/extension/qoder-converter.ts @@ -83,6 +83,10 @@ function loadQoderConfig(extensionDir: string): QoderPluginConfig { typeof config.version === 'string' && config.version.length > 0 ? config.version : '1.0.0', + displayName: + typeof config.displayName === 'string' ? config.displayName : undefined, + description: + typeof config.description === 'string' ? config.description : undefined, }; } @@ -95,6 +99,7 @@ function loadMcpServersFile( if (!mcpPath || !fs.existsSync(mcpPath)) { return undefined; } + const safeMcpPath = stripAnsiAndControl(mcpPath); let parsed: unknown; try { @@ -102,13 +107,13 @@ function loadMcpServersFile( } catch (error) { throw new Error( stripAnsiAndControl( - `Invalid Qoder MCP configuration at ${mcpPath}: ${error instanceof Error ? error.message : String(error)}`, + `Invalid Qoder MCP configuration at ${safeMcpPath}: ${error instanceof Error ? error.message : String(error)}`, ), ); } if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { throw new Error( - `Invalid Qoder MCP configuration at ${mcpPath}: expected a JSON object`, + `Invalid Qoder MCP configuration at ${safeMcpPath}: expected a JSON object`, ); } const hasWrapper = Object.prototype.hasOwnProperty.call(parsed, 'mcpServers'); @@ -123,13 +128,13 @@ function loadMcpServersFile( Array.isArray(servers) ) { throw new Error( - `Invalid Qoder MCP configuration at ${mcpPath}: expected an "mcpServers" object`, + `Invalid Qoder MCP configuration at ${safeMcpPath}: expected an "mcpServers" object`, ); } return normalizeMcpServers( servers as Record, - mcpPath, + safeMcpPath, ); } From 171c99163edb786f73f4ae282e2ba86279e332a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=B6=E5=85=AC?= Date: Fri, 7 Aug 2026 15:51:18 +0000 Subject: [PATCH 6/8] fix(core): address Qoder extension round-3 review feedback Co-authored-by: Qwen-Coder --- .../core/src/extension/claude-converter.ts | 33 ++++-- .../core/src/extension/extension-converter.ts | 25 ++-- .../src/extension/extensionManager.test.ts | 107 ++++++++++++++++++ .../core/src/extension/extensionManager.ts | 19 +++- packages/core/src/extension/github.test.ts | 34 +++--- .../src/extension/qoder-converter.test.ts | 33 ++++++ .../core/src/extension/qoder-converter.ts | 8 +- 7 files changed, 211 insertions(+), 48 deletions(-) diff --git a/packages/core/src/extension/claude-converter.ts b/packages/core/src/extension/claude-converter.ts index 9d7278f0392..afef729db4a 100644 --- a/packages/core/src/extension/claude-converter.ts +++ b/packages/core/src/extension/claude-converter.ts @@ -453,7 +453,11 @@ export async function convertClaudePluginPackage( pluginName: string, networkPolicy?: ExtensionInstallMetadata['networkPolicy'], signal?: AbortSignal, -): Promise<{ config: ExtensionConfig; convertedDir: string }> { +): Promise<{ + config: ExtensionConfig; + convertedDir: string; + externalContent: boolean; +}> { signal?.throwIfAborted(); // Step 1: Load marketplace.json const marketplaceJsonPath = path.join( @@ -493,7 +497,7 @@ export async function convertClaudePluginPackage( ); await fs.promises.mkdir(pluginDir, { recursive: true }); - const pluginSource = await resolvePluginSource( + const { pluginSource, externalContent } = await resolvePluginSource( marketplacePlugin, extensionDir, pluginDir, @@ -544,7 +548,11 @@ export async function convertClaudePluginPackage( mergedConfig = marketplacePlugin as ClaudePluginConfig; } - return buildQwenExtensionFromPlugin(pluginSource, mergedConfig); + const converted = await buildQwenExtensionFromPlugin( + pluginSource, + mergedConfig, + ); + return { ...converted, externalContent }; } /** @@ -1014,7 +1022,10 @@ export function isClaudePluginConfig( /** * Resolve plugin source from marketplace plugin configuration. - * Returns the absolute path to the plugin source directory. + * Returns the absolute path to the plugin source directory and whether the + * plugin content was fetched from a source external to the marketplace + * repository (in which case the marketplace clone's commit does not describe + * the installed content). */ async function resolvePluginSource( pluginConfig: ClaudeMarketplacePluginConfig, @@ -1022,7 +1033,7 @@ async function resolvePluginSource( pluginDir: string, networkPolicy?: ExtensionInstallMetadata['networkPolicy'], signal?: AbortSignal, -): Promise { +): Promise<{ pluginSource: string; externalContent: boolean }> { signal?.throwIfAborted(); const source = pluginConfig.source; @@ -1047,7 +1058,7 @@ async function resolvePluginSource( signal?.throwIfAborted(); await cloneFromGit(installMetadata, pluginDir, signal); } - return pluginDir; + return { pluginSource: pluginDir, externalContent: true }; } // Relative path within marketplace. Confine it: a manifest source like @@ -1082,12 +1093,12 @@ async function resolvePluginSource( // If source path equals marketplace dir (source is '.' or ''), // return marketplaceDir directly to avoid copying to subdirectory of self if (path.resolve(sourcePath) === path.resolve(marketplaceDir)) { - return marketplaceDir; + return { pluginSource: marketplaceDir, externalContent: false }; } // Copy to plugin directory await fs.promises.cp(sourcePath, pluginDir, { recursive: true }); - return pluginDir; + return { pluginSource: pluginDir, externalContent: false }; } // Handle object source (github or url) @@ -1103,7 +1114,7 @@ async function resolvePluginSource( signal?.throwIfAborted(); await cloneFromGit(installMetadata, pluginDir, signal); } - return pluginDir; + return { pluginSource: pluginDir, externalContent: true }; } if (source.source === 'url') { @@ -1118,7 +1129,7 @@ async function resolvePluginSource( signal?.throwIfAborted(); await cloneFromGit(installMetadata, pluginDir, signal); } - return pluginDir; + return { pluginSource: pluginDir, externalContent: true }; } if (source.source === 'git-subdir') { @@ -1162,7 +1173,7 @@ async function resolvePluginSource( `Plugin subdirectory "${sanitizeForError(source.path)}" resolves through a symlink outside the repository root of ${sanitizeForError(source.url)}`, ); } - return subDir; + return { pluginSource: subDir, externalContent: true }; } throw new Error(`Unsupported plugin source type: ${JSON.stringify(source)}`); diff --git a/packages/core/src/extension/extension-converter.ts b/packages/core/src/extension/extension-converter.ts index be9212057bb..ca36733ee9d 100644 --- a/packages/core/src/extension/extension-converter.ts +++ b/packages/core/src/extension/extension-converter.ts @@ -37,10 +37,15 @@ export async function convertCompatibleExtension( pluginName?: string, networkPolicy?: ExtensionNetworkPolicy, signal?: AbortSignal, -): Promise<{ extensionDir: string; originSource: ExtensionOriginSource }> { +): Promise<{ + extensionDir: string; + originSource: ExtensionOriginSource; + externalContent: boolean; +}> { signal?.throwIfAborted(); let newExtensionDir = extensionDir; let originSource: ExtensionOriginSource = 'QwenCode'; + let externalContent = false; const configFilePath = path.join( extensionDir, SUPPORTED_EXTENSION_MANIFESTS[0], @@ -55,15 +60,15 @@ export async function convertCompatibleExtension( newExtensionDir = (await convertQoderPlugin(extensionDir)).convertedDir; originSource = 'Qoder'; } else if (pluginName) { - newExtensionDir = ( - await convertClaudePluginPackage( - extensionDir, - pluginName, - networkPolicy, - signal, - ) - ).convertedDir; + const converted = await convertClaudePluginPackage( + extensionDir, + pluginName, + networkPolicy, + signal, + ); + newExtensionDir = converted.convertedDir; originSource = 'Claude'; + externalContent = converted.externalContent; } else if ( fs.existsSync(path.join(extensionDir, SUPPORTED_EXTENSION_MANIFESTS[3])) ) { @@ -72,5 +77,5 @@ export async function convertCompatibleExtension( originSource = 'Claude'; } signal?.throwIfAborted(); - return { extensionDir: newExtensionDir, originSource }; + return { extensionDir: newExtensionDir, originSource, externalContent }; } diff --git a/packages/core/src/extension/extensionManager.test.ts b/packages/core/src/extension/extensionManager.test.ts index 5d6ff90b287..ae773aa7122 100644 --- a/packages/core/src/extension/extensionManager.test.ts +++ b/packages/core/src/extension/extensionManager.test.ts @@ -879,6 +879,113 @@ describe('extension tests', () => { expect(extension.installMetadata?.gitCommit).toBe('sample-commit'); }); + it('should retain the recorded commit when a marketplace plugin lives in the marketplace repo', async () => { + mockGit.clone.mockImplementation(async () => { + const sourcePath = mockGit.path(); + fs.mkdirSync(path.join(sourcePath, '.claude-plugin'), { + recursive: true, + }); + fs.writeFileSync( + path.join(sourcePath, '.claude-plugin', 'marketplace.json'), + JSON.stringify({ + name: 'sample-marketplace', + owner: { name: 'Example', email: 'example@example.com' }, + plugins: [ + { name: 'sample-plugin', source: './plugins/sample-plugin' }, + ], + }), + ); + const pluginConfigDir = path.join( + sourcePath, + 'plugins', + 'sample-plugin', + '.claude-plugin', + ); + fs.mkdirSync(pluginConfigDir, { recursive: true }); + fs.writeFileSync( + path.join(pluginConfigDir, 'plugin.json'), + JSON.stringify({ name: 'sample-plugin', version: '1.0.0' }), + ); + }); + mockGit.getRemotes.mockResolvedValue([ + { + name: 'origin', + refs: { fetch: 'https://github.com/example/sample-marketplace' }, + }, + ]); + mockGit.fetch.mockResolvedValue(undefined); + mockGit.checkout.mockResolvedValue(undefined); + const manager = createExtensionManager(); + await manager.refreshCache(); + + const extension = await manager.installExtension( + { + type: 'git', + source: 'https://github.com/example/sample-marketplace', + pluginName: 'sample-plugin', + }, + async () => {}, + ); + + expect(extension.name).toBe('sample-plugin'); + expect(extension.installMetadata?.originSource).toBe('Claude'); + expect(extension.installMetadata?.gitCommit).toBe('sample-commit'); + }); + + it('should drop the recorded commit when a marketplace plugin resolves from an external source', async () => { + let cloneCalls = 0; + mockGit.clone.mockImplementation(async () => { + const sourcePath = mockGit.path(); + cloneCalls += 1; + fs.mkdirSync(path.join(sourcePath, '.claude-plugin'), { + recursive: true, + }); + if (cloneCalls === 1) { + fs.writeFileSync( + path.join(sourcePath, '.claude-plugin', 'marketplace.json'), + JSON.stringify({ + name: 'sample-marketplace', + owner: { name: 'Example', email: 'example@example.com' }, + plugins: [ + { + name: 'sample-plugin', + source: { source: 'github', repo: 'example/nested-plugin' }, + }, + ], + }), + ); + } else { + fs.writeFileSync( + path.join(sourcePath, '.claude-plugin', 'plugin.json'), + JSON.stringify({ name: 'sample-plugin', version: '1.0.0' }), + ); + } + }); + mockGit.getRemotes.mockResolvedValue([ + { + name: 'origin', + refs: { fetch: 'https://github.com/example/sample-marketplace' }, + }, + ]); + mockGit.fetch.mockResolvedValue(undefined); + mockGit.checkout.mockResolvedValue(undefined); + const manager = createExtensionManager(); + await manager.refreshCache(); + + const extension = await manager.installExtension( + { + type: 'git', + source: 'https://github.com/example/sample-marketplace', + pluginName: 'sample-plugin', + }, + async () => {}, + ); + + expect(extension.name).toBe('sample-plugin'); + expect(extension.installMetadata?.originSource).toBe('Claude'); + expect(extension.installMetadata?.gitCommit).toBeUndefined(); + }); + it('should emit mutation lifecycle events around install', async () => { const archivePath = path.join(tempWorkspaceDir, 'local-extension.zip'); fs.writeFileSync(archivePath, 'not used by mocked extractor'); diff --git a/packages/core/src/extension/extensionManager.ts b/packages/core/src/extension/extensionManager.ts index 9f91d5ed671..c4ead481b47 100644 --- a/packages/core/src/extension/extensionManager.ts +++ b/packages/core/src/extension/extensionManager.ts @@ -1810,12 +1810,13 @@ export class ExtensionManager { signal?.throwIfAborted(); try { const sourceBeforeConversion = localSourcePath; - const { extensionDir, originSource } = await convertCompatibleExtension( - sourceBeforeConversion, - installMetadata.pluginName, - installMetadata.networkPolicy, - signal, - ); + const { extensionDir, originSource, externalContent } = + await convertCompatibleExtension( + sourceBeforeConversion, + installMetadata.pluginName, + installMetadata.networkPolicy, + signal, + ); signal?.throwIfAborted(); if (extensionDir !== sourceBeforeConversion) { @@ -1823,6 +1824,12 @@ export class ExtensionManager { } localSourcePath = extensionDir; installMetadata.originSource = originSource; + if (externalContent) { + // The commit recorded above belongs to the outer clone (e.g. the + // marketplace repo), not plugin content fetched from a nested + // source; drop it so update checks don't compare the wrong repo. + installMetadata.gitCommit = undefined; + } newExtensionConfig = this.loadExtensionConfig({ extensionDir: localSourcePath, diff --git a/packages/core/src/extension/github.test.ts b/packages/core/src/extension/github.test.ts index 87e5b6a72d1..fff744a505b 100644 --- a/packages/core/src/extension/github.test.ts +++ b/packages/core/src/extension/github.test.ts @@ -628,23 +628,26 @@ describe('git extension helpers', () => { ]); }); - it('does not update-check legacy Qoder Git installs without a recorded commit', async () => { - const extension = createExtension({ - installMetadata: { - type: 'git', - source: 'https://github.com/example/sample-qoder-plugin', - originSource: 'Qoder', - }, - }); + it.each(['Qoder', 'Claude'] as const)( + 'does not update-check legacy %s Git installs without a recorded commit', + async (originSource) => { + const extension = createExtension({ + installMetadata: { + type: 'git', + source: 'https://github.com/example/sample-qoder-plugin', + originSource, + }, + }); - const result = await checkForExtensionUpdate( - extension, - mockExtensionManager, - ); + const result = await checkForExtensionUpdate( + extension, + mockExtensionManager, + ); - expect(result).toBe(ExtensionUpdateState.NOT_UPDATABLE); - expect(mockGit.listRemote).not.toHaveBeenCalled(); - }); + expect(result).toBe(ExtensionUpdateState.NOT_UPDATABLE); + expect(mockGit.listRemote).not.toHaveBeenCalled(); + }, + ); it('pins public Git update checks and disables redirects and proxies', async () => { vi.spyOn(dns, 'lookup').mockResolvedValue([ @@ -820,6 +823,7 @@ describe('git extension helpers', () => { const result = await checkForExtensionUpdate(extension, mockManager); expect(result).toBe(ExtensionUpdateState.UPDATE_AVAILABLE); + expect(await fs.readdir(tempDir)).toEqual(['.qoder-plugin']); } finally { await fs.rm(tempDir, { recursive: true, force: true }); } diff --git a/packages/core/src/extension/qoder-converter.test.ts b/packages/core/src/extension/qoder-converter.test.ts index df79e1fa527..0f45960c318 100644 --- a/packages/core/src/extension/qoder-converter.test.ts +++ b/packages/core/src/extension/qoder-converter.test.ts @@ -231,6 +231,39 @@ describe('convertQoderPlugin', () => { fs.rmSync(result.convertedDir, { recursive: true, force: true }); }); + it('treats null mcpServers as absent and falls back to the root MCP file', async () => { + writeManifest({ + name: 'sample-qoder-plugin', + mcpServers: null, + }); + fs.writeFileSync( + path.join(root, '.mcp.json'), + JSON.stringify({ + mcpServers: { + sample: { type: 'http', url: 'https://example.com/mcp' }, + }, + }), + 'utf-8', + ); + + const result = await convertQoderPlugin(root); + + expect(Object.keys(result.config.mcpServers ?? {})).toEqual(['sample']); + fs.rmSync(result.convertedDir, { recursive: true, force: true }); + }); + + it('treats null mcpServers without a root MCP file as absent', async () => { + writeManifest({ + name: 'sample-qoder-plugin', + mcpServers: null, + }); + + const result = await convertQoderPlugin(root); + + expect(result.config.mcpServers).toBeUndefined(); + fs.rmSync(result.convertedDir, { recursive: true, force: true }); + }); + it('rejects malformed root MCP config', async () => { writeManifest({ name: 'sample-qoder-plugin' }); fs.writeFileSync(path.join(root, '.mcp.json'), '\u001b[31m{', 'utf-8'); diff --git a/packages/core/src/extension/qoder-converter.ts b/packages/core/src/extension/qoder-converter.ts index b2b9829bae9..fb5db93bc39 100644 --- a/packages/core/src/extension/qoder-converter.ts +++ b/packages/core/src/extension/qoder-converter.ts @@ -145,12 +145,8 @@ function resolveMcpServers( if (typeof configured === 'string') { return loadMcpServersFile(extensionDir, configured, false); } - if (configured !== undefined) { - if ( - typeof configured !== 'object' || - configured === null || - Array.isArray(configured) - ) { + if (configured !== undefined && configured !== null) { + if (typeof configured !== 'object' || Array.isArray(configured)) { throw new Error('Qoder plugin mcpServers must be an object or file path'); } return normalizeMcpServers( From 809914a03a0944138395901f2948a6696b4a6299 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=B6=E5=85=AC?= Date: Fri, 7 Aug 2026 18:39:52 +0000 Subject: [PATCH 7/8] fix(core): honor explicit marketplace selection over Qoder manifest --- .../core/src/extension/extension-converter.ts | 10 +++- .../src/extension/qoder-converter.test.ts | 58 +++++++++++++++++-- 2 files changed, 61 insertions(+), 7 deletions(-) diff --git a/packages/core/src/extension/extension-converter.ts b/packages/core/src/extension/extension-converter.ts index ca36733ee9d..2bc766b5398 100644 --- a/packages/core/src/extension/extension-converter.ts +++ b/packages/core/src/extension/extension-converter.ts @@ -56,10 +56,11 @@ export async function convertCompatibleExtension( newExtensionDir = (await convertGeminiExtensionPackage(extensionDir)) .convertedDir; originSource = 'Gemini'; - } else if (fs.existsSync(path.join(extensionDir, QODER_PLUGIN_MANIFEST))) { - newExtensionDir = (await convertQoderPlugin(extensionDir)).convertedDir; - originSource = 'Qoder'; } else if (pluginName) { + // An explicit marketplace selection must win over root-manifest + // detection: a repo can carry both a marketplace and a root plugin + // manifest, and silently substituting the latter installs different + // content than the one selected. const converted = await convertClaudePluginPackage( extensionDir, pluginName, @@ -69,6 +70,9 @@ export async function convertCompatibleExtension( newExtensionDir = converted.convertedDir; originSource = 'Claude'; externalContent = converted.externalContent; + } else if (fs.existsSync(path.join(extensionDir, QODER_PLUGIN_MANIFEST))) { + newExtensionDir = (await convertQoderPlugin(extensionDir)).convertedDir; + originSource = 'Qoder'; } else if ( fs.existsSync(path.join(extensionDir, SUPPORTED_EXTENSION_MANIFESTS[3])) ) { diff --git a/packages/core/src/extension/qoder-converter.test.ts b/packages/core/src/extension/qoder-converter.test.ts index 0f45960c318..d6ca4faec9d 100644 --- a/packages/core/src/extension/qoder-converter.test.ts +++ b/packages/core/src/extension/qoder-converter.test.ts @@ -121,10 +121,7 @@ describe('convertQoderPlugin', () => { 'utf-8', ); - const result = await convertCompatibleExtension( - root, - 'ignored-plugin-name', - ); + const result = await convertCompatibleExtension(root); expect(result.originSource).toBe('Qoder'); const converted = JSON.parse( @@ -139,6 +136,59 @@ describe('convertQoderPlugin', () => { fs.rmSync(result.extensionDir, { recursive: true, force: true }); }); + it('honors an explicit marketplace selection over a root Qoder manifest', async () => { + writeManifest({ name: 'sample-qoder-plugin', version: '9.9.9' }); + fs.mkdirSync(path.join(root, '.claude-plugin'), { recursive: true }); + fs.writeFileSync( + path.join(root, '.claude-plugin', 'marketplace.json'), + JSON.stringify({ + name: 'sample-marketplace', + owner: { name: 'Test Owner', email: 'owner@example.com' }, + plugins: [ + { + name: 'requested-plugin', + version: '2.0.0', + source: './plugin-src', + }, + ], + }), + 'utf-8', + ); + const pluginSourceDir = path.join(root, 'plugin-src'); + fs.mkdirSync(path.join(pluginSourceDir, '.claude-plugin'), { + recursive: true, + }); + fs.writeFileSync( + path.join(pluginSourceDir, '.claude-plugin', 'plugin.json'), + JSON.stringify({ name: 'requested-plugin', version: '2.0.0' }), + 'utf-8', + ); + + const selected = await convertCompatibleExtension(root, 'requested-plugin'); + expect(selected.originSource).toBe('Claude'); + const selectedConfig = JSON.parse( + fs.readFileSync( + path.join(selected.extensionDir, 'qwen-extension.json'), + 'utf-8', + ), + ) as Record; + expect(selectedConfig['name']).toBe('requested-plugin'); + expect(selectedConfig['version']).toBe('2.0.0'); + fs.rmSync(selected.extensionDir, { recursive: true, force: true }); + + const unselected = await convertCompatibleExtension(root); + expect(unselected.originSource).toBe('Qoder'); + const unselectedConfig = JSON.parse( + fs.readFileSync( + path.join(unselected.extensionDir, 'qwen-extension.json'), + 'utf-8', + ), + ) as Record; + expect(unselectedConfig['name']).toBe('sample-qoder-plugin'); + expect(unselectedConfig['version']).toBe('9.9.9'); + fs.rmSync(unselected.extensionDir, { recursive: true, force: true }); + }); + it('omits null optional metadata from the generated config', async () => { writeManifest({ name: 'sample-qoder-plugin', From 9854b9d81e14676fef8e699686c5fc319f141c7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=B6=E5=85=AC?= Date: Sat, 8 Aug 2026 09:46:17 +0800 Subject: [PATCH 8/8] fix(core): preserve nested plugin update provenance --- packages/core/src/config/config.ts | 1 + .../src/extension/extensionManager.test.ts | 64 ++++++++++++++++++ .../core/src/extension/extensionManager.ts | 1 + packages/core/src/extension/github.test.ts | 67 +++++++++++++++++++ packages/core/src/extension/github.ts | 8 +++ 5 files changed, 141 insertions(+) diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 9c6f06a92ec..283f0e678ce 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -681,6 +681,7 @@ export interface ExtensionInstallMetadata { originSource?: ExtensionOriginSource; releaseTag?: string; // Only present for github-release and npm installs. gitCommit?: string; // Commit recorded when the installation source was cloned. + externalContent?: boolean; // Installed content came from a source nested outside the recorded source. registryUrl?: string; // Only present for npm installs. ref?: string; autoUpdate?: boolean; diff --git a/packages/core/src/extension/extensionManager.test.ts b/packages/core/src/extension/extensionManager.test.ts index ae773aa7122..ddd899f2de7 100644 --- a/packages/core/src/extension/extensionManager.test.ts +++ b/packages/core/src/extension/extensionManager.test.ts @@ -930,6 +930,7 @@ describe('extension tests', () => { expect(extension.name).toBe('sample-plugin'); expect(extension.installMetadata?.originSource).toBe('Claude'); expect(extension.installMetadata?.gitCommit).toBe('sample-commit'); + expect(extension.installMetadata?.externalContent).toBe(false); }); it('should drop the recorded commit when a marketplace plugin resolves from an external source', async () => { @@ -984,6 +985,69 @@ describe('extension tests', () => { expect(extension.name).toBe('sample-plugin'); expect(extension.installMetadata?.originSource).toBe('Claude'); expect(extension.installMetadata?.gitCommit).toBeUndefined(); + expect(extension.installMetadata?.externalContent).toBe(true); + }); + + it('should mark external marketplace content downloaded from a GitHub release as not independently updatable', async () => { + const { downloadFromGitHubRelease } = await import('./github.js'); + vi.mocked(downloadFromGitHubRelease).mockImplementationOnce( + async (_metadata, destination) => { + fs.mkdirSync(path.join(destination, '.claude-plugin'), { + recursive: true, + }); + fs.writeFileSync( + path.join(destination, '.claude-plugin', 'marketplace.json'), + JSON.stringify({ + name: 'sample-marketplace', + owner: { name: 'Example', email: 'example@example.com' }, + plugins: [ + { + name: 'sample-plugin', + source: { source: 'github', repo: 'example/nested-plugin' }, + }, + ], + }), + ); + return { type: 'github-release', tagName: 'v1.0.0' }; + }, + ); + mockGit.clone.mockImplementation(async () => { + const sourcePath = mockGit.path(); + fs.mkdirSync(path.join(sourcePath, '.claude-plugin'), { + recursive: true, + }); + fs.writeFileSync( + path.join(sourcePath, '.claude-plugin', 'plugin.json'), + JSON.stringify({ name: 'sample-plugin', version: '1.0.0' }), + ); + }); + mockGit.getRemotes.mockResolvedValue([ + { + name: 'origin', + refs: { fetch: 'https://github.com/example/nested-plugin' }, + }, + ]); + mockGit.fetch.mockResolvedValue(undefined); + mockGit.checkout.mockResolvedValue(undefined); + const manager = createExtensionManager(); + await manager.refreshCache(); + + const extension = await manager.installExtension( + { + type: 'git', + source: 'https://github.com/example/sample-marketplace', + pluginName: 'sample-plugin', + }, + async () => {}, + ); + + expect(extension.installMetadata).toMatchObject({ + type: 'github-release', + releaseTag: 'v1.0.0', + originSource: 'Claude', + externalContent: true, + }); + expect(extension.installMetadata?.gitCommit).toBeUndefined(); }); it('should emit mutation lifecycle events around install', async () => { diff --git a/packages/core/src/extension/extensionManager.ts b/packages/core/src/extension/extensionManager.ts index c4ead481b47..a4d60e778e5 100644 --- a/packages/core/src/extension/extensionManager.ts +++ b/packages/core/src/extension/extensionManager.ts @@ -1824,6 +1824,7 @@ export class ExtensionManager { } localSourcePath = extensionDir; installMetadata.originSource = originSource; + installMetadata.externalContent = externalContent; if (externalContent) { // The commit recorded above belongs to the outer clone (e.g. the // marketplace repo), not plugin content fetched from a nested diff --git a/packages/core/src/extension/github.test.ts b/packages/core/src/extension/github.test.ts index fff744a505b..b45952b4f76 100644 --- a/packages/core/src/extension/github.test.ts +++ b/packages/core/src/extension/github.test.ts @@ -649,6 +649,73 @@ describe('git extension helpers', () => { }, ); + it.each(['git', 'github-release'] as const)( + 'does not update-check external marketplace content installed through %s', + async (type) => { + const extension = createExtension({ + installMetadata: { + type, + source: 'https://github.com/example/sample-marketplace', + originSource: 'Claude', + releaseTag: 'v1.0.0', + externalContent: true, + }, + }); + + const result = await checkForExtensionUpdate( + extension, + mockExtensionManager, + ); + + expect(result).toBe(ExtensionUpdateState.NOT_UPDATABLE); + expect(mockGit.getRemotes).not.toHaveBeenCalled(); + expect(mockGit.listRemote).not.toHaveBeenCalled(); + expect(mockHttpsGet).not.toHaveBeenCalled(); + }, + ); + + it('does not update-check legacy Claude marketplace releases without content provenance', async () => { + const extension = createExtension({ + installMetadata: { + type: 'github-release', + source: 'https://github.com/example/sample-marketplace', + originSource: 'Claude', + pluginName: 'sample-plugin', + releaseTag: 'v1.0.0', + }, + }); + + const result = await checkForExtensionUpdate( + extension, + mockExtensionManager, + ); + + expect(result).toBe(ExtensionUpdateState.NOT_UPDATABLE); + expect(mockHttpsGet).not.toHaveBeenCalled(); + }); + + it('update-checks marketplace releases with confirmed repository content', async () => { + mockHttpsResponses(JSON.stringify({ tag_name: 'v2.0.0' })); + const extension = createExtension({ + installMetadata: { + type: 'github-release', + source: 'https://github.com/example/sample-marketplace', + originSource: 'Claude', + pluginName: 'sample-plugin', + releaseTag: 'v1.0.0', + externalContent: false, + }, + }); + + const result = await checkForExtensionUpdate( + extension, + mockExtensionManager, + ); + + expect(result).toBe(ExtensionUpdateState.UPDATE_AVAILABLE); + expect(mockHttpsGet).toHaveBeenCalledOnce(); + }); + it('pins public Git update checks and disables redirects and proxies', 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 4e2a4a9395d..f293a80a926 100644 --- a/packages/core/src/extension/github.ts +++ b/packages/core/src/extension/github.ts @@ -428,6 +428,14 @@ export async function checkForExtensionUpdate( ) { return ExtensionUpdateState.NOT_UPDATABLE; } + if ( + installMetadata.externalContent === true || + (installMetadata.externalContent === undefined && + installMetadata.originSource === 'Claude' && + installMetadata.pluginName !== undefined) + ) { + return ExtensionUpdateState.NOT_UPDATABLE; + } try { if (installMetadata.type === 'git') { const { simpleGit } = await loadSimpleGit();