fix: bind pi runtime config at spawn
This commit is contained in:
@@ -1,8 +1,9 @@
|
|||||||
import {
|
import {
|
||||||
closeSync, constants, fstatSync, lstatSync, openSync, readFileSync,
|
chmodSync, closeSync, constants, fstatSync, lstatSync, mkdtempSync, openSync,
|
||||||
|
readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync, type Dirent,
|
||||||
} from "node:fs";
|
} from "node:fs";
|
||||||
import { homedir } from "node:os";
|
import { homedir, tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join, resolve } from "node:path";
|
||||||
|
|
||||||
const MAX_AGENT_CONFIG_BYTES = 1024 * 1024;
|
const MAX_AGENT_CONFIG_BYTES = 1024 * 1024;
|
||||||
|
|
||||||
@@ -55,13 +56,15 @@ export function validateDeclarativePiConfig(raw: string): void {
|
|||||||
assertDeclarativePiConfig(parsePiConfigJson(raw));
|
assertDeclarativePiConfig(parsePiConfigJson(raw));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function readConfiguredPiAgentFile(name: "auth.json"): string;
|
function configuredPiAgentDir(): string {
|
||||||
export function readConfiguredPiAgentFile(name: "models.json", optional: true): string | undefined;
|
return resolve(process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent"));
|
||||||
export function readConfiguredPiAgentFile(
|
}
|
||||||
|
|
||||||
|
function readPiAgentFile(
|
||||||
|
configuredAgentDir: string,
|
||||||
name: "auth.json" | "models.json",
|
name: "auth.json" | "models.json",
|
||||||
optional = false,
|
optional: boolean,
|
||||||
): string | undefined {
|
): string | undefined {
|
||||||
const configuredAgentDir = process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
|
|
||||||
const path = join(configuredAgentDir, name);
|
const path = join(configuredAgentDir, name);
|
||||||
let fd: number | undefined;
|
let fd: number | undefined;
|
||||||
try {
|
try {
|
||||||
@@ -85,3 +88,74 @@ export function readConfiguredPiAgentFile(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function readConfiguredPiAgentFile(name: "auth.json"): string;
|
||||||
|
export function readConfiguredPiAgentFile(name: "auth.json", optional: true): string | undefined;
|
||||||
|
export function readConfiguredPiAgentFile(name: "models.json", optional: true): string | undefined;
|
||||||
|
export function readConfiguredPiAgentFile(
|
||||||
|
name: "auth.json" | "models.json",
|
||||||
|
optional = false,
|
||||||
|
): string | undefined {
|
||||||
|
return readPiAgentFile(configuredPiAgentDir(), name, optional);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PiRuntimeAgentSnapshot {
|
||||||
|
agentDir: string;
|
||||||
|
sessionDir: string;
|
||||||
|
cleanup: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bind a session Pi process to the exact managed auth/model bytes validated at spawn time.
|
||||||
|
* Other agent resources remain live through symlinks, while session storage stays persistent.
|
||||||
|
*/
|
||||||
|
export function createPiRuntimeAgentSnapshot(): PiRuntimeAgentSnapshot {
|
||||||
|
const sourceAgentDir = configuredPiAgentDir();
|
||||||
|
const auth = readPiAgentFile(sourceAgentDir, "auth.json", true);
|
||||||
|
const models = readPiAgentFile(sourceAgentDir, "models.json", true);
|
||||||
|
if (auth !== undefined) validateDeclarativePiConfig(auth);
|
||||||
|
if (models !== undefined) validateDeclarativePiConfig(models);
|
||||||
|
|
||||||
|
let snapshotDir: string | undefined;
|
||||||
|
try {
|
||||||
|
snapshotDir = mkdtempSync(join(tmpdir(), "thoth-pi-runtime-agent-"));
|
||||||
|
chmodSync(snapshotDir, 0o700);
|
||||||
|
let entries: Dirent[];
|
||||||
|
try {
|
||||||
|
entries = readdirSync(sourceAgentDir, { withFileTypes: true });
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException)?.code !== "ENOENT") throw error;
|
||||||
|
entries = [];
|
||||||
|
}
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.name === "auth.json" || entry.name === "models.json") continue;
|
||||||
|
symlinkSync(
|
||||||
|
join(sourceAgentDir, entry.name),
|
||||||
|
join(snapshotDir, entry.name),
|
||||||
|
entry.isDirectory() ? (process.platform === "win32" ? "junction" : "dir") : "file",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (auth !== undefined) {
|
||||||
|
writeFileSync(join(snapshotDir, "auth.json"), auth, { flag: "wx", mode: 0o600 });
|
||||||
|
}
|
||||||
|
if (models !== undefined) {
|
||||||
|
writeFileSync(join(snapshotDir, "models.json"), models, { flag: "wx", mode: 0o600 });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (snapshotDir !== undefined) {
|
||||||
|
try { rmSync(snapshotDir, { recursive: true, force: true }); } catch { /* sanitized */ }
|
||||||
|
}
|
||||||
|
throw new PiManagedConfigError();
|
||||||
|
}
|
||||||
|
|
||||||
|
let cleaned = false;
|
||||||
|
return {
|
||||||
|
agentDir: snapshotDir,
|
||||||
|
sessionDir: process.env.PI_CODING_AGENT_SESSION_DIR || join(sourceAgentDir, "sessions"),
|
||||||
|
cleanup: () => {
|
||||||
|
if (cleaned) return;
|
||||||
|
cleaned = true;
|
||||||
|
try { rmSync(snapshotDir, { recursive: true, force: true }); } catch { /* sanitized */ }
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { buildPiChildEnv, canonicalPiProvider } from "./provider-credentials.js"
|
|||||||
import { loadPiAuthProviders } from "./auth-providers.js";
|
import { loadPiAuthProviders } from "./auth-providers.js";
|
||||||
import { secretValue } from "../config/secret-bundle.js";
|
import { secretValue } from "../config/secret-bundle.js";
|
||||||
import { clearPrincipalEnvironment, principalEnvironment, type PrincipalContext } from "../auth/principal.js";
|
import { clearPrincipalEnvironment, principalEnvironment, type PrincipalContext } from "../auth/principal.js";
|
||||||
|
import { createPiRuntimeAgentSnapshot } from "./managed-config.js";
|
||||||
|
|
||||||
export interface SessionRuntime {
|
export interface SessionRuntime {
|
||||||
rpc: RpcClient;
|
rpc: RpcClient;
|
||||||
@@ -37,13 +38,14 @@ export class PiProcessManager {
|
|||||||
private spawnFn: (
|
private spawnFn: (
|
||||||
sessionId: string, author: string, provider: string | undefined, principal?: PrincipalContext,
|
sessionId: string, author: string, provider: string | undefined, principal?: PrincipalContext,
|
||||||
) => ChildProcessWithoutNullStreams;
|
) => ChildProcessWithoutNullStreams;
|
||||||
private loadAuthProviders: () => ReadonlySet<string>;
|
private loadAuthProviders: (agentDir: string) => ReadonlySet<string>;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private cfg: AppConfig,
|
private cfg: AppConfig,
|
||||||
opts?: { spawnFn?: SpawnFn; authProviders?: () => ReadonlySet<string> },
|
opts?: { spawnFn?: SpawnFn; authProviders?: (agentDir: string) => ReadonlySet<string> },
|
||||||
) {
|
) {
|
||||||
this.loadAuthProviders = opts?.authProviders ?? (() => loadPiAuthProviders());
|
this.loadAuthProviders = opts?.authProviders
|
||||||
|
?? ((agentDir) => loadPiAuthProviders({ agentDir }));
|
||||||
if (opts?.spawnFn) {
|
if (opts?.spawnFn) {
|
||||||
this.spawnFn = (sessionId, author, provider, principal) =>
|
this.spawnFn = (sessionId, author, provider, principal) =>
|
||||||
this.spawnPi(opts.spawnFn!, sessionId, author, provider, principal);
|
this.spawnPi(opts.spawnFn!, sessionId, author, provider, principal);
|
||||||
@@ -56,45 +58,57 @@ export class PiProcessManager {
|
|||||||
private spawnPi(
|
private spawnPi(
|
||||||
spawnFn: SpawnFn, sessionId: string, author: string, provider: string | undefined, principal?: PrincipalContext,
|
spawnFn: SpawnFn, sessionId: string, author: string, provider: string | undefined, principal?: PrincipalContext,
|
||||||
): ChildProcessWithoutNullStreams {
|
): ChildProcessWithoutNullStreams {
|
||||||
const env = buildPiChildEnv({
|
// This is the final shared boundary for createFor(), spawnFor(), and resume(). Validate
|
||||||
provider,
|
// before auth-provider inspection, then make Pi consume the exact copied bytes rather than
|
||||||
authProviders: this.loadAuthProviders(),
|
// reopening mutable mounted auth/models files after this check.
|
||||||
credentialValue: secretValue(this.cfg, "THT_MODEL_API_KEY"),
|
const agent = createPiRuntimeAgentSnapshot();
|
||||||
credentialFile: this.cfg.modelApiKeyFile,
|
let child: ChildProcessWithoutNullStreams | undefined;
|
||||||
additions: { THT_SESSION: sessionId, THT_AUTHOR: author },
|
|
||||||
});
|
|
||||||
clearPrincipalEnvironment(env);
|
|
||||||
if (principal) Object.assign(env, principalEnvironment(principal));
|
|
||||||
// The Thoth gate executes the deterministic `tht` CLI as a Pi tool. Give only
|
|
||||||
// this managed session process the adapter values already loaded by the core
|
|
||||||
// entrypoint; the generic provider helper continues to scrub them by default.
|
|
||||||
for (const name of [
|
|
||||||
"THT_DWH_API_KEY", "THT_VEC_API_KEY", "THT_VEC_WRITE_API_KEY",
|
|
||||||
] as const) {
|
|
||||||
const value = secretValue(this.cfg, name) ?? process.env[name];
|
|
||||||
if (value !== undefined) env[name] = value;
|
|
||||||
}
|
|
||||||
const ca = secretValue(this.cfg, "THT_SSL_CA")
|
|
||||||
?? secretValue(this.cfg, "THT_CA")
|
|
||||||
?? process.env.THT_SSL_CA
|
|
||||||
?? process.env.THT_CA;
|
|
||||||
if (ca !== undefined) {
|
|
||||||
env.THT_CA = ca;
|
|
||||||
env.THT_SSL_CA = ca;
|
|
||||||
}
|
|
||||||
delete env.THT_DATA_ROOT;
|
|
||||||
if (this.cfg.dataRoot !== undefined) env.THT_DATA_ROOT = this.cfg.dataRoot;
|
|
||||||
// pi 0.73 removed `--approve`: rpc mode is headless and its argv is intentionally minimal.
|
|
||||||
const child = spawnFn(this.cfg.piBin, ["--mode", "rpc"], {
|
|
||||||
cwd: this.cfg.harnessDir,
|
|
||||||
env,
|
|
||||||
});
|
|
||||||
try {
|
try {
|
||||||
|
const env = buildPiChildEnv({
|
||||||
|
provider,
|
||||||
|
authProviders: this.loadAuthProviders(agent.agentDir),
|
||||||
|
credentialValue: secretValue(this.cfg, "THT_MODEL_API_KEY"),
|
||||||
|
credentialFile: this.cfg.modelApiKeyFile,
|
||||||
|
additions: { THT_SESSION: sessionId, THT_AUTHOR: author },
|
||||||
|
});
|
||||||
|
env.PI_CODING_AGENT_DIR = agent.agentDir;
|
||||||
|
env.PI_CODING_AGENT_SESSION_DIR = agent.sessionDir;
|
||||||
|
clearPrincipalEnvironment(env);
|
||||||
|
if (principal) Object.assign(env, principalEnvironment(principal));
|
||||||
|
// The Thoth gate executes the deterministic `tht` CLI as a Pi tool. Give only
|
||||||
|
// this managed session process the adapter values already loaded by the core
|
||||||
|
// entrypoint; the generic provider helper continues to scrub them by default.
|
||||||
|
for (const name of [
|
||||||
|
"THT_DWH_API_KEY", "THT_VEC_API_KEY", "THT_VEC_WRITE_API_KEY",
|
||||||
|
] as const) {
|
||||||
|
const value = secretValue(this.cfg, name) ?? process.env[name];
|
||||||
|
if (value !== undefined) env[name] = value;
|
||||||
|
}
|
||||||
|
const ca = secretValue(this.cfg, "THT_SSL_CA")
|
||||||
|
?? secretValue(this.cfg, "THT_CA")
|
||||||
|
?? process.env.THT_SSL_CA
|
||||||
|
?? process.env.THT_CA;
|
||||||
|
if (ca !== undefined) {
|
||||||
|
env.THT_CA = ca;
|
||||||
|
env.THT_SSL_CA = ca;
|
||||||
|
}
|
||||||
|
delete env.THT_DATA_ROOT;
|
||||||
|
if (this.cfg.dataRoot !== undefined) env.THT_DATA_ROOT = this.cfg.dataRoot;
|
||||||
|
// pi 0.73 removed `--approve`: rpc mode is headless and its argv is intentionally minimal.
|
||||||
|
child = spawnFn(this.cfg.piBin, ["--mode", "rpc"], {
|
||||||
|
cwd: this.cfg.harnessDir,
|
||||||
|
env,
|
||||||
|
});
|
||||||
|
child.once("exit", agent.cleanup);
|
||||||
|
child.once("close", agent.cleanup);
|
||||||
// Log stderr for debugging (was silently drained)
|
// Log stderr for debugging (was silently drained)
|
||||||
child.stderr.on("data", (d: Buffer) => console.error(`[pi:${sessionId}] stderr:`, d.toString().trim()));
|
child.stderr.on("data", (d: Buffer) => console.error(`[pi:${sessionId}] stderr:`, d.toString().trim()));
|
||||||
return child;
|
return child;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
try { child.kill(); } catch { /* preserve the initialization error */ }
|
if (child) {
|
||||||
|
try { child.kill(); } catch { /* preserve the initialization error */ }
|
||||||
|
}
|
||||||
|
agent.cleanup();
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,14 +3,36 @@ import { spawn } from "node:child_process";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { EventEmitter } from "node:events";
|
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 { PiProcessManager } from "../src/pi/pi-process-manager.js";
|
||||||
import { loadConfig } from "../src/config.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 __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const FAKE = path.resolve(__dirname, "../../harness/tests/fake_pi/fake_pi_rpc.mjs");
|
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 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 () => {
|
test("spawnFor avvia un runtime e il bridge emette il widget F1", async () => {
|
||||||
const cfg = loadConfig({ THT_HARNESS_DIR: "../harness" });
|
const cfg = loadConfig({ THT_HARNESS_DIR: "../harness" });
|
||||||
const mgr = new PiProcessManager(cfg, { spawnFn: () => spawn("node", [FAKE, SCRIPT]) as any });
|
const mgr = new PiProcessManager(cfg, { spawnFn: () => spawn("node", [FAKE, SCRIPT]) as any });
|
||||||
@@ -146,6 +168,117 @@ function recordingChild() {
|
|||||||
return ch;
|
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", () => {
|
test("createFor kills a spawned child when post-spawn initialization throws", () => {
|
||||||
const child = recordingChild();
|
const child = recordingChild();
|
||||||
child.kill = vi.fn();
|
child.kill = vi.fn();
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import { buildApp as buildRealApp } from "../src/app.js";
|
|||||||
import { loadConfig } from "../src/config.js";
|
import { loadConfig } from "../src/config.js";
|
||||||
import { SseHub } from "../src/sse/sse-hub.js";
|
import { SseHub } from "../src/sse/sse-hub.js";
|
||||||
import { MaintenanceBarrier } from "../src/runtime/maintenance-gate.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 FAKE = path.resolve("../harness/tests/fake_pi/fake_pi_rpc.mjs");
|
||||||
const SCRIPT = path.resolve("../harness/tests/fake_pi/scripts/f1_disambiguation.json");
|
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);
|
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 () => {
|
test("POST /sessions/:id/resume returns 409 for a read-only session without calling ollamaEnsure", async () => {
|
||||||
let ensureCalled = false;
|
let ensureCalled = false;
|
||||||
const app = mutApp({
|
const app = mutApp({
|
||||||
|
|||||||
Reference in New Issue
Block a user