1import { describe, test, expect, beforeAll, afterAll } from "bun:test";
2import { AgentError } from "../src/util/errors.js";
3import { expandEnvVars, resolveConfigValue, resolveHeaders } from "../src/util/env.js";
4import { buildGetApiKey } from "../src/agent/runner.js";
5import type { RumiloConfig } from "../src/config/schema.js";
6
7const stubConfig: RumiloConfig = {
8 defaults: { model: "anthropic:test", cleanup: true },
9 web: { model: "anthropic:test" },
10 repo: { model: "anthropic:test", default_depth: 1, blob_limit: "5m" },
11};
12
13function customModel(provider: string, apiKey?: string): RumiloConfig {
14 return {
15 ...stubConfig,
16 custom_models: {
17 mymodel: {
18 id: "m1",
19 name: "M1",
20 api: "openai-completions" as any,
21 provider,
22 base_url: "http://localhost:8000/v1",
23 reasoning: false,
24 input: ["text"],
25 cost: { input: 0, output: 0 },
26 context_window: 8192,
27 max_tokens: 4096,
28 ...(apiKey ? { api_key: apiKey } : {}),
29 },
30 },
31 };
32}
33
34describe("AgentError", () => {
35 test("has correct name, code, and inherits from Error", () => {
36 const err = new AgentError("boom");
37 expect(err).toBeInstanceOf(Error);
38 expect(err.name).toBe("AgentError");
39 expect(err.code).toBe("AGENT_ERROR");
40 expect(err.message).toBe("boom");
41 });
42});
43
44describe("resolveConfigValue", () => {
45 const saved: Record<string, string | undefined> = {};
46
47 beforeAll(() => {
48 saved["RUMILO_TEST_KEY"] = process.env["RUMILO_TEST_KEY"];
49 process.env["RUMILO_TEST_KEY"] = "resolved-value";
50 });
51
52 afterAll(() => {
53 if (saved["RUMILO_TEST_KEY"] === undefined) delete process.env["RUMILO_TEST_KEY"];
54 else process.env["RUMILO_TEST_KEY"] = saved["RUMILO_TEST_KEY"];
55 });
56
57 test("resolves bare env var name", () => {
58 expect(resolveConfigValue("RUMILO_TEST_KEY")).toBe("resolved-value");
59 });
60
61 test("resolves $VAR reference", () => {
62 expect(resolveConfigValue("$RUMILO_TEST_KEY")).toBe("resolved-value");
63 });
64
65 test("resolves ${VAR} reference", () => {
66 expect(resolveConfigValue("${RUMILO_TEST_KEY}")).toBe("resolved-value");
67 });
68
69 test("treats unknown name as literal", () => {
70 expect(resolveConfigValue("sk-literal-key-12345")).toBe("sk-literal-key-12345");
71 });
72
73 test("returns undefined for unknown $VAR", () => {
74 expect(resolveConfigValue("$RUMILO_NONEXISTENT_XYZ")).toBeUndefined();
75 });
76
77 test("executes shell commands with ! prefix", () => {
78 expect(resolveConfigValue("!echo hello")).toBe("hello");
79 });
80
81 test("returns undefined for failing shell command", () => {
82 expect(resolveConfigValue("!false")).toBeUndefined();
83 });
84});
85
86describe("expandEnvVars", () => {
87 const saved: Record<string, string | undefined> = {};
88
89 beforeAll(() => {
90 saved["FOO"] = process.env["FOO"];
91 saved["BAR"] = process.env["BAR"];
92 process.env["FOO"] = "hello";
93 process.env["BAR"] = "world";
94 });
95
96 afterAll(() => {
97 if (saved["FOO"] === undefined) delete process.env["FOO"];
98 else process.env["FOO"] = saved["FOO"];
99 if (saved["BAR"] === undefined) delete process.env["BAR"];
100 else process.env["BAR"] = saved["BAR"];
101 });
102
103 test("expands $VAR", () => {
104 expect(expandEnvVars("Bearer $FOO")).toBe("Bearer hello");
105 });
106
107 test("expands ${VAR}", () => {
108 expect(expandEnvVars("Bearer ${FOO}")).toBe("Bearer hello");
109 });
110
111 test("expands multiple vars", () => {
112 expect(expandEnvVars("$FOO-$BAR")).toBe("hello-world");
113 });
114
115 test("missing var becomes empty string", () => {
116 expect(expandEnvVars("key=$NONEXISTENT_RUMILO_VAR_XYZ")).toBe("key=");
117 });
118
119 test("string without vars is unchanged", () => {
120 expect(expandEnvVars("plain text")).toBe("plain text");
121 });
122});
123
124describe("resolveHeaders", () => {
125 const saved: Record<string, string | undefined> = {};
126
127 beforeAll(() => {
128 saved["RUMILO_HDR_KEY"] = process.env["RUMILO_HDR_KEY"];
129 process.env["RUMILO_HDR_KEY"] = "hdr-value";
130 });
131
132 afterAll(() => {
133 if (saved["RUMILO_HDR_KEY"] === undefined) delete process.env["RUMILO_HDR_KEY"];
134 else process.env["RUMILO_HDR_KEY"] = saved["RUMILO_HDR_KEY"];
135 });
136
137 test("returns undefined for undefined input", () => {
138 expect(resolveHeaders(undefined)).toBeUndefined();
139 });
140
141 test("resolves header values via resolveConfigValue", () => {
142 const result = resolveHeaders({ "X-Key": "RUMILO_HDR_KEY" });
143 expect(result).toEqual({ "X-Key": "hdr-value" });
144 });
145
146 test("drops entries that resolve to undefined", () => {
147 const result = resolveHeaders({ "X-Key": "$RUMILO_NONEXISTENT_XYZ" });
148 expect(result).toBeUndefined();
149 });
150});
151
152describe("buildGetApiKey", () => {
153 const saved: Record<string, string | undefined> = {};
154
155 beforeAll(() => {
156 saved["ANTHROPIC_API_KEY"] = process.env["ANTHROPIC_API_KEY"];
157 saved["CUSTOM_KEY"] = process.env["CUSTOM_KEY"];
158 process.env["ANTHROPIC_API_KEY"] = "sk-ant-test";
159 process.env["CUSTOM_KEY"] = "sk-custom-test";
160 });
161
162 afterAll(() => {
163 for (const [k, v] of Object.entries(saved)) {
164 if (v === undefined) delete process.env[k];
165 else process.env[k] = v;
166 }
167 });
168
169 test("falls back to pi-ai env var lookup for built-in providers", () => {
170 const getKey = buildGetApiKey(stubConfig);
171 expect(getKey("anthropic")).toBe("sk-ant-test");
172 });
173
174 test("returns undefined for unknown provider with no config", () => {
175 const getKey = buildGetApiKey(stubConfig);
176 expect(getKey("unknown-provider")).toBeUndefined();
177 });
178
179 test("resolves literal api_key from custom model", () => {
180 const config = customModel("myprovider", "sk-literal-key");
181 const getKey = buildGetApiKey(config);
182 expect(getKey("myprovider")).toBe("sk-literal-key");
183 });
184
185 test("resolves api_key via env var name", () => {
186 const config = customModel("myprovider", "CUSTOM_KEY");
187 const getKey = buildGetApiKey(config);
188 expect(getKey("myprovider")).toBe("sk-custom-test");
189 });
190
191 test("resolves api_key via $VAR reference", () => {
192 const config = customModel("myprovider", "$CUSTOM_KEY");
193 const getKey = buildGetApiKey(config);
194 expect(getKey("myprovider")).toBe("sk-custom-test");
195 });
196
197 test("resolves api_key via shell command", () => {
198 const config = customModel("myprovider", "!echo shell-key");
199 const getKey = buildGetApiKey(config);
200 expect(getKey("myprovider")).toBe("shell-key");
201 });
202
203 test("custom model provider doesn't shadow built-in provider lookup", () => {
204 const config = customModel("other-provider", "sk-other");
205 const getKey = buildGetApiKey(config);
206 expect(getKey("anthropic")).toBe("sk-ant-test");
207 });
208
209 test("falls back to env var lookup when custom model has no api_key", () => {
210 const config = customModel("anthropic");
211 const getKey = buildGetApiKey(config);
212 expect(getKey("anthropic")).toBe("sk-ant-test");
213 });
214});