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]>
This commit is contained in:
2026-10-09 22:05:43 -05:00
co-authored by Claude Opus 5.5
parent 8cad772282
commit 08b428ecf1
27 changed files with 1935 additions and 80 deletions
+64 -14
View File
@@ -267,23 +267,62 @@ Each is a reading of the brief or a lead decision, recorded so a reviewer
can disagree with it.
- **Seal.** The argv is `--mode rpc --no-extensions --no-prompt-templates
--no-themes --session <absolute file>`, then optional `engine.extraArgs`.
--no-themes --no-approve --session <absolute file>`, then optional
`engine.extraArgs`. `--no-approve` keeps a project's `.pi/settings.json`,
`SYSTEM.md`, `APPEND_SYSTEM.md` and skills out even when the operator's
`~/.pi/agent/trust.json` trusts the project, as it trusts this checkout
on the build host. Without it a project `SYSTEM.md` would replace the
system prompt and a project `settings.json` could choose the binary the
bash tool runs (`shellPath`) (Filbert F2 on #1522; smoke.test.mjs runs
the real Pi with a trusted project, sealed and with `--approve`).
The seal doesn't pass `--no-context-files`, so Pi still loads `AGENTS.md`
or `CLAUDE.md` from `~/.pi/agent`, the engine's working directory and
its parents into the system prompt (Pi's usage.md, "Context Files").
That is instruction text, not settings or code; whoever sets
`engine.cwd` chooses it (Darkwing's note on #1522).
The seal is an allow-list: extraArgs may carry only `--model`,
`--provider` and `--thinking`, each at most once with one plain value
(not starting with `-` or `@`). Anything else refuses `unsealed-engine`
at construction and again at bind, before spawn: an `-e`/`--extension`
argument, a missing `--no-*` flag, a second `--mode` or `--session` (Pi
keeps the last of each), a session or output flag (`--no-session`,
`--fork`, `--export`, `--print`, `--continue`, ...) or a bare word, which
Pi reads as a prompt (lead decision 31; N24, including the missing flag
argument, a missing `--no-*` flag, `--approve`, a second `--mode` or
`--session` (Pi keeps the last of each), a session or output flag
(`--no-session`, `--fork`, `--export`, `--print`, `--continue`, ...) or a
bare word, which Pi reads as a prompt (lead decision 31; N24, including the missing flag
through `checkSeal`).
- **Engine command.** `engine.command` and `engine.preArgs` default to
`node <pinRoot>/node_modules/@earendil-works/pi-coding-agent/dist/bundle/cli.js`.
A non-default value is a test hook for the fake engine. The pin check
reads only the lock files under `pinRoot` and the seal checks only Pi's
arguments, so neither says what runs under an overridden command. The
binding's `argvDigest` records the full command line. `preArgs` carrying
`--extension` still refuses.
- **Engine command and environment (slice 1 S5, #1522).** The controller
always launches `node <pinRoot>/node_modules/@earendil-works/pi-coding-agent/dist/bundle/cli.js`
and checks that at construction and again at bind. `engine` takes only
`extraArgs`, `cwd` and `envKeys`; any other key (`command`, `preArgs`,
`env`), or an `engine` that isn't an object, refuses `unsealed-engine`.
The fake engine comes in through `opts[TEST_ENGINE]`, a symbol key that
JSON config can't carry, so no config file reaches an unsealed command.
`preArgs` carrying `--extension` still refuses there too. The binding's
`argvDigest` records the full command line.
- **Engine environment.** The engine gets `ENGINE_ENV` (`pi-pin.mjs`):
`PATH`, `HOME`, `USER`, `LOGNAME`, `SHELL`, `LANG`, `LC_ALL`,
`LC_CTYPE`, `TZ`, `TERM`, `TMPDIR`, `PI_CODING_AGENT_DIR`, `PI_OFFLINE`,
`PI_SKIP_VERSION_CHECK` and `PI_TELEMETRY`, plus the names in
`engine.envKeys`, which must look like a provider credential
(`*_API_KEY` or `*_TOKEN`). The pattern also matches founder
credentials such as `GITEA_TOKEN`, which the S6 runner refuses; whoever
wires a launch must not name them (Filbert N2 on #1522, DEFERRED.md).
A name that is unset is left out. Nothing
else is inherited, so `NODE_OPTIONS`, `LD_PRELOAD` or `PI_PACKAGE_DIR`
in the controller's environment never reach Pi (N24b). The tradeoff:
`HOME` passes, so Pi reads the operator's `~/.pi/agent` unless
`PI_CODING_AGENT_DIR` is set. That is where its credentials and model
settings live, and dropping `HOME` would break them. `ScopeLauncher`
adds `XDG_RUNTIME_DIR` and `DBUS_SESSION_BUS_ADDRESS` so `systemd-run
--user` reaches the user manager; the engine inherits both, and neither
names code to load. The user bus does let the engine ask the user manager
to run a command outside its scope; that is the same-UID limit the
project already accepts (Filbert N3 on #1522). The engine also sees `INVOCATION_ID` from systemd,
and `PWD` (plus `SHLVL` under bash) from the shim's `/bin/sh`; none of
them comes from the controller's environment (K19).
- **A force stop that throws.** If admission or the poison throws after a
force stop sets `escalating`, the flag is cleared before the error
propagates, so the next force stop runs instead of refusing `fenced`
(Darkwing F2 on #1507; races.test.mjs).
- **Session key.** The claim's session key is the Pi header ID (D1), read
at construction. A hard link or a copy of a session under another seat
has a different conversation ID but the same header ID, so its
@@ -402,6 +441,17 @@ A thin view over the client library; it renders the same `Transcript` (E7).
`not admitted: controller` and sends nothing; the buffer is kept (S5).
- Enter takes the composer at that key: text after it in the same input
chunk starts the next message.
- Input after Ctrl-T or Ctrl-O waits until that action finishes, whether it
is in the same chunk or a later one, so it is judged as if typed one key
at a time. Ctrl-T then `hi` and Enter in one chunk sends `hi` once the
takeover lands; `hi`, Ctrl-T and Enter sends nothing, because the
transfer clears the composer. The held input waits for its own Ctrl-T
or Ctrl-O, not for an earlier action such as a slow Ctrl-G (Darkwing
and Filbert T1 on #1522). If an action throws, the keys after it, the
input held behind it and later input still run, in order (Filbert F2 on
#1507, N1 on #1522). The terminal command shows the error in the status
line (`failed: <reason>`) when it happens, instead of exiting, so a later
status such as `prompt: admitted` is not overwritten (Filbert N4).
- A bracketed paste is inserted literally, newlines included, and never
submits by itself. A paste marker split across input chunks, even right
after its ESC, is still a paste marker; a lone trailing ESC waits for the
@@ -495,8 +545,8 @@ no prompt:
|---|---|
| `claim.test.mjs` | W1–W17, W20, G1–G3: the writer claim, crash barriers, the guard |
| `races.test.mjs` | H1–H4, H9–H23: takeover, Interrupt and force stop, retries, incarnations |
| `turns.test.mjs` | N1–N25: the turn tracker against the fake engine's Pi behaviors |
| `cohort.test.mjs` | K1–K18: scopes, force stop, proofs, recovery, eligibility (needs a systemd user manager) |
| `turns.test.mjs` | N1–N25, N24b: the turn tracker against the fake engine's Pi behaviors; the engine seal and environment |
| `cohort.test.mjs` | K1–K19: scopes, force stop, proofs, recovery, eligibility, the scope's environment (needs a systemd user manager) |
| `flows.test.mjs` | S1–S7, P3, E1–E7, the terminal, and a CHAT-01 schema check of every record produced |
| `smoke.test.mjs` | the pinned Pi binary, as above |
+7 -1
View File
@@ -95,9 +95,15 @@ export class ScopeLauncher {
this.startTimeoutMs = startTimeoutMs;
}
// systemd-run --user reaches the user manager through these two; a scope's
// command inherits systemd-run's environment, so the engine sees them too.
// Neither loads code.
static MANAGER_ENV = Object.freeze(["XDG_RUNTIME_DIR", "DBUS_SESSION_BUS_ADDRESS"]);
async launch({ unitName, socketPath, command, args, cwd, env }) {
const manager = Object.fromEntries(ScopeLauncher.MANAGER_ENV.filter((k) => typeof process.env[k] === "string").map((k) => [k, process.env[k]]));
const proc = spawn("systemd-run", ["--user", "--scope", "-p", "Delegate=yes", `--unit=${unitName}`, "--quiet", "--", process.execPath, this.shimPath, "--socket", socketPath, "--", command, ...args], {
cwd, env, stdio: ["pipe", "pipe", "pipe"],
cwd, env: { ...manager, ...env }, stdio: ["pipe", "pipe", "pipe"],
});
const exited = new Promise((r) => proc.on("exit", (code, signal) => r({ code, signal })));
const end = Date.now() + this.startTimeoutMs;
+33 -8
View File
@@ -30,7 +30,7 @@ import { LineSplitter, encodeLine, parseLine } from "./framing.mjs";
import { LiveSessionGuard, realPath } from "./guard.mjs";
import { ID, fragments, safeId } from "./parts.mjs";
import { parseSnapshot } from "./pi.mjs";
import { ENGINE_PIN_MISMATCH, PI_BIN, UNSEALED_ENGINE, argvDigest, buildPiArgs, checkEnginePin, checkSeal } from "./pi-pin.mjs";
import { ENGINE_PIN_MISMATCH, PI_BIN, UNSEALED_ENGINE, argvDigest, buildPiArgs, checkEnginePin, checkSeal, engineEnv } from "./pi-pin.mjs";
import { ACTOR, conversationId, createReader, rootsFromSpecs } from "./reader.mjs";
import { clone, equal, hash, newId, receiptAllows, record, scopeMatch, sealProof, sha256, targetOf } from "./records.mjs";
import { ControlRefusal, Refusal } from "./safe-fs.mjs";
@@ -66,6 +66,12 @@ export const ALL_CAPABILITIES = Object.freeze(["observe", "send", "take-control"
// CHAT-04 or I4 and refuse `unsupported-capability`.
export const VERIFIED_OPERATIONS = Object.freeze(["observe", "prompt", "takeover", "acquire-recovery-control", "interrupt", "force-stop", "recover", "issue-confirmation", "answer-confirmation"]);
// The engine a test runs instead of Pi: `{ command, preArgs, env }`. A
// symbol key, so no JSON configuration can carry it; the plain `engine`
// option takes only extraArgs, cwd and envKeys (I3, both reviewers on #1507).
export const TEST_ENGINE = Symbol("conversation.test-engine");
const ENGINE_KEYS = new Set(["extraArgs", "cwd", "envKeys"]);
export const TIMEOUTS = Object.freeze({ ack: 5000, state: 5000, start: 5000, clear: 5000, abort: 10000, settle: 10000, grace: 1000, write: 5000, maxRounds: 3 });
const FINAL = new Set(["finished", "failed", "dispatch-refused", "delivery-unknown"]);
@@ -160,17 +166,24 @@ export class Controller {
this.project = project;
this.workspace = workspace;
this.conversation = conversationId(this.root, basename(this.paths.sessionFile));
if (!engine || typeof engine !== "object" || Array.isArray(engine)) throw new ControlRefusal(UNSEALED_ENGINE, "engine is not an object");
const extra = Object.keys(engine).find((k) => !ENGINE_KEYS.has(k));
if (extra !== undefined) throw new ControlRefusal(UNSEALED_ENGINE, `engine.${extra.slice(0, 40)} can't be configured; the controller launches the pinned Pi with its own environment`);
const test = opts[TEST_ENGINE] ?? null;
this.engine = {
command: engine.command ?? process.execPath,
preArgs: engine.preArgs ?? [join(pinRoot, PI_BIN)],
sealed: test === null,
command: test ? test.command : process.execPath,
preArgs: test ? test.preArgs : [join(pinRoot, PI_BIN)],
extraArgs: engine.extraArgs ?? [],
cwd: engine.cwd ?? projectRoot,
env: engine.env ?? process.env,
env: test ? test.env : engineEnv(engine.envKeys),
};
for (const k of ["preArgs", "extraArgs"]) {
if (!Array.isArray(this.engine[k])) throw new ControlRefusal(UNSEALED_ENGINE, `engine.${k} is not a list`);
}
if (typeof this.engine.command !== "string" || !this.engine.command) throw new ControlRefusal(UNSEALED_ENGINE, "the engine command is not a path");
this.piArgs = buildPiArgs({ sessionFile: this.paths.sessionFile, extraArgs: this.engine.extraArgs });
this.pinRoot = pinRoot;
this.#checkSeal();
this.launcher = launcher;
this.verifier = verifier;
@@ -178,7 +191,6 @@ export class Controller {
this.barrier = barrier;
this.now = now;
this.T = { ...TIMEOUTS, ...timeouts };
this.pinRoot = pinRoot;
this.policyRevision = policyRevision;
this.sourceRootRef = sourceRootRef;
this.approvedMappings = approvedMappings ?? [sourceRootRef];
@@ -232,7 +244,12 @@ export class Controller {
if (this.barrier) await this.barrier(name, detail);
}
// The seal covers the command: unless a test engine was given, the launch
// is this Node running the pinned Pi's bin, and nothing else.
#checkSeal() {
if (this.engine.sealed && (this.engine.command !== process.execPath || this.engine.preArgs.length !== 1 || this.engine.preArgs[0] !== join(this.pinRoot, PI_BIN))) {
throw new ControlRefusal(UNSEALED_ENGINE, "the engine command is not the pinned Pi");
}
const bad = this.engine.preArgs.find((a) => typeof a !== "string" || a === "-e" || a === "--extension" || a.startsWith("--extension="));
if (bad !== undefined) throw new ControlRefusal(UNSEALED_ENGINE, `engine pre-arguments carry ${bad}`);
checkSeal(this.piArgs);
@@ -684,9 +701,17 @@ export class Controller {
if (!this.#checkConfirmation(r, c, op)) return refused("confirmation");
const s = this.#startStop("force-stop", { requestId: r.id, connection: c.id, target: t });
this.escalating = s.id;
this.closers.add("force-stop");
this.#admission();
this.exec?.link?.poison("force-stop");
try {
this.closers.add("force-stop");
this.#admission();
this.exec?.link?.poison("force-stop");
} catch (err) {
// #handle drops `after` on a throw, so #forceStop never runs to clear
// the flag; without this every later force stop is refused `fenced`
// until restart (Darkwing F2 on #1507).
if (this.escalating === s.id) this.escalating = null;
throw err;
}
return { outcome: "force-stop-fenced", stop: s, after: () => this.#forceStop(s, { confirmation: cmd.confirmation }) };
}
if (op === "recover") return this.#recover(c, r);
+30 -8
View File
@@ -7,12 +7,16 @@
// 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.
// --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
@@ -28,7 +32,7 @@ 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 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";
@@ -60,7 +64,7 @@ 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
// 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, ...)
@@ -87,6 +91,24 @@ export function checkSeal(args) {
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");
}
+81 -6
View File
@@ -14,7 +14,9 @@
//
// Keys: Enter submits; Ctrl-J or Alt-Enter adds a newline; Ctrl-T takes
// control; Ctrl-G interrupts; Ctrl-O reconnects if needed and re-reads the
// page; PageUp and PageDown scroll; Ctrl-C or Ctrl-D quits.
// page; PageUp and PageDown scroll; Ctrl-C or Ctrl-D quits. Input after a
// Ctrl-T or Ctrl-O waits until that finishes, in the same chunk or a later
// one, so it is judged as if typed one key at a time.
//
// Engine text is shown with control characters made visible, so transcript
// content can't drive the operator's terminal.
@@ -26,6 +28,10 @@ import { Transcript } from "./transcript.mjs";
export const NOT_CONTROLLER = "not admitted: controller";
const PASTE_START = "\x1b[200~";
const PASTE_END = "\x1b[201~";
// Keys whose action can change who holds control. Input after one waits until
// it finishes, so an Enter in the same chunk is judged as the keys would be
// one at a time (Filbert F2 on #1507).
const HOLDS = new Set(["takeover", "reload"]);
const KEYS = Object.freeze({ "\r": "submit", "\n": "newline", "\x7f": "backspace", "\b": "backspace", "\x14": "takeover", "\x07": "interrupt", "\x0f": "reload", "\x03": "quit", "\x04": "quit" });
// Control characters, line and paragraph separators, bidi controls, invisible
@@ -65,6 +71,7 @@ export class Terminal {
this.frame = [];
this.sent = 0;
this.queue = Promise.resolve();
this.held = null;
client.on((m) => this.#onClient(m));
this.transcript.on(() => this.render());
}
@@ -91,7 +98,71 @@ export class Terminal {
}
// Feeds raw terminal input. Resolves when the actions it started finish.
// While a takeover or reload is pending, input is held, not parsed.
key(data) {
return this.#feed(data, null);
}
// Feeds terminal input like key(), but an action's error goes to the
// status line when it happens instead of rejecting, so an unhandled
// rejection can't end the process (Ctrl-T before the handshake throws "not
// connected"), and a later status, such as the held Enter's "prompt:
// admitted", is never overwritten by an earlier error (Filbert N4 on #1522).
input(data) {
return this.#feed(data, (err) => {
this.status = `failed: ${err.refusal ?? err.message}`;
this.render();
});
}
// Held input joins the run of the takeover or reload that holds it: its
// promise settles when that run finishes, and an error there belongs to
// the call that started the run, not to this one.
#feed(data, report) {
if (this.held !== null) {
this.held += data;
return this.queue.catch(() => {});
}
const actions = this.#parse(data);
const holds = this.held !== null;
// A chunk runs after the one before it whether that one finished or
// threw; the throw belongs to the call that started it, and later input
// still runs (Filbert N1 on #1522).
this.queue = this.queue.catch(() => {}).then(() => this.#run(actions, holds, report));
return report ? this.queue.catch(() => {}) : this.queue;
}
// Runs one chunk's actions in order. If that chunk's parse set `held`
// (`holds`), it then parses and runs what was held behind its takeover or
// reload. Only that run drains it: an earlier chunk's run that finishes
// first leaves it, so the held Enter is judged after the takeover (Darkwing
// T1 on #1522). An action that throws doesn't stop the ones after it or
// the held input, as if each key came on its own. With `report` an error
// is reported when it happens; without, the first one is rethrown at the
// end.
async #run(actions, holds, report) {
let failure = null;
while (actions) {
for (const run of actions) {
try {
await run();
} catch (err) {
if (report) report(err);
else failure ??= err;
}
}
if (!holds) break;
const rest = this.held;
this.held = null;
actions = this.#parse(rest);
holds = this.held !== null;
}
if (failure) throw failure;
}
// Applies a chunk to the composer and returns the actions it starts. After
// a HOLDS action the rest of the chunk goes to `held`.
#parse(data) {
const actions = [];
let s = this.carry + data;
this.carry = "";
@@ -138,13 +209,17 @@ export class Terminal {
// chunk starts the next message instead of joining this one.
const text = this.#take();
if (text !== null) actions.push(() => this.#send(text));
} else if (action) actions.push(() => this.#act(action));
else if (s[i] >= " ") this.composer += s[i];
} else if (action) {
actions.push(() => this.#act(action));
if (HOLDS.has(action)) {
this.held = s.slice(i + 1);
break;
}
} else if (s[i] >= " ") this.composer += s[i];
i += 1;
}
this.render();
for (const run of actions) this.queue = this.queue.then(run);
return this.queue;
return actions;
}
async #act(action) {
@@ -272,7 +347,7 @@ async function main() {
term.rows = stdout.rows || 24;
term.render();
});
stdin.on("data", (c) => void term.key(c.toString("utf8")));
stdin.on("data", (c) => void term.input(c.toString("utf8")));
stdin.on("end", quit);
term.render();
}
+32 -6
View File
@@ -1,9 +1,10 @@
// CHAT-03 §6 stop, cohort proof and recovery (#1507): K1–K18. The scope
// CHAT-03 §6 stop, cohort proof and recovery (#1507): K1–K19. The scope
// fixtures run the fake engine as a real process under ScopeLauncher, so the
// shim, the `engine` cgroup and systemd's invocation ID are all real; they
// skip when systemd user scopes are unavailable. K2 runs on the process-group
// fallback. K6–K9 and K16–K18 use the in-process fake, whose force stop is a
// fixture stand-in (see FakeLauncher). Controllers that must die run in
// fixture stand-in (see FakeLauncher). K19 launches /bin/sleep through
// ScopeLauncher with no controller. Controllers that must die run in
// ctrl-child.mjs.
import { test, after } from "node:test";
@@ -11,11 +12,11 @@ import assert from "node:assert/strict";
import { appendFileSync, chmodSync, copyFileSync, mkdirSync, readFileSync, rmdirSync, writeFileSync } from "node:fs";
import { spawn, spawnSync } from "node:child_process";
import { dirname, join } from "node:path";
import { ClaimStore, FOREIGN_HOST } from "../src/claim.mjs";
import { ClaimStore, FOREIGN_HOST, newClaimId, unitNameFor } from "../src/claim.mjs";
import { ConversationClient } from "../src/client.mjs";
import { AUTHORITY, PgroupLauncher, ScopeLauncher, scopeAvailable, shimRequest, systemctlShow, systemdUnits } from "../src/cohort.mjs";
import { Controller, ELIGIBILITY } from "../src/controller.mjs";
import { ENGINE_PIN_MISMATCH } from "../src/pi-pin.mjs";
import { Controller, ELIGIBILITY, TEST_ENGINE } from "../src/controller.mjs";
import { ENGINE_ENV, ENGINE_PIN_MISMATCH, engineEnv } from "../src/pi-pin.mjs";
import { FixtureVerifier, newId } from "../src/records.mjs";
import { ControlClient, FakeLauncher } from "./fake-pi.mjs";
import { FAST, REPO, assistantEntry, claimRecords, cleanupAll, controllerFor, fixture, killChildren, noUnits, reap, receiptState, spawnController, started, tick } from "./harness.mjs";
@@ -95,7 +96,7 @@ async function live({ kind = "scope", barrier = null, verifier = new FixtureVeri
const ctrl = new Controller({
fixtureRoot: fx.base, claimRoot: fx.claimRoot, socketDir: fx.socketDir, sessionFile: fx.sessionFile, seat: fx.seat,
launcher: kind === "scope" ? new ScopeLauncher() : new PgroupLauncher(),
engine: { command: process.execPath, preArgs: [FAKE_PI], env: { ...process.env, FAKE_PI_CONTROL: control, FAKE_PI_LOG: join(fx.base, "fake.log") }, cwd: fx.proj },
engine: { cwd: fx.proj }, [TEST_ENGINE]: { command: process.execPath, preArgs: [FAKE_PI], env: { ...process.env, FAKE_PI_CONTROL: control, FAKE_PI_LOG: join(fx.base, "fake.log") } },
verifier, units: kind === "scope" ? systemdUnits : noUnits, timeouts: FAST, barrier,
});
await ctrl.start();
@@ -714,3 +715,28 @@ test("K18: the leaf changes after eligibility: launch refused; the reservation s
await h.close();
}
});
test("K19: a scope launched with only the engine environment still reaches the user manager; the engine sees no other names", NEEDS_SCOPE, async () => {
// A real launch passes engineEnv(), which names neither variable systemd-run
// needs; ScopeLauncher.MANAGER_ENV adds them (slice 1 S5, #1522).
if (!ScopeLauncher.MANAGER_ENV.some((k) => typeof process.env[k] === "string")) return;
const fx = track(fixture());
mkdirSync(fx.socketDir, { recursive: true });
const unitName = unitNameFor(newClaimId());
const env = engineEnv([]);
for (const k of ScopeLauncher.MANAGER_ENV) assert.equal(k in env, false, k);
const socketPath = join(fx.socketDir, "k19.sock");
const proc = await new ScopeLauncher().launch({ unitName, socketPath, command: "/bin/sleep", args: ["60"], cwd: fx.proj, env });
try {
const names = readFileSync(`/proc/${proc.pid}/environ`, "utf8").split("\0").filter(Boolean).map((e) => e.slice(0, e.indexOf("=")));
// systemd-run sets INVOCATION_ID; the shim's /bin/sh sets PWD, and SHLVL
// and _ when it is bash. None comes from the controller's environment.
const set = ["INVOCATION_ID", "PWD", "OLDPWD", "SHLVL", "_"];
for (const k of names) assert.ok(ENGINE_ENV.includes(k) || ScopeLauncher.MANAGER_ENV.includes(k) || set.includes(k), k);
for (const k of ScopeLauncher.MANAGER_ENV) if (typeof process.env[k] === "string") assert.ok(names.includes(k), k);
} finally {
assert.equal((await shimRequest(socketPath, "kill", { timeoutMs: 5000 }, 8000)).ok, true);
assert.equal((await shimRequest(socketPath, "release")).ok, true);
await proc.exited;
}
});
+2 -2
View File
@@ -15,7 +15,7 @@
import { createInterface } from "node:readline";
import { join } from "node:path";
import { Controller } from "../src/controller.mjs";
import { Controller, TEST_ENGINE } from "../src/controller.mjs";
import { AUTHORITY, PgroupLauncher, ScopeLauncher, systemdUnits } from "../src/cohort.mjs";
import { FixtureVerifier } from "../src/records.mjs";
import { defaultHost } from "../src/claim.mjs";
@@ -75,7 +75,7 @@ try {
ctrl = new Controller({
fixtureRoot: fx.base, claimRoot: fx.claimRoot, socketDir: cfg.socketDir ?? fx.socketDir, sessionFile: fx.sessionFile, seat: fx.seat,
launcher: cfg.launcher === "scope" ? new ScopeLauncher() : new PgroupLauncher(),
engine: { command: process.execPath, preArgs: [fakePi], env: { ...process.env, ...(cfg.fakeEnv ?? {}) }, cwd: fx.proj },
engine: { cwd: fx.proj }, [TEST_ENGINE]: { command: process.execPath, preArgs: [fakePi], env: { ...process.env, ...(cfg.fakeEnv ?? {}) } },
verifier: cfg.verifier === false ? null : new FixtureVerifier({ authorities: [AUTHORITY] }),
units: cfg.units === "absent" ? { lookup: async () => ({ state: "absent" }) } : systemdUnits,
host, barrier, timeouts: cfg.timeouts,
+2 -2
View File
@@ -486,8 +486,8 @@ export class FakeLauncher {
this.launches = [];
}
async launch({ unitName, command, args, cwd }) {
this.launches.push({ unitName, command, args, cwd });
async launch({ unitName, command, args, cwd, env }) {
this.launches.push({ unitName, command, args, cwd, env });
const toEngine = new PassThrough();
const fromEngine = new PassThrough();
const stderr = new PassThrough();
+190
View File
@@ -584,6 +584,196 @@ test("terminal: a paste-start marker split right after its ESC still opens the p
assert.equal(term.composer, "x\n");
});
// An observer stub whose takeover (or reconnect) waits on a gate, then makes
// this connection the controller and pushes the binding before it resolves,
// as the controller does.
function takeoverStub({ grant = true, closed = false } = {}) {
const { stub, sent } = promptStub();
let listener = () => {};
let open;
const gate = new Promise((r) => (open = r));
const become = () => {
stub.isController = true;
stub.binding = { state: "active", controllerConnection: "conn-1" };
listener({ type: "push", kind: "binding" });
};
Object.assign(stub, {
closed, isController: false, binding: { state: "active", controllerConnection: "conn-2" },
on: (fn) => (listener = fn),
takeover: async () => {
await gate;
if (!grant) return { outcome: "refused:controller", refusal: "controller" };
become();
return { outcome: "transferred", refusal: null };
},
connect: async () => (await gate, (stub.closed = false), become()),
});
return { stub, sent, open };
}
test("terminal: Ctrl-T then Enter in one chunk is judged after the takeover, as if typed one key at a time (Filbert F2, #1507)", async () => {
{
const { stub, sent, open } = takeoverStub();
const term = new Terminal({ client: stub });
const done = term.key("\x14hi\r");
open();
await done;
assert.deepEqual(sent, ["hi"], "text typed after the takeover is sent");
assert.equal(term.status, "prompt: admitted");
}
{
// §4: text typed before the takeover is cleared by the transfer, so the
// Enter after it sends nothing.
const { stub, sent, open } = takeoverStub();
const term = new Terminal({ client: stub });
const done = term.key("hi\x14\r");
open();
await done;
assert.deepEqual(sent, []);
assert.equal(term.composer, "");
}
{
// A refused takeover leaves an observer: the Enter is refused and the
// text stays for the operator.
const { stub, sent, open } = takeoverStub({ grant: false });
const term = new Terminal({ client: stub });
const done = term.key("\x14hi\r");
open();
await done;
assert.deepEqual(sent, []);
assert.equal(term.composer, "hi");
assert.equal(term.status, NOT_CONTROLLER);
}
{
// Input in later chunks waits behind the pending takeover and keeps its
// order, including a paste split across the hold.
const { stub, sent, open } = takeoverStub();
const term = new Terminal({ client: stub });
const first = term.key("\x14a");
const second = term.key(`b${PASTE_START}c\rd`);
const third = term.key(`${PASTE_END}e\rf`);
assert.equal(term.composer, "", "nothing is parsed while the takeover is pending");
open();
await Promise.all([first, second, third]);
assert.deepEqual(sent, ["abc\rde"]);
assert.equal(term.composer, "f");
}
{
// Ctrl-O reconnecting a closed client holds the same way.
const { stub, sent, open } = takeoverStub({ closed: true });
const term = new Terminal({ client: stub });
const done = term.key("\x0fhi\r");
open();
await done;
assert.deepEqual(sent, ["hi"]);
}
});
test("terminal: input held behind Ctrl-T waits for that takeover while an earlier action is still pending (Darkwing T1 on #1522)", async () => {
// An observer presses Ctrl-G (a slow interrupt), then Ctrl-T and "hi"
// Enter. The interrupt finishing first must not release the text held
// behind the takeover.
for (const chunks of [["\x07", "\x14", "hi\r"], ["\x07", "\x14hi\r"], ["\x07\x14hi\r"]]) {
const { stub, sent, open } = takeoverStub();
const log = [];
let openInterrupt;
const interruptGate = new Promise((r) => (openInterrupt = r));
stub.interrupt = async () => {
log.push("interrupt start");
await interruptGate;
log.push("interrupt end");
return { outcome: "refused:controller", refusal: "controller" };
};
const takeover = stub.takeover;
stub.takeover = async () => (log.push("takeover start"), await takeover(), log.push("takeover end"), { outcome: "transferred", refusal: null });
const prompt = stub.prompt;
stub.prompt = async (t) => (log.push(`prompt ${t}`), prompt(t));
const term = new Terminal({ client: stub });
const done = Promise.all(chunks.map((c) => term.key(c)));
openInterrupt();
await new Promise((r) => setTimeout(r, 10));
assert.deepEqual(sent, [], `${JSON.stringify(chunks)}: nothing is sent before the takeover finishes`);
assert.notEqual(term.held, null, `${JSON.stringify(chunks)}: the text is still held`);
open();
await done;
assert.deepEqual(sent, ["hi"], JSON.stringify(chunks));
assert.equal(term.composer, "");
assert.equal(term.status, "prompt: admitted");
assert.deepEqual(log, ["interrupt start", "interrupt end", "takeover start", "takeover end", "prompt hi"], JSON.stringify(chunks));
}
});
test("terminal: an action that throws still releases the input held behind it, in order, then rethrows", async () => {
const { stub, sent, open } = takeoverStub();
stub.takeover = async () => {
await new Promise((r) => setTimeout(r, 5));
throw new Error("boom");
};
const term = new Terminal({ client: stub });
const done = term.key("\x14x\r");
open();
await assert.rejects(done, /boom/);
assert.equal(term.held, null);
assert.equal(term.composer, "x", "the held text was parsed; the Enter was refused as an observer");
assert.deepEqual(sent, []);
});
test("terminal: after an action throws, later input still runs; input() puts the error in the status line (Filbert N1 on #1522)", async () => {
const { stub, sent, open } = takeoverStub();
const takeover = stub.takeover;
let calls = 0;
stub.takeover = async () => {
calls += 1;
if (calls === 1) throw new Error("not connected");
return takeover();
};
const term = new Terminal({ client: stub });
await assert.rejects(term.key("\x14"), /not connected/);
// The queue is rejected now; the next chunk still runs its actions.
const done = term.key("\x14hi\r");
open();
await done;
assert.deepEqual(sent, ["hi"]);
assert.equal(term.composer, "");
// input() never rejects: the error lands in the status line.
stub.takeover = async () => {
throw new Error("not connected");
};
await term.input("\x14");
assert.equal(term.status, "failed: not connected");
});
test("terminal: input() reports an error when it happens, so it never overwrites a later status; held input's promise doesn't carry the holder's error (Filbert N4 on #1522)", async () => {
// Ctrl-G throws. The takeover after it still runs, as it would if typed on
// its own, and the held Enter is admitted: the status ends on the
// admission, not the old error.
for (const chunks of [["\x07\x14hi\r"], ["\x07\x14", "hi\r"]]) {
const { stub, sent, open } = takeoverStub();
stub.interrupt = async () => {
throw new Error("boom");
};
const term = new Terminal({ client: stub });
const done = Promise.all(chunks.map((c) => term.input(c)));
open();
await done;
assert.deepEqual(sent, ["hi"], JSON.stringify(chunks));
assert.equal(term.status, "prompt: admitted", JSON.stringify(chunks));
}
// Through key() the error belongs to the call that started the run; the
// held chunk's call resolves when that run finishes.
const { stub, sent, open } = takeoverStub();
stub.interrupt = async () => {
throw new Error("boom");
};
const term = new Terminal({ client: stub });
const holder = term.key("\x07\x14");
const held = term.key("hi\r");
open();
await assert.rejects(holder, /boom/);
await held;
assert.deepEqual(sent, ["hi"]);
});
test("terminal: invisible and bidi characters are made visible; head, status and notice lines stay one line", () => {
assert.equal(visible("a\u061cb\u200bc\u2060d\ufeffe\u{e0041}f"), "a<U+061C>b<U+200B>c<U+2060>d<U+FEFF>e<U+E0041>f");
assert.equal(visible("\u{1F469}\u200d\u{1F4BB}"), "\u{1F469}\u200d\u{1F4BB}", "ZWJ sequences pass");
@@ -563,6 +563,30 @@ test("H10: a second force stop while the first escalation runs refuses fenced; o
}
});
test("a force stop whose fence throws leaves no escalation flag behind, so the next force stop runs (Darkwing F2, #1507)", async () => {
const h = await started();
try {
const link = h.ctrl.exec.link;
const poison = link.poison;
link.poison = () => {
throw new Error("poison failed");
};
const failed = await h.client.request("force-stop", { confirmation: await confirm(h.client, "force-stop") });
assert.equal(failed.outcome, "error", JSON.stringify(failed));
assert.equal(h.ctrl.escalating, null, "the flag is cleared on the throw");
assert.equal(h.launcher.stops ?? 0, 0, "no escalation ran");
link.poison = poison;
await synced(h, h.client);
const next = await h.client.request("force-stop", { confirmation: await confirm(h.client, "force-stop") });
assert.equal(next.outcome, "force-stop-fenced", JSON.stringify(next));
await waitState(h, "stopped");
assert.equal(h.launcher.stops, 1);
assert.equal(h.ctrl.escalating, null);
} finally {
await h.close();
}
});
test("H17: a confirmation reused, answered from another connection, or used after the stop changed is refused", async () => {
// Reused while its force stop is still running.
{
+28 -6
View File
@@ -11,7 +11,7 @@
import { test, after } from "node:test";
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { PassThrough } from "node:stream";
@@ -26,11 +26,11 @@ after(() => rmSync(scratch, { recursive: true, force: true }));
const COMMANDS = ["get_state", "get_commands", "clear_queue", "abort", "get_tree"];
const exchanges = {};
function session(name, entries) {
const dir = join(scratch, "proj", ".pi", "state", "smoke", "sessions");
function session(name, entries, project = "proj") {
const dir = join(scratch, project, ".pi", "state", "smoke", "sessions");
mkdirSync(dir, { recursive: true });
const file = join(dir, name);
writeFileSync(file, [header(join(scratch, "proj")), ...entries].map((v) => JSON.stringify(v) + "\n").join(""));
writeFileSync(file, [header(join(scratch, project)), ...entries].map((v) => JSON.stringify(v) + "\n").join(""));
return file;
}
@@ -82,13 +82,17 @@ async function converse(input, output, { onExit = null } = {}) {
return { lines, responses };
}
async function realPi(tag, sessionFile) {
// `trusted` writes the agent dir's trust.json first, marking that project
// trusted the way an operator's `~/.pi/agent/trust.json` can; `after` is
// argv appended past the seal, which checkSeal would refuse.
async function realPi(tag, sessionFile, { project = "proj", trusted = null, after = [] } = {}) {
const { home, agent, env } = scratchEnv(tag);
assert.equal(existsSync(join(home, ".pi", "agent", "auth.json")), false);
assert.deepEqual(readdirSync(agent), [], "the agent dir starts empty");
if (trusted) writeFileSync(join(agent, "trust.json"), JSON.stringify({ [realpathSync(join(scratch, trusted))]: true }));
const args = buildPiArgs({ sessionFile });
checkSeal(args);
const pi = spawn(process.execPath, [join(REPO, PI_BIN), ...args], { cwd: join(scratch, "proj"), env, stdio: ["pipe", "pipe", "pipe"] });
const pi = spawn(process.execPath, [join(REPO, PI_BIN), ...args, ...after], { cwd: join(scratch, project), env, stdio: ["pipe", "pipe", "pipe"] });
let stderr = "";
pi.stderr.on("data", (c) => (stderr += c));
const exited = new Promise((r) => pi.on("exit", (code, signal) => r({ code, signal })));
@@ -161,6 +165,24 @@ test("pinned Pi, sealed and without credentials, answers the controller's comman
}
});
test("sealed, pinned Pi ignores a trusted project's .pi resources; --approve past the seal would load them, and checkSeal refuses it (Filbert F2 on #1522)", async () => {
const skill = join(scratch, "trusted", ".pi", "skills", "probe");
mkdirSync(skill, { recursive: true });
writeFileSync(join(skill, "SKILL.md"), "---\nname: probe\ndescription: A project skill that only a trusted load offers.\n---\nprobe\n");
const entries = [thinkingEntry(), userEntry("a1b2c3d4", "f0e1d2c3", "hello"), assistantEntry("b2c3d4e5", "a1b2c3d4", "hi")];
const offered = (side) => (side.responses.get_commands.data?.commands ?? []).map((c) => c.name);
const sealed = await realPi("trust-sealed", session("trust-sealed.jsonl", entries, "trusted"), { project: "trusted", trusted: "trusted" });
assert.deepEqual(offered(sealed), ["llama"], "the project skill is not loaded under the seal");
// The control: the same trusted project, with --approve after the seal (Pi
// keeps the last of --approve and --no-approve), loads the skill, so the
// assertion above can see a load.
const approved = await realPi("trust-approved", session("trust-approved.jsonl", entries, "trusted"), { project: "trusted", trusted: "trusted", after: ["--approve"] });
assert.ok(offered(approved).includes("skill:probe"), `with --approve: ${offered(approved)}`);
for (const flag of ["--approve", "-a"]) {
assert.throws(() => checkSeal([...buildPiArgs({ sessionFile: "/s.jsonl" }), flag]), /not one of/, flag);
}
});
test("pinned Pi appends thinking_level_change at start when the branch lacks one, so the leaf moves (K8 then fails closed)", async () => {
const entries = [userEntry("a1b2c3d4", null, "hello"), assistantEntry("b2c3d4e5", "a1b2c3d4", "hi")];
const realFile = session("bare-real.jsonl", entries);
+63 -3
View File
@@ -6,10 +6,10 @@ import { test, after } from "node:test";
import assert from "node:assert/strict";
import { PassThrough } from "node:stream";
import { join } from "node:path";
import { Controller, HANDLED_WITHOUT_RUN, ACK_WITHOUT_START, NO_TURN, RUN_OVERLAP, TRANSPORT_UNKNOWN } from "../src/controller.mjs";
import { Controller, TEST_ENGINE, REPO_ROOT, HANDLED_WITHOUT_RUN, ACK_WITHOUT_START, NO_TURN, RUN_OVERLAP, TRANSPORT_UNKNOWN } from "../src/controller.mjs";
import { AUTHORITY } from "../src/cohort.mjs";
import { FixtureVerifier, sha256 } from "../src/records.mjs";
import { SEAL_FLAGS, UNSEALED_ENGINE, checkSeal } from "../src/pi-pin.mjs";
import { ENGINE_ENV, PI_BIN, SEAL_FLAGS, UNSEALED_ENGINE, checkSeal } from "../src/pi-pin.mjs";
import { LineSplitter, encodeLine, parseLine } from "../src/framing.mjs";
import { BUSY_ERROR, FakeLauncher, FakePi } from "./fake-pi.mjs";
import { fixture, started, receiptState, outcomeUnknownPush, sessionText, cleanupAll, tick, noUnits, FAST } from "./harness.mjs";
@@ -970,7 +970,7 @@ test("N24: the seal is an allow-list: --extension, a missing --no-* flag, a seco
assert.throws(() => checkSeal(args), (e) => e.code === UNSEALED_ENGINE, JSON.stringify(args));
}
assert.equal(checkSeal(["--mode", "rpc", ...SEAL_FLAGS, "--session", fx.sessionFile, "--model", "m", "--provider", "p", "--thinking", "off"]), true);
// The argv a real bind launches carries all three and no --extension.
// The argv a real bind launches carries every seal flag and no --extension.
const h = await started({ fx: fixture() });
try {
const args = h.launcher.launches[0].args;
@@ -981,3 +981,63 @@ test("N24: the seal is an allow-list: --extension, a missing --no-* flag, a seco
await h.close();
}
});
test("N24b: the seal covers the engine command and environment: config can't name either, the env is built from names, and a mutated command is refused at bind", async () => {
const fx = fixture();
const make = (extra) => new Controller({ fixtureRoot: fx.base, claimRoot: fx.claimRoot, socketDir: fx.socketDir, sessionFile: fx.sessionFile, seat: fx.seat, launcher: new FakeLauncher(), units: noUnits, timeouts: FAST, ...extra });
// The plain engine option takes extraArgs, cwd and envKeys; the command,
// pre-arguments and environment are the controller's (I3, #1507).
for (const engine of [{ command: "/bin/sh" }, { preArgs: [join(REPO_ROOT, PI_BIN)] }, { command: process.execPath, preArgs: [join(REPO_ROOT, PI_BIN), "--extension", "x"] }, { env: {} }, { env: process.env }, null, [], "pi"]) {
assert.throws(() => make({ engine }), (e) => e.code === UNSEALED_ENGINE, JSON.stringify(engine));
}
// A configuration is JSON, which can't carry the symbol-keyed test engine.
const parsed = JSON.parse(JSON.stringify({ engine: { cwd: fx.proj }, [TEST_ENGINE]: { command: "/bin/sh", preArgs: [], env: {} } }));
assert.equal(Object.getOwnPropertySymbols(parsed).length, 0);
// envKeys may name provider credentials only.
for (const envKeys of [["NODE_OPTIONS"], ["LD_PRELOAD"], ["PI_PACKAGE_DIR"], ["BASH_ENV"], ["ZAI_API_KEY", "PATH"], ["zai_api_key"], ["_API_KEY"], [3], "ZAI_API_KEY"]) {
assert.throws(() => make({ engine: { envKeys } }), (e) => e.code === UNSEALED_ENGINE, JSON.stringify(envKeys));
}
// The test engine is still sealed on its arguments.
assert.throws(() => make({ [TEST_ENGINE]: { command: process.execPath, preArgs: ["fake.mjs", "-e", "x"], env: {} } }), (e) => e.code === UNSEALED_ENGINE);
assert.throws(() => make({ [TEST_ENGINE]: { command: "", preArgs: [], env: {} } }), (e) => e.code === UNSEALED_ENGINE);
// The launch: this Node, the pinned bin, and an environment of named keys
// only, whatever the controller's own environment holds.
const planted = { NODE_OPTIONS: "--require /tmp/x.cjs", LD_PRELOAD: "/tmp/x.so", PI_PACKAGE_DIR: "/tmp/pkg", ZAI_API_KEY: "zai-test-value", OTHER_API_KEY: "other-test-value" };
const saved = Object.fromEntries(Object.keys(planted).map((k) => [k, process.env[k]]));
Object.assign(process.env, planted);
let h;
try {
h = await started({ fx: fixture(), engine: { envKeys: ["ZAI_API_KEY"] } });
} finally {
for (const [k, v] of Object.entries(saved)) if (v === undefined) delete process.env[k]; else process.env[k] = v;
}
try {
const l = h.launcher.launches[0];
assert.equal(l.command, process.execPath);
assert.equal(l.args[0], join(REPO_ROOT, PI_BIN));
assert.equal(l.env.ZAI_API_KEY, "zai-test-value");
for (const k of Object.keys(l.env)) assert.ok(ENGINE_ENV.includes(k) || k === "ZAI_API_KEY", k);
for (const k of ["NODE_OPTIONS", "LD_PRELOAD", "PI_PACKAGE_DIR", "OTHER_API_KEY"]) assert.equal(k in l.env, false, k);
if (process.env.PATH) assert.equal(l.env.PATH, process.env.PATH);
} finally {
await h.close();
}
// A command changed after construction is refused at bind; nothing launches.
const fx2 = fixture();
const launcher = new FakeLauncher();
const ctrl = new Controller({ fixtureRoot: fx2.base, claimRoot: fx2.claimRoot, socketDir: fx2.socketDir, sessionFile: fx2.sessionFile, seat: fx2.seat, launcher, units: noUnits, timeouts: FAST });
// Closed in finally: a start that wrongly succeeds holds the socket open.
try {
ctrl.engine.command = "/bin/sh";
await assert.rejects(ctrl.start(), (e) => e.code === UNSEALED_ENGINE);
assert.equal(launcher.launches.length, 0);
ctrl.engine.command = process.execPath;
ctrl.engine.preArgs = [join(fx2.base, "cli.js")];
await assert.rejects(ctrl.start(), (e) => e.code === UNSEALED_ENGINE);
assert.equal(launcher.launches.length, 0);
} finally {
await ctrl.close().catch(() => {});
}
});