fix: bind pi runtime config at spawn
This commit is contained in:
@@ -3,14 +3,36 @@ 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 {
|
||||
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 { 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","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 });
|
||||
@@ -146,6 +168,117 @@ function recordingChild() {
|
||||
return ch;
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
@@ -8,6 +8,8 @@ import { buildApp as buildRealApp } from "../src/app.js";
|
||||
import { loadConfig } from "../src/config.js";
|
||||
import { SseHub } from "../src/sse/sse-hub.js";
|
||||
import { MaintenanceBarrier } from "../src/runtime/maintenance-gate.js";
|
||||
import { PiProcessManager } from "../src/pi/pi-process-manager.js";
|
||||
import { validateDeclarativePiConfig } from "../src/pi/managed-config.js";
|
||||
|
||||
const FAKE = path.resolve("../harness/tests/fake_pi/fake_pi_rpc.mjs");
|
||||
const SCRIPT = path.resolve("../harness/tests/fake_pi/scripts/f1_disambiguation.json");
|
||||
@@ -2426,6 +2428,112 @@ test("POST /sessions marks a persisted session failed when runtime construction
|
||||
expect(failed).toBe(1);
|
||||
});
|
||||
|
||||
test.each([
|
||||
[
|
||||
"new",
|
||||
"auth.json",
|
||||
'{"unrelated":{"credential":{"nested":["!post-session-auth-command route-secret /private/route-auth"]}}}\n',
|
||||
"Session startup failed. Check configuration and connectivity, then Resume the session.",
|
||||
],
|
||||
[
|
||||
"resume",
|
||||
"models.json",
|
||||
'{"providers":{"local-qwen":{"models":[{"id":"qwen3.6-35b-a3b","headers":{"x":"!post-session-model-command route-secret /private/route-model"}}]}}}\n',
|
||||
"Session could not be resumed. Check configuration and connectivity, then try again.",
|
||||
],
|
||||
] as const)(
|
||||
"POST %s refuses executable %s changed after session persistence without spawning or leaking",
|
||||
async (flow, changedFile, unsafeRaw, publicMessage) => {
|
||||
const agentDir = mkdtempSync(path.join(tmpdir(), "tht-route-runtime-config-"));
|
||||
const safeAuth = '{"deepseek":{"type":"api_key","key":"safe-token"}}\n';
|
||||
const safeModels = '{"providers":{"local-qwen":{"baseUrl":"http://model.invalid/v1","models":[{"id":"qwen3.6-35b-a3b"}]}}}\n';
|
||||
writeFileSync(path.join(agentDir, "auth.json"), safeAuth, { mode: 0o600 });
|
||||
writeFileSync(path.join(agentDir, "models.json"), safeModels, { mode: 0o600 });
|
||||
vi.stubEnv("PI_CODING_AGENT_DIR", agentDir);
|
||||
const cfg = loadConfig({ THT_HARNESS_DIR: "../harness" });
|
||||
let authResolutions = 0;
|
||||
let spawns = 0;
|
||||
const mgr = new PiProcessManager(cfg, {
|
||||
authProviders: () => { authResolutions += 1; return new Set(); },
|
||||
spawnFn: () => { spawns += 1; throw new Error("ROUTE_SPAWN_BOUNDARY_REACHED"); },
|
||||
});
|
||||
const hub = new SseHub();
|
||||
const events: Array<{ event: string; data: object }> = [];
|
||||
const sessionId = `post-persistence-${flow}`;
|
||||
hub.subscribe(sessionId, (event, data) => events.push({ event, data }));
|
||||
const failSession = vi.fn(async () => {});
|
||||
const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
const validateEarlierState = () => {
|
||||
validateDeclarativePiConfig(readFileSync(path.join(agentDir, "auth.json"), "utf8"));
|
||||
validateDeclarativePiConfig(readFileSync(path.join(agentDir, "models.json"), "utf8"));
|
||||
};
|
||||
|
||||
if (flow === "resume") {
|
||||
// This session was admitted and persisted while both mounted files were safe.
|
||||
validateEarlierState();
|
||||
writeFileSync(path.join(agentDir, changedFile), unsafeRaw, { mode: 0o600 });
|
||||
}
|
||||
|
||||
const app = buildApp(cfg, {
|
||||
mgr,
|
||||
hub,
|
||||
thtRunner: {
|
||||
sessionNew: async () => {
|
||||
// Model admission completed immediately above this persistence boundary.
|
||||
writeFileSync(path.join(agentDir, changedFile), unsafeRaw, { mode: 0o600 });
|
||||
return { id: sessionId };
|
||||
},
|
||||
failSession,
|
||||
searchPack: async () => {},
|
||||
sessionShow: async () => ({
|
||||
id: sessionId,
|
||||
status: "open",
|
||||
archived: false,
|
||||
provider: "local-qwen",
|
||||
model: "qwen3.6-35b-a3b",
|
||||
thinking: "low",
|
||||
}),
|
||||
reopenSession: async () => {},
|
||||
} as any,
|
||||
readiness: { ensure: async () => ({ ok: true }) } as any,
|
||||
getSettings: () => ({
|
||||
workspace: "w",
|
||||
provider: "local-qwen",
|
||||
model: "qwen3.6-35b-a3b",
|
||||
thinking: "low",
|
||||
}) as any,
|
||||
listModels: async () => {
|
||||
validateEarlierState();
|
||||
return [{
|
||||
provider: "local-qwen", id: "qwen3.6-35b-a3b", name: "Qwen", reasoning: true,
|
||||
}];
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const response = flow === "new"
|
||||
? await app.inject({ method: "POST", url: "/sessions", payload: { question: "q" } })
|
||||
: await app.inject({ method: "POST", url: `/sessions/${sessionId}/resume` });
|
||||
const logs = consoleError.mock.calls.flat().map(String).join(" ");
|
||||
|
||||
expect(response.statusCode).toBe(503);
|
||||
expect(response.json()).toEqual({ error: publicMessage });
|
||||
expect({ authResolutions, spawns, runtimes: mgr.count() }).toEqual({
|
||||
authResolutions: 0, spawns: 0, runtimes: 0,
|
||||
});
|
||||
expect(failSession).toHaveBeenCalledTimes(flow === "new" ? 1 : 0);
|
||||
expect(events).toEqual([]);
|
||||
expect(`${response.body}\n${logs}`).not.toContain(unsafeRaw.trim());
|
||||
expect(`${response.body}\n${logs}`).not.toMatch(/route-secret|post-session-(?:auth|model)-command|\/private\/route-|tht-route-runtime-config/);
|
||||
} finally {
|
||||
await app.close();
|
||||
consoleError.mockRestore();
|
||||
vi.unstubAllEnvs();
|
||||
rmSync(agentDir, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test("POST /sessions/:id/resume returns 409 for a read-only session without calling ollamaEnsure", async () => {
|
||||
let ensureCalled = false;
|
||||
const app = mutApp({
|
||||
|
||||
Reference in New Issue
Block a user