@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 → dist}/index.d.ts +386 -377
- package/{src → dist}/index.js +12 -2
- package/package.json +9 -6
- package/src/index.d.ts.map +0 -1
- package/src/index.js.map +0 -1
- package/src/index.test.ts +0 -1418
- package/src/index.ts +0 -1242
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
|
-
});
|