Compare commits

...
Author SHA1 Message Date
jason.woltje 3330c901b9 plans: #436b approved at r5 1be535aca (dewey-0763); build carry N9 (restart after a revoke on an open rotation, 404 on delete by id is done) and N10 (serviceLeft a list); DESIGN.md unchanged; no code change 2026-10-20 18:36:07 -05:00
jason.woltje 1be535aca9 plans: #436b design r5 on dewey-0761: revoke on an open rotation deletes both previousTokenId and the minted tokenId, failures to serviceLeft (X-G5); N8 step 0 refuses grant-token-unknown while held; section 7 unchanged; no code change 2026-10-20 18:35:14 -05:00
jason.woltje 3e66f5acfd plans: #436b design r4 on dewey-0759: item 1 option (b) per sage-1578 (step 0 refusal unspent only until the next start, cost in D5, X-G11 restart case); item 2 settle 200 unknown id drops memory, deletes nothing, grant-token-unknown, held at start; N7 secretCheck in 3d; duckworth-0067 N1 kind filter, X-G15; section 7 unchanged; no code change 2026-10-20 18:33:29 -05:00
jason.woltje fa5658941b plans: #436b design r3 on dewey-0756 (B1-B3, N1-N6), section 7 guard per gizmoduck-0081/-0084 and duckworth-0065, F13 settle probe, Q-B4 closed by mos-1146; no code change 2026-10-20 18:27:52 -05:00
jason.woltje be823a6f73 plans: #436b design r2.1 work in progress, section 7 on gizmoduck-0081 (not final: dewey-0756 and duckworth-0065 still open); no code change 2026-10-20 17:55:11 -05:00
jason.woltje 6db7cc45d5 plans: #436b design r2 on dewey-0754: Gitea user id and a nonce adopt, grant.* as reserved action.allowed bodies with a system/grant trigger, seat-token rotation settled by GET /api/v1/token, the admin file's real reach (PATCH /admin/users proven), admin checks before consume with grants run, items 6 to 12; F8 to F12 from scratch 1.27.1; no code change 2026-10-20 17:49:30 -05:00
jason.woltje 714c073043 plans: #436b draft r1, 3b's open item closed on a scratch 1.27.1: GET /api/v1/token needs the token header (Basic 404s) and reports normalized scopes; no code change 2026-10-20 17:39:14 -05:00
jason.woltje 82ff4ae4b1 plans: #436b design draft r1, the Gitea grant path on #436a r4: the binding, the executor's admin credential (mos-1130 item 3 a to e, mos-1136 scopes and the 1.27.0 floor), steps, restart, revoke, approval notes; the shared part proposed to Bushroot (megavolt-0036); no code change 2026-10-20 17:38:17 -05:00
jason.woltje e10e03f6be plans: row 199 DESIGN.md section 7a, the grant path (mos-1124, Q188, Q190); A1 to A4 are the fallback only; Q-J1 and Q-J2 settled; no code change (#1697) 2026-10-20 17:20:38 -05:00
jason.woltje 07141966e1 cli: row 199 rotate on a due gitea key; ISSUED names rotation-due, the state the bus's status() gives a gitea key past its rotate-by date, so credentials rotate no longer refuses it; a test over every status state; BUILD.md deviation 15 (gizmoduck-0050, sage-1494, #1697) 2026-10-20 16:16:43 -05:00
jason.woltje addee664ab cli: row 199 sage-1440: publishResult's poll is bounded by publishDeadlineMs (four times git's limit and grace plus 30 s) and refuses publish-timeout, filed once; end-to-end tests for ref-moved and decision-mismatch mid-flight and for the timeout; DESIGN.md T3 now says such a push refuses; BUILD.md deviations 11, 13 and 14 ruled (#1697) 2026-10-20 15:27:54 -05:00
jason.woltje 8315594389 plans: row 199 BUILD.md, deviation 10 ruled (sage-1421), deviations 11 to 14 for publish, the publish runs (#1697) 2026-10-20 15:21:13 -05:00
jason.woltje 6d5e71b129 cli: row 199 the git socket's publish op; checkPublish, the seat's checks under the lock, then publishPush and a poll of publishResult, then recordPublish; a refusal after the ticket is filed once by publishRefused; the bind rule adds git.push.remote; an end-to-end test through the in-process runtime, the store and a TLS fake Gitea (#1697) 2026-10-20 15:21:12 -05:00
jason.woltje f83f3e6553 bus: row 199 publishPush and publishResult; the push runs in the bus child under withGitea with fork, user, source and dst from the ticket (publishTarget), and the host polls; recordPublish refuses a ref that isn't the ticket's dst; the bus process answers the five publish ops; publishTls is in-process only (#1697) 2026-10-20 15:21:12 -05:00
jason.woltje 00499a4fb3 seat: row 199 publish and publishRemote; the tip and identity checks under the working repo's lock, then ls-remote and a push by oid to refs/heads/<user>/<branch> on the agent's fork; the token reaches git on fd 3 through a one-shot helper for the fork's exact host and port; no redirects, no stored remote or credential; a TLS fake Gitea for the tests (#1697) 2026-10-20 15:21:06 -05:00
jason.woltje 94c4a484f0 plans: row 199 BUILD.md, the lifecycle and CLI commits, deviations 7 to 10 (#1697) 2026-10-20 15:06:36 -05:00
jason.woltje 0cdace4155 cli: row 199 mosaic credentials status, issue, rotate, revoke and delete; issue and rotate read the token on stdin, never argv; --tester takes status and revoke; scripts/mosaic routes credentials to packages/cli (#1697) 2026-10-20 15:06:22 -05:00
jason.woltje aa740cc715 bus: row 199 credential lifecycle verbs, credentials.issue (rotate over an issued entry), credentials.revoke and credentials.delete; human-only, the tester revokes; delete waits for the agent's claims; each verb leaves one changes.jsonl receipt with no value, record() for a store-only change (#1697) 2026-10-20 15:04:08 -05:00
jason.woltje a18fc6cf8c tasks: row 199 request calls use useFor keyed from the session; the host's start probe keeps use; a D5 test pins the use( call sites (#1697) 2026-10-20 14:59:45 -05:00
jason.woltje 39d9bd6e64 plans: row 199 BUILD.md, deviations 1 to 6, N9 for the routing commit, R1 backup set checked (#1697) 2026-10-20 14:57:24 -05:00
jason.woltje 5043367978 bus: row 199 runtime wiring, the store root is <configDir>/secrets/agents, and the op table gets checkPublish, withGitea, recordPublish and publishRefused; withGitea keys from the open publish ticket (#1697)
Deviation from D9b, recorded for BUILD.md: withGitea takes the ticket, not a cap. The key is the
session checkPublish checked as an agent holding its claim (broker.publishSession), so a tester,
reader or human cap never reaches a token, and the token is usable only between checkPublish and
recordPublish or publishRefused. Its refusals are filed once, by publishRefused.
2026-10-20 14:56:03 -05:00
jason.woltje 609dc9364d bus: row 199 per-agent credential store, store references, issue (tmp, fsync, rename, commit, memory), revoke (memory first, store file only), remove, useFor keyed from the session, not-issued and revoked states (#1697)
Design D1, D3a, D4 and section 6. Tests T1, T2 and the store half of T5 use fake tokens in a scratch
root. A wrong owner can't be set without root, so T1 covers modes, symlinks and dirs only.
2026-10-20 14:52:52 -05:00
jason.woltje a2c387dba6 bus: row 199 broker publish authority, the decision binds the latest push (pushCommit), checkPublish builds the agent's fork, recordPublish spends the decision; the D9c parse moves to vocabulary.mjs to keep the broker's import closure (#1697)
git.push.remote is a hard wait. decision.raise records the task's latest push commit and refuses
no-push without one; the authority check refuses decision-mismatch when the latest push moved.
checkPublish refuses decision-consumed, remote-not-granted, remote-host-mismatch, remote-not-fork,
no-push, work-not-yours and work-ref, and returns the fork, user, email, source and host-built
destination. pm.md and chief-of-staff.md list git.push.remote among the hard waits.
2026-10-20 14:51:54 -05:00
jason.woltje 286bf598c0 business: row 199 agent identity, publish grant, project upstream with the D9c parse, store credential references; git.push.remote as a hard wait (#1697) 2026-10-20 14:45:44 -05:00
jason.woltje 4d3007ec95 plans: row 199 design r6, answers duckworth-0026 B5 (ls-remote, push and helper use the derived fork) and B6 (URL parse in D9c), N10, N11 (#1697) 2026-10-20 14:42:02 -05:00
jason.woltje 34bfe4a53d plans: row 199 design r5, the host derives each agent's fork from the upstream and its own Gitea user (webby-0011 N1), identity optional and required by publish (#1697) 2026-10-20 14:36:20 -05:00
jason.woltje 12c85ada18 Merge remote-tracking branch 'origin/refactor' into megavolt/row199 2026-10-20 14:34:09 -05:00
jason.woltje e1aa6216c7 plans: row 199 design r4, answers duckworth-0017 B3 (TLS fake through a test seam), B4 (decision binds the commit, push by oid), N6 to N8 (#1697) 2026-10-20 14:32:16 -05:00
jason.woltje c2c729ddf0 plans: row 199 design r3, Mos rulings mos-1027 (forks, git.push.remote as a hard wait, tester status and revoke only) (#1697) 2026-10-20 14:27:49 -05:00
jason.woltje e806656b1d plans: row 199 design r2, answers duckworth-0013 B1, B2 and N1 to N5 (#1697) 2026-10-20 14:26:36 -05:00
jason.woltje aa1bd209e3 gate-lane: row 189 gate timing table for row 197 (Herb) 2026-10-20 14:25:17 -05:00
jason.woltje 624e8ea3ab plans: row 199 design, per-agent credential store, own git identity, publish to a granted remote (#1697) 2026-10-20 14:22:26 -05:00
jason.woltje ace27dc5eb row 189 (#1687): stack-release.sh activate --no-send drops the Console's --send (Sage; Dewey approve 29485; Sage gate passed) 2026-10-20 14:21:56 -05:00
jason.woltje a4a42dbac2 plans: stack-release --sso brief (#1698, Launchpad, reviewer Webby, row 192 F3; chained on 192) 2026-10-20 14:18:08 -05:00
jason.woltje a31f1a0f8b plans: row 198 brief records Mos's call, in-process board reads behind the Console's auth (mos-1019) 2026-10-20 14:17:03 -05:00
jason.woltje 992650232f plans: agent credentials and git identity brief (MVP T1 item 6, #1697, Megavolt, reviewer Duckworth); Megavolt seat dir 2026-10-20 14:14:33 -05:00
jason.woltje a7661ada06 plans: row 195 acceptance script takes Gizmoduck's gate item list; B8 issuer and TLS front end (mos-1011) 2026-10-20 14:12:19 -05:00
jason.woltje f871cb3096 plans: retire the control board writer on 7331 brief (#1696, Bushroot, reviewer Webby, mos-1008 item 2); Bushroot seat dir 2026-10-20 14:11:21 -05:00
jason.woltje 0bbd23c619 plans: row 195 brief adds the identity provider check and flags any new model account for Jason (mos-1008) 2026-10-20 14:10:28 -05:00
jason.woltje 0217b71c8f plans: row 195 brief rewritten as the hub on its own host, host left to Jason (#1693, mos-0998, mos-1005) 2026-10-20 14:07:57 -05:00
jason.woltje c6f528a32f plans: second gate lane brief (#1695, Herb, reviewer Webby, mos-0977 item 6); Herb seat dir; gate template and busy check copies for the row 2026-10-20 14:03:36 -05:00
jason.woltje 96f243fafc plans: human boundary row brief (P19 333, #1694, Gander, reviewer Duckworth, mos-0989); Gander seat dir; seat READMEs name the credentials row, not row 195 (now 190b) 2026-10-20 14:00:58 -05:00
jason.woltje c862699352 sessions: Filbert row 190 design review r3, approved 2026-10-20 14:00:05 -05:00
jason.woltje 48ebee0ffe plans: row 190 brief scopes isolation to addressing on one uid, points to row 190b (Filbert N7, mos-0986) 2026-10-20 13:58:07 -05:00
jason.woltje 66bdd84b8f sessions: Filbert row 190 design review r2, changes requested 2026-10-20 13:57:10 -05:00
jason.woltje 6966164f0c plans: row 190b brief, Melanie's hub under its own Linux user (#1693, Darkwing, reviewer Duckworth), mos-0986 2026-10-20 13:56:34 -05:00
jason.woltje 53bb594621 sage: common start for MVP T1 builder seats (mos-0974, mos-0977, mos-0981) 2026-10-20 13:55:47 -05:00
jason.woltje 5e01c36833 agents: seat dirs and roster rows for Launchpad, Morgana, Honker (Sage's T1 builders) and Webby, Duckworth (Mos's reviewers); row 193 brief takes Mos's B1 starting rule (mos-0985) 2026-10-20 13:52:28 -05:00
jason.woltje 9f56d2098d plans: rows 192 and 194 review by Webby, not Filbert or Dewey (mos-0981) 2026-10-20 13:51:25 -05:00
jason.woltje 0599b119f4 plans: MVP T1 briefs, SSO phase 1 (#1690, Launchpad), human users (#1691, Morgana), agent lifecycle (#1692, Honker), mos-0974/0977 2026-10-20 13:49:15 -05:00
jason.woltje b416d2ede9 sage: Q175 SSO design, Mos C1 condition (mos-0977): straggler check before Jason step 4 in the phase 2 window 2026-10-20 13:49:15 -05:00
jason.woltje 5fc73a729c revs 2237-2247: row 189 added, briefed, started, in-review round 1 (Dewey, comment 29457); rows 190 (Darkwing) and 191 (Rocko) added and briefed for MVP T1 (mos-0974); darkwing-190-start (rev 2245, Darkwing's) and rocko-191-start (rev 2244, made for Rocko) 2026-10-20 13:47:51 -05:00
jason.woltje 99772421ab sage: row 189 candidate manifest for Dewey's review (d6116771f) 2026-10-20 13:46:35 -05:00
jason.woltje da230ca489 plans: MVP T1 briefs, several instances per host (#1688, Darkwing) and the kill switch (#1689, Rocko), mos-0974 2026-10-20 13:42:33 -05:00
jason.woltje 4f2d1be68a sessions: Filbert Q175 SSO design D1/D2 ack 2026-10-20 13:41:20 -05:00
jason.woltje d55d86bb04 sage: Q175 SSO design r3 fix, Filbert r3 D1 (loader checks slot shape only) and D2 (slot from config dir, scratch Console dataRoot and unreachable issuer) 2026-10-20 13:40:49 -05:00
jason.woltje c56ccf916e sessions: Filbert Q175 SSO design review r3, approve with D1 2026-10-20 13:40:20 -05:00
jason.woltje 817e8f51b0 sage: Q175 SSO design r3 for #412, Filbert r2 C1 (shared loader, config.json inside the phase 2 window, rollback order, test 25) and C2 (401 signed-out page, test 24), notes and Q-E 2026-10-20 13:39:21 -05:00
jason.woltje 5d019d8768 sessions: Filbert Q175 SSO design review r2, changes 2026-10-20 13:34:53 -05:00
jason.woltje 59475fc5a8 sage: row 163 landing record, P19 comments 372/373 on 337/412, and P19 #413 filed for the --no-send row 2026-10-20 13:32:55 -05:00
jason.woltje 1a268249e6 sage: Q175 SSO design r2 for #412, Filbert r1 B1-B6, rulings Q-B/Q-C/Q-D, config in config.json (mos-0973) 2026-10-20 13:32:26 -05:00
jason.woltje def2e79327 plans: brief for stack-release activate --no-send (Q175 interim, mos-0972/0973, #1687, P19 413) 2026-10-20 13:21:30 -05:00
jason.woltje c30b36fffb sessions: Filbert Q175 SSO design review r1, changes 2026-10-20 13:19:06 -05:00
79 changed files with 5704 additions and 188 deletions
+9
View File
@@ -16,6 +16,15 @@ not fleet seats. Keep changes in `/mnt/storage/src/mosaic-stack`; do not modify
| Rocko | General development, investigation, testing, and review | T3 session (Codex, gpt-6.1-sol, thread d99326cc, lead decision 122; Claude thread b84bb264 retired 2026-10-12); launcher Claude Code, Sonnet model | `agents/rocko/launch.sh` |
| Filbert | General development, investigation, testing, and review | T3 session (Claude, Opus 5.5, thread 048f3c63, non-sandbox reviews only, lead decisions 130 and 134; Codex threads f712d8af and 50342f6a retired 2026-10-13, Claude thread 9cb9731e retired 2026-10-12); launcher Pi, `openai-codex/gpt-6-astra:low`, not to be used under R26 until it changes (lead decision 69) | `agents/filbert/launch.sh` |
| Researcher | Source-grounded technical research and evidence | T3 session (Claude, Sonnet 5.5, thread 9bac0794, rows 97 and 98, lead decision 124); Haiku rows (71, 80, 84, 88, later 96) run as subagents Sage dispatches with model haiku, effort high and a worktree (lead decision 129); Haiku thread c98aaf4a retired 2026-10-13 unused; launcher Pi, configured Mosaic model | `agents/researcher/launch.sh` |
| Launchpad | Builder, MVP T1 row 192 (Q175 SSO phase 1) | T3 session (Claude, Opus 5.5), started by Sage under mos-0974/0977 | none, T3 only |
| Morgana | Builder, MVP T1 row 193 (human users and RBAC) | T3 session (Claude, Opus 5.5), started by Sage under mos-0974/0977 | none, T3 only |
| Honker | Builder, MVP T1 row 194 (agent lifecycle) | T3 session (Claude, Opus 5.5), started by Sage under mos-0974/0977 | none, T3 only |
| Gander | Builder, the human boundary across uids (P19 333, #1694) | T3 session (Codex, gpt-6.1-sol), started by Sage under mos-0977 | none, T3 only |
| Herb | Builder, a second gate lane (#1695) | T3 session (Codex, gpt-6.1-sol), started by Sage under mos-0977 | none, T3 only |
| Bushroot | Builder, retire the control board writer on 7331 (#1696) | T3 session (Claude, claude-haiku-5-5), started by Sage under mos-1008 | none, T3 only |
| Megavolt | Builder, MVP T1 item 6 (agent credentials and git identity, #1697) | T3 session (Claude, Opus 5.5), started by Sage under mos-0974/0977 | none, T3 only |
| Webby | Reviewer: Case 2 designs, rows 192 and 194 | T3 session (Claude, Opus 5.5, thread 03086006), started by Mos (mos-0981) | none, T3 only |
| Duckworth | Reviewer: federation designs, row 193 | T3 session (Claude, Opus 5.5, thread ffceafec), started by Mos (mos-0977) | none, T3 only |
| Sage | Project lead: coordination, review, integration; earlier DYOR strategy records retained | T3 session (Claude Code); Pi launcher `zai/glm-5.3:high` retained | `agents/sage/launch.sh` |
Each script supports `--check` and `--fresh`. Normal launches resume the agent's
+8
View File
@@ -0,0 +1,8 @@
# Bushroot
Builder seat, Claude Haiku 5.5, started by Sage under mos-1008 item 2 (a low-level model under Q177) for the row
that retires the control board writer on 7331 (#1696).
No launcher in this directory. The seat runs as a T3 thread and works in its own worktree. It has no
credentials of its own; until the credentials row settles per-agent identity, it pushes only its own `bushroot/*`
branches through the repository credential helper (mos-0981), never `refactor`, `main` or another seat's branch.
+7
View File
@@ -0,0 +1,7 @@
# Duckworth
Reviewer seat, Claude Opus 5.5, started by Mos (mos-0977, thread ffceafec). Reviews federation designs and MVP T1 row 193.
No launcher in this directory. The seat runs as a T3 thread and works in its own worktree. It has no
credentials of its own; until the credentials row settles per-agent identity, it pushes only its own `duckworth/*` branches
through the repository credential helper (mos-0981), never `refactor`, `main` or another seat's branch.
+8
View File
@@ -0,0 +1,8 @@
# Gander
Builder seat, Codex gpt-6.1-sol, started by Sage under mos-0977 item 5 (more Codex Sol rows) for the human
boundary row (P19 333, #1694, mos-0989).
No launcher in this directory. The seat runs as a T3 thread and works in its own worktree. It has no
credentials of its own; until the credentials row settles per-agent identity, it pushes only its own `gander/*`
branches through the repository credential helper (mos-0981), never `refactor`, `main` or another seat's branch.
+8
View File
@@ -0,0 +1,8 @@
# Herb
Builder seat, Codex gpt-6.1-sol, started by Sage under mos-0977 item 5 (more Codex Sol rows) for a second gate lane
design row (#1695, mos-0977 item 6).
No launcher in this directory. The seat runs as a T3 thread and works in its own worktree. It has no
credentials of its own; until the credentials row settles per-agent identity, it pushes only its own `herb/*`
branches through the repository credential helper (mos-0981), never `refactor`, `main` or another seat's branch.
+7
View File
@@ -0,0 +1,7 @@
# Honker
Builder seat, Claude Opus 5.5, started by Sage for MVP T1 row 194 (agent lifecycle, #1692) under mos-0974 and mos-0977.
No launcher in this directory. The seat runs as a T3 thread and works in its own worktree. It has no
credentials of its own; until the credentials row settles per-agent identity, it pushes only its own `honker/*` branches
through the repository credential helper (mos-0981), never `refactor`, `main` or another seat's branch.
+7
View File
@@ -0,0 +1,7 @@
# Launchpad
Builder seat, Claude Opus 5.5, started by Sage for MVP T1 row 192 (Q175 SSO phase 1, #1690) under mos-0974 and mos-0977.
No launcher in this directory. The seat runs as a T3 thread and works in its own worktree. It has no
credentials of its own; until the credentials row settles per-agent identity, it pushes only its own `launchpad/*` branches
through the repository credential helper (mos-0981), never `refactor`, `main` or another seat's branch.
+8
View File
@@ -0,0 +1,8 @@
# Megavolt
Builder seat, Claude Opus 5.5, started by Sage under mos-0974 and mos-0977 item 5 for MVP T1 item 6, the
credential store per agent with its own git identity (#1697).
No launcher in this directory. The seat runs as a T3 thread and works in its own worktree. It has no
credentials of its own; until the credentials row settles per-agent identity, it pushes only its own `megavolt/*`
branches through the repository credential helper (mos-0981), never `refactor`, `main` or another seat's branch.
+10
View File
@@ -0,0 +1,10 @@
# #436b build carry items
The design is approved at r5: 1be535aca, DESIGN.md sha256 68df02cdf890564c6ed0a5426204114389ba7eb0eea1cf04f1bf5938b6de45f4. Dewey gave the approval in dewey-0763 (verdict sha256 de52be1b…6b68). Section 7 is approved at fa5658941 by duckworth-0067 and is unchanged since. DESIGN.md stays as approved. The row's build takes the items below, and its BUILD.md cites this file.
- N9 (dewey-0763). Revoke writes `grant.revoked`, not a `grant.step`. A restart after a revoke on an open rotation therefore still sees a `commit` with no settle after it, and settles again: no file, `grant-lost`, a wrong message to Jason, and repeated deletes that come back 404 and go to `serviceLeft`. Two fixes:
- The section 5 restart row "`commit`, rotation, settle-pending" takes the held row's clause "with no `grant.revoked` after it".
- A 404 on a delete by id counts as done, not as a `serviceLeft` entry. That applies to revoke, sweep and the settle deletes.
- Add a test: revoke on an open rotation, then a restart. No settle runs, no Gitea call is made, and the restart sends no message about it.
- N10 (dewey-0763). `grant.revoked {serviceLeft}` can now hold two ids, so it is a list of ids everywhere. `sweep` reads it as one.
- duckworth-0067 N1 is in r4 (section 5, X-G15). Nothing more is carried for it.
+640
View File
@@ -0,0 +1,640 @@
# Gitea grant path, design draft r5 (Vikunja #436b, Gitea #1697 follow-up)
Author: Megavolt. Reviewer: Dewey. GitHub half: Bushroot. Base: Gizmoduck's #436a r6 (f9039682e, final,
`agents/gizmoduck/work/machine-grant/DESIGN.md` on origin/gizmoduck/machine-grant-design) and row 199's
DESIGN.md section 7a (e10e03f6b). Mos rulings used here: mos-1130 and mos-1136.
This draft builds nothing. It says how an Approve in Jason's inbox makes an agent's Gitea user, its token and
its forks with no manual step, and it answers mos-1130 item 3 (a to e) on the executor's admin credential.
## r5 changes (dewey-0761 on r4 3e66f5acf)
Section 7 is unchanged from fa5658941.
- Item 1 (blocks). A revoke on an open rotation deletes both tokens. When the last `grant.step` is a
rotation's `commit` with no settle after it (settle-pending or `grant-token-unknown`), section 6 step 3
deletes `previousTokenId` and the minted `tokenId`, each failure to `serviceLeft`. Section 5's
`grant-token-unknown` text now says both (X-G5).
- N8. Step 0 refuses `grant-token-unknown` while the agent is held, so a revoke is the only way out.
## r4 changes (dewey-0759 on r3 fa5658941, sage-1578, duckworth-0067)
Section 7 is unchanged from fa5658941, the text Duckworth approved and Gizmoduck cites.
- Item 1 (blocks), option (b), Sage's ruling in sage-1578. A step 0 refusal leaves the decision unspent only
until the next start. #436a r6 X3's start reconcile spends every resolved-yes grant decision with no
`action.allowed` as `interrupted`, and this draft no longer claims otherwise. `grants run` works while the
bus that refused is still up; after a restart the chief raises again and Jason approves again. `status`
says so on each open decision (D5, section 4 step 0, section 5, X-G11). (a) would change #436a r6, which is
final. The cost is in D5.
- Item 2 (blocks). A settle that gets 200 with an `id` that is neither token no longer leaves the file in use.
Memory drops the credential (199 `revoke`'s memory step), the file and both recorded tokens stay, nothing
is deleted at Gitea, and the step ends `grant.failed {code: 'grant-token-unknown'}` for Jason. A restart
keeps the credential out of memory (`Credentials` `held`, built in #436b's row like `reload`). Revoke clears
it (section 5, X-G6).
- N7. 3d says the private session-less writer runs `#secretCheck` on every guarded body, as `#event` does
(`broker.mjs:559`). The sentence is in 3d, so section 7 doesn't change.
- duckworth-0067 N1. Every reader of grant evidence filters on `kind = 'action.allowed'` and the grant actor
pair, so a `grant.*` name under another kind is not evidence (section 5, X-G15).
## r3 changes (dewey-0756 on r2 6db7cc45d, gizmoduck-0081 and -0084, duckworth-0065, mos-1144)
- B1 (blocks). Only `#consumeGrant`'s own record carries `decision`. Every other `grant.*` body names the
decision `grantDecision`, so `#spent` sees exactly one spend. A step 0 or step 1 refusal leaves the
decision unspent (section 7, X-G11, a mutant).
- B2 (blocks). Settle with the seat token is proven on scratch (F13): `GET /api/v1/token` answers 200 with the
minted id and 401 after the token is deleted. Only a 401 ends `grant-lost`. 403, 404, 5xx, a timeout, an
unreachable Gitea or a failed C1 to C4 leave the step open as `settle-pending`, delete nothing, and retry
on the next start and in `grants sweep` (section 5, X-G6). `Credentials.reload` is built by #436b's own
row in `packages/bus/src/credentials.mjs`; row 199's candidate doesn't change.
- B3 (blocks). Step 5 compares against the user's forks of every upstream in the agent's `publish` list, not
only `target.projects`. The rendered block names those other upstreams as reach the token may have. A fork
whose upstream has left `publish` refuses `gitea-reach-wider` with its name (section 2, step 5, X-G12).
- Section 7, the guard (gizmoduck-0081, -0084, duckworth-0065): one pattern, `grant.*`, on `body.action` and
on `body.operation` (Dewey N2: the consume record's `operation: 'grant.<method>'`). #436a renames its
`host.enroll.step`, `host.enrolled`, `host.checked` and `host.removed` into `grant.host.*`, so row 210's
`host.sudo` and `host.run` stay outside. The grant actor is `actor_role NULL, actor_run 'grant'`, which no
session or human event can produce (answers Dewey N1, same safety as `_bus`).
- D12 / Q-B4 closed by mos-1146 (section 8).
- N3. Step 5 reads every page of `/user/repos` and checks the total. N4. One nonce format, and the adopt
check compares the lowercased login. N5. `grants run` takes the `#inflight` claim. N6. One open-decision
query, `openGrantDecisions`, for `status`, rotate's void count and the start reconcile (section 7).
## r2 changes (from r1 714c07304, Dewey's dewey-0754, verdict sha256 20d63dfb…)
- D1 (blocks). `gitea_users` records the Gitea user `id`, and every later grant, rotation and fire compares it
(step 2). A nonce goes in `grant.step user` before the create and in the user's `full_name`, so a restart
adopts a user it made and refuses anything else (section 5, X-G6). Both are proven on scratch (F8, F9).
- D2 (blocks). The audit records are `action.allowed` bodies with reserved `grant.*` actions, plus a trigger
in the store migration that only `system/grant` writes them (section 7). A call and its result are two
records paired by `callId`, because `events` is append-only (3d). To settle once with Gizmoduck for #436a.
- D3 (blocks). Seat-token rotation gets its own rows. After `issue` has renamed the file, the executor settles
by asking Gitea which token the file holds (`GET /api/v1/token`, F7), and goes forward or back on that
answer, never by guessing (section 5).
- D4 (blocks). 3c and section 8 now state the real gap. Reading the admin file gives instance admin, which is
more than a forged Approve gives. `write:admin` resets any user's password, email and admin flag, proven on
scratch (F10). The separate uid is deferred on cost, and the draft says so.
- D5. The admin checks run before `#consumeGrant`, so a predictable refusal leaves Jason's Approve unspent. A
human `mosaic grants run <decision>` starts it again (step 0). The raise refuses when the admin slot is past
its rotate-by date. r4 (sage-1578): unspent only until the next start, when #436a r6 X3's reconcile spends
it as `interrupted`. The cost: if a step 0 refusal is followed by a restart before Jason rotates the admin
token or upgrades Gitea, the chief raises again and Jason approves again.
- D6. One list of start checks (3a, 3e). The "no organization" claim is now part of the bootstrap promise,
and the check is that `GET /user/repos` is empty, which catches org and collaborator repositories (F11, and
F12 for a site-admin token).
- D7. Admin rotation: a timestamped name (F9: names are unique per user), `--rotate-by` required, and `setAt`
changes, so open approvals are voided and `status` says so.
- D8. The restart's remote undo runs the same admin checks and writes the same call records.
- D9 (Q-B1). On fire: `prohibit_login` (F10) and delete every token the user has, not only the recorded one.
- D10 (Q-B2). Step 5 checks the seat token's real reach with `GET /user/repos` (F11). Section 8 says reach can
grow later.
- D11. One Gitea per business, stated, and checked at `set` and at raise.
- D12. Section 8 asks for the jason.woltje comparison. I can't check it myself.
## 0. What this adds to #436a
#436a section 11 left `method: "gitea"` for a later draft, because the executor needs an admin credential and
roles may never hold the admin scope (`packages/business/src/vocabulary.mjs:108-112`). That rule stays. The
admin credential here is not a role service: no `useFor` reaches it, no definition can list it, and only the
executor reads it.
Everything in #436a's sections 4 and 5 holds unless this draft says otherwise: the `credential.mint` raise
with a `grant` body, chief only (R3), the rendered block (R4), Jason's Approve via cli (R5, X1),
`#consumeGrant` with `operation: 'grant.gitea'`, the `#inflight` claim and abort (X5), `grant.step` records and
restart reconcile (X3), and revoke local first (section 6).
What row 199 already gives: the agent's `identity = {name, email, gitea: {url, user}}`, its
`publish: [project]` grant and each project's `remote` (199 D8, D9), the store and `issue(instance, service,
token, date, commit)` (`packages/bus/src/credentials.mjs:156`), and section 7a's rule that `issue` accepts a
resolved `credential.mint` decision as the human path. `issue` writes the file (tmp, fsync, rename), then calls
`commit`, then fills memory (`credentials.mjs:156-193`). Section 5 depends on that order.
## 1. Facts, proven on a scratch Gitea
Run on gitea/gitea:1.27.1 (sha256:fe4e4b1c8b93) on 127.0.0.1, throwaway data, container removed after each
run. Scripts and logs: `/mnt/storage/scratch/megavolt-r199/g436b/`. No log holds a token or password value.
F1 to F6 were reported in megavolt-0034, and Mos accepted them in mos-1136.
- F1 (`run4.log`). `POST /api/v1/users/{u}/tokens` with `Authorization: Basic <admin>:<admin token>` creates a
token for another user: 201. The same token in a `token` or `Bearer` header gets 401 on that route, while the
`token` header works on `GET /api/v1/user` (200). So the executor sends Basic on the token routes and
`token` everywhere else.
- F2 (`run4.log`). The child-scope cap: a child scope broader than the parent's gets 403 ("cannot create an
access token with a broader scope than the authenticating token"), for `write:organization` and for `all`.
A narrower one gets 201.
- F3 (`run4.log`). TOTP 2FA on the admin account blocks password Basic (401) and doesn't block token Basic
(201).
- F4. The route's check is `reqSelfOrAdmin`: whether the account is a site admin, not the token's scope. Any
`write:user` token on a site-admin account can mint a token for any user.
- F5. `write:X` implies `read:X` under Gitea's `HasScope`.
- F6. The cap is Gitea #38406, backported in #38426 (de4b8277e9 on release/v1.27, 2026-07-12). It first ships
in v1.27.0. v1.27.0-rc0 lacks it, and so does every 1.24.x, 1.25.x and 1.26.x through 1.26.4.
- F7 (`introspect.log`). `GET /api/v1/token` with the `token` header gives 200 with `id`, `name`, `scopes`,
`user`, `created_at` and `last_used_at`, and no token value. With Basic it gives 404. Scopes come back
normalized (3b).
- F8 (`r2probe.log` 1a-1d). `POST /admin/users` with a `full_name` gives 201 with the user's `id` and that
`full_name`. `GET /users/SEAT-ONE` finds `seat-one` with the same `id`: names match case-insensitively. A
create of `Seat-One` next to `seat-one` gets 422.
- F9 (`r2probe.log` 2a-2c). A second token with a name already used by that user gets 400. `GET
/users/{u}/tokens` with Basic lists the user's tokens.
- F10 (`r2probe.log` 4a-4f, 5a). With `write:admin`, `PATCH /admin/users/{u}` on another site admin sets a new
password (200; the new password then signs in, the old one gets 401) and a new email. On a plain user it sets
`admin: true`, then `admin: false` with `prohibit_login: true`. Without `write:admin` the same PATCH gets 403.
- F11 (`reach.log`). With the seat token (`write:repository`, `read:user`), `GET /user/repos` lists the user's
own fork, then also a repository it was made collaborator on, then also an org repository after it joined an
org team. `GET /user/orgs` and `GET /users/{u}/orgs` need `read:organization`, which neither token holds.
- F12 (`c4.log`). With a site-admin account's three-scope token, `GET /user/repos` is empty on an instance
where other users and an org it isn't in hold public and private repositories: being site admin doesn't add
them. It lists a repository once the account is a collaborator, and an org repository once it joins that
org's team. Joining an org with no repositories leaves it empty, and `GET /user/orgs` gets 403 (no
`read:organization`).
- F13 (`b2.log` b1-b7). Two seat tokens minted (201, ids 2 and 3). `GET /api/v1/token` with each token in the
`token` header gives 200, and the `id` is the minted one for both. After `DELETE` of seat-a's token (204), the
same call with it gives 401. After `prohibit_login` on seat-b (200), the call with seat-b's token gives 403
("This account is prohibited from signing in"), not 401. A value never issued gives 401. So 401 is the only
answer that means the token is gone; 403 means the account is shut, and section 5 leaves it `settle-pending`.
## 2. The binding
The shared part (section 10) is proposed to Bushroot in megavolt-0036. This section is the Gitea half.
```
grant: { "method": "gitea", "agent": "<instance>", "rotateBy": "YYYY-MM-DD",
"target": { "projects": ["<project>", ...] } }
```
Code fixes the scopes of the agent's token: `write:repository` and `read:user`, row 199 A2's set. The raise
can't name scopes, so the chief can't widen them.
Checks at raise, inside the raise transaction. Each refusal stores nothing:
- `agent` is a role instance of this business, its definition declares `{"service": "gitea"}`, and it has
`identity` and a `publish` grant (199 D8). Otherwise `gitea-not-declared`.
- Each project in `target.projects` is in the agent's `publish` list (`remote-not-granted`), and its
`remote`'s origin equals `identity.gitea.url`'s (`remote-host-mismatch`), the same checks as 199 D9 step 2.
- The business has an admin slot for `gitea` whose recorded `url` has the same origin
(`grant-admin-missing`), and the slot isn't past its rotate-by date (`grant-admin-rotation-due`).
- `rotateBy` is inside 199's rotation window.
The rendered block, appended by the broker as #436a R4 does: agent, Gitea user, email, Gitea URL, each fork
URL it will make (`<url>/<user>/<repo>`), the two fixed scopes, the rotate-by date, one of "new Gitea user",
"existing stack-made user, new token" or "token rotation" (step 2), and the line "The token can write every
repository this user can write. Step 5 checks that this is only its forks." Then, when the agent's `publish`
list has projects outside `target.projects`, the line "It may also reach this user's existing forks of:" and
each such upstream. The raise makes no network call, so the block names every such upstream, not only those
with a fork (B3). The 4096-character cap applies as in R4.
The raise event body gets `grant = {method, agent, target, rotateBy, definitionSha256, identitySha256,
adminSha256}`:
- `definitionSha256` as in #436a.
- `identitySha256` over the agent's `identity`, its `publish` list and the `remote` of every project in
`publish` (step 5 checks against all of them).
- `adminSha256` over the admin slot's metadata (`url`, `account`, `setAt`), never over the token.
At step 1 any of the three that differs refuses `decision-mismatch`. So a changed identity, publish list,
upstream or admin credential between raise and Approve voids the Approve (REQ-GRANT-2).
## 3. The executor's admin credential (mos-1130 item 3)
### 3a. A dedicated account
One Gitea account per Gitea instance, named `mosaic-grant` by default. It is a site admin and has TOTP 2FA on
(F3: the token path still works). Its password and TOTP secret stay with Jason and never reach the stack. It
is never jason.woltje or any other human's account. It owns no repositories, belongs to no organization and is
no repository's collaborator.
The admin checks, one list, run at `set` (3e), before every executor start (step 0) and before every remote
undo (section 5):
- C1. `GET /api/v1/version` is at least 1.27.0 (F6), compared as numbers, or `gitea-too-old`.
- C2. `GET /api/v1/user` gives `login == account` as recorded at `set`, and `is_admin` true, or
`grant-admin-account`.
- C3. `GET /api/v1/token` gives exactly the three scopes (3b), or `grant-admin-scope`.
- C4. `GET /api/v1/user/repos` is empty, or `grant-admin-not-dedicated`. By F11 and F12 this list covers
owned, collaborator and org-team repositories, and site admin alone doesn't fill it.
What code can't check: that nobody else uses the account, and org membership with no repositories (F11: the
orgs routes need `read:organization`, which the admin token doesn't hold and shouldn't). Both are the bootstrap
step's promise and go in the approval notes (section 8).
### 3b. The scopes, each shown needed
The admin token holds exactly `write:admin`, `write:user` and `write:repository` (mos-1136 item 1). From
`matrix.log`, with each scope dropped in turn:
| Parent token | A1 create user | A2 mint seat token | A3 fork (seat token) | A5 delete seat token |
|---|---|---|---|---|
| all four | 201 | 201 | 202 | 204 |
| without `write:admin` | **403** | 201 | 202 | 204 |
| without `write:user` | 201 | **403** | 202 | **403** |
| without `write:repository` | 201 | **403** (cap, F2) | 202 | 204 |
| without `read:user` | 201 | 201 | 202 | 204 |
- `write:admin` is needed for A1, and for `prohibit_login` on fire (F10).
- `write:user` is needed for A2 and A5.
- `write:repository` is needed only because of the cap. The seat's token holds `write:repository`, and a
parent must hold at least what it mints. The executor itself never writes a repository with it.
- `read:user` is covered by `write:user` (F5), so it is dropped.
C3 reads the scopes with the `token` header (F7). Gitea reports normalized scopes, so a token made with the
three plus `read:user` reads as the three. That is harmless, because `read:user` adds nothing to `write:user`.
A token made with `all`, or with another category, refuses.
### 3c. Where it lives, who can read it
- The token: `<configDir>/secrets/grants/<business>/gitea/admin`, 0600, in 0700 dirs, owned by the stack's
OS user. Written and checked by row 199's store code (D1: tmp, fsync, rename, the mode and owner checks).
- The metadata: `admin.json` next to it, 0600, holding `url`, `account`, `rotateBy` and `setAt`. No secret.
- A business grants on one Gitea. `set` refuses a second `url` while a slot exists (`grant-admin-exists`;
`remove` first), and the raise checks the agent's Gitea against the slot's `url` (section 2). Two businesses
on one Gitea each hold their own token for the same `mosaic-grant` account.
Who can read the file: the stack's OS user, and root.
Whether an agent process runs as that uid:
- On dragon-lin, yes. The stack runs as jwoltje, and so does every agent process on the machine: 13 claude, 4
codex and 2 pi processes, the bwrap sandboxes and node, counted 2026-10-20. That includes the T3 dev seats,
which are not sandboxed and can read any jwoltje file.
- Stack workers run in the seat sandbox, which has a private HOME and no `~/.config`, `~/.mosaic` or `~/.ssh`
(`packages/seat/src/sandbox.mjs`). A sandboxed worker can't reach the file by path. The sandbox doesn't
confine the network or the host uid (`sandbox.mjs:29-31`). So the file is safe from a sandboxed worker and
not from an unsandboxed process running as the same uid.
- On the .53 and .54 installs (#432) the stack runs as its own OS user. Workers run sandboxed as that user.
The T3 seats don't run there.
What a separate uid would buy. A same-uid process has two paths. It can forge a human proof (`verifyHuman` is
a cooperative same-UID check, `packages/bus/src/human.mjs:50-52`), and an approved grant then gives it a
two-scope token for a stack-made, non-admin user, because step 2 refuses foreign and admin users. Or it can
read the admin file, which gives instance admin (section 8). A separate uid for the file and the executor would
close the second path and leave the first. That is a real step up, from "a seat token for a stack user" to "no
admin".
I defer it on cost, not because it wouldn't help. It means the executor runs as its own process under its own
uid, with an IPC path from the bus that carries the decision id and nothing the bus could forge. That is a row
of its own, and it is cleaner after the human proof stops being same-uid (#435 behind Q175 sign-in). Jason
approves the gap as section 8 states it.
### 3d. Every use logged, and rotation
Every Gitea call made with the admin token, and every call the executor makes with a seat token, writes two
records (section 7 has where they live):
```
grant.call { callId, reason, method: "gitea", op } written before the call
grant.result { callId, status } written after it
```
- `op` is one of `version`, `self`, `token-scopes`, `repos-check`, `user-get`, `user-create`, `user-patch`,
`token-list`, `token-create`, `token-delete`, `admin-rotate`, `fork-get`, `fork`, `seat-self`,
`seat-repos`, `token-whoami`.
- `reason` is the decision id for a grant or its undo, the `grant.revoked` record id for a revoke, the
`grant.sweep` record id for a sweep, or `admin:<verb>` with the human actor for `set`, `rotate` and `remove`.
- No body, no header, no token, no URL query. `Credentials.assertClean` (`packages/bus/src/credentials.mjs:233`,
the runtime's `secretCheck`) runs on both. The private session-less writer of section 7 runs `#secretCheck`
on every guarded body it takes, `grant.step`, `grant.applied`, `grant.failed`, `grant.revoked`,
`grant.sweep` and the consume record included, as `#event` does (`packages/bus/src/broker.mjs:559`) (N7).
- `events` is append-only (`events_no_update`, `packages/bus/src/migrations/3b.sql:131`), so the result is its
own record, written in the transaction of the step's next `grant.step` where there is one. A `grant.call`
with no `grant.result` means the process died during the call. Reconcile lists each one in its message.
Rotation of the admin token: a human-only verb, `mosaic grants admin rotate gitea --rotate-by <date>`. It never
runs from an agent, the chief or the tester, and it doesn't need a decision because only `verifyHuman` can
start it:
1. C1 to C4 with the current token.
2. Mint a successor for the admin account itself: `POST /users/<account>/tokens` with Basic and the same three
scopes, named `mosaic-grant-<UTC yyyymmddThhmmssZ>`, so a second rotation on one day doesn't collide (F9).
F2's cap holds it to the current token's scopes.
3. C2 and C3 with the successor.
4. Write it through 199's tmp, fsync and rename. Update `admin.json` with the new `rotateBy` and a new `setAt`.
5. Delete the old token at Gitea by name, with the new token. If that fails, `status` shows
`adminTokenLeft: <name>` until `rotate --finish` succeeds.
A new `setAt` changes `adminSha256`, so every grant decision raised before the rotation and not yet run
refuses `decision-mismatch` (fail closed). `rotate` prints how many open grant decisions that voids, and
`status` lists them.
`status` shows the rotate-by date. Past it, the raise and the executor refuse `grant-admin-rotation-due` until
Jason rotates. A forgotten rotation stops grants rather than letting an old token run on.
If the admin token leaks, Jason deletes it in Gitea (Settings, Applications, as `mosaic-grant`) or disables
the account, then runs `mosaic grants admin remove gitea`, which unlinks the slot. Live agent tokens keep
working. Each one is revoked on its own (section 6).
### 3e. The one bootstrap step
Once per Gitea instance per stack, at #436b deploy. This is the only manual credential step left, and
Jason's approval names it:
1. In Gitea, Jason creates `mosaic-grant` as a site admin with no repositories and no organizations, turns on
TOTP, and as that user creates a token with `write:admin`, `write:user` and `write:repository`.
2. On the stack host, as the stack's OS user:
`mosaic grants admin set gitea --url <url> --account mosaic-grant --rotate-by <date>`, with the token on
stdin (never argv).
`set` is human-only (`verifyHuman`). It runs C1 to C4 (3a) before it writes anything, and any failure writes
nothing.
## 4. The executor's steps
0. **Start and pre-checks.** X1 starts the executor on Jason's yes. It claims `(agent, "gitea")` in
`#inflight` (#436a X5), then runs C1 to C4 and the admin rotate-by check. A refusal here writes
`grant.failed {code, grantDecision}` and leaves the decision unspent (section 7's spend rule). After the
fix (a rotation, an upgrade), Jason runs `mosaic grants run <decision>` (human-only, `verifyHuman`), which
starts step 0 again, claim included. That holds only while the bus that refused is up: at the next start,
#436a r6 X3's reconcile spends every resolved-yes grant decision with no `action.allowed` as
`interrupted`, a step 0 refusal included, and the chief raises again (D5, sage-1578). `status` lists each
such open decision with `spentAtNextStart: true`. While the agent is held (`grant-token-unknown`, section
5), step 0 refuses `grant-token-unknown`, so a revoke is the only way out (N8). While a start or another `grants run` holds the claim it refuses
`grant-busy` before any call, as #436a X2's `host-busy` does. It refuses `decision-consumed` for a spent
decision. No other path starts it. The tag is #436a X2 step 2's, over business, agent and `"gitea"`.
1. **Recheck and consume**, in one transaction: `#consumeGrant` with `operation: 'grant.gitea'`, the
definition still declares `gitea`, the three hashes match (section 2), no revoke of `(agent, gitea)` newer
than the raise. A failure rolls the transaction back, so the decision is not spent, and then writes
`grant.failed {code, grantDecision}`. Nothing remote has changed. `grants run` may retry it, but these
refusals (`decision-mismatch`, a newer revoke, the definition) recur until the chief raises again.
2. **User.** `GET /users/{user}` (F8: case-insensitive).
- 404: write `grant.step {step: 'user', nonce}`, where `nonce` is 16 random lowercase hex digits. Then
`POST /admin/users` with the agent's `gitea.user` and `email`, `full_name` = `<identity.name> [mosaic
<nonce>]`, a random password that is never stored, and `must_change_password: false`. Then, in one
transaction, record `gitea_users {user, id, nonce}` with the `id` from the response (F8) and
`grant.result`.
- 200, and `gitea_users` has this business and agent with the same `id`: go on. That is a second grant or a
rotation. A user deleted at Gitea and remade by a human under the same name has a new `id`, and refuses.
- 200 with any other `id`, or no `gitea_users` record: refuse `gitea-user-foreign`. The executor never mints
a token for a user this stack didn't create. Without this check an `identity` naming a human's account,
jason.woltje included, would get a token minted for that account on Approve.
- The user is a site admin, or has `prohibit_login`: refuse `gitea-user-admin` or `gitea-user-disabled`.
3. **Token.** Write `grant.step {step: 'mint', tokenName}`, with the name `mosaic-<tag>-<decision>`. Then
`POST /users/{user}/tokens` with Basic and scopes `write:repository` and `read:user`. The value is held in
memory only. Write `grant.step {step: 'minted', tokenId}`.
4. **Forks.** With the new token, for each project: `GET /repos/{user}/{repo}`. If it exists, its `parent`
must be the project's upstream, or `fork-foreign`. If it's 404, `POST /repos/{owner}/{repo}/forks`.
Push-to-create stays off on the Gitea side (199 N11).
5. **Verify.** With the new token: `GET /api/v1/user` gives `login == user` and the recorded `id`. Then
`GET /api/v1/user/repos?limit=50&page=N` from page 1 until a page holds fewer than 50, with the count
equal to `X-Total-Count`. More than 20 pages, or a count that disagrees, refuses `gitea-reach-unknown`.
Every listed repository must be owned by this user and have as `parent` the upstream of a project in the
agent's `publish` list (F11). Anything else refuses `gitea-reach-wider` and names it: a repository from a
collaborator or org-team add, or the user's old fork of an upstream that has left `publish`. So the token
never reaches past the rendered block (B3).
6. **Go live.** Write `grant.step {step: 'commit', previousTokenId}` (`null` for a first grant). Check the
abort signal, and with no `await` between, call 199 `issue(agent, "gitea", token, rotateBy, commit)`.
`commit` writes the `gitea_grants` record (tokenId, tokenName, projects, rotateBy, decision). The reference
is registered first (#436a X4).
7. **Old token.** For a rotation, `DELETE /users/{user}/tokens/{previousTokenId}` after the new one is live.
A failure adds `serviceLeft: <id>`, which `sweep` clears.
8. **Applied.** `grant.applied {grantDecision, agent, user, userId, tokenId, forks}` and a message to the human and
the chief. The token appears in no record, message or log.
The claim is released in a `finally`.
## 5. Failure and restart
Ordered so a failure leaves nothing an agent can use. "Delete the token" is `DELETE /users/{user}/tokens/{id
or name}` with the admin token, after C1 to C4, with `grant.call` and `grant.result` whose reason is the
decision (D8). Best effort; a failure adds `serviceLeft`.
**Settle.** Once `issue` has renamed the file, the file may hold the new token or the previous one, and the
record may name either. The executor and the restart never guess. They read the store file and ask Gitea which
token it is: `GET /api/v1/token` with that value in the `token` header (F7 for admin tokens, F13 for the
two-scope seat token; op `token-whoami`). The answer is an `id`, never a value.
- 200, `id` is the new token: forward. The `gitea_grants` record gets the new token (if `commit` hadn't
run), memory loads the file (`Credentials.reload(instance, service)`, a new method with load's checks,
built in #436b's row; row 199 doesn't change), and the previous token is deleted.
- 200, `id` is the previous token: back. The new token is deleted. The record keeps or gets the previous
token.
- 401: the file's token no longer exists at Gitea (F13 b4). The previous token can't be in the file, so the
agent holds nothing usable. Delete both by id, unregister, delete the record, unlink the file, and end
`grant.failed {code: 'grant-lost'}`. A new decision is needed. No file at all is the same case.
- 200, `id` is neither token: the file holds a live token this grant didn't mint and step 5 never checked.
Memory drops it (199 `revoke`'s memory step) and the agent gets `credential-unavailable`. The file, the
record and both recorded tokens stay, and nothing is deleted at Gitea. The step ends `grant.failed {code:
'grant-token-unknown', grantDecision, tokenId}`, where `tokenId` is the id Gitea gave, and Jason gets a
message. `status` shows `tokenUnknown`. At each start, before `Credentials` loads, the reconcile passes such
`(agent, "gitea")` pairs as `held` (a `Credentials` option built in #436b's row, like `reload`), so memory
never loads that file. A revoke (section 6) clears it: it unlinks the file and deletes both recorded tokens.
The unknown token is Jason's to look at.
- Anything else is `settle-pending`, and nothing is deleted: 403, 404, 5xx, a timeout, Gitea unreachable, or
C1 to C4 failing first. The last `grant.step` stays `commit`, the
`grant.call` without a `grant.result` stays listed, and `status` shows `settlePending`. The file stays as
it is, and the agent can use it: it holds one of the two tokens, and both passed step 5. The next start
and `mosaic grants sweep gitea` settle again. Only a 401 deletes.
| Fails at | State left | Cleanup |
|---|---|---|
| 0 | nothing, the decision unspent until the next start | none. `grants run` retries while this bus is up; after a restart the reconcile spends it (`interrupted`) |
| 1 | nothing | none |
| 2, before the create | nothing | none |
| 2, after the create, before the record | a user with the nonce | none here. Restart adopts it by the nonce (below) |
| 3, before `minted` | maybe a token nobody holds | delete the token by its recorded name |
| 4 or 5 | a token, maybe some forks | delete the token. Forks stay; they hold only upstream content |
| 6 before the rename, first grant | a token | unregister, delete the token |
| 6 after the rename, first grant | a store file with the new token, maybe the record | unlink the file, unregister, delete the record if written, delete the token |
| 6 before the rename, rotation | the previous grant intact | delete the new token |
| 6 after the rename, rotation | the file holds the new token, memory the previous | settle (forward: the new token passed step 5) |
| 7 | both tokens live, the new one in use | `serviceLeft: <previousTokenId>` |
**Restart**, before `Credentials` loads, from the last `grant.step`, as #436a X3. The local part runs first;
the remote part runs after the bus is up, with C1 to C4.
| Last step | Local | Remote |
|---|---|---|
| `user` | none | `GET /users/{user}`. 200 with `full_name` ending `[mosaic <nonce>]`, not admin: adopt, record `gitea_users` with its `id`. 404: none. Anything else: `gitea-user-orphan` with the name, for Jason to look at; nothing is deleted |
| `mint` or `minted` | none | delete the token by name |
| `commit`, first grant | unlink the file if present, delete the record if present | delete the new token |
| `commit`, rotation | none | settle |
| `commit`, rotation, settle-pending | none; memory loads the file as usual | none. The step stays open, and the next start or `grants sweep` settles again |
| `grant.failed {code: 'grant-token-unknown'}` with no `grant.revoked` after it | pass `(agent, "gitea")` as `held`; memory doesn't load the file | none. Jason's message repeats until a revoke |
Every reader of grant evidence (this reconcile, `status`, `openGrantDecisions`'s callers, `grants sweep`)
reads a `grant.*` record only where `kind = 'action.allowed'` and `actor_role IS NULL AND actor_run IS
'grant'`, so a `grant.*` name under another kind or actor is never evidence (duckworth-0067 N1).
Then `grant.failed {code: 'interrupted'}` (or `grant.applied` if a rotation settled forward) and a message to
Jason. A `grant.call` with no `grant.result` is listed in that message. After `interrupted`, the chief raises
again and Jason approves again.
## 6. Revoke
As #436a section 6, with the same revokers (the human, the assistant in its own organization, the tester on
dev only):
1. 199 `revoke`: memory, abort a use or the executor in flight, unlink the file.
2. One store transaction deletes the `gitea_grants` record and records `grant.revoked`.
3. After C1 to C4, `DELETE /users/{user}/tokens/{tokenId}` with the admin token. If Gitea can't be reached,
`grant.revoked {serviceLeft: <tokenId>}`, and `mosaic grants sweep gitea` clears it later. When the last
`grant.step` is a rotation's `commit` with no settle after it (settle-pending, or `grant-token-unknown`),
two tokens are live and the record may name either (section 5), so the revoke deletes both
`previousTokenId` and the minted `tokenId`, each failure listed in `serviceLeft` (dewey-0761). The id
`grant-token-unknown` named is not one this stack minted; it stays for Jason.
That closes 199 D12's gap: the token is gone at Gitea without Jason's A5 step.
The Gitea user and its forks stay after a revoke, so a new grant can reuse them.
On `credentials delete` (a fire), after the steps above and with the recorded `id` checked first:
- `GET /users/{user}/tokens` with Basic (F9), and delete every token it lists, not only the recorded one. A
token the record doesn't know about doesn't outlive a fire.
- `PATCH /admin/users/{user}` with `prohibit_login: true` (F10).
- Each step that fails is listed in `serviceLeft`, and `sweep` retries it.
## 7. Store records
Two tables, in #436a S1's migration if #436a row b and this row land together, otherwise a `3j-3k.sql`:
- `gitea_users`: business, agent, user, id, nonce, the decision that created it.
- `gitea_grants`: business, agent, user, tokenId, tokenName, projects, rotate-by date, decision.
The executor, revoke and `sweep` are the only writers, each in the transaction of its record. The admin slot
is a file, not a table, so a store backup holds no admin token.
The audit records (`grant.step`, `grant.call`, `grant.result`, `grant.applied`, `grant.failed`,
`grant.revoked`, `grant.sweep`) are `action.allowed` events with that name in `body.action`. They are not new
event kinds, so `events` and its CHECK (`3b.sql:9-30`) aren't rebuilt. This section is the rule's only home
(mos-1144). #436a r6 (f9039682e) uses it too: #436a row a's BUILD.md cites this section at the commit Duckworth
approves and maps r6's names to `body.action` values, with these renames (gizmoduck-0084):
`host.enroll.step` to `grant.host.enroll.step`, `host.enrolled` to `grant.host.enrolled`, `host.checked` to
`grant.host.checked`, `host.removed` to `grant.host.removed`. Cites are origin/refactor 00d4cc1f6.
**The guarded namespace** is one pattern: an `action.allowed` whose `body.action` or `body.operation` GLOBs
`'grant.*'`. `operation` is included because `#consumeGrant`'s consume record is
`{operation: 'grant.<method>', decision}` (#436a X1), and a forged one would spend a decision (Dewey N2). Row
210's `host.sudo` and `host.run`, written from agent sessions, stay outside it, and no grant-path name is left
under `host.*`.
**The grant actor** is `actor_role IS NULL AND actor_run IS 'grant'`. The only production insert into `events`
is `#event` (`packages/bus/src/broker.mjs:568`), which writes the session's role and run. Every session with a
NULL role also has a NULL run: the human (`:537`), the reader (`:549`) and a session-less record (`:1124`). The
tester's role is `tester`. So neither a session nor the human path can produce that pair, as no instance can
be named `_bus` (`:166-168`). A `system` role would not be safe, because `system` matches `ID_PATTERN`
(Dewey N1). The broker writes guarded events, the consume record included, through a private session-less
writer added in #436a row a; `#event` is unchanged. Revoke, sweep and check run for the human, the assistant,
the tester or the chief, and their events still go out under the grant actor, with `body.by` naming the caller
the broker checked.
**Two guards** stop a forged record:
- `recordEvent`, the trusted adapters' path (`broker.mjs:1111`), refuses `reserved-event` for any
`action.allowed` in the guarded namespace, as it does today for `git.push.working` and the merge action.
- The migration adds a trigger: an `action.allowed` in the guarded namespace aborts unless
`actor_role IS NULL AND actor_run IS 'grant'`. That covers any path that misses the first guard.
**Spend rule (B1).** `#spent` (`broker.mjs:811-817`) counts any `action.allowed` that names the decision in
`body.decision` and whose `operation` isn't `decision.raise`; a record with no `operation` counts. So the
consume record is the only guarded body with a `decision` key. Every other `grant.*` body names the decision
`grantDecision`. A step 0 or step 1 refusal therefore leaves the decision unspent, and the start reconcile
for "resolved yes, unspent" keeps reading `#spent` unchanged.
A wider gap sits under this one and isn't this row's: today any `recordEvent` `action.allowed` that carries
`body.decision` spends that decision. Follow-up for Sage to file.
**Open decisions (N6).** One query, `openGrantDecisions(business, method)`: `credential.mint` decisions
resolved yes by the human whose raise body has `grant.method = method`, and for which `#spent` is false.
`status` lists them, `grants admin rotate` counts the ones it voids (all of them, since `adminSha256`
changes), and the start reconcile reads the same query.
**No task refs.** Guarded bodies never carry `target` or `task_ref` holding a task ref, so they stay clear of
`task-subject-required` (`broker.mjs:561-565`) on any path. No other `action.allowed` reader sees these
events: the rest key on `decision.raise` (`:670`, `:803`), `git.push.working` (`:863`, `:2019`) or
routine (`:2100-2102`).
## 8. Approval notes for Jason
Stated once, here, for the #436b approval:
- **Site admin plus `write:user`, and `write:admin`.** The admin token can mint a token for any Gitea user,
with any scope up to its own three, including users the stack didn't create (F4). With `write:admin` it can
also set any user's password, email and admin flag, and disable any user (F10, proven on scratch against a
second site admin). Whoever holds the token can take over any account on that Gitea, jason.woltje's
included, make any user a site admin, and read or write any repository. The executor uses none of that
beyond its steps, but the token itself can.
- **Same uid on dragon-lin.** The token is a file readable by jwoltje, and every agent process on dragon-lin,
T3 dev seats included, runs as jwoltje (3c). The seat sandbox keeps sandboxed workers away from it. It does
not keep an unsandboxed same-uid process away. Such a process gets instance admin by reading the file, which
is more than it could get by forging an Approve. A separate uid would close that path. It is deferred on cost
(3c), not because it wouldn't help.
- **For comparison, today (D12).** Every dev seat on dragon-lin already pushes as jason.woltje through
`scripts/git-credential-mosaic.mjs` (mos-1106). jason.woltje is a site admin on git.mosaicstack.dev, with
restricted false (mos-1146: Mos read `/api/v1/users/jason.woltje` with the mos token at 23:05:29Z; Jason's
token wasn't used). So a same-uid process on dragon-lin can already act as a site admin there through that
credential, and the admin file adds little exposure on that Gitea beyond what the seats already have. It
still matters on any other Gitea a business grants on, and once seats stop pushing as jason.woltje (#431).
- **A seat token's reach can grow.** Step 5 checks, at the grant, that the token reaches only its forks. If
someone later adds the user to an org or as a collaborator, the token reaches that too, until the next grant
or rotation checks again.
- **The admin account is dedicated by promise.** Code checks that it is a site admin with these three scopes
and no reachable repositories (C1 to C4). It can't check that nobody else uses it or that it is in no org.
- **The bootstrap step** (3e) is the one manual credential step: Jason creates `mosaic-grant`, its TOTP and
its token, and enters the token once on stdin.
## 9. Tests and mutants (for the row, not built here)
Unit tests use a fake Gitea over TLS, as row 199's publish tests do. Section 1's facts get a scratch
gitea/gitea:1.27.1 test on 127.0.0.1 with throwaway data, removed after. No test contacts git.mosaicstack.dev
or any other live service. No token value is printed.
| Test | Claim |
|---|---|
| X-G1 | a gitea raise for an agent without `gitea`, `identity` or `publish`, or a project outside `publish`, a remote on another host, no admin slot, or a slot past rotate-by refuses and stores nothing |
| X-G2 | `set` refuses a version below 1.27.0 (fake reports 1.26.4, 1.27.0-rc0, and 1.9.0 against 1.27.0 as a string trap), a non-admin, a login mismatch, an account that reaches a repository, a token with another scope or `all`, and a second `url`. It writes nothing on any refusal |
| X-G3 | on scratch 1.27.1, end to end: user made with the nonce and its `id` recorded, token minted with the two scopes, forks made, step 5 passes, `issue` writes the 0600 file, `grant.applied`, then revoke deletes the token at Gitea (gone from its list) |
| X-G4 | an existing user with no record, a recorded name whose `id` differs (deleted and remade, and a case variant of the name), a site-admin user and a disabled user each refuse before any token call |
| X-G5 | each failure row of section 5 leaves the stated state, rotation rows included. A failed delete leaves `serviceLeft`. A revoke while a rotation's `commit` is settle-pending, and one after `grant-token-unknown`, deletes both `previousTokenId` and the minted `tokenId` at the fake, and a failure of either lands in `serviceLeft`. A new grant for a held agent refuses `grant-token-unknown` at step 0, before any Gitea call |
| X-G6 | a restart after `user` adopts a user whose `full_name` ends with ` [mosaic <nonce>]` (16 lowercase hex), whose lowercased login equals `gitea.user` lowercased and who isn't an admin; does nothing on 404; and reports `gitea-user-orphan` for a different `full_name`, a different login or an admin. A restart after `mint`, `minted` and `commit` of a first grant leaves no token at Gitea, no file and no record. A restart after `commit` of a rotation settles forward when the file holds the new token and back when it holds the previous one, ends `grant-lost` only on a 401, and stays `settle-pending` with nothing deleted on 403, 404, 500, a timeout and an unreachable fake. On 200 with an unknown `id` it drops the credential from memory, deletes nothing at Gitea, keeps the file, ends `grant-token-unknown`, and a second restart still doesn't load the file; a revoke then clears it |
| X-G7 | an identity, publish, upstream or admin slot change between raise and Approve refuses `decision-mismatch` before any Gitea call. An admin rotate voids open grant decisions and `status` lists them |
| X-G8 | every admin-token and seat-token call writes `grant.call` before it and `grant.result` after it, paired by `callId`. A crash between them is listed by reconcile. No record body holds a token |
| X-G9 | admin rotate: C1 to C4, the successor named by time, checked, written, the old token deleted. Two rotations in one second's name refuse cleanly, not half done. Past rotate-by, the raise and the executor refuse |
| X-G10 | seat-token rotation: the new token is live before the old one is deleted |
| X-G11 | a step 0 refusal (rotation due, too old) and a step 1 refusal leave `#spent` false, and `grants run` by the human gets past `#consumeGrant`. `grants run` from an agent, the chief or the tester refuses. A `grants run` while a start holds the claim refuses `grant-busy` before any Gitea call. No `grant.*` body other than the consume record has a `decision` key. A step 0 refusal, then a restart, then `grants run`: the reconcile has spent the decision as `interrupted` and `grants run` refuses `decision-consumed`; before the restart `status` shows `spentAtNextStart` |
| X-G12 | step 5 refuses `gitea-reach-wider` when the user was made collaborator on another repository, when it joined an org team, and when it owns a fork of an upstream that has left `publish`, and deletes the token. It passes with an old fork of a `publish` project outside `target.projects`, which the rendered block named. A fake returning 51 forks over two pages is read in full; a total that disagrees refuses `gitea-reach-unknown` |
| X-G13 | `recordEvent` refuses an `action.allowed` with `body.action` `grant.call` (and each `grant.*`) and one with `body.operation` `grant.gitea`. A direct store insert aborts on the trigger for `grant.step` and `grant.host.enrolled` from a session, and for `grant.step` under the human's NULL pair. `host.sudo` from an agent session still writes |
| X-G14 | fire: every token the user has is deleted, the user gets `prohibit_login`, and a failure of either lands in `serviceLeft` |
| X-G15 | a `grant.step {step: 'commit'}` stored under `action.refused`, and one under the human's NULL pair, are ignored by the restart reconcile: no settle runs and no Gitea call is made |
Mutants, a first list: drop the version floor; compare the version as a string; accept another admin scope;
skip `gitea-user-foreign`; compare the user by name instead of `id`; skip the admin-user refusal; adopt a user
at restart without the nonce; create the user before writing `grant.step user`; mint before writing
`grant.step mint`; delete the old token before `issue`; settle a rotation by the record instead of
`token-whoami`; run C1 to C4 after `#consumeGrant` instead of before; drop the `identitySha256` compare; drop
`adminSha256`; skip the `grant.call` write; write `grant.result` before the call; drop the `grant.` reservation
in `recordEvent`; drop the trigger; widen the trigger to `host.*` (breaks `host.sudo`); guard `body.action` only, not
`operation`; key the trigger on `actor_run` alone; put `decision` in step 0's `grant.failed`; delete both
tokens on a settle 403 or 5xx; compare step 5 against `target.projects` instead of `publish`; read one page
of `/user/repos`; let `grants run` skip the claim; send the admin token in a `token` header on the mint route; revoke the
remote token before the local steps; skip the fork `parent` check; skip step 5's reach check; delete only the
recorded token on fire; let `grants run` accept an agent; keep the file in use on a settle 200 with an
unknown `id`; load a `held` pair at start; revoke only `tokenId` on an open rotation; let step 0 pass a held agent; drop the `kind` filter from the restart reconcile.
## 10. Shared with Bushroot's GitHub half (proposed in megavolt-0036, answer pending)
- S1. The `grant` body: `{method, agent, rotateBy, target}`, scopes fixed by code per method.
- S2. The admin slot: `<configDir>/secrets/grants/<business>/<method>/admin`, set once by
`mosaic grants admin set <method>`, never an agent service. One service instance per method per business.
- S3. `grant.call` and `grant.result` (r2: two records, paired by `callId`) for every service call, as reserved
`action.allowed` bodies (section 7).
- S4. The executor shape: pre-checks before consume (step 0), claim, consume, mint, verify, `issue`, applied,
with `grant.step` before and after the mint, and settle by asking the service which credential the file
holds. GitHub may not fit "mint once and store" (App installation tokens expire within an hour). If so, S4
becomes per method and the rest stays shared.
- S5. Revoke local first, then the record, then the service, with `serviceLeft` and `grants sweep`.
- S6. A capability floor per method, checked at `set`, before every start and before every remote undo.
Dewey notes items 2, 3 and 5 of dewey-0754 apply to S3, S4 and S6. The r2 text above carries them.
## 11. Questions
- Q-B1. Answered (dewey-0754 item 9): on fire, `prohibit_login` and delete every token (section 6). The PATCH
route is proven (F10).
- Q-B2. Answered (dewey-0754 item 10): no organizations. The token's real reach is checked at step 5 and
stated in section 8.
- Q-B3. Bushroot's answer on S1 to S6.
- Q-B4. Answered (mos-1146): jason.woltje is a site admin on git.mosaicstack.dev, restricted false. Section 8
says so.
- Q-B5. Answered (gizmoduck-0081, -0084, mos-1144): one rule for #436a and #436b, section 7. #436a row a's
BUILD.md cites section 7 at the commit Duckworth approves, or carries its text in full if row a's candidate
comes first. Duckworth checks it with that candidate.
+167
View File
@@ -0,0 +1,167 @@
# Row 199 (#1697): per-agent credential store, each agent's own git identity, publish to its fork
Builder: Megavolt. Brief: `docs/plans/2026-10-20_agent-credentials.md`. Design: `DESIGN.md` r6 at
4d3007ec9, approved by Duckworth in duckworth-0027 (REVIEW-r6.md at 640011518). Authority model ruled
by Mos in mos-1027. Branch: megavolt/row199. Reviewer: Duckworth.
Status: in progress. The full gate has not run; it is Sage's. Only single test files have run on
this branch, apart from one slip noted under "Runs".
## Commits so far
- 286bf598c business: agent identity, the publish grant, the project upstream with the D9c parse, store
credential references; git.push.remote as a hard wait.
- a2c387dba bus: broker publish authority (checkPublish, recordPublish, publishRefused, pushCommit on
the raise); the D9c parse moves to `business/src/vocabulary.mjs`; pm.md and chief-of-staff.md list
git.push.remote among the hard waits.
- 609dc9364 bus: the per-agent credential store in `credentials.mjs`.
- 504336797 bus: runtime wiring, the store root and the four publish entries in the op table.
- a18fc6cf8 tasks: request calls use `useFor` keyed from the session; the start probe keeps `use`. The
D5 test in `credential-store.test.mjs` pins the `use(` call sites to `credentials.mjs` (useFor's own
call) and `tasks/src/adapter.mjs` (the host's @sync and start probe).
- aa740cc71 bus: the lifecycle verbs `credentials.issue`, `credentials.revoke` and `credentials.delete`
in `credential-verbs.mjs`, the broker branch, the runtime wiring; `organizationWriter` gets a
`detail` on `write` and a receipt-only `record`.
- 0cdace415 cli: `mosaic credentials status|issue|rotate|revoke|delete`, the scripts/mosaic route, docs.
## Deviations from DESIGN.md r6
1. **Identity is optional on an agent.** D8 reads as identity on every agent. Today's business files
carry none, so identity stays optional and `publish` requires it (N10's load refusal). An agent
without identity commits as E7 (T6).
2. **Revoke of a `{file}` or `{env}` reference.** Section 6 describes revoke for store references.
For a reference outside the store, revoke forgets the token in memory and never unlinks the file;
the lifecycle verb then turns the business file's reference into `store`, so a restart finds
nothing. The test: `credential-store.test.mjs`, "revoke of a {file} reference".
3. **Issue order.** Issue writes the token file (tmp, fsync, rename, dir fsync), then runs the
caller's commit (the business file's reference becomes `store`), then sets memory. A failed commit
leaves the file on disk and the token out of memory, so the agent stays `not-issued` until the
issue is run again. The test: "a failed commit leaves the token on disk but not in memory".
4. **The D9c parse lives in `business/src/vocabulary.mjs`, not `business.mjs`.** The broker reads
`forkProblem` and `forkUrl`, and `project-runtime.test.mjs` pins the broker's import closure to
files that import nothing. `business.mjs` wraps `parseGiteaUrl` with the loader's refusal
messages. The pin test names the two new imports.
5. **`withGitea` takes the publish ticket, not a cap (D9b).** The key is the session checkPublish
checked with `#agent` (a run holding its claim; never a tester, reader or human), read through
`broker.publishSession(ticket)`. The token is usable only between checkPublish and recordPublish
or publishRefused. Its refusals are filed once, by publishRefused on the task's trail, and not
also by `refusing()`. The test: `publish.test.mjs`, "runtime: withGitea keys from the open ticket
only".
6. **Role contracts.** `workflow-roles.test.mjs` requires the hard waits listed in `roles/pm.md` and
`roles/chief-of-staff.md` to equal GATED_ONLY. Adding git.push.remote to the vocabulary made it
fail, so both lists now name git.push.remote. Prose only; no hash pin reads either file. Duckworth
should review this hunk because `roles/` is reviewed authority.
7. **Delete takes a reason.** Section 6 lists `delete AGENT` with no reason. The broker requires
`reason` on delete as on revoke, so every receipt that ends a credential says why.
8. **Rotate is `credentials.issue`.** There are three bus verbs, not four. An issue over an entry the
bus holds is recorded as `rotate` in the receipt and the answer. The CLI's `rotate` reads the
status first and refuses before reading stdin when nothing is issued.
9. **Issue covers entries the bus loaded.** The business file must already name the kind for the
agent, and the bus must have loaded that reference at start; otherwise `credential-unknown`. An
agent added after the bus started needs the restart row 165 already asks for. Row 194's human
`create` is where a new agent's `store` references come from.
10. **No Console page yet.** Section 6 puts the verbs in the Console's agent page too. This branch has
the bus verbs and the CLI. The Console page is a separate piece (packages/webui, its `BUS_VERBS`
and `STATUS` pins); I raise the split with Sage before the manifest. Ruled in sage-1421: the
Console page is out of row 199 and becomes a follow-up row with Webby as reviewer. The manifest
holds bus, business, tasks, cli and seat.
11. **The push runs in the bus child, and the git socket polls for it (D9b).** The git socket runs in
the host process. The broker, the runtime and the token are in the bus child that
`packages/cli/src/host.mjs` forks, and host ops cross that IPC one at a time with a 10 s answer
limit. ls-remote and the push can run longer than that, so `withGitea(ticket, fn)` can't wrap a
host-side git. The split: the socket runs D9 steps 3 and 4 (tip and identity under the lock,
`working.publish`), then op `publishPush`, which starts `publishRemote` in the bus child under
`withGitea` and answers `{state: 'running'}`. The socket polls op `publishResult` every 200 ms
until it reads `pushed` or `refused`. Fork, user, source and destination come from the ticket
(`broker.publishTarget`), never from the host's message. `recordPublish` also refuses `ref-moved`
when the pushed ref isn't the ticket's destination. A second `publishPush` on a running ticket
refuses `publish-busy`. The token reaches git on fd 3 only (`runGit` option `pass`), read once
by a one-shot credential helper that answers `get` for https on the fork's exact host and port.
On close, a running publish's git isn't awaited; git's own time limit ends it, and the ticket
goes with the process. The 10 s IPC limit stays as it is (sage-1440). The poll has a bound
(sage-1440): `publishDeadlineMs`, by default four times git's limit and grace (publishRemote runs
at most four git commands) plus 30 s. Past it the socket refuses `publish-timeout`, filed once by
publishRefused, and logs that the bus process's push may still reach the fork. A failure that is not a refusal is filed as `git-failed` and logged on
the bus's stderr by error name and code only. Tests: `seat/tests/publish.test.mjs` (11),
`bus/tests/publish.test.mjs` (the process ops), `cli/tests/publish-socket.test.mjs` (7, end to
end through the socket, the in-process runtime, the store and a TLS fake Gitea; one is the
publish-timeout outcome).
12. **No harness tool for publish.** `packages/harness/src/git-tools.mjs` has push, fetch and land
clients. A runner tool that calls the socket's `publish` op is in packages/harness, outside the
manifest scope sage-1421 set. Today a publish needs a socket client; the tool is a follow-up.
13. **The bind rule's `git.push.remote` clause has no test of its own.** D11 adds it and the
launcher has it. A role can publish only a push its own role made (`work-not-yours`), and
`recordPush` needs `git.push.working`, which already binds the socket. No role reaches the
clause alone, so dropping it changes no outcome today. It matters once a role can publish work
it didn't push. Ruled in sage-1440: keep it; its mutant goes in the section 11 list as
equivalent, with this reasoning, and Duckworth judges it.
14. **A push between checkPublish and the git push refuses; it never lands a different commit.**
T3 says such a push "doesn't change the commit that lands". Under the hard wait it refuses:
`working.publish` checks the work ref's tip against the ticket's source under the lock
(`ref-moved`, `seat/tests/publish.test.mjs`), and `recordPublish` checks `pushCommit` again
(`decision-mismatch`, `bus/tests/publish.test.mjs` "r4 B4"). The fork gets only the ticket's
oid either way. N9's routing-commit test still applies. Ruled in sage-1440: T3's wording in
DESIGN.md now reads "refuses with `ref-moved` or `decision-mismatch`", and an end-to-end test
asserts each code mid-flight in `cli/tests/publish-socket.test.mjs`: a push after checkPublish
refuses `ref-moved` with no request to the fork; a push recorded while the bus process pushes
refuses `decision-mismatch`, filed once, and the fork holds only the ticket's oid.
15. **Fix: rotate on a due gitea key (gizmoduck-0050, sage-1494).** The CLI's `ISSUED` set
(`packages/cli/src/credentials.mjs`) listed valid, expiring and expired, but the bus's `status()`
gives a gitea key past its rotate-by date `rotation-due`. So `credentials rotate` refused the one
case it exists for. `ISSUED` now names every state status() gives a held token. The test:
`cli/tests/credentials.test.mjs`, "rotate goes ahead on each held state". Its mutant (the old set)
fails that test and goes in the section 11 list.
## N9, for the routing commit (sage-1388)
Today git.push.remote is a hard wait, so every publish carries a decision, and the decision's
`pushCommit` fixes the commit. If a later commit routes git.push.remote within role, a publish has
no decision and no `pushCommit`. `#publishFacts` then takes the commit from `#latestPush`, and the
git socket's tip check under the lock is the only thing that holds it. That commit needs a test
that a push between checkPublish and the git push doesn't change what lands.
## R1, the backup set
Checked at 504336797. Row 185's `backup_store` (`scripts/stack-release.sh:372-392`) copies only
`<dataRoot>/bus/bus.sqlite`, plus its sums, modes, listing and stamps, into
`<dataRoot>/backups/stack/<activation>/`. Nothing under `<configDir>` is copied, so
`<configDir>/secrets/agents` isn't in the backup set. `rehearse` copies the live business file
through `R_BUSINESS_JS` (`:1268-1288`). Its `swap` replaces every credential reference, a `store`
one included, with a placeholder file in the rehearsal's own dir. The guard then refuses
`credential-ref-live` unless every reference is such a placeholder (`:1329-1337`), so a rehearsal
never reads a store token. A backup tool outside this repository that copies `<configDir>` would
copy tokens. That is R1's open half, and it is Jason's to check on each VM.
## T1 limit
A wrong owner on a store dir or file can't be set up without root, so T1 covers modes, symlinks and
dirs. The owner check (`st.uid !== process.getuid()`) is in `privateDir` and `#read`, and is untested.
## Runs
Single files, each green on its commit: bus broker, decision-close, decision-route, decisions,
hard-waits, land, project-runtime, launch-notice, review, publish, credential-store, credentials,
credential-status, limits, tester, plugins-verb, routes-verb, limits-runtime, instances-verb, web,
usage-runtime, limit-checks, daily-quota-runtime, chief-of-staff, tasks, tester-writes; every
business test file. Since: credential-verbs (2/2), credential-store (11/11, with D5), each tasks file,
tester, credential-status, plugins-verb, routes-verb, instances-verb; business launch-settings,
plugin-settings, instance-settings, token-limit-settings; cli credentials (3/3), cli, app,
tester-reads, tester, other-tree, transport, instances, limits, verbs. Hash pins: none on a changed
file (`thread-migration.test.mjs:46` pins DDL only).
Publish, single files, each green before its commit: seat
checkout (77), working (26), publish (11), landing (8); bus process (8), land (17), publish (12),
credential-verbs (2); cli publish-socket (4), git-socket (24), launcher (29); harness tools (22).
Hash pins: none on a changed file.
sage-1440 changes (the poll bound, the mid-flight tests), single files, green before the commit: cli
publish-socket (7), git-socket (24), launcher (29). Hash pins: none on a changed file.
The rotation-due fix, single files: cli credentials (4), green; the old-set mutant fails it 3/1.
Hash pins: none on a changed file.
Slip, 2026-10-20 about 19:51Z: I ran `node --test packages/tasks/tests/*.test.mjs` in one command,
which is a package suite, without asking Sage for the slot. It passed, 97 pass and 0 fail, in under a
minute. Reported to Sage in megavolt-0017.
+430
View File
@@ -0,0 +1,430 @@
# Row 199 design: a credential store per agent, own git identity, pushes to a remote
Megavolt, 2026-10-20. Gitea #1697, MVP T1 item 6. Brief: `docs/plans/2026-10-20_agent-credentials.md`.
Reviewer Duckworth. Mos rules on the authority model (section 9) before any code. Base: refactor at 992650232.
No credential value was read to write this. Evidence is code and docs only.
r2 answers duckworth-0013 (review at 6ab9cd927 on duckworth/reviews): B1 in D3a, T1 and T5, B2 in D9 step 5
and T3, N1 in section 6 and Q-M3, N2 in section 6, N3 in D9 step 3, N4 in D9a and T3, N5 in E5 and D9b.
r3 records Mos's rulings (mos-1027) in section 9. The Q-M2 hard wait changes D9 step 1, D9b and T3. Q-M1
drops O2 (D10, A3, R2).
r4 answers duckworth-0017 (REVIEW-r3.md at 4065ed59f): B3 in T3 and D9 step 5, B4 in D9 steps 1, 3 and 5, N6
and N7 in D9a, N8 in D9 step 1.
r5 takes webby-0011 N1 (row 194 review, through honker-0009): the host checks at each push that the
remote's owner is the agent's own Gitea user. r4 named one `remote` per project, which can't be a fork per
agent (O1) once two agents publish the same project. So `projects.<p>.remote` now names the upstream
repository, and the host builds each agent's fork URL from it (D9 step 2, D10, T3). It also fixes a conflict
found while building: D8 made `identity` required for every agent whose definition declares `gitea`, while D9
step 2 says today's files load unchanged. Coder declares `gitea`, so r5 makes `identity` optional and
`publish` require it (D8).
r6 answers duckworth-0026 (REVIEW-r5.md at 3cb6888e1), with Mos's acceptance (mos-1043). B5: the derived URL
has one name, `fork`, and D9a's `ls-remote`, step 5's push, the helper's host match, `checkPublish`'s result
and step 6's event all use it (D9, D9a, D9b, T3). B6: the URL parse is written down (D9c) and tested (T3).
N10 in D8 and T3. N11 in D9a and A3. N9 is carried to BUILD.md for the routing commit.
## 0. Terms
- **Stack instance**: one Mosaic Stack deployment (row 190). It has one OS user, one config dir
`~/.config/mosaic-NAME` (here `<configDir>`), one data root and one bus. Case 1 and Case 2 are two stack
instances on two VMs (DECISIONS Q180).
- **Agent**: a role instance in a business file (`roles.<agent>`). Today's code calls it "instance". This
design says agent.
- **Kind**: a credential type: `gitea`, `vikunja`, `model`.
- **Host**: the bus process and its git socket, running as the stack instance's OS user, outside any sandbox.
## 1. What exists (measured at 992650232)
| Code | Fact | Where |
|---|---|---|
| E1 | The business file references credentials per agent: `roles.<agent>.credentials.<kind> = {file or env, rotateBy or expires}`. The kinds an agent may hold are the ones its role definition lists | `business/src/business.mjs:89-111`, `credentials.mjs:18-38`, `role.mjs:105-118` |
| E2 | The bus reads each token once at start (O_NOFOLLOW, own uid, 0600, at most 16 KiB, outside the repos and data root) into a private map keyed `<business>/<agent>:<kind>` | `bus/src/credentials.mjs:18-89`, `bus/src/runtime.mjs:78-89` |
| E3 | Only Vikunja uses a token: `credentials.use("<business>/<agent>", "vikunja", ...)`. Gitea tokens are loaded and never used. `assertClean` refuses any event that contains a token | `tasks/src/adapter.mjs:70`, `runtime.mjs:134`, `broker.mjs:560` |
| E4 | Agents run in bwrap as the bus's own uid. `~/.config`, `~/.mosaic` and `~/.ssh` are not bound. The git socket is bound only for `git.push.working`, `review.verdict` or a landing | `seat/src/sandbox.mjs:21-30, 104-124`, `cli/src/launcher.mjs:401` |
| E5 | A session proves who it is with a 64-hex cap the broker mints and maps to a frozen session record. The git socket resolves the cap to (business, role) before anything else | `broker.mjs:280, 379-383`, `packages/cli/src/git-socket.mjs:398-407` |
| E6 | The model credential is one env file per host (`EnvironmentFile=-%h/.config/mosaic-dev/secrets/claude-session.env`). `ENV_ALLOW` passes it into every sandbox, so every agent holds the same model token | `cli/systemd/mosaic-bus.service.in:24`, `seat/src/session.mjs:83-94` |
| E7 | Commits in a session's clone use `<agent>@<business>.mosaic.invalid`. Landed squashes use "Mosaic bus host" | `sandbox.mjs:151-157`, `working.mjs:280-281` |
| E8 | No code pushes to a remote. The git socket pushes bundles into a local bare working repo with no remote and no credential | `working.mjs:1-14, 198-238` |
| E9 | Adding or changing an agent refuses a definition that declares credentials (`organization-credentials-out-of-scope`). Binding them is a hand edit. Row 194's design keeps that refusal for create and hire "until the credentials row" | `business/src/instance-settings.mjs:20-29`, row 194 DESIGN.md D1 |
| E10 | The development repository's helper pushes as one identity from one JSON file | `scripts/git-credential-mosaic.mjs` |
So per-agent references and a host-only reader already exist for two kinds. What's missing: a fixed place for
the files, an agent identity, any remote push, lifecycle verbs, and a model credential that agents don't
share.
## 2. The store (brief item 1)
D1. **Layout.** Each stack instance keeps its agents' secrets under its own config dir:
```
<configDir>/secrets/agents/ 0700, owner = the stack instance's OS user
<configDir>/secrets/agents/<business>/<agent>/ 0700
<configDir>/secrets/agents/<business>/<agent>/<kind> 0600, the token only, one line
```
This is row 190's secrets root (`<configDir>/secrets/...`, as Q175's slot uses). The token file keeps E2's
format, so the bus reader doesn't change. Nothing in a token file is a name, an email or a URL. Those are not
secret and go in the business file (D6), where review sees them.
D2. **The business file reference.** A new reference form, `{"store": true, "rotateBy" | "expires": DATE}`,
means "the file at the layout path for this business, agent and kind". The path is derived, never written,
so an agent's entry can't name another agent's file. The old `{file}` and `{env}` forms stay valid for
migration (section 7). A `{file}` reference that points inside `<configDir>/secrets/agents/` but not at its
own agent's path refuses `credential-not-yours` when the business file loads.
D3. **Read path.** Unchanged in shape: the bus reads every entry once at start (E2) and on a store change
(section 6), and holds the values in memory. No sandbox binds `<configDir>` (E4 already holds). No token goes
into a sandbox for `gitea` or `vikunja`. The agent asks the bus to act, and the bus uses the token.
D3a. **A `store` reference with no file.** ENOENT on a `store` path loads as an absent entry. `status` shows
`not-issued`, and `useFor` refuses `credential-unavailable`. The bus still starts. This is the state after a
create without `issue` (D13) and after `revoke` (section 6). Every other defect still refuses start: wrong
owner or mode on the file or a dir, a symlink, a non-regular file, bad format, a missing or wrong-mode
`secrets/agents/` dir. `{file}` and `{env}` keep E2's behavior, where a missing file refuses start.
## 3. The "RLS equivalent" (brief item 1)
Three layers. Each one is enforced by code or the OS, not by prose.
D4. **OS, between stack instances.** Files are 0600 and dirs 0700, owned by the stack instance's own OS user.
Case 1's user can't read Case 2's store (Q180: each instance under its own user without sudo). The bus refuses
to start when any file or dir under `secrets/agents/` has the wrong owner or mode (`credential-file`, as E2
does for files today, extended to the dirs).
D5. **Bus, between agents of one stack instance.** The principal for every credential use comes only from the
verified cap (E5). The agent-facing API is `credentials.useFor(session, kind, op)`: `session` is the broker's
frozen record, and the key is built from `session.business` and `session.role`. It has no agent argument, so
there is nothing in a request to point at another agent. The existing `use(name, kind, op)` stays for the
host's own `@sync` entry and is not reachable from a request. The tasks adapter's per-role calls (`ctx.as`,
`verbs.mjs:126`) move to `useFor`. Any request field that names an agent other than the caller (`as`,
`agent`, `instance`, `role` on a credential path) is refused `credential-not-yours` and recorded through
`broker.refuse()` as `action.refused`, with the caller's cap and the task ref. A build test greps every
`credentials.use(` call site and fails on a new one outside the allowlist.
D6. **What stays open.** A process of the same OS user outside the sandbox can read every agent's file. That
is the same limit as the human proof (P19 333). Q180's per-user VMs move the boundary to "one stack instance
per OS user". Agents of one instance are kept apart by the sandbox and D5, not by the OS.
## 4. Kinds (brief item 2)
| Kind | 10-27 | Use |
|---|---|---|
| `gitea` | yes | Git push to a granted remote (section 5). Gitea API verbs (issues, pull requests) wait. The factory's review flow runs in the working repo today, and no 10-27 case needs an agent to open a pull request |
| `vikunja` | yes, exists | Moves to `store` references and `useFor`. No behavior change |
| `model` | per stack instance only | See D7 |
D7. **Model provider.** A model token has to sit inside the sandbox today, because the harness calls the
provider itself (E6). Per-agent model tokens need a proxy that holds the real token and gives each run its own
short-lived one, which is the CLIProxyAPI line in TARGET.md. That waits for its own row. For 10-27 the model
credential is per stack instance: row 190's `<configDir>/secrets/claude-session.env`, owned by that
instance's OS user. It is never a copy of jwoltje's file. Jason signs in on each VM user himself (A6). Whether
Case 1 and Case 2 run on Jason's existing subscription or need a new account is Jason's call (Q-J1). This row
doesn't pick. A pi model credential also waits for P19 #383.
## 5. Git identity and push (brief item 3)
D8. **Identity.** A new business-file key, `roles.<agent>.identity = {name, email, gitea: {url, user}}`. It is
optional, because today's business files with a `gitea` agent (coder) must load unchanged. `publish` (D9
step 2) requires it, and requires that the definition declare `gitea`. A business file with a `publish`
grant on an agent that lacks either refuses at load, and the error names `roles.<agent>.publish` and says
"needs identity and a gitea definition" (N10). Load is the only place this is checked: a business file that
fails it never reaches the bus. The sandbox sets `GIT_AUTHOR_*` and `GIT_COMMITTER_*` from
it in place of E7's invalid domain. The email is the one Jason gives the agent's Gitea user, so Gitea links the
commits to that user. Agents without `gitea` keep E7.
D9. **Push to a remote: a new git socket op, `publish`.** No new socket, no bundle, and no token in the sandbox.
```
publish {"cap", "op": "publish", "task_ref", "project", "branch", "decision"}
-> {"ok": true, "result": {"fork", "ref", "commit", "previous"}} or a refusal
```
The host checks, in order, and each refusal goes through `broker.refuse()`:
1. Identity from the cap (E5), then the broker's `#checkAuthority(s, "git.push.remote", {decision, target:
task_ref})`, as `checkLand` does for a merge (`broker.mjs:907-913`). `git.push.remote` is a new action in
ACTIONS and is not in ROUTED (REQ-WAIT-3, `packages/business/src/vocabulary.mjs:52-58`,
`broker.mjs:42-69`), so it is a hard wait and classifies `gated` for every role. Every publish therefore
needs a decision the human resolved for this task ref. A decision is spent once (`decision-consumed`).
**The decision binds one commit.** `#authorityFor` binds action, role, thread and target only
(`broker.mjs:769-798`), so the commit needs its own carrier. The raise records it the same way it records
`holderRun` for `role.revoke` (`broker.mjs:1733`). When the action is `git.push.remote`, the
`decision.raise` event body gets `pushCommit`. That is the commit of `#latestPush(business, target)` at
raise time, read by the broker and never taken from the agent. A raise with no recorded push for the task
refuses `no-push`. The decision's question shown to the human ends with that commit. `#authorityFor` then
refuses `decision-mismatch` when `context.pushCommit` differs from the commit of `#latestPush` now, which
is the same check `holderRun` gets at `broker.mjs:796`. So the sequence approve at A, push B, publish B
refuses.
No role definition grants the action yet. When Jason's PRD version routes it (Sage, Vikunja #420), a
reviewed commit adds it to both ROUTED lists and to coder's `withinRole`. From then on a coder publishes
without a decision, and `decision` is optional for a within-role class only.
2. The project is in the agent's grant, `roles.<agent>.publish: [project]`, and the business file names the
project's upstream repository, `projects.<project>.remote: "https://HOST/OWNER/REPO"`
(`remote-not-granted`). Both URLs pass D9c's parse at load. The upstream's host and port equal
`identity.gitea.url`'s (`remote-host-mismatch`). The host never contacts that URL. It builds the agent's
fork, `fork = https://HOST[:PORT]/<gitea.user>/REPO`, from the upstream's origin and repository name and
the agent's own `gitea.user`. `fork` is the only URL the rest of `publish` uses: D9a's `ls-remote`, step
5's push, the helper's host match, `checkPublish`'s result and step 6's event. So the owner of the
repository that receives a push is the agent's own Gitea user on every push, by construction, and the
agent can't name it (webby-0011 N1). D9c is what makes "by construction" hold. Gitea's refusal of a
push to a repository the user doesn't own or collaborate on is the second layer. An upstream whose owner
equals the agent's `gitea.user`, compared without case, refuses `remote-not-fork`, at load and at push. `remote` is a new key
beside `root` and `repo` (`business.mjs:186`), and `identity` and `publish` are new keys on a role entry
(`business.mjs:89`). All three are optional, so today's business files load unchanged.
3. The source is one commit, `C = context.pushCommit`. It is checked three ways, so all three are equal:
the decision's commit, the commit of `#latestPush`, and the work ref's tip in the working repo,
`work/vikunja-<project>-<task>` (`working.mjs:110-114`). The tip is read under the repository's lock, and
if they differ the push refuses `ref-moved`. The work ref is per task, not per agent. Two checks make it the
caller's: `#latestPush` was recorded by the caller's role (`work-not-yours`), and step 4. The destination is always `refs/heads/<gitea.user>/<branch>`, built by the host.
`validBranch(branch)` holds (`invalid-request`). The agent never names the destination ref.
4. Every commit in `main..<work ref>` has author and committer email equal to `identity.email`
(`identity-mismatch`).
5. The push runs through `credentials.useFor(session, "gitea", ...)`: `git push <fork> <C>:<dst>`, by oid,
with no `+` and no `--force`. It never pushes by ref name, so a push to the working repo after
`checkPublish` can't change what goes out (`non-fast-forward` from git stays a refusal). Git runs with E8's SAFE flags
(`working.mjs:92-98`), which set `protocol.allow=never`. `publish` adds exactly two flags,
`protocol.https.allow=always` and `http.followRedirects=false`, plus `GIT_TERMINAL_PROMPT=0` and no global
or system config. The token reaches git through a one-shot credential helper that reads it from an
inherited pipe, never through argv, env, the URL or a file. Seat `gitEnv` (`checkout.mjs:79-89`) takes only
PATH from the host and sets the rest itself, so no proxy or `GIT_SSL_*` variable reaches git. `publish` takes a `tls` parameter that
defaults to `[]`, the same way `push` takes `fsck = FSCK` (`working.mjs:100`). Tests pass
`["-c", "http.sslCAInfo=<scratch CA>"]`. The host never sets it, and nothing in config or env reaches it. The helper answers `get` only when `protocol`
is `https` and `host`, port included, equals `fork`'s. It writes nothing for any other `get` and for
`store` and `erase`. `assertClean` checks git's output, so a fork that echoes the token gets
`credential-leak`.
6. The bus records `action.allowed` for `git.push.remote` with fork, ref and commit.
D9a. **The agent's fork must share root's history.** A push sends every object the fork lacks. The working
repo's `main` is the project root's history (`working.mjs`, seed), so a fork that doesn't share it would
receive all of root, landing squashes included. The check runs on `fork`, never on the upstream: a related
upstream says nothing about what the agent's `<user>/REPO` holds.
`publish` runs `seed` first (`working.mjs:164-176`), under the repository's lock as `push` does, so `main`
catches up with root before the check below. That means a fork also receives any landed root commits that
upstream doesn't have yet, because they sit under the agent's work. That is expected: the agent's branch is
built on them.
Before the push, the host runs `git ls-remote --symref <fork> HEAD` (same flags and helper). It refuses
`remote-unrelated` when the fork is empty or when that tip isn't a commit the working repo already has and an
ancestor of `main`. When `ls-remote` exits non-zero, for a fork that doesn't exist, a refused token or a
network failure, `publish` refuses `fork-unavailable` and the push doesn't run (N11). Git's stderr isn't
parsed to tell those apart and isn't returned, since it can carry the URL. Only `ls-remote` crosses the
network for this, so nothing is fetched from the fork. If the fork has moved past root, the refusal stands
until root catches up. That is a human pull into root, not an agent's.
D9c. **The URL parse (B6).** `projects.<p>.remote` and `identity.gitea.url` are parsed with `new URL()` at load,
and the business file refuses to load unless all of these hold:
- The scheme is `https:`. There is no username, password, query or fragment.
- The string equals its own normalized form: for the upstream, `url.origin + url.pathname`; for
`gitea.url`, `url.origin + "/"` or `url.origin`. So a dot segment (`/<user>/../OWNER/REPO`), a
percent-encoded dot, an upper-case host, an explicit `:443`, a backslash, userinfo, a query and a fragment
each refuse, because `new URL()` would change them (checked with node: each differs from its normalized
form). A trailing slash survives normalization and is refused by the two-segment rule below. The checks below run on that path, so the owner that is checked is
the owner that is used.
- The upstream's path is exactly `/OWNER/REPO`: two non-empty segments, no more and no fewer. One trailing `.git` on
REPO is allowed and stripped. `fork` is built from the stripped REPO.
- `OWNER`, `REPO` and `gitea.user` each match Gitea's name rule, `^[A-Za-z0-9_.-]{1,100}$` and not `.` or
`..`. REPO after stripping must still match.
- `gitea.url`'s path is `/`. A Gitea served under a sub-path isn't supported by this row and refuses at load.
- Host and port are compared after `new URL()` normalized them, and an omitted port counts as 443 on both
sides.
- Gitea compares user and repository names without case (`lower_name`), so `remote-not-fork` compares the
upstream's OWNER with `gitea.user` without case. An upstream owned by `Coder` and a `gitea.user` of `coder`
would otherwise build a "fork" that is the upstream itself.
D9b. **How `publish` reaches the broker and the store.** The git socket runs in the bus process
(`cli.mjs:504-506`) and reaches the broker only through the host's op table (`bus/src/runtime.mjs:222-246`).
`publish` adds three entries there, each wrapped in `refusing()`:
- `checkPublish(cap, args)`: steps 1 to 3, including the decision check. It returns `{ticket, fork, user,
email, source}` and no token. The ticket holds the session and the decision until `recordPublish`, the
same way `checkLand` holds a landing.
- `withGitea(cap, fn)`: `credentials.useFor(broker.session(cap), "gitea", fn)`. `fn` gets the token and runs
step 5. The token never leaves that call.
- `recordPublish(ticket, result)`: step 6, and it spends the decision.
The op table gets no entry that returns a token.
D10. **Which refs.** Our side: only `refs/heads/<gitea.user>/*`, set by the host (D9 step 3). The default
branch and other agents' prefixes can't be named. Gitea side, two options (Q-M1):
- **O1. A fork per agent. Ruled by Mos (mos-1027).** The agent's Gitea user has read on the upstream repo and owns a fork
named like it. The host derives the fork's URL from the upstream and the agent's user (D9 step 2).
Its token can write only repos the user owns or collaborates on, so Gitea itself stops any push to upstream
or to another agent's fork. No branch rules are needed per agent, so none can drift. The cost: work reaches
upstream as a pull request from the fork, opened by a human or a later API verb.
- **O2. One shared repo. Not chosen.** The agent is a Write collaborator. Upstream's default and release branches get
branch protection with no agent on the push list, plus a rule `<user>/*` per agent. Gitea doesn't stop agent
A from creating an unprotected name outside `A/*` unless a catch-all rule outranks the per-agent ones. That
depends on Gitea's rule priority, which I haven't verified on our version (probe P1). Every hire adds a rule.
Token scopes for either: `write:repository` and `read:user`, nothing else, per vocabulary.mjs rules (no
`all`, no admin).
D11. **The host git socket.** `publish` is added to the socket. `push`, `fetch` and `land` don't change. The
socket's bind rule (E4) adds `git.push.remote`. The working repo stays credential-free and remote-free. The
`fork` URL is passed per call and never written into its config. A landing still squashes into the project
root as "Mosaic bus host". Landings don't push anywhere.
## 6. Lifecycle (brief item 4)
All verbs are on the bus, in the CLI (`mosaic credentials ...`) and in the Console's agent page. Every verb is
human-only. Under Q173 the tester may run `status` and `revoke` on the dev stack instance. `issue` and
`rotate` stay with Jason, because the tester is an agent and the token would pass through its session
(duckworth N1). Each one appends to the organization
writer's `changes.jsonl` (actor, agent, kind, verb, date; never a value). No schema migration, no new event
kind. A refusal uses `action.refused`.
| Verb | What it does |
|---|---|
| `issue AGENT KIND --rotate-by DATE` (gitea) or `--expires DATE` (vikunja) | Reads the token on stdin (never argv), checks E2's format, writes `<kind>.tmp` 0600, fsyncs, renames over `<kind>`, and sets the business file reference to `{"store": true, date}`. The bus reloads that one entry, with no restart |
| `rotate` | Same as `issue` over an existing entry. The old value is gone from disk and memory when the call returns. Jason then deletes the old token in Gitea (A5) |
| `revoke AGENT [KIND] --reason` | Drops the entry from the bus's memory first, then unlinks the file. A use in flight finishes. Every later use refuses `credential-unavailable` |
| `delete AGENT` | `revoke` for every kind, then removes the agent's dir. Refused while the agent has a running claim |
| `status [AGENT]` | Kind, date and state per entry (E3's `status()`), no values |
D12. **"Stops working at once."** On our side this holds without a timing window. A fired agent never holds a
`gitea` or `vikunja` token (D3), so revoking means the bus no longer has it. On the Gitea and Vikunja side the
token still exists until Jason deletes it (A5). The status line for a revoked entry says so until Jason
confirms. The model token is the exception: it sits in the running sandbox (D7), so a fired agent's runs must
end. Row 194's fire ends them.
D13. **What row 194 calls.**
- `credentials.issue` is never called by the chief. Row 194's hire stays uncredentialed (its D1).
- A human `create` of a credentialed definition: row 194 lifts E9's refusal for the human path only. The agent
is created with `store` references and no files. `launch` refuses `credential-unavailable` until Jason runs
`issue`.
- `fire` and `delete`: row 194 calls `credentials.revoke(business, agent, {reason, actor})` before it changes
the business file. A failed revoke stops the fire (`credential-revoke-failed`), so an agent can't be fired
with its credentials still live on our side.
- `regenerate`: no credential change.
## 7. Who does what (brief item 5)
Agents never mint accounts or tokens. Jason's steps, per agent that needs `gitea`:
- A1. Create the Gitea user: Site Administration, User Accounts, Create User Account. The name is the agent's
`gitea.user`. The email is the agent's `identity.email`.
- A2. Create its token: sign in as that user, Settings, Applications, Generate New Token, scopes
`write:repository` and `read:user`, nothing else.
- A3. Create the fork of each granted repo as that user (O1). Gitea's push-to-create
(`ENABLE_PUSH_CREATE_USER`, `ENABLE_PUSH_CREATE_ORG`) stays off, so a push to a missing `<user>/REPO`
fails rather than creating a plain repository (N11). `publish` refuses `fork-unavailable` before that.
- A4. On the stack instance's VM, as its OS user: `mosaic credentials issue AGENT gitea --rotate-by DATE`, with
the token pasted into stdin.
- A5. On fire or rotate: delete the token in Gitea (Settings, Applications), or disable the user.
- A6. Model: sign in to the provider on each VM user to write `<configDir>/secrets/claude-session.env`.
Vikunja bot accounts and tokens are already Jason's (tracker bots). Only A4 changes for them.
## 7a. The grant path (mos-1124, Q188, Q190)
Q188: no agent creates a grant. The chief raises the request, Jason approves it in the inbox, and protected
stack code creates the grant. Jason called manual grant steps a no-fly, so A1 to A4 above are the fallback only.
- a. `credentials.issue`, and so `rotate`, accepts a resolved `credential.mint` decision, Jason's Approve, as
the human path. #436b's executor calls it with that decision and nothing pasted. #436b adds that path and
the decision kind. Row 199 ships the verbs with today's human check only.
- b. Under Q190 ("Q12: O1"), each agent seat gets its own Gitea account. On the Approve, #436b's executor does
A1 to A3: the seat's Gitea user, its token and its forks. Until #436b lands, pushes stay as jason.woltje.
- c. Q185 settles Q-J1 and Q190 settles Q-J2.
No code in 07141966e changes for this section. The executor and the decision route are #436b's.
## 8. Migration (brief item 6)
- M1. Existing `{file}` and `{env}` references keep working. A business moves one agent at a time with
`issue`, which rewrites that agent's reference to `store`.
- M2. Nothing here touches the dev repository's helper, its `.git/config`, `~/.mosaic` or `~/.mosaic-dev`.
Dev seats (jarvis, seat tokens) keep pushing as today. The mos-0981 rule ends only when Mos says so.
- M3. The live dev stack instance needs no change at re-pin: no reference uses `store` until someone issues
one.
## 9. Questions
Mos ruled on the authority model in mos-1027 (2026-10-20T19:27Z):
- Q-M1. O1, one fork per agent.
- Q-M2. `git.push.remote` and its bounds in D9 to D11 are approved. It lands as a hard wait: out of ROUTED,
and no role grants it within-role (D9 step 1). Sage puts its routing in the PRD version draft going to
Jason with `role.hire` and `role.fire` (Vikunja #420). After his approval, a reviewed commit routes it and
grants it to coder.
- Q-M3. Issue, rotate and delete are human-only, and the chief gets none of them. Under Q173 the dev
instance's tester gets `status` and `revoke` only.
- Q-M4. The model credential stays per stack instance for 10-27. Per-agent model tokens wait for a proxy
row.
Open for Jason. Mos takes these to him one at a time:
- Q-J1. The model account for Case 1 and Case 2: existing subscription or a new account. Settled by Q185 (7a).
- Q-J2. Gitea users and tokens for the agents that need them (A1 to A3). Settled by Q190 (7a).
## 10. What this closes (brief item 7)
- G11: closes the store (D1 to D6) and lifecycle (section 6) for `gitea` and `vikunja`. Still open: per-agent
model credentials (D7), and the same-uid limit inside one stack instance (D6).
- G13: closes "own credentials for git" (D8 to D10). Still open: Gitea API verbs (section 4).
- C2.5 (git remote): Case 2's books repo can use `publish` once its project names a remote.
## 11. Tests and mutants
All tests use scratch roots, fake tokens and a fake Gitea: `git http-backend` behind a node http server on
127.0.0.1 that checks basic auth against a fake token and records each ref update. No request leaves the host.
- T1. Layout: wrong mode or owner on a dir or file refuses start. A `{file}` reference into another agent's
path refuses `credential-not-yours`. A bus restart after a create without `issue` starts, and the agent's
status is `not-issued`. A missing `{file}` still refuses start.
- T2. `useFor` keys from the cap only. A request naming another agent is refused and leaves one
`action.refused`.
- T3. The fake Gitea serves https on 127.0.0.1 with a cert from a scratch CA made by the test. It reaches git
only through the `tls` parameter (D9 step 5). Each refusal test that should reach the network asserts that
the first port received the request, so no test passes because git refused http first. `publish`: the
happy path lands `refs/heads/<user>/<branch>` on the agent's fork in the fake, which shares root's history, at
exactly the decision's commit. Refusals for each step in D9, one test each, including `work-not-yours`. Until routing, a publish with
no decision refuses, one with a decision for another task or commit refuses, and a second publish on a
spent decision refuses `decision-consumed`. Approve while the latest push is A, push B, publish: refuses
`decision-mismatch`, and the fork receives nothing. A push to the working repo between `checkPublish` and
the git push refuses with `ref-moved` or `decision-mismatch` (sage-1440, BUILD.md deviation 14). A raise with no recorded push refuses `no-push`. A test pins that `git.push.remote` is in neither ROUTED list
and that no `roles/*.json` grants it. A force-push attempt is
refused by git's non-fast-forward. A fake fork that answers 302 to a second 127.0.0.1 port: the push
fails, and the second port receives no request with an Authorization header. A remote on the right host
and a different port is refused at step 2. Two agents granted one project each land on their own fork:
agent A's push reaches `/<A's user>/REPO` only and B's reaches `/<B's user>/REPO` only, and the fake's
upstream repository receives nothing. An upstream owned by the agent's own user refuses `remote-not-fork`,
and so does one whose owner differs from `gitea.user` only in case. With an upstream that shares root's
history, a first publish to an empty fork and to an unrelated fork each refuse `remote-unrelated`, and
neither fork nor upstream receives objects. A fork that doesn't exist refuses `fork-unavailable`, and the
fake records no push. `ls-remote` and the push each reach the fork's path only (the fake records every
path). Load refusals (D9c), one test each: a dot segment, an extra path segment, a missing segment,
a trailing slash, userinfo, a query, a fragment, an explicit `:443`, a name outside Gitea's rule, and a `gitea.url` with a
sub-path. A `.git` suffix loads and builds the fork without it. A `publish` grant on an agent without
`identity`, and on one whose definition lacks `gitea`, each refuse at load with the N10 message.
- T4. Token handoff: the token never appears in argv, env, the fork URL, the working repo config,
`changes.jsonl`, events or logs (scan every file under the scratch roots).
- T5. Lifecycle: issue, rotate, revoke and delete, each live with no restart. After revoke, `publish` and the
Vikunja path refuse `credential-unavailable`, and so they do after a bus restart that follows the revoke.
The tester can run `status` and `revoke`, and `issue` and `rotate` refuse it.
- T6. Default: an agent without `identity` commits as E7. Existing `{file}` references load unchanged.
Mutants to kill: drop the cap-derived key in `useFor`, let the request name the destination ref, add `+` to
the refspec, skip the identity check, put the token in the URL, revoke file before memory, skip the dir mode
check, allow a `{file}` reference into another agent's dir, make ENOENT on a `store` path fatal again, make
ENOENT on a `{file}` path silent, a helper that answers any host, drop `http.followRedirects=false`, drop the
`work-not-yours` check, drop the `remote-unrelated` check, add `git.push.remote` to ROUTED, skip the spent
check on the decision, drop the `pushCommit` comparison in `#authorityFor`, push by ref name in place of the
oid, push to the upstream URL in place of the derived fork, run `ls-remote` against the upstream in place of
the fork, take the fork's owner from anywhere but the agent's `gitea.user`, compare the owner with case, run the
D9c checks on the raw path in place of the normalized one, read a failed `ls-remote` as empty, skip the tip
check under the lock, and pass a CA flag in production.
## 12. Risks
- R1. Backups: if a backup tool copies `<configDir>`, it copies tokens. The build checks row 185's backup set
and says what it includes.
- R2. Withdrawn. O1 was ruled, so probe P1 isn't needed.
- R3. Outbound HTTPS from the bus host to Gitea is new network reach for the host process. On the VMs this is
Jason's network to confirm.
+7
View File
@@ -0,0 +1,7 @@
# Morgana
Builder seat, Claude Opus 5.5, started by Sage for MVP T1 row 193 (human users and RBAC, #1691) under mos-0974 and mos-0977.
No launcher in this directory. The seat runs as a T3 thread and works in its own worktree. It has no
credentials of its own; until the credentials row settles per-agent identity, it pushes only its own `morgana/*` branches
through the repository credential helper (mos-0981), never `refactor`, `main` or another seat's branch.
+8
View File
@@ -0,0 +1,8 @@
#!/bin/bash
n=0
for p in /proc/[0-9]*; do
c=$(tr '\0' ' ' < $p/cmdline 2>/dev/null) || continue
case "$c" in *shell-snapshots*|*chrome-devtools-mcp*|*sage-busy-now*) continue;; esac
if echo "$c" | grep -qE 'node --test|gate\.sh|mutant|scripts/test-[a-z-]+\.sh|docker compose'; then echo "BUSY ${p#/proc/}: ${c:0:160}"; n=$((n+1)); fi
done
[ $n -eq 0 ] && echo CLEAR
+24
View File
@@ -0,0 +1,24 @@
#!/bin/bash
set -o pipefail
# Landing gate template (2026-10-14): sed the G= and OUT= lines and this comment.
# Sequential suites, each teed to its own log; stops at the first failure.
G=/mnt/storage/scratch/SET-ME-wt
OUT=/mnt/storage/scratch/SET-ME-out
cd "$G" || exit 9
mkdir -p "$OUT" || exit 9 # a missing OUT failed the row 142 gate at step 1 (2026-10-15)
# test-task.sh live cases run `docker compose` from $G, so without a name the project is "wt"
# and leaves wt_default behind (row 142 gate, 2026-10-15). Name it per gate dir and remove it on exit.
export COMPOSE_PROJECT_NAME="$(basename "$(dirname "$G")")"
trap 'docker network rm "${COMPOSE_PROJECT_NAME}_default" >/dev/null 2>&1 && echo "removed network ${COMPOSE_PROJECT_NAME}_default"' EXIT
# A gate tree without the node_modules link fails cli tests with ENOENT (2026-10-13).
[ -d "$G/node_modules" ] || { echo "STOP no node_modules in $G"; exit 8; }
echo "base $(git rev-parse --short HEAD) dirty $(git status --short | grep -vc node_modules) DOCKER_HOST=${DOCKER_HOST:-unset}"
run() { local name="$1" dir="$2" cmd="$3"
(cd "$G/$dir" && bash -c "$cmd") > "$OUT/$name.log" 2>&1; local rc=$?
echo "$name rc=$rc $(grep -E '^ℹ (pass|fail) [0-9]+' "$OUT/$name.log" | tr '\n' ' ') | $(tail -n1 "$OUT/$name.log")"
[ $rc -eq 0 ] || { echo "STOP $name"; exit $rc; }; }
# No pkg-queue step: scripts/test-queue.sh runs packages/queue/tests/ itself (same 156 tests; CI debate item 2a, Vikunja #274).
for d in packages/*/; do p=$(basename "$d"); [ -d "$d/tests" ] || continue; [ "$p" = queue ] && continue
run "pkg-$p" "packages/$p" 'node --test tests/*.test.mjs'; done
for s in scripts/test-*.sh; do n=$(basename "$s" .sh); run "script-${n#test-}" . "bash $s"; done
echo GATE_PASSED
+36
View File
@@ -0,0 +1,36 @@
# Row 189 gate timing (for row 197, Herb)
Sage, 2026-10-20T19:25:04Z. Gate on 0c177e9ef (base 0217b71c8), one lane. The slot was mine. Seats may have run single test files at the same time, which the slot rules allow, and I did not record that.
Start 2026-10-20T19:09:37Z, end 2026-10-20T19:20:36Z. Total wall time: 659 s.
Wall time per suite is the gap between one suite's log mtime and the one before it (the first suite starts at start.txt), so it includes node start-up. node's duration_ms is listed where the suite prints it. Scripts print no duration.
| Suite | Wall s | duration_ms s | Ended (UTC) |
|---|---|---|---|
| pkg-bus | 3 | 3.8 | 19:09:40 |
| pkg-business | 3 | 2.2 | 19:09:43 |
| pkg-cli | 388 | 388.5 | 19:16:11 |
| pkg-control-board | 1 | 0.8 | 19:16:12 |
| pkg-conversation | 35 | 35.4 | 19:16:47 |
| pkg-discord | 3 | 2.6 | 19:16:50 |
| pkg-harness | 12 | 11.6 | 19:17:02 |
| pkg-ledger | 13 | 13.5 | 19:17:15 |
| pkg-mosaic | 3 | 2.3 | 19:17:18 |
| pkg-plugins | 0 | 0.3 | 19:17:18 |
| pkg-project | 1 | 1.2 | 19:17:19 |
| pkg-runs | 1 | 0.8 | 19:17:20 |
| pkg-seat | 6 | 6.2 | 19:17:26 |
| pkg-tasks | 1 | 1.2 | 19:17:27 |
| pkg-webui | 58 | 57.9 | 19:18:25 |
| script-auth | 1 | - | 19:18:26 |
| script-conductor | 5 | - | 19:18:31 |
| script-config | 2 | - | 19:18:33 |
| script-discord | 8 | - | 19:18:41 |
| script-extension-package | 1 | - | 19:18:42 |
| script-foundation | 40 | - | 19:19:22 |
| script-land-work | 11 | - | 19:19:33 |
| script-queue | 21 | - | 19:19:54 |
| script-release | 11 | - | 19:20:05 |
| script-task | 31 | - | 19:20:36 |
Source: /mnt/storage/scratch/sage-g189/out/*.log mtimes and gate.log. The cli suite is about 59% of the run, and webui plus conversation add about 14%.
+321 -95
View File
@@ -1,8 +1,42 @@
# Q175 design: the Console verifies SSO itself (Vikunja #412)
Author Sage, 2026-10-20. Reviewer Filbert, and the review comes before any code. Line numbers are at refactor
4ca8d6e94. This replaces Part B (row 178 proxy proof, #385 comments 345 and 353). #385 stays open until the
Console stops trusting `X-authentik-username`.
Author Sage, 2026-10-20, r3. Reviewer Filbert, and the review comes before any code. Line numbers are at
refactor 4ca8d6e94 unless a line says otherwise. This replaces Part B (row 178 proxy proof, #385 comments 345
and 353). #385 stays open until the Console stops trusting `X-authentik-username`.
## Changes from r2 (Filbert's r2 review, CHANGES)
- C1: the `console` key goes into the shared loader, scripts/mosaic-config.mjs, which today refuses any
unknown top-level key (:113). Section 4 names the blast radius. Jason step 4 moves inside the phase 2
window, after the pin and before the Console starts. The phase 2 rollback removes `console` first. A
test-config.sh case covers it (test 25). Mos confirms the sequence before phase 1 (section 5).
- C2: under `--sso` the page treats a 401 `sign-in required` as signed out and links to
`/auth/login?return=<path>` (section 5 phase 1, test 24).
- Notes: tests 7 and 15 are built so that only the record rule refuses; test 9a is an alg test; Q-B's two
classes are defined, and test 23 adds a 200 HTML page; the phase 2 scratch Console uses a scratch config,
a dummy slot, no `--send` and no `--tester-port`.
- r3 fix (Filbert r3 approve): D1, the loader checks the slot reference's shape only, and the file checks
run in the Console's `--sso` start; D2, the slot resolves from the config dir, and the scratch Console has
a scratch dataRoot and an unreachable issuer (section 5).
- Q-E: settled as Filbert ruled. The `subject-unknown` line keeps iss and sub, JSON-escaped and capped
(section 1, tests 10 and 19).
## Changes from r1 (Filbert's r1 review, CHANGES)
- B1: the provider config moves to `~/.config/mosaic-dev/config.json` (section 4). Mos agreed (mos-0973). The
Console prints provider ids, issuers and subject counts at start. The rehearse-strip follow-up is dropped.
- B2: one return-path rule, `new URL(ret, publicOrigin).origin === publicOrigin` (section 1 step 7), with
tests for `/\evil.example` and `/%09/evil.example`.
- B3: refusals log a fixed code and the provider id, never the error or its cause (section 1).
- B4: login records are capped at 32 (section 1 step 1), with a test.
- B5: test 9 splits into a forged payload under a real kid and a kid missing from the JWKS, and a try/catch
mutant joins the list. Test 2 no longer claims the signature mutant (section 9).
- B6: the phase 2 forged-header check runs on a scratch Console, or Mos or Jason runs it on live (section 5).
- Rulings: Q-B start degraded (section 4), Q-C only the public Host on 7332 (section 3), Q-D keep 1 h idle
but polls don't count as activity (section 3).
- Section 6: `startServer` throws on `tester && sso`, and `human` joins `tester` as a refused subject label.
- The missing tests and nits N1-N5 are in sections 1, 3, 7 and 9.
- Section 8: the human-cli statement stays as written (mos-0972). The recommendation is now Mos's ruling.
## The ruling this implements
@@ -22,7 +56,9 @@ trust with verification" and confirmed five points (mos-0970):
## What exists today
The live Console runs from copies/490e2771d3d0 with `--public-host mosaic.woltje.com --auth-header
X-authentik-username --allow-user jason.woltje --send --tester-port 7333`.
X-authentik-username --allow-user jason.woltje --send --tester-port 7333`. Row 189 (#1687, P19 #413) adds
`stack-release.sh activate --no-send`, and Mos's ruling mos-0972 takes `--send` off the live Console until
phase 2.
- `previewOptions` (packages/webui/src/serve.mjs:205) builds `{ host, header, user }`.
- A request is `named` when its Host equals the public name (:493). A named request passes sign-in when it
@@ -45,41 +81,73 @@ New module `packages/webui/src/sso.mjs`, used only by the human listener (7332).
| Route | Method | What it does |
|---|---|---|
| `/auth/login` | GET | One provider configured: start its flow. Several: a plain page listing them, each a link to `/auth/login?provider=ID`. |
| `/auth/login` | GET | One provider configured: start its flow. Several: a plain page listing them, each a link to `/auth/login?provider=ID`. A provider whose discovery hasn't succeeded gets 503 (section 4). |
| `/auth/callback` | GET | Finish the flow, create the session, 303 to the saved return path. |
| `/auth/logout` | POST | Same-origin checks, delete the session, 303 to the provider's `end_session_endpoint`. |
| `/auth/signed-out` | GET | Static "signed out" page with a sign-in link. |
Every URL the Console hands a provider is built from the configured `publicOrigin`: `redirect_uri` is
`publicOrigin + '/auth/callback'` and `post_logout_redirect_uri` is `publicOrigin + '/auth/signed-out'`. No
code path reads the request's Host to build them (N2).
The flow per provider:
1. Start. Generate a state, a nonce and a PKCE verifier (`generateRandomState`, `generateRandomNonce`,
`generateRandomCodeVerifier`), and a challenge with `calculatePKCECodeChallenge` (S256 only). Put them, the
provider id and the return path in a server-side login record keyed by a random id. Set
`__Host-mosaic_login` to that id. 303 to `authorization_endpoint` with `response_type=code`,
`scope=openid profile email`, `code_challenge_method=S256`, `redirect_uri`, `state` and `nonce`.
provider id, the return path and an expiry 10 minutes out in a server-side login record keyed by a random
id. The store holds at most 32 login records; a new one evicts the oldest (B4). Set `__Host-mosaic_login` to
that id. 303 to `authorization_endpoint` with `response_type=code`, `scope=openid profile email`,
`code_challenge_method=S256`, `redirect_uri`, `state` and `nonce`.
2. Callback. Read exactly one `__Host-mosaic_login` cookie, then take and delete its record (single use, so a
replayed callback finds nothing). Expire the cookie in the response. The provider comes from the record,
never from the query, which stops a mix-up between providers.
never from the query, which stops a mix-up between providers. Two tabs signing in at once each set the
cookie, so the later one wins and the earlier tab's callback finds no record and gets the 403 page with a
sign-in link (N5). That costs one retry and keeps one cookie per browser.
3. `validateAuthResponse(as, client, url, record.state)` checks state, refuses an error response, and checks
the `iss` parameter when the provider advertises RFC 9207.
4. `authorizationCodeGrantRequest(as, client, clientAuth, params, redirectUri, record.verifier)`, then
`processAuthorizationCodeResponse(as, client, response, { expectedNonce: record.nonce, requireIdToken: true })`.
That checks iss, aud (and azp when aud has several values), exp, iat and nonce, and checks `alg` against
`client.id_token_signed_response_alg`.
4. `authorizationCodeGrantRequest(as, client, clientAuth, params, redirectUri, record.verifier)` returns a
`Response`. Keep that object. `processAuthorizationCodeResponse(as, client, response, { expectedNonce:
record.nonce, requireIdToken: true })` checks iss, aud (and azp when aud has several values), exp, iat and
nonce, and checks `alg` against `client.id_token_signed_response_alg`. That field gets the provider's whole
`algs` array, which the library's `checkSigningAlgorithm` accepts, so a provider listing RS256 and ES256
takes either and nothing else (N1). Test 16 pins this.
5. Signature. oauth4webapi does not verify the signature of an ID token that came straight from the token
endpoint over TLS (OIDC Core 3.1.3.7 allows that, and the library docs for
`validateApplicationLevelSignature` say so). Point 2 asks for the JWKS check, so the callback always calls
`validateApplicationLevelSignature(as, response)` after step 4. It fetches the JWKS from the discovered
`jwks_uri`, picks the key by `kid` and `alg`, and refuses HS* outright.
6. Our own checks on top of the library: `header.alg` is in the provider's configured `algs`, the token's
`iss` equals the configured issuer exactly, and `(iss, sub)` is in the provider's `subjects` list. Anything
else is a 403 that logs `refused sign-in iss=... sub=...` (never a token, code or cookie).
7. Create the session (section 3) and 303 to the return path. The return path must start with one `/` and
is otherwise replaced with `/`.
`validateApplicationLevelSignature(as, response)` with the same `Response` object from step 4. It fetches
the JWKS from the discovered `jwks_uri`, picks the key by `kty`, `kid`, `alg` and `use`, and refuses HS*
outright. On a `kid` it doesn't have, it refetches the JWKS once if the cache is at least 60 s old, which
covers key rotation (test 18). The call is not wrapped in a catch that continues; its throw ends the
callback.
6. Our own checks on top of the library: `header.alg` is in the provider's `algs`, the token's `iss` equals
the configured issuer as a raw string compare, with no URL normalising (N3; the library's discovery check
compares normalised hrefs, so this one is stricter), and `(iss, sub)` is in the provider's `subjects` list.
7. Create the session (section 3), then 303 to the return path. The rule (B2): resolve
`u = new URL(ret, publicOrigin)`, and use `u.pathname + u.search` only when
`u.origin === publicOrigin`; otherwise use `/`. A missing or unparsable `ret` gives `/`.
Every refusal in steps 2-6 ends at a 403 page saying sign-in failed, with a link back to `/auth/login`. No
step falls back to a header.
Logging (B3). A refusal logs one line: a fixed code from a closed list (`login-record-missing`,
`state-mismatch`, `auth-error-response`, `grant-failed`, `id-token-invalid`, `signature-invalid`,
`alg-refused`, `issuer-mismatch`, `subject-unknown`, `discovery-failed`, `jwks-failed`) and the provider id.
It never logs the exception, its message or its `cause`. oauth4webapi's errors carry token bodies and claims
in `cause`, so even a message can leak. The one extra field is on `subject-unknown`: it logs `iss` and `sub`,
because Jason step 3 reads his `sub` from that line (Q-E, Filbert's ruling). Authentik's default subject mode
is a hashed user id that its admin view doesn't show, so the line is the practical source. Neither value is a
credential. Three conditions hold:
- Only a token that passed steps 3-6 up to the subject check reaches this line. Every earlier refusal logs
its own code and the provider id only.
- Both values are written with `JSON.stringify` and each is cut to 255 characters first. Any account at the
provider can get a signed token, so a `sub` may hold a newline, and an unescaped one would let a sign-in
forge a log line.
- Test 10 uses a `sub` holding `\n` and a fake `refused sign-in` suffix and asserts one escaped line.
Test 19 asserts that no log line holds a token, code, state, nonce, verifier, client secret or session id.
It exempts only the `iss` and `sub` fields of the `subject-unknown` line.
### 2. Library: oauth4webapi 3.8.8, pinned exactly
Mos's steer (mos-0971) was to prefer oauth4webapi over hand-rolled verification on `node:crypto` unless I
@@ -103,34 +171,78 @@ entry. Root is right because `scripts/stack-copy.sh:23-31` copies only the root
webui's first runtime dependency. The root dependency rule (exact pins, never unversioned) applies to it.
Upgrades are reviewed commits.
### 3. Sessions, cookies and logout
### 3. Sessions, cookies, Host and logout
- Session id: 32 random bytes, base64url. The store keys on its sha256, so a heap dump doesn't hold usable ids.
- Cookie `__Host-mosaic_session=<id>; Secure; HttpOnly; SameSite=Lax; Path=/`, no Domain, no Max-Age (a
browser-session cookie). The `__Host-` prefix makes the browser refuse it unless it was set over https on
this exact host.
- Login cookie `__Host-mosaic_login`, same attributes plus `Max-Age=600`. The login record also expires at
10 minutes server-side.
- Session id: 32 random bytes, base64url. The store keys on its sha256 and holds no raw id, so a heap dump
doesn't hold usable ids. Test 20 checks that the store's keys and values never contain the cookie value.
- Cookie, exactly: `__Host-mosaic_session=<id>; Path=/; Secure; HttpOnly; SameSite=Lax`, no Domain, no
Max-Age (a browser-session cookie). The `__Host-` prefix makes the browser refuse it unless it was set over
https on this exact host. Test 21 asserts the whole Set-Cookie value, and a mutant drops `HttpOnly`.
- Login cookie `__Host-mosaic_login`, the same attributes plus `Max-Age=600`. The login record also expires
at 10 minutes server-side.
- Lax, not Strict, because the callback is a top-level navigation from the provider's site, and a Strict
cookie set there would be missing on the redirect to `/`. Lax still keeps the session off cross-site POSTs.
The write routes keep their exact `Origin` and `Sec-Fetch-Site: same-origin` checks (:171, :250, :297, :340,
:387, :425), so cross-site writes are refused twice.
- Lifetime: 8 h absolute, 1 h idle, both server-side. The store is in memory with a cap of 64 sessions
(oldest goes first), so a Console restart signs everyone out. That's acceptable for one human and keeps
sessions off disk.
- Lifetime: 8 h absolute, 1 h idle, both server-side (Q-D, Filbert's ruling). The store is in memory with a
cap of 64 sessions (oldest goes first), so a Console restart signs everyone out. That's acceptable for one
human and keeps sessions off disk.
- Activity (Q-D). Only a page load (a GET outside `/api/`) or a write (a POST) refreshes `seen`. The page's
background reads don't: `refresh` re-reads the board every 10 s (packages/webui/src/public/app.js:346) and
the 5 s timer at :301 redraws the age label. Without this rule an open tab would never go idle. Test 13
covers it.
- The session holds `{ provider, iss, sub, name, idToken, created, seen }`. `name` is the subject entry's
label. It goes where `preview.user` goes today (send logs, settings, seat routes), so the downstream writer
shape stays as it is. `idToken` is kept only as the logout hint and never leaves the server.
- Host (Q-C, Filbert's ruling). Under `--sso`, 7332 serves only requests whose Host is the public host.
Any other Host, loopback included, gets 403 `public Host required`, except `GET /healthz`. The loopback read
path at :509-512 is gone under `--sso`. Today's readers of 127.0.0.1:7332 and where they go:
- `scripts/webui-service.sh status` (:406), the stack-release gate (scripts/stack-release.sh:484) and the
rehearse gate (:1369) read only `/healthz`, which stays open on loopback.
- The webui README (:83) describes the loopback path. It changes in phase 1.
- Agent reads of the board go to the tester listener on 7333, which already serves reads. I found no other
code that reads 7332 on loopback. A person opening http://127.0.0.1:7332 in a local browser gets the 403
and uses the public URL instead.
Test 22 covers a loopback Host on `/`, `/api/board` and a write, and `/healthz` passing.
- Every request on 7332 except `/healthz` and `/auth/*` needs a valid session. Without one, `/api/*` gets
401 JSON and other GETs get a 303 to `/auth/login?return=<path>`.
- Logout: `POST /auth/logout` with the same-origin checks. It deletes the session, expires the cookie, and
303s to `end_session_endpoint?id_token_hint=...&post_logout_redirect_uri=https://mosaic.woltje.com/auth/signed-out&client_id=...`.
303s to `end_session_endpoint?id_token_hint=...&post_logout_redirect_uri=<publicOrigin>/auth/signed-out&client_id=...`.
If the provider has no `end_session_endpoint`, it goes straight to `/auth/signed-out`.
- Limit: signing out at the provider does not end a Console session until idle or absolute expiry. Back-channel
logout is a follow-up, not phase 1.
### 4. Provider config: shape and location
Location (B1, Filbert's ruling, Mos agreed in mos-0973): a `console.sso` key in
`~/.config/mosaic-dev/config.json`. The reason is who can write it. The business file is writable by agents
through `organizationWriter` (the tester verbs and the chief's `organization.set`), so a provider entry there
could be changed by the agents the Console is meant to keep out. config.json is user-authored and never
auto-written: `scripts/bootstrap.sh` creates it and nothing overwrites it. Adding the key is a Jason step
(section 10).
The loader (C1). Every tool reads config.json through one loader, scripts/mosaic-config.mjs. Its `validate`
refuses any top-level key other than `configVersion`, `environment`, `dataRoot` and `execution` with
`unsupported configuration key` (:113). The business, seat, cli, discord, bootstrap, land-work, stack-copy and
conductor-apply code all use it. So phase 1 changes the shared loader, not a Console-only reader:
- mosaic-config.mjs accepts an optional `console` key, and the strict `console.sso` shape validator (the
config checks below, except the slot file checks) runs inside the loader. The fail-closed canon holds for
every reader. The loader checks shape only and never touches a slot file: `clientSecret` must be
`{ slot: <name> }` with the name matching `[a-z0-9-]{1,64}`. The file checks and the read run only in the
Console's `--sso` start (D1), so a missing slot or a 0640 slot stops the Console, not the stack.
- The blast radius is the whole stack. A typo in `console.sso` makes every tool refuse the config, not just
the Console. That's the price of one loader, and the reason step 4 writes the key inside a window with a
backup first.
- The other direction breaks too. A copy older than phase 1 refuses a config that has the key. If the key
went in before the phase 2 pin, the live copy (490e2771d3d0 or later) would refuse its own config on the
next read, and the bus, seats and host would stop. After phase 2, a rollback to a pre-phase-1 copy fails
the same way until the key is removed. This is the same trap as the plug-in kind before a rollback.
- Files without the key still load, on old and new copies.
Test 25 in scripts/test-config.sh: a config with a valid `console.sso` loads, a malformed one fails with
the bad key path named, and a config without `console` loads.
No secret value goes in this file. The client secret is a slot reference.
```json
@@ -151,38 +263,54 @@ No secret value goes in this file. The client secret is a slot reference.
}
```
Location: a new optional `console` key in the business file (`~/.config/mosaic-dev/businesses/<id>.json`). The
Console already serves one business (`--business`), the identities map to that business's human, and Q173
lets agents edit the test business with a backup first. Adding the key means adding `console` to `TOP_KEYS`
(packages/business/src/business.mjs:21) with a strict validator. It's additive, and existing files without
it still load. The alternative is `~/.config/mosaic-dev/config.json`, which is user-authored and never
auto-written. That fits a deployment setting well, but it puts per-business identity in system config.
**Filbert, rule on this one.**
At start, `--sso` prints one line per provider: its id, its issuer and the number of subjects. No client id
value beyond that, no secret, no `sub`.
The slot is `~/.config/mosaic-dev/secrets/mosaic-stack/<slot>`, read at start. The Console refuses to start if
the file isn't a regular file, isn't owned by the Console's uid, has any group or other mode bits, or
resolves under the repo or a stack copy. The value is never logged or put in a refusal message.
The slot is `<configDir>/secrets/mosaic-stack/<slot>`, where `configDir` is the directory of the config file
the loader read (`MOSAIC_CONFIG`, default `~/.config/mosaic-dev`), so a scratch config gets a scratch slot
(D2). The Console's `--sso` start reads it. The Console refuses to start if
the file is a symlink, isn't a regular file, isn't owned by the Console's uid, has any group or other mode
bits (0640 refuses as well as 0644), or resolves under the repo or a stack copy. The value is never logged or
put in a refusal message.
`stack-release.sh rehearse` copies a backup onto a scratch root. A rehearsed Console must not use the live
redirect URI, so rehearse strips `console.sso` from the scratch business file and the rehearsed Console runs
without SSO (tester listener only). That's a row 163 follow-up, named in the build brief.
`stack-release.sh rehearse` starts its Console with `serve --port N --business B` and no `--sso`, so a
rehearsed Console never reads `console.sso` and never uses the live redirect URI. No rehearse change is
needed; r1's follow-up is dropped.
Startup checks for each provider, all fail-closed (the Console doesn't start, and the message names the
provider and the check):
Config checks, run at start without the network. Any failure refuses the start, and the message names the
provider and the check:
- `issuer` is https with no query or fragment. Discovery returns that issuer exactly.
- `id_token_signing_alg_values_supported` shares at least one alg with `algs`, and `algs` holds only
asymmetric algs from the library's list. mosaic-preview advertises HS256 only, so the Console refuses it at
start.
- `publicOrigin` is an https origin with no path, query or fragment.
- Provider ids are unique and `[a-z0-9-]{1,32}`.
- `issuer` is https with no query or fragment.
- `algs` is non-empty and holds only asymmetric algs from the library's list. `HS256`, any `HS*` and `none`
refuse.
- `subjects` is non-empty, each `sub` is 1..255 visible characters, and no `name` is `tester` or `human`.
- `clientSecret` is `{ slot: <name> }` with the name matching `[a-z0-9-]{1,64}` (shape only, in the loader).
- In the Console's `--sso` start only, the slot file passes the file checks above.
Discovery checks, run per provider when its discovery succeeds:
- Discovery returns `issuer` exactly.
- `id_token_signing_alg_values_supported` shares at least one alg with `algs`. mosaic-preview advertises HS256
only, so it fails here.
- `code_challenge_methods_supported` includes `S256`.
- `jwks_uri` returns at least one key usable for one of `algs`. The empty mosaic-preview JWKS fails here too.
- `subjects` is non-empty, and each `sub` is 1..255 visible characters.
- `client.id_token_signed_response_alg` is set to `algs[0]`, so the library enforces it per token.
- Provider ids are unique and `[a-z0-9-]{1,32}`.
Starting while the provider is unreachable refuses the start. A Console with no working sign-in serves
nothing, so refusing loudly costs nothing, and the unit restarts it. The tradeoff is that an IdP outage
keeps the Console down until the IdP is back. With every route behind a session, it would be unusable anyway.
Unreachable provider (Q-B, Filbert's ruling). Config errors still refuse the start. Each fetch of the
discovery document or the JWKS lands in one of two classes:
- Unreachable: a network error, a timeout, a status other than 200, or a body that doesn't parse as JSON. An
Authentik maintenance page served as 200 HTML is unreachable, not refused, so it can't block a restart.
- Refused: a JSON document that fails the issuer, alg, S256 or JWKS check.
An unreachable provider doesn't stop the Console. It starts, that provider's `/auth/login` gets 503
`sign-in unavailable`, and a retry runs with backoff (5 s, doubling, capped at 5 minutes). `/healthz` stays
200 and adds `sso: { <id>: "ready" | "unreachable" | "refused" }`, so the gates still pass and an operator
can see the state. A refused provider at start refuses the start, because it's a configuration problem. On a
later retry it stays disabled with the `discovery-failed` code until a restart. Sessions already made keep
working while a provider is unreachable, since a session check needs no network. Test 23 covers the degraded
start, a 200 HTML page, and the recovery.
Several providers: Pocket ID, WorkOS and other compliant OPs use the same entry shape. The login page lists
them. Each entry has its own issuer, client, algs and subjects, and a session records which provider made it.
@@ -190,13 +318,48 @@ them. Each entry has its own issuer, client, algs and subjects, and a session re
### 5. Retiring `--auth-header`, `--allow-user` and the proof header
1. **Phase 1 (code row).** Add `--sso` to `cli.mjs serve` and `webui-service.sh`. It reads `console.sso` from
the business file. `--sso` cannot be combined with `--auth-header`, `--allow-user` or `--proof-*`. Under
config.json. `--sso` cannot be combined with `--auth-header`, `--allow-user` or `--proof-*`. Under
`--sso`, `named` means "has a session", and `writer` comes from the session. `--send` needs `--sso` or the
old preview set. Nothing live changes until the re-pin.
old preview set. Phase 1 also carries the loader change and its test-config.sh case (section 4, test 25).
Nothing live changes until the re-pin.
The page (C2). Today app.js signs out only on `res.type === 'opaqueredirect'` (packages/webui/src/public/
app.js:111 and :305), which is the outpost's redirect. Under `--sso` the Console answers an `/api/*`
request without a session with 401 `sign-in required`, so the page would show an error on every 10 s
refresh and never offer a way back. Phase 1 makes both fetch paths treat a 401 whose body has
`error: 'sign-in required'` as signed out, and the signed-out notice links to
`/auth/login?return=<current path and query>`. Other 401s stay errors. The opaqueredirect branch stays
until phase 3, because the preview flags still run behind the outpost until then. Test 24 covers it.
Before phase 1 starts, Mos confirms the config.json sequence in phase 2 (C1).
2. **Phase 2 (Jason-gated window).** Re-pin to the phase 1 commit with `--sso --send --tester-port 7333`
instead of the preview flags, after the Jason steps below. Backup first, STATE after (Q173). Acceptance:
Jason signs in through the Console flow, sends one message, signs out, and a forged-header request with no
cookie gets 401.
instead of the preview flags, after Jason steps 1 to 3. Backup first, STATE after (Q173). The order
inside the window:
1. Back up the store, the pointer, the unit and config.json.
2. Pin the phase 1 copy with the Console stopped. The copy's loader accepts a config with or without
`console`, so the bus and seats keep reading the old config.
3. Straggler check (Mos's C1 condition, mos-0977): list every running process, unit and timer that
still runs from the old copy and reads config.json. Each one is restarted on the new copy or shown
not to re-read config. The old loader refuses the key, so a straggler would fail on its next read.
Step 4 waits until the list is clean.
4. Jason step 4: the `console.sso` key goes into config.json, written by Jason or by an agent on his go
for that one key. The new loader reads it back (`scripts/mosaic-config.mjs` validate) before anything
starts.
5. Start the Console with `--sso --send --tester-port 7333`.
Acceptance: Jason signs in through the Console flow, sends one message and signs out. The forged-header
check (a request with `X-authentik-username` and no cookie gets 401) runs either on a scratch Console or
on live by Mos or Jason (B6). No agent sends the auth header to the live 7332 (section 8). The scratch
Console runs from the same copy as `serve --sso --port <scratch port>`, with `MOSAIC_CONFIG` pointing at a
scratch config in a scratch dir. That config has a scratch `dataRoot`, and any business it serves comes
from the scratch config dir; neither points at live. Its dummy client secret sits in the scratch slot
(`<scratch configDir>/secrets/mosaic-stack/<slot>`). Its issuer is an unreachable loopback host, not
Authentik, so the scratch Console sends nothing outbound (D2, mos-0971). It comes up in the degraded
state (Q-B), and the 401 check needs no sign-in. It has no `--send` and no `--tester-port`.
Rollback for phase 2: remove the `console` key from config.json first (restore the step 1 backup), then
roll the pointer back. An older copy's loader refuses a config holding the key (section 4), so the other
order stops the bus and seats. The window plan lists that order, and the STATE check after a rollback
reads config.json for the key's absence.
3. **Phase 3 (code row).** Remove `previewOptions`, `proofOptions` and their tests. `cli.mjs` and
`webui-service.sh` refuse the old flags with `--auth-header was retired by Q175; use --sso`. Close #385
with the phase 2 STATE and the phase 3 commit.
@@ -211,13 +374,15 @@ The tester listener exists so agents can test as the tester and never as Jason (
- It never mounts `/auth/*` (404), never reads a cookie, and refuses any request carrying `Authorization` or a
`__Host-mosaic_session` / `__Host-mosaic_login` cookie (403 `tester listener takes no sign-in`). The same
goes for `X-authentik-jwt` and the old auth and proof header names.
- `startServer` throws when its options hold both `tester` and `sso`, so the SSO module can't be mounted on
the tester listener by a wiring mistake. A unit test calls it with both.
- Its writer stays the fixed `{ tester: true, user: 'tester' }`, and it allows only the row 175 writes, as
today. No session or header can change it.
- Its bus calls stay on `TESTER_CLI`. The bus checks the tester proof and refuses human verbs from it, and the
broker refuses a business whose human is `tester` and any role named `tester`. So nothing that reaches 7333
can become the human.
- 7332 keeps refusing `x-mosaic-tester` for every Host (:485).
- `subjects` validation refuses an entry named `tester`.
- `subjects` validation refuses a `name` of `tester` or `human`.
### 7. Point 5, the proxy-forwarded token
@@ -225,7 +390,9 @@ Not in phase 1. Point 5 makes it optional and the code flow covers the ruling. I
- Verify with jose (MIT, exact pin), against the JWKS from the configured issuer's own discovery. Never use
the `X-authentik-meta-jwks` header, because it arrives with the request and the forger controls it.
- Use the same alg allow list, iss, aud (the proxy provider's client id), exp and subject list.
- Use the same alg allow list, iss, exp and subject list. `aud` is the proxy provider's client id, and the
config check refuses it if it equals any code-flow `clientId`, so an ID token issued to the Console can't be
replayed as a proxy token or the other way round (N4).
- The mosaic-preview provider signs with HS256 and has an empty JWKS, so its `X-authentik-jwt` fails every
check until Jason gives it an RSA or EC key (Jason step 1).
@@ -237,9 +404,11 @@ Today, with `--send` live, any same-uid process on dragon-lin can connect to 127
strips client copies of the header, so a remote forger needs local code execution first. Users on other uids
can reach the loopback port too (P19 333).
Recommendation: Mos restarts the Console without `--send` until phase 2. Reads stay up, the header path stops
carrying writes, and only Jason's sends through the Console stop. That's a Mos call (Console start belongs to
Jason or Mos). Until then, no agent sends a request with the auth header to 7332. The tests use fixture servers.
Mos ruled (mos-0972) that the Console runs without `--send` until phase 2. Reads stay up, the header path
stops carrying writes, and Jason sends piece 1 with `mosaic talk pm` instead. Row 189 (`stack-release.sh
activate --no-send`, #1687) makes that change through the pointer so the unit and stack-active.json stay in
step; the restart follows its landing. Until phase 2, no agent sends a request with the auth header to 7332.
The tests use fixture servers.
To be clear about the limit: SSO protects Console ingress. It doesn't protect the bus's human boundary. The
human proof is path-bound (#225), so a same-uid process can already run the tree's `human-cli.mjs` and act
@@ -249,11 +418,11 @@ as the human without touching the Console. Q175 doesn't change that. P19 333 own
All tests run against a fake OP in `packages/webui/tests/helpers/fake-oidc.mjs`. It's a loopback https
server with a self-signed cert. Each test passes the library a `customFetch` that trusts only that cert, so
production code needs no insecure-request flag. The fake OP has discovery, a
JWKS, and authorization, token and end-session endpoints, with keys made by `node:crypto` (RSA 2048, P-256, and an
HMAC secret for the forgeries). No test reaches a real provider. Each refusal test asserts the status, that no
`__Host-mosaic_session` was set, and that the bus send stub was never called. Each test also passes once
unmutated, as the control.
production code needs no insecure-request flag. The fake OP has discovery, a JWKS, and authorization, token
and end-session endpoints, with keys made by `node:crypto` (RSA 2048, P-256, and an HMAC secret for the
forgeries). It can serve two issuers at once and rotate its keys. No test reaches a real provider. Each
refusal test asserts the status, that no `__Host-mosaic_session` was set, and that the bus send stub was
never called. Each test also passes once unmutated, as the control.
Refusals Mos named:
@@ -261,35 +430,87 @@ Refusals Mos named:
Fetch Metadata, and no cookie: 401 on `/api/bus/message`, `/api/settings` and `/api/config`, and a 303 to
login on `/`.
2. HS256 ID token. The fake OP signs the ID token with HS256, using the client secret as the key (the classic
confusion) and `kid` set to a real RSA kid: refused at the callback.
3. Wrong aud. A correctly signed RS256 token with `aud` set to another client id: refused.
4. Expired token. A correctly signed token with `exp` 10 minutes ago (beyond the library's clock tolerance):
refused.
confusion) and `kid` set to a real RSA kid: refused at the callback. The library's alg check refuses it
before any signature check, so this test proves the alg rule, not the signature check. Test 9 covers that.
3. Wrong aud. A correctly signed RS256 token with `aud` set to another client id: refused. Also an `aud`
array holding our client id and another, with no `azp`: refused.
4. Expired token. A correctly signed token with `exp` 10 minutes ago (beyond the library's 30 s clock
tolerance): refused.
More refusals:
5. Wrong `iss`, both a different issuer and a trailing-slash variant.
6. Nonce mismatch or missing nonce.
7. State mismatch, a missing login cookie, a replayed callback (record already taken), an expired login record,
and two login cookies.
and two login cookies. For the replay, the fake OP is set to accept the same code twice, so the second
callback reaches the record lookup and only the take-and-delete rule refuses it.
8. A provider that demands no PKCE. The token request must carry `code_verifier`, and the fake OP refuses a
wrong one.
9. `alg: none`, and an RS256 token signed by a key not in the JWKS.
10. A valid token for a `sub` not in `subjects`: 403 and one log line with iss and sub only.
11. Startup refusals: HS-only discovery (the mosaic-preview shape), an empty JWKS, no S256, an issuer mismatch,
a slot with mode 0644, and `--sso` together with `--auth-header`.
9. The signature check (B5), three cases: (a) `alg: none`; (b) an RS256 token whose header names a real kid in
the JWKS, with a changed payload and the original signature; (c) an RS256 token signed by a key whose kid
isn't in the JWKS, even after the refetch. (a) is refused by the alg check in step 4, so it's an alg test
like test 2. Only (b) and (c) reach step 5, and (b) is the case only the signature check catches.
10. A valid token for a `sub` not in `subjects`: 403 and one `subject-unknown` log line with the provider id,
iss and sub only.
11. Start refusals: `algs` holding `HS256` or `none`, HS-only discovery (the mosaic-preview shape), an empty
JWKS, no S256, an issuer mismatch, a subject named `tester` or `human`, `--sso` together with
`--auth-header`, and slot files that are a symlink, mode 0644, mode 0640, or under the repo. The owner
check has no test: making a file owned by another uid needs root, which the suite doesn't have. The
review reads that branch instead.
12. Tester port: a valid session cookie (taken from a 7332 sign-in), `Authorization: Bearer <valid id token>`,
and `X-authentik-jwt` each get 403. `/auth/login` on 7333 gets 404.
and `X-authentik-jwt` each get 403. `/auth/login` on 7333 gets 404. `startServer` with `tester` and `sso`
throws.
13. Session: idle expiry, absolute expiry, a logout followed by reuse of the old cookie, and logout without
same-origin (refused, session kept).
14. Open redirect: `return=//evil.example` and `return=https://evil.example` both land on `/`.
same-origin (refused, session kept). Background `/api/` GETs every 10 s for over an hour don't keep the
session alive; a page load does.
14. Open redirect: `return=//evil.example`, `return=https://evil.example`, `return=/\evil.example` and
`return=/%09/evil.example` all land on `/`, and `return=/board?x=1` lands there.
15. Provider mix-up. Two providers, A and B. A login started for A whose callback carries B's `iss` and a
code from B is refused, and a `provider=` query on the callback is ignored. B's code is issued against
A-record's PKCE challenge, so the verifier matches and only the record rule (the callback belongs to A)
refuses it.
16. Several algs: a provider with `algs: ["RS256", "ES256"]` takes a token signed with either, and refuses
PS256 even when the JWKS holds a usable PS256 key.
17. Login record cap: 33 started logins leave 32 records, and the first login's callback finds no record.
18. JWKS rotation: the fake OP rotates to a new key with a new kid. A sign-in after the rotation succeeds
through the one refetch, and a token under the removed kid is refused.
19. Logs: across every test in this file, captured log lines hold none of the tokens, codes, states, nonces,
verifiers, the client secret or a session id that the test produced.
20. The session store holds no raw session id (keys are sha256 values, and no value holds the cookie).
21. Set-Cookie: the exact session and login cookie strings, including the expiry of the login cookie at
callback.
22. Host: under `--sso`, a loopback Host on `/`, `/api/board` and a write each get 403; `/healthz` on
loopback gets 200.
23. Degraded start: the fake OP is down at start; the Console starts, `/auth/login` is 503, `/healthz` reports
`unreachable`; the OP comes up and the next retry makes sign-in work. A discovery URL that answers 200
with an HTML page also counts as `unreachable` and the Console starts. A provider that answers with
HS-only discovery at start refuses the start.
24. Signed-out page (C2). A page test in the board-held.test.mjs style stubs fetch to answer 401
`{ error: 'sign-in required' }`: the page shows the signed-out notice, its link is
`/auth/login?return=<current path and query>`, and the refresh stops. A 401 with another error stays an
error. The existing opaqueredirect case still signs out.
25. Loader (C1), in scripts/test-config.sh: a config with a valid `console.sso` loads; a malformed one (an HS
alg, a missing `slot` field, a slot name outside the pattern, an unknown key under `console`) fails and
names the bad key path, and no case reads a slot file (the file cases stay in test 11); a config without
`console` loads.
Positive paths: an RS256 and an ES256 provider each complete sign-in and one send, and two providers configured
together each work with their own subjects.
Mutants for the gate: drop the `validateApplicationLevelSignature` call (test 2 or 9 must fail), drop the
`subjects` check, accept HS* in `algs`, take the provider from the query, let the tester listener read
cookies, and skip the login record delete.
Mutants for the gate, each killed by the named test:
- drop the `validateApplicationLevelSignature` call (9b);
- wrap that call in a try/catch that continues (9b);
- drop the `subjects` check (10);
- accept HS* in `algs` (11);
- take the provider from the query (15);
- let the tester listener read cookies (12);
- skip the login record delete (7);
- drop `HttpOnly` from the session cookie (21);
- refresh `seen` on every request (13);
- build `redirect_uri` from the request Host (a test with a different Host on `/auth/login` checks the
`redirect_uri` sent to the fake OP);
- serve the loopback read path under `--sso` (22).
### 10. Jason-gated steps
@@ -300,16 +521,21 @@ cookies, and skip the login record delete.
post-logout URI `https://mosaic.woltje.com/auth/signed-out`, keep RS256, and give the client id. Put the
client secret into slot `console-sso-authentik` (or say an agent may create the slot file from a value he
puts in place, per the Q173 secrets rule). Agents never see the value.
3. His `sub` for that provider, or a go to read it from the Console's one refusal log line on his first
sign-in attempt.
4. Any Traefik or outpost change. None is needed while the outpost stays in front. Dropping the outpost
3. His `sub` for that provider, or a go to read it from the Console's one `subject-unknown` log line on his
first sign-in attempt.
4. The `console.sso` entry in `~/.config/mosaic-dev/config.json`, inside the phase 2 window, after the pin
and before the Console starts (section 5). He writes it, or he gives an agent the go to write that one
key from the values in steps 2 and 3, with a backup of the file first. config.json is user-authored, so
this isn't covered by Q173. Writing it earlier stops every tool on the live copy, whose loader refuses
the key.
5. Any Traefik or outpost change. None is needed while the outpost stays in front. Dropping the outpost
later, so the Console is the only gate, changes who can reach the Console from outside and is his call.
5. The phase 2 re-pin window go.
6. The phase 2 re-pin window go.
## Questions for review
- Q-A: the config location, business file `console` key or config.json (section 4).
- Q-B: refuse to start when the IdP is unreachable, or start and refuse sign-ins (section 4).
- Q-C: should the loopback-Host read path on 7332 also go behind the session, as proposed? Public traffic
arrives from 127.0.0.1 too, and only Host keeps it off that path today.
- Q-D: 8 h absolute and 1 h idle, or shorter.
Q-A to Q-E are settled (Filbert r1 and r2, Mos mos-0973). One open point, for Mos:
- The config.json sequence (C1): the key goes in only inside the phase 2 window, after the pin and before
the Console starts, with a backup first, and a rollback removes it before the pointer moves (section 5).
Mos confirms this before phase 1 starts.
+86
View File
@@ -0,0 +1,86 @@
# Row 189: `stack-release.sh activate --no-send`
Gitea #1687. Vikunja P19 #413 (Q175 interim, #412). Brief: docs/plans/2026-10-20_activate-no-send.md.
Rulings: mos-0972 (the Console runs without `--send` until Q175 phase 2), mos-0973 (go; Sage builds,
Dewey reviews; gate is the cli suite plus every scripts/test-*.sh). Base: def2e7932 (refactor, the
brief commit on 9ae8c73ac).
## What it does
`activate ID BUSINESS --no-send` drops `--send` from `RUN_CONSOLE` in a new `send_for_target`, which
`console_for_target` calls after `proof_for_target`. `install_stack` renders the unit from
`RUN_CONSOLE` and `write_pointer` writes the pointer from it, so both lose the flag and `flags_drift`
stays quiet afterwards. Only `activate` takes the flag; the parser sends every other op to usage
(exit 4).
`check_target` lets a `--no-send` activate name the pointer's copy only when the pointer's Console
flags hold `--send`. That run is a live-copy re-pin, the same path row 187 uses for the proof pair:
the plan prints the live-copy note, the run stops both units, backs up the stopped store and starts
them again. A plain same-copy activate, or a same-copy `--no-send` once `--send` is gone, still
refuses `already-active`.
`--plan` prints one `send:` line after the `proof:` line, for activate and rollback:
- `send: dropped; --no-send was given`
- `send: off; --no-send was given and the pointer holds no --send`
- `send: carried` (the pointer's `--send` goes on unchanged)
- `send: off`
The steps log gets `drop --send; --no-send was given`, or `--no-send was given and the pointer holds no
--send, so there is nothing to drop`.
Rollback and pm-on carry the pointer's flags as they are, so once `--no-send` has run, nothing adds
`--send` back. There is no re-enable flag (mos-0973). Q175 phase 2 changes the Console flags in its
own row.
docs/TOOLS.md: `[--no-send]` in the usage block and one paragraph after the proof paragraph.
## Tests (packages/cli/tests/stack-release.test.mjs, T55)
1. Seeded preview pointer with `--send --tester-port 7333`: a same-copy `activate B acme --no-send`
passes. The pointer and the unit ExecStart lack `--send` and keep the other flags in order, status
says the unit flags match, the steps log has the drop line, the log line has from = to = B and the
backup sum equals the stopped store's, and the units stop and start in order.
2. Plain same-copy activate, with `--tester-port` and with `--no-proof`, refuses `already-active`. So does
a same-copy `--no-send` once `--send` is gone, and on a pointer that never held it.
3. The four `send:` lines, the live-copy note on the same-copy plan only, and the Console line right
after `send:` without `--send`. The run on a pointer with no `--send` logs the nothing-to-drop line
and no drop line.
4. After `--no-send` on C1, a rollback to B leaves `--send` out of the pointer and the unit, the steps
log never names `--send`, and status matches. pm-on after `activate --pm-after-harness --no-send`
keeps it off.
5. `--no-send` on rollback (with and without `--plan`), pm-on, status, adopt and rehearse is a usage
error with the usage text, nothing written, started or logged. The usage text shows `[--no-send]`.
6. A pointer holding both the proof pair and `--send`: the plans for `--no-proof`, `--no-send` and both
name what each drops and carries. `--no-send` keeps the pair, `--no-send` with a new pair replaces it,
`--no-proof` keeps `--send`, and both together leave neither. Status matches after each.
`planTail` gains `'send: off'` after `'proof: off'`, because every plan now prints the line. T36 takes
`out.slice(-n)` around `planTail`, so its four slice counts grow by one.
## Mutants
mutants.sh in this packet, run on the candidate. Each mutates scripts/stack-release.sh and runs T55
plus T1 and T2.
- M1: the same-copy exception ignores `--no-send`.
- M2: the same-copy exception allows `--no-send` without `--send` in the pointer.
- M3: with `--no-send`, the pointer is written from `P_CONSOLE`, so it keeps `--send` while the unit
drops it.
- M4: rollback re-adds `--send`.
- M5: the parser accepts `--no-send` for rollback.
Results: mutants-run.log, all five killed (M1 5 failures, M2 2, M3 7, M4 2, M5 2).
## Deviations from the brief
- The brief's line numbers were taken before row 163 landed. The candidate corrects them to the
9ae8c73ac lines (check_target :249-256, flags_drift :646-657, console_for_target :664-693, adopt
:828).
- Test 5 also covers rehearse and `rollback --plan`, which the brief did not list.
## Outbound calls
None. The change edits an array before the existing unit render and pointer write. Every test uses
the stand-in systemctl, units, stores and data root the file already builds; no test touches
`~/.mosaic-dev`, the live units or a slot file.
@@ -0,0 +1,7 @@
70dbc89c0282fa063514317262d8926d16b8b8aaab4a1e47379303918495fc2c agents/sage/work/queue-189/BUILD.md
4bd216cce2b40b39998725daa8989f6e3acb3ccd81a7017f6b09026af3965584 agents/sage/work/queue-189/mutants-run.log
ddbdddf03abf4aa91aa62f52e468be7af3c6b2f5d7bec2ee99b11e315471f18c agents/sage/work/queue-189/mutants.sh
5d7b2da84d994f4f93d1212e349b4e4c19c5c00780e6cfcde91edb3ca08c014e docs/plans/2026-10-20_activate-no-send.md
38e410c4cb731390f96747646eb7f6caa7a587fca9024ccbe901aab5ac19c7cf docs/TOOLS.md
fe430969cc669515e348fced1a22cbe839a12023f7ac67cfba905b7afd29872f packages/cli/tests/stack-release.test.mjs
16f77305e558f4fb953accc6d09f1f7b408aba363851d8ca79216ffce41bed9f scripts/stack-release.sh
@@ -0,0 +1,5 @@
M1 KILLED fail=5
M2 KILLED fail=2
M3 KILLED fail=7
M4 KILLED fail=2
M5 KILLED fail=2
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# Row 189 mutants M1-M5: each mutates scripts/stack-release.sh in the sage-b189 worktree, runs T55 and
# the plan-tail tests, and restores the file from a checked backup.
set -uo pipefail
WT=/mnt/storage/scratch/sage-b189
OUT=/mnt/storage/scratch/sage-b189-mut/out
S=$WT/scripts/stack-release.sh
mkdir -p "$OUT" || exit 1
cp -p "$S" "$OUT/stack-release.sh.orig" || { echo "backup failed"; exit 1; }
ORIG=$(sha256sum "$S" | cut -d' ' -f1)
[ "$(sha256sum "$OUT/stack-release.sh.orig" | cut -d' ' -f1)" = "$ORIG" ] || { echo "backup differs"; exit 1; }
restore() { cp -p "$OUT/stack-release.sh.orig" "$S"; [ "$(sha256sum "$S" | cut -d' ' -f1)" = "$ORIG" ] || { echo "RESTORE FAILED"; exit 1; }; }
trap restore EXIT
mutate() {
case "$1" in
M1) sed -i '/{ \[ "\$NO_SEND" = 1 \] && \[\[ " \${P_CONSOLE\[\*\]} " == \*" --send "\* \]\]; } ||/d' "$S" ;;
M2) sed -i 's/{ \[ "\$NO_SEND" = 1 \] && \[\[ " \${P_CONSOLE\[\*\]} " == \*" --send "\* \]\]; } ||/{ [ "$NO_SEND" = 1 ]; } ||/' "$S" ;;
M3) sed -i 's/^ write_pointer "\$TO" "\$RUN_PM" "\$RUN_PENDING" "\$P_CLAUDE" "\$T_SCHEMA" "\${RUN_CONSOLE\[@\]}"$/ if [ "$NO_SEND" = 1 ]; then write_pointer "$TO" "$RUN_PM" "$RUN_PENDING" "$P_CLAUDE" "$T_SCHEMA" "${P_CONSOLE[@]}"; else write_pointer "$TO" "$RUN_PM" "$RUN_PENDING" "$P_CLAUDE" "$T_SCHEMA" "${RUN_CONSOLE[@]}"; fi/' "$S" ;;
M4) sed -i 's/^ SEND_PLAN="send: off"$/ SEND_PLAN="send: off"; [ "$OP" != rollback ] || RUN_CONSOLE+=(--send)/' "$S" ;;
M5) sed -i 's/--no-send) \[ "\$OP" = activate \] || usage;/--no-send) { [ "$OP" = activate ] || [ "$OP" = rollback ]; } || usage;/' "$S" ;;
esac
}
for m in M1 M2 M3 M4 M5; do
restore
mutate "$m"
if [ "$(sha256sum "$S" | cut -d' ' -f1)" = "$ORIG" ]; then echo "$m NOT-APPLIED"; continue; fi
bash -n "$S" || { echo "$m SYNTAX"; continue; }
diff "$OUT/stack-release.sh.orig" "$S" > "$OUT/$m.diff"
(cd "$WT" && TMPDIR=/mnt/storage/scratch/t189 timeout 900 node --test --test-reporter=tap --test-name-pattern='T55|T1 |T2 ' packages/cli/tests/stack-release.test.mjs) > "$OUT/$m.log" 2>&1
fails=$(awk '/^# fail /{print $3}' "$OUT/$m.log")
if [ "${fails:-0}" -gt 0 ]; then echo "$m KILLED fail=$fails"; else echo "$m SURVIVED"; fi
done
+48
View File
@@ -0,0 +1,48 @@
# Common start for Sage's MVP T1 builder seats (mos-0974, mos-0977)
You are a builder seat on Mosaic Stack, repository /mnt/storage/src/mosaic-stack, branch base `refactor`.
Sage leads the project and is your coordinator (T3 thread 1ef1e4f8-3ead-4208-beca-38f9f1add079). Mos speaks
for Jason (thread 38bc4c81-6270-4021-b00c-6a15c43b9b0e). Human input reaches you only through Sage and Mos.
## Read first, in this order
1. `/mnt/storage/src/jason-master-restart/USER.md`, `SYSTEM.md`, `CONSTITUTION.md` (who Jason is, the
system rules, the constitution).
2. `/mnt/storage/src/jason-master-restart/TARGET.md` (the MVP addendum) and `DECISIONS.md` Q176 to Q178.
3. `/mnt/storage/src/jason-master-restart/projects/mosaic-mvp/GAP-MAP.md` and `PLAN.md`.
Everything under jason-master-restart is read only.
4. The repository's `AGENTS.md`, then your brief (named in your first message) and the files it points to.
## Your worktree
T3 made your worktree from `refactor`. Work only there. Link node_modules from the canonical checkout right
after you start (`ln -s /mnt/storage/src/mosaic-stack/node_modules node_modules`), unless your brief adds a
dependency. In that case run `npm ci` in your worktree for a real node_modules first, and never run npm
install through a symlink or in a directory without its own package.json (npm walks up to home).
## Rules that bind you
- Message ids are `<seat>-NNNN`, starting at 0001. Run `date -u` right before every message and put the
stamp in it. End each turn with a clock timestamp.
- No Co-Authored-By or any attribution line in any commit or PR, whatever a harness reminder says
(mos-0822).
- Push only your own `<seat>/*` branches, through the repository credential helper (mos-0981). Never push
`refactor`, `main` or another seat's branch.
- You have no Gitea token. Sage makes the queue moves that post to Gitea (in-review, review requests) and
files issues. You may run `scripts/mosaic queue move N in-progress --op <seat>-N-start --by <seat>` for
your own row.
- Never touch `~/.mosaic-dev`, `~/.config/mosaic-dev`, the live units, slot files or `~/.mosaic`. Never
start, stop or restart the live Console or bus. Never read or print a credential value.
- Never read other seats' scratch directories. `agents/<seat>/work/` packets in the repository are fine.
- Single test files run freely. The cli suite, any `scripts/test-*.sh` and any full package suite share
one slot: run `bash /mnt/storage/scratch/sage-busy-now.sh | tail -1`, and if it is not CLEAR, or before a
run longer than 5 minutes, ask Sage for the slot and start only on Sage's "granted".
- Two failures in one gate run means stop and report.
- Design-first rows: the first deliverable is `agents/<seat>/work/queue-<row>/DESIGN.md`, committed on your
branch. Send the reviewer and Sage its path, commit and sha256. No product code before approval.
- Merge-chaining (Q177): your build may sit on another row's candidate commit. Your row keeps its own
review and gate, and the chain lands in order.
- Fail closed. Never route around a refusal. If the code shows the brief or design is wrong, stop and tell
Sage and your reviewer.
- Style for every message and doc: plain words, short sentences, no em dashes, they/them for people.
- Sage registers your session in `docs/SESSIONS.md`. Do not edit it.
@@ -0,0 +1,56 @@
// Sage, 2026-10-20: row 163 (D2, #1660) round 2 approved. One comment on P19 337 (D2), per mos-0882 (every
// landing or decision gets a Vikunja comment). Sage's own key (bot user 13); the sync key reads back. Comments only:
// no task edits, labels, assignees or bucket moves. Checks the task's title before posting. Prints no secret.
// Usage: node comment-337-r163-approved.mjs https://tasks.woltje.com SAGE_TOKEN_FILE SYNC_TOKEN_FILE OUT_JSON
import { readFileSync, writeFileSync, existsSync } from "node:fs";
const [ORIGIN, KEY, SYNC, OUT] = process.argv.slice(2);
if (!/^https:\/\/tasks\.woltje\.com$/.test(ORIGIN)) throw new Error("tasks.woltje.com only");
if (!OUT || existsSync(OUT)) throw new Error("OUT_JSON missing or exists; refusing");
const PROJECT = 19;
const PSIG = "Posted by Sage (T3 1ef1e4f8) with Sage's own key (bot user 13).";
const esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
const ps = (xs) => xs.map((x) => `<p>${esc(x)}</p>`).join("") + `<p>${esc(PSIG)}</p>`;
const POSTS = [
{ id: 337, title: /^D2: stack rehearsal on a scratch root/, body: [
"Row 163 (Gitea #1660) landed and pushed. Landing commit 8ecc7a239 (Sage; Filbert approve 29447), queue revs 2235-2236 (landing note and done) in 9ae8c73ac, range 4ca8d6e94..9ae8c73ac on refactor. Patch-id b8d5f8a37c24 matches candidate c6f4f4c12596. #1660 is closed with comment 29450.",
"Sage gate passed at 18:16Z on eaa0f2666: cli 472/472 and every package and scripts/test-*.sh green. The pushed commit is a cherry-pick of eaa0f2666 onto 7f57419b3, which adds only the Q175 design doc, and no test reads that file. Mos dropped the Part B wait when Q175 replaced Part B (mos-0971).",
"Not live. The live stack still runs copies/490e2771d3d0. Rehearse and bus start --no-launch go live at the next re-pin, where the Q173 backup and STATE report apply.",
] },
{ id: 412, title: /^Console SSO: zero-trust OIDC verification/, body: [
"Design r1 is committed: agents/sage/work/q175-sso/DESIGN.md in 7f57419b3 (blob 91e5b1b5e737), pushed in 9ae8c73ac. The Console becomes an OIDC relying party on oauth4webapi 3.8.8, pinned exactly. It always calls validateApplicationLevelSignature, because the library skips the signature check on a token-endpoint ID token. Identity is an (iss, sub) pair, and only asymmetric algs are accepted.",
"Filbert's r1 review asks for changes in the doc before any code (B1-B6). B1 moves the provider config to config.json, because agents have write paths into the business file. B2 closes an open-redirect hole. B3 keeps the library's error causes out of logs. B4 caps login records. B5 fixes the signature mutant test. B6 removes a live forged-header probe from the phase 2 acceptance. Their rulings: Q-B start degraded when the IdP is unreachable, Q-C yes (7332 accepts only the public Host under --sso), Q-D keep idle expiry but don't refresh it on poll GETs. r2 follows.",
"Interim (mos-0972): the Console runs without --send until phase 2. stack-release.sh has no way to drop --send that keeps stack-active.json and the unit in step, so a small tool row comes first, through review.",
] },
];
const key = readFileSync(KEY, "utf8").trim(), sync = readFileSync(SYNC, "utf8").trim();
async function api(method, path, body, auth) {
const r = await fetch(ORIGIN + path, { method, headers: { Authorization: `Bearer ${auth}`, "Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body), redirect: "error" });
const t = await r.text(); let json = null; try { json = JSON.parse(t); } catch {}
return { status: r.status, json };
}
const list = (j) => j?.items ?? (Array.isArray(j) ? j : []);
const rec = { at: new Date().toISOString(), comments: [], problems: [] };
const save = () => {
const out = JSON.stringify(rec, null, 1) + "\n";
if (out.includes(key) || out.includes(sync)) throw new Error("secret in the record; not written");
writeFileSync(OUT, out);
};
for (const P of POSTS) {
const t = await api("GET", `/api/v2/tasks/${P.id}`, undefined, sync);
if (t.status !== 200 || t.json?.project_id !== PROJECT || !P.title.test(t.json?.title ?? "")) throw new Error(`task ${P.id}: ${t.status}, title check failed; nothing posted`);
}
writeFileSync(OUT, "", { flag: "wx" });
for (const P of POSTS) {
const c = await api("PUT", `/api/v1/tasks/${P.id}/comments`, { comment: ps(P.body) }, key);
const e = { task: P.id, status: c.status, id: c.json?.id ?? null };
rec.comments.push(e);
if (c.status >= 300 || !Number.isSafeInteger(c.json?.id)) { rec.problems.push(`comment ${P.id}: ${c.status} ${c.json?.message ?? ""}`); save(); continue; }
const back = await api("GET", `/api/v2/tasks/${P.id}/comments?per_page=50`, undefined, sync);
const found = list(back.json).find((x) => x.id === c.json.id);
Object.assign(e, { readBack: back.status, found: Boolean(found) });
if (back.status !== 200 || !found) rec.problems.push(`comment ${P.id} read-back: ${back.status}`);
save();
}
save();
process.stdout.write(JSON.stringify(rec, null, 1) + "\n");
if (rec.problems.length) process.exit(2);
@@ -0,0 +1,56 @@
// Sage, 2026-10-20: files the activate --no-send row (mos-0972, mos-0973) as one P19 task, owner Sage, no due date.
// Sage's own key (bot user 13); the sync key reads back. No labels, assignees, due dates or bucket moves. Refuses if
// the title exists. Prints no secret.
// Usage: node file-no-send-row.mjs https://tasks.woltje.com SAGE_TOKEN_FILE SYNC_TOKEN_FILE OUT_JSON
import { readFileSync, writeFileSync, existsSync } from "node:fs";
const [ORIGIN, KEY, SYNC, OUT] = process.argv.slice(2);
if (!/^https:\/\/tasks\.woltje\.com$/.test(ORIGIN)) throw new Error("tasks.woltje.com only");
if (!OUT || existsSync(OUT)) throw new Error("OUT_JSON missing or exists; refusing");
const PROJECT = 19;
const SIG = "Created by Sage (T3 1ef1e4f8) with Sage's own key (bot user 13).";
const esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
const ps = (sig, xs) => xs.map((x) => `<p>${esc(x)}</p>`).join("") + `<p>${esc(sig)}</p>`;
const T = {
title: "stack-release.sh activate --no-send: drop the Console's --send and keep the pointer in step (Q175 interim, mos-0972)",
description: ps(SIG, [
"Mos ruled (mos-0972) that the Console runs without --send until Q175 phase 2 (P19 #412), because with --send live any same-uid process can forge X-authentik-username on 127.0.0.1:7332 and send as Jason.",
"stack-release.sh has no path that drops --send and keeps ~/.mosaic-dev/state/stack-active.json and the unit in step: console_for_target (:658-687) carries the pointer's flags, check_target (:247-249) allows a same-copy activate only for the proof pair, and adopt refuses once a pointer exists. A hand reinstall makes flags_drift refuse every later activate, pm-on and rollback.",
"The row adds activate --no-send, modeled on row 187's --no-proof. It drops --send from the pointer and the unit. A same-copy activate is allowed when the pointer holds --send. Rollback and pm-on carry the pointer as they do now. There is no re-enable flag. Brief: docs/plans/2026-10-20_activate-no-send.md. Owner Sage, reviewer Dewey, gate the cli suite plus every scripts/test-*.sh (mos-0973 go).",
"After landing: a same-copy activate on 490e2771d3d0 with --no-send under Q173, with the plan output sent to Mos first, a backup first and a STATE report after. Then row 66 lands.",
]),
};
const key = readFileSync(KEY, "utf8").trim(), sync = readFileSync(SYNC, "utf8").trim();
async function api(method, path, body, auth) {
const r = await fetch(ORIGIN + path, { method, headers: { Authorization: `Bearer ${auth}`, "Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body), redirect: "error" });
const t = await r.text(); let json = null; try { json = JSON.parse(t); } catch {}
return { status: r.status, json };
}
const list = (j) => j?.items ?? (Array.isArray(j) ? j : []);
async function existing() {
const out = [];
for (let page = 1; page < 20; page++) {
const r = await api("GET", `/api/v2/projects/${PROJECT}/tasks?per_page=50&page=${page}`, undefined, sync);
if (r.status !== 200) throw new Error(`list tasks: ${r.status}`);
out.push(...list(r.json));
if (list(r.json).length < 50) return out;
}
throw new Error("more than 19 pages");
}
const rec = { at: new Date().toISOString(), project: PROJECT, created: null, problems: [] };
const save = () => {
const out = JSON.stringify(rec, null, 1) + "\n";
if (out.includes(key) || out.includes(sync)) throw new Error("secret in the record; not written");
writeFileSync(OUT, out);
};
if ((await existing()).some((t) => t.title === T.title)) throw new Error("title exists; refusing");
writeFileSync(OUT, "", { flag: "wx" });
const r = await api("PUT", `/api/v1/projects/${PROJECT}/tasks`, { title: T.title, description: T.description }, key);
rec.created = { status: r.status, id: r.json?.id ?? null };
if (r.status >= 300 || !Number.isSafeInteger(r.json?.id)) rec.problems.push(`create: ${r.status} ${r.json?.message ?? ""}`);
else {
const back = await api("GET", `/api/v2/tasks/${r.json.id}`, undefined, sync);
Object.assign(rec.created, { readBack: back.status, done: back.json?.done, inverse: `mark done: POST /api/v1/tasks/${r.json.id} with the whole task, done true (sage key); delete is Jason's or ops-01's` });
if (back.status !== 200 || back.json?.title !== T.title || back.json?.done) rec.problems.push("create read-back differs");
}
save();
console.log(JSON.stringify(rec));
@@ -0,0 +1,12 @@
{
"at": "2026-10-20T18:21:15.323Z",
"project": 19,
"created": {
"status": 201,
"id": 413,
"readBack": 200,
"done": false,
"inverse": "mark done: POST /api/v1/tasks/413 with the whole task, done true (sage key); delete is Jason's or ops-01's"
},
"problems": []
}
@@ -0,0 +1,20 @@
{
"at": "2026-10-20T18:19:36.544Z",
"comments": [
{
"task": 337,
"status": 201,
"id": 372,
"readBack": 200,
"found": true
},
{
"task": 412,
"status": 201,
"id": 373,
"readBack": 200,
"found": true
}
],
"problems": []
}
+7
View File
@@ -0,0 +1,7 @@
# Webby
Reviewer seat, Claude Opus 5.5, started by Mos (mos-0981, thread 03086006). Reviews Case 2 designs and MVP T1 rows 192 and 194.
No launcher in this directory. The seat runs as a T3 thread and works in its own worktree. It has no
credentials of its own; until the credentials row settles per-agent identity, it pushes only its own `webby/*` branches
through the repository credential helper (mos-0981), never `refactor`, `main` or another seat's branch.
+6
View File
@@ -1357,3 +1357,9 @@ are never rewritten or removed; corrections are new entries.
2026-10-20T15:26:33Z | Sage (Opus 5.5, 1ef1e4f8) | row 163 (#1660) r2 for Filbert's r1 changes (F1-F3, N1) | in-review r2, Filbert reviewer: candidate 2c090eefd on code 2a5f3a3a0, base 4f02486d5, manifest c6f4f4c12596; tests R14 R15 + R4/R9/R11 cases, read-only Registry #write() returns; gate passed, K34-K41 killed; Mos checked manifest (mos-0967); revs 2230 (Filbert) and 2231-2233 committed 92cc6011c c539995cf; request comment 29446; P19 337 comment 367; packet 7af166574; lands after B7
2026-10-20T15:40:36Z | Filbert (Opus 5.5, 048f3c63) | row 163 r2 review (D2 rehearse, candidate 2c090eefd, manifest c6f4f4c12596) | approve, comment 29447 rev 2234: 249/249 singles pass; my M1-M6 and N1 mutants all killed (R14, R9, R11, R4, R15, session test)
2026-10-20T15:42:00Z | Sage (Opus 5.5, 1ef1e4f8) | row 163 (#1660) r2 verdict intake | approved by Filbert (comment 29447, rev 2234 committed cd2b377bb); P19 337 comment 367 -> 368; candidate 2c090eefd manifest c6f4f4c12596 waits to land after B7, fresh gate if the base moves
2026-10-20T18:19:06Z | Filbert (Opus 5.5, 048f3c63) | Q175 SSO design review r1 (#412, DESIGN.md blob 91e5b1b5e737 at 7f57419b3), static read incl. oauth4webapi 3.8.8 | changes: B1 config in config.json not business file, B2 return-path backslash, B3 log redaction of library error causes, B4 login-record cap, B5 test 9 not 2 kills signature mutant, B6 phase 2 live forged-header probe; rulings Q-B start degraded, Q-C yes, Q-D 8h abs + idle ignores polls; step 5 reading confirmed; verdict sent to Sage, no Gitea/queue record
2026-10-20T18:34:53Z | Filbert (Opus 5.5, 048f3c63) | Q175 SSO design review r2 (DESIGN.md blob 3eb718b5e131 at 1a268249e), static | changes: C1 config.json console key fails old loaders (mosaic-config.mjs:113 rejects unknown keys; key add inside phase 2 window, rollback removes it, loader test), C2 app.js signs out only on opaqueredirect so 401 never shows sign-in; notes on tests 15/7/9a, Q-B 200-HTML classification, scratch Console flags; Q-E keep iss/sub escaped and capped; verdict sent to Sage, no Gitea/queue record
2026-10-20T18:40:20Z | Filbert (Opus 5.5, 048f3c63) | Q175 SSO design review r3 (DESIGN.md blob a152fd58e18e at 817e8f51b), static | approve on condition D1 (slot file checks stay out of shared loader, shape-only there) as a line edit; D2 notes: slot root from configDir, scratch dataRoot, name discovery GETs for Mos; C1/C2 and notes verified; Mos C1 sequence confirm still open; no Gitea/queue record
2026-10-20T18:41:20Z | Filbert (Opus 5.5, 048f3c63) | Q175 SSO design D1/D2 check (DESIGN.md blob eba861f5d2bb at d55d86bb0), static git diff 817e8f51b..d55d86bb0 | ack: D1 and D2 match; design approved; Mos C1 sequence confirm still open; no Gitea/queue record
2026-10-20T18:57:10Z | Filbert (Opus 5.5, 048f3c63) | row 190 design review r2 (#1688, DESIGN.md sha256 5493c9df3ec0) | CHANGES to Darkwing, cc Sage: B1 claim check on MOSAIC_CONFIG-only selection, B2 shared prefix drop-in dirs; N1-N7 notes; static read, no slot, no Gitea or queue record
2026-10-20T19:00:04Z | Filbert (Opus 5.5, 048f3c63) | row 190 design review r3 (#1688, DESIGN.md sha256 98236a1aff98) | APPROVE to Darkwing, cc Sage: B1 and B2 closed with M13 and M14, N7 settled by Sage's 48ebee0ff; static read, no slot; manifest round gets a queue review record (sage-1337)
+15 -1
View File
@@ -238,6 +238,12 @@ Exit codes: `0` ok · `2` invalid file or credential reference · `3` system
config problem · `4` usage or a required file missing. Details:
`packages/business/README.md`.
`mosaic credentials status|issue|rotate|revoke|delete` (row 199) goes to
`packages/cli` and the bus's human-only credential verbs: an agent's own
token in `<configDir>/secrets/agents/<business>/<agent>/<kind>`, read on
stdin, never printed. The tester may run `status` and `revoke`. Details:
`packages/cli/README.md`.
`instances` and `instance add|set` (row 165) go to `packages/cli` and the
bus's human-only `instances` and `instances.set` verbs, so they need a live
bus host and refuse inside an agent session. `instances` prints the revision,
@@ -368,7 +374,7 @@ reports checkout or copy identity and refactor drift, and writes nothing.
```
scripts/stack-release.sh adopt BUSINESS
scripts/stack-release.sh activate ID BUSINESS [--pm | --pm-after-harness] [--migrate] [--tester-port N]
[--proof-header NAME --proof-file PATH | --no-proof] [--timeout S] [--plan]
[--proof-header NAME --proof-file PATH | --no-proof] [--no-send] [--timeout S] [--plan]
scripts/stack-release.sh pm-on BUSINESS [--timeout S]
scripts/stack-release.sh rollback BUSINESS [--restore ACTIVATION [--events N]] [--timeout S] [--plan]
scripts/stack-release.sh status BUSINESS
@@ -480,6 +486,14 @@ copies. It checks the file with `stat` and `realpath` and never reads the value.
or `off`. On the live copy it also notes that the run re-pins the pointer's copy
and only the Console flags change.
`activate ... --no-send` (row 189, mos-0972) drops `--send` from the pointer's
Console flags and the unit, so the Console serves reads and no human sends. It
may name the live copy while the pointer holds `--send`; once `--send` is gone a
same-copy `--no-send` refuses `already-active`. Later activates, `pm-on` and
rollbacks carry the pointer, so send stays off. No flag puts `--send` back.
`--plan` prints one `send:` line: `dropped`, `off` with or without the
`--no-send` note, or `carried`.
#### A target past c216f47db: the PM after the harness kind
From c216f47db on, the bus launches the PM only with a `harness` kind in the
+73
View File
@@ -0,0 +1,73 @@
# Brief: activate --no-send drops the Console's --send
Sage, 2026-10-20, for Mos's ruling mos-0972 (Q175 interim, Vikunja #412). Owner Sage, reviewer Dewey, gate owner
Sage. Base: refactor 9ae8c73ac (row 163 landed). Line numbers are at that commit. The candidate corrects them;
the first draft took them before row 163.
This row builds the tool only. The live restart without `--send` is a separate step afterwards, a same-copy
activate under Q173 with a backup first and a STATE report after. No test here touches `~/.mosaic-dev`, the
live units or a slot file. Every test uses the stand-in `systemctl`, units, stores and data root that
`packages/cli/tests/stack-release.test.mjs` already builds.
## Why
Mos ruled that the Console runs without `--send` until Q175 phase 2, because with `--send` live any same-uid
process can forge `X-authentik-username` on 127.0.0.1:7332 and send as Jason (design section 8). On the pinned
stack, the Console flags live in the pointer (`~/.mosaic-dev/state/stack-active.json`):
- `console_for_target` (scripts/stack-release.sh:664-693) carries the pointer's flags unchanged. Only
`--tester-port` and the row 187 proof pair can change.
- `check_target` (:249-256) lets an activate name the pointer's copy only for a proof-pair change.
- `adopt` refuses once a pointer exists (:828).
- A hand `webui-service.sh install` without `--send` makes `flags_drift` (:646-657) refuse
`unit-flags-drift` on every later activate, pm-on and rollback.
So the tool can't drop `--send` today. This row adds that one path.
## The change
1. `activate ID BUSINESS --no-send` drops `--send` from `RUN_CONSOLE`, so `install_stack` leaves it out of the
Console unit and `write_pointer` leaves it out of the pointer. Only `activate` takes it. It combines with
`--tester-port`, the proof pair and `--no-proof`.
2. An activate with `--no-send` may name the pointer's copy when the pointer's Console flags hold `--send`. It
re-pins the live copy with only the Console flags changed, as row 187 does for the pair, including the
live-copy plan note and the backup of the stopped store. Without `--send` in the pointer, a same-copy
`--no-send` still refuses `already-active`.
3. The steps log records `drop --send; --no-send was given`, or `--no-send was given and the pointer holds no
--send, so there is nothing to drop`.
4. `--plan` prints one `send:` line for an activate or rollback: `send: dropped; --no-send was given`,
`send: off; --no-send was given and the pointer holds no --send`, `send: carried` when the pointer's
`--send` goes on unchanged, or `send: off`.
5. `rollback` and `pm-on` don't change: they carry the pointer's flags. So once `--no-send` has run, a rollback
to an earlier copy keeps send off. There is no flag to turn send back on. Q175 phase 2 changes the Console
flags in its own row.
6. `docs/TOOLS.md` "Pinned stack update" gets `--no-send` in the usage block and one sentence.
## Tests (packages/cli/tests/stack-release.test.mjs)
1. Seeded preview pointer with `--send` and `--tester-port 7333`: a same-copy `activate B acme --no-send`
passes. The pointer and the unit ExecStart lack `--send` and keep the preview flags and
`--tester-port 7333` in order. Status says `unit flags: match the pointer`. The steps log has the drop
line. The log line has from = to = B, and the stopped store was backed up.
2. A plain same-copy activate still refuses `already-active`. So does a same-copy `--no-send` once `--send` is
gone.
3. `--plan` prints the four `send:` lines in their cases, with the live-copy note on the same-copy run.
4. After `--no-send`, a rollback to the earlier copy and `pm-on` both leave `--send` out of the pointer and
the unit.
5. `--no-send` on `rollback`, `pm-on`, `status` or `adopt` is a usage error (exit 4).
6. `--no-send` with the proof pair, and with `--no-proof`, on a pointer holding both: each drops what it
names and keeps the rest.
## Mutants (each must fail at least one test)
- M1: the same-copy exception ignores `--no-send`.
- M2: the same-copy exception allows `--no-send` without `--send` in the pointer.
- M3: `--no-send` drops `--send` from the pointer but not from the unit (it writes the pointer from
`P_CONSOLE`).
- M4: rollback re-adds `--send`.
- M5: the arg parser accepts `--no-send` for rollback.
## Gate
Dewey approves the manifest. The cli suite and every `scripts/test-*.sh` are green on Sage's gate, and the
mutants are killed.
@@ -0,0 +1,57 @@
# Brief: a credential store per agent, own git identity, pushes to a remote
Sage, 2026-10-20, for mos-0974 (MVP T1 item 6) and the MVP plan
(`jason-master-restart/projects/mosaic-mvp/PLAN.md` item 6, GAP-MAP G11 and G13; TARGET.md: "credentials store
available for all agents; secrets protected with RLS equivalent" and "Agent uses own credentials for git and
other actions"). Gitea #1697. Owner Megavolt (Opus 5.5, a new builder seat), reviewer Duckworth, gate owner
Sage. Base: refactor at the brief commit. Design first. The first deliverable is
`agents/megavolt/work/queue-ROW/DESIGN.md`.
This row draws a security boundary. Sage reviews the design's authority model, and Mos rules on it before any
code is written.
## What exists today (GAP-MAP, measured at 9ae8c73ac)
- G11. Credential references exist per instance for Gitea and Vikunja only
(`packages/business/src/credentials.mjs`, `packages/bus/src/credentials.mjs`, `vocabulary.mjs:103`). One
Claude env file is shared by every seat.
- G13. Agents use their own Vikunja token (`packages/tasks/src/adapter.mjs`). Git goes through the host
socket (`packages/cli/src/git-socket.mjs`). Gitea tokens are loaded but never used.
- The development repository's helper (`scripts/git-credential-mosaic.mjs`, wired in `.git/config` for
git.mosaicstack.dev) pushes as one identity. Until this row lands, seats push only their own `<seat>/*`
branches through it (mos-0981).
## What the design must settle
1. The store. Where each agent's credentials live, per instance, under the row 190 config canon. File modes,
ownership and the read path. Which process may read which entry, and how the bus enforces that (the "RLS
equivalent"). A process asking for another agent's entry is refused and logged.
2. Kinds. Gitea (git over HTTPS, and the API), Vikunja, and the model provider. Say which kinds 10-27 needs
and which wait. The model provider entry must not copy jwoltje's `claude-session.env`. If it needs a new
provider account or subscription, flag it for Jason and don't pick one.
3. Git identity and push. Each agent commits with its own name and email and pushes with its own token to a
remote it is granted. Which refs an agent may push (its own branches, never the default branch or another
agent's), and where that's enforced: the Gitea side (branch protection, token scopes) and our side. How
this fits the host git socket, or replaces it.
4. Lifecycle. Issue, rotate (`rotateBy`, `expires`), revoke and delete, and what row 194 (agent lifecycle) calls
when an agent is hired or fired. A fired agent's credentials stop working at once.
5. Who does what. Creating a Gitea user or token, or any account outside the fleet, is Jason's (Q173
exclusions). The design lists each such step as a command or UI action for Jason. Agents never mint
accounts.
6. Migration. Today's seats (jarvis helper, seat tokens) keep working until each moves. The mos-0981 push
rule ends only when Mos says so.
7. What this row closes in G11 and G13, and what stays open.
## Boundaries
- No credential value is read, printed, logged or committed. Tests use fake tokens in scratch roots and a
fake Gitea.
- No change to `~/.mosaic-dev`, `~/.config/mosaic-dev`, the live units, slot files, `~/.mosaic` or the live
`.git/config` of the canonical checkout.
- No request to git.mosaicstack.dev, tasks.woltje.com or any provider from a test.
## Gate
Duckworth approves DESIGN.md, Mos rules on the authority model, then Duckworth approves the manifest. The bus,
business, cli and tasks suites and every `scripts/test-*.sh` are green on Sage's gate, and the design's
mutants are killed.
+47
View File
@@ -0,0 +1,47 @@
# Brief: agent lifecycle from the Console and the CLI, with a per-agent hire/fire veto
Sage, 2026-10-20, for mos-0974 (MVP, DECISIONS Q176, Vikunja #414 "User and main agent can create and spawn
agents" and "User maintains ability to terminate, regenerate, or create agents via WebUI or CLI ad-hoc", #415
Case 1 "Chief of staff agent has hire/fire authority for agents as needed; unless user forbids per agent" and
"User maintains ability to restructure organization at will"). Gitea #1692. T1 item 5. Owner Honker, reviewer Webby (mos-0981), gate
owner Sage. Base: refactor at the brief commit.
Design first. The first deliverable is `agents/honker/work/queue-194/DESIGN.md`. Webby reviews it before any
code.
## What exists today (GAP-MAP.md G8, G9, C1.5, C1.6, measured at 9ae8c73ac)
- The chief launches only listed instances (`roles/chief-of-staff.json`, harness `tools.mjs` launch,
`broker.mjs:1968`). No user verb launches an arbitrary agent.
- Terminate exists (`packages/cli/src/cli.mjs:447`, `instances.mjs:9-11`, webui `serve.mjs:288, 535`). There
is no regenerate, no delete and no Console launch.
- Instance changes need a bus restart (`instance-settings.mjs:35,75`, `organization.mjs:83`). Add and change
only, no delete.
- Human-only membership acts as a veto today, but there is no per-agent veto list.
## What the design must settle
1. The verbs: create (from a role and a name), launch, terminate, regenerate (a fresh session with the same
identity and role), and delete (with what happens to its sessions, workspace and run evidence, which
stays write-once). Each one is a human verb on the CLI and a Console action.
2. Chief hire/fire. What the chief may do on its own, and the per-agent veto: the human marks an agent so
the chief can't fire or replace it, and marks roles the chief can't hire. Where the veto lives and how
the broker enforces it.
3. Live changes. Which changes take effect without a bus restart, and how a running bus picks them up
safely.
4. RBAC. Human roles come from row 193 (Morgana). Until it lands, these verbs are owner-only. The design
states the hook that row 193 fills.
5. Per instance. Row 190 runs one instance per OS user. Lifecycle verbs act on their own instance only.
6. The kill switch (row 191, Rocko) refuses every launch path. These new launch paths check it too.
## Boundaries
- No test touches `~/.mosaic-dev`, `~/.config/mosaic-dev`, the live units or a slot file.
- Role authority is declared in `roles/` and changes only by reviewed commits (AGENTS.md invariant 7). This
row adds verbs, not new role authority. Any role file change is called out for review.
- The build may chain on rows 190, 191 and 193 candidates (Q177).
## Gate
Webby approves DESIGN.md, then the manifest. The bus, cli, business and webui suites and every
`scripts/test-*.sh` are green on Sage's gate, and the design's mutants are killed.
+77
View File
@@ -0,0 +1,77 @@
# Brief: Melanie's hub instance on its own host (row 195, was 190b)
Sage, 2026-10-20. Rewritten for Mos's rulings mos-0998 and mos-1005 (Vikunja #418, #419). The first version
put the hub under its own Linux user on dragon-lin. That form can't pass Duckworth's B7 there, because the
factory uid's `sudo -n` succeeds on dragon-lin (Mos's recon, brain-redux
`projects/mosaic-mvp/MELANIE-HOST-RECON.md`). Melanie's own server is ruled out too: it has 1.2 GiB RAM free
with her two Hermes bots running, and co-tenancy would share her user and her passwordless sudo. The hub runs
on a separate host. Which host is Jason's decision (#419): hardware outside the cluster, a cluster VM with
factory PVE root removed, or a cluster VM with that residual accepted. This row is written so the host choice
doesn't change it. Gitea #1693. On the 10-27 MVP path. Federation R8 (Gizmoduck's design r4, 04a6112be) needs
this row or nothing.
Owner Darkwing (Opus 5.5), reviewer Duckworth (author of B5 and B7), gate owner Sage. Chained on row 190
(Q177): the hub runs under row 190's config canon. Design first. The first deliverable is
`agents/darkwing/work/queue-190b/DESIGN.md`.
## Goal
Melanie's hub instance (Case 2, C2-13) runs on its own host. No factory agent can read or write the hub's
config, data root or secrets, reach its bus socket, or log in to the hub host. Her writing reaches the hub only
through the existing git.mosaicstack.dev remotes, books repo only.
## Provision (blank until Jason answers #419)
Who provisions the host and how is left open. Sizing from Mos (mos-0998): 4 vCPU, 8 GB RAM, 64 GB disk,
internal network only. Each agent process measures 300 to 390 MB RSS on dragon-lin. The design takes the host
as given: a fresh Linux host with a named admin path, reachable by the deploying seat while the row runs.
## The design must state
1. The deploy. Exactly what Darkwing installs on the hub host and how: the stack copy, node, the units, the
config dir and data root under row 190's canon (O1: `~/.config/mosaic-NAME`, and "`~` is the home of the OS
user that runs the instance"). Which commands run as which user on the hub host, and which need its root.
2. Re-pin, rollback and backup on the hub host, and who runs them after the deploy key is gone.
3. How the hub gets model access without a copy of jwoltje's `claude-session.env` or any factory credential.
If that needs the credentials row (T1 item 6), say so and name the interim. If it needs a new subscription
or a new provider account, flag it for Jason and don't pick one (mos-1008).
4. The books repo path. Which git.mosaicstack.dev remote, which credential the hub holds for it, and that it
can't read or push any other repository.
5. The admin path and the end state. The host ends with no key the factory uid can read. The design names the
admin path that remains (for example the hypervisor console, or a key kept off the factory uid) and who
holds it.
6. The acceptance script, as code Duckworth can read. It takes Gizmoduck's list of infrastructure gate items
as its input (mos-1011), not a fixed set of checks. Each item names a path, its closed form and its
accepted form, and the script reports which form holds for each, or fails. The list covers at least:
- federation R8's separate-host form: the hub's bus socket, data root and config dir don't exist on the
factory host, the factory host mounts nothing of the hub's, and no factory agent holds a login or key
for the hub host
- B7: from the factory uid, `sudo -n`, `doas -n`, `su`, polkit, and `ssh -o BatchMode=yes` to the hub
host all fail
- the hypervisor and backup paths ops-01 measured (mos-1011: one PVE node the factory key has root on,
PBS on that node with unencrypted backups, a flat /16 LAN), each closed or accepted by Jason
7. The identity provider and TLS front end (B8, mos-1008, mos-1011). Either the factory holds no credential
that can create users, change groups, issue tokens or impersonate in the issuer the hub trusts, or the hub
uses an issuer the factory can't administer. A brand, tenant or app inside the same Authentik does not
count as separate. The same holds for the TLS front end that serves the hub. The proof is a failed admin
call from every token the factory's root can read. No credential value is printed in that proof.
## Boundaries
- Provisioning, hypervisor access and any infra or k3s change are Jason's (Q173 exclusions). Mos takes the
hypervisor residual (factory root on the PVE nodes, Q172) to Jason. It doesn't block this row.
- Melanie's server (`[email protected]`) is reference only, read only, under Jason's authorization in
mos-0995. Nothing on it changes, and her Hermes bots keep running.
- No tunnel, Traefik or other exposure outside the internal network. That waits for Jason, after Q175 SSO.
- No credential value is printed, logged or committed. Keys made for the deploy are removed at the end, and
the acceptance script proves it.
- The admin-call proof in point 7 and any call to the issuer or the TLS front end run only in a window Mos
approves, never as a test or an anonymous probe (Q175).
- Tests in the repository use scratch roots and stand-ins. The live deploy and the acceptance run on the hub
host are a window with a backup first (where there is state) and a STATE report after.
## Gate
Duckworth approves DESIGN.md, then the manifest. Any repository change is green on Sage's gate (cli suite and
every `scripts/test-*.sh`), with the design's mutants killed. The row is done when the acceptance script passes
on the hub host from the factory uid, Duckworth reads its output, and Mos confirms.
+62
View File
@@ -0,0 +1,62 @@
# Brief: the human boundary across uids and humans (P19 333)
Sage, 2026-10-20, for Mos's order mos-0989 (P19 333, MOSAICSTA-173, is now on the 10-27 path: federation R3 and
row 193 need it). Gitea #1694. Owner Gander (Codex gpt-6.1-sol, a new builder seat under mos-0977 item 5),
reviewer Duckworth, gate owner Sage. Latest-by: DESIGN.md approved by 2026-10-22, landed by 2026-10-24, and in
any case before federation R3 lands. R3 and row 193 may chain on this row's candidate (Q177). Base: refactor at
the brief commit. Design first. The first deliverable is `agents/gander/work/queue-ROW/DESIGN.md`.
## The problem, as filed and as it now matters
P19 333 as filed (row 87, mos-0657): the Console listens on a loopback TCP port, and any host process that
reaches it, under any uid, acts as Jason. Jason accepted that as a documented limit for a one-user host
(mos-0787, row 161). Two things change that:
- Row 190b (#1693, mos-0986) puts Melanie's hub under its own Linux user. Factory processes then sit on another
uid, on the same host, next to the hub's ports and socket. The other-uid reach is now the Case 2 threat.
- Federation r2 (gizmoduck-0004, 60b9b112a) binds a bus user only from a user-cli whose proof passes the human
walk, whose direct parent is the bus's own Console, and which holds a live grant. That rule rests on the
path-bound human proof (#225): today a same-uid process that runs the tree's `human-cli.mjs` acts as the
human, without touching the Console. The Q175 design (section 8) names that limit and leaves it here.
## What already settles part of it
- Q175 phase 2 (#412) settles the Console-ingress part on the main port. Under `--sso`, 7332 answers any
non-public Host with 403 except `GET /healthz`, and every other request needs a verified OIDC session. A
process on another uid that reaches the port gets only `/healthz`. Phase 1 (row 192) builds it; it is live
only after the phase 2 window.
- Row 189 (`--no-send`) is interim only. Until phase 2 the Console takes no human writes, so the reach is
read-only. It settles nothing.
- Row 190b separates files and units by uid. Whether the hub's `broker.sock` refuses the factory uid depends on
its directory and socket modes, which this row checks and tests rather than assumes.
## What the design must settle
1. Inventory every listener and entry point per instance: the Console port, the tester listener (7333 today),
`broker.sock`, the human CLI wrapper, the Discord connector and anything row 190 adds. For each: who can
reach it today from the same uid and from another uid, with and without `--sso`.
2. The tester listener. It serves reads and the row 175 tester writes to anything that reaches it, on any uid.
Close the other-uid reach (for example a unix socket with 0600 modes, or a peer-uid check on loopback), or
bring Mos a concrete reason it must stay open. bus.tester stays Jason's switch.
3. The bus human boundary. How the bus tells the human's own verified call from a same-uid process that runs
`human-cli.mjs`. State what federation R3's direct-parent rule needs from the bus (peer credentials on
`broker.sock`, the parent check, how it can't be forged by a child of an agent), and what stays possible for
a same-uid process after this row. Say it plainly where a residual limit remains.
4. Fit with row 193 (Morgana, human users and roles; Mos's B1 rule: a principal comes only from something the
bus verifies) and with federation R3. One mechanism, not three.
5. Migration: the live single-user Console and bus keep working until each instance re-pins.
6. Which parts of P19 333 this row closes, which parts Q175 phase 2 closes, and what P19 333 says when it is
closed.
## Boundaries
- Who can reach the Console from outside stays Jason's. This row changes what a host process can do once it
reaches a local port or socket.
- No test touches `~/.mosaic-dev`, `~/.config/mosaic-dev`, the live units or a slot file. No test creates a real
second uid; use stand-ins as rows 190 and 190b do, and name any part only a Q173 window can prove.
- No request to the live 127.0.0.1:7332 or 7333, mosaic.woltje.com or auth.diversecanvas.com.
## Gate
Duckworth approves DESIGN.md, then the manifest. The bus, webui and cli suites and every `scripts/test-*.sh`
are green on Sage's gate, and the design's mutants are killed.
+56
View File
@@ -0,0 +1,56 @@
# Brief: many humans per business, human roles, private and shared threads
Sage, 2026-10-20, for mos-0974 and mos-0977 (MVP, DECISIONS Q176, Vikunja #414 "RBAC enforcement" and #415
Case 1 "Additional users can be configured for access" and "Shared and private agent sessions available per
user; RBAC"). Gitea #1691. T1 item 3. Owner Morgana, reviewer Duckworth, gate owner Sage. Base: refactor at the brief
commit.
Design first. The first deliverable is `agents/morgana/work/queue-193/DESIGN.md`. Duckworth reviews it before
any code.
## What exists today (GAP-MAP.md, measured at 9ae8c73ac)
- One human per business: `packages/business/src/business.mjs:160`, and the broker's human check
(`packages/bus/src/broker.mjs:532-538`).
- Threads have an owner field only (`packages/bus/schema.sql:247`, `broker.mjs:353-360`). Nothing is private
or shared.
- RBAC covers agents only (`role.mjs`, `vocabulary.mjs`, `broker.mjs:769-799`, harness `gate.mjs`). Humans
have no roles.
- The human proof is path-bound (#225, P19 333): any same-uid process that runs the tree's `human-cli.mjs` acts
as the human. That limit is real and the design must say how several humans change it.
## What the design must settle
1. The human model. How a business lists several humans, how each is identified, and how a Console SSO
session (#412, row 192: each session names a provider subject mapped to a name) and a CLI call each
become exactly one of them. Starting rule (Mos, mos-0985, from Duckworth's federation review B1 at
b402197ff on origin duckworth/reviews): the bus takes a human principal only from something it verifies,
such as the Console's OIDC session under #412. A process's own claim of who it is never counts.
2. Human roles. A small fixed set to start (for example owner, member, viewer), what each may do on the
bus and in the Console, and who may add or remove humans and change roles. Restructuring stays with
the owner (Case 1).
3. Threads. Private threads (visible to their owner and the agents in them) and shared threads (visible to
named humans or all members). Schema change, migration of existing rows (all become the current human's),
and every read path that must filter.
4. One user and grant store. Gizmoduck's federation design (thread 9312a896, design 6925f78f0 on
gizmoduck/federation-design) proposes `access.json` for grants. Mos's reading (mos-0977): one model for
local and federated humans, because two stores drift. Lead decision (Sage, 2026-10-20, to gizmoduck-0002):
row 193 takes Gizmoduck's human-authored `<configDir>/access.json` (federation DESIGN.md section 3.5) as
the one user and grant store, and extends it with local humans, roles and shared threads. Federation adds
only a `home` field. The design confirms this or brings a concrete reason against it to Sage and
Gizmoduck before the build. Federation's R1 (the access.json loader) then comes before this row's build.
5. The bus human boundary. With several humans, the human verbs must know which human calls. State what a
same-uid process can still do (P19 333) and whether row 190's one-instance-per-OS-user direction narrows
it.
6. Migration and rollback. Existing businesses keep working with their one human as owner.
## Boundaries
- Changing who can reach the Console from outside is Jason's. This row changes who is allowed once inside.
- No test touches `~/.mosaic-dev`, `~/.config/mosaic-dev`, the live units or a slot file.
- The build may chain on rows 190 and 192 candidates (Q177). Each row keeps its own review and gate.
## Gate
Duckworth approves DESIGN.md, then the manifest. The bus, business, cli and webui suites and every
`scripts/test-*.sh` are green on Sage's gate, and the design's mutants are killed.
+65
View File
@@ -0,0 +1,65 @@
# Brief: one kill switch for the bus, every run, Discord and new launches
Sage, 2026-10-20, for Mos's order mos-0974 (MVP, DECISIONS Q176, Vikunja #414 "Kill switch is available to
stop all processing immediately"). Gitea #1689. T1 item 4. Owner Rocko, reviewer Dewey, gate owner Sage. Base: refactor
at the brief commit.
Design first. The first deliverable is `agents/rocko/work/queue-191/DESIGN.md`, which Dewey reviews before
any code. Then the build follows the design, with tests and mutants.
## The goal
One human command and one Console button stop all processing at once and keep it stopped until the human
clears it:
- The bus takes no new work. Agent verbs that start or continue work refuse with a fixed code while the
switch is on. Reads and the human's own verbs, including the clear, still work.
- Every running managed session and task run is stopped (SIGTERM, then SIGKILL after a bounded wait), and
each stop is recorded as an event.
- Discord stops (its existing STOP file and `stop` verb) and can't restart while the switch is on.
- New launches refuse: the launcher, chief hire, `run-task.sh`, and any other path that starts an agent.
- The switch survives a bus restart and a host reboot (it lives in the data root, not in memory).
- Turning it on and clearing it are human-only verbs, through the bus human proof, and each leaves one
event with who and when.
## What exists today (GAP-MAP.md G10, measured at 9ae8c73ac)
These are pieces, with no single switch over them. The design inventories every path that starts or
continues work and says how each one checks the switch:
- `launch.revoke` and `launch.restore` in `packages/bus/src/broker.mjs` (around :2087), human-only.
- The `mosaic launches off|on` verbs in `packages/cli/src/cli.mjs` (around :456-533) and
`packages/cli/src/launcher.mjs` (around :769).
- The Discord STOP file (`packages/discord/src/cli.mjs:18-27`).
## What the design must settle
1. Where the switch state lives, its format, and how each process reads it (the broker checks on every
work verb; the launcher and Discord check before start). The race: a launch already past its check when
the switch goes on must still be stopped.
2. The CLI verb names (for example `mosaic halt` and `mosaic resume`) and the Console button. The button
is a human write, so it goes through the same writer and same-origin checks as other Console writes, and
it works under the Q175 SSO session (#412) once phase 2 lands. Until then, test it with fixture servers
only.
3. Per instance. Row 190 adds several instances per host. The switch is per instance, so it stops one
instance and never another. The design names how it finds the instance's data root. This row may build
on the row 190 candidate (Q177 merge-chaining).
4. What a stopped run's record says, so run evidence stays write-once and honest.
## Tests
Each part of the goal gets a test: work verbs refuse, a running stand-in session and run are stopped,
Discord refuses to start, every launch path refuses, the switch survives a restart, an agent can't set or
clear it, and the clear lets work resume. Mutants: drop the check from one launch path, let an agent clear
the switch, keep the switch in memory only, skip the SIGKILL after the wait. Each one must fail a test.
## Boundaries
- No test touches `~/.mosaic-dev`, the live units, a real Discord bot or a slot file. Use the stand-in
units, scratch data roots and fixture servers the suites already build.
- Rocko has no git credentials. Sage commits the candidate and runs the queue moves Rocko can't.
## Gate
Dewey approves DESIGN.md, then the manifest. The cli suite, the bus, discord and webui package suites and
every `scripts/test-*.sh` are green on Sage's gate, and the mutants are killed.
+70
View File
@@ -0,0 +1,70 @@
# Brief: several Mosaic Stack instances on one host
Sage, 2026-10-20, for Mos's order mos-0974 (MVP, DECISIONS Q176-Q178, Vikunja #414). Gitea #1688. T1 item 1. Owner
Darkwing, reviewer Filbert, gate owner Sage. Base: refactor at the brief commit. This row blocks both MVP
cases: Case 1 (Jason's software factory) and Case 2 (Melanie's creative writing hub) run as two instances
on one homelab host, beside the current mosaic-stack test instance.
Design first. The first deliverable is `agents/darkwing/work/queue-190/DESIGN.md`. Filbert reviews it
before any code, as Q175 did. Then the build follows the design, with tests and mutants.
## The goal
Two or more instances run side by side on dragon-lin. Each one has its own config file, data root (store,
broker.sock, host.lock, state, runs, sessions, workspaces), businesses, bus unit, Console unit and port,
tester port, Discord unit, Claude session env file, stack-active pointer and activation log. No instance's
commands, units or tools address another instance's state by default or by mistake. On one uid this is
addressing only, not a security boundary: a same-uid process can still read another instance's files.
Isolation against that is row 190b (#1693, mos-0986), one Linux user per instance. The current instance keeps working unchanged: its
config path, data root, unit names and ports stay as they are, and no re-pin is needed for it.
## Known single-instance points (GAP-MAP.md, measured at 9ae8c73ac)
The design starts with a full inventory. Grep every script and package, not only these:
- `packages/cli/systemd/mosaic-bus.service.in`: `mosaic-bus@<business>` sets no `MOSAIC_CONFIG` and reads the
fixed `EnvironmentFile=-%h/.config/mosaic-dev/secrets/claude-session.env`.
- `packages/webui/systemd/mosaic-webui.service.in` and `scripts/webui-service.sh`: one `mosaic-webui.service`.
- `packages/discord/systemd/[email protected]` and `scripts/discord-service.sh`.
- `scripts/stack-copy.sh:8` hardcodes `BUSINESS=mosaic-stack`, and `:95` reads `mosaic-webui.service`.
- `scripts/stack-release.sh`: unit names, the Console port and the tester port default, `COPIES` (:28), the
pointer under `$DATA/state` (:1474), and `rehearse`.
- `scripts/mosaic-config.mjs`, `scripts/bootstrap.sh`, `scripts/reset.sh`, `scripts/land-work.sh`,
`packages/webui/src/cli.mjs`, `packages/business/src/{business,vars}.mjs`, and
`packages/conversation/src/guard.mjs` each name the default config path or data root.
## What the design must settle
1. Instance identity. How a name maps to its config file and how every tool picks the instance (an
`--instance NAME` flag, `MOSAIC_CONFIG`, or both), and what happens with neither (the current default,
unchanged). Name rules, and refusal of a name that collides.
2. Config canon. AGENTS.md invariant 2 says `~/.config/mosaic-dev/config.json` is the sole system config,
created only by `scripts/bootstrap.sh`. Several instances mean several config files. The design proposes
the exact wording change and how bootstrap creates an instance config without overwriting one. Mos rules
on the canon change before the build.
3. Unit names and templates. The bus, Console and Discord units per instance, how `MOSAIC_CONFIG` and the
Claude env file reach each unit, and how the current unit names stay valid for the default instance.
4. Ports. Each instance's Console and tester ports, with a refusal when two instances claim the same port.
5. Stack copies and the pointer. Whether copies are shared between instances (read-only trees, so sharing
may be fine) and how the pointer, activation log, backups and `flags_drift` stay per instance.
`stack-release.sh` and `stack-copy.sh` take the instance.
6. Secrets. The per-instance Claude session env file and the secrets slot root, resolved from the instance's
config dir (the Q175 design resolves its slot the same way: `<configDir>/secrets/mosaic-stack/<slot>`).
7. Isolation proof. What the tests show: two instances started side by side on stand-in units and scratch
roots, each reads only its own config and data root, a command aimed at one never touches the other, and
the default instance's paths and unit names are byte-identical to today.
## Boundaries
- No test touches `~/.mosaic-dev`, `~/.config/mosaic-dev`, the live units or a slot file. Use the stand-in
systemctl, units and scratch roots that `packages/cli/tests/stack-release.test.mjs` already builds.
- Standing up a real second instance on dragon-lin is a later window under Q173, with a backup first and a
STATE report after. Exposing a new Console through the tunnel or Traefik changes who can reach it from
outside, so it waits for Jason. SSO (Q175, #412) comes first for any instance a second user can reach.
- Merge-chaining (Q177): rows that need instances (human users, kill switch, lifecycle) may build on this
row's candidate. This row keeps its own review and gate.
## Gate
Filbert approves DESIGN.md, then the manifest. The cli suite and every `scripts/test-*.sh` are green on
Sage's gate, and the design's mutants are killed.
@@ -0,0 +1,57 @@
# Brief: retire the control board writer on 7331
Sage, 2026-10-20, for Mos's ruling mos-1008 item 2. Gitea #1696. MVP 10-27 work. Owner Bushroot (Haiku 5.5, a
low-level model under Q177), reviewer Webby, gate owner Sage. Base: refactor at the brief commit.
## Why
`packages/control-board/src/serve.mjs` is a loopback HTTP service on 7331 with no authentication. It reads
transcripts and writes through `/api/reply`. Any host process that reaches the port can do both. That fails
Q175 ("zero trust with verification"). Gander found it in the row 196 inventory (gander-0004). Nothing listens
on 7331 now (`ss -ltnp`, 2026-10-20T19:05:09Z), so retiring it costs the least.
## Steps, in order
1. Inventory first. List every reference to the control board and to 7331: scripts, units, `scripts/mosaic`
and other CLI dispatch, docs (AGENTS.md, READMEs, TOOLS.md), package.json scripts, tests, and the Console
(`packages/webui`). For each, say whether it starts the writer, reads its code, or only mentions it.
Commit the list as `agents/bushroot/work/queue-ROW/INVENTORY.md` and send it to Sage and Webby.
2. Stop if something depends on it. If a reference shows a live dependency (the Console or another package
imports code that only the control board has, or a unit or script still starts it), stop. Sage takes it to
Mos before anything is removed.
3. Otherwise retire the writer. Either remove the `serve` entry point, or make it refuse to start with a
message that points to the Console. Keep any read-only code another package imports, and say which.
Update the docs that tell people to start it. The AGENTS.md text about the control-board reply convention
is a separate matter. Change it only if it tells someone to start 7331.
## Mos's call on the Console dependency (mos-1019)
The inventory (004f133bd) found that the Console proxies `/api/board`, `/api/seen`, `/api/reply` and the
conversation routes to `DEFAULT_BOARD` on 7331 (`packages/webui/src/serve.mjs:12`), so the board view returns
502 today. Mos took Sage's recommendation: retire the 7331 listener and its README start commands, and have
the Console call the board's read code in-process, behind the Console's own auth (Q175 SSO on 7332).
`/api/reply` stays only under `--send`. The DESIGN.md for that change comes before code, and Webby approves
it.
## Acceptance
Nothing in the stack can start a writer on 7331. A test proves it: the entry point refuses (or is gone), no
script or unit starts it, and a grep check finds no starter. Each change gets a mutant that brings the writer
back and the test kills it. Mos added three items (mos-1019):
- The board view answers from the Console with nothing listening on 7331.
- Every board route sits behind the Console's auth.
- A test proves `/api/reply` refuses without `--send`.
Test fixtures that bind port 0 may stay if they only test the code in-process.
## Boundaries
- No change to the live units, `~/.mosaic-dev`, `~/.config/mosaic-dev`, slot files or `~/.mosaic`.
- No request to any live port.
- Single test files run freely. Suites need Sage's slot.
## Gate
Webby approves INVENTORY.md, then DESIGN.md, then the manifest. The control-board and webui package suites, the cli suite and
every `scripts/test-*.sh` are green on Sage's gate, and the mutants are killed.
+42
View File
@@ -0,0 +1,42 @@
# Brief: a second gate lane, two full gates at once on dragon-lin
Sage, 2026-10-20, for Mos's order mos-0977 item 6 ("put the design for a second gate lane on the list now")
and the MVP push (mos-0974). Gitea #1695. Owner Herb (Codex gpt-6.1-sol, a new builder seat), reviewer
Webby, gate owner Sage. Base: refactor at the brief commit. Design first. The first deliverable is
`agents/herb/work/queue-ROW/DESIGN.md`.
## Why
Every full run shares one slot today. A full gate (cli suite plus every `scripts/test-*.sh`) takes about 13
minutes or more, reviewers' runs and mutants take the same slot, and with eight or more seats building for
10-27 the slot is the bottleneck. The slot exists because concurrent runs broke each other. The busy check is
`agents/sage/work/gate-lane/busy-now.sh` (a copy of Sage's), and the gate is
`agents/sage/work/gate-lane/gate-template.sh` (a copy of Sage's template). The host has 16 cores and 62 GB.
## What the design must settle
1. Every shared resource two concurrent runs can collide on, found from the code and tests, not guessed:
Docker compose project names and networks (`scripts/test-task.sh` and others), fixed TCP ports in tests,
unix socket paths and their 107-byte limit under TMPDIR, the sandbox's private `/tmp`, shared caches,
scratch and worktree paths, the queue lock, and any test that reads host-wide state (process lists,
`pgrep`, `/proc`). For each: how a lane gets its own copy, or why it is safe to share.
2. Timing. Which tests have time limits that a doubled CPU load could break, measured, and what changes (a
bound, or a lane rule that keeps those suites apart).
3. The lane mechanism: lane ids, a lock per lane that a run takes and releases even on a crash, the busy
check that reports per lane, and how a seat asks for a lane. Sage still grants it.
4. The gate template takes a lane id and isolates everything from point 1 by it.
5. Proof: two full gates on different candidates at once, three times, with zero cross-failures, plus the
wall time of each against a single-lane run. Any flake found is named with its cause.
## Boundaries
- No change to the live units, `~/.mosaic-dev`, `~/.config/mosaic-dev` or slot files. The proof runs use
scratch roots only, and it needs Sage's grant of the whole slot while it runs.
- Test code changes that only isolate a resource are in scope. A change to what a test checks is not, and
goes to its owner.
- Docker networks and containers a run makes are removed by the run (trap), and nothing else is touched.
## Gate
Webby approves DESIGN.md, then the manifest. The cli suite and every `scripts/test-*.sh` are green on Sage's
gate, both singly and in the two-lane proof.
+47
View File
@@ -0,0 +1,47 @@
# Brief: Q175 phase 1, the Console verifies SSO itself (--sso)
Sage, 2026-10-20, for Q175 (Jason's ruling), mos-0971/0973/0977 and Vikunja #412. Gitea #1690. MVP T1 item 2. Owner
Launchpad, reviewer Webby (mos-0981), gate owner Sage. Base: refactor at the brief commit.
The design is approved: `agents/sage/work/q175-sso/DESIGN.md` at d55d86bb0 (blob eba861f5d2bb), Filbert r3
approve plus the D1/D2 ack, Mos C1 confirmation in mos-0977, whose straggler-check condition is in the phase 2 window at b416d2ede. Build phase 1 exactly as the design says. If
the code shows the design is wrong somewhere, stop and tell Sage, Webby and Filbert before building around it.
## Scope (phase 1 only, design section 5 item 1)
- oauth4webapi 3.8.8, pinned exactly (design section 2). No other new dependency.
- The OIDC relying party in the Console: `/auth/login`, `/auth/callback`, `/auth/logout`, `/auth/signed-out`,
the callback steps 1-7, our own checks on top of the library, and sessions, cookies and Host rules
(design sections 1 and 3).
- The shared loader, `scripts/mosaic-config.mjs`, accepts an optional `console` key and checks only its
shape. The slot file checks and the read run in the Console's `--sso` start, and the slot resolves from
the config dir (section 4, D1, D2).
- The degraded start for an unreachable provider, and the unreachable/refused classes (Q-B, section 4).
- `--sso` for `cli.mjs serve` and `webui-service.sh`, with its refusals against the old flags.
- The tester listener rules (section 6).
- The page: both fetch paths in `packages/webui/src/public/app.js` (:111 and :305) treat a 401
`sign-in required` as signed out, and the notice links to `/auth/login?return=<path>` (C2).
- Logging as the design says, including the Q-E `subject-unknown` line.
Out of scope: the phase 2 window, the live config.json key, phase 3 removals, the proxy-forwarded token
(section 7), and any change to the live Console.
## Tests and mutants
Design section 9: tests 1-25, the positive paths, and every listed mutant, each killed by its named test.
All tests run against the fake OP in `packages/webui/tests/helpers/fake-oidc.mjs`. No test reaches a real
provider or Authentik, and no test touches `~/.mosaic-dev`, `~/.config/mosaic-dev`, the live units or a
slot file. Test 25 goes in `scripts/test-config.sh`.
## Boundaries
- No request of any kind to auth.diversecanvas.com, mosaic.woltje.com or the live 127.0.0.1:7332 (mos-0971,
design section 8).
- No credential value is read, printed or logged.
- `npm install` runs only inside the worktree that has its own package.json, never in a directory that would
walk up to home.
## Gate
Webby approves the manifest. Filbert approved the design and answers design questions. The webui and cli suites, `scripts/test-config.sh` and every
`scripts/test-*.sh` are green on Sage's gate, and the design's mutants are killed.
@@ -0,0 +1,39 @@
# Brief: stack-release.sh activates a Console with --sso (Q175 phase 2 prerequisite)
Sage, 2026-10-20, for Launchpad's finding F3 on row 192 (launchpad-0001) and Q175 (#412). Gitea #1698. Owner
Launchpad, reviewer Webby, gate owner Sage. Chained on row 192's candidate (Q177): it lands after row 192 and
before the phase 2 window. Base: row 192's candidate.
## Why
The phase 2 window re-pins the Console to the phase 1 commit with `--sso --send --tester-port 7333`
(`agents/sage/work/q175-sso/DESIGN.md`, section 5). `scripts/stack-release.sh` doesn't know `--sso` today.
Its `proof_checks` (`:751`) requires `--public-host`, `--auth-header` and `--allow-user` whenever a proof pair is
carried, and the pointer's Console flags still hold the forward-auth pair (`--auth-header X-authentik-username
--allow-user jason.woltje`) that `--sso` refuses to start with. So the window can't run through the script, and
a hand-edited pointer is not an option.
## What the row does
1. `activate ID BUSINESS --sso` writes Console flags with `--sso` and without `--auth-header`, `--allow-user`
and any proof pair, and `--plan` names each flag it drops and carries, in the style of row 189's `send:`
line. The design names the full flag set the phase 2 Console starts with and checks it against row 192's
refusals.
2. Before anything stops, the script refuses a target copy whose Console doesn't support `--sso`, and refuses
when the config's `console` key or its slot file fails the checks row 192's `--sso` start runs. It reads
neither value and only uses the same stat and shape checks.
3. Rollback from an `--sso` pointer to a pre-phase-1 copy follows the design's phase 2 rollback order (remove
the `console` key first). The script refuses a rollback that would start a Console on a config it can't
read, and says which step comes first.
4. `docs/TOOLS.md` gets the flag.
## Boundaries
- No change to the live pointer, units, `~/.mosaic-dev` or `~/.config/mosaic-dev`. Tests use scratch roots
and seeded pointers, as rows 187 and 189 do.
- No request to the live Console, mosaic.woltje.com or auth.diversecanvas.com.
## Gate
Webby approves the manifest. The cli suite and every `scripts/test-*.sh` are green on Sage's gate, and the
mutants are killed (one per refusal, and one that keeps `--auth-header` in the written flags).
+4 -1
View File
@@ -33,7 +33,7 @@ Gaps found while working go to `docs/plans/DEFERRED.md`, not here.
<!-- mosaic-queue:begin -->
Generated from `docs/plans/queue.json` revision 2236 by `queue render`. Do not edit between the markers; change the queue with `scripts/mosaic queue`.
Generated from `docs/plans/queue.json` revision 2247 by `queue render`. Do not edit between the markers; change the queue with `scripts/mosaic queue`.
| # | Piece | Owner | Issues | State | After | Gate | Brief | Note |
|---|---|---|---|---|---|---|---|---|
@@ -225,6 +225,9 @@ Generated from `docs/plans/queue.json` revision 2236 by `queue render`. Do not e
| 186 | stack-release --plan: the backup line says what the run will do (P19 404, row 185 N1/N2) | darkwing; reviewers filbert | #1684 | done | — | Filbert approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the three brief mutants killed (sage) | `docs/plans/2026-10-20_plan-backup-line.md` § Brief: the activate plan's backup line says what the run will do | Landed 269134d8d (author Darkwing) on 3c5da3a91. Patch-id c97b9f6e25cd matches the r1 candidate (manifest 449e9121, Filbert approve 29419). Sage gate on b367f5730+patch 09:00:20Z-09:08:59Z: node 2681/0, scripts 422/0, queue 156, network removed; 3c5da3a91 adds only a SESSIONS line. Not live: the stack runs copy 490e2771d3d0. Follow-ups on P19 404: Filbert M6 gap, store-unclean reading. |
| 187 | stack-release activate carries the row 178 proof pair (mos-0928 Part B A0) | darkwing; reviewers filbert | #1685 | done | 186 done | Filbert approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the brief mutants killed (sage) | `docs/plans/2026-10-20_activate-proof-pair.md` § Brief: activate carries the row 178 proof pair | Landed 9a71e4e80 (author Darkwing) on d17328a55; patch-id 6b57c47bc661 matches candidate 52a5ecc1d143 and Filbert's r1 reading. Sage gate passed 10:21:55Z: cli 446/446 and every package and scripts/test-*.sh green. Filbert approve 29424, rev 2204; follow-ups N1-N4 filed on P19. |
| 188 | stack-release tests: a carried proof pair and a proof file inside the repository (P19 409, 410; gates Part B) | darkwing; reviewers filbert | #1686 | done | 187 settled | Filbert approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the brief mutants killed (sage) | `docs/plans/2026-10-20_proof-test-gaps.md` § Brief: tests for the two proof-file paths row 187 left open | Landed 8fc0ee216 (author Darkwing) on 23a609c5c; patch-id c9a56b167c07 matches candidate 86aa549bad75 and Filbert's r1 reading; stack-release.sh blob 2586a2a37cd2 unchanged. Sage gate passed 10:58Z: cli 453/453 and every package and scripts/test-*.sh green. Filbert approve 29433, rev 2212; N1 (REPO logical path) to P19 after Part B (mos-0959). |
| 189 | stack-release activate --no-send drops the Console's --send and keeps the pointer in step (Q175 interim, mos-0972, P19 413) | sage; reviewers dewey | #1687 | in-review, round 1, request posted | 163 done | Dewey approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the brief mutants killed (sage) | `docs/plans/2026-10-20_activate-no-send.md` § Brief: activate --no-send drops the Console's --send | — |
| 190 | several Mosaic Stack instances on one host: per-instance config, data root, units and ports (MVP T1.1, mos-0974, Vikunja #414) | darkwing; reviewers filbert | #1688 | in-progress | — | Filbert approves DESIGN.md then the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the design mutants killed (sage) | `docs/plans/2026-10-20_multi-instance.md` § Brief: several Mosaic Stack instances on one host | — |
| 191 | one kill switch for the bus, every run, Discord and new launches, from the CLI and the Console (MVP T1.4, mos-0974, Vikunja #414) | rocko; reviewers dewey | #1689 | in-progress | — | Dewey approves DESIGN.md then the manifest; cli, bus, discord and webui suites and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_kill-switch.md` § Brief: one kill switch for the bus, every run, Discord and new launches | — |
<!-- mosaic-queue:end -->
+471 -1
View File
@@ -1,7 +1,7 @@
{
"version": 1,
"canonicalRoot": "/mnt/storage/src/mosaic-stack",
"revision": 2236,
"revision": 2247,
"rows": [
{
"id": 1,
@@ -19820,6 +19820,153 @@
"createdAt": "2026-10-20T10:31:30.403Z",
"updatedAt": "2026-10-20T11:11:25.098Z",
"updatedBy": "sage"
},
{
"id": 189,
"piece": "stack-release activate --no-send drops the Console's --send and keeps the pointer in step (Q175 interim, mos-0972, P19 413)",
"owner": "sage",
"issues": [
1687
],
"closes": [
1687
],
"state": "in-review",
"previousState": null,
"required": false,
"requiredSince": null,
"gate": "Dewey approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the brief mutants killed",
"gateOwner": "sage",
"brief": {
"path": "docs/plans/2026-10-20_activate-no-send.md",
"anchor": "Brief: activate --no-send drops the Console's --send",
"blob": "0444b774faf09084311357fdd59b0ef3a3d347eb"
},
"after": [
{
"id": 163,
"when": "done"
}
],
"reviewers": [
"dewey"
],
"review": {
"rounds": [
{
"n": 1,
"op": "sage-189-inreview-1",
"by": "sage",
"at": "2026-10-20T18:46:06.124Z",
"issue": 1687,
"candidate": {
"kind": "manifest",
"digest": "67af69bd579561fd8c20be2a47695240a9f09f119dbfff88a8e72e2a7416e668",
"text": "70dbc89c0282fa063514317262d8926d16b8b8aaab4a1e47379303918495fc2c agents/sage/work/queue-189/BUILD.md\n4bd216cce2b40b39998725daa8989f6e3acb3ccd81a7017f6b09026af3965584 agents/sage/work/queue-189/mutants-run.log\nddbdddf03abf4aa91aa62f52e468be7af3c6b2f5d7bec2ee99b11e315471f18c agents/sage/work/queue-189/mutants.sh\n5d7b2da84d994f4f93d1212e349b4e4c19c5c00780e6cfcde91edb3ca08c014e docs/plans/2026-10-20_activate-no-send.md\n38e410c4cb731390f96747646eb7f6caa7a587fca9024ccbe901aab5ac19c7cf docs/TOOLS.md\nfe430969cc669515e348fced1a22cbe839a12023f7ac67cfba905b7afd29872f packages/cli/tests/stack-release.test.mjs\n16f77305e558f4fb953accc6d09f1f7b408aba363851d8ca79216ffce41bed9f scripts/stack-release.sh\n"
},
"request": "comment",
"attempts": [
{
"op": "sage-189-inreview-1",
"by": "sage",
"at": "2026-10-20T18:46:06.124Z",
"state": "posted",
"comment": 29457,
"transport": {
"outcome": "posted",
"status": 201,
"comment": 29457,
"detail": "created"
},
"resolutions": []
}
],
"duplicateRisk": false,
"receipts": []
}
]
},
"claim": {
"seat": "sage",
"op": "sage-189-start"
},
"note": null,
"blockedReason": null,
"createdAt": "2026-10-20T18:21:38.646Z",
"updatedAt": "2026-10-20T18:46:24.267Z",
"updatedBy": "sage"
},
{
"id": 190,
"piece": "several Mosaic Stack instances on one host: per-instance config, data root, units and ports (MVP T1.1, mos-0974, Vikunja #414)",
"owner": "darkwing",
"issues": [
1688
],
"closes": [
1688
],
"state": "in-progress",
"previousState": null,
"required": false,
"requiredSince": null,
"gate": "Filbert approves DESIGN.md then the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the design mutants killed",
"gateOwner": "sage",
"brief": {
"path": "docs/plans/2026-10-20_multi-instance.md",
"anchor": "Brief: several Mosaic Stack instances on one host",
"blob": "79fa60fbf17a352edc5431d6aabc77c72ec5c48f"
},
"after": [],
"reviewers": [
"filbert"
],
"review": null,
"claim": {
"seat": "darkwing",
"op": "darkwing-190-start"
},
"note": null,
"blockedReason": null,
"createdAt": "2026-10-20T18:42:48.052Z",
"updatedAt": "2026-10-20T18:45:23.430Z",
"updatedBy": "darkwing"
},
{
"id": 191,
"piece": "one kill switch for the bus, every run, Discord and new launches, from the CLI and the Console (MVP T1.4, mos-0974, Vikunja #414)",
"owner": "rocko",
"issues": [
1689
],
"closes": [
1689
],
"state": "in-progress",
"previousState": null,
"required": false,
"requiredSince": null,
"gate": "Dewey approves DESIGN.md then the manifest; cli, bus, discord and webui suites and every scripts/test-*.sh green on Sage's gate; the mutants killed",
"gateOwner": "sage",
"brief": {
"path": "docs/plans/2026-10-20_kill-switch.md",
"anchor": "Brief: one kill switch for the bus, every run, Discord and new launches",
"blob": "aed33125fa67ae69af73c18ffc9e2a83481421f3"
},
"after": [],
"reviewers": [
"dewey"
],
"review": null,
"claim": {
"seat": "rocko",
"op": "rocko-191-start"
},
"note": null,
"blockedReason": null,
"createdAt": "2026-10-20T18:43:05.222Z",
"updatedAt": "2026-10-20T18:45:05.621Z",
"updatedBy": "rocko"
}
],
"log": [
@@ -78756,6 +78903,329 @@
"receipt": "ok sage-163-done rev 2236 row 163 in-review→done round 2"
},
"viewSha": "e44815d32d46b91ee37d4c3c4701716956ba35d29c16c24c89020cce345bd61b"
},
{
"rev": 2237,
"op": "sage-189-add",
"verb": "add",
"args": {
"piece": "stack-release activate --no-send drops the Console's --send and keeps the pointer in step (Q175 interim, mos-0972, P19 413)",
"gate": "Dewey approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the brief mutants killed",
"brief": "docs/plans/2026-10-20_activate-no-send.md#Brief: activate --no-send drops the Console's --send",
"issues": [
1687
],
"note": null,
"owner": "sage",
"gateOwner": "sage",
"after": [
{
"id": 163,
"when": "done"
}
],
"reviewers": [
"dewey"
],
"required": null
},
"by": "sage",
"at": "2026-10-20T18:21:38.646Z",
"semantics": 2,
"result": {
"row": 189,
"from": null,
"to": "queued",
"brief": {
"path": "docs/plans/2026-10-20_activate-no-send.md",
"anchor": "Brief: activate --no-send drops the Console's --send",
"blob": "0444b774faf09084311357fdd59b0ef3a3d347eb"
},
"receipt": "ok sage-189-add rev 2237 row 189 none→queued"
},
"viewSha": "70bf9967156881afecaba9ccf4edf8cbc63a344eb7c6407b3453c950781f34c5"
},
{
"rev": 2238,
"op": "sage-189-briefed",
"verb": "move",
"args": {
"id": 189,
"to": "briefed",
"reason": null,
"candidate": null,
"evidence": null,
"issue": null
},
"by": "sage",
"at": "2026-10-20T18:22:01.437Z",
"semantics": 2,
"result": {
"row": 189,
"from": "queued",
"to": "briefed",
"round": null,
"issue": null,
"candidate": null,
"receipt": "ok sage-189-briefed rev 2238 row 189 queued→briefed"
},
"viewSha": "27ee8a26722d3eaf4c4b346e06ffdb853037aad2d1a7fe14f440cbe1bb9a2a27"
},
{
"rev": 2239,
"op": "sage-189-start",
"verb": "move",
"args": {
"id": 189,
"to": "in-progress",
"reason": null,
"candidate": null,
"evidence": null,
"issue": null
},
"by": "sage",
"at": "2026-10-20T18:22:18.476Z",
"semantics": 2,
"result": {
"row": 189,
"from": "briefed",
"to": "in-progress",
"round": null,
"issue": null,
"candidate": null,
"receipt": "ok sage-189-start rev 2239 row 189 briefed→in-progress"
},
"viewSha": "230578585341fcfc9429364332ca8b78988ffd669d3c1bb5e2e94a74d9916649"
},
{
"rev": 2240,
"op": "sage-190-add",
"verb": "add",
"args": {
"piece": "several Mosaic Stack instances on one host: per-instance config, data root, units and ports (MVP T1.1, mos-0974, Vikunja #414)",
"gate": "Filbert approves DESIGN.md then the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the design mutants killed",
"brief": "docs/plans/2026-10-20_multi-instance.md#Brief: several Mosaic Stack instances on one host",
"issues": [
1688
],
"note": null,
"owner": "darkwing",
"gateOwner": "sage",
"after": null,
"reviewers": [
"filbert"
],
"required": null
},
"by": "sage",
"at": "2026-10-20T18:42:48.052Z",
"semantics": 2,
"result": {
"row": 190,
"from": null,
"to": "queued",
"brief": {
"path": "docs/plans/2026-10-20_multi-instance.md",
"anchor": "Brief: several Mosaic Stack instances on one host",
"blob": "79fa60fbf17a352edc5431d6aabc77c72ec5c48f"
},
"receipt": "ok sage-190-add rev 2240 row 190 none→queued"
},
"viewSha": "b4319dbd993254b58e4db8be18769b04322028903e325c0c8cadcfdec527929f"
},
{
"rev": 2241,
"op": "sage-191-add",
"verb": "add",
"args": {
"piece": "one kill switch for the bus, every run, Discord and new launches, from the CLI and the Console (MVP T1.4, mos-0974, Vikunja #414)",
"gate": "Dewey approves DESIGN.md then the manifest; cli, bus, discord and webui suites and every scripts/test-*.sh green on Sage's gate; the mutants killed",
"brief": "docs/plans/2026-10-20_kill-switch.md#Brief: one kill switch for the bus, every run, Discord and new launches",
"issues": [
1689
],
"note": null,
"owner": "rocko",
"gateOwner": "sage",
"after": null,
"reviewers": [
"dewey"
],
"required": null
},
"by": "sage",
"at": "2026-10-20T18:43:05.222Z",
"semantics": 2,
"result": {
"row": 191,
"from": null,
"to": "queued",
"brief": {
"path": "docs/plans/2026-10-20_kill-switch.md",
"anchor": "Brief: one kill switch for the bus, every run, Discord and new launches",
"blob": "aed33125fa67ae69af73c18ffc9e2a83481421f3"
},
"receipt": "ok sage-191-add rev 2241 row 191 none→queued"
},
"viewSha": "2cdc7c37430226f1ea01ed9f3d60a2c12be16eddac38d18dc16f889c7b54b14e"
},
{
"rev": 2242,
"op": "sage-190-brief",
"verb": "move",
"args": {
"id": 190,
"to": "briefed",
"reason": null,
"candidate": null,
"evidence": null,
"issue": null
},
"by": "sage",
"at": "2026-10-20T18:43:25.630Z",
"semantics": 2,
"result": {
"row": 190,
"from": "queued",
"to": "briefed",
"round": null,
"issue": null,
"candidate": null,
"receipt": "ok sage-190-brief rev 2242 row 190 queued→briefed"
},
"viewSha": "6203315b484302369ce5ea7d854082a6bca4a95f9793e14ec123f57381234c95"
},
{
"rev": 2243,
"op": "sage-191-brief",
"verb": "move",
"args": {
"id": 191,
"to": "briefed",
"reason": null,
"candidate": null,
"evidence": null,
"issue": null
},
"by": "sage",
"at": "2026-10-20T18:43:43.042Z",
"semantics": 2,
"result": {
"row": 191,
"from": "queued",
"to": "briefed",
"round": null,
"issue": null,
"candidate": null,
"receipt": "ok sage-191-brief rev 2243 row 191 queued→briefed"
},
"viewSha": "9cc7dda5fa676258e864dedfdf2f4e3904c8723ba9c36474cff76757f52e5c07"
},
{
"rev": 2244,
"op": "rocko-191-start",
"verb": "move",
"args": {
"id": 191,
"to": "in-progress",
"reason": null,
"candidate": null,
"evidence": null,
"issue": null
},
"by": "rocko",
"at": "2026-10-20T18:45:05.621Z",
"semantics": 2,
"result": {
"row": 191,
"from": "briefed",
"to": "in-progress",
"round": null,
"issue": null,
"candidate": null,
"receipt": "ok rocko-191-start rev 2244 row 191 briefed→in-progress"
},
"viewSha": "cd2423e53ac82a667c737852bef6adfdafba82987f37cd090f5484b15b3a0e0c"
},
{
"rev": 2245,
"op": "darkwing-190-start",
"verb": "move",
"args": {
"id": 190,
"to": "in-progress",
"reason": null,
"candidate": null,
"evidence": null,
"issue": null
},
"by": "darkwing",
"at": "2026-10-20T18:45:23.430Z",
"semantics": 2,
"result": {
"row": 190,
"from": "briefed",
"to": "in-progress",
"round": null,
"issue": null,
"candidate": null,
"receipt": "ok darkwing-190-start rev 2245 row 190 briefed→in-progress"
},
"viewSha": "014db845fb2e380678ab1250f2cd3c0d7b192dc39909416515482c810440149b"
},
{
"rev": 2246,
"op": "sage-189-inreview-1",
"verb": "move",
"args": {
"id": 189,
"to": "in-review",
"reason": null,
"candidate": "agents/sage/work/queue-189/candidate-manifest.sha256",
"evidence": null,
"issue": null
},
"by": "sage",
"at": "2026-10-20T18:46:06.124Z",
"semantics": 2,
"result": {
"row": 189,
"from": "in-progress",
"to": "in-review",
"round": 1,
"issue": 1687,
"candidate": {
"kind": "manifest",
"digest": "67af69bd579561fd8c20be2a47695240a9f09f119dbfff88a8e72e2a7416e668",
"text": "70dbc89c0282fa063514317262d8926d16b8b8aaab4a1e47379303918495fc2c agents/sage/work/queue-189/BUILD.md\n4bd216cce2b40b39998725daa8989f6e3acb3ccd81a7017f6b09026af3965584 agents/sage/work/queue-189/mutants-run.log\nddbdddf03abf4aa91aa62f52e468be7af3c6b2f5d7bec2ee99b11e315471f18c agents/sage/work/queue-189/mutants.sh\n5d7b2da84d994f4f93d1212e349b4e4c19c5c00780e6cfcde91edb3ca08c014e docs/plans/2026-10-20_activate-no-send.md\n38e410c4cb731390f96747646eb7f6caa7a587fca9024ccbe901aab5ac19c7cf docs/TOOLS.md\nfe430969cc669515e348fced1a22cbe839a12023f7ac67cfba905b7afd29872f packages/cli/tests/stack-release.test.mjs\n16f77305e558f4fb953accc6d09f1f7b408aba363851d8ca79216ffce41bed9f scripts/stack-release.sh\n"
},
"receipt": "ok sage-189-inreview-1 rev 2246 row 189 in-progress→in-review round 1 on #1687; request sage-189-inreview-1 requesting"
},
"viewSha": "d8a3aa936a9a819329e86679d0bfcf658d773d0f7cfd29c10587c6447f78e968"
},
{
"rev": 2247,
"op": "sage-189-inreview-1.outcome",
"verb": "review-outcome",
"args": {
"id": 189,
"attempt": "sage-189-inreview-1",
"outcome": "posted",
"status": 201,
"comment": 29457,
"detail": "created"
},
"by": "sage",
"at": "2026-10-20T18:46:24.267Z",
"semantics": 2,
"result": {
"row": 189,
"round": 1,
"attempt": "sage-189-inreview-1",
"state": "posted",
"receipt": "ok sage-189-inreview-1.outcome rev 2247 row 189 round 1 request sage-189-inreview-1 posted comment 29457"
},
"viewSha": "6d7e91ae3cab0b76e5c5d791fb821b97c9da7db16389ff2d531d7b77d2a069f1"
}
]
}
+7
View File
@@ -215,6 +215,13 @@ an operator inspect content and delivery evidence after an uncertain result.
| `trail` | `{subject}`: task ref, decision UUID or message UUID. Linked records in broker write order. |
| `decisions` | `{list}`: `roles` or `recent`; human and reader sessions only. Open decisions routed to a role, or the last 50 closed. |
| `credentials` | No args; human and reader sessions only. `[{service,account,role,dateKind,date,state}]` for the capability's business, never a token, file path or variable name. |
| `credentials.issue` | `{agent,service,token,date}`; human only (row 199, #1697). Writes the token to `<configDir>/secrets/agents/<business>/<agent>/<service>` (0600, dirs 0700: tmp, fsync, rename), rewrites the agent's reference in the business file to `{"store":true,<rotateBy or expires>:date}`, then holds the token in memory, with no restart. Over an issued entry it is a rotate: the old value is gone from disk and memory when it returns. `service` is `gitea` or `vikunja` and the business file must already name it for the agent (`credential-unknown`). A token the bus already holds is `credential-leak`; a bad token `credential-format`; a bad date `credential-date`. Returns `{agent,verb,service,date,state}`, `verb` `issue` or `rotate`. The tester gets `tester-refused`. |
| `credentials.revoke` | `{agent,service?,reason}`; human, or the tester (Q173). Drops the entry from memory, then unlinks a store file; a `file` or `env` reference keeps its file and becomes `store`, so a restart loads nothing. Without `service`, every kind the agent has. Returns `{agent,verb,services:[{service,state}]}`. |
| `credentials.delete` | `{agent,reason}`; human only. `credentials.revoke` for every kind, then removes the agent's store dir; a file there other than a token is `credential-file`. While the agent or one of its threads holds a claim, `agent-running`. |
Each of the three leaves one receipt in the organization's `changes.jsonl`: the actor and a
`detail` of `{verb,agent,kind,date or reason}`, never a value. A change to the store alone writes
a receipt with empty `changes`. A refusal is `action.refused` with its code alone.
| `requirements` | No args; role sessions only, as `task.create` is (row 90, #1584). The approved PRD's requirement ids, `{prd,version,requirements:[{id,title}]}`, from `packages/tasks/requirements.json`, loaded when the broker starts; a bad file refuses the start. A human session refuses `agent-required`, a reader `read-only`. |
| `state` | No args; role, human and reader sessions (row 109, #1603). `{launch:{state,at,event},tasks:{open:[{task_ref,title}],total,cap}}` for the capability's business. `launch` is the latest `launch.revoked` or `launch.restored` event: `allowed` with `at` and `event` null when none exists. `open` is the tracker snapshot's open tasks in `task_ref` order, at most `cap` (the `tasks` page size, 50); `total` counts every open task. The harness runner reads it before each turn. Writes nothing. |
| `replies` | `{id}`: a message UUID; human and reader sessions only. The messages whose `in_reply_to` is that id, oldest first, at most 50. Writes nothing. |
+153 -6
View File
@@ -3,7 +3,7 @@ import { isAbsolute } from 'node:path';
import { NAME as HARNESS_NAME } from '../../plugins/src/kinds.mjs';
import { loadRequirements } from '../../tasks/src/requirements.mjs';
import { MERGE, LandingRefusal, readLanding } from './landing.mjs';
import { ID_PATTERN, THREAD_DEFINITION, END_PROTECTED } from '../../business/src/vocabulary.mjs';
import { ID_PATTERN, THREAD_DEFINITION, END_PROTECTED, forkProblem, forkUrl } from '../../business/src/vocabulary.mjs';
// Kept closed while S1 lands; its vocabulary is the integration authority.
export const ACTIONS = Object.freeze([
@@ -18,6 +18,8 @@ export const ACTIONS = Object.freeze([
'git.push.working',
'git.push.protected',
'git.merge.protected',
// Row 199 (#1697, mos-1027 Q-M2): publish to the agent's fork. Not in ROUTED, so a hard wait.
'git.push.remote',
'review.request',
'review.verdict',
'record.write',
@@ -101,6 +103,11 @@ export const TESTER_VERBS = Object.freeze(['message.send', 'message.receive', 'm
// Row 175: the organization verbs a tester may call. Its writes run every check the human's do
// and log actor `tester`; row 176 adds routes reads; limits.set, organization.set and organization.revision refuse it.
export const TESTER_ORGANIZATION_VERBS = Object.freeze(['limits', 'plugins', 'instances', 'plugins.set', 'instances.set', 'routes.set', 'routes']);
// Row 199 (#1697, design section 6): the credential lifecycle, human-only. Under Q173 the tester may
// revoke; issue (rotate is an issue over an issued entry) and delete refuse it, because a token it
// issued would pass through its session (duckworth N1). The status read stays the `credentials` verb.
export const CREDENTIAL_VERBS = Object.freeze(['credentials.issue', 'credentials.revoke', 'credentials.delete']);
const TESTER_CREDENTIAL_VERBS = Object.freeze(['credentials.revoke']);
const SELF_KINDS = new Set(['task.created', 'task.assigned', 'task.state', 'task.closed', 'task.conflict']);
const POLL_KINDS = new Set([
'task.changed.external',
@@ -191,10 +198,13 @@ export class Broker {
#received = new Set();
// Row 137 (#1631): landings checkLand let start, by ticket, until recordLand or landRefused ends them.
#landings = new Map();
// Row 199: a publish between checkPublish and recordPublish or publishRefused.
#publishes = new Map();
#secretCheck;
#credentialStatus;
#requirements;
#organization;
#credentialVerbs;
#project;
#usage;
#limits;
@@ -203,13 +213,15 @@ export class Broker {
#stops = new Map();
#clock = 0;
// credentialStatus is the runtime's metadata-only Credentials.status(); the broker never gets use().
constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, usage = null, limits = null, harnessOn = null }) {
constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, usage = null, limits = null, harnessOn = null, credentialVerbs = null }) {
if (!object(businesses) || !Object.keys(businesses).length) fail('invalid-business');
this.#businesses = structuredClone(businesses);
this.#store = store;
this.#secretCheck = secretCheck;
this.#credentialStatus = credentialStatus;
this.#organization = organization;
// Row 199: the runtime's credential lifecycle (credential-verbs.mjs); it holds the store, not the broker.
this.#credentialVerbs = credentialVerbs;
this.#project = project;
this.#usage = usage;
this.#limits = limits;
@@ -795,7 +807,12 @@ export class Broker {
// records `?? null` and the current holder reads undefined.
if (action === 'role.revoke' && (context.holderRun ?? null) !== (this.#holder(s.business, target)?.holder_run ?? null))
fail('decision-mismatch');
return { class: cls, decision: d.id };
// Row 199 (r4 B4): approve at A, push B, publish refuses.
if (action === 'git.push.remote' && (context.pushCommit ?? null) !== (this.#latestPush(s.business, target)?.commit ?? null))
fail('decision-mismatch');
return action === 'git.push.remote'
? { class: cls, decision: d.id, pushCommit: context.pushCommit ?? null }
: { class: cls, decision: d.id };
}
// The raise's context: its target and approval option, from the decision.raise action.allowed.
#raised(business, decision) {
@@ -858,13 +875,14 @@ export class Broker {
if (body.commit !== push.commit) fail('verdict-stale');
}
// A push is the action.allowed recordPush wrote, the only one for git.push.working with a commit.
// role is the role that pushed (row 199, work-not-yours).
#latestPush(business, task) {
const r = this.#store.get(
"SELECT body FROM events WHERE business=? AND kind='action.allowed' AND json_extract(body,'$.action')='git.push.working' AND json_type(body,'$.commit')='text' AND subject=? ORDER BY seq DESC LIMIT 1",
"SELECT body, actor_role FROM events WHERE business=? AND kind='action.allowed' AND json_extract(body,'$.action')='git.push.working' AND json_type(body,'$.commit')='text' AND subject=? ORDER BY seq DESC LIMIT 1",
business,
task,
);
return r ? JSON.parse(r.body) : null;
return r ? { ...JSON.parse(r.body), role: r.actor_role } : null;
}
// Row 93: the bus host's git socket asks before it takes a push's bytes. Nothing is recorded
// unless it refuses: the push is recorded, and its authority consumed, once the ref moved.
@@ -998,6 +1016,98 @@ export class Broker {
this.#landings.delete(ticket);
return code;
}
// Row 199 (#1697, design D9 steps 1 to 3, D9b): the bus host's git socket publishes a task's
// recorded work commit to the agent's own fork. checkPublish runs before anything touches the
// network. The run holds its claim; git.push.remote is a hard wait, so it cites a decision the
// human resolved for this task, bound to the task's latest push (#authorityFor). The project must
// be in the agent's publish grant with an upstream; the fork is built from that upstream and the
// agent's own Gitea user, never from the request. The ticket carries the session and decision to
// recordPublish or publishRefused. No token is in the result.
checkPublish(cap, args) {
const s = this.#session(cap);
return this.#store.transaction(() => {
keys(args, ['task_ref', 'project', 'branch', 'decision'], ['task_ref', 'project', 'branch']);
if (!taskRef(args.task_ref) || (args.decision !== undefined && !id(args.decision))) fail('invalid-request');
identifier(args.project);
string(args.branch, 200);
this.#agent(s);
const { task_ref, project, branch } = args;
const decision = args.decision ?? null;
const result = this.#checkAuthority(s, 'git.push.remote', { decision, target: task_ref });
const facts = this.#publishFacts(s, task_ref, project, result);
const ticket = randomUUID();
const dst = `refs/heads/${facts.user}/${branch}`;
this.#publishes.set(ticket, { s, task_ref, project, decision, dst, ...facts });
return { ticket, task_ref, project, fork: facts.fork, user: facts.user, email: facts.email, source: facts.source, dst };
});
}
// Steps 2 and 3, checked again at recordPublish. After routing a within-role publish has no
// decision, so the commit is the latest push's (N9); the git socket still checks the work ref tip.
#publishFacts(s, task_ref, project, result) {
if (result.decision && this.#spent(s.business, result.decision)) fail('decision-consumed');
const b = this.#business(s.business);
const role = b.roles?.[s.role];
const upstream = b.projects?.[project]?.remote;
if (!role?.identity || !(role.publish ?? []).includes(project) || !upstream) fail('remote-not-granted');
const problem = forkProblem(upstream, role.identity);
if (problem) fail(problem);
const push = this.#latestPush(s.business, task_ref);
if (!push) fail('no-push');
if (push.role !== s.role) fail('work-not-yours');
if (push.project !== project) fail('work-ref');
const source = result.pushCommit ?? push.commit;
if (source !== push.commit) fail('decision-mismatch');
const user = role.identity.gitea.user;
return { fork: forkUrl(upstream, user), user, email: role.identity.email, source };
}
#publish(ticket) {
return (typeof ticket === 'string' && this.#publishes.get(ticket)) || fail('publish-unknown');
}
// Design D5: the only key the git socket's gitea token use gets is the agent checkPublish checked
// (#agent: a run holding its claim, never a tester, reader or human). Trusted host only.
publishSession(ticket) {
const { s } = this.#publish(ticket);
return Object.freeze({ business: s.business, role: s.role });
}
// Where the bus process's git sends the push (D9 step 5): all from the ticket, none from the
// host's message. Trusted host only.
publishTarget(ticket) {
const { s, project, fork, user, source, dst } = this.#publish(ticket);
return Object.freeze({ business: s.business, project, fork, user, source, dst });
}
// Step 6: records the push the git socket made as the action.allowed for git.push.remote, which
// spends the decision. The checks run again; the run may have ended while git ran, so the holder
// check is not repeated (as recordLand). Trusted host only.
recordPublish(ticket, pushed) {
const t = this.#publish(ticket);
const done = this.#store.transaction(() => {
keys(pushed, ['ref', 'commit', 'previous'], ['ref', 'commit', 'previous']);
if (!oid(pushed.commit) || (pushed.previous !== null && !oid(pushed.previous))) fail('invalid-request');
string(pushed.ref, 260);
const result = this.#authorityFor(t.s, 'git.push.remote', { decision: t.decision, target: t.task_ref });
const facts = this.#publishFacts(t.s, t.task_ref, t.project, result);
if (facts.source !== t.source || pushed.commit !== t.source || facts.fork !== t.fork || pushed.ref !== t.dst) fail('ref-moved');
const { ref, commit, previous } = pushed;
this.#event(
t.s,
'action.allowed',
{ project: t.project, fork: t.fork, ref, commit, previous, action: 'git.push.remote', ...result, target: t.task_ref },
t.task_ref,
);
return { task_ref: t.task_ref, fork: t.fork, ref, commit, previous };
});
this.#publishes.delete(ticket);
return done;
}
// Ends a publish that didn't push: action.refused with the code on the task's trail. The git
// socket answers the run with the same code. Trusted host only.
publishRefused(ticket, { code } = {}) {
const t = this.#publish(ticket);
if (!/^[a-z-]{1,64}$/.test(code ?? '')) fail('invalid-request');
this.#store.transaction(() => this.#event(t.s, 'action.refused', { code }, t.task_ref));
this.#publishes.delete(ticket);
return code;
}
// The outcome as a RESULT from the bus, as #notice sends: the runner takes a turn on it and sends
// no automatic reply. It goes to the role in the thread the decision was raised in.
#landMessage(s, decision, body) {
@@ -1245,6 +1355,7 @@ export class Broker {
return this.#requestProject(s, { verb: request.verb, args: structuredClone(args) });
if (PROJECT_FILE_VERBS.includes(request.verb))
return this.#requestProjectFile(s, { verb: request.verb, args: structuredClone(args) });
if (CREDENTIAL_VERBS.includes(request.verb)) return this.#requestCredential(s, request.verb, structuredClone(args));
if (["limits", "limits.set", "organization.set", "organization.revision", "plugins", "plugins.set", "instances", "instances.set", "routes", "routes.set"].includes(request.verb)) {
// Row 123 (#1617, REQ-COS-1, REQ-LIMIT-1): organization.set is limits.set for a role whose
// file lists organization.change. It takes the same keys, runs the same write path and
@@ -1316,6 +1427,35 @@ export class Broker {
throw this.#refused(s, e, request);
}
}
// Row 199 (#1697, design section 6). The token of an issue comes in args from the human's stdin; the
// secret check above has refused one the bus already holds, a refusal records its code alone, and
// nothing returned carries it. Outside the store transaction, as the organization branch.
#requestCredential(s, verb, a) {
if (s.tester ? !TESTER_CREDENTIAL_VERBS.includes(verb) : !s.human) fail(s.tester ? 'tester-refused' : 'human-required');
if (!this.#credentialVerbs) fail('not-configured');
if (verb === 'credentials.issue') keys(a, ['agent', 'service', 'token', 'date'], ['agent', 'service', 'token', 'date']);
else keys(a, verb === 'credentials.revoke' ? ['agent', 'service', 'reason'] : ['agent', 'reason'], ['agent', 'reason']);
string(a.agent, 64);
if (a.service !== undefined && !['gitea', 'vikunja'].includes(a.service)) fail('invalid-request');
if (a.token !== undefined) string(a.token, 16384);
if (a.date !== undefined) string(a.date, 10);
if (a.reason !== undefined) string(a.reason, 500);
if (!Object.hasOwn(this.#business(s.business).roles, a.agent)) fail('credential-unknown');
// A running agent keeps its tokens until its runs end: delete waits for every claim to end.
if (verb === 'credentials.delete' && (this.#holder(s.business, a.agent)?.op === 'claim' || this.#threadHolders(s.business, a.agent).length))
fail('agent-running');
let result;
try {
result = this.#credentialVerbs(s.business, verb, a, s.tester ? 'tester' : s.human);
} catch (e) {
if (e instanceof BusError && e.code.startsWith('credential-')) throw e;
if (e.code === 'revision-conflict' || e.code?.startsWith('organization-')) fail(e.code);
if (e.name === 'BusinessError') fail('invalid-request');
throw new BusError('outcome-unknown');
}
this.#secretCheck(result);
return result;
}
// Only the runner's bound session and delivered message identify a turn; no host path crosses the wire.
// Row 156 (#1651, sage-0808): a role run's landing turn is a merge decision id instead. Its begin
// needs the decision held for the run's role; its end needs that begin, since the landing may
@@ -1698,6 +1838,12 @@ export class Broker {
by,
);
}
// Row 199 (#1697, design D9 step 1, r4 B4): a publish decision binds one commit, the task's
// latest recorded push now, read here and never taken from the agent. The human sees it.
const pushCommit =
a.action === 'git.push.remote' ? (this.#latestPush(s.business, a.target)?.commit ?? fail('no-push')) : null;
const question = pushCommit ? `${a.question} (commit ${pushCommit})` : a.question;
string(question);
const did = randomUUID();
this.#store.run(
'INSERT INTO decisions(id,at,business,project,raised_by_role,raised_by_run,class,action,route_to,question,options,recommendation,task_ref,requirement_ref,blocking,supersedes,spec_path,spec_commit) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)',
@@ -1710,7 +1856,7 @@ export class Broker {
cls,
a.action,
route,
a.question,
question,
JSON.stringify(a.options),
a.recommendation,
a.task_ref ?? null,
@@ -1732,6 +1878,7 @@ export class Broker {
approvalChoice,
holderRun:
a.action === 'role.revoke' ? (this.#holder(s.business, a.target)?.holder_run ?? null) : null,
...(pushCommit ? { pushCommit } : {}),
},
a.task_ref ?? (taskRef(a.target) ? a.target : did),
);
+53
View File
@@ -0,0 +1,53 @@
import { BusError } from './broker.mjs';
// Row 199 (#1697, design section 6): issue, rotate, revoke and delete of one agent's credentials, for
// the broker's human-only verbs. The broker checks the session, the keys and a running claim first.
// Each verb leaves one receipt in the organization's changes.jsonl: the actor and a detail naming the
// verb, agent, kinds, date or reason, never a value. Nothing here logs, and a result carries no token.
const DATE_KEY = Object.freeze({ gitea: 'rotateBy', vikunja: 'expires' });
const deny = (code) => {
throw new BusError(code);
};
export function credentialVerbs({ credentials, writer }) {
const state = (instance, service) =>
credentials.status().find((e) => e.instance === instance && e.service === service)?.state;
return (id, verb, args, actor) => {
const { agent } = args;
const instance = id + '/' + agent;
const { document, revision } = writer.read(id);
const current = document.roles?.[agent]?.credentials;
if (!current) deny('credential-unknown');
if (verb === 'credentials.issue') {
const { service, token, date } = args;
if (!current[service]) deny('credential-unknown');
// rotate is an issue over an entry the bus holds now; the old value is gone when this returns.
const kind = ['not-issued', 'revoked', undefined].includes(state(instance, service)) ? 'issue' : 'rotate';
const next = structuredClone(document);
next.roles[agent].credentials[service] = { store: true, [DATE_KEY[service]]: date };
const detail = { verb: kind, agent, kind: [service], date };
const status = credentials.issue(instance, service, token, date, () =>
writer.write(id, next, { revision, actor, detail }),
);
return { agent, verb: kind, service, date: status.date, state: status.state };
}
const services = verb === 'credentials.revoke' && args.service ? [args.service] : Object.keys(current);
if (services.some((service) => !current[service])) deny('credential-unknown');
// Memory first, then the store file (Credentials.revoke), then the business file. A reference
// outside the store becomes `store` with its date, so a restart loads nothing for it. When that
// write fails, the entry stays revoked in memory and the refusal says to run the verb again.
if (verb === 'credentials.delete') credentials.remove(instance);
else for (const service of services) credentials.revoke(instance, service);
const detail = { verb: verb.slice('credentials.'.length), agent, kind: services, reason: args.reason };
const moved = services.filter((service) => current[service].store !== true);
if (moved.length) {
const next = structuredClone(document);
for (const service of moved)
next.roles[agent].credentials[service] = { store: true, [DATE_KEY[service]]: current[service][DATE_KEY[service]] };
writer.write(id, next, { revision, actor, detail });
} else writer.record(id, { actor, detail });
return {
agent,
verb: detail.verb,
services: services.map((service) => ({ service, state: state(instance, service) ?? 'not-issued' })),
};
};
}
+163 -19
View File
@@ -1,5 +1,8 @@
import { openSync, closeSync, fstatSync, readFileSync, lstatSync, realpathSync, constants } from 'node:fs';
import { isAbsolute, relative, resolve } from 'node:path';
import {
openSync, closeSync, fstatSync, readFileSync, lstatSync, realpathSync, constants, mkdirSync, writeSync, fsyncSync,
renameSync, unlinkSync, rmdirSync, readdirSync,
} from 'node:fs';
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
import { BusError } from './broker.mjs';
const deny = (code) => {
throw new BusError(code);
@@ -13,9 +16,33 @@ const day = (value) =>
/^\d{4}-\d{2}-\d{2}$/.test(value) &&
!Number.isNaN(Date.parse(value)) &&
new Date(value).toISOString().slice(0, 10) === value;
const TOKEN = /^[-A-Za-z0-9._~]+\n?$/;
// Row 199 (#1697, design D1, D4): a store dir is the stack instance's user's own and nobody else's.
const privateDir = (path, { missing = false } = {}) => {
let st;
try {
st = lstatSync(path);
} catch (e) {
if (missing && e.code === 'ENOENT') return false;
deny('credential-file');
}
if (!st.isDirectory() || st.uid !== process.getuid() || (st.mode & 0o077) !== 0) deny('credential-file');
return true;
};
export class Credentials {
#tokens = new Map();
constructor({ references = {}, repoRoots = [], dataRoot, env = process.env }) {
// Row 199: every reference the bus loaded, so issue, rotate, revoke and status work per entry.
#refs = new Map();
#revoked = new Set();
#storeRoot;
#repoRoots;
#dataRoot;
// storeRoot is <configDir>/secrets/agents (design D1); a reference with store: true must name the
// path under it for its own business, agent and service.
constructor({ references = {}, repoRoots = [], dataRoot, env = process.env, storeRoot = null }) {
this.#storeRoot = storeRoot;
this.#repoRoots = repoRoots;
this.#dataRoot = dataRoot;
try {
for (const [instance, services] of Object.entries(references))
for (const [service, ref] of Object.entries(services)) {
@@ -26,12 +53,41 @@ export class Credentials {
if (
(ref.service !== undefined && ref.service !== service) ||
Object.keys(ref).some(
(k) => !['service', 'file', 'env', service === 'vikunja' ? 'expires' : 'rotateBy'].includes(k),
(k) => !['service', 'file', 'env', 'store', service === 'vikunja' ? 'expires' : 'rotateBy'].includes(k),
) ||
Boolean(ref.file) === Boolean(ref.env)
Boolean(ref.file) === Boolean(ref.env) ||
(ref.store !== undefined && (ref.store !== true || ref.file !== this.#storePath(instance, service)))
)
deny('credential-reference');
this.#refs.set(instance + ':' + service, { ref, date });
const token = this.#read(ref, env);
if (token !== null) this.#tokens.set(instance + ':' + service, { token, date });
}
} catch (e) {
this.close();
throw e;
}
}
#storePath(instance, service) {
if (!this.#storeRoot) deny('credential-reference');
const [business, agent, ...rest] = instance.split('/');
if (!business || !agent || rest.length || agent.startsWith('@')) deny('credential-reference');
return join(this.#storeRoot, business, agent, service);
}
// One token, read and checked, or null for a store reference with no file yet (design D3a).
#read(ref, env) {
let token;
if (ref.store) {
const agentDir = dirname(ref.file);
privateDir(this.#storeRoot);
if (!privateDir(dirname(agentDir), { missing: true }) || !privateDir(agentDir, { missing: true })) return null;
try {
lstatSync(ref.file);
} catch (e) {
if (e.code === 'ENOENT') return null;
deny('credential-file');
}
}
if (ref.file) {
if (!isAbsolute(ref.file)) deny('credential-location');
let resolved, original;
@@ -43,7 +99,7 @@ export class Credentials {
}
if (original.isSymbolicLink() || !original.isFile()) deny('credential-file');
if (
[...repoRoots, dataRoot].filter(Boolean).some((root) =>
[...this.#repoRoots, this.#dataRoot].filter(Boolean).some((root) =>
inside(
(() => {
try {
@@ -81,16 +137,98 @@ export class Credentials {
if (!/^[A-Z_][A-Z0-9_]*$/.test(ref.env)) deny('credential-reference');
token = env[ref.env];
}
if (typeof token !== 'string' || token.length > 16384 || !/^[-A-Za-z0-9._~]+\n?$/.test(token))
deny('credential-format');
token = token.replace(/\n$/, '');
if (token.length < 16) deny('credential-format');
this.#tokens.set(instance + ':' + service, { token, date });
}
} catch (e) {
this.close();
throw e;
return Credentials.#format(token);
}
static #format(token) {
if (typeof token !== 'string' || token.length > 16384 || !TOKEN.test(token)) deny('credential-format');
token = token.replace(/\n$/, '');
if (token.length < 16) deny('credential-format');
return token;
}
#known(instance, service) {
const known = this.#refs.get(instance + ':' + service);
if (!known) deny('credential-unknown');
return known;
}
// Row 199 (design section 6): write the token to the agent's store path (tmp, fsync, rename), then
// `commit` (the business file's reference becomes store), then memory. The token comes from the
// caller's stdin and is never logged. Returns the entry's new status.
issue(instance, service, token, date, commit = () => {}) {
this.#known(instance, service);
if (!day(date)) deny('credential-date');
token = Credentials.#format(token);
const file = this.#storePath(instance, service);
const agentDir = dirname(file);
mkdirSync(this.#storeRoot, { recursive: true, mode: 0o700 });
privateDir(this.#storeRoot);
for (const dir of [dirname(agentDir), agentDir]) {
if (!privateDir(dir, { missing: true })) mkdirSync(dir, { mode: 0o700 });
privateDir(dir);
}
const tmp = file + '.tmp';
try {
unlinkSync(tmp);
} catch (e) {
if (e.code !== 'ENOENT') deny('credential-file');
}
let fd;
try {
fd = openSync(tmp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
writeSync(fd, token + '\n');
fsyncSync(fd);
} finally {
if (fd !== undefined) closeSync(fd);
}
renameSync(tmp, file);
const dirFd = openSync(agentDir, constants.O_RDONLY);
try {
fsyncSync(dirFd);
} finally {
closeSync(dirFd);
}
commit();
this.#refs.set(instance + ':' + service, { ref: { service, store: true, file, [service === 'vikunja' ? 'expires' : 'rotateBy']: date }, date });
this.#tokens.set(instance + ':' + service, { token, date });
this.#revoked.delete(instance + ':' + service);
return this.status().find((e) => e.instance === instance && e.service === service);
}
// Memory first, then the store file (design section 6, D12). A file or env reference outside the
// store is never unlinked; the caller turns its reference into store, so a restart finds nothing.
revoke(instance, service) {
const { ref } = this.#known(instance, service);
this.#tokens.delete(instance + ':' + service);
this.#revoked.add(instance + ':' + service);
if (ref.store) {
try {
unlinkSync(ref.file);
} catch (e) {
if (e.code !== 'ENOENT') deny('credential-file');
}
}
return { file: Boolean(ref.store) };
}
// revoke for every service, then the agent's dir. A file other than a token or its tmp refuses.
remove(instance) {
const services = [...this.#refs.keys()].filter((k) => k.startsWith(instance + ':')).map((k) => k.slice(instance.length + 1));
for (const service of services) this.revoke(instance, service);
const agentDir = dirname(this.#storePath(instance, 'gitea'));
let names;
try {
names = readdirSync(agentDir);
} catch (e) {
if (e.code === 'ENOENT') return { services };
deny('credential-file');
}
if (names.some((n) => !/^(gitea|vikunja)(\.tmp)?$/.test(n))) deny('credential-file');
for (const n of names) unlinkSync(join(agentDir, n));
rmdirSync(agentDir);
return { services };
}
// The agent-facing use (design D5): the key comes only from the broker's verified session.
async useFor(session, service, operation) {
if (!session || typeof session.business !== 'string' || typeof session.role !== 'string' || session.role.startsWith('@'))
deny('credential-not-yours');
return this.use(session.business + '/' + session.role, service, operation);
}
assertClean(value) {
let encoded;
@@ -109,14 +247,16 @@ export class Credentials {
let result;
try {
result = await operation(entry.token);
} catch {
} catch (e) {
// Row 199: a host step's own refusal (publish) keeps its code; anything else is the service's.
if (e instanceof BusError) throw e;
deny('service-failed');
}
this.assertClean(result);
return result;
}
status() {
return [...this.#tokens.entries()].map(([key, { date }]) => {
return [...this.#refs.entries()].map(([key, { date }]) => {
const i = key.lastIndexOf(':'),
service = key.slice(i + 1),
left = Date.parse(date + 'T00:00:00Z') - Date.now();
@@ -124,8 +264,11 @@ export class Credentials {
instance: key.slice(0, i),
service,
date,
state:
service === 'gitea'
state: !this.#tokens.has(key)
? this.#revoked.has(key)
? 'revoked'
: 'not-issued'
: service === 'gitea'
? left <= 0
? 'rotation-due'
: 'valid'
@@ -139,5 +282,6 @@ export class Credentials {
}
close() {
this.#tokens.clear();
this.#refs.clear();
}
}
+13 -1
View File
@@ -14,6 +14,11 @@ const LAUNCH_OPS = new Set([
'checkLand',
'recordLand',
'landRefused',
'checkPublish',
'publishPush',
'publishResult',
'recordPublish',
'publishRefused',
'landHeld',
'threadsState',
'threadIdle',
@@ -34,6 +39,12 @@ function launchOp(m) {
if (m.op === 'checkLand') return runtime.checkLand(m.cap, m.args);
if (m.op === 'recordLand') return runtime.recordLand(m.ticket, m.land);
if (m.op === 'landRefused') return runtime.landRefused(m.ticket, m.args);
// Row 199 (#1697, design D9b): the git socket's publish op. No entry returns a token.
if (m.op === 'checkPublish') return runtime.checkPublish(m.cap, m.args);
if (m.op === 'publishPush') return runtime.publishPush(m.ticket);
if (m.op === 'publishResult') return runtime.publishResult(m.ticket);
if (m.op === 'recordPublish') return runtime.recordPublish(m.ticket, m.pushed);
if (m.op === 'publishRefused') return runtime.publishRefused(m.ticket, m.args);
if (m.op === 'landHeld') return runtime.landHeld(m.record);
// Row 121 (#1615): the launcher's view of the business's threads, and its idle release.
if (m.op === 'threadsState') return runtime.threadsState(m.business);
@@ -99,7 +110,8 @@ if (!process.send) {
booted = true;
clearTimeout(timer);
// `trackers` is S3's plain-data boot config; the adapter is loaded only when a business has one.
const { trackers, ...config } = message.config ?? {};
// Row 199: publishTls is the tests' scratch CA; no boot config sets it.
const { trackers, publishTls: _tls, ...config } = message.config ?? {};
if (trackers) {
const { tasksAdapter } = await import('../../tasks/src/adapter.mjs');
config.tasks = tasksAdapter({ trackers });
+56 -1
View File
@@ -8,9 +8,12 @@ import { Credentials } from './credentials.mjs';
import { serve } from './server.mjs';
import { launchSettings } from '../../business/src/launch-settings.mjs';
import { readSessions } from '../../seat/src/session.mjs';
import { publishRemote, workingRepo, WorkingError } from '../../seat/src/working.mjs';
import { createLoader, hasModule } from '../../plugins/src/loader.mjs';
import { pluginSettings } from '../../business/src/plugin-settings.mjs';
import { instanceSettings } from '../../business/src/instance-settings.mjs';
import { organizationWriter } from '../../business/src/organization.mjs';
import { credentialVerbs } from './credential-verbs.mjs';
import { webService } from './web.mjs';
import { verifyHuman } from './human.mjs';
import { createUsage } from './usage.mjs';
@@ -72,6 +75,9 @@ export async function startBroker({
repoRoots = [],
tasks = null,
organization = null,
// Row 199: the tests' scratch CA for publish's git (`-c http.sslCAInfo=...`). process.mjs drops
// it from the boot config, so only an in-process caller sets it.
publishTls = [],
}) {
let store, credentials, server, adapter;
try {
@@ -86,7 +92,9 @@ export async function startBroker({
...repoRoots,
...Object.values(businesses).flatMap((b) => Object.values(b.projects ?? {}).map((p) => p.root)),
];
credentials = new Credentials({ references, repoRoots: protectedRoots, dataRoot });
// Row 199 (#1697, design D1): the per-agent store is <configDir>/secrets/agents.
const storeRoot = organization ? join(organization.dir, 'secrets', 'agents') : null;
credentials = new Credentials({ references, repoRoots: protectedRoots, dataRoot, storeRoot });
store = new Store(dataRoot);
// vikunja:19/305: the plug-in switch through the same human-only path as the limits: the verbs
// plugins and plugins.set, logged in changes.jsonl with the actor, a missing module refused.
@@ -133,6 +141,11 @@ export async function startBroker({
}:null,
secretCheck: (value) => credentials.assertClean(value),
credentialStatus: () => credentials.status(),
// Row 199 (#1697, design section 6): issue, rotate, revoke and delete, live with no restart; each
// rewrites or records through the organization writer, so changes.jsonl holds the receipt.
credentialVerbs: organization
? credentialVerbs({ credentials, writer: organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir }) })
: null,
// Row 65: a new launch binds only on a harness its business switches on, read from the file
// as it stands now; an unreadable file switches none on.
harnessOn: (id, name) => {
@@ -212,6 +225,42 @@ export async function startBroker({
// Row 137 (#1631): the git socket's land op. checkLand's refusal is the run's, like checkPush's.
// A landing then ends in recordLand or landRefused under its ticket, which outlive the run's cap.
const checkLand = (cap, args) => refusing(cap, () => broker.checkLand(cap, args), args?.task_ref);
// Row 199 (#1697, design D9b): the git socket's publish op. checkPublish's refusal is the run's.
// The gitea token is used only under the open ticket, keyed from the agent checkPublish checked,
// and the op table returns no token. A publish then ends in recordPublish or publishRefused,
// which files any refusal once, withGitea's included.
const checkPublish = (cap, args) => refusing(cap, () => broker.checkPublish(cap, args), args?.task_ref);
const withGitea = async (ticket, fn) => credentials.useFor(broker.publishSession(ticket), 'gitea', fn);
// The push itself runs here, where the token is (D9 step 5), and the git socket's host polls
// for its end: the host's op channel answers one op at a time within 10 s, and ls-remote and
// push can take longer. Fork, user, source and dst all come from the ticket. A WorkingError's
// code is the refusal; git's stderr stays here.
const remotes = new Map();
function publishPush(ticket) {
if (remotes.has(ticket)) throw new BusError('publish-busy');
const { business, project, fork, user, source, dst } = broker.publishTarget(ticket);
const entry = { state: 'running' };
remotes.set(ticket, entry);
withGitea(ticket, (token) =>
publishRemote({ repo: workingRepo(dataRoot, business, project), fork, user, source, dst, token, tls: publishTls }).catch((e) => {
throw e instanceof WorkingError ? new BusError(e.code) : e;
}),
).then(
(r) => Object.assign(entry, { state: 'pushed', ...r }),
(e) => {
// Not a refusal: the error's name and code only, never a message that could carry git's output.
if (!(e instanceof BusError)) process.stderr.write(`bus: publish git-failed: ${JSON.stringify({ name: e?.name, code: e?.code })}\n`);
Object.assign(entry, { state: 'refused', code: e instanceof BusError ? e.code : 'git-failed' });
},
);
return { state: 'running' };
}
function publishResult(ticket) {
const entry = remotes.get(ticket);
if (!entry) throw new BusError('publish-unknown');
if (entry.state !== 'running') remotes.delete(ticket);
return { ...entry };
}
// Metadata only (service, date, state); the token stays in Credentials.
function credentialStatus(business, role) {
return credentials
@@ -237,6 +286,12 @@ export async function startBroker({
checkLand,
recordLand: (ticket, land) => broker.recordLand(ticket, land),
landRefused: (ticket, args) => broker.landRefused(ticket, args),
checkPublish,
withGitea,
publishPush,
publishResult,
recordPublish: (ticket, pushed) => broker.recordPublish(ticket, pushed),
publishRefused: (ticket, args) => broker.publishRefused(ticket, args),
landHeld: (record) => broker.landHeld(record),
// Row 156 (#1651): the launcher's landing pass.
landHolders: (business) => broker.landHolders(business),
@@ -0,0 +1,182 @@
// Row 199 (#1697, design D1, D3a, D4, section 6, T1 and T5): the per-agent credential store under
// <configDir>/secrets/agents/<business>/<agent>/<service>. Fake tokens in a scratch root only.
import test from 'node:test';
import assert from 'node:assert/strict';
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Credentials } from '../src/credentials.mjs';
const TOKEN = 'fake_store_token_0123456789';
const ROTATED = 'fake_rotated_token_9876543210';
const AGENT = { business: 'acme', role: 'coder' };
function setup(t) {
const root = mkdtempSync(join(tmpdir(), 'bus-store-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const storeRoot = join(root, 'secrets', 'agents');
mkdirSync(storeRoot, { recursive: true, mode: 0o700 });
chmodSync(join(root, 'secrets'), 0o700);
const file = join(storeRoot, 'acme', 'coder', 'gitea');
const references = { 'acme/coder': { gitea: { service: 'gitea', store: true, file, rotateBy: '2099-01-01' } } };
const open = (extra = {}) => new Credentials({ references, storeRoot, repoRoots: [], dataRoot: '/nonexistent-data', ...extra });
const state = (c) => c.status().find((e) => e.instance === 'acme/coder' && e.service === 'gitea').state;
const read = (c, session = AGENT) => c.useFor(session, 'gitea', async (token) => ({ same: token === TOKEN, rotated: token === ROTATED }));
return { root, storeRoot, file, references, open, state, read };
}
test('T1: a store reference with no file yet starts, and the agent is not-issued until issue', async (t) => {
const s = setup(t);
const c = s.open();
assert.equal(s.state(c), 'not-issued');
await assert.rejects(s.read(c), /credential-unavailable/);
const status = c.issue('acme/coder', 'gitea', TOKEN, '2099-01-01');
assert.equal(status.state, 'valid');
assert.deepEqual(await s.read(c), { same: true, rotated: false });
assert.equal(statSync(s.file).mode & 0o777, 0o600);
for (const dir of [join(s.storeRoot, 'acme'), join(s.storeRoot, 'acme', 'coder')]) assert.equal(statSync(dir).mode & 0o777, 0o700);
assert.deepEqual(readdirSync(join(s.storeRoot, 'acme', 'coder')), ['gitea']);
// A restart reads the issued file.
assert.deepEqual(await s.read(s.open()), { same: true, rotated: false });
});
test('T1: a store path the reference does not own, or no store root, refuses the reference', (t) => {
const s = setup(t);
const other = join(s.storeRoot, 'acme', 'pm', 'gitea');
assert.throws(() => s.open({ references: { 'acme/coder': { gitea: { store: true, file: other, rotateBy: '2099-01-01' } } } }), /credential-reference/);
assert.throws(() => new Credentials({ references: s.references, repoRoots: [], dataRoot: '/nonexistent-data' }), /credential-reference/);
assert.throws(() => s.open({ references: { 'acme/@tester': { gitea: { store: true, file: join(s.storeRoot, 'acme', '@tester', 'gitea'), rotateBy: '2099-01-01' } } } }),
/credential-reference/);
});
test('T1: a wrong mode on the store root, a business dir, an agent dir or the file refuses start', (t) => {
const s = setup(t);
s.open().issue('acme/coder', 'gitea', TOKEN, '2099-01-01');
for (const [path, bad, good] of [
[s.storeRoot, 0o750, 0o700],
[join(s.storeRoot, 'acme'), 0o705, 0o700],
[join(s.storeRoot, 'acme', 'coder'), 0o770, 0o700],
[s.file, 0o640, 0o600],
]) {
chmodSync(path, bad);
assert.throws(() => s.open(), /credential-file/, path);
chmodSync(path, good);
}
s.open();
});
test('T1: a missing store root refuses start; a missing {file} reference still refuses start', (t) => {
const s = setup(t);
rmSync(s.storeRoot, { recursive: true });
assert.throws(() => s.open(), /credential-file/);
assert.throws(() => s.open({ references: { coder: { gitea: { file: join(s.root, 'absent'), rotateBy: '2099-01-01' } } } }), /credential-file/);
});
test('T1: a store file that is a directory or a symlink refuses start', (t) => {
const s = setup(t);
mkdirSync(join(s.storeRoot, 'acme', 'coder'), { recursive: true, mode: 0o700 });
chmodSync(join(s.storeRoot, 'acme'), 0o700);
mkdirSync(s.file, { mode: 0o700 });
assert.throws(() => s.open(), /credential-file/);
rmSync(s.file, { recursive: true });
const target = join(s.root, 'target');
writeFileSync(target, TOKEN + '\n', { mode: 0o600 });
symlinkSync(target, s.file);
assert.throws(() => s.open(), /credential-file/);
});
test('T2: useFor keys from the session only', async (t) => {
const s = setup(t);
const c = s.open();
c.issue('acme/coder', 'gitea', TOKEN, '2099-01-01');
await assert.rejects(s.read(c, { business: 'acme', role: 'pm' }), /credential-unavailable/);
await assert.rejects(s.read(c, { business: 'acme', role: '@tester' }), /credential-not-yours/);
await assert.rejects(s.read(c, null), /credential-not-yours/);
await assert.rejects(s.read(c, { business: 'acme' }), /credential-not-yours/);
});
test('T5: issue, rotate, revoke and delete are live with no restart, and revoke holds across one', async (t) => {
const s = setup(t);
const c = s.open();
let committed = 0;
c.issue('acme/coder', 'gitea', TOKEN, '2099-01-01', () => committed++);
assert.equal(committed, 1);
// Rotate is a second issue: the new token is used at once, with no tmp file left.
c.issue('acme/coder', 'gitea', ROTATED + '\n', '2099-06-01');
assert.deepEqual(await s.read(c), { same: false, rotated: true });
assert.equal(c.status()[0].date, '2099-06-01');
assert.deepEqual(readdirSync(join(s.storeRoot, 'acme', 'coder')), ['gitea']);
// Revoke: memory first, then the file.
assert.deepEqual(c.revoke('acme/coder', 'gitea'), { file: true });
assert.equal(s.state(c), 'revoked');
assert.equal(existsSync(s.file), false);
await assert.rejects(s.read(c), /credential-unavailable/);
const restarted = s.open();
await assert.rejects(s.read(restarted), /credential-unavailable/);
assert.equal(s.state(restarted), 'not-issued');
// Delete removes the agent's dir.
c.issue('acme/coder', 'gitea', TOKEN, '2099-01-01');
assert.deepEqual(c.remove('acme/coder'), { services: ['gitea'] });
assert.equal(existsSync(join(s.storeRoot, 'acme', 'coder')), false);
await assert.rejects(s.read(c), /credential-unavailable/);
});
test('T5: a failed commit leaves the token on disk but not in memory, and a bad token or date writes nothing', async (t) => {
const s = setup(t);
const c = s.open();
assert.throws(() => c.issue('acme/coder', 'gitea', 'short', '2099-01-01'), /credential-format/);
assert.throws(() => c.issue('acme/coder', 'gitea', TOKEN, '2099-13-01'), /credential-date/);
assert.throws(() => c.issue('acme/pm', 'gitea', TOKEN, '2099-01-01'), /credential-unknown/);
assert.equal(existsSync(s.file), false);
assert.throws(() => c.issue('acme/coder', 'gitea', TOKEN, '2099-01-01', () => { throw new Error('commit failed'); }), /commit failed/);
assert.equal(existsSync(s.file), true);
assert.equal(s.state(c), 'not-issued');
await assert.rejects(s.read(c), /credential-unavailable/);
});
test('T5: revoke of a {file} reference forgets the token and never unlinks the file', async (t) => {
const s = setup(t);
const path = join(s.root, 'outside-token');
writeFileSync(path, TOKEN + '\n', { mode: 0o600 });
const c = s.open({ references: { 'acme/coder': { gitea: { file: path, rotateBy: '2099-01-01' } } } });
assert.deepEqual(await s.read(c), { same: true, rotated: false });
assert.deepEqual(c.revoke('acme/coder', 'gitea'), { file: false });
assert.equal(existsSync(path), true);
await assert.rejects(s.read(c), /credential-unavailable/);
});
test('T5: delete refuses an agent dir holding anything but tokens', (t) => {
const s = setup(t);
const c = s.open();
c.issue('acme/coder', 'gitea', TOKEN, '2099-01-01');
writeFileSync(join(s.storeRoot, 'acme', 'coder', 'notes'), 'x', { mode: 0o600 });
assert.throws(() => c.remove('acme/coder'), /credential-file/);
assert.equal(existsSync(join(s.storeRoot, 'acme', 'coder', 'notes')), true);
});
// Design D5: the name-keyed use() is the host's own, for @sync and the tracker's start check of each
// role's token. Every other credential use goes through useFor, keyed from the broker's session. A
// new use( call site outside this list fails here.
test('D5: every credentials use( call site is on the allowlist', () => {
const packages = new URL('../../', import.meta.url);
const allowed = new Map([
['bus/src/credentials.mjs', 1], // useFor's own call
['tasks/src/adapter.mjs', 1], // the host's use(instance) for @sync and probe
]);
const found = new Map();
const walk = (dir, rel) => {
for (const name of readdirSync(dir)) {
if (name === 'node_modules' || name === 'tests') continue;
const path = join(dir, name);
if (statSync(path).isDirectory()) walk(path, `${rel}${name}/`);
else if (name.endsWith('.mjs') || name.endsWith('.js')) {
const code = readFileSync(path, 'utf8').split('\n').filter((line) => !/^\s*\/\//.test(line)).join('\n');
const n = (code.match(/(?:credentials|this)\.use\(/g) ?? []).length;
if (n) found.set(`${rel}${name}`, n);
}
}
};
for (const pkg of readdirSync(packages)) if (statSync(new URL(`${pkg}/src`, packages), { throwIfNoEntry: false })?.isDirectory())
walk(new URL(`${pkg}/src`, packages).pathname, `${pkg}/src/`);
assert.deepEqual(Object.fromEntries([...found].sort()), Object.fromEntries([...allowed].sort()));
});
@@ -0,0 +1,163 @@
// Row 199 (#1697, design section 6, T4, T5): credentials.issue, credentials.revoke and
// credentials.delete through the runtime. Human-only; the tester revokes and never issues. Each verb
// is live with no restart and leaves one receipt in changes.jsonl with no value in it. Fake tokens in a
// scratch root only.
import test from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { startBroker } from '../src/runtime.mjs';
import { Broker, CREDENTIAL_VERBS } from '../src/broker.mjs';
import { Store } from '../src/store.mjs';
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../business/tests/helpers.mjs';
const TOKEN = 'fake_issued_token_0123456789';
const ROTATED = 'fake_rotated_token_9876543210';
const OTHER = 'fake_other_token_5555555555';
const DATE = '2099-01-01';
const role = { authority: { withinRole: ['message.send'], crossRole: [] } };
async function setup(t) {
const root = mkdtempSync(join(tmpdir(), 'bus-cred-verbs-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const rolesDir = rolesCopy(root);
const doc = { ...businessDoc(root, 'demo'), plugins: { harness: ['pi'] } };
doc.roles.coder.credentials.gitea = { store: true, rotateBy: DATE };
writeJson(join(root, 'businesses/demo.json'), doc);
mkdirSync(join(root, 'data'), { mode: 0o700 });
mkdirSync(join(root, 'secrets', 'agents'), { mode: 0o700 });
const store = (agent, service) => join(root, 'secrets', 'agents', 'demo', agent, service);
// The bus's resolved business carries the same references; a store one names its derived file.
const demo = {
id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, vars: { 'bus.tester': true },
roles: {
coder: { ...role, credentials: { gitea: { store: true, file: store('coder', 'gitea'), rotateBy: DATE }, vikunja: doc.roles.coder.credentials.vikunja } },
reviewer: { ...role, credentials: doc.roles.reviewer.credentials },
},
};
const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo }, organization: { dir: root, rolesDir, system: systemFor(root) } });
t.after(() => bus.close());
const human = bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const tester = bus.broker.bindTester({ business: 'demo' });
const ask = (cap, verb, args) => bus.broker.request(cap, { verb, args });
const file = () => JSON.parse(readFileSync(join(root, 'businesses/demo.json'), 'utf8'));
const receipts = () => {
const log = join(root, 'businesses/.history/demo/changes.jsonl');
return existsSync(log) ? readFileSync(log, 'utf8').trim().split('\n').map(JSON.parse) : [];
};
const committed = () => receipts().filter((r) => r.state === 'committed');
const status = (agent) => Object.fromEntries(bus.credentialStatus('demo', agent).map((e) => [e.service, e.state]));
return { root, bus, human, tester, ask, file, receipts, committed, status, store };
}
test('row 199: the lifecycle verbs refuse every session but the human, and the tester revokes only', (t) => {
const root = mkdtempSync(join(tmpdir(), 'bus-cred-gate-'));
const s = new Store(root);
t.after(() => { s.close(); rmSync(root, { recursive: true, force: true }); });
const calls = [];
const b = new Broker({
store: s,
businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, vars: { 'bus.tester': true }, roles: { coder: role } } },
credentialVerbs: (id, verb, args, actor) => (calls.push([verb, actor]), { ok: true }),
});
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const tester = b.bindTester({ business: 'demo' });
const reader = b.bindReader({ business: 'demo' });
const coder = b.bindLaunch({ business: 'demo', role: 'coder', run: 'coder-run', harness: 'pi' });
const args = {
'credentials.issue': { agent: 'coder', service: 'gitea', token: TOKEN, date: DATE },
'credentials.revoke': { agent: 'coder', reason: 'fired' },
'credentials.delete': { agent: 'coder', reason: 'fired' },
};
assert.deepEqual(CREDENTIAL_VERBS, Object.keys(args));
for (const verb of CREDENTIAL_VERBS) {
assert.throws(() => b.request(reader, { verb, args: args[verb] }), /human-required/, verb);
assert.throws(() => b.request(coder, { verb, args: args[verb] }), /human-required/, verb);
}
assert.throws(() => b.request(tester, { verb: 'credentials.issue', args: args['credentials.issue'] }), /tester-refused/);
assert.throws(() => b.request(tester, { verb: 'credentials.delete', args: args['credentials.delete'] }), /tester-refused/);
// Keys: a token on revoke, a missing reason, an unknown service, an unknown agent, an @ agent.
assert.throws(() => b.request(human, { verb: 'credentials.revoke', args: { ...args['credentials.revoke'], token: TOKEN } }), /invalid-request/);
assert.throws(() => b.request(human, { verb: 'credentials.revoke', args: { agent: 'coder' } }), /invalid-request/);
assert.throws(() => b.request(human, { verb: 'credentials.issue', args: { ...args['credentials.issue'], service: 'model' } }), /invalid-request/);
assert.throws(() => b.request(human, { verb: 'credentials.issue', args: { ...args['credentials.issue'], file: '/x' } }), /invalid-request/);
assert.throws(() => b.request(human, { verb: 'credentials.issue', args: { ...args['credentials.issue'], agent: 'pm' } }), /credential-unknown/);
assert.throws(() => b.request(human, { verb: 'credentials.revoke', args: { agent: '@sync', reason: 'x' } }), /credential-unknown/);
assert.equal(calls.length, 0);
// A refusal records its code alone, never the token.
const refused = s.all("SELECT body FROM events WHERE kind='action.refused'");
assert.ok(refused.length >= 10);
for (const { body } of refused) assert.equal(body.includes(TOKEN), false);
// delete waits for the agent's claim to end.
b.request(coder, { verb: 'role.claim' });
assert.throws(() => b.request(human, { verb: 'credentials.delete', args: args['credentials.delete'] }), /agent-running/);
b.request(coder, { verb: 'role.release' });
for (const verb of CREDENTIAL_VERBS) b.request(human, { verb, args: args[verb] });
b.request(tester, { verb: 'credentials.revoke', args: args['credentials.revoke'] });
assert.deepEqual(calls, [['credentials.issue', 'jason'], ['credentials.revoke', 'jason'], ['credentials.delete', 'jason'], ['credentials.revoke', 'tester']]);
// No lifecycle without the runtime's store.
const bare = new Broker({ store: s, businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: role } } } });
const h2 = bare.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
assert.throws(() => bare.request(h2, { verb: 'credentials.revoke', args: args['credentials.revoke'] }), /not-configured/);
});
test('T5: issue, rotate, revoke and delete are live, rewrite the business file and leave one receipt each, with no value anywhere', async (t) => {
const c = await setup(t);
const coder = { business: 'demo', role: 'coder' };
const read = (service) => c.bus.credentials.useFor(coder, service, async (token) => token === TOKEN ? 'issued' : token === ROTATED ? 'rotated' : 'other');
assert.deepEqual(c.status('coder'), { gitea: 'not-issued', vikunja: 'valid' });
// issue: the file, then the business file's reference, then memory.
const issued = c.ask(c.human, 'credentials.issue', { agent: 'coder', service: 'gitea', token: TOKEN + '\n', date: DATE });
assert.deepEqual(issued, { agent: 'coder', verb: 'issue', service: 'gitea', date: DATE, state: 'valid' });
assert.equal(await read('gitea'), 'issued');
assert.equal(statSync(c.store('coder', 'gitea')).mode & 0o777, 0o600);
assert.deepEqual(c.file().roles.coder.credentials.gitea, { store: true, rotateBy: DATE });
let last = c.committed().at(-1);
assert.equal(last.actor, 'jason');
assert.deepEqual(last.detail, { verb: 'issue', agent: 'coder', kind: ['gitea'], date: DATE });
// The same token again is one the bus holds: the secret check refuses it before any write.
const count = c.receipts().length;
assert.throws(() => c.ask(c.human, 'credentials.issue', { agent: 'coder', service: 'gitea', token: TOKEN, date: DATE }), /credential-leak/);
assert.throws(() => c.ask(c.human, 'credentials.issue', { agent: 'coder', service: 'gitea', token: 'short', date: DATE }), /credential-format/);
assert.throws(() => c.ask(c.human, 'credentials.issue', { agent: 'coder', service: 'gitea', token: OTHER, date: '2099-02-30' }), /credential-date/);
assert.equal(c.receipts().length, count);
// rotate: an issue over an issued entry, used at once.
const rotated = c.ask(c.human, 'credentials.issue', { agent: 'coder', service: 'gitea', token: ROTATED, date: '2099-06-01' });
assert.equal(rotated.verb, 'rotate');
assert.equal(await read('gitea'), 'rotated');
assert.deepEqual(c.file().roles.coder.credentials.gitea, { store: true, rotateBy: '2099-06-01' });
assert.deepEqual(c.committed().at(-1).detail, { verb: 'rotate', agent: 'coder', kind: ['gitea'], date: '2099-06-01' });
// revoke of a store entry by the tester: memory and file, no document change, a receipt.
const bytes = readFileSync(join(c.root, 'businesses/demo.json'), 'utf8');
const revoked = c.ask(c.tester, 'credentials.revoke', { agent: 'coder', service: 'gitea', reason: 'test' });
assert.deepEqual(revoked, { agent: 'coder', verb: 'revoke', services: [{ service: 'gitea', state: 'revoked' }] });
await assert.rejects(read('gitea'), /credential-unavailable/);
assert.equal(existsSync(c.store('coder', 'gitea')), false);
assert.equal(readFileSync(join(c.root, 'businesses/demo.json'), 'utf8'), bytes);
last = c.committed().at(-1);
assert.equal(last.actor, 'tester');
assert.deepEqual(last.changes, []);
assert.deepEqual(last.detail, { verb: 'revoke', agent: 'coder', kind: ['gitea'], reason: 'test' });
// revoke of a {file} reference: memory, the outside file kept, the reference becomes store.
const outside = c.file().roles.coder.credentials.vikunja.file;
c.ask(c.human, 'credentials.revoke', { agent: 'coder', service: 'vikunja', reason: 'moved' });
await assert.rejects(read('vikunja'), /credential-unavailable/);
assert.equal(existsSync(outside), true);
assert.deepEqual(c.file().roles.coder.credentials.vikunja, { store: true, expires: DATE });
assert.ok(c.committed().at(-1).changes.some((e) => e.path === 'roles.coder.credentials.vikunja.store' && e.new === true));
// delete: every kind revoked, the agent's dir gone, both references store.
c.ask(c.human, 'credentials.issue', { agent: 'reviewer', service: 'gitea', token: OTHER, date: DATE });
assert.equal(existsSync(c.store('reviewer', 'gitea')), true);
const deleted = c.ask(c.human, 'credentials.delete', { agent: 'reviewer', reason: 'fired' });
assert.deepEqual(deleted.services.map((e) => e.state), ['revoked', 'revoked']);
assert.equal(existsSync(join(c.root, 'secrets', 'agents', 'demo', 'reviewer')), false);
assert.deepEqual(c.file().roles.reviewer.credentials, { gitea: { store: true, rotateBy: DATE }, vikunja: { store: true, expires: DATE } });
assert.deepEqual(c.committed().at(-1).detail, { verb: 'delete', agent: 'reviewer', kind: ['gitea', 'vikunja'], reason: 'fired' });
// The status read never carries a value.
assert.deepEqual(c.ask(c.human, 'credentials', {}).map((e) => e.state).sort(), ['revoked', 'revoked', 'revoked', 'revoked']);
// T4: no token in any file under the root (receipts, business file, events, logs).
const walk = (dir) => readdirSync(dir, { withFileTypes: true }).flatMap((e) => e.isDirectory() ? walk(join(dir, e.name)) : e.isFile() ? [join(dir, e.name)] : []);
for (const path of walk(c.root))
for (const token of [TOKEN, ROTATED, OTHER]) assert.equal(readFileSync(path).includes(token), false, path);
});
+7 -3
View File
@@ -10,7 +10,7 @@ import { fixture } from './spec-fixtures.mjs';
// PRD 1.5 REQ-WAIT-1, REQ-WAIT-2 and REQ-DEC-2 (row 139, #1633).
const WAITS = ['spend', 'data.delete', 'network.expose', 'account.create', 'credential.mint', 'message.external',
'git.push.protected', 'deploy'];
'git.push.protected', 'git.push.remote', 'deploy'];
const LEFT = ['git.merge.protected', 'policy.change', 'prd.approve', 'role.revoke'];
const cite = f => ({ project: 'stack', spec_path: 'spec/north-star.md', spec_commit: f.commit });
const routed = (f, d) => ({ ...f.store.get('SELECT class, route_to FROM decisions WHERE id=?', d.id) });
@@ -36,10 +36,14 @@ test('REQ-WAIT-2: a business granting a hard wait at either class is refused; th
});
test('REQ-DEC-2: every hard wait goes to the human, cited or not, and the arbiter cannot resolve it', async t => {
const f = await fixture(t);
const f = await fixture(t, { roles: { coder: { definition: 'coder',
authority: { withinRole: ['message.send', 'record.write', 'git.push.working'], crossRole: [] } } } });
// Row 199: a git.push.remote raise binds the task's latest push, so the task has one.
f.broker.recordPush(f.coder, { task_ref: 'vikunja:1/2', project: 'stack', branch: 'feat', ref: 'work/vikunja-stack-2',
commit: 'a'.repeat(40), previous: null });
for (const action of WAITS) {
for (const args of [{}, cite(f), { ...cite(f), domain: 'technical' }]) {
const d = await f.raise({ action, ...args });
const d = await f.raise({ action, ...(action === 'git.push.remote' ? { target: 'vikunja:1/2' } : {}), ...args });
assert.deepEqual(routed(f, d), { class: 'gated', route_to: 'human' }, `${action} ${JSON.stringify(args)}`);
}
}
+2 -2
View File
@@ -22,8 +22,8 @@ test('Broker keeps the sandbox client import closure: only the listed imports, n
"import { MERGE, LandingRefusal, readLanding } from './landing.mjs';",
// Row 141 (#1635): the business ID_PATTERN for instance names. The vocabulary is already in
// RUNNER_FILES and imports nothing. Row 155 (#1649) reads THREAD_DEFINITION and END_PROTECTED
// from it too.
"import { ID_PATTERN, THREAD_DEFINITION, END_PROTECTED } from '../../business/src/vocabulary.mjs';",
// from it too. Row 199 (#1697) reads the D9c fork parse, forkProblem and forkUrl, from it.
"import { ID_PATTERN, THREAD_DEFINITION, END_PROTECTED, forkProblem, forkUrl } from '../../business/src/vocabulary.mjs';",
]);
assert.doesNotMatch(source, /\bimport\s*\(/);
assert.doesNotMatch(readFileSync(new URL('../src/landing.mjs', import.meta.url), 'utf8'), /\bimport\b/);
+274
View File
@@ -0,0 +1,274 @@
// Row 199 (#1697, design D9 steps 1 to 3 and 6, D9b): the broker side of publish. git.push.remote
// is a hard wait; its decision binds the task's latest push (pushCommit). checkPublish builds the
// agent's fork from the project's upstream and the agent's own Gitea user; recordPublish spends the
// decision; publishRefused files the refusal.
import test from 'node:test';
import assert from 'node:assert/strict';
import { fork } from 'node:child_process';
import { once } from 'node:events';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Store } from '../src/store.mjs';
import { Broker } from '../src/broker.mjs';
const TASK = 'vikunja:1/2';
const A = 'a'.repeat(40);
const B = 'b'.repeat(40);
const C = 'c'.repeat(40);
const identity = (user) => ({ name: user, email: `${user}@example.com`, gitea: { url: 'https://git.example', user } });
const pusher = { withinRole: ['git.push.working', 'message.send'], crossRole: [] };
function businesses(upstream = 'https://git.example/mosaic/stack') {
return {
demo: {
id: 'demo',
human: 'jason',
arbiters: { technical: 'reviewer', delivery: 'reviewer' },
projects: { stack: { root: '/nonexistent', remote: upstream }, books: { root: '/nonexistent-books' } },
roles: {
coder: { authority: pusher, identity: identity('acme-coder'), publish: ['stack'] },
second: { authority: pusher, identity: identity('acme-second'), publish: ['stack'] },
plain: { authority: pusher },
reviewer: { authority: { withinRole: ['message.send'], crossRole: [] } },
},
},
};
}
function setup(t, upstream) {
const root = mkdtempSync(join(tmpdir(), 'bus-publish-'));
const store = new Store(root);
const b = new Broker({ store, businesses: businesses(upstream) });
t.after(() => {
store.close();
rmSync(root, { recursive: true, force: true });
});
const caps = {};
for (const role of ['coder', 'second', 'plain', 'reviewer']) {
caps[role] = b.bindLaunch({ business: 'demo', role, run: `${role}-run`, harness: 'pi' });
b.request(caps[role], { verb: 'role.claim', args: {} });
}
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const call = (cap, verb, args) => b.request(cap, { verb, args });
const push = (commit, previous = null, role = 'coder', project = 'stack') => {
b.checkPush(caps[role], TASK);
return b.recordPush(caps[role], { task_ref: TASK, project, branch: 'feat', ref: 'work/vikunja-stack-2', commit, previous });
};
const raise = (role = 'coder') =>
call(caps[role], 'decision.raise', {
action: 'git.push.remote', target: TASK, task_ref: TASK, question: 'Publish?', blocking: false, recommendation: 'yes',
options: [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }],
});
const approve = (role = 'coder') => {
const d = raise(role);
call(human, 'decision.resolve', { id: d.id, choice: 'yes' });
return d;
};
const publish = (decision, role = 'coder', extra = {}) =>
b.checkPublish(caps[role], { task_ref: TASK, project: 'stack', branch: 'feat', ...(decision ? { decision: decision.id } : {}), ...extra });
const events = (kind) => store.all('SELECT * FROM events WHERE kind=? ORDER BY seq', kind).map((e) => ({ ...e, body: JSON.parse(e.body) }));
return { b, store, caps, human, call, push, raise, approve, publish, events };
}
test('a raise with no recorded push refuses no-push; a raise binds the latest push and shows it', (t) => {
const f = setup(t);
assert.throws(() => f.raise(), /no-push/);
f.push(A);
const d = f.raise();
assert.match(d.question, new RegExp(`commit ${A}\\)$`));
const raised = f.events('action.allowed').find((e) => e.body.operation === 'decision.raise');
assert.equal(raised.body.pushCommit, A);
assert.deepEqual({ ...f.store.get('SELECT class, route_to FROM decisions WHERE id=?', d.id) }, { class: 'gated', route_to: 'human' });
});
test('happy path: the fork is the upstream repo under the agent\'s own user, the destination is host-built', (t) => {
const f = setup(t);
f.push(A);
const d = f.approve();
const r = f.publish(d);
assert.deepEqual({ ...r, ticket: typeof r.ticket }, {
ticket: 'string', task_ref: TASK, project: 'stack', fork: 'https://git.example/acme-coder/stack',
user: 'acme-coder', email: '[email protected]', source: A, dst: 'refs/heads/acme-coder/feat',
});
const done = f.b.recordPublish(r.ticket, { ref: r.dst, commit: A, previous: null });
assert.deepEqual(done, { task_ref: TASK, fork: r.fork, ref: r.dst, commit: A, previous: null });
const allowed = f.events('action.allowed').filter((e) => e.body.action === 'git.push.remote' && e.body.operation !== 'decision.raise');
assert.equal(allowed.length, 1);
assert.equal(allowed[0].subject, TASK);
assert.equal(allowed[0].body.decision, d.id);
assert.equal(allowed[0].body.fork, r.fork);
// Spent once.
assert.throws(() => f.publish(d), /decision-consumed/);
assert.throws(() => f.b.recordPublish(r.ticket, { ref: r.dst, commit: A, previous: null }), /publish-unknown/);
});
test('a hard wait: no decision refuses, and a decision for another task refuses', (t) => {
const f = setup(t);
f.push(A);
assert.throws(() => f.publish(null), /decision-required/);
const other = f.call(f.caps.coder, 'decision.raise', {
action: 'deploy', target: 'vikunja:1/3', question: 'Ship?', blocking: false, recommendation: 'yes',
options: [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }],
});
f.call(f.human, 'decision.resolve', { id: other.id, choice: 'yes' });
assert.throws(() => f.publish(other), /decision-mismatch/);
const d = f.raise();
assert.throws(() => f.publish(d), /decision-not-approved/);
});
test('r4 B4: approve while the latest push is A, push B, publish refuses decision-mismatch', (t) => {
const f = setup(t);
f.push(A);
const d = f.approve();
f.push(B, A);
assert.throws(() => f.publish(d), /decision-mismatch/);
// And a push between checkPublish and recordPublish refuses the record.
const f2 = setup(t);
f2.push(A);
const d2 = f2.approve();
const r = f2.publish(d2);
f2.push(C, A);
assert.throws(() => f2.b.recordPublish(r.ticket, { ref: r.dst, commit: A, previous: null }), /decision-mismatch/);
assert.throws(() => f2.b.recordPublish(r.ticket, { ref: r.dst, commit: C, previous: null }), /decision-mismatch/);
});
test('grant and fork refusals', (t) => {
const f = setup(t);
f.push(A, null, 'plain');
// plain has no identity and no grant; its raise still binds the push, the publish refuses.
const d = f.approve('plain');
assert.throws(() => f.publish(d, 'plain'), /remote-not-granted/);
assert.throws(() => f.publish(d, 'plain', { project: 'books' }), /remote-not-granted|decision-mismatch/);
// work-not-yours: second publishes a push coder made.
const g = setup(t);
g.push(A);
const d2 = g.approve('second');
assert.throws(() => g.publish(d2, 'second'), /work-not-yours/);
});
test('remote-not-fork, compared without case, and remote-host-mismatch, at push', (t) => {
for (const [upstream, code] of [
['https://git.example/acme-coder/stack', /remote-not-fork/],
['https://git.example/ACME-Coder/stack', /remote-not-fork/],
['https://other.example/mosaic/stack', /remote-host-mismatch/],
['https://git.example:3000/mosaic/stack', /remote-host-mismatch/],
]) {
const f = setup(t, upstream);
f.push(A);
const d = f.approve();
assert.throws(() => f.publish(d), code, upstream);
}
});
test('two agents granted one project each get their own fork', (t) => {
const f = setup(t);
f.push(A);
const r1 = f.publish(f.approve());
assert.equal(r1.fork, 'https://git.example/acme-coder/stack');
const g = setup(t);
g.push(A, null, 'second');
const r2 = g.publish(g.approve('second'), 'second');
assert.equal(r2.fork, 'https://git.example/acme-second/stack');
assert.equal(r2.dst, 'refs/heads/acme-second/feat');
});
test('the request cannot name the fork, the user or the destination', (t) => {
const f = setup(t);
f.push(A);
const d = f.approve();
for (const extra of [{ fork: 'https://git.example/x/stack' }, { dst: 'refs/heads/main' }, { user: 'x' }, { remote: 'https://x' }])
assert.throws(() => f.publish(d, 'coder', extra), /invalid-request/);
});
test('publishRefused files the code on the task\'s trail and drops the ticket', (t) => {
const f = setup(t);
f.push(A);
const r = f.publish(f.approve());
assert.equal(f.b.publishRefused(r.ticket, { code: 'remote-unrelated' }), 'remote-unrelated');
const refused = f.events('action.refused').filter((e) => e.subject === TASK);
assert.deepEqual(refused.at(-1).body, { code: 'remote-unrelated' });
assert.throws(() => f.b.publishRefused(r.ticket, { code: 'x' }), /publish-unknown/);
});
test('git.push.remote is in neither ROUTED list and no shipped role grants it', async () => {
const { ACTIONS, HARD_WAITS } = await import('../src/broker.mjs');
const vocabulary = await import('../../business/src/vocabulary.mjs');
assert.ok(ACTIONS.includes('git.push.remote') && HARD_WAITS.includes('git.push.remote'));
assert.ok(!vocabulary.ROUTED.includes('git.push.remote'));
const { readdirSync, readFileSync } = await import('node:fs');
const dir = new URL('../../../roles/', import.meta.url);
for (const name of readdirSync(dir).filter((n) => n.endsWith('.json')))
assert.ok(!readFileSync(new URL(name, dir), 'utf8').includes('git.push.remote'), name);
});
// D1 and D9b through the real startBroker: the store is <configDir>/secrets/agents, and the gitea
// token is used only under an open publish ticket, keyed from the agent checkPublish checked.
test('runtime: withGitea keys from the open ticket only, and the store root is the organization dir', async (t) => {
const { startBroker } = await import('../src/runtime.mjs');
const { businessDoc, rolesCopy, systemFor, writeJson } = await import('../../business/tests/helpers.mjs');
const { chmodSync, mkdirSync } = await import('node:fs');
const root = mkdtempSync(join(tmpdir(), 'bus-publish-rt-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const rolesDir = rolesCopy(root);
// The runtime reads the harness switch from the organization file as it stands.
writeJson(join(root, 'businesses/demo.json'), { ...businessDoc(root, 'demo'), plugins: { harness: ['pi'] } });
mkdirSync(join(root, 'data'), { mode: 0o700 });
mkdirSync(join(root, 'secrets', 'agents'), { recursive: true, mode: 0o700 });
chmodSync(join(root, 'secrets'), 0o700);
const demo = businesses().demo;
demo.roles.coder.credentials = { gitea: { store: true, file: join(root, 'secrets', 'agents', 'demo', 'coder', 'gitea'), rotateBy: '2099-01-01' } };
const organization = { dir: root, rolesDir, system: systemFor(root) };
// No organization, no store root: a store reference refuses the start.
await assert.rejects(startBroker({ dataRoot: join(root, 'data'), businesses: { demo } }), /credential-reference/);
const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo }, organization });
t.after(() => bus.close());
assert.deepEqual(bus.credentialStatus('demo', 'coder'), [{ service: 'gitea', date: '2099-01-01', state: 'not-issued' }]);
const coder = bus.broker.bindLaunch({ business: 'demo', role: 'coder', run: 'coder-run', harness: 'pi' });
const human = bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
bus.broker.request(coder, { verb: 'role.claim', args: {} });
bus.checkPush(coder, TASK);
bus.recordPush(coder, { task_ref: TASK, project: 'stack', branch: 'feat', ref: 'work/vikunja-stack-2', commit: A, previous: null });
const d = bus.broker.request(coder, { verb: 'decision.raise', args: {
action: 'git.push.remote', target: TASK, task_ref: TASK, question: 'Publish?', blocking: false, recommendation: 'yes',
options: [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }] } });
bus.broker.request(human, { verb: 'decision.resolve', args: { id: d.id, choice: 'yes' } });
assert.throws(() => bus.checkPublish(coder, { task_ref: TASK, project: 'stack', branch: 'feat' }), /decision-required/);
const ticket = bus.checkPublish(coder, { task_ref: TASK, project: 'stack', branch: 'feat', decision: d.id }).ticket;
await assert.rejects(bus.withGitea(ticket, async () => 1), /credential-unavailable/);
bus.credentials.issue('demo/coder', 'gitea', 'fake_publish_token_0123456789', '2099-01-01');
assert.equal(await bus.withGitea(ticket, async (token) => token === 'fake_publish_token_0123456789'), true);
await assert.rejects(bus.withGitea(ticket, async (token) => ({ echo: token })), /credential-leak/);
await assert.rejects(bus.withGitea('not-a-ticket', async () => 1), /publish-unknown/);
await assert.rejects(bus.withGitea(coder, async () => 1), /publish-unknown/);
bus.recordPublish(ticket, { ref: 'refs/heads/acme-coder/feat', commit: A, previous: null });
await assert.rejects(bus.withGitea(ticket, async () => 1), /publish-unknown/);
});
// Row 199 (D9b, deviation 11): the bus process answers the socket's five publish ops from their own
// launchOp lines. A LAUNCH_OPS name without one would get launch-op-refused. A boot config that
// carries publishTls still boots; process.mjs drops it before startBroker.
test('the bus process answers the publish ops from their own lines, not launch-op-refused', async (t) => {
const root = mkdtempSync(join(tmpdir(), 'bus-publish-ops-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const child = fork(new URL('../src/process.mjs', import.meta.url), [], { stdio: ['ignore', 'ignore', 'ignore', 'ipc'] });
t.after(() => {
if (child.exitCode === null) child.kill('SIGKILL');
});
const ask = async (m) => {
const reply = once(child, 'message');
child.send(m);
return (await reply)[0];
};
const config = { dataRoot: root, businesses: businesses(), launches: [], readers: [], publishTls: ['-c', 'http.sslCAInfo=/nonexistent'] };
assert.equal((await ask({ op: 'boot', config })).ok, true);
const unknown = 'not-a-ticket';
assert.deepEqual(await ask({ op: 'publishPush', ticket: unknown, id: 1 }), { ok: false, error: 'publish-unknown', id: 1 });
assert.deepEqual(await ask({ op: 'publishResult', ticket: unknown, id: 2 }), { ok: false, error: 'publish-unknown', id: 2 });
assert.deepEqual(await ask({ op: 'recordPublish', ticket: unknown, pushed: { ref: 'refs/heads/x/y', commit: A, previous: null }, id: 3 }), { ok: false, error: 'publish-unknown', id: 3 });
assert.deepEqual(await ask({ op: 'publishRefused', ticket: unknown, args: { code: 'x' }, id: 4 }), { ok: false, error: 'publish-unknown', id: 4 });
assert.deepEqual(await ask({ op: 'checkPublish', cap: 'not-a-cap', args: { task_ref: TASK, project: 'stack', branch: 'feat' }, id: 5 }), { ok: false, error: 'unauthenticated', id: 5 });
const exit = once(child, 'exit');
child.send({ op: 'close' });
assert.equal((await exit)[0], 0);
});
+57 -8
View File
@@ -10,6 +10,7 @@ import { checkVars } from "./vars.mjs";
import { parseCredentialRef } from "./credentials.mjs";
import { loadRole } from "./role.mjs";
import { checkPlugins } from "../../plugins/src/kinds.mjs";
import { forkProblem, giteaName, parseGiteaUrl } from "./vocabulary.mjs";
import {
requireObject, rejectUnknownKeys, requireId, requirePositiveInt, requireString, requireDistinctList, readJsonFile, deepFreeze,
} from "./util.mjs";
@@ -40,7 +41,7 @@ function checkBot(tracker, where) {
return { bot: tracker.bot, botId: tracker.botId };
}
function checkCredentialMap(map, services, where) {
function checkCredentialMap(map, services, where, store = null) {
requireObject(map, where);
const names = Object.keys(map).sort();
const wanted = [...services].sort();
@@ -48,10 +49,37 @@ function checkCredentialMap(map, services, where) {
refuse(`${where} must reference exactly the services its role definition needs (${wanted.join(", ") || "none"}; got ${names.join(", ") || "none"})`);
}
const out = {};
for (const service of names) out[service] = parseCredentialRef(map[service], service, `${where}.${service}`);
for (const service of names) out[service] = parseCredentialRef(map[service], service, `${where}.${service}`, { store });
return out;
}
// Row 199 (#1697, design D8): the agent's own git identity. The email is the one its Gitea user
// has, so Gitea links the commits to that user; gitea.url is the instance its pushes go to.
const EMAIL = /^[A-Za-z0-9._%+-]{1,64}@[A-Za-z0-9.-]{1,190}\.[A-Za-z]{2,24}$/;
const NAME = /^[^\0\n\r<>]{1,100}$/;
// Row 199 (design D9c): the parse is vocabulary.mjs's, where the broker reads it; this wraps it with
// the loader's refusals. Returns {url, owner, repo}.
export function httpsUrl(value, where, { repo = false } = {}) {
const parsed = parseGiteaUrl(value, { repo });
if (parsed.problem === "form") refuse(`${where} must be a normalized https URL with no user, query or fragment`);
if (parsed.problem === "instance") refuse(`${where} must name the Gitea instance only, https://HOST[:PORT]`);
if (parsed.problem === "repository") refuse(`${where} must name a repository, https://HOST/OWNER/REPO`);
return parsed;
}
function checkIdentity(identity, where) {
requireObject(identity, where);
rejectUnknownKeys(identity, ["name", "email", "gitea"], where);
if (typeof identity.name !== "string" || !NAME.test(identity.name)) refuse(`${where}.name must be 1 to 100 characters with no <, > or line break`);
if (typeof identity.email !== "string" || !EMAIL.test(identity.email)) refuse(`${where}.email must be an email address`);
requireObject(identity.gitea, `${where}.gitea`);
rejectUnknownKeys(identity.gitea, ["url", "user"], `${where}.gitea`);
httpsUrl(identity.gitea.url, `${where}.gitea.url`);
if (!giteaName(identity.gitea.user)) refuse(`${where}.gitea.user must be a Gitea user name`);
return { name: identity.name, email: identity.email, gitea: { url: identity.gitea.url, user: identity.gitea.user } };
}
function checkTracker(tracker, file) {
const where = `${file} tracker`;
requireObject(tracker, where);
@@ -74,7 +102,7 @@ function checkTracker(tracker, file) {
return { sync, labels };
}
function checkRoles(roles, file, rolesDir) {
function checkRoles(roles, file, rolesDir, business) {
const where = `${file} roles`;
requireObject(roles, where);
if (Object.keys(roles).length === 0) refuse(`${where} must declare at least one role instance`);
@@ -86,7 +114,7 @@ function checkRoles(roles, file, rolesDir) {
requireId(instance, `${where} instance name`);
const at = `${where}.${instance}`;
requireObject(entry, at);
rejectUnknownKeys(entry, ["definition", "holder", "vars", "tracker", "credentials"], at);
rejectUnknownKeys(entry, ["definition", "holder", "vars", "tracker", "credentials", "identity", "publish"], at);
requireId(entry.definition, `${at}.definition`);
const definition = Object.hasOwn(definitions, entry.definition) ? definitions[entry.definition] : loadRole(rolesDir, entry.definition);
definitions[entry.definition] = definition;
@@ -108,8 +136,18 @@ function checkRoles(roles, file, rolesDir) {
holder: entry.holder === undefined ? null : requireId(entry.holder, `${at}.holder`),
vars: checkVars(entry.vars ?? {}, "agent", `${at}.vars`),
tracker,
credentials: checkCredentialMap(entry.credentials ?? {}, services, `${at}.credentials`),
credentials: checkCredentialMap(entry.credentials ?? {}, services, `${at}.credentials`,
{ configDir: dirname(dirname(file)), business, agent: instance }),
};
// Row 199 (#1697, design D8, D9 step 2): identity and publish are optional, so today's files
// load unchanged. publish needs both an identity and a gitea credential; the project ids it
// names are checked against projects once those are read.
if (entry.identity !== undefined) out[instance].identity = checkIdentity(entry.identity, `${at}.identity`);
if (entry.publish !== undefined) {
// N10: one message for both, naming the grant.
if (!out[instance].identity || !services.includes("gitea")) refuse(`${at}.publish needs identity and a gitea definition`);
out[instance].publish = requireDistinctList(entry.publish, `${at}.publish`, (p) => requireId(p, `${at}.publish entry`), { nonEmpty: true });
}
}
return { roles: out, definitions };
}
@@ -159,7 +197,7 @@ export function validateBusinessDocument(document, file, { rolesDir }) {
}
const human = requireId(document.human, "business human");
const vars = checkVars(document.vars ?? {}, "business", `${file} vars`);
const { roles, definitions } = checkRoles(document.roles, file, rolesDir);
const { roles, definitions } = checkRoles(document.roles, file, rolesDir, document.id);
requireObject(document.arbiters, `${file} arbiters`);
rejectUnknownKeys(document.arbiters, ["delivery", "technical"], `${file} arbiters`);
@@ -183,7 +221,7 @@ export function validateBusinessDocument(document, file, { rolesDir }) {
for (const [id, entry] of Object.entries(document.projects)) {
requireId(id, `${file} project id`);
requireObject(entry, `${file} projects.${id}`);
rejectUnknownKeys(entry, ["root", "repo"], `${file} projects.${id}`);
rejectUnknownKeys(entry, ["root", "repo", "remote"], `${file} projects.${id}`);
if (typeof entry.root !== "string" || !isAbsolute(entry.root) || normalize(entry.root) !== entry.root || entry.root.includes("\0")) {
refuse(`${file} projects.${id}.root must be a normalized absolute path`);
}
@@ -191,7 +229,18 @@ export function validateBusinessDocument(document, file, { rolesDir }) {
normalize(entry.repo) !== entry.repo || entry.repo.includes("\0"))) {
refuse(`${file} projects.${id}.repo must be a normalized absolute path`);
}
projects[id] = { root: entry.root, ...(Object.hasOwn(entry, "repo") ? { repo: entry.repo } : {}) };
// Row 199 (#1697, design D9 step 2): the upstream; a granted agent's work goes to its fork.
if (Object.hasOwn(entry, "remote")) httpsUrl(entry.remote, `${file} projects.${id}.remote`, { repo: true });
projects[id] = { root: entry.root, ...(Object.hasOwn(entry, "repo") ? { repo: entry.repo } : {}),
...(Object.hasOwn(entry, "remote") ? { remote: entry.remote } : {}) };
}
for (const [instance, role] of Object.entries(roles)) {
for (const project of role.publish ?? []) {
if (!Object.hasOwn(projects, project)) refuse(`${file} roles.${instance}.publish names ${project}, which the business file doesn't declare`);
if (!projects[project].remote) refuse(`${file} roles.${instance}.publish names ${project}, which has no "remote"`);
const problem = forkProblem(projects[project].remote, role.identity);
if (problem) refuse(`${file} roles.${instance}.publish names ${project}: ${problem}`);
}
}
const tracker = checkTracker(document.tracker, file);
+29 -8
View File
@@ -3,7 +3,7 @@
// lstat and realpath only; nothing in this package opens a token file.
import { lstatSync, realpathSync } from "node:fs";
import { isAbsolute, normalize, relative, sep } from "node:path";
import { isAbsolute, join, normalize, relative, sep } from "node:path";
import { refuse } from "./errors.mjs";
import { SERVICES } from "./vocabulary.mjs";
import { requireObject, rejectUnknownKeys, requireDate } from "./util.mjs";
@@ -14,21 +14,35 @@ const WARN_DAYS = 7;
const DAY_MS = 24 * 60 * 60 * 1000;
// Shape check for one reference. Returns a frozen { service, file | env,
// rotateBy | expires }.
export function parseCredentialRef(ref, service, where) {
// rotateBy | expires }. Row 199 (#1697, design D1 and D2): `store` is the
// file at <configDir>/secrets/agents/<business>/<agent>/<service>, derived
// here and never written, so an entry can't name another agent's file. A
// reference with no agent (the tracker's sync bot) passes store = null and
// can't use the form. A `file` reference into the agents' store must be the
// agent's own path (`credential-not-yours`).
export function parseCredentialRef(ref, service, where, { store = null } = {}) {
if (!SERVICES.includes(service)) refuse(`${where}: unknown credential service ${JSON.stringify(service)}`);
requireObject(ref, where);
const dateKey = DATE_KEY[service];
rejectUnknownKeys(ref, ["file", "env", dateKey], where);
rejectUnknownKeys(ref, ["file", "env", "store", dateKey], where);
const hasFile = ref.file !== undefined;
const hasEnv = ref.env !== undefined;
if (hasFile === hasEnv) refuse(`${where} must name exactly one of "file" or "env"`);
const hasStore = ref.store !== undefined;
if (Number(hasFile) + Number(hasEnv) + Number(hasStore) !== 1) refuse(`${where} must name exactly one of "file", "env" or "store"`);
const out = { service };
if (hasFile) {
if (hasStore) {
if (ref.store !== true) refuse(`${where}.store must be true`);
if (!store) refuse(`${where}: a "store" reference needs an agent`);
out.store = true;
out.file = storePath(store, service);
} else if (hasFile) {
if (typeof ref.file !== "string" || !isAbsolute(ref.file) || normalize(ref.file) !== ref.file || ref.file.includes("\0")) {
refuse(`${where}.file must be a normalized absolute path`);
}
out.file = ref.file;
if (store && inside(join(store.configDir, "secrets", "agents"), ref.file) && ref.file !== storePath(store, service)) {
refuse(`${where}.file credential-not-yours: ${ref.file} is in the agents' store but not this agent's path`);
}
} else {
if (typeof ref.env !== "string" || !ENV_NAME.test(ref.env)) refuse(`${where}.env must match ${ENV_NAME}`);
out.env = ref.env;
@@ -38,6 +52,11 @@ export function parseCredentialRef(ref, service, where) {
return Object.freeze(out);
}
// The store path for one agent and service (design D1).
export function storePath({ configDir, business, agent }, service) {
return join(configDir, "secrets", "agents", business, agent, service);
}
function inside(root, path) {
const rel = relative(root, path);
return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
@@ -55,8 +74,10 @@ export function checkCredentialRef(ref, { forbiddenRoots = [], now = new Date(),
let stat = null;
try {
stat = lstatSync(ref.file);
} catch {
problems.push(`${label}: not found`);
} catch (e) {
// Row 199 (design D3a): a store reference with no file is an agent not issued yet.
if (ref.store && e.code === "ENOENT") warnings.push(`${label}: not issued`);
else problems.push(`${label}: not found`);
}
if (stat) {
if (stat.isSymbolicLink() || !stat.isFile()) problems.push(`${label}: must be a regular file, not a symbolic link`);
+22 -3
View File
@@ -174,7 +174,9 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
}
}
function write(id, document, { revision, actor }) {
// Row 199 (#1697, design section 6): `detail` says what a credential verb did (verb, agent, kind,
// date, reason), never a value; the other setters pass none.
function write(id, document, { revision, actor, detail }) {
requireId(actor, "authenticated actor");
requireId(id, "organization id");
return withLock(id, ({ home, parent }) => {
@@ -201,7 +203,7 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
syncDir(tx);
const event = {
transaction, actor, business: id, before, after,
changes: changes(old, written), at: new Date().toISOString(),
changes: changes(old, written), ...(detail === undefined ? {} : { detail }), at: new Date().toISOString(),
};
log(id, { ...event, state: "prepared" });
syncDir(home);
@@ -219,5 +221,22 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
});
}
return { logFile, read, write, recover: id => withLock(id, () => recover(id)) };
// Row 199: a receipt with no document change, for a credential verb that changes only the agents'
// store (a revoke or delete of store references). Committed at once; there is nothing to recover.
function record(id, { actor, detail }) {
requireId(actor, "authenticated actor");
requireId(id, "organization id");
return withLock(id, () => {
recover(id);
const hash = sha256(regular(businessFilePath(id, dir)));
const transaction = randomUUID();
log(id, {
transaction, actor, business: id, before: hash, after: hash, changes: [], detail,
at: new Date().toISOString(), state: "committed",
});
return { revision: hash, transaction };
});
}
return { logFile, read, write, record, recover: id => withLock(id, () => recover(id)) };
}
+52 -2
View File
@@ -20,6 +20,9 @@ export const ACTIONS = Object.freeze([
"git.push.working",
"git.push.protected",
"git.merge.protected",
// Row 199 (#1697, mos-1027 Q-M2): the host pushes an agent's recorded work commit to the fork its
// business grants it. A hard wait under REQ-WAIT-3 until a PRD version routes it.
"git.push.remote",
"review.request",
"review.verdict",
"record.write",
@@ -83,8 +86,8 @@ export const ROUTED = Object.freeze([
// The hard waits (PRD 1.5 REQ-WAIT-1): always gated, always the human's,
// cited spec or not. A role file may not list these under withinRole or
// crossRole (REQ-WAIT-2). Today this is spend, data.delete, network.expose,
// account.create, credential.mint, message.external, git.push.protected and
// deploy; a test pins the list.
// account.create, credential.mint, message.external, git.push.protected,
// git.push.remote and deploy; a test pins the list.
export const GATED_ONLY = Object.freeze(ACTIONS.filter((action) => !ROUTED.includes(action)));
// pi's documented built-in tools; the same list scripts/mosaic-task.mjs
@@ -133,3 +136,50 @@ export const THREAD_DEFINITION = "assistant";
// the human resolved through the CLI, may end or revoke. Row 155 moves the list here, and no
// organization write may give an instance one of them.
export const END_PROTECTED = Object.freeze([THREAD_DEFINITION]);
// Row 199 (#1697, design D9c): the Gitea URL parse. It lives here because the broker reads it and
// this file imports nothing. Gitea's name rule for users and repositories: letters, digits, "_", "."
// and "-", not "." or "..".
export const giteaName = (name) =>
typeof name === "string" && /^[A-Za-z0-9_.-]{1,100}$/.test(name) && name !== "." && name !== "..";
// An https URL that is already its own normalized form, so a dot segment, an encoded dot, userinfo, a
// query, a fragment, an upper-case host or an explicit :443 each fail, and the path checked is the
// path used. With repo, the path is exactly /OWNER/REPO (one ".git" stripped); without, it is "/".
// Returns {url, owner, repo}, or {problem} naming the rule that failed: form, instance or repository.
export function parseGiteaUrl(value, { repo = false } = {}) {
let url = null;
try {
url = typeof value === "string" ? new URL(value) : null;
} catch {
url = null;
}
const normal = url && (repo ? [url.origin + url.pathname] : [url.origin, url.origin + "/"]);
if (!url || url.protocol !== "https:" || !normal.includes(value)) return { problem: "form" };
if (!repo) return url.pathname === "/" ? { url } : { problem: "instance" };
const segments = url.pathname.split("/").slice(1);
const name = segments[1]?.endsWith(".git") ? segments[1].slice(0, -4) : segments[1];
if (segments.length !== 2 || !giteaName(segments[0]) || !giteaName(name)) return { problem: "repository" };
return { url, owner: segments[0], repo: name };
}
// Design D9 step 2: the agent's fork of an upstream, https://HOST[:PORT]/<user>/REPO, or null for an
// upstream that doesn't parse. The owner is always the agent's own Gitea user; nothing else names it.
// URL.host drops a default port, so 443 counts the same on both sides.
export function forkUrl(upstream, user) {
const { url, repo } = parseGiteaUrl(upstream, { repo: true });
return url ? `https://${url.host}/${user}/${repo}` : null;
}
// The refusal code for publishing `upstream` as an agent with `identity`, or null. Gitea compares
// names without case (lower_name), so the owner check does too. An upstream or gitea.url that doesn't
// parse is remote-not-granted: the business loader refuses both, so only a hand-built business gets
// here.
export function forkProblem(upstream, identity) {
const { url, owner } = parseGiteaUrl(upstream, { repo: true });
const instance = parseGiteaUrl(identity?.gitea?.url).url;
if (!url || !instance || typeof identity.gitea.user !== "string") return "remote-not-granted";
if (url.host !== instance.host) return "remote-host-mismatch";
if (owner.toLowerCase() === identity.gitea.user.toLowerCase()) return "remote-not-fork";
return null;
}
@@ -0,0 +1,126 @@
// Row 199 (#1697): an agent's own git identity, its publish grant, the project's upstream and the
// credential store reference. Design D1, D8, D9 step 2 and D9c.
import { test } from "node:test";
import assert from "node:assert/strict";
import { join } from "node:path";
import { BusinessError, parseCredentialRef, validateBusinessDocument } from "../src/index.mjs";
import { forkProblem, forkUrl } from "../src/vocabulary.mjs";
import { businessDoc, FAR_DATE, REPO_ROLES, tmp } from "./helpers.mjs";
function refuses(fn, pattern) {
assert.throws(fn, (error) => {
assert.ok(error instanceof BusinessError, `expected BusinessError, got ${error}`);
assert.equal(error.exitCode, 2);
assert.match(error.message, pattern);
return true;
});
}
const IDENTITY = { name: "Coder", email: "[email protected]", gitea: { url: "https://git.example", user: "acme-coder" } };
// The fixture with coder granted publish on stack, whose upstream is mosaic/stack.
function check(mutate) {
const root = tmp();
const doc = businessDoc(root);
doc.projects.stack.remote = "https://git.example/mosaic/stack";
doc.roles.coder.identity = structuredClone(IDENTITY);
doc.roles.coder.publish = ["stack"];
mutate?.(doc, root);
return validateBusinessDocument(doc, join(root, "businesses", "acme.json"), { rolesDir: REPO_ROLES });
}
test("today's files load unchanged: identity, publish and remote are optional", () => {
const root = tmp();
const business = validateBusinessDocument(businessDoc(root), join(root, "businesses", "acme.json"), { rolesDir: REPO_ROLES });
assert.equal(business.roles.coder.identity, undefined);
assert.equal(business.roles.coder.publish, undefined);
assert.equal(business.projects.stack.remote, undefined);
});
test("a granted agent loads with its identity, grant and the project's upstream", () => {
const business = check();
assert.deepEqual({ ...business.roles.coder.identity, gitea: { ...business.roles.coder.identity.gitea } }, IDENTITY);
assert.deepEqual([...business.roles.coder.publish], ["stack"]);
assert.equal(business.projects.stack.remote, "https://git.example/mosaic/stack");
});
test("N10: publish needs identity and a gitea definition, and the error names the grant", () => {
refuses(() => check((d) => { delete d.roles.coder.identity; }), /roles\.coder\.publish needs identity and a gitea definition/);
refuses(() => check((d) => { d.roles.chief.identity = structuredClone(IDENTITY); d.roles.chief.publish = ["stack"]; }),
/roles\.chief\.publish needs identity and a gitea definition/);
});
test("publish names declared projects that have a remote, each once", () => {
refuses(() => check((d) => { d.roles.coder.publish = ["books"]; }), /publish names books, which the business file doesn't declare/);
refuses(() => check((d) => { delete d.projects.stack.remote; }), /publish names stack, which has no "remote"/);
refuses(() => check((d) => { d.roles.coder.publish = []; }), /publish/);
refuses(() => check((d) => { d.roles.coder.publish = ["stack", "stack"]; }), /publish/);
});
test("identity fields are checked", () => {
refuses(() => check((d) => { d.roles.coder.identity.email = "coder"; }), /identity\.email/);
refuses(() => check((d) => { d.roles.coder.identity.name = "a\nb"; }), /identity\.name/);
refuses(() => check((d) => { d.roles.coder.identity.extra = 1; }), /identity/);
refuses(() => check((d) => { d.roles.coder.identity.gitea.user = ".."; }), /gitea\.user/);
refuses(() => check((d) => { d.roles.coder.identity.gitea.user = "a/b"; }), /gitea\.user/);
});
test("D9c: the upstream must be its own normalized form, exactly /OWNER/REPO", () => {
const bad = {
"a dot segment": "https://git.example/acme-coder/../mosaic/stack",
"an encoded dot segment": "https://git.example/acme-coder/%2e%2e/mosaic/stack",
"an extra segment": "https://git.example/mosaic/stack/more",
"a missing segment": "https://git.example/stack",
"a trailing slash": "https://git.example/mosaic/stack/",
userinfo: "https://u:[email protected]/mosaic/stack",
"a query": "https://git.example/mosaic/stack?x=1",
"a fragment": "https://git.example/mosaic/stack#f",
"an explicit :443": "https://git.example:443/mosaic/stack",
"an upper-case host": "https://GIT.example/mosaic/stack",
"a backslash": "https://git.example\\mosaic\\stack",
"a name outside Gitea's rule": "https://git.example/mosaic/st%41ck",
"a dot-dot repo": "https://git.example/mosaic/...git",
http: "http://git.example/mosaic/stack",
};
for (const [what, remote] of Object.entries(bad)) {
assert.throws(() => check((d) => { d.projects.stack.remote = remote; }), BusinessError, what);
}
});
test("D9c: one .git is stripped, and the fork is built from the stripped name", () => {
assert.equal(check((d) => { d.projects.stack.remote = "https://git.example/mosaic/stack.git"; }).projects.stack.remote,
"https://git.example/mosaic/stack.git");
assert.equal(forkUrl("https://git.example/mosaic/stack.git", "acme-coder"), "https://git.example/acme-coder/stack");
assert.equal(forkUrl("https://git.example:3000/mosaic/stack", "acme-coder"), "https://git.example:3000/acme-coder/stack");
});
test("D9c: gitea.url names the instance only", () => {
for (const url of ["https://git.example/gitea", "https://git.example/gitea/", "https://git.example/?x", "https://git.example:443", "http://git.example"]) {
assert.throws(() => check((d) => { d.roles.coder.identity.gitea.url = url; }), BusinessError, url);
}
check((d) => { d.roles.coder.identity.gitea.url = "https://git.example/"; });
});
test("the upstream's host and port must be the agent's Gitea's", () => {
refuses(() => check((d) => { d.projects.stack.remote = "https://other.example/mosaic/stack"; }), /remote-host-mismatch/);
refuses(() => check((d) => { d.projects.stack.remote = "https://git.example:3000/mosaic/stack"; }), /remote-host-mismatch/);
assert.equal(forkProblem("https://git.example/mosaic/stack", { gitea: { url: "https://git.example/", user: "u" } }), null);
});
test("remote-not-fork: an upstream the agent's own user owns, compared without case", () => {
refuses(() => check((d) => { d.projects.stack.remote = "https://git.example/acme-coder/stack"; }), /remote-not-fork/);
refuses(() => check((d) => { d.projects.stack.remote = "https://git.example/ACME-Coder/stack"; }), /remote-not-fork/);
});
test("store references: own path only (design D1, D3a)", () => {
const store = { configDir: "/cfg", business: "acme", agent: "coder" };
const ref = parseCredentialRef({ store: true, rotateBy: FAR_DATE }, "gitea", "r", { store });
assert.equal(ref.file, "/cfg/secrets/agents/acme/coder/gitea");
assert.equal(ref.store, true);
refuses(() => parseCredentialRef({ store: true, rotateBy: FAR_DATE }, "gitea", "r"), /store/);
refuses(() => parseCredentialRef({ store: true, file: "/a", rotateBy: FAR_DATE }, "gitea", "r", { store }), /exactly one/);
refuses(() => parseCredentialRef({ file: "/cfg/secrets/agents/acme/pm/gitea", rotateBy: FAR_DATE }, "gitea", "r", { store }),
/credential-not-yours/);
assert.equal(parseCredentialRef({ file: "/cfg/secrets/agents/acme/coder/gitea", rotateBy: FAR_DATE }, "gitea", "r", { store }).file,
"/cfg/secrets/agents/acme/coder/gitea");
});
+1 -1
View File
@@ -9,7 +9,7 @@ import { validateRoleDocument } from "../src/index.mjs";
import { tmp } from "./helpers.mjs";
const WAITS = ["spend", "data.delete", "network.expose", "account.create", "credential.mint", "message.external",
"git.push.protected", "deploy"];
"git.push.protected", "deploy", "git.push.remote"];
const LEFT = ["git.merge.protected", "policy.change", "prd.approve", "role.revoke"];
const role = (authority) => {
const dir = tmp();
+13
View File
@@ -1009,6 +1009,19 @@ names no one; the restart itself needs Mos's go or Jason's hand. A vars or launc
prints `no restart`, because the launcher reads it at its next launch check.
The Console Settings page has the same read and write (`packages/webui`).
## credentials (row 199)
`mosaic credentials [status [AGENT]] [--json] [--tester]` lists each agent's
credential kinds with date and state, never a value. `issue AGENT gitea
--rotate-by DATE` and `issue AGENT vikunja --expires DATE` read the token on
stdin (pipe it, or paste it and end input) and send it once to the bus's
`credentials.issue`; it is never an argument. `rotate` is the same over an
entry the bus holds now, and refuses before reading stdin when there is none.
`revoke AGENT [KIND] --reason TEXT` and `delete AGENT --reason TEXT` drop the
entries; `delete` refuses `agent-running` while the agent holds a claim. With
`--tester` only `status` and `revoke` run. After a rotate or revoke, delete
the old token in Gitea or Vikunja yourself; the bus only forgets it.
## Token usage
`mosaic usage [--business ID] [--json] [--tester]` reads the bus's accounting, without
+4
View File
@@ -17,6 +17,7 @@ import { projectBusCommand, projectCommand, PROJECT_BUS_USAGE, PROJECT_USAGE } f
import { resolveThread, threadCommand, threadMembers, threadsCommand, THREAD_TALK_USAGE, THREAD_USAGE, THREADS_USAGE } from "./thread.mjs";
import { limitsCommand, LIMITS_USAGE } from "./limits.mjs";
import { pluginsCommand, PLUGINS_USAGE } from "./plugins.mjs";
import { credentialsCommand, CREDENTIALS_USAGE } from "./credentials.mjs";
import { instancesCommand, INSTANCES_USAGE } from "./instances.mjs";
import { createChannelCaller } from "../../plugins/src/channel.mjs";
import { PluginError } from "../../plugins/src/kinds.mjs";
@@ -38,6 +39,7 @@ export const USAGE = `usage:
${USAGE_USAGE}
${LIMITS_USAGE}
${PLUGINS_USAGE}
${CREDENTIALS_USAGE}
${INSTANCES_USAGE}
${PROJECT_USAGE}
${PROJECT_BUS_USAGE}
@@ -576,6 +578,8 @@ export async function main(argv, io = { env: process.env, stdin: process.stdin,
if (verb === "limits") return limitsCommand(rest,io,p=>callFor(p,io,deps));
if (verb === "usage") return usageCommand(rest, io, p => callFor(p, io, deps));
if (verb === "plugins") return pluginsCommand(rest, io, (p) => callFor(p, io, deps));
// Row 199 (#1697): issue and rotate read the token on stdin.
if (verb === "credentials") return credentialsCommand(rest, io, (p) => callFor(p, io, deps));
// Row 165 (#1662): scripts/mosaic sends only `business instances` and `business instance` here.
if (verb === "business") return instancesCommand(rest, io, (p) => callFor(p, io, deps));
if (verb === "talk") return talk(rest, io, deps);
+83
View File
@@ -0,0 +1,83 @@
// `mosaic credentials` (row 199, #1697, design section 6): an agent's per-agent credentials through
// the broker's credentials, credentials.issue, credentials.revoke and credentials.delete. Every verb
// is the human's; with --tester (Q173) only status and revoke. issue and rotate read the token on
// stdin, never argv, and nothing here prints it: the broker's answer carries the kind, date and state.
import { CliError } from "./errors.mjs";
export const CREDENTIALS_USAGE = "mosaic credentials [status [AGENT]] [--json] [--tester] | issue|rotate AGENT gitea --rotate-by DATE | " +
"issue|rotate AGENT vikunja --expires DATE | revoke AGENT [KIND] --reason TEXT [--tester] | delete AGENT --reason TEXT [--business ID]";
const DATE_FLAG = Object.freeze({ gitea: "--rotate-by", vikunja: "--expires" });
const MAX_TOKEN = 16384;
function parse(args) {
const out = { positional: [], flags: new Set(), values: {} };
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === "--json" || arg === "--tester") out.flags.add(arg);
else if (["--business", "--rotate-by", "--expires", "--reason"].includes(arg)) {
if (!args[i + 1] || Object.hasOwn(out.values, arg)) throw new CliError(CREDENTIALS_USAGE, 4);
out.values[arg] = args[++i];
} else if (arg.startsWith("--")) throw new CliError(CREDENTIALS_USAGE, 4);
else out.positional.push(arg);
}
return out;
}
// The whole of stdin, one token; a terminal shows what is typed, so a pipe or a paste then EOF.
async function readToken(stdin) {
let text = "";
for await (const chunk of stdin) {
text += chunk;
if (text.length > MAX_TOKEN + 2) throw new CliError("refused: the token on stdin is too long", 2);
}
if (!text.trim()) throw new CliError("refused: no token on stdin; pipe or paste it, then end input", 4);
return text;
}
// The states the bus's status() gives a held token: gitea is valid or rotation-due, vikunja valid,
// expiring or expired. not-issued and revoked hold none.
const ISSUED = new Set(["valid", "rotation-due", "expiring", "expired"]);
export async function credentialsCommand(args, io, callFor) {
const parsed = parse(args);
const [verb = "status", agent, kind, ...extra] = parsed.positional;
const v = parsed.values;
const shape = {
status: () => kind === undefined && !v["--rotate-by"] && !v["--expires"] && !v["--reason"],
issue: () => agent && DATE_FLAG[kind] && v[DATE_FLAG[kind]] && !v[kind === "gitea" ? "--expires" : "--rotate-by"] && !v["--reason"],
revoke: () => agent && (kind === undefined || DATE_FLAG[kind]) && v["--reason"] && !v["--rotate-by"] && !v["--expires"],
delete: () => agent && kind === undefined && v["--reason"] && !v["--rotate-by"] && !v["--expires"],
};
shape.rotate = shape.issue;
if (extra.length || !shape[verb]?.()) throw new CliError(CREDENTIALS_USAGE, 4);
// Q173 (duckworth N1): a token the tester issued would pass through its session.
if (parsed.flags.has("--tester") && !["status", "revoke"].includes(verb)) {
throw new CliError(`--tester takes only status and revoke\n${CREDENTIALS_USAGE}`, 4);
}
try {
const call = callFor(parsed);
let result;
if (verb === "status") {
const rows = await call("credentials", {});
result = agent ? rows.filter((r) => r.role === agent) : rows;
if (!parsed.flags.has("--json")) {
io.stdout.write(result.map((r) => `${r.role}\t${r.service}\t${r.state}\t${r.dateKind} ${r.date}`).join("\n") + (result.length ? "\n" : ""));
return;
}
} else if (verb === "issue" || verb === "rotate") {
// rotate names an entry the bus holds now; checked before stdin is read.
if (verb === "rotate") {
const held = (await call("credentials", {})).find((r) => r.role === agent && r.service === kind);
if (!ISSUED.has(held?.state)) throw new CliError(`refused: ${agent} has no issued ${kind} credential to rotate; use issue`, 2);
}
const token = await readToken(io.stdin);
result = await call("credentials.issue", { agent, service: kind, token, date: v[DATE_FLAG[kind]] });
} else if (verb === "revoke") {
result = await call("credentials.revoke", { agent, ...(kind ? { service: kind } : {}), reason: v["--reason"] });
} else result = await call("credentials.delete", { agent, reason: v["--reason"] });
io.stdout.write(JSON.stringify(result, null, 2) + "\n");
} catch (error) {
if (error instanceof CliError) throw error;
throw new CliError(error.message, 2);
}
}
+72 -3
View File
@@ -26,6 +26,19 @@
// squash that will land. The gate runs after the answer, and its
// outcome is the broker's: recordLand or landRefused, each on the
// task's trail and told to the role as a RESULT message.
// publish {"cap", "op": "publish", "task_ref", "project", "branch", "decision"}
// (row 199, #1697, design D9 to D11). The host answers {"ok": true,
// "result": {fork, ref, commit, previous}} or a refusal. The broker's
// checkPublish names the fork, the agent's email, the source commit
// and the destination; the request names none of them. publish
// (working.mjs) checks the work ref and the commits' identity under
// the repository's lock, then the bus process runs ls-remote and the
// push with the agent's gitea token (publishPush), and the host polls
// publishResult every publishPollMs: the host's op channel answers
// one op at a time within 10 s. The poll ends at publishDeadlineMs
// (publish-timeout), past git's own limit on each of the four git
// runs. recordPublish files the push; any refusal after checkPublish
// is filed once, by publishRefused.
//
// A refusal is {"ok": false, "error": "<code>"}, and the connection ends.
// The host checks, in order: identity (a role of this business), the request
@@ -64,9 +77,10 @@ import { randomBytes } from "node:crypto";
import { closeSync, createReadStream, existsSync, lstatSync, mkdirSync, openSync, rmSync, statSync, symlinkSync, writeSync, chmodSync } from "node:fs";
import { createServer } from "node:net";
import { join } from "node:path";
import { GIT_GRACE_MS, GIT_TIMEOUT_MS } from "../../seat/src/checkout.mjs";
import { gitSocketPath } from "../../seat/src/session.mjs";
import { gateEnv, isMosaicStack, rootHits, runGate } from "../../seat/src/landing.mjs";
import { BUNDLE_MAX, dropLand, finishLand, FSCK, HAVE_MAX, prepareLand, push, review, validBranch, workRef, WorkingError } from "../../seat/src/working.mjs";
import { BUNDLE_MAX, dropLand, finishLand, FSCK, HAVE_MAX, prepareLand, publish, push, review, validBranch, workRef, WorkingError } from "../../seat/src/working.mjs";
import { CliError } from "./errors.mjs";
const HEADER_MAX = 4096;
@@ -84,6 +98,11 @@ export const bodyDeadline = (size) => BODY_IDLE_MS + Math.ceil(size / BODY_MIN_R
const MAX_ACTIVE = 8;
// The longest close() waits for its host-closing answers to leave.
export const CLOSE_WAIT_MS = 2000;
// How often the host asks the bus process whether a publish's push ended.
const PUBLISH_POLL_MS = 200;
// The longest the host polls: publishRemote runs at most four git commands
// (ls-remote, cat-file, merge-base, push), each ended by git's own limit.
const PUBLISH_DEADLINE_MS = 4 * (GIT_TIMEOUT_MS + GIT_GRACE_MS) + 30_000;
const CAP = /^[0-9a-f]{64}$/;
const OID = /^[0-9a-f]{40}([0-9a-f]{24})?$/;
@@ -272,7 +291,7 @@ Applied-under: git socket land (bus host)
// host: startHost()'s handle; business: loadBusiness() output. bundleMax,
// bodyDeadline, bodyIdle, maxActive, fsck and gate (LAND_GATE's shape) are
// for the tests.
export function createGitSocket({ host, business: b, dataRoot, env = process.env, log = () => {}, bundleMax = BUNDLE_MAX, bodyDeadline: deadlineOf = bodyDeadline, bodyIdle = BODY_IDLE_MS, maxActive = MAX_ACTIVE, fsck = FSCK, gate = LAND_GATE }) {
export function createGitSocket({ host, business: b, dataRoot, env = process.env, log = () => {}, bundleMax = BUNDLE_MAX, bodyDeadline: deadlineOf = bodyDeadline, bodyIdle = BODY_IDLE_MS, maxActive = MAX_ACTIVE, fsck = FSCK, gate = LAND_GATE, publishPollMs = PUBLISH_POLL_MS, publishDeadlineMs = PUBLISH_DEADLINE_MS }) {
const path = gitSocketPath(dataRoot);
const dir = join(dataRoot, "bus-host");
let server = null;
@@ -386,6 +405,55 @@ export function createGitSocket({ host, business: b, dataRoot, env = process.env
})();
}
async function doPublish(socket, cap, req) {
const { task_ref, branch, decision } = req;
const p = project(req.project);
if (!p || !validBranch(branch) || (decision !== undefined && typeof decision !== "string")) throw new Refusal("invalid-request");
workRef(task_ref);
const facts = await op({ op: "checkPublish", cap, args: { task_ref, project: req.project, branch, ...(decision === undefined ? {} : { decision }) } });
const { ticket } = facts;
// From the ticket on, a refusal is the publish's own, filed once by publishRefused.
const refused = async (e) => {
const code = e instanceof Refusal || e instanceof Recorded || e instanceof WorkingError ? e.code : "git-failed";
if (code === "git-failed") log(`git socket: publish ${task_ref}: ${code}: ${e.message}`);
await host.op({ op: "publishRefused", ticket, args: { code } }).catch((x) => log(`git socket: publish ${task_ref}: refusal ${code} not recorded: ${x.code ?? x.message}`));
return new Recorded(code);
};
let pushed;
try {
pushed = await publish({
dataRoot, business: b.id, project: req.project, root: p.root, taskRef: task_ref, source: facts.source, email: facts.email, env,
remote: async () => {
await op({ op: "publishPush", ticket });
const until = Date.now() + publishDeadlineMs;
for (;;) {
// The bus's git may still finish after this; the fork could then hold the commit with
// no record, as when recordPublish refuses after a push.
if (Date.now() >= until) {
log(`git socket: publish ${task_ref}: publish-timeout; the bus process's push may still reach the fork`);
throw new Refusal("publish-timeout");
}
await new Promise((r) => setTimeout(r, publishPollMs));
const r = await op({ op: "publishResult", ticket });
if (r.state === "pushed") return { ref: r.ref, commit: r.commit, previous: r.previous };
if (r.state === "refused") throw new Recorded(r.code);
}
},
});
} catch (e) {
throw await refused(e);
}
// The commit is on the fork now; a record the broker refuses is filed as that refusal.
let result;
try {
result = await op({ op: "recordPublish", ticket, pushed });
} catch (e) {
log(`git socket: publish ${task_ref}: ${pushed.commit} is on ${facts.fork} but wasn't recorded: ${e.code}`);
throw await refused(e);
}
socket.end(`${JSON.stringify({ ok: true, result: { fork: result.fork, ref: result.ref, commit: result.commit, previous: result.previous } })}\n`);
}
async function serve(socket) {
let cap = null;
let subject = null;
@@ -410,7 +478,8 @@ export function createGitSocket({ host, business: b, dataRoot, env = process.env
if (req.op === "push") await doPush(socket, cap, req, rest, file);
else if (req.op === "fetch") await doFetch(socket, cap, req, file);
else if (req.op === "land") await doLand(socket, cap, req);
else throw new Refusal("invalid-request", "op is push, fetch or land");
else if (req.op === "publish") await doPublish(socket, cap, req);
else throw new Refusal("invalid-request", "op is push, fetch, land or publish");
} catch (e) {
if (e instanceof Recorded) return fail(e.code);
const code = e instanceof Refusal || e instanceof WorkingError ? e.code : "git-failed";
+5 -3
View File
@@ -47,8 +47,9 @@
// launchedBy = the business's human and no caller capability.
//
// listen() also serves the git socket (src/git-socket.mjs, row 93), which
// the sandbox binds for a role that holds git.push.working or
// review.verdict, or a merge decision to land (landHeld, row 137);
// the sandbox binds for a role that holds git.push.working,
// review.verdict or git.push.remote (row 199), or a merge decision to land
// (landHeld, row 137);
// session.json names it as gitSocket.
//
// Thread sessions (row 121, #1615). A thread instance (an instance whose
@@ -397,8 +398,9 @@ export function createLauncher({
const transcript = call(model.harness, "transcript", () => harness.transcript({ runDir, store }));
const harnessMounts = call(model.harness, "mounts", () => harness.mounts({ env }));
// Row 93: the git socket, for a role that pushes work or reviews it. Row 137: or lands it.
// Row 199 (D11): or publishes it, once a reviewed commit routes git.push.remote within-role.
const held = [...resolved.limits.authority.withinRole, ...resolved.limits.authority.crossRole];
const git = land || held.includes("git.push.working") || held.includes("review.verdict") ? gitSocketPath(dataRoot) : null;
const git = land || ["git.push.working", "review.verdict", "git.push.remote"].some((a) => held.includes(a)) ? gitSocketPath(dataRoot) : null;
writeSessionFile(runDir, {
...sessionDefaults,
business: b.id,
+114
View File
@@ -0,0 +1,114 @@
// Row 199 (#1697, design section 6, T4, T5): `mosaic credentials`. issue and rotate read the token on
// stdin and pass it once to credentials.issue; nothing printed carries it. --tester takes status and
// revoke only. A fake transport records the calls; no bus runs here.
import test from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { main } from "../src/cli.mjs";
import { io, tmp } from "./helpers.mjs";
const TOKEN = "fake_cli_token_0123456789";
const MOSAIC = fileURLToPath(new URL("../../../scripts/mosaic", import.meta.url));
const ROWS = [
{ service: "gitea", account: null, role: "coder", dateKind: "rotateBy", date: "2099-01-01", state: "valid" },
{ service: "vikunja", account: "bot", role: "coder", dateKind: "expires", date: "2099-01-01", state: "not-issued" },
{ service: "gitea", account: null, role: "reviewer", dateKind: "rotateBy", date: "2099-01-01", state: "revoked" },
];
function deps(calls, rows = ROWS) {
const answer = (who) => (business) => async (verb, args) => {
calls.push({ who, business, verb, args });
if (verb === "credentials") return rows;
if (verb === "credentials.issue") return { agent: args.agent, verb: "issue", service: args.service, date: args.date, state: "valid" };
return { agent: args.agent, verb: verb.slice(12), services: [] };
};
return { system: { dataRoot: "/nonexistent" }, transport: answer("human"), testerTransport: answer("tester") };
}
const withStdin = (x, text) => {
x.stdin.end(text);
return x;
};
test("row 199: issue and rotate send the stdin token once to credentials.issue and print no value", async () => {
const calls = [];
const d = deps(calls);
const x = withStdin(io(), TOKEN + "\n");
await main(["credentials", "issue", "coder", "gitea", "--rotate-by", "2099-01-01", "--business", "acme"], x, d);
assert.deepEqual(calls, [{ who: "human", business: "acme", verb: "credentials.issue", args: { agent: "coder", service: "gitea", token: TOKEN + "\n", date: "2099-01-01" } }]);
assert.equal(x.out.text.includes(TOKEN), false);
assert.equal(JSON.parse(x.out.text).state, "valid");
// rotate checks the entry is issued before it reads stdin.
calls.length = 0;
await main(["credentials", "rotate", "coder", "gitea", "--rotate-by", "2099-06-01", "--business", "acme"], withStdin(io(), TOKEN), d);
assert.deepEqual(calls.map((c) => c.verb), ["credentials", "credentials.issue"]);
calls.length = 0;
await assert.rejects(main(["credentials", "rotate", "coder", "vikunja", "--expires", "2099-06-01", "--business", "acme"], withStdin(io(), TOKEN), d), /no issued vikunja credential to rotate; use issue/);
assert.deepEqual(calls.map((c) => c.verb), ["credentials"]);
// No token on stdin, the date flag of the other kind, a token in argv: refused before any write.
calls.length = 0;
await assert.rejects(main(["credentials", "issue", "coder", "gitea", "--rotate-by", "2099-01-01", "--business", "acme"], withStdin(io(), "\n"), d), /no token on stdin/);
await assert.rejects(main(["credentials", "issue", "coder", "gitea", "--expires", "2099-01-01", "--business", "acme"], withStdin(io(), TOKEN), d), /mosaic credentials/);
await assert.rejects(main(["credentials", "issue", "coder", "gitea", TOKEN, "--rotate-by", "2099-01-01", "--business", "acme"], withStdin(io(), TOKEN), d), /mosaic credentials/);
await assert.rejects(main(["credentials", "issue", "coder", "gitea", "--rotate-by", "2099-01-01", "--business", "acme"], withStdin(io(), "x".repeat(17000)), d), /too long/);
assert.equal(calls.filter((c) => c.verb === "credentials.issue").length, 0);
// From inside an agent run, the human's verbs refuse before any call.
await assert.rejects(main(["credentials", "issue", "coder", "gitea", "--rotate-by", "2099-01-01", "--business", "acme"], withStdin(io({ CLAUDECODE: "1" }), TOKEN), d), /outside any agent run/);
});
// gizmoduck-0050: a gitea key past its rotate-by date is rotation-due, and rotate is how a human
// clears it. Every state the bus's status() gives a held token rotates; the other two refuse.
test("row 199: rotate goes ahead on each held state, rotation-due included, and refuses not-issued, revoked and an unknown entry", async () => {
const states = { valid: true, "rotation-due": true, expiring: true, expired: true, "not-issued": false, revoked: false };
for (const [state, held] of Object.entries(states)) {
const calls = [];
const d = deps(calls, [{ service: "gitea", account: null, role: "coder", dateKind: "rotateBy", date: "2020-01-01", state }]);
const run = main(["credentials", "rotate", "coder", "gitea", "--rotate-by", "2099-06-01", "--business", "acme"], withStdin(io(), TOKEN), d);
if (held) await run;
else await assert.rejects(run, /no issued gitea credential to rotate; use issue/);
assert.deepEqual(calls.map((c) => c.verb), held ? ["credentials", "credentials.issue"] : ["credentials"], state);
}
const calls = [];
await assert.rejects(main(["credentials", "rotate", "tester", "gitea", "--rotate-by", "2099-06-01", "--business", "acme"], withStdin(io(), TOKEN), deps(calls)), /no issued gitea credential/);
assert.deepEqual(calls.map((c) => c.verb), ["credentials"]);
});
test("row 199: status, revoke and delete; the tester runs status and revoke only", async () => {
const calls = [];
const d = deps(calls);
const x = io();
await main(["credentials", "status", "coder", "--business", "acme"], x, d);
assert.equal(x.out.text, "coder\tgitea\tvalid\trotateBy 2099-01-01\ncoder\tvikunja\tnot-issued\texpires 2099-01-01\n");
const j = io();
await main(["credentials", "--json", "--business", "acme", "--tester"], j, d);
assert.equal(JSON.parse(j.out.text).length, 3);
await main(["credentials", "revoke", "coder", "gitea", "--reason", "fired", "--business", "acme", "--tester"], io(), d);
await main(["credentials", "revoke", "coder", "--reason", "fired", "--business", "acme"], io(), d);
await main(["credentials", "delete", "coder", "--reason", "fired", "--business", "acme"], io(), d);
assert.deepEqual(calls.map((c) => [c.who, c.verb, c.args]), [
["human", "credentials", {}],
["tester", "credentials", {}],
["tester", "credentials.revoke", { agent: "coder", service: "gitea", reason: "fired" }],
["human", "credentials.revoke", { agent: "coder", reason: "fired" }],
["human", "credentials.delete", { agent: "coder", reason: "fired" }],
]);
calls.length = 0;
for (const args of [
["issue", "coder", "gitea", "--rotate-by", "2099-01-01", "--tester"],
["rotate", "coder", "gitea", "--rotate-by", "2099-01-01", "--tester"],
["delete", "coder", "--reason", "x", "--tester"],
]) await assert.rejects(main(["credentials", ...args, "--business", "acme"], withStdin(io(), TOKEN), d), /--tester takes only status and revoke/);
for (const args of [["revoke", "coder"], ["delete", "coder"], ["delete", "coder", "gitea", "--reason", "x"], ["revoke", "coder", "model", "--reason", "x"], ["purge", "coder"]])
await assert.rejects(main(["credentials", ...args, "--business", "acme"], io(), d), /mosaic credentials/, args.join(" "));
assert.equal(calls.length, 0);
});
test("row 199: scripts/mosaic sends credentials to packages/cli, not the seat CLI", (t) => {
const root = tmp(t, "mosaic-cli-cred-route-");
const env = { PATH: process.env.PATH, HOME: join(root, "home"), MOSAIC_CONFIG: join(root, "none.json") };
const r = spawnSync(MOSAIC, ["credentials", "purge"], { env, encoding: "utf8" });
assert.equal(r.status, 4);
assert.match(r.stderr, /^mosaic: mosaic credentials \[status \[AGENT\]\]/);
assert.doesNotMatch(r.stderr, /mosaic launch </);
});
+281
View File
@@ -0,0 +1,281 @@
// Row 199 (#1697, design D9, D9b, D11, T3): the git socket's publish op end to end. The socket,
// working.mjs, the bus runtime (in this process, so its publishTls can name the scratch CA), the
// per-agent store and a TLS fake Gitea on 127.0.0.1. The host stand-in answers the socket's ops
// the way packages/bus/src/process.mjs does. Fake tokens in a scratch root only.
import { test } from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { chmodSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { connect } from "node:net";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { DatabaseSync } from "node:sqlite";
import { startBroker } from "../../bus/src/runtime.mjs";
import { businessDoc, rolesCopy, systemFor, writeJson } from "../../business/tests/helpers.mjs";
import { pushWorking } from "../../harness/src/git-tools.mjs";
import { gitEnv } from "../../seat/src/checkout.mjs";
import { workRef } from "../../seat/src/working.mjs";
import { fakeGitea, scratchCa } from "../../seat/tests/fake-gitea.mjs";
import { createGitSocket } from "../src/git-socket.mjs";
const TASK = "vikunja:1/2";
const TASK2 = "vikunja:1/3";
const TOKEN = "fake_socket_token_0123456789";
const TOKEN2 = "fake_second_token_0123456789";
function git(cwd, ...args) {
const r = spawnSync("git", args, { cwd, encoding: "utf8", env: gitEnv() });
assert.equal(r.status, 0, `git ${args.join(" ")}: ${r.stderr}`);
return r.stdout.trim();
}
function commit(dir, text, email) {
writeFileSync(join(dir, "README"), `${text}\n`);
git(dir, "add", "README");
git(dir, "-c", `user.name=${text}`, "-c", `user.email=${email}`, "commit", "-q", "-m", text);
return git(dir, "rev-parse", "HEAD");
}
function raw(path, req) {
return new Promise((resolve, reject) => {
const s = connect(path);
let buf = "";
s.on("connect", () => s.write(`${JSON.stringify(req)}\n`));
s.on("data", (b) => (buf += b));
s.on("end", () => resolve(JSON.parse(buf.split("\n")[0])));
s.on("error", reject);
});
}
// The socket's ops, answered as process.mjs's launchOp answers them.
const OPS = {
identity: (r, m) => r.identity(m.cap),
refuse: (r, m) => r.refuse(m.cap, m.code, m.subject),
checkPush: (r, m) => r.checkPush(m.cap, m.task_ref),
recordPush: (r, m) => r.recordPush(m.cap, m.push),
checkPublish: (r, m) => r.checkPublish(m.cap, m.args),
publishPush: (r, m) => r.publishPush(m.ticket),
publishResult: (r, m) => r.publishResult(m.ticket),
recordPublish: (r, m) => r.recordPublish(m.ticket, m.pushed),
publishRefused: (r, m) => r.publishRefused(m.ticket, m.args),
};
async function setup(t, { publishDeadlineMs } = {}) {
const root = mkdtempSync(join(tmpdir(), "cli-publish-"));
const down = [];
t.after(async () => {
for (const fn of down) await fn();
rmSync(root, { recursive: true, force: true });
});
const ca = scratchCa(join(root, "ca"));
const gitea = await fakeGitea({ dir: join(root, "gitea"), tls: ca, users: { coder: TOKEN, second: TOKEN2 } });
down.push(() => gitea.close());
const project = join(root, "project");
mkdirSync(project);
git(project, "init", "-q", "-b", "main");
commit(project, "one", "[email protected]");
// Each agent's fork and the upstream, mosaic/stack, all clones of the root.
for (const owner of ["coder", "second", "mosaic"]) {
mkdirSync(join(gitea.repo(owner, "stack"), ".."), { recursive: true });
git(root, "clone", "-q", "--bare", project, gitea.repo(owner, "stack"));
}
const forkRepo = gitea.repo("coder", "stack");
const rolesDir = rolesCopy(root);
writeJson(join(root, "businesses/demo.json"), { ...businessDoc(root, "demo"), plugins: { harness: ["pi"] } });
mkdirSync(join(root, "data"), { mode: 0o700 });
mkdirSync(join(root, "secrets", "agents"), { recursive: true, mode: 0o700 });
chmodSync(join(root, "secrets"), 0o700);
const agent = (user) => ({
authority: { withinRole: ["git.push.working", "message.send"], crossRole: [] },
identity: { name: user, email: `${user}@example.com`, gitea: { url: gitea.origin, user } },
publish: ["stack"],
credentials: { gitea: { store: true, file: join(root, "secrets", "agents", "demo", user, "gitea"), rotateBy: "2099-01-01" } },
});
const demo = {
id: "demo", human: "jason", arbiters: { technical: "reviewer", delivery: "reviewer" },
projects: { stack: { root: project, remote: `${gitea.origin}/mosaic/stack` } },
roles: {
coder: agent("coder"),
second: agent("second"),
reviewer: { authority: { withinRole: ["message.send"], crossRole: [] } },
},
};
const dataRoot = join(root, "data");
const bus = await startBroker({ dataRoot, businesses: { demo }, organization: { dir: root, rolesDir, system: systemFor(root) }, publishTls: ["-c", `http.sslCAInfo=${ca.ca}`] });
down.unshift(() => bus.close());
// A test sets hooks[op] to act around one op: hook(message, next), where next() answers it.
const hooks = {};
const host = { op: async (m) => (hooks[m.op] ? hooks[m.op](m, () => OPS[m.op](bus, m)) : OPS[m.op](bus, m)) };
const logs = [];
mkdirSync(join(dataRoot, "bus-host"), { mode: 0o700 });
const sock = createGitSocket({ host, business: demo, dataRoot, env: process.env, log: (l) => logs.push(l), publishPollMs: 20, ...(publishDeadlineMs ? { publishDeadlineMs } : {}) });
down.unshift(() => sock.close());
await sock.listen();
const caps = {};
for (const role of ["coder", "second"]) {
caps[role] = bus.broker.bindLaunch({ business: "demo", role, run: `${role}-run`, harness: "pi" });
bus.broker.request(caps[role], { verb: "role.claim", args: {} });
}
const human = bus.broker.bindHuman({ business: "demo", human: "jason", via: "cli", outsideAgent: true });
// Each agent's workspace: a clone of the root with one commit of its own on feat, pushed to the
// working repo for its task.
const work = async (role, task) => {
const workspace = join(root, `ws-${role}`);
const tmp = join(root, `tmp-${role}`);
mkdirSync(workspace);
mkdirSync(tmp);
git(workspace, "clone", "-q", project, "stack");
const clone = join(workspace, "stack");
git(clone, "checkout", "-q", "-b", "feat");
const commitId = commit(clone, `work by ${role}`, `${role}@example.com`);
await pushWorking({ path: sock.path, cap: caps[role], workspace, tmp, task_ref: task, project: "stack", branch: "feat" });
return commitId;
};
// A second commit on the coder's feat, pushed to the working repo through the socket.
const again = async () => {
const workspace = join(root, "ws-coder");
const commitId = commit(join(workspace, "stack"), "more work", "[email protected]");
await pushWorking({ path: sock.path, cap: caps.coder, workspace, tmp: join(root, "tmp-coder"), task_ref: TASK, project: "stack", branch: "feat" });
return commitId;
};
const approve = (role = "coder", task = TASK) => {
const d = bus.broker.request(caps[role], { verb: "decision.raise", args: {
action: "git.push.remote", target: task, task_ref: task, question: "Publish?", blocking: false, recommendation: "yes",
options: [{ key: "yes", text: "Yes" }, { key: "no", text: "No" }] } });
bus.broker.request(human, { verb: "decision.resolve", args: { id: d.id, choice: "yes" } });
return d.id;
};
const publish = (decision, extra = {}, role = "coder", task = TASK) =>
raw(sock.path, { cap: caps[role], op: "publish", task_ref: task, project: "stack", branch: "feat", decision, ...extra });
// The bus holds the writer lock; the test reads its events through a read-only handle.
const events = (kind, task = TASK) => {
const db = new DatabaseSync(join(dataRoot, "bus", "bus.sqlite"), { readOnly: true });
try {
return db.prepare("SELECT * FROM events WHERE kind=? AND subject=? ORDER BY seq").all(kind, task).map((e) => ({ ...e, body: JSON.parse(e.body) }));
} finally {
db.close();
}
};
return { root, bus, gitea, forkRepo, work: await work("coder", TASK), more: work, again, hooks, caps, approve, publish, logs, events };
}
test("T3 end to end: publish under an approved decision sends the work commit to the agent's own fork and records it once", async (t) => {
const w = await setup(t);
// Nothing issued: the store has no token, and the refusal is filed once.
const first = w.approve();
assert.deepEqual(await w.publish(first), { ok: false, error: "credential-unavailable" });
assert.deepEqual(w.events("action.refused").map((e) => e.body.code), ["credential-unavailable"]);
assert.equal(w.gitea.requests.length, 0);
w.bus.credentials.issue("demo/coder", "gitea", TOKEN, "2099-01-01");
const reply = await w.publish(first);
assert.deepEqual(reply, { ok: true, result: { fork: `${w.gitea.origin}/coder/stack`, ref: "refs/heads/coder/feat", commit: w.work, previous: null } });
assert.equal(git(w.root, `--git-dir=${w.forkRepo}`, "rev-parse", "refs/heads/coder/feat"), w.work);
// Every request went to the agent's fork; the upstream, mosaic/stack, received none.
assert.ok(w.gitea.requests.some((r) => r.path === "/coder/stack/git-receive-pack" && r.user === "coder"));
for (const r of w.gitea.requests) assert.match(r.path, /^\/coder\/stack\//);
const allowed = w.events("action.allowed").filter((e) => e.body.action === "git.push.remote" && e.body.operation !== "decision.raise");
assert.equal(allowed.length, 1);
assert.equal(allowed[0].body.fork, `${w.gitea.origin}/coder/stack`);
assert.equal(allowed[0].body.commit, w.work);
// The decision is spent.
assert.deepEqual(await w.publish(first), { ok: false, error: "decision-consumed" });
// T4: no token in any answer, event or log line, nor in any file under the scratch root but the
// agent's own store file.
assert.equal(JSON.stringify([reply, w.events("action.allowed"), w.events("action.refused"), w.logs]).includes(TOKEN), false);
const store = join(w.root, "secrets", "agents", "demo", "coder", "gitea");
const holding = readdirSync(w.root, { recursive: true })
.map((name) => join(w.root, name))
.filter((file) => lstatSync(file).isFile() && readFileSync(file).includes(TOKEN));
assert.deepEqual(holding, [store]);
});
test("T3 end to end: a fork the token can't reach is fork-unavailable, filed once, and nothing is recorded as allowed", async (t) => {
const w = await setup(t);
w.bus.credentials.issue("demo/coder", "gitea", "fake_wrong_token_0123456789", "2099-01-01");
assert.deepEqual(await w.publish(w.approve()), { ok: false, error: "fork-unavailable" });
assert.ok(w.gitea.requests.length > 0);
assert.deepEqual(w.events("action.refused").map((e) => e.body.code), ["fork-unavailable"]);
assert.equal(w.events("action.allowed").filter((e) => e.body.action === "git.push.remote" && e.body.operation !== "decision.raise").length, 0);
});
test("a bad branch or project refuses before the broker; a fork, user or ref in the request is ignored", async (t) => {
const w = await setup(t);
const d = w.approve();
assert.deepEqual(await w.publish(d, { branch: "../main" }), { ok: false, error: "invalid-request" });
assert.deepEqual(await w.publish(d, { project: "nope" }), { ok: false, error: "invalid-request" });
// Filed through the socket's refuse op, as a bad push request is; no publish ticket opened.
assert.deepEqual(w.events("action.refused").map((e) => e.body.code), ["invalid-request", "invalid-request"]);
assert.equal(w.gitea.requests.length, 0);
// The broker builds the fork and the destination; the request's own are never read.
w.bus.credentials.issue("demo/coder", "gitea", TOKEN, "2099-01-01");
const reply = await w.publish(d, { fork: "https://127.0.0.1:1/other/stack", user: "other", dst: "refs/heads/main", ref: "refs/heads/main" });
assert.deepEqual(reply.result, { fork: `${w.gitea.origin}/coder/stack`, ref: "refs/heads/coder/feat", commit: w.work, previous: null });
for (const r of w.gitea.requests) assert.match(r.path, /^\/coder\/stack\//);
assert.equal(spawnSync("git", [`--git-dir=${w.forkRepo}`, "rev-parse", "-q", "--verify", "refs/heads/other/feat"], { env: gitEnv() }).status, 1);
});
test("T3 end to end: two agents, one project, each lands on its own fork only; the upstream receives nothing", async (t) => {
const w = await setup(t);
const second = await w.more("second", TASK2);
w.bus.credentials.issue("demo/coder", "gitea", TOKEN, "2099-01-01");
w.bus.credentials.issue("demo/second", "gitea", TOKEN2, "2099-01-01");
assert.equal((await w.publish(w.approve())).ok, true);
const coderRequests = w.gitea.requests.length;
for (const r of w.gitea.requests) assert.deepEqual([r.path.split("/")[1], r.user ?? "coder"], ["coder", "coder"]);
const reply = await w.publish(w.approve("second", TASK2), {}, "second", TASK2);
assert.deepEqual(reply.result, { fork: `${w.gitea.origin}/second/stack`, ref: "refs/heads/second/feat", commit: second, previous: null });
for (const r of w.gitea.requests.slice(coderRequests)) assert.deepEqual([r.path.split("/")[1], r.user ?? "second"], ["second", "second"]);
const refs = (owner) => git(w.root, `--git-dir=${w.gitea.repo(owner, "stack")}`, "for-each-ref", "--format=%(refname) %(objectname)");
assert.equal(refs("coder").includes(`refs/heads/coder/feat ${w.work}`), true);
assert.equal(refs("coder").includes("refs/heads/second/"), false);
assert.equal(refs("second").includes(`refs/heads/second/feat ${second}`), true);
assert.equal(refs("second").includes("refs/heads/coder/"), false);
assert.equal(refs("mosaic").split("\n").length, 1, "the upstream holds only main");
});
// sage-1440 on BUILD.md 14: a push between checkPublish and the git push refuses, with one code
// for each place it can come in, and the fork never receives a commit the decision didn't bind.
test("T3: a push after checkPublish and before the lock refuses ref-moved; the fork receives nothing", async (t) => {
const w = await setup(t);
w.bus.credentials.issue("demo/coder", "gitea", TOKEN, "2099-01-01");
w.hooks.checkPublish = async (m, next) => {
const facts = await next();
await w.again();
return facts;
};
assert.deepEqual(await w.publish(w.approve()), { ok: false, error: "ref-moved" });
assert.deepEqual(w.events("action.refused").map((e) => e.body.code), ["ref-moved"]);
assert.equal(w.gitea.requests.length, 0);
assert.equal(git(w.root, `--git-dir=${w.forkRepo}`, "for-each-ref", "refs/heads/coder/"), "");
});
test("T3: a push recorded while the bus process pushes refuses decision-mismatch; only the decision's commit reached the fork", async (t) => {
const w = await setup(t);
w.bus.credentials.issue("demo/coder", "gitea", TOKEN, "2099-01-01");
// The working repo's lock is held through the git push, so a socket push would wait for it; the
// broker's record of a newer push is what a push that won the lock leaves.
w.hooks.publishPush = async (m, next) => {
w.bus.checkPush(w.caps.coder, TASK);
w.bus.recordPush(w.caps.coder, { task_ref: TASK, project: "stack", branch: "feat", ref: workRef(TASK), commit: "c".repeat(40), previous: w.work });
return next();
};
assert.deepEqual(await w.publish(w.approve()), { ok: false, error: "decision-mismatch" });
assert.deepEqual(w.events("action.refused").map((e) => e.body.code), ["decision-mismatch"]);
assert.equal(w.events("action.allowed").filter((e) => e.body.action === "git.push.remote" && e.body.operation !== "decision.raise").length, 0);
assert.equal(git(w.root, `--git-dir=${w.forkRepo}`, "rev-parse", "refs/heads/coder/feat"), w.work);
assert.ok(w.logs.some((l) => l.includes("wasn't recorded")));
});
test("sage-1440: the publishResult poll is bounded: publish-timeout, filed once, and nothing recorded", async (t) => {
const w = await setup(t, { publishDeadlineMs: 150 });
let polls = 0;
w.hooks.publishResult = async () => {
polls++;
return { state: "running" };
};
assert.deepEqual(await w.publish(w.approve()), { ok: false, error: "publish-timeout" });
assert.ok(polls > 0);
assert.deepEqual(w.events("action.refused").map((e) => e.body.code), ["publish-timeout"]);
assert.equal(w.events("action.allowed").filter((e) => e.body.action === "git.push.remote" && e.body.operation !== "decision.raise").length, 0);
assert.ok(w.logs.some((l) => l.includes("publish-timeout")));
});
+112 -5
View File
@@ -381,7 +381,7 @@ async function seed(e,{copy=B,pm=false,store='3g',pointer:how='adopt'}={}){
const rolledBack=(code,to=B)=>`stack-release: ${code}; rolled back to ${to}, which passed the gate\n`;
const planBackup=(e)=>`backup: the run backs up the stopped store to ${e.data}/backups/stack/<activation>/ before any migration`;
const planNoBackup='backup: none; the run rolls back before the backup';
const planTail=(e,to,pm=false)=>[`bus: scripts/bus-service.sh install --copy ${to} --copies ${COPIES}${pm?' --pm':''} --claude ${e.claude}`,'proof: off',`Console: scripts/webui-service.sh install --copy ${to} --copies ${COPIES} --port ${e.port} --business acme`,`CLI: scripts/stack-copy.sh wrapper ${to} --copies ${COPIES} --bin ${e.bin}`];
const planTail=(e,to,pm=false)=>[`bus: scripts/bus-service.sh install --copy ${to} --copies ${COPIES}${pm?' --pm':''} --claude ${e.claude}`,'proof: off','send: off',`Console: scripts/webui-service.sh install --copy ${to} --copies ${COPIES} --port ${e.port} --business acme`,`CLI: scripts/stack-copy.sh wrapper ${to} --copies ${COPIES} --bin ${e.bin}`];
// --- The T table ---
@@ -1030,17 +1030,17 @@ test('T36 --plan shows the pending state and lists the row 169 refusals it can s
return r.out.split('\n');
};
let out=await quiet(0,'activate',C1,'acme','--pm-after-harness','--plan');
assert.deepEqual(out.slice(-6),['pm: the bus starts without --pm and the PM stays pending; next: Jason\'s plugins set of harness, then pm-on',...planTail(e,C1),'']);
assert.deepEqual(out.slice(-7),['pm: the bus starts without --pm and the PM stays pending; next: Jason\'s plugins set of harness, then pm-on',...planTail(e,C1),'']);
biz(e,HARNESS);
out=await quiet(3,'activate',A,'acme','--pm-after-harness','--plan');
assert.deepEqual(out.slice(-7),['pm: the bus starts without --pm and the PM stays pending; next: Jason\'s plugins set of harness, then pm-on',...planTail(e,A),`refuses unknown-kind-for-target: the business file names plug-in kind(s) harness, which ${A} does not know`,'']);
assert.deepEqual(out.slice(-8),['pm: the bus starts without --pm and the PM stays pending; next: Jason\'s plugins set of harness, then pm-on',...planTail(e,A),`refuses unknown-kind-for-target: the business file names plug-in kind(s) harness, which ${A} does not know`,'']);
out=await quiet(3,'activate',C1,'acme','--pm-after-harness','--plan');
assert.equal(out.at(-2),'refuses pm-after-harness-set: the business file already sets harness ["claude-code"]; use --pm');
biz(e);assert.equal((await run(e,'activate',C1,'acme','--pm-after-harness')).code,0);
out=await quiet(3,'activate',C,'acme','--plan');
assert.deepEqual(out.slice(-7),['pm: the pointer is pending (activate --pm-after-harness); the bus runs without its PM',...planTail(e,C,true),`refuses pm-pending: ${PENDING_MSG}`,'']);
assert.deepEqual(out.slice(-8),['pm: the pointer is pending (activate --pm-after-harness); the bus runs without its PM',...planTail(e,C,true),`refuses pm-pending: ${PENDING_MSG}`,'']);
out=await quiet(0,'rollback','acme','--plan');
assert.deepEqual(out.slice(-6),['pm: the pointer is pending (activate --pm-after-harness); the bus runs without its PM',...planTail(e,B,true),'']);
assert.deepEqual(out.slice(-7),['pm: the pointer is pending (activate --pm-after-harness); the bus runs without its PM',...planTail(e,B,true),'']);
});
// Row 176 test 11: supported and older Console copies deliberately differ from the checkout.
@@ -1706,6 +1706,113 @@ test('T54 row 188: a carried pair gets the proof file checks, and a proof file i
});
});
// Row 189 (#1687, mos-0972/0973): activate --no-send drops the pointer's --send; nothing turns it back on.
const sendLines=(out)=>out.filter((l)=>l.startsWith('send:'));
const noSend=(c)=>c.filter((x)=>x!=='--send');
const LIVE_NOTE=`note: ${B} is the pointer's copy; the run re-pins it and only the Console flags change`;
test('T55 row 189: activate --no-send drops --send, and rollback and pm-on carry it off',async(t)=>{
await t.test('test 1: a live-copy --no-send drops --send from the pointer and the unit, keeps the rest in order, logs the step and backs up the stopped store',async(t)=>{
const e=await mk(t),base=await seedPreview(e,undefined,['--send','--tester-port','7333']),want=noSend(base);
assert.deepEqual(want.slice(-2),['--tester-port','7333']);
const sum=createHash('sha256').update(readFileSync(e.store)).digest('hex'),n=calls(e).length;
let r=await prun(e,'activate',B,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
assert.equal(pointer(e).copy,B);assert.deepEqual(pointer(e).console,want);
assert.ok(conExec(e).endsWith(` --checkout ${repo} ${want.join(' ')}`),conExec(e));assert.doesNotMatch(conExec(e),/ --send( |$)/);
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
assert.match(stepText(e),/Z drop --send; --no-send was given\n/);
const act=pointer(e).activation,l=logLines(e).at(-1);
assert.deepEqual([l.op,l.from,l.to,l.result,l.backup],['activate',B,B,'ok',act]);assert.equal(backupSum(e,act),sum);
assert.deepEqual(startsStops(e,n),[`stop ${CON}`,`stop ${BUS}`,`start ${BUS}`,`start ${CON}`]);
await noLeak(e);
});
await t.test('test 2: a plain live-copy activate still refuses already-active, and so does --no-send once --send is gone',async(t)=>{
const e=await mk(t);await seedPreview(e,undefined,['--send']);
await refused(e,['activate',B,'acme'],'already-active',`${B} is the pointer's copy`);
await refused(e,['activate',B,'acme','--tester-port','7333'],'already-active',`${B} is the pointer's copy`);
await refused(e,['activate',B,'acme','--no-proof'],'already-active',`${B} is the pointer's copy`);
const r=await prun(e,'activate',B,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
await refused(e,['activate',B,'acme','--no-send'],'already-active',`${B} is the pointer's copy`);
const g=await mk(t);await seedPreview(g);
await refused(g,['activate',B,'acme','--no-send'],'already-active',`${B} is the pointer's copy`);
for(const x of [e,g])await noLeak(x);
});
await t.test('test 3: --plan prints one send: line in each case, with the live-copy note on a same-copy run',async(t)=>{
const e=await mk(t),base=await seedPreview(e,undefined,['--send']);
let out=await planOnly(e,0,'activate',B,'acme','--no-send');
assert.deepEqual(sendLines(out),['send: dropped; --no-send was given']);
assert.deepEqual(out.filter((l)=>l.startsWith('note: ')),[LIVE_NOTE]);
assert.equal(out[out.indexOf('send: dropped; --no-send was given')+1],`Console: scripts/webui-service.sh install --copy ${B} --copies ${COPIES} ${noSend(base).join(' ')}`);
out=await planOnly(e,0,'activate',C1,'acme');
assert.deepEqual(sendLines(out),['send: carried']);assert.deepEqual(out.filter((l)=>l.startsWith('note: ')),[]);
assert.ok(out.find((l)=>l.startsWith('Console: ')).endsWith(' --send'));
assert.deepEqual(sendLines(await planOnly(e,0,'activate',C1,'acme','--no-send')),['send: dropped; --no-send was given']);
let r=await prun(e,'activate',C1,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
assert.deepEqual(pointer(e).console,noSend(base));assert.match(stepText(e),/Z drop --send; --no-send was given\n/);
assert.deepEqual(sendLines(await planOnly(e,0,'activate',B,'acme','--no-send')),['send: off; --no-send was given and the pointer holds no --send']);
assert.deepEqual(sendLines(await planOnly(e,0,'activate',B,'acme')),['send: off']);
assert.deepEqual(sendLines(await planOnly(e,0,'rollback','acme')),['send: off']);
r=await prun(e,'activate',B,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
assert.match(stepText(e),/Z --no-send was given and the pointer holds no --send, so there is nothing to drop\n/);
assert.doesNotMatch(stepText(e),/drop --send/);
await noLeak(e);
});
await t.test('test 4: after --no-send, a rollback to the earlier copy leaves --send out of the pointer and the unit',async(t)=>{
const e=await mk(t),base=await seedPreview(e,undefined,['--send']);
let r=await prun(e,'activate',C1,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
r=await prun(e,'rollback','acme');assert.equal(r.code,0,r.out+r.err);
assert.equal(pointer(e).copy,B);assert.deepEqual(pointer(e).console,noSend(base));assert.doesNotMatch(conExec(e),/ --send( |$)/);
assert.doesNotMatch(stepText(e),/--send/);
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
await noLeak(e);
});
await t.test('test 4: pm-on after --no-send keeps --send off',async(t)=>{
const e=await mk(t),base=await seedPreview(e,{pm:true},['--send']);
let r=await prun(e,'activate',C1,'acme','--pm-after-harness','--no-send');assert.equal(r.code,0,r.out+r.err);
biz(e,HARNESS);
r=await prun(e,'pm-on','acme');assert.equal(r.code,0,r.out+r.err);
assert.equal(pointer(e).pm,true);assert.deepEqual(pointer(e).console,noSend(base));assert.doesNotMatch(conExec(e),/ --send( |$)/);
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
await noLeak(e);
});
await t.test('test 5: --no-send on rollback, pm-on, status, adopt or rehearse is a usage error',async(t)=>{
const e=await mk(t);await seedPreview(e,undefined,['--send']);
const usageText=(await prun(e,'--help')).err,n=calls(e).length,logs=logLines(e).length,before=fp(e);
for(const args of [['rollback','acme','--no-send'],['rollback','acme','--plan','--no-send'],['pm-on','acme','--no-send'],['status','acme','--no-send'],['adopt','acme','--no-send'],
['rehearse','acme','--backup','/tmp/x','--port','7400','--no-send']]){
const r=await prun(e,...args);assert.equal(r.code,4,args.join(' '));assert.equal(r.out,'');assert.equal(r.err,usageText);
}
assert.match(usageText,/\| --no-proof\] \[--no-send\]/);
assert.deepEqual(fp(e),before);assert.deepEqual(startsStops(e,n),[]);assert.equal(logLines(e).length,logs);
await noLeak(e);
});
await t.test('test 6: with both a pair and --send in the pointer, --no-send and --no-proof each drop what they name and keep the rest',async(t)=>{
const e=await mk(t),base=await seedPreview(e,undefined,['--send']),pre=noSend(base),f=await proofFile(e),g=await proofFile(e,'proof2.189',0o400);
let r=await prun(e,'activate',B,'acme',...pair(f));assert.equal(r.code,0,r.out+r.err);
assert.deepEqual(pointer(e).console,[...pre,...pair(f),'--send']);
let out=await planOnly(e,0,'activate',C1,'acme','--no-proof');
assert.deepEqual([...proofLines(out),...sendLines(out)],['proof: dropped; --no-proof drops header x-console-proof','send: carried']);
out=await planOnly(e,0,'activate',C1,'acme','--no-send');
assert.deepEqual([...proofLines(out),...sendLines(out)],['proof: carried, header x-console-proof','send: dropped; --no-send was given']);
out=await planOnly(e,0,'activate',B,'acme','--no-send','--no-proof');
assert.deepEqual([...proofLines(out),...sendLines(out)],['proof: dropped; --no-proof drops header x-console-proof','send: dropped; --no-send was given']);
r=await prun(e,'activate',C1,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
assert.deepEqual(pointer(e).console,[...pre,...pair(f)]);
r=await prun(e,'activate',C1,'acme','--no-send',...pair(g,'X-Proof-Two'));assert.equal(r.code,0,r.out+r.err);
assert.deepEqual(pointer(e).console,[...pre,...pair(g,'X-Proof-Two')]);
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
const h=await mk(t),hbase=await seedPreview(h,undefined,['--send']),hpre=noSend(hbase),k=await proofFile(h);
r=await prun(h,'activate',B,'acme',...pair(k));assert.equal(r.code,0,r.out+r.err);
r=await prun(h,'activate',C1,'acme','--no-proof');assert.equal(r.code,0,r.out+r.err);
assert.deepEqual(pointer(h).console,[...hpre,'--send']);
r=await prun(h,'activate',C1,'acme','--no-send','--no-proof');assert.equal(r.code,0,r.out+r.err);
assert.deepEqual(pointer(h).console,hpre);assert.doesNotMatch(conExec(h),/--proof-| --send( |$)/);
const steps=stepText(h);
assert.match(steps,/Z --no-proof was given and the pointer holds no proof pair, so there is nothing to drop\n/);assert.match(steps,/Z drop --send; --no-send was given\n/);
r=await prun(h,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
for(const x of [e,h])await noLeak(x);
});
});
// --- rehearse (D2, row 163; the brief's "Stack rehearsal on a scratch root") ---
// Copies of their own in RCOPIES: the real scripts/mosaic, config loader, business loader, roles and
// mosaic-config.mjs, with stand-ins for the bus host (cli.mjs), the Console, the Store and
+7 -3
View File
@@ -101,8 +101,10 @@ export function gitEnv(env = process.env) {
// consequence: a Ctrl-C at the terminal of a host run by hand no longer
// reaches its git, though a systemd stop still does (it kills the cgroup).
// `stdin` names a file git reads as its input (row 137: patch-id); without
// it git gets none.
export function runGit(argv, env, limits = GIT_LIMITS, { stdin = null } = {}) {
// it git gets none. `pass` (row 199, publish) is written once to a pipe on
// fd 3, which git and what it starts inherit; publish's credential helper
// reads its token there, so the token is in no argv, env, URL or file.
export function runGit(argv, env, limits = GIT_LIMITS, { stdin = null, pass = null } = {}) {
return new Promise((resolve) => {
let input = "ignore";
try {
@@ -110,8 +112,10 @@ export function runGit(argv, env, limits = GIT_LIMITS, { stdin = null } = {}) {
} catch (e) {
return resolve({ ok: false, out: "", err: "", why: e.code ?? e.message });
}
const child = spawn("git", argv, { env, detached: true, stdio: [input, "pipe", "pipe"] });
const child = spawn("git", argv, { env, detached: true, stdio: [input, "pipe", "pipe", ...(pass === null ? [] : ["pipe"])] });
if (typeof input === "number") closeSync(input);
// A git that never asked for it closes the pipe unread: EPIPE is no fault.
if (pass !== null) child.stdio[3].on("error", () => {}).end(pass);
let out = "";
let err = "";
let signalled = false;
+89 -1
View File
@@ -63,6 +63,22 @@
// compare and swap (base-moved), then record() runs; when it throws, main
// goes back, and a main that can't go back refuses rollback-failed.
// dropLand removes the worktree; the caller runs it on every path.
//
// publish (row 199, #1697, design D9 to D9b) sends a recorded work commit to
// the agent's own fork. It runs in two processes, because the gitea token
// lives in the bus process and the lock in the host's:
// publish, in the host, under the lock: the seed; the work ref is at the
// source commit (ref-moved); every commit in main..source has author and
// committer email equal to the agent's (identity-mismatch); then
// remote(), which the caller wires to publishRemote in the bus process.
// publishRemote, in the bus process, with the token on fd 3 only:
// `ls-remote --symref <fork> HEAD <dst>`; a non-zero exit refuses
// fork-unavailable. The fork's HEAD must be a commit this repository has
// and an ancestor of main (remote-unrelated, an empty fork included).
// Then `push <fork> <source>:<dst>` by oid, with no `+`. Only https
// crosses (protocol.https.allow), redirects are refused, and output
// holding the token refuses credential-leak. Git's stderr is never
// returned: it can carry the URL.
import { randomBytes } from "node:crypto";
import { closeSync, lstatSync, mkdirSync, openSync, readSync, renameSync, rmSync, writeFileSync } from "node:fs";
@@ -101,7 +117,7 @@ export const FSCK = ["-c", "transfer.fsckObjects=true"];
// `limits` is { timeoutMs, graceMs }; the tests shorten them. runGit
// (checkout.mjs) runs git in its own process group and signals the group.
const git = (args, env, { pre = [], limits = GIT_LIMITS, stdin = null } = {}) => runGit([...SAFE, ...pre, ...args], gitEnv(env), limits, { stdin });
const git = (args, env, { pre = [], limits = GIT_LIMITS, stdin = null, pass = null } = {}) => runGit([...SAFE, ...pre, ...args], gitEnv(env), limits, { stdin, pass });
// git with this call's env and limits, for the helpers below.
const bound = (env, limits) => (args, opts = {}) => git(args, env, { ...opts, limits });
@@ -402,3 +418,75 @@ async function removeWorktree(repo, worktree, g) {
if (!r.ok) rmSync(worktree, { recursive: true, force: true });
await g([`--git-dir=${repo}`, "worktree", "prune"]);
}
// await publish({ dataRoot, business, project, root, taskRef, source, email, remote, env, limits })
// Resolves what remote() resolves; rejects with WorkingError.
export function publish({ dataRoot, business, project, root, taskRef, source, email, remote, env = process.env, limits = GIT_LIMITS }) {
if (!PROJECT.test(project ?? "") || !OID.test(source ?? "") || typeof email !== "string" || !email) refuse("invalid-request");
const ref = `refs/heads/${workRef(taskRef)}`;
const repo = workingRepo(dataRoot, business, project);
const g = bound(env, limits);
return locked(repo, async () => {
try {
if (!lstatSync(repo).isDirectory()) refuse("working-repo-invalid");
} catch (e) {
if (e instanceof WorkingError) throw e;
refuse("working-repo-invalid", `${repo} can't be read: ${e.code}`);
}
await seed(repo, root, g);
const at = `--git-dir=${repo}`;
if ((await tip(repo, ref, g)) !== source) refuse("ref-moved", `${ref} isn't at ${source}`);
const range = await g([at, "log", "--format=%ae%x00%ce", `refs/heads/main..${source}`]);
if (!range.ok) refuse("working-repo-invalid", range.why);
for (const line of range.out.split("\n").filter(Boolean)) {
if (line !== `${email}\0${email}`) refuse("identity-mismatch", `a commit in main..${source.slice(0, 12)} isn't by ${email}`);
}
return remote();
});
}
const HOST = /^[a-z0-9.-]{1,253}(:[0-9]{1,5})?$/;
const NAME = /^[A-Za-z0-9_.-]{1,100}$/;
// The one-shot helper: for a `get` on https at exactly the fork's host and
// port, it answers the user and the line it reads from fd 3. Anything else,
// and `store` and `erase`, gets no answer. HOST and NAME keep both shell-safe.
const helper = (host, user) =>
`!f() { test "$1" = get || exit 0; p=; h=; while IFS= read -r l; do case "$l" in protocol=*) p=\${l#protocol=};; host=*) h=\${l#host=};; "") break;; esac; done; ` +
`test "$p" = https && test "$h" = '${host}' || exit 0; IFS= read -r t <&3 || exit 0; printf 'username=%s\\npassword=%s\\n' '${user}' "$t"; }; f`;
// await publishRemote({ repo, fork, user, source, dst, token, tls, env, limits })
// Resolves { ref, commit, previous }, previous being dst's tip on the fork
// before the push, or null. `tls` is for the tests' scratch CA only.
export async function publishRemote({ repo, fork, user, source, dst, token, tls = [], env = process.env, limits = GIT_LIMITS }) {
let url;
try {
url = new URL(fork);
} catch {
refuse("invalid-request");
}
if (url.protocol !== "https:" || !HOST.test(url.host) || !NAME.test(user ?? "") || !OID.test(source ?? "") || !dst?.startsWith(`refs/heads/${user}/`)) refuse("invalid-request");
const secret = String(token).trim();
const net = async (args) => {
const pre = [...tls, "-c", "protocol.https.allow=always", "-c", "http.followRedirects=false", "-c", "credential.helper=", "-c", `credential.helper=${helper(url.host, user)}`];
const r = await git([`--git-dir=${repo}`, ...args], env, { pre, limits, pass: `${secret}\n` });
if (r.out.includes(secret) || r.err.includes(secret)) refuse("credential-leak");
return r;
};
const g = bound(env, limits);
const listed = await net(["ls-remote", "--symref", fork, "HEAD", dst]);
if (!listed.ok) refuse("fork-unavailable");
let head = null;
let previous = null;
for (const line of listed.out.split("\n")) {
const [oid, name] = line.split("\t");
if (!OID.test(oid ?? "")) continue;
if (name === "HEAD") head = oid;
else if (name === dst) previous = oid;
}
if (!head) refuse("remote-unrelated", "the fork is empty");
const known = await g([`--git-dir=${repo}`, "cat-file", "-e", `${head}^{commit}`]);
if (!known.ok || !(await g([`--git-dir=${repo}`, "merge-base", "--is-ancestor", head, "refs/heads/main"])).ok) refuse("remote-unrelated", "the fork's HEAD isn't in main's history");
const pushed = await net(["push", "--porcelain", "--no-follow-tags", fork, `${source}:${dst}`]);
if (!pushed.ok) refuse(/\((non-fast-forward|fetch first)\)/.test(pushed.out) ? "non-fast-forward" : "push-failed");
return { ref: dst, commit: source, previous };
}
+95
View File
@@ -0,0 +1,95 @@
// Row 199 (#1697, design T3): a fake Gitea for publish, on 127.0.0.1 over TLS with a scratch CA,
// serving bare repositories under `<dir>/repos/<user>/<repo>.git` through git http-backend. Every
// request needs Basic auth with the user and token it was made with, or with any pair in `users`,
// as a private repository on Gitea does. It records each request's method, path and user, never the password. `redirect`
// makes every request a 302 to `redirect`. A repository's own hooks run on a push, so a test can
// make the fork echo something back as `remote:` lines. Nothing here leaves 127.0.0.1.
import { spawn, spawnSync } from "node:child_process";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { createServer } from "node:https";
import { join } from "node:path";
// A CA and a server certificate for IP 127.0.0.1, made once per directory with openssl.
export function scratchCa(dir) {
mkdirSync(dir, { recursive: true, mode: 0o700 });
const ssl = (...args) => {
const r = spawnSync("openssl", args, { cwd: dir, encoding: "utf8" });
if (r.status !== 0) throw new Error(`openssl ${args[0]}: ${r.stderr}`);
};
ssl("req", "-x509", "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:P-256", "-nodes", "-keyout", "ca.key", "-out", "ca.pem", "-days", "2", "-subj", "/CN=mosaic test CA");
ssl("req", "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:P-256", "-nodes", "-keyout", "server.key", "-out", "server.csr", "-subj", "/CN=127.0.0.1");
writeFileSync(join(dir, "san.ext"), "subjectAltName=IP:127.0.0.1\n");
ssl("x509", "-req", "-in", "server.csr", "-CA", "ca.pem", "-CAkey", "ca.key", "-CAcreateserial", "-out", "server.pem", "-days", "2", "-extfile", "san.ext");
return { ca: join(dir, "ca.pem"), key: readFileSync(join(dir, "server.key")), cert: readFileSync(join(dir, "server.pem")) };
}
export async function fakeGitea({ dir, tls, user, token, users = { [user]: token }, redirect = null }) {
const root = join(dir, "repos");
mkdirSync(root, { recursive: true });
const requests = [];
const server = createServer({ key: tls.key, cert: tls.cert }, (req, res) => {
const url = new URL(req.url, "https://127.0.0.1");
const auth = /^Basic (.+)$/.exec(req.headers.authorization ?? "");
const [who, pass] = auth ? Buffer.from(auth[1], "base64").toString().split(":") : [null, null];
requests.push({ method: req.method, path: url.pathname, user: who });
if (redirect) {
res.writeHead(302, { location: redirect + req.url });
return res.end();
}
if (!who || !Object.hasOwn(users, who) || pass !== users[who]) {
res.writeHead(401, { "www-authenticate": 'Basic realm="Gitea"' });
return res.end();
}
// /<owner>/<repo>/info/refs and the like, on <root>/<owner>/<repo>.git.
const m = /^\/([^/]+)\/([^/]+?)(?:\.git)?(\/.*)$/.exec(url.pathname);
if (!m) {
res.writeHead(404);
return res.end();
}
const cgi = spawn("git", ["http-backend"], {
env: {
PATH: process.env.PATH,
GIT_PROJECT_ROOT: root,
GIT_HTTP_EXPORT_ALL: "1",
GIT_CONFIG_NOSYSTEM: "1",
GIT_CONFIG_GLOBAL: "/dev/null",
HOME: "/nonexistent",
PATH_INFO: `/${m[1]}/${m[2]}.git${m[3]}`,
REQUEST_METHOD: req.method,
QUERY_STRING: url.search.slice(1),
CONTENT_TYPE: req.headers["content-type"] ?? "",
HTTP_CONTENT_ENCODING: req.headers["content-encoding"] ?? "",
GIT_PROTOCOL: req.headers["git-protocol"] ?? "",
REMOTE_USER: who,
REMOTE_ADDR: "127.0.0.1",
},
stdio: ["pipe", "pipe", "ignore"],
});
req.pipe(cgi.stdin);
const chunks = [];
cgi.stdout.on("data", (c) => chunks.push(c));
cgi.on("close", () => {
const out = Buffer.concat(chunks);
const split = out.indexOf("\r\n\r\n");
const head = out.subarray(0, split).toString().split("\r\n");
const headers = {};
let status = 200;
for (const line of head) {
const [k, ...v] = line.split(":");
if (k.toLowerCase() === "status") status = Number.parseInt(v.join(":").trim(), 10);
else headers[k] = v.join(":").trim();
}
res.writeHead(status, headers);
res.end(out.subarray(split + 4));
});
});
await new Promise((r) => server.listen(0, "127.0.0.1", r));
const port = server.address().port;
return {
port,
origin: `https://127.0.0.1:${port}`,
requests,
repo: (owner, name) => join(root, owner, `${name}.git`),
close: () => new Promise((r) => server.close(r)),
};
}
+185
View File
@@ -0,0 +1,185 @@
// Row 199 (#1697, design D9 step 3 to 5, D9a, T3): publish and publishRemote (src/working.mjs)
// against a TLS fake Gitea on 127.0.0.1 (fake-gitea.mjs) with a scratch CA, passed only through
// the `tls` parameter. Fake tokens in a scratch root. Each refusal that should reach the network
// checks the fake saw the request, so no case passes because git refused https first.
import { test } from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { gitEnv } from "../src/checkout.mjs";
import { publish, publishRemote, push, workingRepo } from "../src/working.mjs";
import { fakeGitea, scratchCa } from "./fake-gitea.mjs";
const TASK = "vikunja:1/2";
const TOKEN = "fake_publish_token_0123456789";
const USER = "coder";
const EMAIL = "[email protected]";
const DST = "refs/heads/coder/feat";
function git(cwd, ...args) {
const r = spawnSync("git", args, { cwd, encoding: "utf8", env: gitEnv() });
assert.equal(r.status, 0, `git ${args.join(" ")}: ${r.stderr}`);
return r.stdout.trim();
}
function commit(dir, text, email = EMAIL) {
writeFileSync(join(dir, "README"), `${text}\n`);
git(dir, "add", "README");
git(dir, "-c", `user.name=${text}`, "-c", `user.email=${email}`, "commit", "-q", "-m", text);
return git(dir, "rev-parse", "HEAD");
}
const rejects = (p, code) => assert.rejects(p, (e) => e.code === code || assert.fail(`${e.code}: ${e.message}, wanted ${code}`));
// A root, a coder's clone with work on feat pushed to the working repo, and a fake Gitea whose
// coder/stack fork is a bare clone of the root.
async function world(t, { fork = true, redirect = null } = {}) {
const dir = mkdtempSync(join(tmpdir(), "mosaic-publish-"));
t.after(() => rmSync(dir, { recursive: true, force: true }));
const root = join(dir, "root");
mkdirSync(root);
git(root, "init", "-q", "-b", "main");
const first = commit(root, "one", "[email protected]");
const clone = join(dir, "clone");
git(dir, "clone", "-q", root, clone);
git(clone, "checkout", "-q", "-b", "feat");
const work = commit(clone, "work");
const dataRoot = join(dir, "data");
mkdirSync(dataRoot, { mode: 0o700 });
const file = join(dir, "b.bundle");
git(clone, "bundle", "create", "-q", file, "refs/heads/feat", "--not", "--remotes=origin");
await push({ dataRoot, business: "acme", project: "stack", root, taskRef: TASK, branch: "feat", bundle: file });
const ca = scratchCa(join(dir, "ca"));
const gitea = await fakeGitea({ dir: join(dir, "gitea"), tls: ca, user: USER, token: TOKEN, redirect });
t.after(() => gitea.close());
const forkRepo = gitea.repo(USER, "stack");
if (fork) {
mkdirSync(join(forkRepo, ".."), { recursive: true });
git(dir, "clone", "-q", "--bare", root, forkRepo);
}
const tls = ["-c", `http.sslCAInfo=${ca.ca}`];
const repo = workingRepo(dataRoot, "acme", "stack");
const remote = (extra = {}) => publishRemote({ repo, fork: `${gitea.origin}/${USER}/stack`, user: USER, source: work, dst: DST, token: TOKEN + "\n", tls, ...extra });
const go = (extra = {}) => publish({ dataRoot, business: "acme", project: "stack", root, taskRef: TASK, source: work, email: EMAIL, remote: () => remote(), ...extra });
return { dir, root, first, clone, work, dataRoot, repo, gitea, forkRepo, tls, remote, go };
}
test("T3: publish pushes the work commit by oid to <user>/<branch> on the fork, with the token only through the helper", async (t) => {
const w = await world(t);
assert.deepEqual(await w.go(), { ref: DST, commit: w.work, previous: null });
assert.equal(git(w.dir, `--git-dir=${w.forkRepo}`, "rev-parse", DST), w.work);
// Every request went to the fork's path, and each one that carried auth carried the agent's user.
assert.ok(w.gitea.requests.length > 0);
for (const r of w.gitea.requests) assert.match(r.path, /^\/coder\/stack\/(info\/refs|git-receive-pack|git-upload-pack)$/);
assert.ok(w.gitea.requests.some((r) => r.user === USER && r.path.endsWith("git-receive-pack")));
// A second publish of the same commit is a no-op push and names the previous tip.
assert.deepEqual(await w.go(), { ref: DST, commit: w.work, previous: w.work });
// The working repo holds no remote and no credential config.
assert.equal(spawnSync("git", [`--git-dir=${w.repo}`, "config", "--get-regexp", "^(remote|credential)\\."], { env: gitEnv() }).stdout.toString(), "");
});
test("T3: a moved fork branch refuses non-fast-forward; no + and no force", async (t) => {
const w = await world(t);
const other = join(w.dir, "other");
git(w.dir, "clone", "-q", w.root, other);
const elsewhere = commit(other, "elsewhere");
git(other, "push", "-q", w.forkRepo, `${elsewhere}:${DST}`);
await rejects(w.go(), "non-fast-forward");
assert.equal(git(w.dir, `--git-dir=${w.forkRepo}`, "rev-parse", DST), elsewhere);
});
test("T3: D9a: an empty fork and an unrelated fork refuse remote-unrelated; a missing fork refuses fork-unavailable", async (t) => {
const empty = await world(t, { fork: false });
git(empty.dir, "init", "-q", "--bare", empty.forkRepo);
await rejects(empty.go(), "remote-unrelated");
assert.ok(empty.gitea.requests.length > 0);
assert.equal(empty.gitea.requests.some((r) => r.path.endsWith("git-receive-pack")), false);
const unrelated = await world(t, { fork: false });
const alone = join(unrelated.dir, "alone");
mkdirSync(alone);
git(alone, "init", "-q", "-b", "main");
commit(alone, "unrelated");
git(unrelated.dir, "clone", "-q", "--bare", alone, unrelated.forkRepo);
await rejects(unrelated.go(), "remote-unrelated");
assert.equal(unrelated.gitea.requests.some((r) => r.path.endsWith("git-receive-pack")), false);
const missing = await world(t, { fork: false });
await rejects(missing.go(), "fork-unavailable");
assert.ok(missing.gitea.requests.length > 0);
});
test("T3: a fork past main refuses remote-unrelated until root catches up", async (t) => {
const w = await world(t);
const other = join(w.dir, "other");
git(w.dir, "clone", "-q", w.forkRepo, other);
const ahead = commit(other, "ahead");
git(other, "push", "-q", w.forkRepo, `${ahead}:refs/heads/main`);
await rejects(w.go(), "remote-unrelated");
});
test("T3: a wrong token refuses fork-unavailable; git's stderr isn't in the refusal", async (t) => {
const w = await world(t);
await assert.rejects(w.remote({ token: "fake_wrong_token_0123456789" }), (e) => e.code === "fork-unavailable" && e.message === "fork-unavailable");
assert.ok(w.gitea.requests.length > 0);
assert.equal(git(w.dir, `--git-dir=${w.forkRepo}`, "for-each-ref", DST), "");
});
test("T3: a 302 is not followed: fork-unavailable, and the target sees no request", async (t) => {
const w = await world(t);
const target = await fakeGitea({ dir: join(w.dir, "target"), tls: scratchCa(join(w.dir, "ca2")), user: USER, token: TOKEN });
t.after(() => target.close());
const bounce = await fakeGitea({ dir: join(w.dir, "bounce"), tls: scratchCa(join(w.dir, "ca3")), user: USER, token: TOKEN, redirect: target.origin });
t.after(() => bounce.close());
const tls = ["-c", `http.sslCAInfo=${join(w.dir, "ca3", "ca.pem")}`];
await rejects(w.remote({ fork: `${bounce.origin}/${USER}/stack`, tls }), "fork-unavailable");
assert.ok(bounce.requests.length > 0);
assert.equal(target.requests.length, 0);
});
test("T3: the helper answers only the fork's host and port: a fork on another port gets no token", async (t) => {
const w = await world(t);
// The fork names port 1; insteadOf (through the tests' tls parameter) sends git to the fake's
// port. The helper was told port 1, so it answers nothing and no password goes out.
const before = w.gitea.requests.length;
await rejects(w.remote({ fork: `https://127.0.0.1:1/${USER}/stack`, tls: [...w.tls, "-c", `url.https://127.0.0.1:${w.gitea.port}/.insteadOf=https://127.0.0.1:1/`] }), "fork-unavailable");
assert.ok(w.gitea.requests.length > before, "the rewritten URL reached the fake");
assert.equal(w.gitea.requests.slice(before).some((r) => r.user !== null), false, "no credential went to a host the helper wasn't told");
});
test("T3: a fork that echoes the token refuses credential-leak", async (t) => {
const w = await world(t);
const hook = join(w.forkRepo, "hooks", "pre-receive");
writeFileSync(hook, `#!/bin/sh\necho ${TOKEN} >&2\nexit 0\n`);
chmodSync(hook, 0o755);
await rejects(w.go(), "credential-leak");
});
test("T3: no tls parameter, no trust: the scratch CA reaches git only through it", async (t) => {
const w = await world(t);
await rejects(w.remote({ tls: [] }), "fork-unavailable");
});
test("D9 steps 3 and 4: ref-moved and identity-mismatch refuse before any request", async (t) => {
const w = await world(t);
await rejects(w.go({ source: w.first }), "ref-moved");
const other = commit(w.clone, "by someone else", "[email protected]");
const file = join(w.dir, "b2.bundle");
git(w.clone, "bundle", "create", "-q", file, "refs/heads/feat", "--not", "--remotes=origin");
await push({ dataRoot: w.dataRoot, business: "acme", project: "stack", root: w.root, taskRef: TASK, branch: "feat", bundle: file });
await rejects(w.go({ source: other }), "identity-mismatch");
assert.equal(w.gitea.requests.length, 0);
});
test("publishRemote refuses a fork, user or destination that isn't shell- and ref-safe before any git", async (t) => {
const w = await world(t);
for (const extra of [
{ fork: `http://127.0.0.1:${w.gitea.port}/${USER}/stack` },
{ fork: `https://127.0.0.1:${w.gitea.port}'/${USER}/stack` },
{ user: "co'der" },
{ dst: "refs/heads/main" },
{ dst: "refs/heads/pm/feat" },
{ source: "HEAD" },
]) await rejects(w.remote(extra), "invalid-request");
assert.equal(w.gitea.requests.length, 0);
});
+6 -2
View File
@@ -80,7 +80,11 @@ export function tasksAdapter({
tree: null,
bots: new Set([businesses[name].tracker.sync.botId, ...Object.values(s.roles).map((r) => r.botId)]),
sync: use('@sync'),
as: (role, method, path, opts) => use(role)(method, path, opts),
// Row 199 (#1697, design D5): a request's call keys from the broker's session for its cap,
// never from a role name. probe is the host's own start check of each role's token.
as: (session, method, path, opts) =>
credentials.useFor(session, 'vikunja', (token) => call(token, method, path, opts)),
probe: (role, method, path, opts) => use(role)(method, path, opts),
state: 'starting',
refused: null,
queue: Promise.resolve(),
@@ -96,7 +100,7 @@ export function tasksAdapter({
timers: [],
};
ctx.syncCall = (_token, method, path, opts) => ctx.sync(method, path, opts);
ctx.asCall = (role) => (_token, method, path, opts) => ctx.as(role, method, path, opts);
ctx.asCall = (role) => (_token, method, path, opts) => ctx.probe(role, method, path, opts);
ctx.handle = verbs(ctx);
contexts.set(name, ctx);
}
+2 -2
View File
@@ -41,7 +41,7 @@ export async function startup(ctx) {
if (probe.status === 404 || probe.status === 403 || outcome(probe) === 'ok') refuse('scope-too-broad');
expect(probe, 401, 'tracker-unexpected');
for (const [role, r] of Object.entries(ctx.roles)) {
const control = await ctx.as(role, 'GET', `/tasks/${MISSING}`);
const control = await ctx.probe(role, 'GET', `/tasks/${MISSING}`);
if (control.status !== 404 || control.json?.code !== 4002) {
expect(control, 404, 'tracker-unexpected');
refuse('tracker-unexpected');
@@ -49,7 +49,7 @@ export async function startup(ctx) {
const probes = [['DELETE', `/tasks/${MISSING}`, undefined]];
if (r.definition !== 'pm') probes.push(['POST', `/tasks/${MISSING}/labels`, { label_id: 1 }]);
for (const [method, path, body] of probes) {
const p = await ctx.as(role, method, path, { body });
const p = await ctx.probe(role, method, path, { body });
if (p.status === 404 || p.status === 403 || outcome(p) === 'ok') refuse('scope-too-broad');
if (p.status !== 401) refuse(p.status === 0 || p.status >= 500 ? 'tracker-unavailable' : 'tracker-unexpected');
}
+9 -8
View File
@@ -122,8 +122,9 @@ export function verbs(ctx) {
const relatedTo = (task, kind, other) =>
Array.isArray(task.related_tasks?.[kind]) && task.related_tasks[kind].some((t) => t?.id === other);
// One write with the acting role's token. An uncertain answer is settled by a re-read.
async function step(role, method, path, body, at, done) {
const r = await ctx.as(role, method, path, { body });
// `me` is the broker's session for the caller's cap (design D5).
async function step(me, method, path, body, at, done) {
const r = await ctx.as(me, method, path, { body });
const o = outcome(r);
if (o === 'ok') return r;
if (o === 'uncertain' && at !== null && done) {
@@ -144,7 +145,7 @@ export function verbs(ctx) {
if (!mine) fail('tracker-role');
if (PM_VERBS.has(verb) && mine.definition !== 'pm') fail('field-writer');
const plan = prepare(verb, args, role, mine);
if (verb === 'task.create') return create(cap, role, plan);
if (verb === 'task.create') return create(cap, me, plan);
const at = target(args.task_ref);
const before = await read(at);
if (args.expect !== undefined && args.expect !== before.digest) {
@@ -165,7 +166,7 @@ export function verbs(ctx) {
let landed = 0;
try {
for (const [method, path, body, done] of work.steps) {
await step(role, method, path, body, at, done);
await step(me, method, path, body, at, done);
landed++;
}
} catch (e) {
@@ -385,12 +386,12 @@ export function verbs(ctx) {
fail('unknown-verb');
}
}
async function create(cap, role, plan) {
async function create(cap, me, plan) {
if (!ctx.broker.taskView(ctx.business, 'input', plan.request)) fail('request-not-found');
await located(plan.others);
ctx.broker.authorize(cap, 'task.create', { decision: plan.decision, target: null });
// No re-read can find a create whose answer was lost, so an uncertain create is reported as such.
const r = await step(role, 'POST', `/projects/${plan.at.project}/tasks`, plan.body, null, null);
const r = await step(me, 'POST', `/projects/${plan.at.project}/tasks`, plan.body, null, null);
if (r.status !== 201 && r.status !== 200) fail('tracker-unexpected');
checkTask(r.json);
const id = r.json.id;
@@ -399,9 +400,9 @@ export function verbs(ctx) {
let failure = null;
try {
for (const l of plan.labels)
await step(role, 'POST', `/tasks/${id}/labels`, { label_id: l }, at, (a) => a.fields.labels.includes(l));
await step(me, 'POST', `/tasks/${id}/labels`, { label_id: l }, at, (a) => a.fields.labels.includes(l));
for (const x of plan.relations)
await step(role, 'POST', `/tasks/${id}/relations`, { other_task_id: x.other, relation_kind: x.kind }, at, (a) =>
await step(me, 'POST', `/tasks/${id}/relations`, { other_task_id: x.other, relation_kind: x.kind }, at, (a) =>
relatedTo(a.task, x.kind, x.other),
);
} catch (e) {
+2 -1
View File
@@ -73,7 +73,8 @@ business's arbiter for its domain. Pass `domain` to `raise_decision`,
gated raise, and every hard wait, goes to the human through the inbox.
The hard waits are `spend`, `data.delete`, `network.expose`,
`account.create`, `credential.mint`, `message.external`,
`git.push.protected` and `deploy`; a citation never moves them.
`git.push.protected`, `git.push.remote` and `deploy`; a citation never
moves them.
A `role.revoke` raise whose target is the assistant is a hard wait too:
it goes to the human, cited or not.
+2 -1
View File
@@ -53,7 +53,8 @@ business's arbiter for its domain. Pass `domain` to `raise_decision`,
gated raise, and every hard wait, goes to the human through the inbox.
The hard waits are `spend`, `data.delete`, `network.expose`,
`account.create`, `credential.mint`, `message.external`,
`git.push.protected` and `deploy`; a citation never moves them.
`git.push.protected`, `git.push.remote` and `deploy`; a citation never
moves them.
A `role.revoke` raise whose target is the assistant is a hard wait too:
it goes to the human, cited or not.
+1 -1
View File
@@ -35,6 +35,6 @@ if [ "${1:-}" = business ]; then
exec node "$REPO/packages/business/src/cli.mjs" "$@"
fi
case "${1:-}" in
usage|project|limits|inbox|decide|tasks|agents|trail|talk|stop|launches|bus|plugins|name|names|follow|threads|thread) exec node "$REPO/packages/cli/src/cli.mjs" "$@" ;;
usage|project|limits|inbox|decide|tasks|agents|trail|talk|stop|launches|bus|plugins|credentials|name|names|follow|threads|thread) exec node "$REPO/packages/cli/src/cli.mjs" "$@" ;;
esac
exec node "$REPO/packages/seat/src/cli.mjs" "$@"
+38 -6
View File
@@ -3,7 +3,7 @@
# Moves the bus, the Console and the human CLI from the pointer's copy to another copy together,
# behind a health gate, and puts the old stack back inside the same run when the gate fails.
# Console tester listener flags follow target support (row 176, #1674); so does the row 178 proof
# pair, which activate sets or drops (row 187, #1685).
# pair, which activate sets or drops (row 187, #1685). activate --no-send drops --send (row 189, #1687).
# Design and tests: agents/darkwing/work/queue-162/BUILD.md.
#
# Who runs it. Decision 96 (Mos, mos-0716) lets this script stop and start the live units under
@@ -30,13 +30,13 @@ BIN="$HOME/.npm-global/bin"
UNIT_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
CONSOLE_UNIT=mosaic-webui.service
TIMEOUT=60 WANT_PM=0 AFTER_HARNESS=0 MIGRATE=0 PLAN=0 RESTORE="" EVENTS="" TESTER_PORT=""
PROOF_HEADER="" PROOF_FILE="" NO_PROOF=0 R_BACKUP="" R_PORT=""
PROOF_HEADER="" PROOF_FILE="" NO_PROOF=0 NO_SEND=0 R_BACKUP="" R_PORT=""
usage() {
cat >&2 <<'USAGE'
usage: stack-release.sh adopt BUSINESS
stack-release.sh activate ID BUSINESS [--pm | --pm-after-harness] [--migrate] [--tester-port N]
[--proof-header NAME --proof-file PATH | --no-proof] [--timeout S] [--plan]
[--proof-header NAME --proof-file PATH | --no-proof] [--no-send] [--timeout S] [--plan]
stack-release.sh pm-on BUSINESS [--timeout S]
stack-release.sh rollback BUSINESS [--restore ACTIVATION [--events N]] [--timeout S] [--plan]
stack-release.sh status BUSINESS
@@ -85,6 +85,7 @@ parse_opts() {
--proof-header) [ "$OP" = activate ] && [ $# -ge 2 ] && [ -n "$2" ] || usage; PROOF_HEADER="$2"; shift 2 ;;
--proof-file) [ "$OP" = activate ] && [ $# -ge 2 ] && [ -n "$2" ] || usage; PROOF_FILE="$2"; shift 2 ;;
--no-proof) [ "$OP" = activate ] || usage; NO_PROOF=1; shift ;;
--no-send) [ "$OP" = activate ] || usage; NO_SEND=1; shift ;;
--migrate) [ "$OP" = activate ] || usage; MIGRATE=1; shift ;;
--restore) [ "$OP" = rollback ] && [ $# -ge 2 ] && [[ "$2" =~ ^[0-9]{8}T[0-9]{6}Z-[0-9a-f]{12}(-[1-9])?$ ]] || usage; RESTORE="$2"; shift 2 ;;
--backup) [ "$OP" = rehearse ] && [ $# -ge 2 ] && { [[ "$2" =~ ^[0-9]{8}T[0-9]{6}Z-[0-9a-f]{12}(-[1-9])?$ ]] || [[ "$2" =~ ^/[A-Za-z0-9/._+-]*$ ]]; } || usage; R_BACKUP="$2"; shift 2 ;;
@@ -244,14 +245,16 @@ target_schema() {
in_chain() { local v; for v in "${T_CHAIN[@]}"; do [ "$v" != "$1" ] || return 0; done; return 1; }
# The item 3 checks on the target copy, in order: copy, schema, already-active. An activate that
# sets a proof pair, or drops the pointer's pair with --no-proof, may name the pointer's copy: it
# re-pins the live copy with new Console flags (row 187, Part B's B3 and B-R1).
# sets a proof pair, drops the pointer's pair with --no-proof, or drops the pointer's --send with
# --no-send, may name the pointer's copy: it re-pins the live copy with new Console flags (row 187,
# Part B's B3 and B-R1; row 189, mos-0972).
check_target() {
copy_exists "$TO" 2>/dev/null || refuse copy-missing "no copy $TO in $COPIES"
copy_ok "$TO" 2>/dev/null || refuse copy-unreachable "$TO is not reachable from refactor and origin/refactor"
target_schema "$TO" || refuse copy-schema-unknown "cannot read SCHEMA_VERSION and its chain from $COPIES/$TO/packages/bus/src/store.mjs"
[ "$TO" != "$P_COPY" ] || [ -n "$PROOF_HEADER" ] ||
{ [ "$NO_PROOF" = 1 ] && [[ " ${P_CONSOLE[*]} " == *" --proof-header "* ]]; } ||
{ [ "$NO_SEND" = 1 ] && [[ " ${P_CONSOLE[*]} " == *" --send "* ]]; } ||
refuse already-active "$TO is the pointer's copy"
}
@@ -690,6 +693,33 @@ console_for_target() {
DROP_TESTER="--tester-port $inherited"
fi
proof_for_target
send_for_target
}
# Row 189 (mos-0972): --no-send drops --send from RUN_CONSOLE, so the unit and the pointer both lose
# it; without the flag the pointer's --send goes on unchanged. Rollback and pm-on never take it, so
# they carry the pointer as it is. Sets SEND_PLAN (the plan's send: line) and SEND_STEP.
send_for_target() {
local had=0 i
local -a without_send=()
for ((i = 0; i < ${#RUN_CONSOLE[@]}; i++)); do
if [ "${RUN_CONSOLE[i]}" = --send ]; then had=1; else without_send+=("${RUN_CONSOLE[i]}"); fi
done
SEND_STEP=""
if [ "$NO_SEND" = 1 ]; then
RUN_CONSOLE=("${without_send[@]}")
if [ "$had" = 1 ]; then
SEND_PLAN="send: dropped; --no-send was given"
SEND_STEP="drop --send; --no-send was given"
else
SEND_PLAN="send: off; --no-send was given and the pointer holds no --send"
SEND_STEP="--no-send was given and the pointer holds no --send, so there is nothing to drop"
fi
elif [ "$had" = 1 ]; then
SEND_PLAN="send: carried"
else
SEND_PLAN="send: off"
fi
}
# Row 187: the row 178 proof pair in RUN_CONSOLE. A new pair replaces the pointer's and goes last;
@@ -943,6 +973,7 @@ plan() {
echo "bus: scripts/bus-service.sh install --copy $TO --copies $COPIES$([ "$RUN_PM" = 0 ] || echo ' --pm') --claude $P_CLAUDE"
[ -z "$DROP_TESTER" ] || echo "drop $DROP_TESTER; the target Console does not support it"
echo "$PROOF_PLAN"
echo "$SEND_PLAN"
echo "Console: scripts/webui-service.sh install --copy $TO --copies $COPIES${RUN_CONSOLE[*]:+ ${RUN_CONSOLE[*]}}"
echo "CLI: scripts/stack-copy.sh wrapper $TO --copies $COPIES --bin $BIN"
local r
@@ -956,6 +987,7 @@ run_steps() {
capture || refuse capture-failed "could not write the rollback set $RS; nothing was stopped"
[ -z "$DROP_TESTER" ] || say "drop $DROP_TESTER; the target Console does not support it"
[ -z "$PROOF_STEP" ] || say "$PROOF_STEP"
[ -z "$SEND_STEP" ] || say "$SEND_STEP"
trap 'after_fail signal' INT TERM HUP
stop_both || rollback_in_run stop-timeout
if [ -e "$STORE" ]; then
@@ -1478,7 +1510,7 @@ STORE="$DATA/bus/bus.sqlite"
BUS_UNIT="mosaic-bus@$BUSINESS.service"
FROM="" TO="" ACT="" RS="" POST="" BACKUP="" TAKEN="" RESTORED="" STEPS="" COUNT="" UNCLEAN="" FAIL=""
STORE_SCHEMA="" START_EPOCH="" START_MS=0 GATE="" GATE_WHY="" S_CHANGED=""
P_COPY="" P_PM=0 P_PENDING=0 P_SCHEMA="" P_CLAUDE="" P_CONSOLE=() RUN_PM=0 RUN_PENDING=0 RUN_CONSOLE=() DROP_TESTER="" PROOF_PLAN="" PROOF_STEP="" PLAN_REFUSE=()
P_COPY="" P_PM=0 P_PENDING=0 P_SCHEMA="" P_CLAUDE="" P_CONSOLE=() RUN_PM=0 RUN_PENDING=0 RUN_CONSOLE=() DROP_TESTER="" PROOF_PLAN="" PROOF_STEP="" SEND_PLAN="" SEND_STEP="" PLAN_REFUSE=()
R="" R_RC=0 R_EARLY=() R_BK="" R_HOST_PID="" R_CONSOLE_PID="" R_STARTED="" LIVE_CFG="" LIVE_BIZ=""
case "$OP" in
adopt) do_adopt ;;