@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.
Files changed (156) hide show
  1. package/dist/events/log.d.ts.map +1 -1
  2. package/dist/events/log.js +38 -1
  3. package/dist/events/log.js.map +1 -1
  4. package/dist/index.d.ts +1 -1
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +1 -1
  7. package/dist/index.js.map +1 -1
  8. package/dist/permissions/engine.d.ts.map +1 -1
  9. package/dist/permissions/engine.js +80 -17
  10. package/dist/permissions/engine.js.map +1 -1
  11. package/dist/plugins/discovery.d.ts.map +1 -1
  12. package/dist/plugins/discovery.js +48 -7
  13. package/dist/plugins/discovery.js.map +1 -1
  14. package/dist/plugins/host.d.ts.map +1 -1
  15. package/dist/plugins/host.js +54 -11
  16. package/dist/plugins/host.js.map +1 -1
  17. package/dist/plugins/lifecycle.d.ts.map +1 -1
  18. package/dist/plugins/lifecycle.js +7 -0
  19. package/dist/plugins/lifecycle.js.map +1 -1
  20. package/dist/plugins/loader.d.ts +9 -0
  21. package/dist/plugins/loader.d.ts.map +1 -1
  22. package/dist/plugins/loader.js +34 -8
  23. package/dist/plugins/loader.js.map +1 -1
  24. package/dist/plugins/registry-kinds.d.ts +21 -2
  25. package/dist/plugins/registry-kinds.d.ts.map +1 -1
  26. package/dist/plugins/registry-kinds.js +3 -1
  27. package/dist/plugins/registry-kinds.js.map +1 -1
  28. package/dist/plugins/toposort.d.ts.map +1 -1
  29. package/dist/plugins/toposort.js +43 -18
  30. package/dist/plugins/toposort.js.map +1 -1
  31. package/dist/preferences.d.ts.map +1 -1
  32. package/dist/preferences.js +5 -4
  33. package/dist/preferences.js.map +1 -1
  34. package/dist/registries/active-backend-registry.d.ts.map +1 -1
  35. package/dist/registries/active-backend-registry.js +10 -1
  36. package/dist/registries/active-backend-registry.js.map +1 -1
  37. package/dist/registries/channels.d.ts +2 -1
  38. package/dist/registries/channels.d.ts.map +1 -1
  39. package/dist/registries/channels.js +30 -20
  40. package/dist/registries/channels.js.map +1 -1
  41. package/dist/registries/commands.d.ts.map +1 -1
  42. package/dist/registries/commands.js +27 -8
  43. package/dist/registries/commands.js.map +1 -1
  44. package/dist/registries/isolators.d.ts +27 -1
  45. package/dist/registries/isolators.d.ts.map +1 -1
  46. package/dist/registries/isolators.js +35 -1
  47. package/dist/registries/isolators.js.map +1 -1
  48. package/dist/registries/modes.d.ts +4 -0
  49. package/dist/registries/modes.d.ts.map +1 -1
  50. package/dist/registries/modes.js +15 -4
  51. package/dist/registries/modes.js.map +1 -1
  52. package/dist/registries/providers.d.ts +8 -0
  53. package/dist/registries/providers.d.ts.map +1 -1
  54. package/dist/registries/providers.js +8 -0
  55. package/dist/registries/providers.js.map +1 -1
  56. package/dist/registries/skills.d.ts +9 -0
  57. package/dist/registries/skills.d.ts.map +1 -1
  58. package/dist/registries/skills.js +22 -0
  59. package/dist/registries/skills.js.map +1 -1
  60. package/dist/requirements.d.ts.map +1 -1
  61. package/dist/requirements.js +7 -1
  62. package/dist/requirements.js.map +1 -1
  63. package/dist/run-turn.d.ts.map +1 -1
  64. package/dist/run-turn.js +65 -17
  65. package/dist/run-turn.js.map +1 -1
  66. package/dist/session.d.ts +9 -0
  67. package/dist/session.d.ts.map +1 -1
  68. package/dist/session.js +25 -3
  69. package/dist/session.js.map +1 -1
  70. package/dist/sessions/persistence.d.ts +15 -19
  71. package/dist/sessions/persistence.d.ts.map +1 -1
  72. package/dist/sessions/persistence.js +116 -49
  73. package/dist/sessions/persistence.js.map +1 -1
  74. package/dist/skills/loader.d.ts.map +1 -1
  75. package/dist/skills/loader.js +11 -2
  76. package/dist/skills/loader.js.map +1 -1
  77. package/dist/skills/synthesize.d.ts.map +1 -1
  78. package/dist/skills/synthesize.js +87 -36
  79. package/dist/skills/synthesize.js.map +1 -1
  80. package/dist/subagents/events.js +33 -2
  81. package/dist/subagents/events.js.map +1 -1
  82. package/dist/subagents/registry.d.ts +22 -1
  83. package/dist/subagents/registry.d.ts.map +1 -1
  84. package/dist/subagents/registry.js +72 -2
  85. package/dist/subagents/registry.js.map +1 -1
  86. package/dist/subagents/run-child.d.ts.map +1 -1
  87. package/dist/subagents/run-child.js +138 -46
  88. package/dist/subagents/run-child.js.map +1 -1
  89. package/dist/surfaces/host.d.ts +10 -0
  90. package/dist/surfaces/host.d.ts.map +1 -1
  91. package/dist/surfaces/host.js +41 -3
  92. package/dist/surfaces/host.js.map +1 -1
  93. package/dist/usage-stats.d.ts +7 -1
  94. package/dist/usage-stats.d.ts.map +1 -1
  95. package/dist/usage-stats.js +41 -15
  96. package/dist/usage-stats.js.map +1 -1
  97. package/dist/view/parse.d.ts.map +1 -1
  98. package/dist/view/parse.js +119 -19
  99. package/dist/view/parse.js.map +1 -1
  100. package/package.json +4 -4
  101. package/src/events/log.test.ts +45 -0
  102. package/src/events/log.ts +39 -1
  103. package/src/index.ts +0 -1
  104. package/src/permissions/engine.test.ts +60 -0
  105. package/src/permissions/engine.ts +81 -16
  106. package/src/plugins/discovery.test.ts +59 -1
  107. package/src/plugins/discovery.ts +57 -9
  108. package/src/plugins/host.test.ts +40 -0
  109. package/src/plugins/host.ts +48 -10
  110. package/src/plugins/lifecycle.test.ts +29 -0
  111. package/src/plugins/lifecycle.ts +7 -0
  112. package/src/plugins/loader.ts +45 -7
  113. package/src/plugins/registry-kinds.test.ts +53 -0
  114. package/src/plugins/registry-kinds.ts +25 -3
  115. package/src/plugins/toposort.ts +45 -17
  116. package/src/preferences.test.ts +52 -1
  117. package/src/preferences.ts +5 -4
  118. package/src/registries/active-backend-registry.ts +10 -1
  119. package/src/registries/channels.test.ts +79 -0
  120. package/src/registries/channels.ts +33 -18
  121. package/src/registries/commands.test.ts +65 -0
  122. package/src/registries/commands.ts +25 -8
  123. package/src/registries/isolators.test.ts +37 -0
  124. package/src/registries/isolators.ts +48 -1
  125. package/src/registries/modes.test.ts +21 -1
  126. package/src/registries/modes.ts +15 -2
  127. package/src/registries/providers.ts +8 -0
  128. package/src/registries/semantics.test.ts +33 -1
  129. package/src/registries/skills.ts +28 -0
  130. package/src/registries/transcribers.test.ts +13 -0
  131. package/src/requirements.test.ts +35 -0
  132. package/src/requirements.ts +7 -1
  133. package/src/run-turn.test.ts +219 -0
  134. package/src/run-turn.ts +68 -18
  135. package/src/session.test.ts +24 -0
  136. package/src/session.ts +24 -3
  137. package/src/sessions/page.test.ts +18 -39
  138. package/src/sessions/persistence.test.ts +93 -1
  139. package/src/sessions/persistence.ts +138 -68
  140. package/src/skills/loader.test.ts +18 -0
  141. package/src/skills/loader.ts +12 -1
  142. package/src/skills/synthesize.test.ts +59 -0
  143. package/src/skills/synthesize.ts +89 -36
  144. package/src/subagents/events.test.ts +56 -0
  145. package/src/subagents/events.ts +32 -2
  146. package/src/subagents/registry.test.ts +106 -0
  147. package/src/subagents/registry.ts +77 -3
  148. package/src/subagents/run-child.test.ts +125 -2
  149. package/src/subagents/run-child.ts +150 -47
  150. package/src/surfaces/host.test.ts +71 -1
  151. package/src/surfaces/host.ts +39 -2
  152. package/src/usage-stats.test.ts +99 -1
  153. package/src/usage-stats.ts +42 -17
  154. package/src/view/parse-extended.test.ts +80 -0
  155. package/src/view/parse.test.ts +14 -0
  156. 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
- let re: RegExp;
174
- try {
175
- // `inputMatches` values are UNANCHORED regexes by design: `.test` does a
176
- // substring/partial match, so the pattern matches if it occurs anywhere
177
- // in `candidate`. This is the documented, stable contract (see
178
- // PolicyRule.inputMatches) never wrap it in `^(?:…)$`, as that would
179
- // silently break existing permission files. Authors who need a full
180
- // match anchor their own pattern with `^…$`.
181
- re = new RegExp(v);
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
- const re = new RegExp('^' + pattern.split('*').map(escapeRe).join('.*') + '$');
236
- return re.test(candidate);
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 });
@@ -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 Promise.all(
44
- uniquePaths.map((pkgPath) => readPluginManifest(pkgPath, opts.logger)),
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 in parallel
70
- // (scoped `@org` dirs need their own readdir), then flatten in entry order so
71
- // the result matches the prior sequential walk.
72
- const perEntry = await Promise.all(
73
- entries.map(async (entry): Promise<string[]> => {
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;
@@ -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({
@@ -220,21 +220,59 @@ export class PluginHost implements PluginHostHandle {
220
220
  }
221
221
 
222
222
  private applyPlugin(plugin: Plugin, manifest?: ResolvedPluginManifest): LoadedRecord {
223
- // Snapshot the contributed names BEFORE registering (same as the original
224
- // two-phase order), keyed by REGISTRY_KINDS field. Building the names from
225
- // the same table the registration loop uses keeps the two in lockstep.
226
- const names = {} as Record<keyof RegistryNameRecord, ReadonlyArray<string>>;
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
- for (const kind of REGISTRY_KINDS) {
235
- for (const def of kind.defs(plugin)) kind.register(this.opts, def);
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({
@@ -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`)),
@@ -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
- let jitiInstance: ((id: string) => unknown) | null = null;
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
- if (jitiInstance) return jitiInstance;
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) return null;
19
- jitiInstance = factory(cwd, { interopDefault: true });
20
- return jitiInstance;
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
- const bust = opts.cacheBust?.() ?? `${Date.now()}-${Math.random()}`;
43
- const url = `${pathToFileURL(entry).href}?v=${bust}`;
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
  });