@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.
@@ -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 { createManifestLoader, resolveModel, resolveProviderEnv } from "./manifest.ts";
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", {});
@@ -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 — no provider env needed
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
@@ -34,6 +34,7 @@ export interface DiscoveredAgent {
34
34
  const KNOWN_INSTRUCTION_PATHS = [
35
35
  join(".claude", "CLAUDE.md"), // Claude Code, Pi
36
36
  "AGENTS.md", // Codex (future)
37
+ "GEMINI.md", // Gemini CLI
37
38
  ];
38
39
 
39
40
  /**