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:
@@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user