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
+10
View File
@@ -3766,3 +3766,13 @@ Follow-ups, none blocking:
- A refusal line with a future `at` holds the DM silently. A clock step back lengthens the wait (Darkwing, Filbert). Both fail safe.
- The README calls 429 "unknown" while the journal records it as refused (Filbert).
- A regular file at the directory path reports "mode 0700" where "not a directory" would be clearer (Darkwing).
### 2026-10-09 — Sage, slice 1 runbook section 1: Gitea bots through the admin API (row 35, #1517, lead decision 74)
Before: no `mosaic-stack-*-bot` users (all four returned 404). `~/.config/mosaic-dev/secrets/mosaic-stack/` existed at 0700 and was empty, since Jason had run section 0 at 22:13 UTC. Row 35 waited on Jason to run the whole runbook by hand.
Jason ruled that agents run the steps his admin grant to the jarvis Gitea token covers. Sage ran section 1 with a Node script outside the repo, now kept with its receipt in `agents/sage/work/gitea-setup/`. Nothing secret reached argv, stdout or a URL. Each bot's password was random, held in memory and discarded after it minted that bot's token.
After: users ids 114 to 117, none admin, all `restricted` with `private` visibility. Collaborators Write for pm, cto and coder, Read for reviewer. Tokens ids 191 to 194 with the guide's scopes, four files at 0600 and 40 bytes. `verify.mjs` showed each token logs in as its bot. Reviewer has push=false. Every token gets 403 on `admin/users` and on the org listing. The main and next allowlists stayed empty. A `prohibit_login` toggle on the pm bot gave 403 on every route and then restored it.
Not done: sections 2 to 4 (Vikunja). They need the estate instance (T236) and the owner and `svc-mosaic-stack` logins, and no agent holds those. Follow-up due before 2027-01-07: the script deletes the old token during a rotation. No suite covers this work. It touches no code the suites run.
@@ -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 : "-"}`);
}
+1
View File
@@ -540,3 +540,4 @@ are never rewritten or removed; corrections are new entries.
2026-10-09T14:09:53Z | Rocko (T3 Claude Code, thread b84bb264) | row 45 (#1527) S4 follow-up round 2 candidate | Packet agents/rocko/work/s4-follow-up/ on d539d8d2: build.patch c8cec070…6756, candidate manifest 5b067a9d…0d0e, packet manifest 4ec1ea9a…2d30. R1 send callbacks at host.mjs:144/150/184/188 with deterministic EPIPE tests; R2 30-min refusal wait from the journal (decision 73); README manual recovery; X9, X14, notes 1 and 3; append type-check declined with reason. Gate green except test-task's 2 Docker cases (conversation 152/0); 31/31 mutants killed. Queue revs 212-214 uncommitted: round 2 opened, request rocko-r45-review-r2 failed (no credential), for Sage to post. No commits, pushes, Gitea calls, token or binding reads.
2026-10-09T14:26Z | Filbert (T3 Claude Code, thread 9cb9731e) | row 45 (#1527) S4 follow-up review round 2 | approve, comment 26886, queue rev 218 (5eb9fa8a), record 9b067be1; B1 resolved (permanent 403 sends at 0/30/60/90/120 min, gave-up at 120, also under a 15 s crash loop and a 401), R1 resolved (4 callbacks, each mutant fails an EPIPE test), G184 fails in 22 s (CLOSE_TIMEOUT_MS) not a hang, append type-check decline agreed (A3: 20 real DMs in 10 min), 31/31 of Rocko's mutants killed, gate green but test-task 26/2 (Docker recall), notes: future-at silent hold, README 429 wording, digest-refusal pacing untested
2026-10-09T14:29:13Z | Sage (T3 Claude Code, thread 1ef1e4f8) | land row 45 (#1527) | Darkwing (26884) and Filbert (26886) approve round 2, candidate 5b067a9d. Gate on b13fef4c plus the patch all green, no COMPOSE_PROJECT_NAME override, test-task 98/0. Candidate 9cdb6d82, my trackers-boot copy removed 2be51cf5, Rocko's BUILD-LOG entries and SESSIONS line plus my landing entry 5c680166, queue rev 219 f345ae85, pushed; #1527 closed (comment 26892). Reviewers' non-blocking notes recorded for a follow-up row. Gate worktree removed; Rocko told to remove their tree.
2026-10-09T22:24:54Z | Sage (T3 Claude Code, thread 1ef1e4f8) | row 35 (#1517) runbook section 1, Gitea bots, on Jason's admin-token grant | lead decision 74; four mosaic-stack bots ids 114-117 restricted and non-admin, collaborators W/W/W/R, tokens 191-194 at 0600 in ~/.config/mosaic-dev/secrets/mosaic-stack (stat only), verify.mjs and a prohibit_login revocation test pass, protections unchanged; guide and SR brief updated; row 35 note and gate, revs 221-222; Vikunja half still Jason's
+36 -5
View File
@@ -1,10 +1,17 @@
# Slice 1 identities: Gitea bots, Vikunja bots and their tokens
Jason runs this guide once per business, by hand (PRD REQ-CRED-1, round
An operator runs this guide once per business (PRD REQ-CRED-1, round
3, 1A and 2A). It creates every service identity slice 1 uses and writes
each token to a 0600 file the broker reads. Nothing in v1 mints these
tokens for you. Brief: `docs/plans/2026-10-04_slice-1.md`, row SR.
Who the operator is depends on the credential. On 2026-10-09 Jason gave
the jarvis Gitea token site admin rights and ruled that agents run the
steps it covers, so Sage ran section 1 for `mosaic-stack` through the
API (lead decision 74). Sections 2 to 4 need the Vikunja owner and
`svc-$BIZ` logins, which no agent holds, so they stay with Jason until he
grants a Vikunja credential.
Plan on about 20 minutes with an existing Vikunja, and 30 if you start
the bundled one.
@@ -18,7 +25,9 @@ the bundled one.
- To check a token file, use `stat`, never `cat`.
- The Vikunja owner and `svc-$BIZ` passwords and the Gitea admin login
are the high-value secrets. They are used only in this guide, and never reach
the broker or an agent.
the broker or a worker. The one exception is the jarvis Gitea admin
token, which Jason granted to the lead seat for section 1 (decision 74).
It stays in its fleet file, and the broker never reads it.
## 0. Set up the shell
@@ -40,8 +49,21 @@ The commands need curl 7.76 or later, for `--fail-with-body`.
## 1. Gitea: four bot users and their tokens
Gitea tokens don't expire, and the HTTP route for creating one needs a
password, so this part uses the web UI.
Gitea tokens don't expire, and the HTTP route for creating one needs
the bot's password. The steps below use the web UI.
With a site admin token there's an API route that does the same five
steps: `agents/sage/work/gitea-setup/setup.mjs`, which ran for
`mosaic-stack` on 2026-10-09 (receipt `2026-10-09_run.txt` beside it). It
creates each bot with a random password that exists only in its
memory, adds the collaborator, mints the token with basic auth as the bot,
and writes the file with `O_EXCL` at 0600. The password is never
written down, so nobody can log in as a bot. It also creates each bot as
`restricted` with `private` visibility, which the steps below don't ask
for. A restricted user sees only repositories it collaborates on. The
bots' addresses are `<user>@noreply.mosaicstack.dev`, which receive no
mail. `verify.mjs` checks each token's login, repository permission and
refusals, and prints no secret.
1. As a site admin, create the users `mosaic-stack-pm-bot`,
`mosaic-stack-cto-bot`, `mosaic-stack-coder-bot` and
@@ -369,8 +391,17 @@ role. The stack never writes this file.
the broker, then delete the old token in the same settings page.
Update `rotateBy`. Each rotation gets one line in
`docs/SESSIONS.md`: date, who, which identities, and no values.
The `mosaic-stack` bots from `setup.mjs` have no known password, so
nobody can log in to rotate. Rerunning the script for one role after
moving its old file aside resets the password and mints a new token. It
doesn't yet delete the old token, which needs the same basic auth.
Adding that is a follow-up due before the first `rotateBy`,
2027-01-07.
- **Revoking a role at once:** delete its token, the Gitea one in the
bot's settings and the Vikunja one with the `DELETE` above as
`svc-$BIZ`, or remove the bot's collaborator access or, as the owner,
its project share. The broker's next
its project share. For a bot with no known password, a Gitea admin
removes the collaborator
(`DELETE /repos/{owner}/{repo}/collaborators/{user}`) or sets
`prohibit_login` on the user, which also stops its tokens. The broker's next
call gets a 401 or 403 and refuses.
+43
View File
@@ -1464,3 +1464,46 @@ which stay with him. Each item names who decided it and what happened.
before the signal and so never hit the start-up race. Row 46
landed the fixture fix as 2d308abd. The BUILD-LOG landing entry
records it.
74. **Agents run the Gitea steps Jason's admin token covers, and
section 1 is done (2026-10-09).** Source: Jason in Sage's thread,
2026-10-09, after I asked him to run section 1: "I should not be
doing this. I have granted agentic tokens to admin the gitea server
at git.mosaicstack.dev ... I would like you to use the gitea token to
set things up. Ask me any questions for the setup, but I should not
be performing the steps I already granted rights for."
- The rule: a step a granted credential covers is an agent's step,
not Jason's. The lead seat runs it and records it. A step that
needs a credential no agent holds still goes to Jason, and so do
decisions about granting one. The grant covers the jarvis token's
admin rights on git.mosaicstack.dev only. It gives no push to
`next` or `main`, no merge and no branch-protection change, and
R10 and the merge plan's gates stand.
- Sage ran runbook section 1 for `mosaic-stack` through the API
between 22:21 and 22:23 UTC. Users `mosaic-stack-{pm,cto,coder,reviewer}-bot`
are ids 114 to 117, none admin. Collaborators on
`mosaicstack/stack` are Write for pm, cto and coder and Read for
reviewer. Tokens are ids 191 to 194, named
`mosaic-stack-<role>-2026-10-09` with the guide's scopes, in
`~/.config/mosaic-dev/secrets/mosaic-stack/<role>-gitea.token`
at 0600, 40 bytes each, checked with `stat`. The `rotateBy` base
is 2026-10-09, so the first rotation is due 2027-01-07. The main
and next protections' push, merge and approvals allowlists are
empty before and after. Receipt and scripts:
`agents/sage/work/gitea-setup/`.
- Choices the guide didn't make, all narrower than it: each bot is
`restricted` with `private` visibility. Bot passwords were random,
held in memory only and discarded, so nobody can log in as a bot.
Addresses are `<user>@noreply.mosaicstack.dev`. Jason invited
questions on these. Each had a narrower option, so I took it
instead of asking, and he can reverse any of them.
- A test on the pm bot showed `prohibit_login` stops its token at
once (403) and clearing it restores the token. With no bot
password, that flag and collaborator removal are the immediate
revocations. Follow-up before 2027-01-07: the setup script also
deletes the old token during a rotation.
- Not covered: the Vikunja half. Sections 2 to 4 need the estate
instance (T236, not serving) and the owner and `svc-mosaic-stack`
logins. No agent holds a Vikunja admin credential, so R5 keeps
`svc-mosaic-stack` with Jason until he grants one. Row 35 stays
waiting on Jason for that half only. Its note said Path B, which
decisions 66 and 67 replaced, and the note now says so.
+11 -5
View File
@@ -195,14 +195,20 @@ No token value appears in the runbook, a command line or a URL.
### Out of scope
- Any script that creates users or tokens. Minting is out of v1.
- Running the runbook. Jason does that, and row S3's live tests wait for
it.
- Any script in the product that creates users or tokens. Minting is
out of v1. Sage's operator script for section 1
(`agents/sage/work/gitea-setup/`, decision 74) isn't part of the
stack, and nothing in the stack calls it.
- Running the runbook. Sage ran section 1 (Gitea) on 2026-10-09 with
the admin token Jason granted (decision 74). Jason runs sections 2 to
4 (Vikunja) until he grants a Vikunja credential, and row S3's live
tests wait for them.
### Gate
Darkwing approves the scope tables. Jason runs it, and the broker's
startup probe passes for every identity.
Darkwing approves the scope tables. The runbook runs, section 1 by
Sage and sections 2 to 4 by Jason, and the broker's startup probe passes
for every identity.
## Slice 1 S1: roles v2, business and project files, variable layers