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