@os-eco/overstory-cli 0.7.8 → 0.8.0
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 +17 -8
- package/agents/coordinator.md +41 -0
- package/agents/orchestrator.md +239 -0
- package/package.json +1 -1
- package/src/agents/guard-rules.test.ts +372 -0
- package/src/agents/manifest.test.ts +168 -1
- package/src/agents/manifest.ts +23 -2
- package/src/commands/agents.ts +1 -0
- package/src/commands/coordinator.test.ts +334 -0
- package/src/commands/coordinator.ts +366 -0
- package/src/commands/init.test.ts +3 -1
- package/src/commands/init.ts +3 -2
- package/src/commands/prime.test.ts +1 -0
- package/src/commands/update.test.ts +465 -0
- package/src/commands/update.ts +263 -0
- package/src/config.test.ts +65 -1
- package/src/config.ts +23 -0
- package/src/doctor/structure.test.ts +1 -0
- package/src/doctor/structure.ts +1 -0
- package/src/e2e/init-sling-lifecycle.test.ts +3 -2
- package/src/index.ts +21 -2
- package/src/runtimes/gemini.test.ts +537 -0
- package/src/runtimes/gemini.ts +235 -0
- package/src/runtimes/registry.test.ts +15 -1
- package/src/runtimes/registry.ts +2 -0
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
DANGEROUS_BASH_PATTERNS,
|
|
4
|
+
INTERACTIVE_TOOLS,
|
|
5
|
+
NATIVE_TEAM_TOOLS,
|
|
6
|
+
SAFE_BASH_PREFIXES,
|
|
7
|
+
WRITE_TOOLS,
|
|
8
|
+
} from "./guard-rules.ts";
|
|
9
|
+
|
|
10
|
+
// ─── NATIVE_TEAM_TOOLS ───────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
describe("NATIVE_TEAM_TOOLS", () => {
|
|
13
|
+
test("is a non-empty array", () => {
|
|
14
|
+
expect(Array.isArray(NATIVE_TEAM_TOOLS)).toBe(true);
|
|
15
|
+
expect(NATIVE_TEAM_TOOLS.length).toBeGreaterThan(0);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("contains all expected Claude Code team/task tools", () => {
|
|
19
|
+
const expected = [
|
|
20
|
+
"Task",
|
|
21
|
+
"TeamCreate",
|
|
22
|
+
"TeamDelete",
|
|
23
|
+
"SendMessage",
|
|
24
|
+
"TaskCreate",
|
|
25
|
+
"TaskUpdate",
|
|
26
|
+
"TaskList",
|
|
27
|
+
"TaskGet",
|
|
28
|
+
"TaskOutput",
|
|
29
|
+
"TaskStop",
|
|
30
|
+
];
|
|
31
|
+
for (const tool of expected) {
|
|
32
|
+
expect(NATIVE_TEAM_TOOLS).toContain(tool);
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("has exactly 10 entries", () => {
|
|
37
|
+
expect(NATIVE_TEAM_TOOLS.length).toBe(10);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("has no duplicate entries", () => {
|
|
41
|
+
const unique = new Set(NATIVE_TEAM_TOOLS);
|
|
42
|
+
expect(unique.size).toBe(NATIVE_TEAM_TOOLS.length);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("all entries are non-empty strings", () => {
|
|
46
|
+
for (const tool of NATIVE_TEAM_TOOLS) {
|
|
47
|
+
expect(typeof tool).toBe("string");
|
|
48
|
+
expect(tool.length).toBeGreaterThan(0);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// ─── INTERACTIVE_TOOLS ───────────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
describe("INTERACTIVE_TOOLS", () => {
|
|
56
|
+
test("is a non-empty array", () => {
|
|
57
|
+
expect(Array.isArray(INTERACTIVE_TOOLS)).toBe(true);
|
|
58
|
+
expect(INTERACTIVE_TOOLS.length).toBeGreaterThan(0);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("contains AskUserQuestion", () => {
|
|
62
|
+
expect(INTERACTIVE_TOOLS).toContain("AskUserQuestion");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("contains EnterPlanMode", () => {
|
|
66
|
+
expect(INTERACTIVE_TOOLS).toContain("EnterPlanMode");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("contains EnterWorktree", () => {
|
|
70
|
+
expect(INTERACTIVE_TOOLS).toContain("EnterWorktree");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("has no duplicate entries", () => {
|
|
74
|
+
const unique = new Set(INTERACTIVE_TOOLS);
|
|
75
|
+
expect(unique.size).toBe(INTERACTIVE_TOOLS.length);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("all entries are non-empty strings", () => {
|
|
79
|
+
for (const tool of INTERACTIVE_TOOLS) {
|
|
80
|
+
expect(typeof tool).toBe("string");
|
|
81
|
+
expect(tool.length).toBeGreaterThan(0);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("does not contain any NATIVE_TEAM_TOOLS (no overlap)", () => {
|
|
86
|
+
const nativeSet = new Set(NATIVE_TEAM_TOOLS);
|
|
87
|
+
for (const tool of INTERACTIVE_TOOLS) {
|
|
88
|
+
expect(nativeSet.has(tool)).toBe(false);
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// ─── WRITE_TOOLS ─────────────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
describe("WRITE_TOOLS", () => {
|
|
96
|
+
test("is a non-empty array", () => {
|
|
97
|
+
expect(Array.isArray(WRITE_TOOLS)).toBe(true);
|
|
98
|
+
expect(WRITE_TOOLS.length).toBeGreaterThan(0);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("contains Write", () => {
|
|
102
|
+
expect(WRITE_TOOLS).toContain("Write");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("contains Edit", () => {
|
|
106
|
+
expect(WRITE_TOOLS).toContain("Edit");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("contains NotebookEdit", () => {
|
|
110
|
+
expect(WRITE_TOOLS).toContain("NotebookEdit");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("has no duplicate entries", () => {
|
|
114
|
+
const unique = new Set(WRITE_TOOLS);
|
|
115
|
+
expect(unique.size).toBe(WRITE_TOOLS.length);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("all entries are non-empty strings", () => {
|
|
119
|
+
for (const tool of WRITE_TOOLS) {
|
|
120
|
+
expect(typeof tool).toBe("string");
|
|
121
|
+
expect(tool.length).toBeGreaterThan(0);
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("does not overlap with NATIVE_TEAM_TOOLS", () => {
|
|
126
|
+
const nativeSet = new Set(NATIVE_TEAM_TOOLS);
|
|
127
|
+
for (const tool of WRITE_TOOLS) {
|
|
128
|
+
expect(nativeSet.has(tool)).toBe(false);
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("does not overlap with INTERACTIVE_TOOLS", () => {
|
|
133
|
+
const interactiveSet = new Set(INTERACTIVE_TOOLS);
|
|
134
|
+
for (const tool of WRITE_TOOLS) {
|
|
135
|
+
expect(interactiveSet.has(tool)).toBe(false);
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// ─── DANGEROUS_BASH_PATTERNS ─────────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
describe("DANGEROUS_BASH_PATTERNS", () => {
|
|
143
|
+
test("is a non-empty array", () => {
|
|
144
|
+
expect(Array.isArray(DANGEROUS_BASH_PATTERNS)).toBe(true);
|
|
145
|
+
expect(DANGEROUS_BASH_PATTERNS.length).toBeGreaterThan(0);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("all entries are non-empty strings", () => {
|
|
149
|
+
for (const pattern of DANGEROUS_BASH_PATTERNS) {
|
|
150
|
+
expect(typeof pattern).toBe("string");
|
|
151
|
+
expect(pattern.length).toBeGreaterThan(0);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("all entries are valid regex patterns", () => {
|
|
156
|
+
for (const pattern of DANGEROUS_BASH_PATTERNS) {
|
|
157
|
+
expect(() => new RegExp(pattern)).not.toThrow();
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("has no duplicate entries", () => {
|
|
162
|
+
const unique = new Set(DANGEROUS_BASH_PATTERNS);
|
|
163
|
+
expect(unique.size).toBe(DANGEROUS_BASH_PATTERNS.length);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// Verify key dangerous operations are covered
|
|
167
|
+
test("contains sed -i pattern", () => {
|
|
168
|
+
const pattern = DANGEROUS_BASH_PATTERNS.find((p) => p.includes("sed") && p.includes("-i"));
|
|
169
|
+
expect(pattern).toBeDefined();
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("contains echo redirect pattern", () => {
|
|
173
|
+
const pattern = DANGEROUS_BASH_PATTERNS.find((p) => p.includes("echo") && p.includes(">"));
|
|
174
|
+
expect(pattern).toBeDefined();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("contains printf redirect pattern", () => {
|
|
178
|
+
const pattern = DANGEROUS_BASH_PATTERNS.find((p) => p.includes("printf") && p.includes(">"));
|
|
179
|
+
expect(pattern).toBeDefined();
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("contains cat redirect pattern", () => {
|
|
183
|
+
const pattern = DANGEROUS_BASH_PATTERNS.find((p) => p.includes("cat") && p.includes(">"));
|
|
184
|
+
expect(pattern).toBeDefined();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("contains tee pattern", () => {
|
|
188
|
+
const pattern = DANGEROUS_BASH_PATTERNS.find((p) => p.includes("tee"));
|
|
189
|
+
expect(pattern).toBeDefined();
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("contains rm pattern", () => {
|
|
193
|
+
const pattern = DANGEROUS_BASH_PATTERNS.find((p) => p.includes("rm"));
|
|
194
|
+
expect(pattern).toBeDefined();
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("contains mv pattern", () => {
|
|
198
|
+
const pattern = DANGEROUS_BASH_PATTERNS.find((p) => p.includes("mv"));
|
|
199
|
+
expect(pattern).toBeDefined();
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("contains cp pattern", () => {
|
|
203
|
+
const pattern = DANGEROUS_BASH_PATTERNS.find((p) => p.includes("cp"));
|
|
204
|
+
expect(pattern).toBeDefined();
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("contains mkdir pattern", () => {
|
|
208
|
+
const pattern = DANGEROUS_BASH_PATTERNS.find((p) => p.includes("mkdir"));
|
|
209
|
+
expect(pattern).toBeDefined();
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("contains git add pattern", () => {
|
|
213
|
+
const pattern = DANGEROUS_BASH_PATTERNS.find((p) => p.includes("git") && p.includes("add"));
|
|
214
|
+
expect(pattern).toBeDefined();
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test("contains git commit pattern", () => {
|
|
218
|
+
const pattern = DANGEROUS_BASH_PATTERNS.find((p) => p.includes("git") && p.includes("commit"));
|
|
219
|
+
expect(pattern).toBeDefined();
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("contains git push pattern", () => {
|
|
223
|
+
const pattern = DANGEROUS_BASH_PATTERNS.find((p) => p.includes("git") && p.includes("push"));
|
|
224
|
+
expect(pattern).toBeDefined();
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("contains git reset pattern", () => {
|
|
228
|
+
const pattern = DANGEROUS_BASH_PATTERNS.find((p) => p.includes("git") && p.includes("reset"));
|
|
229
|
+
expect(pattern).toBeDefined();
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("contains npm install pattern", () => {
|
|
233
|
+
const pattern = DANGEROUS_BASH_PATTERNS.find((p) => p.includes("npm") && p.includes("install"));
|
|
234
|
+
expect(pattern).toBeDefined();
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test("contains bun install pattern", () => {
|
|
238
|
+
const pattern = DANGEROUS_BASH_PATTERNS.find((p) => p.includes("bun") && p.includes("install"));
|
|
239
|
+
expect(pattern).toBeDefined();
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
// Runtime eval bypass patterns
|
|
243
|
+
test("contains bun -e / --eval pattern (runtime eval bypass)", () => {
|
|
244
|
+
const hasEval = DANGEROUS_BASH_PATTERNS.some(
|
|
245
|
+
(p) => p.includes("bun") && (p.includes("-e") || p.includes("eval")),
|
|
246
|
+
);
|
|
247
|
+
expect(hasEval).toBe(true);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test("contains node -e / --eval pattern (runtime eval bypass)", () => {
|
|
251
|
+
const hasEval = DANGEROUS_BASH_PATTERNS.some(
|
|
252
|
+
(p) => p.includes("node") && (p.includes("-e") || p.includes("eval")),
|
|
253
|
+
);
|
|
254
|
+
expect(hasEval).toBe(true);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test("contains python -c pattern (runtime eval bypass)", () => {
|
|
258
|
+
const hasEval = DANGEROUS_BASH_PATTERNS.some((p) => p.includes("python") && p.includes("-c"));
|
|
259
|
+
expect(hasEval).toBe(true);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
// Functional: combined pattern matches dangerous commands
|
|
263
|
+
test("combined pattern matches 'sed -i' command", () => {
|
|
264
|
+
const combined = new RegExp(DANGEROUS_BASH_PATTERNS.join("|"));
|
|
265
|
+
expect(combined.test("sed -i 's/foo/bar/' file.txt")).toBe(true);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test("combined pattern matches 'echo foo > file' command", () => {
|
|
269
|
+
const combined = new RegExp(DANGEROUS_BASH_PATTERNS.join("|"));
|
|
270
|
+
expect(combined.test("echo foo > file.txt")).toBe(true);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test("combined pattern matches 'rm -rf' command", () => {
|
|
274
|
+
const combined = new RegExp(DANGEROUS_BASH_PATTERNS.join("|"));
|
|
275
|
+
expect(combined.test("rm -rf /tmp/foo")).toBe(true);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test("combined pattern matches 'git commit' command", () => {
|
|
279
|
+
const combined = new RegExp(DANGEROUS_BASH_PATTERNS.join("|"));
|
|
280
|
+
expect(combined.test("git commit -m 'message'")).toBe(true);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test("combined pattern matches 'git push' command", () => {
|
|
284
|
+
const combined = new RegExp(DANGEROUS_BASH_PATTERNS.join("|"));
|
|
285
|
+
expect(combined.test("git push origin main")).toBe(true);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test("combined pattern matches 'bun --eval' command", () => {
|
|
289
|
+
const combined = new RegExp(DANGEROUS_BASH_PATTERNS.join("|"));
|
|
290
|
+
expect(combined.test("bun --eval 'console.log(1)'")).toBe(true);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test("combined pattern matches 'node -e' command", () => {
|
|
294
|
+
const combined = new RegExp(DANGEROUS_BASH_PATTERNS.join("|"));
|
|
295
|
+
expect(combined.test("node -e 'process.exit(1)'")).toBe(true);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test("combined pattern does NOT match safe read commands", () => {
|
|
299
|
+
const combined = new RegExp(DANGEROUS_BASH_PATTERNS.join("|"));
|
|
300
|
+
expect(combined.test("cat README.md")).toBe(false);
|
|
301
|
+
expect(combined.test("grep -r 'foo' src/")).toBe(false);
|
|
302
|
+
expect(combined.test("ls -la")).toBe(false);
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
// ─── SAFE_BASH_PREFIXES ──────────────────────────────────────────────────────
|
|
307
|
+
|
|
308
|
+
describe("SAFE_BASH_PREFIXES", () => {
|
|
309
|
+
test("is a non-empty array", () => {
|
|
310
|
+
expect(Array.isArray(SAFE_BASH_PREFIXES)).toBe(true);
|
|
311
|
+
expect(SAFE_BASH_PREFIXES.length).toBeGreaterThan(0);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test("all entries are non-empty strings", () => {
|
|
315
|
+
for (const prefix of SAFE_BASH_PREFIXES) {
|
|
316
|
+
expect(typeof prefix).toBe("string");
|
|
317
|
+
expect(prefix.length).toBeGreaterThan(0);
|
|
318
|
+
}
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
test("has no duplicate entries", () => {
|
|
322
|
+
const unique = new Set(SAFE_BASH_PREFIXES);
|
|
323
|
+
expect(unique.size).toBe(SAFE_BASH_PREFIXES.length);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
test("includes overstory CLI shorthand 'ov '", () => {
|
|
327
|
+
expect(SAFE_BASH_PREFIXES).toContain("ov ");
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
test("includes overstory CLI full name 'overstory '", () => {
|
|
331
|
+
expect(SAFE_BASH_PREFIXES).toContain("overstory ");
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
test("includes beads CLI 'bd '", () => {
|
|
335
|
+
expect(SAFE_BASH_PREFIXES).toContain("bd ");
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
test("includes seeds CLI 'sd '", () => {
|
|
339
|
+
expect(SAFE_BASH_PREFIXES).toContain("sd ");
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
test("includes mulch CLI 'mulch '", () => {
|
|
343
|
+
expect(SAFE_BASH_PREFIXES).toContain("mulch ");
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
test("includes read-only git commands", () => {
|
|
347
|
+
expect(SAFE_BASH_PREFIXES).toContain("git status");
|
|
348
|
+
expect(SAFE_BASH_PREFIXES).toContain("git log");
|
|
349
|
+
expect(SAFE_BASH_PREFIXES).toContain("git diff");
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
test("does not include destructive git commands as safe prefixes", () => {
|
|
353
|
+
// git push, git reset, git commit should NOT be safe (builders can commit
|
|
354
|
+
// but non-implementation agents should not)
|
|
355
|
+
expect(SAFE_BASH_PREFIXES).not.toContain("git push");
|
|
356
|
+
expect(SAFE_BASH_PREFIXES).not.toContain("git reset");
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
test("safe prefixes match expected commands via startsWith", () => {
|
|
360
|
+
const isSafe = (cmd: string) =>
|
|
361
|
+
SAFE_BASH_PREFIXES.some((prefix) => cmd.trimStart().startsWith(prefix));
|
|
362
|
+
|
|
363
|
+
expect(isSafe("ov mail send --to parent --subject test")).toBe(true);
|
|
364
|
+
expect(isSafe("overstory status")).toBe(true);
|
|
365
|
+
expect(isSafe("sd close overstory-1234")).toBe(true);
|
|
366
|
+
expect(isSafe("bd ready")).toBe(true);
|
|
367
|
+
expect(isSafe("mulch record cli --type convention")).toBe(true);
|
|
368
|
+
expect(isSafe("git status")).toBe(true);
|
|
369
|
+
expect(isSafe("git log --oneline")).toBe(true);
|
|
370
|
+
expect(isSafe("git diff HEAD")).toBe(true);
|
|
371
|
+
});
|
|
372
|
+
});
|
|
@@ -5,7 +5,12 @@ import { join } from "node:path";
|
|
|
5
5
|
import { AgentError } from "../errors.ts";
|
|
6
6
|
import { cleanupTempDir } from "../test-helpers.ts";
|
|
7
7
|
import type { AgentManifest, OverstoryConfig } from "../types.ts";
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
createManifestLoader,
|
|
10
|
+
expandAliasFromEnv,
|
|
11
|
+
resolveModel,
|
|
12
|
+
resolveProviderEnv,
|
|
13
|
+
} from "./manifest.ts";
|
|
9
14
|
|
|
10
15
|
const VALID_MANIFEST = {
|
|
11
16
|
version: "1.0",
|
|
@@ -673,6 +678,168 @@ describe("resolveModel", () => {
|
|
|
673
678
|
});
|
|
674
679
|
});
|
|
675
680
|
|
|
681
|
+
describe("expandAliasFromEnv", () => {
|
|
682
|
+
test("returns expanded model ID when env var is set", () => {
|
|
683
|
+
expect(
|
|
684
|
+
expandAliasFromEnv("haiku", {
|
|
685
|
+
ANTHROPIC_DEFAULT_HAIKU_MODEL: "us.anthropic.claude-3-5-haiku-20241022-v1:0",
|
|
686
|
+
}),
|
|
687
|
+
).toBe("us.anthropic.claude-3-5-haiku-20241022-v1:0");
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
test("returns alias unchanged when env var is unset", () => {
|
|
691
|
+
expect(expandAliasFromEnv("haiku", {})).toBe("haiku");
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
test("expands all three aliases via their env vars", () => {
|
|
695
|
+
const env = {
|
|
696
|
+
ANTHROPIC_DEFAULT_HAIKU_MODEL: "bedrock-haiku-id",
|
|
697
|
+
ANTHROPIC_DEFAULT_SONNET_MODEL: "bedrock-sonnet-id",
|
|
698
|
+
ANTHROPIC_DEFAULT_OPUS_MODEL: "bedrock-opus-id",
|
|
699
|
+
};
|
|
700
|
+
expect(expandAliasFromEnv("haiku", env)).toBe("bedrock-haiku-id");
|
|
701
|
+
expect(expandAliasFromEnv("sonnet", env)).toBe("bedrock-sonnet-id");
|
|
702
|
+
expect(expandAliasFromEnv("opus", env)).toBe("bedrock-opus-id");
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
test("trims whitespace from env var value", () => {
|
|
706
|
+
expect(
|
|
707
|
+
expandAliasFromEnv("sonnet", {
|
|
708
|
+
ANTHROPIC_DEFAULT_SONNET_MODEL: " bedrock-sonnet-id ",
|
|
709
|
+
}),
|
|
710
|
+
).toBe("bedrock-sonnet-id");
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
test("returns alias when env var is empty string", () => {
|
|
714
|
+
expect(expandAliasFromEnv("sonnet", { ANTHROPIC_DEFAULT_SONNET_MODEL: "" })).toBe("sonnet");
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
test("returns alias when env var is whitespace only", () => {
|
|
718
|
+
expect(expandAliasFromEnv("sonnet", { ANTHROPIC_DEFAULT_SONNET_MODEL: " " })).toBe("sonnet");
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
test("returns unknown alias unchanged", () => {
|
|
722
|
+
expect(expandAliasFromEnv("gpt-4", {})).toBe("gpt-4");
|
|
723
|
+
});
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
describe("resolveModel env var expansion", () => {
|
|
727
|
+
const baseManifest: AgentManifest = {
|
|
728
|
+
version: "1.0",
|
|
729
|
+
agents: {
|
|
730
|
+
scout: {
|
|
731
|
+
file: "scout.md",
|
|
732
|
+
model: "haiku",
|
|
733
|
+
tools: ["Read"],
|
|
734
|
+
capabilities: ["explore"],
|
|
735
|
+
canSpawn: false,
|
|
736
|
+
constraints: [],
|
|
737
|
+
},
|
|
738
|
+
builder: {
|
|
739
|
+
file: "builder.md",
|
|
740
|
+
model: "sonnet",
|
|
741
|
+
tools: ["Read", "Write"],
|
|
742
|
+
capabilities: ["implement"],
|
|
743
|
+
canSpawn: false,
|
|
744
|
+
constraints: [],
|
|
745
|
+
},
|
|
746
|
+
},
|
|
747
|
+
capabilityIndex: { explore: ["scout"], implement: ["builder"] },
|
|
748
|
+
};
|
|
749
|
+
|
|
750
|
+
function makeConfig(models: OverstoryConfig["models"] = {}): OverstoryConfig {
|
|
751
|
+
return {
|
|
752
|
+
project: { name: "test", root: "/tmp/test", canonicalBranch: "main" },
|
|
753
|
+
agents: {
|
|
754
|
+
manifestPath: ".overstory/agent-manifest.json",
|
|
755
|
+
baseDir: ".overstory/agent-defs",
|
|
756
|
+
maxConcurrent: 5,
|
|
757
|
+
staggerDelayMs: 1000,
|
|
758
|
+
maxDepth: 2,
|
|
759
|
+
maxSessionsPerRun: 0,
|
|
760
|
+
maxAgentsPerLead: 5,
|
|
761
|
+
},
|
|
762
|
+
worktrees: { baseDir: ".overstory/worktrees" },
|
|
763
|
+
taskTracker: { backend: "auto", enabled: false },
|
|
764
|
+
mulch: { enabled: false, domains: [], primeFormat: "markdown" },
|
|
765
|
+
merge: { aiResolveEnabled: false, reimagineEnabled: false },
|
|
766
|
+
providers: { anthropic: { type: "native" } },
|
|
767
|
+
watchdog: {
|
|
768
|
+
tier0Enabled: false,
|
|
769
|
+
tier0IntervalMs: 30000,
|
|
770
|
+
tier1Enabled: false,
|
|
771
|
+
tier2Enabled: false,
|
|
772
|
+
staleThresholdMs: 300000,
|
|
773
|
+
zombieThresholdMs: 600000,
|
|
774
|
+
nudgeIntervalMs: 60000,
|
|
775
|
+
},
|
|
776
|
+
models,
|
|
777
|
+
logging: { verbose: false, redactSecrets: true },
|
|
778
|
+
};
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
test("expands alias when env var is set", () => {
|
|
782
|
+
const saved = process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL;
|
|
783
|
+
process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = "us.anthropic.claude-3-5-haiku-20241022-v1:0";
|
|
784
|
+
try {
|
|
785
|
+
const result = resolveModel(makeConfig(), baseManifest, "scout", "sonnet");
|
|
786
|
+
expect(result).toEqual({ model: "us.anthropic.claude-3-5-haiku-20241022-v1:0" });
|
|
787
|
+
} finally {
|
|
788
|
+
if (saved === undefined) {
|
|
789
|
+
delete process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL;
|
|
790
|
+
} else {
|
|
791
|
+
process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = saved;
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
test("passes alias through when env var is unset", () => {
|
|
797
|
+
const saved = process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL;
|
|
798
|
+
delete process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL;
|
|
799
|
+
try {
|
|
800
|
+
const result = resolveModel(makeConfig(), baseManifest, "scout", "sonnet");
|
|
801
|
+
expect(result).toEqual({ model: "haiku" });
|
|
802
|
+
} finally {
|
|
803
|
+
if (saved !== undefined) {
|
|
804
|
+
process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = saved;
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
test("config override to full model ID is not affected by env vars", () => {
|
|
810
|
+
const saved = process.env.ANTHROPIC_DEFAULT_SONNET_MODEL;
|
|
811
|
+
process.env.ANTHROPIC_DEFAULT_SONNET_MODEL = "bedrock-sonnet";
|
|
812
|
+
try {
|
|
813
|
+
// Config overrides to a direct model string (not an alias)
|
|
814
|
+
const config = makeConfig({ builder: "claude-3-5-sonnet-20241022" });
|
|
815
|
+
const result = resolveModel(config, baseManifest, "builder", "haiku");
|
|
816
|
+
expect(result).toEqual({ model: "claude-3-5-sonnet-20241022" });
|
|
817
|
+
} finally {
|
|
818
|
+
if (saved === undefined) {
|
|
819
|
+
delete process.env.ANTHROPIC_DEFAULT_SONNET_MODEL;
|
|
820
|
+
} else {
|
|
821
|
+
process.env.ANTHROPIC_DEFAULT_SONNET_MODEL = saved;
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
test("config override to alias also expands via env var", () => {
|
|
827
|
+
const saved = process.env.ANTHROPIC_DEFAULT_OPUS_MODEL;
|
|
828
|
+
process.env.ANTHROPIC_DEFAULT_OPUS_MODEL = "bedrock-opus-id";
|
|
829
|
+
try {
|
|
830
|
+
const config = makeConfig({ scout: "opus" });
|
|
831
|
+
const result = resolveModel(config, baseManifest, "scout", "haiku");
|
|
832
|
+
expect(result).toEqual({ model: "bedrock-opus-id" });
|
|
833
|
+
} finally {
|
|
834
|
+
if (saved === undefined) {
|
|
835
|
+
delete process.env.ANTHROPIC_DEFAULT_OPUS_MODEL;
|
|
836
|
+
} else {
|
|
837
|
+
process.env.ANTHROPIC_DEFAULT_OPUS_MODEL = saved;
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
});
|
|
841
|
+
});
|
|
842
|
+
|
|
676
843
|
describe("resolveProviderEnv", () => {
|
|
677
844
|
test("returns null for unknown provider", () => {
|
|
678
845
|
const result = resolveProviderEnv("unknown", "some/model", {});
|
package/src/agents/manifest.ts
CHANGED
|
@@ -34,6 +34,27 @@ interface RawManifest {
|
|
|
34
34
|
|
|
35
35
|
const MODEL_ALIASES = new Set(["sonnet", "opus", "haiku"]);
|
|
36
36
|
|
|
37
|
+
// Env var mapping: alias → ANTHROPIC_DEFAULT_{ALIAS}_MODEL
|
|
38
|
+
const ALIAS_ENV_VARS: Record<string, string> = {
|
|
39
|
+
haiku: "ANTHROPIC_DEFAULT_HAIKU_MODEL",
|
|
40
|
+
sonnet: "ANTHROPIC_DEFAULT_SONNET_MODEL",
|
|
41
|
+
opus: "ANTHROPIC_DEFAULT_OPUS_MODEL",
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Expand a model alias via its corresponding ANTHROPIC_DEFAULT_{ALIAS}_MODEL env var.
|
|
46
|
+
* Returns the env var value if set, otherwise the original alias.
|
|
47
|
+
*/
|
|
48
|
+
export function expandAliasFromEnv(
|
|
49
|
+
alias: string,
|
|
50
|
+
env: Record<string, string | undefined> = process.env as Record<string, string | undefined>,
|
|
51
|
+
): string {
|
|
52
|
+
const envVar = ALIAS_ENV_VARS[alias];
|
|
53
|
+
if (!envVar) return alias;
|
|
54
|
+
const value = env[envVar];
|
|
55
|
+
return value?.trim() || alias;
|
|
56
|
+
}
|
|
57
|
+
|
|
37
58
|
/**
|
|
38
59
|
* Validate that a raw parsed object conforms to the AgentDefinition shape.
|
|
39
60
|
* Returns a list of error messages for any violations.
|
|
@@ -333,9 +354,9 @@ export function resolveModel(
|
|
|
333
354
|
const configModel = config.models[role];
|
|
334
355
|
const rawModel = configModel ?? manifest.agents[role]?.model ?? fallback;
|
|
335
356
|
|
|
336
|
-
// Simple alias —
|
|
357
|
+
// Simple alias — expand via env var if set (e.g. ANTHROPIC_DEFAULT_SONNET_MODEL)
|
|
337
358
|
if (MODEL_ALIASES.has(rawModel)) {
|
|
338
|
-
return { model: rawModel };
|
|
359
|
+
return { model: expandAliasFromEnv(rawModel) };
|
|
339
360
|
}
|
|
340
361
|
|
|
341
362
|
// Provider-prefixed: split on first "/" to get provider name and model ID
|