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