@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.
- 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/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 -0
- package/dist/sessions/persistence.d.ts.map +1 -1
- package/dist/sessions/persistence.js +116 -13
- 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/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 +17 -0
- package/src/sessions/persistence.test.ts +93 -1
- package/src/sessions/persistence.ts +138 -30
- 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
package/src/events/log.test.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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({
|