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