From e6ed740eb16438e7f08ea8c64beb9940206f305d Mon Sep 17 00:00:00 2001 From: qqqys Date: Sun, 11 Oct 2026 12:59:02 +0000 Subject: [PATCH] feat(mods): add TypeScript authoring and configuration (PR2a) (#13890) * feat(mods): add TypeScript authoring and configuration * fix(mods): preserve marketplace dependencies and update notices --------- Co-authored-by: qqqys <266654365+qqqys@users.noreply.github.com> --- docs/users/extension/mods.md | 110 ++++++- esbuild.config.js | 4 + packages/cli/src/config/settings.test.ts | 35 ++ packages/cli/src/config/settingsSchema.ts | 33 +- packages/cli/src/config/settingsUtils.ts | 1 + .../src/nonInteractive/mod-session.test.ts | 310 +++++++++++++++++- .../cli/src/nonInteractive/mod-session.ts | 84 ++++- .../serve/routes/workspace-settings.test.ts | 13 + packages/core/package.json | 4 +- .../src/extension/claude-converter.test.ts | 76 +++-- .../core/src/extension/claude-converter.ts | 3 + packages/core/src/mods/dependencies.test.ts | 91 +++++ packages/core/src/mods/dependencies.ts | 109 ++++++ packages/core/src/mods/discovery.test.ts | 49 ++- packages/core/src/mods/discovery.ts | 55 ++-- packages/core/src/mods/guest.ts | 4 +- packages/core/src/mods/mod-worker.ts | 30 +- packages/core/src/mods/module-loader.test.ts | 249 ++++++++++++++ packages/core/src/mods/module-loader.ts | 169 ++++++++++ packages/core/src/mods/options.test.ts | 154 +++++++++ packages/core/src/mods/options.ts | 128 ++++++++ packages/core/src/mods/runtime.test.ts | 31 +- packages/core/src/mods/runtime.ts | 7 +- packages/core/src/mods/types.ts | 23 +- packages/vscode-ide-companion/NOTICES.txt | 103 ++++-- .../schemas/settings.schema.json | 33 +- pnpm-lock.yaml | 12 +- scripts/tests/mod-worker-bundle.test.js | 99 ++++++ 28 files changed, 1912 insertions(+), 107 deletions(-) create mode 100644 packages/core/src/mods/dependencies.test.ts create mode 100644 packages/core/src/mods/dependencies.ts create mode 100644 packages/core/src/mods/module-loader.test.ts create mode 100644 packages/core/src/mods/module-loader.ts create mode 100644 packages/core/src/mods/options.test.ts create mode 100644 packages/core/src/mods/options.ts create mode 100644 scripts/tests/mod-worker-bundle.test.js diff --git a/docs/users/extension/mods.md b/docs/users/extension/mods.md index 7db01039f2a..58d3cc22184 100644 --- a/docs/users/extension/mods.md +++ b/docs/users/extension/mods.md @@ -73,8 +73,8 @@ are supported. A stream-json process retains module variables between turns. | Surface | Behavior | | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Packaging | One self-contained `.js` or `.mjs` ESM module; canonical or custom hooks file inside the installed plugin | -| Entry | `register(on, options)`; async registration is awaited; options is empty | +| Packaging | One `.js`, `.mjs`, `.cjs`, `.ts`, `.mts` or `.cts` ESM entry; relative static imports and re-exports inside the installed plugin | +| Entry | `register(on, options)`; async registration is awaited; typed non-secret options are copied and deeply frozen | | Lifecycle | `session.start` before the first prompt; `session.end` on exit and conversation replacement; clear/resume do not reload the module | | Commands | Register during `session.start` using name, description and optional argumentHint; exact `command.run` matcher for the plugin's own command; return `{text}` or `{}` | | Middleware | Explicit `next(e)` and repeated calls; event input is frozen; host identity fields cannot change | @@ -82,7 +82,7 @@ are supported. A stream-json process retains module variables between turns. | Isolation | Separate QuickJS environment per plugin; no Node, filesystem or network APIs; memory, stack, source/output and execution limits; cancellable worker | This is a supported subset, not full compatibility with existing Claude Mods. -Imports, TypeScript, userConfig, dependencies, wildcard/nested matchers, +JSX/TSX, runtime package imports, wildcard/nested matchers, registration modifiers, advanced `next` metadata, cross-plugin interception, tools, context, state/store, rendering and hot reload are not implemented. Unsupported hooks or calls fail explicitly. Commands cannot return `context`, @@ -95,17 +95,115 @@ rejection. Disabled slash command names are matched against the resulting name. Mods do not run in TUI, ACP, managed/SSH, sandbox or session-agent entry points in this version. Safe mode, bare mode and `disableAllHooks` also prevent loading. -Restart after changing plugin code or extension activation. An already loaded +Restart after changing plugin code, options or extension activation. An already loaded command checks activation again before execution and refuses a disabled plugin. Native Qwen extensions with an absolute or external hooks manifest path continue to use only the classic hook loader. Claude hooks paths that conversion already ignores because they are absolute or outside the extension are also skipped by Mod discovery. Mod manifests must be relative paths inside the extension. +## TypeScript and relative imports + +Change the hooks entry to `./register.ts` to use TypeScript. Imports and +re-exports may use relative paths, extensionless paths, directory `index` files, +or JavaScript spellings of TypeScript files (`./helper.js` can resolve to +`helper.ts`). Exact files win. Next come `.js` → `.ts`, `.mjs` → `.mts`, and +`.cjs` → `.cts` substitutions, followed by `.ts`, `.js`, `.mjs`, `.cjs`, `.mts`, +`.cts` candidates, trying each file and then its directory index. All six +suffixes use ESM, including `.cjs` and `.cts`. Module cycles and shared imported +state within one plugin are supported. + +Compilation uses fixed options inside the cancellable worker; it does not +type-check or load `tsconfig.json`, package resolvers or build plugins. Type-only +imports, including types from `claude-code`, are erased. Runtime value imports +from `claude-code` require APIs outside this subset and are rejected, as are +package/Node/URL imports, dynamic imports, `require`, JSX and imported top-level +`await`. Guest code continues to execute only in QuickJS. + +## Plugin options + +Declare `userConfig` in the plugin manifest: + +```json +{ + "name": "hello-mod", + "version": "1.0.0", + "userConfig": { + "greeting": { + "type": "string", + "title": "Greeting", + "description": "Greeting used by /hello", + "default": "Hello" + } + } +} +``` + +Set options in user or system settings, then read `options.greeting` from the +second argument to `register`: + +```json +{ + "experimental": { "mods": true }, + "pluginConfigs": { + "hello-mod": { "options": { "greeting": "Welcome" } } + } +} +``` + +Fields require `type`, `title` and `description`. Supported types are `string`, +`number`, `boolean`, `directory` and `file`. Optional constraints include +`required`, `default`, numeric `min`/`max`, string `multiple`, and string +`options` choices. File and directory values are strings, with no existence +check or added filesystem access. `sensitive: true` is explicitly unsupported, +including when a default is provided; there is no secret storage or config UI. + +Qwen uses SystemDefaults → User → System precedence, merging individual option +keys. Workspace settings are ignored even in trusted workspaces. Qwen maps +extensions without marketplace provenance to the inline identity. They accept +`name` and `name@inline`; the latter wins within each settings source. +Marketplace plugins use their actual `name@marketplace` identity. A higher +priority source wins over either local alias from a lower priority source. + +As in the inspected Claude implementation, missing optional values become `''` +unless they have defaults; optional `''` also bypasses type checks. Required +values reject missing or `''`, while `false` and `0` remain valid. Invalid stored +string choices fall back to defaults. With a nonempty schema, unknown option +keys pass through too, limited to strings, numbers, booleans or string arrays. +An absent or empty schema receives `{}`. Options stay frozen until restart. + +## Installed dependencies + +Manifest `dependencies` accepts plugin names, `name@marketplace`, or objects +such as `{ "name": "base", "marketplace": "team", "version": "^1.0.0" }`. +Bare names refer to the consumer's marketplace. An installed inline plugin with +the same name can override a marketplace dependency, without a version check. +Otherwise, version ranges are checked against the installed extension's +manifest version. Ordinary extensions without Mods can satisfy dependencies. +Their transitive dependencies are checked when reachable from a selected Mod; +unrelated ordinary extensions' dependency declarations do not participate. + +Dependencies must already be installed, enabled and selected for this invocation. +Qwen does not fetch, install or enable them automatically. Selected Mods are +registered in stable consumer-before-dependency order for direct dependencies +between Mods; ordinary providers are not registration nodes. Each still has a separate +realm and its own command handlers; ordering does not yet provide cross-plugin +event wrapping or shared capabilities. + +Missing, ambiguous, incompatible or cyclic dependencies reject startup before +any Mod worker starts. Invalid Mod options also reject startup. This differs +from Claude's selective dependency demotion and cycle-order fallback; Qwen +retains the experimental subset's whole-session startup failure policy. + +## Resource limits + The reader accepts regular files up to 256 KiB inside the installed extension and -rejects symbolic links within that root. Each guest has a 64 MiB memory limit, +rejects symbolic links within that root. Graphs are limited to 64 files, 1 MiB +of input and 1 MiB of compiled code. These bounds and symlink rejection are +stricter than Claude's. Each guest has a 64 MiB memory limit, 1 MiB stack limit and a 10-second dispatch budget. Startup allows 30 seconds for -worker initialization. All session-end hooks share a 1.5-second shutdown budget. +worker initialization and compilation; the guest memory limit does not cover +the compiler's host memory. All session-end hooks share a 1.5-second shutdown budget. The author API is based on Claude Code 2.1.296 declarations and the [official Mod documentation](https://code.claude.com/docs/en/plugins/mods/create). diff --git a/esbuild.config.js b/esbuild.config.js index 07effc6ab98..d962858e9e8 100644 --- a/esbuild.config.js +++ b/esbuild.config.js @@ -273,6 +273,10 @@ const workerBuild = esbuild.build({ // Bundled CJS dependencies need the same require()-shim the main bundle uses for // CJS interop in ESM output. inject: [path.resolve(__dirname, 'scripts/esbuild-shims.js')], + define: { + __dirname: '__qwen_dirname', + __filename: '__qwen_filename', + }, banner: { js: `"use strict";`, }, diff --git a/packages/cli/src/config/settings.test.ts b/packages/cli/src/config/settings.test.ts index ed84b194f23..ddea1ffa427 100644 --- a/packages/cli/src/config/settings.test.ts +++ b/packages/cli/src/config/settings.test.ts @@ -4233,6 +4233,41 @@ describe('Settings Loading and Merging', () => { }); }); + it.each([true, false])( + 'excludes workspace pluginConfigs when trusted=%s', + (isTrusted) => { + vi.mocked(isWorkspaceTrusted).mockReturnValue({ + isTrusted, + source: 'file', + }); + (mockFsExistsSync as Mock).mockReturnValue(true); + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (p === USER_SETTINGS_PATH) + return JSON.stringify({ + pluginConfigs: { example: { options: { greeting: 'user' } } }, + }); + if (p === MOCK_WORKSPACE_SETTINGS_PATH) + return JSON.stringify({ + pluginConfigs: { + example: { options: { greeting: 'workspace', injected: true } }, + }, + }); + return '{}'; + }, + ); + const settings = loadSettings(MOCK_WORKSPACE_DIR); + expect(settings.merged.pluginConfigs).toEqual({ + example: { options: { greeting: 'user' } }, + }); + expect( + getSettingsWarnings(settings).some((warning) => + warning.includes('pluginConfigs'), + ), + ).toBe(true); + }, + ); + describe('WORKSPACE_RESTRICTED_SETTINGS as the single source', () => { it.each([undefined, 0, 25])( 'ignores workspace memory budget zero while preserving user budget %s', diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 137c42bc410..c790e92ce7e 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -39,6 +39,7 @@ import type { CustomTheme } from '../ui/themes/theme.js'; import { getLanguageSettingsOptions } from '../i18n/languages.js'; import { MergeStrategy } from '../utils/deepMerge.js'; import type { Mem0Settings } from './mem0-settings.js'; +import type { ModPluginConfigs } from '@qwen-code/qwen-code-core/mods/types.js'; export const DEFAULT_OPENAI_LOG_RETENTION_DAYS = 7; @@ -1620,6 +1621,36 @@ const SETTINGS_SCHEMA = { showInDialog: true, }, + pluginConfigs: { + type: 'object', + label: 'Plugin Configuration', + category: 'Extensions', + requiresRestart: true, + default: {} as ModPluginConfigs, + jsonSchemaOverride: { + type: 'object', + additionalProperties: { + type: 'object', + properties: { + options: { + type: 'object', + additionalProperties: { + anyOf: [ + { type: 'string' }, + { type: 'number' }, + { type: 'boolean' }, + { type: 'array', items: { type: 'string' } }, + ], + }, + }, + }, + }, + }, + description: + 'Mod options keyed by plugin name or name@marketplace. Only user and system settings apply; restart after changes.', + showInDialog: false, + }, + advisorModel: { type: 'string', label: 'Advisor Model', @@ -4251,7 +4282,7 @@ const SETTINGS_SCHEMA = { requiresRestart: true, default: false, description: - 'Run the experimental JavaScript Mod command subset from enabled extensions in trusted headless sessions.', + 'Run the experimental JavaScript and TypeScript Mod command subset from enabled extensions in trusted headless sessions.', showInDialog: false, }, liveVoice: { diff --git a/packages/cli/src/config/settingsUtils.ts b/packages/cli/src/config/settingsUtils.ts index fda3b694f57..c2bd6f2ada6 100644 --- a/packages/cli/src/config/settingsUtils.ts +++ b/packages/cli/src/config/settingsUtils.ts @@ -300,6 +300,7 @@ export const WORKSPACE_RESTRICTED_SETTINGS = [ export const WORKSPACE_RESTRICTED_ROOT_SETTINGS = [ 'advisorModel', 'advisorMaxUses', + 'pluginConfigs', ] as const; /** diff --git a/packages/cli/src/nonInteractive/mod-session.test.ts b/packages/cli/src/nonInteractive/mod-session.test.ts index f7ad35cc120..46ee9749121 100644 --- a/packages/cli/src/nonInteractive/mod-session.test.ts +++ b/packages/cli/src/nonInteractive/mod-session.test.ts @@ -5,7 +5,11 @@ */ import { afterEach, describe, expect, it, vi } from 'vitest'; +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; import type { Config } from '@qwen-code/qwen-code-core/config/config.js'; +import { convertClaudePluginPackage } from '@qwen-code/qwen-code-core/extension/claude-converter.js'; import { OutputFormat } from '@qwen-code/qwen-code-core/output/types.js'; import type { ModLog } from '@qwen-code/qwen-code-core/mods/types.js'; import type { LoadedSettings } from '../config/settings.js'; @@ -15,9 +19,14 @@ import { CommandService } from '../services/CommandService.js'; import { CommandKind, type CommandContext } from '../ui/commands/types.js'; import { registerCleanup, runExitCleanup } from '../utils/cleanup.js'; -const mocks = vi.hoisted(() => ({ load: vi.fn(), create: vi.fn() })); +const mocks = vi.hoisted(() => ({ + load: vi.fn(), + dependencies: vi.fn(), + create: vi.fn(), +})); vi.mock('@qwen-code/qwen-code-core/mods/discovery.js', () => ({ loadModSource: mocks.load, + loadModDependencies: mocks.dependencies, })); vi.mock('@qwen-code/qwen-code-core/mods/runtime.js', () => ({ ModRuntime: { create: mocks.create }, @@ -25,10 +34,12 @@ vi.mock('@qwen-code/qwen-code-core/mods/runtime.js', () => ({ describe('headless Mod session ownership', () => { const sessions: ModSession[] = []; + const tempDirs: string[] = []; const setup = (overrides: Record = {}) => { const extension = { id: 'id', name: 'hello-mod', + version: '1.0.0', path: '/mod', isActive: true, }; @@ -57,7 +68,13 @@ describe('headless Mod session ownership', () => { end: vi.fn().mockResolvedValue(undefined), dispose: vi.fn().mockResolvedValue(undefined), }; - mocks.load.mockResolvedValue('export function register() {}'); + mocks.load.mockResolvedValue({ + root: '/mod', + entry: '/mod/entry.js', + source: 'export function register() {}', + dependencies: [], + }); + mocks.dependencies.mockResolvedValue([]); mocks.create.mockImplementation( async (_source, _cwd, log: (entry: ModLog) => void) => { log({ to: 'transcript', text: 'startup log' }); @@ -73,6 +90,11 @@ describe('headless Mod session ownership', () => { }; afterEach(async () => { await Promise.all(sessions.splice(0).map((session) => session.close())); + await Promise.all( + tempDirs + .splice(0) + .map((dir) => fs.rm(dir, { recursive: true, force: true })), + ); vi.resetAllMocks(); }); @@ -130,6 +152,290 @@ describe('headless Mod session ownership', () => { expect(commands[0].modelInvocable).toBe(false); }); + it('uses operator scopes and validates options before starting any runtime', async () => { + const { config, adapter } = setup(); + mocks.load.mockResolvedValue({ + root: '/mod', + entry: '/mod/entry.ts', + source: 'export function register() {}', + dependencies: [], + userConfig: { + value: { + type: 'string', + title: 'Value', + description: 'Value', + required: true, + }, + }, + }); + const settings = { + merged: { + experimental: { mods: true }, + pluginConfigs: { 'hello-mod': { options: { value: 'workspace' } } }, + }, + systemDefaults: { settings: {} }, + user: { + settings: { + pluginConfigs: { 'hello-mod@inline': { options: { value: 'user' } } }, + }, + }, + system: { + settings: { + pluginConfigs: { 'hello-mod': { options: { value: 'system' } } }, + }, + }, + } as unknown as LoadedSettings; + const session = ModSession.create( + config as unknown as Config, + settings, + adapter as unknown as JsonOutputAdapterInterface, + )!; + sessions.push(session); + await session.initialize(new AbortController().signal); + expect(mocks.create.mock.calls[0][4]).toEqual({ value: 'system' }); + }); + + it.each(['missing', 'cycle', 'options'])( + 'starts no workers when a later plugin has invalid %s', + async (kind) => { + const extensions = [ + { id: 'a', name: 'a', path: '/a', version: '1.0.0', isActive: true }, + { id: 'b', name: 'b', path: '/b', version: '1.0.0', isActive: true }, + ]; + const { session } = setup({ getExtensions: () => extensions }); + mocks.load.mockImplementation(async (path: string) => ({ + root: path, + entry: path + '/entry.js', + source: '', + dependencies: + kind === 'cycle' + ? [{ name: path === '/a' ? 'b' : 'a' }] + : kind === 'missing' && path === '/b' + ? [{ name: 'absent' }] + : [], + userConfig: + kind === 'options' && path === '/b' + ? { + needed: { + type: 'string', + title: '', + description: '', + required: true, + }, + } + : undefined, + })); + await expect( + session.initialize(new AbortController().signal), + ).rejects.toThrow( + kind === 'cycle' + ? /cycle/ + : kind === 'missing' + ? /installed, enabled/ + : /required/, + ); + expect(mocks.create).not.toHaveBeenCalled(); + }, + ); + + it('orders Mod consumers before providers and accepts an ordinary extension dependency', async () => { + const extensions = ['base', 'ordinary', 'consumer'].map((name) => ({ + id: name + '-id', + name, + path: '/' + name, + version: '1.0.0', + isActive: true, + })); + const { session } = setup({ getExtensions: () => extensions }); + mocks.load.mockImplementation(async (path: string) => + path === '/ordinary' + ? undefined + : { + root: path, + entry: path + '/entry.js', + source: '', + dependencies: + path === '/consumer' + ? [{ name: 'base' }, { name: 'ordinary' }] + : [], + }, + ); + await session.initialize(new AbortController().signal); + expect(mocks.create.mock.calls.map(([source]) => source.root)).toEqual([ + '/consumer', + '/base', + ]); + }); + + it('checks transitive ordinary dependencies but ignores unrelated declarations', async () => { + const extensions = ['consumer', 'ordinary', 'unrelated'].map((name) => ({ + id: name, + name, + path: '/' + name, + version: '1.0.0', + isActive: true, + })); + const { session } = setup({ getExtensions: () => extensions }); + mocks.load.mockImplementation(async (path: string) => + path !== '/consumer' + ? undefined + : { + root: path, + entry: path + '/entry.js', + source: '', + dependencies: [{ name: 'ordinary' }], + }, + ); + mocks.dependencies.mockResolvedValue([{ name: 'missing' }]); + await expect( + session.initialize(new AbortController().signal), + ).rejects.toThrow(/ordinary.*missing.*installed/); + expect(mocks.dependencies).toHaveBeenCalledExactlyOnceWith('/ordinary'); + expect(mocks.create).not.toHaveBeenCalled(); + }); + + it('does not turn an ordinary provider into a Mod ordering node', async () => { + const extensions = ['base', 'ordinary', 'consumer'].map((name) => ({ + id: name, + name, + path: '/' + name, + version: '1.0.0', + isActive: true, + })); + const { session } = setup({ getExtensions: () => extensions }); + mocks.load.mockImplementation(async (path: string) => + path === '/ordinary' + ? undefined + : { + root: path, + entry: path + '/entry.js', + source: '', + dependencies: path === '/consumer' ? [{ name: 'ordinary' }] : [], + }, + ); + mocks.dependencies.mockResolvedValue([{ name: 'base' }]); + await session.initialize(new AbortController().signal); + expect(mocks.create.mock.calls.map(([source]) => source.root)).toEqual([ + '/base', + '/consumer', + ]); + }); + + describe.each(['direct', 'ordinary'] as const)( + 'converted marketplace %s dependencies', + (via) => { + it.each(['missing', 'incompatible', 'satisfied', 'invalid'])( + 'validates %s dependencies before starting a worker', + async (state) => { + const root = await fs.mkdtemp( + path.join(os.tmpdir(), 'mod-marketplace-'), + ); + tempDirs.push(root); + const names = [ + 'consumer', + ...(via === 'ordinary' ? ['ordinary'] : []), + ]; + if (state !== 'missing') names.push('base'); + const entries = []; + for (const name of names) { + const version = + name === 'base' && state === 'incompatible' ? '1.0.0' : '2.0.0'; + const declaresDependency = + name === (via === 'direct' ? 'consumer' : 'ordinary'); + entries.push({ + name, + version, + source: `./${name}`, + ...(declaresDependency + ? { + dependencies: + state === 'invalid' + ? false + : [{ name: 'base', version: '^2.0.0' }], + } + : {}), + }); + await fs.mkdir(path.join(root, name, '.claude-plugin'), { + recursive: true, + }); + await fs.writeFile( + path.join(root, name, '.claude-plugin/plugin.json'), + JSON.stringify({ + name, + version, + ...(name === 'consumer' && via === 'ordinary' + ? { dependencies: ['ordinary'] } + : {}), + }), + ); + } + await fs.mkdir(path.join(root, '.claude-plugin')); + await fs.writeFile( + path.join(root, '.claude-plugin/marketplace.json'), + JSON.stringify({ + name: 'fixture', + owner: { name: 'Fixture' }, + plugins: entries, + }), + ); + await fs.mkdir(path.join(root, 'consumer/hooks')); + await fs.writeFile( + path.join(root, 'consumer/hooks/hooks.json'), + JSON.stringify({ modules: ['./entry.js'] }), + ); + await fs.writeFile( + path.join(root, 'consumer/hooks/entry.js'), + 'export function register() {}', + ); + const extensions = await Promise.all( + names.map(async (name) => { + const { convertedDir, config } = await convertClaudePluginPackage( + root, + name, + ); + tempDirs.push(convertedDir); + return { + ...config, + id: name, + path: convertedDir, + isActive: true, + installMetadata: { marketplaceConfig: { name: 'fixture' } }, + }; + }), + ); + const { session } = setup({ getExtensions: () => extensions }); + const discovery = await vi.importActual< + typeof import('@qwen-code/qwen-code-core/mods/discovery.js') + >('@qwen-code/qwen-code-core/mods/discovery.js'); + mocks.load.mockImplementation(discovery.loadModSource); + mocks.dependencies.mockImplementation(discovery.loadModDependencies); + + const initialization = session.initialize( + new AbortController().signal, + ); + if (state === 'satisfied') { + await initialization; + expect(mocks.create).toHaveBeenCalledTimes(1); + expect( + (await session.loadCommands(new AbortController().signal)).map( + ({ name }) => name, + ), + ).toEqual(['hello']); + } else { + await expect(initialization).rejects.toThrow( + state === 'missing' + ? /dependency base.*installed, enabled/ + : state === 'incompatible' + ? /dependency base.*incompatible installed version/ + : /Invalid Mod dependencies declaration/, + ); + expect(mocks.create).not.toHaveBeenCalled(); + } + }, + ); + }, + ); + it('keeps Agent Plugins v1 hooks ignored', async () => { const { session } = setup({ getExtensions: () => [{ isActive: true, format: 'agent-plugins-v1' }], diff --git a/packages/cli/src/nonInteractive/mod-session.ts b/packages/cli/src/nonInteractive/mod-session.ts index 8af5941f60f..272a38c52b0 100644 --- a/packages/cli/src/nonInteractive/mod-session.ts +++ b/packages/cli/src/nonInteractive/mod-session.ts @@ -6,7 +6,15 @@ import type { Config } from '@qwen-code/qwen-code-core/config/config.js'; import { OutputFormat } from '@qwen-code/qwen-code-core/output/types.js'; -import { loadModSource } from '@qwen-code/qwen-code-core/mods/discovery.js'; +import { + loadModSource, + loadModDependencies, +} from '@qwen-code/qwen-code-core/mods/discovery.js'; +import { + orderModPlugins, + resolveModDependency, +} from '@qwen-code/qwen-code-core/mods/dependencies.js'; +import { resolveModOptions } from '@qwen-code/qwen-code-core/mods/options.js'; import { ModRuntime } from '@qwen-code/qwen-code-core/mods/runtime.js'; import { MOD_END_TIMEOUT_MS } from '@qwen-code/qwen-code-core/mods/types.js'; import type { ModLog } from '@qwen-code/qwen-code-core/mods/types.js'; @@ -39,6 +47,7 @@ export class ModSession implements ICommandLoader { constructor( private readonly config: Config, private readonly adapter: JsonOutputAdapterInterface, + private readonly optionScopes: readonly unknown[] = [], ) { this.sessionId = config.getSessionId(); this.unregisterCleanup = registerCleanup(() => { @@ -54,7 +63,11 @@ export class ModSession implements ICommandLoader { adapter: JsonOutputAdapterInterface, ): ModSession | undefined { return settings.merged.experimental?.mods - ? new ModSession(config, adapter) + ? new ModSession(config, adapter, [ + settings.systemDefaults.settings.pluginConfigs, + settings.user.settings.pluginConfigs, + settings.system.settings.pluginConfigs, + ]) : undefined; } @@ -91,6 +104,7 @@ export class ModSession implements ICommandLoader { private async load(signal: AbortSignal): Promise { try { + const candidates = []; for (const extension of this.config .getExtensions() .filter( @@ -99,15 +113,77 @@ export class ModSession implements ICommandLoader { signal.throwIfAborted(); try { const source = await loadModSource(extension.path); - if (source === undefined) continue; - const runtime = await ModRuntime.create( + const marketplace = + extension.installMetadata?.marketplaceConfig?.name; + candidates.push({ + extension, + name: extension.name, + version: extension.version, + marketplace, source, + dependencies: source?.dependencies ?? [], + options: source + ? resolveModOptions( + source.userConfig, + this.optionScopes, + extension.name, + marketplace, + ) + : {}, + }); + } catch (error) { + throw new Error( + `Mod ${extension.name}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + const pending = candidates.filter((item) => item.source); + const reachable = new Set<(typeof candidates)[number]>(); + while (pending.length) { + signal.throwIfAborted(); + const plugin = pending.shift()!; + if (reachable.has(plugin)) continue; + reachable.add(plugin); + if (!plugin.source) { + try { + plugin.dependencies = await loadModDependencies( + plugin.extension.path, + ); + } catch (error) { + throw new Error( + `Mod dependency ${plugin.name}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + for (const dependency of plugin.dependencies) { + pending.push(resolveModDependency(candidates, plugin, dependency)); + } + } + const selected = candidates.filter((item) => reachable.has(item)); + orderModPlugins(selected); + // Ordinary providers participate in preflight, not Mod registration order. + const mods = selected + .filter((item) => item.source) + .map((plugin) => ({ + ...plugin, + dependencies: plugin.dependencies.filter( + (dependency) => + resolveModDependency(selected, plugin, dependency).source, + ), + })); + for (const { extension, source, options } of orderModPlugins(mods)) { + if (!source) continue; + signal.throwIfAborted(); + try { + const runtime = await ModRuntime.create( + { root: source.root, entry: source.entry, source: source.source }, this.config.getTargetDir(), (entry) => { this.pendingLogs.push({ ...entry, plugin: extension.name }); if (this.logsReady) this.flushLogs(); }, signal, + options, ); this.mods.push({ id: extension.id, name: extension.name, runtime }); } catch (error) { diff --git a/packages/cli/src/serve/routes/workspace-settings.test.ts b/packages/cli/src/serve/routes/workspace-settings.test.ts index cbcc010a992..3013e459d95 100644 --- a/packages/cli/src/serve/routes/workspace-settings.test.ts +++ b/packages/cli/src/serve/routes/workspace-settings.test.ts @@ -732,6 +732,19 @@ describe('POST /workspace/settings', () => { expect(persistSetting).toHaveBeenCalled(); }); + it('refuses workspace pluginConfigs without persisting operator options', async () => { + const { app, persistSetting } = makeApp(); + const response = await request(app) + .post('/workspace/settings') + .send({ + scope: 'workspace', + key: 'pluginConfigs', + value: { example: { options: { greeting: 'injected' } } }, + }); + expect(response.status).toBe(400); + expect(persistSetting).not.toHaveBeenCalled(); + }); + describe('aux-model selector credential scrubbing', () => { // visionModel / imageModel / advisorModel / fastModel persist as // `authType:id\0baseUrl`; a userinfo-bearing baseUrl is a credential and diff --git a/packages/core/package.json b/packages/core/package.json index caf4ee6c0ff..e6fa535437c 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -160,12 +160,14 @@ "prompts": "^2.4.2", "proper-lockfile": "^4.1.2", "quickjs-emscripten-core": "0.32.0", + "semver": "^7.7.2", "sharp": "^0.35.4", "shell-quote": "^1.9.0", "simple-git": "^3.36.0", "strip-ansi": "^7.1.0", "tar": "^7.5.19", "turndown": "^7.2.4", + "typescript": "^5.3.3", "undici": "^7.28.0", "uuid": "^9.0.1", "web-tree-sitter": "^0.24.7", @@ -192,6 +194,7 @@ "@types/node": "20.19.1", "@types/picomatch": "^4.0.1", "@types/prompts": "^2.4.9", + "@types/semver": "^7.7.1", "@types/tar": "^6.1.13", "@types/turndown": "^5.0.6", "@types/ws": "^8.5.10", @@ -199,7 +202,6 @@ "msw": "^2.3.4", "strip-json-comments": "^3.1.1", "tree-sitter-wasms": "^0.1.13", - "typescript": "^5.3.3", "vitest": "^3.1.1" }, "engines": { diff --git a/packages/core/src/extension/claude-converter.test.ts b/packages/core/src/extension/claude-converter.test.ts index 5782d81531f..75cb072b35f 100644 --- a/packages/core/src/extension/claude-converter.test.ts +++ b/packages/core/src/extension/claude-converter.test.ts @@ -752,7 +752,7 @@ describe('convertClaudePluginPackage', () => { ); const result = await convertPackage(dir, 'marketplace-mod'); - expect(await loadModSource(result.convertedDir)).toBe(source); + expect((await loadModSource(result.convertedDir))?.source).toBe(source); expect(result.config.hooks).toEqual({ SessionStart: [ { @@ -778,28 +778,66 @@ describe('convertClaudePluginPackage', () => { }, ); - it.each(['userConfig', 'dependencies'])( - 'keeps unsupported Mod %s visible after marketplace conversion', - async (field) => { - const { result } = await layoutAndConvert( - 'unsupported-mod-config', - { name: 'unsupported-mod', hooks: './custom/hooks.json' }, + it('preserves Mod authoring metadata after marketplace conversion', async () => { + const userConfig = { + greeting: { + type: 'string', + title: 'Greeting', + description: 'A greeting', + }, + }; + const dependencies = [ + { name: 'base', marketplace: 'example', version: '^1.0.0' }, + ]; + const { result } = await layoutAndConvert( + 'unsupported-mod-config', + { name: 'unsupported-mod', hooks: './custom/hooks.json' }, + { + '.claude-plugin/plugin.json': JSON.stringify({ + name: 'unsupported-mod', + version: '1.0.0', + userConfig, + dependencies, + }), + 'custom/hooks.json': JSON.stringify({ + modules: ['./entry.js'], + hooks: {}, + }), + 'custom/entry.js': 'export function register() {}', + }, + ); + const definition = await loadModSource(result.convertedDir); + expect(definition?.userConfig).toEqual(userConfig); + expect(definition?.dependencies).toEqual(dependencies); + }); + + it.each([ + { dependencies: [{ name: 'marketplace-base', version: '^2.0.0' }] }, + { dependencies: [] }, + ])( + 'uses marketplace dependency declarations over plugin.json: $dependencies', + async ({ dependencies }) => { + const originalManifest = JSON.stringify({ + name: 'consumer', + version: '1.0.0', + dependencies: ['plugin-base'], + }); + const { dir, result } = await layoutAndConvert( + 'marketplace-dependencies', + { name: 'consumer', dependencies }, { - '.claude-plugin/plugin.json': JSON.stringify({ - name: 'unsupported-mod', - version: '1.0.0', - [field]: { required: 'value' }, - }), - 'custom/hooks.json': JSON.stringify({ - modules: ['./entry.js'], - hooks: {}, - }), - 'custom/entry.js': 'export function register() {}', + '.claude-plugin/plugin.json': originalManifest, + 'hooks/hooks.json': JSON.stringify({ modules: ['./entry.js'] }), + 'hooks/entry.js': 'export function register() {}', }, ); - await expect(loadModSource(result.convertedDir)).rejects.toThrow( - `Mod ${field} is not supported yet.`, + + expect((await loadModSource(result.convertedDir))?.dependencies).toEqual( + dependencies, ); + expect( + fs.readFileSync(path.join(dir, '.claude-plugin/plugin.json'), 'utf8'), + ).toBe(originalManifest); }, ); diff --git a/packages/core/src/extension/claude-converter.ts b/packages/core/src/extension/claude-converter.ts index 5e0371deff4..5763b35976c 100644 --- a/packages/core/src/extension/claude-converter.ts +++ b/packages/core/src/extension/claude-converter.ts @@ -53,6 +53,7 @@ export interface ClaudePluginConfig { repository?: string; license?: string; keywords?: string[]; + dependencies?: unknown; commands?: string | string[]; agents?: string | string[]; skills?: string | string[]; @@ -1069,6 +1070,8 @@ export function mergeClaudeConfigs( merged.repository = marketplacePlugin.repository; if (marketplacePlugin.license) merged.license = marketplacePlugin.license; if (marketplacePlugin.keywords) merged.keywords = marketplacePlugin.keywords; + if (marketplacePlugin.dependencies !== undefined) + merged.dependencies = marketplacePlugin.dependencies; if (marketplacePlugin.commands) merged.commands = marketplacePlugin.commands; if (marketplacePlugin.agents) merged.agents = marketplacePlugin.agents; if (marketplacePlugin.skills) merged.skills = marketplacePlugin.skills; diff --git a/packages/core/src/mods/dependencies.test.ts b/packages/core/src/mods/dependencies.test.ts new file mode 100644 index 00000000000..4c625d8a267 --- /dev/null +++ b/packages/core/src/mods/dependencies.test.ts @@ -0,0 +1,91 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { orderModPlugins, parseModDependencies } from './dependencies.js'; +import type { ModDependency } from './types.js'; + +const plugin = ( + name: string, + dependencies: ModDependency[] = [], + marketplace?: string, + version = '1.0.0', +) => ({ name, dependencies, marketplace, version }); + +describe('installed Mod dependencies', () => { + it('parses qualified strings and preserves object version constraints', () => { + expect(parseModDependencies(['base.v2@market.dev'])).toEqual([ + { name: 'base.v2', marketplace: 'market.dev' }, + ]); + expect(() => parseModDependencies(['_bad'])).toThrow(/declaration/); + expect( + parseModDependencies([ + 'base', + 'other@market', + { name: 'third', marketplace: 'elsewhere', version: '^2.0.0' }, + ]), + ).toEqual([ + { name: 'base' }, + { name: 'other', marketplace: 'market' }, + { name: 'third', marketplace: 'elsewhere', version: '^2.0.0' }, + ]); + expect(() => parseModDependencies({ base: '*' })).toThrow(/declaration/); + expect(() => + parseModDependencies([{ name: 'base', version: 'not-semver' }]), + ).toThrow(/version range/); + }); + + it('registers consumers first while retaining stable unrelated order', () => { + const provider = plugin('base'); + const unrelated = plugin('unrelated'); + const consumer = plugin('consumer', [{ name: 'base' }]); + expect(orderModPlugins([provider, unrelated, consumer])).toEqual([ + unrelated, + consumer, + provider, + ]); + }); + + it('resolves same-market versions and qualified local overrides', () => { + const consumer = plugin( + 'consumer', + [{ name: 'base', version: '^2.0.0' }], + 'market', + ); + expect(() => + orderModPlugins([consumer, plugin('base', [], 'other', '2.0.0')]), + ).toThrow(/installed, enabled/); + expect(() => + orderModPlugins([consumer, plugin('base', [], 'market')]), + ).toThrow(/incompatible/); + expect( + orderModPlugins([consumer, plugin('base', [], 'market', '2.1.0')]), + ).toHaveLength(2); + expect(orderModPlugins([consumer, plugin('base')])).toHaveLength(2); + expect( + orderModPlugins([ + plugin('consumer', [ + { name: 'base', marketplace: 'market', version: '^2.0.0' }, + ]), + plugin('base'), + ]), + ).toHaveLength(2); + }); + + it('rejects missing, ambiguous and cyclic graphs', () => { + const consumer = plugin('consumer', [{ name: 'base' }]); + expect(() => orderModPlugins([consumer])).toThrow(/installed, enabled/); + expect(() => + orderModPlugins([consumer, plugin('base'), plugin('base')]), + ).toThrow(/one installed/); + expect(() => + orderModPlugins([consumer, plugin('base', [{ name: 'consumer' }])]), + ).toThrow(/cycle/); + expect(() => orderModPlugins([plugin('self', [{ name: 'self' }])])).toThrow( + /cycle/, + ); + }); +}); diff --git a/packages/core/src/mods/dependencies.ts b/packages/core/src/mods/dependencies.ts new file mode 100644 index 00000000000..84c2fbd5080 --- /dev/null +++ b/packages/core/src/mods/dependencies.ts @@ -0,0 +1,109 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { satisfies, validRange } from 'semver'; +import { z } from 'zod'; +import type { ModDependency } from './types.js'; + +const name = z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/); +const dependency = z.union([ + z + .string() + .regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]*(?:@[a-zA-Z0-9][a-zA-Z0-9._-]*)?$/), + z + .object({ + name, + marketplace: name.optional(), + version: z.string().optional(), + }) + .strict(), +]); + +export function parseModDependencies(value: unknown): ModDependency[] { + const parsed = z + .array(dependency) + .safeParse(value === undefined ? [] : value); + if (!parsed.success) throw new Error('Invalid Mod dependencies declaration.'); + return parsed.data.map((item) => { + if (typeof item === 'string') { + const [name, marketplace] = item.split('@'); + return { name, marketplace }; + } + if (item.version !== undefined && !validRange(item.version)) { + throw new Error(`Invalid Mod dependency version range for ${item.name}.`); + } + return item; + }); +} + +interface ModPlugin { + name: string; + marketplace?: string; + version: string; + dependencies: ModDependency[]; +} + +export function resolveModDependency( + plugins: readonly T[], + plugin: T, + dependency: ModDependency, +): T { + const marketplace = dependency.marketplace ?? plugin.marketplace; + const local = plugins.filter( + (item) => item.name === dependency.name && !item.marketplace, + ); + const matches = local.length + ? local + : plugins.filter( + (item) => + item.name === dependency.name && item.marketplace === marketplace, + ); + if (matches.length !== 1) { + throw new Error( + `Mod ${plugin.name}: dependency ${dependency.name} must identify one installed, enabled plugin.`, + ); + } + const target = matches[0]; + if ( + target.marketplace && + dependency.version && + !satisfies(target.version, dependency.version) + ) { + throw new Error( + `Mod ${plugin.name}: dependency ${dependency.name} has an incompatible installed version.`, + ); + } + return target; +} + +export function orderModPlugins( + plugins: readonly T[], +): T[] { + const edges = plugins.map(() => new Set()); + const incoming = plugins.map(() => 0); + for (const [index, plugin] of plugins.entries()) { + for (const dependency of plugin.dependencies) { + const targetIndex = plugins.indexOf( + resolveModDependency(plugins, plugin, dependency), + ); + if (!edges[index].has(targetIndex)) { + edges[index].add(targetIndex); + incoming[targetIndex]++; + } + } + } + const ordered: T[] = []; + const remaining = new Set(plugins.map((_, index) => index)); + while (remaining.size) { + const next = [...remaining].find((index) => incoming[index] === 0); + if (next === undefined) + throw new Error('Mod dependency cycle: activation refused.'); + remaining.delete(next); + ordered.push(plugins[next]); + for (const target of edges[next]) incoming[target]--; + } + return ordered; +} diff --git a/packages/core/src/mods/discovery.test.ts b/packages/core/src/mods/discovery.test.ts index 016b73d3204..bcc3afca017 100644 --- a/packages/core/src/mods/discovery.test.ts +++ b/packages/core/src/mods/discovery.test.ts @@ -8,7 +8,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { promises as fs } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { loadModSource } from './discovery.js'; +import { loadModSource, loadModDependencies } from './discovery.js'; import { MOD_MAX_BYTES } from './types.js'; describe('Mod discovery', () => { @@ -31,7 +31,7 @@ describe('Mod discovery', () => { it('reads the source without executing module code', async () => { await write('hooks/hooks.json', { modules: ['./register.js'], hooks: {} }); await write('hooks/register.js', 'throw new Error("MUST NOT EXECUTE");'); - expect(await loadModSource(root)).toContain('MUST NOT EXECUTE'); + expect((await loadModSource(root))?.source).toContain('MUST NOT EXECUTE'); }); it('keeps a converted Claude custom manifest relative to its original file', async () => { @@ -42,7 +42,9 @@ describe('Mod discovery', () => { }); await write('custom/hooks.json', { modules: ['./entry.mjs'] }); await write('custom/entry.mjs', 'export function register() {}'); - expect(await loadModSource(root)).toContain('export function register'); + expect((await loadModSource(root))?.source).toContain( + 'export function register', + ); }); it('ignores ordinary extensions without modules', async () => { @@ -102,13 +104,15 @@ describe('Mod discovery', () => { }); await write('custom/hooks.json', { modules: ['./entry.js'] }); await write('custom/entry.js', 'export function register() {}'); - expect(await loadModSource(root)).toBe('export function register() {}'); + expect((await loadModSource(root))?.source).toBe( + 'export function register() {}', + ); }); it.each([ [[], /one relative/], [['./a.js', './b.js'], /one relative/], - [['./entry.ts'], /one relative/], + [['./entry.tsx'], /one relative/], [['/tmp/entry.js'], /one relative/], [['../../escape.js'], /inside/], ])('rejects invalid modules %j', async (modules, error) => { @@ -129,16 +133,39 @@ describe('Mod discovery', () => { await expect(loadModSource(root)).rejects.toThrow(/symbolic links/); }); - it('rejects oversized files and undeclared configuration support', async () => { + it('rejects oversized files', async () => { await write('hooks/hooks.json', { modules: ['./entry.js'] }); await write('hooks/entry.js', ' '.repeat(MOD_MAX_BYTES + 1)); await expect(loadModSource(root)).rejects.toThrow(/256 KiB/); - await write('.claude-plugin/plugin.json', { - name: 'config', - userConfig: { greeting: { type: 'string' } }, + }); + + it('reads ordinary provider dependencies without probing an external hooks path', async () => { + await write('qwen-extension.json', { + hooks: '../outside/hooks.json', + dependencies: ['base.v2'], }); - await expect(loadModSource(root)).rejects.toThrow( - /userConfig is not supported/, + expect(await loadModSource(root)).toBeUndefined(); + expect(await loadModDependencies(root)).toEqual([{ name: 'base.v2' }]); + }); + + it('preserves configuration and parses dependencies without executing TypeScript', async () => { + await write('hooks/hooks.json', { modules: ['./entry.ts'] }); + await write('hooks/entry.ts', 'throw new Error("MUST NOT EXECUTE");'); + const userConfig = { + greeting: { type: 'string', title: 'Greeting', description: 'Greeting' }, + }; + await write('.claude-plugin/plugin.json', { + userConfig, + dependencies: ['base@market', { name: 'other', version: '^1.0.0' }], + }); + const definition = await loadModSource(root); + expect(definition?.userConfig).toEqual(userConfig); + expect(definition?.dependencies).toEqual([ + { name: 'base', marketplace: 'market' }, + { name: 'other', version: '^1.0.0' }, + ]); + expect(definition?.entry).toBe( + path.join(await fs.realpath(root), 'hooks/entry.ts'), ); }); }); diff --git a/packages/core/src/mods/discovery.ts b/packages/core/src/mods/discovery.ts index 83aa0d96660..97a5429a899 100644 --- a/packages/core/src/mods/discovery.ts +++ b/packages/core/src/mods/discovery.ts @@ -7,7 +7,8 @@ import { promises as fs } from 'node:fs'; import path from 'node:path'; import { openNoFollow } from '../utils/no-follow-open.js'; -import { MOD_MAX_BYTES } from './types.js'; +import { MOD_MAX_BYTES, type ModDefinition } from './types.js'; +import { parseModDependencies } from './dependencies.js'; function isContainedFile(root: string, file: string): boolean { const relative = path.relative(root, file); @@ -19,7 +20,10 @@ function isContainedFile(root: string, file: string): boolean { ); } -async function readWithin(root: string, file: string): Promise { +export async function readModFile( + root: string, + file: string, +): Promise<{ entry: string; source: string }> { if (!isContainedFile(root, file)) { throw new Error('Mod file must stay inside the installed extension.'); } @@ -31,10 +35,16 @@ async function readWithin(root: string, file: string): Promise { throw new Error('Mod files cannot be symbolic links.'); } if (current === file && !stat.isFile()) { - throw new Error('Mod input must be a regular file.'); + throw Object.assign(new Error('Mod input must be a regular file.'), { + code: stat.isDirectory() ? 'EISDIR' : undefined, + }); } } - const handle = await openNoFollow(file); + const entry = await fs.realpath(file); + if (!isContainedFile(root, entry)) { + throw new Error('Mod file must stay inside the installed extension.'); + } + const handle = await openNoFollow(entry); try { const stat = await handle.stat(); if (!stat.isFile() || stat.size > MOD_MAX_BYTES) { @@ -43,7 +53,7 @@ async function readWithin(root: string, file: string): Promise { const buffer = Buffer.alloc(MOD_MAX_BYTES + 1); const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0); if (bytesRead > MOD_MAX_BYTES) throw new Error('Mod file is too large.'); - return buffer.subarray(0, bytesRead).toString('utf8'); + return { entry, source: buffer.subarray(0, bytesRead).toString('utf8') }; } finally { await handle.close(); } @@ -58,7 +68,7 @@ async function readManifest( throw new Error('Mod manifest path must be relative to the extension.'); } const value: unknown = JSON.parse( - await readWithin(root, path.resolve(root, relative)), + (await readModFile(root, path.resolve(root, relative))).source, ); if (!value || typeof value !== 'object' || Array.isArray(value)) { throw new Error('Mod manifest must be a JSON object.'); @@ -72,7 +82,7 @@ async function readManifest( export async function loadModSource( extensionPath: string, -): Promise { +): Promise { const root = await fs.realpath(extensionPath); const qwen = await readManifest(root, 'qwen-extension.json'); // External Qwen hook manifests belong to the existing classic hook loader. @@ -106,20 +116,25 @@ export async function loadModSource( modules.length !== 1 || typeof modules[0] !== 'string' || path.isAbsolute(modules[0]) || - !/\.(?:js|mjs)$/.test(modules[0]) + !/\.(?:[cm]?js|[cm]?ts)$/.test(modules[0]) ) { - throw new Error('Mods currently require one relative .js or .mjs module.'); + throw new Error( + 'Mods require one relative JavaScript or TypeScript module.', + ); } - for (const manifest of [qwen, claude]) { - for (const key of ['userConfig', 'dependencies']) { - const value = manifest?.[key]; - if (value !== undefined && Object.keys(Object(value)).length > 0) { - throw new Error(`Mod ${key} is not supported yet.`); - } - } - } - return readWithin( + const manifest = { ...claude, ...qwen }; + const entry = path.resolve(root, path.dirname(hooksPath), modules[0]); + return { root, - path.resolve(root, path.dirname(hooksPath), modules[0]), - ); + ...(await readModFile(root, entry)), + userConfig: manifest['userConfig'], + dependencies: parseModDependencies(manifest['dependencies']), + }; +} + +export async function loadModDependencies(extensionPath: string) { + const root = await fs.realpath(extensionPath); + const qwen = await readManifest(root, 'qwen-extension.json'); + const claude = await readManifest(root, '.claude-plugin/plugin.json'); + return parseModDependencies({ ...claude, ...qwen }['dependencies']); } diff --git a/packages/core/src/mods/guest.ts b/packages/core/src/mods/guest.ts index d76ffaf3efa..2ea3f2a7378 100644 --- a/packages/core/src/mods/guest.ts +++ b/packages/core/src/mods/guest.ts @@ -119,10 +119,10 @@ export const MOD_GUEST_FACTORY = String.raw` }; return { - load: async (register, cwd) => { + load: async (register, cwd, options) => { if (typeof register !== 'function') throw new Error('Mod must export register(on, options)'); registering = true; - try { await register(on, freeze({})); } finally { registering = false; } + try { await register(on, frozen(parse(options))); } finally { registering = false; } starting = true; try { await dispatch('session.start', { cwd, surface: null, isInteractive: false }); } finally { starting = false; } diff --git a/packages/core/src/mods/mod-worker.ts b/packages/core/src/mods/mod-worker.ts index 92c17dbd333..e6e409c0167 100644 --- a/packages/core/src/mods/mod-worker.ts +++ b/packages/core/src/mods/mod-worker.ts @@ -11,6 +11,7 @@ import { type QuickJSHandle, } from 'quickjs-emscripten-core'; import { MOD_GUEST_FACTORY } from './guest.js'; +import { compileModModules } from './module-loader.js'; import { MOD_END_TIMEOUT_MS, MOD_MAX_BYTES, @@ -24,11 +25,6 @@ const wasm = await newQuickJSWASMModuleFromVariant(variant); const runtime = wasm.newRuntime(); runtime.setMemoryLimit(64 * 1024 * 1024); runtime.setMaxStackSize(1024 * 1024); -runtime.setModuleLoader(() => { - throw new Error( - 'Mod imports are not supported yet; use a self-contained JS module.', - ); -}); let deadline = Infinity; runtime.setInterruptHandler(() => Date.now() >= deadline); const vm = runtime.newContext(); @@ -85,10 +81,24 @@ port.on('message', async (request: ModRequest) => { let pending: QuickJSHandle; if (request.type === 'load') { if (loaded) throw new Error('Mod already loaded.'); - if (Buffer.byteLength(request.source) > MOD_MAX_BYTES) - throw new Error('Mod source is too large.'); + const { modules, imports } = await compileModModules(request.source); + runtime.setModuleLoader( + (name) => { + const source = modules.get(name); + if (source === undefined) throw new Error('Unknown Mod module.'); + return source; + }, + (base, name) => { + const resolved = imports.get(base)?.get(name); + if (!resolved) throw new Error('Unknown Mod import.'); + return resolved; + }, + ); + deadline = Date.now() + MOD_TIMEOUT_MS; const evaluation = vm.unwrapResult( - vm.evalCode(request.source, 'mod.mjs', { type: 'module' }), + vm.evalCode(modules.get(request.source.entry)!, request.source.entry, { + type: 'module', + }), ); let namespace: QuickJSHandle; try { @@ -98,13 +108,15 @@ port.on('message', async (request: ModRequest) => { } const register = vm.getProp(namespace, 'register'); const cwd = vm.newString(request.cwd); + const options = vm.newString(JSON.stringify(request.options)); try { pending = vm.unwrapResult( - vm.callMethod(guest, 'load', [register, cwd]), + vm.callMethod(guest, 'load', [register, cwd, options]), ); } finally { register.dispose(); cwd.dispose(); + options.dispose(); namespace.dispose(); } loaded = true; diff --git a/packages/core/src/mods/module-loader.test.ts b/packages/core/src/mods/module-loader.test.ts new file mode 100644 index 00000000000..59b7280e417 --- /dev/null +++ b/packages/core/src/mods/module-loader.test.ts @@ -0,0 +1,249 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { compileModModules } from './module-loader.js'; +import { ModRuntime } from './runtime.js'; +import { loadModSource } from './discovery.js'; + +describe('confined Mod module graphs', () => { + let root: string; + const write = async (file: string, source: string) => { + await fs.mkdir(path.dirname(path.join(root, file)), { recursive: true }); + await fs.writeFile(path.join(root, file), source); + }; + const input = (source: string, entry = 'entry.ts') => ({ + root, + entry: path.join(root, entry), + source, + }); + beforeEach(async () => { + root = await fs.realpath( + await fs.mkdtemp(path.join(os.tmpdir(), 'qwen-mod-graph-')), + ); + }); + afterEach(async () => { + vi.restoreAllMocks(); + await fs.rm(root, { recursive: true, force: true }); + }); + + it('resolves extensionless, index, extension substitution and re-exports in CC order', async () => { + await write('helper.ts', 'export const value = 1;'); + await write('helper.js', 'export const value = 2;'); + await write('dir/index.ts', 'export { value } from "../helper.js";'); + await write('substitute.ts', 'export const extra = 3;'); + await write('pick/index.ts', 'export const pick = 4;'); + await write('pick.js', 'throw new Error("WRONG_PRECEDENCE");'); + await write('index.ts', 'export const value = 0;'); + const indexGraph = await compileModModules( + input('export { value } from ".";'), + ); + expect(indexGraph.imports.get(path.join(root, 'entry.ts'))?.get('.')).toBe( + path.join(root, 'index.ts'), + ); + const graph = await compileModModules( + input( + 'import { value } from "./helper"; export { value as indexed } from "./dir"; export { extra } from "./substitute.js"; export { pick } from "./pick"; export { value };', + ), + ); + expect(graph.imports.get(path.join(root, 'entry.ts'))).toEqual( + new Map([ + ['./helper', path.join(root, 'helper.ts')], + ['./dir', path.join(root, 'dir/index.ts')], + ['./substitute.js', path.join(root, 'substitute.ts')], + ['./pick', path.join(root, 'pick/index.ts')], + ]), + ); + expect( + graph.imports.get(path.join(root, 'dir/index.ts'))?.get('../helper.js'), + ).toBe(path.join(root, 'helper.js')); + expect([...graph.modules.values()].join('\n')).not.toContain( + 'WRONG_PRECEDENCE', + ); + }); + + it('erases type-only imports without reading them or author build configuration', async () => { + const probe = vi.spyOn(fs, 'lstat'); + const graph = await compileModModules( + input( + 'import type { On } from "claude-code"; import type { Missing } from "./missing.js"; export function register(on: On) {}', + ), + ); + expect(graph.modules.size).toBe(1); + expect([...graph.modules.values()][0]).not.toContain('import'); + expect(probe).not.toHaveBeenCalled(); + }); + + it('shares canonical entry and import identities on case-insensitive filesystems', async ({ + skip, + }) => { + await write('hooks/hooks.json', '{"modules":["./ENTRY.js"]}'); + await write( + 'hooks/state.js', + 'let count = 0; export function next() { return ++count; }', + ); + await write( + 'hooks/entry.js', + `import { next as lower } from './state.js'; + import { next as upper } from './STATE.js'; + import { marker as self } from './entry.js'; + export const marker = 'entry'; + export function register(on) { + on('session.start', async ($, e, next) => { await $.command.register({name:'hello',description:'identity'}); return next(e); }); + on('command.run', {command:'hello'}, () => ({text: lower() + ',' + upper() + ':' + self})); + }`, + ); + if (!(await fs.stat(path.join(root, 'hooks/ENTRY.js')).catch(() => false))) + skip(); + const source = (await loadModSource(root))!; + expect(source.entry).toBe(path.join(root, 'hooks/entry.js')); + expect((await compileModModules(source)).modules.size).toBe(2); + const runtime = await ModRuntime.create( + source, + root, + () => {}, + new AbortController().signal, + ); + try { + expect( + await runtime.runCommand('hello', '', new AbortController().signal), + ).toBe('1,2:entry'); + } finally { + await runtime.dispose(); + } + }); + + it.each(['.ts', '.mts', '.cts', '.js', '.mjs', '.cjs'])( + 'runs %s as ESM with shared imported state and source cycles inside QuickJS', + async (suffix) => { + await write( + 'state.ts', + 'import { marker } from "./cycle.js"; let count: number = 0; export function next() { return marker() + ++count; }', + ); + await write( + 'cycle.ts', + 'export { next } from "./state.js"; export function marker() { return "call:"; }', + ); + const source = input( + `import { next } from './state.js'; import * as again from './state.js'; export function register(on) { + on('session.start', async ($, e, next) => { await $.command.register({name:'hello',description:'graph'}); return next(e); }); + on('command.run',{command:'hello'}, () => ({text: next() + ',' + again.next() + ':' + typeof process})); + }`, + `entry${suffix}`, + ); + const runtime = await ModRuntime.create( + source, + root, + () => {}, + new AbortController().signal, + ); + try { + expect( + await runtime.runCommand('hello', '', new AbortController().signal), + ).toBe('call:1,call:2:undefined'); + expect( + await runtime.runCommand('hello', '', new AbortController().signal), + ).toBe('call:3,call:4:undefined'); + } finally { + await runtime.dispose(); + } + }, + ); + + it.each([ + ['import "node:fs";', /relative static/], + ['import "claude-code";', /relative static/], + ['import "https://example.invalid/a.js";', /relative static/], + ['const p = import("./a.js");', /Dynamic import/], + ['const p = require("fs");', /require/], + ['import p = require("./a.js");', /require/], + ['import "./a.json";', /extension/], + ['import "./a.tsx";', /extension/], + ['import "../outside.js";', /inside/], + ['export const broken: =;', /Invalid Mod TypeScript/], + ])( + 'rejects unsupported code before guest execution: %s', + async (source, error) => { + await expect(compileModModules(input(source))).rejects.toThrow(error); + }, + ); + + it('rejects symlinked imports and imported top-level await', async () => { + await write('real.ts', 'export const value = 1;'); + await fs.symlink(path.join(root, 'real.ts'), path.join(root, 'link.ts')); + await expect(compileModModules(input('import "./link";'))).rejects.toThrow( + /symbolic links/, + ); + await write('async.ts', 'await Promise.resolve(); export const value = 1;'); + await expect(compileModModules(input('import "./async";'))).rejects.toThrow( + /Top-level await/, + ); + }); + + it.each([ + "export const C = class { [await Promise.resolve('ok')]() {} };", + "export const value = { [await Promise.resolve('ok')]() {} };", + 'export const C = class extends (await Promise.resolve(Object)) {};', + ])( + 'rejects imported await evaluated outside a function body: %s', + async (source) => { + await write('async.js', source); + await expect( + compileModModules(input('import "./async.js";')), + ).rejects.toThrow(/Top-level await/); + }, + ); + + it('rejects imported top-level await introduced by TypeScript compilation', async () => { + await write( + 'resource.ts', + 'await using resource = null; export const value = 7;', + ); + await expect( + compileModModules(input('import "./resource.js";')), + ).rejects.toThrow(/Top-level await/); + }); + + it('bounds individual sources, aggregate bytes and graph file count', async () => { + await write('large.js', ' '.repeat(262_145)); + await expect( + compileModModules(input('import "./large.js";')), + ).rejects.toThrow(/256 KiB/); + await expect(compileModModules(input(' '.repeat(262_145)))).rejects.toThrow( + /byte limit/, + ); + for (let i = 0; i < 64; i++) + await write(`${i}.js`, i === 63 ? '' : `import './${i + 1}.js';`); + await expect(compileModModules(input('import "./0.js";'))).rejects.toThrow( + /file or byte limit/, + ); + for (let i = 0; i < 5; i++) + await write(`bytes${i}.js`, `/*${' '.repeat(220_000)}*/`); + await expect( + compileModModules( + input( + Array.from({ length: 5 }, (_, i) => `import './bytes${i}.js';`).join( + '\n', + ), + ), + ), + ).rejects.toThrow(/file or byte limit/); + }); + + it('bounds compiled expansion independently of original source bytes', async () => { + const source = + 'export enum Values {' + + Array.from({ length: 30_000 }, (_, i) => 'M' + i).join(',') + + '}'; + expect(Buffer.byteLength(source)).toBeLessThan(262_144); + await expect(compileModModules(input(source))).rejects.toThrow( + /Compiled Mod graph/, + ); + }); +}); diff --git a/packages/core/src/mods/module-loader.ts b/packages/core/src/mods/module-loader.ts new file mode 100644 index 00000000000..13bb5ea99d0 --- /dev/null +++ b/packages/core/src/mods/module-loader.ts @@ -0,0 +1,169 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import path from 'node:path'; +import ts from 'typescript'; +import { readModFile } from './discovery.js'; +import { MOD_MAX_BYTES, type ModSource } from './types.js'; + +const suffixes = ['.ts', '.js', '.mjs', '.cjs', '.mts', '.cts']; +const maxGraphBytes = 1024 * 1024; + +async function resolveImport( + root: string, + importer: string, + specifier: string, +) { + if (!/^(?:\.{1,2}\/|\.{1,2}$)/.test(specifier) || specifier.includes('\\')) { + throw new Error( + 'Mod imports must be relative static JavaScript or TypeScript imports.', + ); + } + const target = path.resolve( + path.dirname(importer), + specifier, + /^\.{1,2}\/?$/.test(specifier) ? 'index' : '', + ); + const extension = path.extname(target); + if (extension && !suffixes.includes(extension)) { + throw new Error('Unsupported Mod import extension.'); + } + const replacements: Record = { + '.js': '.ts', + '.mjs': '.mts', + '.cjs': '.cts', + }; + const candidates = [target]; + if (replacements[extension]) + candidates.push( + target.slice(0, -extension.length) + replacements[extension], + ); + for (const suffix of suffixes) + candidates.push(target + suffix, path.join(target, 'index' + suffix)); + for (const candidate of candidates) { + try { + const file = await readModFile(root, candidate); + if (!suffixes.includes(path.extname(candidate))) + throw new Error('Unsupported Mod import extension.'); + return file; + } catch (error) { + if ( + !['ENOENT', 'ENOTDIR', 'EISDIR'].includes( + (error as NodeJS.ErrnoException).code ?? '', + ) + ) + throw error; + } + } + throw new Error(`Mod import not found: ${specifier}.`); +} + +export async function compileModModules(input: ModSource) { + const modules = new Map(); + const imports = new Map>(); + let inputBytes = 0; + let outputBytes = 0; + async function visit(entry: string, source: string): Promise { + if (modules.has(entry)) return; + const bytes = Buffer.byteLength(source); + inputBytes += bytes; + if ( + bytes > MOD_MAX_BYTES || + inputBytes > maxGraphBytes || + modules.size >= 64 + ) { + throw new Error('Mod source graph exceeds its file or byte limit.'); + } + const isTypescript = /\.[cm]?ts$/.test(entry); + const syntax = ts.createSourceFile( + isTypescript ? 'mod.ts' : 'mod.js', + source, + ts.ScriptTarget.ES2022, + true, + isTypescript ? ts.ScriptKind.TS : ts.ScriptKind.JS, + ); + function check(node: ts.Node, inFunction = false): void { + if ( + (ts.isImportEqualsDeclaration(node) && !node.isTypeOnly) || + (ts.isCallExpression(node) && + (node.expression.kind === ts.SyntaxKind.ImportKeyword || + (ts.isIdentifier(node.expression) && + node.expression.text === 'require'))) + ) { + throw new Error( + 'Dynamic import and require are not supported in Mods.', + ); + } + if ( + entry !== input.entry && + !inFunction && + (ts.isAwaitExpression(node) || + (ts.isForOfStatement(node) && node.awaitModifier)) + ) { + throw new Error( + 'Top-level await in imported Mod modules is not supported.', + ); + } + ts.forEachChild(node, (child) => { + const functionBody = + ts.isFunctionLike(node) && + (ts.isParameter(child) || ('body' in node && node.body === child)); + check(child, inFunction || functionBody); + }); + } + check(syntax); + let output = source; + if (isTypescript) { + const compiled = ts.transpileModule(source, { + fileName: 'mod.ts', + compilerOptions: { + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.ESNext, + isolatedModules: true, + }, + reportDiagnostics: true, + }); + if ( + compiled.diagnostics?.some( + (item) => item.category === ts.DiagnosticCategory.Error, + ) + ) { + throw new Error(`Invalid Mod TypeScript: ${path.basename(entry)}.`); + } + output = compiled.outputText; + } + outputBytes += Buffer.byteLength(output); + if (outputBytes > maxGraphBytes) + throw new Error('Compiled Mod graph exceeds the 1 MiB limit.'); + modules.set(entry, output); + const edges = new Map(); + imports.set(entry, edges); + const emitted = ts.createSourceFile( + 'mod.js', + output, + ts.ScriptTarget.ES2022, + true, + ts.ScriptKind.JS, + ); + check(emitted); + for (const statement of emitted.statements) { + if ( + !ts.isImportDeclaration(statement) && + !ts.isExportDeclaration(statement) + ) + continue; + const specifier = statement.moduleSpecifier; + if (!specifier) continue; + if (!ts.isStringLiteral(specifier)) + throw new Error('Invalid Mod static import.'); + const resolved = await resolveImport(input.root, entry, specifier.text); + edges.set(specifier.text, resolved.entry); + await visit(resolved.entry, resolved.source); + } + } + await visit(input.entry, input.source); + return { modules, imports }; +} diff --git a/packages/core/src/mods/options.test.ts b/packages/core/src/mods/options.test.ts new file mode 100644 index 00000000000..89be46bbfec --- /dev/null +++ b/packages/core/src/mods/options.test.ts @@ -0,0 +1,154 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { resolveModOptions } from './options.js'; + +const field = (type: string, extra = {}) => ({ + type, + title: 'Option', + description: 'Description', + ...extra, +}); +const resolve = (schema: unknown, options: unknown = {}) => + resolveModOptions(schema, [{ example: { options } }], 'example'); + +describe('Mod userConfig', () => { + it('matches default, empty, enum fallback and unknown-key behavior', () => { + const schema = { + text: field('string', { default: 'default' }), + count: field('number'), + flag: field('boolean'), + choice: field('string', { options: ['red', 'blue'], default: 'red' }), + }; + expect( + resolve(schema, { text: '', choice: 'invalid', extra: ['one'] }), + ).toEqual({ text: '', count: '', flag: '', choice: 'red', extra: ['one'] }); + expect(resolve({}, { extra: 'ignored' })).toEqual({}); + expect(resolve(undefined, { extra: 'ignored' })).toEqual({}); + }); + + it('validates all types and treats false, zero and multiple strings as present', () => { + const schema = { + flag: field('boolean', { required: true }), + count: field('number', { required: true, min: 0, max: 2 }), + tags: field('string', { multiple: true }), + file: field('file'), + dir: field('directory'), + }; + expect( + resolve(schema, { + flag: false, + count: 0, + tags: [], + file: '/absent', + dir: '/absent', + }), + ).toEqual({ + flag: false, + count: 0, + tags: [], + file: '/absent', + dir: '/absent', + }); + expect( + resolve(schema, { flag: false, count: 2, tags: 'one' })['tags'], + ).toBe('one'); + for (const [key, value] of [ + ['flag', 'secret-bad'], + ['count', 3], + ['file', 1], + ['dir', true], + ['tags', ['one', 2]], + ]) { + expect(() => + resolve(schema, { flag: false, count: 0, [key as string]: value }), + ).toThrow(/Invalid Mod/); + } + expect(() => resolve(schema, { count: 0 })).toThrow(/required.*flag/); + expect(() => + resolve({ name: field('string', { required: true }) }, { name: '' }), + ).toThrow(/required.*name/); + }); + + it.each([ + null, + [], + { x: { type: 'string' } }, + { x: field('string', { surprise: true }) }, + { 'bad-key': field('string') }, + { x: field('string', { options: ['x', 'X'], default: 'x' }) }, + { x: field('string', { options: ['x'] }) }, + ])('rejects malformed schema %j', (schema) => { + expect(() => resolve(schema)).toThrow(/userConfig declaration/); + }); + + it('refuses sensitive declarations even with a default, without echoing values', () => { + expect(() => + resolve({ + token: field('string', { sensitive: true, default: 'PRIVATE_VALUE' }), + }), + ).toThrow('Sensitive Mod userConfig is not supported yet.'); + expect(() => + resolve({ count: field('number') }, { count: 'PRIVATE_VALUE' }), + ).toThrow('Invalid Mod option: count.'); + }); + + it('restricts extra option values and payload size', () => { + for (const extra of [null, {}, ['one', 1], Infinity]) { + expect(() => resolve({ name: field('string') }, { extra })).toThrow( + /configured option: extra/, + ); + } + expect(() => + resolve({ name: field('string') }, { name: 'x'.repeat(262_145) }), + ).toThrow(/256 KiB/); + }); + + it('merges aliases within each source before applying operator precedence', () => { + const schema = { + value: field('string'), + kept: field('string'), + higher: field('string'), + }; + const scopes = [ + { example: { options: { value: 'default', kept: 'default' } } }, + { + example: { options: { value: 'user' } }, + 'example@inline': { options: { value: 'inline-user', higher: 'kept' } }, + }, + { example: { options: { value: 'system' } } }, + ]; + expect( + resolveModOptions(schema, scopes.slice(0, 2), 'example')['value'], + ).toBe('inline-user'); + expect(resolveModOptions(schema, scopes, 'example')).toEqual({ + value: 'system', + kept: 'default', + higher: 'kept', + }); + expect( + resolveModOptions( + schema, + [...scopes, { 'example@market': { options: { value: 'market' } } }], + 'example', + 'market', + )['value'], + ).toBe('market'); + expect( + resolveModOptions(schema, scopes, 'example', 'market')['value'], + ).toBe(''); + }); + + it('does not let special option names mutate object prototypes', () => { + const declaration = JSON.parse( + '{"__proto__":{"type":"string","title":"x","description":"x","default":"value"}}', + ); + const result = resolve(declaration); + expect(Object.getPrototypeOf(result)).toBe(Object.prototype); + expect(({} as Record)['value']).toBeUndefined(); + }); +}); diff --git a/packages/core/src/mods/options.ts b/packages/core/src/mods/options.ts new file mode 100644 index 00000000000..61892692914 --- /dev/null +++ b/packages/core/src/mods/options.ts @@ -0,0 +1,128 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { z } from 'zod'; +import { MOD_MAX_BYTES, type ModOptions } from './types.js'; + +const option = z.union([ + z.string(), + z.number().finite(), + z.boolean(), + z.array(z.string()), +]); +const field = z + .object({ + type: z.enum(['string', 'number', 'boolean', 'directory', 'file']), + title: z.string(), + description: z.string(), + required: z.boolean().optional(), + default: option.optional(), + multiple: z.boolean().optional(), + sensitive: z.boolean().optional(), + min: z.number().finite().optional(), + max: z.number().finite().optional(), + options: z.array(z.string().min(1).max(64)).nonempty().optional(), + }) + .strict() + .refine((value) => { + if (!value.options) return true; + return ( + value.type === 'string' && + !value.multiple && + !value.sensitive && + value.options.every( + (item) => item.trim() === item && !/[\p{Cc}\p{Cf}]/u.test(item), + ) && + new Set(value.options.map((item) => item.toLowerCase())).size === + value.options.length && + (value.default === undefined + ? value.required === true + : value.options.includes(value.default as string)) + ); + }); +const schema = z.record(z.string().regex(/^[A-Za-z_]\w*$/), field); +const storedOptions = z.record(option); + +export function resolveModOptions( + declaration: unknown, + scopes: readonly unknown[], + name: string, + marketplace?: string, +): ModOptions { + const parsed = schema.safeParse(declaration === undefined ? {} : declaration); + if (!parsed.success) throw new Error('Invalid Mod userConfig declaration.'); + const fields = Object.entries(parsed.data); + if (!fields.length) return {}; + if (fields.some(([, value]) => value.sensitive)) { + throw new Error('Sensitive Mod userConfig is not supported yet.'); + } + let stored: ModOptions = {}; + const keys = marketplace + ? [`${name}@${marketplace}`] + : [name, `${name}@inline`]; + for (const scope of scopes) { + if (scope === undefined) continue; + if (!scope || typeof scope !== 'object' || Array.isArray(scope)) { + throw new Error('Invalid Mod pluginConfigs settings.'); + } + for (const key of keys) { + if (!Object.hasOwn(scope, key)) continue; + const config = (scope as Record)[key]; + if (!config || typeof config !== 'object' || Array.isArray(config)) { + throw new Error('Invalid Mod pluginConfigs entry.'); + } + const options = (config as Record)['options']; + if (options === undefined) continue; + const values = storedOptions.safeParse(options); + if (!values.success) + throw new Error( + `Invalid Mod configured option: ${String(values.error.issues[0]?.path[0] ?? '').slice(0, 64)}.`, + ); + stored = { ...stored, ...values.data }; + } + } + const result: ModOptions = Object.fromEntries( + fields + .filter(([, config]) => config.default !== undefined || !config.required) + .map(([key, config]) => [key, config.default ?? '']), + ); + for (const [key, value] of Object.entries(stored)) { + const choices = Object.hasOwn(parsed.data, key) + ? parsed.data[key].options + : undefined; + if (!choices || choices.includes(value as string)) + Object.defineProperty(result, key, { + value, + enumerable: true, + writable: true, + configurable: true, + }); + } + for (const [key, config] of fields) { + const value = Object.hasOwn(result, key) ? result[key] : undefined; + if (value === undefined || value === '') { + if (config.required) + throw new Error(`Missing required Mod option: ${key}.`); + } else { + const valid = + config.type === 'number' + ? typeof value === 'number' && + (config.min === undefined || value >= config.min) && + (config.max === undefined || value <= config.max) + : config.type === 'boolean' + ? typeof value === 'boolean' + : typeof value === 'string' || + (config.type === 'string' && + config.multiple && + Array.isArray(value)); + if (!valid) throw new Error(`Invalid Mod option: ${key}.`); + } + } + if (Buffer.byteLength(JSON.stringify(result)) > MOD_MAX_BYTES) { + throw new Error('Mod options exceed the 256 KiB limit.'); + } + return result; +} diff --git a/packages/core/src/mods/runtime.test.ts b/packages/core/src/mods/runtime.test.ts index d33bbc336e9..9857228e6f2 100644 --- a/packages/core/src/mods/runtime.test.ts +++ b/packages/core/src/mods/runtime.test.ts @@ -6,7 +6,7 @@ import { afterEach, describe, expect, it } from 'vitest'; import { ModRuntime } from './runtime.js'; -import type { ModLog } from './types.js'; +import type { ModLog, ModOptions } from './types.js'; const signal = () => new AbortController().signal; const fixture = ( @@ -31,12 +31,17 @@ const fixture = ( describe('ModRuntime with the real QuickJS worker', () => { const runtimes: ModRuntime[] = []; const logs: ModLog[] = []; - const create = async (source = fixture(), abort = signal()) => { + const create = async ( + source = fixture(), + abort = signal(), + options: ModOptions = {}, + ) => { const runtime = await ModRuntime.create( - source, + { root: '/fixture', entry: '/fixture/mod.js', source }, '/workspace', (log) => logs.push(log), abort, + options, ); runtimes.push(runtime); return runtime; @@ -107,7 +112,7 @@ describe('ModRuntime with the real QuickJS worker', () => { [ 'imports', "import fs from 'node:fs'; export function register() {}", - /imports are not supported/, + /relative static/, ], [ 'unknown event', @@ -186,6 +191,24 @@ describe('ModRuntime with the real QuickJS worker', () => { ).rejects.toThrow(/cancelled/); }); + it('passes a frozen JSON copy of options into register', async () => { + const options = { label: 'original', tags: ['one'] }; + const runtime = await create( + `export function register(on, options) { + if (!Object.isFrozen(options) || !Object.isFrozen(options.tags)) throw new Error('unfrozen'); + on('session.start', async ($, e, next) => { await $.command.register({name:'hello',description:'options'}); return next(e); }); + on('command.run', {command:'hello'}, () => ({ text: options.label + ':' + options.tags.join(',') })); + }`, + signal(), + options, + ); + options.label = 'changed'; + options.tags.push('two'); + expect(await runtime.runCommand('hello', '', signal())).toBe( + 'original:one', + ); + }); + it('bounds logs and applies a whole session.end timeout', async () => { const noisy = await create( fixture( diff --git a/packages/core/src/mods/runtime.ts b/packages/core/src/mods/runtime.ts index 062a493fb43..7d04ed55779 100644 --- a/packages/core/src/mods/runtime.ts +++ b/packages/core/src/mods/runtime.ts @@ -18,6 +18,8 @@ import { type ModLog, type ModReply, type ModRequest, + type ModSource, + type ModOptions, } from './types.js'; function workerEntry(): URL | string { @@ -62,15 +64,16 @@ export class ModRuntime { } static async create( - source: string, + source: ModSource, cwd: string, log: (entry: ModLog) => void, signal: AbortSignal, + options: ModOptions = {}, ): Promise { const runtime = new ModRuntime(log); try { const result = await runtime.request( - { type: 'load', source, cwd }, + { type: 'load', source, cwd, options }, signal, 30_000, ); diff --git a/packages/core/src/mods/types.ts b/packages/core/src/mods/types.ts index 9d20b0b7c5a..44af7d97281 100644 --- a/packages/core/src/mods/types.ts +++ b/packages/core/src/mods/types.ts @@ -8,6 +8,27 @@ export const MOD_MAX_BYTES = 262_144; export const MOD_TIMEOUT_MS = 10_000; export const MOD_END_TIMEOUT_MS = 1_500; +export interface ModSource { + root: string; + entry: string; + source: string; +} + +export type ModOption = string | number | boolean | string[]; +export type ModOptions = Record; +export type ModPluginConfigs = Record; + +export interface ModDependency { + name: string; + marketplace?: string; + version?: string; +} + +export interface ModDefinition extends ModSource { + userConfig?: unknown; + dependencies: ModDependency[]; +} + export interface ModCommand { name: string; description: string; @@ -20,7 +41,7 @@ export interface ModLog { } export type ModRequest = - | { type: 'load'; source: string; cwd: string } + | { type: 'load'; source: ModSource; options: ModOptions; cwd: string } | { type: 'command'; command: string; args: string } | { type: 'end'; sessionId: string; reason: 'clear' | 'resume' | 'other' }; diff --git a/packages/vscode-ide-companion/NOTICES.txt b/packages/vscode-ide-companion/NOTICES.txt index 7678bc5e99a..ffff0ae2584 100644 --- a/packages/vscode-ide-companion/NOTICES.txt +++ b/packages/vscode-ide-companion/NOTICES.txt @@ -15615,6 +15615,27 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +============================================================ +semver@7.8.5 +(git+https://github.com/npm/node-semver.git) + +The ISC License + +Copyright (c) Isaac Z. Schlueter and Contributors + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR +IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + + ============================================================ sharp@0.35.4 (git+https://github.com/lovell/sharp.git) @@ -16107,27 +16128,6 @@ detect-libc@2.1.2 limitations under the License. -============================================================ -semver@7.8.5 -(git+https://github.com/npm/node-semver.git) - -The ISC License - -Copyright (c) Isaac Z. Schlueter and Contributors - -Permission to use, copy, modify, and/or distribute this software for any -purpose with or without fee is hereby granted, provided that the above -copyright notice and this permission notice appear in all copies. - -THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES -WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF -MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR -ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES -WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN -ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR -IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. - - ============================================================ shell-quote@1.9.0 (http://github.com/ljharb/shell-quote.git) @@ -16600,6 +16600,67 @@ NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +============================================================ +typescript@5.8.3 +(https://github.com/microsoft/TypeScript.git) + +Apache License + +Version 2.0, January 2004 + +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. + +"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. + +"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). + +"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. + +"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: + +You must give any other recipients of the Work or Derivative Works a copy of this License; and + +You must cause any modified files to carry prominent notices stating that You changed the files; and + +You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and + +If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + + ============================================================ undici@7.29.0 (git+https://github.com/nodejs/undici.git) diff --git a/packages/vscode-ide-companion/schemas/settings.schema.json b/packages/vscode-ide-companion/schemas/settings.schema.json index 54859ca2db4..4b8828b881d 100644 --- a/packages/vscode-ide-companion/schemas/settings.schema.json +++ b/packages/vscode-ide-companion/schemas/settings.schema.json @@ -708,6 +708,37 @@ "default": 0, "minimum": 0 }, + "pluginConfigs": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "options": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + } + } + } + }, + "description": "Mod options keyed by plugin name or name@marketplace. Only user and system settings apply; restart after changes." + }, "advisorModel": { "description": "Model selector for the Advisor tool. Leave empty to disable Advisor. Enabling it sends the active conversation to that model, even when it uses another provider.", "type": "string", @@ -4422,7 +4453,7 @@ "type": "object", "properties": { "mods": { - "description": "Run the experimental JavaScript Mod command subset from enabled extensions in trusted headless sessions.", + "description": "Run the experimental JavaScript and TypeScript Mod command subset from enabled extensions in trusted headless sessions.", "type": "boolean", "default": false }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b8a5ece515a..efdb9c21da3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1053,6 +1053,9 @@ importers: quickjs-emscripten-core: specifier: 0.32.0 version: 0.32.0 + semver: + specifier: ^7.7.2 + version: 7.8.5 sharp: specifier: ^0.35.4 version: 0.35.4(@types/node@20.19.1) @@ -1071,6 +1074,9 @@ importers: turndown: specifier: ^7.2.4 version: 7.2.4 + typescript: + specifier: 5.8.3 + version: 5.8.3 undici: specifier: ^7.28.0 version: 7.29.0 @@ -1117,6 +1123,9 @@ importers: '@types/prompts': specifier: ^2.4.9 version: 2.4.9 + '@types/semver': + specifier: ^7.7.1 + version: 7.7.1 '@types/tar': specifier: ^6.1.13 version: 6.1.13 @@ -1138,9 +1147,6 @@ importers: tree-sitter-wasms: specifier: ^0.1.13 version: 0.1.13 - typescript: - specifier: 5.8.3 - version: 5.8.3 vitest: specifier: ^3.1.1 version: 3.2.7(@types/debug@4.1.13)(@types/node@20.19.1)(jiti@2.7.0)(jsdom@26.1.0(supports-color@7.2.0))(lightningcss@1.32.0)(msw@2.10.4(@types/node@20.19.1)(typescript@5.8.3))(supports-color@7.2.0)(tsx@4.20.3)(yaml@2.9.0) diff --git a/scripts/tests/mod-worker-bundle.test.js b/scripts/tests/mod-worker-bundle.test.js new file mode 100644 index 00000000000..4b16918af0d --- /dev/null +++ b/scripts/tests/mod-worker-bundle.test.js @@ -0,0 +1,99 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { afterEach, expect, it, vi } from 'vitest'; +import { mkdtemp, writeFile, rm, realpath } from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; +import { Worker } from 'node:worker_threads'; + +const state = vi.hoisted(() => ({ directory: '', build: undefined })); +vi.mock('esbuild', async (importOriginal) => { + const actual = await importOriginal(); + return { + default: { + build(options) { + if (!options.entryPoints['mod-worker']) + return Promise.resolve({ metafile: {} }); + state.build = actual.build({ + ...options, + entryPoints: { 'mod-worker': options.entryPoints['mod-worker'] }, + outdir: state.directory, + }); + return state.build; + }, + }, + }; +}); +// Import the real build configuration without cleaning the shared dist directory. +vi.mock('node:fs', async (importOriginal) => ({ + ...(await importOriginal()), + rmSync: vi.fn(), +})); + +let worker; +afterEach(async () => { + await worker?.terminate(); + worker = undefined; + if (state.directory) + await rm(state.directory, { recursive: true, force: true }); + vi.unstubAllEnvs(); +}); + +it('runs a relocated bundled worker with TypeScript and relative imports', async () => { + state.directory = await realpath( + await mkdtemp(path.join(os.tmpdir(), 'qwen-mod-worker-bundle-')), + ); + vi.stubEnv('DEV', 'false'); + await import('../../esbuild.config.js'); + await state.build; + await writeFile( + path.join(state.directory, 'package.json'), + '{"type":"module"}', + ); + await writeFile( + path.join(state.directory, 'helper.ts'), + 'export const greeting: string = "bundled";', + ); + worker = new Worker(path.join(state.directory, 'mod-worker.js')); + const dispatch = (request) => + new Promise((resolve, reject) => { + const onError = (error) => { + cleanup(); + reject(error); + }; + const onMessage = (reply) => { + if (reply.type === 'log') return; + cleanup(); + if (reply.type === 'error') reject(new Error(reply.message)); + else resolve(JSON.parse(reply.json)); + }; + const cleanup = () => { + worker.off('error', onError); + worker.off('message', onMessage); + }; + worker.on('error', onError); + worker.on('message', onMessage); + worker.postMessage(request); + }); + const commands = await dispatch({ + type: 'load', + cwd: state.directory, + options: { value: 'option' }, + source: { + root: state.directory, + entry: path.join(state.directory, 'entry.ts'), + source: `import { greeting } from './helper.js'; export function register(on, options) { + on('session.start', async ($, e, next) => { await $.command.register({ name: 'hello', description: 'Bundled' }); return next(e); }); + on('command.run', {command:'hello'}, () => ({text: greeting + ':' + options.value + ':' + typeof process})); + }`, + }, + }); + expect(commands).toEqual([{ name: 'hello', description: 'Bundled' }]); + expect( + await dispatch({ type: 'command', command: 'hello', args: '' }), + ).toEqual({ text: 'bundled:option:undefined' }); +});