Files
stack/packages/conversation/src/pi-pin.mjs
T
jason.woltjeandClaude Opus 5.5 08b428ecf1 feat(webui,conversation): S5 WebUI views and CHAT-03 follow-ups (row 40, #1522)
Dewey's round 3 candidate, manifest
agents/dewey/work/queue-40/candidate-manifest-r3.sha256 (d0aa0ded,
27 files, checked OK in the canonical tree).

- WebUI inbox, tasks, agents and trail views, read-only over /api/bus.
  The README says the bus proof ends at the Console process.
- CHAT-03 seal: the engine command is fixed, the engine environment is
  explicit, SEAL_FLAGS has --no-approve, escalating is cleared on throw.
- Terminal input typed after Ctrl-T or Ctrl-O is held. Only the run whose
  own parse set held drains it (T1), and #run catches errors per action.
- DEFERRED keeps N2 and moves F2 to done, citing T1.

Reviews: Filbert approve (comment 27011, rev 260), Darkwing approve
(27013, rev 264). Landing gate on 8cad7722 plus the candidate: webui 22,
conversation 161, control-board 124, every scripts/test-*.sh green,
test-task 98/0. Mutant Mr survives; its flows test is the first
follow-up row.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 22:05:43 -05:00

115 lines
6.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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, --no-themes and --no-approve, and
// never an --extension argument (cli/args.js; usage.md 224 and 233–236).
// --no-approve sets the project trust override to false, so a project's
// .pi/settings.json, SYSTEM.md, APPEND_SYSTEM.md and skills don't load even
// when trust.json under the agent dir trusts it (main.js 574–581;
// usage.md 126; Filbert F2 on #1522). 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", "--no-approve"]);
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 four --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;
}
// The engine's environment (I3, Darkwing F3 on #1507): built from names,
// never inherited whole. ENGINE_ENV names what Pi needs to run; a launch may
// add provider credentials by name (`engine.envKeys`), and nothing else, so
// NODE_OPTIONS, LD_PRELOAD and the PI_PACKAGE_DIR family can't load code
// around the seal. Values come from the controller's own environment; an
// unset name is left out, not set empty.
export const ENGINE_ENV = Object.freeze(["PATH", "HOME", "USER", "LOGNAME", "SHELL", "LANG", "LC_ALL", "LC_CTYPE", "TZ", "TERM", "TMPDIR", "PI_CODING_AGENT_DIR", "PI_OFFLINE", "PI_SKIP_VERSION_CHECK", "PI_TELEMETRY"]);
export const CREDENTIAL_NAME = /^[A-Z][A-Z0-9_]{0,62}_(API_KEY|TOKEN)$/;
export function engineEnv(envKeys = [], source = process.env) {
if (!Array.isArray(envKeys)) throw new ControlRefusal(UNSEALED_ENGINE, "engine.envKeys is not a list");
const bad = envKeys.find((k) => typeof k !== "string" || !CREDENTIAL_NAME.test(k));
if (bad !== undefined) throw new ControlRefusal(UNSEALED_ENGINE, `engine.envKeys names ${String(bad).slice(0, 80)}, which is not a provider credential (*_API_KEY or *_TOKEN)`);
const env = {};
for (const k of [...ENGINE_ENV, ...envKeys]) if (typeof source[k] === "string") env[k] = source[k];
return env;
}
export function argvDigest(command, args) {
return createHash("sha256").update(JSON.stringify([command, ...args])).digest("hex");
}