feat(discord): git verbs for the Discord Sage on the shared-signals root, seat identity through a package credential helper, vault record protocol (#1509)

Row 24. A writable root that is a git work tree may carry a git object in
the binding; the seat then has git_status, git_commit (explicit paths, seat
author, Requested-by trailer from the envelope requester, push at once per
D6), git_pull (ff-only) and git_push (one branch, never force), plus
reserve_id and per-write clone locks under protocol vault. Git children run
with no host config and one credential helper, bin/git-credential.mjs,
reading the 0600 seat token file named in the binding; the fleet helper
serves only the Gitea hosts. Suite 58/58, node 143. rev-code-02 APPROVED
round 1 (#1509 comment 26375, tree 82ab962f).

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
2026-09-18 07:52:35 -05:00
co-authored by Claude Fable 5.1
parent 1685deb423
commit 1949ed8d31
23 changed files with 1284 additions and 48 deletions
+124 -15
View File
@@ -48,14 +48,21 @@ import { constants, lstatSync, openSync, fstatSync, readSync, writeSync, closeSy
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";
import { GIT_TOOL_NAMES, RESERVE_TOOL_NAME, GIT_REFUSAL, GitRefusal, COMMIT_MESSAGE_MAX, COMMIT_PATHS_MAX, VAULT_PREFIXES, VAULT_REGISTRY, loadGitConfig, gitStatus, gitCommit, gitPull, gitPush, reserveId, withVaultLock } from "./git.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.
// The tools a config enables, in the order pi's --tools list names them:
// the reads always, the writes with a writable root, the web pair with a
// web key, the git verbs with a root that carries a git key, reserve_id
// with a root whose git key names the vault protocol.
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);
const roots = config && Array.isArray(config.roots) ? config.roots : [];
if (roots.some((r) => r.write === true)) names.push(...WRITE_TOOL_NAMES);
if (config && config.web) names.push(...WEB_TOOL_NAMES);
if (roots.some((r) => r.git)) names.push(...GIT_TOOL_NAMES);
if (roots.some((r) => r.git && r.git.protocol === "vault")) names.push(RESERVE_TOOL_NAME);
return names;
}
export const TOOLS_ENV = "MOSAIC_DISCORD_TOOLS";
@@ -135,9 +142,10 @@ export function loadToolsConfig(raw, where = TOOLS_ENV) {
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", "write"].includes(k)) throw new Error(`${w}: unknown key ${JSON.stringify(k)}`);
if (!["name", "path", "write", "git"].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 (r.git !== undefined && r.write !== true) throw new Error(`${w}: git needs write: true`);
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`);
@@ -151,7 +159,8 @@ 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, write: r.write === true });
const git = r.git === undefined ? null : loadGitConfig(r.git, `${w}.git`, real);
return Object.freeze({ name: r.name, path: r.path, real, write: r.write === true, git });
});
if (new Set(roots.map((r) => r.name)).size !== roots.length) throw new Error(`${where}: duplicate root name`);
const merged = { ...TOOL_DEFAULTS, ...raw };
@@ -490,8 +499,8 @@ 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 };
withVaultLock(root, target.rel, () => replaceVerified(target, data));
return { root: root.name, path: target.rel, bytes: data.length, created: target.st === null, git: root.git !== null };
}
export function editFile(config, { root: rootName, path, old, new: replacement } = {}) {
@@ -503,18 +512,50 @@ export function editFile(config, { root: rootName, path, old, new: replacement }
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 };
withVaultLock(root, target.rel, () => replaceVerified(target, data));
return { root: root.name, path: target.rel, bytes: data.length, created: false, git: root.git !== null };
}
// --- git verbs: path fencing here, process running in git.mjs ---
function gitRoot(config, name) {
const root = writableRoot(config, name);
if (!root.git) throw new GitRefusal(GIT_REFUSAL.NO_GIT);
return root;
}
// Every commit path goes through the same walk the reads use and must be
// a regular file now; the registry path is allowed by name so a reserved
// id travels with its record.
function commitPaths(root, paths) {
if (!Array.isArray(paths) || paths.length === 0 || paths.length > COMMIT_PATHS_MAX) throw new GitRefusal(GIT_REFUSAL.BAD_PATHS);
const rels = paths.map((p) => {
if (typeof p !== "string") throw new GitRefusal(GIT_REFUSAL.BAD_PATHS);
const r = resolveUnder(root, p);
if (!r.st.isFile()) throw new Refusal(REFUSAL.NOT_FILE);
return r.rel;
});
return [...new Set(rels)];
}
// --- the tool set the extension registers: budget plus rendering ---
// The web tools are asynchronous; call() returns a promise for them and a
// plain result for the file tools, and the extension awaits either.
// plain result for the file tools, and the extension awaits either. The
// git verbs read `state.requester`, which the extension sets from each
// message's envelope before the run starts.
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),
git_status: (config, { root }) => gitStatus(gitRoot(config, root)),
git_commit: (config, { root, message, paths }, state) => {
const r = gitRoot(config, root);
return gitCommit(r, { message, rels: commitPaths(r, paths), requester: state.requester });
},
git_pull: (config, { root }) => gitPull(gitRoot(config, root)),
git_push: (config, { root }) => gitPush(gitRoot(config, root)),
reserve_id: (config, { root, prefix, title }) => reserveId(gitRoot(config, root), { prefix, title }),
});
function render(name, out) {
@@ -529,7 +570,32 @@ function render(name, out) {
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`;
const next = out.git ? "not committed yet: commit it with git_commit, naming this path" : "not committed, say which file changed";
return `${out.created ? "created" : "replaced"} ${out.root}/${out.path} (${out.bytes} bytes); ${next}`;
}
if (name === "git_status") {
const lines = [
`${out.root}: branch ${out.branch}${out.upstream ? ` tracking ${out.upstream}` : ""}${out.ahead !== null ? `, ahead ${out.ahead}, behind ${out.behind}` : ""}`,
...out.conflicts.map((p) => `conflict: ${p}`),
...out.changed.map((c) => `changed (${c.state}): ${c.path}`),
...out.untracked.map((p) => `untracked: ${p}`),
];
if (out.changed.length + out.untracked.length + out.conflicts.length === 0) lines.push("clean");
if (out.truncated) lines.push("… list cut short");
return lines.join("\n");
}
if (name === "git_commit") {
const push = out.pushed ? "pushed to origin" : `NOT pushed (${out.pushError}); say so, the next commit retries`;
return `committed ${out.hash} on ${out.branch} for ${out.requester}: ${out.paths.join(", ")}; ${push}`;
}
if (name === "git_pull") {
return out.updated ? `${out.root}: ${out.branch} moved ${out.from} -> ${out.to}` : `${out.root}: ${out.branch} already up to date at ${out.to}`;
}
if (name === "git_push") {
return out.upToDate ? `${out.root}: origin already has ${out.hash}` : `${out.root}: pushed ${out.branch} at ${out.hash} to origin`;
}
if (name === "reserve_id") {
return `reserved ${out.id}; the registry line is in ${out.registry}, stage it with the record${out.note ? ` (${out.note})` : ""}`;
}
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}` : ""})`;
@@ -548,6 +614,7 @@ function render(name, out) {
// is a bug and propagates.
export function createToolSet(config) {
let calls = 0;
const state = { requester: null };
const enabled = new Set(enabledToolNames(config));
const call = (name, params) => {
const fn = enabled.has(name) ? TOOL_FNS[name] : undefined;
@@ -560,16 +627,24 @@ export function createToolSet(config) {
}
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 };
const bytes = name === "list_dir" || name === "search" || name === "web_search" || name.startsWith("git_") || name === "reserve_id" ? undefined : out.bytes;
const extra = name === "web_fetch" ? { url: out.finalUrl, status: out.status }
: name === "web_search" ? { hits: out.results.length }
: name === "git_commit" ? { hash: out.hash, pushed: out.pushed, paths: out.paths, requester: out.requester }
: name === "git_push" ? { hash: out.hash, pushed: true }
: name === "git_pull" ? { hash: out.to, updated: out.updated }
: name === "git_status" ? { branch: out.branch }
: name === "reserve_id" ? { id: out.id }
: { 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 } };
if (!(err instanceof Refusal) && !(err instanceof WebRefusal) && !(err instanceof GitRefusal)) throw err;
const reason = err instanceof GitRefusal ? err.message : err.reason;
return { ok: false, text: `refused: ${reason}`, details: { ...base, ok: false, reason, ...(err.status ? { status: err.status } : {}), ms: Date.now() - t0 } };
};
try {
const out = fn(config, params || {});
const out = fn(config, params || {}, state);
if (out && typeof out.then === "function") return out.then(done, refused);
return done(out);
} catch (err) {
@@ -581,6 +656,15 @@ export function createToolSet(config) {
resetBudget() {
calls = 0;
},
// The name the binding gives the Discord author of the running message,
// for the commit trailer. null between messages, so a commit outside a
// message is refused.
setRequester(name) {
state.requester = typeof name === "string" && name.length > 0 ? name : null;
},
get requester() {
return state.requester;
},
get calls() {
return calls;
},
@@ -614,4 +698,29 @@ export const TOOL_DESCRIPTIONS = Object.freeze({
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",
},
git_status: {
label: "Git status",
description: "Show the branch, how far it is ahead of or behind origin, and the changed and untracked paths in a root that has git. Read only.",
snippet: "git_status shows the branch and changed paths of a git root",
},
git_commit: {
label: "Git commit",
description: `Stage exactly the named files under a root that has git, commit them as the seat with a trailer naming who asked, and push to origin at once. Name every file you changed (and ${VAULT_REGISTRY} after reserve_id). Message: one to ${COMMIT_MESSAGE_MAX} characters saying what changed and why. Refused when the index already holds other staged work, when a path is locked by another contributor, or when the record validator fails.`,
snippet: "git_commit commits named files as the seat and pushes at once",
},
git_pull: {
label: "Git pull",
description: "Fast-forward the root's branch to origin. Refused, with nothing merged, when origin has diverged or local changes would be overwritten.",
snippet: "git_pull fast-forwards a git root to origin",
},
git_push: {
label: "Git push",
description: "Push the root's branch to origin. git_commit already pushes; use this only when an earlier push was reported as failed.",
snippet: "git_push pushes a git root's branch to origin",
},
reserve_id: {
label: "Reserve record id",
description: `Reserve the next free record id for a prefix (${VAULT_PREFIXES.join(", ")}) in a root that follows the record protocol, before creating the record file. Returns the id; the registry line it appends must be committed with the record.`,
snippet: "reserve_id reserves the next record id before a new record is written",
},
});