// Piece D (8.9): the request comment and its transport. The queue never // reads a token. It checks the acting seat's credential file with lstat // only, and `scripts/gitea-api.sh`, the one reader, sends the token to curl // through a config stream. Every call runs under a hard deadline. import { spawnSync } from "node:child_process"; import { lstatSync, realpathSync } from "node:fs"; import { isAbsolute, join, resolve } from "node:path"; import { FAILED_CODES } from "./queue.mjs"; export const REPO_API = "repos/mosaicstack/stack"; export const DEFAULT_DEADLINE_MS = 30000; const LOGIN_RE = /^[a-z0-9][a-z0-9._-]{0,38}$/; const MAX_BODY = 60000; // The Gitea account a seat posts as. The lead's is jarvis (lead decision 37). export function loginFor(actor) { return actor === "sage" ? "jarvis" : actor; } // The acting seat's own token file, checked without opening it. Returns // null when it passes, else the reason. export function credCheck(env, actor) { const login = loginFor(actor); const p = env.MOSAIC_GITEA_CREDENTIAL_FILE; if (p === undefined || p === "") return `MOSAIC_GITEA_CREDENTIAL_FILE is not set; a request posts only with ${login}'s own token file`; if (!isAbsolute(p)) return "MOSAIC_GITEA_CREDENTIAL_FILE must be an absolute path"; if (env.HOME && resolve(p) === resolve(env.HOME, "secrets/mosaic.gitea.json")) return "MOSAIC_GITEA_CREDENTIAL_FILE names the shared default file; a request posts only with the seat's own token file"; const want = `/agents/${login}/secrets/gitea-mosaicstack-${login}.token`; if (!resolve(p).endsWith(want)) return `MOSAIC_GITEA_CREDENTIAL_FILE is not ${login}'s token file (…${want})`; let st; try { st = lstatSync(p); } catch { return `${login}'s token file does not exist`; } if (st.isSymbolicLink() || !st.isFile()) return `${login}'s token file must be a regular file, not a symlink`; // A linked directory must not lead to another seat's file. if (!realpathSync(p).endsWith(want)) return `MOSAIC_GITEA_CREDENTIAL_FILE resolves outside ${login}'s secrets directory`; if (typeof process.getuid === "function" && st.uid !== process.getuid()) return `${login}'s token file is not owned by this user`; if ((st.mode & 0o777) !== 0o600) return `${login}'s token file must be mode 0600`; return null; } // The two markers `review resolve` checks in a comment it is shown. export function markers(op, row, round, digest) { return [``, ``]; } function fence(text) { const longest = Math.max(0, ...(text.match(/`+/g) ?? []).map((s) => s.length)); return "`".repeat(Math.max(3, longest + 1)); } // The request comment for one attempt. export function requestBody(row, round, op) { const c = round.candidate; const lines = [ ...markers(op, row.id, round.n, c.digest), "", `Review request for queue row ${row.id}, round ${round.n}: ${row.piece}`, "", `- Owner: ${row.owner}`, `- Reviewers: ${row.reviewers.join(", ")}`, `- Gate: ${row.gate} (${row.gateOwner})`, `- Brief: ${row.brief ? `\`${row.brief.path}\` § ${row.brief.anchor} @${row.brief.blob.slice(0, 12)}` : "none"}`, `- Candidate: ${c.kind} \`${c.digest}\``, "", ]; if (c.kind === "manifest") { const f = fence(c.text); lines.push("The manifest:", "", `${f}text`, c.text.replace(/\n$/, ""), f, ""); lines.push(`Check a tree against it with \`scripts/mosaic queue review verify-commit ${row.id} REF\`.`, ""); } else { lines.push(`Check a prospective commit against it with \`scripts/mosaic queue review verify-commit ${row.id} REF\`.`, ""); } lines.push( "Post your verdict as a comment here, then record it:", "", "```", `scripts/mosaic queue review record ${row.id} --verdict approve|changes --comment COMMENT_ID --candidate ${c.digest} --op OP --by SEAT`, "```", ); return `${lines.join("\n")}\n`; } export function bodyTooLong(body) { return Buffer.byteLength(body) > MAX_BODY; } // One helper call under the deadline. `timeout -s KILL` kills the helper's // whole process group, curl included, so nothing runs on after it. export function callTool(ctx, top, args) { const tool = join(top, "scripts/gitea-api.sh"); const secs = String(ctx.deadlineMs / 1000); const r = spawnSync("timeout", ["-s", "KILL", secs, tool, ...args], { cwd: top, env: ctx.env, encoding: "utf8", maxBuffer: 16 << 20 }); const m = /^HTTP (\d{3})$/m.exec(r.stderr ?? ""); return { spawnFailed: r.error?.code === "ENOENT", killed: r.signal === "SIGKILL" || r.status === 137, error: r.error ? true : false, exit: r.status, http: m ? Number(m[1]) : null, requestFailed: /^gitea-api: request failed$/m.test(r.stderr ?? ""), stdout: r.stdout ?? "", }; } function jsonOrNull(text) { try { return JSON.parse(text); } catch { return null; } } // The POST's outcome. Details are fixed text: nothing from the response is // recorded or echoed. export function classifyPost(r) { const out = (outcome, status, comment, detail) => ({ outcome, status, comment, detail }); if (r.spawnFailed) return out("failed", null, null, "pre-send: the timeout command could not run"); if (r.killed) return out("uncertain", null, null, "no answer before the deadline"); if (r.error) return out("uncertain", r.http, null, "the helper call failed"); if (r.http === 201) { const j = jsonOrNull(r.stdout); const id = j && typeof j === "object" ? j.id : undefined; if (Number.isSafeInteger(id) && id > 0) return out("posted", 201, id, "created"); return out("uncertain", 201, null, "HTTP 201 without a comment id"); } if (r.http !== null && FAILED_CODES.includes(r.http)) return out("failed", r.http, null, `refused with HTTP ${r.http}`); if (r.http !== null) return out("uncertain", r.http, null, `unexpected HTTP ${r.http}`); if (r.requestFailed) return out("uncertain", null, null, "the request failed in transit"); return out("uncertain", null, null, "no HTTP status came back"); } // GET user: the token must belong to the acting seat's login. Returns null // or the reason, which is safe to print. export function checkUser(r, login) { if (r.spawnFailed) return "the timeout command could not run"; if (r.killed) return "GET user had no answer before the deadline"; if (r.error || r.http === null) return "GET user failed"; if (r.http !== 200) return `GET user answered HTTP ${r.http}`; const j = jsonOrNull(r.stdout); const got = j && typeof j === "object" ? j.login : undefined; if (got === login) return null; const shown = typeof got === "string" && LOGIN_RE.test(got) ? got : "an unexpected value"; return `the token belongs to ${shown}, not ${login}`; } // GET issues/comments/ID for `review resolve`: the comment must be on the // round's issue and carry both of the attempt's markers. export function checkComment(r, { id, issue, op, row, round, digest, author }) { if (r.spawnFailed || r.killed || r.error || r.http === null) return { code: 1, reason: `GET comment ${id} failed or had no answer before the deadline` }; if (r.http === 404) return { code: 2, reason: `comment ${id} does not exist` }; if (r.http !== 200) return { code: 1, reason: `GET comment ${id} answered HTTP ${r.http}` }; const j = jsonOrNull(r.stdout); if (!j || typeof j !== "object") return { code: 1, reason: `GET comment ${id} did not answer JSON` }; const wrong = []; if (j.id !== id) wrong.push("its id"); if (!j.user || j.user.login !== author) wrong.push(`its author (want ${author})`); if (typeof j.issue_url !== "string" || !j.issue_url.endsWith(`/issues/${issue}`)) wrong.push(`the issue (want #${issue})`); const body = typeof j.body === "string" ? j.body.split("\n") : []; const [mOp, mRound] = markers(op, row, round, digest); if (!body.includes(mOp)) wrong.push(`the op marker for ${op}`); if (!body.includes(mRound)) wrong.push(`the round marker (row ${row} round ${round} candidate ${digest})`); return wrong.length ? { code: 2, reason: `comment ${id} does not match request ${op}: ${wrong.join(", ")}` } : null; }