feat(discord): read-only tools for the Discord Sage through a Mosaic pi extension confined to declared roots (#1509)
A binding may declare `tools` with named roots. pi starts with --no-builtin-tools and the package's own extension, allowlisting list_dir, read_file and search. src/tools.mjs holds the rules: names not paths, per-segment lstat walk, one checked descriptor read that refuses symlinks, swaps, FIFOs, hard links and oversize files, credential shapes refusing the whole read, and a per-message call budget. The engine settles on agent_end and records tool calls in the turn record. Jason's rulings R1-R7 in the brief, section 7. rev-code-02 approved round 2 (comment 26276) on tree 43f0329b after four round 1 fixes. Suite 48/48, node tests 116. Not pushed. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
@@ -2,7 +2,8 @@ import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { createEngine, buildPiArgs, PI_FIXED_ARGS, assistantText } from "../src/engine-pi.mjs";
|
||||
import { createEngine, buildPiArgs, PI_FIXED_ARGS, READONLY_TOOLS_EXTENSION, assistantText } from "../src/engine-pi.mjs";
|
||||
import { existsSync } from "node:fs";
|
||||
import { makeRoot } from "./helpers.mjs";
|
||||
|
||||
const fakePi = join(import.meta.dirname, "fake-pi.mjs");
|
||||
@@ -25,6 +26,45 @@ test("engine: buildPiArgs carries the fixed flags, engine settings, session dir
|
||||
assert.deepEqual(args.slice(-9), ["--provider", "zai", "--model", "glm-5.3", "--thinking", "high", "--session-dir", "/s", "--append-system-prompt", "/p.md", "--continue"].slice(-9));
|
||||
assert.ok(!buildPiArgs({ provider: "p", model: "m", thinking: "off", sessionDir: "/s", appendSystemPromptFile: "/p", continueSession: false }).includes("--continue"));
|
||||
assert.equal(assistantText({ content: [{ type: "thinking", thinking: "x" }, { type: "text", text: " a " }, { type: "text", text: "b" }] }), "a b".replace(" ", " "));
|
||||
assert.ok(!args.includes("--no-builtin-tools") && !args.includes("--extension"), "no extension without tools");
|
||||
});
|
||||
|
||||
test("engine: with tools, buildPiArgs turns pi's own tools off, loads the extension explicitly and allowlists exactly our three", () => {
|
||||
const tools = { roots: [{ name: "docs", path: "/r" }], maxFileBytes: 4096, maxCallsPerTurn: 8 };
|
||||
const args = buildPiArgs({ provider: "p", model: "m", thinking: "off", sessionDir: "/s", appendSystemPromptFile: "/p", continueSession: false, tools });
|
||||
assert.ok(!args.includes("--no-tools"), "--no-tools would hide the extension's tools too");
|
||||
assert.ok(args.includes("--no-extensions"), "discovery stays off; only the explicit path loads");
|
||||
assert.ok(args.includes("--no-builtin-tools"));
|
||||
assert.equal(args[args.indexOf("--extension") + 1], READONLY_TOOLS_EXTENSION);
|
||||
assert.equal(args[args.indexOf("--tools") + 1], "list_dir,read_file,search");
|
||||
assert.ok(existsSync(READONLY_TOOLS_EXTENSION), READONLY_TOOLS_EXTENSION);
|
||||
});
|
||||
|
||||
test("engine: a run with tool turns settles once, on the answer, with every tool call in the result", async () => {
|
||||
const { engine } = start(makeRoot());
|
||||
const r = await engine.prompt("tools 3");
|
||||
assert.equal(r.text, "read 3 file(s)");
|
||||
assert.equal(r.turns, 2);
|
||||
assert.equal(r.tools.length, 3);
|
||||
assert.deepEqual(r.tools[0], { name: "read_file", root: "docs", path: "f1.md", ok: true, reason: null, bytes: 9, ms: 2 });
|
||||
assert.equal(r.tools[2].ok, false);
|
||||
assert.match(r.tools[2].reason, /budget/);
|
||||
const plain = await engine.prompt("hello");
|
||||
assert.equal(plain.text, "echo: hello");
|
||||
assert.deepEqual(plain.tools, []);
|
||||
assert.equal(plain.turns, 1);
|
||||
assert.equal(engine.busy, false);
|
||||
await engine.stop();
|
||||
});
|
||||
|
||||
test("engine: a run that ends on a tool-only turn fails the prompt as empty; a retried run settles on the real end", async () => {
|
||||
const { engine } = start(makeRoot());
|
||||
const r = await engine.prompt("toolonly");
|
||||
assert.equal(r.text, "", "no text: the connector turns this into engine-empty");
|
||||
assert.equal(r.tools.length, 1);
|
||||
const again = await engine.prompt("retry");
|
||||
assert.equal(again.text, "after retry");
|
||||
await engine.stop();
|
||||
});
|
||||
|
||||
test("engine: one prompt, one turn, text and usage come back", async () => {
|
||||
@@ -61,6 +101,19 @@ test("engine: timeout sends abort and fails only that turn; the process stays",
|
||||
await engine.stop();
|
||||
});
|
||||
|
||||
test("engine: tool events from a run that outlived its timeout never land in the next prompt's record", async () => {
|
||||
const { engine } = start(makeRoot());
|
||||
try {
|
||||
await assert.rejects(engine.prompt("late 200", { timeoutMs: 40 }), (err) => err.details.code === "timeout");
|
||||
const r = await engine.prompt("after late");
|
||||
assert.equal(r.text, "echo: after late");
|
||||
assert.deepEqual(r.tools, [], "the dead run's read is not this prompt's evidence");
|
||||
assert.equal(r.turns, 1, "the dead run's turns are not counted here");
|
||||
} finally {
|
||||
await engine.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test("engine: a malformed JSONL line fails the turn, not the process", async () => {
|
||||
const { engine, logs } = start(makeRoot());
|
||||
await assert.rejects(engine.prompt("garbage"), (err) => err.details.code === "engine-protocol");
|
||||
|
||||
Reference in New Issue
Block a user