SPB Git

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%
9.9 KB · 258 lines typescript
Raw Blame History
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