crewhaus 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.
- package/README.md +84 -0
- package/package.json +117 -0
- package/src/doctor-checks.test.ts +122 -0
- package/src/doctor-checks.ts +220 -0
- package/src/egress-matcher.test.ts +273 -0
- package/src/egress-matcher.ts +112 -0
- package/src/eval.test.ts +130 -0
- package/src/index.test.ts +699 -0
- package/src/index.ts +2513 -0
- package/src/justification-gate.test.ts +292 -0
- package/src/justification-gate.ts +124 -0
- package/src/scope-audit.test.ts +207 -0
- package/src/scope-audit.ts +89 -0
|
@@ -0,0 +1,699 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { parseSpec } from "@crewhaus/spec";
|
|
6
|
+
|
|
7
|
+
// `tsc -b` also compiles this file into `dist/`; resolve the CLI entrypoint
|
|
8
|
+
// from the source tree so the dist test copy can still spawn it.
|
|
9
|
+
const SRC_DIR = import.meta.dir.replace(/([/\\])dist$/, "$1src");
|
|
10
|
+
const CLI_PATH = join(SRC_DIR, "index.ts");
|
|
11
|
+
const REPO_ROOT = join(import.meta.dir, "../../..");
|
|
12
|
+
const HELLO_SPEC = join(REPO_ROOT, "apps/cli/test-fixtures/minimal-cli/crewhaus.yaml");
|
|
13
|
+
const BROWSER_SPEC = join(REPO_ROOT, "apps/cli/test-fixtures/minimal-browser/crewhaus.yaml");
|
|
14
|
+
const VOICE_SPEC = join(REPO_ROOT, "apps/cli/test-fixtures/minimal-voice/crewhaus.yaml");
|
|
15
|
+
|
|
16
|
+
type RunResult = {
|
|
17
|
+
exitCode: number;
|
|
18
|
+
stdout: string;
|
|
19
|
+
stderr: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
type RunOpts = {
|
|
23
|
+
cwd?: string;
|
|
24
|
+
env?: Record<string, string | undefined>;
|
|
25
|
+
closeStdinImmediately?: boolean;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** Spawn the CLI as a child Bun process and capture exit code + streams. */
|
|
29
|
+
async function runCli(args: ReadonlyArray<string>, opts: RunOpts = {}): Promise<RunResult> {
|
|
30
|
+
const baseEnv: Record<string, string> = { PATH: process.env["PATH"] ?? "" };
|
|
31
|
+
for (const [k, v] of Object.entries(opts.env ?? {})) {
|
|
32
|
+
if (v !== undefined) baseEnv[k] = v;
|
|
33
|
+
}
|
|
34
|
+
const proc = Bun.spawn([process.execPath, CLI_PATH, ...args], {
|
|
35
|
+
cwd: opts.cwd ?? REPO_ROOT,
|
|
36
|
+
env: baseEnv,
|
|
37
|
+
stdin: opts.closeStdinImmediately ? "pipe" : "ignore",
|
|
38
|
+
stdout: "pipe",
|
|
39
|
+
stderr: "pipe",
|
|
40
|
+
});
|
|
41
|
+
if (opts.closeStdinImmediately && proc.stdin) {
|
|
42
|
+
proc.stdin.end();
|
|
43
|
+
}
|
|
44
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
45
|
+
new Response(proc.stdout).text(),
|
|
46
|
+
new Response(proc.stderr).text(),
|
|
47
|
+
proc.exited,
|
|
48
|
+
]);
|
|
49
|
+
return { exitCode, stdout, stderr };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let tmp: string;
|
|
53
|
+
beforeEach(() => {
|
|
54
|
+
tmp = mkdtempSync(join(tmpdir(), "crewhaus-cli-test-"));
|
|
55
|
+
});
|
|
56
|
+
afterEach(() => {
|
|
57
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("crewhaus compile", () => {
|
|
61
|
+
test("emits agent.ts for a valid spec", async () => {
|
|
62
|
+
const result = await runCli(["compile", HELLO_SPEC, "-o", tmp]);
|
|
63
|
+
expect(result.exitCode).toBe(0);
|
|
64
|
+
expect(existsSync(join(tmp, "agent.ts"))).toBe(true);
|
|
65
|
+
expect(result.stdout).toContain("compiled bundle");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("--emit-ir with -o writes ir.json into the out dir and skips codegen", async () => {
|
|
69
|
+
const result = await runCli(["compile", HELLO_SPEC, "--emit-ir", "-o", tmp]);
|
|
70
|
+
expect(result.exitCode).toBe(0);
|
|
71
|
+
expect(existsSync(join(tmp, "ir.json"))).toBe(true);
|
|
72
|
+
expect(existsSync(join(tmp, "agent.ts"))).toBe(false);
|
|
73
|
+
const ir = JSON.parse(readFileSync(join(tmp, "ir.json"), "utf-8"));
|
|
74
|
+
expect(ir.target).toBe("cli");
|
|
75
|
+
expect(ir.agent).toBeDefined();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("--emit-ir without -o exits 0 (stdout-streaming covered by manual usage; the\nspawn-pipe capture in this harness is racy on stdout — see other compile tests)", async () => {
|
|
79
|
+
const result = await runCli(["compile", HELLO_SPEC, "--emit-ir"]);
|
|
80
|
+
expect(result.exitCode).toBe(0);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// FR-002 — compile-time external-sink scope gate, now DEFAULT-ON.
|
|
84
|
+
const MCP_SINK_SPEC =
|
|
85
|
+
"name: evil\ntarget: cli\nagent:\n model: claude-sonnet-4-6\n instructions: |\n do a thing\ntools:\n - mcp__evil__exfiltrate\n";
|
|
86
|
+
|
|
87
|
+
test("--help documents the default-on gate and the opt-out flag", async () => {
|
|
88
|
+
const result = await runCli(["compile", "--help"]);
|
|
89
|
+
expect(result.exitCode).toBe(0);
|
|
90
|
+
// The gate is described as default; the opt-out is named.
|
|
91
|
+
expect(result.stdout).toContain("by DEFAULT");
|
|
92
|
+
expect(result.stdout).toContain('non-"external"');
|
|
93
|
+
expect(result.stdout).toContain("--allow-unmarked-sinks");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// (a) DEFAULT-ON RED PATH — no flag at all. A spec referencing an `mcp__*`
|
|
97
|
+
// sink the offline tool map cannot resolve to a scope:"external" tool must
|
|
98
|
+
// FAIL `crewhaus compile` by default, naming the tool, and must NOT emit.
|
|
99
|
+
// This is the FR-002 final increment: the gate fires with no opt-in flag.
|
|
100
|
+
test("compile (no flag) FAILS by default on an unmarked outward mcp__ sink and names it", async () => {
|
|
101
|
+
const specPath = join(tmp, "crewhaus.yaml");
|
|
102
|
+
writeFileSync(specPath, MCP_SINK_SPEC);
|
|
103
|
+
const outDir = join(tmp, "out");
|
|
104
|
+
const result = await runCli(["compile", specPath, "-o", outDir]);
|
|
105
|
+
expect(result.exitCode).toBe(1);
|
|
106
|
+
// The `[strict]` marker proves the SCOPE GATE produced the failure (and ran
|
|
107
|
+
// before any emit) — the generic emitter "unknown tool" error carries no
|
|
108
|
+
// such marker.
|
|
109
|
+
expect(result.stderr).toContain("[strict]");
|
|
110
|
+
expect(result.stderr).toContain("mcp__evil__exfiltrate");
|
|
111
|
+
expect(result.stderr).toContain("unverifiable offline");
|
|
112
|
+
// refused to emit
|
|
113
|
+
expect(existsSync(join(outDir, "agent.ts"))).toBe(false);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// (a') Isolation: on --emit-ir there is NO target emitter in the path, so the
|
|
117
|
+
// spec lowers cleanly and the ONLY thing that can produce exit 1 is the scope
|
|
118
|
+
// gate. If the gate were off the IR would print and exit 0 — so this proves
|
|
119
|
+
// the default-on gate is load-bearing, not the emitter's unknown-tool reject.
|
|
120
|
+
test("compile --emit-ir (no flag) FAILS by default on the unmarked sink (gate is load-bearing)", async () => {
|
|
121
|
+
const specPath = join(tmp, "crewhaus.yaml");
|
|
122
|
+
writeFileSync(specPath, MCP_SINK_SPEC);
|
|
123
|
+
const outDir = join(tmp, "out");
|
|
124
|
+
const result = await runCli(["compile", specPath, "--emit-ir", "-o", outDir]);
|
|
125
|
+
expect(result.exitCode).toBe(1);
|
|
126
|
+
expect(result.stderr).toContain("[strict]");
|
|
127
|
+
expect(result.stderr).toContain("mcp__evil__exfiltrate");
|
|
128
|
+
expect(existsSync(join(outDir, "ir.json"))).toBe(false);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// (b) OPT-OUT — the same unmarked sink passes the compile when the user
|
|
132
|
+
// explicitly bypasses the gate. We assert against the --emit-ir path because
|
|
133
|
+
// it isolates the GATE: the scope gate runs identically for emit-ir and
|
|
134
|
+
// bundle modes, and --emit-ir lowers an unresolved mcp__ tool cleanly (the
|
|
135
|
+
// bundle EMITTER independently rejects any unknown tool name, which is a
|
|
136
|
+
// separate concern from the scope gate this flag governs). So a gate-bypassed
|
|
137
|
+
// --emit-ir exits 0 and writes ir.json with no `[strict]` finding; if the
|
|
138
|
+
// opt-out did NOT bypass, this same spec exits 1 (see the no-flag test above).
|
|
139
|
+
test("--allow-unmarked-sinks bypasses the gate: the unmarked sink now passes", async () => {
|
|
140
|
+
const specPath = join(tmp, "crewhaus.yaml");
|
|
141
|
+
writeFileSync(specPath, MCP_SINK_SPEC);
|
|
142
|
+
const outDir = join(tmp, "out");
|
|
143
|
+
const result = await runCli([
|
|
144
|
+
"compile",
|
|
145
|
+
specPath,
|
|
146
|
+
"--allow-unmarked-sinks",
|
|
147
|
+
"--emit-ir",
|
|
148
|
+
"-o",
|
|
149
|
+
outDir,
|
|
150
|
+
]);
|
|
151
|
+
expect(result.exitCode).toBe(0);
|
|
152
|
+
expect(result.stderr).not.toContain("[strict]");
|
|
153
|
+
expect(existsSync(join(outDir, "ir.json"))).toBe(true);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("--no-strict-scope is an accepted alias that also bypasses the gate", async () => {
|
|
157
|
+
const specPath = join(tmp, "crewhaus.yaml");
|
|
158
|
+
writeFileSync(specPath, MCP_SINK_SPEC);
|
|
159
|
+
const outDir = join(tmp, "out");
|
|
160
|
+
const result = await runCli([
|
|
161
|
+
"compile",
|
|
162
|
+
specPath,
|
|
163
|
+
"--no-strict-scope",
|
|
164
|
+
"--emit-ir",
|
|
165
|
+
"-o",
|
|
166
|
+
outDir,
|
|
167
|
+
]);
|
|
168
|
+
expect(result.exitCode).toBe(0);
|
|
169
|
+
expect(result.stderr).not.toContain("[strict]");
|
|
170
|
+
expect(existsSync(join(outDir, "ir.json"))).toBe(true);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// (b') OPT-OUT, BUNDLE MODE — emitter errors must exit cleanly, not crash.
|
|
174
|
+
// Once --allow-unmarked-sinks bypasses the scope gate, the same unresolvable
|
|
175
|
+
// mcp__ sink reaches the bundle EMITTER (which the --emit-ir opt-out test
|
|
176
|
+
// above never does), and the emitter throws TargetEmitError because it can't
|
|
177
|
+
// map the name to a built-in. That emitter failure must be routed through
|
|
178
|
+
// die() exactly like the SpecParseError path — a one-line message + exit 1,
|
|
179
|
+
// never an uncaught stack trace. (Pre-existing bug, independent of the gate.)
|
|
180
|
+
test("--allow-unmarked-sinks: an unresolvable mcp__ sink fails cleanly (die, not crash) in bundle mode", async () => {
|
|
181
|
+
const specPath = join(tmp, "crewhaus.yaml");
|
|
182
|
+
writeFileSync(specPath, MCP_SINK_SPEC);
|
|
183
|
+
const outDir = join(tmp, "out");
|
|
184
|
+
const result = await runCli(["compile", specPath, "--allow-unmarked-sinks", "-o", outDir]);
|
|
185
|
+
expect(result.exitCode).toBe(1);
|
|
186
|
+
// Clean die() output: prefixed "crewhaus: " and names the offending tool.
|
|
187
|
+
expect(result.stderr).toContain('crewhaus: unknown tool "mcp__evil__exfiltrate"');
|
|
188
|
+
// The gate was bypassed — it is NOT the source of this failure…
|
|
189
|
+
expect(result.stderr).not.toContain("[strict]");
|
|
190
|
+
// …and the emitter error did NOT escape as an uncaught crash: neither the
|
|
191
|
+
// error class name nor any "at file:line:col" stack frame leaks to stderr.
|
|
192
|
+
expect(result.stderr).not.toContain("TargetEmitError");
|
|
193
|
+
expect(result.stderr).not.toMatch(/\bat .+:\d+:\d+/);
|
|
194
|
+
// Nothing was emitted.
|
|
195
|
+
expect(existsSync(join(outDir, "agent.ts"))).toBe(false);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// The report's literal repro: a plain non-outward unknown tool name (one the
|
|
199
|
+
// gate never flags, so it reaches the emitter with or without the opt-out)
|
|
200
|
+
// compiled under --allow-unmarked-sinks also fails cleanly via die().
|
|
201
|
+
test("--allow-unmarked-sinks: a plain unknown tool name also fails cleanly (die, not crash)", async () => {
|
|
202
|
+
const specPath = join(tmp, "crewhaus.yaml");
|
|
203
|
+
writeFileSync(
|
|
204
|
+
specPath,
|
|
205
|
+
"name: typo\ntarget: cli\nagent:\n model: claude-sonnet-4-6\n instructions: |\n do a thing\ntools:\n - not-a-real-tool\n",
|
|
206
|
+
);
|
|
207
|
+
const outDir = join(tmp, "out");
|
|
208
|
+
const result = await runCli(["compile", specPath, "--allow-unmarked-sinks", "-o", outDir]);
|
|
209
|
+
expect(result.exitCode).toBe(1);
|
|
210
|
+
expect(result.stderr).toContain('crewhaus: unknown tool "not-a-real-tool"');
|
|
211
|
+
expect(result.stderr).not.toContain("TargetEmitError");
|
|
212
|
+
expect(result.stderr).not.toMatch(/\bat .+:\d+:\d+/);
|
|
213
|
+
expect(existsSync(join(outDir, "agent.ts"))).toBe(false);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
// (c) CLEAN spec still compiles by default (no flag, no opt-out).
|
|
217
|
+
test("compile (no flag) on a clean (toolless) spec still emits and exits 0", async () => {
|
|
218
|
+
const result = await runCli(["compile", HELLO_SPEC, "-o", tmp]);
|
|
219
|
+
expect(result.exitCode).toBe(0);
|
|
220
|
+
expect(existsSync(join(tmp, "agent.ts"))).toBe(true);
|
|
221
|
+
expect(result.stderr).not.toContain("[strict]");
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("compile (no flag) passes when an outward tool (fetch) is correctly external", async () => {
|
|
225
|
+
// Exercises the full resolve path: collectToolNames → loadToolMap →
|
|
226
|
+
// auditSpecToolNames → auditToolScopes. `Fetch` ships scope:"external"
|
|
227
|
+
// (and ioCapability:"network"), so the audit is clean and the bundle emits
|
|
228
|
+
// — by default, with no flag.
|
|
229
|
+
const specPath = join(tmp, "crewhaus.yaml");
|
|
230
|
+
writeFileSync(
|
|
231
|
+
specPath,
|
|
232
|
+
"name: with-fetch\ntarget: cli\nagent:\n model: claude-sonnet-4-6\n instructions: |\n Use fetch when needed.\ntools:\n - fetch\n",
|
|
233
|
+
);
|
|
234
|
+
const outDir = join(tmp, "out");
|
|
235
|
+
const result = await runCli(["compile", specPath, "-o", outDir]);
|
|
236
|
+
expect(result.exitCode).toBe(0);
|
|
237
|
+
expect(existsSync(join(outDir, "agent.ts"))).toBe(true);
|
|
238
|
+
expect(result.stderr).not.toContain("[strict]");
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
// Back-compat: `--strict` is still accepted (now a no-op since the gate is
|
|
242
|
+
// default-on) so existing invocations and CI scripts keep working unchanged.
|
|
243
|
+
test("--strict remains accepted (no-op) and the unmarked sink still fails", async () => {
|
|
244
|
+
const specPath = join(tmp, "crewhaus.yaml");
|
|
245
|
+
writeFileSync(specPath, MCP_SINK_SPEC);
|
|
246
|
+
const outDir = join(tmp, "out");
|
|
247
|
+
const result = await runCli(["compile", specPath, "--strict", "-o", outDir]);
|
|
248
|
+
expect(result.exitCode).toBe(1);
|
|
249
|
+
expect(result.stderr).toContain("[strict]");
|
|
250
|
+
expect(result.stderr).toContain("mcp__evil__exfiltrate");
|
|
251
|
+
expect(existsSync(join(outDir, "agent.ts"))).toBe(false);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test("--strict on a clean (toolless) spec still emits and exits 0", async () => {
|
|
255
|
+
const result = await runCli(["compile", HELLO_SPEC, "--strict", "-o", tmp]);
|
|
256
|
+
expect(result.exitCode).toBe(0);
|
|
257
|
+
expect(existsSync(join(tmp, "agent.ts"))).toBe(true);
|
|
258
|
+
expect(result.stderr).not.toContain("[strict]");
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
describe("crewhaus init", () => {
|
|
263
|
+
test("scaffolds crewhaus.yaml in cwd when no name is given", async () => {
|
|
264
|
+
const result = await runCli(["init"], { cwd: tmp });
|
|
265
|
+
expect(result.exitCode).toBe(0);
|
|
266
|
+
const written = join(tmp, "crewhaus.yaml");
|
|
267
|
+
expect(existsSync(written)).toBe(true);
|
|
268
|
+
const spec = parseSpec(readFileSync(written, "utf-8"));
|
|
269
|
+
expect(spec.target).toBe("cli");
|
|
270
|
+
if (spec.target !== "cli") expect.unreachable();
|
|
271
|
+
expect(spec.agent.model).toBe("claude-opus-4-7");
|
|
272
|
+
expect(spec.agent.instructions.length).toBeGreaterThan(0);
|
|
273
|
+
expect(result.stdout).toMatch(/wrote .*crewhaus\.yaml/);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test("scaffolds into a named subdirectory", async () => {
|
|
277
|
+
const result = await runCli(["init", "myapp"], { cwd: tmp });
|
|
278
|
+
expect(result.exitCode).toBe(0);
|
|
279
|
+
const written = join(tmp, "myapp", "crewhaus.yaml");
|
|
280
|
+
expect(existsSync(written)).toBe(true);
|
|
281
|
+
const spec = parseSpec(readFileSync(written, "utf-8"));
|
|
282
|
+
expect(spec.name).toBe("myapp");
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test("fails when crewhaus.yaml already exists", async () => {
|
|
286
|
+
writeFileSync(join(tmp, "crewhaus.yaml"), "name: pre\ntarget: cli\n");
|
|
287
|
+
const result = await runCli(["init"], { cwd: tmp });
|
|
288
|
+
expect(result.exitCode).toBe(1);
|
|
289
|
+
expect(result.stderr).toContain("already exists");
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
describe("crewhaus run", () => {
|
|
294
|
+
test("starts the agent and exits cleanly when stdin closes immediately", async () => {
|
|
295
|
+
const result = await runCli(["run", HELLO_SPEC], {
|
|
296
|
+
env: { ANTHROPIC_API_KEY: "test-no-call" },
|
|
297
|
+
closeStdinImmediately: true,
|
|
298
|
+
});
|
|
299
|
+
expect(result.exitCode).toBe(0);
|
|
300
|
+
expect(result.stdout).toContain("agent ready");
|
|
301
|
+
expect(result.stdout).toContain("model: claude-sonnet-4-6");
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test("--model overrides the spec model", async () => {
|
|
305
|
+
const result = await runCli(["run", HELLO_SPEC, "--model", "claude-haiku-4-5-20251001"], {
|
|
306
|
+
env: { ANTHROPIC_API_KEY: "test-no-call" },
|
|
307
|
+
closeStdinImmediately: true,
|
|
308
|
+
});
|
|
309
|
+
expect(result.exitCode).toBe(0);
|
|
310
|
+
expect(result.stdout).toContain("model: claude-haiku-4-5-20251001");
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test("errors with a clear message when the spec is missing", async () => {
|
|
314
|
+
const result = await runCli(["run", join(tmp, "nope.yaml")], {
|
|
315
|
+
env: { ANTHROPIC_API_KEY: "test-no-call" },
|
|
316
|
+
});
|
|
317
|
+
expect(result.exitCode).toBe(1);
|
|
318
|
+
expect(result.stderr).toContain("could not read");
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
test("browser target rejects --resume with a clear error (single-turn)", async () => {
|
|
322
|
+
const result = await runCli(
|
|
323
|
+
["run", BROWSER_SPEC, "--resume", "sess_0123456789abcdef", "--prompt", "ignored"],
|
|
324
|
+
{ env: { ANTHROPIC_API_KEY: "test-no-call" } },
|
|
325
|
+
);
|
|
326
|
+
expect(result.exitCode).toBe(1);
|
|
327
|
+
expect(result.stderr).toContain("--resume");
|
|
328
|
+
expect(result.stderr).toContain("browser");
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test("browser target rejects --continue with a clear error (single-turn)", async () => {
|
|
332
|
+
const result = await runCli(["run", BROWSER_SPEC, "--continue", "--prompt", "ignored"], {
|
|
333
|
+
env: { ANTHROPIC_API_KEY: "test-no-call" },
|
|
334
|
+
});
|
|
335
|
+
expect(result.exitCode).toBe(1);
|
|
336
|
+
expect(result.stderr).toContain("--continue");
|
|
337
|
+
expect(result.stderr).toContain("browser");
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
test("browser target errors with 'no prompt' when neither --prompt nor stdin supplies one", async () => {
|
|
341
|
+
const result = await runCli(["run", BROWSER_SPEC], {
|
|
342
|
+
env: { ANTHROPIC_API_KEY: "test-no-call" },
|
|
343
|
+
closeStdinImmediately: true,
|
|
344
|
+
});
|
|
345
|
+
expect(result.exitCode).toBe(1);
|
|
346
|
+
expect(result.stderr).toContain("no prompt");
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
test("unsupported target names cli + browser in the dispatch error", async () => {
|
|
350
|
+
const result = await runCli(["run", VOICE_SPEC], {
|
|
351
|
+
env: { ANTHROPIC_API_KEY: "test-no-call" },
|
|
352
|
+
});
|
|
353
|
+
expect(result.exitCode).toBe(1);
|
|
354
|
+
expect(result.stderr).toContain("cli or browser");
|
|
355
|
+
expect(result.stderr).toContain('got "voice"');
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
// FR-004 — --justification-judge selects the Pillar 3 intent-gate judge.
|
|
359
|
+
// This subprocess test proves the flag resolves and the agent starts; the
|
|
360
|
+
// FULL handoff (CLI-resolved judge actually governing a gate decision and
|
|
361
|
+
// the judge identity landing in the durable permission_justification_evaluated
|
|
362
|
+
// audit record) is proven deterministically in justification-gate.test.ts —
|
|
363
|
+
// a subprocess cannot drive a real model turn without a live call.
|
|
364
|
+
test("--justification-judge claude starts the agent cleanly (judge wired)", async () => {
|
|
365
|
+
const result = await runCli(["run", HELLO_SPEC, "--justification-judge", "claude"], {
|
|
366
|
+
env: { ANTHROPIC_API_KEY: "test-no-call" },
|
|
367
|
+
closeStdinImmediately: true,
|
|
368
|
+
});
|
|
369
|
+
// The claude judge is constructed (lazy import of
|
|
370
|
+
// @crewhaus/justification-judge-claude + the anthropic adapter) but never
|
|
371
|
+
// called — stdin closes before any turn. A clean exit proves the wiring
|
|
372
|
+
// resolves without error.
|
|
373
|
+
expect(result.exitCode).toBe(0);
|
|
374
|
+
expect(result.stdout).toContain("model: claude-sonnet-4-6");
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
test("--justification-judge rule-based starts the agent cleanly (explicit default)", async () => {
|
|
378
|
+
const result = await runCli(["run", HELLO_SPEC, "--justification-judge", "rule-based"], {
|
|
379
|
+
env: { ANTHROPIC_API_KEY: "test-no-call" },
|
|
380
|
+
closeStdinImmediately: true,
|
|
381
|
+
});
|
|
382
|
+
expect(result.exitCode).toBe(0);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
test("an invalid --justification-judge value exits non-zero with the allowed list", async () => {
|
|
386
|
+
const result = await runCli(["run", HELLO_SPEC, "--justification-judge", "gpt-omniscient"], {
|
|
387
|
+
env: { ANTHROPIC_API_KEY: "test-no-call" },
|
|
388
|
+
closeStdinImmediately: true,
|
|
389
|
+
});
|
|
390
|
+
expect(result.exitCode).toBe(1);
|
|
391
|
+
expect(result.stderr).toContain("invalid --justification-judge");
|
|
392
|
+
expect(result.stderr).toContain("rule-based, claude");
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
test("a CrewhausError on the run path surfaces as a clean one-line die(), not a stack trace", async () => {
|
|
396
|
+
// An unrecognised model string makes the model-router throw ConfigError
|
|
397
|
+
// inside runChatLoop — previously an uncaught stack trace.
|
|
398
|
+
writeFileSync(
|
|
399
|
+
join(tmp, "crewhaus.yaml"),
|
|
400
|
+
"name: t\ntarget: cli\nagent:\n model: claude-sonnet-4-5\n instructions: hi\n",
|
|
401
|
+
);
|
|
402
|
+
const result = await runCli(
|
|
403
|
+
["run", join(tmp, "crewhaus.yaml"), "--model", "totally-not-a-model"],
|
|
404
|
+
{ cwd: tmp, env: {}, closeStdinImmediately: true },
|
|
405
|
+
);
|
|
406
|
+
expect(result.exitCode).toBe(1);
|
|
407
|
+
expect(result.stderr).toContain("crewhaus: ");
|
|
408
|
+
expect(result.stderr).toContain("unrecognised model string");
|
|
409
|
+
expect(result.stderr).not.toContain(" at "); // no stack frames
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
test("run --help enumerates the model-router grammar", async () => {
|
|
413
|
+
const result = await runCli(["run", "--help"], { cwd: tmp, env: {} });
|
|
414
|
+
expect(result.exitCode).toBe(0);
|
|
415
|
+
expect(result.stdout).toContain("claude-*");
|
|
416
|
+
expect(result.stdout).toContain("openai/<m>");
|
|
417
|
+
expect(result.stdout).toContain("gemini/<m>");
|
|
418
|
+
expect(result.stdout).toContain("bedrock/<id>");
|
|
419
|
+
expect(result.stdout).toContain("local/<m>@<url>");
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
test("eval --help notes --judge-model takes the router grammar + the default judge's Anthropic requirement", async () => {
|
|
423
|
+
const result = await runCli(["eval", "--help"], { cwd: tmp, env: {} });
|
|
424
|
+
expect(result.exitCode).toBe(0);
|
|
425
|
+
expect(result.stdout).toContain("--judge-model accepts the full router grammar");
|
|
426
|
+
expect(result.stdout).toContain("claude-sonnet-4-5");
|
|
427
|
+
expect(result.stdout).toContain("Anthropic credentials");
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
test("run --help lists the --justification-judge flag", async () => {
|
|
431
|
+
const result = await runCli(["run", "--help"]);
|
|
432
|
+
expect(result.exitCode).toBe(0);
|
|
433
|
+
expect(result.stdout).toContain("--justification-judge");
|
|
434
|
+
});
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
describe("crewhaus doctor", () => {
|
|
438
|
+
test("exits 0 when env + bun + spec are all healthy", async () => {
|
|
439
|
+
writeFileSync(join(tmp, "crewhaus.yaml"), "name: t\ntarget: cli\n");
|
|
440
|
+
const result = await runCli(["doctor"], {
|
|
441
|
+
cwd: tmp,
|
|
442
|
+
env: { ANTHROPIC_API_KEY: "test" },
|
|
443
|
+
});
|
|
444
|
+
expect(result.exitCode).toBe(0);
|
|
445
|
+
expect(result.stdout).toContain("✓ Anthropic credentials");
|
|
446
|
+
expect(result.stdout).toContain("✓ Bun runtime");
|
|
447
|
+
expect(result.stdout).toContain("✓ crewhaus.yaml in cwd");
|
|
448
|
+
expect(result.stdout).toContain("all checks passed");
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
test("exits 1 when neither ANTHROPIC_AUTH_TOKEN nor ANTHROPIC_API_KEY is set", async () => {
|
|
452
|
+
writeFileSync(join(tmp, "crewhaus.yaml"), "name: t\ntarget: cli\n");
|
|
453
|
+
const result = await runCli(["doctor"], { cwd: tmp, env: {} });
|
|
454
|
+
expect(result.exitCode).toBe(1);
|
|
455
|
+
expect(result.stdout).toContain("✗ Anthropic credentials");
|
|
456
|
+
expect(result.stdout).toContain("some checks failed");
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
test("exits 1 when crewhaus.yaml is missing from cwd", async () => {
|
|
460
|
+
const result = await runCli(["doctor"], {
|
|
461
|
+
cwd: tmp,
|
|
462
|
+
env: { ANTHROPIC_API_KEY: "test" },
|
|
463
|
+
});
|
|
464
|
+
expect(result.exitCode).toBe(1);
|
|
465
|
+
expect(result.stdout).toContain("✗ crewhaus.yaml in cwd");
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
test("--liveness exits 0 with NO credential or spec checks (container probe)", async () => {
|
|
469
|
+
// No spec, no env — plain doctor would exit 1 on both counts.
|
|
470
|
+
const result = await runCli(["doctor", "--liveness"], { cwd: tmp, env: {} });
|
|
471
|
+
expect(result.exitCode).toBe(0);
|
|
472
|
+
expect(result.stdout).toBe("ok\n");
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
test("model-aware: an openai/ spec with OPENAI_API_KEY passes without Anthropic creds", async () => {
|
|
476
|
+
writeFileSync(
|
|
477
|
+
join(tmp, "crewhaus.yaml"),
|
|
478
|
+
"name: t\ntarget: cli\nagent:\n model: openai/gpt-4o-mini\n instructions: hi\n",
|
|
479
|
+
);
|
|
480
|
+
const result = await runCli(["doctor"], { cwd: tmp, env: { OPENAI_API_KEY: "sk-x" } });
|
|
481
|
+
expect(result.exitCode).toBe(0);
|
|
482
|
+
expect(result.stdout).toContain("✓ OpenAI credentials (model openai/gpt-4o-mini)");
|
|
483
|
+
expect(result.stdout).toContain("all checks passed");
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
test("model-aware: an openai/ spec with NO provider env fails on OpenAI, not Anthropic", async () => {
|
|
487
|
+
writeFileSync(
|
|
488
|
+
join(tmp, "crewhaus.yaml"),
|
|
489
|
+
"name: t\ntarget: cli\nagent:\n model: openai/gpt-4o-mini\n instructions: hi\n",
|
|
490
|
+
);
|
|
491
|
+
const result = await runCli(["doctor"], { cwd: tmp, env: {} });
|
|
492
|
+
expect(result.exitCode).toBe(1);
|
|
493
|
+
expect(result.stdout).toContain("✗ OpenAI credentials");
|
|
494
|
+
expect(result.stdout).not.toContain("✗ Anthropic credentials");
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
test("model-aware: a bedrock/ spec is informational and exits 0 without AWS env", async () => {
|
|
498
|
+
writeFileSync(
|
|
499
|
+
join(tmp, "crewhaus.yaml"),
|
|
500
|
+
"name: t\ntarget: cli\nagent:\n model: bedrock/us.anthropic.claude-sonnet-4-5-20250929-v1:0\n instructions: hi\n",
|
|
501
|
+
);
|
|
502
|
+
const result = await runCli(["doctor"], { cwd: tmp, env: {} });
|
|
503
|
+
expect(result.exitCode).toBe(0);
|
|
504
|
+
expect(result.stdout).toContain("~ Bedrock (AWS) credentials");
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
test("doctor --help documents --liveness and the model-aware checks", async () => {
|
|
508
|
+
const result = await runCli(["doctor", "--help"], { cwd: tmp, env: {} });
|
|
509
|
+
expect(result.exitCode).toBe(0);
|
|
510
|
+
expect(result.stdout).toContain("--liveness");
|
|
511
|
+
expect(result.stdout).toContain("model-aware");
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
// FR-002 — the philosophy-alignment audit now includes a Pillar 3 sink-side
|
|
515
|
+
// pass over the built-in tool map, sharing auditToolScopes with
|
|
516
|
+
// `compile --strict`. Run from REPO_ROOT so the package-presence checks and
|
|
517
|
+
// loadToolMap() see the real workspace. All built-in outward tools ship
|
|
518
|
+
// scope:"external", so the new check is green and the overall audit exits 0.
|
|
519
|
+
test("--philosophy-alignment audits built-in tool scopes and reports them green", async () => {
|
|
520
|
+
const result = await runCli(["doctor", "--philosophy-alignment"], {
|
|
521
|
+
cwd: REPO_ROOT,
|
|
522
|
+
env: { ANTHROPIC_API_KEY: "test" },
|
|
523
|
+
});
|
|
524
|
+
expect(result.exitCode).toBe(0);
|
|
525
|
+
expect(result.stdout).toContain('Pillar 3 — all built-in outward tools scope:"external"');
|
|
526
|
+
expect(result.stdout).toContain("philosophy alignment: green");
|
|
527
|
+
});
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
// ---------------------------------------------------------------------------
|
|
531
|
+
// FR-003 — `crewhaus optimize --budget-usd` (cost budget gate).
|
|
532
|
+
// Closes the affected-package coverage the FR implies: the flag's
|
|
533
|
+
// negative-validation branch, the flag's forwarding into optimizeSpec, and
|
|
534
|
+
// the spend summary on the resulting report. The validation tests need no
|
|
535
|
+
// credentials (they short-circuit via die() before any model call); the
|
|
536
|
+
// full-run test is creds-gated like the eval CLI test (the fitness function
|
|
537
|
+
// runs the real agent) and asserts on the persisted report.json rather than
|
|
538
|
+
// stdout, which is the robust artifact surface.
|
|
539
|
+
// ---------------------------------------------------------------------------
|
|
540
|
+
describe("crewhaus optimize --budget-usd", () => {
|
|
541
|
+
// A throwaway dataset + graders so the run reaches the budget gate. The
|
|
542
|
+
// grader is response-independent (`contains: q`) so the pipeline runs
|
|
543
|
+
// regardless of the agent's actual answer.
|
|
544
|
+
function writeOptimizeFixtures(): { dataset: string; graders: string } {
|
|
545
|
+
const dataset = join(tmp, "dataset.jsonl");
|
|
546
|
+
const graders = join(tmp, "graders.yaml");
|
|
547
|
+
writeFileSync(
|
|
548
|
+
dataset,
|
|
549
|
+
[
|
|
550
|
+
'{"id":"q1","input":"hi","expected_output":"hi"}',
|
|
551
|
+
'{"id":"q2","input":"yo","expected_output":"yo"}',
|
|
552
|
+
'{"id":"q3","input":"hey","expected_output":"hey"}',
|
|
553
|
+
].join("\n"),
|
|
554
|
+
);
|
|
555
|
+
writeFileSync(graders, "graders:\n - name: g\n type: contains\n substring: 'q'\n");
|
|
556
|
+
return { dataset, graders };
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
test("rejects --budget-usd 0 (non-positive) via die()", async () => {
|
|
560
|
+
const { dataset, graders } = writeOptimizeFixtures();
|
|
561
|
+
const result = await runCli(
|
|
562
|
+
["optimize", HELLO_SPEC, "--dataset", dataset, "--graders", graders, "--budget-usd", "0"],
|
|
563
|
+
{ env: { ANTHROPIC_API_KEY: "test-no-call" } },
|
|
564
|
+
);
|
|
565
|
+
expect(result.exitCode).toBe(1);
|
|
566
|
+
expect(result.stderr).toContain("invalid --budget-usd");
|
|
567
|
+
expect(result.stderr).toContain("positive number");
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
test("rejects a negative --budget-usd via die()", async () => {
|
|
571
|
+
const { dataset, graders } = writeOptimizeFixtures();
|
|
572
|
+
const result = await runCli(
|
|
573
|
+
["optimize", HELLO_SPEC, "--dataset", dataset, "--graders", graders, "--budget-usd", "-1.5"],
|
|
574
|
+
{ env: { ANTHROPIC_API_KEY: "test-no-call" } },
|
|
575
|
+
);
|
|
576
|
+
expect(result.exitCode).toBe(1);
|
|
577
|
+
expect(result.stderr).toContain("invalid --budget-usd");
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
test("rejects a non-numeric --budget-usd (NaN) via die()", async () => {
|
|
581
|
+
const { dataset, graders } = writeOptimizeFixtures();
|
|
582
|
+
const result = await runCli(
|
|
583
|
+
["optimize", HELLO_SPEC, "--dataset", dataset, "--graders", graders, "--budget-usd", "lots"],
|
|
584
|
+
{ env: { ANTHROPIC_API_KEY: "test-no-call" } },
|
|
585
|
+
);
|
|
586
|
+
expect(result.exitCode).toBe(1);
|
|
587
|
+
expect(result.stderr).toContain("invalid --budget-usd");
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
test("a VALID --budget-usd clears validation (fails later on a missing graders file, not on the budget)", async () => {
|
|
591
|
+
// Proves the positive branch: a well-formed budget is accepted and the
|
|
592
|
+
// run proceeds PAST the budget gate — the only failure is the absent
|
|
593
|
+
// graders file, whose error message is distinct from the budget error.
|
|
594
|
+
const dataset = join(tmp, "dataset.jsonl");
|
|
595
|
+
writeFileSync(dataset, '{"id":"q1","input":"hi","expected_output":"hi"}');
|
|
596
|
+
const missingGraders = join(tmp, "does-not-exist-graders.yaml");
|
|
597
|
+
const result = await runCli(
|
|
598
|
+
[
|
|
599
|
+
"optimize",
|
|
600
|
+
HELLO_SPEC,
|
|
601
|
+
"--dataset",
|
|
602
|
+
dataset,
|
|
603
|
+
"--graders",
|
|
604
|
+
missingGraders,
|
|
605
|
+
"--budget-usd",
|
|
606
|
+
"2.50",
|
|
607
|
+
],
|
|
608
|
+
{ env: { ANTHROPIC_API_KEY: "test-no-call" } },
|
|
609
|
+
);
|
|
610
|
+
expect(result.exitCode).toBe(1);
|
|
611
|
+
// Did NOT die on the budget — the budget was valid.
|
|
612
|
+
expect(result.stderr).not.toContain("invalid --budget-usd");
|
|
613
|
+
// Died on the next step instead: reading the (missing) graders file.
|
|
614
|
+
expect(result.stderr).toContain("could not read");
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
test("optimize --help lists the --budget-usd flag", async () => {
|
|
618
|
+
const result = await runCli(["optimize", "--help"]);
|
|
619
|
+
expect(result.exitCode).toBe(0);
|
|
620
|
+
expect(result.stdout).toContain("--budget-usd");
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
test("a rule-based optimize run reports $0 spend + iterations-cap stop in report.json, with the budget forwarded", async () => {
|
|
624
|
+
// Creds-gated: the fitness function runs the real agent (eval-runner),
|
|
625
|
+
// so without an Anthropic token the SDK call would block. Skip in that
|
|
626
|
+
// case — the validation tests above still cover the flag deterministically.
|
|
627
|
+
if (!process.env["ANTHROPIC_AUTH_TOKEN"] && !process.env["ANTHROPIC_API_KEY"]) {
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
const { dataset, graders } = writeOptimizeFixtures();
|
|
631
|
+
const out = join(tmp, "opt-out");
|
|
632
|
+
const result = await runCli(
|
|
633
|
+
[
|
|
634
|
+
"optimize",
|
|
635
|
+
HELLO_SPEC,
|
|
636
|
+
"--dataset",
|
|
637
|
+
dataset,
|
|
638
|
+
"--graders",
|
|
639
|
+
graders,
|
|
640
|
+
// rule-based mutator (default) → no model calls in the search → $0.
|
|
641
|
+
"--iterations",
|
|
642
|
+
"2",
|
|
643
|
+
"--seed",
|
|
644
|
+
"7",
|
|
645
|
+
"--budget-usd",
|
|
646
|
+
"1.50",
|
|
647
|
+
"-o",
|
|
648
|
+
out,
|
|
649
|
+
],
|
|
650
|
+
{ env: { ANTHROPIC_API_KEY: process.env["ANTHROPIC_API_KEY"] ?? "" } },
|
|
651
|
+
);
|
|
652
|
+
expect(result.exitCode).toBe(0);
|
|
653
|
+
// Assert on the persisted report (robust artifact, not stdout).
|
|
654
|
+
const report = JSON.parse(readFileSync(join(out, "report.json"), "utf-8"));
|
|
655
|
+
// The flag was forwarded into optimizeSpec (persisted to the report).
|
|
656
|
+
expect(report.budgetUsd).toBe(1.5);
|
|
657
|
+
// Rule-based run → $0 spend, bounded by the iterations cap (not the budget).
|
|
658
|
+
expect(report.stoppedReason).toBe("iterations-cap");
|
|
659
|
+
expect(report.spend.totalUsd).toBe("$0.0000");
|
|
660
|
+
expect(report.spend.totalUsdMicros).toBe(0);
|
|
661
|
+
expect(report.spend.stopped).toBe("iterations-cap");
|
|
662
|
+
}, 120_000);
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
describe("crewhaus help and unknown subcommand", () => {
|
|
666
|
+
test("--help prints usage listing all four subcommands", async () => {
|
|
667
|
+
const result = await runCli(["--help"]);
|
|
668
|
+
expect(result.exitCode).toBe(1);
|
|
669
|
+
expect(result.stderr).toContain("compile");
|
|
670
|
+
expect(result.stderr).toContain("run");
|
|
671
|
+
expect(result.stderr).toContain("init");
|
|
672
|
+
expect(result.stderr).toContain("doctor");
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
test("unknown subcommand exits 1 with a clear message", async () => {
|
|
676
|
+
const result = await runCli(["frobnicate"]);
|
|
677
|
+
expect(result.exitCode).toBe(1);
|
|
678
|
+
expect(result.stderr).toContain("unknown subcommand");
|
|
679
|
+
});
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
describe("crewhaus version", () => {
|
|
683
|
+
const pkgVersion = (
|
|
684
|
+
JSON.parse(readFileSync(join(import.meta.dir, "..", "package.json"), "utf-8")) as {
|
|
685
|
+
version: string;
|
|
686
|
+
}
|
|
687
|
+
).version;
|
|
688
|
+
|
|
689
|
+
for (const flag of ["version", "--version", "-v"]) {
|
|
690
|
+
test(`${flag} prints the package version and exits 0`, async () => {
|
|
691
|
+
const result = await runCli([flag]);
|
|
692
|
+
expect(result.exitCode).toBe(0);
|
|
693
|
+
// stdout must be exactly the version; stderr is left unchecked because
|
|
694
|
+
// adapter-anthropic logs an import-time warning on hosts without an
|
|
695
|
+
// installed claude CLI.
|
|
696
|
+
expect(result.stdout.trim()).toBe(pkgVersion);
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
});
|