import { test, expect } from "vitest"; import { spawn } from "node:child_process"; import { chmodSync, mkdtempSync, writeFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import path from "node:path"; import { createPiModelLister } from "../src/pi/list-models.js"; import { loadConfig } from "../src/config.js"; const FAKE = path.resolve("../harness/tests/fake_pi/fake_pi_rpc.mjs"); function scriptWith(models: unknown[]): string { const dir = mkdtempSync(join(tmpdir(), "tht-models-")); const file = join(dir, "models.json"); writeFileSync(file, JSON.stringify({ available_models: models })); return file; } test("createPiModelLister returns mapped PiModel[] from get_available_models", async () => { const script = scriptWith([ { provider: "zai", id: "glm-5.2", name: "GLM 5.2", reasoning: true, extra: "ignored" }, { provider: "anthropic", id: "claude-opus-4-8", name: "Claude Opus 4.8", reasoning: true }, ]); try { const lister = createPiModelLister(loadConfig({ THT_HARNESS_DIR: "../harness" }), { spawnFn: () => spawn("node", [FAKE, script]) as any, }); const models = await lister(); expect(models).toEqual([ { provider: "zai", id: "glm-5.2", name: "GLM 5.2", reasoning: true }, { provider: "anthropic", id: "claude-opus-4-8", name: "Claude Opus 4.8", reasoning: true }, ]); } finally { rmSync(path.dirname(script), { recursive: true, force: true }); } }); test("createPiModelLister caches within ttl (spawns once for two calls)", async () => { const script = scriptWith([{ provider: "zai", id: "glm-5.2", name: "GLM 5.2", reasoning: true }]); try { let spawns = 0; const lister = createPiModelLister(loadConfig({ THT_HARNESS_DIR: "../harness" }), { spawnFn: () => { spawns++; return spawn("node", [FAKE, script]) as any; }, ttlMs: 10_000, nowMs: () => 1000, }); await lister(); await lister(); expect(spawns).toBe(1); } finally { rmSync(path.dirname(script), { recursive: true, force: true }); } }); test("production model-list spawn preserves PATH and passes the portable data root", async () => { const script = scriptWith([]); const calls: any[][] = []; const previousPath = process.env.PATH; process.env.PATH = "/usr/local/bin:/usr/bin"; try { const lister = createPiModelLister(loadConfig({ THT_HARNESS_DIR: "/app/harness", PI_BIN: "/usr/local/bin/pi", THT_DATA_ROOT: "/data", }), { spawnFn: (...args: any[]) => { calls.push(args); return spawn("node", [FAKE, script]) as any; }, }); await lister(); expect(calls[0][0]).toBe("/usr/local/bin/pi"); expect(calls[0][1]).toEqual(["--mode", "rpc"]); expect(calls[0][2]).toMatchObject({ cwd: "/app/harness", env: expect.objectContaining({ PATH: "/usr/local/bin:/usr/bin", THT_DATA_ROOT: "/data" }), }); } finally { if (previousPath === undefined) delete process.env.PATH; else process.env.PATH = previousPath; rmSync(path.dirname(script), { recursive: true, force: true }); } }); test("model-list spawn scrubs ambient provider credentials and generic secret metadata", async () => { const script = scriptWith([]); const calls: any[][] = []; const previousDataRoot = process.env.THT_DATA_ROOT; const previousCredential = process.env.PI_PROVIDER_API_KEY; process.env.THT_DATA_ROOT = "/ambient-must-not-leak"; process.env.PI_PROVIDER_API_KEY = "must-not-leak"; process.env.OPENAI_API_KEY = "must-not-leak"; process.env.AWS_SECRET_ACCESS_KEY = "must-not-leak"; process.env.CLOUDFLARE_ACCOUNT_ID = "must-not-leak"; try { const lister = createPiModelLister(loadConfig({ PI_BIN: "/usr/local/bin/pi" }), { spawnFn: (...args: any[]) => { calls.push(args); return spawn("node", [FAKE, script]) as any; }, }); await lister(); expect(calls[0][2].env).not.toHaveProperty("THT_DATA_ROOT"); expect(calls[0][2].env).not.toHaveProperty("PI_PROVIDER_API_KEY"); expect(calls[0][2].env).not.toHaveProperty("OPENAI_API_KEY"); expect(calls[0][2].env).not.toHaveProperty("AWS_SECRET_ACCESS_KEY"); expect(calls[0][2].env).not.toHaveProperty("CLOUDFLARE_ACCOUNT_ID"); } finally { if (previousDataRoot === undefined) delete process.env.THT_DATA_ROOT; else process.env.THT_DATA_ROOT = previousDataRoot; if (previousCredential === undefined) delete process.env.PI_PROVIDER_API_KEY; else process.env.PI_PROVIDER_API_KEY = previousCredential; delete process.env.OPENAI_API_KEY; delete process.env.AWS_SECRET_ACCESS_KEY; delete process.env.CLOUDFLARE_ACCOUNT_ID; rmSync(path.dirname(script), { recursive: true, force: true }); } }); test("model-list spawn loads only the selected canonical provider credential", async () => { const script = scriptWith([]); const secret = join(path.dirname(script), "model-key"); writeFileSync(secret, "selected-secret", { mode: 0o600 }); chmodSync(secret, 0o600); const calls: any[][] = []; const lister = createPiModelLister(loadConfig({ PI_PROVIDER: "Gemini", THT_MODEL_API_KEY_FILE: secret, }), { spawnFn: (...args: any[]) => { calls.push(args); return spawn("node", [FAKE, script]) as any; }, }); try { await lister(); expect(calls[0][2].env.GEMINI_API_KEY).toBe("selected-secret"); expect(calls[0][2].env).not.toHaveProperty("THT_MODEL_API_KEY_FILE"); expect(JSON.stringify(calls[0].slice(0, 2))).not.toContain("selected-secret"); } finally { rmSync(path.dirname(script), { recursive: true, force: true }); } }); test("model-list spawn uses the same single secret bundle as sessions", async () => { const script = scriptWith([]); const bundle = join(path.dirname(script), "bundle"); writeFileSync(bundle, "THT_MODEL_API_KEY=selected-bundle-secret\n", { mode: 0o600 }); const calls: any[][] = []; const lister = createPiModelLister(loadConfig({ PI_PROVIDER: "openai", THT_SECRETS_FILE: bundle, }), { spawnFn: (...args: any[]) => { calls.push(args); return spawn("node", [FAKE, script]) as any; }, }); try { await lister(); expect(calls[0][2].env.OPENAI_API_KEY).toBe("selected-bundle-secret"); expect(calls[0][2].env).not.toHaveProperty("THT_SECRETS_FILE"); } finally { rmSync(path.dirname(script), { recursive: true, force: true }); } }); test.each(["amazon-bedrock", "azure-openai-responses", "cloudflare-workers-ai", "cloudflare-ai-gateway"])( "model listing rejects compound provider %s before spawning Pi", async (provider) => { const script = scriptWith([]); const secret = join(path.dirname(script), "model-key"); writeFileSync(secret, "selected-secret", { mode: 0o600 }); let spawns = 0; const lister = createPiModelLister(loadConfig({ PI_PROVIDER: provider, THT_MODEL_API_KEY_FILE: secret, }), { spawnFn: () => { spawns += 1; throw new Error("must not spawn"); }, }); try { await expect(lister()).rejects.toThrow( "compound credential bundles are unsupported by THT_MODEL_API_KEY_FILE; dedicated provider configuration is required", ); expect(spawns).toBe(0); } finally { rmSync(path.dirname(script), { recursive: true, force: true }); } }, );