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
+384
View File
@@ -0,0 +1,384 @@
// Git for the Discord Sage (row 24, Jason's word 2026-09-16: "Sage will need
// to have git tooling to commit, push, pull, etc. for the shared-signals
// repo"). Four fixed verbs plus one record helper, each a child process
// with a fixed argument list, run only in a root that is marked writable
// and carries a `git` key. No verb takes free-form arguments and nothing
// here goes through a shell.
//
// git_status(root) branch, ahead/behind, changed paths
// git_commit(root, message, paths) stage exactly those paths, commit as
// the seat, push at once (D6)
// git_pull(root) pull --ff-only origin <branch>
// git_push(root) push origin <branch>, never --force
// reserve_id(root, prefix, title) vault protocol only: the next free
// record id, appended to the registry
//
// Fences shared by the verbs, decided here and tested without a remote:
// - the current branch must be the one the binding names; a detached
// head, another branch, a merge, rebase or cherry-pick in progress,
// or a conflicted path refuses every verb
// - a commit refuses an index that already holds staged changes (Jason's
// own work in the same clone is never swept in), a message that is
// empty or over COMMIT_MESSAGE_MAX characters, a path that is not a
// vetted regular file under the root, and an empty result
// - the commit author is the seat (`git.author`), with a trailer naming
// the Discord requester by the name the binding gives, never an id
// - a child runs at most GIT_TIMEOUT_MS; its output is cut at
// GIT_OUTPUT_CAP and masked before it reaches the model or a record
// - children see an allowlisted environment: no global or system git
// config (so the host's own credential helpers never run), terminal
// prompts off, one credential helper (bin/git-credential.mjs) that
// reads the seat's token file only during a push or pull, and the
// seat's name for the vault lock tool
//
// The token is never read here, never printed, never on a command line:
// the helper receives the file's path in its environment and hands the
// value to git on the credential protocol's stdout, nowhere else.
//
// The `vault` protocol is the shared-signals record protocol
// (tools/vault_lock.py, tools/validate_vault.py, docs/ID-REGISTRY.txt in
// that repository): a commit first checks that no other owner locks a
// staged path and that the validator passes; writes take the clone lock
// around the replace; reserve_id appends the next free id. Those scripts
// belong to that repository; this file calls them as fixed argv inside
// the root and nowhere else.
import { spawnSync } from "node:child_process";
import { existsSync, lstatSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
export const GIT_TOOL_NAMES = Object.freeze(["git_status", "git_commit", "git_pull", "git_push"]);
export const RESERVE_TOOL_NAME = "reserve_id";
export const GIT_PROTOCOLS = Object.freeze(["vault"]);
export const GIT_TIMEOUT_MS = 60_000;
export const GIT_OUTPUT_CAP = 4096;
export const COMMIT_MESSAGE_MAX = 500;
export const COMMIT_PATHS_MAX = 50;
export const STATUS_PATHS_MAX = 100;
export const GIT_TOKEN_FILE_ENV = "MOSAIC_DISCORD_GIT_TOKEN_FILE";
export const GIT_USERNAME_ENV = "MOSAIC_DISCORD_GIT_USERNAME";
export const CREDENTIAL_HELPER = fileURLToPath(new URL("../bin/git-credential.mjs", import.meta.url));
export const VAULT_PREFIXES = Object.freeze(["BUS", "PRJ", "SS", "DEC", "REF"]);
export const VAULT_REGISTRY = "docs/ID-REGISTRY.txt";
export const VAULT_LOCK_TOOL = "tools/vault_lock.py";
export const VAULT_VALIDATOR = "tools/validate_vault.py";
export const RESERVE_TITLE_MAX = 200;
export const GIT_REFUSAL = Object.freeze({
NO_GIT: "that root has no git",
DETACHED: "the work tree is on a detached head; Jason resolves it from the terminal",
BRANCH: "the work tree is not on the branch the binding names; Jason resolves it from the terminal",
IN_PROGRESS: "a merge, rebase or cherry-pick is in progress; Jason resolves it from the terminal",
CONFLICT: "the work tree has conflicted paths; Jason resolves them from the terminal",
INDEX_DIRTY: "the index already holds staged changes that are not yours; Jason resolves them from the terminal",
BAD_MESSAGE: `message must be one to ${COMMIT_MESSAGE_MAX} characters of plain text`,
BAD_PATHS: `paths must name one to ${COMMIT_PATHS_MAX} files under the root`,
NO_REQUESTER: "the requester of this message is unknown; the commit is refused",
LOCKED: "a staged path is locked by another contributor",
INVALID: "the record validator failed; fix the records before committing",
NOTHING: "nothing to commit: those paths have no changes",
COMMIT_FAILED: "git refused the commit",
NON_FF: "origin has moved in a way that is not a fast-forward; Jason reconciles from the terminal",
DIRTY: "local changes would be overwritten by the pull; commit or ask Jason first",
PULL_FAILED: "git could not pull",
PUSH_FAILED: "git could not push",
BAD_PREFIX: `prefix must be one of ${VAULT_PREFIXES.join(", ")}`,
BAD_TITLE: `title must be one to ${RESERVE_TITLE_MAX} characters on one line`,
RESERVE_FAILED: "the id could not be reserved",
NO_PROTOCOL: "that root has no record protocol; ids are not reserved there",
TIMEOUT: "git took too long and was stopped",
});
export class GitRefusal extends Error {
constructor(reason, detail = null) {
super(detail ? `${reason}: ${detail}` : reason);
this.reason = reason;
this.detail = detail;
}
}
const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
const BRANCH_NAME = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,99}$/;
const IDENTITY = /^[a-z0-9][a-z0-9._-]{0,63}$/;
const AUTHOR = /^([^<>\r\n]{1,64}?) <([^<>\s@]+@[^<>\s@]+)>$/;
// Validate a root's `git` key. `real` is the root's real path; it must be a
// git work tree (a .git directory or file). The token file must be a
// private file now so a bad binding fails the start, not the first push.
export function loadGitConfig(raw, where, real) {
if (!isObject(raw)) throw new Error(`${where}: not an object`);
for (const k of Object.keys(raw)) {
if (!["branch", "identity", "tokenFile", "author", "protocol"].includes(k)) throw new Error(`${where}: unknown key ${JSON.stringify(k)}`);
}
if (typeof raw.branch !== "string" || !BRANCH_NAME.test(raw.branch) || raw.branch.includes("..")) throw new Error(`${where}.branch: must be a branch name`);
if (typeof raw.identity !== "string" || !IDENTITY.test(raw.identity)) throw new Error(`${where}.identity: must match ${IDENTITY}`);
if (typeof raw.tokenFile !== "string" || !raw.tokenFile.startsWith("/") || raw.tokenFile.includes("\0") || raw.tokenFile.includes("'")) throw new Error(`${where}.tokenFile: must be an absolute path`);
if (typeof raw.author !== "string" || !AUTHOR.test(raw.author)) throw new Error(`${where}.author: must be "Name <email>"`);
const [, name, email] = raw.author.match(AUTHOR);
if (raw.protocol !== undefined && !GIT_PROTOCOLS.includes(raw.protocol)) throw new Error(`${where}.protocol: must be one of ${GIT_PROTOCOLS.join(", ")}`);
checkPrivateFile(raw.tokenFile, `${where}.tokenFile`);
if (typeof real === "string") {
let st = null;
try {
st = lstatSync(join(real, ".git"));
} catch {
// handled below
}
if (!st || !(st.isDirectory() || st.isFile())) throw new Error(`${where}: ${real} is not a git work tree`);
}
if (process.execPath.includes("'") || CREDENTIAL_HELPER.includes("'")) throw new Error(`${where}: the node or helper path holds a quote and cannot be a credential helper`);
return Object.freeze({
branch: raw.branch, identity: raw.identity, tokenFile: raw.tokenFile,
author: Object.freeze({ name: name.trim(), email }), protocol: raw.protocol ?? null,
});
}
function checkPrivateFile(path, what) {
let st;
try {
st = lstatSync(path);
} catch {
throw new Error(`${what}: not found: ${path}`);
}
if (st.isSymbolicLink()) throw new Error(`${what}: must not be a symlink: ${path}`);
if (!st.isFile()) throw new Error(`${what}: not a regular file: ${path}`);
if ((st.mode & 0o777) !== 0o600) throw new Error(`${what}: must be mode 0600: ${path}`);
if (st.size === 0) throw new Error(`${what}: is empty: ${path}`);
}
// The environment every child sees. No global or system git configuration,
// so the host's own helpers (gh, the fleet helper) never run for the seat;
// one helper of our own, named with the node binary that runs the
// connector; the seat's author identity; the seat's name for the lock
// tool. The token file's path is added only for the verbs that talk to
// origin.
export function gitEnv(git, { remote = false } = {}) {
const config = [
["credential.helper", `!'${process.execPath}' '${CREDENTIAL_HELPER}'`],
["user.name", git.author.name],
["user.email", git.author.email],
["core.askPass", ""],
["push.default", "nothing"],
];
const env = {
PATH: process.env.PATH || "/usr/bin:/bin",
HOME: process.env.HOME || "/nonexistent",
LANG: "C.UTF-8",
LC_ALL: "C.UTF-8",
GIT_CONFIG_GLOBAL: "/dev/null",
GIT_CONFIG_NOSYSTEM: "1",
GIT_TERMINAL_PROMPT: "0",
GIT_CONFIG_COUNT: String(config.length),
MOSAIC_AGENT_NAME: git.identity,
VAULT_LOCK_OWNER: git.identity,
[GIT_USERNAME_ENV]: git.identity,
};
config.forEach(([k, v], i) => {
env[`GIT_CONFIG_KEY_${i}`] = k;
env[`GIT_CONFIG_VALUE_${i}`] = v;
});
if (remote) env[GIT_TOKEN_FILE_ENV] = git.tokenFile;
return env;
}
// Anything that could carry a credential is masked before it goes further:
// a userinfo part in a url, and token-shaped words.
export function maskSecrets(text) {
return String(text ?? "")
.replace(/(https?:\/\/)[^/\s@]*@/g, "$1<masked>@")
.replace(/\b(ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}\b/g, "<masked>")
.replace(/\bgithub_pat_[A-Za-z0-9_]{20,}\b/g, "<masked>");
}
function cap(text) {
const s = maskSecrets(text).replace(/\0/g, "");
return s.length > GIT_OUTPUT_CAP ? `${s.slice(0, GIT_OUTPUT_CAP)}\n… cut at ${GIT_OUTPUT_CAP} characters` : s;
}
// Run one child with a fixed argv. Returns {status, stdout, stderr}; a
// timeout or spawn failure is a refusal, never an exception.
function run(cmd, args, cwd, env, spawn) {
const r = spawn(cmd, args, { cwd, env, encoding: "utf8", timeout: GIT_TIMEOUT_MS, maxBuffer: 4 * 1024 * 1024, stdio: ["ignore", "pipe", "pipe"] });
if (r.error && r.error.code === "ETIMEDOUT") throw new GitRefusal(GIT_REFUSAL.TIMEOUT, `${cmd} ${args[0] || ""}`);
if (r.error) throw new GitRefusal(GIT_REFUSAL.NO_GIT, `${cmd} could not start (${r.error.code || r.error.message})`);
return { status: r.status, stdout: cap(r.stdout || ""), stderr: cap(r.stderr || "") };
}
function git(root, gitCfg, args, { remote = false, spawn = spawnSync } = {}) {
return run("git", args, root.real, gitEnv(gitCfg, { remote }), spawn);
}
function firstLines(text, n = 5) {
return text.split("\n").filter((l) => l.trim().length > 0).slice(0, n).join("\n");
}
// The guard every verb runs first: on the named branch, nothing in
// progress, no conflicts.
function guard(root, gitCfg, deps) {
const head = git(root, gitCfg, ["symbolic-ref", "--short", "-q", "HEAD"], deps);
if (head.status !== 0) throw new GitRefusal(GIT_REFUSAL.DETACHED);
const branch = head.stdout.trim();
if (branch !== gitCfg.branch) throw new GitRefusal(GIT_REFUSAL.BRANCH, `on ${branch}, binding names ${gitCfg.branch}`);
const dir = git(root, gitCfg, ["rev-parse", "--absolute-git-dir"], deps);
if (dir.status !== 0) throw new GitRefusal(GIT_REFUSAL.NO_GIT, firstLines(dir.stderr, 1));
const gitDir = dir.stdout.trim();
for (const marker of ["MERGE_HEAD", "CHERRY_PICK_HEAD", "REVERT_HEAD", "rebase-merge", "rebase-apply", "BISECT_LOG"]) {
if (existsSync(join(gitDir, marker))) throw new GitRefusal(GIT_REFUSAL.IN_PROGRESS, marker);
}
const conflicts = git(root, gitCfg, ["diff", "--name-only", "--diff-filter=U"], deps);
if (conflicts.status === 0 && conflicts.stdout.trim().length > 0) throw new GitRefusal(GIT_REFUSAL.CONFLICT, firstLines(conflicts.stdout, 3));
return branch;
}
// Parse `git status --porcelain=v2 --branch` into plain data.
export function parseStatus(text) {
const out = { branch: null, upstream: null, ahead: null, behind: null, changed: [], untracked: [], conflicts: [], truncated: false };
let count = 0;
const push = (list, item) => {
if (count >= STATUS_PATHS_MAX) {
out.truncated = true;
return;
}
list.push(item);
count += 1;
};
for (const line of text.split("\n")) {
if (line.startsWith("# branch.head ")) out.branch = line.slice(14).trim();
else if (line.startsWith("# branch.upstream ")) out.upstream = line.slice(18).trim();
else if (line.startsWith("# branch.ab ")) {
const m = line.match(/\+(\d+) -(\d+)/);
if (m) {
out.ahead = Number(m[1]);
out.behind = Number(m[2]);
}
} else if (line.startsWith("1 ") || line.startsWith("2 ")) {
const f = line.split(" ");
const path = line.startsWith("2 ") ? f.slice(9).join(" ").split("\t")[0] : f.slice(8).join(" ");
push(out.changed, { path, state: f[1] });
} else if (line.startsWith("? ")) push(out.untracked, line.slice(2));
else if (line.startsWith("u ")) push(out.conflicts, line.split(" ").slice(10).join(" "));
}
return out;
}
export function gitStatus(root, deps = {}) {
const gitCfg = root.git;
guard(root, gitCfg, deps);
const r = git(root, gitCfg, ["status", "--porcelain=v2", "--branch", "--untracked-files=normal"], deps);
if (r.status !== 0) throw new GitRefusal(GIT_REFUSAL.NO_GIT, firstLines(r.stderr, 1));
return { root: root.name, ...parseStatus(r.stdout) };
}
// The vault protocol's pre-commit checks, run as fixed argv inside the root.
function vaultChecks(root, gitCfg, rels, deps) {
const env = gitEnv(gitCfg);
const check = run("python3", [VAULT_LOCK_TOOL, "check", "--owner", gitCfg.identity, "--", ...rels], root.real, env, deps.spawn || spawnSync);
if (check.status !== 0) throw new GitRefusal(GIT_REFUSAL.LOCKED, firstLines(check.stderr || check.stdout, 3));
const validate = run("python3", [VAULT_VALIDATOR], root.real, env, deps.spawn || spawnSync);
if (validate.status !== 0) throw new GitRefusal(GIT_REFUSAL.INVALID, firstLines(validate.stderr || validate.stdout, 5));
}
// Stage exactly `rels` (already vetted by the caller as regular files under
// the root), commit as the seat with the requester trailer, then push. A
// push that fails is reported in the result, not thrown: the commit is
// real and the next commit's push carries it (D6).
export function gitCommit(root, { message, rels, requester }, deps = {}) {
const gitCfg = root.git;
if (typeof message !== "string" || message.includes("\0") || message.trim().length === 0 || message.length > COMMIT_MESSAGE_MAX) throw new GitRefusal(GIT_REFUSAL.BAD_MESSAGE);
if (!Array.isArray(rels) || rels.length === 0 || rels.length > COMMIT_PATHS_MAX || rels.some((p) => typeof p !== "string" || p.length === 0 || p.startsWith("-"))) throw new GitRefusal(GIT_REFUSAL.BAD_PATHS);
if (typeof requester !== "string" || !/^[^\r\n:<>]{1,100}$/.test(requester)) throw new GitRefusal(GIT_REFUSAL.NO_REQUESTER);
const branch = guard(root, gitCfg, deps);
const staged = git(root, gitCfg, ["diff", "--cached", "--name-only"], deps);
if (staged.status !== 0) throw new GitRefusal(GIT_REFUSAL.COMMIT_FAILED, firstLines(staged.stderr, 1));
if (staged.stdout.trim().length > 0) throw new GitRefusal(GIT_REFUSAL.INDEX_DIRTY, firstLines(staged.stdout, 3));
if (gitCfg.protocol === "vault") vaultChecks(root, gitCfg, rels, deps);
const add = git(root, gitCfg, ["add", "--", ...rels], deps);
if (add.status !== 0) throw new GitRefusal(GIT_REFUSAL.BAD_PATHS, firstLines(add.stderr, 2));
const unstage = () => git(root, gitCfg, ["reset", "-q", "--", ...rels], deps);
const now = git(root, gitCfg, ["diff", "--cached", "--name-only"], deps);
if (now.stdout.trim().length === 0) {
unstage();
throw new GitRefusal(GIT_REFUSAL.NOTHING);
}
const commit = git(root, gitCfg, ["commit", "-q", "--no-status", "-m", message.trim(), "--trailer", `Requested-by: ${requester.trim()}`], deps);
if (commit.status !== 0) {
unstage();
throw new GitRefusal(GIT_REFUSAL.COMMIT_FAILED, firstLines(commit.stderr || commit.stdout, 5));
}
const rev = git(root, gitCfg, ["rev-parse", "--short", "HEAD"], deps);
const hash = rev.status === 0 ? rev.stdout.trim() : null;
const committed = now.stdout.trim().split("\n");
let pushed = false;
let pushError = null;
try {
pushOnce(root, gitCfg, branch, deps);
pushed = true;
} catch (err) {
if (!(err instanceof GitRefusal)) throw err;
pushError = err.message;
}
return { root: root.name, branch, hash, paths: committed, requester: requester.trim(), pushed, pushError };
}
function pushOnce(root, gitCfg, branch, deps) {
const r = git(root, gitCfg, ["push", "origin", `${branch}:${branch}`], { ...deps, remote: true });
if (r.status !== 0) throw new GitRefusal(GIT_REFUSAL.PUSH_FAILED, firstLines(r.stderr || r.stdout, 3));
return /Everything up-to-date/.test(r.stderr) ? { upToDate: true } : { upToDate: false };
}
export function gitPush(root, deps = {}) {
const gitCfg = root.git;
const branch = guard(root, gitCfg, deps);
const before = git(root, gitCfg, ["rev-parse", "--short", "HEAD"], deps).stdout.trim();
const r = pushOnce(root, gitCfg, branch, deps);
return { root: root.name, branch, hash: before, pushed: true, upToDate: r.upToDate };
}
export function gitPull(root, deps = {}) {
const gitCfg = root.git;
const branch = guard(root, gitCfg, deps);
const before = git(root, gitCfg, ["rev-parse", "--short", "HEAD"], deps).stdout.trim();
const r = git(root, gitCfg, ["pull", "--ff-only", "--no-rebase", "origin", branch], { ...deps, remote: true });
if (r.status !== 0) {
const text = `${r.stderr}\n${r.stdout}`;
if (/fast-forward|diverg|Not possible/i.test(text)) throw new GitRefusal(GIT_REFUSAL.NON_FF, firstLines(r.stderr || r.stdout, 2));
if (/would be overwritten|local changes/i.test(text)) throw new GitRefusal(GIT_REFUSAL.DIRTY, firstLines(r.stderr || r.stdout, 3));
throw new GitRefusal(GIT_REFUSAL.PULL_FAILED, firstLines(r.stderr || r.stdout, 3));
}
const after = git(root, gitCfg, ["rev-parse", "--short", "HEAD"], deps).stdout.trim();
return { root: root.name, branch, from: before, to: after, updated: before !== after };
}
// vault protocol: reserve the next free id for a prefix. The tool appends
// the registry line itself; the caller stages `docs/ID-REGISTRY.txt` with
// the record in the next commit.
export function reserveId(root, { prefix, title }, deps = {}) {
const gitCfg = root.git;
if (gitCfg.protocol !== "vault") throw new GitRefusal(GIT_REFUSAL.NO_PROTOCOL);
if (typeof prefix !== "string" || !VAULT_PREFIXES.includes(prefix)) throw new GitRefusal(GIT_REFUSAL.BAD_PREFIX);
if (typeof title !== "string" || title.trim().length === 0 || title.length > RESERVE_TITLE_MAX || /[\r\n\0]/.test(title) || title.startsWith("-")) throw new GitRefusal(GIT_REFUSAL.BAD_TITLE);
guard(root, gitCfg, deps);
const r = run("python3", [VAULT_LOCK_TOOL, "reserve", prefix, "--owner", gitCfg.identity, "--title", title.trim()], root.real, gitEnv(gitCfg, { remote: true }), deps.spawn || spawnSync);
const id = r.stdout.trim().split("\n").pop() || "";
if (r.status !== 0 || !/^[A-Z]{2,3}-\d{3,}$/.test(id)) throw new GitRefusal(GIT_REFUSAL.RESERVE_FAILED, firstLines(r.stderr || r.stdout, 3));
const note = firstLines(r.stderr, 1) || null;
return { root: root.name, id, registry: VAULT_REGISTRY, note };
}
// vault protocol: hold the clone lock for `rel` while `fn` runs. A lock
// another owner holds refuses with that owner named; the lock is released
// whatever `fn` does.
export function withVaultLock(root, rel, fn, deps = {}) {
const gitCfg = root.git;
if (!gitCfg || gitCfg.protocol !== "vault") return fn();
const env = gitEnv(gitCfg);
const spawn = deps.spawn || spawnSync;
const lock = run("python3", [VAULT_LOCK_TOOL, "lock", "--owner", gitCfg.identity, "--ttl", "300", "--", rel], root.real, env, spawn);
if (lock.status !== 0) throw new GitRefusal(GIT_REFUSAL.LOCKED, firstLines(lock.stderr || lock.stdout, 2));
try {
return fn();
} finally {
run("python3", [VAULT_LOCK_TOOL, "unlock", "--owner", gitCfg.identity, "--", rel], root.real, env, spawn);
}
}