Compare commits

..
49 changed files with 499 additions and 5369 deletions

No files matched your search

-2
View File
@@ -5290,5 +5290,3 @@ Before: managed project/thread verbs had no Console conversation area. After: fi
2026-10-22 08:00:02 UTC | Sage (1ef1e4f8) | row 280 #1785 landed | before: the bus tests' scratch sshd helper returned a port on a connect alone, and 194 b16's gate hung at G13 (base hung too, honker-0127). After: b0fd28b6f on refactor, a ff from da5d3651d (b765f5cb8 + Rocko slim packet; full evidence d6c924764 on rocko/row280-da5d-20261022). Duckworth approve rev 3153 (manifest 27806c05). Sage gate bus 656/0/0, ge 41/0/0, hr 26/0/0 (c30431). Rocko repeat 2010/2010 under cli 1588/1588, no timeout; M1/M2 killed 4/4. Two build-time slips recorded in Rocko's packet: a runner root path (no test ran) and the CRLF sshd log line. Overlap 13/257 clean. Note rev 3154, done 3155, #1785 closed c30432, P19 511 comment 623. 194 b17 rebases on b0fd28b6f (sage-2542).
2026-10-22 08:38:11 UTC | Sage (1ef1e4f8) | row 194 #1692 landed | before: 194 b16 9cdc47d04 went red three times (stopRun deadline, then the bus test sshd port race), fixed by rows 279 and 280. After: fd6dd327c on refactor, a ff on e122aad8e. b18 is b17 3f451772d rebased after my doc and queue push blocked a ff (16 =, series patch-id 64303253f8e6, packages equal b17). Webby approve rev 3160 on manifest 29f131ab. Honker gate on fd6dd327c: runb8 503/0/0, mutants 17/17; 18 packages 3668/0, ten scripts rc=0, Docker up, env skips named. The secret scan hit three fake_*_token fixtures in bus tests (false positive). Note 3161, done 3162; #1692 closed c30435; P19 414 comment 625. LIVE still 86f95bb27.
2026-10-22 08:42:04 UTC | Honker (Claude Opus 5.5, ecd17786) | row 194 #1692 agent lifecycle build | before: no Console or CLI lifecycle for agents (create, launch, terminate, regenerate, delete) and no per-agent hire/fire veto. After: 16 commits landed as fd6dd327c (b18, a rebase of b17 3f451772d on e122aad8e: 16 =, series patch-id 64303253f8e6, packages equal b17). Manifest 53 paths, digest 29f131ab. Final run on fd6dd327c: runb8 39 files whole 503/0/0, mutants 17/17 killed, restores matched; gate.sh merge onto e122aad8e, 18 packages 3668/0, ten scripts rc=0, Docker up, four env skips named (webui esbuild pin, queue render outside canonical root, test-task live provider x2); sweep clean. Corrections: b16 went red at G13; my diagnosis (60 runs, base hang on port 38927 sshd bind EADDRINUSE) placed it outside 194, fixed by row 280; the g13diag load kill missed its process group and overran about 8 minutes; one runb8 launch failed rc=126 on a lost exec bit before any test ran; the b17 runb8 was stopped unscored when refactor moved. In-review revs 3156/3157, Webby approve rev 3160.
2026-10-22 09:43:34 UTC | Sage (Claude Opus 5.5, 1ef1e4f8) | row 236 #1742 Gitea grant landing, and row 281 filing | 236 landed bd5f11ded at 09:37:31Z, a fast-forward on be2b008cc: 08840f3c1 rebased past 194 (commit 1 unions only, 2-7 =), e40b74beb for two stale out-of-manifest pins, bd5f11ded for a gitea-not-configured raise test. Dewey approve rev 3168 on manifest 0b00269b (34 paths). Megavolt gate under my slot: 2c bus 131, cli 224, route 28, docker 1; whole bus 765, business 228, cli 832, ten scripts rc=0; mutants 40/40; skips named, env only. Overlap with 13 and 257 clean. Note 3169, done 3170; #1742 closed c30451; P19 436 comment 639. Correction: the first whole run on ce74477e1 went red 762/764 on the two stale pins; that run is void. Follow-up per mos-1271: row 281 (#1786), a fire or delete revokes host and Gitea grants, Megavolt owns, Dewey reviews; brief docs/plans/2026-10-22_fire-revokes-grants.md; DEFERRED entry points at it.
+74
View File
@@ -0,0 +1,74 @@
# Row 256 (#1761) build notes (append only)
## 2026-10-22 b1 (Honker)
Design d2 8c50481d2 was accepted by Webby (webby-0123). The code is on honker/row256 from be2b008cc.
### Correction to the design: the non-creative END_PROTECTED definition is `assistant`
- DESIGN.md (d1 and d2) says "the chief of staff" for the END_PROTECTED definition that isn't creative,
following the brief's wording. In the code that definition is `assistant`:
- `THREAD_DEFINITION = "assistant"` (vocabulary.mjs:203).
- `END_PROTECTED = THREAD_DEFINITIONS = [assistant, ...CREATIVE_DEFINITIONS]`.
- The `chief-of-staff` definition (the `chief` instance) isn't END_PROTECTED. Its delete refuses
`organization-instance-referenced`, because it is launch.by, and that is unchanged.
- So T3 and M2 use an `assistant` instance. The code is the same either way: protectedRefusal refuses
every END_PROTECTED definition that isn't creative.
- DESIGN.md isn't edited after approval; this note is the record.
### Persona file on delete (brief item 4)
- `<configDir>/personas/<business>/<instance>.md` is left in place. Nothing removes it, so there is no
new open and no O_NOFOLLOW test.
- Nothing reads it once the entry is gone. bundle.mjs reads it only for a launched creative instance,
and the name is retired, so `add` refuses it (instance-settings.mjs, organization-instance-retired)
and no later instance picks up the old persona.
- The human removes the file by hand if they want it gone.
### What changed
- packages/business/src/instance-settings.mjs:
- New `protectedRefusal(document, instance, actor)`.
- `deleteRefusal(..., actor)`, `planDelete(..., actor)` and `checkDelete(..., actor)` take the
actor, and set op delete passes its actor.
- `read(business, actor)`. Its `endProtected` is `protectedRefusal(...) !== null`.
- commit's and fire's returned listings read with the writer's actor.
- The comment at :28-31 is rewritten.
- packages/bus/src/runtime.mjs: `instances` and `instances.check` pass the actor. There is no broker
change.
- packages/webui/src/public/agents.js: comment only.
- docs/plans/DEFERRED.md: a closing line on the creative-delete entry.
- Tests:
- business instance-settings.test: T1-T3 and T5 in one test, T4 in its own test, and the row 213
test's `endProtected` for the human's add is now `false`.
- business lifecycle-settings.test: checkDelete with the actor; fire unchanged.
- bus creative.test: T6 on the real runtime.
- webui agents-browser.test: T7/T8. The comment on the existing assertion is reworded.
### Mutants for the gate (exact-once replacements, runb8 shape)
- M1 (instance-settings.mjs): ` if (actor === document.human) return null;` becomes ` return null;`.
The kill is instance-settings.test "row 256 T1-T3".
- M2 (instance-settings.mjs): move the human check ahead of the creative one. Replace
` if (!CREATIVE_DEFINITIONS.includes(definition)) return { code: "organization-definition-refused" };\n if (actor === document.human) return null;`
with
` if (actor === document.human) return null;\n if (!CREATIVE_DEFINITIONS.includes(definition)) return { code: "organization-definition-refused" };`.
The kill is "row 256 T1-T3" (T3).
- M3 (instance-settings.mjs): ` endProtected: protectedRefusal(document, name, actor) !== null,` becomes
` endProtected: END_PROTECTED.includes(entry.definition),`. The kills are "row 256 T1-T3" (T5) and
bus creative.test T6.
- M4a (runtime.mjs): `instances.checkDelete(id,args.instance,actor)` becomes
`instances.checkDelete(id,args.instance)`. The kill is creative.test "row 256 T6".
- M4b (instance-settings.mjs): `planDelete(old, instance, actor), { revision` becomes
`planDelete(old, instance), { revision`. The kills are "row 256 T1-T3" (T1) and creative.test T6.
### Tests not run
None of these have run. They wait for Sage's CLEAR, after the rebase onto 236.
## 2026-10-22 b2 (Honker): Webby's N1 (webby-0124)
- Two comments said the chief of staff where they mean the assistant. Both now name the assistant:
- instance-settings.mjs:30, "The assistant is refused for every actor, the human included."
- agents.js:46, "true for the assistant and on the tester's."
- Comment-only. The mutant strings above are unchanged and still occur once.
+186
View File
@@ -0,0 +1,186 @@
# Row 256 (#1761) design: the human deletes a creative instance
Brief: docs/plans/2026-10-21_creative-delete-human.md (REQ-COS-3). Base be2b008cc (row 194 landed as
fd6dd327c). Builder Honker, reviewer Webby. Draft 2, 2026-10-22: Webby's d1 review (webby-0122, notes
171480e6e) R1, Q1, Q2 and N1-N2 applied; the changes are listed at the end.
## What changes
### 1. One predicate in instance-settings.mjs
Today `deleteRefusal(document, rolesDir, instance)` refuses every END_PROTECTED definition with
`organization-definition-refused`, and read()'s `endProtected` field is the same check
(`END_PROTECTED.includes(definition)`).
New, in `packages/business/src/instance-settings.mjs`:
```js
// Row 256 (#1761, REQ-COS-3): the protected-definition part of a delete. A creative instance is
// the human's to delete; any other actor is refused. A non-creative END_PROTECTED definition (the
// chief of staff) is refused for every actor, the human included.
function protectedRefusal(document, definition, actor) {
if (!END_PROTECTED.includes(definition)) return null;
if (!CREATIVE_DEFINITIONS.includes(definition)) return { code: "organization-definition-refused" };
return actor === document.human ? null : { code: "organization-creative-human-only" };
}
export function deleteRefusal(document, rolesDir, instance, actor) {
...unknown-instance unchanged...
const refusal = protectedRefusal(document, entry.definition, actor);
if (refusal) return refusal;
...references, kept, credentials unchanged...
}
```
- A creative definition with `actor === document.human` goes on to the remaining checks:
references, then kept, then credentials.
- The code and message match `creativeHumanOnly` in launch-settings.mjs:13.
- The code is `organization-creative-human-only`.
- It is raised as a plain refusal code here, as deleteRefusal's other codes are.
- I don't call `creativeHumanOnly` itself, because it throws and deleteRefusal returns.
- `actor` undefined, null, "tester", or a role actor (`<definition>.<instance>`) never equals
`document.human`, so those actors are refused.
- validateBusinessDocument runs `requireId(document.human)` (business.mjs:239), so an undefined
actor can't match an undefined human.
### 2. The actor reaches every delete path
| Path | Today | Change |
| --- | --- | --- |
| `planDelete(document, instance)` | no actor | `planDelete(document, instance, actor)` |
| `set(business, args, actor)` op delete (:207) | has the actor, doesn't pass it | passes it to planDelete |
| `checkDelete(business, instance)` | no actor | `checkDelete(business, instance, actor)` |
| runtime.mjs:283 `instances.check` | `checkDelete(id, args.instance)` | `checkDelete(id, args.instance, actor)` |
- The broker's verb check `#deleteRequest` already passes `s.human` to `instances.check` (broker.mjs:2843).
- The launcher pass `lifecycleDelete` already passes the request's `r.by`, which is
`#owner(s)` at record time, to `instances.set op delete`.
- So the broker gets no logic change. Only the deferred comment at :2825 is touched, if it names
the old rule.
- The CLI (`mosaic instances delete`) and the Console both reach the same bus verb with the human's
session.
- The tester is refused `tester-refused` at the verb, before any of this (broker.mjs:2595,
serve.mjs:394).
- That is unchanged.
### 3. The listing: `endProtected` becomes the per-viewer delete check
- `read(business)` becomes `read(business, actor)`, and runtime.mjs passes the actor.
- The broker already passes `"tester"` for a tester session and `s.human` otherwise (broker.mjs:2627).
- Each instance's `endProtected` becomes `protectedRefusal(document, def, actor) !== null`.
- This is the same server predicate the delete runs, per the brief.
- The human's read shows `endProtected: false` for a creative instance.
- The tester's read, and any role read, show `true`.
- The chief of staff shows `true` for everyone.
- The listing that a write returns is read with the writer's actor.
- This covers `commit`'s `{ ...read(business), ... }` at :405 and fire's at :338.
- So the human's add of bean returns `endProtected: false`, and the existing :288 assertion
changes from `true` to `false` for "jason".
- I keep the field name, so the listing gains no key and the deepEqual shapes in
instance-settings.test, lifecycle-settings.test and agents-browser.test keep their keys.
- Only the value for a creative entry read by the human changes.
- The cost is a name that now means "delete refused for its protected definition, for this
viewer".
- The read() comment says so.
- A rename (for example `deletable`) touches every listing fixture; I'd rather not, but I'll take
Webby's call.
- agents.js:46 is unchanged in logic: `if (!e.endProtected)` offers Delete.
- Its comment changes to name row 256.
- Delete stays inside the existing `!c.isTester` branch, so the tester page offers none whatever
the field says.
- The tester has two guards: the server's `true`, and the page's tester branch.
### 4. Fire is unchanged
- `fireChecks` / `checkFire` / `instance.fire` / `role.fire` keep refusing END_PROTECTED for
every actor.
- protectedRefusal is not called from fire.
- A test pins that the human's fire of a creative instance still refuses.
### 5. Persona file
- `<configDir>/personas/<business>/<instance>.md` is left in place on delete.
- That is the brief's smallest choice.
- The delete commits the business file and renames the workspace under `.retired`, as today.
- The persona file isn't under either path, and nothing reads it once the entry is gone.
- bundle.mjs reads it only for a launched creative instance.
- Retired names are never reused (row 194 D1), so a later hire can't pick up the old persona.
- This goes in BUILD.md, plus a DEFERRED line if Sage wants a cleanup later.
- No new file open, so no O_NOFOLLOW test.
### 6. DEFERRED.md
The "Creative instances: the human can't delete one" entry (DEFERRED.md:387) gets a closing line:
> Closed by row 256 (#1761) for delete. Fire still refuses, by REQ-COS-3.
The entry says "the row covers fire and delete together". The brief keeps fire unchanged, so the
closing line says the fire half is closed by ruling, not by code.
## Tests
| File | Test |
| --- | --- |
| business/tests/instance-settings.test.mjs | T1: the human deletes a creative instance (bean); the file loses the entry. |
| | T2: the same delete by a role actor (`chief-of-staff.chief`), by "tester" and by no actor refuses `organization-creative-human-only`, and the file is unchanged. |
| | T3: the human's delete of the chief of staff refuses `organization-definition-refused`. |
| | T4 (R1): with bean the only name in launch.instances, the human's delete of bean gets the last-listed BusinessError from deletedDocument (:64-66), not the creative code, so it passes protectedRefusal and reaches the later checks. A creative instance can't be in launch.keep, launch.by or the arbiters (business.mjs:208, :181-183, :251-255), so those aren't used. |
| | T5: read(business, human) shows `endProtected: false` for bean; read(business, "tester") and read(business) with no actor show `true`; the chief of staff shows `true` for every actor. |
| | The existing :283-292 test is updated where it pinned the old refusal. |
| business/tests/lifecycle-settings.test.mjs | checkDelete with the human passes for a creative instance and with another actor refuses. |
| | The fire of a creative instance by the human still refuses (fire unchanged). |
| bus/tests/creative.test.mjs (Q2: its runtime() is a real startBroker with the business writer) | T6: the human's `instances.set op delete` on bean records a lifecycle.delete request. |
| | Taking it and running the launcher pass commits the delete (the r.by path). |
| | The chief is refused `human-required` (broker.mjs:2585) and the tester `tester-refused` (:2595), both at the verb. |
| | The human's `instances` read has `endProtected: false` for bean; the tester's has `true`. |
| webui/tests/agents-browser.test.mjs | T7: the human page with bean at `endProtected: false` offers Delete for bean. |
| | T8: the tester page with the same listing offers no Delete. |
| | The existing sage-2164 assertion moves to the tester-shaped listing (bean `endProtected: true`). |
- The browser tests stub `/api/instances`, so they pin the page's use of the field.
- The server side of "never on the tester listener" is T5/T6.
- No test touches live paths: fixtures under mktemp in /mnt/storage/scratch, as today.
## Mutants (all must be killed)
- M1: protectedRefusal's creative branch returns `null` for every actor, so a non-human deletes a
creative instance. Killed by T2 only (N1): through the bus no non-human actor reaches the delete,
since the chief and the tester are refused at the verb.
- M2: protectedRefusal checks the human before the creative check, so
`actor === document.human ? null : ...` for every END_PROTECTED definition. The human then
deletes the chief of staff. Killed by T3.
- M3: read() keeps the old `END_PROTECTED.includes`, so the listing isn't the per-viewer predicate.
Killed by T5.
- M4 (N2): the actor isn't threaded: runtime.mjs:283 calls `checkDelete(id, args.instance)`
without it. Killed by T6, the human's real-writer delete. A second variant, set op delete not
passing the actor to planDelete, is killed by T1 and by T6's launcher pass.
## Boundaries kept
- No change to M10 add/change, role.hire, fire, veto or launch.keep.
- No schema change and no migration.
- No new verb.
- No change to the tester gate.
## Gate (on Sage's CLEAR)
- Touched test files, each whole.
- The business, bus, cli and webui suites.
- Every scripts/test-*.sh.
- The four mutants above (M4 in both variants), each on a green base, restored and blob-checked (the runb8 shape).
## Comments rewritten (Q1)
- `endProtected` keeps its name: agents.js:46 is its only reader, and it means "don't offer Delete".
- instance-settings.mjs:28-31 loses "no one deletes one yet". It says the human deletes a creative
instance and any other actor is refused, and that the listing's endProtected is protectedRefusal
for the reader.
- agents.js:45 says the same, naming row 256.
## Changes from draft 1 (webby-0122)
- R1: T4 uses the last-listed refusal instead of launch.keep, which the validator refuses for a
creative instance.
- Q1: keep the name; comments rewritten; T5 pins read(business) with no actor as true.
- Q2: the bus test goes in creative.test.mjs.
- N1: M1 is killed by T2 only; T6 pins the chief's `human-required`.
- N2: M4 added for the threading.
-149
View File
@@ -1,149 +0,0 @@
# Row 236 build notes: #436b Gitea grant path (#1742, schema 3j-3k)
Builder Megavolt, reviewer Dewey, gate owner Sage. Draft; the results sections are filled in after the
runs on Sage's gate.
## Sources
- Brief: `docs/plans/2026-10-20_agent-credentials.md`, "Brief: #436b Gitea grant path, build (schema 3j-3k)",
blob 9259ba2a1.
- Design: `agents/megavolt/work/436b/DESIGN.md` r5 at 1be535aca (sha256 68df02cd…f4), unedited. Scope:
sections 2 to 7, and section 9's X-G1 to X-G15 and mutants. Section 10 (GitHub half) is out of scope.
- Build carry: `agents/megavolt/work/436b/BUILD-CARRY.md` at 3330c901b (N9, N10).
- Schema label 3j-3k per sage-2237. If 264a or 206 lands first, this rebases onto a new branch name and reruns
the whole bus suite, the schema.test counts and the T46 lists.
- Rebases: onto 86f95bb27 after row 273 (sage-2403, no conflicts), then onto 0fa7bf421 after row 191
(sage-2452, sage-2457). The 191 rebase took three unions in commit 1, 191's `processing` first and then the
grant options: the Broker constructor (broker.mjs:317), the runtime's Broker options (runtime.mjs:173) and
the cli case line in `scripts/mosaic` (191's `halt|resume|processing|` plus `grants`).
## What changed
- `packages/bus/src/gitea-grant.mjs` (new): the grant body check, the admin slot (set, rotate, remove), the
admin and seat API calls, the start-time scan (`giteaStart`, `openGiteaGrants`, `tokenUnknown`).
- `packages/bus/src/broker.mjs`: the gitea executor (steps 0 to 8), settle, restart reconcile, revoke and fire,
sweep, status, and the `grants.*` verbs.
- `packages/bus/src/credentials.mjs`: `held` pairs at start; the register and claim paths the executor uses.
- `packages/bus/src/migrations/3j-3k.sql`, `schema.sql`, `store.mjs`: `gitea_users` and `gitea_grants`, new
tables only.
- `packages/bus/src/runtime.mjs`, `server.mjs`, `human-cli.mjs`: wiring of the start scan and the verbs.
- `packages/cli/src/grants.mjs` (new), `cli.mjs`, `scripts/mosaic`: `mosaic grants`.
- Tests: `gitea-grant.test.mjs`, `gitea-migration.test.mjs`, `gitea-grant-docker.test.mjs` (X-G3),
`fake-gitea-api.mjs`, `fixtures/schema-3j.sql` (02da6a2ae's schema.sql), `cli/tests/grants.test.mjs`, and the
pin updates in the migration tests, `schema.test.mjs`, `channel-proof.test.mjs` and `stack-release.test.mjs`
(T46).
- READMEs of bus and cli.
## Choices and assumptions
Each is a reading of the design where it left room. Dewey may rule otherwise.
1. Admin rotation is due once today is past `rotateBy` (the date itself is still good).
2. A seat-token rotation moves the record at step 8, after the old token's delete (step 7).
3. A step 7 failure leaves the grant applied, with the old id in `serviceLeft` and the notice "The previous
token X is still live".
4. A failure after the commit step's rename returns `settle-pending`; the next start settles it.
5. tester-cli keeps its 5 s timeout; the grant verbs answer at once and the executor runs after.
6. A revoke of a token-unknown agent takes both ids from that decision's last commit step.
7. A user adopted at restart is upserted in the same transaction as its `grant.failed interrupted`.
8. `#giteaUnlink` (first-grant commit undo) uses a temporary reference dated `9999-12-31`, so it is never due
while it lives.
9. X-G12's org-team case: the fake models team membership as collaborator reach on the repository, which is
what `/user/repos` shows (F11, F12).
10. X-G15's look-alikes are stored under `action.refused` and under a non-grant kind, since the 3j trigger
refuses the human's NULL pair on `action.allowed`. The trigger cases of X-G13 (session, NULL pair,
`grant.host.enrolled`) are in `grant-migration.test.mjs`.
11. Step 6 faults: a pre-rename failure is injected by making `gitea.tmp` a directory; a step 8 store fault by
a TEMP trigger on `gitea_grants`.
12. The credentials-verb revoke close is tracked, so `giteaIdle()` waits for it.
13. `openGiteaGrants` gives `agent: null` for a spend with no step yet, so `status.running` can show a null
agent; the CLI prints it empty.
14. N9: a restart ignores a settle-pending commit with a `grant.revoked` after it and ends it `revoked` with
no message (notify false). A 404 on a delete by id counts as done.
15. N10: `serviceLeft` is a list in every body and answer.
16. A restart claims the agent's key for each open grant it undoes. One executor runs per agent
(grant-busy), so a killed child leaves at most one open grant per agent. If a store ever held two, the
second's claim fails and the undo runs with no admin: its token is left and named in `serviceLeft`,
never deleted unclaimed.
17. X-G7 changes the binding with a fixture decision whose hash differs (row 234's G9 pattern), since the
broker keeps its own copy of the business. The admin url check takes row 199's `parseGiteaUrl`, which
accepts the origin with or without a trailing slash.
18. r2, Dewey B1 (dewey-0781): step 8 and both settle branches (forward, back) check for a `grant.revoked`
of the agent after the decision's raise, in the same transaction as their write. A revoke that came
while step 7's or the settle's delete awaited took the record and both tokens, so the grant ends
`revoked` with the human's notice, as a revoke of a running executor ends it (X-G11), and no record is
written back. N9's restart case stays silent: a restart never settles a revoked rotation.
19. r2, Dewey B2: a sweep retry of `tokens` or `login` checks the user's id first, as the revoke close does,
against the `gitea_users` row (it survives a fire). A 404 skips with `gitea-user-gone`, a 200 with
another id with `gitea-user-foreign`, any other status leaves the entry. Skips go in `grant.swept` and
the sweep answer as `skipped: [{agent, user, id, reason}]`; a skipped entry is not left, so the next
sweep does not retry it.
20. r3, Dewey N5 (dewey-0783): the settle's 401 branch and its grant-token-unknown branch take the same
revoke check. A revoke during the whoami ends the grant `revoked`, not `grant-lost`, and the
token-unknown branch neither holds the agent again nor writes `grant-token-unknown`, so no start
names a file the revoke unlinked.
21. sage-2453, ruling (a) on megavolt-0123: 191's `#processingGate` refuses every agent verb off its read list
while processing is halted. `grants.status` is a read like `hosts.status`, and the kill-switch plan
(`docs/plans/2026-10-20_kill-switch.md`:16) says "Reads and the human's own verbs, including the clear,
still work", so it joins the list. This touches row 191's list (one entry). An agent's `grants.revoke`
stays refused while halted, as `hosts.revoke` does.
## Named for DEFERRED (r2, Dewey N1-N4)
None is a small local change, so none is in r2. sage-2388: all four DEFERRED; N3 and N4 get a bus README
sentence each in r3 (a left admin token is deleted by hand at Gitea; a running executor keeps the admin
api it started with).
- N1: an admin rotate cut between the mint and the slot write leaves a token at Gitea that no event names.
A `grant.admin rotate-begin {oldName}` before the mint would close it; that is a new event shape.
- N2: `adminTokenLeft` is `unknown` when `/api/v1/token` gives no name. Which 1.27 F-fact shows the name
needs a check against the container, not a code change.
- N3: admin set or remove records `adminTokenLeft: null`, so a left token drops out of status. Carrying it
forward lets `rotate --finish` delete by name under a slot of another account or url; refusing remove
while one is left blocks the human's remove when Gitea is down. Either needs a ruling.
- N4: rotate or remove while a gitea claim is held. Refusing it blocks the human's remove during a run;
the running executor keeps the api it began with. Needs a ruling or a line in the README.
## Q188
No test creates a grant except through fixtures or the human's yes on a scratch store. No test reaches
git.mosaicstack.dev, tasks.woltje.com or any provider. Unit tests use the fake Gitea on 127.0.0.1; X-G3 uses a
throwaway gitea/gitea:1.27.1 container from the local image (never pulled), removed after. Every token is a
fake or a scratch-container token; values are compared with `assert.ok`, never printed.
## Runs
(filled in after Sage's CLEAR)
## Mutants
(section 9's list, each with the test that kills it; filled in after the runs)
A static pass over the 39 mutants (all apply, problems=0) found five with no test to kill them. Four tests
were added before any mutant run:
- M8, M9 (user or mint step written after its call) and M16 (grant.result written before its call): "X-G6
and X-G8: the user and mint steps are written before their calls, and each result after its call". It
reads the store from the fake while user-create and token-create are at Gitea, and checks every result's
status.
- M29 (fork parent not checked): "X-G4: a fork by the seat user of another repository than the project
refuses fork-foreign".
- M28 (memory and file revoked after the Gitea delete): "X-G11: a revoke unloads memory and removes the file
before its Gitea delete", which holds the DELETE open and reads the file and state.
- M37 (evidence without its kind filter): "X-G15: a look-alike spend under another kind with the grant writer
pair is not an open grant".
r2 tests, each red on 8bf442be9 by reading:
- B1: "X-G11: a revoke during a rotation step 7 ends it revoked, and step 8 writes no record back" and
"X-G11: a revoke while a sweep settles a rotation, forward or back, ends it revoked with no record".
On 8bf442be9 step 8 and the settle write the record and `grant.applied` (forward) or `grant.failed
interrupted settled:back` after the revoke deleted it.
- B2: "X-G14: a sweep retries a fire only for the user it fired: a remade or gone user is skipped and
named". On 8bf442be9 the retry fires the remade user (token list, delete, PATCH) and has no `skipped`.
- N5 (r3, sage-2388): "X-G11: a revoke during a settle whoami that finds a foreign token ends it revoked, not
held" and "X-G11: a revoke during a settle whoami that gets a 401 ends it revoked, not lost". On 77dd43c9e
they end `grant-token-unknown` (the agent back in tokenUnknown) and `grant-lost`.
- Choice 21 (sage-2453): "kill switch (plan :16): while halted the chief still reads grants.status, and an
agent grants.revoke refuses". M38 drops `'grants.status'` from the read list; the chief's status then
refuses `processing-halted` and the test fails.
-610
View File
@@ -1,610 +0,0 @@
# Row 281 build notes: a fire or a delete revokes the instance's grants (#1786)
Builder Megavolt, reviewer Dewey, gate owner Sage. Design draft d6 for Dewey's approval. No code until it is
approved. The results sections are filled in after the runs on Sage's gate.
History: d1 dfebd16cc. d2 c746ce378 added Sage's Q1/Q2 rulings (sage-2573). d3 36c2d98b8 added the
approved-before-fire tests (sage-2574). d4 answers Dewey's CHANGES on d3 (dewey-0823):
- B1: the set takes every grant decision with no outcome, and a raise refuses a fired or firing agent.
- B2: step G runs detached from the verb, so neither caller waits on the network.
- B3: one step G per agent, with named refusals for everything that could race it.
- N1-N3 are folded in.
d5 adds sage-2575: two requests per granted delete, with its five conditions. It also adds a `job` id to every
answer, event and message, and tests T3, T3b and T3c with mutants M16-M18.
d6 answers Dewey's CHANGES on d5 (dewey-0825):
- C1: the job's step C records authority without the chief's claim (`#authorityFor`, as a landing's record
does), processing-halted doesn't hold the job, and a step B or C failure records `fire.failed {job, step,
code}` with its own message. Tests T11 and T12, mutants M19 and M20.
- N4: boot closes a job a restart interrupted, with one message. Test T13, mutant M21.
- N5: section 7 names the refusals the manual verbs gain.
- N6: the set counts Q1 leftover lines, in fire step 3 and in the delete text.
## Sources
- Brief: `docs/plans/2026-10-22_fire-revokes-grants.md`, "Brief: no grant outlives its instance" (sage-2568,
mos-1271, Vikunja 524).
- Code read at 47a35bf43: `packages/bus/src/broker.mjs`, `runtime.mjs`, `credentials.mjs`, `host-grant.mjs`,
`gitea-grant.mjs`, `packages/business/src/instance-settings.mjs`, `tools/host/mosaic-keys-write`.
- Row 256's delete (honker/row256-r2 at 1e761962a, not landed) changes only the delete's checks. Its write still
goes through `lifecycleDelete` and the same `revoke()`, so this design covers it unchanged.
- Row 235 r6 section 6 (host revoke) and #436b r5 section 6 (Gitea revoke and the fire steps).
## 1. Why: what happens today (read from the code, to be pinned by a test before the change)
In short: today a delete of a granted instance unlinks the key files but leaves both grant records, the host
lines and the Gitea token live, and a fire refuses `credential-revoke-failed` (sage-2573 asked for this here
and on #1786). The details:
- **Fire.** `revoke()` (runtime.mjs:151) revokes only the roles entry's `credentials` services, then lists the
agent's store directory and refuses `credential-revoke-failed` if any file is left. A host grant's key is
`<store>/<business>/<agent>/host.<id>` (host-grant.mjs:102), and a Gitea grant's token is `.../gitea`
(gitea-grant.mjs:379). Neither is a roles-entry service, so a fire of an instance with a grant refuses at
step B with "host.<id> still in the store". It doesn't leak, but it can't finish until a human runs
`mosaic hosts revoke` or `mosaic grants revoke` by hand. If the business file also names `gitea` as a
roles-entry credential, `credentials.revoke(instance, 'gitea')` unlinks the granted token file with no
`gitea_grants` change and no Gitea call.
- **Delete.** `credentials.remove(instance)` (credentials.mjs:400) revokes every registered reference,
`host.<id>` and `gitea` included, and unlinks the directory. The `host_grants` and `gitea_grants` records
stay, the host line stays on each host, and the Gitea token, user and login stay live. This is the leak.
The next start then finds a grant record whose key file is gone. What the start does with that isn't read
yet. The T1 test below pins it.
- **An open grant decision.** A fire delists the instance but keeps its roles entry. An approved host or Gitea
grant decision for that agent that hasn't run yet still passes `#hostDefinition` and `#giteaRole`, so it can
still apply after the fire. A delete removes the roles entry, so its executor refuses `unknown-role` or
`gitea-not-declared`.
- **A raise after the fire** (dewey-0823 B1). `#raiseGrant` (broker.mjs:1529) checks only that the caller is
the chief, and the grant bindings check only the roles entry. So the chief can raise a new grant for a fired
agent, and if the human approves it, it applies.
## 2. Where the grant revokes sit (D1, rewritten in d4 for B2)
d3 awaited step G inside the verb. Dewey's B2 showed that neither caller can wait that long:
- **Delete.** The launcher's `host.op` waits `REQUEST_TIMEOUT_MS = 10000` (cli/src/host.mjs:31). On a timeout,
`breakChannel` stops the whole bus host. One SSH remove can take 30 s (host-grant.mjs:231, :311), and each
Gitea call 15 s (`CALL_MS`, gitea-grant.mjs:38).
- **Fire.** server.mjs answers a verb that isn't on its async list synchronously (:154), and the async list
gives 120 s (:150). The chief's runner client times out at 30 s (harness/src/runner.mjs:449). Two hosts
plus the Gitea calls can pass both limits.
So step G never runs inside a verb. The fire and the delete both use the same answer-at-once job, for the same
timeout reason (sage-2575 condition 5). Both verbs stay synchronous and answer at once. Step G runs as tracked
broker work after the answer, as row 236's grant executors do (`#track`, `setImmediate`). No caller waits on
the network, so `server.mjs`, `process.mjs`, `cli/src/host.mjs` and the launcher don't change.
Each job gets an id, `job` (a UUID), when it starts. The verb's answer, the job's start event, its end event
and every inbox message about it carry that id. The human and the chief can then match a later outcome to
the request that started it.
### The fire
1. `write('instance.fireCheck')`, as today.
2. Step A, as today, with one addition. In the same transaction, after `fire.stopped`, the grant set is read
(section 3). Reading it there means a grant raise either committed before step A, and is in the set, or
comes after `fire.stopped` and refuses (section 3d).
3. If the set is empty, steps B and C run synchronously, exactly as today. A fire with no grant answers
today's result with today's events (T7). The set counts everything step G would touch (d6, N6): grant
rows, open decisions, the agent's Gitea leftovers (section 5) and its Q1 leftover host lines (section 5).
Any of them makes the set non-empty and goes to the job.
4. Otherwise the verb takes the agent's step G lock (section 3e), records `fire.revoking {job, grants}` in
step A's transaction, starts the job, and answers at once:
`{...stopped, job, revoking: ['host:<id>' | 'gitea', ...], done: false}`. There's no `transaction`, since
step B hasn't run.
5. The job runs step G, then step B (`write('instance.fire')`) and step C (`#recordAuthority`, below, then
`fire.done {job, grants}`). It releases the lock when it ends, whichever way it ends, `fire.failed`
included.
What the chief sees meanwhile: the instance is "firing". That's an existing state (`#firing`, broker.mjs:645):
`fire.stopped` with no later `fire.done`. Starts already refuse `instance-firing`.
- A repeat fire while the job runs refuses `fire-in-progress`, a new code.
- A repeat after the job finishes takes the delisted path (webby-0094 N1) and answers done.
- A repeat after a refused step G runs step G again (section 5).
The human sees the outcome either way: `fire.done` names the job and the grants, and a refusal sends the
inbox message in section 4.
**Steps B and C in the job (d6, C1).** d4 left "step C without the chief's run" as a build-time check, and
Dewey checked it: it fails.
- `#consumeAuthority` calls `#checkAuthority` (broker.mjs:2852). That runs `#processingWork()` and
`#agent(s)`, and `#agent` refuses `not-holder` once the chief's run no longer holds its claim (:749).
- After the verb answers, the job can take a minute or more. If the chief's run ends meanwhile, step C would
throw after step B had delisted. The instance would be left delisted with no `fire.done`.
So the verb does the full check, and the job only records:
- The verb runs `#checkAuthority(s, action, authority)`, as today, before step A. The chief holds its claim
then.
- The job's step C runs `#authorityFor(s, action, authority)`, the check without the holder test. It then
runs the `#spent` check and the `action.allowed` event, as `#consumeAuthority` does. That's the shape a
landing's record already uses (`recordPublish`, broker.mjs:3174, row 137): the run that asked held its
claim when it asked, and may have ended since. It's one small private method, `#recordAuthority`, shared
with nothing else.
- The job keeps a copy of the session fields `#authorityFor` and `#event` read (business, role, thread,
run). It doesn't keep the live session object.
- `processing-halted` doesn't hold the job. A halt stops agent work. Step G only removes access, and steps
B and C finish a decision the chief made while processing ran. Holding them would leave a delisted or
half-revoked instance "firing" for the length of the halt. The verb itself still refuses during a halt,
through `#checkAuthority`, as today.
**A step B or C failure in the job** gets its own record, not `grant.revoke.refused`, since every grant in the
set was revoked by then:
- It records `fire.failed {job, step: 'B' | 'C', code}`. `code` is the thrown code, for example the writer's
`credential-revoke-failed`, or `decision-consumed`.
- It sends an inbox message:
> Fire of <agent>: job <job> revoked its grants (<list>) but stopped at step <B|C> (<code>). The instance
> is still firing. Run the fire again once <code> is cleared.
- The instance stays firing. A repeat fire finds an empty set and takes today's path for that state, with the
chief's own claim: B and C after a step B failure (T12), the delisted path (webby-0094 N1) after a step C
failure.
`grant.revoke.refused` with reason `internal` stays only for an unexpected throw inside step G.
### The delete (sage-2575)
`lifecycleDelete` (broker.mjs:3293) stays synchronous. After its running and starting checks, it reads the
grant set.
- **No grant.** An empty set: `instances.set delete`, one request, exactly as today (condition 4, T3c).
- **The first request.** For a non-empty set, it takes the lock and records `grants.revoking {job, agent,
verb: 'lifecycle.delete', grants}`. It starts the job and refuses `grants-revoking` at once. The launcher
closes the request with that code, well inside its 10 s.
The launcher can carry only the code, so the broker also sends the human an inbox message (condition 1):
> Delete of <agent> is waiting on its grants. Revoking started (job <job>) for: host:<id>, ..., gitea.
> Raise the delete again when the inbox says the revoke is done.
- **A delete raised while the job runs.** It refuses `grants-revoking` again and starts no second job
(condition 2, the 3e lock). No second message goes out either: the first one still applies.
- **The job ends.** The human gets one inbox message (condition 3).
- If it completed, the message names each grant revoked, with any `grantsLeft` (section 5), then "Raise the
delete again." It records `grants.revoked.done {job, agent, verb, grants, grantsLeft}`.
- If it refused, the message names the grant, the reason and how to clear it by hand, as section 4
describes. It records `grant.revoke.refused {job, ...}`.
- **The delete raised after a completed job** finds an empty set and goes as today.
- **The delete raised after a refused job** doesn't complete over a live grant. Its set is non-empty, since
the refused grant's record is still there. It refuses `grant-revoke-failed`, naming the earlier job's
reason in a message:
> Delete of <agent> refused: job <job> couldn't revoke <grant> (<reason>). Retrying now as job <new>. Raise
> the delete again when the inbox says it's done, or clear <grant> by hand first.
It starts a new job for what's left. The broker knows the last job refused because the agent's latest job
end event is a `grant.revoke.refused` with verb `lifecycle.delete`, and no `grants.revoked.done` came after
it. A delete after the human cleared the grant by hand doesn't find an empty set if a manual revoke left a
line (Q1). The leftover line makes the set non-empty, so that delete starts a job for the line removal
and refuses `grants-revoking` (d6, N6, and T3b's second case). If nothing is left, the set is empty and the
delete goes on.
The tradeoff (ruled by sage-2575): deleting a granted instance now always takes two requests. That keeps the
cli and the host channel out of row 281, and a delete of an instance with a grant is rare. Holding the
launcher's request open past its 10 s would need a host channel change and a strand path, and polling would
widen the row into the launcher for a one-time wait.
While a delete's step G runs, the instance is still listed and not firing, so it could be started. That's
harmless: its grants are being revoked, and the repeat delete refuses `instance-running` as today. No new
start check.
### Why not in `beforeWrite` or `revoke()`
Both are synchronous and run inside the organization writer, after its checks. A host `remove` is SSH and a
Gitea revoke is HTTP, so they can't run there. The business package also has no reach into the broker's grant
tables, and shouldn't get any.
### Why after step A
A fire is the decision to stop the agent. If step G came first, a host that can't be reached would keep the
fired agent running. After step A, a refused step G leaves the same state as a failed step B today: the
instance is "firing" (stopped, still listed) until a repeat. Row 194 already handles that state. The repeat
fire's delisted path (webby-0094 N1) also runs step G, since the set is read before step B on both paths.
## 3. Step G: what it revokes, and how
The set, for (business, agent), is read in one store transaction: step A's for a fire, the delete's checks
for a delete (section 2). It holds:
- each `host_grants` row of the agent;
- each host named by an open host grant decision for the agent;
- the Gitea grant if the agent has a `gitea_grants` row, a `gitea_users` row, or an open Gitea grant decision.
An open decision (d4, B1) is any grant decision raised for the agent with no outcome yet. That is: not denied,
not expired, no `grant.applied` or `grant.failed`, and no `grant.revoked` of the same (agent, host), or of the
agent's Gitea, recorded after its raise. A raised decision the human hasn't decided is open. d3 required
"approved", which left that one out.
An empty set means no step G and no new records. A fire of an instance with no grant is then exactly today's
fire, with no new events (brief tests, fourth bullet).
### 3a. A host grant: the host line first (D2)
hosts.revoke goes local first: the record drops, the key leaves memory and its file, and only then is
`remove <user> <tag>` sent. If the host can't be reached, the revoke closes with `hostLineLeft` and the
record is already gone. The brief says a fire may not drop a grant record while its key could still be on the
host. So the fire's host revoke reverses one step and keeps the rest the same:
1. Abort an executor in flight for (agent, host), with no record change (`hostKeys.abortClaim`, as the
`credential-unknown` branch of `#revokeHost` already does).
2. `remove <user> <tag>` through the installer, pinned to the host record's keys. `mosaic-keys-write` says
"a missing tag changes nothing", so this is safe to repeat and safe for a host named only by an open
decision. If the host record is gone, or the remove doesn't exit 0, step G refuses for this host
(section 4). Nothing local has changed for this grant: the record, the key file and the memory copy stay.
3. hosts.revoke's step 1 transaction, unchanged: the `host_grants` row goes and `grant.revoked {revoke, agent,
host, tag, hostLine: 'pending', granted, reason, by}` is recorded, plus `fire: true` (or `delete: true`)
so the human can tell it from a manual revoke.
4. hosts.revoke's step 2, unchanged: `hostKeys.revoke`, so the key leaves memory and its file goes.
5. `grant.revoke.closed {hostLine: 'removed'}` with no second remove. Step 2 already did it.
This is one shared method, `#revokeHostGrant(business, agent, host, reason, by, { lineFirst })`.
hosts.revoke calls it with `lineFirst: false` and its behavior and records don't change (brief item 4). The
fire and the delete call it with `lineFirst: true`.
The tradeoff: hosts.revoke goes local first so that "the part that matters can't wait on the network" (r6
section 6). For a fire, step A has already stopped the agent's runs, and a refused fire keeps the instance
"firing". So waiting on the host costs nothing in exposure, and the record never lies about the host.
### 3b. The Gitea grant (D3)
The Gitea revoke stays grants.revoke's steps with `fire: true`, which is what `credentials delete` already
runs (#436b section 6). Step 1 takes the token out of memory and unlinks the file. Step 2 is
`#giteaRevokeLocal(..., fire: true)`. Step 3 is `#giteaCloseRevoke`: every token deleted and
`prohibit_login: true`. The fire awaits step 3 instead of leaving it to `setImmediate`.
The Gitea side can't go remote first the way a host does. The record has to drop and `grant.revoked` has to
name the tokens before the DELETE calls, or a restart in between would find a live record whose tokens are
gone (section 5's reconcile). Two changes keep the fire closed:
- **Preflight.** Before step 1, `#giteaAdminChecked` runs. If the admin token can't be read or checked,
step G refuses with nothing changed.
- **Left means not done.** If `grant.revoke.closed` lists anything in `serviceLeft`, step G refuses. The
token ids stay in the evidence, which is exactly what `mosaic grants sweep gitea` retries, so nothing
forgets them. The record is gone, but no key of this stack's is still usable: the file and memory copy are
gone, and only the Gitea side is left. That is the case the brief's host rule guards against, and it is
kept visible here, not hidden.
**A Gitea executor in flight** (d3 T6c, and B3 seen from the executor's side). Step G's Gitea part goes in
this order:
1. Record `grant.revoked {granted: false}` for each open Gitea decision (3c).
2. Await this agent's tracked Gitea executors and remote undos, if any are running. Each one ends `revoked` at
its next `#giteaRevokedSince` check, or it committed `applied` before step 1.
3. Read the `gitea_grants` row again and run the steps above.
`#giteaCloseRevoke` with `fire: true` lists the user's tokens and deletes every one. The list comes after
step 2, so it includes any token an in-flight executor created. `#giteaRuns` is a single Set today. It
becomes keyed by (business, agent) so step G can await one agent's executors alone.
### 3c. Open decisions
A host named only by an open decision has no record. Step 3a still runs `remove` (a no-op) and records
`grant.revoked {granted: false}`, as hosts.revoke does for a revoke with no record (N4). The executor's
`#revokedSince` then ends that decision `revoked`. The Gitea side is the same through
`#giteaRevokedSince`.
This is the case where a decision is approved before the fire and executed after it (sage-2574). Both checks
look for a `grant.revoked` of the agent recorded after the decision was raised (broker.mjs:1156 and :1678 at
47a35bf43). A fire's `grant.revoked` is always later than the approval, so the executor ends the decision
whenever it runs. The fired agent keeps its roles entry, so `#hostDefinition` and `#giteaRole` still pass, and
this check is the only thing that stops the decision. T6 and T6b test it. A delete also removes the roles
entry, so the executor refuses there as well.
The same holds for a decision raised but not yet approved (B1). It's in the set, so it gets its
`grant.revoked {granted: false}`. If the human approves it later, its executor ends `revoked` (T6d). The
decision stays approvable in the inbox. Approving it does nothing but end it, which `grant.failed` reports as
it does for any revoked grant.
### 3d. A raise for a fired agent (d4, B1)
`#hostGrantBinding` and `#giteaGrantBinding` both run inside the raise transaction (broker.mjs:1539 and
:1548; the host raise's keyscan is awaited before that transaction). Both gain one check. The raise refuses
`instance-fired` if the agent is:
- firing (`#firing`);
- fired, by the writer's own rule (instance-settings.mjs, hire): a roles entry, not in `launch.instances`,
and not `launch.by`.
A rehire lists the name again, so a rehired agent can be granted again.
Because the check is in the transaction that records the decision, there's no gap. A raise that commits
before step A's transaction is in the set. One that commits after it refuses. A raise for an agent whose
delete's step G is running refuses `grant-revoke-running` (3e).
No executor recheck is added. A raise can't commit after `fire.stopped`, and every decision raised before it
is in the set and gets its `grant.revoked`.
### 3e. One step G per agent (d4, B3)
The broker keeps `#revoking`, a map from (business, agent) to the verb whose step G is running. The verb sets
it synchronously, in the same turn as the set read, before it answers. The job clears it in a `finally`.
While it's set:
| Caller | Answer |
|---|---|
| `instance.fire` | refuses `fire-in-progress` |
| `lifecycleDelete` | refuses `grants-revoking`, no second job |
| `hosts.revoke` / `grants.revoke` for the agent | refuse `grant-revoke-running` |
| grant raise for the agent | refuses `grant-revoke-running` |
| `grants.sweep` | skips this agent's entries and names it as `running` in its result |
Two fires started together (the chief retrying after a client timeout): the first takes the lock before it
answers, and the second refuses. The map is in memory. After a broker restart the job is gone and so is the
lock. The instance is still firing, or the delete still has a non-empty set, and the repeat starts a fresh
step G (section 5).
A manual revoke can't race step G, since it refuses while the lock is held. Step G's transactions still
check what they read: a `host_grants` row already gone at 3a step 3 records `grant.revoked {granted: false}`
and goes on, as hosts.revoke does today for a revoke with no record.
## 4. A refusal (D4)
Step G stops at the first grant that can't be revoked. Grants already revoked in this step G stay revoked and
recorded, and the rest stay as they were. The job doesn't run step B: the organization file is unchanged, and
the credentials are unrevoked, since `revoke()` never ran. A fire stays "firing". A delete's request was
already closed with `grants-revoking` (section 2).
The verb has already answered, so the refusal is a record, not a thrown code:
- One grant-evidence event, `grant.revoke.refused {job, agent, grant: 'host:<id>' | 'gitea', reason, verb:
'instance.fire' | 'lifecycle.delete', by}`.
- `reason` is one of `host-unknown`, `host-remove-failed`, `gitea-admin-unavailable` and `gitea-left:<ids>`.
- `grant-revoke-failed` is the code in the event and in the inbox message.
A message lands in the human's inbox, as `grant.failed` does today (broker.mjs:1139). It names the instance,
the grant and the reason, with the manual way out beside the retry (N2):
> Fire of <agent> stopped: its grant on <host> couldn't be revoked (<reason>). Run the fire again once <host>
> is reachable. If <host> won't come back, run `mosaic hosts revoke <agent> <host>` first: the grant record
> goes, the line is named as left, and the next fire completes and names that line (section 5).
The delete's message has the same shape, with "raise the delete again". For Gitea, the way out is
`mosaic grants sweep gitea` once Gitea is back.
A job that throws something unexpected inside step G records `grant.revoke.refused` with reason `internal`,
sends the same message, and releases the lock. There's no unhandled rejection. A failure at step B or C is
`fire.failed` (section 2).
**A restart mid-job (d6, N4).** The lock and the job are in memory, so a restart ends them with no end
record. A fire would stay "firing" until the chief happened to repeat it. A delete's request is already
closed, so no one would hear anything. Boot closes that gap:
- A job is a start record (`fire.revoking` or `grants.revoking`) whose `job` has no end record. The end
records are `fire.done`, `fire.failed`, `grants.revoked.done`, `grant.revoke.refused` and the new
`grants.job.interrupted`.
- When the broker starts, for each such job of each business, it records `grants.job.interrupted {job,
agent, verb}` and sends the human one inbox message:
> <Fire|Delete> of <agent>: job <job> was interrupted by a bus restart. Some grants may be revoked. Run the
> fire again, or raise the delete again. The repeat goes on from what's left.
- Recording the end record in the same transaction as the message means a second boot finds nothing to
send.
- Boot reads only events, with no network call, so it adds nothing to the start time beyond one query.
## 5. A repeat fire (D5)
A repeat runs the whole fire again: the checks, step A (nothing left to stop), the set read, then step G, B
and C in the job. A repeat while the job runs refuses `fire-in-progress` (3e). A repeat delete works the same
way through the human's new request.
- A host grant already revoked has no row and no open decision, so it isn't in the set. It's done.
- A host grant that refused still has its row, so it's retried from 3a step 1.
- Gitea (d4, N1): if the agent has entries in `#giteaLeft` or `#giteaOpenRevokes`, step G retries only those.
It calls `#giteaCloseRevoke` for each, as `#sweepGitea` does per entry. It doesn't run the business-wide
sweep, which would retry every agent's entries and settle every open rotation under the chief's name. Any
entry still left for this agent refuses `gitea-left`. An entry left by an earlier manual
`mosaic grants revoke` of the same agent blocks the fire the same way. That's intended: no grant outlives
its instance, whichever verb started its revoke. These entries count toward a non-empty set (section 2,
fire step 3), so they go to the job and never run inside the verb.
- A host line left by an earlier manual `hosts.revoke` doesn't block the fire (Q1 below). These are the
agent's `grant.revoke.closed` records with `hostLineLeft` and no later line removal for that tag. For each
one whose host record still exists, step G sends `remove <user> <tag>` once. On success it records
`grant.revoke.closed {revoke: <the old revoke id>, hostLine: 'removed', fire: true}`, so a later fire doesn't
find it again. On failure it records nothing new and the fire goes on. The host and the tag go in
`grantsLeft` (section 6). These leftover lines also make the set non-empty, so the retry runs in the job,
inside B2's answer (dewey-0823 accepted the single retry on that condition).
## Rulings
- **Q1 (sage-2573):** a leftover line doesn't block the fire. If the host is reachable, the fire tries the
removal once, because a fired agent's key line still on a host is the exact risk this row closes. If that
try fails, the fire doesn't refuse. It names the host and the leftover line in the result and in the event
the human sees. The human accepted that residue when their manual `hosts.revoke` left it, so it shouldn't
stop a fire forever on a dead host. Dewey may push back on the retry if it widens step G more than it's
worth.
- **Q2 (sage-2573):** a delete refused at step G closes its request through the launcher's normal refusal
path, and the human raises it again. A request left taken would strand, which is the shape DEFERRED already
records for the >64-stops done. Conditions: the refusal names the instance, the grant and the reason (the
`grant.revoke.refused` event, section 4). The repeat delete skips grants already revoked, as the repeat fire
does. T3b tests both.
- **Q2 extended (sage-2575):** every delete of a granted instance takes two requests. The first starts the
revoke job and refuses `grants-revoking`, and the human raises the delete again when the inbox says it's
done. This keeps the cli and the host channel out of the row, and such a delete is rare. Conditions, each
with a test:
1. The first refusal tells the human what happened and what's next: the named grants, and to raise the
delete again when the inbox says done. A bare code isn't enough (T3).
2. A delete raised while the job runs refuses `grants-revoking` again and starts no second job (T3, T9).
3. The job's end message names each grant revoked. If one failed, it names the grant, the reason and the
manual way out (`mosaic hosts revoke` or `mosaic grants revoke`). The next delete then refuses with that
reason and doesn't complete over a live grant (T3b).
4. A delete of an instance with no grant completes in one request, exactly as today (T3c).
5. Section 2 says the fire uses the same answer-at-once job, and the fire's result and event name the job
(T2).
## 6. The result and the event (D6)
- **A fire with a non-empty set** answers at once with `job`, `revoking` and `done: false`, and records
`fire.revoking {job, grants}` (section 2).
- **The job's outcome** is recorded, not returned. `fire.done` (step C) carries `job` and `grants: [{grant:
'host:<id>' | 'gitea', revoke, granted}]`, plus `grantsLeft` for any leftover from section 5's last bullet.
So the event the human sees names them.
- **A delete's job** records `grants.revoking {job, ...}` at the start and `grants.revoked.done {job, agent,
verb: 'lifecycle.delete', grants, grantsLeft}` at the end. It sends the inbox messages in section 2.
- **A fire with an empty set** carries none of these keys, so its shape is exactly today's.
## 7. What doesn't change (brief item 4)
- `hosts.revoke`, `grants.revoke` and `grants.sweep`: same callers and the same records. Their refusals stay
the same (`tester-dev-only` included) with one addition from section 3e (d6, N5): while a step G job holds
the agent's lock, `hosts.revoke` and `grants.revoke` for that agent refuse `grant-revoke-running`, and
`grants.sweep` skips the agent and names it as `running`. hosts.revoke's own order stays local first.
- No new verb, no Console route, no CLI change, no schema change. Every new record is an `events` row in the
existing grant-evidence shape.
- `revoke()` in runtime.mjs and the organization writer don't change.
- `server.mjs`, `process.mjs`, `cli/src/host.mjs` and `cli/src/launcher.mjs` don't change. Both verbs stay
synchronous. The launcher already closes a request with whatever code `lifecycleDelete` refuses.
- New codes: `fire-in-progress`, `grants-revoking`, `grant-revoke-running`, `instance-fired`, and
`grant-revoke-failed` in events and messages. New event types: `fire.revoking`, `grants.revoking`,
`grants.revoked.done`, `grant.revoke.refused`, `fire.failed` and `grants.job.interrupted`.
- The broker's start gains one events query for interrupted jobs (section 4, N4). `lifecycleDone` checks a refusal against `LIFECYCLE_CODE =
/^[a-z-]{1,64}$/` (broker.mjs:149), and `grants-revoking` matches it.
## 8. Tests (each in the owning package, whole files)
The bus tests use the fake installer and the fake Gitea from rows 235 and 236. They wait on the job with a new
trusted test API, `grantsIdle()`, which resolves once every step G job and Gitea executor has ended. It's the
same shape as `giteaIdle()`. Unless a test says otherwise, every fire or delete below is awaited that way.
- T1 (pins section 1 before the change): a fire of an instance with a host grant refuses
`credential-revoke-failed` today. A delete leaves the `host_grants` row. Then: what the next start does with
a record whose key file is gone.
- T2: a fire with a host grant on two hosts and a Gitea grant. The verb answers with a `job` id, `revoking`
with all three, and `done: false`, and `fire.revoking` carries the same job (sage-2575, condition 5). After
the job:
- all three are revoked, each with `grant.revoked` and `grant.revoke.closed` as the manual verbs record
them, plus `fire: true`;
- the line is gone on both fake hosts, every token is deleted, and login is prohibited at the fake Gitea;
- `fire.done` names all three;
- that agent's `host.run` and Gitea use both refuse.
- T3 (sage-2575, conditions 1-3): the same grants through the delete (`lifecycleTake` then
`lifecycleDelete`).
- The first delete refuses `grants-revoking` and starts the job. The human's inbox gets the start message
naming the job and all three grants, and saying to raise the delete again when it's done.
- A delete raised while the job is held refuses `grants-revoking`, starts no second job and sends no second
message.
- After the job, the end message names the job and each grant revoked.
- A third request completes as today.
- T3b (Q2, sage-2575 condition 3): a delete whose job refuses (host B unreachable).
- The end message and `grant.revoke.refused` name the instance, `host:B`, the reason and
`mosaic hosts revoke`.
- The file and the instance are unchanged.
- The next delete refuses `grant-revoke-failed`, not `grants-revoking`. Its message names the earlier job's
reason and the new job, and the instance is not deleted.
- With B back, that retry job revokes only B and Gitea, skipping host A, and the delete after it completes.
- A second case clears B by hand with `hosts.revoke` before the next delete. That delete starts a job only
for Gitea and the leftover line.
- T3c (sage-2575 condition 4): a delete of an instance with no grant completes in one request. Same result,
same events counted, no inbox message.
- T4: host B unreachable in a fire. The job records `grant.revoke.refused` naming the agent, `host:B` and
`host-remove-failed`, and the inbox message names `mosaic hosts revoke` (N2). Afterwards:
- the file is unchanged and the instance is still firing;
- host A is revoked and recorded, and host B's row and key file are still there;
- the Gitea grant is untouched, since it comes after B in the order;
- the credentials are unrevoked.
A repeat with B reachable completes and revokes only B and Gitea.
- T4b (Q1): an earlier manual `hosts.revoke` left a line on host C. A fire with C reachable removes it and
records the close. With C unreachable, the fire completes and names C and the tag in `grantsLeft` and in
`fire.done`.
- T5: Gitea's admin token can't be checked. The job refuses with the Gitea grant intact.
- T5b: a token DELETE fails. The job refuses `gitea-left`. A repeat with Gitea back retries only this agent's
entries and completes. Another agent's left entry is still left afterwards (N1).
- T6 (sage-2574): a host grant decision approved before the fire, executed after it. The fire records
`grant.revoked {granted: false}` for (agent, host). The executor runs after `fire.done` and ends the decision
`revoked`: no `host_grants` row, no key file, no `add` sent to the host.
- T6b (sage-2574): the same for a Gitea grant decision. The executor ends it `revoked` before any Gitea call:
no `gitea_grants` row, no token file, and the fake Gitea saw no token create.
- T6c: a host or Gitea executor already in flight when step G starts.
- The host side aborts its claim (3a step 1) and ends `revoked`.
- The Gitea side: step G awaits the executor (3b), and then the fake Gitea holds no token for the user. That
includes one the executor created mid-flight.
- T6d (B1): a host decision and a Gitea decision, raised before the fire and still undecided. The fire puts
both in the set. The human approves both after `fire.done`, and both executors end `revoked`, with nothing
applied.
- T6e (B1): a host raise and a Gitea raise refuse `instance-fired`:
- while the instance is firing;
- after `fire.done`.
After a rehire of the same name, a raise goes through. While a delete's job runs, a raise refuses
`grant-revoke-running`.
- T7: an instance with no grant fires exactly as today, synchronously. Same result keys, no `revoking`, and
the same events, counted.
- T8: hosts.revoke and grants.revoke keep their records, their order and their tester refusals.
- T9 (B3): with the job held on a remove:
- a second fire refuses `fire-in-progress`;
- a delete of the same agent refuses `grants-revoking` and starts no second job;
- `hosts.revoke` and `grants.revoke` for the agent refuse `grant-revoke-running`;
- `grants.sweep` names the agent as `running`.
After a release, one set of `grant.revoked` records exists per grant.
- T10 (B2): the fake installer holds a remove and the fake Gitea holds a DELETE, neither answering until
released.
- `instance.fire` and `lifecycleDelete` both answer before either is released. The test checks this with a
timer, not a wall-clock bound.
- Meanwhile the broker answers another verb from another session.
- After release, the jobs complete.
- A cli test drives the launcher's delete pass against a bus whose remove is held. The pass closes the
request `grants-revoking` and the host channel stays up.
- T11 (d6, C1): a fire whose job is held on a remove. While it's held, the chief's run ends and its claim is
released, and processing is halted. After the release, the job completes: `fire.done` is recorded with the
job, the consumed `action.allowed` event is there, a second use of the same authority refuses
`decision-consumed`, and the instance is out of the file. No `fire.failed` and no `grant.revoke.refused`.
- T12 (d6, C1): a fire with a grant whose step B refuses in the job. The test uses T1's setup, where
`revoke()` refuses `credential-revoke-failed`. After the job:
- `fire.failed` carries the job, step `B` and `credential-revoke-failed`, and the inbox message names the
job, the revoked grants, the step and the code;
- there's no `grant.revoke.refused` and no `fire.done`;
- the instance is still firing and still in the file.
With the cause cleared, a repeat fire finds an empty set, runs B and C synchronously and completes.
- T13 (d6, N4): a fire and a delete, each with its job held, then the broker is closed and a new one opened
on the same data root. The new broker records `grants.job.interrupted` for both jobs and sends two inbox
messages, one saying to fire again and one to raise the delete again. A third broker on the same root sends
nothing more. A job that ended before the restart gets no interrupted record.
Files (N3). Every file listed runs whole in the manifest round, touched or not:
- New: `packages/bus/tests/fire-revokes-grants.test.mjs` holds T2-T13.
- Changed:
- `grant-host.test.mjs` and `gitea-grant.test.mjs`: T1's pins, and the T8 regressions next to their
existing cases;
- `packages/cli/tests/lifecycle.test.mjs`: the T10 launcher pass;
- `fake-gitea-api.mjs` and `host-fixtures.mjs`, if T10's holds need a hook there.
- Run whole because they call `instance.fire` or `lifecycleDelete` at 47a35bf43:
- bus: `chief-of-staff`, `lifecycle-revoke`, `lifecycle-verbs`, `reload`;
- cli: `lifecycle`, `instances`;
- harness: `tools`.
- Also run: `grant-executor.test.mjs` (the executors' `#giteaRuns` change) and `gitea-grant-docker.test.mjs`
(brief gate).
- `server.mjs`, `process.mjs`, `host.mjs` and `launcher.mjs` are unchanged, so no test changes there for this
row.
## 9. Mutants
- M1: step G skips the host revokes. Killed by T2.
- M2: step G skips the Gitea revoke. Killed by T2.
- M3: a refused grant revoke is ignored and the job goes on to step B. Killed by T4.
- M4: `lifecycleDelete` skips step G. Killed by T3.
- M5: the fire's host revoke goes local first (`lineFirst: false`). Killed by T4, because B's row is gone.
- M6: a Gitea revoke with something in `serviceLeft` counts as done. Killed by T5b.
- M7: open decisions left out of the set. Killed by T6 and T6b.
- M8: step G skips the leftover-line retry. Killed by T4b.
- M9: a failed leftover-line retry refuses the fire. Killed by T4b.
- M10: the set takes only approved decisions (d3's rule). Killed by T6d.
- M11: the raise bindings skip the fired check. Killed by T6e.
- M12: no `#revoking` lock. Killed by T9.
- M13: the verb awaits the job before it answers. Killed by T10.
- M14: step G's Gitea part doesn't await the in-flight executor. Killed by T6c.
- M15: step G's Gitea retry runs the business-wide `#sweepGitea`. Killed by T5b.
- M16: the delete's first refusal sends no start message. Killed by T3.
- M17: a delete after a refused job refuses `grants-revoking` instead of `grant-revoke-failed`. Killed by T3b.
- M18: a delete of an instance with no grant goes through the job. Killed by T3c.
- M19: the job's step C uses `#consumeAuthority`, with the holder and processing checks. Killed by T11.
- M20: a step B or C failure in the job is recorded as `grant.revoke.refused internal`. Killed by T12.
- M21: boot skips the interrupted-job check. Killed by T13.
## 10. Order and rebase
Built from 47a35bf43 for this read. The code goes on whatever refactor is when the build starts. Rows 274, 256
and 194's follow-ups touch the broker and instance-settings, so expect a rebase and a whole rerun. The row lands
before the .53 Case 1 install hires an instance with a grant. It doesn't hold the dragon-lin re-pin.
## Runs
(after the gate)
## Mutants
(after the gate)
-4
View File
@@ -1505,7 +1505,3 @@ are never rewritten or removed; corrections are new entries.
2026-10-22 07:56:59 UTC | Rocko (Codex gpt-6.1-sol, d99326cc) | row 280 slim packet under sage-2541 | b0fd28b6f on rocko/row280-packet-b765, parent b765f5cb8; 22 text files, 21-entry slim inventory914f888d, full evidence d6c924764 retained, portability/follow-ups recorded; no tests or source changes.
2026-10-22 08:00:02 UTC | Sage (1ef1e4f8) | row 280 review, gate and landing | design approve sage-2533; CLEARs for phase 1, diagnostic and repeat; published d6c924764 as jarvis; review request rev 3151/3152; gate bus 656/0/0; landed b0fd28b6f (ff), note 3154, done 3155; #1785 closed; P19 511 comment 623; Honker told for 194 b17.
2026-10-22 08:38:11 UTC | Sage (1ef1e4f8) | row 194 b17/b18 window and landing | b17 CLEAR (sage-2544), then my refactor push forced b18 (sage-2545, Webby sage-2546); review request rev 3158/3159; landed fd6dd327c (ff), note 3161, done 3162; #1692 closed; P19 414 comment 625
2026-10-22 08:42:04 UTC | Honker (Claude Opus 5.5, ecd17786) | row 194 #1692 build to landing | b8-b18 on honker/row194-b*; G13 diagnosis 60 runs (slot returned 07:11Z); b17 then b18 rebases (no force-push); runb8 + gate whole green on fd6dd327c, slot returned 08:31Z swept clean; in-review move revs 3156/3157 (request failed, no token); landed by Sage fd6dd327c; no live action.
2026-10-22T08:45:16Z | Dewey (Opus 5.5, 84fb346c) | row 236 (#1742) rebase onto be2b008cc, ce74477e1 (sage-2553/2555, megavolt-0128) | static look clean: c1 unions match 194 code, 2-7 =, manifest d7dd27b8 ok; fire-keeps-grant note to Sage; verdict waits on the green gate
2026-10-22T09:37:08Z | Dewey (Opus 5.5, 84fb346c) | row 236 (#1742) manifest round 1 on 0b00269b (bd5f11ded) | APPROVE, comment 30450, queue rev 3168; no blockers, no notes
2026-10-22T09:43:34Z | Sage (1ef1e4f8) | row 236 landing, row 281 filing | landed bd5f11ded (ff), note 3169, done 3170, #1742 closed c30451, P19 436 comment 639; row 281 #1786 brief and DEFERRED entry for mos-1271
@@ -1,65 +0,0 @@
# A fire or a delete revokes the instance's grants (194/236 follow-up, MVP)
Sage, 2026-10-22, for mos-1271. Dewey found it on the row 236 rebase look (dewey-0815). Row 281, Gitea #1786, Vikunja 524.
## Why
Row 194's `instance.fire` revokes only the roles entry's credential services. It does this through
`revoke()` in packages/bus/src/runtime.mjs (:151), which instance-settings.mjs `fire()` calls as its
beforeWrite (:337, :343). Row 256's human delete goes through the same `revoke()`. Neither touches a
grant. A fired agent's host grants (row 235) stay on every host they name, and its Gitea user and token
(row 236) stay live. They last until someone runs `mosaic hosts revoke AGENT HOST` or `mosaic grants
revoke AGENT` by hand. Those CLI verbs are the human's. The tester's `hosts.revoke` and `grants.revoke`
refuse `tester-dev-only` outside a dev business (broker.mjs :1573 and #grantsCaller). The Console has
no revoke route. Nothing tells the human that a fired instance still holds a grant.
Live is 86f95bb27, which carries 235's host grants but not 236. dragon-lin runs as tester with no real
host grants, so nothing live holds a grant today. The Case 1 install on .53 will hold them.
## Brief: no grant outlives its instance
`--after` 236. It lands before the Case 1 install on .53 hires any instance with a grant (mos-1271
item 4). It doesn't hold the dragon-lin re-pin. It comes after 274 in the landing order unless Mos
moves it.
Design before code. The design goes in the packet BUILD.md. The reviewer approves it before any code.
What to build:
1. A fire, and a delete, end every grant the instance holds before the write that removes the
instance. That covers each host grant on each host, through the same steps `hosts.revoke` takes
(the grant.revoked record, the key removal and the host line, row 235 r6 section 6). It also covers
the Gitea grant, through the same steps `grants.revoke` takes (row 236 #436b).
2. Fail closed, as `revoke()` does for credentials. If any grant revoke can't complete, the verb stops,
the organization file stays unchanged, and the refusal names the instance, the grant and the reason.
A repeated fire then picks up where the first stopped, as 194's repeated fire already does for step
C (webby-0011 B2). A grant that's already revoked counts as done. The design says how a host that
can't be reached is handled. It may not drop a grant record while its key could still be on the
host.
3. The fire's result, and the event the human sees, list the grants revoked as well as the credential
services.
4. Nothing else changes. The hosts and grants verbs keep their callers and refusals. The tester's
`tester-dev-only` stays. No new Console route.
Tests, each in the owning package, whole files:
- A fire of an instance with a host grant on two hosts and a Gitea grant revokes all three. It records
each revoke the way the manual verbs do, and a later host.run or Gitea use by that agent refuses.
- The same for the human's delete (row 256's path).
- A grant revoke that fails: the fire refuses, the file is unchanged, and the instance and its other
grants are as they were or recorded as revoked, per the design. A repeat fire then completes.
- An instance with no grant fires exactly as it does today, with no new events.
Mutants, each killed by a named test:
- M1. The fire skips the host grant revokes.
- M2. The fire skips the Gitea grant revoke.
- M3. A failed grant revoke is ignored, so the fire goes on to the write.
- M4. The delete path skips grant revokes.
Gate: the reviewer approves the design and then the manifest. The bus, business and cli suites run
whole, with every touched test file whole on its own. The Docker gitea-grant test runs. Every
scripts/test-*.sh is green on Sage's gate, and M1-M4 are killed. Any red goes to Sage, and a red Sage
can't place goes to Mos.
Owner Megavolt. Reviewer Dewey. Gate owner Sage.
+6 -11
View File
@@ -396,6 +396,12 @@ at every gate. Started 2026-09-12 during the control board MVP.
(instances.set launch: false) but neither fire nor delete removes a
creative entry (sage-2166). The row covers fire and delete together;
Sage files it. (2026-10-21)
Closed for delete by row 256 (#1761, REQ-COS-3): `deleteRefusal` takes
the actor, so the human deletes a creative instance and any other actor
is refused `organization-creative-human-only`, and the listing's
endProtected is the same check for its reader. Fire still refuses every
END_PROTECTED instance, by the row 256 brief's ruling, not by a code
change. (2026-10-22)
- **A fire stuck between step A and step B has no human escape.** (Webby
N4 on row 194 b5, webby-0094; Sage's ruling sage-2241, #1692.) The
broker's chief fire records `fire.stopped` at step A, and `#firing`
@@ -425,17 +431,6 @@ at every gate. Started 2026-09-12 during the control board MVP.
comes near 64 runs. The follow-up row either raises the cap or splits
the done. Its test: a fire's done over the cap refuses, and the runs it
left alive still get the alive flag. (2026-10-22)
- **A fired or deleted instance keeps its grants.** (Dewey on the row 236
rebase look, dewey-0815; Mos mos-1271; Sage sage-2558.) Row 194's
`instance.fire` and row 256's delete revoke only the roles entry's
credential services through `revoke()` in `runtime.mjs`. The agent's
host grants (row 235) and its Gitea user and token (row 236) stay live
until a human runs `mosaic hosts revoke` or `mosaic grants revoke`. The
Console has no revoke route and doesn't say a grant is left. Row 281
(#1786) closes it: a fire or a delete revokes every host and Gitea grant
the instance holds, fail closed, with a test for each kind. It lands
before the .53 Case 1 install hires an instance with a grant.
(2026-10-22)
## Queue
+3 -4
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 3172 by `queue render`. Do not edit between the markers; change the queue with `scripts/mosaic queue`.
Generated from `docs/plans/queue.json` revision 3162 by `queue render`. Do not edit between the markers; change the queue with `scripts/mosaic queue`.
| # | Piece | Owner | Issues | State | After | Gate | Brief | Note |
|---|---|---|---|---|---|---|---|---|
@@ -272,7 +272,7 @@ Generated from `docs/plans/queue.json` revision 3172 by `queue render`. Do not e
| 233 | Installer I3: stack-upgrade.sh root driver and the ops-01 host run sheet (MVP, Q187, Vikunja #432) | darkwing; reviewers filbert | #1735 | done | 232 done | Filbert approves the manifest. The test file, the cli suite and every scripts/test-*.sh are green on Sage's gate, with the mutants killed. (sage) | `docs/plans/2026-10-20_installer-rows.md` § I3 the upgrade command and the host run sheet | Landed 57f59e0c3 on 5465fd52d (pushed 05:39Z). verify-commit matches the manifest (7 paths). Patch-id b16ac924 equals the candidate's (1a1dc80ca vs e65a3ab4d). Modes match. Sage's gate on the landing tree: cli 617/0, scripts/test-*.sh 585/0. Filbert N1, N4 and N5 go to row 240, N2 and N3 to DEFERRED. |
| 234 | Machine grant row a: host enrollment, store records and migration 3i-3j (#436a, design r6 f9039682e, lands by 10-25 before the .53/.54 installs) | gizmoduck; reviewers duckworth | #1740 | done | 210 done | Duckworth approves the manifest. The bus, business and cli suites and every scripts/test-*.sh are green on Sage's gate. The design's section 10 row a tests and mutants pass, the migration test included. (sage) (sage) | `docs/plans/2026-10-20_host-credentials.md` § Machine grant row a: enrollment, store records and migration 3i-3j | LANDED c771a36f4 (pushed 06:16Z 10-21) on caa4b5944: r3 c783d5e39 41 paths via temp index, modes from ls-tree. patch-id 32bbbea2bd6f equals the candidate's; verify-commit ok 41 paths. Gate on the c783d5e39 tree 06:02:12Z-06:15:26Z: 15 packages 2890/0, ten scripts/test-*.sh 585/0. Duckworth r3 approve 29935 rev 2683. N10, N12 to row 235. |
| 235 | Machine grant row b: raise binding, executor, reconcile and revoke (#436, design r6 f9039682e, C1, N1, G27, G28) | gizmoduck; reviewers duckworth | #1741 | done | 234 done | Duckworth approves the manifest. The bus, business and cli suites and every scripts/test-*.sh are green on Sage's gate. Section 10's row b tests and mutants pass, G27, G28 and the upsert mutant included. (sage) (sage) | `docs/plans/2026-10-20_host-credentials.md` § Machine grant row b: raise binding, executor, reconcile and revoke | LANDED b1fece6f0, pushed (rode Darkwing SESSIONS aa19a2464). Cand ed4193316, manifest dad377ca8fe4, 13 paths, verify-commit ok, patch-id c355d2fc457d. Gate 10:30:55-10:42:43Z: bus 596/0, business 200/0, cli 666/0, all scripts green. Brief Gate met: duckworth-0089 approve, upsert mutant equivalent, row b and creative tests and mutants on Gizmoduck slot runs (38 of 39 killed, B17 equivalent). |
| 236 | #436b Gitea grant path build: seat accounts and tokens through an approved grant, schema 3j-3k (design r5 1be535aca, due 10-27 at risk; live waits for Jason's section 8 approval and bootstrap step) | megavolt; reviewers dewey | #1742 | done | 210 done, 234 done | Dewey approves the manifest. The bus, business and cli suites and every scripts/test-*.sh are green on Sage's gate. Section 9's mutants are killed, N9's restart test included. (sage) (sage) | `docs/plans/2026-10-20_agent-credentials.md` § Brief: #436b Gitea grant path, build (schema 3j-3k) | Landed bd5f11ded on refactor, a fast-forward on be2b008cc. It is 08840f3c1 rebased past 194 (commit 1 unions only, 2-7 =) plus 2 stale-pin fixes and a gitea-not-configured raise test (sage-2559). Dewey approve on manifest 0b00269b. Megavolt gate under Sage CLEAR: 2c green, bus 765, business 228, cli 832, ten scripts rc=0, mutants 40/40; skips named, env-only. Overlap 13/257 clean. Schema 3j-3k. #1742 closed. |
| 236 | #436b Gitea grant path build: seat accounts and tokens through an approved grant, schema 3j-3k (design r5 1be535aca, due 10-27 at risk; live waits for Jason's section 8 approval and bootstrap step) | megavolt; reviewers dewey | #1742 | in-progress | 210 done, 234 done | Dewey approves the manifest. The bus, business and cli suites and every scripts/test-*.sh are green on Sage's gate. Section 9's mutants are killed, N9's restart test included. (sage) (sage) | `docs/plans/2026-10-20_agent-credentials.md` § Brief: #436b Gitea grant path, build (schema 3j-3k) | — |
| 237 | Row 199 follow-up N1: one decision publishes once (publish-busy or #spent recheck) | megavolt; reviewers duckworth | #1743 | done | 199 done | Duckworth approves the manifest. The bus suite and every scripts/test-*.sh are green on Sage's gate, and the mutant is killed. (sage) (sage) | `docs/plans/2026-10-20_agent-credentials.md` § Brief: row 199 follow-up N1, one decision publishes once | Landed d7d943199 on refactor (local, push pending), patch-id c5f4beb4 = e42415935, manifest bb777cb0 blobs checked. Sage gate 01:31-01:44Z on 816d48b03+4 paths: M1 killed (bus 1/13, cli 1/8), every package suite and scripts/test-*.sh green (bus 478, cli 564). |
| 238 | row 200 follow-up N1: the sso-config hint carries --instance NAME (#1744) | launchpad; reviewers webby | #1744 | done | 200 done | Webby approves the manifest; stack-release.test.mjs whole, cli suite and every scripts/test-*.sh green on Sage's gate; the mutant killed (sage) | `docs/plans/2026-10-20_stack-release-sso.md` § Brief: row 200 follow-up N1, the sso-config hint names the instance | Landed d49b1f2bd (pushed 07:25Z) on 86249ac55: the 3 manifest paths from 4c90099ea. Patch-id b9d0bb0ee3c7 equals the candidate's; modes and blobs match; verify-commit ok (3 paths). Sage's combined 228+238 gate on c515273db plus both rows' 6 paths, 07:11:30Z to 07:23:34Z: seat 165/0, cli 640/0, all ten scripts/test-*.sh 585/0. Mutant (sso-config drops --instance) killed, survived none. Round 1 N1 stays on the #1744 carry list. |
| 239 | Row 194 follow-up: the credential revoke runs under the writer's lock, refusals after a revoke say so, and a stale-revision fire test (duckworth-0074, mos-1158) | honker; reviewers duckworth | #1745 | queued | 194 done | Duckworth approves the manifest. The bus, business, cli and webui suites and every scripts/test-*.sh are green on Sage's gate, and the mutants are killed. (sage) (sage) | `docs/plans/2026-10-20_agent-lifecycle.md` § Brief: row 194 follow-up, the revoke under the writer's lock and a fire test | — |
@@ -292,7 +292,7 @@ Generated from `docs/plans/queue.json` revision 3172 by `queue render`. Do not e
| 253 | F27: case2-unit.sh and case1-accept.sh move to --tester-socket and refuse --tester-port with stack-release's migration wording; no .53/.54 copy with 196 until this lands (mos-1198 item 4) | gander; reviewers duckworth | #1758 | in-progress | 196 done | reviewer approves DESIGN.md then the manifest; cli and webui suites whole and every scripts/test-*.sh on Sage's slot; mutants per DESIGN.md; static check covers scripts and tests (sage) | `docs/plans/2026-10-21_tester-socket-scripts.md` § Brief: case2-unit.sh and case1-accept.sh move to --tester-socket (F27) | — |
| 254 | Library sync follow-up: client timeout, STATUS codes, README (222 deferrals, #1759) | megavolt; reviewers dewey | #1759 | done | 196 done | Dewey approves the manifest; bus, library and webui suites whole and every scripts/test-*.sh green on Sage's slot; the timeout and STATUS mutants killed (sage) | `docs/plans/2026-10-21_library-sync-followup.md` § Gate | Landed 331597709 (candidate b31300d79 blobs and modes on 41b0cf755; patch-id 96216216 equal, range-diff differs only in the message) plus doc eb9672c91 (Dewey N1 README sentence closed; N2 four 502 codes to DEFERRED, binding the Console library routes row). Merged-tree rerun on Sage's slot 23:44-23:55Z: library 77/0, bus 628/0, webui 305/0 with 1 skip, cli 749/0. Megavolt's gate megavolt-0102 covers scripts and both mutants. |
| 255 | Bundled Pocket ID for users with no OIDC provider, design (Q203, #1760) | launchpad; reviewers webby | — | queued | 252 done | Webby approves DESIGN.md; design only, build rows follow after Sage checks it against Q203, O6, REQ-WEB-2 and REQ-INST-1 (sage) | `docs/plans/2026-10-21_bundled-pocket-id.md` § Gate | Vikunja 458, Gitea #1760, mos-1202 item 2. REQ-INST-2 lands in PRD 1.10 as post-v1 (mos-1204). Design only: no install, no download, no .53/.54, dragon-lin keeps Authentik. |
| 256 | the human deletes a creative instance; any other actor refused, fire unchanged (REQ-COS-3, before the v1 tag) | honker; reviewers webby | #1761 | in-progress | 194 done | Webby approves the manifest; touched test files whole plus business, bus, cli and webui suites and every scripts/test-*.sh green on Sage's slot; both mutants killed (sage) | `docs/plans/2026-10-21_creative-delete-human.md` § Brief: the human deletes a creative instance (REQ-COS-3, after row 194) | — |
| 256 | the human deletes a creative instance; any other actor refused, fire unchanged (REQ-COS-3, before the v1 tag) | honker; reviewers webby | #1761 | briefed | 194 done | Webby approves the manifest; touched test files whole plus business, bus, cli and webui suites and every scripts/test-*.sh green on Sage's slot; both mutants killed (sage) | `docs/plans/2026-10-21_creative-delete-human.md` § Brief: the human deletes a creative instance (REQ-COS-3, after row 194) | — |
| 257 | Q198 S1: pressure parser and pure rules evaluator (post-v1) | morgana; reviewers filbert | #1762 | in-review, round 2, request posted | — | Reviewer approves the manifest; touched test files whole plus the package suite green on Sage's slot; both brief mutants killed (post-v1, MVP keeps slot priority) (sage) | `docs/plans/2026-10-21_post-v1-row-briefs.md` § Brief: Q198 S1 pressure parser and pure rules evaluator | — |
| 258 | Email E01: mail PRD addendum draft and schemas (post-v1) | unassigned | #1763 | queued | — | Reviewer approves the manifest; the row's named tests whole plus the mail suite and touched suites green on Sage's slot; reviewer-named mutants killed (post-v1, MVP keeps slot priority) (sage) | `docs/plans/2026-10-21_post-v1-row-briefs.md` § Brief: E01 mail PRD addendum draft and schemas | — |
| 259 | Email E03: mail ledger, state and quotas (post-v1) | unassigned | #1764 | queued | 258 done | Reviewer approves the manifest; the row's named tests whole plus the mail suite and touched suites green on Sage's slot; reviewer-named mutants killed (post-v1, MVP keeps slot priority) (sage) | `docs/plans/2026-10-21_post-v1-row-briefs.md` § Brief: E03 mail ledger, state and quotas | — |
@@ -317,7 +317,6 @@ Generated from `docs/plans/queue.json` revision 3172 by `queue render`. Do not e
| 278 | The Console Agents view shows thread claims (follow-up to row 277; no user thread live before it) | gander; reviewers webby | #1783 | briefed | 277 done | Webby approves the manifest. The touched test files run whole, the webui and bus suites and every scripts/test-*.sh are green on Sage's gate, and the mutants are killed. (sage) | `docs/plans/2026-10-22_owner-thread-claims.md` § Brief: the Console shows thread claims | — |
| 279 | stopRun reads a live run's status at the deadline: the deadline branch is the only time exit (191 follow-up, MVP, mos-1269; lands before 194 b16) | rocko; reviewers dewey | #1784 | done | — | Dewey approves the manifest. The processing-control package's tests and every other touched test file run whole, the business and cli suites are green on Sage's gate, and M1 is killed. (sage) | `docs/plans/2026-10-22_stoprun-deadline.md` § Brief: stopRun's deadline branch is the only time exit | LANDED ceadad993 (carry of cad927137 onto 343b6dd76: range-diff 1:= 2:=, patch-ids 6d8ae7f4/e484c297 equal, Dewey APPROVE dewey-0809 c30409 + carry). Fix: stopRun loop is for(;;), deadline branch is the only time exit. Sage gate green: business 209/0/0, cli 794/0/0, discord processing+stop 9/0/0; Rocko stop-run 13/13, processing-control 62/62, M1 killed. Overlap sweep clean (13, 257). Next: 194 b16 rebases onto it (mos-1269). |
| 280 | Bus tests: the scratch sshd proves it owns its port (194 gate follow-up, MVP, mos-1270) | rocko; reviewers duckworth | #1785 | done | — | Duckworth approves the manifest. Both files run whole, the bus suite is green on Sage's gate, the 30-run repeat has no timeout, and M1 is killed. Row 194 b17's full gate then runs on top. Any red goes to Mos (mos-1270). (sage) | `docs/plans/2026-10-22_sshd-port-race.md` § Brief: the scratch sshd's port is ours before a test uses it | Landed b0fd28b6f 07:57:30Z, ff from da5d3651d: b765f5cb8 (Duckworth approve rev 3153, manifest 27806c05) + slim packet. Sage gate bus 656/0/0, ge 41, hr 26 (c30431). Repeat 2010/2010 under cli 1588/1588, no timeout; M1/M2 killed 4/4. Full evidence d6c924764 on rocko/row280-da5d-20261022. N2-N4 carry in BUILD.md. 194 b17 rebases on b0fd28b6f. |
| 281 | A fire or a delete revokes the instance's host and Gitea grants (194/236 follow-up, MVP, mos-1271) | megavolt; reviewers dewey | #1786 | briefed | 236 done | Dewey approves the design, then the manifest. Bus, business, cli and every touched test file run whole, plus the Docker gitea-grant test and every scripts/test-*.sh, green on Sage's gate. M1-M4 killed. Lands before .53 Case 1 hires a granted instance. Red goes to Sage, then Mos. (sage) | `docs/plans/2026-10-22_fire-revokes-grants.md` § Brief: no grant outlives its instance | — |
<!-- mosaic-queue:end -->
+13 -363
View File
@@ -1,7 +1,7 @@
{
"version": 1,
"canonicalRoot": "/mnt/storage/src/mosaic-stack",
"revision": 3172,
"revision": 3162,
"rows": [
{
"id": 1,
@@ -24617,7 +24617,7 @@
"closes": [
1742
],
"state": "done",
"state": "in-progress",
"previousState": null,
"required": false,
"requiredSince": null,
@@ -24641,70 +24641,16 @@
"reviewers": [
"dewey"
],
"review": {
"rounds": [
{
"n": 1,
"op": "megavolt-236-inreview-r5b",
"by": "megavolt",
"at": "2026-10-22T09:30:56.710Z",
"issue": 1742,
"candidate": {
"kind": "manifest",
"digest": "0b00269b9c0ddf9723c6c6c85a68972775380df3f32413ea01eb721c02b60598",
"text": "684ea4328a4fdb463403350b406c57e2e5724263c4ad8750c1727ab2a201d60e agents/megavolt/work/queue-236/BUILD.md\nd52ca4742e19618096b75f2695c385e733cb5333dfc7eca223a8d4c31393249a packages/bus/README.md\naba9240825a35fdcc741439d0708efa33afcf519d11cac9952c7207ad99250f0 packages/bus/schema.sql\n878f8cd35d6ebee2b63e82e721d3c3590d76c77a87df5223d54c1303ea24066d packages/bus/src/broker.mjs\nc0d7cd81000d770a999ce96ade4fe97a73cd50b7ac7e5a9e4f9d85fdf271b68e packages/bus/src/credentials.mjs\nc8f88c5a4ea58d74920cdc53dadc25686b0777344d66704e8006e6e14a2aeaa1 packages/bus/src/gitea-grant.mjs\n271b9781d73cf31cc562a56180e3ebcd725b5fde148a83347a201fdef2fc28af packages/bus/src/human-cli.mjs\n2e2bb1fe70076f5e24406a18a923b9e55c4192178a0a704a1b9716032676f9d4 packages/bus/src/migrations/3j-3k.sql\n7dada9331d1d7691aa2fd8580f6779428277c38ce881191091d245f8f68521f5 packages/bus/src/runtime.mjs\nd2d798cdbf8d889063c04ed39dad7cc77ba55ed9c85889f01926c5077a12a98d packages/bus/src/server.mjs\na575716a0164e3a8f8d94a6194df36702523a015caafba46968cc35c1f2e33e7 packages/bus/src/store.mjs\n31e0687c4dd30387907cb7bfa61e14ef221eaac56a7ea55495622f10f1bcf0c7 packages/bus/tests/account-migration.test.mjs\n65ffedcbf60fac3c3ca01ebddd85ea05005423757abe7c83d22e64c00f8a8f66 packages/bus/tests/channel-proof.test.mjs\nccd902b4a84a37271e33206290f33ae74ab857eb03aa5836668ceb096a9a669c packages/bus/tests/chief-of-staff.test.mjs\ne817e27aeb87f503b1c4ee49399da4f618fb64aeec78dd02b5c7f6a9048c26be packages/bus/tests/comment-migration.test.mjs\n90f9c8be96eacb7a1fce5e9925dc60c8259e17c4228f705d01238c254fa722a1 packages/bus/tests/fake-gitea-api.mjs\n0a7b633ce7eec9d0bb37c3255f4244d97bd0c8eac38db91a11b4ef6cc982ec8c packages/bus/tests/fixtures/schema-3j.sql\n1e564aacb9c2f80d0755d5bbe71f966db93bebaad3adfaa31cb3efa50548053a packages/bus/tests/gitea-grant-docker.test.mjs\n65d05d9683625b5611627290f0f80e0e72b9f45ff9bfe29e3c51e709d6ab6202 packages/bus/tests/gitea-grant.test.mjs\nfe2035c01cd02d97025a0ba4217bac9eb8e986ddc92b84adb2fc468f2ad2d8e6 packages/bus/tests/gitea-migration.test.mjs\n6fe28edabfe9744e90420ffbc8e7344367765c685f22c451abf933d183743ed2 packages/bus/tests/grant-host.test.mjs\n78eb40d3bf38c953750d8d7a512b7df702af4393ac61db8c4a3c7d9e3511f138 packages/bus/tests/grant-migration.test.mjs\n3a5fc4c32147659a9a4495fa4064e23cd503d362569b62a053b4ca6c2321fc0d packages/bus/tests/name-migration.test.mjs\n057ed3ecebcaed3942e5514ad9a55bf8644c6a1de64f0117319f81dc7736cfcd packages/bus/tests/project-runtime.test.mjs\nde7dc8ef8a90f6b5af761424c171634ab9b64b1cf3de8b97104d1d5d4a220294 packages/bus/tests/schema.test.mjs\n03dc1ad78192227bfc579125053a2599162b523bd155f2f6a96cbf16e48b81a0 packages/bus/tests/spec-migration.test.mjs\n7d3c3e7d7dba9711e252e93738e3027a30e6a38f0dc445bf5632af59f42ccfe0 packages/bus/tests/thread-migration.test.mjs\n874f5e85945c190b6cf0da1ec1395d39ad6698f9b0b350d2b53f110e37108fd3 packages/bus/tests/usage-migration.test.mjs\n4f7d07b7f697acf310b0f5f768a8ea1aba145628773c6ce18c12c34fb65d2938 packages/cli/README.md\n807739c2b81e46eb9e544afefa1d49457cc987b9ea215033b5267ff11e11da11 packages/cli/src/cli.mjs\n6b4c406aed5fadfc69b3a9811d0663020436a7e7022e679cbbf5f3db93cdaca4 packages/cli/src/grants.mjs\ne886fa96eb2c365cac2ceb2b674c78c377ca3b2504fb0695cb9a270c861eacbd packages/cli/tests/grants.test.mjs\n891529c46ace42bc36daf3a3c37f824e3e90a450662ab79c8a10f966dc03bfc6 packages/cli/tests/stack-release.test.mjs\na96ef55ece724f3d13b83ddf7ad1f7c64e0899bbac41991076c017a41dd511f3 scripts/mosaic\n"
},
"request": "comment",
"attempts": [
{
"op": "megavolt-236-inreview-r5b",
"by": "megavolt",
"at": "2026-10-22T09:30:56.710Z",
"state": "failed",
"comment": null,
"transport": {
"outcome": "failed",
"status": null,
"comment": null,
"detail": "pre-send: the credential or account check failed"
},
"resolutions": []
},
{
"op": "sage-236-request-r1",
"by": "sage",
"at": "2026-10-22T09:33:12.587Z",
"state": "posted",
"comment": 30449,
"transport": {
"outcome": "posted",
"status": 201,
"comment": 30449,
"detail": "created"
},
"resolutions": []
}
],
"duplicateRisk": false,
"receipts": [
{
"reviewer": "dewey",
"op": "dewey-236-r1-verdict",
"at": "2026-10-22T09:36:29.654Z",
"verdict": "approve",
"comment": 30450,
"candidate": "0b00269b9c0ddf9723c6c6c85a68972775380df3f32413ea01eb721c02b60598"
}
]
}
]
"review": null,
"claim": {
"seat": "megavolt",
"op": "megavolt-236-claim"
},
"claim": null,
"note": "Landed bd5f11ded on refactor, a fast-forward on be2b008cc. It is 08840f3c1 rebased past 194 (commit 1 unions only, 2-7 =) plus 2 stale-pin fixes and a gitea-not-configured raise test (sage-2559). Dewey approve on manifest 0b00269b. Megavolt gate under Sage CLEAR: 2c green, bus 765, business 228, cli 832, ten scripts rc=0, mutants 40/40; skips named, env-only. Overlap 13/257 clean. Schema 3j-3k. #1742 closed.",
"note": null,
"blockedReason": null,
"createdAt": "2026-10-20T23:39:42.567Z",
"updatedAt": "2026-10-22T09:38:38.148Z",
"updatedBy": "sage"
"updatedAt": "2026-10-22T00:32:54.651Z",
"updatedBy": "megavolt"
},
{
"id": 237,
@@ -26374,7 +26320,7 @@
"closes": [
1761
],
"state": "in-progress",
"state": "briefed",
"previousState": null,
"required": false,
"requiredSince": null,
@@ -26395,15 +26341,12 @@
"webby"
],
"review": null,
"claim": {
"seat": "honker",
"op": "honker-256-start"
},
"claim": null,
"note": null,
"blockedReason": null,
"createdAt": "2026-10-21T21:29:03.192Z",
"updatedAt": "2026-10-22T08:43:10.250Z",
"updatedBy": "honker"
"updatedAt": "2026-10-21T21:29:35.868Z",
"updatedBy": "sage"
},
{
"id": 257,
@@ -27687,44 +27630,6 @@
"createdAt": "2026-10-22T07:12:32.562Z",
"updatedAt": "2026-10-22T07:58:48.101Z",
"updatedBy": "sage"
},
{
"id": 281,
"piece": "A fire or a delete revokes the instance's host and Gitea grants (194/236 follow-up, MVP, mos-1271)",
"owner": "megavolt",
"issues": [
1786
],
"closes": [
1786
],
"state": "briefed",
"previousState": null,
"required": false,
"requiredSince": null,
"gate": "Dewey approves the design, then the manifest. Bus, business, cli and every touched test file run whole, plus the Docker gitea-grant test and every scripts/test-*.sh, green on Sage's gate. M1-M4 killed. Lands before .53 Case 1 hires a granted instance. Red goes to Sage, then Mos.",
"gateOwner": "sage",
"brief": {
"path": "docs/plans/2026-10-22_fire-revokes-grants.md",
"anchor": "Brief: no grant outlives its instance",
"blob": "8a2d256acf65dfe1939350516e164ffa23be90ac"
},
"after": [
{
"id": 236,
"when": "done"
}
],
"reviewers": [
"dewey"
],
"review": null,
"claim": null,
"note": null,
"blockedReason": null,
"createdAt": "2026-10-22T09:44:24.187Z",
"updatedAt": "2026-10-22T09:45:05.398Z",
"updatedBy": "sage"
}
],
"log": [
@@ -111176,261 +111081,6 @@
"receipt": "ok sage-194-done-2548 rev 3162 row 194 in-review→done round 1"
},
"viewSha": "bf8058cccaba20ce46ff687de92ea1fbf659dd26b090b1d4ce090bb9f0d32057"
},
{
"rev": 3163,
"op": "honker-256-start",
"verb": "move",
"args": {
"id": 256,
"to": "in-progress",
"reason": null,
"candidate": null,
"evidence": null,
"issue": null
},
"by": "honker",
"at": "2026-10-22T08:43:10.250Z",
"semantics": 2,
"result": {
"row": 256,
"from": "briefed",
"to": "in-progress",
"round": null,
"issue": null,
"candidate": null,
"receipt": "ok honker-256-start rev 3163 row 256 briefed→in-progress"
},
"viewSha": "373efeac81f5ed9280c679608113ec4edbf22ed069299a9123ff0ebead854c6a"
},
{
"rev": 3164,
"op": "megavolt-236-inreview-r5b",
"verb": "move",
"args": {
"id": 236,
"to": "in-review",
"reason": null,
"candidate": "/mnt/storage/scratch/megavolt-r236/manifest-bd5f11ded.txt",
"evidence": null,
"issue": null
},
"by": "megavolt",
"at": "2026-10-22T09:30:56.710Z",
"semantics": 2,
"result": {
"row": 236,
"from": "in-progress",
"to": "in-review",
"round": 1,
"issue": 1742,
"candidate": {
"kind": "manifest",
"digest": "0b00269b9c0ddf9723c6c6c85a68972775380df3f32413ea01eb721c02b60598",
"text": "684ea4328a4fdb463403350b406c57e2e5724263c4ad8750c1727ab2a201d60e agents/megavolt/work/queue-236/BUILD.md\nd52ca4742e19618096b75f2695c385e733cb5333dfc7eca223a8d4c31393249a packages/bus/README.md\naba9240825a35fdcc741439d0708efa33afcf519d11cac9952c7207ad99250f0 packages/bus/schema.sql\n878f8cd35d6ebee2b63e82e721d3c3590d76c77a87df5223d54c1303ea24066d packages/bus/src/broker.mjs\nc0d7cd81000d770a999ce96ade4fe97a73cd50b7ac7e5a9e4f9d85fdf271b68e packages/bus/src/credentials.mjs\nc8f88c5a4ea58d74920cdc53dadc25686b0777344d66704e8006e6e14a2aeaa1 packages/bus/src/gitea-grant.mjs\n271b9781d73cf31cc562a56180e3ebcd725b5fde148a83347a201fdef2fc28af packages/bus/src/human-cli.mjs\n2e2bb1fe70076f5e24406a18a923b9e55c4192178a0a704a1b9716032676f9d4 packages/bus/src/migrations/3j-3k.sql\n7dada9331d1d7691aa2fd8580f6779428277c38ce881191091d245f8f68521f5 packages/bus/src/runtime.mjs\nd2d798cdbf8d889063c04ed39dad7cc77ba55ed9c85889f01926c5077a12a98d packages/bus/src/server.mjs\na575716a0164e3a8f8d94a6194df36702523a015caafba46968cc35c1f2e33e7 packages/bus/src/store.mjs\n31e0687c4dd30387907cb7bfa61e14ef221eaac56a7ea55495622f10f1bcf0c7 packages/bus/tests/account-migration.test.mjs\n65ffedcbf60fac3c3ca01ebddd85ea05005423757abe7c83d22e64c00f8a8f66 packages/bus/tests/channel-proof.test.mjs\nccd902b4a84a37271e33206290f33ae74ab857eb03aa5836668ceb096a9a669c packages/bus/tests/chief-of-staff.test.mjs\ne817e27aeb87f503b1c4ee49399da4f618fb64aeec78dd02b5c7f6a9048c26be packages/bus/tests/comment-migration.test.mjs\n90f9c8be96eacb7a1fce5e9925dc60c8259e17c4228f705d01238c254fa722a1 packages/bus/tests/fake-gitea-api.mjs\n0a7b633ce7eec9d0bb37c3255f4244d97bd0c8eac38db91a11b4ef6cc982ec8c packages/bus/tests/fixtures/schema-3j.sql\n1e564aacb9c2f80d0755d5bbe71f966db93bebaad3adfaa31cb3efa50548053a packages/bus/tests/gitea-grant-docker.test.mjs\n65d05d9683625b5611627290f0f80e0e72b9f45ff9bfe29e3c51e709d6ab6202 packages/bus/tests/gitea-grant.test.mjs\nfe2035c01cd02d97025a0ba4217bac9eb8e986ddc92b84adb2fc468f2ad2d8e6 packages/bus/tests/gitea-migration.test.mjs\n6fe28edabfe9744e90420ffbc8e7344367765c685f22c451abf933d183743ed2 packages/bus/tests/grant-host.test.mjs\n78eb40d3bf38c953750d8d7a512b7df702af4393ac61db8c4a3c7d9e3511f138 packages/bus/tests/grant-migration.test.mjs\n3a5fc4c32147659a9a4495fa4064e23cd503d362569b62a053b4ca6c2321fc0d packages/bus/tests/name-migration.test.mjs\n057ed3ecebcaed3942e5514ad9a55bf8644c6a1de64f0117319f81dc7736cfcd packages/bus/tests/project-runtime.test.mjs\nde7dc8ef8a90f6b5af761424c171634ab9b64b1cf3de8b97104d1d5d4a220294 packages/bus/tests/schema.test.mjs\n03dc1ad78192227bfc579125053a2599162b523bd155f2f6a96cbf16e48b81a0 packages/bus/tests/spec-migration.test.mjs\n7d3c3e7d7dba9711e252e93738e3027a30e6a38f0dc445bf5632af59f42ccfe0 packages/bus/tests/thread-migration.test.mjs\n874f5e85945c190b6cf0da1ec1395d39ad6698f9b0b350d2b53f110e37108fd3 packages/bus/tests/usage-migration.test.mjs\n4f7d07b7f697acf310b0f5f768a8ea1aba145628773c6ce18c12c34fb65d2938 packages/cli/README.md\n807739c2b81e46eb9e544afefa1d49457cc987b9ea215033b5267ff11e11da11 packages/cli/src/cli.mjs\n6b4c406aed5fadfc69b3a9811d0663020436a7e7022e679cbbf5f3db93cdaca4 packages/cli/src/grants.mjs\ne886fa96eb2c365cac2ceb2b674c78c377ca3b2504fb0695cb9a270c861eacbd packages/cli/tests/grants.test.mjs\n891529c46ace42bc36daf3a3c37f824e3e90a450662ab79c8a10f966dc03bfc6 packages/cli/tests/stack-release.test.mjs\na96ef55ece724f3d13b83ddf7ad1f7c64e0899bbac41991076c017a41dd511f3 scripts/mosaic\n"
},
"receipt": "ok megavolt-236-inreview-r5b rev 3164 row 236 in-progress→in-review round 1 on #1742; request megavolt-236-inreview-r5b requesting"
},
"viewSha": "4f12d2541f4e42d823454d49873f74db153ed4a474a52409995496e8744bfa83"
},
{
"rev": 3165,
"op": "megavolt-236-inreview-r5b.outcome",
"verb": "review-outcome",
"args": {
"id": 236,
"attempt": "megavolt-236-inreview-r5b",
"outcome": "failed",
"status": null,
"comment": null,
"detail": "pre-send: the credential or account check failed"
},
"by": "megavolt",
"at": "2026-10-22T09:31:32.186Z",
"semantics": 2,
"result": {
"row": 236,
"round": 1,
"attempt": "megavolt-236-inreview-r5b",
"state": "failed",
"receipt": "ok megavolt-236-inreview-r5b.outcome rev 3165 row 236 round 1 request megavolt-236-inreview-r5b failed"
},
"viewSha": "74d2f0a2cf037a29bacc72cc12f65487097c8c43a5619ded8aa08e13548d0077"
},
{
"rev": 3166,
"op": "sage-236-request-r1",
"verb": "review-request",
"args": {
"id": 236
},
"by": "sage",
"at": "2026-10-22T09:33:12.587Z",
"semantics": 2,
"result": {
"row": 236,
"round": 1,
"attempt": "sage-236-request-r1",
"state": "requesting",
"receipt": "ok sage-236-request-r1 rev 3166 row 236 round 1 request sage-236-request-r1 requesting"
},
"viewSha": "a9a1528ed56ddec9afbaa4dd5600f141935c24faffd33020d07fb486e1acc677"
},
{
"rev": 3167,
"op": "sage-236-request-r1.outcome",
"verb": "review-outcome",
"args": {
"id": 236,
"attempt": "sage-236-request-r1",
"outcome": "posted",
"status": 201,
"comment": 30449,
"detail": "created"
},
"by": "sage",
"at": "2026-10-22T09:33:49.312Z",
"semantics": 2,
"result": {
"row": 236,
"round": 1,
"attempt": "sage-236-request-r1",
"state": "posted",
"receipt": "ok sage-236-request-r1.outcome rev 3167 row 236 round 1 request sage-236-request-r1 posted comment 30449"
},
"viewSha": "25cfe1f887dcf60e94bbd5e3e33ded1dfd024efffaa5b54a6c093d2da272f156"
},
{
"rev": 3168,
"op": "dewey-236-r1-verdict",
"verb": "review-record",
"args": {
"id": 236,
"verdict": "approve",
"comment": 30450,
"candidate": "0b00269b9c0ddf9723c6c6c85a68972775380df3f32413ea01eb721c02b60598"
},
"by": "dewey",
"at": "2026-10-22T09:36:29.654Z",
"semantics": 2,
"result": {
"row": 236,
"round": 1,
"verdict": "approve",
"receipt": "ok dewey-236-r1-verdict rev 3168 row 236 round 1 approve by dewey (comment 30450)"
},
"viewSha": "1e1e2f44c4c98c63736dd0b180cfc60ccd603829c10f8b065096f1fbb5e4f742"
},
{
"rev": 3169,
"op": "sage-236-landed-note",
"verb": "note",
"args": {
"id": 236,
"text": "Landed bd5f11ded on refactor, a fast-forward on be2b008cc. It is 08840f3c1 rebased past 194 (commit 1 unions only, 2-7 =) plus 2 stale-pin fixes and a gitea-not-configured raise test (sage-2559). Dewey approve on manifest 0b00269b. Megavolt gate under Sage CLEAR: 2c green, bus 765, business 228, cli 832, ten scripts rc=0, mutants 40/40; skips named, env-only. Overlap 13/257 clean. Schema 3j-3k. #1742 closed."
},
"by": "sage",
"at": "2026-10-22T09:38:01.750Z",
"semantics": 2,
"result": {
"row": 236,
"field": "note",
"receipt": "ok sage-236-landed-note rev 3169 row 236 note"
},
"viewSha": "0b632f873832029ac283af0747b4e74f95835545e56511f206cc3bcadd96484f"
},
{
"rev": 3170,
"op": "sage-236-done",
"verb": "move",
"args": {
"id": 236,
"to": "done",
"reason": null,
"candidate": null,
"evidence": null,
"issue": null
},
"by": "sage",
"at": "2026-10-22T09:38:38.148Z",
"semantics": 2,
"result": {
"row": 236,
"from": "in-review",
"to": "done",
"round": 1,
"issue": null,
"candidate": null,
"receipt": "ok sage-236-done rev 3170 row 236 in-review→done round 1"
},
"viewSha": "a0e157a3276e17d133a391db8b4c5a4605966a543059ba344e9b9dde9afe72e7"
},
{
"rev": 3171,
"op": "sage-firegrants-add-2566c",
"verb": "add",
"args": {
"piece": "A fire or a delete revokes the instance's host and Gitea grants (194/236 follow-up, MVP, mos-1271)",
"gate": "Dewey approves the design, then the manifest. Bus, business, cli and every touched test file run whole, plus the Docker gitea-grant test and every scripts/test-*.sh, green on Sage's gate. M1-M4 killed. Lands before .53 Case 1 hires a granted instance. Red goes to Sage, then Mos.",
"brief": "docs/plans/2026-10-22_fire-revokes-grants.md#Brief: no grant outlives its instance",
"issues": [
1786
],
"note": null,
"owner": "megavolt",
"gateOwner": "sage",
"after": [
{
"id": 236,
"when": "done"
}
],
"reviewers": [
"dewey"
],
"required": null
},
"by": "sage",
"at": "2026-10-22T09:44:24.187Z",
"semantics": 2,
"result": {
"row": 281,
"from": null,
"to": "queued",
"brief": {
"path": "docs/plans/2026-10-22_fire-revokes-grants.md",
"anchor": "Brief: no grant outlives its instance",
"blob": "8a2d256acf65dfe1939350516e164ffa23be90ac"
},
"receipt": "ok sage-firegrants-add-2566c rev 3171 row 281 none→queued"
},
"viewSha": "8eb04b5a52bf536c32a3fb7b90134c9301b6b091fec6edb33eb6360903b6defa"
},
{
"rev": 3172,
"op": "sage-281-briefed-2566",
"verb": "move",
"args": {
"id": 281,
"to": "briefed",
"reason": null,
"candidate": null,
"evidence": null,
"issue": null
},
"by": "sage",
"at": "2026-10-22T09:45:05.398Z",
"semantics": 2,
"result": {
"row": 281,
"from": "queued",
"to": "briefed",
"round": null,
"issue": null,
"candidate": null,
"receipt": "ok sage-281-briefed-2566 rev 3172 row 281 queued→briefed"
},
"viewSha": "16fd296944bff692f32960970e31d394c1bc7e06d1ac372d5b962e347782eb4b"
}
]
}
+4 -15
View File
@@ -225,11 +225,6 @@ an operator inspect content and delivery evidence after an uncertain result.
| `hosts.remove` | `{host}`; the human or the tester. Deletes the record, writes `grant.host.removed` and removes the installer key. A `host_grants` record for the host is `host-in-use`, a guarded event naming a host line left on the host `host-line-left`. |
| `hosts.revoke` | `{agent,host,reason}`; the human, the business's assistant, or the tester when `bus.tester` is exactly `true` (row 235, #1741, r6 section 6). Anyone else gets `revoke-refused`. The tester check reads `bus.tester` again at the call, `tester-dev-only`. One transaction deletes the `host_grants` record and writes `grant.revoked` with `hostLine:"pending"`. With no await between, the key leaves memory and its file goes, and a `host.run` or executor in flight aborts. Then `remove <user> <tag>` runs on the host and `grant.revoke.closed` records `hostLine:"removed"` or `hostLineLeft`. A revoke with no record still runs, with `granted:false`. Returns `{agent,host,tag,granted,keyFile,hostLine or hostLineLeft}`. A restart with a pending marker runs the remove once the bus is up; `hosts.status` lists its tag under `revoking`. |
| `hosts.sweep` | `{host}`; the human or the tester (r6 X3). Runs `list <user>` and removes each listed tag this business recorded for the host in a `grant.step` or `grant.revoked` that no live grant holds. Another business's line and an unrecorded `mosaic-` line stay. Writes `grant.host.swept {host,removed,left}`; `hostLineLeft` then counts from it. Returns `{host,removed,hostLineLeft}`. |
| `grants.status` | `{service:"gitea"}`; the human, the tester or the chief (row 236, #1742, #436b r5). The admin slot's url, account and rotate-by date with `rotationDue`, `adminTokenLeft`, the `gitea_users` and `gitea_grants` records, approved decisions not yet run (`spentAtNextStart`), grants `running` or `settlePending`, agents held as `tokenUnknown`, the `serviceLeft` entries a sweep retries, and revokes still `revoking`. No token. |
| `grants.run` | `{decision}`; the human only. Starts the Gitea executor of a `credential.mint` decision with a gitea grant the human said yes to, as the yes itself does (X1). Step 0's refusals come back with no record: `not-authorized`, `decision-consumed`, `grant-token-unknown`, `grant-busy`, `gitea-not-configured`. Returns `{decision,agent,started:true}`; the outcome is a `grant.applied` or `grant.failed` and a notice. |
| `grants.revoke` | `{service:"gitea",agent,reason}`; the human, the business's assistant, or the tester when `bus.tester` is exactly `true`. The token leaves memory and its file goes, an executor in flight aborts, then one transaction deletes the `gitea_grants` record and writes `grant.revoked` with the token ids to delete. The deletes run with the admin token after C1 to C4, and `grant.revoke.closed` records `serviceLeft`, a list. `credentials.revoke AGENT gitea` and `credentials.delete` do the same for a stack-made user; delete also deletes every token of the user and sets `prohibit_login` (fire). |
| `grants.sweep` | `{service:"gitea"}`; the human or the tester. Closes open revokes, settles open rotations again, and retries each `serviceLeft` entry. A fire retry (`tokens`, `login`) first checks the user's id as the revoke close does, and a gone or remade user is skipped with `gitea-user-gone` or `gitea-user-foreign`. Writes `grant.sweep` and `grant.swept {left,skipped,settled,closed}`. |
| `grants.admin.set`, `grants.admin.rotate`, `grants.admin.remove` | The human only (#436b r5 section 3). set takes `{service,url,account,rotateBy,token}`, checks C1 to C4 with the token, then writes the slot under `<configDir>/secrets/grants/<business>/gitea`; a slot already there is `grant-admin-exists`. rotate `{service,rotateBy}` mints a successor admin token and names the old one in `adminTokenLeft`; `{service,finish:true}` deletes it. Rotation voids the approved decisions not yet run, as their `adminSha256` no longer matches. remove drops the slot. Each writes `grant.admin` with no secret. A left admin token that set or remove drops from `adminTokenLeft`, or one shown as `unknown`, has to be deleted by hand at Gitea. A running executor, revoke close or sweep keeps the admin api it started with: rotate voids only decisions not yet run, and remove takes effect from the next run. |
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
@@ -716,10 +711,10 @@ scope probes and rotation/expiry notifications; S2 never probes real services.
## Schema and verification
`schema.sql` is v3k, SHA-256
`aba9240825a35fdcc741439d0708efa33afcf519d11cac9952c7207ad99250f0`.
It has 20 tables, 72 triggers and five views. Source digest and a trusted full
sqlite_master digest are checked at open, alongside schema version `3k`, WAL,
`schema.sql` is v3j, SHA-256
`0a7b633ce7eec9d0bb37c3255f4244d97bd0c8eac38db91a11b4ef6cc982ec8c`.
It has 18 tables, 72 triggers and five views. Source digest and a trusted full
sqlite_master digest are checked at open, alongside schema version `3j`, WAL,
quick_check and foreign-key integrity. Missing metadata refuses; it never
reinitializes an existing file. Full synchronous WAL transactions either commit
or roll back. SQL UPDATE, DELETE and INSERT OR REPLACE refuse on evidence tables.
@@ -887,12 +882,6 @@ step runs in one transaction, and a failure leaves the store at 3i. The 3i fixtu
`tests/fixtures/schema-3i.sql`, is pinned from 8fede5fc6's tree, and `tests/grant-migration.test.mjs`
covers the step and the trigger.
Schema 3k (row 236, #1742, #436b r5 section 7) adds only two tables: `gitea_users`, the Gitea
users the stack made (user, id and the nonce in their full name), and `gitea_grants`, the live Gitea
grants (user, token id and name, projects, rotate-by date and decision), whose key references its
`gitea_users` row. Each write goes in one transaction with a `grant.*` event. The step runs in one
transaction, and a failure leaves the store at 3j. `tests/gitea-migration.test.mjs` covers it.
The managed runner calls usage.begin before an actual harness turn and usage.end
after it, including a failed/stopped turn. Each takes only {turn: deliveredMessageId}.
The broker requires the bound role holder and its delivery, binds run identity,
-21
View File
@@ -394,24 +394,3 @@ CREATE TRIGGER events_grant_actor BEFORE INSERT ON events
AND (json_extract(NEW.body, '$.action') GLOB 'grant.*' OR json_extract(NEW.body, '$.operation') GLOB 'grant.*')
AND NOT (NEW.actor_role IS NULL AND NEW.actor_run IS 'grant')
BEGIN SELECT RAISE(ABORT, 'only the grant writer records grant events'); END;
CREATE TABLE gitea_users (
business TEXT NOT NULL,
agent TEXT NOT NULL,
user TEXT NOT NULL,
id INTEGER NOT NULL CHECK (id > 0),
nonce TEXT NOT NULL CHECK (length(nonce) = 16 AND NOT nonce GLOB '*[^0-9a-f]*'),
decision TEXT NOT NULL REFERENCES decisions(id),
PRIMARY KEY (business, agent)
) STRICT;
CREATE TABLE gitea_grants (
business TEXT NOT NULL,
agent TEXT NOT NULL,
user TEXT NOT NULL,
token_id INTEGER NOT NULL CHECK (token_id > 0),
token_name TEXT NOT NULL,
projects TEXT NOT NULL CHECK (json_valid(projects) AND json_type(projects) = 'array' AND json_array_length(projects) > 0),
rotate_by TEXT NOT NULL,
decision TEXT NOT NULL REFERENCES decisions(id),
PRIMARY KEY (business, agent),
FOREIGN KEY (business, agent) REFERENCES gitea_users(business, agent)
) STRICT;
File diff suppressed because it is too large. Load diff
+5 -65
View File
@@ -116,26 +116,20 @@ export class Credentials {
#revoked = new Set();
// Row 210 (design D5, D9): instance:host.<id> -> the AbortController of the call in flight.
#inflight = new Map();
// Row 236 (#436b section 5): instance:service entries whose file memory never loads, the
// grant-token-unknown pairs. A revoke or a new issue clears one.
#held = 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. `held` lists "instance:service" keys whose
// reference loads and whose file doesn't (#436b section 5).
constructor({ references = {}, repoRoots = [], dataRoot, env = process.env, storeRoot = null, held = [] }) {
// 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;
for (const k of held) this.#held.add(k);
try {
for (const [instance, services] of Object.entries(references))
for (const [service, ref] of Object.entries(services)) {
const date = this.#checkRef(instance, service, ref);
this.#refs.set(instance + ':' + service, { ref, date });
if (this.#held.has(instance + ':' + service)) continue;
const value = this.#read(ref, env, service);
if (value !== null) this.#tokens.set(instance + ':' + service, { ...value, date });
}
@@ -325,66 +319,14 @@ export class Credentials {
this.#refs.set(instance + ':' + service, { ref: { service, store: true, file, [service === 'vikunja' ? 'expires' : 'rotateBy']: date }, date });
this.#tokens.set(instance + ':' + service, { ...value, date });
this.#revoked.delete(instance + ':' + service);
this.#held.delete(instance + ':' + service);
return this.status().find((e) => e.instance === instance && e.service === service);
}
// Row 236 (#436b section 5, settle forward): memory takes the store file as it is now, with the
// constructor's checks, and `date` becomes the entry's date. A held entry refuses.
reload(instance, service, date) {
const k = instance + ':' + service;
const { ref } = this.#known(instance, service);
if (this.#held.has(k) || !ref.store) deny('credential-reference');
if (!day(date)) deny('credential-date');
const value = this.#read(ref, {}, service);
if (value === null) deny('credential-file');
this.#refs.set(k, { ref: { ...ref, rotateBy: date }, date });
this.#tokens.set(k, { ...value, date });
this.#revoked.delete(k);
}
// Row 236 (#436b section 5, grant-token-unknown): memory drops the token, as revoke's memory step,
// and a call in flight aborts. The file stays for the human to look at, and nothing loads it again
// until a revoke or an issue.
hold(instance, service) {
const k = instance + ':' + service;
this.#known(instance, service);
this.#tokens.delete(k);
this.#held.add(k);
this.#inflight.get(k)?.abort();
}
held(instance, service) {
return this.#held.has(instance + ':' + service);
}
// Row 236 (#436b section 5, settle): `operation` gets the token the store file holds now, read with
// the constructor's checks, or null when there is no file. Memory is untouched. The result is checked
// as use() checks it.
async useFile(instance, service, operation) {
const { ref } = this.#known(instance, service);
if (!ref.store) deny('credential-reference');
const value = this.#read(ref, {}, service);
const result = await operation(value?.token ?? null);
this.assertClean(result);
if (value && JSON.stringify(result)?.includes(value.token)) deny('credential-leak');
return result;
}
// Row 236 (#436b section 5, a first grant's open commit at restart): the store file goes, and with it
// a token no record backs. An entry with a token in memory refuses: only a held entry is undone so.
unlinkStore(instance, service) {
const k = instance + ':' + service;
const { ref } = this.#known(instance, service);
if (this.#tokens.has(k) || !ref.store) deny('credential-reference');
try {
unlinkSync(ref.file);
} catch (e) {
if (e.code !== 'ENOENT') deny('credential-file');
}
}
// 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);
this.#held.delete(instance + ':' + service);
// Row 210 (design D9): a host.run in flight for this agent and host is killed first.
this.#inflight.get(instance + ':' + service)?.abort();
if (ref.store) {
@@ -483,11 +425,9 @@ export class Credentials {
date,
...(hostKey ? { fingerprint: hostKey.fingerprint, publicKey: hostKey.publicLine } : {}),
state: !this.#tokens.has(key)
? this.#held.has(key)
? 'held'
: this.#revoked.has(key)
? 'revoked'
: 'not-issued'
? this.#revoked.has(key)
? 'revoked'
: 'not-issued'
: service !== 'vikunja'
? left <= 0
? 'rotation-due'
-461
View File
@@ -1,461 +0,0 @@
// Row 236 (#1742, #436b design r5 at 1be535aca): the trusted half of the Gitea grant path, run in the bus
// child. The broker gets it as `giteaGrant` and holds every store write and every step's order. This module
// holds what touches the network or a secret file: the HTTPS calls to Gitea (each made through the
// broker's `log`, which writes grant.call before and grant.result after), the admin slot (section 3c), the
// binding's shape check (section 2), the admin checks C1 to C4 (section 3a), the start pre-load of held
// pairs (section 5) and `giteaKeys`, the broker's narrow handle on Credentials.
//
// The admin token lives at <configDir>/secrets/grants/<business>/gitea/admin, 0600 in 0700 dirs, with
// admin.json next to it ({url, account, rotateBy, setAt}, no secret). Both are written tmp, fsync, rename,
// as row 199's issue writes. Neither value reaches an event, a message or a log.
import { request as httpsRequest } from 'node:https';
import { createHash, randomBytes } from 'node:crypto';
import {
closeSync, constants, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, rmdirSync, unlinkSync, writeSync,
} from 'node:fs';
import { join } from 'node:path';
import { BusError, GRANT_EVIDENCE } from './broker.mjs';
import { day, privateDir } from './credentials.mjs';
import { definitionSha256, grantTag } from './host-grant.mjs';
import { canonicalJson } from '../../business/src/util.mjs';
import { forkUrl, giteaName, ID_PATTERN, parseGiteaUrl } from '../../business/src/vocabulary.mjs';
const deny = (code) => {
throw new BusError(code);
};
const sha256 = (value) => createHash('sha256').update(canonicalJson(value)).digest('hex');
// Section 2: the seat token's scopes, fixed by code (row 199 A2). Section 3b: the admin token's, exactly.
// Both sorted, as Gitea reports scopes.
export const SEAT_SCOPES = Object.freeze(['read:user', 'write:repository']);
export const ADMIN_SCOPES = Object.freeze(['write:admin', 'write:repository', 'write:user']);
// F6: the child-scope cap first ships in v1.27.0.
export const MIN_VERSION = Object.freeze([1, 27, 0]);
// Step 5: pages of 50, at most 20 of them.
export const REPO_PAGE = 50;
export const REPO_PAGES = 20;
const TOKEN = /^[-A-Za-z0-9._~]+\n?$/;
const MAX_BODY = 4 * 1024 * 1024;
const CALL_MS = 15000;
const META_KEYS = ['url', 'account', 'rotateBy', 'setAt'];
const AT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z$/;
// C1: numbers, never strings, so 1.9.0 is below 1.27.0. A prerelease is below its release, so
// 1.27.0-rc0 refuses (F6). Build metadata after "+" is ignored. Anything that doesn't parse refuses.
const VERSION = /^v?(\d{1,6})\.(\d{1,6})\.(\d{1,6})(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$/;
export function versionAtLeast(value, floor = MIN_VERSION) {
const m = typeof value === 'string' ? VERSION.exec(value) : null;
if (!m) return false;
const v = [m[1], m[2], m[3]].map(Number);
for (let i = 0; i < 3; i++) if (v[i] !== floor[i]) return v[i] > floor[i];
return !m[4];
}
// Section 2's binding. Anything off refuses invalid-request; the broker checks the agent, its definition,
// identity, publish list and the admin slot inside the raise transaction. rotateBy is a real calendar day
// after `today`, the bus clock's UTC day: the rule row 199's host grants use (hostGrantCheck), since 199
// names no other rotation window. Returns a fresh object with only the bound keys.
export function giteaGrantCheck(grant, today) {
const allowed = ['method', 'agent', 'rotateBy', 'target'];
if (!grant || typeof grant !== 'object' || Array.isArray(grant)) deny('invalid-request');
const own = Object.keys(grant);
if (own.some((k) => !allowed.includes(k)) || allowed.some((k) => !own.includes(k))) deny('invalid-request');
if (grant.method !== 'gitea') deny('invalid-request');
if (typeof grant.agent !== 'string' || !ID_PATTERN.test(grant.agent)) deny('invalid-request');
const target = grant.target;
if (!target || typeof target !== 'object' || Array.isArray(target) || Object.keys(target).join() !== 'projects') deny('invalid-request');
const projects = target.projects;
if (!Array.isArray(projects) || projects.length < 1 || projects.length > 50) deny('invalid-request');
if (projects.some((p) => typeof p !== 'string' || !ID_PATTERN.test(p)) || new Set(projects).size !== projects.length) deny('invalid-request');
if (!day(grant.rotateBy) || grant.rotateBy <= today) deny('invalid-request');
return { method: 'gitea', agent: grant.agent, target: { projects: [...projects] }, rotateBy: grant.rotateBy };
}
// Section 2: over the agent's identity, its publish list and the remote of every project in it, since
// step 5 checks against all of them. `projects` is the business's project map.
export const identitySha256 = (role, projects) =>
sha256({
identity: role.identity ?? null,
publish: role.publish ?? [],
remotes: Object.fromEntries((role.publish ?? []).map((p) => [p, projects?.[p]?.remote ?? null])),
});
// Section 2: over the admin slot's metadata, never the token.
export const adminSha256 = (meta) => sha256({ url: meta.url, account: meta.account, setAt: meta.setAt });
// Step 3: `mosaic-<tag>-<decision>`. grantTag already starts with "mosaic-".
export const tokenName = (business, agent, decision) => `${grantTag(business, agent, 'gitea')}-${decision}`;
export { definitionSha256 };
// Section 3d: one HTTPS call. Never rejects: no answer (refused, reset, TLS failure, timeout, a body over
// the cap) is status 0. A redirect is returned as its status, never followed. `auth` is {token} for the
// `token` header or {basic: [account, token]} (F1). The result is {status, json, total}, total from
// X-Total-Count; it holds no header and no request detail.
export function send({ url, method, path, auth, body, ca, timeoutMs = CALL_MS }) {
return new Promise((resolve) => {
let done = false,
req = null;
const end = (r) => {
if (done) return;
done = true;
clearTimeout(timer);
resolve(r);
};
const timer = setTimeout(() => {
req?.destroy();
end({ status: 0, json: null, total: null });
}, timeoutMs);
try {
const headers = {
accept: 'application/json',
authorization: auth.basic ? 'Basic ' + Buffer.from(auth.basic.join(':')).toString('base64') : 'token ' + auth.token,
};
const data = body === undefined ? null : Buffer.from(JSON.stringify(body));
if (data) {
headers['content-type'] = 'application/json';
headers['content-length'] = String(data.length);
}
req = httpsRequest(new URL(path, url), { method, headers, ca, agent: false }, (res) => {
const chunks = [];
let size = 0;
res.on('data', (c) => {
size += c.length;
if (size > MAX_BODY) {
req.destroy();
end({ status: 0, json: null, total: null });
} else chunks.push(c);
});
res.on('end', () => {
let json = null;
try {
json = size ? JSON.parse(Buffer.concat(chunks).toString('utf8')) : null;
} catch {}
const t = res.headers['x-total-count'];
end({ status: res.statusCode, json, total: t === undefined || !/^\d+$/.test(t) ? null : Number(t) });
});
res.on('error', () => end({ status: 0, json: null, total: null }));
});
req.on('error', () => end({ status: 0, json: null, total: null }));
req.end(data ?? undefined);
} catch {
end({ status: 0, json: null, total: null });
}
});
}
const segment = (value) => encodeURIComponent(String(value));
// C1 to C4 (section 3a), each call through the api's log. Throws the refusal code. Returns the admin
// token's own name from C3 (F7), which rotate deletes by.
export async function adminChecks(api, account) {
const v = await api.version();
if (v.status !== 200) deny('gitea-failed');
if (!versionAtLeast(v.json?.version)) deny('gitea-too-old');
const self = await api.self();
if (self.status === 401 || self.status === 403) deny('grant-admin-account');
if (self.status !== 200) deny('gitea-failed');
if (self.json?.login !== account || self.json?.is_admin !== true) deny('grant-admin-account');
const t = await api.tokenScopes();
if (t.status !== 200) deny(t.status === 401 || t.status === 403 ? 'grant-admin-account' : 'gitea-failed');
const scopes = t.json?.scopes;
if (!Array.isArray(scopes) || [...scopes].sort().join() !== ADMIN_SCOPES.join()) deny('grant-admin-scope');
const r = await api.reposCheck();
if (r.status !== 200 || !Array.isArray(r.json)) deny('gitea-failed');
if (r.json.length !== 0 || (r.total !== null && r.total !== 0)) deny('grant-admin-not-dedicated');
return typeof t.json?.name === 'string' ? t.json.name : null;
}
// One token file's text, read with Credentials' checks: no symlink, a regular file the stack's user owns,
// 0600, at most 16 KiB, then the token format. null if `missing` and there is no file.
function readSecretFile(file, { missing = false } = {}) {
let fd;
try {
fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW);
} catch (e) {
if (missing && e.code === 'ENOENT') return null;
deny('credential-file');
}
try {
const s = fstatSync(fd);
if (!s.isFile() || s.uid !== process.getuid() || (s.mode & 0o777) !== 0o600 || s.size > 16384) deny('credential-file');
return readFileSync(fd, 'utf8');
} catch (e) {
if (e instanceof BusError) throw e;
deny('credential-file');
} finally {
closeSync(fd);
}
}
export function checkToken(token) {
if (typeof token !== 'string' || token.length > 16384 || !TOKEN.test(token)) deny('credential-format');
const t = token.replace(/\n$/, '');
if (t.length < 16) deny('credential-format');
return t;
}
function writeFileAtomic(dir, name, text) {
const file = join(dir, name);
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, text);
fsyncSync(fd);
} finally {
if (fd !== undefined) closeSync(fd);
}
renameSync(tmp, file);
const dirFd = openSync(dir, constants.O_RDONLY);
try {
fsyncSync(dirFd);
} finally {
closeSync(dirFd);
}
}
// The trusted Gitea half. `ca` is a PEM the HTTPS calls trust instead of the system's, for the tests'
// fake Gitea; production passes none. `send` is replaceable only for the tests' failure rows.
export function giteaGrant({ configDir, ca = undefined, timeoutMs = CALL_MS, transport = send }) {
const grantsRoot = join(configDir, 'secrets', 'grants');
const slotDir = (business) => join(grantsRoot, business, 'gitea');
const slotDirs = (business) => [grantsRoot, join(grantsRoot, business), slotDir(business)];
// Section 3c: the slot's metadata, or null when there is no slot. A slot dir or file that isn't the
// stack user's own and private refuses credential-file, as does metadata of any other shape.
const adminMeta = (business) => {
for (const dir of slotDirs(business)) if (!privateDir(dir, { missing: true })) return null;
const text = readSecretFile(join(slotDir(business), 'admin.json'), { missing: true });
if (text === null) return null;
let meta;
try {
meta = JSON.parse(text);
} catch {
deny('credential-file');
}
if (
!meta || typeof meta !== 'object' || Array.isArray(meta) ||
Object.keys(meta).sort().join() !== [...META_KEYS].sort().join() ||
!parseGiteaUrl(meta.url).url || !giteaName(meta.account) || !day(meta.rotateBy) ||
typeof meta.setAt !== 'string' || !AT.test(meta.setAt)
)
deny('credential-file');
return { url: meta.url, account: meta.account, rotateBy: meta.rotateBy, setAt: meta.setAt };
};
const adminToken = (business) => {
for (const dir of slotDirs(business)) privateDir(dir);
return checkToken(readSecretFile(join(slotDir(business), 'admin')));
};
// The admin api over one token. Basic on the token routes (F1), the `token` header elsewhere. Every
// call goes through log(op, run, after).
const adminApi = (url, account, token, log) => {
const header = { token };
const basic = { basic: [account, token] };
const go = (op, method, path, auth, body, after) => log(op, () => transport({ url, method, path, auth, body, ca, timeoutMs }), after);
return {
version: () => go('version', 'GET', '/api/v1/version', header),
self: () => go('self', 'GET', '/api/v1/user', header),
tokenScopes: () => go('token-scopes', 'GET', '/api/v1/token', header),
reposCheck: () => go('repos-check', 'GET', '/api/v1/user/repos?limit=1&page=1', header),
userGet: (user) => go('user-get', 'GET', `/api/v1/users/${segment(user)}`, header),
userCreate: (body, after) => go('user-create', 'POST', '/api/v1/admin/users', header, body, after),
userPatch: (user, body) => go('user-patch', 'PATCH', `/api/v1/admin/users/${segment(user)}`, header, body),
tokenList: (user, page) => go('token-list', 'GET', `/api/v1/users/${segment(user)}/tokens?limit=50&page=${page}`, basic),
tokenCreate: (user, name, scopes = SEAT_SCOPES, op = 'token-create') =>
go(op, 'POST', `/api/v1/users/${segment(user)}/tokens`, basic, { name, scopes: [...scopes] }),
tokenDelete: (user, idOrName) => go('token-delete', 'DELETE', `/api/v1/users/${segment(user)}/tokens/${segment(idOrName)}`, basic),
};
};
return {
giteaGrantCheck,
identitySha256,
adminSha256,
definitionSha256,
tokenName,
adminMeta,
openGrants: openGiteaGrants,
tokenUnknown,
checkToken,
forkUrl,
repoPage: REPO_PAGE,
repoPages: REPO_PAGES,
// The admin api over the slot's token, for a grant, an undo, a revoke or a sweep. Refuses
// grant-admin-missing with no slot.
admin(business, log) {
const meta = adminMeta(business);
if (!meta) deny('grant-admin-missing');
return { meta, api: adminApi(meta.url, meta.account, adminToken(business), log) };
},
adminChecks,
// The seat token's calls, made with the minted token (steps 4 and 5) or the store file's (settle).
seat(url, token, log) {
const header = { token };
const go = (op, method, path, body) => log(op, () => transport({ url, method, path, auth: header, body, ca, timeoutMs }));
return {
self: () => go('seat-self', 'GET', '/api/v1/user'),
repos: (page) => go('seat-repos', 'GET', `/api/v1/user/repos?limit=${REPO_PAGE}&page=${page}`),
forkGet: (owner, repo) => go('fork-get', 'GET', `/api/v1/repos/${segment(owner)}/${segment(repo)}`),
fork: (owner, repo) => go('fork', 'POST', `/api/v1/repos/${segment(owner)}/${segment(repo)}/forks`, {}),
whoami: () => go('token-whoami', 'GET', '/api/v1/token'),
};
},
// Section 3e: `set` checks C1 to C4 with the given token before it writes anything. A slot already
// there refuses grant-admin-exists, whatever its url (remove first). Returns the metadata written.
async setAdmin(business, { url, account, rotateBy, token, setAt }, log) {
const t = checkToken(token);
if (adminMeta(business)) deny('grant-admin-exists');
await adminChecks(adminApi(url, account, t, log), account);
if (adminMeta(business)) deny('grant-admin-exists');
const meta = { url, account, rotateBy, setAt };
this.writeAdmin(business, t, meta);
return meta;
},
// The slot's write: the token, then admin.json, each tmp, fsync, rename. Dirs are made 0700.
writeAdmin(business, token, meta) {
mkdirSync(join(configDir, 'secrets'), { recursive: true, mode: 0o700 });
for (const dir of slotDirs(business)) {
if (!privateDir(dir, { missing: true })) mkdirSync(dir, { mode: 0o700 });
privateDir(dir);
}
writeFileAtomic(slotDir(business), 'admin', checkToken(token) + '\n');
writeFileAtomic(slotDir(business), 'admin.json', JSON.stringify(meta) + '\n');
},
// Section 3d rotate: C1 to C4, the successor minted for the admin account itself and named by the
// UTC second, C2 and C3 with it, then the write. The old token is deleted by name with the new one;
// `left` names it if that fails. A name already taken (two rotations in one second) refuses before
// anything is written. `stamp` is yyyymmddThhmmssZ.
async rotateAdmin(business, { rotateBy, setAt, stamp }, log) {
const { meta, api } = this.admin(business, log);
const oldName = await adminChecks(api, meta.account);
const name = `mosaic-grant-${stamp}`;
if (name === oldName) deny('grant-admin-name-taken');
const r = await api.tokenCreate(meta.account, name, ADMIN_SCOPES, 'admin-rotate');
if (r.status === 400 || r.status === 422) deny('grant-admin-name-taken');
if (r.status !== 201 || typeof r.json?.sha1 !== 'string') deny('gitea-failed');
const successor = checkToken(r.json.sha1);
const next = adminApi(meta.url, meta.account, successor, log);
const self = await next.self();
if (self.status !== 200 || self.json?.login !== meta.account || self.json?.is_admin !== true) deny('grant-admin-account');
const t = await next.tokenScopes();
if (t.status !== 200 || !Array.isArray(t.json?.scopes) || [...t.json.scopes].sort().join() !== ADMIN_SCOPES.join()) deny('grant-admin-scope');
const written = { url: meta.url, account: meta.account, rotateBy, setAt };
this.writeAdmin(business, successor, written);
let left = null;
if (oldName === null) left = 'unknown';
else {
const d = await next.tokenDelete(meta.account, oldName);
if (d.status !== 204 && d.status !== 404) left = oldName;
}
return { meta: written, tokenName: name, adminTokenLeft: left };
},
// rotate --finish: the old token by name, with the slot's token.
async finishRotate(business, oldName, log) {
const { meta, api } = this.admin(business, log);
await adminChecks(api, meta.account);
const d = await api.tokenDelete(meta.account, oldName);
if (d.status !== 204 && d.status !== 404) deny('gitea-failed');
},
// Section 3d remove: the slot's files and dir go. No network call.
removeAdmin(business) {
if (!adminMeta(business)) deny('grant-admin-missing');
for (const name of ['admin', 'admin.json', 'admin.tmp', 'admin.json.tmp'])
try {
unlinkSync(join(slotDir(business), name));
} catch (e) {
if (e.code !== 'ENOENT') deny('credential-file');
}
try {
rmdirSync(slotDir(business));
} catch {
deny('credential-file');
}
},
// Step 2: the never-stored password of a new user and the nonce in its full_name.
password: () => randomBytes(24).toString('base64url'),
nonce: () => randomBytes(8).toString('hex'),
};
}
// The store reference for an agent's gitea token, as Credentials checks it.
export const giteaReference = (storeRoot, business, agent, rotateBy) => ({
store: true,
file: storeRoot ? join(storeRoot, business, agent, 'gitea') : null,
rotateBy,
});
// The grant executor's calls on Credentials, keyed by (business, agent). The broker gets this and never
// Credentials itself, so it still has no use(); useFile hands the file's token only to a settle call.
export function giteaKeys({ credentials, storeRoot }) {
const at = (business, agent) => business + '/' + agent;
return {
claim: (business, agent) => credentials.claim(at(business, agent), 'gitea'),
release: (business, agent, signal) => credentials.release(at(business, agent), 'gitea', signal),
abortClaim: (business, agent) => credentials.abortClaim(at(business, agent), 'gitea'),
known: (business, agent) => credentials.status().some((e) => e.instance === at(business, agent) && e.service === 'gitea'),
register: (business, agent, rotateBy) => credentials.register(at(business, agent), 'gitea', giteaReference(storeRoot, business, agent, rotateBy)),
unregister: (business, agent) => credentials.unregister(at(business, agent), 'gitea'),
issue: (business, agent, token, rotateBy, commit) => credentials.issue(at(business, agent), 'gitea', token, rotateBy, commit),
revoke: (business, agent) => credentials.revoke(at(business, agent), 'gitea'),
reload: (business, agent, rotateBy) => credentials.reload(at(business, agent), 'gitea', rotateBy),
hold: (business, agent) => credentials.hold(at(business, agent), 'gitea'),
held: (business, agent) => credentials.held(at(business, agent), 'gitea'),
useFile: (business, agent, fn) => credentials.useFile(at(business, agent), 'gitea', fn),
unlinkStore: (business, agent) => credentials.unlinkStore(at(business, agent), 'gitea'),
};
}
// Section 5, before Credentials loads, read-only on the store: each gitea_grants record gives its agent
// a store reference to gitea, and `held` lists the pairs whose file memory must not load. Those are an
// agent whose last gitea grant.failed is grant-token-unknown with no grant.revoked after it, and an
// agent with an open first grant whose last step is commit: its file may hold a token no record backs,
// and the broker's reconcile unlinks it. Only grant evidence counts (duckworth-0067 N1). `references`
// is changed in place: a record's reference replaces a business-file gitea reference, which names the
// same store path or a file the record supersedes.
export function giteaStart(store, storeRoot, businesses, references) {
const held = [];
for (const g of store.all('SELECT business, agent, rotate_by FROM gitea_grants ORDER BY business, agent')) {
if (!Object.hasOwn(businesses, g.business)) continue;
const refs = (references[g.business + '/' + g.agent] = { ...(references[g.business + '/' + g.agent] ?? {}) });
refs.gitea = giteaReference(storeRoot, g.business, g.agent, g.rotate_by);
}
for (const business of Object.keys(businesses)) {
for (const agent of tokenUnknown(store, business).keys()) held.push(business + '/' + agent + ':gitea');
for (const o of openGiteaGrants(store, business))
if (o.step?.step === 'commit' && o.step.previousTokenId === null) held.push(business + '/' + o.agent + ':gitea');
}
return { held: [...new Set(held)] };
}
const evidence = (store, business, where, ...args) =>
store.all(`SELECT seq, body FROM events WHERE business=? AND ${GRANT_EVIDENCE} AND ${where} ORDER BY seq`, business, ...args)
.map((e) => ({ seq: e.seq, body: JSON.parse(e.body) }));
// agent -> {decision, seq}: the agents whose last gitea grant.failed is grant-token-unknown with no gitea
// grant.revoked after it (section 5's held row).
export function tokenUnknown(store, business) {
const last = new Map();
for (const e of evidence(store, business, "json_extract(body,'$.method')='gitea' AND json_extract(body,'$.action') IN ('grant.failed','grant.revoked')"))
if (e.body.action === 'grant.revoked') last.delete(e.body.agent);
else if (e.body.code === 'grant-token-unknown') last.set(e.body.agent, { decision: e.body.grantDecision, seq: e.seq });
return last;
}
// Every gitea grant spent with no grant.applied or grant.failed after its spend: {decision, agent, step,
// seq, revokedAfter}. step is the last grant.step body or null, seq that step's or the spend's, and
// revokedAfter says a gitea grant.revoked of the agent came after it (N9). A step 0 grant.failed comes
// before the spend, so only ends after it count.
export function openGiteaGrants(store, business) {
const out = [];
for (const spend of evidence(store, business, "json_extract(body,'$.operation')='grant.gitea'")) {
const decision = spend.body.decision;
const mine = evidence(store, business, "json_extract(body,'$.grantDecision')=? AND seq > ?", decision, spend.seq);
if (mine.some((e) => e.body.action === 'grant.applied' || e.body.action === 'grant.failed')) continue;
const last = mine.filter((e) => e.body.action === 'grant.step').at(-1) ?? null;
out.push({ decision, agent: last?.body.agent ?? null, step: last?.body ?? null, seq: last?.seq ?? spend.seq });
}
for (const o of out)
o.revokedAfter =
o.agent !== null &&
evidence(store, business, "json_extract(body,'$.method')='gitea' AND json_extract(body,'$.action')='grant.revoked' AND json_extract(body,'$.agent')=? AND seq > ?", o.agent, o.seq)
.length > 0;
return out;
}
+2 -3
View File
@@ -25,9 +25,8 @@ try {
const client = new Client({
path: process.argv[2],
// Row 254: library.sync fetches for up to 100 s and the server holds its socket for 120 s,
// so wait past that for the server's own answer. Row 236: the grants verbs call Gitea and get the
// same wait. Every other verb keeps the 5 s default.
timeout: request.verb === 'library.sync' || request.verb.startsWith('grants.') ? 130_000 : undefined,
// so wait past that for the server's own answer. Every other verb keeps the 5 s default.
timeout: request.verb === 'library.sync' ? 130_000 : undefined,
human: {
business: request.business,
pid: me.pid,
-24
View File
@@ -1,24 +0,0 @@
-- Row 236 (#436b, #1742, design r5 section 7): the Gitea users this stack made and the Gitea grants
-- live in the store. The grant executor, revoke and sweep are the only writers, each in the
-- transaction of its event. Only new tables; the grant event trigger from 3j covers their events.
CREATE TABLE gitea_users (
business TEXT NOT NULL,
agent TEXT NOT NULL,
user TEXT NOT NULL,
id INTEGER NOT NULL CHECK (id > 0),
nonce TEXT NOT NULL CHECK (length(nonce) = 16 AND NOT nonce GLOB '*[^0-9a-f]*'),
decision TEXT NOT NULL REFERENCES decisions(id),
PRIMARY KEY (business, agent)
) STRICT;
CREATE TABLE gitea_grants (
business TEXT NOT NULL,
agent TEXT NOT NULL,
user TEXT NOT NULL,
token_id INTEGER NOT NULL CHECK (token_id > 0),
token_name TEXT NOT NULL,
projects TEXT NOT NULL CHECK (json_valid(projects) AND json_type(projects) = 'array' AND json_array_length(projects) > 0),
rotate_by TEXT NOT NULL,
decision TEXT NOT NULL REFERENCES decisions(id),
PRIMARY KEY (business, agent),
FOREIGN KEY (business, agent) REFERENCES gitea_users(business, agent)
) STRICT;
+4 -12
View File
@@ -23,7 +23,6 @@ import { busBusiness } from './business.mjs';
import { webService } from './web.mjs';
import { hostExecutor, sweepRunDir } from './host-run.mjs';
import { grantKeys, hostGrant, hostReference, sweepHostKeys } from './host-grant.mjs';
import { giteaGrant, giteaKeys, giteaStart } from './gitea-grant.mjs';
import { verifyPrincipal } from './principal.mjs';
import { inspectInterpreter, recordInterpreterStart } from './interpreter.mjs';
import { createPeerReader } from './peercred.mjs';
@@ -132,10 +131,7 @@ export async function startBroker({
// Row 235 (r6 section 6, N1): a host.<id> key file with no record goes before Credentials loads.
for (const entry of sweepHostKeys(storeRoot, Object.keys(businesses), granted))
process.stderr.write(`hosts: ${JSON.stringify({ event: 'host-key-swept', ...entry })}\n`);
// Row 236 (#1742, #436b r5 section 5): each gitea grant record gives its agent a store reference to
// gitea, and `held` names the files memory must not load until reconcile or a revoke.
const { held } = storeRoot ? giteaStart(store, storeRoot, businesses, references) : { held: [] };
credentials = new Credentials({ references, repoRoots: protectedRoots, dataRoot, storeRoot, held });
credentials = new Credentials({ references, repoRoots: protectedRoots, dataRoot, storeRoot });
// 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.
const plugins=organization?pluginSettings({dir:organization.dir,rolesDir:organization.rolesDir,exists:hasModule}):null;
@@ -257,9 +253,6 @@ export async function startBroker({
// Row 235 (#1741, r6 X4, X5): the grant executor's claim, register and issue, never use().
hostKeys: storeRoot ? grantKeys({ credentials, storeRoot }) : null,
processing,
// Row 236 (#1742): the Gitea grant path. The admin slot is <configDir>/secrets/grants/<business>/gitea.
giteaGrant: organization ? giteaGrant({ configDir: organization.dir }) : null,
giteaKeys: storeRoot ? giteaKeys({ credentials, storeRoot }) : null,
usage,
limits,
store,
@@ -283,11 +276,12 @@ export async function startBroker({
organization:settings?(id,verb,args,actor,context)=>{
if(verb==='limits')return withTokens(id,settings.read(id));
if(verb==='plugins')return plugins.read(id);
if(verb==='instances')return instances.read(id);
// Row 256 (#1761): the actor is the reader, so endProtected is the delete check for that reader.
if(verb==='instances')return instances.read(id,actor);
// Row 65 (mos-0840): the Console's routing table.
if(verb==='routes')return settings.routes(id);
// Row 194 (#1692, V5): a delete's checks at the verb, with no write and no reload.
if(verb==='instances.check')return instances.checkDelete(id,args.instance);
if(verb==='instances.check')return instances.checkDelete(id,args.instance,actor);
// Row 194 b6 (webby-0094 B1): a fire's checks against the file, before step A.
if(verb==='instance.fireCheck')return instances.checkFire(id,args.name);
let result;
@@ -409,8 +403,6 @@ export async function startBroker({
// Row 235 (r6 X3): the remote undo of interrupted host grants, once the bus is up, so a start doesn't
// wait on the network.
broker.reconcileHosts();
// Row 236 (#436b r5 section 5): the Gitea grants' remote undo, settle and open revokes, likewise.
broker.reconcileGitea();
// S6 launcher operations. The host decides capacity and the instance list; the broker
// authorizes role.launch and keeps the evidence, refusals included.
// A refusal is recorded against the caller, as request() records one.
+1 -3
View File
@@ -1,6 +1,6 @@
import { createServer } from 'node:net';
import { lstatSync, chmodSync, unlinkSync } from 'node:fs';
import { BusError, TASK_VERBS, WEB_VERBS, HOST_VERBS, HOSTS_VERBS, GRANTS_VERBS, PROJECT_FILE_VERBS, LIBRARY_VERBS, PROJECT_SAVE_VERB, PROJECT_SAVE_REQUEST_MAX } from './broker.mjs';
import { BusError, TASK_VERBS, WEB_VERBS, HOST_VERBS, HOSTS_VERBS, PROJECT_FILE_VERBS, LIBRARY_VERBS, PROJECT_SAVE_VERB, PROJECT_SAVE_REQUEST_MAX } from './broker.mjs';
import { inspectInterpreter } from './interpreter.mjs';
import { createPeerReader } from './peercred.mjs';
const LIMIT = 65536;
@@ -140,8 +140,6 @@ export async function serve({ broker, path, authenticateHuman = null, authentica
r.verb === 'spec.read' || r.verb === 'record.write' || PROJECT_FILE_VERBS.includes(r.verb) || LIBRARY_VERBS.includes(r.verb) ||
// Row 234 (#1740): a grant raise awaits its keyscan, and hosts.check its ssh.
HOSTS_VERBS.includes(r.verb) ||
// Row 236 (#1742): the grants verbs may call Gitea several times, 15 s each at most.
GRANTS_VERBS.includes(r.verb) ||
(r.verb === 'decision.raise' && (Object.hasOwn(r.args ?? {}, 'spec_path') || Object.hasOwn(r.args ?? {}, 'spec_commit') ||
Object.hasOwn(r.args ?? {}, 'grant')))) {
const own = transient;
+2 -7
View File
@@ -13,8 +13,8 @@ import { join, isAbsolute } from 'node:path';
import { createHash } from 'node:crypto';
const ddl = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8');
export const SCHEMA_SHA256 = 'aba9240825a35fdcc741439d0708efa33afcf519d11cac9952c7207ad99250f0';
export const SCHEMA_VERSION = '3k';
export const SCHEMA_SHA256 = '0a7b633ce7eec9d0bb37c3255f4244d97bd0c8eac38db91a11b4ef6cc982ec8c';
export const SCHEMA_VERSION = '3j';
// Ordered, named offline steps. New schema changes append a step and keep prior DDL pinned.
const legacyDDL = readFileSync(new URL('./migrations/3b.sql', import.meta.url), 'utf8');
const LEGACY_SHA256 = '179ffe356d4ff19a49b5ebad39b6c6bfd7771deb8e1b7c55b5e746f039d69e65';
@@ -61,11 +61,6 @@ const migrations = [{
from: '3i',
to: '3j',
sql: readFileSync(new URL('./migrations/3i-3j.sql', import.meta.url), 'utf8'),
}, {
// Row 236 (#1742, #436b section 7): the Gitea users the stack made and the Gitea grants. Only new tables.
from: '3j',
to: '3k',
sql: readFileSync(new URL('./migrations/3j-3k.sql', import.meta.url), 'utf8'),
}];
const timedTables = [
'events',
@@ -70,7 +70,6 @@ async function engine(root, name, { store, schema, step = (s) => s }) {
copyFileSync(here('../src/migrations/' + file), join(moduleRoot, 'src/migrations', file));
writeFileSync(join(moduleRoot, 'src/migrations/3h-3i.sql'), step(readFileSync(here('../src/migrations/3h-3i.sql'), 'utf8')));
copyFileSync(here('../src/migrations/3i-3j.sql'), join(moduleRoot, 'src/migrations/3i-3j.sql'));
copyFileSync(here('../src/migrations/3j-3k.sql'), join(moduleRoot, 'src/migrations/3j-3k.sql'));
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
}
@@ -78,7 +77,7 @@ test('REQ-LIMIT-4 3h copy migrates to 3i: every row keeps its seq, the sequence
const { root, path } = source3h(t), before = snapshot(path), was = master(path);
assert.throws(() => new Store(root), /schema-mismatch/);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3k');
assert.equal(version(path), '3j');
// Every table's rows as they were, usage_records with its seq values and sqlite_sequence with 50.
assert.deepEqual(snapshot(path, before.columns).rows, before.rows);
assert.deepEqual(before.rows.usage_records.map(r => r.seq), [1, 2, 3]);
@@ -87,9 +86,7 @@ test('REQ-LIMIT-4 3h copy migrates to 3i: every row keeps its seq, the sequence
const old = ' account TEXT CHECK (account IS NULL),\n';
assert.equal(was.filter(r => r.sql?.includes(old)).map(r => r.name).join(), 'usage_records');
// Row 234 (#1740) adds 3i→3j's own objects after this step. Its test is grant-migration.test.mjs.
// Row 236 (#1742) adds 3j→3k's; its test is gitea-migration.test.mjs.
const later = ['hosts', 'host_grants', 'events_grant_actor', 'sqlite_autoindex_hosts_1', 'sqlite_autoindex_host_grants_1',
'gitea_users', 'gitea_grants', 'sqlite_autoindex_gitea_users_1', 'sqlite_autoindex_gitea_grants_1'];
const later = ['hosts', 'host_grants', 'events_grant_actor', 'sqlite_autoindex_hosts_1', 'sqlite_autoindex_host_grants_1'];
assert.deepEqual(master(path).filter(r => !later.includes(r.name)), was.map(r => r.name === 'usage_records' ? { ...r, sql: r.sql.replace(old, ' account TEXT,\n') } : r));
assert.equal(was.filter(r => r.type === 'trigger' && r.tbl_name === 'usage_records').length, 3);
const store = new Store(root);
@@ -128,7 +125,7 @@ test('REQ-LIMIT-4 a 3h→3i step that fails partway leaves the store at 3h with
assert.deepEqual(snapshot(path, before.columns).rows, before.rows);
// Still a whole 3h store: the real step migrates it.
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3k');
assert.equal(version(path), '3j');
assert.deepEqual(snapshot(path, before.columns).rows, before.rows);
});
@@ -140,14 +137,14 @@ test('REQ-LIMIT-4 the 3h engine refuses a migrated store, migrating or not, and
schema: ddl3h,
store: (source) => source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3h).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3k';", "export const SCHEMA_VERSION = '3h';")
.replace("export const SCHEMA_VERSION = '3j';", "export const SCHEMA_VERSION = '3h';")
.replace(/\}, \{\n {2}\/\/ Row 126 \(#1620\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'),
});
const pinned = readFileSync(join(root, 'engine-3h/src/store.mjs'), 'utf8');
assert.ok(pinned.includes("export const SCHEMA_VERSION = '3h';") && !pinned.includes('3h-3i'), 'the 3h engine has no 3h→3i step');
assert.throws(() => new Store3h(root), /schema-mismatch/);
assert.throws(() => new Store3h(root, { migrateSchema: true }), /schema-mismatch/);
assert.equal(version(path), '3k');
assert.equal(version(path), '3j');
assert.deepEqual(master(path), was);
assert.deepEqual(snapshot(path, after.columns).rows, after.rows);
});
+1 -3
View File
@@ -4,8 +4,6 @@
// and the ban on channel plug-ins importing a proof caller are retained.
// Row 254 (#1759) re-pins human-cli.mjs for the 130 s library.sync client timeout and webui
// bus.mjs for six library-* STATUS entries; no proof or transport change.
// Row 236 (#1742) re-pins human-cli.mjs for the same 130 s wait on the grants verbs; no proof or
// transport change.
import test from "node:test";
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
@@ -16,7 +14,7 @@ import { Client } from "../src/client.mjs";
const baselines = {
"packages/bus/src/human.mjs": "0c6d96abe4607cba0b02b0638a2fc217ff8135bac9a47761a0ef015fbe9d3079",
"packages/bus/src/human-cli.mjs": "271b9781d73cf31cc562a56180e3ebcd725b5fde148a83347a201fdef2fc28af",
"packages/bus/src/human-cli.mjs": "9c9af66b98ea0a490c9a880fc81c3c62414a8ee0b5ba6478608771b8c4f87932",
"packages/cli/src/transport.mjs": "5129c44b4d9f642e825344df65c9bfb4bac4fc8d6b25343b42d39853dbd40774",
// Row 165 (#1662): seven STATUS entries for the role instance routes (organization-instance-exists,
// organization-unknown-instance, organization-credentials-out-of-scope, organization-launch-not-configured,
+2 -2
View File
@@ -614,7 +614,7 @@ async function database3c(t) {
let source = readFileSync(here('../src/store.mjs'), 'utf8');
const pinned = source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3c).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3k';", "export const SCHEMA_VERSION = '3c';")
.replace("export const SCHEMA_VERSION = '3j';", "export const SCHEMA_VERSION = '3c';")
.replace(/\}, \{\n {2}from: '3c',[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
assert.notEqual(pinned, source);
assert.ok(!pinned.includes('3c-3d'), 'the 3c engine has no 3c→3d step');
@@ -638,7 +638,7 @@ test('REQ-ROLE-2, REQ-COS-1: the 3c→3d migration lets the chief of staff end a
assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true });
t.after(() => store.close());
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3k');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3j');
assert.equal(store.get("SELECT body FROM events WHERE id='preserved'").body, '{"fixture":true}');
assert.equal(store.get("SELECT holder_run FROM role_claims WHERE role='coder'").holder_run, 'coder-run');
const b = new Broker({ store, businesses: demo(), organization: null });
@@ -33,7 +33,7 @@ test('explicit 3b copy migration preserves evidence and opens as 3g; normal star
const original = readFileSync(source);
assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true });
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3k');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3j');
assert.equal(store.get("SELECT body FROM events WHERE id='preserved'").body, '{"fixture":true}');
assert.equal(store.get('SELECT count(*) n FROM task_comment_cursors').n, 0);
const epoch = store.get("SELECT value FROM meta WHERE key='comment_cursor_epoch'").value;
@@ -75,15 +75,15 @@ test('named migration registry chains 3b through 3g to a fixture-only future ver
const sha = createHash('sha256').update(futureDDL).digest('hex');
let source = readFileSync(new URL('../src/store.mjs', import.meta.url), 'utf8');
source = source.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${sha}';`)
.replace("export const SCHEMA_VERSION = '3k';", "export const SCHEMA_VERSION = '3l';")
.replace('}];\nconst timedTables', `}, { from: '3k', to: '3l', sql: ${JSON.stringify(futureSQL)} }];\nconst timedTables`);
.replace("export const SCHEMA_VERSION = '3j';", "export const SCHEMA_VERSION = '3k';")
.replace('}];\nconst timedTables', `}, { from: '3j', to: '3k', sql: ${JSON.stringify(futureSQL)} }];\nconst timedTables`);
writeFileSync(join(moduleRoot, 'src/store.mjs'), source);
writeFileSync(join(moduleRoot, 'schema.sql'), futureDDL);
for (const name of ['3b.sql', '3b-3c.sql', '3c-3d.sql', '3d-3e.sql', '3e-3f.sql', '3f-3g.sql', '3g-3h.sql', '3h-3i.sql', '3i-3j.sql', '3j-3k.sql'])
for (const name of ['3b.sql', '3b-3c.sql', '3c-3d.sql', '3d-3e.sql', '3e-3f.sql', '3f-3g.sql', '3g-3h.sql', '3h-3i.sql', '3i-3j.sql'])
copyFileSync(new URL('../src/migrations/' + name, import.meta.url), join(moduleRoot, 'src/migrations', name));
const { Store: FutureStore } = await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')));
const future = new FutureStore(root, { migrateSchema: true });
assert.equal(future.get("SELECT value FROM meta WHERE key='schema_version'").value, '3l');
assert.equal(future.get("SELECT value FROM meta WHERE key='schema_version'").value, '3k');
assert.equal(future.get("SELECT name FROM sqlite_master WHERE name='future_fixture'").name, 'future_fixture');
assert.equal(future.get("SELECT count(*) n FROM task_comment_cursors").n, 0);
assert.equal(future.get("SELECT id FROM events").id, 'preserved');
@@ -109,7 +109,7 @@ test('offline command migrates only the supplied fixture root and reports usage
const command = new URL('../src/migrate-schema.mjs', import.meta.url).pathname;
const migrated = spawnSync(process.execPath, [command, root], { encoding: 'utf8' });
assert.equal(migrated.status, 0, migrated.stderr);
assert.match(migrated.stdout, /bus schema 3k ready/);
assert.match(migrated.stdout, /bus schema 3j ready/);
new Store(root).close();
const usage = spawnSync(process.execPath, [command], { encoding: 'utf8' });
assert.equal(usage.status, 2);
+28 -1
View File
@@ -123,7 +123,7 @@ async function runtime(t) {
const history = join(root, 'businesses/.history/demo/changes.jsonl');
const log = () => (existsSync(history) ? readFileSync(history, 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse) : []);
const bytes = () => readFileSync(file, 'utf8');
return { human, tester, chief, call, log, bytes };
return { bus, human, tester, chief, call, log, bytes };
}
test('M10 through the broker: the chief and the tester are refused on bean, the human\'s limits.set of its models key works', async (t) => {
@@ -151,3 +151,30 @@ test('M10 through the broker: the chief and the tester are refused on bean, the
assert.equal(JSON.parse(bytes()).launch.models['claude-sonnet-5-5'].model, 'claude-sonnet-5-5-next');
assert.deepEqual(log().filter((e) => e.state === 'committed').map((e) => e.actor), ['jason']);
});
// Row 256 (#1761, REQ-COS-3, T6): the human deletes bean through the verb and the launcher's pass, on
// the real writer, so the actor reaches the delete check at both. The chief is refused human-required
// and the tester tester-refused at the verb, so neither reaches the writer. Each reader's instances
// view carries its own endProtected.
test('row 256 T6 the human deletes a creative instance through the broker; the chief and the tester are refused at the verb', async (t) => {
const { bus, human, tester, chief, call, log, bytes } = await runtime(t);
assert.equal(call(human, 'instances').instances.bean.endProtected, false);
assert.equal(call(tester, 'instances').instances.bean.endProtected, true);
const { revision } = call(human, 'instances');
const before = bytes();
assert.throws(() => call(chief, 'instances.set', { revision, instance: 'bean', op: 'delete' }), { code: 'human-required' });
assert.throws(() => call(tester, 'instances.set', { revision, instance: 'bean', op: 'delete' }), { code: 'tester-refused' });
assert.equal(bytes(), before);
// The verb's check runs instances.check with the human as actor, and records the request.
const { request } = call(human, 'instances.set', { revision, instance: 'bean', op: 'delete' });
assert.equal(bytes(), before);
assert.equal(bus.lifecycleTake({ business: 'demo', request }).go, true);
// The launcher's pass commits the delete with the request's by, the human.
const { transaction } = bus.lifecycleDelete({ business: 'demo', request });
const file = JSON.parse(bytes());
assert.equal(Object.hasOwn(file.roles, 'bean'), false);
assert.deepEqual(file.retired, ['bean']);
const committed = log().filter((e) => e.state === 'committed');
assert.deepEqual(committed.map((e) => e.actor), ['jason']);
assert.equal(committed[0].transaction, transaction);
});
-191
View File
@@ -1,191 +0,0 @@
// Row 236 (#1742, #436b r5 section 9): a fake Gitea API for the grant executor, on 127.0.0.1 over TLS
// with row 199's scratch CA. It keeps users, their tokens and repositories in memory and answers the
// routes gitea-grant.mjs calls, with Gitea's auth rules as section 1 found them: the token routes take
// Basic auth as the account itself or a site admin, the rest the `token` header. Every token is a fake
// made here. The request log holds the method, the path, the auth kind and the login, never a token.
// `rule(match, answer)` makes one route answer otherwise: a status and body, `hang` (no answer, so the
// caller's timeout runs) or `drop` (the socket closes). Nothing here leaves 127.0.0.1.
import { randomBytes } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { createServer } from 'node:https';
import { scratchCa } from '../../seat/tests/fake-gitea.mjs';
const fakeToken = () => randomBytes(20).toString('hex');
export async function fakeGiteaApi(t, dir, { version = '1.27.1', admin = 'mosaic-grant', adminScopes = ['write:admin', 'write:repository', 'write:user'] } = {}) {
const tls = scratchCa(dir);
let nextId = 1;
const users = new Map();
const repos = [];
const rules = [];
const requests = [];
const state = { version };
const lower = (x) => String(x).toLowerCase();
const userJson = (u) => ({ id: u.id, login: u.login, email: u.email, full_name: u.full_name, is_admin: u.is_admin, prohibit_login: u.prohibit_login });
const repoJson = (r) => ({
id: r.id, name: r.name, full_name: `${r.owner}/${r.name}`, owner: { login: r.owner }, fork: Boolean(r.parent),
parent: r.parent ? { full_name: r.parent } : null,
});
const api = {
url: null,
ca: readFileSync(tls.ca, 'utf8'),
state,
requests,
users,
repos,
// Every token value this fake made, for the tests' leak checks. Never printed.
issued: [],
// A user and, with `scopes`, one token named `tokenName`. Returns the user and that token or null.
addUser(login, { is_admin = false, prohibit_login = false, full_name = login, email = `${login}@example.test`, scopes = null, tokenName = 'test' } = {}) {
const u = { id: nextId++, login, email, full_name, is_admin, prohibit_login, tokens: [] };
users.set(lower(login), u);
return { user: u, token: scopes ? api.addToken(login, tokenName, scopes) : null };
},
addToken(login, name, scopes) {
const u = users.get(lower(login));
const tok = { id: nextId++, name, sha1: fakeToken(), scopes: [...scopes].sort() };
u.tokens.push(tok);
api.issued.push(tok.sha1);
return tok;
},
removeUser(login) {
users.delete(lower(login));
},
addRepo(owner, name, { parent = null, collaborators = [] } = {}) {
const r = { id: nextId++, owner, name, parent, collaborators: collaborators.map(lower) };
repos.push(r);
return r;
},
tokens: (login) => users.get(lower(login))?.tokens.map((x) => ({ id: x.id, name: x.name, scopes: x.scopes })) ?? null,
rule(match, answer) {
const r = { match, answer };
rules.push(r);
return () => rules.splice(rules.indexOf(r), 1);
},
// The ops of the log, as "METHOD path" lines without the query.
calls: () => requests.map((r) => `${r.method} ${r.path.split('?')[0]}`),
};
const adminUser = api.addUser(admin, { is_admin: true, scopes: adminScopes, tokenName: 'mosaic-grant-bootstrap' });
api.admin = adminUser.user;
api.adminToken = adminUser.token.sha1;
const byToken = (sha1) => {
for (const u of users.values()) {
const tok = u.tokens.find((x) => x.sha1 === sha1);
if (tok) return { user: u, token: tok };
}
return null;
};
const authOf = (req) => {
const h = req.headers.authorization ?? '';
if (h.startsWith('token ')) return { kind: 'token', who: byToken(h.slice(6)) };
if (h.startsWith('Basic ')) {
const [login, ...rest] = Buffer.from(h.slice(6), 'base64').toString('utf8').split(':');
const who = byToken(rest.join(':'));
return { kind: 'basic', who: who && lower(who.user.login) === lower(login) ? who : null };
}
return { kind: 'none', who: null };
};
const reachable = (u) => repos.filter((r) => lower(r.owner) === lower(u.login) || r.collaborators.includes(lower(u.login)));
function route(method, path, query, auth, body) {
const who = auth.who;
const p = path.split('/').slice(3).map(decodeURIComponent);
if (method === 'GET' && path === '/api/v1/version') return [200, { version: state.version }];
if (!who) return [401, { message: 'unauthorized' }];
const me = who.user;
if (method === 'GET' && path === '/api/v1/user') return [200, userJson(me)];
if (method === 'GET' && path === '/api/v1/token') return [200, { id: who.token.id, name: who.token.name, scopes: who.token.scopes }];
if (method === 'GET' && path === '/api/v1/user/repos') {
const all = reachable(me);
const limit = Number(query.get('limit') ?? 30);
const page = Number(query.get('page') ?? 1);
return [200, all.slice((page - 1) * limit, page * limit).map(repoJson), all.length];
}
if (p[0] === 'users' && p.length === 2 && method === 'GET') {
const u = users.get(lower(p[1]));
return u ? [200, userJson(u)] : [404, { message: 'user does not exist' }];
}
if (p[0] === 'admin' && p[1] === 'users') {
if (!me.is_admin || !who.token.scopes.includes('write:admin')) return [403, { message: 'forbidden' }];
if (method === 'POST' && p.length === 2) {
if (users.has(lower(body.username))) return [422, { message: 'user already exists' }];
const u = { id: nextId++, login: body.username, email: body.email, full_name: body.full_name, is_admin: false, prohibit_login: false, tokens: [] };
users.set(lower(u.login), u);
return [201, userJson(u)];
}
if (method === 'PATCH' && p.length === 3) {
const u = users.get(lower(p[2]));
if (!u) return [404, { message: 'user does not exist' }];
if (typeof body.prohibit_login === 'boolean') u.prohibit_login = body.prohibit_login;
return [200, userJson(u)];
}
}
if (p[0] === 'users' && p[2] === 'tokens') {
if (auth.kind !== 'basic') return [401, { message: 'basic auth required' }];
const u = users.get(lower(p[1]));
if (lower(me.login) !== lower(p[1]) && !me.is_admin) return [403, { message: 'forbidden' }];
if (!u) return [404, { message: 'user does not exist' }];
if (method === 'GET' && p.length === 3) {
const limit = Number(query.get('limit') ?? 30);
const page = Number(query.get('page') ?? 1);
const list = u.tokens.map((x) => ({ id: x.id, name: x.name, scopes: x.scopes }));
return [200, list.slice((page - 1) * limit, page * limit), list.length];
}
if (method === 'POST' && p.length === 3) {
if (u.tokens.some((x) => x.name === body.name)) return [400, { message: 'access token name has been used already' }];
const tok = api.addToken(u.login, body.name, body.scopes);
return [201, { id: tok.id, name: tok.name, sha1: tok.sha1, scopes: tok.scopes }];
}
if (method === 'DELETE' && p.length === 4) {
const i = u.tokens.findIndex((x) => String(x.id) === p[3] || x.name === p[3]);
if (i < 0) return [404, { message: 'token not found' }];
u.tokens.splice(i, 1);
return [204, null];
}
}
if (p[0] === 'repos' && p.length === 3 && method === 'GET') {
const r = repos.find((x) => lower(x.owner) === lower(p[1]) && lower(x.name) === lower(p[2]));
return r ? [200, repoJson(r)] : [404, { message: 'not found' }];
}
if (p[0] === 'repos' && p.length === 4 && p[3] === 'forks' && method === 'POST') {
const up = repos.find((x) => lower(x.owner) === lower(p[1]) && lower(x.name) === lower(p[2]));
if (!up) return [404, { message: 'not found' }];
if (repos.some((x) => lower(x.owner) === lower(me.login) && lower(x.name) === lower(up.name))) return [409, { message: 'exists' }];
return [202, repoJson(api.addRepo(me.login, up.name, { parent: `${up.owner}/${up.name}` }))];
}
return [404, { message: 'no route' }];
}
const server = createServer({ key: tls.key, cert: tls.cert }, (req, res) => {
const chunks = [];
req.on('data', (c) => chunks.push(c));
req.on('end', async () => {
const u = new URL(req.url, 'https://127.0.0.1');
const auth = authOf(req);
let body = null;
try {
body = chunks.length ? JSON.parse(Buffer.concat(chunks).toString('utf8')) : null;
} catch {}
const entry = { method: req.method, path: u.pathname + u.search, auth: auth.kind, as: auth.who?.user.login ?? null };
requests.push(entry);
const rule = rules.find((r) => r.match(entry, body));
// An answer function may be async, to hold a call open until the test lets it go.
const answer = rule ? (typeof rule.answer === 'function' ? await rule.answer(entry, body) : rule.answer) : null;
if (answer?.hang) return;
if (answer?.drop) return req.socket.destroy();
const [status, json, total] = answer && !answer.pass ? [answer.status, answer.json ?? null, answer.total] : route(req.method, u.pathname, u.searchParams, auth, body ?? {});
const headers = { 'content-type': 'application/json' };
if (total !== undefined && total !== null) headers['x-total-count'] = String(total);
res.writeHead(status, headers);
res.end(json === null || status === 204 ? undefined : JSON.stringify(json));
});
});
await new Promise((r) => server.listen(0, '127.0.0.1', r));
t.after(() => new Promise((r) => {
server.closeAllConnections?.();
server.close(() => r());
}));
api.url = `https://127.0.0.1:${server.address().port}`;
return api;
}
-396
View File
@@ -1,396 +0,0 @@
PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;
CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL) STRICT;
CREATE TABLE events (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
id TEXT NOT NULL UNIQUE,
at TEXT NOT NULL,
business TEXT NOT NULL,
kind TEXT NOT NULL CHECK (kind IN (
'session.launched',
'session.ended',
'action.allowed',
'action.refused',
'task.created',
'task.assigned',
'task.state',
'task.closed',
'task.changed.external',
'task.conflict',
'task.missing',
'review.requested',
'review.verdict',
'human.input',
'config.refused',
'credential.expiring',
'credential.expired',
'credential.changed',
'launch.revoked',
'launch.restored',
'digest.sent')),
actor_role TEXT, actor_run TEXT,
subject TEXT,
corrects TEXT REFERENCES events(id),
body TEXT NOT NULL CHECK (json_valid(body))
) STRICT;
CREATE TABLE role_claims (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL, role TEXT NOT NULL,
op TEXT NOT NULL CHECK (op IN ('claim','release','revoke')),
holder_run TEXT NOT NULL,
harness TEXT NOT NULL, address TEXT,
by TEXT NOT NULL, reason TEXT,
decision TEXT
) STRICT;
CREATE TABLE decisions (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
id TEXT NOT NULL UNIQUE,
at TEXT NOT NULL,
business TEXT NOT NULL, project TEXT,
raised_by_role TEXT NOT NULL, raised_by_run TEXT NOT NULL,
class TEXT NOT NULL CHECK (class IN ('routine','within-role','cross-role','gated')),
action TEXT NOT NULL,
route_to TEXT NOT NULL,
question TEXT NOT NULL,
options TEXT NOT NULL CHECK (json_valid(options) AND json_array_length(options) BETWEEN 2 AND 9),
recommendation TEXT NOT NULL,
task_ref TEXT CHECK (task_ref IS NULL OR (task_ref GLOB 'vikunja:[1-9]*/[1-9]*' AND NOT substr(task_ref, 9) GLOB '*[^0-9/]*' AND NOT substr(task_ref, 9) GLOB '*/*/*')), requirement_ref TEXT,
blocking INTEGER NOT NULL CHECK (blocking IN (0,1)),
supersedes TEXT REFERENCES decisions(id)
, spec_path TEXT, spec_commit TEXT) STRICT;
CREATE TABLE decision_events (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
decision TEXT NOT NULL REFERENCES decisions(id),
at TEXT NOT NULL,
op TEXT NOT NULL CHECK (op IN ('seen','resolved','withdrawn','expired')),
by TEXT NOT NULL,
choice TEXT, note TEXT, via TEXT
) STRICT;
CREATE TABLE messages (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
id TEXT NOT NULL UNIQUE,
at TEXT NOT NULL,
business TEXT NOT NULL,
from_role TEXT NOT NULL, from_run TEXT NOT NULL,
to_role TEXT NOT NULL,
class TEXT NOT NULL,
in_reply_to TEXT REFERENCES messages(id),
decision TEXT REFERENCES decisions(id),
corrects TEXT REFERENCES messages(id),
body TEXT NOT NULL
, to_name TEXT) STRICT;
CREATE TABLE deliveries (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
message TEXT NOT NULL REFERENCES messages(id),
at TEXT NOT NULL,
op TEXT NOT NULL CHECK (op IN ('routed','delivered','failed','read')),
holder_run TEXT, transport TEXT, address TEXT, detail TEXT
) STRICT;
CREATE TABLE task_snapshots (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL,
task_ref TEXT NOT NULL CHECK (task_ref GLOB 'vikunja:[1-9]*/[1-9]*' AND NOT substr(task_ref, 9) GLOB '*[^0-9/]*' AND NOT substr(task_ref, 9) GLOB '*/*/*'),
updated TEXT NOT NULL,
etag TEXT,
digest TEXT NOT NULL CHECK (length(digest) = 64 AND NOT digest GLOB '*[^0-9a-f]*'),
fields TEXT NOT NULL CHECK (json_valid(fields)),
source TEXT NOT NULL CHECK (source IN ('self','poll')),
via TEXT CHECK (via IN ('board','cursor','task','reconcile')),
read_at TEXT,
role TEXT, run TEXT,
CHECK ((source = 'self') = (role IS NOT NULL AND run IS NOT NULL)),
CHECK ((source = 'poll') = (via IS NOT NULL AND read_at IS NOT NULL)),
CHECK (json_type(fields, '$.bucket') IS 'integer'
OR (source IS 'poll' AND via IS 'task' AND json_type(fields, '$.gone') IS 'text'))
) STRICT;
CREATE INDEX task_snapshots_ref ON task_snapshots (business, task_ref, seq);
CREATE TRIGGER decisions_resolve_once BEFORE INSERT ON decision_events
WHEN NEW.op IN ('resolved','withdrawn','expired') AND EXISTS (
SELECT 1 FROM decision_events WHERE decision = NEW.decision AND op IN ('resolved','withdrawn','expired'))
BEGIN SELECT RAISE(ABORT, 'decision already closed'); END;
CREATE TRIGGER decisions_resolved_choice BEFORE INSERT ON decision_events
WHEN NEW.op = 'resolved' AND (NEW.choice IS NULL OR NOT EXISTS (
SELECT 1 FROM decisions d, json_each(d.options) o WHERE d.id = NEW.decision AND json_extract(o.value,'$.key') = NEW.choice))
BEGIN SELECT RAISE(ABORT, 'resolution must name one of the options'); END;
CREATE TRIGGER role_one_holder BEFORE INSERT ON role_claims
WHEN NEW.op = 'claim' AND (SELECT op FROM role_claims WHERE business = NEW.business AND role = NEW.role ORDER BY seq DESC LIMIT 1) = 'claim'
BEGIN SELECT RAISE(ABORT, 'role already held'); END;
CREATE TRIGGER role_release_by_holder BEFORE INSERT ON role_claims
WHEN NEW.op IN ('release','revoke') AND COALESCE((SELECT op FROM role_claims WHERE business = NEW.business AND role = NEW.role ORDER BY seq DESC LIMIT 1),'') <> 'claim'
BEGIN SELECT RAISE(ABORT, 'role is not held'); END;
CREATE TRIGGER role_release_same_run BEFORE INSERT ON role_claims
WHEN NEW.op = 'release' AND (SELECT holder_run FROM role_claims WHERE business = NEW.business AND role = NEW.role ORDER BY seq DESC LIMIT 1) <> NEW.holder_run
BEGIN SELECT RAISE(ABORT, 'only the holder releases; others revoke'); END;
CREATE TRIGGER role_revoke_needs_decision BEFORE INSERT ON role_claims
WHEN NEW.op = 'revoke' AND NEW.decision IS NULL AND NEW.reason IS NULL
BEGIN SELECT RAISE(ABORT, 'revoke needs a resolved decision or a reason'); END;
CREATE TRIGGER meta_no_update BEFORE UPDATE ON meta BEGIN SELECT RAISE(ABORT, 'meta is append-only'); END;
CREATE TRIGGER meta_no_delete BEFORE DELETE ON meta BEGIN SELECT RAISE(ABORT, 'meta is append-only'); END;
CREATE TRIGGER events_no_update BEFORE UPDATE ON events BEGIN SELECT RAISE(ABORT, 'events is append-only'); END;
CREATE TRIGGER events_no_delete BEFORE DELETE ON events BEGIN SELECT RAISE(ABORT, 'events is append-only'); END;
CREATE TRIGGER role_claims_no_update BEFORE UPDATE ON role_claims BEGIN SELECT RAISE(ABORT, 'role_claims is append-only'); END;
CREATE TRIGGER role_claims_no_delete BEFORE DELETE ON role_claims BEGIN SELECT RAISE(ABORT, 'role_claims is append-only'); END;
CREATE TRIGGER decisions_no_update BEFORE UPDATE ON decisions BEGIN SELECT RAISE(ABORT, 'decisions is append-only'); END;
CREATE TRIGGER decisions_no_delete BEFORE DELETE ON decisions BEGIN SELECT RAISE(ABORT, 'decisions is append-only'); END;
CREATE TRIGGER decision_events_no_update BEFORE UPDATE ON decision_events BEGIN SELECT RAISE(ABORT, 'decision_events is append-only'); END;
CREATE TRIGGER decision_events_no_delete BEFORE DELETE ON decision_events BEGIN SELECT RAISE(ABORT, 'decision_events is append-only'); END;
CREATE TRIGGER messages_no_update BEFORE UPDATE ON messages BEGIN SELECT RAISE(ABORT, 'messages is append-only'); END;
CREATE TRIGGER messages_no_delete BEFORE DELETE ON messages BEGIN SELECT RAISE(ABORT, 'messages is append-only'); END;
CREATE TRIGGER deliveries_no_update BEFORE UPDATE ON deliveries BEGIN SELECT RAISE(ABORT, 'deliveries is append-only'); END;
CREATE TRIGGER deliveries_no_delete BEFORE DELETE ON deliveries BEGIN SELECT RAISE(ABORT, 'deliveries is append-only'); END;
CREATE TRIGGER meta_no_replace BEFORE INSERT ON meta WHEN EXISTS (SELECT 1 FROM meta WHERE key = NEW.key) BEGIN SELECT RAISE(ABORT, 'meta is append-only'); END;
CREATE TRIGGER events_no_replace BEFORE INSERT ON events WHEN EXISTS (SELECT 1 FROM events WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'events is append-only'); END;
CREATE TRIGGER role_claims_no_replace BEFORE INSERT ON role_claims WHEN EXISTS (SELECT 1 FROM role_claims WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'role_claims is append-only'); END;
CREATE TRIGGER decisions_no_replace BEFORE INSERT ON decisions WHEN EXISTS (SELECT 1 FROM decisions WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'decisions is append-only'); END;
CREATE TRIGGER decision_events_no_replace BEFORE INSERT ON decision_events WHEN EXISTS (SELECT 1 FROM decision_events WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'decision_events is append-only'); END;
CREATE TRIGGER messages_no_replace BEFORE INSERT ON messages WHEN EXISTS (SELECT 1 FROM messages WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'messages is append-only'); END;
CREATE TRIGGER deliveries_no_replace BEFORE INSERT ON deliveries WHEN EXISTS (SELECT 1 FROM deliveries WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'deliveries is append-only'); END;
CREATE TRIGGER task_snapshots_no_update BEFORE UPDATE ON task_snapshots BEGIN SELECT RAISE(ABORT, 'task_snapshots is append-only'); END;
CREATE TRIGGER task_snapshots_no_delete BEFORE DELETE ON task_snapshots BEGIN SELECT RAISE(ABORT, 'task_snapshots is append-only'); END;
CREATE TRIGGER task_snapshots_no_replace BEFORE INSERT ON task_snapshots WHEN EXISTS (SELECT 1 FROM task_snapshots WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'task_snapshots is append-only'); END;
CREATE TRIGGER decisions_blocking_needs_task BEFORE INSERT ON decisions
WHEN NEW.blocking = 1 AND NEW.task_ref IS NULL
BEGIN SELECT RAISE(ABORT, 'a blocking decision cites the task it blocks'); END;
CREATE TRIGGER events_launch_by_human BEFORE INSERT ON events
WHEN NEW.kind IN ('launch.revoked','launch.restored') AND (NEW.actor_role IS NOT NULL OR NEW.actor_run IS NOT NULL)
BEGIN SELECT RAISE(ABORT, 'only the human revokes or restores launching'); END;
CREATE TRIGGER events_credential_body BEFORE INSERT ON events
WHEN NEW.kind GLOB 'credential.*' AND (
json_extract(NEW.body, '$.service') IS NULL OR json_extract(NEW.body, '$.service') NOT IN ('gitea','vikunja')
OR json_extract(NEW.body, '$.instance') IS NULL)
BEGIN SELECT RAISE(ABORT, 'credential events name a service and a role instance'); END;
CREATE TRIGGER events_task_missing_body BEFORE INSERT ON events
WHEN NEW.kind = 'task.missing' AND (
json_extract(NEW.body, '$.reason') IS NULL OR json_extract(NEW.body, '$.reason') NOT IN ('moved','not-found','no-access')
OR (json_extract(NEW.body, '$.reason') = 'moved' AND json_type(NEW.body, '$.project') IS NOT 'integer'))
BEGIN SELECT RAISE(ABORT, 'task.missing carries a reason, and the new project when moved'); END;
CREATE TRIGGER events_task_subject BEFORE INSERT ON events
WHEN NEW.kind GLOB 'task.*' AND (NEW.subject IS NULL OR NOT (NEW.subject GLOB 'vikunja:[1-9]*/[1-9]*' AND NOT substr(NEW.subject, 9) GLOB '*[^0-9/]*' AND NOT substr(NEW.subject, 9) GLOB '*/*/*'))
BEGIN SELECT RAISE(ABORT, 'a task event names its task in subject'); END;
CREATE TRIGGER events_task_created_body BEFORE INSERT ON events
WHEN NEW.kind = 'task.created' AND (
json_type(NEW.body, '$.request') IS NOT 'text'
OR NOT EXISTS (SELECT 1 FROM events WHERE id = json_extract(NEW.body, '$.request')
AND kind = 'human.input' AND business = NEW.business)
OR json_type(NEW.body, '$.requirement') IS NOT 'text'
OR NOT json_extract(NEW.body, '$.requirement') GLOB 'REQ-[A-Z]*-[1-9]*'
OR json_extract(NEW.body, '$.requirement') GLOB 'REQ-*[^A-Z0-9-]*')
BEGIN SELECT RAISE(ABORT, 'task.created cites the human.input that asked for it and a requirement id'); END;
CREATE VIEW launch_state AS
SELECT business, CASE kind WHEN 'launch.revoked' THEN 'revoked' ELSE 'allowed' END AS state, at
FROM events e WHERE kind IN ('launch.revoked','launch.restored')
AND seq = (SELECT max(seq) FROM events WHERE business = e.business AND kind IN ('launch.revoked','launch.restored'));
CREATE VIEW task_external_changes AS
SELECT p.business, p.task_ref, p.seq, p.via, p.updated, p.digest, ls.digest AS self_digest
FROM task_snapshots p
LEFT JOIN task_snapshots ls ON ls.seq = (SELECT max(seq) FROM task_snapshots
WHERE business = p.business AND task_ref = p.task_ref AND source = 'self')
WHERE p.source = 'poll'
AND p.seq = (SELECT max(seq) FROM task_snapshots WHERE business = p.business AND task_ref = p.task_ref)
AND (ls.seq IS NULL OR (p.updated >= ls.updated AND p.read_at > ls.at AND p.digest <> ls.digest));
CREATE VIEW task_current AS
SELECT s.seq, s.at, s.business, s.task_ref, s.updated, s.etag, s.digest, s.fields, s.source, s.via, s.read_at, s.role, s.run
FROM task_snapshots s
WHERE s.seq = (SELECT max(c.seq) FROM task_snapshots c
WHERE c.business = s.business AND c.task_ref = s.task_ref
AND (c.source = 'self'
OR c.read_at > coalesce((SELECT ls.at FROM task_snapshots ls
WHERE ls.business = c.business AND ls.task_ref = c.task_ref AND ls.source = 'self'
ORDER BY ls.seq DESC LIMIT 1), '')));
CREATE VIEW tasks_open AS
SELECT s.business, s.task_ref, json_extract(s.fields, '$.bucket') AS bucket, s.seq
FROM task_current s
WHERE json_type(s.fields, '$.gone') IS NULL
AND json_extract(s.fields, '$.done') = 0;
CREATE VIEW urgent_inbox AS
SELECT d.id, d.business, d.task_ref, d.question, d.at
FROM decisions d
WHERE d.class = 'gated' AND d.blocking = 1
AND NOT EXISTS (SELECT 1 FROM decision_events x WHERE x.decision = d.id AND x.op IN ('resolved','withdrawn','expired'));
CREATE TABLE task_comment_cursors (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
business TEXT NOT NULL,
task_id INTEGER NOT NULL CHECK (task_id > 0),
last_comment INTEGER NOT NULL CHECK (last_comment > 0)
) STRICT;
CREATE INDEX task_comment_cursors_latest ON task_comment_cursors (business, task_id, seq);
CREATE TRIGGER task_comment_cursors_no_update BEFORE UPDATE ON task_comment_cursors
BEGIN SELECT RAISE(ABORT, 'task_comment_cursors is append-only'); END;
CREATE TRIGGER task_comment_cursors_no_delete BEFORE DELETE ON task_comment_cursors
BEGIN SELECT RAISE(ABORT, 'task_comment_cursors is append-only'); END;
CREATE TRIGGER task_comment_cursors_no_replace BEFORE INSERT ON task_comment_cursors
WHEN EXISTS (SELECT 1 FROM task_comment_cursors WHERE seq = NEW.seq)
BEGIN SELECT RAISE(ABORT, 'task_comment_cursors is append-only'); END;
CREATE TRIGGER task_comment_cursors_forward BEFORE INSERT ON task_comment_cursors
WHEN NEW.last_comment <= COALESCE((SELECT last_comment FROM task_comment_cursors
WHERE business = NEW.business AND task_id = NEW.task_id ORDER BY seq DESC LIMIT 1), 0)
BEGIN SELECT RAISE(ABORT, 'comment cursor must advance'); END;
CREATE TABLE projects (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
id TEXT NOT NULL UNIQUE,
at TEXT NOT NULL,
business TEXT NOT NULL,
name TEXT NOT NULL,
repo TEXT,
created_by TEXT NOT NULL,
UNIQUE (business, name)
) STRICT;
CREATE TABLE threads (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
id TEXT NOT NULL UNIQUE,
at TEXT NOT NULL,
business TEXT NOT NULL,
project TEXT NOT NULL REFERENCES projects(id),
instance TEXT NOT NULL,
owner TEXT NOT NULL,
name TEXT NOT NULL,
UNIQUE (project, name)
) STRICT;
CREATE TABLE thread_claims (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL,
thread TEXT NOT NULL REFERENCES threads(id),
op TEXT NOT NULL CHECK (op IN ('claim','release','revoke')),
holder_run TEXT NOT NULL,
harness TEXT NOT NULL, address TEXT,
by TEXT NOT NULL, reason TEXT,
decision TEXT
) STRICT;
CREATE TABLE message_threads (
message TEXT PRIMARY KEY REFERENCES messages(id),
thread TEXT NOT NULL REFERENCES threads(id)
) STRICT;
CREATE TABLE thread_opens (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
thread TEXT NOT NULL REFERENCES threads(id),
by TEXT NOT NULL
) STRICT;
CREATE INDEX thread_claims_thread ON thread_claims (thread, seq);
CREATE INDEX message_threads_thread ON message_threads (thread);
CREATE TRIGGER threads_project_business BEFORE INSERT ON threads
WHEN (SELECT business FROM projects WHERE id = NEW.project) IS NOT NEW.business
BEGIN SELECT RAISE(ABORT, 'a thread belongs to its project''s business'); END;
CREATE TRIGGER thread_one_holder BEFORE INSERT ON thread_claims
WHEN NEW.op = 'claim' AND (SELECT op FROM thread_claims WHERE thread = NEW.thread ORDER BY seq DESC LIMIT 1) = 'claim'
BEGIN SELECT RAISE(ABORT, 'thread already held'); END;
CREATE TRIGGER thread_one_per_run BEFORE INSERT ON thread_claims
WHEN NEW.op = 'claim' AND EXISTS (SELECT 1 FROM thread_claims c WHERE c.holder_run = NEW.holder_run AND c.business = NEW.business
AND c.seq = (SELECT max(seq) FROM thread_claims WHERE thread = c.thread) AND c.op = 'claim')
BEGIN SELECT RAISE(ABORT, 'run already holds a thread'); END;
CREATE TRIGGER thread_release_by_holder BEFORE INSERT ON thread_claims
WHEN NEW.op IN ('release','revoke') AND COALESCE((SELECT op FROM thread_claims WHERE thread = NEW.thread ORDER BY seq DESC LIMIT 1),'') <> 'claim'
BEGIN SELECT RAISE(ABORT, 'thread is not held'); END;
CREATE TRIGGER thread_release_same_run BEFORE INSERT ON thread_claims
WHEN NEW.op = 'release' AND (SELECT holder_run FROM thread_claims WHERE thread = NEW.thread ORDER BY seq DESC LIMIT 1) <> NEW.holder_run
BEGIN SELECT RAISE(ABORT, 'only the holder releases; others revoke'); END;
CREATE TRIGGER thread_revoke_needs_decision BEFORE INSERT ON thread_claims
WHEN NEW.op = 'revoke' AND NEW.decision IS NULL AND NEW.reason IS NULL
BEGIN SELECT RAISE(ABORT, 'revoke needs a resolved decision or a reason'); END;
CREATE TRIGGER thread_claims_business BEFORE INSERT ON thread_claims
WHEN (SELECT business FROM threads WHERE id = NEW.thread) IS NOT NEW.business
BEGIN SELECT RAISE(ABORT, 'a thread claim names the thread''s business'); END;
CREATE TRIGGER message_threads_match BEFORE INSERT ON message_threads
WHEN NOT EXISTS (SELECT 1 FROM messages m JOIN threads t ON t.id = NEW.thread
WHERE m.id = NEW.message AND m.business = t.business AND t.instance IN (m.to_role, m.from_role))
BEGIN SELECT RAISE(ABORT, 'a threaded message is to or from the thread''s instance'); END;
CREATE TRIGGER delivery_same_thread BEFORE INSERT ON deliveries
WHEN NEW.op IN ('routed','delivered') AND (NEW.op = 'delivered' OR NEW.holder_run IS NOT NULL)
AND EXISTS (SELECT 1 FROM message_threads mt JOIN messages m ON m.id = mt.message JOIN threads t ON t.id = mt.thread
WHERE mt.message = NEW.message AND m.to_role = t.instance
AND NOT EXISTS (SELECT 1 FROM thread_claims c WHERE c.seq = (SELECT max(seq) FROM thread_claims WHERE thread = t.id)
AND c.op = 'claim' AND c.holder_run IS NEW.holder_run))
BEGIN SELECT RAISE(ABORT, 'a threaded message goes only to its thread''s holder'); END;
CREATE TRIGGER projects_no_update BEFORE UPDATE ON projects BEGIN SELECT RAISE(ABORT, 'projects is append-only'); END;
CREATE TRIGGER projects_no_delete BEFORE DELETE ON projects BEGIN SELECT RAISE(ABORT, 'projects is append-only'); END;
CREATE TRIGGER projects_no_replace BEFORE INSERT ON projects WHEN EXISTS (SELECT 1 FROM projects WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'projects is append-only'); END;
CREATE TRIGGER threads_no_update BEFORE UPDATE ON threads BEGIN SELECT RAISE(ABORT, 'threads is append-only'); END;
CREATE TRIGGER threads_no_delete BEFORE DELETE ON threads BEGIN SELECT RAISE(ABORT, 'threads is append-only'); END;
CREATE TRIGGER threads_no_replace BEFORE INSERT ON threads WHEN EXISTS (SELECT 1 FROM threads WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'threads is append-only'); END;
CREATE TRIGGER thread_claims_no_update BEFORE UPDATE ON thread_claims BEGIN SELECT RAISE(ABORT, 'thread_claims is append-only'); END;
CREATE TRIGGER thread_claims_no_delete BEFORE DELETE ON thread_claims BEGIN SELECT RAISE(ABORT, 'thread_claims is append-only'); END;
CREATE TRIGGER thread_claims_no_replace BEFORE INSERT ON thread_claims WHEN EXISTS (SELECT 1 FROM thread_claims WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'thread_claims is append-only'); END;
CREATE TRIGGER message_threads_no_update BEFORE UPDATE ON message_threads BEGIN SELECT RAISE(ABORT, 'message_threads is append-only'); END;
CREATE TRIGGER message_threads_no_delete BEFORE DELETE ON message_threads BEGIN SELECT RAISE(ABORT, 'message_threads is append-only'); END;
CREATE TRIGGER message_threads_no_replace BEFORE INSERT ON message_threads WHEN EXISTS (SELECT 1 FROM message_threads WHERE message = NEW.message) BEGIN SELECT RAISE(ABORT, 'message_threads is append-only'); END;
CREATE TRIGGER thread_opens_no_update BEFORE UPDATE ON thread_opens BEGIN SELECT RAISE(ABORT, 'thread_opens is append-only'); END;
CREATE TRIGGER thread_opens_no_delete BEFORE DELETE ON thread_opens BEGIN SELECT RAISE(ABORT, 'thread_opens is append-only'); END;
CREATE TRIGGER thread_opens_no_replace BEFORE INSERT ON thread_opens WHEN EXISTS (SELECT 1 FROM thread_opens WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'thread_opens is append-only'); END;
CREATE TABLE instance_names (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL,
instance TEXT NOT NULL,
name TEXT NOT NULL,
by TEXT NOT NULL
) STRICT;
CREATE TRIGGER instance_names_unique BEFORE INSERT ON instance_names
WHEN EXISTS (SELECT 1 FROM instance_names n WHERE n.business = NEW.business AND n.name = NEW.name
AND n.instance <> NEW.instance
AND n.seq = (SELECT max(seq) FROM instance_names WHERE business = n.business AND instance = n.instance))
BEGIN SELECT RAISE(ABORT, 'an instance name is unique in its business'); END;
CREATE TRIGGER instance_names_no_update BEFORE UPDATE ON instance_names BEGIN SELECT RAISE(ABORT, 'instance_names is append-only'); END;
CREATE TRIGGER instance_names_no_delete BEFORE DELETE ON instance_names BEGIN SELECT RAISE(ABORT, 'instance_names is append-only'); END;
CREATE TRIGGER instance_names_no_replace BEFORE INSERT ON instance_names WHEN EXISTS (SELECT 1 FROM instance_names WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'instance_names is append-only'); END;
-- Row 124 (#1618): immutable source snapshots and turn outcomes, never transcript text.
CREATE TABLE usage_records (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL,
run TEXT NOT NULL,
instance TEXT NOT NULL,
provider TEXT,
model TEXT,
account TEXT,
turn TEXT NOT NULL,
source TEXT NOT NULL,
phase TEXT NOT NULL CHECK (phase IN ('begin','end')),
status TEXT NOT NULL CHECK (status IN ('known','unknown')),
code TEXT,
cursor TEXT CHECK (cursor IS NULL OR json_valid(cursor)),
input INTEGER,
output INTEGER,
cache_write INTEGER,
cache_read INTEGER,
UNIQUE (business,run,turn,source,phase),
CHECK ((status='unknown' AND input IS NULL AND output IS NULL AND cache_write IS NULL AND cache_read IS NULL)
OR (status='known' AND input >= 0 AND output >= 0 AND cache_write >= 0 AND cache_read >= 0
AND input IS NOT NULL AND output IS NOT NULL AND cache_write IS NOT NULL AND cache_read IS NOT NULL))
) STRICT;
CREATE TRIGGER usage_records_no_update BEFORE UPDATE ON usage_records BEGIN SELECT RAISE(ABORT, 'usage_records is append-only'); END;
CREATE TRIGGER usage_records_no_delete BEFORE DELETE ON usage_records BEGIN SELECT RAISE(ABORT, 'usage_records is append-only'); END;
CREATE TRIGGER usage_records_no_replace BEFORE INSERT ON usage_records WHEN EXISTS (SELECT 1 FROM usage_records WHERE seq=NEW.seq OR (business=NEW.business AND run=NEW.run AND turn=NEW.turn AND source=NEW.source AND phase=NEW.phase)) BEGIN SELECT RAISE(ABORT, 'usage_records is append-only'); END;
CREATE TABLE hosts (
business TEXT NOT NULL,
host TEXT NOT NULL,
address TEXT NOT NULL,
port INTEGER NOT NULL CHECK (port BETWEEN 1 AND 65535),
user TEXT NOT NULL,
sudo INTEGER NOT NULL CHECK (sudo IN (0,1)),
host_keys TEXT NOT NULL CHECK (json_valid(host_keys) AND json_type(host_keys) = 'array' AND json_array_length(host_keys) > 0),
installer_fingerprint TEXT,
state TEXT NOT NULL CHECK (state IN ('pending-install','ready')),
decision TEXT NOT NULL REFERENCES decisions(id),
checked_at TEXT,
PRIMARY KEY (business, host),
CHECK (state = 'pending-install' OR (installer_fingerprint IS NOT NULL AND checked_at IS NOT NULL))
) STRICT;
CREATE TABLE host_grants (
business TEXT NOT NULL,
agent TEXT NOT NULL,
host TEXT NOT NULL,
commands TEXT NOT NULL CHECK (json_valid(commands) AND json_type(commands) = 'array' AND json_array_length(commands) > 0),
rotate_by TEXT NOT NULL,
tag TEXT NOT NULL CHECK (length(tag) = 39 AND tag GLOB 'mosaic-*' AND NOT substr(tag, 8) GLOB '*[^0-9a-f]*'),
decision TEXT NOT NULL REFERENCES decisions(id),
PRIMARY KEY (business, agent, host),
FOREIGN KEY (business, host) REFERENCES hosts(business, host)
) STRICT;
CREATE TRIGGER events_grant_actor BEFORE INSERT ON events
WHEN NEW.kind = 'action.allowed'
AND (json_extract(NEW.body, '$.action') GLOB 'grant.*' OR json_extract(NEW.body, '$.operation') GLOB 'grant.*')
AND NOT (NEW.actor_role IS NULL AND NEW.actor_run IS 'grant')
BEGIN SELECT RAISE(ABORT, 'only the grant writer records grant events'); END;
@@ -1,200 +0,0 @@
// Row 236 (#1742, #436b r5 section 9, X-G3): the grant path end to end on a scratch gitea/gitea:1.27.1, as
// section 1's facts were found: a throwaway container on 127.0.0.1 over TLS with row 199's scratch CA, its
// data inside the container, removed after. The test never pulls an image; without Docker or the image it
// skips. The admin and seat tokens are scratch tokens this container made; none is printed, and no test
// here reaches git.mosaicstack.dev or any provider.
import test from 'node:test';
import assert from 'node:assert/strict';
import { randomBytes } from 'node:crypto';
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs';
import { request } from 'node:https';
import { createServer } from 'node:net';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { spawnSync } from 'node:child_process';
import { Broker, GRANT_EVIDENCE } from '../src/broker.mjs';
import { Credentials } from '../src/credentials.mjs';
import { SEAT_SCOPES, giteaGrant, giteaKeys } from '../src/gitea-grant.mjs';
import { Store } from '../src/store.mjs';
import { scratchCa } from '../../seat/tests/fake-gitea.mjs';
const IMAGE = 'gitea/gitea:1.27.1';
const USER = 'demo-coder';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const dayFrom = (n) => new Date(Date.now() + n * 86400000).toISOString().slice(0, 10);
const none = { authority: { withinRole: ['message.send'], crossRole: [] } };
const docker = (...args) => spawnSync('docker', args, { encoding: 'utf8', timeout: 120000 });
const skip = docker('image', 'inspect', IMAGE).status === 0 ? false : `no Docker or no local ${IMAGE}; this test never pulls`;
const freePort = () =>
new Promise((resolve, reject) => {
const s = createServer();
s.once('error', reject);
s.listen(0, '127.0.0.1', () => {
const { port } = s.address();
s.close(() => resolve(port));
});
});
// One HTTPS call to the scratch container, trusting only the scratch CA.
function call(url, ca, method, path, { auth = null, body = null } = {}) {
return new Promise((resolve) => {
const data = body ? JSON.stringify(body) : null;
const headers = { accept: 'application/json', ...(data ? { 'content-type': 'application/json' } : {}), ...(auth ? { authorization: auth } : {}) };
const req = request(url + path, { method, ca, headers, timeout: 10000 }, (res) => {
let text = '';
res.on('data', (c) => (text += c));
res.on('end', () => {
let json = null;
try {
json = text ? JSON.parse(text) : null;
} catch {}
resolve({ status: res.statusCode, json });
});
});
req.on('timeout', () => req.destroy());
req.on('error', () => resolve({ status: 0, json: null }));
req.end(data ?? undefined);
});
}
test('X-G3: on scratch 1.27.1, a grant makes the user, mints, forks, checks reach, writes the file, and revoke deletes the token', { skip, timeout: 300000 }, async (t) => {
const dir = mkdtempSync(join(tmpdir(), 'g236-docker-'));
t.after(() => rmSync(dir, { recursive: true, force: true }));
const tlsDir = join(dir, 'tls');
const tls = scratchCa(tlsDir);
// The container's git user reads the scratch key; it is a throwaway key for this run only.
chmodSync(tlsDir, 0o755);
chmodSync(join(tlsDir, 'server.key'), 0o644);
const ca = readFileSync(tls.ca, 'utf8');
const port = await freePort();
const url = `https://127.0.0.1:${port}`;
const name = 'g236-' + randomBytes(6).toString('hex');
t.after(() => docker('rm', '-f', name));
const started = docker('run', '-d', '--rm', '--name', name, '-p', `127.0.0.1:${port}:3000`, '-v', `${tlsDir}:/certs:ro`,
'-e', 'GITEA__security__INSTALL_LOCK=true',
'-e', 'GITEA__database__DB_TYPE=sqlite3',
'-e', 'GITEA__service__DISABLE_REGISTRATION=true',
'-e', 'GITEA__server__PROTOCOL=https',
'-e', 'GITEA__server__CERT_FILE=/certs/server.pem',
'-e', 'GITEA__server__KEY_FILE=/certs/server.key',
'-e', `GITEA__server__ROOT_URL=${url}/`,
IMAGE);
assert.equal(started.status, 0, started.stderr);
const end = Date.now() + 120000;
while ((await call(url, ca, 'GET', '/api/v1/version')).status !== 200) {
if (Date.now() > end) assert.fail('the scratch Gitea did not answer in 120 s: ' + docker('logs', '--tail', '20', name).stdout);
await sleep(500);
}
const gitea = (...args) => docker('exec', '-u', 'git', name, 'gitea', ...args);
const password = () => randomBytes(18).toString('base64url');
for (const [user, admin] of [['mosaic-grant', true], ['acme', false]]) {
const r = gitea('admin', 'user', 'create', '--username', user, '--email', `${user}@example.test`, '--password', password(), '--must-change-password=false', ...(admin ? ['--admin'] : []));
assert.equal(r.status, 0, r.stderr);
}
const minted = gitea('admin', 'user', 'generate-access-token', '--username', 'mosaic-grant', '--token-name', 'mosaic-grant-bootstrap',
'--scopes', 'write:admin,write:repository,write:user', '--raw');
assert.equal(minted.status, 0, 'generate-access-token failed');
const adminToken = minted.stdout.trim();
assert.match(adminToken, /^[0-9a-f]{40}$/, 'the admin token has the shape of a Gitea token');
const basic = 'Basic ' + Buffer.from(`mosaic-grant:${adminToken}`).toString('base64');
for (const repo of ['app', 'lib']) {
const r = await call(url, ca, 'POST', '/api/v1/admin/users/acme/repos', { auth: 'token ' + adminToken, body: { name: repo, auto_init: true } });
assert.equal(r.status, 201);
}
const configDir = join(dir, 'cfg');
mkdirSync(configDir, { mode: 0o700 });
mkdirSync(join(dir, 'data'), { mode: 0o700 });
const storeRoot = join(configDir, 'secrets', 'agents');
const store = new Store(join(dir, 'data'));
const credentials = new Credentials({ references: {}, storeRoot, dataRoot: join(dir, 'data') });
t.after(() => {
credentials.close();
store.close();
});
const business = {
id: 'demo',
human: 'jason',
arbiters: { technical: 'pm', delivery: 'pm' },
roles: {
chief: { definition: 'chief-of-staff', ...none },
pm: { definition: 'pm', ...none },
coder: { definition: 'coder', ...none, identity: { name: 'Coder', email: '[email protected]', gitea: { url, user: USER } }, publish: ['app', 'lib'] },
},
definitions: {
'chief-of-staff': { roleVersion: 2, credentials: [] },
pm: { roleVersion: 2, credentials: [] },
coder: { roleVersion: 2, credentials: [{ service: 'gitea' }] },
},
projects: { app: { remote: `${url}/acme/app` }, lib: { remote: `${url}/acme/lib` } },
vars: {},
};
const broker = new Broker({
store,
businesses: { demo: business },
secretCheck: (value) => credentials.assertClean(value),
giteaGrant: giteaGrant({ configDir, ca, timeoutMs: 20000 }),
giteaKeys: giteaKeys({ credentials, storeRoot }),
});
const chief = broker.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi', address: 'chief' });
broker.request(chief, { verb: 'role.claim' });
const human = broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
// C1 to C4 on the real server, then the slot is written.
await broker.request(human, { verb: 'grants.admin.set', args: { service: 'gitea', url, account: 'mosaic-grant', rotateBy: dayFrom(60), token: adminToken + '\n' } });
const d = await broker.request(chief, {
verb: 'decision.raise',
args: {
action: 'credential.mint', question: 'Grant coder Gitea?', options: [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }], recommendation: 'yes', blocking: false,
grant: { method: 'gitea', agent: 'coder', target: { projects: ['app'] }, rotateBy: dayFrom(30) },
},
});
broker.request(human, { verb: 'decision.resolve', args: { id: d.id, choice: 'yes' } });
const events = () => store.all(`SELECT body FROM events WHERE business='demo' AND ${GRANT_EVIDENCE} ORDER BY seq`).map((e) => JSON.parse(e.body));
const ended = () => events().find((e) => e.grantDecision === d.id && ['grant.applied', 'grant.failed'].includes(e.action));
const until = Date.now() + 120000;
while (!ended() && Date.now() < until) await sleep(100);
await broker.giteaIdle();
const applied = ended();
assert.equal(applied?.action, 'grant.applied', JSON.stringify(applied));
// The user, made with the nonce, and its id recorded.
const nonce = events().find((e) => e.grantDecision === d.id && e.step === 'user').nonce;
const u = await call(url, ca, 'GET', `/api/v1/users/${USER}`, { auth: 'token ' + adminToken });
assert.equal(u.status, 200);
assert.equal(u.json.full_name, `Coder [mosaic ${nonce}]`);
assert.equal(u.json.is_admin, false);
const row = store.get("SELECT id, nonce FROM gitea_users WHERE business='demo' AND agent='coder'");
assert.deepEqual({ ...row }, { id: u.json.id, nonce });
// One token, with the two scopes, at the id the record names.
const list = await call(url, ca, 'GET', `/api/v1/users/${USER}/tokens`, { auth: basic });
assert.equal(list.status, 200);
assert.equal(list.json.length, 1);
assert.deepEqual([...list.json[0].scopes].sort(), [...SEAT_SCOPES]);
const grant = store.get("SELECT token_id FROM gitea_grants WHERE business='demo' AND agent='coder'");
assert.equal(list.json[0].id, grant.token_id);
// The fork, with its parent.
const fork = await call(url, ca, 'GET', `/api/v1/repos/${USER}/app`, { auth: 'token ' + adminToken });
assert.equal(fork.status, 200);
assert.equal(fork.json.parent?.full_name, 'acme/app');
// The 0600 file, whose token Gitea knows as the recorded id; the value is compared, never printed.
const file = join(storeRoot, 'demo', 'coder', 'gitea');
assert.equal(statSync(file).mode & 0o777, 0o600);
const who = await call(url, ca, 'GET', '/api/v1/token', { auth: 'token ' + readFileSync(file, 'utf8').trim() });
assert.equal(who.status, 200);
assert.equal(who.json.id, grant.token_id);
// Revoke deletes the token at Gitea: gone from the list, the file and the record gone.
const out = await broker.request(human, { verb: 'grants.revoke', args: { service: 'gitea', agent: 'coder', reason: 'X-G3' } });
assert.deepEqual(out.serviceLeft, []);
await broker.giteaIdle();
const after = await call(url, ca, 'GET', `/api/v1/users/${USER}/tokens`, { auth: basic });
assert.equal(after.status, 200);
assert.deepEqual(after.json, []);
assert.equal(existsSync(file), false);
assert.equal(store.get("SELECT count(*) AS n FROM gitea_grants WHERE business='demo'").n, 0);
// No scratch token reached the store.
const text = store.all("SELECT body FROM events WHERE business='demo'").map((r) => r.body).join('\n');
assert.ok(!text.includes(adminToken), 'the admin token reached the store');
});
File diff suppressed because it is too large. Load diff
-159
View File
@@ -1,159 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { chmodSync, copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';
import { createHash } from 'node:crypto';
import { DatabaseSync } from 'node:sqlite';
import { Store, schemaDigest } from '../src/store.mjs';
// Row 236 (#1742, #436b r5 section 7): the 3j→3k step adds the gitea_users and gitea_grants tables and
// nothing else. It runs on a 3j store built here from the pinned 3j schema (02da6a2ae's schema.sql) and
// fixture rows. No live store is read or copied.
const AT = '2026-10-21T00:00:00.000Z';
const ddl3j = readFileSync(new URL('./fixtures/schema-3j.sql', import.meta.url), 'utf8');
const here = (p) => new URL(p, import.meta.url);
const NEW = ['gitea_users', 'gitea_grants', 'sqlite_autoindex_gitea_users_1', 'sqlite_autoindex_gitea_grants_1'];
function source3j(t) {
const root = mkdtempSync(join(tmpdir(), 'gitea-migrate-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
mkdirSync(join(root, 'bus'), { mode: 0o700 });
const path = join(root, 'bus/bus.sqlite'), db = new DatabaseSync(path);
assert.equal(createHash('sha256').update(ddl3j).digest('hex'), '0a7b633ce7eec9d0bb37c3255f4244d97bd0c8eac38db91a11b4ef6cc982ec8c');
db.exec(ddl3j);
const run = (sql, ...args) => db.prepare(sql).run(...args);
run('INSERT INTO meta VALUES (?,?)', 'schema_version', '3j');
run('INSERT INTO meta VALUES (?,?)', 'schema_digest', schemaDigest(db));
run('INSERT INTO meta VALUES (?,?)', 'comment_cursor_epoch', AT);
run(`INSERT INTO decisions(id,at,business,raised_by_role,raised_by_run,class,action,route_to,question,options,recommendation,blocking)
VALUES('d1',?,'demo','chief','run1','gated','credential.mint','human','q','["yes","no"]','yes',0)`, AT);
run('INSERT INTO events(id,at,business,kind,actor_role,actor_run,body) VALUES(?,?,?,?,?,?,?)', 'e1', AT, 'demo', 'action.allowed', null, 'grant', '{"operation":"grant.host-enroll","decision":"d1"}');
run('INSERT INTO events(id,at,business,kind,body) VALUES(?,?,?,?,?)', 'e2', AT, 'demo', 'action.allowed', '{}');
db.close(); chmodSync(path, 0o600);
return { root, path };
}
function rows(path) {
const db = new DatabaseSync(path, { readOnly: true });
try {
return Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('gitea_users','gitea_grants')").all()
.map(({ name }) => [name, db.prepare(`SELECT * FROM ${name} ORDER BY rowid`).all()
.filter(r => name !== 'meta' || !['schema_version', 'schema_digest'].includes(r.key)).map(r => ({ ...r }))]));
} finally { db.close(); }
}
function master(path) {
const db = new DatabaseSync(path, { readOnly: true });
try {
return db.prepare('SELECT type,name,tbl_name,sql FROM sqlite_master ORDER BY type,name').all().map(r => ({ ...r }));
} finally { db.close(); }
}
const version = (path) => {
const db = new DatabaseSync(path, { readOnly: true });
try { return db.prepare("SELECT value FROM meta WHERE key='schema_version'").get().value; } finally { db.close(); }
};
// A copy of the bus module whose store.mjs, schema.sql and 3j→3k step the caller edits.
async function engine(root, name, { store, schema, step = (s) => s }) {
const moduleRoot = join(root, name);
mkdirSync(join(moduleRoot, 'src/migrations'), { recursive: true });
const source = readFileSync(here('../src/store.mjs'), 'utf8'), edited = store ? store(source) : source;
if (store) assert.notEqual(edited, source);
writeFileSync(join(moduleRoot, 'src/store.mjs'), edited);
writeFileSync(join(moduleRoot, 'schema.sql'), schema ?? readFileSync(here('../schema.sql'), 'utf8'));
for (const file of ['3b.sql', '3b-3c.sql', '3c-3d.sql', '3d-3e.sql', '3e-3f.sql', '3f-3g.sql', '3g-3h.sql', '3h-3i.sql', '3i-3j.sql'])
copyFileSync(here('../src/migrations/' + file), join(moduleRoot, 'src/migrations', file));
writeFileSync(join(moduleRoot, 'src/migrations/3j-3k.sql'), step(readFileSync(here('../src/migrations/3j-3k.sql'), 'utf8')));
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
}
function fresh(t) {
const root = mkdtempSync(join(tmpdir(), 'gitea-store-'));
const store = new Store(root);
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
store.run(`INSERT INTO decisions(id,at,business,raised_by_role,raised_by_run,class,action,route_to,question,options,recommendation,blocking)
VALUES('d1',?,'demo','chief','run1','gated','credential.mint','human','q','["yes","no"]','yes',0)`, AT);
return store;
}
const user = (store, values = {}) => store.run(
'INSERT INTO gitea_users(business,agent,user,id,nonce,decision) VALUES(:business,:agent,:user,:id,:nonce,:decision)',
{ business: 'demo', agent: 'coder', user: 'demo-coder', id: 7, nonce: '0123456789abcdef', decision: 'd1', ...values });
const grant = (store, values = {}) => store.run(
'INSERT INTO gitea_grants(business,agent,user,token_id,token_name,projects,rotate_by,decision) VALUES(:business,:agent,:user,:token,:name,:projects,:rotate,:decision)',
{ business: 'demo', agent: 'coder', user: 'demo-coder', token: 9, name: 'mosaic-demo-coder-gitea-d1', projects: '["app"]', rotate: '2026-12-01', decision: 'd1', ...values });
test('3j copy migrates to 3k: every row kept, and sqlite_master gains only the two tables and their keys', t => {
const { root, path } = source3j(t), before = rows(path), was = master(path);
assert.throws(() => new Store(root), /schema-mismatch/);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3k');
assert.deepEqual(rows(path), before);
const now = master(path);
assert.deepEqual(now.filter(r => !NEW.includes(r.name)), was);
assert.deepEqual(now.filter(r => NEW.includes(r.name)).map(r => r.name).sort(), [...NEW].sort());
const store = new Store(root);
try {
assert.equal(store.get('PRAGMA quick_check').quick_check, 'ok');
assert.deepEqual(store.all('PRAGMA foreign_key_check'), []);
assert.equal(store.get('SELECT count(*) AS n FROM gitea_users').n, 0);
assert.equal(store.get('SELECT count(*) AS n FROM gitea_grants').n, 0);
user(store);
grant(store);
} finally { store.close(); }
});
test('a 3j→3k step that fails partway leaves the store at 3j with every row and the schema intact', async t => {
const { root, path } = source3j(t), before = rows(path), was = master(path);
// The real step plus a statement that fails only when events has a row, so the empty replay at
// import passes and the store's run fails after both tables exist.
const Failing = await engine(root, 'engine-failing', { step: (sql) => sql + 'INSERT INTO events SELECT * FROM events LIMIT 1;\n' });
assert.throws(() => new Failing(root, { migrateSchema: true }), /events is append-only/);
assert.equal(version(path), '3j');
assert.deepEqual(master(path), was);
assert.deepEqual(rows(path), before);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3k');
});
test('the 3j engine refuses a 3k store, migrating or not, and leaves it at 3k', async t => {
const { root, path } = source3j(t);
new Store(root, { migrateSchema: true }).close();
const after = rows(path), was = master(path);
const Store3j = await engine(root, 'engine-3j', {
schema: ddl3j,
store: (source) => source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3j).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3k';", "export const SCHEMA_VERSION = '3j';")
.replace(/\}, \{\n {2}\/\/ Row 236 \(#1742[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'),
});
const pinned = readFileSync(join(root, 'engine-3j/src/store.mjs'), 'utf8');
assert.ok(pinned.includes("export const SCHEMA_VERSION = '3j';") && !pinned.includes('3j-3k'), 'the 3j engine has no 3j→3k step');
assert.throws(() => new Store3j(root), /schema-mismatch/);
assert.throws(() => new Store3j(root, { migrateSchema: true }), /schema-mismatch/);
assert.equal(version(path), '3k');
assert.deepEqual(master(path), was);
assert.deepEqual(rows(path), after);
});
test('gitea_users: one user per business and agent, an id above 0, a 16-hex nonce and a real decision', t => {
const store = fresh(t);
user(store);
assert.throws(() => user(store, { user: 'other' }), /UNIQUE constraint failed: gitea_users\.business, gitea_users\.agent/);
user(store, { business: 'other' });
for (const nonce of ['0123456789abcde', '0123456789abcdef0', '0123456789ABCDEF', '0123456789abcdeg'])
assert.throws(() => user(store, { agent: 'x', nonce }), /CHECK constraint failed/, nonce);
assert.throws(() => user(store, { agent: 'x', id: 0 }), /CHECK constraint failed/);
assert.throws(() => user(store, { agent: 'x', id: 'seven' }), /cannot store TEXT value in INTEGER column/);
assert.throws(() => user(store, { agent: 'x', decision: 'nope' }), /FOREIGN KEY constraint failed/);
});
test('gitea_grants: one grant per agent, a projects array, a token id above 0, and only for a recorded user', t => {
const store = fresh(t);
assert.throws(() => grant(store), /FOREIGN KEY constraint failed/);
user(store);
for (const projects of ['[]', '{}', '"app"', 'not json'])
assert.throws(() => grant(store, { projects }), /CHECK constraint failed/, projects);
assert.throws(() => grant(store, { token: 0 }), /CHECK constraint failed/);
assert.throws(() => grant(store, { decision: 'nope' }), /FOREIGN KEY constraint failed/);
grant(store);
assert.throws(() => grant(store, { token: 10 }), /UNIQUE constraint failed/);
assert.throws(() => store.run("DELETE FROM gitea_users WHERE business='demo' AND agent='coder'"), /FOREIGN KEY constraint failed/);
store.run("DELETE FROM gitea_grants WHERE business='demo' AND agent='coder'");
store.run("DELETE FROM gitea_users WHERE business='demo' AND agent='coder'");
});
+1 -11
View File
@@ -86,8 +86,7 @@ test('G1, R4: the grant takes exactly method, agent, host, commands and rotateBy
for (const bad of [
noRotate,
{ ...g, extra: 1 },
// Row 236 (#1742) makes gitea a known method with its own path, so the unknown method is another name.
{ ...g, method: 'ftp' },
{ ...g, method: 'gitea' },
{ ...g, method: 'Host' },
{ ...g, agent: 'Ops' },
{ ...g, agent: '' },
@@ -102,15 +101,6 @@ test('G1, R4: the grant takes exactly method, agent, host, commands and rotateBy
assert.ok(rotateBy);
});
test('row 236 (#1742): a gitea grant on a broker with no Gitea path refuses gitea-not-configured, and nothing is stored', async (t) => {
const f = setup(t);
const before = f.decisions();
const refused = f.refusals().length;
await refusesWith(f.raise({ method: 'gitea' }), 'gitea-not-configured');
assert.equal(f.decisions(), before);
assert.deepEqual(f.refusals().slice(refused), ['gitea-not-configured']);
});
test('G2: an agent whose definition lacks host refuses host-not-declared, and an unknown agent unknown-role', async (t) => {
const f = setup(t);
const before = f.decisions();
+6 -9
View File
@@ -16,8 +16,6 @@ const TAG = 'mosaic-' + 'a'.repeat(32);
const ddl3i = readFileSync(new URL('./fixtures/schema-3i.sql', import.meta.url), 'utf8');
const here = (p) => new URL(p, import.meta.url);
const NEW = ['hosts', 'host_grants', 'events_grant_actor', 'sqlite_autoindex_hosts_1', 'sqlite_autoindex_host_grants_1'];
// Row 236 (#1742): 3j→3k's objects, after this step. Their test is gitea-migration.test.mjs.
const LATER = ['gitea_users', 'gitea_grants', 'sqlite_autoindex_gitea_users_1', 'sqlite_autoindex_gitea_grants_1'];
function source3i(t) {
const root = mkdtempSync(join(tmpdir(), 'grant-migrate-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
@@ -37,7 +35,7 @@ function source3i(t) {
function rows(path) {
const db = new DatabaseSync(path, { readOnly: true });
try {
return Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('hosts','host_grants','gitea_users','gitea_grants')").all()
return Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('hosts','host_grants')").all()
.map(({ name }) => [name, db.prepare(`SELECT * FROM ${name} ORDER BY rowid`).all()
.filter(r => name !== 'meta' || !['schema_version', 'schema_digest'].includes(r.key)).map(r => ({ ...r }))]));
} finally { db.close(); }
@@ -63,7 +61,6 @@ async function engine(root, name, { store, schema, step = (s) => s }) {
for (const file of ['3b.sql', '3b-3c.sql', '3c-3d.sql', '3d-3e.sql', '3e-3f.sql', '3f-3g.sql', '3g-3h.sql', '3h-3i.sql'])
copyFileSync(here('../src/migrations/' + file), join(moduleRoot, 'src/migrations', file));
writeFileSync(join(moduleRoot, 'src/migrations/3i-3j.sql'), step(readFileSync(here('../src/migrations/3i-3j.sql'), 'utf8')));
copyFileSync(here('../src/migrations/3j-3k.sql'), join(moduleRoot, 'src/migrations/3j-3k.sql'));
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
}
function fresh(t) {
@@ -87,9 +84,9 @@ test('3i copy migrates to 3j: every row kept, and sqlite_master gains only the t
const { root, path } = source3i(t), before = rows(path), was = master(path);
assert.throws(() => new Store(root), /schema-mismatch/);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3k');
assert.equal(version(path), '3j');
assert.deepEqual(rows(path), before);
const now = master(path).filter(r => !LATER.includes(r.name));
const now = master(path);
assert.deepEqual(now.filter(r => !NEW.includes(r.name)), was);
assert.deepEqual(now.filter(r => NEW.includes(r.name)).map(r => r.name).sort(), [...NEW].sort());
const store = new Store(root);
@@ -112,7 +109,7 @@ test('a 3i→3j step that fails partway leaves the store at 3i with every row an
assert.deepEqual(master(path), was);
assert.deepEqual(rows(path), before);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3k');
assert.equal(version(path), '3j');
});
test('the 3i engine refuses a 3j store, migrating or not, and leaves it at 3j', async t => {
@@ -123,14 +120,14 @@ test('the 3i engine refuses a 3j store, migrating or not, and leaves it at 3j',
schema: ddl3i,
store: (source) => source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3i).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3k';", "export const SCHEMA_VERSION = '3i';")
.replace("export const SCHEMA_VERSION = '3j';", "export const SCHEMA_VERSION = '3i';")
.replace(/\}, \{\n {2}\/\/ Row 234 \(#1740\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'),
});
const pinned = readFileSync(join(root, 'engine-3i/src/store.mjs'), 'utf8');
assert.ok(pinned.includes("export const SCHEMA_VERSION = '3i';") && !pinned.includes('3i-3j'), 'the 3i engine has no 3i→3j step');
assert.throws(() => new Store3i(root), /schema-mismatch/);
assert.throws(() => new Store3i(root, { migrateSchema: true }), /schema-mismatch/);
assert.equal(version(path), '3k');
assert.equal(version(path), '3j');
assert.deepEqual(master(path), was);
assert.deepEqual(rows(path), after);
});
+1 -1
View File
@@ -55,7 +55,7 @@ test('3f copy migrates to 3g preserving every old column/row, with an empty inst
assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true });
try {
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3k');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3j');
assert.deepEqual({ ...store.get('SELECT to_name FROM messages') }, { to_name: null });
assert.equal(store.get('SELECT count(*) AS n FROM instance_names').n, 0);
assert.deepEqual({ ...store.get('SELECT spec_path,spec_commit FROM decisions') }, { spec_path: 'docs/spec.md', spec_commit: 'c'.repeat(40) });
+1 -2
View File
@@ -30,8 +30,7 @@ test('Broker keeps the sandbox client import closure: only the listed imports, n
// Row 234 (#1740) reads HOST_ID for the hosts verbs.
// Row 213 (#1714) reads CREATIVE_DEFINITIONS for the creative raise refusal (M6). Row 194
// (#1692, D2.4) reads unhireableAction, the hire veto's action name.
// Row 236 (#1742, #436b) reads giteaName and parseGiteaUrl for the Gitea grant path.
"import { ID_PATTERN, THREAD_DEFINITION, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl } from '../../business/src/vocabulary.mjs';",
"import { ID_PATTERN, THREAD_DEFINITION, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction } from '../../business/src/vocabulary.mjs';",
]);
assert.doesNotMatch(source, /\bimport\s*\(/);
assert.doesNotMatch(readFileSync(new URL('../src/landing.mjs', import.meta.url), 'utf8'), /\bimport\b/);
+1 -2
View File
@@ -520,8 +520,7 @@ test('v3b prototype refusals, views and append-only mutations', (t) => {
// Row 141 (#1635) adds instance_names and its 4 triggers; names.test.mjs tests them.
// Row 124 adds usage_records and its three append-only triggers.
// Row 234 (#1740) adds hosts, host_grants and the events_grant_actor guard; grant-migration.test.mjs tests them.
// Row 236 (#1742) adds gitea_users and gitea_grants; gitea-migration.test.mjs tests them.
assert.equal(count('table') - 1, 20);
assert.equal(count('table') - 1, 18);
assert.equal(count('trigger'), 71);
assert.equal(count('view'), 5);
});
+1 -1
View File
@@ -53,7 +53,7 @@ test('3e copy migrates through 3f to 3g preserving every old column/row and addi
assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true });
try {
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3k');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3j');
assert.deepEqual({ ...store.get('SELECT spec_path,spec_commit FROM decisions') }, { spec_path: null, spec_commit: null });
assert.equal(store.get('PRAGMA quick_check').quick_check, 'ok');
assert.deepEqual(store.all('PRAGMA foreign_key_check'), []);
+3 -3
View File
@@ -49,7 +49,7 @@ async function database3d(t) {
const source = readFileSync(here('../src/store.mjs'), 'utf8');
const pinned = source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3d).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3k';", "export const SCHEMA_VERSION = '3d';")
.replace("export const SCHEMA_VERSION = '3j';", "export const SCHEMA_VERSION = '3d';")
.replace(/\}, \{\n {2}from: '3d',[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
assert.ok(!pinned.includes('3d-3e'), 'the 3d engine has no 3d→3e step');
writeFileSync(join(moduleRoot, 'src/store.mjs'), pinned);
@@ -101,7 +101,7 @@ test('REQ-THREAD-1 REQ-ORG-2: the 3d→3e migration adds the thread tables and l
assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true });
t.after(() => store.close());
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3k');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3j');
for (const table of ['projects', 'threads', 'thread_claims', 'message_threads', 'thread_opens'])
assert.equal(store.get(`SELECT count(*) n FROM ${table}`).n, 0, table);
// Parked: the undelivered ones are counted per business and role, and none is changed or given a thread.
@@ -161,5 +161,5 @@ test('REQ-THREAD-1: the offline command prints the kept-message counts before th
const command = new URL('../src/migrate-schema.mjs', import.meta.url).pathname;
const migrated = spawnSync(process.execPath, [command, root], { encoding: 'utf8' });
assert.equal(migrated.status, 0, migrated.stderr);
assert.equal(migrated.stdout, [...REPORT, 'bus schema 3k ready', ''].join('\n'));
assert.equal(migrated.stdout, [...REPORT, 'bus schema 3j ready', ''].join('\n'));
});
+1 -1
View File
@@ -56,7 +56,7 @@ test('REQ-LIMIT-4 3g copy migrates through 3h and 3i to 3j preserving every old
assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true });
try {
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3k');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3j');
assert.deepEqual({ ...store.get('SELECT to_name FROM messages') }, { to_name: null });
assert.equal(store.get('SELECT count(*) AS n FROM usage_records').n, 0);
assert.equal(store.get('SELECT name FROM instance_names').name, 'Builder');
+28 -16
View File
@@ -25,15 +25,25 @@ const revisionOk = value => typeof value === "string" && /^[a-f0-9]{64}$/.test(v
// Every limits.* key merges by intersection, so it can only narrow.
const HIRE_VAR = key => key === "model" || key.startsWith("limits.");
// Row 194 (#1692, V5): why an instance can't be deleted, checked in the design's order, or null.
// The last-listed check runs when the proposed file is validated (deletedDocument). A creative
// definition is END_PROTECTED too (row 213), so no one deletes one yet (sage-2164, DEFERRED). The
// listing's endProtected is this same check, so the Agents page offers no Delete it would refuse.
const endProtected = definition => END_PROTECTED.includes(definition);
export function deleteRefusal(document, rolesDir, instance) {
// Row 256 (#1761, REQ-COS-3): the END_PROTECTED part of a delete, for one actor. A creative
// instance is the human's to delete, and any other actor is refused, as row 213 refuses it a
// change. The assistant is refused for every actor, the human included. The listing's
// endProtected is this same check for its reader, so the Agents page offers no Delete it would refuse.
function protectedRefusal(document, instance, actor) {
const { definition } = document.roles[instance];
if (!END_PROTECTED.includes(definition)) return null;
if (!CREATIVE_DEFINITIONS.includes(definition)) return { code: "organization-definition-refused" };
if (actor === document.human) return null;
return { code: "organization-creative-human-only", message: `${instance} is a ${definition} instance, which only the human deletes` };
}
// Row 194 (#1692, V5): why an instance can't be deleted by actor, checked in the design's order, or
// null. The last-listed check runs when the proposed file is validated (deletedDocument).
export function deleteRefusal(document, rolesDir, instance, actor) {
if (!Object.hasOwn(document.roles, instance)) return { code: "organization-unknown-instance" };
const entry = document.roles[instance];
if (endProtected(entry.definition)) return { code: "organization-definition-refused" };
const refused = protectedRefusal(document, instance, actor);
if (refused) return refused;
const references = [];
if (document.launch?.by === instance) references.push("launch.by");
for (const kind of ["delivery", "technical"]) if (document.arbiters?.[kind] === instance) references.push(`arbiters.${kind}`);
@@ -157,7 +167,9 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null, r
return out;
}
function read(business) {
// Row 256: actor is the reader, so endProtected is the delete check for that reader. A read with
// no actor (the bus's reloads) answers as for any non-human.
function read(business, actor) {
const { document, revision } = writer.read(business);
validateBusinessDocument(document, businessFilePath(business, dir), { rolesDir });
const launch = document.launch ?? null;
@@ -168,7 +180,7 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null, r
launcher: launch ? launch.by === name : false,
protected: closed(entry.definition),
kept: (launch?.keep ?? []).includes(name),
endProtected: endProtected(entry.definition),
endProtected: protectedRefusal(document, name, actor) !== null,
}]));
// Row 194 (D1): the human's forbid lists and the retired names. The bus adds running runs.
return {
@@ -204,7 +216,7 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null, r
if (Object.keys(args).some(key => !["revision", "instance", "op"].includes(key))) throw new OrganizationError("invalid-request");
const services = Object.keys(old.roles[instance]?.credentials ?? {});
const beforeWrite = revoke ? () => revoke(business, instance, "delete", services) : null;
const result = commit(business, old, planDelete(old, instance), { revision: args.revision, actor, beforeWrite });
const result = commit(business, old, planDelete(old, instance, actor), { revision: args.revision, actor, beforeWrite });
// sage-1610 (b): the bot user and any token it holds live in Vikunja, which this write
// can't reach (row 199 A5).
const bot = old.roles[instance]?.tracker?.bot;
@@ -255,15 +267,15 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null, r
// Row 194 (V5): the delete's checks against the file as it stands, and its document. The broker
// runs this at the verb with no write, and the launcher's pass runs set op delete after the take.
function planDelete(document, instance) {
const refusal = deleteRefusal(document, rolesDir, instance);
function planDelete(document, instance, actor) {
const refusal = deleteRefusal(document, rolesDir, instance, actor);
if (refusal) throw new OrganizationError(refusal.code, refusal.message);
return deletedDocument(document, instance);
}
function checkDelete(business, instance) {
function checkDelete(business, instance, actor) {
const { document, revision } = writer.read(business);
const next = planDelete(document, instance);
const next = planDelete(document, instance, actor);
validateBusinessDocument(next, businessFilePath(business, dir), { rolesDir });
return { revision };
}
@@ -335,7 +347,7 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null, r
// its tokens revoked, with no write. A revoke is safe to repeat.
if (!old.launch.instances.includes(name)) {
const revoked = revoke ? revoke(business, name, "fire", services) : null;
return { ...read(business), transaction: null, delisted: false, ...(revoked ? { credentials: revoked } : {}) };
return { ...read(business, actor), transaction: null, delisted: false, ...(revoked ? { credentials: revoked } : {}) };
}
const next = structuredClone(old);
next.launch.instances = old.launch.instances.filter(entry => entry !== name);
@@ -402,7 +414,7 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null, r
if (writer.read(business).revision !== revision) throw new OrganizationError("revision-conflict", "organization revision changed; read again");
if (beforeWrite) beforeWrite();
const receipt = writer.write(business, next, { revision, actor, context });
return { ...read(business), transaction: receipt.transaction, restart: false };
return { ...read(business, actor), transaction: receipt.transaction, restart: false };
}
return { writer, read, set, checkDelete, checkFire, veto, hire, fire };
@@ -285,7 +285,8 @@ test("row 213 a creative instance is the human's: add, vars and definition chang
e => e.code === "organization-creative-human-only", `${actor} redefine`);
}
let done = f.settings.set("acme", { revision: view.revision, ...add }, "jason");
assert.deepEqual(done.instances.bean, { definition: "companion", vars: persona, launched: false, launcher: false, protected: false, kept: false, endProtected: true });
// Row 256: the listing a write returns is read for the writer, and the human may delete bean.
assert.deepEqual(done.instances.bean, { definition: "companion", vars: persona, launched: false, launcher: false, protected: false, kept: false, endProtected: false });
for (const actor of ["chief-of-staff.chief", "tester"]) {
for (const change of [{ vars: {} }, { launch: true }, { definition: "security-reviewer" }]) {
assert.throws(() => f.settings.set("acme", { revision: done.revision, instance: "bean", op: "change", ...change }, actor),
@@ -315,3 +316,65 @@ test("row 213 a creative instance is the human's: add, vars and definition chang
assert.equal(st.mode & 0o777, st.isDirectory() ? 0o700 : 0o600, name);
}
});
// Row 256 (#1761, REQ-COS-3): the human deletes a creative instance and any other actor is refused
// with the creative code. The assistant, the END_PROTECTED definition that isn't creative, refuses
// every actor, the human included. The listing's endProtected is that same check for its reader.
test("row 256 T1-T3, T5 the human deletes a creative instance, no other actor does, and the assistant stays", () => {
const f = fixture(doc => {
doc.roles.bean = { definition: "companion" };
doc.roles.muse = { definition: "ideas" };
doc.roles.assistant = { definition: "assistant", holder: "mos" };
});
const del = (instance, actor) => () => f.settings.set("acme", { revision: f.settings.read("acme").revision, instance, op: "delete" }, actor);
const refused = (act, code, label) => {
const bytes = readFileSync(f.file, "utf8");
assert.throws(act, e => e.code === code, label);
assert.equal(readFileSync(f.file, "utf8"), bytes, label);
};
// T5: the reader's view. A read with no actor answers as for any non-human.
for (const [actor, bean] of [["jason", false], ["tester", true], ["chief-of-staff.chief", true], [undefined, true]]) {
const view = f.settings.read("acme", actor);
assert.equal(view.instances.bean.endProtected, bean, `bean ${actor}`);
assert.equal(view.instances.assistant.endProtected, true, `assistant ${actor}`);
assert.equal(view.instances.coder.endProtected, false, `coder ${actor}`);
}
assert.equal(f.settings.read("acme").instances.bean.endProtected, true);
// T2: every actor but the human gets the creative code, and the message says why.
for (const actor of ["chief-of-staff.chief", "tester", "mos", undefined]) {
refused(del("bean", actor), "organization-creative-human-only", String(actor));
assert.throws(() => f.settings.checkDelete("acme", "bean", actor), e => e.code === "organization-creative-human-only", `check ${actor}`);
}
assert.throws(del("bean", "tester"), /bean is a companion instance, which only the human deletes/);
// T3: the assistant refuses every actor, the human included.
for (const actor of ["jason", "chief-of-staff.chief", "tester"]) {
refused(del("assistant", actor), "organization-definition-refused", `assistant ${actor}`);
}
// T1: the human's delete.
assert.deepEqual(f.settings.checkDelete("acme", "bean", "jason"), { revision: f.settings.read("acme").revision });
const done = del("bean", "jason")();
assert.equal(Object.hasOwn(done.instances, "bean"), false);
assert.deepEqual(done.retired, ["bean"]);
assert.equal(done.instances.muse.endProtected, false);
const file = JSON.parse(readFileSync(f.file, "utf8"));
assert.equal(Object.hasOwn(file.roles, "bean"), false);
assert.deepEqual(file.retired, ["bean"]);
assert.equal(f.log().at(-1).actor, "jason");
const run = f.validate();
assert.equal(run.status, 0, run.stderr);
});
// Row 256 (R1, webby-0122): a creative delete by the human goes on to the later checks. A creative
// instance can't be kept, launch.by or an arbiter, so the check it reaches is the last-listed one.
test("row 256 T4 the human's delete of the only listed instance, a creative one, is the last-listed refusal", () => {
const f = fixture(doc => {
doc.roles.bean = { definition: "companion" };
doc.launch.instances = ["bean"];
});
const bytes = readFileSync(f.file, "utf8");
const revision = f.settings.read("acme").revision;
assert.throws(() => f.settings.set("acme", { revision, instance: "bean", op: "delete" }, "jason"), e => e.name === "BusinessError");
assert.throws(() => f.settings.checkDelete("acme", "bean", "jason"), e => e.name === "BusinessError");
assert.throws(() => f.settings.set("acme", { revision, instance: "bean", op: "delete" }, "tester"), e => e.code === "organization-creative-human-only");
assert.equal(readFileSync(f.file, "utf8"), bytes);
});
@@ -229,3 +229,18 @@ test("D2.2 fire delists and keeps the entry, a repeat is done with no write, and
const one = fixture(doc => { doc.launch.instances = ["sec-1"]; });
refuses(one, () => fire(one, "sec-1"), "BusinessError");
});
// Row 256 (#1761): checkDelete takes the actor, and fire is unchanged: a creative instance refuses
// every actor's fire, the human's included.
test("row 256 checkDelete passes the human's creative delete and refuses another actor's; fire still refuses", () => {
const f = fixture(doc => {
doc.roles.bean = { definition: "companion" };
doc.launch.instances.push("bean");
});
const bytes = readFileSync(f.file, "utf8");
assert.deepEqual(f.settings.checkDelete("acme", "bean", "jason"), { revision: f.revision() });
for (const actor of ["chief", "tester", undefined]) refuses(f, () => f.settings.checkDelete("acme", "bean", actor), "organization-creative-human-only");
for (const actor of ["chief", "jason"]) refuses(f, () => f.settings.fire("acme", { name: "bean" }, { actor, context: CONTEXT }), "organization-definition-refused");
assert.throws(() => f.settings.checkFire("acme", "bean"), e => e.code === "organization-definition-refused");
assert.equal(readFileSync(f.file, "utf8"), bytes);
});
-11
View File
@@ -1061,17 +1061,6 @@ A status line shows `revoking:` with the tags of a revoke still in progress.
Enrollment itself is a `host-enroll` grant the chief raises and the human approves, not a CLI
command. A usage error exits 4 and a bus refusal 2.
## grants (row 236)
`mosaic grants status gitea [--json] [--tester]` prints the Gitea grant path's state: the admin
slot, each grant and open decision, settle-pending rotations, held agents, service left behind and
revokes in progress. `run DECISION` starts the executor of a Gitea grant the human approved.
`revoke AGENT gitea --reason R` takes the agent's Gitea token away and deletes it at Gitea. `sweep
gitea` retries what a failure left. `admin set gitea --url URL --account NAME --rotate-by DATE`
reads the admin token on stdin, never argv; `admin rotate gitea --rotate-by DATE`, `admin rotate
gitea --finish` and `admin remove gitea` keep the slot. run and admin are the human's alone. Nothing
prints a token. A usage error exits 4 and a bus refusal 2.
## Access (FED R5, #414)
```
-4
View File
@@ -19,7 +19,6 @@ import { limitsCommand, LIMITS_USAGE } from "./limits.mjs";
import { pluginsCommand, PLUGINS_USAGE } from "./plugins.mjs";
import { credentialsCommand, CREDENTIALS_USAGE } from "./credentials.mjs";
import { hostsCommand, HOSTS_USAGE } from "./hosts.mjs";
import { grantsCommand, GRANTS_USAGE } from "./grants.mjs";
import { instancesCommand, INSTANCES_USAGE } from "./instances.mjs";
import { accessCommand, accessContextFor } from "./access.mjs";
import { createChannelCaller } from "../../plugins/src/channel.mjs";
@@ -44,7 +43,6 @@ export const USAGE = `usage:
${PLUGINS_USAGE}
${CREDENTIALS_USAGE}
${HOSTS_USAGE}
${GRANTS_USAGE}
${INSTANCES_USAGE}
${PROJECT_USAGE}
${PROJECT_BUS_USAGE}
@@ -634,8 +632,6 @@ export async function main(argv, io = { env: process.env, stdin: process.stdin,
if (verb === "credentials") return credentialsCommand(rest, io, (p) => callFor(p, io, deps));
// Row 234 (#1740, r6 E4): the host records.
if (verb === "hosts") return hostsCommand(rest, io, (p) => callFor(p, io, deps));
// Row 236 (#1742, #436b r5): the Gitea grant path; admin set reads the admin token on stdin.
if (verb === "grants") return grantsCommand(rest, io, (p) => callFor(p, io, deps));
// Row 165 (#1662): scripts/mosaic sends only `business instances`, `business instance` and (row 194) `business veto` here.
if (verb === "business") return instancesCommand(rest, io, (p) => callFor(p, io, deps));
if (verb === "talk") return talk(rest, io, deps);
-105
View File
@@ -1,105 +0,0 @@
// `mosaic grants` (row 236, #1742, #436b r5): the broker's grants verbs for the Gitea grant path. run
// starts the executor of a decision the human said yes to, and admin set, rotate and remove keep the
// admin slot; those are the human's alone. status is the human's, the tester's or the chief's; revoke
// and sweep the human's or, with --tester, the tester's. admin set reads the admin token on stdin, never
// argv, and nothing here prints a token: the answers carry ids and names only.
import { CliError } from "./errors.mjs";
export const GRANTS_USAGE =
"mosaic grants status gitea [--json] [--tester] [--business ID] | run DECISION [--business ID] | " +
"revoke AGENT gitea --reason R [--tester] [--business ID] | sweep gitea [--tester] [--business ID] | " +
"admin set gitea --url URL --account NAME --rotate-by DATE [--business ID] | " +
"admin rotate gitea --rotate-by DATE | admin rotate gitea --finish | admin remove gitea";
const MAX_TOKEN = 16384;
const VALUES = ["--business", "--reason", "--url", "--account", "--rotate-by"];
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" || arg === "--finish") {
if (out.flags.has(arg)) throw new CliError(GRANTS_USAGE, 4);
out.flags.add(arg);
} else if (VALUES.includes(arg)) {
if (!args[i + 1] || Object.hasOwn(out.values, arg)) throw new CliError(GRANTS_USAGE, 4);
out.values[arg] = args[++i];
} else if (arg.startsWith("--")) throw new CliError(GRANTS_USAGE, 4);
else out.positional.push(arg);
}
return out;
}
// The whole of stdin, one token, as `mosaic credentials issue` reads it.
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;
}
// Each shape: the positional count after the verb, the flags and values it takes and needs.
const SHAPES = {
status: { n: 1, flags: ["--json", "--tester"], need: [], may: ["--business"] },
run: { n: 1, flags: [], need: [], may: ["--business"] },
revoke: { n: 2, flags: ["--tester"], need: ["--reason"], may: ["--business"] },
sweep: { n: 1, flags: ["--tester"], need: [], may: ["--business"] },
"admin set": { n: 1, flags: [], need: ["--url", "--account", "--rotate-by"], may: ["--business"] },
"admin rotate": { n: 1, flags: ["--finish"], need: [], may: ["--rotate-by", "--business"] },
"admin remove": { n: 1, flags: [], need: [], may: ["--business"] },
};
const lines = (s) => {
const out = [];
out.push(s.admin?.error ? `admin: ${s.admin.error}` : s.admin ? `admin: ${s.admin.account} on ${s.admin.url}, rotate by ${s.admin.rotateBy}${s.admin.rotationDue ? " (due)" : ""}` : "admin: none");
if (s.adminTokenLeft) out.push(`admin token left: ${s.adminTokenLeft}`);
for (const g of s.grants) out.push(["grant", g.agent, g.user, `token ${g.tokenId}`, g.projects.join(","), `rotate by ${g.rotateBy}`, g.decision].join("\t"));
for (const o of s.open) out.push(["open", o.decision, o.agent].join("\t"));
for (const o of s.running) out.push(["running", o.decision, o.agent, o.step ?? "spent"].join("\t"));
for (const o of s.settlePending) out.push(["settle-pending", o.decision, o.agent].join("\t"));
for (const o of s.tokenUnknown) out.push(["token-unknown", o.agent, o.decision].join("\t"));
for (const e of s.serviceLeft) out.push(["left", e.agent, e.user, String(e.id)].join("\t"));
for (const r of s.revoking) out.push(["revoking", r.agent, r.revoke].join("\t"));
return out.join("\n") + "\n";
};
export async function grantsCommand(args, io, callFor) {
const parsed = parse(args);
let [verb, ...rest] = parsed.positional;
if (verb === "admin") {
verb = `admin ${rest[0] ?? ""}`;
rest = rest.slice(1);
}
// Own keys only, so `constructor` is a usage error, not a verb.
const shape = Object.hasOwn(SHAPES, verb ?? "") ? SHAPES[verb] : null;
if (
!shape || rest.length !== shape.n ||
[...parsed.flags].some((f) => !shape.flags.includes(f)) ||
Object.keys(parsed.values).some((v) => !shape.need.includes(v) && !shape.may.includes(v)) ||
shape.need.some((v) => !Object.hasOwn(parsed.values, v)) ||
(verb !== "run" && rest.at(-1) !== "gitea") ||
(verb === "admin rotate" && parsed.flags.has("--finish") === Object.hasOwn(parsed.values, "--rotate-by"))
)
throw new CliError(GRANTS_USAGE, 4);
try {
const call = callFor(parsed);
const v = parsed.values;
let request;
if (verb === "status") request = ["grants.status", { service: "gitea" }];
else if (verb === "run") request = ["grants.run", { decision: rest[0] }];
else if (verb === "revoke") request = ["grants.revoke", { service: "gitea", agent: rest[0], reason: v["--reason"] }];
else if (verb === "sweep") request = ["grants.sweep", { service: "gitea" }];
else if (verb === "admin set")
request = ["grants.admin.set", { service: "gitea", url: v["--url"], account: v["--account"], rotateBy: v["--rotate-by"], token: await readToken(io.stdin) }];
else if (verb === "admin rotate")
request = ["grants.admin.rotate", parsed.flags.has("--finish") ? { service: "gitea", finish: true } : { service: "gitea", rotateBy: v["--rotate-by"] }];
else request = ["grants.admin.remove", { service: "gitea" }];
const result = await call(...request);
io.stdout.write(verb === "status" && !parsed.flags.has("--json") ? lines(result) : JSON.stringify(result, null, 2) + "\n");
} catch (error) {
if (error instanceof CliError) throw error;
throw new CliError(error.message, 2);
}
}
-138
View File
@@ -1,138 +0,0 @@
// Row 236 (#1742, #436b r5): `mosaic grants status|run|revoke|sweep|admin`. A fake transport records the
// calls; no bus runs here. The bus side of each verb is in packages/bus/tests/gitea-grant.test.mjs. The
// admin token is a fake made here, read once from stdin and never printed.
import test from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { randomBytes } from "node:crypto";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { main } from "../src/cli.mjs";
import { io, tmp } from "./helpers.mjs";
const MOSAIC = fileURLToPath(new URL("../../../scripts/mosaic", import.meta.url));
const STATUS = {
admin: { url: "https://git.example.test", account: "mosaic-grant", rotateBy: "2026-12-20", setAt: "2026-10-21T00:00:00.000Z", rotationDue: false },
adminTokenLeft: "mosaic-grant-bootstrap",
users: [{ agent: "coder", user: "demo-coder", id: 7 }],
grants: [{ agent: "coder", user: "demo-coder", tokenId: 9, projects: ["app", "lib"], rotateBy: "2026-11-20", decision: "d1" }],
open: [{ decision: "d2", agent: "coder", spentAtNextStart: true }],
running: [{ decision: "d3", agent: null, step: null }],
settlePending: [{ decision: "d4", agent: "coder" }],
tokenUnknown: [{ agent: "ops", decision: "d5" }],
serviceLeft: [{ agent: "coder", user: "demo-coder", id: 11 }],
revoking: [{ agent: "coder", revoke: "r1" }],
};
function deps(calls) {
const answer = (who) => (business) => async (verb, args) => {
calls.push({ who, business, verb, args });
if (verb === "grants.status") return STATUS;
if (verb === "grants.run") return { decision: args.decision, agent: "coder", started: true };
return { ok: true };
};
return { system: { dataRoot: "/nonexistent" }, transport: answer("human"), testerTransport: answer("tester") };
}
const withStdin = (x, text) => (x.stdin.end(text), x);
test("row 236: grants status prints one line per entry, and --json the answer", async () => {
const calls = [];
const x = io();
await main(["grants", "status", "gitea", "--business", "acme"], x, deps(calls));
assert.deepEqual(calls, [{ who: "human", business: "acme", verb: "grants.status", args: { service: "gitea" } }]);
assert.equal(x.out.text, [
"admin: mosaic-grant on https://git.example.test, rotate by 2026-12-20",
"admin token left: mosaic-grant-bootstrap",
"grant\tcoder\tdemo-coder\ttoken 9\tapp,lib\trotate by 2026-11-20\td1",
"open\td2\tcoder",
"running\td3\t\tspent",
"settle-pending\td4\tcoder",
"token-unknown\tops\td5",
"left\tcoder\tdemo-coder\t11",
"revoking\tcoder\tr1",
].join("\n") + "\n");
const y = io();
await main(["grants", "status", "gitea", "--json", "--tester", "--business", "acme"], y, deps(calls));
assert.deepEqual(JSON.parse(y.out.text), STATUS);
assert.equal(calls[1].who, "tester");
const z = io();
const due = { ...deps([]), transport: () => async () => ({ ...STATUS, admin: { ...STATUS.admin, rotationDue: true }, adminTokenLeft: null }) };
await main(["grants", "status", "gitea", "--business", "acme"], z, due);
assert.match(z.out.text, /^admin: mosaic-grant on https:\/\/git\.example\.test, rotate by 2026-12-20 \(due\)\ngrant\t/);
const none = { ...deps([]), transport: () => async () => ({ ...STATUS, admin: null, adminTokenLeft: null }) };
const w = io();
await main(["grants", "status", "gitea", "--business", "acme"], w, none);
assert.match(w.out.text, /^admin: none\n/);
});
test("row 236: run, revoke, sweep and admin rotate and remove send their args", async () => {
const calls = [];
const x = io();
await main(["grants", "run", "d1", "--business", "acme"], x, deps(calls));
await main(["grants", "revoke", "coder", "gitea", "--reason", "left the team", "--business", "acme"], x, deps(calls));
await main(["grants", "revoke", "coder", "gitea", "--reason", "r", "--tester", "--business", "acme"], x, deps(calls));
await main(["grants", "sweep", "gitea", "--tester", "--business", "acme"], x, deps(calls));
await main(["grants", "admin", "rotate", "gitea", "--rotate-by", "2027-01-20", "--business", "acme"], x, deps(calls));
await main(["grants", "admin", "rotate", "gitea", "--finish", "--business", "acme"], x, deps(calls));
await main(["grants", "admin", "remove", "gitea", "--business", "acme"], x, deps(calls));
assert.deepEqual(calls.map(({ who, verb, args }) => ({ who, verb, args })), [
{ who: "human", verb: "grants.run", args: { decision: "d1" } },
{ who: "human", verb: "grants.revoke", args: { service: "gitea", agent: "coder", reason: "left the team" } },
{ who: "tester", verb: "grants.revoke", args: { service: "gitea", agent: "coder", reason: "r" } },
{ who: "tester", verb: "grants.sweep", args: { service: "gitea" } },
{ who: "human", verb: "grants.admin.rotate", args: { service: "gitea", rotateBy: "2027-01-20" } },
{ who: "human", verb: "grants.admin.rotate", args: { service: "gitea", finish: true } },
{ who: "human", verb: "grants.admin.remove", args: { service: "gitea" } },
]);
assert.match(x.out.text, /^\{\n {2}"decision": "d1",\n {2}"agent": "coder",\n {2}"started": true\n\}\n/);
});
test("row 236: admin set sends the stdin token once to grants.admin.set and prints no value", async () => {
const calls = [];
const token = randomBytes(20).toString("hex");
const x = withStdin(io(), token + "\n");
await main(["grants", "admin", "set", "gitea", "--url", "https://git.example.test", "--account", "mosaic-grant", "--rotate-by", "2026-12-20", "--business", "acme"], x, deps(calls));
assert.equal(calls.length, 1);
assert.equal(calls[0].verb, "grants.admin.set");
const { token: sent, ...rest } = calls[0].args;
assert.ok(sent === token + "\n", "the stdin token went to the bus as read");
assert.deepEqual(rest, { service: "gitea", url: "https://git.example.test", account: "mosaic-grant", rotateBy: "2026-12-20" });
assert.ok(!x.out.text.includes(token) && !x.err.text.includes(token), "no token value printed");
// An empty stdin, and one too long, refuse before any call.
await assert.rejects(main(["grants", "admin", "set", "gitea", "--url", "u", "--account", "a", "--rotate-by", "2026-12-20", "--business", "acme"], withStdin(io(), "\n"), deps(calls)),
(e) => (assert.equal(e.exitCode, 4), assert.match(e.message, /no token on stdin/), true));
await assert.rejects(main(["grants", "admin", "set", "gitea", "--url", "u", "--account", "a", "--rotate-by", "2026-12-20", "--business", "acme"], withStdin(io(), "x".repeat(20000)), deps(calls)),
(e) => (assert.equal(e.exitCode, 2), assert.match(e.message, /too long/), true));
assert.equal(calls.length, 1);
});
test("row 236: a malformed grants command is a usage error and calls nothing", async () => {
const calls = [];
for (const argv of [
["grants"], ["grants", "status"], ["grants", "status", "host"], ["grants", "status", "gitea", "x"], ["grants", "run"], ["grants", "run", "d1", "d2"],
["grants", "run", "d1", "--tester"], ["grants", "revoke", "coder", "gitea"], ["grants", "revoke", "coder", "host", "--reason", "r"],
["grants", "revoke", "gitea", "--reason", "r"], ["grants", "sweep"], ["grants", "sweep", "host"], ["grants", "sweep", "gitea", "--reason", "r"],
["grants", "admin"], ["grants", "admin", "set", "gitea"], ["grants", "admin", "set", "gitea", "--url", "u", "--account", "a"],
["grants", "admin", "set", "gitea", "--url", "u", "--account", "a", "--rotate-by", "d", "--tester"],
["grants", "admin", "rotate", "gitea"], ["grants", "admin", "rotate", "gitea", "--finish", "--rotate-by", "2027-01-01"],
["grants", "admin", "rotate", "gitea", "--finish", "--finish"], ["grants", "admin", "remove", "gitea", "--tester"], ["grants", "admin", "purge", "gitea"],
["grants", "status", "gitea", "--business"], ["grants", "status", "gitea", "--force"], ["grants", "constructor", "gitea"],
["grants", "__proto__", "gitea"], ["grants", "admin", "constructor", "gitea"], ["grants", "status", "gitea", "--json", "--json"],
])
await assert.rejects(main([...argv], io(), deps(calls)), (e) => (assert.equal(e.exitCode, 4, argv.join(" ")), assert.match(e.message, /mosaic grants/), true));
assert.equal(calls.length, 0);
});
test("row 236: a bus refusal exits 2 with its code", async () => {
const refusing = { system: { dataRoot: "/nonexistent" }, transport: () => async () => { throw new Error("grant-admin-missing"); } };
await assert.rejects(main(["grants", "run", "d1", "--business", "acme"], io(), refusing), (e) => (assert.equal(e.exitCode, 2), assert.match(e.message, /grant-admin-missing/), true));
});
test("row 236: scripts/mosaic sends grants to packages/cli, not the seat CLI", (t) => {
const root = tmp(t, "mosaic-cli-grants-route-");
const env = { PATH: process.env.PATH, HOME: join(root, "home"), MOSAIC_CONFIG: join(root, "none.json") };
const r = spawnSync(MOSAIC, ["grants", "purge", "gitea"], { env, encoding: "utf8" });
assert.equal(r.status, 4);
assert.match(r.stderr, /^mosaic: mosaic grants status gitea/);
assert.doesNotMatch(r.stderr, /mosaic launch </);
});
@@ -918,8 +918,6 @@ test('T46 row 181 item 4: every table in the bus schema is in exactly one of fou
const hasAt=(n)=>db.prepare(`SELECT count(*) AS n FROM pragma_table_info(?) WHERE name='at'`).get(n).n===1;
const always={decisions:'decision.raise writes action.allowed in the same transaction'};
const untimed={
gitea_grants:'no time column; row 236: each write goes in one transaction with a grant.* events row, which is counted',
gitea_users:'no time column; row 236: each write goes in one transaction with a grant.* events row, which is counted',
host_grants:'no time column; row 234: each write goes in one transaction with a grant.* events row, which is counted',
hosts:'no time column; row 234: each write goes in one transaction with a grant.* events row, which is counted',
meta:'key and value only; its one UPDATE is the migration step',
+2 -1
View File
@@ -42,7 +42,8 @@
if (!c.isTester) {
acts.push(confirmPair('regenerate', name, 'Regenerate', `Regenerate ${name}? Its workspace is renamed under .retired and it starts fresh.`));
acts.push(e.kept ? button('unkeep', name, 'Unkeep') : button('keep', name, 'Keep'));
// endProtected is the server's delete check, creative definitions included (sage-2164).
// endProtected is the server's delete check for this reader (row 256): false on the human's
// page for a creative instance, true for the assistant and on the tester's.
if (!e.endProtected) acts.push(confirmPair('delete', name, 'Delete', `Delete ${name}? Its evidence and its workspace under .retired stay.`));
}
return `<tr><th scope="row">${txt(name)}</th><td>${txt(e.definition)}</td><td>${e.launched ? 'yes' : 'no'}</td><td>${e.kept ? 'yes' : 'no'}</td><td>${running.length ? running.map(r => `<code>${txt(r)}</code>`).join(' ') : '<span class="muted">none</span>'}${alive.map(r => ` <strong class="agent-alive">still running after fire: <code>${txt(r)}</code></strong>`).join('')}</td><td class="agent-actions">${acts.join(' ')}</td></tr>`;
+28 -2
View File
@@ -40,8 +40,8 @@ test('row 194 T16 Settings Agents: unavailable read-only, launch follows its req
assert.equal(await b.evaluate('document.querySelectorAll("#agents-section [data-agent-action]").length>0'),true);
// The protected assistant has no Delete; the kept instance offers Unkeep; reviewer is no-hire.
assert.equal(await b.evaluate('!!document.querySelector(\'[data-agent-action="delete"][data-agent-name="assistant"]\')'),false);
// sage-2164: a creative instance isn't protected (the human changes it), yet the server refuses its
// delete, so endProtected hides Delete for it too.
// A creative instance isn't protected (the human changes it). This listing marks bean endProtected,
// as a non-human reader's does, so the page hides its Delete; row 256's test below has the human's.
assert.equal(await b.evaluate('!!document.querySelector(\'[data-agent-action="delete"][data-agent-name="bean"]\')'),false);
assert.equal(await b.evaluate('!!document.querySelector(\'[data-agent-action="launch"][data-agent-name="bean"]\')'),true);
assert.equal(await b.evaluate(`document.querySelector('[data-agent-action="unkeep"]').dataset.agentName`),hostile);
@@ -123,3 +123,29 @@ test('row 194 T16 Settings Agents: unavailable read-only, launch follows its req
assert.equal(await b.evaluate('posts.length'),sent+1);
}finally{await b.close();await close(web);}
});
// Row 256 (#1761, T7, T8): the human's listing carries endProtected false for a creative instance
// (the server's delete check for that reader), so the human's page offers Delete for it. The tester's
// page offers no Delete even on that same listing; the tester's own listing marks bean endProtected
// as well (bus creative.test T6).
test('row 256 T7 T8 the human page offers Delete on a creative instance, the tester page never does',{timeout:90000},async()=>{
const human={...view,instances:{...view.instances,bean:{...view.instances.bean,endProtected:false}}};
const bus={messages:async()=>({messages:[],unread:0}),usage:async()=>{throw Error('no usage');},instances:async()=>human};
const web=await startServer({port:0,board:'http://127.0.0.1:9',bus,reads:{settings:()=>({business:'acme',release:'fixture',notifier:{}})}}),b=await browser();
try{
await b.call('Page.addScriptToEvaluateOnNewDocument',{source:`const q=new URL(location.href).searchParams;const original=window.fetch;window.fetch=async(url,options={})=>{if(url==='/api/config')return new Response(JSON.stringify(q.has('tester')?{tester:true}:{send:true}));return original(url,options);};`});
const port=web.address().port;
const wait=expr=>b.evaluate(`(async()=>{for(let n=0;n<400;n++){if(${expr})return true;await new Promise(r=>setTimeout(r,30));}throw Error('wait failed: '+${JSON.stringify(expr)});})()`);
const del=name=>`document.querySelector('[data-agent-action="delete"][data-agent-name="${name}"]')`;
// T7: the human's page.
await b.navigate(`http://127.0.0.1:${port}/?human=1#/settings`);
await wait(`document.querySelector('[data-agent-action="launch"][data-agent-name="bean"]')?.disabled===false`);
assert.equal(await b.evaluate(`${del('bean')}?.disabled`),false);
assert.equal(await b.evaluate(`!!${del('assistant')}`),false);
assert.equal(await b.evaluate(`!!${del('coder')}`),true);
// T8: the tester's page, on the same listing.
await b.navigate(`http://127.0.0.1:${port}/?tester=1#/settings`);
await wait(`!!document.querySelector('#tester-banner')&&!!document.querySelector('[data-agent-action="launch"][data-agent-name="bean"]')`);
assert.equal(await b.evaluate(`document.querySelectorAll('[data-agent-action="delete"]').length`),0);
}finally{await b.close();await close(web);}
});
+1 -1
View File
@@ -41,6 +41,6 @@ if [ "${1:-}" = library ] && [ "${2:-}" = survey ]; then
exec node "$REPO/packages/library/src/survey.mjs" "$@"
fi
case "${1:-}" in
halt|resume|processing|usage|project|limits|inbox|decide|tasks|agents|trail|talk|stop|launches|bus|plugins|credentials|hosts|grants|name|names|follow|threads|thread|access) exec node "$REPO/packages/cli/src/cli.mjs" "$@" ;;
halt|resume|processing|usage|project|limits|inbox|decide|tasks|agents|trail|talk|stop|launches|bus|plugins|credentials|hosts|name|names|follow|threads|thread|access) exec node "$REPO/packages/cli/src/cli.mjs" "$@" ;;
esac
exec node "$REPO/packages/seat/src/cli.mjs" "$@"