@moxxy/core 0.4.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 (151) 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/permissions/engine.d.ts.map +1 -1
  5. package/dist/permissions/engine.js +80 -17
  6. package/dist/permissions/engine.js.map +1 -1
  7. package/dist/plugins/discovery.d.ts.map +1 -1
  8. package/dist/plugins/discovery.js +48 -7
  9. package/dist/plugins/discovery.js.map +1 -1
  10. package/dist/plugins/host.d.ts.map +1 -1
  11. package/dist/plugins/host.js +54 -11
  12. package/dist/plugins/host.js.map +1 -1
  13. package/dist/plugins/lifecycle.d.ts.map +1 -1
  14. package/dist/plugins/lifecycle.js +7 -0
  15. package/dist/plugins/lifecycle.js.map +1 -1
  16. package/dist/plugins/loader.d.ts +9 -0
  17. package/dist/plugins/loader.d.ts.map +1 -1
  18. package/dist/plugins/loader.js +34 -8
  19. package/dist/plugins/loader.js.map +1 -1
  20. package/dist/plugins/registry-kinds.d.ts +21 -2
  21. package/dist/plugins/registry-kinds.d.ts.map +1 -1
  22. package/dist/plugins/registry-kinds.js +3 -1
  23. package/dist/plugins/registry-kinds.js.map +1 -1
  24. package/dist/plugins/toposort.d.ts.map +1 -1
  25. package/dist/plugins/toposort.js +43 -18
  26. package/dist/plugins/toposort.js.map +1 -1
  27. package/dist/preferences.d.ts.map +1 -1
  28. package/dist/preferences.js +5 -4
  29. package/dist/preferences.js.map +1 -1
  30. package/dist/registries/active-backend-registry.d.ts.map +1 -1
  31. package/dist/registries/active-backend-registry.js +10 -1
  32. package/dist/registries/active-backend-registry.js.map +1 -1
  33. package/dist/registries/channels.d.ts +2 -1
  34. package/dist/registries/channels.d.ts.map +1 -1
  35. package/dist/registries/channels.js +30 -20
  36. package/dist/registries/channels.js.map +1 -1
  37. package/dist/registries/commands.d.ts.map +1 -1
  38. package/dist/registries/commands.js +27 -8
  39. package/dist/registries/commands.js.map +1 -1
  40. package/dist/registries/isolators.d.ts +27 -1
  41. package/dist/registries/isolators.d.ts.map +1 -1
  42. package/dist/registries/isolators.js +35 -1
  43. package/dist/registries/isolators.js.map +1 -1
  44. package/dist/registries/modes.d.ts +4 -0
  45. package/dist/registries/modes.d.ts.map +1 -1
  46. package/dist/registries/modes.js +15 -4
  47. package/dist/registries/modes.js.map +1 -1
  48. package/dist/registries/providers.d.ts +8 -0
  49. package/dist/registries/providers.d.ts.map +1 -1
  50. package/dist/registries/providers.js +8 -0
  51. package/dist/registries/providers.js.map +1 -1
  52. package/dist/registries/skills.d.ts +9 -0
  53. package/dist/registries/skills.d.ts.map +1 -1
  54. package/dist/registries/skills.js +22 -0
  55. package/dist/registries/skills.js.map +1 -1
  56. package/dist/requirements.d.ts.map +1 -1
  57. package/dist/requirements.js +7 -1
  58. package/dist/requirements.js.map +1 -1
  59. package/dist/run-turn.d.ts.map +1 -1
  60. package/dist/run-turn.js +65 -17
  61. package/dist/run-turn.js.map +1 -1
  62. package/dist/session.d.ts +9 -0
  63. package/dist/session.d.ts.map +1 -1
  64. package/dist/session.js +25 -3
  65. package/dist/session.js.map +1 -1
  66. package/dist/sessions/persistence.d.ts +15 -0
  67. package/dist/sessions/persistence.d.ts.map +1 -1
  68. package/dist/sessions/persistence.js +116 -13
  69. package/dist/sessions/persistence.js.map +1 -1
  70. package/dist/skills/loader.d.ts.map +1 -1
  71. package/dist/skills/loader.js +11 -2
  72. package/dist/skills/loader.js.map +1 -1
  73. package/dist/skills/synthesize.d.ts.map +1 -1
  74. package/dist/skills/synthesize.js +87 -36
  75. package/dist/skills/synthesize.js.map +1 -1
  76. package/dist/subagents/events.js +33 -2
  77. package/dist/subagents/events.js.map +1 -1
  78. package/dist/subagents/registry.d.ts +22 -1
  79. package/dist/subagents/registry.d.ts.map +1 -1
  80. package/dist/subagents/registry.js +72 -2
  81. package/dist/subagents/registry.js.map +1 -1
  82. package/dist/subagents/run-child.d.ts.map +1 -1
  83. package/dist/subagents/run-child.js +138 -46
  84. package/dist/subagents/run-child.js.map +1 -1
  85. package/dist/surfaces/host.d.ts +10 -0
  86. package/dist/surfaces/host.d.ts.map +1 -1
  87. package/dist/surfaces/host.js +41 -3
  88. package/dist/surfaces/host.js.map +1 -1
  89. package/dist/usage-stats.d.ts +7 -1
  90. package/dist/usage-stats.d.ts.map +1 -1
  91. package/dist/usage-stats.js +41 -15
  92. package/dist/usage-stats.js.map +1 -1
  93. package/dist/view/parse.d.ts.map +1 -1
  94. package/dist/view/parse.js +119 -19
  95. package/dist/view/parse.js.map +1 -1
  96. package/package.json +4 -4
  97. package/src/events/log.test.ts +45 -0
  98. package/src/events/log.ts +39 -1
  99. package/src/permissions/engine.test.ts +60 -0
  100. package/src/permissions/engine.ts +81 -16
  101. package/src/plugins/discovery.test.ts +59 -1
  102. package/src/plugins/discovery.ts +57 -9
  103. package/src/plugins/host.test.ts +40 -0
  104. package/src/plugins/host.ts +48 -10
  105. package/src/plugins/lifecycle.test.ts +29 -0
  106. package/src/plugins/lifecycle.ts +7 -0
  107. package/src/plugins/loader.ts +45 -7
  108. package/src/plugins/registry-kinds.test.ts +53 -0
  109. package/src/plugins/registry-kinds.ts +25 -3
  110. package/src/plugins/toposort.ts +45 -17
  111. package/src/preferences.test.ts +52 -1
  112. package/src/preferences.ts +5 -4
  113. package/src/registries/active-backend-registry.ts +10 -1
  114. package/src/registries/channels.test.ts +79 -0
  115. package/src/registries/channels.ts +33 -18
  116. package/src/registries/commands.test.ts +65 -0
  117. package/src/registries/commands.ts +25 -8
  118. package/src/registries/isolators.test.ts +37 -0
  119. package/src/registries/isolators.ts +48 -1
  120. package/src/registries/modes.test.ts +21 -1
  121. package/src/registries/modes.ts +15 -2
  122. package/src/registries/providers.ts +8 -0
  123. package/src/registries/semantics.test.ts +33 -1
  124. package/src/registries/skills.ts +28 -0
  125. package/src/registries/transcribers.test.ts +13 -0
  126. package/src/requirements.test.ts +35 -0
  127. package/src/requirements.ts +7 -1
  128. package/src/run-turn.test.ts +219 -0
  129. package/src/run-turn.ts +68 -18
  130. package/src/session.test.ts +24 -0
  131. package/src/session.ts +24 -3
  132. package/src/sessions/page.test.ts +17 -0
  133. package/src/sessions/persistence.test.ts +93 -1
  134. package/src/sessions/persistence.ts +138 -30
  135. package/src/skills/loader.test.ts +18 -0
  136. package/src/skills/loader.ts +12 -1
  137. package/src/skills/synthesize.test.ts +59 -0
  138. package/src/skills/synthesize.ts +89 -36
  139. package/src/subagents/events.test.ts +56 -0
  140. package/src/subagents/events.ts +32 -2
  141. package/src/subagents/registry.test.ts +106 -0
  142. package/src/subagents/registry.ts +77 -3
  143. package/src/subagents/run-child.test.ts +125 -2
  144. package/src/subagents/run-child.ts +150 -47
  145. package/src/surfaces/host.test.ts +71 -1
  146. package/src/surfaces/host.ts +39 -2
  147. package/src/usage-stats.test.ts +99 -1
  148. package/src/usage-stats.ts +42 -17
  149. package/src/view/parse-extended.test.ts +80 -0
  150. package/src/view/parse.test.ts +14 -0
  151. package/src/view/parse.ts +113 -18
@@ -77,6 +77,51 @@ describe('EventLog', () => {
77
77
  ).resolves.toBeDefined();
78
78
  });
79
79
 
80
+ it('does not hang the append when a listener never resolves (bounded watchdog)', async () => {
81
+ vi.useFakeTimers();
82
+ const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true);
83
+ try {
84
+ const log = new EventLog();
85
+ // A listener that never settles must not block append forever.
86
+ log.subscribe(() => new Promise<void>(() => {}));
87
+ const fast = vi.fn();
88
+ log.subscribe(fast);
89
+
90
+ const pending = log.append({
91
+ type: 'user_prompt',
92
+ sessionId: sid,
93
+ turnId: tid,
94
+ source: 'user',
95
+ text: 'x',
96
+ });
97
+
98
+ // Drive the watchdog past its window; the hung listener is abandoned and
99
+ // the fan-out continues to the next (fast) listener.
100
+ await vi.advanceTimersByTimeAsync(31_000);
101
+ await expect(pending).resolves.toBeDefined();
102
+ expect(fast).toHaveBeenCalledTimes(1);
103
+ expect(stderr).toHaveBeenCalled();
104
+ } finally {
105
+ stderr.mockRestore();
106
+ vi.useRealTimers();
107
+ }
108
+ });
109
+
110
+ it('clears the watchdog timer for a fast async listener (no leaked timers)', async () => {
111
+ vi.useFakeTimers();
112
+ try {
113
+ const log = new EventLog();
114
+ const fast = vi.fn(async () => {});
115
+ log.subscribe(fast);
116
+ await log.append({ type: 'user_prompt', sessionId: sid, turnId: tid, source: 'user', text: 'x' });
117
+ expect(fast).toHaveBeenCalledTimes(1);
118
+ // A fast listener settles immediately; no watchdog timer should remain.
119
+ expect(vi.getTimerCount()).toBe(0);
120
+ } finally {
121
+ vi.useRealTimers();
122
+ }
123
+ });
124
+
80
125
  it('seeds preserve existing events but new appends start at length()', async () => {
81
126
  const seedLog = new EventLog();
82
127
  const e1 = await seedLog.append({
package/src/events/log.ts CHANGED
@@ -10,6 +10,16 @@ import { materializeEvent } from './factory.js';
10
10
 
11
11
  export type EventListener = (event: MoxxyEvent) => void | Promise<void>;
12
12
 
13
+ /**
14
+ * Per-listener watchdog for {@link EventLog.append}'s sequential fan-out. A
15
+ * listener that never resolves (e.g. a persistence sidecar blocked on a stuck fs
16
+ * handle) would otherwise block `append`'s promise forever and wedge the
17
+ * appending turn. We bound each awaited listener: if it hasn't settled within
18
+ * this window we log and continue rather than hang. Well-behaved listeners (the
19
+ * common case) settle far inside it, so timing is unchanged for them.
20
+ */
21
+ const LISTENER_TIMEOUT_MS = 30_000;
22
+
13
23
  export class EventLog implements EventLogReader {
14
24
  private readonly events: MoxxyEvent[] = [];
15
25
  private readonly listeners = new Set<EventListener>();
@@ -135,7 +145,7 @@ export class EventLog implements EventLogReader {
135
145
  const snapshot = [...this.listeners];
136
146
  for (const fn of snapshot) {
137
147
  try {
138
- await fn(event);
148
+ await callListenerBounded(fn, event);
139
149
  } catch {
140
150
  // Listeners must not block the log; failures are non-fatal here. Hook
141
151
  // failures are recorded as ErrorEvents by the dispatcher above this.
@@ -254,3 +264,31 @@ export class EventLog implements EventLogReader {
254
264
  return this;
255
265
  }
256
266
  }
267
+
268
+ /**
269
+ * Await a listener but give up after {@link LISTENER_TIMEOUT_MS} so a hung
270
+ * listener can't block the appending turn indefinitely. A synchronous (void)
271
+ * listener resolves immediately and the timer is cleared before it can fire, so
272
+ * fast listeners pay nothing. The timer is `unref`'d so it never keeps the
273
+ * process alive on its own.
274
+ */
275
+ async function callListenerBounded(fn: EventListener, event: MoxxyEvent): Promise<void> {
276
+ const result = fn(event);
277
+ if (!(result instanceof Promise)) return;
278
+ let timer: ReturnType<typeof setTimeout> | undefined;
279
+ const timeout = new Promise<void>((resolve) => {
280
+ timer = setTimeout(() => {
281
+ process.stderr.write(
282
+ `moxxy: event-log listener exceeded ${LISTENER_TIMEOUT_MS}ms on ${event.type} ` +
283
+ `(seq ${event.seq}); continuing without it\n`,
284
+ );
285
+ resolve();
286
+ }, LISTENER_TIMEOUT_MS);
287
+ (timer as { unref?: () => void }).unref?.();
288
+ });
289
+ try {
290
+ await Promise.race([result, timeout]);
291
+ } finally {
292
+ if (timer) clearTimeout(timer);
293
+ }
294
+ }
@@ -135,6 +135,42 @@ describe('PermissionEngine', () => {
135
135
  expect(e.check(call('Bash', { cmd: 'rm -rf /' }))).toBeNull();
136
136
  });
137
137
 
138
+ it('bounds a pathological pattern + long model input so the check cannot hang (ReDoS guard)', () => {
139
+ // The permission check runs author-supplied patterns over MODEL-controlled
140
+ // input on the synchronous critical path of every tool call. A catastrophic
141
+ // pattern plus a long input string would otherwise pin the event loop. The
142
+ // candidate is truncated before `.test`, so the worst case is bounded.
143
+ const e = new PermissionEngine({
144
+ allow: [{ name: 'Bash', inputMatches: { cmd: '(a+)+$' } }],
145
+ deny: [],
146
+ });
147
+ // 200k of 'a' followed by a non-matching char is the classic ReDoS trigger;
148
+ // unbounded this never returns. Bounded, it completes effectively instantly.
149
+ const evil = 'a'.repeat(200_000) + '!';
150
+ const start = Date.now();
151
+ const decision = e.check(call('Bash', { cmd: evil }));
152
+ const elapsed = Date.now() - start;
153
+ // No catastrophic backtracking: must return well under a second.
154
+ expect(elapsed).toBeLessThan(1000);
155
+ // After truncation to 8 KB the trailing '!' is dropped, so '(a+)+$' matches
156
+ // the all-'a' prefix — the decision is well-defined, not a hang.
157
+ expect(decision?.mode).toBe('allow');
158
+ });
159
+
160
+ it('truncates the candidate to the match cap before a glob name test', () => {
161
+ // The glob `prefix-*` becomes `^prefix-.*$`. With the candidate length-capped
162
+ // at 8 KB, a name longer than that still matches the prefix glob (the tail is
163
+ // dropped) and the check returns promptly rather than scanning a huge string.
164
+ const e = new PermissionEngine({
165
+ allow: [{ name: 'prefix-*' }],
166
+ deny: [],
167
+ });
168
+ const start = Date.now();
169
+ const decision = e.check(call('prefix-' + 'x'.repeat(100_000)));
170
+ expect(Date.now() - start).toBeLessThan(1000);
171
+ expect(decision?.mode).toBe('allow');
172
+ });
173
+
138
174
  it('loads policy from disk and handles ENOENT', async () => {
139
175
  const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mox-perm-'));
140
176
  const file = path.join(tmp, 'permissions.json');
@@ -146,6 +182,30 @@ describe('PermissionEngine', () => {
146
182
  expect(e2.check(call('Read'))?.mode).toBe('allow');
147
183
  });
148
184
 
185
+ it('throws on a corrupt (non-ENOENT) policy file rather than silently loading empty rules', async () => {
186
+ // Security-critical: a present-but-unparseable policy file MUST fail loud, not
187
+ // degrade to the empty `{ allow: [], deny: [] }` policy. Silently emptying it
188
+ // would drop every user deny rule (fail-OPEN) — a truncated mid-write or
189
+ // hand-corrupted file would then let denied tool calls through. ENOENT
190
+ // (no file at all) is the only "absent → empty" case; corruption throws.
191
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mox-perm-'));
192
+ try {
193
+ // (a) Non-JSON garbage.
194
+ const badJson = path.join(tmp, 'bad.json');
195
+ await fs.writeFile(badJson, '{ this is not json', 'utf8');
196
+ await expect(PermissionEngine.load(badJson)).rejects.toBeInstanceOf(Error);
197
+
198
+ // (b) Valid JSON but schema-invalid (allow is not an array of rules) — a
199
+ // shape a naive `try { parse } catch { empty }` would also have to reject,
200
+ // or it would silently discard the deny rules in a malformed file.
201
+ const badShape = path.join(tmp, 'shape.json');
202
+ await fs.writeFile(badShape, JSON.stringify({ allow: 'oops', deny: [{ name: 'Bash' }] }), 'utf8');
203
+ await expect(PermissionEngine.load(badShape)).rejects.toBeInstanceOf(Error);
204
+ } finally {
205
+ await fs.rm(tmp, { recursive: true, force: true });
206
+ }
207
+ });
208
+
149
209
  it('persists addAllow', async () => {
150
210
  const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mox-perm-'));
151
211
  const file = path.join(tmp, 'permissions.json');
@@ -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({