@moxxy/core 0.5.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/events/log.d.ts.map +1 -1
- package/dist/events/log.js +38 -1
- package/dist/events/log.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/permissions/engine.d.ts.map +1 -1
- package/dist/permissions/engine.js +80 -17
- package/dist/permissions/engine.js.map +1 -1
- package/dist/plugins/discovery.d.ts.map +1 -1
- package/dist/plugins/discovery.js +48 -7
- package/dist/plugins/discovery.js.map +1 -1
- package/dist/plugins/host.d.ts.map +1 -1
- package/dist/plugins/host.js +54 -11
- package/dist/plugins/host.js.map +1 -1
- package/dist/plugins/lifecycle.d.ts.map +1 -1
- package/dist/plugins/lifecycle.js +7 -0
- package/dist/plugins/lifecycle.js.map +1 -1
- package/dist/plugins/loader.d.ts +9 -0
- package/dist/plugins/loader.d.ts.map +1 -1
- package/dist/plugins/loader.js +34 -8
- package/dist/plugins/loader.js.map +1 -1
- package/dist/plugins/registry-kinds.d.ts +21 -2
- package/dist/plugins/registry-kinds.d.ts.map +1 -1
- package/dist/plugins/registry-kinds.js +3 -1
- package/dist/plugins/registry-kinds.js.map +1 -1
- package/dist/plugins/toposort.d.ts.map +1 -1
- package/dist/plugins/toposort.js +43 -18
- package/dist/plugins/toposort.js.map +1 -1
- package/dist/preferences.d.ts.map +1 -1
- package/dist/preferences.js +5 -4
- package/dist/preferences.js.map +1 -1
- package/dist/registries/active-backend-registry.d.ts.map +1 -1
- package/dist/registries/active-backend-registry.js +10 -1
- package/dist/registries/active-backend-registry.js.map +1 -1
- package/dist/registries/channels.d.ts +2 -1
- package/dist/registries/channels.d.ts.map +1 -1
- package/dist/registries/channels.js +30 -20
- package/dist/registries/channels.js.map +1 -1
- package/dist/registries/commands.d.ts.map +1 -1
- package/dist/registries/commands.js +27 -8
- package/dist/registries/commands.js.map +1 -1
- package/dist/registries/isolators.d.ts +27 -1
- package/dist/registries/isolators.d.ts.map +1 -1
- package/dist/registries/isolators.js +35 -1
- package/dist/registries/isolators.js.map +1 -1
- package/dist/registries/modes.d.ts +4 -0
- package/dist/registries/modes.d.ts.map +1 -1
- package/dist/registries/modes.js +15 -4
- package/dist/registries/modes.js.map +1 -1
- package/dist/registries/providers.d.ts +8 -0
- package/dist/registries/providers.d.ts.map +1 -1
- package/dist/registries/providers.js +8 -0
- package/dist/registries/providers.js.map +1 -1
- package/dist/registries/skills.d.ts +9 -0
- package/dist/registries/skills.d.ts.map +1 -1
- package/dist/registries/skills.js +22 -0
- package/dist/registries/skills.js.map +1 -1
- package/dist/requirements.d.ts.map +1 -1
- package/dist/requirements.js +7 -1
- package/dist/requirements.js.map +1 -1
- package/dist/run-turn.d.ts.map +1 -1
- package/dist/run-turn.js +65 -17
- package/dist/run-turn.js.map +1 -1
- package/dist/session.d.ts +9 -0
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +25 -3
- package/dist/session.js.map +1 -1
- package/dist/sessions/persistence.d.ts +15 -19
- package/dist/sessions/persistence.d.ts.map +1 -1
- package/dist/sessions/persistence.js +116 -49
- package/dist/sessions/persistence.js.map +1 -1
- package/dist/skills/loader.d.ts.map +1 -1
- package/dist/skills/loader.js +11 -2
- package/dist/skills/loader.js.map +1 -1
- package/dist/skills/synthesize.d.ts.map +1 -1
- package/dist/skills/synthesize.js +87 -36
- package/dist/skills/synthesize.js.map +1 -1
- package/dist/subagents/events.js +33 -2
- package/dist/subagents/events.js.map +1 -1
- package/dist/subagents/registry.d.ts +22 -1
- package/dist/subagents/registry.d.ts.map +1 -1
- package/dist/subagents/registry.js +72 -2
- package/dist/subagents/registry.js.map +1 -1
- package/dist/subagents/run-child.d.ts.map +1 -1
- package/dist/subagents/run-child.js +138 -46
- package/dist/subagents/run-child.js.map +1 -1
- package/dist/surfaces/host.d.ts +10 -0
- package/dist/surfaces/host.d.ts.map +1 -1
- package/dist/surfaces/host.js +41 -3
- package/dist/surfaces/host.js.map +1 -1
- package/dist/usage-stats.d.ts +7 -1
- package/dist/usage-stats.d.ts.map +1 -1
- package/dist/usage-stats.js +41 -15
- package/dist/usage-stats.js.map +1 -1
- package/dist/view/parse.d.ts.map +1 -1
- package/dist/view/parse.js +119 -19
- package/dist/view/parse.js.map +1 -1
- package/package.json +4 -4
- package/src/events/log.test.ts +45 -0
- package/src/events/log.ts +39 -1
- package/src/index.ts +0 -1
- package/src/permissions/engine.test.ts +60 -0
- package/src/permissions/engine.ts +81 -16
- package/src/plugins/discovery.test.ts +59 -1
- package/src/plugins/discovery.ts +57 -9
- package/src/plugins/host.test.ts +40 -0
- package/src/plugins/host.ts +48 -10
- package/src/plugins/lifecycle.test.ts +29 -0
- package/src/plugins/lifecycle.ts +7 -0
- package/src/plugins/loader.ts +45 -7
- package/src/plugins/registry-kinds.test.ts +53 -0
- package/src/plugins/registry-kinds.ts +25 -3
- package/src/plugins/toposort.ts +45 -17
- package/src/preferences.test.ts +52 -1
- package/src/preferences.ts +5 -4
- package/src/registries/active-backend-registry.ts +10 -1
- package/src/registries/channels.test.ts +79 -0
- package/src/registries/channels.ts +33 -18
- package/src/registries/commands.test.ts +65 -0
- package/src/registries/commands.ts +25 -8
- package/src/registries/isolators.test.ts +37 -0
- package/src/registries/isolators.ts +48 -1
- package/src/registries/modes.test.ts +21 -1
- package/src/registries/modes.ts +15 -2
- package/src/registries/providers.ts +8 -0
- package/src/registries/semantics.test.ts +33 -1
- package/src/registries/skills.ts +28 -0
- package/src/registries/transcribers.test.ts +13 -0
- package/src/requirements.test.ts +35 -0
- package/src/requirements.ts +7 -1
- package/src/run-turn.test.ts +219 -0
- package/src/run-turn.ts +68 -18
- package/src/session.test.ts +24 -0
- package/src/session.ts +24 -3
- package/src/sessions/page.test.ts +18 -39
- package/src/sessions/persistence.test.ts +93 -1
- package/src/sessions/persistence.ts +138 -68
- package/src/skills/loader.test.ts +18 -0
- package/src/skills/loader.ts +12 -1
- package/src/skills/synthesize.test.ts +59 -0
- package/src/skills/synthesize.ts +89 -36
- package/src/subagents/events.test.ts +56 -0
- package/src/subagents/events.ts +32 -2
- package/src/subagents/registry.test.ts +106 -0
- package/src/subagents/registry.ts +77 -3
- package/src/subagents/run-child.test.ts +125 -2
- package/src/subagents/run-child.ts +150 -47
- package/src/surfaces/host.test.ts +71 -1
- package/src/surfaces/host.ts +39 -2
- package/src/usage-stats.test.ts +99 -1
- package/src/usage-stats.ts +42 -17
- package/src/view/parse-extended.test.ts +80 -0
- package/src/view/parse.test.ts +14 -0
- package/src/view/parse.ts +113 -18
|
@@ -46,6 +46,49 @@ export type PermissionPolicy = z.infer<typeof permissionPolicySchema>;
|
|
|
46
46
|
|
|
47
47
|
const emptyPolicy: PermissionPolicy = { allow: [], deny: [] };
|
|
48
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Upper bound on the candidate string a policy regex is tested against. The
|
|
51
|
+
* permission check sits on the synchronous critical path of EVERY tool call and
|
|
52
|
+
* runs author-supplied `inputMatches` patterns over MODEL-controlled tool input
|
|
53
|
+
* (the model proposes the call). A pathological pattern (e.g. `(a+)+$`) over a
|
|
54
|
+
* long model-supplied string can pin the event loop (ReDoS). No legitimate
|
|
55
|
+
* permission match needs more than a few KB, so we truncate the candidate first
|
|
56
|
+
* — bounding the worst-case backtracking work to a fixed input size. Truncation
|
|
57
|
+
* only affects matches that depend on content past 8 KB, which a permission
|
|
58
|
+
* pattern should never rely on.
|
|
59
|
+
*/
|
|
60
|
+
const MAX_MATCH_INPUT = 8192;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Bounded cache of compiled regexes keyed by source string. `check()` walks every
|
|
64
|
+
* rule per tool call and the patterns never change between mutations, so without
|
|
65
|
+
* a cache a policy with N pattern rules recompiles N `RegExp`s on every one of M
|
|
66
|
+
* tool calls. Caching keeps `.test` the only per-call work. A `null` value
|
|
67
|
+
* memoizes an UNCOMPILABLE source so we don't re-throw on every check (the
|
|
68
|
+
* stderr warning is still emitted per check by the caller, preserving the
|
|
69
|
+
* documented fail-open/closed surfacing). Bounded to cap memory if a policy ever
|
|
70
|
+
* carries an unbounded set of distinct patterns; eviction just recompiles.
|
|
71
|
+
*/
|
|
72
|
+
const MAX_REGEX_CACHE = 512;
|
|
73
|
+
const regexCache = new Map<string, RegExp | null>();
|
|
74
|
+
|
|
75
|
+
function compileRegex(source: string): RegExp | null {
|
|
76
|
+
const cached = regexCache.get(source);
|
|
77
|
+
if (cached !== undefined) return cached;
|
|
78
|
+
let compiled: RegExp | null;
|
|
79
|
+
try {
|
|
80
|
+
compiled = new RegExp(source);
|
|
81
|
+
} catch {
|
|
82
|
+
compiled = null;
|
|
83
|
+
}
|
|
84
|
+
if (regexCache.size >= MAX_REGEX_CACHE) {
|
|
85
|
+
const oldest = regexCache.keys().next().value;
|
|
86
|
+
if (oldest !== undefined) regexCache.delete(oldest);
|
|
87
|
+
}
|
|
88
|
+
regexCache.set(source, compiled);
|
|
89
|
+
return compiled;
|
|
90
|
+
}
|
|
91
|
+
|
|
49
92
|
export class PermissionEngine {
|
|
50
93
|
private policy: PermissionPolicy;
|
|
51
94
|
private policyPath: string | null;
|
|
@@ -170,17 +213,15 @@ function matchRule(rule: PolicyRule, call: PendingToolCall, intent: 'allow' | 'd
|
|
|
170
213
|
if (!input || typeof input !== 'object') return false;
|
|
171
214
|
for (const [k, v] of Object.entries(rule.inputMatches)) {
|
|
172
215
|
const candidate = stringifyCandidate(input[k]);
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
} catch (err) {
|
|
183
|
-
warnBadPattern(rule.name, k, v, intent, err);
|
|
216
|
+
// `inputMatches` values are UNANCHORED regexes by design: `.test` does a
|
|
217
|
+
// substring/partial match, so the pattern matches if it occurs anywhere
|
|
218
|
+
// in `candidate`. This is the documented, stable contract (see
|
|
219
|
+
// PolicyRule.inputMatches) — never wrap it in `^(?:…)$`, as that would
|
|
220
|
+
// silently break existing permission files. Authors who need a full
|
|
221
|
+
// match anchor their own pattern with `^…$`.
|
|
222
|
+
const re = compileRegex(v);
|
|
223
|
+
if (re === null) {
|
|
224
|
+
warnBadPattern(rule.name, k, v, intent, regexCompileError(v));
|
|
184
225
|
// Uncompilable pattern: a deny rule must still deny (fail closed),
|
|
185
226
|
// an allow rule must not grant (this field fails to match).
|
|
186
227
|
if (intent === 'deny') continue;
|
|
@@ -198,17 +239,36 @@ function matchRule(rule: PolicyRule, call: PendingToolCall, intent: 'allow' | 'd
|
|
|
198
239
|
* structured field (object/array) is JSON-serialized so a rule can match its
|
|
199
240
|
* shape (e.g. `inputMatches: { args: '"--force"' }`) instead of silently
|
|
200
241
|
* seeing `[object Object]` and never matching. `null`/`undefined` → `''`.
|
|
242
|
+
*
|
|
243
|
+
* The result is truncated to {@link MAX_MATCH_INPUT} so a long model-controlled
|
|
244
|
+
* field can't drive catastrophic regex backtracking on the tool-call hot path
|
|
245
|
+
* (see MAX_MATCH_INPUT).
|
|
201
246
|
*/
|
|
202
247
|
function stringifyCandidate(value: unknown): string {
|
|
203
248
|
if (value === null || value === undefined) return '';
|
|
204
249
|
if (typeof value === 'object') {
|
|
205
250
|
try {
|
|
206
|
-
return JSON.stringify(value) ?? '';
|
|
251
|
+
return capLength(JSON.stringify(value) ?? '');
|
|
207
252
|
} catch {
|
|
208
|
-
return String(value);
|
|
253
|
+
return capLength(String(value));
|
|
209
254
|
}
|
|
210
255
|
}
|
|
211
|
-
return String(value);
|
|
256
|
+
return capLength(String(value));
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function capLength(s: string): string {
|
|
260
|
+
return s.length > MAX_MATCH_INPUT ? s.slice(0, MAX_MATCH_INPUT) : s;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Re-derive the compile error for an uncompilable source (off the hot path —
|
|
264
|
+
* only when a rule's pattern is invalid) so `warnBadPattern` keeps its detail. */
|
|
265
|
+
function regexCompileError(source: string): unknown {
|
|
266
|
+
try {
|
|
267
|
+
new RegExp(source);
|
|
268
|
+
return undefined;
|
|
269
|
+
} catch (err) {
|
|
270
|
+
return err;
|
|
271
|
+
}
|
|
212
272
|
}
|
|
213
273
|
|
|
214
274
|
function warnBadPattern(
|
|
@@ -232,8 +292,13 @@ function warnBadPattern(
|
|
|
232
292
|
function nameMatches(pattern: string, candidate: string): boolean {
|
|
233
293
|
if (pattern === candidate) return true;
|
|
234
294
|
if (pattern.includes('*')) {
|
|
235
|
-
|
|
236
|
-
|
|
295
|
+
// Bound the candidate before the glob-derived `.test` (the glob escapes all
|
|
296
|
+
// metachars, so the only backtracking risk is sheer input length). The
|
|
297
|
+
// source is fully escaped, so `compileRegex` always succeeds here; the cache
|
|
298
|
+
// just avoids recompiling the same glob on every tool call.
|
|
299
|
+
const source = '^' + pattern.split('*').map(escapeRe).join('.*') + '$';
|
|
300
|
+
const re = compileRegex(source);
|
|
301
|
+
return re !== null && re.test(capLength(candidate));
|
|
237
302
|
}
|
|
238
303
|
return false;
|
|
239
304
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { promises as fs } from 'node:fs';
|
|
2
2
|
import * as os from 'node:os';
|
|
3
3
|
import * as path from 'node:path';
|
|
4
|
-
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
5
5
|
import { silentLogger } from '../logger.js';
|
|
6
6
|
import { discoverPlugins } from './discovery.js';
|
|
7
7
|
import { createPluginLoader } from './loader.js';
|
|
@@ -85,6 +85,24 @@ describe('discoverPlugins + createPluginLoader (end-to-end)', () => {
|
|
|
85
85
|
expect(manifests.find((m) => m.packageName === 'plain-pkg')).toBeUndefined();
|
|
86
86
|
});
|
|
87
87
|
|
|
88
|
+
it('rejects a plugin entry that escapes its package directory (path traversal)', async () => {
|
|
89
|
+
const pkgRoot = path.join(cwd, 'node_modules', '@acme', 'mox-thing');
|
|
90
|
+
await fs.mkdir(pkgRoot, { recursive: true });
|
|
91
|
+
await fs.writeFile(
|
|
92
|
+
path.join(pkgRoot, 'package.json'),
|
|
93
|
+
JSON.stringify({
|
|
94
|
+
name: '@acme/mox-thing',
|
|
95
|
+
version: '1.0.0',
|
|
96
|
+
moxxy: { plugin: { entry: '../../../../../../tmp/evil.js' } },
|
|
97
|
+
}),
|
|
98
|
+
);
|
|
99
|
+
const manifests = await discoverPlugins({ cwd, logger: silentLogger });
|
|
100
|
+
const ours = manifests.find((m) => m.packageName === '@acme/mox-thing')!;
|
|
101
|
+
expect(ours).toBeDefined();
|
|
102
|
+
const loader = createPluginLoader({ cwd });
|
|
103
|
+
await expect(loader.load(ours)).rejects.toThrow(/escapes its package directory/);
|
|
104
|
+
});
|
|
105
|
+
|
|
88
106
|
it('rejects entries that do not export a moxxy plugin object', async () => {
|
|
89
107
|
const pkgRoot = path.join(cwd, 'node_modules', '@acme', 'mox-thing');
|
|
90
108
|
await makePkg(pkgRoot, {
|
|
@@ -99,6 +117,46 @@ describe('discoverPlugins + createPluginLoader (end-to-end)', () => {
|
|
|
99
117
|
await expect(loader.load(ours)).rejects.toThrow(/did not export a valid Plugin/);
|
|
100
118
|
});
|
|
101
119
|
|
|
120
|
+
it('stays silent on a missing package.json but warns on a non-ENOENT read failure', async () => {
|
|
121
|
+
// A directory with NO package.json (ENOENT) is the common "not a package"
|
|
122
|
+
// case — must not warn.
|
|
123
|
+
const emptyDir = path.join(cwd, 'node_modules', 'no-pkgjson');
|
|
124
|
+
await fs.mkdir(emptyDir, { recursive: true });
|
|
125
|
+
// A directory whose package.json is malformed JSON (SyntaxError, not ENOENT)
|
|
126
|
+
// means a plugin may have been dropped for a non-structural reason → warn,
|
|
127
|
+
// not swallow it identically to "no package.json".
|
|
128
|
+
const badDir = path.join(cwd, 'node_modules', 'bad-pkgjson');
|
|
129
|
+
await fs.mkdir(badDir, { recursive: true });
|
|
130
|
+
await fs.writeFile(path.join(badDir, 'package.json'), '{ this is not json');
|
|
131
|
+
|
|
132
|
+
const warn = vi.fn();
|
|
133
|
+
const logger = { ...silentLogger, warn };
|
|
134
|
+
const manifests = await discoverPlugins({ cwd, logger });
|
|
135
|
+
expect(manifests.find((m) => m.packageName === 'no-pkgjson')).toBeUndefined();
|
|
136
|
+
expect(manifests.find((m) => m.packageName === 'bad-pkgjson')).toBeUndefined();
|
|
137
|
+
// Exactly the malformed one warned; the missing one did not.
|
|
138
|
+
const warnedPaths = warn.mock.calls.map((c) => String((c[1] as { path?: string })?.path ?? ''));
|
|
139
|
+
expect(warnedPaths.some((p) => p.includes('bad-pkgjson'))).toBe(true);
|
|
140
|
+
expect(warnedPaths.some((p) => p.includes('no-pkgjson'))).toBe(false);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('discovers all plugins even when there are more than the concurrency cap', async () => {
|
|
144
|
+
// Bounding fd concurrency must not DROP plugins — create well over the cap
|
|
145
|
+
// and assert every one is found.
|
|
146
|
+
const count = 80;
|
|
147
|
+
for (let i = 0; i < count; i++) {
|
|
148
|
+
const pkgRoot = path.join(cwd, 'node_modules', `mox-bulk-${i}`);
|
|
149
|
+
await fs.mkdir(pkgRoot, { recursive: true });
|
|
150
|
+
await fs.writeFile(
|
|
151
|
+
path.join(pkgRoot, 'package.json'),
|
|
152
|
+
JSON.stringify({ name: `mox-bulk-${i}`, version: '1.0.0', moxxy: { plugin: { entry: './index.mjs' } } }),
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
const manifests = await discoverPlugins({ cwd, logger: silentLogger });
|
|
156
|
+
const found = manifests.filter((m) => m.packageName.startsWith('mox-bulk-'));
|
|
157
|
+
expect(found).toHaveLength(count);
|
|
158
|
+
});
|
|
159
|
+
|
|
102
160
|
it('walks up parent dirs to find node_modules', async () => {
|
|
103
161
|
const nested = path.join(cwd, 'deeply', 'nested');
|
|
104
162
|
await fs.mkdir(nested, { recursive: true });
|
package/src/plugins/discovery.ts
CHANGED
|
@@ -10,6 +10,37 @@ import type { Logger } from '../logger.js';
|
|
|
10
10
|
*/
|
|
11
11
|
const MAX_NODE_MODULES_WALK_DEPTH = 8;
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Cap on simultaneous fs handles opened while fanning out manifest reads /
|
|
15
|
+
* scoped-dir scans. A hoisted monorepo can have thousands of packages;
|
|
16
|
+
* an uncapped `Promise.all` opens that many fds at once and hits EMFILE/ENFILE,
|
|
17
|
+
* which `readPluginManifest` would then swallow as "no plugin" — silently
|
|
18
|
+
* dropping real plugins under fd pressure. Bounding it keeps discovery
|
|
19
|
+
* deterministic.
|
|
20
|
+
*/
|
|
21
|
+
const DISCOVERY_CONCURRENCY = 32;
|
|
22
|
+
|
|
23
|
+
/** Map `items` through `fn` with at most `limit` in flight, preserving order. */
|
|
24
|
+
async function mapWithConcurrency<T, R>(
|
|
25
|
+
items: ReadonlyArray<T>,
|
|
26
|
+
limit: number,
|
|
27
|
+
fn: (item: T, index: number) => Promise<R>,
|
|
28
|
+
): Promise<R[]> {
|
|
29
|
+
const out = new Array<R>(items.length);
|
|
30
|
+
let next = 0;
|
|
31
|
+
const worker = async (): Promise<void> => {
|
|
32
|
+
while (true) {
|
|
33
|
+
const i = next++;
|
|
34
|
+
if (i >= items.length) return;
|
|
35
|
+
const item = items[i]!;
|
|
36
|
+
out[i] = await fn(item, i);
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
const workers = Array.from({ length: Math.min(limit, items.length) }, () => worker());
|
|
40
|
+
await Promise.all(workers);
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
|
|
13
44
|
export interface DiscoveryOptions {
|
|
14
45
|
readonly cwd: string;
|
|
15
46
|
readonly logger: Logger;
|
|
@@ -40,8 +71,8 @@ export async function discoverPlugins(opts: DiscoveryOptions): Promise<ReadonlyA
|
|
|
40
71
|
seen.add(pkgPath);
|
|
41
72
|
return true;
|
|
42
73
|
});
|
|
43
|
-
const manifests = await
|
|
44
|
-
|
|
74
|
+
const manifests = await mapWithConcurrency(uniquePaths, DISCOVERY_CONCURRENCY, (pkgPath) =>
|
|
75
|
+
readPluginManifest(pkgPath, opts.logger),
|
|
45
76
|
);
|
|
46
77
|
for (const manifest of manifests) {
|
|
47
78
|
if (manifest) out.push(manifest);
|
|
@@ -66,11 +97,15 @@ async function listPackageDirs(root: string): Promise<string[]> {
|
|
|
66
97
|
const entries: import('node:fs').Dirent[] = await fs
|
|
67
98
|
.readdir(root, { withFileTypes: true })
|
|
68
99
|
.catch((): import('node:fs').Dirent[] => []);
|
|
69
|
-
// Resolve each top-level entry to its contributed package dirs
|
|
70
|
-
// (scoped `@org` dirs need their own readdir), then flatten in
|
|
71
|
-
// the result matches the prior sequential walk.
|
|
72
|
-
|
|
73
|
-
|
|
100
|
+
// Resolve each top-level entry to its contributed package dirs with bounded
|
|
101
|
+
// concurrency (scoped `@org` dirs need their own readdir), then flatten in
|
|
102
|
+
// entry order so the result matches the prior sequential walk. Bounding the
|
|
103
|
+
// scoped readdirs avoids opening one fd per `@org` dir all at once on a large
|
|
104
|
+
// hoisted node_modules.
|
|
105
|
+
const perEntry = await mapWithConcurrency(
|
|
106
|
+
entries,
|
|
107
|
+
DISCOVERY_CONCURRENCY,
|
|
108
|
+
async (entry): Promise<string[]> => {
|
|
74
109
|
if (!entry.isDirectory() && !entry.isSymbolicLink()) return [];
|
|
75
110
|
const full = path.join(root, entry.name);
|
|
76
111
|
if (entry.name.startsWith('@')) {
|
|
@@ -83,7 +118,7 @@ async function listPackageDirs(root: string): Promise<string[]> {
|
|
|
83
118
|
}
|
|
84
119
|
if (entry.name !== '.bin' && entry.name !== '.pnpm') return [full];
|
|
85
120
|
return [];
|
|
86
|
-
}
|
|
121
|
+
},
|
|
87
122
|
);
|
|
88
123
|
return perEntry.flat();
|
|
89
124
|
}
|
|
@@ -96,7 +131,20 @@ async function readPluginManifest(
|
|
|
96
131
|
let pkg: { name?: string; version?: string; moxxy?: unknown };
|
|
97
132
|
try {
|
|
98
133
|
pkg = JSON.parse(await fs.readFile(pkgJsonPath, 'utf8'));
|
|
99
|
-
} catch {
|
|
134
|
+
} catch (err) {
|
|
135
|
+
// A missing package.json (ENOENT) is the common, expected "not a package"
|
|
136
|
+
// case — stay silent. Any other errno (EMFILE/ENFILE under fd pressure,
|
|
137
|
+
// EACCES, a malformed JSON SyntaxError) means a plugin may have been DROPPED
|
|
138
|
+
// for a non-structural reason, so surface it instead of swallowing it
|
|
139
|
+
// identically to "no package.json".
|
|
140
|
+
const code = (err as NodeJS.ErrnoException | undefined)?.code;
|
|
141
|
+
if (code !== 'ENOENT') {
|
|
142
|
+
logger.warn('discovery: could not read package.json', {
|
|
143
|
+
path: pkgJsonPath,
|
|
144
|
+
...(code ? { code } : {}),
|
|
145
|
+
err: err instanceof Error ? err.message : String(err),
|
|
146
|
+
});
|
|
147
|
+
}
|
|
100
148
|
return null;
|
|
101
149
|
}
|
|
102
150
|
if (!pkg.moxxy) return null;
|
package/src/plugins/host.test.ts
CHANGED
|
@@ -418,6 +418,46 @@ describe('PluginHost', () => {
|
|
|
418
418
|
});
|
|
419
419
|
});
|
|
420
420
|
|
|
421
|
+
it('rolls back ALL contributions when one collides mid-registration (no orphans)', () => {
|
|
422
|
+
const { host, tools, providers } = makeHost();
|
|
423
|
+
// First plugin claims provider "dup".
|
|
424
|
+
host.registerStatic(
|
|
425
|
+
definePlugin({
|
|
426
|
+
name: 'first',
|
|
427
|
+
providers: [
|
|
428
|
+
defineProvider({
|
|
429
|
+
name: 'dup',
|
|
430
|
+
models: [],
|
|
431
|
+
createClient: () => ({ name: 'dup', models: [], stream: async function* () {}, countTokens: async () => 0 }),
|
|
432
|
+
}),
|
|
433
|
+
],
|
|
434
|
+
}),
|
|
435
|
+
);
|
|
436
|
+
// Second plugin registers a tool FIRST (succeeds), then a provider named
|
|
437
|
+
// "dup" which collides → applyPlugin throws part way through. The already-
|
|
438
|
+
// registered tool must be rolled back, and no LoadedRecord must exist.
|
|
439
|
+
const colliding = definePlugin({
|
|
440
|
+
name: 'second',
|
|
441
|
+
tools: [
|
|
442
|
+
defineTool({ name: 'orphan-tool', description: '', inputSchema: z.any(), handler: () => null }),
|
|
443
|
+
],
|
|
444
|
+
providers: [
|
|
445
|
+
defineProvider({
|
|
446
|
+
name: 'dup',
|
|
447
|
+
models: [],
|
|
448
|
+
createClient: () => ({ name: 'dup', models: [], stream: async function* () {}, countTokens: async () => 0 }),
|
|
449
|
+
}),
|
|
450
|
+
],
|
|
451
|
+
});
|
|
452
|
+
expect(() => host.registerStatic(colliding)).toThrow(/already registered/);
|
|
453
|
+
// The tool registered before the collision must NOT be stranded.
|
|
454
|
+
expect(tools.has('orphan-tool')).toBe(false);
|
|
455
|
+
// The first plugin's provider survives; no duplicate from the failed load.
|
|
456
|
+
expect(providers.list().map((p) => p.name)).toEqual(['dup']);
|
|
457
|
+
// No half-loaded record for "second".
|
|
458
|
+
expect(host.list().map((p) => p.name)).toEqual(['first']);
|
|
459
|
+
});
|
|
460
|
+
|
|
421
461
|
it('registerStatic + unload roundtrip transcribers', async () => {
|
|
422
462
|
const { host, transcribers } = makeHost();
|
|
423
463
|
const t = defineTranscriber({
|
package/src/plugins/host.ts
CHANGED
|
@@ -220,21 +220,59 @@ export class PluginHost implements PluginHostHandle {
|
|
|
220
220
|
}
|
|
221
221
|
|
|
222
222
|
private applyPlugin(plugin: Plugin, manifest?: ResolvedPluginManifest): LoadedRecord {
|
|
223
|
-
//
|
|
224
|
-
//
|
|
225
|
-
//
|
|
226
|
-
const
|
|
227
|
-
for (const kind of REGISTRY_KINDS) {
|
|
228
|
-
names[kind.recordField] = kind.defs(plugin).map((def) => kind.nameOf(def));
|
|
229
|
-
}
|
|
223
|
+
// A statically-registered builtin (no manifest) is trusted; a discovered
|
|
224
|
+
// plugin (manifest present) is not. The isolator registry uses this to
|
|
225
|
+
// refuse letting a discovered plugin shadow a trusted isolator name.
|
|
226
|
+
const trusted = manifest === undefined;
|
|
230
227
|
|
|
231
228
|
// Register in REGISTRY_KINDS order — the exact set + order the original
|
|
232
229
|
// hand-written register sequence used (incl. viewRenderers/tunnelProviders
|
|
233
|
-
// via `replace`).
|
|
234
|
-
|
|
235
|
-
|
|
230
|
+
// via `replace`). Track what we successfully registered so a mid-loop throw
|
|
231
|
+
// (e.g. a duplicate name colliding with an already-loaded plugin, since most
|
|
232
|
+
// registries throw on duplicate) doesn't strand half-registered
|
|
233
|
+
// contributions: no LoadedRecord is created on throw, so `unload` could
|
|
234
|
+
// never reach them. On failure, unregister in reverse before rethrowing.
|
|
235
|
+
const registered: Array<{ kind: (typeof REGISTRY_KINDS)[number]; name: string }> = [];
|
|
236
|
+
try {
|
|
237
|
+
for (const kind of REGISTRY_KINDS) {
|
|
238
|
+
for (const def of kind.defs(plugin)) {
|
|
239
|
+
// A `false` return means the registration was REFUSED without taking
|
|
240
|
+
// effect (an untrusted plugin shadowing a trusted isolator). Don't
|
|
241
|
+
// track it — neither for rollback (unregistering would delete the
|
|
242
|
+
// trusted impl this plugin never owned) NOR for the LoadedRecord (so
|
|
243
|
+
// a clean `unload` later doesn't delete that same trusted impl).
|
|
244
|
+
const applied = kind.register(this.opts, def, trusted);
|
|
245
|
+
if (applied === false) continue;
|
|
246
|
+
registered.push({ kind, name: kind.nameOf(def) });
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
} catch (err) {
|
|
250
|
+
for (let i = registered.length - 1; i >= 0; i--) {
|
|
251
|
+
const entry = registered[i];
|
|
252
|
+
if (!entry) continue;
|
|
253
|
+
const { kind, name } = entry;
|
|
254
|
+
// Don't roll back override-on-register kinds (view/tunnel): they're
|
|
255
|
+
// last-wins `replace`, so this plugin may have clobbered a def core or
|
|
256
|
+
// another plugin owned — unregistering would delete that shared def.
|
|
257
|
+
if (kind.overrideOnRegister) continue;
|
|
258
|
+
try {
|
|
259
|
+
kind.unregister(this.opts, name);
|
|
260
|
+
} catch {
|
|
261
|
+
// Best-effort rollback: keep unwinding the rest even if one
|
|
262
|
+
// unregister throws, so we don't leave further orphans behind.
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
throw err;
|
|
236
266
|
}
|
|
237
267
|
|
|
268
|
+
// Build the LoadedRecord's per-kind name lists from what was ACTUALLY
|
|
269
|
+
// applied (the `registered` list), not from the raw `defs` snapshot — a
|
|
270
|
+
// refused isolator registration must not appear here, or `unload` would
|
|
271
|
+
// unregister a name this plugin never owned (deleting a trusted builtin).
|
|
272
|
+
const names = {} as Record<keyof RegistryNameRecord, string[]>;
|
|
273
|
+
for (const kind of REGISTRY_KINDS) names[kind.recordField] = [];
|
|
274
|
+
for (const { kind, name } of registered) names[kind.recordField].push(name);
|
|
275
|
+
|
|
238
276
|
return {
|
|
239
277
|
plugin,
|
|
240
278
|
manifest,
|
|
@@ -168,6 +168,35 @@ describe('HookDispatcherImpl', () => {
|
|
|
168
168
|
expect(d.hasEventHooks()).toBe(true);
|
|
169
169
|
});
|
|
170
170
|
|
|
171
|
+
it('does not surface an unhandledRejection when a hook rejects AFTER timing out', async () => {
|
|
172
|
+
const unhandled: unknown[] = [];
|
|
173
|
+
const onUnhandled = (reason: unknown): void => void unhandled.push(reason);
|
|
174
|
+
process.on('unhandledRejection', onUnhandled);
|
|
175
|
+
try {
|
|
176
|
+
// Hook stays pending past the (tiny) timeout, THEN rejects. The dispatcher
|
|
177
|
+
// must have a rejection handler attached to the hook promise so this
|
|
178
|
+
// late rejection is swallowed rather than crashing the process.
|
|
179
|
+
const slow = definePlugin({
|
|
180
|
+
name: 'slow',
|
|
181
|
+
hooks: {
|
|
182
|
+
onInit: () =>
|
|
183
|
+
new Promise<void>((_, reject) => {
|
|
184
|
+
setTimeout(() => reject(new Error('late boom')), 30);
|
|
185
|
+
}),
|
|
186
|
+
},
|
|
187
|
+
});
|
|
188
|
+
const d = new HookDispatcherImpl({ logger: silentLogger, hookTimeoutMs: 5 });
|
|
189
|
+
d.setPlugins([slow]);
|
|
190
|
+
await d.dispatchInit(appCtx); // resolves on timeout, not on the late reject
|
|
191
|
+
// Wait long enough for the late rejection to fire and any microtasks to
|
|
192
|
+
// flush, so a missing handler would have been observed by now.
|
|
193
|
+
await new Promise((r) => setTimeout(r, 60));
|
|
194
|
+
expect(unhandled).toEqual([]);
|
|
195
|
+
} finally {
|
|
196
|
+
process.off('unhandledRejection', onUnhandled);
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
|
|
171
200
|
it('logs and continues when a hook throws', async () => {
|
|
172
201
|
const failed = vi.fn();
|
|
173
202
|
const bad = definePlugin({
|
package/src/plugins/lifecycle.ts
CHANGED
|
@@ -112,6 +112,13 @@ export class HookDispatcherImpl implements HookDispatcher {
|
|
|
112
112
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
113
113
|
try {
|
|
114
114
|
const p = Promise.resolve(fn() as Promise<T | undefined | void>);
|
|
115
|
+
// If the timeout wins the race, `p` is still pending and unobserved; a
|
|
116
|
+
// slow hook that later rejects would surface as an unhandledRejection
|
|
117
|
+
// (process-level crash/log spam in strict configs / the desktop main).
|
|
118
|
+
// Attach a no-op handler so a post-timeout rejection is swallowed too.
|
|
119
|
+
// (Promise.race still settles on the original `p`, so this doesn't mask
|
|
120
|
+
// the in-time error path below — that one is observed via the race.)
|
|
121
|
+
p.catch(() => undefined);
|
|
115
122
|
const timeout = new Promise<never>((_, reject) => {
|
|
116
123
|
timer = setTimeout(
|
|
117
124
|
() => reject(new Error(`Hook ${hook} on ${entry.plugin.name} timed out`)),
|
package/src/plugins/loader.ts
CHANGED
|
@@ -5,20 +5,42 @@ import type { PluginLoader } from './host.js';
|
|
|
5
5
|
|
|
6
6
|
export interface JitiLoaderOptions {
|
|
7
7
|
readonly cwd: string;
|
|
8
|
+
/**
|
|
9
|
+
* When provided, its return value is appended as a `?v=` query to the ESM
|
|
10
|
+
* import URL so `import()` re-evaluates the module. Supplying this LEAKS a
|
|
11
|
+
* module instance per call — ESM modules can't be unloaded, so every distinct
|
|
12
|
+
* URL is retained for the process lifetime. Provide it ONLY for an explicit
|
|
13
|
+
* hot-reload of a changed `.js`/`.mjs` plugin; omit it for normal loads so a
|
|
14
|
+
* long-lived runner that reloads repeatedly doesn't grow memory without
|
|
15
|
+
* bound. (`.ts` plugins go through jiti, which has its own cache control.)
|
|
16
|
+
*/
|
|
8
17
|
readonly cacheBust?: () => string;
|
|
9
18
|
}
|
|
10
19
|
|
|
11
|
-
|
|
20
|
+
// Key the jiti cache by cwd: createPluginLoader is called per-session with the
|
|
21
|
+
// session's cwd, so a single process-global instance rooted at the FIRST cwd
|
|
22
|
+
// would resolve every later session's .ts plugins (and jiti's transform cache)
|
|
23
|
+
// against the wrong base directory — non-deterministically by session order.
|
|
24
|
+
// One jiti per distinct plugin root is bounded and correct.
|
|
25
|
+
const jitiByCwd = new Map<string, ((id: string) => unknown) | null>();
|
|
12
26
|
|
|
13
27
|
async function getJiti(cwd: string): Promise<((id: string) => unknown) | null> {
|
|
14
|
-
|
|
28
|
+
const cached = jitiByCwd.get(cwd);
|
|
29
|
+
if (cached !== undefined) return cached;
|
|
15
30
|
try {
|
|
16
31
|
const mod = await import('jiti');
|
|
17
32
|
const factory = (mod as { createJiti?: (cwd: string, opts?: unknown) => (id: string) => unknown; default?: (cwd: string, opts?: unknown) => (id: string) => unknown }).createJiti ?? (mod as { default?: (cwd: string, opts?: unknown) => (id: string) => unknown }).default;
|
|
18
|
-
if (!factory)
|
|
19
|
-
|
|
20
|
-
|
|
33
|
+
if (!factory) {
|
|
34
|
+
jitiByCwd.set(cwd, null);
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
const instance = factory(cwd, { interopDefault: true });
|
|
38
|
+
jitiByCwd.set(cwd, instance);
|
|
39
|
+
return instance;
|
|
21
40
|
} catch {
|
|
41
|
+
// Don't cache a transient import failure as a permanent null — a missing
|
|
42
|
+
// optional `jiti` dep is the only realistic cause and is stable, but not
|
|
43
|
+
// caching here keeps a one-off failure from poisoning all later loads.
|
|
22
44
|
return null;
|
|
23
45
|
}
|
|
24
46
|
}
|
|
@@ -27,6 +49,17 @@ export function createPluginLoader(opts: JitiLoaderOptions): PluginLoader {
|
|
|
27
49
|
return {
|
|
28
50
|
async load(manifest: ResolvedPluginManifest): Promise<Plugin> {
|
|
29
51
|
const entry = path.resolve(manifest.packagePath, manifest.entry);
|
|
52
|
+
// `manifest.entry` comes from the plugin's own package.json (validated only
|
|
53
|
+
// as a non-empty string). Reject an entry that escapes the package dir
|
|
54
|
+
// (e.g. `../../sibling/internals.js` or an absolute path) so a package can
|
|
55
|
+
// only execute code from inside its own tree — cheap defense-in-depth for
|
|
56
|
+
// provenance/auditing and any future sandbox-by-path assumptions.
|
|
57
|
+
const rel = path.relative(manifest.packagePath, entry);
|
|
58
|
+
if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
`Plugin entry escapes its package directory: ${manifest.entry} (package: ${manifest.packagePath})`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
30
63
|
const isTs = entry.endsWith('.ts') || entry.endsWith('.tsx');
|
|
31
64
|
|
|
32
65
|
let mod: unknown;
|
|
@@ -39,8 +72,13 @@ export function createPluginLoader(opts: JitiLoaderOptions): PluginLoader {
|
|
|
39
72
|
}
|
|
40
73
|
mod = jiti(entry);
|
|
41
74
|
} else {
|
|
42
|
-
|
|
43
|
-
|
|
75
|
+
// Only cache-bust on an explicit request (caller supplied cacheBust).
|
|
76
|
+
// Busting unconditionally appended a unique `?v=` on EVERY load, and
|
|
77
|
+
// since ESM never unloads a module, each load permanently retained a
|
|
78
|
+
// fresh copy of the plugin + its transitive graph — an unbounded leak
|
|
79
|
+
// across repeated reloads. The plain URL lets ESM reuse its cache.
|
|
80
|
+
const bust = opts.cacheBust?.();
|
|
81
|
+
const url = bust ? `${pathToFileURL(entry).href}?v=${bust}` : pathToFileURL(entry).href;
|
|
44
82
|
mod = await import(url);
|
|
45
83
|
}
|
|
46
84
|
|
|
@@ -159,4 +159,57 @@ describe('PluginHost register/unregister lockstep (REGISTRY_KINDS)', () => {
|
|
|
159
159
|
expect(byKind[kind.kind]!.list().length, `${kind.kind} should be empty after unload`).toBe(0);
|
|
160
160
|
}
|
|
161
161
|
});
|
|
162
|
+
|
|
163
|
+
it('unloading a discovered plugin whose isolator was REFUSED keeps the trusted builtin', async () => {
|
|
164
|
+
const { host, byKind } = makeHost();
|
|
165
|
+
const isolators = byKind.isolator as IsolatorRegistry;
|
|
166
|
+
// A trusted builtin owns the name "worker".
|
|
167
|
+
const trustedWorker: Isolator = { name: 'worker', strength: 'none', run: async (_c, h) => h(undefined) };
|
|
168
|
+
host.registerStatic(definePlugin({ name: 'builtin-sec', isolators: [trustedWorker] }));
|
|
169
|
+
expect(isolators.get('worker')).toBe(trustedWorker);
|
|
170
|
+
|
|
171
|
+
// A discovered (untrusted) plugin tries to shadow "worker"; the registry
|
|
172
|
+
// refuses, so the plugin never owned it. Unloading the discovered plugin
|
|
173
|
+
// must NOT delete the trusted impl.
|
|
174
|
+
const rogueWorker: Isolator = { name: 'worker', strength: 'none', run: async (_c, h) => h(undefined) };
|
|
175
|
+
host.registerDiscovered(
|
|
176
|
+
definePlugin({ name: 'rogue', isolators: [rogueWorker] }),
|
|
177
|
+
{ entry: './index.js', packageName: '@rogue/pkg', packageVersion: '1.0.0', packagePath: '/tmp/rogue' },
|
|
178
|
+
);
|
|
179
|
+
expect(isolators.get('worker')).toBe(trustedWorker); // refused, not shadowed
|
|
180
|
+
|
|
181
|
+
await host.unload('@rogue/pkg');
|
|
182
|
+
// The trusted worker must survive the rogue plugin's unload.
|
|
183
|
+
expect(isolators.get('worker')).toBe(trustedWorker);
|
|
184
|
+
expect(isolators.has('worker')).toBe(true);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('a mid-load collision in a discovered plugin whose isolator was REFUSED does not delete the trusted builtin', () => {
|
|
188
|
+
const { host, byKind } = makeHost();
|
|
189
|
+
const isolators = byKind.isolator as IsolatorRegistry;
|
|
190
|
+
const tools = byKind.tool as ToolRegistryImpl;
|
|
191
|
+
const trustedWorker: Isolator = { name: 'worker', strength: 'none', run: async (_c, h) => h(undefined) };
|
|
192
|
+
host.registerStatic(definePlugin({ name: 'builtin-sec', isolators: [trustedWorker] }));
|
|
193
|
+
// Another plugin owns tool "dup".
|
|
194
|
+
host.registerStatic(
|
|
195
|
+
definePlugin({ name: 'first', tools: [defineTool({ name: 'dup', description: '', inputSchema: z.any(), handler: () => null })] }),
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
// A discovered plugin: its isolator "worker" is refused (untrusted shadow),
|
|
199
|
+
// then its tool "dup" collides → applyPlugin throws and rolls back. The
|
|
200
|
+
// rollback must NOT unregister "worker" (the plugin never owned it).
|
|
201
|
+
const rogue = definePlugin({
|
|
202
|
+
name: 'rogue',
|
|
203
|
+
isolators: [{ name: 'worker', strength: 'none', run: async (_c, h) => h(undefined) } as Isolator],
|
|
204
|
+
tools: [defineTool({ name: 'dup', description: '', inputSchema: z.any(), handler: () => null })],
|
|
205
|
+
});
|
|
206
|
+
expect(() =>
|
|
207
|
+
host.registerDiscovered(rogue, { entry: './index.js', packageName: '@rogue/pkg', packageVersion: '1.0.0', packagePath: '/tmp/rogue' }),
|
|
208
|
+
).toThrow(/already registered/);
|
|
209
|
+
// Trusted worker untouched; first plugin's tool untouched.
|
|
210
|
+
expect(isolators.get('worker')).toBe(trustedWorker);
|
|
211
|
+
expect(tools.has('dup')).toBe(true);
|
|
212
|
+
// No half-loaded record for the rogue.
|
|
213
|
+
expect(host.list().map((p) => p.name).sort()).toEqual(['builtin-sec', 'first']);
|
|
214
|
+
});
|
|
162
215
|
});
|