import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, expect, it } from "vitest"; import { sandboxEnv, withProcessEnv } from "../../test/harness"; import { parseNumberSetting, resolveSetting, type SettingDeclaration, } from "./templates/pi-ext-settings.ts"; let root: string | undefined; afterEach(() => { if (root) rmSync(root, { recursive: true, force: true }); root = undefined; }); function resolve( declaration: SettingDeclaration, input: { user?: unknown; project?: unknown; env?: Record; flags?: Record; trusted?: boolean; }, ) { root = mkdtempSync(join(tmpdir(), "pi-ext-settings-")); const env = sandboxEnv(root, { env: input.env }); const write = (dir: string, value: unknown) => { mkdirSync(dir, { recursive: true }); writeFileSync(join(dir, "settings.json"), JSON.stringify(value)); }; if (input.user !== undefined) write(join(env.HOME ?? "", ".pi/agent"), input.user); if (input.project !== undefined) write(join(root, ".pi"), input.project); const cwd = root; return withProcessEnv(env, () => resolveSetting( { getFlag: (name) => input.flags?.[name] }, { cwd, isProjectTrusted: () => input.trusted ?? true }, declaration, ), ); } const block: SettingDeclaration> = { key: "demo", parse: (raw) => { if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return undefined; const value = raw as Record; if ("bad" in value) throw new Error("demo.bad is not allowed"); return value; }, default: {}, }; it("deep-merges objects project over user, replacing arrays and scalars", async () => { const result = await resolve(block, { user: { demo: { a: 1, list: [1, 2], nested: { keep: true, over: "user" } }, }, project: { demo: { list: [3], nested: { over: "project" } } }, }); expect(result).toEqual({ ok: true, source: "project", value: { a: 1, list: [3], nested: { keep: true, over: "project" }, }, }); }); it("keeps __proto__ keys as own properties when merging", async () => { root = mkdtempSync(join(tmpdir(), "pi-ext-settings-")); const env = sandboxEnv(root); const agent = join(env.HOME ?? "", ".pi/agent"); mkdirSync(agent, { recursive: true }); mkdirSync(join(root, ".pi"), { recursive: true }); writeFileSync(join(agent, "settings.json"), '{"demo":{"a":1}}'); writeFileSync( join(root, ".pi", "settings.json"), '{"demo":{"__proto__":{"polluted":true}}}', ); const cwd = root; const result = await withProcessEnv(env, () => resolveSetting( { getFlag: () => undefined }, { cwd, isProjectTrusted: () => true }, block, ), ); if (!result.ok) throw new Error(result.error); expect(Object.hasOwn(result.value, "__proto__")).toBe(true); expect(Object.getPrototypeOf(result.value)).toBe(Object.prototype); expect(({} as Record).polluted).toBeUndefined(); }); it("parses numbers from settings and numeric strings from env or flags only", () => { expect(parseNumberSetting(70, "user")).toBe(70); expect(parseNumberSetting("70", "env")).toBe(70); expect(parseNumberSetting("2.5", "flag")).toBe(2.5); expect(parseNumberSetting("70", "project")).toBeUndefined(); for (const raw of ["", " ", "abc", "Infinity", Number.NaN, true, null]) expect(parseNumberSetting(raw, "env"), String(raw)).toBeUndefined(); }); it("ignores untrusted project objects", async () => { const result = await resolve(block, { user: { demo: { a: 1 } }, project: { demo: { a: 2 } }, trusted: false, }); expect(result).toEqual({ ok: true, source: "user", value: { a: 1 } }); }); it("reports thrown parser messages with the merged source", async () => { const result = await resolve(block, { user: { demo: { a: 1 } }, project: { demo: { bad: true } }, }); expect(result).toEqual({ ok: false, source: "project", error: "Invalid demo from merged project and user settings: demo.bad is not allowed", }); }); it("keeps a non-object project value from falling through or merging", async () => { const result = await resolve(block, { user: { demo: { a: 1 } }, project: { demo: "oops" }, }); expect(result).toEqual({ ok: false, source: "project", error: "Invalid demo from project settings", }); }); it("prefers flag, then env, over settings", async () => { const text: SettingDeclaration = { key: "demo.name", parse: (raw) => (typeof raw === "string" ? raw : undefined), default: "default", flag: { name: "demo-name", type: "string", description: "demo" }, env: "PI_DEMO_NAME", }; const settings = { user: { demo: { name: "user" } } }; expect( await resolve(text, { ...settings, env: { PI_DEMO_NAME: "env" }, flags: { "demo-name": "flag" }, }), ).toEqual({ ok: true, source: "flag", value: "flag" }); expect( await resolve(text, { ...settings, env: { PI_DEMO_NAME: "env" } }), ).toEqual({ ok: true, source: "env", value: "env" }); expect(await resolve(text, {})).toEqual({ ok: true, source: "default", value: "default", }); });