Controller, claim store, live-session guard, engine link and seal, turn tracker, cohort force stop and recovery, client library, transcript and mediated terminal, with the fake engine and tests. Fixtures only; no live cutover. Dewey built it. Darkwing (comment 26690) and Filbert (comment 26694) approved round 2. Manifest I1-r2-manifest.sha256 (2b48e333, 27 files). Suites on an export: conversation 152/152, control-board 124, webui 14, seat 19, chat-00/01/01c checks, and all nine scripts/test-*.sh green. Follow-ups for I3 are in DEFERRED. Gate E stays with Jason. Co-Authored-By: Claude Opus 5.5 <[email protected]>
93 lines
4.8 KiB
JavaScript
93 lines
4.8 KiB
JavaScript
// The Pi pin and the engine seal (#1507, CHAT-03 §3, lead decisions 31–32).
|
||
//
|
||
// Pin: package-lock.json and npm's installed record
|
||
// (node_modules/.package-lock.json) must both name the pinned version with the
|
||
// pinned integrity. That ties the install to the package through npm's record;
|
||
// it is not a hash of the files on disk. `pi` runs dist/bundle/cli.js, the
|
||
// package's bin, and the built-in llama.cpp extension ships inside it.
|
||
//
|
||
// Seal: the controller builds the launch argv. It always carries
|
||
// --no-extensions, --no-prompt-templates and --no-themes, and never an
|
||
// --extension argument (cli/args.js; usage.md 224 and 233–236). With
|
||
// --no-extensions Pi loads only command-line extension paths
|
||
// (resource-loader.js 316–318), so no explicit extension loads. Under the seal
|
||
// the Mosaic prompt in the slot is the only thing that can start a run, which
|
||
// is the basis for attributing a run to it by order.
|
||
//
|
||
// The seal is an allow-list. Pi's parser (cli/args.js) keeps the last --mode
|
||
// and the last --session, reads a bare word as a prompt and an `@` word as a
|
||
// file, so the argv must be exactly the controller's prefix followed by
|
||
// ENGINE_OPTIONS pairs, each at most once with one plain value.
|
||
|
||
import { readFileSync } from "node:fs";
|
||
import { isAbsolute, join } from "node:path";
|
||
import { createHash } from "node:crypto";
|
||
import { ControlRefusal } from "./safe-fs.mjs";
|
||
|
||
export const PI_PACKAGE = "@earendil-works/pi-coding-agent";
|
||
export const PI_VERSION = "0.85.1";
|
||
export const PI_INTEGRITY = "sha512-FGRN+OHbWaefBPGaTggAdLjrIHW+s2PzLyglz/5dfLzb9of7uuXMXYC0fJIeZTw+shS32o2cuQ9jF7YSDuL/oQ==";
|
||
export const PI_BIN = join("node_modules", PI_PACKAGE, "dist", "bundle", "cli.js");
|
||
export const SEAL_FLAGS = Object.freeze(["--no-extensions", "--no-prompt-templates", "--no-themes"]);
|
||
export const ENGINE_OPTIONS = Object.freeze(["--model", "--provider", "--thinking"]);
|
||
|
||
export const ENGINE_PIN_MISMATCH = "engine-pin-mismatch";
|
||
export const UNSEALED_ENGINE = "unsealed-engine";
|
||
|
||
function lockEntry(path) {
|
||
let lock;
|
||
try {
|
||
lock = JSON.parse(readFileSync(path, "utf8"));
|
||
} catch {
|
||
return null;
|
||
}
|
||
const entry = lock?.packages?.[`node_modules/${PI_PACKAGE}`];
|
||
return entry && typeof entry === "object" ? entry : null;
|
||
}
|
||
|
||
// `root` holds package-lock.json and node_modules/.package-lock.json.
|
||
export function checkEnginePin(root) {
|
||
for (const path of [join(root, "package-lock.json"), join(root, "node_modules", ".package-lock.json")]) {
|
||
const entry = lockEntry(path);
|
||
if (!entry || entry.version !== PI_VERSION || entry.integrity !== PI_INTEGRITY) {
|
||
throw new ControlRefusal(ENGINE_PIN_MISMATCH, `${path} does not pin ${PI_PACKAGE} ${PI_VERSION} with the pinned integrity`);
|
||
}
|
||
}
|
||
return { version: PI_VERSION, pin: PI_INTEGRITY };
|
||
}
|
||
|
||
export function buildPiArgs({ sessionFile, extraArgs = [] }) {
|
||
return ["--mode", "rpc", ...SEAL_FLAGS, "--session", sessionFile, ...extraArgs];
|
||
}
|
||
|
||
// Refuses any argv that is not `--mode rpc`, the three --no-* flags and
|
||
// `--session <absolute path>`, in that order, followed by ENGINE_OPTIONS
|
||
// pairs. That covers --extension in either spelling, a second --mode or
|
||
// --session, session and output flags (--no-session, --fork, --export, ...)
|
||
// and stray prompt words.
|
||
export function checkSeal(args) {
|
||
if (!Array.isArray(args) || args.some((a) => typeof a !== "string")) throw new ControlRefusal(UNSEALED_ENGINE, "launch argv is not a list of strings");
|
||
const extension = args.find((a) => a === "-e" || a === "--extension" || a.startsWith("--extension="));
|
||
if (extension !== undefined) throw new ControlRefusal(UNSEALED_ENGINE, `launch argv carries ${extension}`);
|
||
for (const flag of SEAL_FLAGS) {
|
||
if (!args.includes(flag)) throw new ControlRefusal(UNSEALED_ENGINE, `launch argv lacks ${flag}`);
|
||
}
|
||
const prefix = ["--mode", "rpc", ...SEAL_FLAGS, "--session"];
|
||
if (prefix.some((a, i) => args[i] !== a)) throw new ControlRefusal(UNSEALED_ENGINE, `launch argv does not start with ${prefix.join(" ")}`);
|
||
const file = args[prefix.length];
|
||
if (typeof file !== "string" || !isAbsolute(file)) throw new ControlRefusal(UNSEALED_ENGINE, "the --session value is not an absolute path");
|
||
const seen = new Set();
|
||
for (let i = prefix.length + 1; i < args.length; i += 2) {
|
||
const flag = args[i], value = args[i + 1];
|
||
if (!ENGINE_OPTIONS.includes(flag)) throw new ControlRefusal(UNSEALED_ENGINE, `launch argv carries ${flag}, which is not one of ${ENGINE_OPTIONS.join(", ")}`);
|
||
if (seen.has(flag)) throw new ControlRefusal(UNSEALED_ENGINE, `launch argv repeats ${flag}`);
|
||
if (typeof value !== "string" || !value || value.startsWith("-") || value.startsWith("@")) throw new ControlRefusal(UNSEALED_ENGINE, `${flag} needs one plain value`);
|
||
seen.add(flag);
|
||
}
|
||
return true;
|
||
}
|
||
|
||
export function argvDigest(command, args) {
|
||
return createHash("sha256").update(JSON.stringify([command, ...args])).digest("hex");
|
||
}
|