spb/khaelor Public
KHAELOR — a terminal-native autonomous engineering agent powered by Anthropic.
TypeScript 82.9%
HTML 14.9%
CSS 1.1%
JavaScript 0.7%
1/**2 * KHAELOR3 * File: tests/config/config.test.ts4 * Description: Config tests — schema validation, precedence merge, and API-key redaction.5 *6 * Author: Simon-Pierre Boucher7 * Contact: contact@spboucher.ai8 */910import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";11import { tmpdir } from "node:os";12import { join } from "node:path";13import { inspect } from "node:util";14import { afterEach, beforeEach, describe, expect, it } from "vitest";15import { DEFAULT_CONFIG, validatePartialConfig } from "../../src/config/schema.js";16import { loadConfig, REDACTED, ResolvedConfig } from "../../src/config/loader.js";1718const SECRET = "sk-ant-super-secret-key-000";1920describe("validatePartialConfig", () => {21 it("accepts a valid partial config", () => {22 const r = validatePartialConfig(23 {24 model: "claude-x",25 thinking: "off",26 maxOutputTokens: 4096,27 permissions: { "file.read": "allow", "process.execute": "ask" },28 },29 "test",30 );31 expect(r.ok).toBe(true);32 if (r.ok) {33 expect(r.value.model).toBe("claude-x");34 expect(r.value.permissions).toEqual({ "file.read": "allow", "process.execute": "ask" });35 }36 });3738 it("accepts nested and explicit-rules permission forms (PERMISSION_MODEL.md §4.1)", () => {39 const r = validatePartialConfig(40 {41 permissions: {42 "file.read": "allow",43 "process.execute": { "git status": "allow", "git push *": "allow", "*": "ask" },44 rules: [45 { capability: "file.write.outsideProject", pattern: "/tmp/*", action: "allow" },46 { capability: "network.access", action: "deny" },47 ],48 },49 },50 "test",51 );52 expect(r.ok).toBe(true);53 if (r.ok) {54 expect(r.value.permissions).toEqual({55 "file.read": "allow",56 "process.execute": { "git status": "allow", "git push *": "allow", "*": "ask" },57 rules: [58 { capability: "file.write.outsideProject", pattern: "/tmp/*", action: "allow" },59 { capability: "network.access", action: "deny" },60 ],61 });62 }63 });6465 it("rejects malformed nested and rules permission forms", () => {66 expect(validatePartialConfig({ permissions: { "x.y": { p: "maybe" } } }, "t").ok).toBe(false);67 expect(validatePartialConfig({ permissions: { rules: "nope" } }, "t").ok).toBe(false);68 expect(validatePartialConfig({ permissions: { rules: [null] } }, "t").ok).toBe(false);69 expect(70 validatePartialConfig({ permissions: { rules: [{ capability: "", action: "allow" }] } }, "t")71 .ok,72 ).toBe(false);73 expect(74 validatePartialConfig({ permissions: { rules: [{ capability: "x", action: "maybe" }] } }, "t")75 .ok,76 ).toBe(false);77 expect(78 validatePartialConfig(79 { permissions: { rules: [{ capability: "x", pattern: 1, action: "allow" }] } },80 "t",81 ).ok,82 ).toBe(false);83 });8485 it("rejects non-object roots and bad field types", () => {86 expect(validatePartialConfig("nope", "t").ok).toBe(false);87 expect(validatePartialConfig(null, "t").ok).toBe(false);88 expect(validatePartialConfig([], "t").ok).toBe(false);89 expect(validatePartialConfig({ model: 42 }, "t").ok).toBe(false);90 expect(validatePartialConfig({ model: "" }, "t").ok).toBe(false);91 expect(validatePartialConfig({ thinking: "sometimes" }, "t").ok).toBe(false);92 expect(validatePartialConfig({ maxOutputTokens: -1 }, "t").ok).toBe(false);93 expect(validatePartialConfig({ maxOutputTokens: 1.5 }, "t").ok).toBe(false);94 expect(validatePartialConfig({ permissions: { x: "maybe" } }, "t").ok).toBe(false);95 expect(validatePartialConfig({ permissions: [] }, "t").ok).toBe(false);96 });9798 it("ignores unknown fields (forward compatibility)", () => {99 const r = validatePartialConfig({ futureFeature: true, model: "m" }, "t");100 expect(r.ok).toBe(true);101 if (r.ok) expect("futureFeature" in r.value).toBe(false);102 });103104 it("rejects secret fields in config files without echoing the value", () => {105 const r = validatePartialConfig({ apiKey: SECRET }, "t");106 expect(r.ok).toBe(false);107 if (!r.ok) {108 expect(r.error.message).toContain("ANTHROPIC_API_KEY");109 expect(r.error.message).not.toContain(SECRET);110 expect(JSON.stringify(r.error.details)).not.toContain(SECRET);111 }112 });113});114115describe("loadConfig precedence (flags > project > user > env > defaults)", () => {116 let root: string;117 let userDir: string;118 let projectDir: string;119120 beforeEach(async () => {121 root = await mkdtemp(join(tmpdir(), "khaelor-config-"));122 userDir = join(root, "user-home", ".khaelor");123 projectDir = join(root, "project");124 await mkdir(userDir, { recursive: true });125 await mkdir(join(projectDir, ".khaelor"), { recursive: true });126 });127128 afterEach(async () => {129 await rm(root, { recursive: true, force: true });130 });131132 const writeUser = (config: unknown) =>133 writeFile(join(userDir, "config.json"), JSON.stringify(config));134 const writeProject = (config: unknown) =>135 writeFile(join(projectDir, ".khaelor", "config.json"), JSON.stringify(config));136 const load = (flags?: { model?: string; auxModel?: string; thinking?: string; maxOutputTokens?: number }, env: Record<string, string | undefined> = {}) =>137 loadConfig(138 flags139 ? { flags, cwd: projectDir, env, userConfigDir: userDir }140 : { cwd: projectDir, env, userConfigDir: userDir },141 );142143 it("returns defaults when nothing is configured", async () => {144 const config = await load();145 expect(config.model).toBe(DEFAULT_CONFIG.model);146 expect(config.auxModel).toBe(DEFAULT_CONFIG.auxModel);147 expect(config.thinking).toBe(DEFAULT_CONFIG.thinking);148 expect(config.maxOutputTokens).toBe(DEFAULT_CONFIG.maxOutputTokens);149 expect(config.apiKey).toBeNull();150 expect(config.hasApiKey).toBe(false);151 });152153 it("user config overrides defaults", async () => {154 await writeUser({ model: "user-model", maxOutputTokens: 8000 });155 const config = await load();156 expect(config.model).toBe("user-model");157 expect(config.maxOutputTokens).toBe(8000);158 expect(config.thinking).toBe(DEFAULT_CONFIG.thinking); // untouched159 });160161 it("project config overrides user config", async () => {162 await writeUser({ model: "user-model", thinking: "off" });163 await writeProject({ model: "project-model" });164 const config = await load();165 expect(config.model).toBe("project-model");166 expect(config.thinking).toBe("off"); // user tier survives where project is silent167 });168169 it("CLI flags override everything", async () => {170 await writeUser({ model: "user-model" });171 await writeProject({ model: "project-model", maxOutputTokens: 2000 });172 const config = await load({ model: "flag-model" });173 expect(config.model).toBe("flag-model");174 expect(config.maxOutputTokens).toBe(2000); // project tier survives where flags are silent175 expect(config.sources[0]).toBe("cli-flags");176 });177178 it("permission maps merge per capability across tiers", async () => {179 await writeUser({ permissions: { "file.read": "allow", "process.execute": "ask" } });180 await writeProject({ permissions: { "process.execute": "allow" } });181 const config = await load();182 expect(config.permissions).toEqual({ "file.read": "allow", "process.execute": "allow" });183 });184185 it("concatenates explicit rules arrays across tiers (user first, project last)", async () => {186 await writeUser({187 permissions: { rules: [{ capability: "network.access", pattern: "curl *", action: "allow" }] },188 });189 await writeProject({190 permissions: { rules: [{ capability: "process.execute", pattern: "npm *", action: "allow" }] },191 });192 const config = await load();193 expect(config.permissions["rules"]).toEqual([194 { capability: "network.access", pattern: "curl *", action: "allow" },195 { capability: "process.execute", pattern: "npm *", action: "allow" },196 ]);197 });198199 it("reads the API key from ANTHROPIC_API_KEY only", async () => {200 const config = await load(undefined, { ANTHROPIC_API_KEY: SECRET });201 expect(config.apiKey).toBe(SECRET);202 expect(config.hasApiKey).toBe(true);203 });204205 it("rejects invalid JSON and invalid flag values with typed errors", async () => {206 await writeProject("{ not json");207 await expect(load()).rejects.toMatchObject({ code: "config-invalid" });208 await rm(join(projectDir, ".khaelor", "config.json"));209 await expect(load({ thinking: "banana" })).rejects.toMatchObject({ code: "config-invalid" });210 });211212 it("rejects config files that try to carry an API key", async () => {213 await writeProject({ apiKey: SECRET });214 await expect(load()).rejects.toMatchObject({ code: "config-invalid" });215 });216});217218describe("ResolvedConfig redaction", () => {219 const make = () =>220 new ResolvedConfig(221 { ...DEFAULT_CONFIG, permissions: { ...DEFAULT_CONFIG.permissions } },222 SECRET,223 ["cli-flags"],224 );225226 it("never exposes the key via JSON.stringify, toString, or util.inspect", () => {227 const config = make();228 expect(JSON.stringify(config)).not.toContain(SECRET);229 expect(JSON.stringify(config)).toContain(REDACTED);230 expect(String(config)).not.toContain(SECRET);231 expect(`${config}`).not.toContain(SECRET);232 expect(inspect(config)).not.toContain(SECRET);233 expect(inspect(config, { showHidden: true, depth: null })).not.toContain(SECRET);234 });235236 it("does not expose the key as an enumerable property", () => {237 const config = make();238 expect(Object.keys(config)).not.toContain("apiKey");239 expect(JSON.parse(JSON.stringify(config)).apiKey).toBe(REDACTED);240 expect(Object.entries(config).some(([, v]) => v === SECRET)).toBe(false);241 });242243 it("still provides the key programmatically for the Anthropic client", () => {244 const config = make();245 expect(config.apiKey).toBe(SECRET);246 });247248 it("shows apiKey: null when absent", () => {249 const config = new ResolvedConfig(250 { ...DEFAULT_CONFIG, permissions: {} },251 null,252 [],253 );254 expect(config.toJSON()["apiKey"]).toBeNull();255 expect(config.hasApiKey).toBe(false);256 });257});258