Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
c2dbf3c
fix(extensions): preserve Claude hooks in dual manifests
destire-mio Aug 6, 2026
089e34f
fix(extensions): address dual-manifest review feedback
destire-mio Aug 7, 2026
9c38138
fix(extensions): address follow-up review blockers
destire-mio Aug 7, 2026
378f6a3
Merge branch 'main' into codex/fix-extension-hooks-8539
wenshao Aug 8, 2026
f8f86c9
test(extensions): cover conversion safety fallbacks
destire-mio Aug 8, 2026
75e2b83
Merge remote-tracking branch 'origin/main' into work/fix-8626
destire-mio Aug 9, 2026
40652c4
fix(extensions): preserve marketplace plugin selection
destire-mio Aug 9, 2026
94dbfdb
Merge remote-tracking branch 'origin/main' into work/fix-8626
destire-mio Aug 12, 2026
9e1f749
Merge remote-tracking branch 'origin/main' into work/fix-8626
destire-mio Aug 12, 2026
9835bcc
Merge remote-tracking branch 'origin/main' into work/fix-8626
destire-mio Aug 12, 2026
7fa4ab3
Merge remote-tracking branch 'origin/main' into work/fix-8626
destire-mio Aug 12, 2026
0292884
Merge remote-tracking branch 'origin/main' into work/fix-8626
destire-mio Aug 12, 2026
cee3cf6
Merge remote-tracking branch 'origin/main' into work/fix-8626
destire-mio Aug 12, 2026
edc749d
Merge remote-tracking branch 'origin/main' into work/fix-8626
destire-mio Aug 12, 2026
cea0397
fix(review): keep repository context within file limit
destire-mio Aug 12, 2026
b766265
Merge remote-tracking branch 'origin/main' into work/fix-8626
destire-mio Aug 12, 2026
5f6cbc1
test(review): pin repository context headroom
destire-mio Aug 12, 2026
d41df19
test(review): make context bound probe robust
destire-mio Aug 12, 2026
96171c4
test(review): avoid changed-path exclusion false positive
destire-mio Aug 12, 2026
76afb30
test(review): harden manifest policy probes
destire-mio Aug 12, 2026
13ef11e
test(review): cover all top-level skill sources
destire-mio Aug 13, 2026
975f74b
test(review): share repository file walker
destire-mio Aug 13, 2026
0b67e70
test(review): normalize walked repository paths
destire-mio Aug 13, 2026
3a89818
fix(extensions): streamline dual manifest conversion
destire-mio Aug 13, 2026
1af22f2
Merge remote-tracking branch 'origin/main' into work/fix-8626
destire-mio Aug 13, 2026
cd2d428
chore(review): drop temporary context CI backport
destire-mio Aug 13, 2026
be19545
fix(extensions): address final review blockers
destire-mio Aug 13, 2026
46b6248
Merge remote-tracking branch 'origin/main' into work/fix-8626
destire-mio Aug 13, 2026
0b4b30c
fix(extensions): preserve numeric direct-root aliases
destire-mio Aug 13, 2026
47ca61c
fix(extensions): harden remote plugin source handling
destire-mio Aug 14, 2026
39faca6
fix(extensions): parse numeric HTTPS plugin aliases
destire-mio Aug 21, 2026
c06964c
Merge origin/main to restore current CI prerequisites
destire-mio Aug 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .qwen/e2e-tests/8539-dual-manifest-extension-hooks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Dual-manifest extension hooks reviewer plan

## Scope

Verify a trusted test extension containing `gemini-extension.json`, `.claude-plugin/plugin.json`, a Claude hooks file, `AGENTS.md`, and a Gemini TOML slash command. Do not run third-party Ponytail hook code for this review.

## How to verify

1. Build Qwen Code from this branch.
2. Create a local fixture extension whose Gemini manifest declares `contextFileName: "AGENTS.md"`, whose Claude manifest points to a hooks JSON file, and whose `commands/` directory contains a valid TOML command.
3. Make the fixture's `SessionStart` hook write a fixed marker to a temporary test directory. Install the fixture once as a standalone extension and once through a local Claude marketplace entry.
4. Remove or move the original fixture source after installation, then start a fresh session for each installed form.
5. Confirm the marker is written by the hook from the installed extension path, the `AGENTS.md` instruction is present in the session context, and the converted slash command is listed and can be invoked.
6. Uninstall both fixture extensions and remove the temporary marker directory.

## Expected result

Both install forms retain the Gemini context and TOML command while registering the Claude hook. The hook command resolves `${CLAUDE_PLUGIN_ROOT}` to the final installed extension directory and does not depend on the original source or conversion directory.
6 changes: 6 additions & 0 deletions docs/users/features/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@ Hooks are user-defined scripts or programs that are automatically executed by Qw
- Integrate with external systems and services
- Modify tool inputs or responses programmatically

### Hooks provided by extensions

Extensions can provide hooks inline in `qwen-extension.json` or reference a hooks JSON file from the manifest. Claude-compatible extensions can likewise declare an inline `hooks` object or a relative hooks file in `.claude-plugin/plugin.json`.

For extensions that contain both `gemini-extension.json` and a root `.claude-plugin/plugin.json`, Qwen Code keeps the Gemini extension resources, context file, and settings while also importing the Claude-compatible hooks for that root. For a named marketplace install, this merge happens only when the selected marketplace entry points to the repository root; fields on that entry still override `plugin.json` as usual. `${CLAUDE_PLUGIN_ROOT}` in imported hooks resolves to the installed extension directory. Install third-party extensions only from sources you trust because command hooks execute with your user permissions.

## Hook Types

Qwen Code supports four hook executor types:
Expand Down
16 changes: 9 additions & 7 deletions packages/cli/src/serve/routes/workspace-extensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -520,13 +520,15 @@ export function registerWorkspaceExtensionRoutes(
plugins: marketplace.plugins.map((plugin) => ({
name: plugin.name,
...(plugin.description ? { description: plugin.description } : {}),
source: redactExtensionDisplaySource(
typeof plugin.source === 'string'
? plugin.source
: plugin.source.source === 'github'
? plugin.source.repo
: plugin.source.url,
),
source: plugin.source
? redactExtensionDisplaySource(
typeof plugin.source === 'string'
? plugin.source
: plugin.source.source === 'github'
? plugin.source.repo
: plugin.source.url,
)
: '.',
...(plugin.category ? { category: plugin.category } : {}),
...(plugin.tags ? { tags: plugin.tags } : {}),
})),
Expand Down
12 changes: 11 additions & 1 deletion packages/cli/src/serve/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6238,7 +6238,8 @@ describe('createServeApp', () => {
owner: { name: string; email: string };
plugins: Array<{
name: string;
source: string;
source?: string;
description?: string;
category?: string;
tags?: string[];
}>;
Expand All @@ -6255,6 +6256,10 @@ describe('createServeApp', () => {
category: 'tools',
tags: ['example'],
},
{
name: 'root-plugin',
description: 'Plugin at the marketplace root',
},
],
});
return testExtension('example-plugin');
Expand Down Expand Up @@ -6297,6 +6302,11 @@ describe('createServeApp', () => {
category: 'tools',
tags: ['example'],
},
{
name: 'root-plugin',
description: 'Plugin at the marketplace root',
source: '.',
},
],
},
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ describe('DiscoverTab', () => {
name: 'demo',
marketplaceName: 'market',
installSource: 'owner/demo',
pluginSourceKind: 'extension-root',
installed: false,
} as DiscoveredPlugin;
const manager = {
Expand Down Expand Up @@ -94,6 +95,9 @@ describe('DiscoverTab', () => {
});

await waitFor(() => expect(manager.installExtension).toHaveBeenCalled());
expect(mockParseInstallSource).toHaveBeenCalledWith('owner/demo', {
pluginSourceKind: 'extension-root',
});
expect(manager.installExtension).toHaveBeenCalledWith(
{ type: 'git', source: 'owner/demo' },
undefined,
Expand Down Expand Up @@ -150,4 +154,52 @@ describe('DiscoverTab', () => {
}),
);
});

it('rejects a direct-JSON entry that has no installable source', async () => {
const plugin = {
name: 'missing-source',
marketplaceName: 'market',
installSource: '',
pluginSourceKind: 'extension-root',
installed: false,
} as DiscoveredPlugin;
const manager = {
discoverPlugins: vi.fn().mockResolvedValue([plugin]),
installExtension: vi.fn(),
setExtensionScope: vi.fn(),
};
const onStatus = vi.fn();

render(
<DiscoverTab
config={{ getExtensionManager: () => manager } as unknown as Config}
isActive
onLockChange={vi.fn()}
onStatus={onStatus}
onInstalled={vi.fn()}
reloadSignal={0}
/>,
);
await waitFor(() => expect(manager.discoverPlugins).toHaveBeenCalled());

await act(async () => {
activeKeypress()({ name: 'return' } as Key);
});
const detailSelect = mockRadioButtonSelect.mock.calls.at(-1)?.[0] as
| SelectProps<'project'>
| undefined;
await act(async () => {
detailSelect?.onSelect('project');
});

await waitFor(() =>
expect(onStatus).toHaveBeenCalledWith(
expect.objectContaining({
text: expect.stringContaining('no installable source'),
}),
),
);
expect(mockParseInstallSource).not.toHaveBeenCalled();
expect(manager.installExtension).not.toHaveBeenCalled();
});
});
11 changes: 10 additions & 1 deletion packages/cli/src/ui/components/extensions/tabs/DiscoverTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,16 @@ export const DiscoverTab = ({
for (const plugin of targets) {
let ext;
try {
const metadata = await parseInstallSource(plugin.installSource);
if (!plugin.installSource) {
throw new Error(
t(
'This marketplace entry has no installable source. Add a Git, archive, or repository source to its marketplace metadata.',
),
);
}
const metadata = await parseInstallSource(plugin.installSource, {
pluginSourceKind: plugin.pluginSourceKind,
});
Comment on lines +229 to +231

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The PR's new root-entry semantics (a marketplace entry without source refers to the marketplace root) are unreachable from the Discover tab for direct-JSON (type 'http') marketplaces: resolveInstallSource's fallback produces a bare plugin-name installSource that parseInstallSource rejects before the converter's root handling can ever run — Failure scenario: an http marketplace entry with no source field (the exact shape the new converter test 'treats a marketplace entry without source as the marketplace root' and the daemon-route display support): resolveInstallSource returns {installSource: plugin.name, pluginSourceKind: 'extension-root'}, and parseInstallSource('root-plugin', ...) — no colon, not a local path/archive/git URL/npm/owner-repo — throws Install source not found: root-plugin, so the Discover install fails. The same entry installed through a GitHub/local marketplace (kind marketplace-entry, source:name) or the daemon choice flow works; source: './' entries hit the local-path guard and degrade to the same unresolvable bare name. No test round-trips the http no-source entry through parseInstallSource.

— deepseek-v4-flash via Qwen Code /review (v0.21.7)

ext = await extensionManager.installExtension(
metadata,
undefined,
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -697,6 +697,7 @@ export type ExtensionOriginSource =
| 'Qoder'
| 'AgentPlugins';
export type ExtensionNetworkPolicy = 'public';
export type ExtensionPluginSourceKind = 'marketplace-entry' | 'extension-root';

export interface ExtensionInstallMetadata {
source: string;
Expand All @@ -720,6 +721,7 @@ export interface ExtensionInstallMetadata {
allowPreRelease?: boolean;
marketplaceConfig?: ClaudeMarketplaceConfig;
pluginName?: string;
pluginSourceKind?: ExtensionPluginSourceKind;
networkPolicy?: ExtensionNetworkPolicy;
}

Expand Down
159 changes: 158 additions & 1 deletion packages/core/src/extension/claude-converter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
import { cloneFromGit, downloadFromGitHubRelease } from './github.js';
import { HookType } from '../hooks/types.js';
import { performVariableReplacement } from './variables.js';
import { ExtensionStorage } from './storage.js';

// The git-subdir source clones a repo; stub the network clone so the security
// guards around the cloned subdirectory can be exercised against a real fs.
Expand Down Expand Up @@ -228,6 +229,116 @@ describe('convertClaudePluginPackage', () => {
}
});

it('cleans the temporary plugin staging directory after conversion', async () => {
const pluginSourceDir = path.join(testDir, 'root-plugin');
const marketplaceDir = path.join(pluginSourceDir, '.claude-plugin');
fs.mkdirSync(marketplaceDir, { recursive: true });
fs.writeFileSync(
path.join(marketplaceDir, 'marketplace.json'),
JSON.stringify({
name: 'test-marketplace',
owner: { name: 'Owner' },
plugins: [
{
name: 'root-plugin',
version: '1.0.0',
source: './',
},
],
}),
'utf-8',
);

const createTmpDir = ExtensionStorage.createTmpDir.bind(ExtensionStorage);
const tempDirs: string[] = [];
const createTmpDirSpy = vi
.spyOn(ExtensionStorage, 'createTmpDir')
.mockImplementation(async () => {
const tempDir = await createTmpDir();
tempDirs.push(tempDir);
return tempDir;
});
let outputDir: string | undefined;

try {
const result = await convertClaudePluginPackage(
pluginSourceDir,
'root-plugin',
);
outputDir = result.convertedDir;

expect(tempDirs).toHaveLength(2);
expect(outputDir).toBe(tempDirs[1]);
expect(fs.existsSync(tempDirs[0])).toBe(false);
expect(fs.existsSync(outputDir)).toBe(true);
} finally {
createTmpDirSpy.mockRestore();
for (const tempDir of tempDirs) {
fs.rmSync(tempDir, { recursive: true, force: true });
}
}
});

it('treats a marketplace entry without source as the marketplace root', async () => {
const marketplaceDir = path.join(testDir, '.claude-plugin');
fs.mkdirSync(marketplaceDir, { recursive: true });
fs.writeFileSync(
path.join(marketplaceDir, 'marketplace.json'),
JSON.stringify({
name: 'root-marketplace',
owner: { name: 'Owner' },
plugins: [{ name: 'root-plugin', version: '1.0.0' }],
}),
);

const result = await convertClaudePluginPackage(testDir, 'root-plugin');
try {
expect(result.config.name).toBe('root-plugin');
expect(result.config.version).toBe('1.0.0');
} finally {
fs.rmSync(result.convertedDir, { recursive: true, force: true });
}
});

it('cleans the marketplace staging directory when conversion fails', async () => {
const marketplaceDir = path.join(testDir, '.claude-plugin');
fs.mkdirSync(marketplaceDir, { recursive: true });
fs.writeFileSync(
path.join(marketplaceDir, 'marketplace.json'),
JSON.stringify({
name: 'strict-marketplace',
owner: { name: 'Owner' },
plugins: [
{
name: 'strict-root',
version: '1.0.0',
source: './',
strict: true,
},
],
}),
);
const createTmpDir = ExtensionStorage.createTmpDir.bind(ExtensionStorage);
const tempDirs: string[] = [];
const createTmpDirSpy = vi
.spyOn(ExtensionStorage, 'createTmpDir')
.mockImplementation(async () => {
const tempDir = await createTmpDir();
tempDirs.push(tempDir);
return tempDir;
});

try {
await expect(
convertClaudePluginPackage(testDir, 'strict-root'),
).rejects.toThrow('Strict mode requires plugin.json');
expect(tempDirs).toHaveLength(1);
expect(fs.existsSync(tempDirs[0])).toBe(false);
} finally {
createTmpDirSpy.mockRestore();
}
});

it('should only collect specified skills when config provides explicit list', async () => {
// Setup: Create a plugin source with multiple skills
const pluginSourceDir = path.join(testDir, 'plugin-source');
Expand Down Expand Up @@ -875,7 +986,8 @@ describe('convertClaudePluginPackage', () => {
expect(
(result.config.hooks!['PostToolUse']![0].hooks![0] as { command: string })
.command,
).toBe(`${pluginSourceDir}/scripts/post-install.sh`);
).toBe(`${result.convertedDir}/scripts/post-install.sh`);
expect(fs.existsSync(result.convertedDir)).toBe(true);

// Clean up converted directory
fs.rmSync(result.convertedDir, { recursive: true, force: true });
Expand Down Expand Up @@ -1069,6 +1181,51 @@ describe('convertClaudePluginStandalone', () => {
fs.rmSync(result.convertedDir, { recursive: true, force: true });
});

it('stops a standalone conversion when its recursive copy is aborted', async () => {
const pluginDir = path.join(testDir, '.claude-plugin');
fs.mkdirSync(pluginDir, { recursive: true });
fs.writeFileSync(
path.join(pluginDir, 'plugin.json'),
JSON.stringify({ name: 'abort-copy', version: '1.0.0' }),
'utf-8',
);
const sourceDir = path.join(testDir, 'assets');
fs.mkdirSync(sourceDir, { recursive: true });
fs.writeFileSync(path.join(sourceDir, 'one.txt'), 'one', 'utf-8');
fs.writeFileSync(path.join(sourceDir, 'two.txt'), 'two', 'utf-8');

const controller = new AbortController();
const reason = new Error('conversion cancelled');
const copyFile = fs.promises.copyFile.bind(fs.promises);
const copySpy = vi
.spyOn(fs.promises, 'copyFile')
.mockImplementation(async (...args) => {
await copyFile(...args);
controller.abort(reason);
});
const createTmpDir = ExtensionStorage.createTmpDir.bind(ExtensionStorage);
const tempDirs: string[] = [];
const createTmpDirSpy = vi
.spyOn(ExtensionStorage, 'createTmpDir')
.mockImplementation(async () => {
const tempDir = await createTmpDir();
tempDirs.push(tempDir);
return tempDir;
});

try {
await expect(
convertClaudePluginStandalone(testDir, false, controller.signal),
).rejects.toBe(reason);
expect(copySpy).toHaveBeenCalledOnce();
expect(tempDirs).toHaveLength(1);
expect(fs.existsSync(tempDirs[0])).toBe(false);
} finally {
copySpy.mockRestore();
createTmpDirSpy.mockRestore();
}
});

it('throws when there is no .claude-plugin/plugin.json', async () => {
await expect(convertClaudePluginStandalone(testDir)).rejects.toThrow(
/Plugin configuration not found/,
Expand Down
Loading
Loading