docs(slice1): runbook section 1 run through the Gitea admin API (row 35, #1517, lead decision 74)

Jason ruled that agents run the steps his admin grant to the jarvis
Gitea token covers. Sage created the four mosaic-stack bots (ids
114-117, restricted, non-admin), added them as collaborators (W/W/W/R),
and minted one scoped token each (ids 191-194). The tokens were written
0600 outside the repo. Scripts and receipt are in
agents/sage/work/gitea-setup/. The guide and SR brief now say who
runs which section. Sections 2 to 4 (Vikunja) stay with Jason.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-09 17:25:08 -05:00
co-authored by Claude Opus 5.5
parent d404a3bab3
commit 5fe6a051d2
8 changed files with 235 additions and 10 deletions
@@ -0,0 +1,40 @@
Runbook section 1 (docs/guides/slice-1-identities.md), run by Sage with the
jarvis admin token on git.mosaicstack.dev (Gitea 1.27.1), 2026-10-09, between 22:21 and 22:23 UTC.
Lead decision 74. Output below is verbatim; it holds names, ids and statuses only.
$ node setup.mjs pm
mosaic-stack-pm-bot: create HTTP 201 id=114 admin=false restricted=true
mosaic-stack-pm-bot: collaborator write HTTP 204
mosaic-stack-pm-bot: token "mosaic-stack-pm-2026-10-09" HTTP 201 id=191 scopes=write:issue,read:repository,read:user
$ node setup.mjs cto coder reviewer
mosaic-stack-cto-bot: create HTTP 201 id=115 admin=false restricted=true
mosaic-stack-cto-bot: collaborator write HTTP 204
mosaic-stack-cto-bot: token "mosaic-stack-cto-2026-10-09" HTTP 201 id=192 scopes=write:issue,write:repository,read:user
mosaic-stack-coder-bot: create HTTP 201 id=116 admin=false restricted=true
mosaic-stack-coder-bot: collaborator write HTTP 204
mosaic-stack-coder-bot: token "mosaic-stack-coder-2026-10-09" HTTP 201 id=193 scopes=write:issue,write:repository,read:user
mosaic-stack-reviewer-bot: create HTTP 201 id=117 admin=false restricted=true
mosaic-stack-reviewer-bot: collaborator read HTTP 204
mosaic-stack-reviewer-bot: token "mosaic-stack-reviewer-2026-10-09" HTTP 201 id=194 scopes=write:issue,write:repository,read:user
$ node verify.mjs
pm: user=200 login=mosaic-stack-pm-bot admin=false | repo=200 push=true admin=false pull=true | issues=200 | admin/users=403 | org repos=403 n=-
cto: user=200 login=mosaic-stack-cto-bot admin=false | repo=200 push=true admin=false pull=true | issues=200 | admin/users=403 | org repos=403 n=-
coder: user=200 login=mosaic-stack-coder-bot admin=false | repo=200 push=true admin=false pull=true | issues=200 | admin/users=403 | org repos=403 n=-
reviewer: user=200 login=mosaic-stack-reviewer-bot admin=false | repo=200 push=false admin=false pull=true | issues=200 | admin/users=403 | org repos=403 n=-
$ stat -c '%a %s %n' ~/.config/mosaic-dev/secrets/mosaic-stack/*
600 40 .../mosaic-stack/coder-gitea.token
600 40 .../mosaic-stack/cto-gitea.token
600 40 .../mosaic-stack/pm-gitea.token
600 40 .../mosaic-stack/reviewer-gitea.token
Branch protections on mosaicstack/stack before and after: main and next,
push, merge and approvals allowlists all empty. No bot is on any.
Revocation check, pm bot, about 22:25 UTC: PATCH admin/users prohibit_login=true
(HTTP 200), then verify.mjs pm gave user=403 repo=403 issues=403. PATCH
prohibit_login=false (HTTP 200, restricted=true admin=false), then verify.mjs pm
gave user=200 repo=200 push=true issues=200 admin/users=403. prohibit_login
stops a bot's tokens at once, and clearing it restores them.
+74
View File
@@ -0,0 +1,74 @@
// Runbook section 1 (docs/guides/slice-1-identities.md) via the admin API.
// Prints names, ids and HTTP statuses only. No secret reaches argv, stdout or a URL.
import fs from "node:fs";
import crypto from "node:crypto";
import os from "node:os";
const BASE = "https://git.mosaicstack.dev/api/v1";
const REPO = "mosaicstack/stack";
const S = `${os.homedir()}/.config/mosaic-dev/secrets/mosaic-stack`;
const DATE = new Date().toISOString().slice(0, 10);
const ADMIN_FILE = `${os.homedir()}/.mosaic/fleet/agents/jarvis/secrets/gitea-mosaicstack-jarvis.token`;
const st = fs.lstatSync(ADMIN_FILE);
if (!st.isFile() || (st.mode & 0o077) !== 0) throw new Error("admin token file refused");
const adminTok = fs.readFileSync(ADMIN_FILE, "utf8").trim();
if (!/^[0-9a-f]{40}$/.test(adminTok)) throw new Error("admin token shape refused");
const sd = fs.lstatSync(S);
if (!sd.isDirectory() || (sd.mode & 0o077) !== 0) throw new Error("secrets dir refused");
const ROLES = [
{ r: "pm", perm: "write", scopes: ["write:issue", "read:repository", "read:user"] },
{ r: "cto", perm: "write", scopes: ["write:issue", "write:repository", "read:user"] },
{ r: "coder", perm: "write", scopes: ["write:issue", "write:repository", "read:user"] },
{ r: "reviewer", perm: "read", scopes: ["write:issue", "write:repository", "read:user"] },
];
async function call(method, path, body, auth = `token ${adminTok}`) {
const res = await fetch(`${BASE}/${path}`, {
method,
headers: { Authorization: auth, "Content-Type": "application/json", Accept: "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
});
const text = await res.text();
let json = null;
try { json = text ? JSON.parse(text) : null; } catch {}
return { status: res.status, json };
}
const only = process.argv.slice(2);
for (const { r, perm, scopes } of ROLES) {
if (only.length && !only.includes(r)) continue;
const u = `mosaic-stack-${r}-bot`;
const file = `${S}/${r}-gitea.token`;
if (fs.existsSync(file)) { console.log(`${u}: token file exists, skipped`); continue; }
let pw = crypto.randomBytes(36).toString("base64url");
const got = await call("GET", `users/${u}`);
let res;
if (got.status === 404) {
res = await call("POST", "admin/users", {
username: u, full_name: `mosaic-stack ${r} bot`, email: `${u}@noreply.mosaicstack.dev`,
password: pw, must_change_password: false, send_notify: false,
restricted: true, visibility: "private",
});
console.log(`${u}: create HTTP ${res.status} id=${res.json?.id} admin=${res.json?.is_admin} restricted=${res.json?.restricted}`);
if (res.status !== 201) { console.log(` message: ${res.json?.message}`); process.exit(1); }
} else if (got.status === 200) {
res = await call("PATCH", `admin/users/${u}`, { login_name: u, source_id: 0, password: pw, must_change_password: false });
console.log(`${u}: exists id=${got.json?.id}, password reset HTTP ${res.status}`);
if (res.status !== 200) process.exit(1);
} else { console.log(`${u}: lookup HTTP ${got.status}`); process.exit(1); }
res = await call("PUT", `repos/${REPO}/collaborators/${u}`, { permission: perm });
console.log(`${u}: collaborator ${perm} HTTP ${res.status}`);
if (res.status !== 204) process.exit(1);
const basic = "Basic " + Buffer.from(`${u}:${pw}`).toString("base64");
pw = null;
res = await call("POST", `users/${u}/tokens`, { name: `mosaic-stack-${r}-${DATE}`, scopes }, basic);
const tok = res.json?.sha1;
console.log(`${u}: token "mosaic-stack-${r}-${DATE}" HTTP ${res.status} id=${res.json?.id} scopes=${(res.json?.scopes || []).join(",")}`);
if (res.status !== 201 || typeof tok !== "string" || tok.length === 0) { console.log(` message: ${res.json?.message}`); process.exit(1); }
fs.writeFileSync(file, tok, { flag: "wx", mode: 0o600 });
}
+20
View File
@@ -0,0 +1,20 @@
// Probe each bot token: identity, repo permission, and refusals. Prints statuses only.
import fs from "node:fs";
import os from "node:os";
const BASE = "https://git.mosaicstack.dev/api/v1";
const S = `${os.homedir()}/.config/mosaic-dev/secrets/mosaic-stack`;
const roles = process.argv.slice(2).length ? process.argv.slice(2) : ["pm", "cto", "coder", "reviewer"];
for (const r of roles) {
const tok = fs.readFileSync(`${S}/${r}-gitea.token`, "utf8");
const h = { Authorization: `token ${tok}`, Accept: "application/json" };
const get = async (p) => { const res = await fetch(`${BASE}/${p}`, { headers: h }); let j = null; try { j = await res.json(); } catch {} return { s: res.status, j }; };
const me = await get("user");
const repo = await get("repos/mosaicstack/stack");
const issue = await get("repos/mosaicstack/stack/issues?limit=1");
const admin = await get("admin/users?limit=1");
const org = await get("orgs/mosaicstack/repos?limit=50");
const p = repo.j?.permissions || {};
console.log(`${r}: user=${me.s} login=${me.j?.login} admin=${me.j?.is_admin} | repo=${repo.s} push=${p.push} admin=${p.admin} pull=${p.pull} | issues=${issue.s} | admin/users=${admin.s} | org repos=${org.s} n=${Array.isArray(org.j) ? org.j.length : "-"}`);
}