diff --git a/BUILD-LOG.md b/BUILD-LOG.md index d5cc3d0e..7c371778 100644 --- a/BUILD-LOG.md +++ b/BUILD-LOG.md @@ -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. diff --git a/agents/sage/work/gitea-setup/2026-10-09_run.txt b/agents/sage/work/gitea-setup/2026-10-09_run.txt new file mode 100644 index 00000000..dc08ef2d --- /dev/null +++ b/agents/sage/work/gitea-setup/2026-10-09_run.txt @@ -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. diff --git a/agents/sage/work/gitea-setup/setup.mjs b/agents/sage/work/gitea-setup/setup.mjs new file mode 100644 index 00000000..897564f0 --- /dev/null +++ b/agents/sage/work/gitea-setup/setup.mjs @@ -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 }); +} diff --git a/agents/sage/work/gitea-setup/verify.mjs b/agents/sage/work/gitea-setup/verify.mjs new file mode 100644 index 00000000..7fccd5bc --- /dev/null +++ b/agents/sage/work/gitea-setup/verify.mjs @@ -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 : "-"}`); +} diff --git a/docs/SESSIONS.md b/docs/SESSIONS.md index d1bad916..016fc295 100644 --- a/docs/SESSIONS.md +++ b/docs/SESSIONS.md @@ -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 diff --git a/docs/guides/slice-1-identities.md b/docs/guides/slice-1-identities.md index a4cdc738..d001dc0d 100644 --- a/docs/guides/slice-1-identities.md +++ b/docs/guides/slice-1-identities.md @@ -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 `@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. diff --git a/docs/plans/2026-09-26_lead-decisions.md b/docs/plans/2026-09-26_lead-decisions.md index ba88e48a..78a0d2c0 100644 --- a/docs/plans/2026-09-26_lead-decisions.md +++ b/docs/plans/2026-09-26_lead-decisions.md @@ -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--2026-10-09` with the guide's scopes, in + `~/.config/mosaic-dev/secrets/mosaic-stack/-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 `@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. diff --git a/docs/plans/2026-10-04_slice-1.md b/docs/plans/2026-10-04_slice-1.md index 78af9bcd..cb08a3ce 100644 --- a/docs/plans/2026-10-04_slice-1.md +++ b/docs/plans/2026-10-04_slice-1.md @@ -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