import { test, expect, vi } from "vitest"; import { spawn } from "node:child_process"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { EventEmitter } from "node:events"; import { chmodSync, writeFileSync } from "node:fs"; import { PiProcessManager } from "../src/pi/pi-process-manager.js"; import { loadConfig } from "../src/config.js"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const FAKE = path.resolve(__dirname, "../../harness/tests/fake_pi/fake_pi_rpc.mjs"); const SCRIPT = path.resolve(__dirname, "../../harness/tests/fake_pi/scripts/f1_disambiguation.json"); test("spawnFor avvia un runtime e il bridge emette il widget F1", async () => { const cfg = loadConfig({ THT_HARNESS_DIR: "../harness" }); const mgr = new PiProcessManager(cfg, { spawnFn: () => spawn("node", [FAKE, SCRIPT]) as any }); const rt = await mgr.spawnFor("2026-06-27-100000-x", {}); const widget = await new Promise((res) => rt.bridge.onClientEvent((e) => e.type === "ui_request" && res(e))); expect(widget.ui_request.widget).toBe("select"); mgr.teardown("2026-06-27-100000-x"); expect(mgr.count()).toBe(0); }); test("l'exit del child rimuove il runtime dalla mappa (exit handler)", async () => { const cfg = loadConfig({ THT_HARNESS_DIR: "../harness" }); const mgr = new PiProcessManager(cfg, { spawnFn: () => spawn("node", [FAKE, SCRIPT]) as any }); const rt = await mgr.spawnFor("exit-test", {}); expect(mgr.count()).toBe(1); // Cause the child to exit on its own and await the 'exit' event (no teardown call). const exited = new Promise((res) => rt.child.on("exit", () => res())); rt.child.kill(); await exited; // Let the manager's registered exit handler run. await new Promise((res) => setImmediate(res)); expect(mgr.count()).toBe(0); expect(mgr.get("exit-test")).toBeUndefined(); }); test("spawnFor sullo STESSO id rifiuta il duplicato senza interrompere il runtime attivo", async () => { const cfg = loadConfig({ THT_HARNESS_DIR: "../harness" }); const mgr = new PiProcessManager(cfg, { spawnFn: () => spawn("node", [FAKE, SCRIPT]) as any }); const first = await mgr.spawnFor("dup-id", {}); expect(mgr.count()).toBe(1); await expect(mgr.spawnFor("dup-id", {})).rejects.toThrow( "session runtime already active: dup-id", ); expect(mgr.count()).toBe(1); expect(mgr.get("dup-id")).toBe(first); mgr.teardown("dup-id"); }); test("l'exit del VECCHIO child non elimina il nuovo runtime (exit identity-checked)", async () => { const cfg = loadConfig({ THT_HARNESS_DIR: "../harness" }); const firstChild = recordingChild(); const secondChild = recordingChild(); const children = [firstChild, secondChild]; const mgr = new PiProcessManager(cfg, { spawnFn: () => children.shift() as any }); const first = mgr.createFor("respawn-id", {}); mgr.teardown("respawn-id"); const second = mgr.createFor("respawn-id", {}); // The old child's exit handler fires after the respawn; it must NOT evict `second`. firstChild.emit("exit", 0); expect(mgr.get("respawn-id")).toBe(second); expect(mgr.count()).toBe(1); void first; mgr.teardown("respawn-id"); }); test("oltre maxPiProcesses solleva errore", async () => { const cfg = { ...loadConfig({}), maxPiProcesses: 1 }; const mgr = new PiProcessManager(cfg, { spawnFn: () => spawn("node", [FAKE, SCRIPT]) as any }); await mgr.spawnFor("a", {}); await expect(mgr.spawnFor("b", {})).rejects.toThrow(/max/i); mgr.teardown("a"); }); function recordingChild() { const ch: any = new EventEmitter(); ch.stdout = new EventEmitter(); ch.stderr = new EventEmitter(); ch._writes = [] as string[]; ch.stdin = { write: (d: any) => { ch._writes.push(String(d)); return true; } }; ch.kill = () => {}; return ch; } test("un exit INATTESO del child notifica il client (info error + agent_end)", async () => { const cfg = loadConfig({}); const child = recordingChild(); const mgr = new PiProcessManager(cfg, { spawnFn: () => child as any }); const rt = await mgr.spawnFor("crash-id", {}); const seen: any[] = []; rt.bridge.onClientEvent((e) => seen.push(e)); child.emit("exit", 137); expect(seen).toEqual([ { type: "info", level: "error", text: expect.stringContaining("137") }, { type: "system_event", event: "session_failed" }, { type: "system_event", event: "agent_end" }, ]); expect(mgr.count()).toBe(0); }); test("l'exit dopo teardown NON emette eventi al client (uscita attesa)", async () => { const cfg = loadConfig({}); const child = recordingChild(); const mgr = new PiProcessManager(cfg, { spawnFn: () => child as any }); const rt = await mgr.spawnFor("stop-id", {}); const seen: any[] = []; rt.bridge.onClientEvent((e) => seen.push(e)); mgr.teardown("stop-id"); child.emit("exit", 0); expect(seen).toEqual([]); }); test("spawnFor resume mode sends /riprendi-sessione ", async () => { const cfg = loadConfig({}); // no provider/model/thinking -> no rpc.request handshakes const child = recordingChild(); const mgr = new PiProcessManager(cfg, { spawnFn: () => child as any }); await mgr.spawnFor("sid-9", { mode: "resume" }); expect(child._writes.join("")).toContain("/riprendi-sessione sid-9"); expect(child._writes.join("")).not.toContain("/nuova-domanda"); mgr.teardown("sid-9"); }); test("spawnFor default (new) mode sends /nuova-domanda", async () => { const cfg = loadConfig({}); const child = recordingChild(); const mgr = new PiProcessManager(cfg, { spawnFn: () => child as any }); await mgr.spawnFor("sid-10", {}); expect(child._writes.join("")).toContain("/nuova-domanda"); mgr.teardown("sid-10"); }); test("spawnFor new mode forwards the real question instead of kickoff", async () => { const cfg = loadConfig({}); const child = recordingChild(); const mgr = new PiProcessManager(cfg, { spawnFn: () => child as any }); await mgr.spawnFor("sid-question", { question: "pazienti con cardioversione e ILR" }); const prompt = JSON.parse(child._writes.at(-1)!); expect(prompt.message).toBe('/nuova-domanda "pazienti con cardioversione e ILR"'); expect(prompt.message).not.toContain("kickoff"); mgr.teardown("sid-question"); }); test("createFor does not prompt until start is called", async () => { const cfg = loadConfig({}); const child = recordingChild(); const mgr = new PiProcessManager(cfg, { spawnFn: () => child as any }); const rt = mgr.createFor("sid-deferred", { question: "q" }); expect(child._writes).toEqual([]); await mgr.configure(rt, {}); expect(child._writes).toEqual([]); mgr.start("sid-deferred", rt, { question: "q" }); expect(JSON.parse(child._writes.at(-1)!).message).toBe('/nuova-domanda "q"'); mgr.teardown("sid-deferred"); }); test("production spawn uses explicit Pi path and passes portable data root without rewriting PATH", async () => { vi.stubEnv("PATH", "/usr/local/bin:/usr/bin"); vi.stubEnv("PI_PROVIDER_API_KEY", "provider-secret"); vi.stubEnv("NODE_EXTRA_CA_CERTS", "/certs/company-ca.pem"); const calls: any[][] = []; const child = recordingChild(); child.stderr.resume = () => {}; const spawnFn = (...args: any[]) => { calls.push(args); return child as any; }; const cfg = loadConfig({ THT_HARNESS_DIR: "/app/harness", PI_BIN: "/usr/local/bin/pi", THT_DATA_ROOT: "/data", }); const mgr = new PiProcessManager(cfg, { spawnFn }); try { await mgr.spawnFor("portable-session", { author: "user@example.test" }); const [bin, args, options] = calls[0]; expect(bin).toBe("/usr/local/bin/pi"); expect(args).toEqual(["--mode", "rpc"]); expect(options.cwd).toBe("/app/harness"); expect(options.env).toMatchObject({ PATH: "/usr/local/bin:/usr/bin", NODE_EXTRA_CA_CERTS: "/certs/company-ca.pem", THT_DATA_ROOT: "/data", THT_SESSION: "portable-session", THT_AUTHOR: "user@example.test", }); expect(options.env).not.toHaveProperty("PI_PROVIDER_API_KEY"); } finally { mgr.teardown("portable-session"); vi.unstubAllEnvs(); } }); test("session Pi spawn omits ambient THT_DATA_ROOT when config does not provide one", async () => { vi.stubEnv("THT_DATA_ROOT", "/ambient-must-not-leak"); vi.stubEnv("PI_PROVIDER_API_KEY", "still-inherited"); const calls: any[][] = []; const child = recordingChild(); child.stderr.resume = () => {}; const mgr = new PiProcessManager(loadConfig({ PI_BIN: "/usr/local/bin/pi" }), { spawnFn: (...args: any[]) => { calls.push(args); return child as any; }, }); try { await mgr.spawnFor("no-data-root", {}); expect(calls[0][2].env).not.toHaveProperty("THT_DATA_ROOT"); expect(calls[0][2].env).not.toHaveProperty("PI_PROVIDER_API_KEY"); } finally { mgr.teardown("no-data-root"); vi.unstubAllEnvs(); } }); test.each([ ["anthropic", "ANTHROPIC_API_KEY"], ["OpenAI", "OPENAI_API_KEY"], ["gemini", "GEMINI_API_KEY"], ["google", "GEMINI_API_KEY"], ["deepseek", "DEEPSEEK_API_KEY"], ["zai", "ZAI_API_KEY"], ["openrouter", "OPENROUTER_API_KEY"], ])("injects the generic file credential only as %s provider env", async (provider, expectedName) => { const secret = path.resolve(__dirname, `.model-key-${process.pid}-${provider}`); writeFileSync(secret, "provider-secret", { mode: 0o600 }); const calls: any[][] = []; const child = recordingChild(); child.stderr.resume = () => {}; const mgr = new PiProcessManager(loadConfig({ PI_BIN: "/usr/local/bin/pi", THT_MODEL_API_KEY_FILE: secret, }), { spawnFn: (...args: any[]) => { calls.push(args); return child as any; } }); try { await mgr.spawnFor("credential-session", { provider }); const env = calls[0][2].env; expect(env[expectedName]).toBe("provider-secret"); expect(env).not.toHaveProperty("PI_PROVIDER_API_KEY"); expect(env).not.toHaveProperty("THT_MODEL_API_KEY_FILE"); expect(JSON.stringify(calls[0].slice(0, 2))).not.toContain("provider-secret"); } finally { mgr.teardown("credential-session"); await import("node:fs/promises").then((fs) => fs.unlink(secret)); } }); test("session Pi spawn reads the single secret bundle and scrubs its path", async () => { const secret = path.resolve(__dirname, `.bundle-${process.pid}`); writeFileSync(secret, "THT_MODEL_API_KEY=bundle-secret\n", { mode: 0o600 }); chmodSync(secret, 0o600); const calls: any[][] = []; const child = recordingChild(); child.stderr.resume = () => {}; const mgr = new PiProcessManager(loadConfig({ PI_BIN: "/usr/local/bin/pi", THT_SECRETS_FILE: secret, }), { spawnFn: (...args: any[]) => { calls.push(args); return child as any; } }); try { await mgr.spawnFor("bundle-session", { provider: "openai" }); expect(calls[0][2].env.OPENAI_API_KEY).toBe("bundle-secret"); expect(calls[0][2].env).not.toHaveProperty("THT_SECRETS_FILE"); } finally { mgr.teardown("bundle-session"); await import("node:fs/promises").then((fs) => fs.unlink(secret)); } }); test.each([["OpenAI", "openai"], ["gemini", "google"]])( "set_model uses canonical packaged provider ID for %s", async (provider, canonical) => { const secret = path.resolve(__dirname, `.canonical-key-${process.pid}-${provider}`); writeFileSync(secret, "provider-secret", { mode: 0o600 }); const child = recordingChild(); child.stderr.resume = () => {}; child.stdin.write = (data: unknown) => { const request = JSON.parse(String(data)); child._writes.push(String(data)); if (request.id) { queueMicrotask(() => child.stdout.emit("data", `${JSON.stringify({ type: "response", id: request.id, success: true, })}\n`)); } return true; }; const mgr = new PiProcessManager(loadConfig({ THT_MODEL_API_KEY_FILE: secret }), { spawnFn: () => child as any, }); try { await mgr.spawnFor("canonical-provider", { provider, model: "model-id" }); expect(child._writes.join("")).toContain(`\"provider\":\"${canonical}\"`); } finally { mgr.teardown("canonical-provider"); await import("node:fs/promises").then((fs) => fs.unlink(secret)); } }, ); test("local providers spawn without a model key and scrub ambient generic credentials", async () => { vi.stubEnv("PI_PROVIDER_API_KEY", "ambient-secret"); vi.stubEnv("THT_MODEL_API_KEY_FILE", "/ambient/secret-path"); vi.stubEnv("OPENAI_API_KEY", "unselected-provider-secret"); const calls: any[][] = []; const child = recordingChild(); child.stderr.resume = () => {}; const mgr = new PiProcessManager(loadConfig({ PI_BIN: "/usr/local/bin/pi" }), { spawnFn: (...args: any[]) => { calls.push(args); return child as any; }, }); try { await mgr.spawnFor("local-session", { provider: "ollama" }); expect(calls[0][2].env).not.toHaveProperty("PI_PROVIDER_API_KEY"); expect(calls[0][2].env).not.toHaveProperty("THT_MODEL_API_KEY_FILE"); expect(calls[0][2].env).not.toHaveProperty("OPENAI_API_KEY"); } finally { mgr.teardown("local-session"); vi.unstubAllEnvs(); } }); test.each(["amazon-bedrock", "azure-openai-responses", "cloudflare-workers-ai", "cloudflare-ai-gateway"])( "session spawn rejects compound provider %s before spawning Pi", async (provider) => { const secret = path.resolve(__dirname, `.compound-key-${process.pid}-${provider}`); writeFileSync(secret, "provider-secret", { mode: 0o600 }); let spawns = 0; const mgr = new PiProcessManager(loadConfig({ THT_MODEL_API_KEY_FILE: secret }), { spawnFn: () => { spawns += 1; throw new Error("must not spawn"); }, }); try { await expect(mgr.spawnFor("compound-provider", { provider })).rejects.toThrow( "compound credential bundles are unsupported by THT_MODEL_API_KEY_FILE; dedicated provider configuration is required", ); expect(spawns).toBe(0); } finally { await import("node:fs/promises").then((fs) => fs.unlink(secret)); } }, ); test.each(["missing", "permissive", "unreadable", "directory", "symlink", "unsupported"])( "hosted provider credential failure is sanitized: %s", async (kind) => { const target = path.resolve(__dirname, `.bad-model-key-${process.pid}-${kind}`); if (kind === "permissive") { writeFileSync(target, "DO_NOT_LEAK", { mode: 0o644 }); chmodSync(target, 0o644); } else if (kind === "unreadable") { writeFileSync(target, "DO_NOT_LEAK", { mode: 0o000 }); } else if (kind === "directory") { await import("node:fs/promises").then((fs) => fs.mkdir(target)); } else if (kind === "symlink") { const source = `${target}-source`; writeFileSync(source, "DO_NOT_LEAK", { mode: 0o600 }); await import("node:fs/promises").then((fs) => fs.symlink(source, target)); } const cfg = loadConfig({ THT_MODEL_API_KEY_FILE: target }); const mgr = new PiProcessManager(cfg, { spawnFn: () => { throw new Error("spawn must not occur"); } }); try { const provider = kind === "unsupported" ? "unknown-hosted" : "anthropic"; await expect(mgr.spawnFor("bad-secret", { provider })) .rejects.toThrow("model provider credential is unavailable"); } finally { if (kind === "permissive") await import("node:fs/promises").then((fs) => fs.unlink(target)); if (kind === "unreadable") { chmodSync(target, 0o600); await import("node:fs/promises").then((fs) => fs.unlink(target)); } if (kind === "directory") await import("node:fs/promises").then((fs) => fs.rmdir(target)); if (kind === "symlink") { await import("node:fs/promises").then(async (fs) => { await fs.unlink(target); await fs.unlink(`${target}-source`); }); } } }, );