feat(discord): writes on write-marked roots, web fetch and search, held prompts (#1509)
Row 23. write_file and edit_file for roots marked write: true under the same fence as reads; web_fetch (https only, public addresses, pinned connection, capped body) and web_search through SearXNG; extension renamed to tools.mjs. Engine holds a prompt while pi is busy and sends it as its own run, so a second message mid-turn no longer folds into the first (live defect). fake-pi models the real follow-up folding. Suite 52/52, node tests 129. rev-code-02 APPROVED round 3, comment 26362, tree dbd2ce9a. Records: QUEUE rows 23-24, CURRENT, BUILD-LOG phase, SESSIONS, row 24 brief (git verbs, D5-D7 ruled). Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
+205
-18
@@ -1,11 +1,12 @@
|
||||
// Read-only tools for the Discord Sage, confined to declared roots. This is
|
||||
// the boundary that decides what a Discord user can make Sage read on this
|
||||
// host, so it is small, has no dependencies, and is tested without pi.
|
||||
// File tools for the Discord Sage, confined to declared roots. This is the
|
||||
// boundary that decides what a Discord user can make Sage read or write on
|
||||
// this host, so it is small, has no dependencies, and is tested without pi.
|
||||
//
|
||||
// The extension in ../extension/readonly-tools.mjs registers the three tools
|
||||
// with pi; every call comes here. Nothing here writes, spawns, or reads the
|
||||
// environment. A refusal is a normal result with ok=false and one fixed
|
||||
// reason; the model never sees a host path outside the root it asked for.
|
||||
// The extension in ../extension/tools.mjs registers the enabled tools with
|
||||
// pi; every call comes here. Nothing here spawns or reads the environment,
|
||||
// and nothing writes except the two write tools below, only into a root
|
||||
// marked write: true. A refusal is a normal result with ok=false and one
|
||||
// fixed reason; the model never sees a host path outside the root it asked for.
|
||||
//
|
||||
// Rules, applied before any read, in this order:
|
||||
// - the root must be one of the declared names; requests carry no
|
||||
@@ -27,11 +28,36 @@
|
||||
// second barrier behind the roots ruling, not the first
|
||||
// - at most maxCallsPerTurn calls between one agent_start and the end of
|
||||
// that run; past it every call is refused with a fixed reason
|
||||
//
|
||||
// Writes (row 23, Jason's word 2026-09-16) exist only for roots the binding
|
||||
// marks `write: true`, and add these rules on top of the read rules:
|
||||
// - the parent folder must already exist under the root, checked by the
|
||||
// same symlink-refusing walk; no folder is ever created
|
||||
// - the target is absent or a regular file with one link; anything else
|
||||
// (a folder, a FIFO, a symlink, a hard-linked file) is refused
|
||||
// - the text is at most maxFileBytes, holds no NUL byte, and carries no
|
||||
// credential shape; a write that would put a secret on disk is refused
|
||||
// like a read that would show one
|
||||
// - the bytes go to a dot-prefixed temp file in the same folder, created
|
||||
// exclusively, then renamed over the target, so a half-written file is
|
||||
// never visible and the reads (which skip dotfiles) never see the temp
|
||||
// - edit_file replaces one exact string that occurs exactly once; the
|
||||
// replaced content goes through the write rules
|
||||
|
||||
import { constants, lstatSync, openSync, fstatSync, readSync, closeSync, readdirSync, realpathSync } from "node:fs";
|
||||
import { constants, lstatSync, openSync, fstatSync, readSync, writeSync, closeSync, readdirSync, realpathSync, renameSync, unlinkSync } from "node:fs";
|
||||
import { isAbsolute, join, sep } from "node:path";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { WEB_TOOL_NAMES, WEB_TOOL_DESCRIPTIONS, FETCH_MAX_TEXT_CHARS, WebRefusal, loadWebConfig, webFetch, webSearch } from "./web.mjs";
|
||||
|
||||
export const TOOL_NAMES = Object.freeze(["list_dir", "read_file", "search"]);
|
||||
export const WRITE_TOOL_NAMES = Object.freeze(["write_file", "edit_file"]);
|
||||
// The tools a config enables, in the order pi's --tools list names them.
|
||||
export function enabledToolNames(config) {
|
||||
const names = [...TOOL_NAMES];
|
||||
if (config && Array.isArray(config.roots) && config.roots.some((r) => r.write === true)) names.push(...WRITE_TOOL_NAMES);
|
||||
if (config && config.web) names.push(...WEB_TOOL_NAMES);
|
||||
return names;
|
||||
}
|
||||
export const TOOLS_ENV = "MOSAIC_DISCORD_TOOLS";
|
||||
export const TOOL_DEFAULTS = Object.freeze({ maxFileBytes: 262144, maxCallsPerTurn: 8 });
|
||||
export const READ_DEFAULT_LINES = 200;
|
||||
@@ -58,6 +84,12 @@ export const REFUSAL = Object.freeze({
|
||||
UNREADABLE: "file cannot be read",
|
||||
CHANGED: "file or folder changed while it was being read",
|
||||
HARDLINK: "file has more than one hard link",
|
||||
READ_ONLY: "that root is read-only",
|
||||
NO_PARENT: "the parent folder does not exist under that root",
|
||||
TARGET: "the target exists and is not a regular file",
|
||||
NOT_TEXT: "text must be a string without NUL bytes",
|
||||
EDIT_MATCH: "old text must occur exactly once in the file",
|
||||
UNWRITABLE: "file cannot be written",
|
||||
});
|
||||
|
||||
// Shapes that must never reach Discord even if a file under a root holds
|
||||
@@ -96,15 +128,16 @@ const ROOT_NAME = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
||||
export function loadToolsConfig(raw, where = TOOLS_ENV) {
|
||||
if (!isObject(raw)) throw new Error(`${where}: not an object`);
|
||||
for (const k of Object.keys(raw)) {
|
||||
if (!["roots", "maxFileBytes", "maxCallsPerTurn"].includes(k)) throw new Error(`${where}: unknown key ${JSON.stringify(k)}`);
|
||||
if (!["roots", "maxFileBytes", "maxCallsPerTurn", "web"].includes(k)) throw new Error(`${where}: unknown key ${JSON.stringify(k)}`);
|
||||
}
|
||||
if (!Array.isArray(raw.roots) || raw.roots.length === 0) throw new Error(`${where}: roots must be a non-empty array`);
|
||||
const roots = raw.roots.map((r, i) => {
|
||||
const w = `${where}.roots[${i}]`;
|
||||
if (!isObject(r)) throw new Error(`${w}: not an object`);
|
||||
for (const k of Object.keys(r)) {
|
||||
if (!["name", "path"].includes(k)) throw new Error(`${w}: unknown key ${JSON.stringify(k)}`);
|
||||
if (!["name", "path", "write"].includes(k)) throw new Error(`${w}: unknown key ${JSON.stringify(k)}`);
|
||||
}
|
||||
if (r.write !== undefined && r.write !== true && r.write !== false) throw new Error(`${w}: write must be true or false`);
|
||||
if (typeof r.name !== "string" || !ROOT_NAME.test(r.name)) throw new Error(`${w}: name must match ${ROOT_NAME}`);
|
||||
if (typeof r.path !== "string" || !isAbsolute(r.path) || r.path.includes("\0")) throw new Error(`${w}: path must be an absolute path`);
|
||||
if (r.path.split(sep).some((s) => s.startsWith(".") && s.length > 0)) throw new Error(`${w}: path has a dot-prefixed segment`);
|
||||
@@ -118,7 +151,7 @@ export function loadToolsConfig(raw, where = TOOLS_ENV) {
|
||||
if (!st.isDirectory()) throw new Error(`${w}: path is not a directory: ${r.path}`);
|
||||
const real = realpathSync(r.path);
|
||||
if (real.split(sep).some((s) => s.startsWith(".") && s.length > 0)) throw new Error(`${w}: real path has a dot-prefixed segment`);
|
||||
return Object.freeze({ name: r.name, path: r.path, real });
|
||||
return Object.freeze({ name: r.name, path: r.path, real, write: r.write === true });
|
||||
});
|
||||
if (new Set(roots.map((r) => r.name)).size !== roots.length) throw new Error(`${where}: duplicate root name`);
|
||||
const merged = { ...TOOL_DEFAULTS, ...raw };
|
||||
@@ -131,6 +164,7 @@ export function loadToolsConfig(raw, where = TOOLS_ENV) {
|
||||
roots: Object.freeze(roots),
|
||||
maxFileBytes: int("maxFileBytes", 1024, 4 * 1024 * 1024),
|
||||
maxCallsPerTurn: int("maxCallsPerTurn", 1, 64),
|
||||
web: raw.web === undefined ? null : loadWebConfig(raw.web, `${where}.web`),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -358,9 +392,130 @@ export function search(config, { root: rootName, text, path = "" } = {}) {
|
||||
return { root: root.name, path: start.rel, text, hits, filesScanned: scanned, truncated };
|
||||
}
|
||||
|
||||
// --- the two write tools, for roots marked write: true ---
|
||||
|
||||
function checkText(text, config) {
|
||||
if (typeof text !== "string" || text.includes("\0")) throw new Refusal(REFUSAL.NOT_TEXT);
|
||||
const data = Buffer.from(text, "utf8");
|
||||
if (data.length > config.maxFileBytes) throw new Refusal(REFUSAL.TOO_LARGE);
|
||||
if (looksLikeCredential(text)) throw new Refusal(REFUSAL.CREDENTIAL);
|
||||
return data;
|
||||
}
|
||||
|
||||
// Resolve a write target: the parent must exist under the root by the same
|
||||
// walk the reads use, and the last segment must be absent or a regular
|
||||
// file with one link. Returns {abs, rel, st} with st null when absent.
|
||||
function resolveTarget(root, path) {
|
||||
const segs = segments(path);
|
||||
if (segs.length === 0) throw new Refusal(REFUSAL.BAD_PATH);
|
||||
const name = segs[segs.length - 1];
|
||||
let parent;
|
||||
try {
|
||||
parent = resolveUnder(root, segs.slice(0, -1).join("/"));
|
||||
} catch (err) {
|
||||
if (err instanceof Refusal && err.reason === REFUSAL.NOT_FOUND) throw new Refusal(REFUSAL.NO_PARENT);
|
||||
throw err;
|
||||
}
|
||||
if (!parent.st.isDirectory()) throw new Refusal(REFUSAL.NO_PARENT);
|
||||
const abs = join(parent.abs, name);
|
||||
let st = null;
|
||||
try {
|
||||
st = lstatSync(abs);
|
||||
} catch (err) {
|
||||
if (err.code !== "ENOENT") throw new Refusal(REFUSAL.UNWRITABLE);
|
||||
}
|
||||
if (st !== null) {
|
||||
if (st.isSymbolicLink()) throw new Refusal(REFUSAL.SYMLINK);
|
||||
if (!st.isFile()) throw new Refusal(REFUSAL.TARGET);
|
||||
if (st.nlink > 1) throw new Refusal(REFUSAL.HARDLINK);
|
||||
}
|
||||
return { abs, rel: segs.join("/"), st, dir: parent.abs };
|
||||
}
|
||||
|
||||
// Put `data` at the resolved target through an exclusive temp file in the
|
||||
// same folder and one rename. The target is checked again just before the
|
||||
// rename: a file that appeared, vanished or changed inode in between is
|
||||
// refused and the temp file removed.
|
||||
export function replaceVerified(target, data) {
|
||||
const tmp = join(target.dir, `.mosaic-write-${randomBytes(8).toString("hex")}`);
|
||||
let fd;
|
||||
try {
|
||||
fd = openSync(tmp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o644);
|
||||
} catch {
|
||||
throw new Refusal(REFUSAL.UNWRITABLE);
|
||||
}
|
||||
try {
|
||||
let n = 0;
|
||||
while (n < data.length) {
|
||||
try {
|
||||
n += writeSync(fd, data, n, data.length - n);
|
||||
} catch {
|
||||
throw new Refusal(REFUSAL.UNWRITABLE);
|
||||
}
|
||||
}
|
||||
closeSync(fd);
|
||||
fd = undefined;
|
||||
let now = null;
|
||||
try {
|
||||
now = lstatSync(target.abs);
|
||||
} catch (err) {
|
||||
if (err.code !== "ENOENT") throw new Refusal(REFUSAL.CHANGED);
|
||||
}
|
||||
const same = (target.st === null && now === null)
|
||||
|| (target.st !== null && now !== null && now.isFile() && now.dev === target.st.dev && now.ino === target.st.ino);
|
||||
if (!same) throw new Refusal(REFUSAL.CHANGED);
|
||||
try {
|
||||
renameSync(tmp, target.abs);
|
||||
} catch {
|
||||
throw new Refusal(REFUSAL.UNWRITABLE);
|
||||
}
|
||||
} catch (err) {
|
||||
if (fd !== undefined) closeSync(fd);
|
||||
try {
|
||||
unlinkSync(tmp);
|
||||
} catch {
|
||||
// the rename already consumed it, or it never existed
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function writableRoot(config, name) {
|
||||
const root = rootByName(config, name);
|
||||
if (!root.write) throw new Refusal(REFUSAL.READ_ONLY);
|
||||
return root;
|
||||
}
|
||||
|
||||
export function writeFile(config, { root: rootName, path, text } = {}) {
|
||||
const root = writableRoot(config, rootName);
|
||||
const data = checkText(text, config);
|
||||
const target = resolveTarget(root, path);
|
||||
replaceVerified(target, data);
|
||||
return { root: root.name, path: target.rel, bytes: data.length, created: target.st === null };
|
||||
}
|
||||
|
||||
export function editFile(config, { root: rootName, path, old, new: replacement } = {}) {
|
||||
const root = writableRoot(config, rootName);
|
||||
if (typeof old !== "string" || old.length === 0 || typeof replacement !== "string") throw new Refusal(`${REFUSAL.BAD_ARGS}: old must be a non-empty string and new a string`);
|
||||
const target = resolveTarget(root, path);
|
||||
if (target.st === null) throw new Refusal(REFUSAL.NOT_FOUND);
|
||||
const { text } = readText(root, path, config);
|
||||
const first = text.indexOf(old);
|
||||
if (first === -1 || text.indexOf(old, first + old.length) !== -1) throw new Refusal(REFUSAL.EDIT_MATCH);
|
||||
const data = checkText(text.slice(0, first) + replacement + text.slice(first + old.length), config);
|
||||
replaceVerified(target, data);
|
||||
return { root: root.name, path: target.rel, bytes: data.length, created: false };
|
||||
}
|
||||
|
||||
// --- the tool set the extension registers: budget plus rendering ---
|
||||
|
||||
const TOOL_FNS = Object.freeze({ list_dir: listDir, read_file: readFile, search });
|
||||
// The web tools are asynchronous; call() returns a promise for them and a
|
||||
// plain result for the file tools, and the extension awaits either.
|
||||
const TOOL_FNS = Object.freeze({
|
||||
list_dir: listDir, read_file: readFile, search, write_file: writeFile, edit_file: editFile,
|
||||
web_fetch: (config, params) => webFetch(config.web, params),
|
||||
web_search: (config, params) => webSearch(config.web, params),
|
||||
});
|
||||
|
||||
function render(name, out) {
|
||||
if (name === "list_dir") {
|
||||
@@ -373,6 +528,17 @@ function render(name, out) {
|
||||
const end = out.offset + out.lines.length - 1;
|
||||
return `${out.root}/${out.path} lines ${out.offset}-${end} of ${out.totalLines}\n${body}`;
|
||||
}
|
||||
if (name === "write_file" || name === "edit_file") {
|
||||
return `${out.created ? "created" : "replaced"} ${out.root}/${out.path} (${out.bytes} bytes); not committed, say which file changed`;
|
||||
}
|
||||
if (name === "web_fetch") {
|
||||
const head = `${out.finalUrl} (${out.status}, ${out.contentType}, ${out.bytes} bytes${out.truncated ? ", cut at the fetch cap" : ""}${out.redirects ? `, ${out.redirects} redirect(s) from ${out.url}` : ""})`;
|
||||
return `${head}${out.title ? `\ntitle: ${out.title}` : ""}\n\n${out.text}${out.textTruncated ? `\n… text cut at ${FETCH_MAX_TEXT_CHARS} characters` : ""}`;
|
||||
}
|
||||
if (name === "web_search") {
|
||||
const body = out.results.map((r, i) => `${i + 1}. ${r.title || "(no title)"}\n ${r.url}${r.snippet ? `\n ${r.snippet}` : ""}`).join("\n");
|
||||
return `${out.results.length} result(s) for "${out.query}"${out.total > out.results.length ? ` (of ${out.total})` : ""}\n${body || "(none)"}`;
|
||||
}
|
||||
const body = out.hits.map((h) => `${h.path}:${h.line}: ${h.text}`).join("\n");
|
||||
return `${out.hits.length} hit(s) for ${JSON.stringify(out.text)} under ${out.root}/${out.path || ""} (${out.filesScanned} files)${out.truncated ? ", cut short" : ""}\n${body || "(none)"}`;
|
||||
}
|
||||
@@ -382,22 +548,32 @@ function render(name, out) {
|
||||
// is a bug and propagates.
|
||||
export function createToolSet(config) {
|
||||
let calls = 0;
|
||||
const enabled = new Set(enabledToolNames(config));
|
||||
const call = (name, params) => {
|
||||
const fn = TOOL_FNS[name];
|
||||
const fn = enabled.has(name) ? TOOL_FNS[name] : undefined;
|
||||
if (!fn) throw new Error(`unknown tool ${name}`);
|
||||
const t0 = Date.now();
|
||||
const base = { tool: name, root: typeof params?.root === "string" ? params.root.slice(0, 64) : null, path: typeof params?.path === "string" ? params.path.slice(0, 512) : null };
|
||||
const str = (k, max) => (typeof params?.[k] === "string" ? params[k].slice(0, max) : null);
|
||||
const base = { tool: name, root: str("root", 64), path: str("path", 512), ...(params?.url !== undefined ? { url: str("url", 512) } : {}), ...(params?.query !== undefined ? { query: str("query", 200) } : {}) };
|
||||
if (calls >= config.maxCallsPerTurn) {
|
||||
return { ok: false, text: `refused: ${REFUSAL.BUDGET}`, details: { ...base, ok: false, reason: REFUSAL.BUDGET, ms: 0 } };
|
||||
}
|
||||
calls += 1;
|
||||
const done = (out) => {
|
||||
const bytes = name === "list_dir" || name === "search" || name === "web_search" ? undefined : out.bytes;
|
||||
const extra = name === "web_fetch" ? { url: out.finalUrl, status: out.status } : name === "web_search" ? { hits: out.results.length } : { path: out.path };
|
||||
return { ok: true, text: render(name, out), details: { ...base, ok: true, ...extra, ...(bytes === undefined ? {} : { bytes }), ms: Date.now() - t0 } };
|
||||
};
|
||||
const refused = (err) => {
|
||||
if (!(err instanceof Refusal) && !(err instanceof WebRefusal)) throw err;
|
||||
return { ok: false, text: `refused: ${err.reason}`, details: { ...base, ok: false, reason: err.reason, ...(err.status ? { status: err.status } : {}), ms: Date.now() - t0 } };
|
||||
};
|
||||
try {
|
||||
const out = fn(config, params || {});
|
||||
const bytes = name === "read_file" ? out.bytes : undefined;
|
||||
return { ok: true, text: render(name, out), details: { ...base, ok: true, path: out.path, ...(bytes === undefined ? {} : { bytes }), ms: Date.now() - t0 } };
|
||||
if (out && typeof out.then === "function") return out.then(done, refused);
|
||||
return done(out);
|
||||
} catch (err) {
|
||||
if (!(err instanceof Refusal)) throw err;
|
||||
return { ok: false, text: `refused: ${err.reason}`, details: { ...base, ok: false, reason: err.reason, ms: Date.now() - t0 } };
|
||||
return refused(err);
|
||||
}
|
||||
};
|
||||
return {
|
||||
@@ -427,4 +603,15 @@ export const TOOL_DESCRIPTIONS = Object.freeze({
|
||||
description: `Find lines containing a fixed string (case-insensitive, no regular expressions) in text files under a declared read-only root, optionally within a subfolder. At most ${SEARCH_MAX_HITS} hits.`,
|
||||
snippet: "search finds a fixed string in files under a declared root",
|
||||
},
|
||||
write_file: {
|
||||
label: "Write file",
|
||||
description: "Create or replace a text file under a root that allows writes. The parent folder must exist; hidden paths, symlinks and credential-bearing text are refused. The file is not committed: tell the user which file changed.",
|
||||
snippet: "write_file creates or replaces a text file under a writable root",
|
||||
},
|
||||
...WEB_TOOL_DESCRIPTIONS,
|
||||
edit_file: {
|
||||
label: "Edit file",
|
||||
description: "Replace one exact string that occurs exactly once in a text file under a root that allows writes. Read the file first so the old text is exact. The file is not committed: tell the user which file changed.",
|
||||
snippet: "edit_file replaces one exact string in a file under a writable root",
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user