@crewhaus/spec 0.1.4 → 0.1.6

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/src/index.test.ts DELETED
@@ -1,1418 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { Spec, SpecParseError, parseSpec } from "./index";
3
-
4
- describe("parseSpec", () => {
5
- test("parses a minimal valid CLI spec", () => {
6
- const spec = parseSpec(`
7
- name: hello
8
- target: cli
9
- agent:
10
- model: claude-sonnet-4-6
11
- instructions: be helpful
12
- `);
13
- expect(spec.name).toBe("hello");
14
- expect(spec.target).toBe("cli");
15
- if (spec.target !== "cli") expect.unreachable();
16
- expect(spec.agent.model).toBe("claude-sonnet-4-6");
17
- expect(spec.agent.instructions).toBe("be helpful");
18
- });
19
-
20
- // Codegen-injection backstop (#147/#148): names flow verbatim into generated
21
- // comments, file paths, JSON manifests and frontmatter — reject the breakout
22
- // characters at parse time so no emitter can be tricked downstream.
23
- describe("name safe-charset backstop", () => {
24
- const cli = (name: string) =>
25
- `\ntarget: cli\nagent:\n model: m\n instructions: be helpful\nname: ${name}\n`;
26
- test.each([
27
- ["a newline (block/line-comment escape)", '"line one\\nglobalThis.x=1"'],
28
- ["a block-comment terminator */", '"safe */ code /* x"'],
29
- ["a slash (path traversal in plugin emitters)", '"../../etc/evil"'],
30
- ["a double-quote (JSON manifest break-out)", '"a\\", \\"dependencies\\": {}"'],
31
- ["a backtick (template-literal escape)", '"a`+code+`b"'],
32
- ])("rejects a name containing %s", (_label, name) => {
33
- expect(() => parseSpec(cli(name))).toThrow(SpecParseError);
34
- });
35
-
36
- test("accepts ordinary names (letters, digits, space, . _ - :)", () => {
37
- const spec = parseSpec(cli('"My Agent v1.2 - prod:eu"'));
38
- expect(spec.name).toBe("My Agent v1.2 - prod:eu");
39
- });
40
- });
41
-
42
- test("preserves multi-line block-scalar instructions", () => {
43
- const spec = parseSpec(`
44
- name: hello
45
- target: cli
46
- agent:
47
- model: m
48
- instructions: |
49
- line one.
50
- line two.
51
- `);
52
- if (spec.target !== "cli") expect.unreachable();
53
- expect(spec.agent.instructions).toBe("line one.\nline two.\n");
54
- });
55
-
56
- test("rejects spec with missing required fields", () => {
57
- expect(() => parseSpec("name: hello")).toThrow(SpecParseError);
58
- });
59
-
60
- test("rejects spec with unknown top-level fields (strict mode)", () => {
61
- expect(() =>
62
- parseSpec(`
63
- name: hello
64
- target: cli
65
- agent:
66
- model: m
67
- instructions: i
68
- extra: nope
69
- `),
70
- ).toThrow(SpecParseError);
71
- });
72
-
73
- test("rejects an unsupported target", () => {
74
- expect(() =>
75
- parseSpec(`
76
- name: hello
77
- target: voice
78
- agent:
79
- model: m
80
- instructions: i
81
- `),
82
- ).toThrow(SpecParseError);
83
- });
84
-
85
- test("rejects malformed YAML", () => {
86
- expect(() => parseSpec("{[\nname: oops")).toThrow(SpecParseError);
87
- });
88
-
89
- test("error message points at the failing path", () => {
90
- try {
91
- parseSpec(`
92
- name: hello
93
- target: cli
94
- agent:
95
- model: ""
96
- instructions: ok
97
- `);
98
- expect.unreachable();
99
- } catch (err) {
100
- expect(err).toBeInstanceOf(SpecParseError);
101
- expect((err as Error).message).toContain("agent.model");
102
- }
103
- });
104
- });
105
-
106
- describe("parseSpec tools field", () => {
107
- test("parses a CLI spec with a tools array", () => {
108
- const spec = parseSpec(`
109
- name: hello
110
- target: cli
111
- agent:
112
- model: m
113
- instructions: i
114
- tools:
115
- - read
116
- - write
117
- `);
118
- if (spec.target !== "cli") expect.unreachable();
119
- expect(spec.tools).toEqual(["read", "write"]);
120
- });
121
-
122
- test("tools field is optional (omitted means undefined)", () => {
123
- const spec = parseSpec(`
124
- name: hello
125
- target: cli
126
- agent:
127
- model: m
128
- instructions: i
129
- `);
130
- if (spec.target !== "cli") expect.unreachable();
131
- expect(spec.tools).toBeUndefined();
132
- });
133
-
134
- test("rejects non-string tool entries", () => {
135
- expect(() =>
136
- parseSpec(`
137
- name: hello
138
- target: cli
139
- agent:
140
- model: m
141
- instructions: i
142
- tools:
143
- - 123
144
- `),
145
- ).toThrow(SpecParseError);
146
- });
147
-
148
- test("rejects empty-string tool names", () => {
149
- expect(() =>
150
- parseSpec(`
151
- name: hello
152
- target: cli
153
- agent:
154
- model: m
155
- instructions: i
156
- tools:
157
- - ""
158
- `),
159
- ).toThrow(SpecParseError);
160
- });
161
- });
162
-
163
- // SECURITY: the code-execution config blob is compiled verbatim into
164
- // `registerCodeExecutionConfig(...)` and the sandbox boundary validates
165
- // images/mounts against THIS same blob — so a spec must NOT be able to
166
- // supply its own sandbox allowlist/backend/mounts. Those keys are owned by
167
- // trusted operator config (CLI / CREWHAUS_SANDBOX* env), never a spec file.
168
- // The blob can arrive under codeExecution/code_execution OR the per-tool
169
- // keys python/javascript/shell (target-cli reads the per-tool key first),
170
- // so every one of those must be rejected.
171
- describe("tool_config code-execution sandbox-override hardening", () => {
172
- const codeExecKeys = [
173
- "codeExecution",
174
- "code_execution",
175
- "python",
176
- "javascript",
177
- "shell",
178
- ] as const;
179
- const overrideKeys = [
180
- ["backend", "backend: noop"],
181
- ["allowedImages", "allowedImages:\n - evil/image:latest"],
182
- ["allowed_images", "allowed_images:\n - evil/image:latest"],
183
- ["mountWhitelist", 'mountWhitelist:\n - "/"'],
184
- ["mount_whitelist", 'mount_whitelist:\n - "/"'],
185
- ["images", "images:\n python: evil/image:latest"],
186
- ["mounts", "mounts:\n /etc: /host-etc"],
187
- ["sandbox", "sandbox: noop"],
188
- ] as const;
189
-
190
- const specWith = (cfgKey: string, body: string) =>
191
- `\nname: hello\ntarget: cli\nagent:\n model: m\n instructions: i\ntools:\n - python\ntool_config:\n ${cfgKey}:\n ${body}\n`;
192
-
193
- for (const cfgKey of codeExecKeys) {
194
- for (const [label, body] of overrideKeys) {
195
- test(`rejects sandbox-override key "${label}" under tool_config.${cfgKey}`, () => {
196
- expect(() => parseSpec(specWith(cfgKey, body))).toThrow(SpecParseError);
197
- });
198
- }
199
-
200
- test(`allows non-security knobs under tool_config.${cfgKey}`, () => {
201
- const spec = parseSpec(specWith(cfgKey, "defaultTimeoutMs: 5000\n warmPoolSize: 2"));
202
- if (spec.target !== "cli") expect.unreachable();
203
- expect(spec.tool_config?.[cfgKey]).toEqual({
204
- defaultTimeoutMs: 5000,
205
- warmPoolSize: 2,
206
- });
207
- });
208
- }
209
-
210
- test("does not constrain non-code-execution tool configs (fetch stays opaque)", () => {
211
- const spec = parseSpec(
212
- "\nname: hello\ntarget: cli\nagent:\n model: m\n instructions: i\ntools:\n - fetch\ntool_config:\n fetch:\n allowedImages:\n - anything\n backend: whatever\n",
213
- );
214
- if (spec.target !== "cli") expect.unreachable();
215
- // `fetch` is not a code-execution tool, so its config is forwarded
216
- // verbatim — these keys are meaningless there and harmless.
217
- expect(spec.tool_config?.["fetch"]).toEqual({
218
- allowedImages: ["anything"],
219
- backend: "whatever",
220
- });
221
- });
222
- });
223
-
224
- describe("Spec schema", () => {
225
- test("schema is exported as a runtime value (Zod)", () => {
226
- expect(typeof Spec.safeParse).toBe("function");
227
- });
228
- });
229
-
230
- describe("parseSpec workflow target", () => {
231
- test("parses a minimal valid workflow spec", () => {
232
- const spec = parseSpec(`
233
- name: hello-workflow
234
- target: workflow
235
- model: claude-sonnet-4-6
236
- steps:
237
- - name: only-step
238
- instructions: do the thing
239
- `);
240
- expect(spec.target).toBe("workflow");
241
- if (spec.target !== "workflow") expect.unreachable();
242
- expect(spec.name).toBe("hello-workflow");
243
- expect(spec.model).toBe("claude-sonnet-4-6");
244
- expect(spec.steps).toHaveLength(1);
245
- expect(spec.steps[0]?.name).toBe("only-step");
246
- expect(spec.steps[0]?.instructions).toBe("do the thing");
247
- expect(spec.steps[0]?.model).toBeUndefined();
248
- expect(spec.steps[0]?.tools).toBeUndefined();
249
- });
250
-
251
- test("parses a workflow spec with multiple steps and per-step tools", () => {
252
- const spec = parseSpec(`
253
- name: w
254
- target: workflow
255
- model: m
256
- steps:
257
- - name: a
258
- instructions: ai
259
- tools:
260
- - bash
261
- - name: b
262
- instructions: bi
263
- `);
264
- if (spec.target !== "workflow") expect.unreachable();
265
- expect(spec.steps).toHaveLength(2);
266
- expect(spec.steps[0]?.tools).toEqual(["bash"]);
267
- expect(spec.steps[1]?.tools).toBeUndefined();
268
- });
269
-
270
- test("parses a workflow spec with per-step model override", () => {
271
- const spec = parseSpec(`
272
- name: w
273
- target: workflow
274
- model: default-model
275
- steps:
276
- - name: a
277
- instructions: ai
278
- model: override-model
279
- - name: b
280
- instructions: bi
281
- `);
282
- if (spec.target !== "workflow") expect.unreachable();
283
- expect(spec.steps[0]?.model).toBe("override-model");
284
- expect(spec.steps[1]?.model).toBeUndefined();
285
- });
286
-
287
- test("rejects a workflow spec with no steps", () => {
288
- expect(() =>
289
- parseSpec(`
290
- name: w
291
- target: workflow
292
- model: m
293
- steps: []
294
- `),
295
- ).toThrow(SpecParseError);
296
- });
297
-
298
- test("rejects a workflow step with empty instructions", () => {
299
- expect(() =>
300
- parseSpec(`
301
- name: w
302
- target: workflow
303
- model: m
304
- steps:
305
- - name: a
306
- instructions: ""
307
- `),
308
- ).toThrow(SpecParseError);
309
- });
310
-
311
- test("rejects a workflow step with empty name", () => {
312
- expect(() =>
313
- parseSpec(`
314
- name: w
315
- target: workflow
316
- model: m
317
- steps:
318
- - name: ""
319
- instructions: ai
320
- `),
321
- ).toThrow(SpecParseError);
322
- });
323
-
324
- test("rejects a workflow spec missing top-level model", () => {
325
- expect(() =>
326
- parseSpec(`
327
- name: w
328
- target: workflow
329
- steps:
330
- - name: a
331
- instructions: ai
332
- `),
333
- ).toThrow(SpecParseError);
334
- });
335
-
336
- test("rejects a workflow spec with extra top-level field (strict)", () => {
337
- expect(() =>
338
- parseSpec(`
339
- name: w
340
- target: workflow
341
- model: m
342
- extra: nope
343
- steps:
344
- - name: a
345
- instructions: ai
346
- `),
347
- ).toThrow(SpecParseError);
348
- });
349
-
350
- test("rejects a workflow step with unknown field (strict)", () => {
351
- expect(() =>
352
- parseSpec(`
353
- name: w
354
- target: workflow
355
- model: m
356
- steps:
357
- - name: a
358
- instructions: ai
359
- bogus: 1
360
- `),
361
- ).toThrow(SpecParseError);
362
- });
363
-
364
- describe("permissions block", () => {
365
- test("accepts a cli spec with permissions: mode + rules", () => {
366
- const spec = parseSpec(`
367
- name: hello
368
- target: cli
369
- agent:
370
- model: m
371
- instructions: i
372
- permissions:
373
- mode: auto
374
- rules:
375
- - type: alwaysAllow
376
- pattern: Read
377
- - type: alwaysDeny
378
- pattern: Bash(rm**)
379
- `);
380
- expect(spec.target).toBe("cli");
381
- if (spec.target !== "cli") return;
382
- expect(spec.permissions?.mode).toBe("auto");
383
- expect(spec.permissions?.rules).toHaveLength(2);
384
- });
385
-
386
- test("accepts a workflow spec with permissions block", () => {
387
- const spec = parseSpec(`
388
- name: w
389
- target: workflow
390
- model: m
391
- steps:
392
- - name: a
393
- instructions: ai
394
- permissions:
395
- mode: plan
396
- `);
397
- expect(spec.target).toBe("workflow");
398
- if (spec.target !== "workflow") return;
399
- expect(spec.permissions?.mode).toBe("plan");
400
- });
401
-
402
- test("rejects mode: bypass in cli spec with a friendly security message", () => {
403
- expect(() =>
404
- parseSpec(`
405
- name: hello
406
- target: cli
407
- agent:
408
- model: m
409
- instructions: i
410
- permissions:
411
- mode: bypass
412
- `),
413
- ).toThrow(SpecParseError);
414
- expect(() =>
415
- parseSpec(`
416
- name: hello
417
- target: cli
418
- agent:
419
- model: m
420
- instructions: i
421
- permissions:
422
- mode: bypass
423
- `),
424
- ).toThrow(/bypass mode is only available via the --permission-mode CLI flag/);
425
- });
426
-
427
- test("rejects mode: bypass in workflow spec", () => {
428
- expect(() =>
429
- parseSpec(`
430
- name: w
431
- target: workflow
432
- model: m
433
- steps:
434
- - name: a
435
- instructions: ai
436
- permissions:
437
- mode: bypass
438
- `),
439
- ).toThrow(SpecParseError);
440
- });
441
-
442
- test("rejects unknown rule type", () => {
443
- expect(() =>
444
- parseSpec(`
445
- name: hello
446
- target: cli
447
- agent:
448
- model: m
449
- instructions: i
450
- permissions:
451
- rules:
452
- - type: neverAllow
453
- pattern: Read
454
- `),
455
- ).toThrow(SpecParseError);
456
- });
457
- });
458
- });
459
-
460
- describe("parseSpec channel target (Section 12)", () => {
461
- test("parses a minimal valid channel spec", () => {
462
- const spec = parseSpec(`
463
- name: hello-channel
464
- target: channel
465
- agent:
466
- model: claude-sonnet-4-6
467
- instructions: be a good bot
468
- channels:
469
- slack:
470
- botToken: xoxb-test
471
- signingSecret: shh
472
- routing:
473
- sessionKey: thread
474
- `);
475
- expect(spec.target).toBe("channel");
476
- if (spec.target !== "channel") expect.unreachable();
477
- expect(spec.agent.model).toBe("claude-sonnet-4-6");
478
- expect(spec.channels.slack?.botToken).toBe("xoxb-test");
479
- expect(spec.routing.sessionKey).toBe("thread");
480
- expect(spec.agent.tools).toBeUndefined();
481
- });
482
-
483
- test("parses a channel spec with agent.tools and permissions", () => {
484
- const spec = parseSpec(`
485
- name: hello-channel
486
- target: channel
487
- agent:
488
- model: m
489
- instructions: i
490
- tools:
491
- - read
492
- - bash
493
- channels:
494
- slack:
495
- botToken: $SLACK_BOT_TOKEN
496
- signingSecret: $SLACK_SIGNING_SECRET
497
- appToken: $SLACK_APP_TOKEN
498
- routing:
499
- sessionKey: user
500
- permissions:
501
- rules:
502
- - type: alwaysAllow
503
- pattern: Read
504
- `);
505
- if (spec.target !== "channel") expect.unreachable();
506
- expect(spec.agent.tools).toEqual(["read", "bash"]);
507
- expect(spec.channels.slack?.appToken).toBe("$SLACK_APP_TOKEN");
508
- expect(spec.routing.sessionKey).toBe("user");
509
- expect(spec.permissions?.rules).toHaveLength(1);
510
- });
511
-
512
- test("rejects a channel spec missing the channels block", () => {
513
- expect(() =>
514
- parseSpec(`
515
- name: hello-channel
516
- target: channel
517
- agent:
518
- model: m
519
- instructions: i
520
- routing:
521
- sessionKey: thread
522
- `),
523
- ).toThrow(SpecParseError);
524
- });
525
-
526
- test("rejects a channel spec with empty channels block (no slack)", () => {
527
- expect(() =>
528
- parseSpec(`
529
- name: hello-channel
530
- target: channel
531
- agent:
532
- model: m
533
- instructions: i
534
- channels: {}
535
- routing:
536
- sessionKey: thread
537
- `),
538
- ).toThrow(/at least one channel/);
539
- });
540
-
541
- test("rejects a channel spec missing routing", () => {
542
- expect(() =>
543
- parseSpec(`
544
- name: hello-channel
545
- target: channel
546
- agent:
547
- model: m
548
- instructions: i
549
- channels:
550
- slack:
551
- botToken: x
552
- signingSecret: y
553
- `),
554
- ).toThrow(SpecParseError);
555
- });
556
-
557
- test("rejects an invalid sessionKey", () => {
558
- expect(() =>
559
- parseSpec(`
560
- name: hello-channel
561
- target: channel
562
- agent:
563
- model: m
564
- instructions: i
565
- channels:
566
- slack:
567
- botToken: x
568
- signingSecret: y
569
- routing:
570
- sessionKey: workspace
571
- `),
572
- ).toThrow(SpecParseError);
573
- });
574
-
575
- test("rejects an unknown channel adapter (strict)", () => {
576
- expect(() =>
577
- parseSpec(`
578
- name: hello-channel
579
- target: channel
580
- agent:
581
- model: m
582
- instructions: i
583
- channels:
584
- telegram:
585
- botToken: x
586
- routing:
587
- sessionKey: thread
588
- `),
589
- ).toThrow(SpecParseError);
590
- });
591
-
592
- test("rejects mode: bypass in channel spec", () => {
593
- expect(() =>
594
- parseSpec(`
595
- name: hello-channel
596
- target: channel
597
- agent:
598
- model: m
599
- instructions: i
600
- channels:
601
- slack:
602
- botToken: x
603
- signingSecret: y
604
- routing:
605
- sessionKey: thread
606
- permissions:
607
- mode: bypass
608
- `),
609
- ).toThrow(SpecParseError);
610
- });
611
- });
612
-
613
- describe("parseSpec mcp_servers block (Section 9)", () => {
614
- test("parses a CLI spec with a stdio MCP server", () => {
615
- const spec = parseSpec(`
616
- name: hello
617
- target: cli
618
- agent:
619
- model: m
620
- instructions: i
621
- mcp_servers:
622
- fs:
623
- transport: stdio
624
- command: npx
625
- args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
626
- env:
627
- DEBUG: "1"
628
- `);
629
- if (spec.target !== "cli") expect.unreachable();
630
- expect(spec.mcp_servers).toBeDefined();
631
- const fs = spec.mcp_servers?.["fs"];
632
- expect(fs?.transport).toBe("stdio");
633
- if (fs?.transport !== "stdio") expect.unreachable();
634
- expect(fs.command).toBe("npx");
635
- expect(fs.args).toEqual(["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]);
636
- expect(fs.env).toEqual({ DEBUG: "1" });
637
- });
638
-
639
- test("parses a CLI spec with an SSE MCP server", () => {
640
- const spec = parseSpec(`
641
- name: hello
642
- target: cli
643
- agent:
644
- model: m
645
- instructions: i
646
- mcp_servers:
647
- remote:
648
- transport: sse
649
- url: https://example.com/sse
650
- headers:
651
- Authorization: "Bearer x"
652
- `);
653
- if (spec.target !== "cli") expect.unreachable();
654
- const remote = spec.mcp_servers?.["remote"];
655
- expect(remote?.transport).toBe("sse");
656
- if (remote?.transport !== "sse") expect.unreachable();
657
- expect(remote.url).toBe("https://example.com/sse");
658
- expect(remote.headers).toEqual({ Authorization: "Bearer x" });
659
- });
660
-
661
- test("parses a workflow spec with mcp_servers", () => {
662
- const spec = parseSpec(`
663
- name: w
664
- target: workflow
665
- model: m
666
- mcp_servers:
667
- fs:
668
- transport: stdio
669
- command: foo
670
- steps:
671
- - name: a
672
- instructions: ai
673
- `);
674
- if (spec.target !== "workflow") expect.unreachable();
675
- expect(spec.mcp_servers?.["fs"]).toBeDefined();
676
- });
677
-
678
- test("mcp_servers field is optional", () => {
679
- const spec = parseSpec(`
680
- name: hello
681
- target: cli
682
- agent:
683
- model: m
684
- instructions: i
685
- `);
686
- if (spec.target !== "cli") expect.unreachable();
687
- expect(spec.mcp_servers).toBeUndefined();
688
- });
689
-
690
- test("rejects an MCP config missing the discriminator", () => {
691
- expect(() =>
692
- parseSpec(`
693
- name: hello
694
- target: cli
695
- agent:
696
- model: m
697
- instructions: i
698
- mcp_servers:
699
- fs:
700
- command: npx
701
- `),
702
- ).toThrow(SpecParseError);
703
- });
704
-
705
- test("rejects an unknown transport value", () => {
706
- expect(() =>
707
- parseSpec(`
708
- name: hello
709
- target: cli
710
- agent:
711
- model: m
712
- instructions: i
713
- mcp_servers:
714
- fs:
715
- transport: ftp
716
- command: x
717
- `),
718
- ).toThrow(SpecParseError);
719
- });
720
-
721
- test("rejects an stdio config with stray sse fields (strict mode)", () => {
722
- expect(() =>
723
- parseSpec(`
724
- name: hello
725
- target: cli
726
- agent:
727
- model: m
728
- instructions: i
729
- mcp_servers:
730
- fs:
731
- transport: stdio
732
- command: x
733
- url: https://nope
734
- `),
735
- ).toThrow(SpecParseError);
736
- });
737
-
738
- test("rejects an SSE config with non-URL url", () => {
739
- expect(() =>
740
- parseSpec(`
741
- name: hello
742
- target: cli
743
- agent:
744
- model: m
745
- instructions: i
746
- mcp_servers:
747
- fs:
748
- transport: sse
749
- url: not-a-url
750
- `),
751
- ).toThrow(SpecParseError);
752
- });
753
- });
754
-
755
- describe("parseSpec — CLI banner (Phase 3 §3.3)", () => {
756
- test("accepts a banner block with taglineMode and taglines", () => {
757
- const spec = parseSpec(`
758
- name: hello
759
- target: cli
760
- agent:
761
- model: claude-sonnet-4-6
762
- instructions: be helpful
763
- cli:
764
- banner:
765
- taglineMode: random
766
- taglines:
767
- - "🦞 first"
768
- - "🦞 second"
769
- `);
770
- if (spec.target !== "cli") throw new Error("unexpected target");
771
- expect(spec.cli?.banner?.taglineMode).toBe("random");
772
- expect(spec.cli?.banner?.taglines).toEqual(["🦞 first", "🦞 second"]);
773
- });
774
-
775
- test("defaults taglineMode to 'static' when omitted", () => {
776
- const spec = parseSpec(`
777
- name: hello
778
- target: cli
779
- agent:
780
- model: claude-sonnet-4-6
781
- instructions: be helpful
782
- cli:
783
- banner:
784
- taglines: ["only one"]
785
- `);
786
- if (spec.target !== "cli") throw new Error("unexpected target");
787
- expect(spec.cli?.banner?.taglineMode).toBe("static");
788
- });
789
-
790
- test("rejects empty taglines array", () => {
791
- expect(() =>
792
- parseSpec(`
793
- name: hello
794
- target: cli
795
- agent:
796
- model: claude-sonnet-4-6
797
- instructions: be helpful
798
- cli:
799
- banner:
800
- taglines: []
801
- `),
802
- ).toThrow(SpecParseError);
803
- });
804
-
805
- test("rejects invalid taglineMode", () => {
806
- expect(() =>
807
- parseSpec(`
808
- name: hello
809
- target: cli
810
- agent:
811
- model: claude-sonnet-4-6
812
- instructions: be helpful
813
- cli:
814
- banner:
815
- taglineMode: invalid
816
- taglines: ["t"]
817
- `),
818
- ).toThrow(SpecParseError);
819
- });
820
- });
821
-
822
- describe("parseSpec — gateway (Phase 3 §3.4)", () => {
823
- test("accepts a gateway block with port + ui", () => {
824
- const spec = parseSpec(`
825
- name: hello
826
- target: channel
827
- agent:
828
- model: claude-sonnet-4-6
829
- instructions: be helpful
830
- channels:
831
- slack:
832
- botToken: $SLACK_BOT_TOKEN
833
- signingSecret: $SLACK_SIGNING_SECRET
834
- routing:
835
- sessionKey: thread
836
- gateway:
837
- port: 19001
838
- ui: true
839
- `);
840
- if (spec.target !== "channel") throw new Error("unexpected target");
841
- expect(spec.gateway?.port).toBe(19001);
842
- expect(spec.gateway?.ui).toBe(true);
843
- });
844
-
845
- test("ui defaults to false when omitted", () => {
846
- const spec = parseSpec(`
847
- name: hello
848
- target: channel
849
- agent:
850
- model: claude-sonnet-4-6
851
- instructions: be helpful
852
- channels:
853
- slack:
854
- botToken: $SLACK_BOT_TOKEN
855
- signingSecret: $SLACK_SIGNING_SECRET
856
- routing:
857
- sessionKey: thread
858
- gateway:
859
- port: 8080
860
- `);
861
- if (spec.target !== "channel") throw new Error("unexpected target");
862
- expect(spec.gateway?.ui).toBe(false);
863
- });
864
-
865
- test("rejects invalid port (out of range)", () => {
866
- expect(() =>
867
- parseSpec(`
868
- name: hello
869
- target: channel
870
- agent:
871
- model: claude-sonnet-4-6
872
- instructions: be helpful
873
- channels:
874
- slack:
875
- botToken: $SLACK_BOT_TOKEN
876
- signingSecret: $SLACK_SIGNING_SECRET
877
- routing:
878
- sessionKey: thread
879
- gateway:
880
- port: 99999
881
- `),
882
- ).toThrow(SpecParseError);
883
- });
884
-
885
- test("gateway is optional", () => {
886
- const spec = parseSpec(`
887
- name: hello
888
- target: channel
889
- agent:
890
- model: claude-sonnet-4-6
891
- instructions: be helpful
892
- channels:
893
- slack:
894
- botToken: $SLACK_BOT_TOKEN
895
- signingSecret: $SLACK_SIGNING_SECRET
896
- routing:
897
- sessionKey: thread
898
- `);
899
- if (spec.target !== "channel") throw new Error("unexpected target");
900
- expect(spec.gateway).toBeUndefined();
901
- });
902
- });
903
-
904
- describe("parseSpec — heartbeat (Phase 3 §3.1)", () => {
905
- test("accepts a heartbeat block with duration and instructions", () => {
906
- const spec = parseSpec(`
907
- name: hello
908
- target: channel
909
- agent:
910
- model: claude-sonnet-4-6
911
- instructions: be helpful
912
- channels:
913
- slack:
914
- botToken: $SLACK_BOT_TOKEN
915
- signingSecret: $SLACK_SIGNING_SECRET
916
- routing:
917
- sessionKey: thread
918
- heartbeat:
919
- every: 2h
920
- instructions: wake and decide
921
- `);
922
- if (spec.target !== "channel") throw new Error("unexpected target");
923
- expect(spec.heartbeat?.every).toBe("2h");
924
- expect(spec.heartbeat?.instructions).toBe("wake and decide");
925
- });
926
-
927
- test.each(["2h", "30m", "60s", "500ms"])("accepts duration string %s", (every: string) => {
928
- const spec = parseSpec(`
929
- name: hello
930
- target: channel
931
- agent:
932
- model: claude-sonnet-4-6
933
- instructions: be helpful
934
- channels:
935
- slack:
936
- botToken: $SLACK_BOT_TOKEN
937
- signingSecret: $SLACK_SIGNING_SECRET
938
- routing:
939
- sessionKey: thread
940
- heartbeat:
941
- every: ${every}
942
- instructions: tick
943
- `);
944
- if (spec.target !== "channel") throw new Error("unexpected target");
945
- expect(spec.heartbeat?.every).toBe(every);
946
- });
947
-
948
- test("rejects invalid duration format", () => {
949
- expect(() =>
950
- parseSpec(`
951
- name: hello
952
- target: channel
953
- agent:
954
- model: claude-sonnet-4-6
955
- instructions: be helpful
956
- channels:
957
- slack:
958
- botToken: $SLACK_BOT_TOKEN
959
- signingSecret: $SLACK_SIGNING_SECRET
960
- routing:
961
- sessionKey: thread
962
- heartbeat:
963
- every: "2 hours"
964
- instructions: tick
965
- `),
966
- ).toThrow(SpecParseError);
967
- });
968
-
969
- test("heartbeat is optional", () => {
970
- const spec = parseSpec(`
971
- name: hello
972
- target: channel
973
- agent:
974
- model: claude-sonnet-4-6
975
- instructions: be helpful
976
- channels:
977
- slack:
978
- botToken: $SLACK_BOT_TOKEN
979
- signingSecret: $SLACK_SIGNING_SECRET
980
- routing:
981
- sessionKey: thread
982
- `);
983
- if (spec.target !== "channel") throw new Error("unexpected target");
984
- expect(spec.heartbeat).toBeUndefined();
985
- });
986
- });
987
-
988
- describe("parseSpec — compaction block (Section 17 + Pillar 2 curator)", () => {
989
- test("accepts the curator opt-in + tuning knobs", () => {
990
- const spec = parseSpec(`
991
- name: hello
992
- target: cli
993
- agent:
994
- model: claude-sonnet-4-6
995
- instructions: be helpful
996
- compaction:
997
- model: claude-haiku-4
998
- curate: true
999
- dedupeThreshold: 0.88
1000
- relevanceTopK: 5
1001
- `);
1002
- if (spec.target !== "cli") throw new Error("unexpected target");
1003
- expect(spec.compaction).toEqual({
1004
- model: "claude-haiku-4",
1005
- curate: true,
1006
- dedupeThreshold: 0.88,
1007
- relevanceTopK: 5,
1008
- });
1009
- });
1010
-
1011
- test("each curator field is independently optional", () => {
1012
- const spec = parseSpec(`
1013
- name: hello
1014
- target: cli
1015
- agent:
1016
- model: m
1017
- instructions: i
1018
- compaction:
1019
- curate: true
1020
- `);
1021
- if (spec.target !== "cli") throw new Error("unexpected target");
1022
- expect(spec.compaction).toEqual({ curate: true });
1023
- });
1024
-
1025
- test("rejects dedupeThreshold > 1 (cosine outputs cap at 1)", () => {
1026
- expect(() =>
1027
- parseSpec(`
1028
- name: hello
1029
- target: cli
1030
- agent:
1031
- model: m
1032
- instructions: i
1033
- compaction:
1034
- dedupeThreshold: 1.5
1035
- `),
1036
- ).toThrow(SpecParseError);
1037
- });
1038
-
1039
- test("rejects dedupeThreshold <= 0", () => {
1040
- expect(() =>
1041
- parseSpec(`
1042
- name: hello
1043
- target: cli
1044
- agent:
1045
- model: m
1046
- instructions: i
1047
- compaction:
1048
- dedupeThreshold: 0
1049
- `),
1050
- ).toThrow(SpecParseError);
1051
- });
1052
-
1053
- test("rejects non-integer relevanceTopK", () => {
1054
- expect(() =>
1055
- parseSpec(`
1056
- name: hello
1057
- target: cli
1058
- agent:
1059
- model: m
1060
- instructions: i
1061
- compaction:
1062
- relevanceTopK: 3.5
1063
- `),
1064
- ).toThrow(SpecParseError);
1065
- });
1066
-
1067
- test("rejects relevanceTopK <= 0", () => {
1068
- expect(() =>
1069
- parseSpec(`
1070
- name: hello
1071
- target: cli
1072
- agent:
1073
- model: m
1074
- instructions: i
1075
- compaction:
1076
- relevanceTopK: 0
1077
- `),
1078
- ).toThrow(SpecParseError);
1079
- });
1080
-
1081
- test("rejects unknown keys inside the compaction block (strict)", () => {
1082
- expect(() =>
1083
- parseSpec(`
1084
- name: hello
1085
- target: cli
1086
- agent:
1087
- model: m
1088
- instructions: i
1089
- compaction:
1090
- enableMagic: true
1091
- `),
1092
- ).toThrow(SpecParseError);
1093
- });
1094
- });
1095
-
1096
- // FR-004 — Pillar 3 security block (intent-gate judge selection).
1097
- describe("parseSpec security.justification", () => {
1098
- test("parses a cli spec with security.justification.judge=claude + model", () => {
1099
- const spec = parseSpec(`
1100
- name: hello
1101
- target: cli
1102
- agent:
1103
- model: m
1104
- instructions: i
1105
- security:
1106
- justification:
1107
- judge: claude
1108
- model: claude-haiku-4-5
1109
- `);
1110
- if (spec.target !== "cli") expect.unreachable();
1111
- expect(spec.security?.justification?.judge).toBe("claude");
1112
- expect(spec.security?.justification?.model).toBe("claude-haiku-4-5");
1113
- });
1114
-
1115
- test("judge defaults to rule-based when the justification block omits it", () => {
1116
- const spec = parseSpec(`
1117
- name: hello
1118
- target: cli
1119
- agent:
1120
- model: m
1121
- instructions: i
1122
- security:
1123
- justification: {}
1124
- `);
1125
- if (spec.target !== "cli") expect.unreachable();
1126
- expect(spec.security?.justification?.judge).toBe("rule-based");
1127
- });
1128
-
1129
- test("rejects an unknown judge enum value", () => {
1130
- expect(() =>
1131
- parseSpec(`
1132
- name: hello
1133
- target: cli
1134
- agent:
1135
- model: m
1136
- instructions: i
1137
- security:
1138
- justification:
1139
- judge: gpt-omniscient
1140
- `),
1141
- ).toThrow(SpecParseError);
1142
- });
1143
-
1144
- test("rejects unknown keys inside the security block (strict)", () => {
1145
- expect(() =>
1146
- parseSpec(`
1147
- name: hello
1148
- target: cli
1149
- agent:
1150
- model: m
1151
- instructions: i
1152
- security:
1153
- enableTelepathy: true
1154
- `),
1155
- ).toThrow(SpecParseError);
1156
- });
1157
-
1158
- test("security block is optional — a spec without it still parses", () => {
1159
- const spec = parseSpec(`
1160
- name: hello
1161
- target: cli
1162
- agent:
1163
- model: m
1164
- instructions: i
1165
- `);
1166
- if (spec.target !== "cli") expect.unreachable();
1167
- expect(spec.security).toBeUndefined();
1168
- });
1169
- });
1170
-
1171
- // FR-006 — Pillar 3 sink-side fabric (egress matcher selector).
1172
- describe("parseSpec security.egressMatcher", () => {
1173
- test("parses a cli spec with security.egressMatcher: semantic", () => {
1174
- const spec = parseSpec(`
1175
- name: hello
1176
- target: cli
1177
- agent:
1178
- model: m
1179
- instructions: i
1180
- security:
1181
- egressMatcher: semantic
1182
- `);
1183
- if (spec.target !== "cli") expect.unreachable();
1184
- expect(spec.security?.egressMatcher).toBe("semantic");
1185
- });
1186
-
1187
- test("parses security.egressMatcher: substring (the explicit default)", () => {
1188
- const spec = parseSpec(`
1189
- name: hello
1190
- target: cli
1191
- agent:
1192
- model: m
1193
- instructions: i
1194
- security:
1195
- egressMatcher: substring
1196
- `);
1197
- if (spec.target !== "cli") expect.unreachable();
1198
- expect(spec.security?.egressMatcher).toBe("substring");
1199
- });
1200
-
1201
- test("rejects an unknown egressMatcher enum value (strict)", () => {
1202
- expect(() =>
1203
- parseSpec(`
1204
- name: hello
1205
- target: cli
1206
- agent:
1207
- model: m
1208
- instructions: i
1209
- security:
1210
- egressMatcher: telepathic
1211
- `),
1212
- ).toThrow(SpecParseError);
1213
- });
1214
-
1215
- test("egressMatcher coexists with justification in the same security block", () => {
1216
- const spec = parseSpec(`
1217
- name: hello
1218
- target: cli
1219
- agent:
1220
- model: m
1221
- instructions: i
1222
- security:
1223
- justification:
1224
- judge: claude
1225
- egressMatcher: semantic
1226
- `);
1227
- if (spec.target !== "cli") expect.unreachable();
1228
- expect(spec.security?.justification?.judge).toBe("claude");
1229
- expect(spec.security?.egressMatcher).toBe("semantic");
1230
- });
1231
-
1232
- test("egressMatcher is optional — a security block without it still parses", () => {
1233
- const spec = parseSpec(`
1234
- name: hello
1235
- target: cli
1236
- agent:
1237
- model: m
1238
- instructions: i
1239
- security:
1240
- justification: {}
1241
- `);
1242
- if (spec.target !== "cli") expect.unreachable();
1243
- expect(spec.security?.egressMatcher).toBeUndefined();
1244
- });
1245
- });
1246
-
1247
- describe("parseSpec pipeline target — vector backend (Section 21)", () => {
1248
- const PIPELINE = (retrieve: string) => `
1249
- name: doc-bot
1250
- target: pipeline
1251
- agent:
1252
- model: claude-sonnet-4-6
1253
- instructions: answer using Retrieve
1254
- retrieve:
1255
- ${retrieve}
1256
- indexing:
1257
- chunkStrategy: fixed
1258
- chunkSize: 200
1259
- chunkOverlap: 0
1260
- documents:
1261
- - id: doc-1
1262
- text: the quick brown fox
1263
- `;
1264
-
1265
- test("defaults vectorBackend to in-memory when omitted", () => {
1266
- const spec = parseSpec(PIPELINE(" embedderModel: mock/det"));
1267
- if (spec.target !== "pipeline") expect.unreachable();
1268
- expect(spec.retrieve.vectorBackend).toBe("in-memory");
1269
- });
1270
-
1271
- test("accepts the file backend (lance) with no extra config", () => {
1272
- const spec = parseSpec(PIPELINE(" embedderModel: mock/det\n vectorBackend: lance"));
1273
- if (spec.target !== "pipeline") expect.unreachable();
1274
- expect(spec.retrieve.vectorBackend).toBe("lance");
1275
- });
1276
-
1277
- test("accepts an http backend with url + collection + apiKey", () => {
1278
- const spec = parseSpec(
1279
- PIPELINE(
1280
- [
1281
- " embedderModel: mock/det",
1282
- " vectorBackend: qdrant",
1283
- " url: https://qdrant.example",
1284
- " collection: docs",
1285
- " apiKey: $QDRANT_API_KEY",
1286
- ].join("\n"),
1287
- ),
1288
- );
1289
- if (spec.target !== "pipeline") expect.unreachable();
1290
- expect(spec.retrieve.vectorBackend).toBe("qdrant");
1291
- expect(spec.retrieve.url).toBe("https://qdrant.example");
1292
- expect(spec.retrieve.collection).toBe("docs");
1293
- expect(spec.retrieve.apiKey).toBe("$QDRANT_API_KEY");
1294
- });
1295
-
1296
- test("rejects an unknown backend id", () => {
1297
- expect(() => parseSpec(PIPELINE(" embedderModel: mock/det\n vectorBackend: faiss"))).toThrow(
1298
- SpecParseError,
1299
- );
1300
- });
1301
-
1302
- test("rejects an http backend missing url", () => {
1303
- expect(() =>
1304
- parseSpec(PIPELINE(" embedderModel: mock/det\n vectorBackend: qdrant\n collection: docs")),
1305
- ).toThrow(/requires retrieve\.url/);
1306
- });
1307
-
1308
- test("rejects an http backend missing collection", () => {
1309
- expect(() =>
1310
- parseSpec(
1311
- PIPELINE(
1312
- " embedderModel: mock/det\n vectorBackend: pinecone\n url: https://pinecone.example",
1313
- ),
1314
- ),
1315
- ).toThrow(/requires retrieve\.collection/);
1316
- });
1317
- });
1318
-
1319
- describe("parseSpec crew target cross-field invariants (Section 22)", () => {
1320
- // Two-role crew with a configurable `entry:` line and an optional trailing
1321
- // routing block, so each post-parse invariant is exercised through a real
1322
- // Zod-valid spec (the cross-field checks run only after safeParse succeeds).
1323
- const CREW = (entry: string, routing = "") => `
1324
- name: team
1325
- target: crew
1326
- model: m
1327
- entry: ${entry}
1328
- roles:
1329
- lead:
1330
- instructions: coordinate the crew
1331
- worker:
1332
- instructions: do the work
1333
- ${routing}`;
1334
-
1335
- test("parses a valid crew with match routing and threads roles/entry/routing", () => {
1336
- const spec = parseSpec(
1337
- CREW(
1338
- "lead",
1339
- [
1340
- "routing:",
1341
- " kind: match",
1342
- " match:",
1343
- " lead:",
1344
- " - contains: help",
1345
- " to: worker",
1346
- ].join("\n"),
1347
- ),
1348
- );
1349
- if (spec.target !== "crew") expect.unreachable();
1350
- expect(Object.keys(spec.roles)).toEqual(["lead", "worker"]);
1351
- expect(spec.entry).toBe("lead");
1352
- expect(spec.routing).toEqual({
1353
- kind: "match",
1354
- match: { lead: [{ contains: "help", to: "worker" }] },
1355
- });
1356
- });
1357
-
1358
- test("accepts llm routing (no match block) — the match-validation loop is skipped", () => {
1359
- const spec = parseSpec(CREW("lead", ["routing:", " kind: llm"].join("\n")));
1360
- if (spec.target !== "crew") expect.unreachable();
1361
- expect(spec.routing).toEqual({ kind: "llm" });
1362
- });
1363
-
1364
- test("accepts a crew with no routing block at all", () => {
1365
- const spec = parseSpec(CREW("worker"));
1366
- if (spec.target !== "crew") expect.unreachable();
1367
- expect(spec.routing).toBeUndefined();
1368
- expect(spec.entry).toBe("worker");
1369
- });
1370
-
1371
- test("rejects a crew whose roles record is empty", () => {
1372
- expect(() => parseSpec("name: t\ntarget: crew\nmodel: m\nentry: lead\nroles: {}\n")).toThrow(
1373
- /crew target requires at least one role/,
1374
- );
1375
- });
1376
-
1377
- test("rejects a crew whose entry does not name a declared role", () => {
1378
- expect(() => parseSpec(CREW("ghost"))).toThrow(
1379
- /crew\.entry "ghost" must name one of crew\.roles \(got: lead, worker\)/,
1380
- );
1381
- });
1382
-
1383
- test("rejects routing whose match source role is not a declared role", () => {
1384
- expect(() =>
1385
- parseSpec(
1386
- CREW(
1387
- "lead",
1388
- [
1389
- "routing:",
1390
- " kind: match",
1391
- " match:",
1392
- " ghost:",
1393
- " - contains: x",
1394
- " to: worker",
1395
- ].join("\n"),
1396
- ),
1397
- ),
1398
- ).toThrow(/crew\.routing\.match\["ghost"\]: source role not in crew\.roles/);
1399
- });
1400
-
1401
- test("rejects routing whose match target role is not a declared role", () => {
1402
- expect(() =>
1403
- parseSpec(
1404
- CREW(
1405
- "lead",
1406
- [
1407
- "routing:",
1408
- " kind: match",
1409
- " match:",
1410
- " lead:",
1411
- " - contains: x",
1412
- " to: ghost",
1413
- ].join("\n"),
1414
- ),
1415
- ),
1416
- ).toThrow(/crew\.routing\.match\["lead"\]\.to = "ghost" — target role not in crew\.roles/);
1417
- });
1418
- });