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 <[email protected]>
This commit is contained in:
qqqysandqqqys authored and GitHub committed 2026-10-11 12:59:02 +00:00
1 parent 6775f24927
commit e6ed740eb1
28 files changed
+1912 -107

No files matched your search

+104 -6
View File
@@ -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).
+4
View File
@@ -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";`,
},
+35
View File
@@ -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',
+32 -1
View File
@@ -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: {
+1
View File
@@ -300,6 +300,7 @@ export const WORKSPACE_RESTRICTED_SETTINGS = [
export const WORKSPACE_RESTRICTED_ROOT_SETTINGS = [
'advisorModel',
'advisorMaxUses',
'pluginConfigs',
] as const;
/**
@@ -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<string, unknown> = {}) => {
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' }],
+80 -4
View File
@@ -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<void> {
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) {
@@ -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
+3 -1
View File
@@ -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": {
@@ -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);
},
);
@@ -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;
@@ -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(['[email protected]'])).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/,
);
});
});
+109
View File
@@ -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<T extends ModPlugin>(
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<T extends ModPlugin>(
plugins: readonly T[],
): T[] {
const edges = plugins.map(() => new Set<number>());
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;
}
+38 -11
View File
@@ -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'),
);
});
});
+35 -20
View File
@@ -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<string> {
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<string> {
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<string> {
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<string | undefined> {
): Promise<ModDefinition | undefined> {
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']);
}
+2 -2
View File
@@ -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; }
+21 -9
View File
@@ -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;
@@ -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/,
);
});
});
+169
View File
@@ -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<string, string> = {
'.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<string, string>();
const imports = new Map<string, Map<string, string>>();
let inputBytes = 0;
let outputBytes = 0;
async function visit(entry: string, source: string): Promise<void> {
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<string, string>();
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 };
}
+154
View File
@@ -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<string, unknown>)['value']).toBeUndefined();
});
});
+128
View File
@@ -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<string, unknown>)[key];
if (!config || typeof config !== 'object' || Array.isArray(config)) {
throw new Error('Invalid Mod pluginConfigs entry.');
}
const options = (config as Record<string, unknown>)['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;
}
+27 -4
View File
@@ -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(
+5 -2
View File
@@ -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<ModRuntime> {
const runtime = new ModRuntime(log);
try {
const result = await runtime.request(
{ type: 'load', source, cwd },
{ type: 'load', source, cwd, options },
signal,
30_000,
);
+22 -1
View File
@@ -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<string, ModOption>;
export type ModPluginConfigs = Record<string, { options?: ModOptions }>;
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' };
+82 -21
View File
@@ -15615,6 +15615,27 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
============================================================
[email protected]
(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.
============================================================
[email protected]
(git+https://github.com/lovell/sharp.git)
@@ -16107,27 +16128,6 @@ [email protected]
limitations under the License.
============================================================
[email protected]
(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.
============================================================
[email protected]
(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.
============================================================
[email protected]
(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
============================================================
[email protected]
(git+https://github.com/nodejs/undici.git)
@@ -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
},
+9 -3
View File
@@ -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/[email protected])
@@ -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/[email protected])(@types/[email protected])([email protected])([email protected]([email protected]))([email protected])([email protected](@types/[email protected])([email protected]))([email protected])([email protected])([email protected])
+99
View File
@@ -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' });
});