Implement approved specification #32 and tickets #33-#37. Keep host authentication server-verified and pin session interaction language. Compile scoped base selectors for browser compatibility and retain full gutters during CSS pruning.
903 lines
37 KiB
TypeScript
903 lines
37 KiB
TypeScript
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, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync,
|
|
} from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { PiProcessManager } from "../src/pi/pi-process-manager.js";
|
|
import type { RuntimeModelCatalog, RuntimeModel } from "../src/models/runtime-model-catalog.js";
|
|
import { loadConfig } from "../src/config.js";
|
|
import {
|
|
PI_MANAGED_CONFIG_ERROR_MESSAGE,
|
|
validateDeclarativePiConfig,
|
|
} from "../src/pi/managed-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");
|
|
|
|
const SAFE_AUTH = '{"deepseek":{"type":"api_key","key":"safe-token"}}\n';
|
|
const SAFE_MODELS = [
|
|
"{",
|
|
' "providers": {',
|
|
' "local-qwen": {"baseUrl":"http://model.invalid/v1","apiKey":"local","models":[{"id":"qwen"}]}',
|
|
" }",
|
|
"}",
|
|
"",
|
|
].join("\n");
|
|
|
|
function writeSafeAgentConfig(agentDir: string): void {
|
|
writeFileSync(path.join(agentDir, "auth.json"), SAFE_AUTH, { mode: 0o600 });
|
|
writeFileSync(path.join(agentDir, "models.json"), SAFE_MODELS, { mode: 0o600 });
|
|
}
|
|
|
|
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<any>((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<void>((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("identity-checked teardown cannot kill a replacement runtime", () => {
|
|
const cfg = loadConfig({ THT_HARNESS_DIR: "../harness" });
|
|
const firstChild = recordingChild();
|
|
const secondChild = recordingChild();
|
|
firstChild.kill = vi.fn();
|
|
secondChild.kill = vi.fn();
|
|
const children = [firstChild, secondChild];
|
|
const mgr = new PiProcessManager(cfg, { spawnFn: () => children.shift() as any });
|
|
const first = mgr.createFor("replace-id", {});
|
|
mgr.teardown("replace-id");
|
|
const second = mgr.createFor("replace-id", {});
|
|
|
|
expect(mgr.teardownIfCurrent("replace-id", first)).toBe(false);
|
|
expect(mgr.get("replace-id")).toBe(second);
|
|
expect(secondChild.kill).not.toHaveBeenCalled();
|
|
|
|
expect(mgr.teardownIfCurrent("replace-id", second)).toBe(true);
|
|
expect(mgr.get("replace-id")).toBeUndefined();
|
|
expect(secondChild.kill).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
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");
|
|
});
|
|
|
|
test("teardownForPrincipal stops only runtimes owned by that user", () => {
|
|
const aliceChild = recordingChild();
|
|
const bobChild = recordingChild();
|
|
aliceChild.kill = vi.fn();
|
|
bobChild.kill = vi.fn();
|
|
const children = [aliceChild, bobChild];
|
|
const mgr = new PiProcessManager(loadConfig({}), { spawnFn: () => children.shift() as any });
|
|
const alice = { issuer: "portal", subject: "alice", roles: ["user"] as const, permissions: ["session.use"] as const, isAdmin: false };
|
|
const bob = { issuer: "portal", subject: "bob", roles: ["user"] as const, permissions: ["session.use"] as const, isAdmin: false };
|
|
mgr.createFor("alice-session", { principal: alice });
|
|
mgr.createFor("bob-session", { principal: bob });
|
|
|
|
expect(mgr.teardownForPrincipal(alice)).toEqual(["alice-session"]);
|
|
|
|
expect(mgr.get("alice-session")).toBeUndefined();
|
|
expect(mgr.get("bob-session")).toBeDefined();
|
|
expect(aliceChild.kill).toHaveBeenCalledOnce();
|
|
expect(bobChild.kill).not.toHaveBeenCalled();
|
|
mgr.teardown("bob-session");
|
|
});
|
|
|
|
test("createFor keeps at most one runtime for the same user", () => {
|
|
const firstChild = recordingChild();
|
|
const secondChild = recordingChild();
|
|
firstChild.kill = vi.fn();
|
|
secondChild.kill = vi.fn();
|
|
const children = [firstChild, secondChild];
|
|
const mgr = new PiProcessManager(loadConfig({}), { spawnFn: () => children.shift() as any });
|
|
const principal = { issuer: "portal", subject: "alice", roles: ["user"] as const, permissions: ["session.use"] as const, isAdmin: false };
|
|
|
|
mgr.createFor("first", { principal });
|
|
const second = mgr.createFor("second", { principal });
|
|
|
|
expect(mgr.count()).toBe(1);
|
|
expect(mgr.get("first")).toBeUndefined();
|
|
expect(mgr.get("second")).toBe(second);
|
|
expect(firstChild.kill).toHaveBeenCalledOnce();
|
|
expect(secondChild.kill).not.toHaveBeenCalled();
|
|
mgr.teardown("second");
|
|
});
|
|
|
|
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("Pi receives the leased workspace runtime config and releases it on direct teardown", () => {
|
|
const child = recordingChild();
|
|
let spawnEnv: NodeJS.ProcessEnv | undefined;
|
|
const release = vi.fn();
|
|
const mgr = new PiProcessManager(loadConfig({}), {
|
|
spawnFn: (_command, _args, options) => {
|
|
spawnEnv = options.env;
|
|
return child as any;
|
|
},
|
|
});
|
|
|
|
mgr.createFor("canonical-runtime", {
|
|
runtimeConfig: { path: "/trusted/runtime-uuid.yaml", release },
|
|
} as any);
|
|
expect(spawnEnv?.THT_CONFIG).toBe("/trusted/runtime-uuid.yaml");
|
|
|
|
// The child deliberately emits neither exit nor close. Ownership cleanup must not depend on it.
|
|
mgr.teardown("canonical-runtime");
|
|
expect(release).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
test("Pi language launch hint comes from the session options and never ambient environment", () => {
|
|
const previous = process.env.THT_INTERACTION_LANGUAGE;
|
|
process.env.THT_INTERACTION_LANGUAGE = "it";
|
|
const environments: NodeJS.ProcessEnv[] = [];
|
|
const mgr = new PiProcessManager(loadConfig({}), {
|
|
spawnFn: (_command, _args, options) => {
|
|
environments.push(options.env);
|
|
return recordingChild() as any;
|
|
},
|
|
});
|
|
try {
|
|
mgr.createFor("explicit-language", { interactionLanguage: "en" });
|
|
mgr.teardown("explicit-language");
|
|
mgr.createFor("manifest-resolved-in-gate");
|
|
mgr.teardown("manifest-resolved-in-gate");
|
|
expect(environments[0].THT_INTERACTION_LANGUAGE).toBe("en");
|
|
expect(environments[1]).not.toHaveProperty("THT_INTERACTION_LANGUAGE");
|
|
} finally {
|
|
if (previous === undefined) delete process.env.THT_INTERACTION_LANGUAGE;
|
|
else process.env.THT_INTERACTION_LANGUAGE = previous;
|
|
}
|
|
});
|
|
|
|
test("a close-only child event releases its temporary Pi agent snapshot", () => {
|
|
const child = recordingChild();
|
|
let snapshotDir: string | undefined;
|
|
const mgr = new PiProcessManager(loadConfig({}), {
|
|
spawnFn: (_command, _args, options) => {
|
|
snapshotDir = options.env.PI_CODING_AGENT_DIR;
|
|
return child as any;
|
|
},
|
|
});
|
|
|
|
mgr.createFor("close-only-snapshot", {});
|
|
expect(snapshotDir).toBeTruthy();
|
|
expect(existsSync(snapshotDir!)).toBe(true);
|
|
|
|
child.emit("close", 0);
|
|
expect(existsSync(snapshotDir!)).toBe(false);
|
|
mgr.teardown("close-only-snapshot");
|
|
});
|
|
|
|
test.each([
|
|
["new", "auth.json", '{"deepseek":{"key":"!runtime-auth-command runtime-secret /private/runtime-auth"}}\n'],
|
|
["new", "models.json", '{"providers":{"local-qwen":{"headers":["!runtime-model-command runtime-secret /private/runtime-model"]}}}\n'],
|
|
["resume", "auth.json", '{"deepseek":{"key":"!resume-auth-command runtime-secret /private/resume-auth"}}\n'],
|
|
["resume", "models.json", '{"providers":{"local-qwen":{"models":[{"apiKey":"!resume-model-command runtime-secret /private/resume-model"}]}}}\n'],
|
|
] as const)(
|
|
"%s runtime rejects post-admission executable %s before auth resolution or child spawn",
|
|
async (mode, changedFile, unsafeRaw) => {
|
|
const root = mkdtempSync(path.join(tmpdir(), "tht-runtime-managed-config-"));
|
|
const agentDir = path.join(root, "agent");
|
|
mkdirSync(agentDir, { mode: 0o700 });
|
|
writeSafeAgentConfig(agentDir);
|
|
vi.stubEnv("PI_CODING_AGENT_DIR", agentDir);
|
|
let authResolutions = 0;
|
|
let spawns = 0;
|
|
const mgr = new PiProcessManager(loadConfig({}), {
|
|
authProviders: () => { authResolutions += 1; return new Set(); },
|
|
spawnFn: () => { spawns += 1; throw new Error("SPAWN_BOUNDARY_REACHED"); },
|
|
});
|
|
|
|
try {
|
|
// Admission/model validation succeeded while the mounted files were still safe, and the
|
|
// session was then persisted. The operator-controlled mount changes before runtime start.
|
|
validateDeclarativePiConfig(readFileSync(path.join(agentDir, "auth.json"), "utf8"));
|
|
validateDeclarativePiConfig(readFileSync(path.join(agentDir, "models.json"), "utf8"));
|
|
writeFileSync(path.join(root, "session-created"), `${mode}\n`);
|
|
writeFileSync(path.join(agentDir, changedFile), unsafeRaw, { mode: 0o600 });
|
|
|
|
let failure: unknown;
|
|
try {
|
|
if (mode === "new") {
|
|
mgr.createFor("post-admission-new", { provider: "local-qwen" });
|
|
} else {
|
|
await mgr.spawnFor("post-admission-resume", {
|
|
provider: "local-qwen", mode: "resume",
|
|
});
|
|
}
|
|
} catch (error) {
|
|
failure = error;
|
|
}
|
|
const message = failure instanceof Error ? failure.message : String(failure);
|
|
|
|
expect({ message, authResolutions, spawns, runtimes: mgr.count() }).toEqual({
|
|
message: PI_MANAGED_CONFIG_ERROR_MESSAGE,
|
|
authResolutions: 0,
|
|
spawns: 0,
|
|
runtimes: 0,
|
|
});
|
|
expect(message).not.toMatch(/runtime-secret|\/private\/|runtime-(?:auth|model)-command|resume-(?:auth|model)-command/);
|
|
} finally {
|
|
mgr.teardown("post-admission-new");
|
|
mgr.teardown("post-admission-resume");
|
|
vi.unstubAllEnvs();
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
},
|
|
);
|
|
|
|
test("runtime Pi consumes exact validated auth/models snapshots and keeps persistent agent resources", async () => {
|
|
const root = mkdtempSync(path.join(tmpdir(), "tht-runtime-agent-snapshot-"));
|
|
const agentDir = path.join(root, "agent");
|
|
const sessionsDir = path.join(agentDir, "sessions");
|
|
const extensionDir = path.join(agentDir, "extensions");
|
|
mkdirSync(sessionsDir, { recursive: true, mode: 0o700 });
|
|
mkdirSync(extensionDir, { recursive: true, mode: 0o700 });
|
|
writeSafeAgentConfig(agentDir);
|
|
const settings = '{"quietStartup":true}\n';
|
|
writeFileSync(path.join(agentDir, "settings.json"), settings, { mode: 0o600 });
|
|
writeFileSync(path.join(extensionDir, "runtime-extension.js"), "export default {};\n");
|
|
vi.stubEnv("PI_CODING_AGENT_DIR", agentDir);
|
|
vi.stubEnv("PI_CODING_AGENT_SESSION_DIR", "");
|
|
const child = recordingChild();
|
|
let spawnEnv: NodeJS.ProcessEnv | undefined;
|
|
const mgr = new PiProcessManager(loadConfig({}), {
|
|
spawnFn: (_command, _args, options) => {
|
|
spawnEnv = options.env;
|
|
// This mutation happens after validation but before the child can open either source file.
|
|
writeFileSync(path.join(agentDir, "auth.json"), '{"deepseek":{"key":"!late-auth-command"}}\n');
|
|
writeFileSync(path.join(agentDir, "models.json"), '{"providers":{"late":{"apiKey":"!late-model-command"}}}\n');
|
|
return child as any;
|
|
},
|
|
});
|
|
let snapshotDir: string | undefined;
|
|
let childExited = false;
|
|
|
|
try {
|
|
await mgr.spawnFor("snapshot-session", { provider: "local-qwen" });
|
|
snapshotDir = spawnEnv?.PI_CODING_AGENT_DIR;
|
|
|
|
expect(snapshotDir).toBeTruthy();
|
|
expect(snapshotDir).not.toBe(agentDir);
|
|
expect(readFileSync(path.join(snapshotDir!, "auth.json"), "utf8")).toBe(SAFE_AUTH);
|
|
expect(readFileSync(path.join(snapshotDir!, "models.json"), "utf8")).toBe(SAFE_MODELS);
|
|
expect(readFileSync(path.join(snapshotDir!, "settings.json"), "utf8")).toBe(settings);
|
|
expect(readFileSync(path.join(snapshotDir!, "extensions", "runtime-extension.js"), "utf8"))
|
|
.toBe("export default {};\n");
|
|
expect(spawnEnv?.PI_CODING_AGENT_SESSION_DIR).toBe(sessionsDir);
|
|
|
|
mgr.teardown("snapshot-session");
|
|
child.emit("exit", 0);
|
|
childExited = true;
|
|
expect(existsSync(snapshotDir!)).toBe(false);
|
|
} finally {
|
|
mgr.teardown("snapshot-session");
|
|
if (!childExited) child.emit("exit", 0);
|
|
vi.unstubAllEnvs();
|
|
if (snapshotDir && snapshotDir !== agentDir) rmSync(snapshotDir, { recursive: true, force: true });
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("createFor kills a spawned child when post-spawn initialization throws", () => {
|
|
const child = recordingChild();
|
|
child.kill = vi.fn();
|
|
child.stdout = {
|
|
on: () => { throw new Error("READER_INIT_SENTINEL"); },
|
|
};
|
|
let snapshotDir: string | undefined;
|
|
const mgr = new PiProcessManager(loadConfig({}), {
|
|
spawnFn: (_command, _args, options) => {
|
|
snapshotDir = options.env.PI_CODING_AGENT_DIR;
|
|
return child as any;
|
|
},
|
|
});
|
|
|
|
expect(() => mgr.createFor("broken-init", {})).toThrow("READER_INIT_SENTINEL");
|
|
|
|
expect(snapshotDir).toBeTruthy();
|
|
expect(existsSync(snapshotDir!)).toBe(false);
|
|
expect(child.kill).toHaveBeenCalledOnce();
|
|
expect(mgr.get("broken-init")).toBeUndefined();
|
|
expect(mgr.count()).toBe(0);
|
|
});
|
|
|
|
test("createFor kills a spawned child when spawn boundary initialization throws", () => {
|
|
const child = recordingChild();
|
|
child.kill = vi.fn();
|
|
child.stderr = {
|
|
on: () => { throw new Error("STDERR_INIT_SENTINEL"); },
|
|
};
|
|
const mgr = new PiProcessManager(loadConfig({}), { spawnFn: () => child as any });
|
|
|
|
expect(() => mgr.createFor("broken-spawn-init", {})).toThrow("STDERR_INIT_SENTINEL");
|
|
|
|
expect(child.kill).toHaveBeenCalledOnce();
|
|
expect(mgr.get("broken-spawn-init")).toBeUndefined();
|
|
expect(mgr.count()).toBe(0);
|
|
});
|
|
|
|
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(rt.bridge.turnState()).toBe("failed");
|
|
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 <id>", 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("configure gives the bridge the context window returned by set_model", async () => {
|
|
const child = recordingChild();
|
|
child.stdin.write = (data: unknown) => {
|
|
const request = JSON.parse(String(data));
|
|
child._writes.push(String(data));
|
|
if (request.type === "set_model") {
|
|
queueMicrotask(() => child.stdout.emit("data", `${JSON.stringify({
|
|
type: "response",
|
|
id: request.id,
|
|
success: true,
|
|
data: { contextWindow: 200_000 },
|
|
})}\n`));
|
|
}
|
|
return true;
|
|
};
|
|
const mgr = new PiProcessManager(loadConfig({}), { spawnFn: () => child as any });
|
|
const rt = mgr.createFor("context-window", {});
|
|
const seen: any[] = [];
|
|
rt.bridge.onClientEvent((event) => seen.push(event));
|
|
|
|
await mgr.configure(rt, { provider: "zai", model: "glm-5.2" });
|
|
child.stdout.emit("data", `${JSON.stringify({
|
|
type: "message_end",
|
|
message: {
|
|
role: "assistant",
|
|
stopReason: "stop",
|
|
usage: { input: 10, cacheRead: 20, output: 5, totalTokens: 35 },
|
|
},
|
|
})}\n`);
|
|
|
|
expect(seen).toContainEqual({
|
|
type: "usage",
|
|
usage: { input: 10, cacheRead: 20, output: 5, totalTokens: 35, contextWindow: 200_000 },
|
|
});
|
|
mgr.teardown("context-window");
|
|
});
|
|
|
|
test("a created runtime is active during configure/bootstrap", () => {
|
|
const child = recordingChild();
|
|
const mgr = new PiProcessManager(loadConfig({}), { spawnFn: () => child as any });
|
|
const runtime = mgr.createFor("starting-id", {});
|
|
expect(runtime.bridge.turnState()).toBe("running");
|
|
mgr.teardown("starting-id");
|
|
});
|
|
|
|
test("start reactivates the runtime before sending the prompt", () => {
|
|
const child = recordingChild();
|
|
const mgr = new PiProcessManager(loadConfig({}), { spawnFn: () => child as any });
|
|
const runtime = mgr.createFor("restart-id", {});
|
|
child.stdout.emit("data", `${JSON.stringify({ type: "agent_end", messages: [] })}\n`);
|
|
expect(runtime.bridge.turnState()).toBe("idle");
|
|
let stateAtWrite = runtime.bridge.turnState();
|
|
child.stdin.write = (data: unknown) => {
|
|
stateAtWrite = runtime.bridge.turnState();
|
|
child._writes.push(String(data));
|
|
return true;
|
|
};
|
|
|
|
mgr.start("restart-id", runtime, { question: "q" });
|
|
|
|
expect(stateAtWrite).toBe("running");
|
|
expect(runtime.bridge.turnState()).toBe("running");
|
|
mgr.teardown("restart-id");
|
|
});
|
|
|
|
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; },
|
|
// Empty auth store: exercise the managed-key injection path deterministically,
|
|
// independent of whatever providers the developer's ~/.pi/agent/auth.json holds.
|
|
authProviders: () => new Set(),
|
|
});
|
|
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("skips managed-key injection for a provider present in pi's auth store", async () => {
|
|
const secret = path.resolve(__dirname, `.model-key-${process.pid}-authskip`);
|
|
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; },
|
|
authProviders: () => new Set(["deepseek"]),
|
|
});
|
|
try {
|
|
await mgr.spawnFor("authskip-session", { provider: "deepseek" });
|
|
const env = calls[0][2].env;
|
|
// pi resolves deepseek from its own auth store, so no key is forced onto it.
|
|
expect(env.DEEPSEEK_API_KEY).toBeUndefined();
|
|
} finally {
|
|
mgr.teardown("authskip-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",
|
|
"THT_DWH_API_KEY=dwh-secret",
|
|
"THT_VEC_API_KEY=vector-reader-secret",
|
|
"THT_VEC_WRITE_API_KEY=vector-writer-secret",
|
|
"THT_CA=/run/secrets/ca-chain.pem",
|
|
"",
|
|
].join("\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).toMatchObject({
|
|
THT_DWH_API_KEY: "dwh-secret",
|
|
THT_VEC_API_KEY: "vector-reader-secret",
|
|
THT_VEC_WRITE_API_KEY: "vector-writer-secret",
|
|
THT_CA: "/run/secrets/ca-chain.pem",
|
|
THT_SSL_CA: "/run/secrets/ca-chain.pem",
|
|
});
|
|
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([false, true])("session Pi uses the catalog bundle despite stale Pi auth: %s", (missingKey) => {
|
|
const root = mkdtempSync(path.join(tmpdir(), "thothii-catalog-credential-"));
|
|
const agentDir = path.join(root, "agent");
|
|
mkdirSync(agentDir, { mode: 0o700 });
|
|
const originalAuth = JSON.stringify({
|
|
deepseek: { type: "api_key", key: "stale-key" },
|
|
anthropic: { type: "api_key", key: "unrelated-key" },
|
|
});
|
|
writeFileSync(path.join(agentDir, "auth.json"), originalAuth, { mode: 0o600 });
|
|
writeFileSync(path.join(agentDir, "models.json"), '{"providers":{}}\n', { mode: 0o600 });
|
|
const secret = path.join(root, "thothii.secrets");
|
|
writeFileSync(secret, missingKey ? "THT_MODEL_API_KEY=legacy-secret\n"
|
|
: "DEEPSEEK_API_KEY=catalog-secret\nTHT_MODEL_API_KEY=legacy-secret\n", { mode: 0o600 });
|
|
const legacyKey = path.join(root, "legacy-key");
|
|
writeFileSync(legacyKey, "legacy-file-key", { mode: 0o600 });
|
|
const model: RuntimeModel = {
|
|
id: "deepseek/deepseek-v4-pro",
|
|
provider: "deepseek",
|
|
model: "deepseek-v4-pro",
|
|
label: "DeepSeek V4 Pro",
|
|
upstreamModel: "deepseek-v4-pro",
|
|
authentication: { mode: "secret_env", apiKeyEnv: "DEEPSEEK_API_KEY" },
|
|
sessionAdapter: { mode: "pi_builtin" },
|
|
session: { reasoning: false },
|
|
};
|
|
const modelCatalog: RuntimeModelCatalog = {
|
|
defaultInteraction: model.id,
|
|
embedding: null,
|
|
sessionModels: () => [model],
|
|
metadataModels: () => [],
|
|
hasSession: (id) => id === model.id,
|
|
};
|
|
const calls: any[][] = [];
|
|
const child = recordingChild();
|
|
child.stderr.resume = () => {};
|
|
vi.stubEnv("PI_CODING_AGENT_DIR", agentDir);
|
|
const mgr = new PiProcessManager(loadConfig({ THT_SECRETS_FILE: secret, THT_MODEL_API_KEY_FILE: legacyKey }), {
|
|
modelCatalog,
|
|
authProviders: () => new Set(["deepseek"]),
|
|
spawnFn: (...args: any[]) => { calls.push(args); return child as any; },
|
|
});
|
|
try {
|
|
const create = () => mgr.createFor("catalog-credential", { provider: model.provider, model: model.model });
|
|
if (missingKey) {
|
|
expect(create).toThrow("model provider credential is unavailable");
|
|
expect(calls).toHaveLength(0);
|
|
return;
|
|
}
|
|
create();
|
|
expect(calls[0][2].env.DEEPSEEK_API_KEY).toBe("catalog-secret");
|
|
expect(calls[0][2].env).not.toHaveProperty("OPENAI_API_KEY");
|
|
expect(calls[0][2].env).not.toHaveProperty("THT_MODEL_API_KEY");
|
|
expect(JSON.parse(readFileSync(path.join(calls[0][2].env.PI_CODING_AGENT_DIR, "auth.json"), "utf8")))
|
|
.toEqual({ anthropic: { type: "api_key", key: "unrelated-key" } });
|
|
} finally {
|
|
expect(readFileSync(path.join(agentDir, "auth.json"), "utf8")).toBe(originalAuth);
|
|
mgr.teardown("catalog-credential");
|
|
vi.unstubAllEnvs();
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
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("set_model translates a canonical catalog key to its upstream Pi model ID", async () => {
|
|
const root = mkdtempSync(path.join(tmpdir(), "thothii-upstream-model-"));
|
|
const agentDir = path.join(root, "agent");
|
|
mkdirSync(agentDir, { mode: 0o700 });
|
|
writeFileSync(path.join(agentDir, "models.json"), JSON.stringify({
|
|
providers: {
|
|
local: {
|
|
baseUrl: "http://ollama:11434/v1", apiKey: "local",
|
|
models: [{ id: "qwen2.5:7b" }],
|
|
},
|
|
},
|
|
}), { mode: 0o600 });
|
|
vi.stubEnv("PI_CODING_AGENT_DIR", agentDir);
|
|
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 model: RuntimeModel = {
|
|
id: "local/qwen", provider: "local", model: "qwen", label: "Qwen",
|
|
upstreamModel: "qwen2.5:7b", endpoint: { baseUrl: "http://ollama:11434/v1" },
|
|
authentication: { mode: "none" }, sessionAdapter: { mode: "openai_compatible" },
|
|
session: { reasoning: false, contextWindow: 32768, maxTokens: 8192 },
|
|
};
|
|
const modelCatalog: RuntimeModelCatalog = {
|
|
defaultInteraction: model.id, embedding: null,
|
|
sessionModels: () => [model], metadataModels: () => [], hasSession: (id) => id === model.id,
|
|
};
|
|
const mgr = new PiProcessManager(loadConfig({ PI_BIN: "/usr/local/bin/pi" }), {
|
|
modelCatalog, authProviders: () => new Set(), spawnFn: () => child as any,
|
|
});
|
|
try {
|
|
await mgr.spawnFor("upstream-model", { provider: "local", model: "qwen" });
|
|
expect(child._writes.join("")).toContain('"modelId":"qwen2.5:7b"');
|
|
} finally {
|
|
mgr.teardown("upstream-model");
|
|
vi.unstubAllEnvs();
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test.each(["installation-local", "private-compatible"])(
|
|
"provider %s configured with a literal apiKey spawns without a managed key",
|
|
async (provider) => {
|
|
const root = mkdtempSync(path.join(tmpdir(), "tht-local-provider-"));
|
|
const agentDir = path.join(root, "agent");
|
|
mkdirSync(agentDir, { mode: 0o700 });
|
|
writeFileSync(path.join(agentDir, "models.json"), JSON.stringify({
|
|
providers: {
|
|
[provider]: {
|
|
baseUrl: "http://model.invalid/v1",
|
|
apiKey: "local",
|
|
models: [{ id: "model" }],
|
|
},
|
|
},
|
|
}), { mode: 0o600 });
|
|
vi.stubEnv("PI_CODING_AGENT_DIR", agentDir);
|
|
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}`, { provider });
|
|
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-${provider}`);
|
|
vi.unstubAllEnvs();
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
},
|
|
);
|
|
|
|
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`);
|
|
});
|
|
}
|
|
}
|
|
},
|
|
);
|