@crewhaus/eval-runner 0.1.1 → 0.1.3

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.
@@ -0,0 +1,445 @@
1
+ /**
2
+ * Coverage for `wireRunOnce` and its helpers (`loadToolMap`,
3
+ * `applyToolConfigs`, `buildRuleSet`) in `wire-once.ts`.
4
+ *
5
+ * `wireRunOnce` is the "build the agent stack once per eval run" factory. In
6
+ * production it spins up real tools, an MCP host (which would launch stdio
7
+ * servers), scans the cwd for hooks/skills/slash-commands, and reads
8
+ * `.crewhaus/settings.json`. To keep these tests deterministic and free of
9
+ * network / child-process / cwd-scanning I/O we stub every heavy `@crewhaus/*`
10
+ * dependency with `mock.module`:
11
+ *
12
+ * - the six tool packages loaded dynamically by `loadToolMap`
13
+ * (`tool-fs/bash/todo/web/image/fetch`) become trivial in-memory tools;
14
+ * - `registerFetchConfig` / `registerWebFetchConfig` become spies so the
15
+ * `applyToolConfigs` branch is observable without touching real config;
16
+ * - `McpHost` / `registerMcpServer` become fakes that record `addServer`
17
+ * calls and register a namespaced tool into the passed catalog — no stdio
18
+ * server is spawned;
19
+ * - `loadHooks` / `discoverSkills` / `loadCommands` return canned data
20
+ * instead of scanning the filesystem; `createSkillTool` / `createTaskTool`
21
+ * return trivial tools.
22
+ *
23
+ * `@crewhaus/permission-engine` is left REAL (it is pure) except for
24
+ * `parsePermissionsConfig`, which a single test overrides — via a captured,
25
+ * non-recursing snapshot — to throw a NON-`PermissionConfigError` so the
26
+ * rethrow arm of `buildRuleSet` is exercised. `ToolCatalog` is also real
27
+ * (pure, in-memory).
28
+ *
29
+ * `.crewhaus/settings.json` is read from a per-test temp dir (hermetic,
30
+ * controlled fixture I/O), never the real project tree.
31
+ *
32
+ * Every `mock.module` override is reinstalled to the real module in
33
+ * `afterAll`; `mock.module` is process-global, so this file is the canonical
34
+ * place those overrides live and they must not leak.
35
+ */
36
+ import { afterAll, afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
37
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
38
+ import { tmpdir } from "node:os";
39
+ import { join } from "node:path";
40
+ import type { HookDef } from "@crewhaus/hooks-engine";
41
+ import type { IrSubAgentDefinition, IrV0 } from "@crewhaus/ir";
42
+ import type { SkillRef } from "@crewhaus/skills-registry";
43
+ import type { SlashCommand } from "@crewhaus/slash-commands";
44
+
45
+ // ── Capture real modules up front (for restoration + non-recursing delegation).
46
+ // Each capture is a plain-object SNAPSHOT (`{ ...ns }`), not the namespace
47
+ // itself: an ESM namespace is a live view, so once mock.module patches a
48
+ // module the namespace's properties resolve to the stubs — restoring from it
49
+ // in afterAll would silently reinstall the stubs.
50
+ const realPermissionEngine = { ...(await import("@crewhaus/permission-engine")) };
51
+ const realParsePermissionsConfig = realPermissionEngine.parsePermissionsConfig;
52
+ const realMcpHost = { ...(await import("@crewhaus/mcp-host")) };
53
+ const realToolMcp = { ...(await import("@crewhaus/tool-mcp")) };
54
+ const realHooks = { ...(await import("@crewhaus/hooks-engine")) };
55
+ const realSkills = { ...(await import("@crewhaus/skills-registry")) };
56
+ const realSlash = { ...(await import("@crewhaus/slash-commands")) };
57
+ const realTaskTool = { ...(await import("@crewhaus/tool-task")) };
58
+ const realSpawner = { ...(await import("@crewhaus/sub-agent-spawner")) };
59
+ const realToolFs = { ...(await import("@crewhaus/tool-fs")) };
60
+ const realToolBash = { ...(await import("@crewhaus/tool-bash")) };
61
+ const realToolTodo = { ...(await import("@crewhaus/tool-todo")) };
62
+ const realToolWeb = { ...(await import("@crewhaus/tool-web")) };
63
+ const realToolImage = { ...(await import("@crewhaus/tool-image")) };
64
+ const realToolFetch = { ...(await import("@crewhaus/tool-fetch")) };
65
+
66
+ // ── Recording channels the stubs write to so tests can assert behaviour.
67
+ const calls = {
68
+ addServer: [] as Array<{ name: string }>,
69
+ registerMcpServer: [] as string[],
70
+ loadHooksCwd: [] as Array<string | undefined>,
71
+ discoverSkillsCwd: [] as Array<string | undefined>,
72
+ loadCommandsCwd: [] as Array<string | undefined>,
73
+ registerFetchConfig: [] as unknown[],
74
+ registerWebFetchConfig: [] as unknown[],
75
+ createSkillTool: [] as unknown[],
76
+ createTaskTool: [] as unknown[],
77
+ };
78
+ // Per-test knobs for what the cwd-scanning stubs return.
79
+ let hooksToReturn: unknown[] = [];
80
+ let skillsToReturn: unknown[] = [];
81
+ let commandsToReturn: Array<[string, unknown]> = [];
82
+ // "real" → delegate to the captured real parser; "throwGeneric" → throw a
83
+ // plain Error (NOT a PermissionConfigError) to hit the rethrow arm.
84
+ let parseMode: "real" | "throwGeneric" = "real";
85
+
86
+ /** Minimal structurally-complete RegisteredTool (only `.name` is read here). */
87
+ function fakeTool(name: string): never {
88
+ return {
89
+ name,
90
+ description: `${name} tool`,
91
+ inputSchema: { parse: (x: unknown) => x } as never,
92
+ execute: async () => ({ ok: true }) as never,
93
+ concurrencySafe: true,
94
+ readOnly: true,
95
+ destructive: false,
96
+ requiresSandbox: false,
97
+ classifyOutput: true,
98
+ scope: "internal",
99
+ requireJustification: false,
100
+ } as never;
101
+ }
102
+
103
+ // ── Tool packages loaded dynamically by `loadToolMap`.
104
+ mock.module("@crewhaus/tool-fs", () => ({
105
+ read: fakeTool("read"),
106
+ write: fakeTool("write"),
107
+ edit: fakeTool("edit"),
108
+ glob: fakeTool("glob"),
109
+ grep: fakeTool("grep"),
110
+ }));
111
+ mock.module("@crewhaus/tool-bash", () => ({ bash: fakeTool("bash") }));
112
+ mock.module("@crewhaus/tool-todo", () => ({ todoWrite: fakeTool("todoWrite") }));
113
+ mock.module("@crewhaus/tool-web", () => ({
114
+ webFetch: fakeTool("webFetch"),
115
+ webSearch: fakeTool("webSearch"),
116
+ registerWebFetchConfig: (cfg: unknown) => {
117
+ calls.registerWebFetchConfig.push(cfg);
118
+ },
119
+ }));
120
+ mock.module("@crewhaus/tool-image", () => ({ readImage: fakeTool("readImage") }));
121
+ mock.module("@crewhaus/tool-fetch", () => ({
122
+ fetch: fakeTool("fetch"),
123
+ registerFetchConfig: (cfg: unknown) => {
124
+ calls.registerFetchConfig.push(cfg);
125
+ },
126
+ }));
127
+
128
+ // ── MCP host + registration (no real stdio servers).
129
+ class FakeMcpHost {
130
+ addServer(name: string, _cfg: unknown): void {
131
+ calls.addServer.push({ name });
132
+ }
133
+ }
134
+ mock.module("@crewhaus/mcp-host", () => ({ ...realMcpHost, McpHost: FakeMcpHost }));
135
+ mock.module("@crewhaus/tool-mcp", () => ({
136
+ ...realToolMcp,
137
+ registerMcpServer: async (
138
+ _host: unknown,
139
+ name: string,
140
+ catalog: { register: (t: unknown) => void },
141
+ ) => {
142
+ calls.registerMcpServer.push(name);
143
+ // Register a namespaced tool so `tempCatalog.list()` reflects MCP wiring.
144
+ catalog.register(fakeTool(`mcp__${name}__do`));
145
+ },
146
+ }));
147
+
148
+ // ── cwd-scanning loaders → canned data.
149
+ mock.module("@crewhaus/hooks-engine", () => ({
150
+ ...realHooks,
151
+ loadHooks: async (opts: { cwd?: string } = {}) => {
152
+ calls.loadHooksCwd.push(opts.cwd);
153
+ return hooksToReturn;
154
+ },
155
+ }));
156
+ mock.module("@crewhaus/skills-registry", () => ({
157
+ ...realSkills,
158
+ discoverSkills: async (opts: { cwd?: string } = {}) => {
159
+ calls.discoverSkillsCwd.push(opts.cwd);
160
+ return skillsToReturn;
161
+ },
162
+ createSkillTool: (skills: unknown) => {
163
+ calls.createSkillTool.push(skills);
164
+ return fakeTool("skill");
165
+ },
166
+ }));
167
+ mock.module("@crewhaus/slash-commands", () => ({
168
+ ...realSlash,
169
+ loadCommands: async (opts: { cwd?: string } = {}) => {
170
+ calls.loadCommandsCwd.push(opts.cwd);
171
+ return new Map(commandsToReturn);
172
+ },
173
+ }));
174
+ mock.module("@crewhaus/tool-task", () => ({
175
+ ...realTaskTool,
176
+ createTaskTool: (arg: unknown) => {
177
+ calls.createTaskTool.push(arg);
178
+ return fakeTool("task");
179
+ },
180
+ }));
181
+
182
+ // ── permission-engine: real, except a swappable parser (non-recursing snapshot).
183
+ mock.module("@crewhaus/permission-engine", () => ({
184
+ ...realPermissionEngine,
185
+ parsePermissionsConfig: (raw: unknown, source: "yaml" | "settings") => {
186
+ if (parseMode === "throwGeneric") {
187
+ throw new TypeError("not a PermissionConfigError");
188
+ }
189
+ return realParsePermissionsConfig(raw, source);
190
+ },
191
+ }));
192
+
193
+ const { wireRunOnce } = await import("./wire-once");
194
+
195
+ // ── IR fixture builder ─────────────────────────────────────────────────────
196
+ function baseIr(overrides: Partial<IrV0> = {}): IrV0 {
197
+ return {
198
+ version: 0,
199
+ name: "wire-test",
200
+ target: "cli",
201
+ agent: { model: "claude-opus-4-7", instructions: "be helpful" },
202
+ tools: [],
203
+ toolConfigs: {},
204
+ mcp_servers: {},
205
+ permissions: { rules: [] },
206
+ subAgents: [],
207
+ compaction: {},
208
+ ...overrides,
209
+ };
210
+ }
211
+
212
+ function subAgent(overrides: Partial<IrSubAgentDefinition> = {}): IrSubAgentDefinition {
213
+ return {
214
+ name: "helper",
215
+ description: "a helper",
216
+ instructions: "help",
217
+ tools: ["read"],
218
+ permissions: "inherit",
219
+ inheritBypass: false,
220
+ ...overrides,
221
+ };
222
+ }
223
+
224
+ const TMP_ROOTS: string[] = [];
225
+ /**
226
+ * Make a temp cwd. When `settings` is supplied it is written verbatim to
227
+ * `.crewhaus/settings.json` (pass already-stringified JSON, or deliberately
228
+ * malformed text, to drive the relevant `buildRuleSet` branch).
229
+ */
230
+ function newCwd(settings?: string): string {
231
+ const dir = mkdtempSync(join(tmpdir(), "crewhaus-wire-"));
232
+ TMP_ROOTS.push(dir);
233
+ if (settings !== undefined) {
234
+ mkdirSync(join(dir, ".crewhaus"), { recursive: true });
235
+ writeFileSync(join(dir, ".crewhaus", "settings.json"), settings, "utf-8");
236
+ }
237
+ return dir;
238
+ }
239
+
240
+ beforeEach(() => {
241
+ for (const k of Object.keys(calls) as Array<keyof typeof calls>) {
242
+ (calls[k] as unknown[]).length = 0;
243
+ }
244
+ hooksToReturn = [];
245
+ skillsToReturn = [];
246
+ commandsToReturn = [];
247
+ parseMode = "real";
248
+ });
249
+ afterEach(() => {
250
+ parseMode = "real";
251
+ });
252
+ afterAll(() => {
253
+ for (const dir of TMP_ROOTS) rmSync(dir, { recursive: true, force: true });
254
+ // Reinstall the real modules so no override leaks into sibling suites.
255
+ mock.module("@crewhaus/permission-engine", () => realPermissionEngine);
256
+ mock.module("@crewhaus/mcp-host", () => realMcpHost);
257
+ mock.module("@crewhaus/tool-mcp", () => realToolMcp);
258
+ mock.module("@crewhaus/hooks-engine", () => realHooks);
259
+ mock.module("@crewhaus/skills-registry", () => realSkills);
260
+ mock.module("@crewhaus/slash-commands", () => realSlash);
261
+ mock.module("@crewhaus/tool-task", () => realTaskTool);
262
+ mock.module("@crewhaus/sub-agent-spawner", () => realSpawner);
263
+ mock.module("@crewhaus/tool-fs", () => realToolFs);
264
+ mock.module("@crewhaus/tool-bash", () => realToolBash);
265
+ mock.module("@crewhaus/tool-todo", () => realToolTodo);
266
+ mock.module("@crewhaus/tool-web", () => realToolWeb);
267
+ mock.module("@crewhaus/tool-image", () => realToolImage);
268
+ mock.module("@crewhaus/tool-fetch", () => realToolFetch);
269
+ });
270
+
271
+ describe("wireRunOnce — tools", () => {
272
+ test("resolves named tools from the tool map (cwd defaults to process.cwd)", async () => {
273
+ const ir = baseIr({ tools: ["read", "bash", "grep"] });
274
+ // No opts.cwd → exercises the `process.cwd()` default.
275
+ const deps = await wireRunOnce(ir);
276
+ expect(deps.tools.map((t) => t.name)).toEqual(["read", "bash", "grep"]);
277
+ // Each cwd-scanning loader saw the default cwd (a string, not undefined).
278
+ expect(typeof calls.loadHooksCwd[0]).toBe("string");
279
+ expect(calls.discoverSkillsCwd[0]).toBe(calls.loadHooksCwd[0]);
280
+ expect(deps.model).toBe("claude-opus-4-7");
281
+ expect(deps.instructions).toBe("be helpful");
282
+ expect(deps.sessionName).toBe("wire-test");
283
+ expect(deps.sessionTarget).toBe("cli");
284
+ // No MCP / sub-agents → those optional keys are absent.
285
+ expect(deps.mcpHost).toBeUndefined();
286
+ expect(deps.subAgents).toBeUndefined();
287
+ expect(deps.spawnSubAgent).toBeUndefined();
288
+ });
289
+
290
+ test("throws RunnerError listing known tools for an unknown tool name", async () => {
291
+ const ir = baseIr({ tools: ["read", "nope"] });
292
+ await expect(wireRunOnce(ir, { cwd: newCwd() })).rejects.toThrow(
293
+ /unknown tool "nope" — known tools: /,
294
+ );
295
+ });
296
+
297
+ test("applies fetch + webFetch tool configs when present (registerXConfig)", async () => {
298
+ const ir = baseIr({
299
+ tools: ["fetch", "webFetch"],
300
+ toolConfigs: { fetch: { allow: ["example.com"] }, webFetch: { timeoutMs: 10 } },
301
+ });
302
+ await wireRunOnce(ir, { cwd: newCwd() });
303
+ expect(calls.registerFetchConfig).toEqual([{ allow: ["example.com"] }]);
304
+ expect(calls.registerWebFetchConfig).toEqual([{ timeoutMs: 10 }]);
305
+ });
306
+
307
+ test("skips config registration when the tool is present but config absent", async () => {
308
+ const ir = baseIr({ tools: ["fetch", "webFetch"], toolConfigs: {} });
309
+ await wireRunOnce(ir, { cwd: newCwd() });
310
+ expect(calls.registerFetchConfig).toEqual([]);
311
+ expect(calls.registerWebFetchConfig).toEqual([]);
312
+ });
313
+ });
314
+
315
+ describe("wireRunOnce — MCP servers", () => {
316
+ test("builds a shared McpHost, adds each server, registers tools", async () => {
317
+ const ir = baseIr({
318
+ tools: ["read"],
319
+ mcp_servers: {
320
+ alpha: { transport: "stdio", command: "x", args: [] },
321
+ beta: { transport: "sse", url: "https://example.com" },
322
+ },
323
+ });
324
+ const deps = await wireRunOnce(ir, { cwd: newCwd() });
325
+ expect(deps.mcpHost).toBeInstanceOf(FakeMcpHost);
326
+ expect(calls.addServer.map((c) => c.name).sort()).toEqual(["alpha", "beta"]);
327
+ expect(calls.registerMcpServer.sort()).toEqual(["alpha", "beta"]);
328
+ // The pre-existing `read` tool plus the two namespaced MCP tools are present.
329
+ const names = deps.tools.map((t) => t.name).sort();
330
+ expect(names).toContain("read");
331
+ expect(names).toContain("mcp__alpha__do");
332
+ expect(names).toContain("mcp__beta__do");
333
+ });
334
+ });
335
+
336
+ describe("wireRunOnce — hooks / skills / slash-commands", () => {
337
+ test("threads loaded hooks/skills/commands and appends a skill tool", async () => {
338
+ hooksToReturn = [{ event: "PreToolUse" }];
339
+ skillsToReturn = [{ name: "skA" }];
340
+ commandsToReturn = [["greet", { name: "greet" }]];
341
+ const ir = baseIr({ tools: ["read"] });
342
+ const deps = await wireRunOnce(ir, { cwd: newCwd() });
343
+ expect(deps.hooks).toEqual(hooksToReturn as readonly HookDef[]);
344
+ expect(deps.skills).toEqual(skillsToReturn as readonly SkillRef[]);
345
+ expect(deps.slashCommands.get("greet")).toEqual({ name: "greet" } as unknown as SlashCommand);
346
+ // skills.length > 0 → a skill tool was created and pushed.
347
+ expect(calls.createSkillTool).toHaveLength(1);
348
+ expect(deps.tools.some((t) => t.name === "skill")).toBe(true);
349
+ });
350
+
351
+ test("does not append a skill tool when no skills are discovered", async () => {
352
+ skillsToReturn = [];
353
+ const ir = baseIr({ tools: ["read"] });
354
+ const deps = await wireRunOnce(ir, { cwd: newCwd() });
355
+ expect(calls.createSkillTool).toHaveLength(0);
356
+ expect(deps.tools.some((t) => t.name === "skill")).toBe(false);
357
+ });
358
+ });
359
+
360
+ describe("wireRunOnce — sub-agents", () => {
361
+ test("builds a sub-agent map, attaches spawnSubAgent, pushes a task tool", async () => {
362
+ const ir = baseIr({
363
+ tools: ["read"],
364
+ subAgents: [
365
+ // model present → the `...(model !== undefined)` spread branch.
366
+ subAgent({ name: "withModel", model: "claude-haiku", inheritBypass: true }),
367
+ // model absent → the spread is skipped.
368
+ subAgent({ name: "noModel", permissions: { allow: ["read"], deny: [] } }),
369
+ ],
370
+ });
371
+ const deps = await wireRunOnce(ir, { cwd: newCwd() });
372
+ expect(deps.subAgents).toBeInstanceOf(Map);
373
+ expect(deps.subAgents?.get("withModel")?.model).toBe("claude-haiku");
374
+ expect(deps.subAgents?.get("withModel")?.inherit_bypass).toBe(true);
375
+ // model omitted ⇒ key not present on the definition.
376
+ expect("model" in (deps.subAgents?.get("noModel") ?? {})).toBe(false);
377
+ expect(deps.subAgents?.get("noModel")?.permissions).toEqual({ allow: ["read"], deny: [] });
378
+ expect(typeof deps.spawnSubAgent).toBe("function");
379
+ expect(deps.spawnSubAgent).toBe(realSpawner.spawnSubAgent);
380
+ expect(calls.createTaskTool).toHaveLength(1);
381
+ expect(deps.tools.some((t) => t.name === "task")).toBe(true);
382
+ });
383
+ });
384
+
385
+ describe("wireRunOnce — buildRuleSet (permission rules)", () => {
386
+ test("no settings.json → settings rules empty, yaml + builtin populated", async () => {
387
+ const ir = baseIr({
388
+ tools: ["read"],
389
+ permissions: { rules: [{ type: "alwaysAllow", pattern: "Bash(ls)" }] },
390
+ });
391
+ const deps = await wireRunOnce(ir, { cwd: newCwd() }); // tmp cwd, no settings file
392
+ expect(deps.permissionRules.settings).toEqual([]);
393
+ expect(deps.permissionRules.yaml).toEqual([
394
+ { type: "alwaysAllow", pattern: "Bash(ls)", source: "yaml" },
395
+ ]);
396
+ expect(deps.permissionRules.flag).toEqual([]);
397
+ expect(deps.permissionRules.hooks).toEqual([]);
398
+ expect(deps.permissionRules.builtin).toBe(realPermissionEngine.BUILTIN_DEFAULT_RULES);
399
+ });
400
+
401
+ test("settings.json with a permissions block → parsed + tagged 'settings'", async () => {
402
+ const settings = JSON.stringify({
403
+ permissions: { rules: [{ type: "alwaysDeny", pattern: "Bash(rm -rf /)" }] },
404
+ });
405
+ const ir = baseIr({ tools: ["read"] });
406
+ const deps = await wireRunOnce(ir, { cwd: newCwd(settings) });
407
+ expect(deps.permissionRules.settings).toEqual([
408
+ { type: "alwaysDeny", pattern: "Bash(rm -rf /)", source: "settings" },
409
+ ]);
410
+ });
411
+
412
+ test("settings.json present but without a permissions key → root undefined branch", async () => {
413
+ const settings = JSON.stringify({ somethingElse: true });
414
+ const ir = baseIr({ tools: ["read"] });
415
+ const deps = await wireRunOnce(ir, { cwd: newCwd(settings) });
416
+ expect(deps.permissionRules.settings).toEqual([]);
417
+ });
418
+
419
+ test("malformed settings.json → RunnerError mentioning the path", async () => {
420
+ const ir = baseIr({ tools: ["read"] });
421
+ await expect(wireRunOnce(ir, { cwd: newCwd("{ not json") })).rejects.toThrow(
422
+ /failed to parse .*settings\.json/,
423
+ );
424
+ });
425
+
426
+ test("PermissionConfigError from a bad permissions block → wrapped RunnerError", async () => {
427
+ // `mode: bypass` is rejected by parsePermissionsConfig with a
428
+ // PermissionConfigError, which buildRuleSet rewraps as a RunnerError.
429
+ const settings = JSON.stringify({ permissions: { mode: "bypass", rules: [] } });
430
+ const ir = baseIr({ tools: ["read"] });
431
+ await expect(wireRunOnce(ir, { cwd: newCwd(settings) })).rejects.toThrow(
432
+ /eval-runner: .*bypass mode cannot be set/,
433
+ );
434
+ });
435
+
436
+ test("a non-PermissionConfigError from the parser is rethrown verbatim", async () => {
437
+ parseMode = "throwGeneric";
438
+ const settings = JSON.stringify({ permissions: { rules: [] } });
439
+ const ir = baseIr({ tools: ["read"] });
440
+ // Not wrapped in RunnerError → the raw TypeError surfaces.
441
+ await expect(wireRunOnce(ir, { cwd: newCwd(settings) })).rejects.toThrow(
442
+ "not a PermissionConfigError",
443
+ );
444
+ });
445
+ });