Compare commits

...
Author SHA1 Message Date
jason.woltje a33597c33f stack-release: the sso-config first step carries --instance NAME on a named instance (row 238, #1744) 2026-10-21 01:43:08 -05:00
jason.woltje c771a36f4b row 234: machine grant row a, host enrollment, store records and bus schema 3i to 3j (#1740, Vikunja #436)
Lands Gizmoduck's round 3 candidate, manifest 4782c8b5ecb7 (c783d5e39 on gizmoduck/row234-r3, one commit
on 57f59e0c3), approved by Duckworth: content on round 1 (861182fa6, 29919), the round 2 rebase check
(29928, rev 2670) and round 3 (REVIEW-r3.md 7c8c38b69, posted 29935, rev 2683). 41 paths.

Round 3 fixes the two failures in Sage's combined gate with row 233: bus schema.test.mjs now counts 18
tables and 71 triggers for the new hosts and host_grants tables, and stack-release.test.mjs T46 puts both
tables in the untimed restore-guard list, since every write shares a transaction with a grant.* event.
Round 3 rebuilt on the row 233 landing as one commit. 38 of the 39 round 2 blobs are unchanged and BUILD.md
is append-only. Gizmoduck's singles on round 3: stack-release 196/0, schema 1/0.

Evidence: Gizmoduck's slot runs, 25 of 25 mutants killed (A22b and A23 included, A9 structural), singles on
r2 387/0 over 47 files. Sage's landing gate on the c783d5e39 tree: 06:02:12Z to 06:15:26Z, every package except queue 2890 pass, 0 fail (15 packages; bus 539, cli 622), and all ten scripts/test-*.sh 585 pass, 0 fail.

This is code only. The live stack runs from a pinned copy at schema 3i. The 3i to 3j migration runs in the
10-24 window (mos-1145), agent-run under Q173, with a backup first. Duckworth's N10 (hosts.mjs
Object.hasOwn) and N12 (doc-only) go to row 235's carry list.
2026-10-21 01:15:43 -05:00
jason.woltje caa4b59440 sessions: Sage, row 233 landing 57f59e0c3, 234 gate stop and r3, 240 briefed, 197/238 drift (sage-1742..1758) 2026-10-21 00:47:45 -05:00
jason.woltje 57f59e0c39 row 233: installer I3, stack-upgrade.sh and the host run sheet (#1735, Vikunja #432)
Lands Darkwing's round 1 candidate, manifest 464ce9eb177a (1a1dc80ca on darkwing's branch), approved by
Filbert (29922, rev 2659). Seven paths: scripts/stack-upgrade.sh (new), scripts/stack-release.sh,
docs/guides/host-run-sheet.md (new), docs/TOOLS.md, packages/cli/tests/stack-upgrade.test.mjs (new),
packages/cli/tests/stack-release.test.mjs and agents/darkwing/work/queue-233/BUILD.md.

Evidence: Darkwing's gate on d99166ade (base e65a3ab4d) 1201/0, mutants m1-m8 8 of 8 killed. The N1 fix
08db6ac3f (flock sleep leak in stack-upgrade.test.mjs) reran c0 20/0 with m1 killed (sage-1708); BUILD.md
doesn't record that rerun. Since e65a3ab4d no commit touches the seven paths. Rows 241 and 242 changed bus
and cli files. Only cli tests read the seven paths, so Sage ran the cli suite on 5465fd52d plus the seven
paths: 617 pass, 0 fail (out233/pkg-cli.log, 05:26:33Z to 05:35:00Z). A combined gate with row 234 failed bus schema.test.mjs:522 on 234's new tables, and
Gizmoduck found a second 234 failure (stack-release.test.mjs T46), so Sage stopped it. Row 234 round 3 fixes
both and rebuilds on this landing, since its T46 lines go in a file this commit changes.

Filbert's N1, N4 and N5 go to row 240 (brief b00d7d94d). N2 and N3 are in DEFERRED.md.
2026-10-21 00:35:18 -05:00
jason.woltje 5465fd52d5 sage: DEFERRED, checkCreative doesn't refuse credentials (row 213 r3 Webby N1, mos-1160) 2026-10-21 00:12:52 -05:00
jason.woltje b00d7d94d0 sage: row 240 (I4) carries Filbert N1, N4 and N5 from the 233 review; N2 and N3 go to DEFERRED (29922, sage-1727) 2026-10-21 00:01:26 -05:00
jason.woltje 642a9b87de sessions: Filbert, row 233 round 1 approve (29922, rev 2659) 2026-10-20 23:58:01 -05:00
jason.woltje 16019b80e6 sessions: Darkwing, row 233 in review round 1 (1a1dc80ca, manifest 464ce9eb, revs 2644 and 2647), with its BUILD-LOG entry 2026-10-20 23:51:01 -05:00
jason.woltje 9b53620185 sage: 10-24 window draft 0 counts grant look-alike events before --migrate (row 234 A22b, gizmoduck-0107) 2026-10-20 23:29:34 -05:00
jason.woltje 7662b04902 sage: 10-24 window draft 0 adds rows 232 (e65a3ab4d), 241 (5d4a61776) and 242 (5fb736b92, fc1cb856b); 199 and 210 evidence list due in draft 1 2026-10-20 23:24:00 -05:00
jason.woltje 04ce8dfd56 queue: revs 2639-2641 (row 242 Duckworth approve record, landing note and done) 2026-10-20 23:22:41 -05:00
jason.woltje fc1cb856b0 publish: row 242 follow-up docs, the git socket comments name the hold and the late record; BUILD.md residual and :311 cite (Duckworth N1, N2, N4, #1748)
Megavolt's c5a125f4b on megavolt/row242-docs, parent 5fb736b92 (the row 242
landing). Comments and .md only: git-socket.mjs changes comment lines at
:39-44 and :433-435, BUILD.md records N2 and N4. No test pins either path's
hash. Landed without a gate under the doc-only rule.
2026-10-20 23:17:14 -05:00
jason.woltje c0c4907af9 sessions: Sage, rows 241 (5d4a61776) and 242 (5fb736b92) landed, queue revs 2628-2641 2026-10-20 23:14:49 -05:00
60 changed files with 4879 additions and 216 deletions
+1
View File
@@ -5175,3 +5175,4 @@ Before: managed project/thread verbs had no Console conversation area. After: fi
2026-10-20 05:10:34 UTC | Row 178 #1676 round 2 verified | Before: C1 render refusals lacked killing tests and row 181 base was pending. Rebased onto 931651f2d, kept all COUNTED/count code and tests, appended T178 tail; added independent owner/collision/absolute/charset/header and empty-loopback proof tests. Four files singly 156/156, all 24 mutants killed, metadata and sandbox fixture probes pass; scratch sweep empty. Packet agents/rocko/work/queue-178/r2/, candidate 9300a670, inventory d0dcde7b (59/59), scan 58 files no findings; in-review revs 2166-2167, expected no-token request failure. No live actions or commits.
2026-10-21T01:28:05Z | Darkwing | row 232 #1734 installer I1 build, round 1 in review | Before: stack-release.sh managed only user units (systemctl --user), and stack-copy.sh had no bundle copy. After: --units external (adopt, activate, rollback --to, status, confirm, revert, gate --since, AWAITING-UNITS, stale-start, external-pm, --plan --expect-from) and stack-copy check --units external, copy --bundle FILE SHA, wrapper ID --create; user mode byte-identical to the base by test. First build 0af0c73c6 on 00d4cc1f6; rebased onto row 200 (cb5fdb064) as a0ffabc3f on darkwing/row232-r1; --sso refused under external (D9), row 200 config_checks run under external (D10). Gate on Sage's slot (sage-1618): cli 561/0, every scripts/test-*.sh green; 17/17 mutants killed. Results commit 84f1a91e6 (pushed, seat branch only). Manifest 7a3b4ea1 (9 paths), queue revs 2595-2596, request comment 29825, reviewer Filbert. Packet agents/darkwing/work/queue-232/BUILD.md.
2026-10-21T04:50:44Z | Darkwing | row 233 #1735 installer I3 build, round 1 in review | Before: root had no upgrade driver for root-owned units and no host run sheet; stack-release.sh had no rehearse --backup last and no external plan refusals. After: scripts/stack-upgrade.sh upgrade and rollback (r6 A1-A5, A2f, R1-R4) with 20 tests, docs/guides/host-run-sheet.md, TOOLS.md lines, and on Sage's rulings stack-release.sh --backup last, BACKUP_STORE and the --plan refusals open-activation and migrate-required (3 tests). Base e65a3ab4d (row 232's landing). Gate on Sage's slot (sage-1704) on d99166ade: cli 616/0, every scripts/test-*.sh green, 1201/0 summed by awk; m1-m8 8/8 killed. Correction: the process sweep after the gate found an orphaned sleep 60 from the lock test's flock holder (N1); 08db6ac3f fixes it test only, Sage ruled the gate stands (sage-1707), rerun c0 20/0 and m1 killed with 0 left (sage-1708). Tip 1a1dc80ca (pushed, seat branch only). Manifest 464ce9eb (7 paths), queue revs 2644 and 2647, request comment 29901, reviewer Filbert. Packet agents/darkwing/work/queue-233/BUILD.md.
+249
View File
@@ -0,0 +1,249 @@
# Row 233 (I3): root's upgrade driver and the host run sheet
Brief: `docs/plans/2026-10-20_installer-rows.md` section I3, blob `e61291bcf1c6` (commit a0e3d7cfd), re-pinned
by Sage (sage-1685, rev 2631) from `f35e7e57daee` (ad6f0be7a). The later blob only appends I4; the I3 text
is the same. Gitea #1735, Vikunja P19
#432. Owner Darkwing, reviewer Filbert, gate owner Sage. Design input: row 195 DESIGN r6,
`agents/darkwing/work/queue-195/DESIGN.md` at b7239bb66, sections 2, 4 and 7. Where the brief and r6
differ, the brief wins. Sage's rulings during the build: sage-1650 and sage-1652 (`--backup last`, the
`--no-rehearsal` mapping, `--rehearse-port`, hostcheck), and sage-1653 (`from_log newest`, the two external
plan refusals, the `BACKUP_STORE` line).
## Files
Base: e65a3ab4d, row 232's landing, on `darkwing/row233-r1`. The build started on ea1e85c7b
(`darkwing/row233`, row 232's candidate e6ca230d2 and its BUILD.md) before row 232 landed. The three row 232
files this row changes are identical on the two bases, so the uncommitted diff moved across unchanged.
- `scripts/stack-upgrade.sh` (new): `upgrade` and `rollback`, root's side of r6 A1-A5, A2f and R1-R4.
- `packages/cli/tests/stack-upgrade.test.mjs` (new, 20 tests).
- `docs/guides/host-run-sheet.md` (new).
- `docs/TOOLS.md`: a new subsection "Root's upgrade driver (`scripts/stack-upgrade.sh`, row 233)", plus the
row 232 lines below.
- Row 232 files, changed on Sage's rulings (the brief's files list doesn't name them):
- `scripts/stack-release.sh`:
- `rehearse --backup last` takes the newest backup the log records (`from_log newest`) and prints
`PASS backup-last`. With none it fails `no-backup`, exits 3 and makes no rehearsal directory.
A newest backup that isn't an activation id aborts `backup-unreadable`, exit 1.
- The rehearsal prints `BACKUP_STORE <activation id> schema <version>` before its migration step.
- `--plan` under `--units external` refuses `open-activation` when any rollback set holds
`AWAITING-UNITS`, and `migrate-required` for a migrating target without `--migrate`. Root reads
only the plan's exit, so these two have to be refusals there. User mode prints the same bytes.
- `packages/cli/tests/stack-release.test.mjs`: three new `row 233:` tests for `--backup last`.
- `docs/TOOLS.md`: `--backup ACTIVATION|PATH|last` in the rehearse usage, a paragraph on `last`, and a
paragraph on the two external plan refusals.
- This file.
## The brief, item by item
| Brief item | Where |
|---|---|
| Root runs from a root-owned checkout, never a copy | `head-mismatch` (A1); TOOLS.md; run sheet section 6 |
| I2's render with `/etc/mosaic-stack/<instance>.args`, `--copy` the one changed value | `read_args`, `args_with`; the args test |
| The args file changes only after a confirmed run | `prepare_args` before A2, `mv` after A5; sequences 1 and 7, m6 |
| `copy --bundle` as the stack's user before A1 | the `fetch`, `ff`, `copy` steps; sequence 1's sudo keys |
| A rehearsal before A2, a failed one refuses with nothing stopped | `rehearse`; three rehearsal tests, m4 |
| The root lock, held to the end, rollback included | `flock -n 9`; the lock test and sequence 3's probe, m1, m2 |
| A1 refuses `open-activation` | stack-release.sh's external plan; the AWAITING-UNITS test, m3 |
| `migrate-required` without `--migrate` | stack-release.sh's external plan; its test, m5 |
| The step log, 0640, one line per step | `logl`, `step`; every test checks lines against `STEP` |
| Exit codes 0-3 | `finish`, `refuse`; exit 4 is usage |
| duckworth-0055's notes | the next section |
| Root parses no stack user output beyond exits | the step log test; the one exception is sage-1653's `BACKUP_STORE` |
| The host run sheet | `docs/guides/host-run-sheet.md` |
## duckworth-0055's notes on r6 (brief line 169)
- N1, A2's stop timeout under `gate --since`. Row 232's BUILD.md left two pieces to I3. The run sheet's
A2f line is in section 6 ("When a run doesn't exit 0", exit 2). The test is `N1: a bus that outlives
A2's timeout keeps its old start`: a bus that ignores `stop` keeps its old `ActiveEnterTimestamp`,
`wait-down` logs `rc=1 ... state: active`, A2f restarts and gates, the gate fails `stale-start`, and the
driver stops both units and exits 2. Sequence 4 is the other half: an A3 exit 3 goes to A2f and exits
1 with the old copy's gate passing.
- N2, the claude path under `/opt/mosaic-stack`. Run sheet section 3 installs it there as root with
managed settings. The args file's `--claude` carries it, and `case2-unit.sh` already refuses a binary
that isn't root's or sits under the home. The driver passes the args file through and adds no claude
check of its own. F6 is I2's.
## Sage's rulings during the build
- sage-1650 and sage-1652. Root always runs the rehearsal and maps its exit:
- 0 goes on, or refuses `rehearsal-required` with `--no-rehearsal`.
- 3 refuses `no-backup`, or with `--no-rehearsal` logs `rehearsal-skipped` and goes on.
- Anything else refuses `rehearsal-failed`.
`--rehearse-port N`, default 7339. Hostcheck: a missing file or a nonzero `--check` refuses
`profile-drift`.
- sage-1653. `from_log newest`, and the external plan refusals `open-activation` and `migrate-required`.
Residual: the `BACKUP_STORE` id and schema land in root's step log, but nothing ties that backup to the
one the activation's own backup step takes later. A stopped backup before the upgrade would close that
gap; it's a later row, not this one.
## Tests
`stack-upgrade.test.mjs` runs the driver as the test's user against a scratch root, with the test seams
`STACK_UPGRADE_*`. The stand-ins are a fake `sudo` (logs its argv, checks the driver's form, then runs the
real command as the test's user, r6's B1 pattern), a fake `case2-unit.sh` render that installs and starts
the fixture units, a fake `systemctl` for root's system units, and a shell `hostcheck.sh`. The stack's
user's steps run the real `stack-release.sh` and `stack-copy.sh` from a clone made from a real bundle. The
units, copies and store are `stack-release-fixture.mjs`'s.
| Test | Covers |
|---|---|
| sequence 1 | confirm fails healthz, rollback with `--cause A5`, exit 1; every sudo call in the driver's form |
| sequence 2 | `activate --migrate` fails at the wrapper, store restored, `--cause A3`, exit 1 |
| sequence 3 | render fails, `--cause A4`; a probe run during `render-old` refuses `upgrade-running` |
| sequence 4 | stage exits 3, A2f restarts and gates the old units, exit 1 |
| N1 | a unit that won't stop: A2f's gate fails `stale-start`, exit 2, units stopped |
| sequence 5 | confirm of the wrong copy refused, `--cause A5`, exit 1 |
| sequence 6 | revert fails, exit 2; the next run refuses `interrupted-run` at its plan |
| sequence 7 | two upgrades and a `rollback --to` with `--restore`; the args file after each confirm; `to-not-previous` |
| sequence 8 | a migration that writes and fails, store back byte for byte, exit 1 |
| lock | a second run while the lock is held: one log line, no call |
| open-activation | any `AWAITING-UNITS` refuses at A1, nothing stopped |
| migrate-required | refused without `--migrate`, no rehearsal, nothing stopped |
| rehearsal-failed | the rehearsal's argv, exit 3, nothing stopped |
| `BACKUP_STORE` | the line after a match; four malformed lines log `BACKUP_STORE none` |
| no-backup | the real rehearsal with no backup refuses; `--no-rehearsal` logs the skip and goes on |
| backup-last | the real rehearsal names the newest backup; `--no-rehearsal` with one refuses `rehearsal-required` |
| args file | group write, other write, two `--copy`, an unknown flag, a symlink, `args-differ` |
| hostcheck | nonzero `--check` and a missing file refuse `profile-drift` before the plan |
| head-mismatch | checkout not at `--sha`; a commit the clone can't fast-forward to refuses `fetch-failed` |
| usage | twelve bad argvs exit 4 with no log and no lock |
The step log check (`STEP`) runs in every test that reads the log: each line must be a step line or the
result line, and none may hold the stack user's output (`stack-release:`, `PASS `, `FAIL `, `REHEARSAL`,
the fake rehearsal's text).
## Mutants
`~/darkwing-scratch/r233/mutants.sh`, not committed because it edits the worktree. It has the brief's six
mutants and two for sage-1650's mapping. The method is row 232's: a checked backup, exact replacements, a
syntax check, the named killers, then the file put back with its sha256 checked. One control runs first:
the whole `stack-upgrade.test.mjs` with no change. `DRY=1` matched each replacement once on the candidate
(2026-10-21).
| Mutant | File | Killers |
|---|---|---|
| m1 drop the flock | stack-upgrade.sh | `a second run while the lock is held`, `sequence 3:` |
| m2 release the lock when the rollback starts | stack-upgrade.sh | `sequence 3:` |
| m3 skip A1's open-activation check | stack-release.sh | `A1 refuses open-activation` |
| m4 skip the rehearsal | stack-upgrade.sh | `the rehearsal runs after the plan`, `sequence 1:` |
| m5 accept a migrating target without `--migrate` | stack-release.sh | `migrate-required` |
| m6 write the args file before confirm | stack-upgrade.sh | `sequence 7:`, `sequence 1:` |
| m7 `--no-rehearsal` accepted with a backup | stack-upgrade.sh | `refuses rehearsal-required` |
| m8 drop the `rehearsal-skipped` line | stack-upgrade.sh | `a real rehearsal with no backup` |
## Deviations
D1. The brief has A1 refuse `open-activation` for an open `AWAITING-UNITS` or a non-empty `interrupted`
set. The interrupted case refuses with row 232's existing code, `interrupted-run`; only `AWAITING-UNITS`
gives `open-activation`. Both reach root as `plan-refused`, exit 3 (sequence 6, the open-activation test).
D2. Root copies one line of the stack user's output into its log, `BACKUP_STORE`, on sage-1653. It
matches a fixed pattern first, takes the last match, logs `BACKUP_STORE none` otherwise, and decides
nothing on it.
D3. The stack user's steps carry no `--instance`. The driver takes `--instance dev` only, row 190's default
instance, which the stack user's commands select with no flag. A named instance's units are a later row.
D4. Most tests fake the rehearsal in the fake `sudo`: the fixture's stub copies can't pass a real one. Two
tests run the real rehearsal (`faults/real-rehearse`): no-backup and backup-last. `stack-release.test.mjs`
covers `--backup last` itself.
D5. Any nonzero `--plan` exit refuses `plan-refused`, exit 3, whatever the code. The stack user's output
above it names the refusal; root doesn't parse it.
D6. `rollback --restore ACT` is accepted without `--events`, as `stack-release.sh rollback` accepts it.
The brief's usage pairs them. `--events` without `--restore` is a usage error.
D7. The refusal of any `STACK_UPGRADE_*` seam when run as root has no test, because the tests don't run
as root. It is three lines at the top of the script.
D8. The fake `sudo` keeps the test's environment, where real `sudo` resets it. The driver passes
everything the stack user needs through `env PATH=...` and `-H`, and the fake checks that form.
D9. Exit 3 means nothing stopped, not nothing changed. A refusal after `fetch` leaves the stack user's
clone fast-forwarded and the new copy made. Both are the user's own state, and the next run reuses them.
TOOLS.md and the run sheet say so.
D10. The brief's usage has neither `--no-rehearsal` nor `--rehearse-port`. Both come from sage-1650 and
sage-1652.
## Decisions
- `rollback` has no HEAD check. Its target is an older copy than root's checkout, which already exists.
It fetches, copies and rehearses nothing (sequence 7).
- The new args file is written next to the old one (`.dev.args.XXXXXX`, same mode) before A2, so a full
`/etc` refuses before anything stops. It moves into place after A5. A2f and the rollback remove it; a failed move keeps it and names it.
- Every stack user step gets `--copies` explicitly, so the copies directory comes from the args file's
`--home`, never from the user's environment.
- The stack's user does the fetch, the fast-forward and the copy, so root never runs git on a tree that
user can write.
- A4 is the render, and `case2-unit.sh` starts the bus, then the Console. The forward path has no
`systemctl start` of its own; only A2f does.
## Run environment
Worktree `/mnt/storage/scratch/darkwing-r233`, `TMPDIR=/mnt/storage/scratch/tmp/r233`, node from PATH. One
whole `stack-upgrade.test.mjs` run takes about 56 s.
## Results
### Singles, run 1, 2026-10-21, Sage's slot (sage-1689)
`~/darkwing-scratch/r233/singles.sh` on `darkwing/row233-r1` (base e65a3ab4d, uncommitted tree), 03:48:20Z
to 03:59:00Z. These are the test files that read a changed script. `stack-instances.test.mjs` reads
`stack-release.sh`, so it joined the three named in the plan.
| File | Result |
|---|---|
| `stack-upgrade.test.mjs` | 20 pass, 0 fail |
| `stack-release.test.mjs` | 194 pass, 1 fail |
| `stack-release-external.test.mjs` | 25 pass, 0 fail |
| `stack-instances.test.mjs` | 23 pass, 0 fail |
The failure was my test, not the script. "row 233: --backup last takes the newest backup..." compares
`untouched(e)` before and after the run, and `untouched` includes `existsSync(e.log)`. The test took its
before snapshot ahead of `rlog()`, which writes the log, so that flag went from false to true. Every hash in
the snapshot matched. The fix takes the snapshot after `rlog()`. No other row 233 test calls `untouched`.
### Singles, run 2
`~/darkwing-scratch/r233/rerun.sh` on aacfa2d7a, Sage's slot (sage-1695), 04:13:05Z to 04:21:28Z:
`stack-release.test.mjs` whole, 195 pass, 0 fail, 0 skipped. The fixed test passed. The other three files
did not change after run 1. The gate and the mutants run next, in Sage's slot.
### Gate and mutants, 2026-10-21, Sage's slot (sage-1704)
`~/darkwing-scratch/r233/gate.sh` then `mutants.sh`, chained behind `busy.sh`, on d99166ade (code
aacfa2d7a; d99166ade changes only this file). Counts are an awk sum over `gate-r1.out`.
| Part | Time (UTC) | Result |
|---|---|---|
| cli suite (`node --test tests/*.test.mjs`) | 04:29:01-04:37:27 | 616 pass, 0 fail, 0 skipped |
| the 10 `scripts/test-*.sh` | 04:37:27-04:39:38 | selftests 429 passed, 0 failed; `test-queue`'s node part 156 pass, 0 fail |
| gate total | | 1201 pass, 0 fail, 11 suites, `GATE_PASSED` |
| c0 control | 04:39:38-04:40:35 | exit 0 |
| m1-m8 | 04:40:35-04:41:25 | 8 of 8 killed, every one exit 1 |
Every restore passed its sha256 check, and `git diff` was clean at d99166ade afterwards. The EXIT trap
removed the `darkwing-r233_default` network.
N1, found in the process sweep after the run: a `sleep 60` was left with its cwd in `packages/cli`.
`stack-upgrade.test.mjs:312` spawns `flock LOCK sleep 60`, and its `t.after` SIGKILLs only the flock, so
flock's child outlives the test by up to 60 s. It exited on its own. Row 232's T20
(`stack-release.test.mjs:370`) spawns its holder `detached` and kills the group with `process.kill(-pid)`.
The fix is the same two lines. That test is one of m1's killers.
Sage ruled it a fix now (sage-1707), and 08db6ac3f makes it, test only. After the gate's d99166ade
the only change outside this file is that test:
```
$ git diff --stat d99166ade 08db6ac3f
packages/cli/tests/stack-upgrade.test.mjs | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
```
The gate stands on Sage's ruling. The rerun is the whole `stack-upgrade.test.mjs` singly plus m1, in
Sage's slot, with a sweep for a leftover `sleep` afterwards.
+310
View File
@@ -0,0 +1,310 @@
# Row 234 (#436a row a, Gitea #1740): host enrollment, store records and bus schema 3i to 3j
Gizmoduck, 2026-10-21. Reviewer Duckworth, gate owner Sage. Lands by 10-25, before the .53 and .54 installs
(mos-1134).
- Brief: `docs/plans/2026-10-20_host-credentials.md`, section "Machine grant row a: enrollment, store records
and migration 3i-3j", blob 64451be5 (queue rev 2617).
- Design: `agents/gizmoduck/work/machine-grant/DESIGN.md` r6, f9039682e on `gizmoduck/machine-grant-design`,
approved by Duckworth (duckworth-0064, REVIEW-r6.md at 8ea792256). Not reopened here. Cites "r6:" are lines
of that file.
- Grant event rule: #436b section 7, fa5658941 on `megavolt/row199`,
`agents/megavolt/work/436b/DESIGN.md:431-490`, file sha256
96510b83778e79a71b33d0d5f6fd0c806dfe74f6de315ff4a9f1fbe5620f6c69, approved by Duckworth (duckworth-0067).
- Base: origin/refactor 8fede5fc6, with rows 199 (cb5fdb064) and 210 (f5753c0e8) landed. Branch
`gizmoduck/row234`. Code cites are `path:line` at 8fede5fc6 unless they say otherwise.
## Names: r6 against section 7
r6's `grant.*` and `host.enroll.*` names are `body.action` values on `action.allowed` events, not event
kinds. The `events` kind CHECK isn't rebuilt. Four r6 names move under `grant.`:
| r6 | This row |
|---|---|
| `host.enroll.step` | `grant.host.enroll.step` |
| `host.enrolled` | `grant.host.enrolled` |
| `host.checked` | `grant.host.checked` |
| `host.removed` | `grant.host.removed` |
Only the consume record carries `decision`: `{operation: 'grant.host-enroll', decision}`. Every other
guarded body names the decision `grantDecision`, so r6's `decision` field in step, enrolled and failed bodies
is `grantDecision` here. The actor of every guarded event is `actor_role` NULL with `actor_run` 'grant',
and `body.by` names the caller the broker checked.
## Split (r6 section 12 Q-S4)
Row a builds:
1. **Schema 3i to 3j.** `migrations/3i-3j.sql` adds `hosts` and `host_grants` (r6 S1, primary keys
(business, host) and (business, agent, host)) and the guard trigger: an `action.allowed` whose
`body.action` or `body.operation` GLOBs `'grant.*'` aborts unless the actor is (NULL, 'grant').
`SCHEMA_VERSION` 3j, a migration test with a step that fails partway.
2. **Grant event writer.** A private session-less broker writer for guarded events. `#event` doesn't change.
`recordEvent` refuses `reserved-event` for the guarded namespace.
3. **The `grant` raise frame.** `decision.raise` takes `grant` only with `credential.mint`, only from the chief
(`grant-chief-only`), and in row a only with `method: 'host-enroll'`. Any other method refuses
`invalid-request` until row b adds `host`. E1's checks, the `ssh-keyscan` set compare before the raise
transaction, and the rendered block.
4. **Enrollment executor.** `#consumeGrant`, the start on the human's cli yes (X1), E2 steps 1 to 4, the
installer key at `<configDir>/secrets/hosts/<business>/<hostId>/installer`, the failure cleanup, and the
start reconcile for enrollments: the `registry` step undo, and a resolved-yes unspent enrollment ending
`grant.failed interrupted` (X3).
5. **`hosts.check` and `hosts.remove`** (E4), with `mosaic hosts status|check|remove`.
6. **`tools/host/`**: `mosaic-keys`, `mosaic-keys-write`, `mosaic-keys.sudoers`, `mosaic-keys.sshd.conf` (E3).
7. **`host.run` on the store** (X4 first and third bullets): it reads the `hosts` and `host_grants` records
and the Approve record check, and at start the runtime gives `Credentials` a reference per `host_grants`
record. The loader refuses `hosts` at the top level and in a role (S1). Row 210's tests move to store
fixtures.
8. **Reader filter** (duckworth-0067 N1): every grant-evidence reader in this row (status, the start
reconcile, `openGrantDecisions`) filters `kind = 'action.allowed' AND actor_role IS NULL AND actor_run =
'grant'`, with a test that an `action.refused` look-alike and a session-written one are ignored.
`hosts.remove`'s `hostLineLeft` reader is one of them (duckworth-0079 M4).
Row b keeps the `host` method's raise binding (R4), the grant executor (X2), the grant rows of X3, X5,
`Credentials.register`/`claim`/`abortClaim`, revoke (section 6) and `sweep`. So row a has no way to make a
`host_grants` record outside a test fixture, and from its landing to row b's no agent holds a host grant
(r6 N9, the run sheet strips row 210's keys).
Tests in row a (r6 section 10): G1 and G5 for the `host-enroll` raise, G6 and G10 for the enrollment
executor, G11's enrollment cases, G14, G15, G16, G17, G18, G20, G21 (its `host-line-left` case reads an
event fixture until row b writes `hostLineLeft`), G25, the migration test, the trigger cases, the reader
filter test, and row 210's T-tests on store fixtures. Row b: G2 to G4, G7 to G9, G11's grant cases, G12,
G13, G19, G22 to G24, G26 to G28.
## Early check items (duckworth-0079, REVIEW-build-split.md 10672772e on `duckworth/reviews`)
Moves into row a:
- M1. G4 for the `host-enroll` block (r6:240-242): a question can't forge the block, and the 4096-character
limit holds with the block appended. G4 for the `host` block stays in row b.
- M2. The tag function (r6:265-266), because E2 step 2 tags the installer line. G24's first sentence moves
here: names of 64 characters with `.` and `_` give distinct tags of the fixed length. The `sweep` half
stays in row b.
- M3. A store-fixture test that `host.run` refuses `host-not-granted` for no record, an unresolved decision,
a decision resolved by an arbiter, the tester or an agent or not via cli, a consume record with operation
`grant.host-enroll` in place of `grant.host`, and an unspent decision (X4 first bullet, r6:593-596). A
restart test that a `host_grants` fixture gives `Credentials` a reference (X4 third bullet).
G21's `host-line-left` case:
- F1. The fixture goes through this row's private grant writer in the real shape: `action.allowed`, actor
(NULL, 'grant'), `body.action` 'grant.failed', `body.host`, `body.hostLineLeft`. No raw SQL around the
trigger.
- F2. `hosts.remove` reads `body.hostLineLeft` from any guarded body, not only `grant.failed`.
- F3. Decided: a guarded body that carries `hostLineLeft` also carries `host`. Row b adds `host` to
`grant.revoke.closed`, so its body is `{tag, host, hostLineLeft}` (r6:716 has `{tag, hostLineLeft}`).
The reader matches on `body.host`, and doesn't map tags back to hosts through `grant.step`. A direct
field is one equality in SQL, and a tag lookup would add a second reader that the N1 filter also has to
cover.
- F4. Row b carry: "no later `sweep` cleared it" (r6:403) needs `grant.sweep`, which only row b writes. Row
b adds the sweep clear to `hosts.remove` with a G21 case, and its G8 or G13 shows `hosts.remove`
refusing after a real `hostLineLeft` write, not only after the fixture. Until then row a's check is "any
guarded `hostLineLeft` for this host refuses".
## Mutants (r6:838-883, numbered here)
Row a, killed on this row's tree:
| # | Mutant |
|---|---|
| A1 | drop the chief check |
| A2 | start the executor on a `credential.mint` with no `grant` |
| A3 | start it on an arbiter resolve |
| A3b | start it on a `cli` yes by someone other than the human (duckworth-0083 N8) |
| A4 | start it on `no` |
| A5 | skip the keyscan compare |
| A6 | compare only one key |
| A7 | write the private key into the event body |
| A8 | drop the "not yet enrolled" check in E2 step 1 |
| A9 | drop the `hosts` primary key |
| A10 | skip the start check for a resolved, unspent enrollment decision |
| A11 | `host.run` reads the business file instead of the grant record |
| A12 | build the tag without the business |
| A13 | drop `from=` in the writer |
| A14 | let the writer touch a line outside the tag pattern |
| A15 | drop `O_NOFOLLOW` on the user file |
| A16 | drop one group from the writer's refused list |
| A17 | drop the user pattern check before the path use |
| A18 | treat a `privileged` entry as enough without `users` |
| A19 | let the writer accept a sudo user not in `privileged` |
| A20 | trigger: drop the `body.operation` arm, so the consume record passes from a session |
| A21 | trigger: accept actor_role NULL with any actor_run |
| A22 | a grant reader drops the `kind = 'action.allowed'` or actor filter. The runner splits it: A22a drops the kind, and G21's `action.refused` look-alikes kill it. A22b drops the actor, which the trigger can't stand in for, because 3i-3j adds the trigger without checking old rows. Its killer is the grant-migration test "A22b, sage-1703": a session-written `grant.host.removed` with `hostLineLeft` and a `grant.host-enroll` row from 3i, migrated to 3j, where `hosts.remove` still removes the host and `reconcileGrants` ends nothing |
| A23 | `host.run` skips the Approve record check (any `host_grants` record is enough) |
Row b keeps the rest of r6's list: the `ready` check, the three hash compares, the definition check, the
install and record order, `remove` on a verify failure, both start sweeps, the reconcile's remote `remove`,
the `grant.step` before install, revoke leaving the record, `register` before `issue`, the abort check and
the `await` before `issue`, revoke and an unreferenced claim, `sweep` and any `mosaic-` line, revoke's host
step order, claim before consume, the resolved unspent grant decision, an async `commit`, `hosts.revoke`
through `credential-verbs.mjs`, the revoke marker reconcile, the upsert, the undo past `grant.revoked`, the
abort after step 5 and a missing `bus.tester` as dev.
## Broker functions this row touches (sage-1677)
For row 236 (Megavolt), which rebuilds on this landing. Old lines are `packages/bus/src/broker.mjs` at
8fede5fc6, new lines are at the last commit that changed this file (they moved by 10 after `runEnrollment`
went in). The planned list is kept in this file's history (827b89ea6).
Changed:
- `constructor` (:279): one more option, `hostGrant`, the object `host-grant.mjs` builds.
- `recordEvent` (old :1243-1251, new :1606): one more refusal. An `action.allowed` whose `body.action` or
`body.operation` is a `grant.*` name refuses `reserved-event`.
- `request()` (old :1362, new :1726): two routes ahead of the sync path, a `decision.raise` with `grant` to
`#raiseGrant` and the `hosts.*` verbs to `#requestHosts`. Both are async.
- `requestHost` (old :1712-1795, new :2079): the business-file lookups (old :1730-1736) become one
`#hostAccess` call, in the same transaction as `#agent`. `commands` and `entry` come from it.
- `#dispatch` (old :1796, new :2160) gets a fifth parameter, `enroll`, which only `#raiseGrant` passes.
- `decision.raise` (new :2244): `grant` joins the allowed keys, and `grant` without `enroll` (or the
reverse) refuses `invalid-request`. The `host.sudo` block reads `#hostAccess` in place of `b.hosts`
and the role's `hosts`, and an undefined `target` still refuses `host-unknown`. `validArgv` and
`validStdin` now run after the access check, so a malformed argv for an ungranted host refuses
`host-not-granted` first. The enrollment block is appended to the question before `string(question)`,
so the 4096 cap counts it (M1). The decision's raise body carries `grant: enroll`.
- `decision.resolve` (new :2409): when `op` is `resolved`, `#approvedGrant` reads the decision, and a
`host-enroll` grant starts `#runEnrollment` from `setImmediate`, after the transaction commits. The
result adds `grant: 'started'`.
New, all private but two:
- `#grantEvent` (:657), the writer of guarded events. It runs the secret check, then `writeGrantEvent`.
- `#approvedGrant` (:667), `#consumeGrant` (:678). `#consumeGrant` sits after `#approvedGrant`, not next
to `#consumeAuthority` as planned.
- `#hostRecord` (:685), `#hostAccess` (:692), `#grantNotice` (:708), `#enrollFailed` (:736),
`#runEnrollment` (:750), `#openGrantDecisions` (:823).
- `runEnrollment` (:745), public, a trusted entry to `#runEnrollment` for G10. No verb reaches it.
- `reconcileGrants` (:833), public, which `runtime.mjs` calls once at start.
- `#raiseGrant` (:866), `#hostsCaller` (:886), `#requestHosts` (:900), `#hostLinesLeft` (:964).
Module level, new (:184-230): `GRANT_RUN`, `grantName`, `GRANT_EVIDENCE` (the N1 filter as SQL),
`registrySha256`, `writeGrantEvent` (exported, the one writer of guarded events, which test fixtures use
for F1), `HOSTS_VERBS`, `fingerprintOf`, `INSTALLER_COMMENT`.
Unchanged: `#event`, `#spent`, `#consumeAuthority`.
Other `src` files:
- `bus/src/host-grant.mjs`, new: the keyscan, the installer key pair, and `hosts.check`'s `list`.
- `bus/src/runtime.mjs`: builds `hostGrant`, calls `reconcileGrants` before the server starts, and adds
the `host_grants` references before `new Credentials` (X4 third bullet).
- `bus/src/host-run.mjs`: `runHost` takes `command`, sent as is in place of the sudo argv, for the
installer's `list`.
- `bus/src/server.mjs`: the `hosts.*` verbs and a raise with `grant` go down the async path.
- `bus/src/store.mjs`: the 3i-3j step, `SCHEMA_VERSION` 3j. `bus/src/credentials.mjs` exports
`privateDir`.
- `business/src/business.mjs` and `business/src/hosts.mjs`: the loader refuses `hosts` (S1).
`checkHosts` and `checkHostGrants` are gone.
- `cli/src/hosts.mjs`, new, routed from `cli.mjs` and `scripts/mosaic`.
## Decisions taken in the build
- **`#hostAccess` binds the agent and the host.** X4 says the Approve record check passes when the
decision is an approved `host` grant with a `grant.host` consume. The check also requires the grant's
`agent` to equal the session's role and its `host` to equal the requested host. Without that, an
approved grant for one agent and host would vouch for any `host_grants` row that names its decision.
Cost: none for row b, whose executor writes the row from the same grant.
- **Store fixtures are written the real way.** `bus/tests/host-fixtures.mjs` inserts the decision rows by
SQL, but every guarded event goes through `writeGrantEvent`, so the trigger sees it (F1). An enrollment
fixture writes `grant.host.enrolled`, or the start reconcile reads it as interrupted.
- **The resolver test for host kinds is gone.** `business/tests/hosts.test.mjs` lost its D5 case, because
the loader now refuses `host.<id>` credential refs. The `isHostKind` filter in `business/src/resolve.mjs`
can't be reached now. I left it in, since row b may want it back, and it's one line.
- **Store before Credentials.** The runtime already opened the store before `Credentials`. The
`host_grants` references depend on that order, and the comment there says so.
- **CLI.** `mosaic hosts` prints tab lines, and `--json` prints the bus rows. Usage errors exit 4 and bus
refusals exit 2, as `mosaic credentials` does. There is no enroll command: enrollment is the chief's raise.
- **Host tools tests live in the bus suite.** `bus/tests/host-tools.test.mjs` loads the writer by path
through a Python harness under `python3 -B -I`. The harness passes its own key dir, config dir, owner
uid and user, group and sudo lookups, and runs `mosaic-keys` from a copy with stub `sudo` and `logger`.
The gate runs the bus suite, so G16 and G25 run there. The real `pwd`, `grp` and `sudo -l` paths are
checked on the #433 VM, not here. The test also runs `visudo -c` on the sudoers file and `sshd -T` on
the drop-in.
- **Writer details the design left open.** The `from` file must hold exactly one value matching
`[0-9A-Za-z.:/*?!_-]+(,[0-9A-Za-z.:/*?!_-]+)*`, so it can't close the quote. `users`, `privileged` and `from` must be
regular, owned by the owner uid and not group or world writable, and are opened without following a
symlink. Lines starting with `#` are comments. A refusal exits 1 with its code on stderr and in syslog.
`add` of a tag that is already there exits 0 and writes nothing.
- **`runEnrollment` is public for G10 (duckworth-0081 B1).** G10 needs a second start for a spent
decision, and the resolve path can't give one: a second resolve refuses `decision-closed`
(broker.mjs:2416) before the executor runs. So
`#runEnrollment` returns its code (`enrolled`, `host-enrolled` or the refusal), and a public
`runEnrollment` calls it, as `reconcileGrants` is public. The test checks a spent
decision gives `decision-consumed`, and an open or agent-resolved one gives `not-authorized`, with
nothing written. That second case also reaches the `via` and `by` checks in `#approvedGrant`, so A3 is
no longer vacuous (N1).
Who can call it (sage-1692): only code holding the `Broker` object, which is the bus process and the
tests. The one `new Broker` in `src` is `runtime.mjs:126`, and no `src` file calls `runEnrollment`;
the resolve path calls the private `#runEnrollment` from `setImmediate` (broker.mjs:2445). A seat
holds no `Broker`. It reaches the bus only over `broker.sock`, where `server.mjs` calls a fixed set of
methods by name (`request` at :134 and :140, `identity`, `disconnect`). `request()` routes on the verb
through the `#dispatch` switch, and neither file looks a method up by name (no `this[` in either), so no
verb string can reach `runEnrollment`.
- **A `sudo -l` that times out refuses `user-sudo` (N2).** `_sudo` returns an empty answer on
`TimeoutExpired`, which isn't the expected line, so `main` logs the refusal and exits 1.
- **Keyscan comment (N3).** A scan that can't start refuses `host-failed`. One that runs and finds no key
returns no lines, and the raise refuses `host-key-mismatch`. The comment in `host-grant.mjs` now says so.
- **No tag on in-run `grant.failed` (N4).** r6:578 tags only the `interrupted` one, so the code follows it.
- **Run sheet (N5).** The writer refuses `config-privileged` when the file is missing, so each host needs an
empty, root-owned `/etc/mosaic-keys/privileged` (0644) before the first enrollment.
## Mutant results (Sage's slot, 2026-10-21)
All 25 killed: A1 to A23, with A3b added for duckworth-0083 N8 and A22 split into A22a and A22b. The
runner is `mutants.py` outside the repo. Each mutant gets its own session, a 420 s limit with a group kill,
and a byte restore checked against git status. None hung. Run 1 (04:24Z, on 95f966b1c) killed A1 to A22a
and stopped at A22b, which survived. Run 2 (04:43Z, on 097892741) killed A22b and A23.
- Each kill comes from a named assertion in the mutant's own test files, except A9. Dropping the `hosts`
primary key leaves `host_grants`' foreign key with no parent key, so the store refuses to open
("foreign key mismatch") and 28 of 29 tests fail at setup. That's a structural kill, not the S1 test.
The schema can't exist without the key (sage-1709).
- A22b drops the actor filter from `GRANT_EVIDENCE`. The trigger can't stand in for it, because a 3i store
can hold a session-written grant row that the 3i-3j step keeps. The grant-migration test "A22b,
sage-1703" kills it (see the A22 row above).
Follow-up, not in row a: G18's control calls `runHost` directly, so nothing runs `hg.check` end to end
with a good pin (duckworth-0083 N9). The #433 VM run covers it.
## Status at 097892741
Done: every Split item, 1 to 8, duckworth-0081 B1 to B3, duckworth-0083 N8, and all 25 mutants killed.
Single files in the slot, all exit 0, 178 pass and 0 fail by an awk sum over 23 logs: bus grant-enroll
24, host-run 24, grant-migration 6, host-tools 6, account-migration 3, chief-of-staff 18,
comment-migration 5, hard-waits 13, name-migration 1, project-runtime 3, spec-migration 1,
thread-migration 3, usage-migration 1, project-files 8, spec-socket 6, spec-review 12, land 17, spec 4;
harness spec-tools 2, raise-domain 2; business hosts 4; cli hosts 5; seat sandbox 10.
Left: Sage runs the whole bus, business and cli suites and every `scripts/test-*.sh` at the landing gate,
after Duckworth approves.
## Round 3 (sage-1742): two tests that count the schema
The landing gate failed `packages/bus/tests/schema.test.mjs:522`. It counts the tables and triggers that
`schema.sql` creates, and row a adds two tables and a trigger, so the counts go from 16 to 18 tables and
from 70 to 71 triggers. My singles didn't run it because I took it for a test of other rows' tables.
I grepped `packages/*/tests/*` for `sqlite_master` and `schema.sql`, with `schema.test.mjs:517` as the
positive control. That turned up one more test that would fail: T46 in
`packages/cli/tests/stack-release.test.mjs:910`. It puts every schema table in exactly one of four lists, and
hosts and host_grants were in none. I'd left the stack-release tests out of the r2 singles on the claim that
they don't load the bus, but T46 reads `schema.sql`. Both tables go in its untimed list (Sage's ruling,
sage-1745: the message_threads pattern). Neither has an `at` column. Row a writes hosts at five lines,
and each runs in one store transaction with a `grant.*` events row, which the restore guard counts.
`#grantEvent` (broker.mjs:657) writes that row through `writeGrantEvent` (:201, INSERT INTO events at
:212).
- :737 DELETE, in `#enrollFailed`, with grant.failed at :738. Both callers wrap it in a transaction,
:816 and :849.
- :763 INSERT, in the step 1 transaction (:754), with grant.host.enroll.step at :761.
- :783 UPDATE installer_fingerprint, in the step 4 transaction (:780), with grant.host.enrolled at :784.
- :932 DELETE, in the hosts.remove transaction (:927), with grant.host.removed at :933.
- :950 UPDATE state ready, in the hosts.check transaction (:944), with grant.host.checked at :951.
Row a writes no host_grants rows. It only reads them (broker.mjs:695, :930, runtime.mjs:103). Row b adds
the writes, and each must share a transaction with a `grant.*` event the same way. That goes on row
235's carry list. The other hits pin an older schema
(account, comment, name, spec, thread and usage migrations, chief-of-staff 3c) or count only their own
tables (state.test.mjs:106 reads every table but pins no list, threads.test.mjs:129).
hosts and host_grants are mutable by design, so the refused-labels loop above `schema.test.mjs:517` gets no
entry for them. hosts.check updates a host to ready. hosts.remove and a failed enrollment delete the
record. Row b updates and deletes grant records. Neither table has append-only triggers. The one new
trigger, events_grant_actor, guards events. grant-migration.test.mjs tests it, and it killed mutants
A20 and A21.
+61
View File
@@ -0,0 +1,61 @@
# Row 238: the sso-config hint names the instance (row 200 follow-up N1)
Launchpad, 2026-10-21. Author Launchpad, reviewer Webby, gate owner Sage. Issue #1744. Brief "Brief: row 200
follow-up N1, the sso-config hint names the instance" in `docs/plans/2026-10-20_stack-release-sso.md` (76134dfde),
from Webby's row 200 round 3 note N1 (comment 29772), assigned in sage-1597.
Round 3 base c771a36f4, row 234's landing (`base.txt`). Branch `launchpad/row238-r3`, one commit.
- Round 1 was `launchpad/row238` bec7e499e on 011d41ba6, approved by Webby (webby-0049). It was built on
76134dfde and rebased before its first push, after row 199 (cb5fdb064) added 6 lines to `docs/TOOLS.md`.
- Rows 232 (e65a3ab4d) and 233 (57f59e0c3) then landed on all 3 paths (sage-1710, sage-1755). Round 2 was a
local rebase onto 57f59e0c3 (ca3d30e5c, never pushed). The merge was clean. `sso_checks` moved from about
:838 to :920 with its body unchanged except for this row's hunk. 233 moved the shared test helpers into
`stack-release-fixture.mjs`, and the new subtest is still under T57.
- Row 234 (c771a36f4) then added two lines to T46's `untimed` map (`host_grants`, `hosts`, now :919-920).
Round 3 is the same patch on c771a36f4 with those lines kept. The code diff on the 3 paths has the same
`git patch-id --stable` on both bases (b9d0bb0ee3c7).
`candidate-manifest.sha256` lists the 3 changed paths; the packet files are not in it.
No test touches `~/.mosaic-dev`, `~/.config/mosaic-dev`, `~/.mosaic`, a live unit or a live slot. The new
subtest runs on row 190's `named()` scratch instance under the test's temp HOME.
## Files
| Path | Change |
| --- | --- |
| `scripts/stack-release.sh` | `sso_checks`: the N4 first step puts `${INSTANCE_FLAGS[*]}` after `activate $TO` in both commands. |
| `packages/cli/tests/stack-release.test.mjs` | T57 subtest "row 238 (#1744)": the sso-config refusal and the `--plan` line on instance `alpha`. |
| `docs/TOOLS.md` | The `sso-config` paragraph says both commands carry `--instance NAME` on a named instance. |
## What it does
On a named instance the hint now reads `activate ID --instance NAME without --sso first, then write
console.sso, then run activate ID --instance NAME --sso`. `INSTANCE_FLAGS` is empty for the default instance,
so `inst` is empty and the default text is unchanged byte for byte. T57's item 2 subtest still asserts the
default text exactly. The flag goes right after `ID`, the same place the other entry points take it from
`INSTANCE_FLAGS`. BUSINESS is not in the hint before or after this row.
## Tests run (this tree)
Logs are in `/mnt/storage/scratch/launchpad-r238/`.
- Round 1: `stack-release.test.mjs` whole, 193/193 on 76134dfde (`sr-whole.log`) and on 011d41ba6
(`sr-whole-2.log`). `stack-instances.test.mjs` whole, 23/23 (`si.log`).
- Round 2, on ca3d30e5c (57f59e0c3), each file singly on Sage's slot (sage-1761): `stack-release.test.mjs`
196/196, `stack-release-external.test.mjs` 25/25, `stack-upgrade.test.mjs` 20/20 (`r2-*.log`, 241/0 by
awk). Sage counts these as pre-merge (sage-1769).
- Round 3, on this blob singly on Sage's slot (sage-1794): `stack-release.test.mjs` 196/196, 06:34:22Z to
06:42:57Z (`r3-stack-release.log`). The other two files and
`stack-release-fixture.mjs` are the same on 57f59e0c3 and c771a36f4, so their round 2 results stand.
- A grep of `packages/*/tests/*` and `scripts/test-*.sh` finds the hint text only in `stack-release.test.mjs`
(`:1538` default, `:1689` and `:1691` named) and no 64-hex pin on the 3 paths.
- The cli suite, every `scripts/test-*.sh` and the mutant go to Sage's slot.
## Mutants (`mutants.py`, run as `python3 mutants.py <tree> [N ...]`, against T57)
| # | Mutant | Result |
| --- | --- | --- |
| 1 | the first step drops `--instance NAME` (`inst=""`) | not run, Sage's slot |
The pattern matches once on this tree.
+1
View File
@@ -0,0 +1 @@
c771a36f4bd0b6c359282425c668996a90072f93
@@ -0,0 +1,3 @@
dd5e0401d28905dcb17b60dc219ac43dca02d852d2db9cd6ae69fa1a3329935a docs/TOOLS.md
4eeae9f672aecccdf853c377fb10847ce2656dbc566720bddfece435c1755d3b packages/cli/tests/stack-release.test.mjs
1896d6518a502d340dfe30d402102b8c5cd5d8520757387b018b636755eb5d36 scripts/stack-release.sh
@@ -0,0 +1,37 @@
# Row 238 (#1744) mutant: `python3 mutants.py <tree> [N ...]`. Each applies to the tree's scripts/stack-release.sh,
# runs T57 in packages/cli/tests/stack-release.test.mjs and restores the file from a saved copy, checked by sha256.
# Killed = test exit != 0.
import hashlib, os, subprocess, sys, signal
R = os.path.abspath(sys.argv[1])
S = os.path.join(R, 'scripts', 'stack-release.sh')
if not os.path.isfile(S): sys.exit(f'ABORT no scripts/stack-release.sh under {sys.argv[1]}')
T, PAT = 'tests/stack-release.test.mjs', '^T57'
M = [
('the sso-config first step drops --instance NAME (row 238)', [(' local first="" inst="${INSTANCE_FLAGS[*]:+ ${INSTANCE_FLAGS[*]}}"', ' local first="" inst=""')]),
]
sha = lambda b: hashlib.sha256(b).hexdigest()
only = sys.argv[2:]
os.chdir(os.path.join(R, 'packages', 'cli'))
survived = []
for i, (name, pairs) in enumerate(M, 1):
if only and str(i) not in only: continue
orig = open(S, 'rb').read(); h = sha(orig)
src = orig.decode()
for a, b in pairs:
if src.count(a) != 1: sys.exit(f'ABORT {i} {name}: pattern found {src.count(a)} times')
src = src.replace(a, b)
try:
open(S, 'w').write(src); os.chmod(S, 0o755)
p = subprocess.Popen(['node', '--test', f'--test-name-pattern={PAT}', T], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, start_new_session=True)
try: out, _ = p.communicate(timeout=300)
except subprocess.TimeoutExpired:
os.killpg(p.pid, signal.SIGKILL); out, _ = p.communicate(); out += b'\nTIMEOUT'
text = out.decode(errors='replace')
ran = [l for l in text.splitlines() if l.startswith('ℹ fail ')]
verdict = 'KILLED' if p.returncode != 0 else 'SURVIVED'
if verdict == 'SURVIVED': survived.append(name)
print(f'{i:2} {verdict} {name} [{ran[0] if ran else "no count"}]', flush=True)
finally:
open(S, 'wb').write(orig); os.chmod(S, 0o755)
if sha(open(S, 'rb').read()) != h: sys.exit(f'ABORT restore failed for {S}')
print('survived:', survived or 'none')
+5 -2
View File
@@ -80,8 +80,8 @@ Results (Sage's slot, sage-1686, 2026-10-21T03:35:00Z to 03:35:15Z): both killed
(cmp ok).
- M1: publish-socket 8/1, the row 242 test fails at :322 in 0.5 s.
- M2: publish-socket 8/1, the row 242 test fails at :327 (the bounded wait at :311 for one allowed
event) in 10.3 s.
- M2: publish-socket 8/1, the row 242 test fails at :327, the wait for one allowed event, through
the assert.fail inside `until` (:311) after its 10 s bound, in 10.3 s.
## Suites
@@ -101,5 +101,8 @@ and scripts/test-*.sh are left for Sage's landing gate.
notified. The allowed event and the bus log line are the only trace. A later publish on the
decision gets decision-consumed, which tells the agent the decision was used. Whether the run
should get a RESULT for a late record is a follow-up question for Sage.
- A late push whose recordPublish refuses (Duckworth N2) is filed with that code: the ref stays on
the fork and the decision is not spent, the same residual as an in-band push whose record
refuses.
- The hold lives in the bus process's memory, like the broker's open tickets. A bus restart during
a hold drops both, the same as today for any publish in flight.
@@ -21,11 +21,11 @@ so Mos doesn't check this draft. Draft 1 names NEW and goes to Mos for the P0 ch
- OLD, live: `4429a104f6d65ccf165f6c83b5d1dce3147dfde0`, schema 3i, pointer `pm: true` (record 99a406ed3).
P1 re-reads the activation id and the Console flags from `status`. I don't copy them from the last window.
- NEW: open. It's the row 234 landing, or a later code landing if Mos rules. Candidates in landing order:
194, 234 (210 and 237 have landed).
194, 234 (210, 237, 232, 241 and 242 have landed).
- REPO, `P`, `WIN=agents/sage/work/window-repin-<NEW12>/`, `SNAP`: as in `repin-4429a104f.md`, with the
sha checks at P0 and before S1, S2 and any D3.
## What NEW carries over OLD (so far, refactor 8fede5fc6)
## What NEW carries over OLD (so far, refactor 04ce8dfd5)
`git log --first-parent 4429a104f..HEAD`, path-limited to packages, scripts, roles, contracts, adapters, skills
and the package files:
@@ -41,6 +41,9 @@ and the package files:
| 230 | c8bc5cf4c | Case 2 scripts, tools only. Not run live. |
| 210 | f5753c0e8 | Host access. The live business has no `hosts`, so nothing is granted. Bus start runs the host-run sweep (M34d). P3 must show it removes nothing. |
| 237 | d7d943199 | Publish busy check (199 N1). |
| 232 | e65a3ab4d | Installer I1: `stack-release.sh --units external` and `stack-copy.sh copy --bundle`, `wrapper --create`. Both are opt-in, and this window uses neither. The default units path must render the same units, so the P2 unit diff covers it with row 190. P0 checks that no window command line carries `--units external`. |
| 241 | 5d4a61776 | Test only (bus land.test.mjs). No live effect. |
| 242 | 5fb736b92 | Bus publish hold: after publish-timeout the bus holds the decision until its push ends, then records a late push. The live business has no publish in flight at W1, and P3 shows none open. Its follow-up fc1cb856b is comments only (git-socket.mjs). |
| 234 | open | Machine grant row a and the 3i to 3j schema step. `--plan` must say `migration: 3i -> 3j`. |
| PRD 1.7+1.8 | 0adf8ddfe | `docs/prd/mosaic-stack.md` and `packages/tasks/requirements.json` move to version 1.8 (mos-1154, mos-1156, DECISIONS Q192). The live pm cites 1.6 until this window. Check after: the pm's requirements read answers version 1.8. |
@@ -54,6 +57,12 @@ Service script drift OLD to HEAD is 415+/91- over the four scripts, mostly row 1
- Rollback after a migration isn't the plain rollback. OLD is 3i and the store is 3j, so going back needs
`--restore <W5 activation>`, and that loses the events written after W5. The row 181 restore guard counts
11 tables, and the untimed ones need the D3 snapshot. Mos decides any restore past W5, as before.
- Grant look-alikes (row 234 A22b, gizmoduck-0107). `3i-3j.sql` adds `events_grant_actor` but doesn't check
rows already in the store, so after the migration only the actor filter in `GRANT_EVIDENCE` (actor_role NULL,
actor_run 'grant') ignores an old session-written row. P3, and again just before `--migrate`: on the
backup copy, count the `action.allowed` events whose body.action or body.operation starts with `grant.`, or
whose body has `hostLineLeft`. Expect 0, since no grant code has run live. Any nonzero count stops the window
until I've read those rows.
- P3 is the rehearsal with `--migrate` on a copy of the live backup. It must show the 3j step and the row
counts before and after.
@@ -71,4 +80,6 @@ history unless the window fails.
`--sso` included, from `status` after that window.
- Q3: who operates. If Darkwing is free, Darkwing operates and I check. Otherwise I operate as in the last
window.
- The P-step evidence list for rows 199 and 210 at bus start, written once both have landed.
- The P-step evidence list for rows 199 and 210 at bus start. Both have landed (cb5fdb064, f5753c0e8), so
it's due in draft 1. I take each log line and exit code from a P3 rehearsal run, not from the code.
- Rows 241 and 242 (bus): P3 shows no publish or land decision open before W5.
+4
View File
@@ -1395,3 +1395,7 @@ are never rewritten or removed; corrections are new entries.
2026-10-21T01:49:55Z | Sage (Opus 5.5, 1ef1e4f8) | row 237 landed d7d943199 (#1743, gate green, M1 killed); PRD 1.7+1.8 landed 0adf8ddfe (Honker 2884ee496, Duckworth approve bf52628f9, tasks 97/0 bus 478/0 on the tip, DECISIONS Q192); rows 239 (#1745) and 240 (#1746) added; 233 briefed; queue revs 2540-2603 committed 584e2bd82 | landed, push next
2026-10-21T02:51:46Z | Sage (Opus 5.5, 1ef1e4f8) | row 210 landing (r5 0485384e9, Duckworth r3 29851) | landed f5753c0e8, patch-id 723b1c83 = r5 diff, manifest 688d38b9 OK; gate 2796/0 packages, 429/0 scripts; mutants 52/53 (M22b equivalent); #1707 closed (29853), Vikunja 424 comment 429; queue revs 2604-2617 c084d8d42; row 234 briefed; Megavolt has the slot for 241 (sage-1668)
2026-10-21T03:01:43Z | Filbert (Opus 5.5, 048f3c63) | row 232 installer I1 review r2, rebase scope (#1734, manifest 7af3c94308e0, da2da2f6c on 8fede5fc6) | APPROVE, Gitea 29865, queue rev 2622 (left for Sage's queue-commit); README = predicted 3-way blob 89a95b32, 7 blobs same as r1, BUILD.md +31/-0; corrected my r1 reader claim (host.test.mjs:855 reads cli README)
2026-10-21T04:14:49Z | Sage (Opus 5.5, 1ef1e4f8) | lead: rows 241 and 242 landed, slot order | row 241 landed 5d4a61776 (#1747 closed 29883, M1-M3 killed, gate 2797/0 + 429/0); row 242 landed 5fb736b92 (#1748 closed 29894, Duckworth approve 29889, gate 2826/0 + 429/0); late-record notice ruled a follow-up (Vikunja 440 c432); row 233 brief re-pinned rev 2631; carry items on #1744 (29885) and #1734 (29886); queue revs 2628-2638 committed 96bac588f
2026-10-21T04:50:44Z | Darkwing (Opus 5.5, 7525c8e4) | row 233 I3 gate, mutants and review request (sage-1704, sage-1707, sage-1708) | gate green on d99166ade (1201/0, cli 616/0) and m1-m8 8/8 killed; N1 flock-holder orphan fixed test-only in 08db6ac3f, rerun c0 20/0 + m1 killed, sweep 0 left; in review round 1, manifest 464ce9eb at 1a1dc80ca on darkwing/row233-r1, comment 29901, revs 2644 and 2647 (lock race resolved); Filbert woken
2026-10-21T04:58:01Z | Filbert (Opus 5.5, 048f3c63) | row 233 (I3, #1735) review round 1 on manifest 464ce9eb177a (1a1dc80ca) | APPROVE, Gitea 29922, rev 2659 (left for Sage); static, no slot; 5 non-blocking notes N1-N5 (args-failed exit 1, REPO ownership, signals, run sheet npm/claude path, sections 8/10 precheck)
2026-10-21T05:47:45Z | Sage (Opus 5.5, 1ef1e4f8) | rows 233+234 landing gate, row 233 landing, 240 brief, drift sweep (sage-1742..1758) | combined gate stopped on two row 234 failures (bus schema.test.mjs:522 counts, cli stack-release T46 restore-guard lists; mos-0823); 233 landed alone 57f59e0c3 (patch-id b16ac924, verify-commit ok, cli 617/0, scripts 585/0), pushed with b00d7d94d, 5465fd52d and riders 642a9b87d, 16019b80e; #1735 closed 29931, done rev 2675, Vikunja 432 comment 436; 240 briefed rev 2676; 234 r3 c783d5e39 rebuilt on the landing in Gizmoduck's slot; 197 and 238 drifted, rebase sent (sage-1754, 1755, 1757 stash fix)
+84 -3
View File
@@ -599,7 +599,8 @@ error. Before anything stops, a run whose Console flags hold `--sso` refuses
`sso-target` for a target Console without it, `sso-config` when the target's own
`checkConsole` refuses the config's `console` key (or there is none, in which
case, from another copy, it names the first step: activate the target without
`--sso`, then write `console.sso`), and `sso-slot` for each provider's slot file
`--sso`, then write `console.sso`; on a named instance both commands carry
`--instance NAME`), and `sso-slot` for each provider's slot file
that is missing, a symlink, not a regular file, not owned by this uid, has group
or other bits, resolves inside the repository or the copies, or is empty. It
uses `lstat`, `stat` and `realpath` on the slot and never reads it. `--plan`
@@ -669,7 +670,7 @@ pinned copy](../packages/cli/README.md#one-pinned-copy-row-85-1579)).
#### Rehearsal on a scratch root (D2, row 163)
```
scripts/stack-release.sh rehearse ID BUSINESS --backup ACTIVATION|PATH --port N [--timeout S]
scripts/stack-release.sh rehearse ID BUSINESS --backup ACTIVATION|PATH|last --port N [--timeout S]
```
`rehearse` starts the target copy's bus host and Console on a copy of a
@@ -680,7 +681,11 @@ runs it against live data. It stops and starts no unit and writes no pointer
or log line.
`--backup` takes an activation id under `<dataRoot>/backups/stack/` or an
absolute path. `--port` is the scratch Console's loopback port, 1024 to
absolute path. `--backup last` (row 233) takes the newest backup the
activation log records and prints `PASS backup-last` naming it. With none in
the log it fails `no-backup`, exits 3 and makes no directory. After the
backup's checks it prints `BACKUP_STORE <activation id> schema <version>`.
`--port` is the scratch Console's loopback port, 1024 to
65535. The run refuses `port-reserved` for 7332, 7333 and the pointer's
`--port` and `--tester-port`, and `port-in-use` for a port something holds;
it never picks another port. `--timeout` (default 60 seconds) bounds the
@@ -779,6 +784,82 @@ instance (the business's `launch.by`), for this business, whose run started at
or after the floor. `status` adds an
`awaiting units:` line for an open activation.
Under `--units external`, `activate --plan` also refuses `open-activation`
while any rollback set holds `AWAITING-UNITS`, and `migrate-required` when
the target migrates the store and the run lacks `--migrate` (row 233). Under
`--units user` the plan's bytes are unchanged.
#### Root's upgrade driver (`scripts/stack-upgrade.sh`, row 233)
```
scripts/stack-upgrade.sh upgrade --instance dev --bundle FILE --sha SHA [--migrate] [--no-rehearsal]
[--rehearse-port N] [--timeout S]
scripts/stack-upgrade.sh rollback --instance dev --to ID [--restore ACT [--events N]] [--timeout S]
```
Root runs it from a root-owned checkout of the reviewed commit, never from a
copy, because the stack's user can write the copies. Each run needs Mos's go,
and `--migrate` needs Jason present (decision 96). The host must be prepared
first (`docs/guides/host-run-sheet.md`). Only the default instance, `dev`, is
accepted; a named instance's units are a later row.
The unit values come from `/etc/mosaic-stack/dev.args`, which I2's install
writes: one `case2-unit.sh` argument per line, the serve flags after `--`,
owned by root with no group or other write, exactly one `--copy`, and that
copy the one both installed units run. Otherwise the run refuses
`args-unreadable` or `args-differ`. The driver writes the new copy id into
that file only after `confirm` passes.
`upgrade`, in order. Every stack user step runs as
`sudo -u USER -H env PATH=<node dir>:/usr/local/bin:/usr/bin:/bin` from the
user's clone `<home>/src/mosaic-stack`:
1. Takes `flock -n` on `/run/mosaic-stack/upgrade-dev.lock` and holds it to
the end, rollback included. A second run refuses `upgrade-running`, and
a lock it can't open refuses `lock-failed`.
2. Fetches the bundle's `refactor` into the clone and fast-forwards it to
SHA (`fetch-failed`), then `stack-copy.sh copy --bundle FILE SHA`
(`copy-failed`).
3. A1: this checkout's HEAD is SHA (`head-mismatch`), the copy exists
(`copy-missing`), `/usr/local/lib/mosaic-stack/hostcheck.sh --check`
exits 0 (`profile-drift`, row 240), both units name one copy
(`units-unreadable`, `units-disagree`) and it is the args file's
(`args-differ`), and
`activate SHA BUSINESS --units external --plan --expect-from OLD` exits 0
(`plan-refused`, which covers `open-activation`, `migrate-required` and
`pointer-differs`).
4. The rehearsal, `rehearse SHA BUSINESS --backup last --port 7339`
(`--rehearse-port`). Exit 0 goes on. Exit 3 means no backup in the log
and goes on only with `--no-rehearsal`, logging `rehearsal-skipped`;
without the flag it refuses `no-backup`. Exit 0 with `--no-rehearsal`
refuses `rehearsal-required`; any other exit refuses `rehearsal-failed`.
5. A2 stops the Console, then the bus, and waits for both to read inactive
or failed. A3 is `activate --units external`, A4 the render with the new
`--copy` (which also starts the bus, then the Console), A5 `confirm`.
The driver writes the new args file beside the old one before A2
(`args-failed`, exit 3) and moves it into place after A5. A failed move
exits 1 `args-failed`: the new copy is live and confirmed, and root puts
the file in place by hand.
A failure in A2 or a stage that exits 3 starts the old units again and gates
them (A2f). A later failure stops both units, runs `revert`, renders the old
copy and runs `confirm --rollback OLD --cause A3|A4|A5`. `rollback` is the
same from A1 on, with `rollback BUSINESS --to ID`, no fetch, no copy and no
rehearsal; `--restore` and `--events` pass through.
Root reads no output the stack user wrote except exit codes and the
rehearsal's `BACKUP_STORE` line, which it copies into its log after a match
against a fixed pattern (`BACKUP_STORE none` otherwise). The step log
`/var/log/mosaic-stack/upgrade-dev.log`, 0640, gets one
`<time> <step> rc=N argv: ...` line per step and a last
`<time> result exit=N <code> <text>` line.
Exit 0 ok, 1 failed with the old stack running and gated, 2 rollback-failed
with both units stopped, 3 refused with nothing stopped, 4 usage. A refusal
after step 2 leaves the user's clone fast-forwarded and the new copy made. After exit
2 the activation stays open, so the next run's plan refuses until root
finishes it.
## Console service (`scripts/webui-service.sh`)
```bash
+240
View File
@@ -0,0 +1,240 @@
# Host run sheet: preparing a host for the stack
ops-01 runs this on a fresh host before install (I2), and once per host for enrollment. Jason places the
credentials in section 9. Brief: `docs/plans/2026-10-20_installer-rows.md`, I3 (row 233, #1735). The
host design is Darkwing's r6 (`agents/darkwing/work/queue-195/DESIGN.md`, section 2) as the brief
changes it.
The hosts are .53 (profile `factory`) and .54 (profile `library`). Both get every section unless a
section says otherwise. Every command runs as root unless it starts with `sudo -u mosaic`.
Each item names the `scripts/case2-precheck.sh` line that verifies it. Install's precheck runs as the
stack's user with `--business ID --profile factory|library`. Where no precheck line checks an item, the
item says "precheck line missing", and Gosalyn adds the line to I2. Today's precheck (C2-13a) has these
gaps; C2-13b adds the library lines (`library.sync`, persona modes, no Discord binding).
## 1. The stack's user and its home
```sh
useradd --system --user-group --home-dir /srv/mosaic --create-home --shell /usr/sbin/nologin mosaic
chmod 0700 /srv/mosaic
install -d -o root -g root -m 0755 /etc/mosaic-stack
install -d -o root -g root -m 0750 /var/log/mosaic-stack
```
`mosaic` gets no sudo rule and no supplementary group. Check with `sudo -l -U mosaic` (expect "not allowed
to run sudo") and `id -G mosaic` (one number).
Precheck lines:
- `ok HOME is /srv/mosaic`
- `ok sudo -n true fails as mosaic`
- `ok id -G prints one group (<gid>)`
- `/srv/mosaic` at 0700: precheck line missing.
## 2. node, pinned under /opt
The pin is the version and sha256 the window names. `<v>` below is that version, for example `v24.x.y`.
```sh
install -d -o root -g root -m 0755 /etc/mosaic-stack/node-keys /opt/mosaic-stack
cd "$(mktemp -d)"
curl -fsSLO https://nodejs.org/dist/<v>/node-<v>-linux-x64.tar.xz
curl -fsSLO https://nodejs.org/dist/<v>/SHASUMS256.txt
curl -fsSLO https://nodejs.org/dist/<v>/SHASUMS256.txt.asc
gpg --no-default-keyring --keyring /etc/mosaic-stack/node-keys/pubring.kbx --verify SHASUMS256.txt.asc SHASUMS256.txt
grep " node-<v>-linux-x64.tar.xz\$" SHASUMS256.txt | sha256sum -c -
echo "<pinned sha256> node-<v>-linux-x64.tar.xz" | sha256sum -c -
tar -xJf node-<v>-linux-x64.tar.xz --no-same-owner
mv node-<v>-linux-x64 /opt/mosaic-stack/node-<v>
chown -R root:root /opt/mosaic-stack/node-<v>
chmod -R go-w /opt/mosaic-stack/node-<v>
```
The keyring holds the Node release keys from the nodejs/release-keys repository, nothing else. Stop on any
failed check; don't unpack. Rollback: `rm -r /opt/mosaic-stack/node-<v>`.
Precheck lines:
- `ok node <v>` (version 24 or later only).
- The `--node` path in the args file must be a root-owned regular file outside the home. `case2-unit.sh`
refuses otherwise at render, which is not a precheck line.
- The installed version equals the pin: precheck line missing.
## 3. claude, factory only (.53)
Root installs it under `/opt`, never under the stack's home (this replaces r6's `/srv/mosaic/.local/bin`).
The pin is 2.1.304 unless the window names a newer one.
```sh
install -d -o root -g root -m 0755 /opt/mosaic-stack/claude-<pin> /etc/claude-code
npm install --prefix /opt/mosaic-stack/claude-<pin> @anthropic-ai/claude-code@<pin>
chown -R root:root /opt/mosaic-stack/claude-<pin>
printf '%s\n' '{"env":{"DISABLE_AUTOUPDATER":"1"}}' > /etc/claude-code/managed-settings.json
chown root:root /etc/claude-code/managed-settings.json
chmod 0644 /etc/claude-code/managed-settings.json
```
Rollback: `rm -r /opt/mosaic-stack/claude-<pin> /etc/claude-code/managed-settings.json`.
The args file's `--claude` names the binary under `/opt/mosaic-stack/claude-<pin>`. `case2-unit.sh`
refuses one that isn't a root-owned regular file outside the home.
Precheck lines:
- `ok claude replaces its prompt with --system-prompt-file (<n> input tokens)`. It runs the claude the bus
unit's `MOSAIC_CLAUDE_BIN` names, so it needs the model credential from section 9.
- Managed settings present with the autoupdater off: precheck line missing.
## 4. bubblewrap, AppArmor and the drift stop
Both hosts. These are r6's items, unchanged.
```sh
apt-get install bubblewrap=0.9.0-1ubuntu0.3
stat -c '%U:%G %a' /usr/bin/bwrap > /etc/mosaic-stack/bwrap.orig # expect root:root 755
cd "$(mktemp -d)"
apt-get download apparmor-profiles=4.0.1really4.0.1-0ubuntu0.24.04.9
dpkg-deb --fsys-tarfile apparmor-profiles_*.deb | tar -x ./usr/share/apparmor/extra-profiles/bwrap-userns-restrict
echo "11d39094f044f0cda0febb3ad517b830301da6b2ce929664af09ee9e4dd264f9 usr/share/apparmor/extra-profiles/bwrap-userns-restrict" | sha256sum -c -
install -o root -g root -m 0644 usr/share/apparmor/extra-profiles/bwrap-userns-restrict /etc/apparmor.d/bwrap-userns-restrict
apparmor_parser -r /etc/apparmor.d/bwrap-userns-restrict
dpkg-statoverride --update --add root mosaic 0750 /usr/bin/bwrap
```
`aa-status` then lists `bwrap` and `unpriv_bwrap` in enforce mode. Install the one profile file, never the
whole `apparmor-profiles` package, whose 20 other profiles would load at the next boot. Rollback, in
reverse order: `dpkg-statoverride --remove /usr/bin/bwrap`, then `chown` and `chmod` to the values in
`bwrap.orig`; `apparmor_parser -R /etc/apparmor.d/bwrap-userns-restrict`, then `rm` it;
`apt-get remove bubblewrap`.
The no-new-privs test must print `bwrap//&unpriv_bwrap (enforce)`:
```sh
systemd-run --wait --pipe --uid=mosaic --gid=mosaic -p NoNewPrivileges=yes -p ProtectSystem=strict \
-p ProtectHome=yes -p PrivateTmp=yes \
/usr/bin/bwrap --unshare-user --unshare-pid --ro-bind / / cat /proc/self/attr/current
```
The P9 checks: another user's `/usr/bin/bwrap --version` fails with Permission denied, `getent group mosaic`
lists no member, and `sysctl fs.protected_hardlinks` prints 1. `sysctl
kernel.apparmor_restrict_unprivileged_userns` prints 1 too (Ubuntu 24.04's default); leave it, so nothing
else on the host gains user namespaces.
The drift stop is row 240's (I4, #1746): `hostcheck.sh` and its pins in `/usr/local/lib/mosaic-stack/`, the
apt hook `/etc/apt/apt.conf.d/80mosaic-hostcheck`
(`DPkg::Post-Invoke { "/usr/local/lib/mosaic-stack/hostcheck.sh --enforce || true"; };`) and the boot unit
`mosaic-hostcheck.service`. Install them from row 240's run sheet lines once it lands. Until then
`stack-upgrade.sh` refuses `profile-drift` on every run, because it runs `hostcheck.sh --check` and a
missing file refuses.
Precheck lines:
- `ok bwrap --unshare-all runs`
- The AppArmor profile, the statoverride, the no-new-privs result and the P9 checks: precheck line missing.
A line that runs `/usr/local/lib/mosaic-stack/hostcheck.sh --check` would cover them once row 240 lands.
## 5. The port floor
Leave `net.ipv4.ip_unprivileged_port_start` at its default (1024) or higher. The Console's port is below
it and the unit gets `CAP_NET_BIND_SERVICE`, so no other `mosaic` process can bind that port.
Precheck line: `ok net.ipv4.ip_unprivileged_port_start <n> is above the Console's port <port>`.
## 6. The release transport
A release is a git bundle of `refs/heads/refactor` plus the 40-character commit id (I1, mos-1118 choice 2).
Mos's go names both, with the bundle's sha256. `<sha>` below is that id.
First install:
```sh
# Copy the bundle in, then check it before anything reads it.
echo "<bundle sha256> /root/<sha>.bundle" | sha256sum -c -
git bundle verify /root/<sha>.bundle
# Root's checkout. stack-upgrade.sh and case2-unit.sh run from here, never from a copy.
git clone -q -b refactor /root/<sha>.bundle /root/mosaic-stack
git -C /root/mosaic-stack checkout -q --detach <sha>
git -C /root/mosaic-stack rev-parse HEAD # must print <sha>
# The stack's user's copy of the bundle, and its own clone.
install -d -o mosaic -g mosaic -m 0700 /srv/mosaic/incoming /srv/mosaic/src
install -o mosaic -g mosaic -m 0600 /root/<sha>.bundle /srv/mosaic/incoming/<sha>.bundle
sudo -u mosaic -H git clone -q -b refactor /srv/mosaic/incoming/<sha>.bundle /srv/mosaic/src/mosaic-stack
```
Each upgrade, before `stack-upgrade.sh upgrade`:
```sh
echo "<bundle sha256> /root/<sha>.bundle" | sha256sum -c -
git bundle verify /root/<sha>.bundle
git -C /root/mosaic-stack fetch -q /root/<sha>.bundle refs/heads/refactor:refs/remotes/origin/refactor
git -C /root/mosaic-stack checkout -q --detach <sha>
install -o mosaic -g mosaic -m 0600 /root/<sha>.bundle /srv/mosaic/incoming/<sha>.bundle
/root/mosaic-stack/scripts/stack-upgrade.sh upgrade --instance dev --bundle /srv/mosaic/incoming/<sha>.bundle --sha <sha>
```
The driver fetches the bundle into the stack's user's clone and makes the copy itself. It refuses
`head-mismatch` when root's checkout isn't at `<sha>`. Usage, exits and the step log are in
`docs/TOOLS.md`, under "Root's upgrade driver". Each run needs Mos's go, and `--migrate` needs Jason present
(decision 96).
When a run doesn't exit 0, send Mos the exit and the last lines of `/var/log/mosaic-stack/upgrade-dev.log`:
- Exit 3: refused before anything stopped. The last line names the code. The stack's user's clone and the
new copy may already hold the new commit; the units still run the old one.
- Exit 1: the old copy runs and passed its gate. Either the driver restarted it after A2 or a refused A3
(A2f), or it rolled back after A3, A4 or A5.
- Exit 2: both units are stopped and the activation stays open, so the next run refuses at its plan until
it is finished with `confirm` or `revert`. One way here: a unit that doesn't stop within `--timeout`
keeps its old start time, A2f's gate fails it `stale-start`, and the driver stops both (duckworth-0055
N1). That is on purpose; a unit that won't stop is a reason to stop.
Precheck lines: none. Root's checkout at `<sha>` and the stack's user's clone: precheck line missing.
After install, `stack-copy.sh check --units external` reads the copy the units run.
## 7. The args file
`/etc/mosaic-stack/dev.args` holds the `case2-unit.sh` arguments, one per line, with the serve flags after a
line holding `--`. I2's install writes it; ops-01 doesn't write it by hand. It must be root's, with no group
or other write (`chmod 0644` or tighter). Before `--` it takes only `--user`, `--home`, `--business`,
`--copy` (exactly once), `--node`, `--profile`, `--claude` and `--pm`. `stack-upgrade.sh` changes only the
`--copy` line, and only after confirm passes.
Precheck line: none. `stack-upgrade.sh` refuses `args-unreadable` on any of these rules.
## 8. Enrollment, once per host
The machine-grant enrollment is Gizmoduck's design r6, commit f9039682e, section 3, steps E1-E4. ops-01's
Q-O1 is answered (mos-1125). The host side is the `tools/host/` files: `mosaic-keys`, `mosaic-keys-write`,
their sudoers line and the sshd drop-in `/etc/ssh/sshd_config.d/<nn>-mosaic-keys.conf`. The verbs that run
it (`hosts.enroll`, the `host-enroll` decision) are rows 234 and 235, not yet on refactor. This item has no
commands until they land.
## 9. Credential slots
Jason places these, all in one sitting per host. No value appears here or in any message. Check a placed
file with `stat -c '%U %a %n' PATH`, never `cat`. `<business>` is the host's business id.
### .53, factory
| Path | File | Mode | Owner | Profile | Precheck line |
|---|---|---|---|---|---|
| `/srv/mosaic/.config/mosaic-dev/secrets/` | `claude-session.env`, one line `CLAUDE_CODE_OAUTH_TOKEN=...` (Q185) | 0600 | mosaic | factory | `claude replaces its prompt with --system-prompt-file` runs claude with it. The file's mode and owner: precheck line missing. |
| `/srv/mosaic/.config/mosaic-dev/secrets/agents/<business>/<agent>/` | one file per service | 0600, dirs 0700 | mosaic | both | none. Filled only by `mosaic credentials issue`, or Approve after #436. Never by hand. |
### .54, library
| Path | File | Mode | Owner | Profile | Precheck line |
|---|---|---|---|---|---|
| `/srv/mosaic/.config/mosaic-dev/secrets/` | `claude-session.env`, one line `ZAI_API_KEY=...` | 0600 | mosaic | library | `<path> sets ZAI_API_KEY` |
| `/srv/mosaic/.config/mosaic-dev/secrets/` | the Console's proof file, the path the args file's `--proof-file` names | 0600 or 0400 | mosaic | library | none. `case2-unit.sh` refuses at render. Precheck line missing. |
| `/srv/mosaic/.config/mosaic-dev/secrets/<business>/` | `library-gitea` | 0600 | mosaic | library | C2-13b's `library.sync` (not landed) |
| `/srv/mosaic/.config/mosaic-dev/secrets/agents/<business>/<agent>/` | one file per service | 0600, dirs 0700 | mosaic | both | none. Filled only by `mosaic credentials issue`, or Approve after #436. Never by hand. |
The installer key, `<configDir>/secrets/hosts/<business>/<hostId>/installer`, is not a slot anyone places.
Enrollment (section 8) writes it under the enrolling stack's config directory and deletes it when the host
leaves.
## 10. Console start
None by hand. I2's `case2-unit.sh` enables both units, starts the bus, then the Console (Q189, mos-1122).
`stack-upgrade.sh` stops and starts both units itself.
+15
View File
@@ -256,6 +256,21 @@ r6 B2 as designed:
The I3 run sheet's hostcheck item points at this row. I3's A1 and its fake don't change.
### Carried from the I3 review (Filbert 29922 on #1735, sage-1727)
Row 233 lands with three non-blocking notes that must be fixed before ops-01. This row takes them, and that
widens its paths to `scripts/stack-upgrade.sh` and `packages/cli/tests/stack-upgrade.test.mjs`:
- N1: after A5 confirms, a failed rename of the new args file calls `finish 1 args-failed` while the new copy
is live (`stack-upgrade.sh:280-282` at 1a1dc80ca). TOOLS.md and the run sheet define exit 1 as "the old copy
runs". Give this case its own exit code, add it to the exit table in TOOLS.md and the run sheet, and add a
test that makes the rename fail after A5 and expects the new code.
- N4: run sheet section 3 doesn't put the pinned node's npm on root's PATH and doesn't name the `--claude`
path. The npm symlink `node_modules/.bin/claude` is what `case2-unit.sh:140` refuses. The sheet must name
the path the check accepts.
- N5: run sheet sections 8 and 10 get their precheck lines.
Filbert's N2 and N3 (no `$REPO` check, no signal handling between A2 and A5) are in DEFERRED.md, not here.
### Gate
Filbert approves the manifest. The test file, the cli suite and every `scripts/test-*.sh` are green on
+22
View File
@@ -216,6 +216,28 @@ at every gate. Started 2026-09-12 during the control board MVP.
READMEs, `docs/TOOLS.md` and Sage's suite recipe should use the glob
form wherever Node 24 is a target. Darkwing, S1 packet. (2026-10-04,
#1518)
- **`stack-upgrade.sh` doesn't check `$REPO`.** Nothing confirms the
source checkout is root-owned or that its tree is clean before the
upgrade builds from it. Filbert N2 on row 233 (29922, #1735).
(2026-10-21)
- **`stack-upgrade.sh` has no signal handling between A2 and A5.** A HUP,
INT or TERM in that stretch leaves the run failed closed, but recovery
is by hand. A trap that writes the stop point and the recovery step to
the log would make it a sheet step. Filbert N3 on row 233 (29922,
#1735). (2026-10-21)
- **`checkCreative` doesn't refuse `credentials`.** Row 213's
`checkCreative` (packages/business/src/role.mjs:143 at 2d2ad2760)
limits a creative role's tools, authority and messaging, but a role
file that gave companion or research a credential would still load.
The business file could then map a credential to that instance
(business.mjs:157). Nobody has traced whether a mapped credential
reaches the run's environment. The decision paths stay closed (M6),
but a creative role with `web` holding a live token is a wider channel
than design 4 means. The fix: refuse any `credentials` in
`checkCreative`, with a role.test.mjs case. It goes after row 213
lands, as a follow-up row or in the next row that edits role.mjs.
Webby N1 on row 213 r3 (REVIEW-r3.md, 3ab3c010b), mos-1160.
(2026-10-21)
## Queue
+2 -2
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 2638 by `queue render`. Do not edit between the markers; change the queue with `scripts/mosaic queue`.
Generated from `docs/plans/queue.json` revision 2641 by `queue render`. Do not edit between the markers; change the queue with `scripts/mosaic queue`.
| # | Piece | Owner | Issues | State | After | Gate | Brief | Note |
|---|---|---|---|---|---|---|---|---|
@@ -278,7 +278,7 @@ Generated from `docs/plans/queue.json` revision 2638 by `queue render`. Do not e
| 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 | — |
| 240 | Installer I4: hostcheck, the bwrap profile check, pins and boot unit, from r6 B2 (MVP, Q187, Vikunja #432; split from I3) | darkwing; reviewers filbert | #1746 | queued | 233 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) (sage) | `docs/plans/2026-10-20_installer-rows.md` § I4 hostcheck: the bwrap profile check, pins and boot unit | — |
| 241 | Row 237 follow-up: one decision lands once, a two-ticket broker land test and three mutants on the landing guards (megavolt-0069, sage-1651, P19 441) | megavolt; reviewers duckworth | #1747 | done | 237 done | Duckworth approves the manifest. The bus, seat and cli suites and every scripts/test-*.sh are green on Sage's gate, and the three mutants are killed. (sage) (sage) | `docs/plans/2026-10-20_agent-credentials.md` § Brief: row 237 follow-up, one decision lands once, with tests at the broker | Landed 5d4a61776 on refactor, pushed (base e65a3ab4d). Blobs from 3e3ed303e on 2 paths; patch-id a1649f80 equals git diff f5753c0e8..3e3ed303e on those paths; ls-tree modes equal; manifest e8266864 verifies on the landed files. Sage slot on bbf3b1ee8: M1-M3 killed; gate 15 packages 2797/0, 10 script suites 429/0. Duckworth approve 29870 rev 2628. |
| 242 | Row 237 follow-up N1: a retry after publish-timeout pushes once; hold the decision or kill and wait for the push, a slow-push retry test and a mutant (duckworth-0069, P19 440) | megavolt; reviewers duckworth | #1748 | in-review, round 1, request posted | 237 done | Duckworth approves the manifest. The bus and cli suites 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 237 follow-up N1, a retry after publish-timeout pushes once | — |
| 242 | Row 237 follow-up N1: a retry after publish-timeout pushes once; hold the decision or kill and wait for the push, a slow-push retry test and a mutant (duckworth-0069, P19 440) | megavolt; reviewers duckworth | #1748 | done | 237 done | Duckworth approves the manifest. The bus and cli suites 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 237 follow-up N1, a retry after publish-timeout pushes once | Landed 5fb736b92 on refactor, pushed (base 96bac588f). Blobs from 635c10924 on 3 paths; patch-id ceb741ee equals git diff 8fede5fc6..635c10924 on those paths; ls-tree modes equal; manifest 1fbfab6c verifies on the landed files. Megavolt M1/M2 killed on Sage's slot; Sage gate 15 packages 2826/0, 10 script suites 429/0. Duckworth approve 29889 rev 2639. |
<!-- mosaic-queue:end -->
+80 -9
View File
@@ -1,7 +1,7 @@
{
"version": 1,
"canonicalRoot": "/mnt/storage/src/mosaic-stack",
"revision": 2638,
"revision": 2641,
"rows": [
{
"id": 1,
@@ -23277,7 +23277,7 @@
"closes": [
1748
],
"state": "in-review",
"state": "done",
"previousState": null,
"required": false,
"requiredSince": null,
@@ -23342,18 +23342,24 @@
}
],
"duplicateRisk": false,
"receipts": []
"receipts": [
{
"reviewer": "duckworth",
"op": "duckworth-r242-record-1",
"at": "2026-10-21T03:48:36.391Z",
"verdict": "approve",
"comment": 29889,
"candidate": "1fbfab6c68e8d76d2d69b13c415257280d4fa8c5584e24d5dfd13dca1420a3da"
}
]
}
]
},
"claim": {
"seat": "megavolt",
"op": "megavolt-242-start-1"
},
"note": null,
"claim": null,
"note": "Landed 5fb736b92 on refactor, pushed (base 96bac588f). Blobs from 635c10924 on 3 paths; patch-id ceb741ee equals git diff 8fede5fc6..635c10924 on those paths; ls-tree modes equal; manifest 1fbfab6c verifies on the landed files. Megavolt M1/M2 killed on Sage's slot; Sage gate 15 packages 2826/0, 10 script suites 429/0. Duckworth approve 29889 rev 2639.",
"blockedReason": null,
"createdAt": "2026-10-21T01:54:53.103Z",
"updatedAt": "2026-10-21T03:46:05.721Z",
"updatedAt": "2026-10-21T04:13:55.780Z",
"updatedBy": "sage"
}
],
@@ -93273,6 +93279,71 @@
"receipt": "ok sage-242-request-1.outcome rev 2638 row 242 round 1 request sage-242-request-1 posted comment 29888"
},
"viewSha": "d7bf764deb95140e4b2748863b838f5598f57162a0388adcfb24b4ca6cb90449"
},
{
"rev": 2639,
"op": "duckworth-r242-record-1",
"verb": "review-record",
"args": {
"id": 242,
"verdict": "approve",
"comment": 29889,
"candidate": "1fbfab6c68e8d76d2d69b13c415257280d4fa8c5584e24d5dfd13dca1420a3da"
},
"by": "duckworth",
"at": "2026-10-21T03:48:36.391Z",
"semantics": 2,
"result": {
"row": 242,
"round": 1,
"verdict": "approve",
"receipt": "ok duckworth-r242-record-1 rev 2639 row 242 round 1 approve by duckworth (comment 29889)"
},
"viewSha": "80ea9e552b2edb1937fce5ff23bc6cf9b6b9e2381cbb2e5808d7c87acf3d4b45"
},
{
"rev": 2640,
"op": "sage-242-landed-note",
"verb": "note",
"args": {
"id": 242,
"text": "Landed 5fb736b92 on refactor, pushed (base 96bac588f). Blobs from 635c10924 on 3 paths; patch-id ceb741ee equals git diff 8fede5fc6..635c10924 on those paths; ls-tree modes equal; manifest 1fbfab6c verifies on the landed files. Megavolt M1/M2 killed on Sage's slot; Sage gate 15 packages 2826/0, 10 script suites 429/0. Duckworth approve 29889 rev 2639."
},
"by": "sage",
"at": "2026-10-21T04:13:30.071Z",
"semantics": 2,
"result": {
"row": 242,
"field": "note",
"receipt": "ok sage-242-landed-note rev 2640 row 242 note"
},
"viewSha": "b0997e13a14bd17120aef27778c84deacdb08149688d0399e035fb8bc365bc37"
},
{
"rev": 2641,
"op": "sage-242-done",
"verb": "move",
"args": {
"id": 242,
"to": "done",
"reason": null,
"candidate": null,
"evidence": null,
"issue": null
},
"by": "sage",
"at": "2026-10-21T04:13:55.780Z",
"semantics": 2,
"result": {
"row": 242,
"from": "in-review",
"to": "done",
"round": 1,
"issue": null,
"candidate": null,
"receipt": "ok sage-242-done rev 2641 row 242 in-review→done round 1"
},
"viewSha": "418b83170fd6c8ba7aa577b935f802b3a5dfead59be2260b11115206f324abbc"
}
]
}
+15 -4
View File
@@ -218,6 +218,9 @@ an operator inspect content and delivery evidence after an uncertain result.
| `credentials.issue` | `{agent,service,token,date}`; human only (row 199, #1697). Writes the token to `<configDir>/secrets/agents/<business>/<agent>/<service>` (0600, dirs 0700: tmp, fsync, rename), rewrites the agent's reference in the business file to `{"store":true,<rotateBy or expires>:date}`, then holds the token in memory, with no restart. Over an issued entry it is a rotate: the old value is gone from disk and memory when it returns. `service` is `gitea` or `vikunja` and the business file must already name it for the agent (`credential-unknown`). A token the bus already holds is `credential-leak`; a bad token `credential-format`; a bad date `credential-date`. Returns `{agent,verb,service,date,state}`, `verb` `issue` or `rotate`. The tester gets `tester-refused`. |
| `credentials.revoke` | `{agent,service?,reason}`; human, or the tester (Q173). Drops the entry from memory, then unlinks a store file; a `file` or `env` reference keeps its file and becomes `store`, so a restart loads nothing. Without `service`, every kind the agent has. Returns `{agent,verb,services:[{service,state}]}`. |
| `credentials.delete` | `{agent,reason}`; human only. `credentials.revoke` for every kind, then removes the agent's store dir; a file there other than a token is `credential-file`. While the agent or one of its threads holds a claim, `agent-running`. |
| `hosts.status` | No args; the human, the tester or the business's chief (row 234, #1740). `[{host,address,port,user,sudo,state,hostKeys,installerFingerprint,checkedAt,hostLineLeft}]` from the `hosts` records, with host keys as fingerprints. Never a key. |
| `hosts.check` | `{host}`; same callers. Runs the host's `list <user>` as `mosaic-installer` with the installer key and the pinned host keys, then sets `state` `ready` and `checkedAt` and writes `grant.host.checked`. A nonzero exit is `host-failed`, a record that changed during the run `host-changed`, a record with no installer key `host-not-enrolled`. |
| `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`. |
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
@@ -704,10 +707,10 @@ scope probes and rotation/expiry notifications; S2 never probes real services.
## Schema and verification
`schema.sql` is v3i, SHA-256
`6cde5232c57ac04dc6073112c0830c89cfc5e953d7a5727e7716263747fe8737`.
It has 16 tables, 71 triggers and five views. Source digest and a trusted full
sqlite_master digest are checked at open, alongside schema version `3i`, 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.
@@ -867,6 +870,14 @@ one transaction; a failure leaves the store at 3h. It runs only through
refuses a 3i store. The 3h fixture, `tests/fixtures/schema-3h.sql`, is pinned from
81f26551b's tree; no live store is used.
Schema 3j (row 234, #1740) adds only new objects: the `hosts` and `host_grants` tables, which
hold enrolled hosts and host grants, and the `events_grant_actor` trigger. The trigger refuses an
`action.allowed` event whose `body.action` or `body.operation` matches `grant.*` unless its actor
is `actor_role` NULL with `actor_run` 'grant', the broker's grant writer (#436b section 7). The
step runs in one transaction, and a failure leaves the store at 3i. The 3i fixture,
`tests/fixtures/schema-3i.sql`, is pinned from 8fede5fc6's tree, and `tests/grant-migration.test.mjs`
covers the step and the trigger.
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,
+31
View File
@@ -363,3 +363,34 @@ CREATE TABLE usage_records (
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;
+400 -17
View File
@@ -3,7 +3,7 @@ import { isAbsolute } from 'node:path';
import { NAME as HARNESS_NAME } from '../../plugins/src/kinds.mjs';
import { loadRequirements } from '../../tasks/src/requirements.mjs';
import { MERGE, LandingRefusal, readLanding } from './landing.mjs';
import { ID_PATTERN, THREAD_DEFINITION, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed } from '../../business/src/vocabulary.mjs';
import { ID_PATTERN, THREAD_DEFINITION, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID } from '../../business/src/vocabulary.mjs';
// Kept closed while S1 lands; its vocabulary is the integration authority.
export const ACTIONS = Object.freeze([
@@ -182,6 +182,52 @@ const STAFF_COUNT = 20;
// Row 93 (#1587): the review loop's evidence, written only by the verbs below. A push is an
// action.allowed for git.push.working that names its commit, written only by recordPush.
const REVIEW_KINDS = ['review.requested', 'review.verdict'];
// Row 234 (#1740, #436b section 7): grant evidence is an action.allowed whose body.action or
// body.operation starts with grant., written only by #grantEvent under this actor. No session has a
// NULL role with a non-NULL run, and the 3j trigger refuses any other actor.
const GRANT_RUN = 'grant';
const grantName = (body) => [body?.action, body?.operation].some(n => typeof n === 'string' && n.startsWith('grant.'));
// Every grant-evidence read filters on this (duckworth-0067 N1): a look-alike under another kind or
// actor is not evidence.
const GRANT_EVIDENCE = "kind='action.allowed' AND actor_role IS NULL AND actor_run='grant'";
// r6 R4: the host record's identity, hashed at raise and at each later check.
export const registrySha256 = ({ address, port, user, sudo, hostKeys }) =>
createHash('sha256').update(JSON.stringify({ address, port, user, sudo: !!sudo, hostKeys })).digest('hex');
// Row 234 (#1740, #436b section 7): the one writer of grant evidence, session-less, as actor (NULL,
// 'grant'). The broker's #grantEvent calls it after its secret check, and the tests write their fixtures
// through it (duckworth-0079 F1). No verb reaches it. Only the consume record {operation, decision}
// names the decision as `decision`, so #spent counts it and nothing else. Every other body has a
// grant.* action, names the decision grantDecision, and body.by names the caller the broker checked.
export function writeGrantEvent(store, at, business, body, subject = null) {
const consume = body?.operation !== undefined;
if (
!grantName(body) ||
(consume
? Object.keys(body).length !== 2 || typeof body.decision !== 'string' || !body.operation.startsWith('grant.')
: typeof body.action !== 'string' || !body.action.startsWith('grant.') || 'decision' in body)
)
throw new Error('grant event shape');
const eid = randomUUID();
store.run(
'INSERT INTO events(id,at,business,kind,actor_role,actor_run,subject,body) VALUES(?,?,?,?,?,?,?,?)',
eid,
at,
business,
'action.allowed',
null,
GRANT_RUN,
subject,
JSON.stringify(body),
);
return eid;
}
export const HOSTS_VERBS = Object.freeze(['hosts.status', 'hosts.check', 'hosts.remove']);
// The OpenSSH fingerprint of a "<type> <base64>" host key line. host-grant.mjs has the same; the broker
// doesn't import it, so the sandbox's RUNNER_FILES stay as they are.
const fingerprintOf = (line) =>
'SHA256:' + createHash('sha256').update(Buffer.from(line.split(' ')[1], 'base64')).digest('base64').replace(/=+$/, '');
// r6 E3: the installer line's comment, then the tag's 32 hex digits.
const INSTALLER_COMMENT = 'mosaic-installer-';
// Row 121 (#1615): an instance of THREAD_DEFINITION (vocabulary.mjs since row 155) talks with the
// user in threads. Its runs claim a thread, not the role, and every message to or from it names its
// thread (REQ-MSG-1).
@@ -224,11 +270,13 @@ export class Broker {
#usage;
#limits;
#harnessOn;
// Row 234 (#1740): the trusted enrollment half (host-grant.mjs), or null where hosts are off.
#hostGrant;
// Row 125 (#1619, REQ-LIMIT-4): a run a limit stops, by business:run, until its next receive or state.
#stops = new Map();
#clock = 0;
// credentialStatus is the runtime's metadata-only Credentials.status(); the broker never gets use().
constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, usage = null, limits = null, harnessOn = null, credentialVerbs = null }) {
constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, usage = null, limits = null, harnessOn = null, credentialVerbs = null, hostGrant = null }) {
if (!object(businesses) || !Object.keys(businesses).length) fail('invalid-business');
this.#businesses = structuredClone(businesses);
this.#store = store;
@@ -237,6 +285,7 @@ export class Broker {
this.#organization = organization;
// Row 199: the runtime's credential lifecycle (credential-verbs.mjs); it holds the store, not the broker.
this.#credentialVerbs = credentialVerbs;
this.#hostGrant = hostGrant;
this.#project = project;
this.#usage = usage;
this.#limits = limits;
@@ -604,9 +653,323 @@ export class Broker {
);
return eid;
}
// Row 234 (#1740): grant evidence, through writeGrantEvent. Caller owns the transaction.
#grantEvent(business, body, subject = null) {
this.#secretCheck({ kind: 'action.allowed', body, subject });
return writeGrantEvent(this.#store, this.#now(), business, body, subject);
}
// PRD 1.6 REQ-WAIT-1: a hard wait goes to the human at every class, cited or not. Jason's ruling
// Q169 makes a role.revoke whose target's definition is in END_PROTECTED one, so no arbiter
// resolves a revoke of the assistant.
// Row 234 (#1740, r6 X1): the raise's grant binding, if the human's cli resolve chose its approval
// option on a credential.mint routed to the human. Null otherwise: an arbiter, the tester or an agent
// closes with via 'broker'.
#approvedGrant(business, decision) {
const d = this.#store.get('SELECT action,route_to FROM decisions WHERE business=? AND id=?', business, decision);
const raise = this.#raised(business, decision);
if (d?.action !== 'credential.mint' || d.route_to !== 'human' || !raise?.grant) return null;
const r = this.#closed(decision);
if (r?.op !== 'resolved' || r.choice !== raise.approvalChoice || r.via !== 'cli' || r.by !== this.#business(business).human)
return null;
return raise.grant;
}
// r6 X1: the executor's own spend. The caller owns the transaction. The consume record is the only
// guarded body with `decision` (#436b section 7), so #spent counts it.
#consumeGrant(business, decision, method) {
const grant = this.#approvedGrant(business, decision);
if (grant?.method !== method) fail('not-authorized');
if (this.#spent(business, decision)) fail('decision-consumed');
this.#grantEvent(business, { operation: 'grant.' + method, decision }, decision);
return grant;
}
#hostRecord(business, host) {
const r = this.#store.get('SELECT * FROM hosts WHERE business=? AND host=?', business, host);
return r && { ...r, sudo: r.sudo === 1, hostKeys: JSON.parse(r.host_keys) };
}
// r6 X4 first bullet, the Approve record check: the host record and the session's grant record, whose
// decision is a credential.mint with a `host` grant, resolved yes by the human via cli and spent by
// #consumeGrant as grant.host. Read at each call, so a grant, a revoke and a hosts.remove need no reload.
#hostAccess(s, host) {
const r = this.#hostRecord(s.business, host);
if (!r) fail('host-unknown');
const g = this.#store.get('SELECT * FROM host_grants WHERE business=? AND agent=? AND host=?', s.business, s.role, host);
const spent = g && this.#store.get(
`SELECT 1 FROM events WHERE business=? AND ${GRANT_EVIDENCE} AND json_extract(body,'$.operation')='grant.host' AND json_extract(body,'$.decision')=? LIMIT 1`,
s.business,
g.decision,
);
// The grant must name this agent and host too, so one approval can't stand behind another record.
const grant = spent && this.#approvedGrant(s.business, g.decision);
if (grant?.method !== 'host' || grant.agent !== s.role || grant.host !== host) fail('host-not-granted');
return { entry: { address: r.address, port: r.port, user: r.user, hostKeys: r.hostKeys }, commands: JSON.parse(g.commands) };
}
// A grant outcome, from the bus, to the human's inbox and to the raising role, threaded as a
// close is.
#grantNotice(business, decision, body) {
const d = this.#store.get('SELECT raised_by_role,raised_by_run FROM decisions WHERE id=?', decision);
const thread = this.#threaded(business, d.raised_by_role)
? (this.#store.get(
"SELECT thread FROM thread_claims WHERE business=? AND holder_run=? AND op='claim' ORDER BY seq DESC LIMIT 1",
business,
d.raised_by_run,
)?.thread ?? null)
: null;
for (const [to, t] of [['human', null], [d.raised_by_role, thread]]) {
const mid = randomUUID();
this.#store.run(
'INSERT INTO messages(id,at,business,from_role,from_run,to_role,class,in_reply_to,decision,corrects,body) VALUES(?,?,?,?,?,?,?,?,?,?,?)',
mid,
this.#now(),
business,
NOTICE_ROLE,
NOTICE_RUN,
to,
'RESULT',
null,
decision,
null,
body,
);
this.#route(business, mid, to, t);
}
}
#enrollFailed(business, decision, host, code, extra = {}) {
this.#store.run('DELETE FROM hosts WHERE business=? AND host=? AND decision=?', business, host, decision);
this.#grantEvent(business, { action: 'grant.failed', grantDecision: decision, host, code, ...extra }, host);
this.#grantNotice(business, decision, `Enrollment of host ${host} (decision ${decision}) failed: ${code}.`);
}
// Trusted host API, as reconcileGrants is, and no verb reaches it: the executor's entry for one
// decision. Step 1 checks everything again, so a second start for a spent decision ends
// decision-consumed, and an unresolved or agent-resolved one ends not-authorized, with nothing
// written (r6 G10).
runEnrollment(business, decision) {
return this.#runEnrollment(business, decision);
}
// r6 E2, started by the human's cli yes. Never throws: the outcome is in the store and the notices,
// and the return value is its code, 'enrolled' or the refusal.
async #runEnrollment(business, decision) {
let grant;
try {
// Step 1, one transaction: spend, the not-enrolled check, the registry step and the record.
grant = this.#store.transaction(() => {
const g = this.#consumeGrant(business, decision, 'host-enroll');
if (this.#hostRecord(business, g.host)) {
this.#grantEvent(business, { action: 'grant.failed', grantDecision: decision, host: g.host, code: 'host-enrolled' }, g.host);
this.#grantNotice(business, decision, `Enrollment of host ${g.host} (decision ${decision}) failed: host-enrolled.`);
return null;
}
this.#grantEvent(business, { action: 'grant.host.enroll.step', grantDecision: decision, host: g.host, step: 'registry' }, g.host);
this.#store.run(
'INSERT INTO hosts(business,host,address,port,user,sudo,host_keys,installer_fingerprint,state,decision,checked_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)',
business, g.host, g.address, g.port, g.user, Number(g.sudo), JSON.stringify(g.hostKeys), null, 'pending-install', decision, null,
);
return g;
});
} catch (e) {
// Spent, not approved or no store: nothing was written. The start reconcile ends a resolved,
// unspent decision.
return e instanceof BusError ? e.code : 'host-failed';
}
if (!grant) return 'host-enrolled';
const host = grant.host;
try {
if (!this.#hostGrant) fail('host-not-configured');
// Steps 2 and 3: the key pair, and the private half to its file.
const key = await this.#hostGrant.enroll(business, host);
// Step 4.
this.#store.transaction(() => {
const r = this.#hostRecord(business, host);
if (r?.decision !== decision) fail('host-changed');
this.#store.run('UPDATE hosts SET installer_fingerprint=? WHERE business=? AND host=?', key.fingerprint, business, host);
this.#grantEvent(
business,
{
action: 'grant.host.enrolled',
grantDecision: decision,
host,
registrySha256: registrySha256(r),
installerFingerprint: key.fingerprint,
user: r.user,
sudo: r.sudo,
},
host,
);
this.#grantNotice(
business,
decision,
`Host ${host} is enrolled (decision ${decision}) and pending install. ops-01 writes this line to ` +
`/etc/ssh/mosaic-keys/mosaic-installer, with <from> the value in /etc/mosaic-keys/from:\n` +
`restrict,command="/usr/local/sbin/mosaic-keys",from="<from>" ${key.publicLine} ` +
`${INSTALLER_COMMENT}${key.tag.slice('mosaic-'.length)}\n` +
`/etc/mosaic-keys/users lists ${r.user}.` +
(r.sudo ? ` /etc/mosaic-keys/privileged lists ${r.user}.` : ` /etc/mosaic-keys/privileged doesn't list ${r.user}.`) +
`\nThen run: mosaic hosts check ${host}`,
);
});
return 'enrolled';
} catch (e) {
const code = e instanceof BusError ? e.code : 'host-failed';
try {
this.#hostGrant?.unenroll(business, host);
} catch {}
try {
this.#store.transaction(() => this.#enrollFailed(business, decision, host, code));
} catch {}
return code;
}
}
// r6 X3 and duckworth-0067 N1: credential.mint decisions for `method`, resolved yes by the human via
// cli and never spent.
#openGrantDecisions(business, method) {
return this.#store
.all("SELECT id FROM decisions WHERE business=? AND action='credential.mint' AND route_to='human' ORDER BY at,id", business)
.map((d) => d.id)
.filter((id) => this.#approvedGrant(business, id)?.method === method && !this.#spent(business, id));
}
// Trusted host API, at start before the socket opens (r6 X3). An enrollment spent with no
// grant.host.enrolled or grant.failed died after its registry step: the key file and the record
// go, and it ends grant.failed interrupted. A resolved, unspent one is spent and ends the same way.
// Neither runs late.
reconcileGrants() {
const ended = [];
for (const business of Object.keys(this.#businesses)) {
const spent = this.#store.all(
`SELECT json_extract(body,'$.decision') AS decision FROM events WHERE business=? AND ${GRANT_EVIDENCE} AND json_extract(body,'$.operation')='grant.host-enroll' ORDER BY seq`,
business,
);
for (const { decision } of spent) {
const end = this.#store.get(
`SELECT 1 FROM events WHERE business=? AND ${GRANT_EVIDENCE} AND json_extract(body,'$.grantDecision')=? AND json_extract(body,'$.action') IN ('grant.host.enrolled','grant.failed') LIMIT 1`,
business,
decision,
);
if (end) continue;
const host = this.#raised(business, decision).grant.host;
this.#hostGrant?.unenroll(business, host);
this.#store.transaction(() =>
this.#enrollFailed(business, decision, host, 'interrupted', { tag: this.#hostGrant?.installerTag(business, host) ?? null }),
);
ended.push(decision);
}
for (const decision of this.#openGrantDecisions(business, 'host-enroll')) {
this.#store.transaction(() => {
const g = this.#consumeGrant(business, decision, 'host-enroll');
this.#grantEvent(business, { action: 'grant.failed', grantDecision: decision, host: g.host, code: 'interrupted', tag: this.#hostGrant?.installerTag(business, g.host) ?? null }, g.host);
this.#grantNotice(business, decision, `Enrollment of host ${g.host} (decision ${decision}) failed: interrupted.`);
});
ended.push(decision);
}
}
return ended;
}
// r6 E1, R3: the raise checks and the keyscan, then the raise transaction.
async #raiseGrant(s, a) {
try {
this.#store.transaction(() => this.#agent(s));
if (a.action !== 'credential.mint' || 'spec_path' in a || 'spec_commit' in a) fail('invalid-request');
if (this.#business(s.business).roles[s.role]?.definition !== STAFF_TO) fail('grant-chief-only');
if (!this.#hostGrant) fail('host-not-configured');
const enroll = this.#hostGrant.enrollCheck(a.grant);
if (this.#hostRecord(s.business, enroll.host)) fail('host-enrolled');
const hostKeys = await this.#hostGrant.keyscan(enroll.address, enroll.port);
if (!this.#hostGrant.sameFingerprints(hostKeys, enroll.fingerprints)) fail('host-key-mismatch');
return this.#store.transaction(() => {
if (this.#hostRecord(s.business, enroll.host)) fail('host-enrolled');
const result = this.#dispatch(s, 'decision.raise', a, null, { ...enroll, hostKeys });
this.#secretCheck(result);
return result;
});
} catch (e) {
throw this.#refused(s, e, { verb: 'decision.raise', args: a });
}
}
#hostsCaller(s, verb) {
if (s.reader) fail('read-only');
if (s.tester) return 'tester';
if (s.human) {
this.#human(s);
return s.human;
}
// hosts.remove takes access away, as a revoke does: the human or the tester (r6 E4).
if (verb === 'hosts.remove') fail('human-required');
this.#store.transaction(() => this.#agent(s));
if (this.#business(s.business).roles[s.role]?.definition !== STAFF_TO) fail('grant-chief-only');
return s.role;
}
// r6 E4: hosts.status, hosts.check and hosts.remove. Async, because check runs ssh.
async #requestHosts(s, verb, a) {
try {
const by = this.#hostsCaller(s, verb);
if (verb === 'hosts.status') {
keys(a, []);
return this.#store
.all('SELECT host FROM hosts WHERE business=? ORDER BY host', s.business)
.map(({ host }) => {
const r = this.#hostRecord(s.business, host);
return {
host,
address: r.address,
port: r.port,
user: r.user,
sudo: r.sudo,
state: r.state,
hostKeys: r.hostKeys.map(fingerprintOf),
installerFingerprint: r.installer_fingerprint,
checkedAt: r.checked_at,
hostLineLeft: this.#hostLinesLeft(s.business, host),
};
});
}
keys(a, ['host'], ['host']);
if (typeof a.host !== 'string' || !HOST_ID.test(a.host)) fail('invalid-request');
if (!this.#hostGrant) fail('host-not-configured');
if (verb === 'hosts.remove') {
return this.#store.transaction(() => {
const r = this.#hostRecord(s.business, a.host);
if (!r) fail('host-unknown');
if (this.#store.get('SELECT 1 FROM host_grants WHERE business=? AND host=? LIMIT 1', s.business, a.host)) fail('host-in-use');
if (this.#hostLinesLeft(s.business, a.host).length) fail('host-line-left');
this.#store.run('DELETE FROM hosts WHERE business=? AND host=?', s.business, a.host);
this.#grantEvent(s.business, { action: 'grant.host.removed', host: a.host, registrySha256: registrySha256(r), by }, a.host);
// Last, so a key file that won't go rolls the record and the event back.
this.#hostGrant.unenroll(s.business, a.host);
return { host: a.host, removed: true };
});
}
const before = this.#hostRecord(s.business, a.host);
if (!before) fail('host-unknown');
if (!before.installer_fingerprint) fail('host-not-enrolled');
const out = await this.#hostGrant.check(s.business, a.host, before);
if (out.exit !== 0) fail('host-failed');
return this.#store.transaction(() => {
const r = this.#hostRecord(s.business, a.host);
const sha = r && registrySha256(r);
if (!r || r.decision !== before.decision || sha !== registrySha256(before) || out.installerFingerprint !== r.installer_fingerprint)
fail('host-changed');
const at = this.#now();
this.#store.run("UPDATE hosts SET state='ready',checked_at=? WHERE business=? AND host=?", at, s.business, a.host);
this.#grantEvent(
s.business,
{ action: 'grant.host.checked', host: a.host, registrySha256: sha, installerFingerprint: r.installer_fingerprint, by },
a.host,
);
return { host: a.host, state: 'ready', checkedAt: at };
});
} catch (e) {
throw this.#refused(s, e, { verb, args: a });
}
}
// r6 E4, duckworth-0079 F2 and F3: every guarded body that left a line on the host names the host.
// Row b adds the sweep clear (F4).
#hostLinesLeft(business, host) {
return this.#store
.all(
`SELECT json_extract(body,'$.hostLineLeft') AS tag FROM events WHERE business=? AND ${GRANT_EVIDENCE} AND json_extract(body,'$.host')=? AND json_extract(body,'$.hostLineLeft') IS NOT NULL ORDER BY seq`,
business,
host,
)
.map((e) => e.tag);
}
#hardWait(s, action, target) {
if (GATED.has(action)) return true;
return action === 'role.revoke' && END_PROTECTED.includes(this.#business(s.business).roles[target]?.definition);
@@ -1246,6 +1609,7 @@ export class Broker {
this.#agent(s);
if (['human.input', 'launch.revoked', 'launch.restored', ...REVIEW_KINDS].includes(kind)) fail('reserved-event');
if (kind === 'action.allowed' && ['git.push.working', MERGE].includes(body?.action)) fail('reserved-event');
if (kind === 'action.allowed' && grantName(body)) fail('reserved-event');
return this.#event(s, kind, body, subject);
});
}
@@ -1371,6 +1735,9 @@ export class Broker {
return this.#requestUsage(s, request.verb, structuredClone(args));
if ((request.verb === 'message.receive' || request.verb === 'state') && this.#stops.has(s.business + ':' + s.run))
this.#limitStop(s);
// Row 234 (#1740, r6 E1): a raise with `grant` runs its keyscan before the raise transaction.
if (request.verb === 'decision.raise' && 'grant' in args) return this.#raiseGrant(s, structuredClone(args));
if (HOSTS_VERBS.includes(request.verb)) return this.#requestHosts(s, request.verb, structuredClone(args));
if (request.verb === 'spec.read' || request.verb === 'record.write' ||
(request.verb === 'decision.raise' && ('spec_path' in args || 'spec_commit' in args)))
return this.#requestProject(s, { verb: request.verb, args: structuredClone(args) });
@@ -1727,21 +2094,18 @@ export class Broker {
a = request.args ?? {};
keys(a, ['host', 'argv', 'stdin', 'timeout', 'decision'], ['host', 'argv']);
this.#secretCheck(a);
this.#store.transaction(() => this.#agent(s));
const b = this.#business(s.business);
identifier(a.host);
if (!Object.hasOwn(b.hosts ?? {}, a.host)) fail('host-unknown');
const grant = Object.hasOwn(b.roles[s.role]?.hosts ?? {}, a.host) ? b.roles[s.role].hosts[a.host] : null;
if (!grant) fail('host-not-granted');
// Row 234 (#1740, r6 X4): the store records, not the business file.
const access = this.#store.transaction(() => (this.#agent(s), this.#hostAccess(s, a.host)));
if (!validArgv(a.argv) || !validStdin(a.stdin)) fail('invalid-request');
if (!commandAllowed(a.argv, grant.commands)) fail('command-not-granted');
if (!commandAllowed(a.argv, access.commands)) fail('command-not-granted');
timeout = a.timeout ?? HOST_TIMEOUT_DEFAULT;
if (!Number.isInteger(timeout) || timeout < 1 || timeout > HOST_TIMEOUT_MAX) fail('invalid-request');
if (a.decision !== undefined) identifier(a.decision);
if (!executor) fail('host-not-configured');
// A busy host refuses before the decision is spent; the executor checks again under its lock.
if (executor.busy(s, a.host)) fail('host-busy');
entry = b.hosts[a.host];
entry = access.entry;
bound = hostRun(a.host, a.argv, a.stdin);
this.#store.transaction(() =>
this.#consumeAuthority(
@@ -1793,7 +2157,7 @@ export class Broker {
});
return out;
}
#dispatch(s, verb, a, spec = null) {
#dispatch(s, verb, a, spec = null, enroll = null) {
const b = this.#business(s.business),
by = s.human || s.run;
switch (verb) {
@@ -1898,9 +2262,12 @@ export class Broker {
'spec_commit',
'argv',
'stdin',
'grant',
],
['action', 'question', 'options', 'recommendation', 'blocking'],
);
// Only #raiseGrant passes `enroll`, after its checks and keyscan.
if (('grant' in a) !== (enroll !== null)) fail('invalid-request');
this.#agent(s);
if ((a.spec_path !== undefined || a.spec_commit !== undefined) && !spec) fail('invalid-spec-citation');
const cls = this.#classification(s, a.action, a.target);
@@ -1961,18 +2328,29 @@ export class Broker {
if (!sudo && (a.argv !== undefined || a.stdin !== undefined)) fail('invalid-request');
let question = pushCommit ? `${a.question} (commit ${pushCommit})` : a.question;
if (sudo) {
if (a.target === undefined || !Object.hasOwn(b.hosts ?? {}, a.target)) fail('host-unknown');
if (!validArgv(a.argv) || !validStdin(a.stdin)) fail('invalid-request');
if (a.target === undefined) fail('host-unknown');
// The human is asked only about a call host.run would take: the raiser's own grant and prefix (N1).
const grant = Object.hasOwn(b.roles[s.role]?.hosts ?? {}, a.target) ? b.roles[s.role].hosts[a.target] : null;
if (!grant) fail('host-not-granted');
if (!commandAllowed(a.argv, grant.commands)) fail('command-not-granted');
const h = b.hosts[a.target];
// Row 234 (r6 X4): read from the store records, with the Approve record check.
const access = this.#hostAccess(s, a.target);
if (!validArgv(a.argv) || !validStdin(a.stdin)) fail('invalid-request');
if (!commandAllowed(a.argv, access.commands)) fail('command-not-granted');
const h = access.entry;
question +=
`\n\nHost ${a.target}, ${h.user} at ${h.address} port ${h.port}, runs as root:` +
`\nargv: ${a.argv.map((x) => JSON.stringify(x)).join(' ')}` +
`\nstdin: ${JSON.stringify(a.stdin ?? '')}`;
}
// Row 234 (#1740, r6 E1): the human sees each scanned key with its fingerprint, and the sudo
// mark in plain words. The chief can't shape this block.
if (enroll) {
question +=
`\n\nEnroll host ${enroll.host}: user ${enroll.user} at ${enroll.address} port ${enroll.port}.` +
`\nHost keys from ssh-keyscan, equal to the requested fingerprints:` +
enroll.hostKeys.map((k) => `\n${fingerprintOf(k)} ${k}`).join('') +
(enroll.sudo
? `\n${enroll.user} has sudo on this host. Approving lets later grants for ${enroll.user} be installed, and section 9's first limit applies to ${enroll.user}.`
: `\n${enroll.user} has no sudo on this host.`);
}
string(question);
const did = randomUUID();
this.#store.run(
@@ -2010,6 +2388,7 @@ export class Broker {
a.action === 'role.revoke' ? (this.#holder(s.business, a.target)?.holder_run ?? null) : null,
...(pushCommit ? { pushCommit } : {}),
...(sudo ? { hostRun: hostRun(a.target, a.argv, a.stdin) } : {}),
...(enroll ? { grant: enroll } : {}),
},
a.task_ref ?? (taskRef(a.target) ? a.target : did),
);
@@ -2060,6 +2439,10 @@ export class Broker {
a.note ?? null,
s.human ? 'cli' : 'broker',
);
// Row 234 (#1740, r6 X1): the human's cli approve of a grant mint starts the executor once this
// transaction commits. It rechecks all of this itself, so a rollback starts nothing.
const grant = op === 'resolved' ? this.#approvedGrant(s.business, d.id) : null;
if (grant?.method === 'host-enroll') setImmediate(() => this.#runEnrollment(s.business, d.id));
if (s.human)
this.#event(
s,
@@ -2103,7 +2486,7 @@ export class Broker {
);
this.#route(s.business, mid, d.raised_by_role, thread);
}
return { id: d.id, op };
return grant ? { id: d.id, op, grant: 'started' } : { id: d.id, op };
}
case 'message.send': {
keys(a, ['to', 'body', 'class', 'in_reply_to', 'corrects', 'decision'], ['to', 'body']);
+1 -1
View File
@@ -98,7 +98,7 @@ export function assertCleanKey(value, key) {
if (key.needles.some((n) => encoded.includes(n))) deny('credential-leak');
}
// Row 199 (#1697, design D1, D4): a store dir is the stack instance's user's own and nobody else's.
const privateDir = (path, { missing = false } = {}) => {
export const privateDir = (path, { missing = false } = {}) => {
let st;
try {
st = lstatSync(path);
+217
View File
@@ -0,0 +1,217 @@
// Row 234 (#1740, #436a row a): the trusted half of host enrollment, run in the bus child. The broker
// gets it as `hostGrant` and holds every store write. This module holds what touches the network or
// a key file: the raise's ssh-keyscan (E1), the installer key pair (E2 steps 2 and 3) and the installer's
// `list` for hosts.check (E4).
//
// The installer key lives at <configDir>/secrets/hosts/<business>/<hostId>/installer, 0600 in 0700 dirs,
// written as 210's issue writes (tmp, fsync, rename). It is never read into an event or a message.
import { spawn } from 'node:child_process';
import { createHash } from 'node:crypto';
import {
closeSync, constants, fsyncSync, mkdirSync, mkdtempSync, openSync, readFileSync, renameSync, rmSync, rmdirSync,
unlinkSync, writeSync,
} from 'node:fs';
import { dirname, join } from 'node:path';
import { BusError } from './broker.mjs';
import { parseHostKey, privateDir } from './credentials.mjs';
import { runHost, HOST_LIMITS } from './host-run.mjs';
import { checkAddress, HOST_KEY, USER } from '../../business/src/hosts.mjs';
import { HOST_ID } from '../../business/src/vocabulary.mjs';
const deny = (code) => {
throw new BusError(code);
};
const PATH = '/usr/bin:/bin';
const KILL_WITH_PARENT = ['/usr/bin/setpriv', '--pdeathsig', 'KILL', '--'];
// E1: the users no enrollment may name. The host writer refuses them again (tools/host/mosaic-keys-write).
export const RESERVED_USERS = Object.freeze(['root', 'localadmin', 'mosaic-installer']);
const FINGERPRINT = /^SHA256:[A-Za-z0-9+/]{43}$/;
// E1: the three key types the keyscan asks for, so any other fingerprint can't match.
const SCAN_TYPES = 'ed25519,ecdsa,rsa';
export const INSTALLER_USER = 'mosaic-installer';
const INSTALLER_AGENT = '@installer';
const SCAN_MS = 10000;
// X2 step 2: `mosaic-` and the first 32 hex digits of sha256 over business, agent and host id joined
// with NUL. Names match ID_PATTERN or HOST_ID, neither holds NUL, so the join is one to one.
export function grantTag(business, agent, hostId) {
return 'mosaic-' + createHash('sha256').update([business, agent, hostId].join('\0')).digest('hex').slice(0, 32);
}
export const installerTag = (business, hostId) => grantTag(business, INSTALLER_AGENT, hostId);
// The OpenSSH fingerprint of a "<type> <base64>" line: sha256 over the decoded blob.
export function fingerprint(line) {
const blob = Buffer.from(line.split(' ')[1], 'base64');
return 'SHA256:' + createHash('sha256').update(blob).digest('base64').replace(/=+$/, '');
}
// E1's binding. Anything off refuses invalid-request; the broker adds the chief, action and enrolled
// checks. Returns a fresh object with only the bound keys.
export function enrollCheck(grant) {
const allowed = ['method', 'host', 'address', 'port', 'user', 'sudo', 'fingerprints'];
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 !== 'host-enroll') deny('invalid-request');
if (typeof grant.host !== 'string' || !HOST_ID.test(grant.host)) deny('invalid-request');
try {
checkAddress(grant.address, 'grant.address');
} catch {
deny('invalid-request');
}
if (!Number.isInteger(grant.port) || grant.port < 1 || grant.port > 65535) deny('invalid-request');
if (typeof grant.user !== 'string' || !USER.test(grant.user) || RESERVED_USERS.includes(grant.user)) deny('invalid-request');
if (typeof grant.sudo !== 'boolean') deny('invalid-request');
const prints = grant.fingerprints;
if (!Array.isArray(prints) || prints.length < 1 || prints.length > 3) deny('invalid-request');
if (prints.some((f) => typeof f !== 'string' || !FINGERPRINT.test(f)) || new Set(prints).size !== prints.length) deny('invalid-request');
return { method: 'host-enroll', host: grant.host, address: grant.address, port: grant.port, user: grant.user, sudo: grant.sudo, fingerprints: [...prints] };
}
// The keyscan's output lines, "<host> <type> <base64>", as "<type> <base64>". Comment lines and any
// other type drop out. A key listed twice counts once.
export function scanLines(stdout) {
const keys = [];
for (const line of stdout.split('\n')) {
if (!line || line.startsWith('#')) continue;
const parts = line.trim().split(/\s+/);
if (parts.length !== 3) continue;
const key = parts[1] + ' ' + parts[2];
if (HOST_KEY.test(key) && !keys.includes(key)) keys.push(key);
}
return keys;
}
// E1: `ssh-keyscan -T 5 -p <port> -t ed25519,ecdsa,rsa <address>` from the stack host. It reads public
// keys and logs in to nothing. A scan that can't start refuses host-failed. One that runs and gets no key,
// as from a host that doesn't answer, returns no lines, so the raise refuses host-key-mismatch
// (duckworth-0081 N3).
export function keyscan(address, port, { bin = 'ssh-keyscan', limitMs = SCAN_MS } = {}) {
return new Promise((resolve, reject) => {
const child = spawn(KILL_WITH_PARENT[0], [...KILL_WITH_PARENT.slice(1), bin, '-T', '5', '-p', String(port), '-t', SCAN_TYPES, '--', address], {
env: { PATH },
detached: true,
stdio: ['ignore', 'pipe', 'ignore'],
});
const out = [];
let size = 0;
child.stdout.on('data', (chunk) => {
size += chunk.length;
if (size <= 65536) out.push(chunk);
});
const timer = setTimeout(() => {
try {
process.kill(-child.pid, 'SIGKILL');
} catch {}
}, limitMs);
child.on('error', () => (clearTimeout(timer), reject(new BusError('host-failed'))));
child.on('close', () => (clearTimeout(timer), resolve(scanLines(Buffer.concat(out).toString('utf8')))));
});
}
// E1: the scanned set equals the requested set, or host-key-mismatch.
export function sameFingerprints(lines, prints) {
const scanned = new Set(lines.map(fingerprint));
return scanned.size === prints.length && prints.every((f) => scanned.has(f));
}
const run = (bin, args) =>
new Promise((resolve, reject) => {
const child = spawn(bin, args, { env: { PATH }, stdio: ['ignore', 'ignore', 'ignore'] });
child.on('error', () => reject(new BusError('host-failed')));
child.on('close', (code) => (code === 0 ? resolve() : reject(new BusError('host-failed'))));
});
export function hostGrant({ configDir, runRoot, limits = HOST_LIMITS, scan = keyscan, keygen = 'ssh-keygen' }) {
const keyRoot = join(configDir, 'secrets', 'hosts');
const keyPath = (business, hostId) => join(keyRoot, business, hostId, 'installer');
const readKey = (business, hostId) => {
const file = keyPath(business, hostId);
for (const dir of [keyRoot, dirname(dirname(file)), dirname(file)]) privateDir(dir);
let fd;
try {
fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW);
return parseHostKey(readFileSync(fd, 'utf8'));
} catch (e) {
if (e instanceof BusError) throw e;
deny('credential-file');
} finally {
if (fd !== undefined) closeSync(fd);
}
};
return {
enrollCheck,
sameFingerprints,
fingerprint,
installerTag,
keyscan: (address, port) => scan(address, port),
// E2 steps 2 and 3. Returns the public line and fingerprint; the private half only reaches the file.
async enroll(business, hostId) {
mkdirSync(runRoot, { recursive: true, mode: 0o700 });
privateDir(runRoot);
const dir = mkdtempSync(join(runRoot, 'h-'));
try {
const tag = installerTag(business, hostId);
await run(keygen, ['-q', '-t', 'ed25519', '-N', '', '-C', INSTALLER_USER + '-' + tag.slice('mosaic-'.length), '-f', join(dir, 'key')]);
const key = parseHostKey(readFileSync(join(dir, 'key'), 'utf8'));
const file = keyPath(business, hostId);
mkdirSync(keyRoot, { recursive: true, mode: 0o700 });
privateDir(keyRoot);
for (const d of [dirname(dirname(file)), dirname(file)]) {
if (!privateDir(d, { missing: true })) mkdirSync(d, { mode: 0o700 });
privateDir(d);
}
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, key.text);
fsyncSync(fd);
} finally {
if (fd !== undefined) closeSync(fd);
}
renameSync(tmp, file);
const dirFd = openSync(dirname(file), constants.O_RDONLY);
try {
fsyncSync(dirFd);
} finally {
closeSync(dirFd);
}
return { publicLine: key.publicLine, fingerprint: key.fingerprint, tag };
} finally {
rmSync(dir, { recursive: true, force: true });
}
},
// The X3 undo and hosts.remove: the installer key and its two dirs, if present.
unenroll(business, hostId) {
const file = keyPath(business, hostId);
for (const f of [file + '.tmp', file]) {
try {
unlinkSync(f);
} catch (e) {
if (e.code !== 'ENOENT') deny('credential-file');
}
}
for (const d of [dirname(file), dirname(dirname(file))]) {
try {
rmdirSync(d);
} catch (e) {
if (!['ENOENT', 'ENOTEMPTY'].includes(e.code)) deny('credential-file');
}
}
},
// E4: `list <user>` as mosaic-installer with the installer key and the pinned host keys. A wrong
// pin refuses host-key-mismatch (runHost). Returns the installer fingerprint and the exit.
async check(business, hostId, record) {
const key = readKey(business, hostId);
const entry = { address: record.address, port: record.port, user: INSTALLER_USER, hostKeys: record.hostKeys };
const out = await runHost({ root: runRoot, hostId, entry, key, timeout: 30, limits, command: `list ${record.user}` });
return { installerFingerprint: key.fingerprint, exit: out.exit };
},
};
}
+4 -2
View File
@@ -159,7 +159,9 @@ function runSsh(args, stdin, timeoutMs, signal, limits, onSpawn) {
// D7: one call. `key` is the parsed entry from credentials.mjs (text and publicLine); `entry` is
// the registry's { address, port, user, hostKeys }. `root` is <configDir>/secrets/run.
export async function runHost({ root, hostId, entry, key, argv, stdin, timeout, signal = null, limits = HOST_LIMITS }) {
// Row 234 (#1740): `command`, when set, is sent as is in place of the sudo argv. The installer key's
// forced command reads it (tools/host/mosaic-keys).
export async function runHost({ root, hostId, entry, key, argv, stdin, timeout, signal = null, limits = HOST_LIMITS, command = null }) {
if (signal?.aborted) deny('credential-unavailable');
const dir = mkdtempSync(join(runRoot(root), 'h-'));
const sock = join(dir, 'a.sock');
@@ -178,7 +180,7 @@ export async function runHost({ root, hostId, entry, key, argv, stdin, timeout,
await addKey(sock, key.text, timeout + 30, { limitMs: limits.agentWaitMs, signal });
if (signal?.aborted) deny('credential-unavailable');
const out = await runSsh(
[...sshArgs({ hostId, ...entry, dir }), remoteCommand(argv)],
[...sshArgs({ hostId, ...entry, dir }), command ?? remoteCommand(argv)],
stdin,
timeout * 1000,
signal,
+35
View File
@@ -0,0 +1,35 @@
-- Row 234 (#436a row a, #1740): enrolled hosts and host grants live in the store, written only by
-- the grant executor, hosts.check, hosts.remove and revoke, each in the transaction of its event.
-- A grant event is an action.allowed whose body.action or body.operation is grant.*; the trigger
-- refuses one unless the actor is (NULL, 'grant'), the broker's own grant writer (#436b section 7).
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;
+15 -2
View File
@@ -16,6 +16,7 @@ import { organizationWriter } from '../../business/src/organization.mjs';
import { credentialVerbs } from './credential-verbs.mjs';
import { webService } from './web.mjs';
import { hostExecutor, sweepRunDir } from './host-run.mjs';
import { hostGrant } from './host-grant.mjs';
import { verifyHuman } from './human.mjs';
import { createUsage } from './usage.mjs';
import { createLimits } from './limits.mjs';
@@ -95,8 +96,16 @@ export async function startBroker({
];
// Row 199 (#1697, design D1): the per-agent store is <configDir>/secrets/agents.
const storeRoot = organization ? join(organization.dir, 'secrets', 'agents') : null;
credentials = new Credentials({ references, repoRoots: protectedRoots, dataRoot, storeRoot });
store = new Store(dataRoot);
// Row 234 (#1740, r6 X4 third bullet): each host grant record gives its agent a store reference to
// host.<id>, so a restart finds the keys the grant executor wrote. The loader refuses host.<id> in a
// role's credentials, so these never meet a business-file reference.
for (const g of store.all('SELECT business, agent, host, rotate_by FROM host_grants ORDER BY business, agent, host')) {
if (!Object.hasOwn(businesses, g.business)) continue;
const refs = (references[g.business + '/' + g.agent] = { ...(references[g.business + '/' + g.agent] ?? {}) });
refs['host.' + g.host] = { store: true, file: storeRoot ? join(storeRoot, g.business, g.agent, 'host.' + g.host) : null, rotateBy: g.rotate_by };
}
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;
@@ -112,7 +121,10 @@ export async function startBroker({
const limits = createLimits({ store, read: id => settings ? settings.current(id) : businesses[id], log: limitLog });
for (const id of Object.keys(businesses)) await limits.refresh(id);
const withTokens = (id, result) => ({ ...result, tokens: limits.report(id) });
// Row 210 (#1707, design D7 step 6) and row 234 (#1740, r6 E2): the run dir and the host enrollment half.
const runRoot = organization ? join(organization.dir, 'secrets', 'run') : null;
const broker = new Broker({
hostGrant: runRoot ? hostGrant({ configDir: organization.dir, runRoot }) : null,
usage,
limits,
store,
@@ -181,8 +193,9 @@ export async function startBroker({
}
const web = await webServices(businesses);
// Row 210 (#1707, design D7 step 6): clear what a killed bus child left, before anyone can call.
const runRoot = organization ? join(organization.dir, 'secrets', 'run') : null;
if (runRoot) sweepRunDir(runRoot);
// Row 234 (r6 X3): end the enrollments a killed bus child left, before anyone can call.
broker.reconcileGrants();
const path = join(store.directory, 'broker.sock');
server = await serve({
web: (business) => web.get(business) ?? null,
+5 -2
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, PROJECT_FILE_VERBS, PROJECT_SAVE_VERB, PROJECT_SAVE_REQUEST_MAX } from './broker.mjs';
import { BusError, TASK_VERBS, WEB_VERBS, HOST_VERBS, HOSTS_VERBS, PROJECT_FILE_VERBS, PROJECT_SAVE_VERB, PROJECT_SAVE_REQUEST_MAX } from './broker.mjs';
const LIMIT = 65536;
// Row 148: a document save may carry up to 1 MiB of text. Only a request that names that verb near
// its start (the proof object comes first) may pass LIMIT; the parsed verb is checked again below.
@@ -123,7 +123,10 @@ export async function serve({ broker, path, authenticateHuman = null, authentica
// Row138: only project Git effects need an await; preserve every other sync path below.
if (r.verb === 'usage.begin' || r.verb === 'usage.end' ||
r.verb === 'spec.read' || r.verb === 'record.write' || PROJECT_FILE_VERBS.includes(r.verb) ||
(r.verb === 'decision.raise' && (Object.hasOwn(r.args ?? {}, 'spec_path') || Object.hasOwn(r.args ?? {}, 'spec_commit')))) {
// Row 234 (#1740): a grant raise awaits its keyscan, and hosts.check its ssh.
HOSTS_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;
transient = null;
// Local Git may take 100 seconds; keep its result beyond the ordinary idle deadline.
+7 -2
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 = '6cde5232c57ac04dc6073112c0830c89cfc5e953d7a5727e7716263747fe8737';
export const SCHEMA_VERSION = '3i';
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';
@@ -56,6 +56,11 @@ const migrations = [{
from: '3h',
to: '3i',
sql: readFileSync(new URL('./migrations/3h-3i.sql', import.meta.url), 'utf8'),
}, {
// Row 234 (#1740): enrolled hosts, host grants and the grant event guard. Only new objects.
from: '3i',
to: '3j',
sql: readFileSync(new URL('./migrations/3i-3j.sql', import.meta.url), 'utf8'),
}];
const timedTables = [
'events',
@@ -69,6 +69,7 @@ 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'])
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'));
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
}
@@ -76,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), '3i');
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]);
@@ -84,7 +85,9 @@ test('REQ-LIMIT-4 3h copy migrates to 3i: every row keeps its seq, the sequence
// sqlite_master as at 3h, the account line aside: same tables, indexes and triggers, same text.
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');
assert.deepEqual(master(path), was.map(r => r.name === 'usage_records' ? { ...r, sql: r.sql.replace(old, ' account TEXT,\n') } : r));
// Row 234 (#1740) adds 3i→3j's own objects after this step. Its test is grant-migration.test.mjs.
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);
try {
@@ -122,11 +125,11 @@ 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), '3i');
assert.equal(version(path), '3j');
assert.deepEqual(snapshot(path, before.columns).rows, before.rows);
});
test('REQ-LIMIT-4 the 3h engine refuses a 3i store, migrating or not, and leaves it at 3i', async t => {
test('REQ-LIMIT-4 the 3h engine refuses a migrated store, migrating or not, and leaves it as it was', async t => {
const { root, path } = source3h(t);
new Store(root, { migrateSchema: true }).close();
const after = snapshot(path), was = master(path);
@@ -134,14 +137,14 @@ test('REQ-LIMIT-4 the 3h engine refuses a 3i store, migrating or not, and leaves
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 = '3i';", "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), '3i');
assert.equal(version(path), '3j');
assert.deepEqual(master(path), was);
assert.deepEqual(snapshot(path, after.columns).rows, after.rows);
});
+2 -2
View File
@@ -606,7 +606,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 = '3i';", "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');
@@ -630,7 +630,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, '3i');
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, '3i');
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 = '3i';", "export const SCHEMA_VERSION = '3j';")
.replace('}];\nconst timedTables', `}, { from: '3i', to: '3j', 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'])
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, '3j');
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 3i 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);
+365
View File
@@ -0,0 +1,365 @@
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;
+489
View File
@@ -0,0 +1,489 @@
// Row 234 (#1740, #436a row a): host enrollment through a credential.mint raise with a `grant` binding
// (r6 E1, E2, E4, X1, X3). The keyscan is a fake that returns key lines this test makes, ssh-keygen is
// real, and the key files go under a scratch config dir. Nothing connects to any host.
import test from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Broker, BusError, writeGrantEvent, registrySha256 } from '../src/broker.mjs';
import { Store } from '../src/store.mjs';
import { hostGrant, fingerprint, grantTag, installerTag } from '../src/host-grant.mjs';
import { grantDecision } from './host-fixtures.mjs';
const options = [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }];
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const keyLine = (dir, name) => {
execFileSync('ssh-keygen', ['-q', '-t', 'ed25519', '-N', '', '-C', 'x', '-f', join(dir, name)]);
return readFileSync(join(dir, name + '.pub'), 'utf8').trim().split(' ').slice(0, 2).join(' ');
};
const refusesWith = async (p, code) => assert.rejects(Promise.resolve().then(() => p), (e) => (assert.equal(e.code, code), true));
const refusesSync = (fn, code) => assert.throws(fn, (e) => (assert.equal(e.code, code), true));
function setup(t, { keygen, check, hang = false } = {}) {
const root = mkdtempSync(join(tmpdir(), 'g234-'));
const store = new Store(root);
t.after(() => {
store.close();
rmSync(root, { recursive: true, force: true });
});
const hostKeys = [keyLine(root, 'hk1'), keyLine(root, 'hk2')];
const scans = [];
const scan = { lines: hostKeys };
const configDir = join(root, 'config');
const real = hostGrant({
configDir,
runRoot: join(configDir, 'secrets', 'run'),
scan: async (address, port) => (scans.push([address, port]), scan.lines),
...(keygen ? { keygen } : {}),
});
const checks = [];
// `hang`: steps 2 and 3 run, then the call never returns, as when the child dies before step 4.
const enroll = hang ? async (...args) => (await real.enroll(...args), new Promise(() => {})) : real.enroll;
const hg = { ...real, enroll, check: async (...args) => (checks.push(args), check ? check(...args) : { installerFingerprint: args[2].installer_fingerprint, exit: 0 }) };
const business = {
id: 'demo',
human: 'jason',
arbiters: { technical: 'pm', delivery: 'pm' },
roles: {
chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send'], crossRole: [] } },
pm: { definition: 'pm', authority: { withinRole: ['message.send'], crossRole: [] } },
},
vars: { 'bus.tester': true },
};
const make = (h = hg) => new Broker({ store, businesses: { demo: business }, hostGrant: h });
const broker = make();
const caps = {};
const bind = (b) => {
for (const role of ['chief', 'pm']) {
caps[role] = b.bindLaunch({ business: 'demo', role, run: role + '-run-' + Math.random(), harness: 'pi', address: role });
b.request(caps[role], { verb: 'role.claim' });
}
caps.human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
caps.tester = b.bindTester({ business: 'demo' });
caps.reader = b.bindReader({ business: 'demo' });
};
bind(broker);
const grant = (over = {}) => ({
method: 'host-enroll',
host: 'web1',
address: '192.0.2.10',
port: 22,
user: 'jarvis',
sudo: true,
fingerprints: hostKeys.map(fingerprint),
...over,
});
const call = (who, verb, args = {}, b = broker) => b.request(caps[who], { verb, args });
const raise = (over = {}, who = 'chief', extra = {}) =>
call(who, 'decision.raise', {
action: 'credential.mint',
question: 'Enroll web1?',
options,
recommendation: 'yes',
blocking: false,
grant: grant(over),
...extra,
});
const events = () =>
store
.all("SELECT kind, actor_role, actor_run, body FROM events WHERE business='demo' ORDER BY seq")
.map((e) => ({ kind: e.kind, actor_role: e.actor_role, actor_run: e.actor_run, ...JSON.parse(e.body) }));
const grantEvents = () => events().filter((e) => e.actor_run === 'grant');
const settle = async (decision) => {
for (let i = 0; i < 250; i++) {
if (grantEvents().some((e) => e.grantDecision === decision && ['grant.host.enrolled', 'grant.failed'].includes(e.action))) return;
await sleep(20);
}
throw new Error('enrollment did not end');
};
const keyFile = (host = 'web1') => join(configDir, 'secrets', 'hosts', 'demo', host, 'installer');
const hostRow = (host = 'web1') => store.get("SELECT * FROM hosts WHERE business='demo' AND host=?", host);
const messages = () => store.all("SELECT from_role, to_role, class, decision, body FROM messages WHERE business='demo' ORDER BY rowid");
return { root, store, broker, make, bind, caps, call, raise, grant, hostKeys, scan, scans, checks, events, grantEvents, settle, keyFile, hostRow, messages, configDir };
}
const approve = async (f, over) => {
const d = await f.raise(over);
const r = f.call('human', 'decision.resolve', { id: d.id, choice: 'yes' });
await f.settle(d.id);
return { d, r };
};
test('G1, A1: only the chief raises a grant, and a refused raise stores nothing and runs no keyscan', async (t) => {
const f = setup(t);
await refusesWith(f.raise({}, 'pm'), 'grant-chief-only');
await refusesWith(f.call('human', 'decision.raise', { action: 'credential.mint', question: 'q', options, recommendation: 'yes', blocking: false, grant: f.grant() }), 'agent-required');
await refusesWith(f.call('tester', 'decision.raise', { action: 'credential.mint', question: 'q', options, recommendation: 'yes', blocking: false, grant: f.grant() }), 'tester-refused');
assert.equal(f.scans.length, 0);
assert.equal(f.store.get('SELECT count(*) AS n FROM decisions').n, 0);
assert.deepEqual(f.events().filter((e) => e.kind === 'action.refused').map((e) => e.code), ['grant-chief-only', 'agent-required', 'tester-refused']);
});
test('G1: grant goes only with credential.mint, row a takes only host-enroll, and E1 refuses each bad field', async (t) => {
const f = setup(t);
await refusesWith(f.raise({}, 'chief', { action: 'deploy' }), 'invalid-request');
for (const over of [
{ method: 'host' },
{ user: 'root' },
{ user: 'localadmin' },
{ user: 'mosaic-installer' },
{ user: 'Bad' },
{ sudo: 'yes' },
{ port: 0 },
{ port: 65536 },
{ host: 'Web_1' },
{ address: '127.0.0.1' },
{ address: 'localhost' },
{ fingerprints: [] },
{ fingerprints: ['SHA256:short'] },
{ fingerprints: [f.grant().fingerprints[0], f.grant().fingerprints[0]] },
{ extra: 1 },
])
await refusesWith(f.raise(over), 'invalid-request');
const { method, ...noMethod } = f.grant();
await refusesWith(
f.call('chief', 'decision.raise', { action: 'credential.mint', question: 'q', options, recommendation: 'yes', blocking: false, grant: noMethod }),
'invalid-request',
);
assert.equal(f.scans.length, 0);
assert.equal(f.store.get('SELECT count(*) AS n FROM decisions').n, 0);
});
test('G5, A5, A6: the scanned key set must equal the requested set, or host-key-mismatch and nothing stored', async (t) => {
const f = setup(t);
const other = keyLine(f.root, 'hk3');
for (const lines of [[f.hostKeys[0]], [...f.hostKeys, other], [f.hostKeys[0], other], []]) {
f.scan.lines = lines;
await refusesWith(f.raise(), 'host-key-mismatch');
}
f.scan.lines = f.hostKeys;
await refusesWith(f.raise({ fingerprints: [fingerprint(f.hostKeys[0])] }), 'host-key-mismatch');
assert.deepEqual(f.scans.at(-1), ['192.0.2.10', 22]);
assert.equal(f.store.get('SELECT count(*) AS n FROM decisions').n, 0);
});
test('G4 (host-enroll block, M1): the broker appends the keys, fingerprints and sudo line, and the cap counts the block', async (t) => {
const f = setup(t);
const d = await f.raise();
assert.equal(d.route_to ?? f.store.get('SELECT route_to FROM decisions WHERE id=?', d.id).route_to, 'human');
const q = f.store.get('SELECT question FROM decisions WHERE id=?', d.id).question;
assert.ok(q.startsWith('Enroll web1?\n\nEnroll host web1: user jarvis at 192.0.2.10 port 22.\n'));
for (const k of f.hostKeys) assert.ok(q.includes(`\n${fingerprint(k)} ${k}`));
assert.ok(q.endsWith('\njarvis has sudo on this host. Approving lets later grants for jarvis be installed, and section 9\'s first limit applies to jarvis.'));
const raiseBody = f.events().find((e) => e.operation === 'decision.raise');
assert.deepEqual(raiseBody.grant, { ...f.grant(), hostKeys: f.hostKeys });
// A question that carries a forged block still gets the broker's block after it.
const forged = 'x\n\nEnroll host web1: user other at 192.0.2.99 port 22.\nother has no sudo on this host.';
const d2 = await f.raise({ host: 'web2', sudo: false }, 'chief', { question: forged });
const q2 = f.store.get('SELECT question FROM decisions WHERE id=?', d2.id).question;
assert.ok(q2.startsWith(forged + '\n\nEnroll host web2: user jarvis at 192.0.2.10 port 22.\n'));
assert.ok(q2.endsWith('\njarvis has no sudo on this host.'));
// The 4096 cap holds with the block appended.
await refusesWith(f.raise({ host: 'web3' }, 'chief', { question: 'q'.repeat(4000) }), 'invalid-request');
assert.equal(f.store.get("SELECT count(*) AS n FROM decisions").n, 2);
});
test('G6, X1: the human\'s cli yes starts enrollment, which writes the record, the key file and the events in order', async (t) => {
const f = setup(t);
const { d, r } = await approve(f);
assert.deepEqual(r, { id: d.id, op: 'resolved', grant: 'started' });
const row = f.hostRow();
assert.equal(row.state, 'pending-install');
assert.equal(row.decision, d.id);
assert.equal(row.user, 'jarvis');
assert.equal(row.sudo, 1);
assert.deepEqual(JSON.parse(row.host_keys), f.hostKeys);
assert.match(row.installer_fingerprint, /^SHA256:/);
// E2 step 3: 0600 in 0700 dirs.
assert.equal(statSync(f.keyFile()).mode & 0o777, 0o600);
for (const dir of [join(f.keyFile(), '..'), join(f.keyFile(), '..', '..')]) assert.equal(statSync(dir).mode & 0o777, 0o700);
const g = f.grantEvents();
assert.deepEqual(g.map((e) => e.operation ?? e.action), ['grant.host-enroll', 'grant.host.enroll.step', 'grant.host.enrolled']);
for (const e of g) assert.deepEqual([e.kind, e.actor_role, e.actor_run], ['action.allowed', null, 'grant']);
assert.deepEqual(g[0], { kind: 'action.allowed', actor_role: null, actor_run: 'grant', operation: 'grant.host-enroll', decision: d.id });
assert.equal(g[1].grantDecision, d.id);
assert.equal(g[1].step, 'registry');
const enrolled = g[2];
assert.equal('decision' in enrolled, false);
assert.equal(enrolled.registrySha256, registrySha256({ address: '192.0.2.10', port: 22, user: 'jarvis', sudo: true, hostKeys: f.hostKeys }));
assert.equal(enrolled.installerFingerprint, row.installer_fingerprint);
assert.deepEqual([enrolled.user, enrolled.sudo], ['jarvis', true]);
// E2 step 4's message to the human and the chief: the installer line, public half only.
const tag = installerTag('demo', 'web1');
const pub = execFileSync('ssh-keygen', ['-y', '-f', f.keyFile()], { encoding: 'utf8' }).trim().split(' ').slice(0, 2).join(' ');
const notes = f.messages().filter((m) => m.from_role === '_bus' && m.decision === d.id);
assert.deepEqual(notes.map((m) => [m.to_role, m.class]), [['human', 'RESULT'], ['chief', 'RESULT']]);
for (const m of notes)
assert.ok(m.body.includes(`\nrestrict,command="/usr/local/sbin/mosaic-keys",from="<from>" ${pub} mosaic-installer-${tag.slice(7)}\n`));
// A7: no part of the private key reaches the store.
const secret = readFileSync(f.keyFile(), 'utf8').split('\n').filter((l) => l.length >= 24 && !l.startsWith('-----'));
const dump = JSON.stringify([f.store.all('SELECT body FROM events'), f.store.all('SELECT body FROM messages'), f.store.all('SELECT * FROM hosts')]);
for (const l of secret) assert.equal(dump.includes(l), false);
// The tmp dir under the run root is gone.
assert.deepEqual(readdirSync(join(f.configDir, 'secrets', 'run')), []);
});
test('A2, A4: a credential.mint without grant, and a no, start nothing', async (t) => {
const f = setup(t);
const plain = f.call('chief', 'decision.raise', { action: 'credential.mint', question: 'Hand-made key?', options, recommendation: 'yes', blocking: false });
assert.deepEqual(f.call('human', 'decision.resolve', { id: plain.id, choice: 'yes' }), { id: plain.id, op: 'resolved' });
const d = await f.raise();
assert.deepEqual(f.call('human', 'decision.resolve', { id: d.id, choice: 'no' }), { id: d.id, op: 'resolved' });
await sleep(200);
assert.deepEqual(f.grantEvents(), []);
assert.equal(f.hostRow(), undefined);
});
test('A3, X1: a resolve by the tester or an agent starts nothing', async (t) => {
const f = setup(t);
const d = await f.raise();
for (const who of ['tester', 'pm', 'chief']) {
try {
f.call(who, 'decision.resolve', { id: d.id, choice: 'yes' });
} catch {}
}
await sleep(200);
assert.deepEqual(f.grantEvents(), []);
assert.equal(f.hostRow(), undefined);
});
test('G6 (duckworth-0081 B3): a withdraw by the chief or the human, and an arbiter resolve, start nothing', async (t) => {
const f = setup(t);
for (const who of ['chief', 'human']) {
const d = await f.raise();
assert.deepEqual(f.call(who, 'decision.withdraw', { id: d.id }), { id: d.id, op: 'withdrawn' });
}
// A grant raise always routes to the human, so the fixture writes an open one routed to the pm
// arbiter, which then resolves it yes through the verb.
const d = { id: grantDecision(f.store, { route: 'pm', grant: f.grant(), close: null }) };
assert.deepEqual(f.call('pm', 'decision.resolve', { id: d.id, choice: 'yes' }), { id: d.id, op: 'resolved' });
await sleep(200);
assert.deepEqual(f.grantEvents(), []);
assert.equal(f.hostRow(), undefined);
assert.deepEqual(f.broker.reconcileGrants(), []);
assert.equal(await f.broker.runEnrollment('demo', d.id), 'not-authorized');
assert.deepEqual(f.grantEvents(), []);
});
test('G10 (duckworth-0081 B1): a second start for a spent decision refuses decision-consumed, and an unresolved or agent-resolved one not-authorized', async (t) => {
const f = setup(t);
const { d } = await approve(f);
const before = { events: f.grantEvents(), row: f.hostRow(), messages: f.messages().length };
assert.equal(await f.broker.runEnrollment('demo', d.id), 'decision-consumed');
assert.deepEqual(f.grantEvents(), before.events);
assert.deepEqual(f.hostRow(), before.row);
assert.equal(f.messages().length, before.messages);
// Unresolved, then resolved by an agent: written by SQL, since the verb refuses the agent first.
const open = await f.raise({ host: 'web2' });
assert.equal(await f.broker.runEnrollment('demo', open.id), 'not-authorized');
f.store.run('INSERT INTO decision_events(decision,at,op,by,choice,via) VALUES(?,?,?,?,?,?)', open.id, new Date().toISOString(), 'resolved', 'chief', 'yes', 'broker');
assert.equal(await f.broker.runEnrollment('demo', open.id), 'not-authorized');
// A cli yes by someone other than the human, so the by check alone refuses it (duckworth-0083 N8, A3b).
const other = await f.raise({ host: 'web3' });
f.store.run('INSERT INTO decision_events(decision,at,op,by,choice,via) VALUES(?,?,?,?,?,?)', other.id, new Date().toISOString(), 'resolved', 'chief', 'yes', 'cli');
assert.equal(await f.broker.runEnrollment('demo', other.id), 'not-authorized');
assert.deepEqual(f.broker.reconcileGrants(), []);
assert.deepEqual(f.grantEvents(), before.events);
assert.equal(f.hostRow('web2'), undefined);
assert.equal(existsSync(f.keyFile('web2')), false);
assert.equal(f.hostRow('web3'), undefined);
assert.equal(existsSync(f.keyFile('web3')), false);
});
test('A8, S1: two approved enrollments for one host: the second ends host-enrolled and the pin stays', async (t) => {
const f = setup(t);
const d1 = await f.raise();
const d2 = await f.raise({ user: 'melanie', sudo: false });
f.call('human', 'decision.resolve', { id: d1.id, choice: 'yes' });
await f.settle(d1.id);
const before = f.hostRow();
f.call('human', 'decision.resolve', { id: d2.id, choice: 'yes' });
await f.settle(d2.id);
assert.deepEqual(f.hostRow(), before);
const failed = f.grantEvents().find((e) => e.grantDecision === d2.id && e.action === 'grant.failed');
assert.equal(failed.code, 'host-enrolled');
assert.ok(f.grantEvents().some((e) => e.operation === 'grant.host-enroll' && e.decision === d2.id));
assert.equal(f.grantEvents().filter((e) => e.grantDecision === d2.id && e.action === 'grant.host.enroll.step').length, 0);
// A raise for an enrolled host refuses before the keyscan.
const scans = f.scans.length;
await refusesWith(f.raise(), 'host-enrolled');
assert.equal(f.scans.length, scans);
});
test('A9: the hosts primary key refuses a second record for (business, host)', (t) => {
const f = setup(t);
const d = f.call('chief', 'decision.raise', { action: 'credential.mint', question: 'q', options, recommendation: 'yes', blocking: false });
const insert = () =>
f.store.run(
"INSERT INTO hosts(business,host,address,port,user,sudo,host_keys,state,decision) VALUES('demo','web1','192.0.2.1',22,'u',0,'[\"k\"]','pending-install',?)",
d.id,
);
insert();
assert.throws(insert, /UNIQUE|PRIMARY/);
});
test('X3: a failure at step 2 deletes the record again and ends grant.failed, with no key file', async (t) => {
const f = setup(t, { keygen: '/nonexistent/ssh-keygen' });
const { d } = await approve(f);
assert.equal(f.hostRow(), undefined);
assert.equal(existsSync(f.keyFile()), false);
const failed = f.grantEvents().find((e) => e.action === 'grant.failed');
assert.deepEqual([failed.grantDecision, failed.host, failed.code], [d.id, 'web1', 'host-failed']);
const notes = f.messages().filter((m) => m.from_role === '_bus' && m.decision === d.id);
assert.deepEqual(notes.map((m) => m.to_role), ['human', 'chief']);
assert.ok(notes[0].body.includes('failed: host-failed'));
});
test('A10, X3: at start a resolved, unspent enrollment is spent and ends interrupted, and never runs late', async (t) => {
const f = setup(t);
const d = await f.raise();
// The resolve commits, and the child dies before step 1: the resolve row alone, written here.
f.store.run("INSERT INTO decision_events(decision,at,op,by,choice,via) VALUES(?,?,?,?,?,?)", d.id, new Date().toISOString(), 'resolved', 'jason', 'yes', 'cli');
const restarted = f.make();
assert.deepEqual(restarted.reconcileGrants(), [d.id]);
const g = f.grantEvents();
assert.deepEqual(g.map((e) => e.operation ?? e.action), ['grant.host-enroll', 'grant.failed']);
assert.deepEqual([g[1].code, g[1].host, g[1].tag], ['interrupted', 'web1', installerTag('demo', 'web1')]);
assert.equal(f.hostRow(), undefined);
assert.deepEqual(restarted.reconcileGrants(), []);
});
test('X3: at start an enrollment that stopped after its registry step loses its record and key file', async (t) => {
const f = setup(t, { hang: true });
const d = await f.raise();
f.call('human', 'decision.resolve', { id: d.id, choice: 'yes' });
for (let i = 0; i < 250 && !existsSync(f.keyFile()); i++) await sleep(20);
assert.equal(f.hostRow().state, 'pending-install');
assert.ok(existsSync(f.keyFile()));
const restarted = f.make();
assert.deepEqual(restarted.reconcileGrants(), [d.id]);
assert.equal(f.hostRow(), undefined);
assert.equal(existsSync(f.keyFile()), false);
const failed = f.grantEvents().find((e) => e.grantDecision === d.id && e.action === 'grant.failed');
assert.deepEqual([failed.code, failed.host, failed.tag], ['interrupted', 'web1', installerTag('demo', 'web1')]);
assert.deepEqual(f.messages().filter((m) => m.decision === d.id && m.from_role === '_bus').map((m) => m.to_role), ['human', 'chief']);
assert.deepEqual(restarted.reconcileGrants(), []);
});
test('G24 first sentence, M2, A12: tags are mosaic- and 32 hex, and differ by business, agent and host', () => {
const long = 'a'.repeat(30) + '.' + '_'.repeat(2) + 'b'.repeat(31);
assert.equal(long.length, 64);
const tags = [grantTag('demo', long, 'web1'), grantTag('demo', long + 'x', 'web1'), grantTag('other', long, 'web1'), grantTag('demo', long, 'web2'), installerTag('demo', 'web1'), installerTag('other', 'web1')];
for (const t of tags) assert.match(t, /^mosaic-[0-9a-f]{32}$/);
assert.equal(new Set(tags).size, tags.length);
});
test('E4: hosts.check sets the record ready and records grant.host.checked; a non-zero list leaves it pending', async (t) => {
const exits = [1, 0];
const f = setup(t, { check: (b, h, r) => ({ installerFingerprint: r.installer_fingerprint, exit: exits.shift() }) });
await approve(f);
await refusesWith(f.call('chief', 'hosts.check', { host: 'web1' }), 'host-failed');
assert.equal(f.hostRow().state, 'pending-install');
await refusesWith(f.call('pm', 'hosts.check', { host: 'web1' }), 'grant-chief-only');
await refusesWith(f.call('chief', 'hosts.check', { host: 'nope' }), 'host-unknown');
const r = await f.call('chief', 'hosts.check', { host: 'web1' });
assert.equal(r.state, 'ready');
assert.equal(f.hostRow().state, 'ready');
assert.equal(f.hostRow().checked_at, r.checkedAt);
const checked = f.grantEvents().find((e) => e.action === 'grant.host.checked');
assert.deepEqual([checked.host, checked.by, checked.installerFingerprint], ['web1', 'chief', f.hostRow().installer_fingerprint]);
assert.equal(checked.registrySha256, registrySha256({ address: '192.0.2.10', port: 22, user: 'jarvis', sudo: true, hostKeys: f.hostKeys }));
const status = await f.call('human', 'hosts.status');
assert.deepEqual(status.map((h) => [h.host, h.state, h.hostKeys, h.hostLineLeft]), [['web1', 'ready', f.hostKeys.map(fingerprint), []]]);
});
test('G20 (duckworth-0081 B3): the human\'s hosts.check marks the host ready, and the reader refuses', async (t) => {
const f = setup(t);
await approve(f);
await refusesWith(f.call('reader', 'hosts.check', { host: 'web1' }), 'read-only');
const r = await f.call('human', 'hosts.check', { host: 'web1' });
assert.equal(r.state, 'ready');
assert.equal(f.hostRow().state, 'ready');
const checked = f.grantEvents().filter((e) => e.action === 'grant.host.checked');
assert.deepEqual(checked.map((e) => e.by), ['jason']);
});
test('G18 (duckworth-0081 B2), broker half: a check refused host-key-mismatch leaves the host pending-install with no grant.host.checked', async (t) => {
const f = setup(t, { check: () => { throw new BusError('host-key-mismatch'); } });
await approve(f);
await refusesWith(f.call('chief', 'hosts.check', { host: 'web1' }), 'host-key-mismatch');
assert.equal(f.checks.length, 1);
assert.equal(f.hostRow().state, 'pending-install');
assert.equal(f.hostRow().checked_at, null);
assert.deepEqual(f.grantEvents().filter((e) => e.action === 'grant.host.checked'), []);
});
test('E4: a check whose installer fingerprint differs from the record refuses host-changed', async (t) => {
const f = setup(t, { check: () => ({ installerFingerprint: 'SHA256:' + 'A'.repeat(43), exit: 0 }) });
await approve(f);
await refusesWith(f.call('tester', 'hosts.check', { host: 'web1' }), 'host-changed');
assert.equal(f.hostRow().state, 'pending-install');
});
test('E4, G21: hosts.remove is the human\'s or the tester\'s, deletes the key and the record, and records grant.host.removed', async (t) => {
const f = setup(t);
await approve(f);
await refusesWith(f.call('chief', 'hosts.remove', { host: 'web1' }), 'human-required');
await refusesWith(f.call('reader', 'hosts.remove', { host: 'web1' }), 'read-only');
assert.deepEqual(await f.call('human', 'hosts.remove', { host: 'web1' }), { host: 'web1', removed: true });
assert.equal(f.hostRow(), undefined);
assert.equal(existsSync(f.keyFile()), false);
assert.equal(existsSync(join(f.keyFile(), '..')), false);
const removed = f.grantEvents().find((e) => e.action === 'grant.host.removed');
assert.deepEqual([removed.host, removed.by], ['web1', 'jason']);
await refusesWith(f.call('tester', 'hosts.remove', { host: 'web1' }), 'host-unknown');
});
test('G21, F1, F2, M4, A22: hosts.remove refuses host-line-left on a guarded hostLineLeft, and ignores look-alikes', async (t) => {
const f = setup(t);
const { d } = await approve(f);
const at = new Date().toISOString();
// Look-alikes: the same body under action.refused, and under another actor of another kind.
const lookAlike = JSON.stringify({ action: 'grant.failed', grantDecision: d.id, host: 'web1', hostLineLeft: installerTag('demo', 'web1') });
f.store.run("INSERT INTO events(id,at,business,kind,actor_role,actor_run,subject,body) VALUES('la1',?,'demo','action.refused',NULL,'grant','web1',?)", at, lookAlike);
f.store.run("INSERT INTO events(id,at,business,kind,actor_role,actor_run,subject,body) VALUES('la2',?,'demo','action.refused','chief','chief-run','web1',?)", at, lookAlike);
// The trigger refuses the look-alike as an action.allowed from a session.
assert.throws(() =>
f.store.run("INSERT INTO events(id,at,business,kind,actor_role,actor_run,subject,body) VALUES('la3',?,'demo','action.allowed','chief','chief-run','web1',?)", at, lookAlike),
);
assert.equal((await f.call('human', 'hosts.status'))[0].hostLineLeft.length, 0);
// F1: the real shape, through the grant writer. F2: any guarded body, not only grant.failed.
writeGrantEvent(f.store, at, 'demo', { action: 'grant.revoke.closed', tag: 'mosaic-' + '0'.repeat(32), host: 'web1', hostLineLeft: 'mosaic-' + '0'.repeat(32) }, 'web1');
assert.deepEqual((await f.call('human', 'hosts.status'))[0].hostLineLeft, ['mosaic-' + '0'.repeat(32)]);
await refusesWith(f.call('human', 'hosts.remove', { host: 'web1' }), 'host-line-left');
assert.ok(f.hostRow());
assert.ok(existsSync(f.keyFile()));
});
test('hosts.remove refuses host-in-use while a grant record names the host', async (t) => {
const f = setup(t);
const { d } = await approve(f);
f.store.run(
"INSERT INTO host_grants(business,agent,host,commands,rotate_by,tag,decision) VALUES('demo','pm','web1','[[\"true\"]]','2099-01-01',?,?)",
grantTag('demo', 'pm', 'web1'),
d.id,
);
await refusesWith(f.call('human', 'hosts.remove', { host: 'web1' }), 'host-in-use');
assert.ok(f.hostRow());
});
test('the grant writer refuses a body outside section 7\'s shape', (t) => {
const f = setup(t);
const at = new Date().toISOString();
for (const body of [
{ action: 'host.sudo' },
{ action: 'grant.failed', decision: 'd' },
{ operation: 'grant.host-enroll', decision: 'd', host: 'web1' },
{ operation: 'grant.host-enroll' },
{ operation: 'decision.raise', decision: 'd' },
])
assert.throws(() => writeGrantEvent(f.store, at, 'demo', body), /grant event shape/);
});
test('recordEvent refuses reserved-event for a grant.* action or operation', (t) => {
const f = setup(t);
for (const body of [{ action: 'grant.failed' }, { operation: 'grant.host-enroll', decision: 'x' }])
refusesSync(() => f.broker.recordEvent(f.caps.chief, { kind: 'action.allowed', body }), 'reserved-event');
assert.equal(f.store.get("SELECT count(*) AS n FROM events WHERE json_extract(body,'$.action') GLOB 'grant.*'").n, 0);
});
+212
View File
@@ -0,0 +1,212 @@
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';
import { Broker } from '../src/broker.mjs';
// Row 234 (#1740, #436a row a): the 3i→3j step adds the hosts and host_grants tables and the grant
// event guard (#436b section 7 at fa5658941). It runs on a 3i store built here from the pinned 3i
// schema (8fede5fc6's schema.sql) and fixture rows. No live store is read or copied.
const AT = '2026-10-21T00:00:00.000Z';
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'];
function source3i(t) {
const root = mkdtempSync(join(tmpdir(), 'grant-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(ddl3i).digest('hex'), '6cde5232c57ac04dc6073112c0830c89cfc5e953d7a5727e7716263747fe8737');
db.exec(ddl3i);
const run = (sql, ...args) => db.prepare(sql).run(...args);
run('INSERT INTO meta VALUES (?,?)', 'schema_version', '3i');
run('INSERT INTO meta VALUES (?,?)', 'schema_digest', schemaDigest(db));
run('INSERT INTO meta VALUES (?,?)', 'comment_cursor_epoch', AT);
run('INSERT INTO events(id,at,business,kind,actor_role,actor_run,body) VALUES(?,?,?,?,?,?,?)', 'e1', AT, 'demo', 'action.allowed', 'coder', 'run1', '{"action":"host.sudo"}');
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 ('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(); }
}
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 3i→3j 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'])
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')));
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
}
function fresh(t) {
const root = mkdtempSync(join(tmpdir(), 'grant-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 event = (store, actor, body, kind = 'action.allowed') => store.run(
'INSERT INTO events(id,at,business,kind,actor_role,actor_run,body) VALUES(?,?,?,?,?,?,?)',
'e' + Math.random().toString(16).slice(2), AT, 'demo', kind, actor[0], actor[1], JSON.stringify(body));
const host = (store, values = {}) => store.run(
`INSERT INTO hosts(business,host,address,port,user,sudo,host_keys,installer_fingerprint,state,decision,checked_at)
VALUES(:business,:host,:address,:port,:user,:sudo,:host_keys,:fp,:state,:decision,:checked)`,
{ business: 'demo', host: 'web1', address: '192.0.2.10', port: 22, user: 'deploy', sudo: 0,
host_keys: '["ssh-ed25519 AAAA"]', fp: null, state: 'pending-install', decision: 'd1', checked: null, ...values });
test('3i copy migrates to 3j: every row kept, and sqlite_master gains only the two tables, their keys and the guard', 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), '3j');
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 hosts').n, 0);
// An event row 210 wrote before the step stays, and a session can still write host.sudo.
event(store, ['coder', 'run2'], { action: 'host.sudo', phase: 'requested' });
} finally { store.close(); }
});
test('a 3i→3j step that fails partway leaves the store at 3i with every row and the schema intact', async t => {
const { root, path } = source3i(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 and the trigger 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), '3i');
assert.deepEqual(master(path), was);
assert.deepEqual(rows(path), before);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3j');
});
test('the 3i engine refuses a 3j store, migrating or not, and leaves it at 3j', async t => {
const { root, path } = source3i(t);
new Store(root, { migrateSchema: true }).close();
const after = rows(path), was = master(path);
const Store3i = await engine(root, 'engine-3i', {
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 = '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), '3j');
assert.deepEqual(master(path), was);
assert.deepEqual(rows(path), after);
});
test('A22b, sage-1703: a session-written grant.* row from before the step survives it, and the grant readers ignore it', async t => {
// The step adds the guard but leaves old rows. At 3i recordEvent had no reserved-event refusal, so a
// session could write these, and after the step only GRANT_EVIDENCE's actor filter keeps them out.
const { root, path } = source3i(t);
const db = new DatabaseSync(path);
const run = (sql, ...args) => db.prepare(sql).run(...args);
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);
const legacy = [{ action: 'grant.host.removed', host: 'web1', hostLineLeft: TAG }, { operation: 'grant.host-enroll', decision: 'd1' }];
for (const [i, body] of legacy.entries())
run('INSERT INTO events(id,at,business,kind,actor_role,actor_run,body) VALUES(?,?,?,?,?,?,?)', 'old' + i, AT, 'demo', 'action.allowed', 'chief', 'run1', JSON.stringify(body));
db.close();
new Store(root, { migrateSchema: true }).close();
const store = new Store(root);
t.after(() => store.close());
assert.equal(store.get("SELECT count(*) AS n FROM events WHERE id IN ('old0','old1')").n, 2);
host(store);
const business = {
id: 'demo',
human: 'jason',
arbiters: { technical: 'pm', delivery: 'pm' },
roles: {
chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send'], crossRole: [] } },
pm: { definition: 'pm', authority: { withinRole: ['message.send'], crossRole: [] } },
},
vars: {},
};
const unenrolled = [];
const broker = new Broker({ store, businesses: { demo: business }, hostGrant: { unenroll: (b, h) => unenrolled.push([b, h]), installerTag: () => null } });
// The enrollment look-alike is not a spent decision, so nothing ends interrupted.
assert.deepEqual(broker.reconcileGrants(), []);
assert.equal(store.get("SELECT count(*) AS n FROM events WHERE actor_run='grant'").n, 0);
// The hostLineLeft look-alike is not a line left on the host.
const human = broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
assert.deepEqual(await broker.request(human, { verb: 'hosts.remove', args: { host: 'web1' } }), { host: 'web1', removed: true });
assert.deepEqual(unenrolled, [['demo', 'web1']]);
});
test('the guard: a grant.* action or operation on action.allowed needs the (NULL, grant) actor', t => {
const store = fresh(t);
const refused = /only the grant writer records grant events/;
for (const body of [{ action: 'grant.step' }, { action: 'grant.host.enrolled' }, { operation: 'grant.host-enroll', decision: 'd1' }]) {
assert.throws(() => event(store, ['chief', 'run1'], body), refused, JSON.stringify(body));
assert.throws(() => event(store, ['tester', 'run1'], body), refused);
assert.throws(() => event(store, [null, null], body), refused, 'the human and session-less pair is not the grant actor');
assert.throws(() => event(store, [null, 'run1'], body), refused);
assert.throws(() => event(store, ['grant', 'grant'], body), refused);
event(store, [null, 'grant'], body);
}
// Row 210's host.sudo and host.run stay outside the namespace, and so does any other kind.
event(store, ['coder', 'run1'], { action: 'host.sudo', phase: 'completed' });
event(store, ['coder', 'run1'], { action: 'grant.step' }, 'action.refused');
event(store, ['coder', 'run1'], { action: 'granted.step', operation: 'grantx' });
assert.equal(store.get("SELECT count(*) AS n FROM events WHERE actor_run='grant'").n, 3);
});
test('hosts: one record per business and host, a ready host has its installer and check, grants need the host', t => {
const store = fresh(t);
host(store);
assert.throws(() => host(store, { address: '192.0.2.11' }), /UNIQUE constraint failed: hosts\.business, hosts\.host/);
host(store, { business: 'other' });
assert.throws(() => host(store, { host: 'web2', state: 'ready' }), /CHECK constraint failed/);
assert.throws(() => host(store, { host: 'web2', state: 'ready', fp: 'SHA256:x' }), /CHECK constraint failed/);
host(store, { host: 'web2', state: 'ready', fp: 'SHA256:x', checked: AT });
assert.throws(() => host(store, { host: 'web3', state: 'live' }), /CHECK constraint failed/);
assert.throws(() => host(store, { host: 'web3', host_keys: '[]' }), /CHECK constraint failed/);
assert.throws(() => host(store, { host: 'web3', decision: 'nope' }), /FOREIGN KEY constraint failed/);
const grant = (values = {}) => store.run(
'INSERT INTO host_grants(business,agent,host,commands,rotate_by,tag,decision) VALUES(:business,:agent,:host,:commands,:rotate,:tag,:decision)',
{ business: 'demo', agent: 'coder', host: 'web2', commands: '[["true"]]', rotate: '2026-12-01', tag: TAG, decision: 'd1', ...values });
assert.throws(() => grant({ host: 'web9' }), /FOREIGN KEY constraint failed/);
for (const tag of ['mosaic-' + 'A'.repeat(32), 'mosaic-' + 'a'.repeat(31), 'mosaix-' + 'a'.repeat(32), 'mosaic-' + 'a'.repeat(31) + 'g'])
assert.throws(() => grant({ tag }), /CHECK constraint failed/, tag);
assert.throws(() => grant({ commands: '[]' }), /CHECK constraint failed/);
grant();
assert.throws(() => grant({ tag: 'mosaic-' + 'b'.repeat(32) }), /UNIQUE constraint failed/);
assert.throws(() => store.run("DELETE FROM hosts WHERE business='demo' AND host='web2'"), /FOREIGN KEY constraint failed/);
});
+6 -4
View File
@@ -7,6 +7,7 @@ import { Store } from '../src/store.mjs';
import { Broker, ACTIONS, HARD_WAITS } from '../src/broker.mjs';
import { ACTIONS as VOCABULARY, GATED_ONLY } from '../../business/src/vocabulary.mjs';
import { fixture } from './spec-fixtures.mjs';
import { hostGrantRecord, hostRecord } from './host-fixtures.mjs';
// PRD 1.5 REQ-WAIT-1, REQ-WAIT-2 and REQ-DEC-2 (row 139, #1633).
const WAITS = ['spend', 'data.delete', 'network.expose', 'account.create', 'credential.mint', 'message.external',
@@ -37,10 +38,11 @@ test('REQ-WAIT-2: a business granting a hard wait at either class is refused; th
test('REQ-DEC-2: every hard wait goes to the human, cited or not, and the arbiter cannot resolve it', async t => {
const f = await fixture(t, { roles: { coder: { definition: 'coder',
authority: { withinRole: ['message.send', 'record.write', 'git.push.working'], crossRole: [] },
hosts: { web1: { commands: [['systemctl', 'status']] } } } },
// Row 210: a host.sudo raise names a registered host the raiser is granted and an argv its grant allows.
hosts: { web1: { address: '10.0.0.5', port: 22, user: 'admin', hostKeys: ['ssh-ed25519 AAAA'] } } });
authority: { withinRole: ['message.send', 'record.write', 'git.push.working'], crossRole: [] } } } });
// Row 210: a host.sudo raise names a registered host the raiser is granted and an argv its grant allows.
// Row 234 (#1740): both are store records.
hostRecord(f.store, { host: 'web1', entry: { address: '10.0.0.5', port: 22, user: 'admin', hostKeys: ['ssh-ed25519 AAAA'] } });
hostGrantRecord(f.store, { agent: 'coder', host: 'web1', commands: [['systemctl', 'status']] });
// Row 199: a git.push.remote raise binds the task's latest push, so the task has one.
f.broker.recordPush(f.coder, { task_ref: 'vikunja:1/2', project: 'stack', branch: 'feat', ref: 'work/vikunja-stack-2',
commit: 'a'.repeat(40), previous: null });
+62
View File
@@ -0,0 +1,62 @@
// Row 234 (#1740, r6 X4): host and grant store records for tests. Row a has no way to make a host_grants
// record outside a fixture, because row b's grant executor writes them. The decision, its raise event and
// its close go in by SQL. The grant evidence goes through writeGrantEvent (F1), so the trigger sees the
// real shape.
import { randomUUID } from 'node:crypto';
import { registrySha256, writeGrantEvent } from '../src/broker.mjs';
import { grantTag } from '../src/host-grant.mjs';
const AT = '2026-10-21T00:00:00.000Z';
const OPTIONS = JSON.stringify([{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }]);
export const HUMAN_YES = { op: 'resolved', by: 'jason', choice: 'yes', via: 'cli' };
// A credential.mint decision with `grant` in its raise body. `close` is the decision_events row, or null
// for an open decision.
export function grantDecision(store, { business = 'demo', action = 'credential.mint', route = 'human', grant, close = HUMAN_YES }) {
const id = randomUUID();
store.run(
'INSERT INTO decisions(id,at,business,raised_by_role,raised_by_run,class,action,route_to,question,options,recommendation,blocking) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)',
id, AT, business, 'chief', 'chief-run', 'gated', action, route, 'Grant?', OPTIONS, 'yes', 0,
);
store.run(
"INSERT INTO events(id,at,business,kind,actor_role,actor_run,subject,body) VALUES(?,?,?,'action.allowed',?,?,?,?)",
randomUUID(), AT, business, 'chief', 'chief-run', id,
JSON.stringify({ decision: id, action, operation: 'decision.raise', approvalChoice: 'yes', ...(grant ? { grant } : {}) }),
);
if (close)
store.run('INSERT INTO decision_events(decision,at,op,by,choice,via) VALUES(?,?,?,?,?,?)', id, AT, close.op, close.by, close.choice ?? null, close.via ?? null);
return id;
}
// A ready host record from an approved, spent and finished enrollment. grant.host.enrolled keeps the
// start reconcile from reading the enrollment as interrupted.
export function hostRecord(store, { business = 'demo', host = 'web1', entry, sudo = true }) {
const grant = { method: 'host-enroll', host, address: entry.address, port: entry.port, user: entry.user, sudo, fingerprints: [] };
const decision = grantDecision(store, { business, grant });
writeGrantEvent(store, AT, business, { operation: 'grant.host-enroll', decision }, decision);
const installerFingerprint = 'SHA256:' + 'A'.repeat(43);
writeGrantEvent(store, AT, business, {
action: 'grant.host.enrolled', grantDecision: decision, host, installerFingerprint, user: entry.user, sudo,
registrySha256: registrySha256({ ...entry, sudo }),
}, decision);
store.run(
'INSERT INTO hosts(business,host,address,port,user,sudo,host_keys,installer_fingerprint,state,decision,checked_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)',
business, host, entry.address, entry.port, entry.user, sudo ? 1 : 0, JSON.stringify(entry.hostKeys), installerFingerprint, 'ready', decision, AT,
);
return decision;
}
// One agent's grant record on a host. By default its decision is a `host` grant for the same agent and
// host, resolved yes by the human via cli and spent as grant.host. Each option breaks one part.
export function hostGrantRecord(store, { business = 'demo', agent, host = 'web1', commands, rotateBy = '2099-01-01', grant = {}, close = HUMAN_YES, spend = 'grant.host', action }) {
const decision = grantDecision(store, {
business, close, action,
grant: grant === null ? undefined : { method: 'host', agent, host, commands, rotateBy, ...grant },
});
if (spend) writeGrantEvent(store, AT, business, { operation: spend, decision }, decision);
store.run(
'INSERT INTO host_grants(business,agent,host,commands,rotate_by,tag,decision) VALUES(?,?,?,?,?,?,?)',
business, agent, host, JSON.stringify(commands), rotateBy, grantTag(business, agent, host), decision,
);
return decision;
}
+115 -7
View File
@@ -12,8 +12,10 @@ import { fileURLToPath } from 'node:url';
import { Credentials, parseHostKey } from '../src/credentials.mjs';
import { addKey, hostExecutor, runHost, sshArgs, startTime, sweepRunDir, quote, remoteCommand } from '../src/host-run.mjs';
import { fixture } from './spec-fixtures.mjs';
import { HUMAN_YES, hostGrantRecord, hostRecord } from './host-fixtures.mjs';
import { Broker, ORGANIZATION_KEYS } from '../src/broker.mjs';
import { Store } from '../src/store.mjs';
import { hostGrant } from '../src/host-grant.mjs';
import { startBroker } from '../src/runtime.mjs';
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../business/tests/helpers.mjs';
@@ -108,13 +110,12 @@ async function hostFixture(t, { commands = GRANT, reviewerHosts, wrongHostKey =
const executor = hostExecutor({ credentials, root });
const entry = { address: '127.0.0.1', port, user: USER, hostKeys: [pubLine(join(dir, wrongHostKey ? 'otherhost' : 'hostkey'))] };
const authority = { withinRole: ['message.send', 'record.write'], crossRole: [] };
const f = await fixture(t, {
roles: {
coder: { definition: 'coder', authority, hosts: { web1: { commands }, ...(coderWeb2 ? { web2: { commands } } : {}) } },
reviewer: { definition: 'reviewer', authority: { withinRole: ['message.send'], crossRole: [] }, ...(reviewerHosts ? { hosts: reviewerHosts } : {}) },
},
hosts: { web1: entry, web2: { ...entry } },
});
const f = await fixture(t, { roles: { coder: { definition: 'coder', authority } } });
// Row 234 (#1740, r6 X4): host.run reads store records, so the hosts and grants are fixtures there.
for (const host of ['web1', 'web2']) hostRecord(f.store, { host, entry });
hostGrantRecord(f.store, { agent: 'coder', host: 'web1', commands });
if (coderWeb2) hostGrantRecord(f.store, { agent: 'coder', host: 'web2', commands });
for (const [host, g] of Object.entries(reviewerHosts ?? {})) hostGrantRecord(f.store, { agent: 'reviewer', host, commands: g.commands });
const approve = async (argv, stdin, host = 'web1') => {
const d = await f.raise({ action: 'host.sudo', target: host, argv, ...(stdin !== undefined ? { stdin } : {}) });
f.call(f.human, 'decision.resolve', { id: d.id, choice: 'yes' });
@@ -291,6 +292,26 @@ test('T14: a host key not in the registry refuses and nothing runs', async (t) =
assert.deepEqual(f.argvSeen(), []);
});
// Row 234 (#1740, r6 G18, duckworth-0081 B2): hosts.check's `list` goes through runHost with the record's
// pinned keys. A stub `list` on the scratch sshd's PATH records each run.
test('G18: hosts.check with a wrong pin refuses host-key-mismatch and runs nothing on the host', async (t) => {
const f = await hostFixture(t);
writeFileSync(join(f.dir, 'fake', 'list'), `#!/bin/sh\necho "$@" >> '${f.dir}/list.log'\n`, { mode: 0o755 });
const configDir = join(f.dir, 'cfg');
const keyDir = join(configDir, 'secrets', 'hosts', 'demo', 'web1');
mkdirSync(keyDir, { recursive: true, mode: 0o700 });
for (const d of [join(configDir, 'secrets'), join(configDir, 'secrets', 'hosts'), join(configDir, 'secrets', 'hosts', 'demo'), keyDir]) chmodSync(d, 0o700);
writeFileSync(join(keyDir, 'installer'), readFileSync(f.keyFile), { mode: 0o600 });
const hg = hostGrant({ configDir, runRoot: f.root });
const record = { address: '127.0.0.1', port: f.port, user: 'jarvis', hostKeys: [pubLine(join(f.dir, 'otherhost'))] };
await refusesWith(hg.check('demo', 'web1', record), 'host-key-mismatch');
assert.equal(existsSync(join(f.dir, 'list.log')), false);
// Control: with the right pin the same command reaches the stub.
const out = await runHost({ root: f.root, hostId: 'web1', entry: f.entry, key: f.key, timeout: 10, command: 'list jarvis' });
assert.equal(out.exit, 0);
assert.equal(readFileSync(join(f.dir, 'list.log'), 'utf8'), 'jarvis\n');
});
test('N3: a remote exit 255 that prints the pin refusal text is the command result, and an ssh failure keeps its message', async (t) => {
const f = await hostFixture(t, { commands: [['fail255']] });
const out = await f.call(['fail255']);
@@ -532,3 +553,90 @@ test('T27: the bus start sweeps <configDir>/secrets/run before it listens, and a
// A bus that starts anyway is closed, so a failure here ends the file instead of holding it open.
await assert.rejects(async () => { const again = await start(); await again.close(); }, /credential-file/);
});
// Row 234 (#1740, duckworth-0079 M3, r6 X4 first bullet): host.run takes a grant record only behind the
// Approve record check. Each case breaks one part of it. The refusals come before the executor, so none
// needs sshd. The control passes the check and stops at host-not-configured.
test('M3: host.run refuses host-not-granted unless the grant record has an approved, spent host grant', async (t) => {
const f = await fixture(t, { roles: { coder: { definition: 'coder', authority: { withinRole: ['message.send'], crossRole: [] } } } });
const entry = { address: '10.0.0.5', port: 22, user: 'admin', hostKeys: ['ssh-ed25519 AAAA'] };
for (const host of ['web1', 'web2']) hostRecord(f.store, { host, entry });
const run = (cap = f.coder, host = 'web1') => f.broker.requestHost(cap, { verb: 'host.run', args: { host, argv: ['true'] } }, null);
const commands = [['true']];
const cases = {
'no record': null,
unresolved: { close: null },
'resolved no': { close: { ...HUMAN_YES, choice: 'no' } },
withdrawn: { close: { op: 'withdrawn', by: 'jason', via: 'cli' } },
'an arbiter': { close: { ...HUMAN_YES, by: 'reviewer', via: 'broker' } },
'the tester': { close: { ...HUMAN_YES, by: 'tester' } },
'an agent': { close: { ...HUMAN_YES, by: 'coder', via: 'broker' } },
'not via cli': { close: { ...HUMAN_YES, via: 'web' } },
'consumed as grant.host-enroll': { spend: 'grant.host-enroll' },
unspent: { spend: null },
'not a credential.mint': { action: 'deploy' },
'no grant in the raise': { grant: null },
'a host-enroll grant': { grant: { method: 'host-enroll' } },
'a grant for another agent': { grant: { agent: 'reviewer' } },
'a grant for another host': { grant: { host: 'web2' } },
};
for (const [name, opts] of Object.entries(cases)) {
f.store.run('DELETE FROM host_grants');
if (opts) hostGrantRecord(f.store, { agent: 'coder', host: 'web1', commands, ...opts });
await refusesWith(run(), 'host-not-granted').catch((e) => assert.fail(`${name}: ${e.message}`));
}
f.store.run('DELETE FROM host_grants');
hostGrantRecord(f.store, { agent: 'coder', host: 'web1', commands });
await refusesWith(run(), 'host-not-configured');
// The record is per agent: the reviewer holds none on web1, and web9 has no host record.
await refusesWith(run(f.reviewer), 'host-not-granted');
await refusesWith(run(f.coder, 'web9'), 'host-unknown');
// The host.sudo raise takes the same check (N1).
f.store.run('DELETE FROM host_grants');
hostGrantRecord(f.store, { agent: 'coder', host: 'web1', commands, spend: null });
assert.throws(() => f.raise({ action: 'host.sudo', target: 'web1', argv: ['true'] }), /host-not-granted/);
});
// Row 234 (#1740, M3, r6 X4 third bullet): at start each host_grants record gives Credentials a store
// reference to host.<id>, so a restart finds the key the grant executor wrote.
test('M3: a host_grants record gives Credentials a store reference at start, and a restart reads its key', async (t) => {
const root = mkdtempSync(join(tmpdir(), 'h234-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const rolesDir = rolesCopy(root);
mkdirSync(join(root, 'data'), { mode: 0o700 });
mkdirSync(join(root, 'secrets', 'agents'), { recursive: true, mode: 0o700 });
chmodSync(join(root, 'secrets'), 0o700);
const demo = {
id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' },
roles: { coder: { authority: { withinRole: ['message.send'], crossRole: [] }, credentials: {} } },
};
const start = () => startBroker({ dataRoot: join(root, 'data'), businesses: { demo }, organization: { dir: root, rolesDir, system: systemFor(root) } });
const hostKeys = (bus) => bus.credentials.status().filter((c) => c.service.startsWith('host.'));
let bus = await start();
assert.deepEqual(hostKeys(bus), []);
await bus.close();
const store = new Store(join(root, 'data'));
hostRecord(store, { host: 'web1', entry: { address: '10.0.0.5', port: 22, user: 'admin', hostKeys: ['ssh-ed25519 AAAA'] } });
hostGrantRecord(store, { agent: 'coder', host: 'web1', commands: [['true']], rotateBy: '2099-02-03' });
store.close();
bus = await start();
try {
assert.deepEqual(hostKeys(bus).map(({ instance, service, date, state }) => ({ instance, service, date, state })), [
{ instance: 'demo/coder', service: 'host.web1', date: '2099-02-03', state: 'not-issued' },
]);
} finally {
await bus.close();
}
const dir = join(root, 'secrets', 'agents', 'demo', 'coder');
mkdirSync(dir, { recursive: true, mode: 0o700 });
chmodSync(join(root, 'secrets', 'agents', 'demo'), 0o700);
keygen(join(dir, 'host.web1'));
bus = await start();
try {
const [key] = hostKeys(bus);
assert.equal(key.publicKey, pubLine(join(dir, 'host.web1')));
assert.notEqual(key.state, 'not-issued');
} finally {
await bus.close();
}
});
+358
View File
@@ -0,0 +1,358 @@
// Row 234 (#1740, #436a row a), design r6 E3: the four host files in tools/host, as G16 and G25.
// The writer runs as the test user through a harness that loads it by path and passes its own dirs,
// owner uid and user, group and sudo lookups, so nothing runs as root and nothing under /etc is read.
// mosaic-keys runs from a copy whose sudo and logger paths point at stubs in a scratch dir.
import test from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync, spawnSync } from 'node:child_process';
import { createHash, randomBytes } from 'node:crypto';
import {
chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, symlinkSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
const REPO = fileURLToPath(new URL('../../../', import.meta.url));
const TOOLS = join(REPO, 'tools', 'host');
const WRITER = join(TOOLS, 'mosaic-keys-write');
const REFUSED_GROUPS = ['sudo', 'admin', 'wheel', 'docker', 'lxd', 'disk', 'libvirt', 'incus-admin'];
const tag = (n) => 'mosaic-' + createHash('sha256').update(String(n)).digest('hex').slice(0, 32);
const u32 = (n) => {
const b = Buffer.alloc(4);
b.writeUInt32BE(n);
return b;
};
const keyBlob = () => Buffer.concat([u32(11), Buffer.from('ssh-ed25519'), u32(32), randomBytes(32)]).toString('base64');
const fingerprint = (b64) => 'SHA256:' + createHash('sha256').update(Buffer.from(b64, 'base64')).digest('base64').replace(/=+$/, '');
const notAllowed = (u) => `User ${u} is not allowed to run sudo on testhost.\n`;
const HARNESS = `import importlib.machinery, importlib.util, json, os, sys
sys.dont_write_bytecode = True
cfg = json.loads(sys.argv[1])
loader = importlib.machinery.SourceFileLoader('mosaic_keys_write', cfg['writer'])
m = importlib.util.module_from_spec(importlib.util.spec_from_loader('mosaic_keys_write', loader))
loader.exec_module(m)
users = cfg['users']
def log(msg):
with open(cfg['log'], 'a') as f:
f.write(msg + '\\n')
def sudo(u):
with open(cfg['sudoLog'], 'a') as f:
f.write(u + '\\n')
return users[u]['sudo'] if 'sudo' in users[u] else 'User %s is not allowed to run sudo on testhost.\\n' % u
host = m.Host(key_dir=cfg['keyDir'], conf_dir=cfg['confDir'], owner=os.getuid(),
passwd=lambda u: users[u]['uid'] if u in users else None,
groups=lambda u: users[u].get('groups', []), sudo=sudo, hostname='testhost', log=log)
sys.exit(m.main(sys.argv[2:], host))
`;
// The users the harness's lookups know. users lists all of them but dave and eve. bob and dave are
// privileged. carol has a sudo line. Each gX is in one refused group.
function userTable() {
const users = {
alice: { uid: 1500 },
bob: { uid: 1501, groups: ['sudo', 'docker'], sudo: 'User bob may run the following commands on testhost:\n (ALL) ALL\n' },
carol: { uid: 1502, sudo: 'User carol may run the following commands on testhost:\n (root) /usr/bin/true\n' },
dave: { uid: 1503 },
eve: { uid: 1504 },
frank: { uid: 1505, groups: ['users', 'adm'] },
'../x': { uid: 1506 },
root: { uid: 0 },
toor: { uid: 0 },
daemon2: { uid: 999 },
localadmin: { uid: 1000 },
'mosaic-installer': { uid: 1001 },
};
for (const [i, g] of REFUSED_GROUPS.entries()) users['g' + g] = { uid: 1600 + i, groups: ['users', g] };
return users;
}
function hostDirs() {
const root = mkdtempSync(join(tmpdir(), 'hk234-'));
const keyDir = join(root, 'keys');
const confDir = join(root, 'conf');
mkdirSync(keyDir, { mode: 0o755 });
mkdirSync(confDir, { mode: 0o755 });
chmodSync(keyDir, 0o755);
const users = userTable();
const conf = (name, text) => {
writeFileSync(join(confDir, name), text, { mode: 0o644 });
chmodSync(join(confDir, name), 0o644);
};
conf('users', '# users the writer may touch\n' + Object.keys(users).filter((u) => !['dave', 'eve'].includes(u)).join('\n') + '\n');
conf('privileged', 'bob\ndave\n');
conf('from', '10.1.2.3/32\n');
const harness = join(root, 'harness.py');
writeFileSync(harness, HARNESS);
const cfg = { writer: WRITER, keyDir, confDir, users, log: join(root, 'syslog'), sudoLog: join(root, 'sudo.log') };
const run = (...args) => {
const r = spawnSync('python3', ['-B', '-I', harness, JSON.stringify(cfg), ...args], { encoding: 'utf8' });
return { status: r.status, stdout: r.stdout, stderr: r.stderr };
};
const read = (file) => readFileSync(join(keyDir, file), 'utf8');
const lastLog = () => readFileSync(cfg.log, 'utf8').trim().split('\n').at(-1);
const sudoCalls = () => (existsSync(cfg.sudoLog) ? readFileSync(cfg.sudoLog, 'utf8').trim().split('\n') : []);
return { root, keyDir, confDir, conf, run, read, lastLog, sudoCalls, done: () => rmSync(root, { recursive: true, force: true }) };
}
const refused = (r, code) => {
assert.equal(r.status, 1, r.stderr);
assert.equal(r.stderr, `mosaic-keys-write: refused ${code}\n`);
};
test('G16: the writer refuses each user, tag and key the design names, before any key file', () => {
const h = hostDirs();
try {
const key = keyBlob();
const t = tag(1);
const add = (user) => h.run('add', user, 'ssh-ed25519', key, t);
// The user pattern stops `../x` before any path, though the lookups and users know it.
for (const user of ['../x', 'Alice', 'a'.repeat(33), '1alice', 'al ice', '']) {
refused(add(user), 'user-pattern');
refused(h.run('list', user), 'user-pattern');
}
// users is always required: privileged alone allows nothing.
refused(add('dave'), 'user-not-listed');
refused(add('eve'), 'user-not-listed');
for (const user of ['root', 'localadmin', 'mosaic-installer']) refused(add(user), 'user-not-listed');
refused(add('toor'), 'user-system');
refused(add('daemon2'), 'user-system');
refused(add('ghost'), 'user-not-listed');
for (const g of REFUSED_GROUPS) {
refused(add('g' + g), 'user-group');
refused(h.run('remove', 'g' + g, t), 'user-group');
}
refused(add('carol'), 'user-sudo');
refused(h.run('list', 'carol'), 'user-sudo');
assert.equal(h.lastLog(), 'result=refused user-sudo user=carol');
for (const bad of ['mosaic-' + 'a'.repeat(31), 'mosaic-' + 'A'.repeat(32), 'mosaic-installer-' + 'a'.repeat(32), 'mosaic-' + 'g'.repeat(32)]) {
refused(h.run('add', 'alice', 'ssh-ed25519', key, bad), 'tag-pattern');
refused(h.run('remove', 'alice', bad), 'tag-pattern');
}
refused(h.run('add', 'alice', 'ssh-dss', key, t), 'key-format');
refused(h.run('add', 'alice', 'ssh-ed25519', key.slice(0, -2) + '!!', t), 'key-format');
refused(h.run('add', 'alice', 'ssh-ed25519', 'A', t), 'key-format');
refused(h.run('add', 'alice', 'ssh-ed25519', key + ' x', t), 'key-format');
for (const args of [[], ['list'], ['list', 'alice', 'bob'], ['add', 'alice', 'ssh-ed25519', key], ['remove', 'alice'], ['rm', 'alice', t]])
refused(h.run(...args), 'request');
// No refusal reached the key dir, so not even the lock exists.
assert.deepEqual(readdirSync(h.keyDir), []);
// Only users past the groups check reach sudo -l, and the privileged never do.
assert.deepEqual([...new Set(h.sudoCalls())], ['carol']);
} finally {
h.done();
}
});
test('G16: from= comes from the host file, a privileged user skips the group and sudo checks, and the line is exact', () => {
const h = hostDirs();
try {
const k1 = keyBlob();
const k2 = keyBlob();
assert.equal(h.run('add', 'alice', 'ssh-ed25519', k1, tag(1)).status, 0);
assert.equal(h.lastLog(), `result=added user=alice tag=${tag(1)} key=${fingerprint(k1)}`);
h.conf('from', '# the stack host\n10.9.9.9,192.168.1.0/24\n');
assert.equal(h.run('add', 'alice', 'ssh-ed25519', k2, tag(2)).status, 0);
assert.equal(
h.read('alice'),
`from="10.1.2.3/32",restrict ssh-ed25519 ${k1} ${tag(1)}\nfrom="10.9.9.9,192.168.1.0/24",restrict ssh-ed25519 ${k2} ${tag(2)}\n`,
);
// A tag already there changes nothing and exits 0.
assert.equal(h.run('add', 'alice', 'ssh-ed25519', k2, tag(1)).status, 0);
assert.equal(h.lastLog(), `result=present user=alice tag=${tag(1)} key=${fingerprint(k2)}`);
assert.equal(h.read('alice').split('\n').length, 3);
// bob is in sudo and docker with a sudo line, and privileged.
assert.equal(h.run('add', 'bob', 'ssh-ed25519', k1, tag(3)).status, 0);
// frank's groups are none of the refused ones.
assert.equal(h.run('add', 'frank', 'ssh-ed25519', k1, tag(4)).status, 0);
assert.deepEqual(h.sudoCalls(), ['alice', 'alice', 'alice', 'frank']);
// A from value that could close the quote, two values, or none, refuses and writes nothing.
for (const text of ['10.0.0.1",command="/bin/sh\n', '10.0.0.1\n10.0.0.2\n', '# none\n', '10.0.0.1 10.0.0.2\n']) {
h.conf('from', text);
refused(h.run('add', 'frank', 'ssh-ed25519', k2, tag(5)), 'config-from');
}
rmSync(join(h.confDir, 'from'));
refused(h.run('add', 'frank', 'ssh-ed25519', k2, tag(5)), 'config-from');
assert.ok(!h.read('frank').includes(tag(5)));
// list and remove don't need from.
assert.equal(h.run('list', 'frank').stdout, `from="10.9.9.9,192.168.1.0/24",restrict ssh-ed25519 ${k1} ${tag(4)}\n`);
for (const name of ['users', 'privileged']) {
chmodSync(join(h.confDir, name), 0o664);
refused(h.run('list', 'alice'), 'not-root-owned');
chmodSync(join(h.confDir, name), 0o644);
}
rmSync(join(h.confDir, 'users'));
refused(h.run('list', 'alice'), 'config-users');
} finally {
h.done();
}
});
test('G16: list shows tagged lines only, and add and remove never touch a line outside the tag pattern', () => {
const h = hostDirs();
try {
const k = keyBlob();
const hand = [
'# written by hand',
`ssh-ed25519 ${k} mosaic-installer-${'a'.repeat(32)}`,
'',
`ssh-ed25519 ${k} ${tag(9).toUpperCase()}`,
`ssh-ed25519 ${k} ${tag(1)} trailing`,
];
const tagged = `from="10.1.2.3/32",restrict ssh-ed25519 ${k} ${tag(7)}`;
writeFileSync(join(h.keyDir, 'alice'), [hand[0], tagged, ...hand.slice(1)].join('\n') + '\n', { mode: 0o644 });
assert.equal(h.run('list', 'alice').stdout, tagged + '\n');
// The last field is the tag, so `... mosaic-<1> trailing` isn't tag 1.
assert.equal(h.run('remove', 'alice', tag(1)).status, 0);
assert.equal(h.lastLog(), `result=absent user=alice tag=${tag(1)}`);
assert.equal(h.run('add', 'alice', 'ssh-ed25519', k, tag(8)).status, 0);
assert.equal(h.run('remove', 'alice', tag(7)).status, 0);
assert.equal(h.lastLog(), `result=removed user=alice tag=${tag(7)}`);
assert.equal(h.read('alice'), [...hand, `from="10.1.2.3/32",restrict ssh-ed25519 ${k} ${tag(8)}`].join('\n') + '\n');
assert.equal(h.run('remove', 'alice', tag(8)).status, 0);
assert.equal(h.read('alice'), hand.join('\n') + '\n');
// A missing file lists nothing and a remove there changes nothing.
assert.equal(h.run('list', 'frank').stdout, '');
assert.equal(h.run('remove', 'frank', tag(1)).status, 0);
assert.ok(!existsSync(join(h.keyDir, 'frank')));
} finally {
h.done();
}
});
test('G25: the writer refuses a symlinked dir or user file, a group-writable dir and an odd tmp, and writes 0644', () => {
const h = hostDirs();
try {
const k = keyBlob();
const add = (n, user = 'alice') => h.run('add', user, 'ssh-ed25519', k, tag(n));
// A first add creates the file, regular, 0644, owned by the owner uid, with no tmp left.
assert.equal(add(1).status, 0);
const st = statSync(join(h.keyDir, 'alice'));
assert.equal(st.mode & 0o777, 0o644);
assert.equal(st.uid, process.getuid());
assert.deepEqual(readdirSync(h.keyDir).sort(), ['.lock', 'alice']);
// A leftover regular tmp is unlinked and the write goes ahead.
writeFileSync(join(h.keyDir, '.alice.tmp'), 'half a write');
assert.equal(add(2).status, 0);
assert.ok(h.read('alice').includes(tag(2)));
assert.ok(!existsSync(join(h.keyDir, '.alice.tmp')));
// A tmp that is a symlink, a dir or a fifo refuses, and nothing is written.
const decoy = join(h.root, 'decoy');
writeFileSync(decoy, 'decoy\n');
const before = h.read('alice');
for (const make of [
(p) => symlinkSync(decoy, p),
(p) => mkdirSync(p),
(p) => execFileSync('mkfifo', [p]),
]) {
make(join(h.keyDir, '.alice.tmp'));
refused(add(3), 'tmp-file');
assert.equal(h.read('alice'), before);
assert.equal(readFileSync(decoy, 'utf8'), 'decoy\n');
rmSync(join(h.keyDir, '.alice.tmp'), { recursive: true });
}
// A symlinked user file refuses, though its target is a regular file the owner owns.
symlinkSync(decoy, join(h.keyDir, 'frank'));
refused(add(4, 'frank'), 'user-file');
refused(h.run('list', 'frank'), 'user-file');
assert.equal(readFileSync(decoy, 'utf8'), 'decoy\n');
assert.ok(lstatSync(join(h.keyDir, 'frank')).isSymbolicLink());
// A user "file" that is a dir refuses.
mkdirSync(join(h.keyDir, 'bob'));
refused(add(5, 'bob'), 'not-root-owned');
// A group-writable dir refuses.
chmodSync(h.keyDir, 0o775);
refused(add(6), 'not-root-owned');
chmodSync(h.keyDir, 0o755);
// A symlinked key dir refuses, and its target is untouched.
const real = join(h.root, 'real-keys');
mkdirSync(real, { mode: 0o755 });
rmSync(h.keyDir, { recursive: true });
symlinkSync(real, h.keyDir);
refused(add(7), 'key-dir');
assert.deepEqual(readdirSync(real), []);
} finally {
h.done();
}
});
// mosaic-keys from a copy with its sudo and logger paths on stubs. The stub sudo records its argv,
// prints a line and exits 3, so the test sees the exit and output pass through.
function forcedCommand() {
const root = mkdtempSync(join(tmpdir(), 'hk234-'));
const sudo = join(root, 'sudo');
const logger = join(root, 'logger');
writeFileSync(sudo, `#!/bin/bash\nprintf '%s\\n' "$@" > ${root}/sudo.argv\necho from-writer\nexit 3\n`, { mode: 0o755 });
writeFileSync(logger, `#!/bin/bash\nprintf '%s\\n' "$*" >> ${root}/logger.log\n`, { mode: 0o755 });
const source = readFileSync(join(TOOLS, 'mosaic-keys'), 'utf8');
assert.equal(source.split('/usr/bin/sudo').length, 2);
assert.equal(source.split('/usr/bin/logger').length, 2);
const copy = join(root, 'mosaic-keys');
writeFileSync(copy, source.replace('/usr/bin/sudo', sudo).replace('/usr/bin/logger', logger), { mode: 0o755 });
const run = (request) => {
rmSync(join(root, 'sudo.argv'), { force: true });
const env = { PATH: '/usr/bin:/bin', SSH_CONNECTION: '10.1.2.3 50000 10.1.2.9 22' };
if (request !== undefined) env.SSH_ORIGINAL_COMMAND = request;
const r = spawnSync(copy, [], { encoding: 'utf8', env });
const argv = existsSync(join(root, 'sudo.argv')) ? readFileSync(join(root, 'sudo.argv'), 'utf8').trim().split('\n') : null;
return { status: r.status, stdout: r.stdout, stderr: r.stderr, argv };
};
const logged = () => readFileSync(join(root, 'logger.log'), 'utf8').trim().split('\n');
return { run, logged, done: () => rmSync(root, { recursive: true, force: true }) };
}
test('G16: mosaic-keys passes the three requests to the writer through sudo and refuses anything else without it', () => {
const f = forcedCommand();
try {
const k = keyBlob();
const t = tag(1);
const writer = ['-n', '/usr/local/sbin/mosaic-keys-write'];
for (const words of [['list', 'alice'], ['add', 'alice', 'ssh-ed25519', k, t], ['add', 'alice', 'ecdsa-sha2-nistp256', 'AAAA', t], ['remove', 'alice', t]]) {
const r = f.run(words.join(' '));
assert.deepEqual(r.argv, [...writer, ...words]);
assert.equal(r.status, 3);
assert.equal(r.stdout, 'from-writer\n');
}
const bad = [
undefined, '', 'list', 'list ../x', 'list Alice', 'list alice;id', 'list alice ', ' list alice', 'list alice', 'list\talice',
'list alice\nlist bob', 'list $(id)', 'list alice`id`', 'list *', 'rm alice', 'list ' + 'a'.repeat(33),
`add alice ssh-dss ${k} ${t}`, `add alice ssh-ed25519 ${k}! ${t}`, `add alice ssh-ed25519 ${k}`, `add alice ssh-ed25519 ${k} ${t} x`,
`remove alice mosaic-${'a'.repeat(31)}`, `remove alice ${t.toUpperCase()}`, `remove alice ${t}\n`, `remove alice ${t}; rm -rf /`,
];
for (const request of bad) {
const r = f.run(request);
assert.equal(r.argv, null, JSON.stringify(request));
assert.equal(r.status, 1);
assert.equal(r.stderr, 'mosaic-keys: refused\n');
}
// Two bad requests hold a newline, so count the logger calls, not the lines.
const logged = f.logged().filter((l) => l.startsWith('-t mosaic-keys -- '));
assert.equal(logged.length, 4 + bad.length);
assert.equal(logged[0], '-t mosaic-keys -- connection=10.1.2.3 50000 10.1.2.9 22 request=list alice');
} finally {
f.done();
}
});
test('E3: the sudoers line and the sshd drop-in are the design lines and parse', () => {
const lines = (file) => readFileSync(join(TOOLS, file), 'utf8').split('\n').filter((l) => l && !l.startsWith('#'));
assert.deepEqual(lines('mosaic-keys.sudoers'), ['mosaic-installer ALL=(root) NOPASSWD: /usr/local/sbin/mosaic-keys-write']);
assert.deepEqual(lines('mosaic-keys.sshd.conf'), ['AuthorizedKeysFile .ssh/authorized_keys .ssh/authorized_keys2 /etc/ssh/mosaic-keys/%u']);
assert.ok(existsSync('/usr/bin/visudo'), 'visudo on the test host');
execFileSync('/usr/bin/visudo', ['-c', '-q', '-f', join(TOOLS, 'mosaic-keys.sudoers')]);
const dir = mkdtempSync(join(tmpdir(), 'hk234-'));
try {
execFileSync('ssh-keygen', ['-q', '-t', 'ed25519', '-N', '', '-f', join(dir, 'hk')]);
writeFileSync(join(dir, 'cfg'), `HostKey ${join(dir, 'hk')}\nInclude ${join(TOOLS, 'mosaic-keys.sshd.conf')}\n`);
const out = execFileSync('/usr/bin/sshd', ['-T', '-f', join(dir, 'cfg'), '-C', 'user=alice,host=x,addr=127.0.0.1'], { encoding: 'utf8' });
// sshd -T prints this key as AuthorizedKeysFile, unlike most.
const line = out.split('\n').filter((l) => /^authorizedkeysfile /i.test(l));
assert.deepEqual(line.map((l) => l.split(' ').slice(1).join(' ')), ['.ssh/authorized_keys .ssh/authorized_keys2 /etc/ssh/mosaic-keys/%u']);
} finally {
rmSync(dir, { recursive: true, force: true });
}
for (const file of ['mosaic-keys', 'mosaic-keys-write']) assert.equal(statSync(join(TOOLS, file)).mode & 0o111, 0o111, file);
assert.equal(readFileSync(WRITER, 'utf8').split('\n')[0], '#!/usr/bin/python3 -I');
});
+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, '3i');
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) });
+2 -1
View File
@@ -25,7 +25,8 @@ test('Broker keeps the sandbox client import closure: only the listed imports, n
// RUNNER_FILES and imports nothing. Row 155 (#1649) reads THREAD_DEFINITION and END_PROTECTED
// from it too. Row 199 (#1697) reads the D9c fork parse, forkProblem and forkUrl, from it.
// Row 210 (#1707) reads credentialKind and the D6 argv check, validArgv and commandAllowed, from it.
"import { ID_PATTERN, THREAD_DEFINITION, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed } from '../../business/src/vocabulary.mjs';",
// Row 234 (#1740) reads HOST_ID for the hosts verbs.
"import { ID_PATTERN, THREAD_DEFINITION, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID } from '../../business/src/vocabulary.mjs';",
]);
assert.doesNotMatch(source, /\bimport\s*\(/);
assert.doesNotMatch(readFileSync(new URL('../src/landing.mjs', import.meta.url), 'utf8'), /\bimport\b/);
+3 -2
View File
@@ -519,7 +519,8 @@ test('v3b prototype refusals, views and append-only mutations', (t) => {
// Row 121 (#1615) adds 5 tables and 24 triggers for projects and threads; threads.test.mjs tests them.
// 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.
assert.equal(count('table') - 1, 16);
assert.equal(count('trigger'), 70);
// Row 234 (#1740) adds hosts, host_grants and the events_grant_actor guard; grant-migration.test.mjs tests them.
assert.equal(count('table') - 1, 18);
assert.equal(count('trigger'), 71);
assert.equal(count('view'), 5);
});
+2 -2
View File
@@ -7,7 +7,7 @@ import { Broker } from '../src/broker.mjs';
import { initProject, readSpec, writeRecord, listProjectFiles, readProjectFile, writeHumanFile, ProjectError } from '../../project/src/index.mjs';
export const git = (repo, ...args) => execFileSync('git', ['-c', 'core.hooksPath=/dev/null', '-C', repo, ...args], { encoding: 'utf8' }).trim();
const options = [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }];
export async function fixture(t, { adapter = true, roles = {}, arbiters = { technical: 'reviewer', delivery: 'reviewer' }, hosts } = {}) {
export async function fixture(t, { adapter = true, roles = {}, arbiters = { technical: 'reviewer', delivery: 'reviewer' } } = {}) {
const root = mkdtempSync(join(tmpdir(), 'bus-spec-'));
const store = new Store(root);
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
@@ -17,7 +17,7 @@ export async function fixture(t, { adapter = true, roles = {}, arbiters = { tech
coder: { definition: 'coder', authority: authority(['message.send', 'record.write']) },
reviewer: { definition: 'reviewer', authority: authority(['message.send']) },
...roles,
}, ...(hosts ? { hosts } : {}) };
} };
const { repo, commit } = await initProject({ business, project: 'stack', dataRoot: root });
const broker = new Broker({ store, businesses: { demo: business }, project: adapter ? {
readSpec: context => readSpec({ ...context, dataRoot: root }),
+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, '3i');
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 = '3i';", "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, '3i');
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 3i ready', ''].join('\n'));
assert.equal(migrated.stdout, [...REPORT, 'bus schema 3j ready', ''].join('\n'));
});
+2 -2
View File
@@ -51,12 +51,12 @@ function snapshot(path, columns) {
return { columns, rows };
} finally { db.close(); }
}
test('REQ-LIMIT-4 3g copy migrates through 3h to 3i preserving every old column, row and sequence; new usage table is empty', t => {
test('REQ-LIMIT-4 3g copy migrates through 3h and 3i to 3j preserving every old column, row and sequence; new usage table is empty', t => {
const { root, path, copy } = source3g(t), before = snapshot(path), untouched = readFileSync(copy);
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, '3i');
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');
+11 -15
View File
@@ -10,8 +10,7 @@ import { checkVars } from "./vars.mjs";
import { parseCredentialRef } from "./credentials.mjs";
import { loadRole } from "./role.mjs";
import { checkPlugins } from "../../plugins/src/kinds.mjs";
import { forkProblem, giteaName, parseGiteaUrl, HOST_SERVICE, HOST_PREFIX } from "./vocabulary.mjs";
import { checkHosts, checkHostGrants } from "./hosts.mjs";
import { forkProblem, giteaName, parseGiteaUrl, HOST_SERVICE } from "./vocabulary.mjs";
import {
requireObject, rejectUnknownKeys, requireId, requirePositiveInt, requireString, requireDistinctList, readJsonFile, deepFreeze,
} from "./util.mjs";
@@ -103,7 +102,11 @@ function checkTracker(tracker, file) {
return { sync, labels };
}
function checkRoles(roles, file, rolesDir, business, hosts) {
// Row 234 (#1740, r6 S1): hosts and host grants are bus store records made by a human-approved grant,
// so the business file can't name them. The key stays known so the refusal says where they went.
const HOSTS_MOVED = "lives in the bus store now: enroll a host with a host-enroll grant (mosaic hosts status)";
function checkRoles(roles, file, rolesDir, business) {
const where = `${file} roles`;
requireObject(roles, where);
if (Object.keys(roles).length === 0) refuse(`${where} must declare at least one role instance`);
@@ -121,14 +124,9 @@ function checkRoles(roles, file, rolesDir, business, hosts) {
definitions[entry.definition] = definition;
if (definition.roleVersion !== 2) refuse(`${at}: role definition ${entry.definition} is version 1 and can't back a business role instance`);
const services = definition.credentials.map((c) => c.service);
// Row 210 (#1707, design D3 steps 1 and 3): a host grant needs a definition that declares the
// host credential, and each granted host needs its own host.<id> key reference.
let hostGrants = null;
if (entry.hosts !== undefined) {
if (!services.includes(HOST_SERVICE)) refuse(`${at}.hosts needs a role definition that declares the host credential`);
hostGrants = checkHostGrants(entry.hosts, hosts, `${at}.hosts`);
}
const kinds = [...services.filter((s) => s !== HOST_SERVICE), ...Object.keys(hostGrants ?? {}).map((id) => HOST_PREFIX + id)];
if (entry.hosts !== undefined) refuse(`${at}.hosts ${HOSTS_MOVED}`);
// A host.<id> key reference comes from the agent's grant record at bus start (r6 X4), never from here.
const kinds = services.filter((s) => s !== HOST_SERVICE);
let tracker = null;
if (services.includes("vikunja")) {
if (entry.tracker === undefined) refuse(`${at} needs "tracker" with its Vikunja bot, because ${entry.definition} uses vikunja`);
@@ -151,7 +149,6 @@ function checkRoles(roles, file, rolesDir, business, hosts) {
// Row 199 (#1697, design D8, D9 step 2): identity and publish are optional, so today's files
// load unchanged. publish needs both an identity and a gitea credential; the project ids it
// names are checked against projects once those are read.
if (hostGrants) out[instance].hosts = hostGrants;
if (entry.identity !== undefined) out[instance].identity = checkIdentity(entry.identity, `${at}.identity`);
if (entry.publish !== undefined) {
// N10: one message for both, naming the grant.
@@ -207,8 +204,8 @@ export function validateBusinessDocument(document, file, { rolesDir }) {
}
const human = requireId(document.human, "business human");
const vars = checkVars(document.vars ?? {}, "business", `${file} vars`);
const hosts = checkHosts(document.hosts, `${file} hosts`);
const { roles, definitions } = checkRoles(document.roles, file, rolesDir, document.id, hosts);
if (document.hosts !== undefined) refuse(`${file} hosts ${HOSTS_MOVED}`);
const { roles, definitions } = checkRoles(document.roles, file, rolesDir, document.id);
requireObject(document.arbiters, `${file} arbiters`);
rejectUnknownKeys(document.arbiters, ["delivery", "technical"], `${file} arbiters`);
@@ -268,7 +265,6 @@ export function validateBusinessDocument(document, file, { rolesDir }) {
return deepFreeze({
businessVersion: 1, id: document.id, file, human, arbiters, projects, vars, tracker, roles, launch, plugins, definitions,
...(document.hosts === undefined ? {} : { hosts }),
});
}
+8 -49
View File
@@ -1,19 +1,19 @@
// Row 210 (#1707, design D3 and D6): the host registry in the business file, an agent's host
// grants, and the argv allowlist the broker matches each host.run against. Hand edits only: the
// chief's organization.set and the human's instances.set take neither key.
// Row 210 (#1707, design D3 and D6): the address, user and host key checks, and the argv allowlist the
// broker matches each host.run against. Row 234 (#1740, r6 S1) moved the host registry and the grants
// out of the business file into bus store records. The enrollment raise checks its fields with these,
// and row b's host grant raise checks its commands with checkCommands.
import { isIP } from "node:net";
import { refuse } from "./errors.mjs";
import { HOST_ID, ARGV_MAX } from "./vocabulary.mjs";
import { ARGV_MAX } from "./vocabulary.mjs";
// Row 210 r4 (#1707): the argv check lives in vocabulary.mjs, which the worker sandbox mounts
// and which imports nothing, so the broker can read it. Re-exported so the D6 checks keep one
// business home here.
export { ARGV_MAX, ARG_BYTES, ARGV_BYTES, validArgv, commandAllowed } from "./vocabulary.mjs";
import { requireObject, rejectUnknownKeys, isPlainObject } from "./util.mjs";
const DNS = /^[a-z0-9]([a-z0-9.-]{0,251}[a-z0-9])?$/;
const USER = /^[a-z_][a-z0-9_-]{0,31}$/;
const HOST_KEY = /^(ssh-ed25519|ecdsa-sha2-nistp256|ssh-rsa) [A-Za-z0-9+/]+={0,2}$/;
export const USER = /^[a-z_][a-z0-9_-]{0,31}$/;
export const HOST_KEY = /^(ssh-ed25519|ecdsa-sha2-nistp256|ssh-rsa) [A-Za-z0-9+/]+={0,2}$/;
// D6: a prefix starting with one of these, by basename, would turn the grant back into "any".
// The list can't be complete. Under PRD 1.7 the control is the human's decision on each call (D4).
@@ -49,7 +49,7 @@ export function ownMachine(address) {
return address === "localhost" || address.endsWith(".localhost");
}
function checkAddress(address, where) {
export function checkAddress(address, where) {
if (typeof address !== "string") refuse(`${where} must be an IP address or a DNS name`);
const version = isIP(address);
if (version === 6 && address.includes("%")) refuse(`${where} must not carry an IPv6 zone`);
@@ -63,32 +63,6 @@ function checkAddress(address, where) {
return address;
}
// The top-level "hosts" key: { <hostId>: { address, port, user, hostKeys } }.
export function checkHosts(hosts, where) {
if (hosts === undefined) return {};
requireObject(hosts, where);
const out = {};
for (const [id, entry] of Object.entries(hosts)) {
if (!HOST_ID.test(id)) refuse(`${where} host id ${JSON.stringify(id)} must match ${HOST_ID}`);
const at = `${where}.${id}`;
requireObject(entry, at);
rejectUnknownKeys(entry, ["address", "port", "user", "hostKeys"], at);
const address = checkAddress(entry.address, `${at}.address`);
if (!Number.isInteger(entry.port) || entry.port < 1 || entry.port > 65535) refuse(`${at}.port must be 1 to 65535`);
if (typeof entry.user !== "string" || !USER.test(entry.user)) refuse(`${at}.user must match ${USER}`);
if (!Array.isArray(entry.hostKeys) || entry.hostKeys.length === 0) refuse(`${at}.hostKeys must be a non-empty list`);
const keys = entry.hostKeys.map((key, i) => {
if (typeof key !== "string" || !HOST_KEY.test(key)) {
refuse(`${at}.hostKeys[${i}] must be "<type> <base64>" with type ssh-ed25519, ecdsa-sha2-nistp256 or ssh-rsa, and no comment`);
}
return key;
});
if (new Set(keys).size !== keys.length) refuse(`${at}.hostKeys lists a key twice`);
out[id] = { address, port: entry.port, user: entry.user, hostKeys: keys };
}
return out;
}
// D6: an argv allowlist and nothing else. No wildcard: "any", "*", {} and [] all refuse.
export function checkCommands(commands, where) {
if (!Array.isArray(commands)) refuse(`${where} must be a list of argv prefixes; there is no wildcard grant`);
@@ -110,18 +84,3 @@ export function checkCommands(commands, where) {
return [...prefix];
});
}
// A role entry's "hosts": { <hostId>: { commands } }, each id in the registry.
export function checkHostGrants(grants, registry, where) {
requireObject(grants, where);
if (Object.keys(grants).length === 0) refuse(`${where} must name at least one host`);
const out = {};
for (const [id, grant] of Object.entries(grants)) {
const at = `${where}.${id}`;
if (!Object.hasOwn(registry, id)) refuse(`${at}: host ${id} isn't in the business file's hosts`);
if (!isPlainObject(grant)) refuse(`${at} must be an object`);
rejectUnknownKeys(grant, ["commands"], at);
out[id] = { commands: checkCommands(grant.commands, `${at}.commands`) };
}
return out;
}
+29 -57
View File
@@ -1,13 +1,12 @@
// Row 210 (#1707, design D3 and D6): the host registry, an agent's host grant and its argv
// allowlist, checked at business load. Tests T1, T2 and T26 of the design.
// Row 210 (#1707, design D3 and D6): the address checks and the argv allowlist. Row 234 (#1740, r6 S1)
// moved the registry and the grants to bus store records, so the business file refuses them.
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { BusinessError, validateBusinessDocument } from "../src/index.mjs";
import { resolveInstance } from "../src/resolve.mjs";
import { REFUSED_COMMANDS, commandAllowed, ownMachine, validArgv } from "../src/hosts.mjs";
import { businessDoc, rolesCopy, systemFor, tmp, tokenFile, FAR_DATE } from "./helpers.mjs";
import { HOST_KEY as HOST_KEY_PATTERN, REFUSED_COMMANDS, USER, checkAddress, checkCommands, commandAllowed, ownMachine, validArgv } from "../src/hosts.mjs";
import { businessDoc, rolesCopy, tmp, tokenFile, FAR_DATE } from "./helpers.mjs";
const HOST_KEY = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOv6Rq3Mvo9w2j3eYvYbqX0J1pJ0x9m8Zr0w1kJ8p0Qn";
@@ -19,8 +18,7 @@ function refuses(fn, pattern) {
});
}
// The fixture with coder's definition declaring the host credential, one registered host, and
// coder granted it with one command.
// The fixture with coder's definition declaring the host credential and no hosts.
function check(mutate, { declare = true } = {}) {
const root = tmp();
const rolesDir = rolesCopy(root);
@@ -31,62 +29,44 @@ function check(mutate, { declare = true } = {}) {
writeFileSync(file, JSON.stringify(coder));
}
const doc = businessDoc(root);
doc.hosts = { web1: { address: "10.1.1.53", port: 22, user: "localadmin", hostKeys: [HOST_KEY] } };
doc.roles.coder.hosts = { web1: { commands: [["systemctl", "status"]] } };
doc.roles.coder.credentials["host.web1"] = { file: tokenFile(join(root, "secrets"), "coder-host.key"), rotateBy: FAR_DATE };
mutate?.(doc, root);
return validateBusinessDocument(doc, join(root, "businesses", "acme.json"), { rolesDir });
}
test("T1: a registry and a grant load, and the resolved session credentials leave the host key out", () => {
const business = check();
assert.deepEqual(business.hosts.web1, { address: "10.1.1.53", port: 22, user: "localadmin", hostKeys: [HOST_KEY] });
assert.deepEqual(business.roles.coder.hosts, { web1: { commands: [["systemctl", "status"]] } });
assert.ok(business.roles.coder.credentials["host.web1"]);
assert.equal(check((d) => { d.hosts.web1.address = "host.example"; }).hosts.web1.address, "host.example");
// A file without hosts loads with no hosts key at all.
assert.equal("hosts" in check((d) => { delete d.hosts; delete d.roles.coder.hosts; delete d.roles.coder.credentials["host.web1"]; }), false);
// Row 234 (#1740, r6 S1): hosts and grants are bus store records, so the business file refuses both keys
// and any host.<id> key reference. The control loads.
test("S1: the business file refuses hosts at the top level and in a role, and host.<id> references", () => {
assert.equal("hosts" in check(), false);
assert.equal("hosts" in check().roles.coder, false);
const entry = { address: "10.1.1.53", port: 22, user: "localadmin", hostKeys: [HOST_KEY] };
refuses(() => check((d) => { d.hosts = { web1: entry }; }), /acme\.json hosts lives in the bus store/);
refuses(() => check((d) => { d.hosts = {}; }), /hosts lives in the bus store/);
refuses(() => check((d) => { d.roles.coder.hosts = { web1: { commands: [["systemctl", "status"]] } }; }), /roles\.coder\.hosts lives in the bus store/);
refuses(() => check((d) => { d.roles.coder.hosts = {}; }, { declare: false }), /roles\.coder\.hosts lives in the bus store/);
const key = (d, root) => { d.roles.coder.credentials["host.web1"] = { file: tokenFile(join(root, "secrets"), "coder-host.key"), rotateBy: FAR_DATE }; };
refuses(() => check(key), /host\.web1/);
});
test("T1: registry field checks name the key path", () => {
refuses(() => check((d) => { d.hosts = []; }), /hosts/);
refuses(() => check((d) => { d.hosts = { "Web 1": d.hosts.web1 }; }), /host id/);
refuses(() => check((d) => { d.hosts.web1.extra = 1; }), /hosts\.web1/);
for (const port of [0, 65536, "22", 1.5]) refuses(() => check((d) => { d.hosts.web1.port = port; }), /hosts\.web1\.port/);
for (const user of ["Root", "a b", "", 7]) refuses(() => check((d) => { d.hosts.web1.user = user; }), /hosts\.web1\.user/);
refuses(() => check((d) => { d.hosts.web1.hostKeys = []; }), /hosts\.web1\.hostKeys/);
refuses(() => check((d) => { d.hosts.web1.hostKeys = [`${HOST_KEY} root@web1`]; }), /hosts\.web1\.hostKeys\[0\]/);
refuses(() => check((d) => { d.hosts.web1.hostKeys = ["ssh-dss AAAA"]; }), /hosts\.web1\.hostKeys\[0\]/);
refuses(() => check((d) => { d.hosts.web1.hostKeys = [HOST_KEY, HOST_KEY]; }), /twice/);
for (const address of ["bad_name", "a..b", "-a.b", "fe80::1%eth0", 7])
refuses(() => check((d) => { d.hosts.web1.address = address; }), /hosts\.web1\.address/);
});
test("T1: every own-machine form refuses, and the controls load", () => {
test("T1: address, user and host key checks, which the enrollment raise uses", () => {
const own = ["127.0.0.2", "0.0.0.0", "::1", "::", "0:0:0:0:0:0:0:1", "::ffff:127.0.0.1", "::ffff:0.0.0.0", "localhost", "a.localhost",
"127.1", "2130706433", "0177.0.0.1", "0x7f.1"];
for (const address of own) refuses(() => check((d) => { d.hosts.web1.address = address; }), /hosts\.web1\.address/);
for (const address of ["10.1.1.53", "host.example", "2001:db8::1"]) assert.equal(check((d) => { d.hosts.web1.address = address; }).hosts.web1.address, address);
"127.1", "2130706433", "0177.0.0.1", "0x7f.1", "bad_name", "a..b", "-a.b", "fe80::1%eth0", 7];
for (const address of own) refuses(() => checkAddress(address, "grant.address"), /grant\.address/);
for (const address of ["10.1.1.53", "host.example", "2001:db8::1"]) assert.equal(checkAddress(address, "grant.address"), address);
assert.equal(ownMachine("::ffff:7f00:1"), true);
assert.equal(ownMachine("::ffff:10.0.0.1"), false);
});
test("T2: a grant naming an unknown host, or on a definition without host, refuses at roles.<agent>.hosts", () => {
refuses(() => check((d) => { d.roles.coder.hosts = { web2: { commands: [["ls"]] } }; }), /roles\.coder\.hosts\.web2: host web2 isn't/);
refuses(() => check(null, { declare: false }), /roles\.coder\.hosts needs a role definition that declares the host credential/);
refuses(() => check((d) => { d.roles.coder.hosts = {}; }), /roles\.coder\.hosts must name at least one host/);
refuses(() => check((d) => { d.roles.coder.hosts.web1.extra = 1; }), /roles\.coder\.hosts\.web1/);
// The key reference is for a granted host only.
refuses(() => check((d) => { d.roles.coder.credentials["host.web2"] = d.roles.coder.credentials["host.web1"]; }), /host\.web2/);
for (const user of ["Root", "a b", ""]) assert.equal(USER.test(user), false);
assert.equal(USER.test("localadmin"), true);
for (const key of [`${HOST_KEY} root@web1`, "ssh-dss AAAA"]) assert.equal(HOST_KEY_PATTERN.test(key), false);
assert.equal(HOST_KEY_PATTERN.test(HOST_KEY), true);
});
test("T26: no wildcard and no shell, runner or interpreter as a prefix", () => {
const at = /roles\.coder\.hosts\.web1\.commands/;
const at = /grant\.commands/;
for (const commands of ["any", "*", {}, [], [[]], [[7]], [["ls", ""]], [["ls"], ["ls"]], [["ls", "*"]], [["ls", "a?"]], [["ls", "[a]"]], [["bin/ls"]], [["ls\0"]]])
refuses(() => check((d) => { d.roles.coder.hosts.web1.commands = commands; }), at);
refuses(() => checkCommands(commands, "grant.commands"), at);
const names = [...REFUSED_COMMANDS, "python", "python3", "python3.12", "awk", "gawk", "mawk", "nawk", "php", "php8.2", "/bin/bash", "/usr/bin/env"];
for (const name of names) refuses(() => check((d) => { d.roles.coder.hosts.web1.commands = [[name, "x"]]; }), at);
for (const name of ["systemctl", "/usr/bin/journalctl", "tee"]) assert.ok(check((d) => { d.roles.coder.hosts.web1.commands = [[name]]; }));
for (const name of names) refuses(() => checkCommands([[name, "x"]], "grant.commands"), at);
for (const name of ["systemctl", "/usr/bin/journalctl", "tee"]) assert.deepEqual(checkCommands([[name]], "grant.commands"), [[name]]);
});
test("D6: argv shape and the literal prefix match", () => {
@@ -101,11 +81,3 @@ test("D6: argv shape and the literal prefix match", () => {
assert.equal(commandAllowed(["systemctl", "restart", "nginx"], commands), false);
assert.equal(commandAllowed(["systemctl", "status*"], commands), false);
});
test("D5: resolveInstance leaves host.<id> out of the session's credentials", () => {
let root;
const business = check((d, r) => { root = r; });
const resolved = resolveInstance({ system: systemFor(root), business, instance: "coder" });
assert.equal("host.web1" in resolved.credentials, false);
assert.ok(resolved.credentials.gitea);
});
+9
View File
@@ -1046,6 +1046,15 @@ entries; `delete` refuses `agent-running` while the agent holds a claim. With
`--tester` only `status` and `revoke` run. After a rotate or revoke, delete
the old token in Gitea or Vikunja yourself; the bus only forgets it.
## hosts (row 234)
`mosaic hosts [status] [--json] [--tester] [--business ID]` prints one tab-separated line per
enrolled host: id, state, `user@address:port`, `sudo` or `no-sudo`, the last check time or
`unchecked`, and any host lines a failed step left. `--json` prints the bus rows. `check HOST`
runs the bus's `hosts.check` and `remove HOST` its `hosts.remove`, each printing the JSON result.
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.
## Token usage
`mosaic usage [--business ID] [--json] [--tester]` reads the bus's accounting, without
+4
View File
@@ -18,6 +18,7 @@ import { resolveThread, threadCommand, threadMembers, threadsCommand, THREAD_TAL
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 { instancesCommand, INSTANCES_USAGE } from "./instances.mjs";
import { createChannelCaller } from "../../plugins/src/channel.mjs";
import { PluginError } from "../../plugins/src/kinds.mjs";
@@ -40,6 +41,7 @@ export const USAGE = `usage:
${LIMITS_USAGE}
${PLUGINS_USAGE}
${CREDENTIALS_USAGE}
${HOSTS_USAGE}
${INSTANCES_USAGE}
${PROJECT_USAGE}
${PROJECT_BUS_USAGE}
@@ -580,6 +582,8 @@ export async function main(argv, io = { env: process.env, stdin: process.stdin,
if (verb === "plugins") return pluginsCommand(rest, io, (p) => callFor(p, io, deps));
// Row 199 (#1697): issue and rotate read the token on stdin.
if (verb === "credentials") return credentialsCommand(rest, io, (p) => callFor(p, io, deps));
// Row 234 (#1740, r6 E4): the host records.
if (verb === "hosts") return hostsCommand(rest, io, (p) => callFor(p, io, deps));
// Row 165 (#1662): scripts/mosaic sends only `business instances` and `business instance` here.
if (verb === "business") return instancesCommand(rest, io, (p) => callFor(p, io, deps));
if (verb === "talk") return talk(rest, io, deps);
+7 -3
View File
@@ -38,7 +38,10 @@
// one op at a time within 10 s. The poll ends at publishDeadlineMs
// (publish-timeout), past git's own limit on each of the four git
// runs. recordPublish files the push; any refusal after checkPublish
// is filed once, by publishRefused.
// is filed once, by publishRefused. After publish-timeout the bus
// holds the decision until its push ends (a retry gets publish-busy),
// then records a push that reached the fork or files the refusal
// (row 242, #1748).
//
// A refusal is {"ok": false, "error": "<code>"}, and the connection ends.
// The host checks, in order: identity (a role of this business), the request
@@ -427,8 +430,9 @@ export function createGitSocket({ host, business: b, dataRoot, env = process.env
await op({ op: "publishPush", ticket });
const until = Date.now() + publishDeadlineMs;
for (;;) {
// The bus's git may still finish after this; the fork could then hold the commit with
// no record, as when recordPublish refuses after a push.
// The bus's git may still finish after this. The bus holds the decision until it does,
// then records a push that reached the fork (row 242); the fork can still hold the
// commit with no record if recordPublish refuses, as after an in-band push.
if (Date.now() >= until) {
log(`git socket: publish ${task_ref}: publish-timeout; the bus process's push may still reach the fork`);
throw new Refusal("publish-timeout");
+44
View File
@@ -0,0 +1,44 @@
// `mosaic hosts` (row 234, #1740, r6 E4): the broker's hosts.status, hosts.check and hosts.remove. The
// human runs all three. With --tester the tester's session runs them. check logs in as mosaic-installer
// and runs `list` on the host, then marks the host ready. remove drops a host no grant uses. Nothing here
// reads a key: the answers carry fingerprints only.
import { CliError } from "./errors.mjs";
export const HOSTS_USAGE = "mosaic hosts [status] [--json] [--tester] [--business ID] | check|remove HOST [--tester] [--business ID]";
function parse(args) {
const out = { positional: [], flags: new Set(), values: {} };
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === "--json" || arg === "--tester") out.flags.add(arg);
else if (arg === "--business") {
if (!args[i + 1] || Object.hasOwn(out.values, arg)) throw new CliError(HOSTS_USAGE, 4);
out.values[arg] = args[++i];
} else if (arg.startsWith("--")) throw new CliError(HOSTS_USAGE, 4);
else out.positional.push(arg);
}
return out;
}
const line = (h) =>
[h.host, h.state, `${h.user}@${h.address}:${h.port}`, h.sudo ? "sudo" : "no-sudo", h.checkedAt ?? "unchecked",
...(h.hostLineLeft.length ? [`line left: ${h.hostLineLeft.join(" ")}`] : [])].join("\t");
export async function hostsCommand(args, io, callFor) {
const parsed = parse(args);
const [verb = "status", host, ...extra] = parsed.positional;
const shape = { status: host === undefined, check: host !== undefined, remove: host !== undefined };
if (extra.length || !shape[verb] || (parsed.flags.has("--json") && verb !== "status")) throw new CliError(HOSTS_USAGE, 4);
try {
const call = callFor(parsed);
if (verb === "status") {
const rows = await call("hosts.status", {});
io.stdout.write(parsed.flags.has("--json") ? JSON.stringify(rows, null, 2) + "\n" : rows.map(line).join("\n") + (rows.length ? "\n" : ""));
return;
}
io.stdout.write(JSON.stringify(await call(`hosts.${verb}`, { host }), null, 2) + "\n");
} catch (error) {
if (error instanceof CliError) throw error;
throw new CliError(error.message, 2);
}
}
+74
View File
@@ -0,0 +1,74 @@
// Row 234 (#1740, r6 E4): `mosaic hosts status|check|remove`. A fake transport records the calls; no bus
// runs here. The bus side of each verb is in packages/bus/tests/grant-enroll.test.mjs.
import test from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { main } from "../src/cli.mjs";
import { io, tmp } from "./helpers.mjs";
const MOSAIC = fileURLToPath(new URL("../../../scripts/mosaic", import.meta.url));
const ROWS = [
{ host: "web1", address: "10.1.1.53", port: 22, user: "deploy", sudo: true, state: "ready", hostKeys: ["SHA256:a"],
installerFingerprint: "SHA256:b", checkedAt: "2026-10-21T00:00:00.000Z", hostLineLeft: [] },
{ host: "web2", address: "10.1.1.54", port: 2222, user: "app", sudo: false, state: "pending-install", hostKeys: ["SHA256:c"],
installerFingerprint: "SHA256:d", checkedAt: null, hostLineLeft: ["mosaic-" + "0".repeat(32)] },
];
function deps(calls) {
const answer = (who) => (business) => async (verb, args) => {
calls.push({ who, business, verb, args });
if (verb === "hosts.status") return ROWS;
if (verb === "hosts.check") return { host: args.host, state: "ready", checkedAt: "2026-10-21T00:00:00.000Z" };
return { host: args.host, removed: true };
};
return { system: { dataRoot: "/nonexistent" }, transport: answer("human"), testerTransport: answer("tester") };
}
test("row 234: hosts status prints one line per host, and --json the rows", async () => {
const calls = [];
const x = io();
await main(["hosts", "--business", "acme"], x, deps(calls));
assert.deepEqual(calls, [{ who: "human", business: "acme", verb: "hosts.status", args: {} }]);
assert.equal(x.out.text,
"web1\tready\[email protected]:22\tsudo\t2026-10-21T00:00:00.000Z\n" +
`web2\tpending-install\t[email protected]:2222\tno-sudo\tunchecked\tline left: mosaic-${"0".repeat(32)}\n`);
const y = io();
await main(["hosts", "status", "--json", "--tester", "--business", "acme"], y, deps(calls));
assert.deepEqual(JSON.parse(y.out.text), ROWS);
assert.equal(calls[1].who, "tester");
});
test("row 234: hosts check and remove send the host id, as the human or the tester", async () => {
const calls = [];
const x = io();
await main(["hosts", "check", "web1", "--business", "acme"], x, deps(calls));
await main(["hosts", "remove", "web2", "--tester", "--business", "acme"], x, deps(calls));
assert.deepEqual(calls.map(({ who, verb, args }) => ({ who, verb, args })), [
{ who: "human", verb: "hosts.check", args: { host: "web1" } },
{ who: "tester", verb: "hosts.remove", args: { host: "web2" } },
]);
});
test("row 234: a malformed hosts command is a usage error and calls nothing", async () => {
const calls = [];
for (const argv of [["hosts", "check"], ["hosts", "remove"], ["hosts", "status", "web1"], ["hosts", "check", "web1", "web2"],
["hosts", "purge", "web1"], ["hosts", "check", "web1", "--json"], ["hosts", "--business"], ["hosts", "--force"]])
await assert.rejects(main([...argv], io(), deps(calls)), (e) => (assert.equal(e.exitCode, 4, argv.join(" ")), assert.match(e.message, /mosaic hosts/), true));
assert.equal(calls.length, 0);
});
test("row 234: a bus refusal exits 2 with its code", async () => {
const refusing = { system: { dataRoot: "/nonexistent" }, transport: () => async () => { throw new Error("host-in-use"); } };
await assert.rejects(main(["hosts", "remove", "web1", "--business", "acme"], io(), refusing), (e) => (assert.equal(e.exitCode, 2), assert.match(e.message, /host-in-use/), true));
});
test("row 234: scripts/mosaic sends hosts to packages/cli, not the seat CLI", (t) => {
const root = tmp(t, "mosaic-cli-hosts-route-");
const env = { PATH: process.env.PATH, HOME: join(root, "home"), MOSAIC_CONFIG: join(root, "none.json") };
const r = spawnSync(MOSAIC, ["hosts", "purge", "web1"], { env, encoding: "utf8" });
assert.equal(r.status, 4);
assert.match(r.stderr, /^mosaic: mosaic hosts \[status\]/);
assert.doesNotMatch(r.stderr, /mosaic launch </);
});
+53
View File
@@ -916,6 +916,8 @@ 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={
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',
message_threads:'no time column; each row goes with a messages row, which is counted',
sqlite_sequence:'SQLite\'s own AUTOINCREMENT table',
@@ -1681,6 +1683,14 @@ test('T57 row 200: activate --sso, its refusals before any stop, and the rollbac
assert.ok(stepText(a).includes(`Z drop --sso; ${B}'s Console does not support it, so the Console flags come from ${set}/${show}\n`));
await noLeak(a);
});
await t.test('row 238 (#1744): on a named instance the sso-config first step names --instance NAME in both commands',async(t)=>{
const e=await mk(t),a=await named(e,'alpha');await seed(a);
assert.equal(JSON.parse(readFileSync(a.cfg,'utf8')).console,undefined);
await refused(a,['activate',C1,'acme','--sso'],'sso-config',`${C1}'s Console would refuse its --sso start: there is no console.sso in the system config; activate ${C1} --instance alpha without --sso first, then write console.sso, then run activate ${C1} --instance alpha --sso`);
const out=await planOnly(a,3,'activate',C1,'acme','--sso');
assert.ok(out.includes(`refuses sso-config: ${C1}'s Console would refuse its --sso start: there is no console.sso in the system config; activate ${C1} --instance alpha without --sso first, then write console.sso, then run activate ${C1} --instance alpha --sso`),out.join('\n'));
await noLeak(a);
});
});
// --- rehearse (D2, row 163; the brief's "Stack rehearsal on a scratch root") ---
@@ -2062,6 +2072,49 @@ test('R15 a rehearsal stopped by TERM, INT or HUP fails signal and still stops t
}
});
// --- Row 233 (#1735): --backup last, the newest backup the log records (sage-1650, sage-1653) ---
// The log's lines, as the runs write them, with only the fields from_log reads set.
function rlog(e,...ls){
mkdirSync(e.state,{recursive:true});
writeFileSync(e.log,ls.map(([op,to,backup],i)=>JSON.stringify({at:`2026-10-20T00:00:0${i}Z`,op,from:null,to,backup,activation:`20261020T00000${i}Z-${to.slice(0,12)}`,result:'ok'})+'\n').join(''));
}
test('row 233: --backup last takes the newest backup the log records; a later line with backup null does not hide it, and the store line names it',async(t)=>{
const e=await rmk(t);
// An older backup that isn't on disk: a run that took it would fail backup-missing.
rlog(e,['activate',C1,`20261019T000000Z-${C1.slice(0,12)}`],['activate',B,e.act],['rollback',B,null]);
const l0=readFileSync(e.log,'utf8'),before=untouched(e);
const r=await rehearse(e,B,'--backup','last','--port',await freePort(),'--timeout','10');
assert.equal(r.code,0,r.out+r.err);
assert.deepEqual(r.names,['PASS backup-last',...PRE,'PASS migration','PASS gate','PASS no-session',...POST]);
assert.ok(r.out.startsWith(`PASS backup-last: the log's newest backup is ${e.act}\n`),r.out);
assert.ok(r.out.includes(`\nBACKUP_STORE ${e.act} schema 3g\n`),r.out);
assert.equal(readFileSync(e.log,'utf8'),l0);assert.equal(untouched(e),before);
// The rehearsal's own steps.log has the PASS and FAIL lines only.
assert.deepEqual(lines(join(r.dir,'steps.log')).map((l)=>l.replace(/^\S+ /,'')),r.out.split('\n').filter((l)=>/^(PASS|FAIL) |^REHEARSAL_/.test(l)));
});
test('row 233: --backup last with no backup in the log, or no log, exits 3 with no-backup and makes no rehearsal directory',async(t)=>{
const e=await rmk(t);
for(const how of ['no log','adopt only']){
if(how==='adopt only')rlog(e,['adopt',B,null]);
const r=await rehearse(e,B,'--backup','last','--port',await freePort(),'--timeout','3');
assert.equal(r.code,3,how+r.out+r.err);
assert.equal(r.out,'FAIL no-backup: the log records no backup\nREHEARSAL_FAIL\n',how);
assert.equal(r.dir,null);assert.deepEqual(rDirs(e),[],how);assert.ok(!existsSync(join(e.data,'rehearsals')),how);
}
});
test('row 233: --backup last refuses a newest backup that is not an activation id, with exit 1',async(t)=>{
const e=await rmk(t);
rlog(e,['activate',B,'../../etc']);
const r=await rehearse(e,B,'--backup','last','--port',await freePort(),'--timeout','3');
assert.equal(r.code,1,r.out+r.err);
assert.equal(r.out,'FAIL backup-unreadable: the log\'s newest backup is not an activation id\nREHEARSAL_FAIL\n');
assert.deepEqual(rDirs(e),[]);
});
// --- Row 190 (#1688): stack instances on one host. Design and the T-I table:
// agents/darkwing/work/queue-190/DESIGN.md. T-I1, T-I2, T-I5, T-I10 and the loader half of T-I7 are in
// stack-instances.test.mjs; these need the units, the stand-ins and stack-release.sh. Every existing
+449
View File
@@ -0,0 +1,449 @@
// scripts/stack-upgrade.sh (row 233, I3, #1735): root's upgrade and rollback of a stack whose units are
// root's, driving stack-release.sh --units external (row 232). Design: r6 sections 4 and 7,
// agents/darkwing/work/queue-195/DESIGN.md, as the I3 brief changes them; deviations:
// agents/darkwing/work/queue-233/BUILD.md. The stand-ins and the module fixture are
// stack-release-fixture.mjs's. Root's sudo, case2-unit.sh render and row 240 hostcheck are stand-ins
// here; the stack's user's steps are the real stack-release.sh from the user's clone, except the
// rehearsal, which the fixture's stub copies can't pass (its own tests are in stack-release.test.mjs).
import test from 'node:test';
import assert from 'node:assert/strict';
import {execFileSync,spawn} from 'node:child_process';
import {mkdirSync,writeFileSync,readFileSync,existsSync,readdirSync,rmSync,chmodSync,copyFileSync,symlinkSync,statSync} from 'node:fs';
import {DatabaseSync} from 'node:sqlite';
import {join,dirname} from 'node:path';
import {once} from 'node:events';
import {B,C,C1,U,COPIES,NODE,SBIN,base,fault,git,lines,logLines,mk,pointer,repo,sha,storeVersion,sysctl,units} from './stack-release-fixture.mjs';
// --- Root's stand-ins. Each is serialized into its own file with K, its baked paths. ---
// sudo -u USER -H env PATH=P CMD...: the form the driver must use, logged to sudo.log, then CMD with
// SBIN before P. A one-shot hook arm/<key>.sh runs first and arm/<key>.id replaces CMD's ID. The
// rehearsal prints a fixed BACKUP_STORE line and exits faults/rehearse-rc unless faults/real-rehearse.
function fakeSudo(K){
const fs=process.getBuiltinModule('node:fs'),path=process.getBuiltinModule('node:path'),cp=process.getBuiltinModule('node:child_process');
const a=process.argv.slice(2),sd=process.env.SD_STATE,f=(n)=>path.join(sd,'faults',n);
if(a[0]!=='-u'||!a[1]||a[2]!=='-H'||a[3]!=='env'||!/^PATH=/.test(a[4]||'')||a.length<6){console.error(`fake sudo: not the driver's form: ${a.join(' ')}`);process.exit(99)}
const P=a[4].slice(5),cmd=a.slice(5);
let key=path.basename(cmd[0]);
if(cmd[0].endsWith('/scripts/stack-release.sh')){
key=cmd[1];
if(['activate','rollback'].includes(key)&&cmd.includes('--plan'))key+='-plan';
if(key==='confirm'&&cmd.includes('--rollback'))key='confirm-rollback';
}else if(cmd[0]==='git')key=`git-${cmd[3]}`;
fs.appendFileSync(path.join(sd,'sudo.log'),JSON.stringify({user:a[1],path:P,key,cmd})+'\n');
const env={...process.env,PATH:`${K.sbin}:${P}`},arm=(n)=>path.join(sd,'arm',`${key}.${n}`);
if(fs.existsSync(arm('sh'))){const run=arm('sh')+'.ran';fs.renameSync(arm('sh'),run);cp.spawnSync('bash',[run],{env,stdio:'inherit'})}
if(fs.existsSync(arm('id'))){cmd[2]=fs.readFileSync(arm('id'),'utf8').trim();fs.rmSync(arm('id'))}
if(key==='rehearse'&&!fs.existsSync(f('real-rehearse'))){
console.log(`fixture rehearse: ${cmd.slice(1).join(' ')}`);
let line='BACKUP_STORE 20261020T000000Z-0123456789ab schema 3g';try{line=fs.readFileSync(f('rehearse-store-line'),'utf8').replace(/\n$/,'')}catch{}
if(line)console.log(line);
let rc=0;try{rc=Number(fs.readFileSync(f('rehearse-rc'),'utf8'))}catch{}
process.exit(rc);
}
const r=cp.spawnSync(cmd[0],cmd.slice(1),{env,stdio:'inherit'});process.exit(r.status??1);
}
// case2-unit.sh: logs its argv, then installs the copy's units as the fixture's user units (its
// WorkingDirectory under the stack's copies) and starts the bus, then the Console, as case2-unit.sh
// does. faults/render-fails-<copy> exits 1 first; faults/render-probe-<copy> (one-shot) first runs
// probe.sh, a second driver, and records its exit and stderr.
function fakeRender(K){
const fs=process.getBuiltinModule('node:fs'),path=process.getBuiltinModule('node:path'),cp=process.getBuiltinModule('node:child_process');
const a=process.argv.slice(2),sd=process.env.SD_STATE,f=(n)=>path.join(sd,'faults',n);
fs.appendFileSync(path.join(sd,'render.log'),JSON.stringify(a)+'\n');
const cut=a.indexOf('--'),pre=a.slice(0,cut),serve=a.slice(cut+1),get=(k)=>pre[pre.indexOf(k)+1];
const copy=get('--copy'),copies=path.join(get('--home'),'.local','share','mosaic-stack','copies');
if(fs.existsSync(f(`render-probe-${copy}`))){
fs.rmSync(f(`render-probe-${copy}`));
const p=cp.spawnSync('bash',[path.join(sd,'probe.sh')],{encoding:'utf8'});
fs.writeFileSync(path.join(sd,'probe.json'),JSON.stringify({code:p.status,err:p.stderr}));
}
if(fs.existsSync(f(`render-fails-${copy}`))){console.error(`fixture render: ${copy} fails`);process.exit(1)}
const sh=(n,...x)=>cp.execFileSync('bash',[path.join(K.repo,'scripts',n),...x],{stdio:['ignore','ignore','inherit']});
sh('bus-service.sh','install','--copy',copy,'--copies',copies,...(pre.includes('--pm')?['--pm']:[]),'--claude',get('--claude'));
sh('webui-service.sh','install','--copy',copy,'--copies',copies,...serve);
for(const u of [`mosaic-bus@${get('--business')}.service`,'mosaic-webui.service'])cp.execFileSync(path.join(K.sbin,'systemctl'),['start',u],{stdio:'inherit'});
}
// Root's systemctl: the fixture's, with systemd's no-op start of an active unit, and a one-shot
// faults/stop-noop-<unit> that leaves the unit running, as a stop past its timeout would.
function fakeSystemctl(K){
const fs=process.getBuiltinModule('node:fs'),path=process.getBuiltinModule('node:path'),cp=process.getBuiltinModule('node:child_process');
const a=process.argv.slice(2),sd=process.env.SD_STATE;
fs.appendFileSync(path.join(sd,'root-systemctl.log'),a.join(' ')+'\n');
const noop=path.join(sd,'faults',`stop-noop-${a[1]}`);
if(a[0]==='stop'&&fs.existsSync(noop)){fs.rmSync(noop);process.exit(0)}
if(a[0]==='start'){let s={};try{s=JSON.parse(fs.readFileSync(path.join(sd,'units.json'),'utf8'))}catch{}if((s[a[1]]||{}).state==='active')process.exit(0)}
const r=cp.spawnSync(path.join(K.sbin,'systemctl'),a,{stdio:'inherit'});process.exit(r.status??1);
}
const FAKES=join(base,'upgrade-fakes');mkdirSync(FAKES);
const K={sbin:SBIN,repo};
for(const [n,fn] of [['sudo',fakeSudo],['render',fakeRender],['systemctl',fakeSystemctl]])writeFileSync(join(FAKES,n),`#!${NODE}\n(${fn})(${JSON.stringify(K)});\n`,{mode:0o755});
writeFileSync(join(FAKES,'hostcheck'),'#!/bin/sh\necho "$*" >> "$SD_STATE/hostcheck.log"\nexit "$(cat "$SD_STATE/faults/hostcheck-rc" 2>/dev/null || echo 0)"\n',{mode:0o755});
const BUNDLE=join(base,'fixture.bundle');git(repo,'bundle','create','-q',BUNDLE,'refactor');
// Root's checkout of a reviewed commit: a clone of the fixture repo, detached at it.
const checkouts=new Map();
const checkout=(id)=>{
if(!checkouts.has(id)){const d=join(base,`root-${id.slice(0,12)}`);git(base,'clone','-q','--no-checkout',repo,d);git(d,'checkout','-q','--detach',id);checkouts.set(id,d)}
return checkouts.get(id);
};
const TO=['--timeout','3'];
// --- Per-test stack ---
// The stack's user's home: its copies (the fixture's, through a link), its clone, its bin. Both units
// installed on `copy` and active, adopted under --units external, and the I2 args file naming it.
async function useed(t,{copy=B}={}){
const e=await mk(t),home=e.env.HOME;
e.hc=join(home,'.local','share','mosaic-stack','copies');mkdirSync(dirname(e.hc),{recursive:true});symlinkSync(COPIES,e.hc);
const bin=join(home,'.npm-global','bin');mkdirSync(bin,{recursive:true});
writeFileSync(join(bin,'mosaic'),readFileSync(join(e.bin,'mosaic'),'utf8').replaceAll(`${e.bin}/`,`${bin}/`),{mode:0o755});
copyFileSync(join(e.bin,'mosaic-old'),join(bin,'mosaic-old'));chmodSync(join(bin,'mosaic-old'),0o755);
e.bin=bin;e.clone=join(home,'src','mosaic-stack');git(base,'clone','-q',repo,e.clone);
const db=new DatabaseSync(e.store);
db.exec('PRAGMA journal_mode=WAL');
db.exec('CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)');
db.exec('CREATE TABLE events (id INTEGER PRIMARY KEY, at TEXT NOT NULL)');
db.prepare("INSERT INTO meta VALUES ('schema_version', '3g')").run();
for(let i=0;i<3;i++)db.prepare('INSERT INTO events (at) VALUES (?)').run(`2000-01-01T00:00:0${i}.000Z`);
db.close();
const sh=(n,...a)=>execFileSync('bash',[join(e.clone,'scripts',n),...a],{env:e.env,encoding:'utf8',stdio:['ignore','pipe','pipe']});
sh('bus-service.sh','install','--copy',copy,'--copies',e.hc,'--claude',e.claude);
sh('webui-service.sh','install','--copy',copy,'--copies',e.hc,'--port',e.port,'--business','acme');
sh('stack-copy.sh','wrapper',copy,'--copies',e.hc,'--bin',e.bin);
execFileSync(join(SBIN,'systemctl'),['start',e.bus],{env:{...e.env,SD_CLOCK_OFFSET:'120'}});
sysctl(e,'start',e.con);
sh('stack-release.sh','adopt','acme','--units','external','--copies',e.hc,'--bin',e.bin);
e.etc=join(e.root,'etc');mkdirSync(e.etc);e.args=join(e.etc,'dev.args');
e.argsText=`--user\nfixture\n--home\n${home}\n--business\nacme\n--copy\n${copy}\n--node\n${NODE}\n--claude\n${e.claude}\n--\n--port\n${e.port}\n--business\nacme\n`;
writeFileSync(e.args,e.argsText);chmodSync(e.args,0o640);
e.run=join(e.root,'run');e.slog=join(e.root,'log','upgrade-dev.log');e.lockf=join(e.run,'upgrade-dev.lock');
mkdirSync(join(e.sd,'arm'));
e.denv={...e.env,STACK_UPGRADE_ETC:e.etc,STACK_UPGRADE_RUN:e.run,STACK_UPGRADE_LOG_DIR:dirname(e.slog),STACK_UPGRADE_UNIT_DIR:e.units,STACK_UPGRADE_HOSTCHECK:join(FAKES,'hostcheck'),STACK_UPGRADE_RENDER:join(FAKES,'render'),STACK_UPGRADE_SUDO:join(FAKES,'sudo'),STACK_UPGRADE_SYSTEMCTL:join(FAKES,'systemctl')};
writeFileSync(join(e.sd,'probe.sh'),`exec bash ${join(checkout(C1),'scripts','stack-upgrade.sh')} upgrade --instance dev --bundle ${BUNDLE} --sha ${C1} --timeout 3\n`);
writeFileSync(join(e.sd,'snap.jsonl'),'');
return e;
}
// The driver from root's checkout of `at`.
async function drive(e,at,...args){
const ch=spawn('bash',[join(checkout(at),'scripts','stack-upgrade.sh'),...args],{env:e.denv,stdio:['ignore','pipe','pipe']});
let out='',err='';ch.stdout.on('data',(d)=>{out+=d});ch.stderr.on('data',(d)=>{err+=d});
const timer=setTimeout(()=>ch.kill('SIGKILL'),180000);
const [code]=await once(ch,'close');clearTimeout(timer);
return {code,out,err,all:out+err};
}
const upgrade=(e,id,...a)=>drive(e,id,'upgrade','--instance','dev','--bundle',BUNDLE,'--sha',id,...TO,...a);
const arm=(e,key,text)=>writeFileSync(join(e.sd,'arm',key),text);
// The step log: every line `<time> <step> rc=N ...` or the result line, root:root 0640 (here the
// test user's), and nothing the stack's user printed. Returns the lines without the time.
const STEP=/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ (start|fetch|ff|copy|hostcheck|units|plan|rehearse|rehearse-store|rehearsal-skipped|stop-console|stop-bus|wait-down|stage|render|confirm|args|revert|render-old|confirm-rollback|gate|start-bus|start-console|stop-units) rc=\d+ |^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ result exit=[0-3] [a-z-]+ /;
function slog(e){
const l=lines(e.slog);
for(const x of l){assert.match(x,STEP);assert.ok(!/stack-release:|fixture rehearse|PASS |FAIL |REHEARSAL/.test(x),x)}
assert.equal(statSync(e.slog).mode&0o777,0o640);
return l.map((x)=>x.slice(21));
}
const names=(e)=>slog(e).map((x)=>x.split(' ')[0]);
const result=(e)=>slog(e).at(-1);
const sudoKeys=(e)=>lines(join(e.sd,'sudo.log')).map((l)=>JSON.parse(l).key);
const lastAct=(e)=>logLines(e).filter((o)=>o.result==='started').at(-1).activation;
const mine=(e,a)=>logLines(e).filter((o)=>o.activation===a);
const open=(e)=>{const m=new Set();for(const o of logLines(e)){if(!o.activation)continue;if(o.result==='started')m.add(o.activation);else m.delete(o.activation)}return [...m]};
const final=(e,a)=>{const [s,f,...more]=mine(e,a);assert.equal(s.result,'started');assert.equal(more.length,0);return f};
const live=(e)=>[units(e)[e.bus].state,units(e)[e.con].state,units(e)[e.bus].wd.split('/').at(-1),units(e)[e.con].wd.split('/').at(-1)];
// Nothing the run did reached the args file: the same bytes and mode, and no temporary file left.
const argsKept=(e)=>{assert.equal(readFileSync(e.args,'utf8'),e.argsText);assert.equal(statSync(e.args).mode&0o777,0o640);assert.deepEqual(readdirSync(e.etc),['dev.args'])};
const argsCopy=(e)=>{const l=readFileSync(e.args,'utf8').split('\n');return l[l.indexOf('--copy')+1]};
const PRE=['start','fetch','ff','copy','hostcheck','units','plan','rehearse','rehearse-store'];
const A2=['stop-console','stop-bus','wait-down','wait-down'];
const R=[...A2,'revert','render-old','confirm-rollback'];
// --- r6 sequences 1-8, through the driver ---
test('row 233: sequence 1: confirm fails healthz, the driver reverts, renders the old copy and confirm --rollback --cause A5 passes: exit 1',async(t)=>{
const e=await useed(t);
fault(e,`healthz-${C1}`);
const r=await upgrade(e,C1);assert.equal(r.code,1,r.all);
assert.equal(r.err.split('\n').at(-2),`stack-upgrade: failed: A5 failed; rolled back to ${B}, which passed the gate`);
assert.deepEqual(names(e),[...PRE,...A2,'stage','render','confirm',...R,'result']);
assert.equal(result(e),`result exit=1 failed A5 failed; rolled back to ${B}, which passed the gate`);
const a=lastAct(e),l=final(e,a);assert.deepEqual([l.op,l.result,l.code,l.rollback,l.from,l.to],['activate','gate-failed','healthz','ok',B,C1]);
assert.deepEqual(open(e),[]);assert.deepEqual(live(e),['active','active',B,B]);
argsKept(e);
// The stack's user's steps went through sudo in the driver's form, as fixture, the pinned node first.
for(const l of lines(join(e.sd,'sudo.log')).map((x)=>JSON.parse(x)))assert.deepEqual([l.user,l.path],['fixture',`${dirname(NODE)}:/usr/local/bin:/usr/bin:/bin`]);
assert.deepEqual(sudoKeys(e),['git-fetch','git-merge','stack-copy.sh','activate-plan','rehearse','activate','confirm','revert','confirm-rollback']);
});
test('row 233: sequence 2: activate --migrate fails at the wrapper, revert restores the store, confirm --rollback --cause A3: exit 1',async(t)=>{
const e=await useed(t);
const s0=sha(e.store);
arm(e,'activate.sh',`chmod 0555 ${e.bin}\n`);arm(e,'revert.sh',`chmod 0755 ${e.bin}\n`);
const r=await upgrade(e,C,'--migrate');assert.equal(r.code,1,r.all);
assert.deepEqual(names(e),[...PRE,...A2,'stage',...R,'result']);
assert.equal(result(e),`result exit=1 failed A3 failed; rolled back to ${B}, which passed the gate`);
const l=final(e,lastAct(e));assert.deepEqual([l.result,l.code,l.rollback],['failed','wrapper','ok']);
assert.equal(storeVersion(e.store,true),'3g');assert.deepEqual(live(e),['active','active',B,B]);
// The bus holds the store open again; its bytes are the backup's once it stops.
sysctl(e,'stop',e.bus);assert.equal(sha(e.store),s0);
argsKept(e);
});
test('row 233: sequence 3: root\'s render of the new copy fails, the driver rolls back with --cause A4, and holds the lock to the end: exit 1',async(t)=>{
const e=await useed(t);
fault(e,`render-fails-${C1}`);fault(e,`render-probe-${B}`);
const r=await upgrade(e,C1);assert.equal(r.code,1,r.all);
// The probe ran inside render-old, during the rollback, and its one line is its refusal.
assert.deepEqual(names(e),[...PRE,...A2,'stage','render',...A2,'revert','result','render-old','confirm-rollback','result']);
const p=JSON.parse(readFileSync(join(e.sd,'probe.json'),'utf8'));assert.equal(p.code,3,p.err);
assert.equal(p.err,`stack-upgrade: upgrade-running: another stack-upgrade.sh run holds ${e.lockf} (nothing was stopped)\n`);
assert.match(slog(e).at(-4),/^result exit=3 upgrade-running /);
assert.match(slog(e).find((x)=>x.startsWith('render ')),/^render rc=1 /);
const l=final(e,lastAct(e));assert.deepEqual([l.result,l.code,l.rollback],['failed','units-start','ok']);
assert.deepEqual(live(e),['active','active',B,B]);argsKept(e);
});
test('row 233: sequence 4: a capture failure is exit 3 from the stage, and A2f restarts the old units and gates them: exit 1',async(t)=>{
const e=await useed(t);
arm(e,'activate.sh',`: > ${join(e.sd,'faults','no-fragment')}\n`);arm(e,'gate.sh',`rm ${join(e.sd,'faults','no-fragment')}\n`);
const r=await upgrade(e,C1);assert.equal(r.code,1,r.all);
assert.deepEqual(names(e),[...PRE,...A2,'stage','start-bus','start-console','gate','result']);
assert.match(slog(e).find((x)=>x.startsWith('stage ')),/^stage rc=3 /);
assert.equal(result(e),`result exit=1 failed A3 failed; ${B} is running again and passed the gate`);
const a=lastAct(e);assert.deepEqual(mine(e,a).map((o)=>[o.result,o.code]),[['started',undefined],['refused','capture-failed']]);
assert.deepEqual(open(e),[]);assert.deepEqual(live(e),['active','active',B,B]);argsKept(e);
});
test('row 233: N1: a bus that outlives A2\'s timeout keeps its old start, so A2f\'s gate fails stale-start: exit 2, both units stopped',async(t)=>{
const e=await useed(t);
fault(e,`stop-noop-${e.bus}`);
const r=await upgrade(e,C1);assert.equal(r.code,2,r.all);
assert.deepEqual(names(e),[...PRE,...A2,'start-bus','start-console','gate','stop-units','result']);
assert.ok(slog(e).includes(`wait-down rc=1 unit: ${e.bus} state: active`),slog(e).join('\n'));
assert.ok(r.out.includes(`gate failed on ${B}: stale-start`),r.out);
assert.equal(result(e),`result exit=2 rollback-failed A2 failed and ${B} failed its gate after the restart; both units are stopped`);
assert.deepEqual(live(e).slice(0,2),['inactive','inactive']);
assert.ok(!sudoKeys(e).includes('activate'));argsKept(e);
});
test('row 233: sequence 5: confirm of the wrong copy is refused, and the driver rolls back with --cause A5: exit 1',async(t)=>{
const e=await useed(t);
arm(e,'confirm.id',B);
const r=await upgrade(e,C1);assert.equal(r.code,1,r.all);
assert.ok(r.out.includes('refusing open-activation')||r.err.includes('refusing open-activation'),r.all);
assert.match(slog(e).find((x)=>x.startsWith('confirm ')),/^confirm rc=3 /);
assert.deepEqual(names(e),[...PRE,...A2,'stage','render','confirm',...R,'result']);
const l=final(e,lastAct(e));assert.deepEqual([l.result,l.code,l.rollback],['gate-failed','unrecorded','ok']);
assert.deepEqual(live(e),['active','active',B,B]);argsKept(e);
});
test('row 233: sequence 6: a revert that fails stops both units at exit 2, and the next run refuses at its --plan with nothing stopped',async(t)=>{
const e=await useed(t);
fault(e,`render-fails-${C1}`);
arm(e,'revert.sh',`for d in ${join(e.state,'stack-rollback')}/*/; do [ -f "$d/AWAITING-UNITS" ] && rm "$d/wrapper"; done\n`);
const r=await upgrade(e,C1);assert.equal(r.code,2,r.all);
assert.deepEqual(names(e),[...PRE,...A2,'stage','render',...A2,'revert','stop-units','result']);
assert.equal(result(e),`result exit=2 rollback-failed A4 failed and revert failed; both units are stopped`);
const a=lastAct(e);assert.deepEqual(open(e),[a]);assert.deepEqual(live(e).slice(0,2),['inactive','inactive']);
argsKept(e);
const n=slog(e).length,sc=lines(join(e.sd,'root-systemctl.log')).length;
rmSync(join(e.sd,'faults',`render-fails-${C1}`));
const again=await upgrade(e,C1);assert.equal(again.code,3,again.all);
assert.ok(again.out.includes(`refuses interrupted-run: activation ${a} started and has no final line`),again.out);
assert.deepEqual(names(e).slice(n),PRE.slice(0,7).concat('result'));
assert.match(result(e),/^result exit=3 plan-refused /);
assert.equal(lines(join(e.sd,'root-systemctl.log')).length,sc);argsKept(e);
});
test('row 233: sequence 7: two upgrades and a rollback --to the previous copy with --restore, the args file following each confirm',async(t)=>{
const e=await useed(t);
// The args file at confirm still names the old copy; the new one moves in after it.
arm(e,'confirm.sh',`cp ${e.args} ${join(e.sd,'args-at-confirm')}\n`);
const one=await upgrade(e,C1);assert.equal(one.code,0,one.all);
assert.equal(one.out.split('\n').at(-2),`stack-upgrade: upgrade ok: ${C1} is live for acme`);
assert.equal(readFileSync(join(e.sd,'args-at-confirm'),'utf8'),e.argsText);
assert.equal(readFileSync(e.args,'utf8'),e.argsText.replace(`--copy\n${B}\n`,`--copy\n${C1}\n`));
assert.equal(statSync(e.args).mode&0o777,0o640);assert.deepEqual(readdirSync(e.etc),['dev.args']);
assert.deepEqual(names(e),[...PRE,...A2,'stage','render','confirm','args','result']);
assert.ok(slog(e).includes(`args rc=0 copy: ${C1}`));
const a1=lastAct(e);
const two=await upgrade(e,C,'--migrate');assert.equal(two.code,0,two.all);
const a2=lastAct(e);assert.equal(argsCopy(e),C);
const l1=final(e,a1),l2=final(e,a2);
assert.deepEqual([l1.op,l1.result,l1.from,l1.to],['activate','ok',B,C1]);
assert.deepEqual([l2.op,l2.result,l2.from,l2.to,l2.backup],['activate','ok',C1,C,a2]);
assert.equal(storeVersion(e.store,true),'3h');
const n=slog(e).length;
const rb=await drive(e,C,'rollback','--instance','dev','--to',C1,'--restore',a2,...TO);assert.equal(rb.code,0,rb.all);
assert.equal(rb.out.split('\n').at(-2),`stack-upgrade: rollback ok: ${C1} is live for acme`);
// A rollback fetches, copies and rehearses nothing.
assert.deepEqual(names(e).slice(n),['start','hostcheck','units','plan',...A2,'stage','render','confirm','args','result']);
const ra=lastAct(e),l3=final(e,ra);assert.deepEqual([l3.op,l3.result,l3.from,l3.to,l3.backup],['rollback','ok',C,C1,a2]);
assert.deepEqual([pointer(e).copy,pointer(e).schema],[C1,'3g']);assert.equal(storeVersion(e.store,true),'3g');
assert.equal(argsCopy(e),C1);assert.deepEqual(live(e),['active','active',C1,C1]);
const m=slog(e).length,sc=lines(join(e.sd,'root-systemctl.log')).length;
const no=await drive(e,C,'rollback','--instance','dev','--to',B,...TO);assert.equal(no.code,3,no.all);
assert.ok(no.all.includes(`refusing to-not-previous: --to ${B} is not the previous copy; the log names ${C}`),no.all);
assert.deepEqual(names(e).slice(m),['start','hostcheck','units','plan','result']);
assert.equal(lines(join(e.sd,'root-systemctl.log')).length,sc);assert.equal(argsCopy(e),C1);
});
test('row 233: sequence 8: a migration that writes and fails: the driver reverts to the backup byte for byte, --cause A3: exit 1',async(t)=>{
const e=await useed(t);
const s0=sha(e.store);
fault(e,'migrate-fails');
const r=await upgrade(e,C,'--migrate');assert.equal(r.code,1,r.all);
assert.match(slog(e).find((x)=>x.startsWith('stage ')),/^stage rc=1 /);
assert.deepEqual(names(e),[...PRE,...A2,'stage',...R,'result']);
const a=lastAct(e),l=final(e,a);assert.deepEqual([l.result,l.code,l.rollback],['failed','migrate-failed','ok']);
sysctl(e,'stop',e.bus);assert.equal(sha(e.store),s0);assert.equal(storeVersion(e.store,false),'3g');
argsKept(e);
});
// --- The lock, A1's refusals and the rehearsal ---
test('row 233: a second run while the lock is held refuses upgrade-running with one log line and no call',async(t)=>{
const e=await useed(t);
mkdirSync(e.run,{recursive:true});
const holder=spawn('flock',[e.lockf,'sleep','60'],{detached:true,stdio:'ignore'});
t.after(()=>{try{process.kill(-holder.pid,'SIGKILL')}catch{}});
for(let i=0;i<100&&execFileSync('bash',['-c','flock -n "$1" true && echo free || echo held','_',e.lockf],{encoding:'utf8'}).trim()==='free';i++)await new Promise((r)=>setTimeout(r,50));
const r=await upgrade(e,C1);assert.equal(r.code,3,r.all);
assert.equal(r.err,`stack-upgrade: upgrade-running: another stack-upgrade.sh run holds ${e.lockf} (nothing was stopped)\n`);
assert.deepEqual(names(e),['result']);
assert.ok(!existsSync(join(e.sd,'sudo.log')));assert.ok(!existsSync(join(e.sd,'root-systemctl.log')));
assert.deepEqual(live(e),['active','active',B,B]);argsKept(e);
});
test('row 233: A1 refuses open-activation on any AWAITING-UNITS file, through the stack\'s user\'s --plan, with nothing stopped',async(t)=>{
const e=await useed(t);
const fake=join(e.state,'stack-rollback',`20000101T000000Z-${C1.slice(0,12)}`);mkdirSync(fake,{recursive:true});writeFileSync(join(fake,'AWAITING-UNITS'),`${C1}\n`);
const logs=logLines(e).length;
const r=await upgrade(e,C1);assert.equal(r.code,3,r.all);
assert.ok(r.out.includes('refuses open-activation'),r.out);
assert.deepEqual(names(e),[...PRE.slice(0,7),'result']);assert.match(result(e),/^result exit=3 plan-refused /);
assert.equal(logLines(e).length,logs);assert.ok(!existsSync(join(e.sd,'root-systemctl.log')));
assert.deepEqual(live(e),['active','active',B,B]);argsKept(e);
});
test('row 233: a target that migrates refuses migrate-required without --migrate, with nothing stopped',async(t)=>{
const e=await useed(t);
const r=await upgrade(e,C);assert.equal(r.code,3,r.all);
assert.ok(r.out.includes('refuses migrate-required'),r.out);
assert.deepEqual(names(e),[...PRE.slice(0,7),'result']);assert.match(result(e),/^result exit=3 plan-refused /);
assert.ok(!sudoKeys(e).includes('rehearse'));assert.ok(!existsSync(join(e.sd,'root-systemctl.log')));
assert.deepEqual(live(e),['active','active',B,B]);argsKept(e);
});
test('row 233: the rehearsal runs after the plan and before A2; a failed one refuses rehearsal-failed with nothing stopped',async(t)=>{
const e=await useed(t);
fault(e,'rehearse-rc','1');
const r=await upgrade(e,C1);assert.equal(r.code,3,r.all);
assert.ok(r.out.includes('fixture rehearse: '),r.out);
assert.deepEqual(names(e),[...PRE,'result']);
assert.equal(result(e),'result exit=3 rehearsal-failed the rehearsal exited 1 (nothing was stopped)');
const rh=lines(join(e.sd,'sudo.log')).map((l)=>JSON.parse(l)).find((l)=>l.key==='rehearse');
assert.deepEqual(rh.cmd.slice(1),['rehearse',C1,'acme','--copies',e.hc,'--backup','last','--port','7339','--timeout','3']);
assert.ok(!existsSync(join(e.sd,'root-systemctl.log')));assert.deepEqual(live(e),['active','active',B,B]);argsKept(e);
});
test('row 233: the rehearsal\'s BACKUP_STORE line goes to the step log after a match, a malformed one as none, the rest of its output never',async(t)=>{
const e=await useed(t);
fault(e,'rehearse-rc','1');
const r=await upgrade(e,C1,'--rehearse-port','7449');assert.equal(r.code,3,r.all);
assert.ok(slog(e).includes('rehearse-store rc=0 BACKUP_STORE 20261020T000000Z-0123456789ab schema 3g'),slog(e).join('\n'));
assert.ok(slog(e).find((x)=>x.startsWith('rehearse ')).includes(' --port 7449 '));
for(const bad of ['BACKUP_STORE ../../etc schema 3g','BACKUP_STORE 20261020T000000Z-0123456789ab schema 3g; rm -rf /','stack-release: BACKUP_STORE 20261020T000000Z-0123456789ab schema 3g','']){
fault(e,'rehearse-store-line',bad);
const n=slog(e).length;
const b=await upgrade(e,C1);assert.equal(b.code,3,b.all);
assert.deepEqual(slog(e).slice(n).filter((x)=>x.startsWith('rehearse-store ')),['rehearse-store rc=0 BACKUP_STORE none'],bad);
}
});
test('row 233: a real rehearsal with no backup in the log refuses no-backup, and goes on only with --no-rehearsal, which logs the skip',async(t)=>{
const e=await useed(t);
fault(e,'real-rehearse');
const r=await upgrade(e,C1);assert.equal(r.code,3,r.all);
assert.ok(r.out.includes('FAIL no-backup: the log records no backup\nREHEARSAL_FAIL\n'),r.out);
assert.equal(result(e),'result exit=3 no-backup the log records no backup to rehearse against; only a go that names --no-rehearsal goes on (nothing was stopped)');
assert.ok(slog(e).includes('rehearse-store rc=0 BACKUP_STORE none'));
assert.ok(!existsSync(join(e.data,'rehearsals')));assert.ok(!existsSync(join(e.sd,'root-systemctl.log')));
const n=slog(e).length;
const s=await upgrade(e,C1,'--no-rehearsal');assert.equal(s.code,0,s.all);
assert.deepEqual(names(e).slice(n),[...PRE,'rehearsal-skipped',...A2,'stage','render','confirm','args','result']);
assert.equal(argsCopy(e),C1);assert.deepEqual(live(e),['active','active',C1,C1]);
});
test('row 233: a real rehearsal after an upgrade names the log\'s newest backup; --no-rehearsal with a backup refuses rehearsal-required',async(t)=>{
const e=await useed(t);
const one=await upgrade(e,C1);assert.equal(one.code,0,one.all);
const a1=lastAct(e);
fault(e,'real-rehearse');
const n=slog(e).length;
// The stub copies fail the rehearsal right after it names the backup (copy-without-no-launch).
const r=await upgrade(e,C,'--migrate');assert.equal(r.code,3,r.all);
assert.ok(r.out.includes(`PASS backup-last: the log's newest backup is ${a1}\n`),r.out);
assert.deepEqual(names(e).slice(n),[...PRE,'result']);assert.match(result(e),/^result exit=3 rehearsal-failed the rehearsal exited 1 /);
rmSync(join(e.sd,'faults','real-rehearse'));
const m=slog(e).length;
const q=await upgrade(e,C,'--migrate','--no-rehearsal');assert.equal(q.code,3,q.all);
assert.deepEqual(names(e).slice(m),[...PRE,'result']);
assert.equal(result(e),'result exit=3 rehearsal-required the log records a backup and the rehearsal ran; --no-rehearsal is only for a stack with none (nothing was stopped)');
assert.equal(argsCopy(e),C1);assert.deepEqual(live(e),['active','active',C1,C1]);
});
// --- Before A1 and the args file ---
test('row 233: the args file must be the running user\'s, with no group or other write, one --copy, and the copy the units run',async(t)=>{
const e=await useed(t);
const tryArgs=async(text,mode,code,why)=>{
writeFileSync(e.args,text);chmodSync(e.args,mode);
const n=existsSync(e.slog)?slog(e).length:0;
const r=await upgrade(e,C1);assert.equal(r.code,3,`${why}: ${r.all}`);
assert.match(slog(e).at(-1),new RegExp(`^result exit=3 ${code} `),why);
assert.ok(!slog(e).slice(n).some((x)=>/^(stop|stage|render|confirm)/.test(x)),why);
};
await tryArgs(e.argsText,0o660,'args-unreadable','group write');
await tryArgs(e.argsText,0o642,'args-unreadable','other write');
await tryArgs(e.argsText.replace('--\n',`--copy\n${C1}\n--\n`),0o640,'args-unreadable','two --copy');
await tryArgs(e.argsText.replace('--\n','--tester\n--\n'),0o640,'args-unreadable','an unknown flag');
await tryArgs(e.argsText.replace(`--copy\n${B}\n`,`--copy\n${C1}\n`),0o640,'args-differ','the units run B');
rmSync(e.args);symlinkSync(join(e.sd,'probe.sh'),e.args);
const l=await upgrade(e,C1);assert.equal(l.code,3);assert.match(result(e),/^result exit=3 args-unreadable /);
assert.ok(!existsSync(join(e.sd,'root-systemctl.log')));assert.deepEqual(live(e),['active','active',B,B]);
});
test('row 233: hostcheck (row 240): a nonzero --check or a missing file refuses profile-drift before the plan',async(t)=>{
const e=await useed(t);
fault(e,'hostcheck-rc','1');
const r=await upgrade(e,C1);assert.equal(r.code,3,r.all);
assert.match(result(e),/^result exit=3 profile-drift /);
assert.deepEqual(lines(join(e.sd,'hostcheck.log')),['--check']);
assert.deepEqual(sudoKeys(e),['git-fetch','git-merge','stack-copy.sh']);
e.denv.STACK_UPGRADE_HOSTCHECK=join(e.root,'no-hostcheck');
const m=await upgrade(e,C1);assert.equal(m.code,3,m.all);
assert.equal(result(e),`result exit=3 profile-drift no executable ${join(e.root,'no-hostcheck')} (row 240) (nothing was stopped)`);
assert.ok(!sudoKeys(e).includes('activate-plan'));assert.ok(!existsSync(join(e.sd,'root-systemctl.log')));argsKept(e);
});
test('row 233: the checkout must be at --sha (head-mismatch), and a commit the clone can\'t fast-forward to refuses fetch-failed',async(t)=>{
const e=await useed(t);
const r=await drive(e,C1,'upgrade','--instance','dev','--bundle',BUNDLE,'--sha',C,'--migrate',...TO);assert.equal(r.code,3,r.all);
assert.equal(result(e),`result exit=3 head-mismatch this checkout, ${checkout(C1)}, is at ${C1}, not ${C} (nothing was stopped)`);
const u=await drive(e,U,'upgrade','--instance','dev','--bundle',BUNDLE,'--sha',U,...TO);assert.equal(u.code,3,u.all);
assert.match(result(e),/^result exit=3 fetch-failed .* fast-forward /);
assert.ok(!sudoKeys(e).includes('activate-plan'));assert.ok(!existsSync(join(e.sd,'root-systemctl.log')));argsKept(e);
});
test('row 233: usage exits 4 before any log or lock',async(t)=>{
const e=await useed(t);
for(const a of [[],['status'],['upgrade','--instance','dev','--bundle',BUNDLE],['upgrade','--instance','other','--bundle',BUNDLE,'--sha',C1],['upgrade','--instance','dev','--bundle','rel/x','--sha',C1],['upgrade','--instance','dev','--bundle',BUNDLE,'--sha',C1.slice(0,12)],['upgrade','--instance','dev','--bundle',BUNDLE,'--sha',C1,'--to',B],['upgrade','--instance','dev','--bundle',BUNDLE,'--sha',C1,'--rehearse-port','80'],['rollback','--instance','dev','--to',B,'--migrate'],['rollback','--instance','dev','--to',B,'--events','3'],['rollback','--instance','dev'],['rollback','--instance','dev','--to',B,'--restore','x']]){
const r=await drive(e,C1,...a);assert.equal(r.code,4,a.join(' '));assert.match(r.err,/^usage: stack-upgrade.sh upgrade /,a.join(' '));
}
assert.ok(!existsSync(dirname(e.slog)));assert.ok(!existsSync(e.run));
});
+1 -1
View File
@@ -35,6 +35,6 @@ if [ "${1:-}" = business ]; then
exec node "$REPO/packages/business/src/cli.mjs" "$@"
fi
case "${1:-}" in
usage|project|limits|inbox|decide|tasks|agents|trail|talk|stop|launches|bus|plugins|credentials|name|names|follow|threads|thread) exec node "$REPO/packages/cli/src/cli.mjs" "$@" ;;
usage|project|limits|inbox|decide|tasks|agents|trail|talk|stop|launches|bus|plugins|credentials|hosts|name|names|follow|threads|thread) exec node "$REPO/packages/cli/src/cli.mjs" "$@" ;;
esac
exec node "$REPO/packages/seat/src/cli.mjs" "$@"
+27 -5
View File
@@ -130,7 +130,7 @@ parse_opts() {
--sso) [ "$OP" = activate ] || usage; SSO=1; shift ;;
--migrate) [ "$OP" = activate ] || usage; MIGRATE=1; shift ;;
--restore) [ "$OP" = rollback ] && [ $# -ge 2 ] && [[ "$2" =~ ^[0-9]{8}T[0-9]{6}Z-[0-9a-f]{12}(-[1-9])?$ ]] || usage; RESTORE="$2"; shift 2 ;;
--backup) [ "$OP" = rehearse ] && [ $# -ge 2 ] && { [[ "$2" =~ ^[0-9]{8}T[0-9]{6}Z-[0-9a-f]{12}(-[1-9])?$ ]] || [[ "$2" =~ ^/[A-Za-z0-9/._+-]*$ ]]; } || usage; R_BACKUP="$2"; shift 2 ;;
--backup) [ "$OP" = rehearse ] && [ $# -ge 2 ] && { [ "$2" = last ] || [[ "$2" =~ ^[0-9]{8}T[0-9]{6}Z-[0-9a-f]{12}(-[1-9])?$ ]] || [[ "$2" =~ ^/[A-Za-z0-9/._+-]*$ ]]; } || usage; R_BACKUP="$2"; shift 2 ;;
--port) [ "$OP" = rehearse ] && [ $# -ge 2 ] && [[ "$2" =~ ^[0-9]{1,5}$ ]] && [ "$((10#$2))" -ge 1024 ] && [ "$((10#$2))" -le 65535 ] || usage; R_PORT="$((10#$2))"; shift 2 ;;
--events) [ "$OP" = rollback ] && [ $# -ge 2 ] && [[ "$2" =~ ^(0|[1-9][0-9]*)$ ]] || usage; EVENTS="$2"; shift 2 ;;
--units) [ "$OP" != rehearse ] && [ $# -ge 2 ] && { [ "$2" = user ] || [ "$2" = external ]; } || usage; UNITS="$2"; shift 2 ;;
@@ -220,7 +220,8 @@ for(const k of open.keys())process.stdout.write(k+"\n");' -- "$LOG"
# Reads the log's ok lines, newest first. `previous` prints rollback's target: the first `to`
# that is not the pointer's copy (release.sh's rule). `backup` prints the backup field of the
# line that made the pointer's copy live, the backup a rollback would restore. `arrival` prints the
# line that made the pointer's copy live, the backup a rollback would restore. `newest` prints the
# backup field of the newest line that has one, rehearse's --backup last (row 233). `arrival` prints the
# activation and `from` of the line that moved the stack onto the pointer's copy from another one,
# past any same-copy re-pin after it (row 200, sage-1389).
from_log() {
@@ -233,6 +234,7 @@ for(const l of lines.reverse()){
if(o.result!=="ok"||!["adopt","activate","rollback"].includes(o.op)||!o.to)continue;
if(mode==="previous"&&o.to!==current){process.stdout.write(o.to+"\n");process.exit(0)}
if(mode==="backup"&&o.to===current){if(o.backup)process.stdout.write(o.backup+"\n");process.exit(o.backup?0:1)}
if(mode==="newest"&&o.backup){process.stdout.write(o.backup+"\n");process.exit(0)}
if(mode==="arrival"&&o.to===current&&o.from!==current){process.stdout.write(`${o.activation??""} ${o.from??""}\n`);process.exit(0)}
}
process.exit(1);' -- "$LOG" "$1" "$P_COPY"
@@ -927,12 +929,13 @@ sso_checks() {
sso_supported || { check_refuse sso-target "$TO's Console does not support --sso"; return 0; }
local cfg="${MOSAIC_CONFIG:-$HOME/.config/mosaic-dev/config.json}" out id slot path why
# N4 (webby-0019, sage-1403): a pre-phase-1 loader refuses a console key, so with the key not yet
# written and the stack on another copy, the first step is the re-pin without --sso.
local first=""
# written and the stack on another copy, the first step is the re-pin without --sso. On a named
# instance both commands carry --instance NAME (row 238).
local first="" inst="${INSTANCE_FLAGS[*]:+ ${INSTANCE_FLAGS[*]}}"
if ! out="$(node -e "$SSO_JS" -- "$COPIES/$TO" "$cfg" 2>&1)"; then
out="${out%%$'\n'*}"
[ "$out" != "there is no console.sso in the system config" ] || [ "$P_COPY" = "$TO" ] ||
first="; activate $TO without --sso first, then write console.sso, then run activate $TO --sso"
first="; activate $TO$inst without --sso first, then write console.sso, then run activate $TO$inst --sso"
check_refuse sso-config "$TO's Console would refuse its --sso start: $out$first"
return 0
fi
@@ -1262,6 +1265,8 @@ plan() {
echo "needs: --migrate$([ "$MIGRATE" = 1 ] && echo ' (given)' || echo ' (not given; the run would roll back with migration-needs-flag)')"
echo "needs: Jason present for this run (decision 96 condition 2)"
[ "$MIGRATE" = 1 ] || early=1
# Row 233: root reads only the plan's exit, so under external a missing --migrate refuses here.
[ "$UNITS" = user ] || [ "$MIGRATE" = 1 ] || PLAN_REFUSE+=("migrate-required: the store is $S_SCHEMA and $TO migrates it to $T_SCHEMA; the go names --migrate")
elif [ "$OP" = activate ]; then
echo "migration: none possible; $S_SCHEMA is not in $T_SCHEMA's migration chain, so the run would roll back with store-newer"
early=1
@@ -1452,6 +1457,13 @@ ext_pointer_checks() {
local open
open="$(interrupted | paste -sd' ' -)"
[ -z "$open" ] || check_refuse interrupted-run "activation $open started and has no final line; Jason or Mos settles it (status shows it)"
# Row 233: root can't read the stack's state, so the plan lists any rollback set still waiting
# for units, whatever the log says.
local m sets=""
local -a markers
mapfile -t markers < <(compgen -G "$STATE/stack-rollback/*/AWAITING-UNITS" || true)
for m in "${markers[@]}"; do sets+=" $(basename "$(dirname "$m")")"; done
[ -z "$sets" ] || check_refuse open-activation "rollback set(s)$sets hold AWAITING-UNITS; root finishes them with confirm or revert first"
}
# A3's checks before `started`. Root has stopped the units and installs them after this run, so
@@ -1973,6 +1985,9 @@ r_env() { env -i HOME="$HOME" PATH="$PATH" MOSAIC_CONFIG="$R/config/config.json"
r_migration() {
local copy="$COPIES/$TO" old="$COPIES/$P_COPY" v out rc
v="$(node -e "$STORE_JS" -- version "$R/data/bus/bus.sqlite" 2>&1)" || r_abort store-unreadable "$v"
# Row 233 (sage-1653): which store the rehearsal ran against, for root's step log. Output only:
# the rehearsal's own steps.log has the backup's path on its backup line.
echo "BACKUP_STORE $R_BACKUP schema $v"
if [ "$v" = "$T_SCHEMA" ]; then r_pass migration "not needed: the backup is already $v"; return 0; fi
in_chain "$v" || r_abort store-newer "the backup is $v, which $TO's migration chain does not start from"
(umask 077; mkdir "$R/refuse" "$R/refuse/bus" && cp "$R_BK"/bus/* "$R/refuse/bus/") || r_abort scratch-build "cannot copy the backup to $R/refuse"
@@ -2043,6 +2058,13 @@ do_rehearse() {
trap 'r_fail signal "the rehearsal was interrupted"; r_end' INT TERM HUP
read_pointer || r_abort no-pointer "no $POINTER; the pointer's copy is the old copy the checks use"
[ "$P_BUSINESS" = "$BUSINESS" ] || r_abort business-mismatch "the pointer is for $P_BUSINESS"
# Row 233 (sage-1650, sage-1653): last is the newest backup the log records. With none, exit 3
# and no directory, the one rehearse refusal stack-upgrade.sh reads as no-backup.
if [ "$R_BACKUP" = last ]; then
R_BACKUP="$(from_log newest)" || { r_fail no-backup "the log records no backup"; R_RC=3; r_end; }
[[ "$R_BACKUP" =~ ^[0-9]{8}T[0-9]{6}Z-[0-9a-f]{12}(-[1-9])?$ ]] || r_abort backup-unreadable "the log's newest backup is not an activation id"
r_pass backup-last "the log's newest backup is $R_BACKUP"
fi
copy_exists "$TO" 2>/dev/null || r_abort copy-missing "no copy $TO in $COPIES"
copy_ok "$TO" 2>/dev/null || r_abort copy-unreachable "$TO is not reachable from refactor and origin/refactor"
target_schema "$TO" || r_abort copy-schema-unknown "cannot read SCHEMA_VERSION and its chain from $COPIES/$TO/packages/bus/src/store.mjs"
+347
View File
@@ -0,0 +1,347 @@
#!/usr/bin/env bash
# Row 233 (I3, #1735): root's upgrade and rollback for a stack whose units are root's system units
# (stack-release.sh --units external, row 232). r6's activate A1-A5, A2f, R1-R4 and rollback
# (agents/darkwing/work/queue-195/DESIGN.md), as the I3 brief changes them
# (docs/plans/2026-10-20_installer-rows.md). Deviations and decisions:
# agents/darkwing/work/queue-233/BUILD.md.
#
# stack-upgrade.sh upgrade --instance dev --bundle FILE --sha SHA [--migrate] [--no-rehearsal]
# [--rehearse-port N] [--timeout S]
# stack-upgrade.sh rollback --instance dev --to ID [--restore ACT [--events N]] [--timeout S]
#
# Root runs it from a root-owned checkout of the reviewed commit, never from a copy, because the
# stack's user can write the copies. The unit values come from /etc/mosaic-stack/<instance>.args,
# which I2's install writes: one case2-unit.sh argument per line, the serve flags after `--`. Only
# --copy changes, and the file changes only after confirm passes. The stack's user runs its steps
# from its own clone, <home>/src/mosaic-stack, through sudo. Root reads none of that user's output
# but exit codes and the rehearsal's BACKUP_STORE line (sage-1653), which it copies into its step
# log after a match against a fixed pattern and decides nothing on.
#
# --no-rehearsal is for a stack whose log records no backup. The rehearsal runs either way: exit 3
# from it (no-backup) goes on only with the flag, and exit 0 with the flag refuses
# rehearsal-required (sage-1650, sage-1652).
#
# The lock /run/mosaic-stack/upgrade-<instance>.lock is held from before A1 to the end of the run,
# rollback included; every child runs with the lock's descriptor closed. The step log
# /var/log/mosaic-stack/upgrade-<instance>.log gets one line per step, `<time> <step> rc=N argv:
# <argv>`, and a last `<time> result exit=N <code> <text>` line.
#
# Exit 0 ok, 1 failed with the old stack running, 2 rollback-failed with the units stopped,
# 3 refused with nothing stopped, 4 usage.
#
# Test seams, for packages/cli/tests/stack-upgrade.test.mjs only. Run as root, it refuses any:
# STACK_UPGRADE_ETC default /etc/mosaic-stack
# STACK_UPGRADE_RUN default /run/mosaic-stack
# STACK_UPGRADE_LOG_DIR default /var/log/mosaic-stack
# STACK_UPGRADE_UNIT_DIR default /etc/systemd/system
# STACK_UPGRADE_HOSTCHECK default /usr/local/lib/mosaic-stack/hostcheck.sh (row 240)
# STACK_UPGRADE_RENDER default scripts/case2-unit.sh in this checkout
# STACK_UPGRADE_SUDO default sudo
# STACK_UPGRADE_SYSTEMCTL default systemctl
set -uo pipefail
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
usage() {
cat >&2 <<'USAGE'
usage: stack-upgrade.sh upgrade --instance dev --bundle FILE --sha SHA [--migrate] [--no-rehearsal] [--rehearse-port N] [--timeout S]
stack-upgrade.sh rollback --instance dev --to ID [--restore ACT [--events N]] [--timeout S]
USAGE
exit 4
}
if [ "$(id -u)" = 0 ]; then
for v in ${!STACK_UPGRADE_@}; do echo "stack-upgrade: refusing: $v is a test seam, and root takes none" >&2; exit 3; done
fi
ETC="${STACK_UPGRADE_ETC:-/etc/mosaic-stack}"
RUN_DIR="${STACK_UPGRADE_RUN:-/run/mosaic-stack}"
LOG_DIR="${STACK_UPGRADE_LOG_DIR:-/var/log/mosaic-stack}"
UNIT_DIR="${STACK_UPGRADE_UNIT_DIR:-/etc/systemd/system}"
HOSTCHECK="${STACK_UPGRADE_HOSTCHECK:-/usr/local/lib/mosaic-stack/hostcheck.sh}"
RENDER="${STACK_UPGRADE_RENDER:-$REPO/scripts/case2-unit.sh}"
SUDO="${STACK_UPGRADE_SUDO:-sudo}"
SYSTEMCTL="${STACK_UPGRADE_SYSTEMCTL:-systemctl}"
ID_RE='^[0-9]{8}T[0-9]{6}Z-[0-9a-f]{12}(-[1-9])?$'
ARGV=("$@")
[ $# -gt 0 ] || usage
OP="$1"; shift
case "$OP" in upgrade|rollback) ;; *) usage ;; esac
INSTANCE="" BUNDLE="" SHA="" MIGRATE=0 NO_REHEARSAL=0 R_PORT=7339 TIMEOUT=60 TO="" RESTORE="" EVENTS=""
while [ $# -gt 0 ]; do
case "$1" in
--instance) [ $# -ge 2 ] || usage; INSTANCE="$2"; shift 2 ;;
--bundle) [ "$OP" = upgrade ] && [ $# -ge 2 ] && [[ "$2" =~ ^/[A-Za-z0-9/._+-]+$ ]] || usage; BUNDLE="$2"; shift 2 ;;
--sha) [ "$OP" = upgrade ] && [ $# -ge 2 ] && [[ "$2" =~ ^[0-9a-f]{40}$ ]] || usage; SHA="$2"; shift 2 ;;
--migrate) [ "$OP" = upgrade ] || usage; MIGRATE=1; shift ;;
--no-rehearsal) [ "$OP" = upgrade ] || usage; NO_REHEARSAL=1; shift ;;
--rehearse-port) [ "$OP" = upgrade ] && [ $# -ge 2 ] && [[ "$2" =~ ^[1-9][0-9]{3,4}$ ]] && [ "$2" -le 65535 ] || usage; R_PORT="$2"; shift 2 ;;
--timeout) [ $# -ge 2 ] && [[ "$2" =~ ^[1-9][0-9]{0,4}$ ]] || usage; TIMEOUT="$2"; shift 2 ;;
--to) [ "$OP" = rollback ] && [ $# -ge 2 ] && [[ "$2" =~ ^[0-9a-f]{40}$ ]] || usage; TO="$2"; shift 2 ;;
--restore) [ "$OP" = rollback ] && [ $# -ge 2 ] && [[ "$2" =~ $ID_RE ]] || usage; RESTORE="$2"; shift 2 ;;
--events) [ "$OP" = rollback ] && [ $# -ge 2 ] && [[ "$2" =~ ^(0|[1-9][0-9]*)$ ]] || usage; EVENTS="$2"; shift 2 ;;
*) usage ;;
esac
done
# The default instance only, whose name is dev (row 190); a named instance's units are a later row.
[ "$INSTANCE" = dev ] || usage
if [ "$OP" = upgrade ]; then [ -n "$BUNDLE" ] && [ -n "$SHA" ] || usage; else [ -n "$TO" ] || usage; fi
[ -z "$EVENTS" ] || [ -n "$RESTORE" ] || usage
# --- The step log and the lock ---
umask 027
LOG="$LOG_DIR/upgrade-$INSTANCE.log"
LOCK="$RUN_DIR/upgrade-$INSTANCE.lock"
{ mkdir -p "$LOG_DIR" "$RUN_DIR" && touch "$LOG" && chmod 0640 "$LOG"; } 2>/dev/null ||
{ echo "stack-upgrade: refusing log-failed: cannot write $LOG or make $RUN_DIR; nothing was changed" >&2; exit 3; }
logl() { printf '%s %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$*" >> "$LOG" || true; }
# The run's last line, in the log and on the terminal, then the exit.
finish() {
local rc="$1" code="$2"; shift 2
logl "result exit=$rc $code $*"
if [ "$rc" = 0 ]; then echo "stack-upgrade: $*"; else echo "stack-upgrade: $code: $*" >&2; fi
exit "$rc"
}
refuse() { local code="$1"; shift; finish 3 "$code" "$* (nothing was stopped)"; }
# One step: $1 its name, the rest the command, run with the lock's descriptor closed. One log line
# with its exit and argv; the command's output goes to the terminal only.
step() {
local name="$1" rc=0; shift
"$@" 9>&- || rc=$?
logl "$name rc=$rc argv:$(printf ' %q' "$@")"
return "$rc"
}
exec 9>> "$LOCK" || refuse lock-failed "cannot open $LOCK"
flock -n 9 || refuse upgrade-running "another stack-upgrade.sh run holds $LOCK"
logl "start rc=0 argv:$(printf ' %q' "$0" "${ARGV[@]}")"
# --- The args file and the stack's user ---
# I2's file: one case2-unit.sh argument per line, owned by root with no group or other write. Sets
# ARGS (the lines), S_USER, S_HOME, BUSINESS, NODE and ARGS_COPY, and the paths derived from them.
read_args() {
local f="$ETC/$INSTANCE.args" st i copies=0 v
[ -f "$f" ] && [ ! -L "$f" ] || refuse args-unreadable "no regular file $f"
st="$(stat -c '%u %a' "$f")" || refuse args-unreadable "cannot stat $f"
[ "${st% *}" = "$(id -u)" ] && (( (8#${st#* } & 8#022) == 0 )) || refuse args-unreadable "$f must be $(id -un)'s with no group or other write (it is uid ${st% *}, mode ${st#* })"
mapfile -t ARGS < "$f" || refuse args-unreadable "cannot read $f"
S_USER="" S_HOME="" BUSINESS="" NODE="" ARGS_COPY=""
for ((i = 0; i < ${#ARGS[@]}; i++)); do
case "${ARGS[i]}" in
--) break ;;
--pm) ;;
--user|--home|--business|--copy|--node|--profile|--claude)
v="${ARGS[i + 1]:-}"
case "${ARGS[i]}" in
--user) S_USER="$v" ;; --home) S_HOME="$v" ;; --business) BUSINESS="$v" ;; --node) NODE="$v" ;;
--copy) ARGS_COPY="$v"; copies=$((copies + 1)) ;;
esac
i=$((i + 1)) ;;
*) refuse args-unreadable "$f has ${ARGS[i]} before --, which case2-unit.sh does not take" ;;
esac
done
[ "$copies" = 1 ] || refuse args-unreadable "$f needs exactly one --copy before --, not $copies"
[[ "$S_USER" =~ ^[a-z_][a-z0-9_-]*$ ]] && [[ "$S_HOME" =~ ^/[A-Za-z0-9/._+-]+$ ]] && [[ "$BUSINESS" =~ ^[a-z0-9][a-z0-9-]*$ ]] &&
[[ "$NODE" =~ ^/[A-Za-z0-9/._+-]+$ ]] && [[ "$ARGS_COPY" =~ ^[0-9a-f]{40}$ ]] ||
refuse args-unreadable "$f lacks a plain --user, --home, --business, --node or --copy"
COPIES_DIR="$S_HOME/.local/share/mosaic-stack/copies"
CLONE="$S_HOME/src/mosaic-stack"
SR="$CLONE/scripts/stack-release.sh"
BUS_UNIT="mosaic-bus@$BUSINESS.service" CON_UNIT=mosaic-webui.service
# The stack's user's steps: its own HOME and default config, the pinned node first on PATH.
AS_USER=("$SUDO" -u "$S_USER" -H env "PATH=$(dirname "$NODE"):/usr/local/bin:/usr/bin:/bin")
}
# RENDER_ARGS: the args file's lines with copy $1 in place of --copy's value.
args_with() {
local i past=""
RENDER_ARGS=()
for ((i = 0; i < ${#ARGS[@]}; i++)); do
RENDER_ARGS+=("${ARGS[i]}")
[ "${ARGS[i]}" != -- ] || past=1
if [ -z "$past" ] && [ "${ARGS[i]}" = --copy ]; then RENDER_ARGS+=("$1"); i=$((i + 1)); fi
done
}
# The new args file, written beside the old one before anything stops, moved into place only after
# confirm passes. $1 is the copy.
prepare_args() {
args_with "$1"
NEW_ARGS="$(mktemp "$ETC/.$INSTANCE.args.XXXXXX")" &&
printf '%s\n' "${RENDER_ARGS[@]}" > "$NEW_ARGS" && chmod --reference="$ETC/$INSTANCE.args" "$NEW_ARGS" ||
{ rm -f "${NEW_ARGS:-}"; refuse args-failed "cannot write the new args file in $ETC"; }
}
drop_args() { rm -f "${NEW_ARGS:-}"; }
# --- A1 ---
# OLD: the copy both installed units run, from their WorkingDirectory under the stack's copies.
read_old() {
local f ids=() id n
for f in "$UNIT_DIR/[email protected]" "$UNIT_DIR/$CON_UNIT"; do
[ -f "$f" ] && [ ! -L "$f" ] || refuse units-unreadable "no regular file $f"
n="$(grep -c '^WorkingDirectory=' "$f")"
id="$(awk -v c="WorkingDirectory=$COPIES_DIR/" 'index($0, c) == 1 { print substr($0, length(c) + 1) }' "$f")"
[ "$n" = 1 ] && [[ "$id" =~ ^[0-9a-f]{40}$ ]] || refuse units-unreadable "$f names no copy under $COPIES_DIR"
ids+=("$id")
done
[ "${ids[0]}" = "${ids[1]}" ] || refuse units-disagree "$UNIT_DIR's units name ${ids[0]} and ${ids[1]}"
OLD="${ids[0]}"
logl "units rc=0 old: $OLD"
[ "$ARGS_COPY" = "$OLD" ] || refuse args-differ "$ETC/$INSTANCE.args names $ARGS_COPY and the units name $OLD"
}
# r6 A1 and row 240: the host's profile still matches, on every host.
hostcheck() {
[ -f "$HOSTCHECK" ] && [ -x "$HOSTCHECK" ] || refuse profile-drift "no executable $HOSTCHECK (row 240)"
step hostcheck "$HOSTCHECK" --check || refuse profile-drift "$HOSTCHECK --check failed"
}
# The stack's user's --plan for the run, $@ its op and arguments; any refusal stops here.
plan() {
local rc=0
step plan "${AS_USER[@]}" "$SR" "$@" --units external --copies "$COPIES_DIR" --plan --expect-from "$OLD" || rc=$?
[ "$rc" = 0 ] || refuse plan-refused "the stack's user's $1 --plan exited $rc; its output above names the refusal"
}
# --- The units ---
stop_wait() {
local u s deadline=$((SECONDS + TIMEOUT)) rc=0
step stop-console "$SYSTEMCTL" stop "$CON_UNIT" || rc=1
step stop-bus "$SYSTEMCTL" stop "$BUS_UNIT" || rc=1
for u in "$CON_UNIT" "$BUS_UNIT"; do
while :; do
s="$("$SYSTEMCTL" is-active "$u" 9>&- 2>/dev/null)"
case "$s" in inactive|failed) break ;; esac
[ "$SECONDS" -lt "$deadline" ] || { rc=1; break; }
sleep 1
done
case "$s" in inactive|failed) logl "wait-down rc=0 unit: $u state: $s" ;; *) logl "wait-down rc=1 unit: $u state: ${s:-unknown}" ;; esac
done
return "$rc"
}
stop_units() { "$SYSTEMCTL" stop "$CON_UNIT" 9>&- || true; "$SYSTEMCTL" stop "$BUS_UNIT" 9>&- || true; logl "stop-units rc=0 units: $CON_UNIT $BUS_UNIT"; }
# A2f: the old units back on the copy they never left, gated on a start at or after this second.
# An A2 that timed out leaves a unit on its old start, which the gate refuses as stale-start.
a2f() {
local cause="$1" since
drop_args
since="$(date -u +%s)"
step start-bus "$SYSTEMCTL" start "$BUS_UNIT"
step start-console "$SYSTEMCTL" start "$CON_UNIT"
if step gate "${AS_USER[@]}" "$SR" gate "$OLD" "$BUSINESS" --units external --copies "$COPIES_DIR" --since "$since" --timeout "$TIMEOUT"; then
finish 1 failed "$cause failed; $OLD is running again and passed the gate"
fi
stop_units
finish 2 rollback-failed "$cause failed and $OLD failed its gate after the restart; both units are stopped"
}
# R1-R4: back to OLD after the stack's user staged $1. $2 is the cause: A3, A4 or A5.
rollback_to_old() {
local id="$1" cause="$2"
drop_args
stop_wait || { stop_units; finish 2 rollback-failed "$cause failed and the units did not stop for the rollback; both units are stopped"; }
step revert "${AS_USER[@]}" "$SR" revert "$id" "$BUSINESS" --units external --copies "$COPIES_DIR" ||
{ stop_units; finish 2 rollback-failed "$cause failed and revert failed; both units are stopped"; }
args_with "$OLD"
step render-old "$RENDER" "${RENDER_ARGS[@]}" ||
{ stop_units; finish 2 rollback-failed "$cause failed and the render of $OLD failed; both units are stopped"; }
# confirm --rollback exits 1 when OLD passed its gate and 2 when it failed; anything else is a
# refusal, with OLD's gate never run.
local rc=0
step confirm-rollback "${AS_USER[@]}" "$SR" confirm "$id" "$BUSINESS" --units external --copies "$COPIES_DIR" --rollback "$OLD" --cause "$cause" --timeout "$TIMEOUT" || rc=$?
[ "$rc" != 1 ] || finish 1 failed "$cause failed; rolled back to $OLD, which passed the gate"
stop_units
finish 2 rollback-failed "$cause failed and the rollback to $OLD did not pass (confirm --rollback exited $rc); both units are stopped"
}
# A2 to A5 for copy $1, once A1 and any rehearsal passed. $@ after it is the stack's user's op.
run_steps() {
local id="$1" rc=0; shift
prepare_args "$id"
# A2
stop_wait || a2f A2
# A3
step stage "${AS_USER[@]}" "$SR" "$@" --units external --copies "$COPIES_DIR" || rc=$?
case "$rc" in
0) ;;
3) a2f A3 ;;
*) rollback_to_old "$id" A3 ;;
esac
# A4
args_with "$id"
step render "$RENDER" "${RENDER_ARGS[@]}" || rollback_to_old "$id" A4
# A5
step confirm "${AS_USER[@]}" "$SR" confirm "$id" "$BUSINESS" --units external --copies "$COPIES_DIR" --timeout "$TIMEOUT" ||
rollback_to_old "$id" A5
if mv -f "$NEW_ARGS" "$ETC/$INSTANCE.args"; then logl "args rc=0 copy: $id"
else logl "args rc=1 copy: $id"; finish 1 args-failed "$id is live and confirmed, but $ETC/$INSTANCE.args still names $OLD; put $NEW_ARGS in its place"
fi
}
do_upgrade() {
read_args
# Before A1: the bundle into the stack's user's clone, then the copy from it (I1).
step fetch "${AS_USER[@]}" git -C "$CLONE" fetch -q "$BUNDLE" refs/heads/refactor:refs/remotes/origin/refactor ||
refuse fetch-failed "the stack's user could not fetch $BUNDLE into $CLONE"
step ff "${AS_USER[@]}" git -C "$CLONE" merge -q --ff-only "$SHA" ||
refuse fetch-failed "the stack's user could not fast-forward $CLONE's refactor to $SHA"
step copy "${AS_USER[@]}" "$CLONE/scripts/stack-copy.sh" copy --bundle "$BUNDLE" "$SHA" --copies "$COPIES_DIR" ||
refuse copy-failed "the stack's user could not make copy $SHA from $BUNDLE"
# A1
local head
head="$(git -C "$REPO" rev-parse HEAD 2>/dev/null)"
[ "$head" = "$SHA" ] || refuse head-mismatch "this checkout, $REPO, is at ${head:-unknown}, not $SHA"
[ -x "$COPIES_DIR/$SHA/scripts/mosaic" ] || refuse copy-missing "no $COPIES_DIR/$SHA/scripts/mosaic"
hostcheck
read_old
local -a migrate=()
[ "$MIGRATE" = 0 ] || migrate=(--migrate)
plan activate "$SHA" "$BUSINESS" "${migrate[@]}"
rehearse
run_steps "$SHA" activate "$SHA" "$BUSINESS" "${migrate[@]}"
finish 0 ok "upgrade ok: $SHA is live for $BUSINESS"
}
# The rehearsal against the log's newest backup, before anything stops. Its output goes to the
# terminal; root keeps only the BACKUP_STORE line, so the go and the log show which store it used.
rehearse() {
local cap rc bs
cap="$(mktemp "$RUN_DIR/rehearse-$INSTANCE.XXXXXX")" || refuse rehearsal-failed "cannot make a capture file in $RUN_DIR"
local -a cmd=("${AS_USER[@]}" "$SR" rehearse "$SHA" "$BUSINESS" --copies "$COPIES_DIR" --backup last --port "$R_PORT" --timeout "$TIMEOUT")
"${cmd[@]}" 9>&- 2>&1 | tee "$cap" 9>&-
rc="${PIPESTATUS[0]}"
logl "rehearse rc=$rc argv:$(printf ' %q' "${cmd[@]}")"
bs="$(grep -E '^BACKUP_STORE [0-9]{8}T[0-9]{6}Z-[0-9a-f]{12}(-[1-9])? schema [0-9a-z]{1,16}$' "$cap" | tail -n 1)"
rm -f "$cap"
logl "rehearse-store rc=0 ${bs:-BACKUP_STORE none}"
case "$rc" in
0) [ "$NO_REHEARSAL" = 0 ] || refuse rehearsal-required "the log records a backup and the rehearsal ran; --no-rehearsal is only for a stack with none" ;;
3) [ "$NO_REHEARSAL" = 1 ] || refuse no-backup "the log records no backup to rehearse against; only a go that names --no-rehearsal goes on"
logl "rehearsal-skipped rc=0 the log records no backup and the go names --no-rehearsal" ;;
*) refuse rehearsal-failed "the rehearsal exited $rc" ;;
esac
}
# r6 rollback: to the log's previous copy, which the stack's user checks against --to. No HEAD
# check: the target is an older copy than this checkout, and no rehearsal.
do_rollback() {
read_args
[ -x "$COPIES_DIR/$TO/scripts/mosaic" ] || refuse copy-missing "no $COPIES_DIR/$TO/scripts/mosaic"
hostcheck
read_old
local -a restore=()
[ -z "$RESTORE" ] || restore=(--restore "$RESTORE")
[ -z "$EVENTS" ] || restore+=(--events "$EVENTS")
plan rollback "$BUSINESS" --to "$TO" "${restore[@]}"
run_steps "$TO" rollback "$BUSINESS" --to "$TO" "${restore[@]}"
finish 0 ok "rollback ok: $TO is live for $BUSINESS"
}
case "$OP" in
upgrade) do_upgrade ;;
rollback) do_rollback ;;
esac
+30
View File
@@ -0,0 +1,30 @@
#!/bin/bash
# Row 234 (#1740, #436a row a), design r6 E3: the installer key's forced command.
#
# Installed as /usr/local/sbin/mosaic-keys, root, 0755, and named by mosaic-installer's own line in
# /etc/ssh/mosaic-keys/mosaic-installer:
# restrict,command="/usr/local/sbin/mosaic-keys",from="<from>" ssh-ed25519 <base64> mosaic-installer-<tag>
#
# It matches SSH_ORIGINAL_COMMAND against three fixed patterns and hands the words to
# mosaic-keys-write through sudo. Nothing else runs. sudo's env_reset drops SSH_CONNECTION, so the
# connection and the request go to syslog here, before sudo. The writer checks everything again.
set -u
export PATH=/usr/bin:/bin LC_ALL=C
request=${SSH_ORIGINAL_COMMAND-}
/usr/bin/logger -t mosaic-keys -- "connection=${SSH_CONNECTION-} request=${request:0:1024}"
user='[a-z_][a-z0-9_-]{0,31}'
tag='mosaic-[0-9a-f]{32}'
key='(ssh-ed25519|ecdsa-sha2-nistp256|ssh-rsa) [A-Za-z0-9+/]+={0,2}'
list_re="^list $user\$"
add_re="^add $user $key $tag\$"
remove_re="^remove $user $tag\$"
if [[ $request =~ $list_re || $request =~ $add_re || $request =~ $remove_re ]]; then
# The patterns hold no space, glob or quote characters, so a split on spaces gives the words back.
read -r -a words <<<"$request"
exec /usr/bin/sudo -n /usr/local/sbin/mosaic-keys-write "${words[@]}"
fi
echo 'mosaic-keys: refused' >&2
exit 1
+285
View File
@@ -0,0 +1,285 @@
#!/usr/bin/python3 -I
# Row 234 (#1740, #436a row a), design r6 E3: the root half of host enrollment.
#
# Installed as /usr/local/sbin/mosaic-keys-write, root, 0755. mosaic-keys (the installer key's forced
# command) runs it through `sudo -n` with one request:
#
# list <user> each line of /etc/ssh/mosaic-keys/<user> with a mosaic- tag
# add <user> <type> <base64> <tag> appends from="<from>",restrict <type> <base64> <tag>, unless the tag exists
# remove <user> <tag> drops the line with that exact tag; a missing tag changes nothing
#
# It checks the request again, whatever mosaic-keys did. Host config is root-owned and written by ops:
# /etc/mosaic-keys/users (the users it may touch), /etc/mosaic-keys/privileged (users with sudo that
# Jason approved by name) and /etc/mosaic-keys/from (the from= value). Standard library only. Exit 0
# on success, 1 on any refusal, with the reason on stderr and in syslog.
import base64
import fcntl
import grp
import hashlib
import os
import pwd
import re
import socket
import stat
import subprocess
import sys
import syslog
KEY_DIR = '/etc/ssh/mosaic-keys'
CONF_DIR = '/etc/mosaic-keys'
USER = re.compile(r'[a-z_][a-z0-9_-]{0,31}')
TAG = re.compile(r'mosaic-[0-9a-f]{32}')
KEY_TYPE = re.compile(r'ssh-ed25519|ecdsa-sha2-nistp256|ssh-rsa')
BASE64 = re.compile(r'[A-Za-z0-9+/]+={0,2}')
FROM = re.compile(r'[0-9A-Za-z.:/*?!_-]+(,[0-9A-Za-z.:/*?!_-]+)*')
# In the code, not in a file: no request may name these, whatever users says.
RESERVED_USERS = ('root', 'localadmin', 'mosaic-installer')
MIN_UID = 1000
# Each is root by another route. disk reads and writes raw block devices, libvirt and incus-admin start
# guests with host mounts (r6 N5). privileged lifts this check and nothing else.
REFUSED_GROUPS = ('sudo', 'admin', 'wheel', 'docker', 'lxd', 'disk', 'libvirt', 'incus-admin')
LOCK = '.lock'
class Refused(Exception):
pass
def _passwd(user):
try:
return pwd.getpwnam(user).pw_uid
except KeyError:
return None
def _groups(user):
entry = pwd.getpwnam(user)
names = set()
for gid in os.getgrouplist(user, entry.pw_gid):
try:
names.add(grp.getgrgid(gid).gr_name)
except KeyError:
pass
return names
def _sudo(user):
try:
out = subprocess.run(
['/usr/bin/sudo', '-l', '-U', user],
env={'LC_ALL': 'C', 'PATH': '/usr/bin:/bin'},
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
timeout=10,
)
except subprocess.TimeoutExpired:
# No answer is not the expected line, so the caller refuses user-sudo and logs it (duckworth-0081 N2).
return ''
return out.stdout.decode('utf-8', 'replace')
def _log(message):
syslog.openlog('mosaic-keys-write', 0, syslog.LOG_AUTH)
syslog.syslog(syslog.LOG_INFO, message)
class Host:
# What the writer reads from the machine. Tests pass their own dirs, owner and lookups.
def __init__(self, key_dir=KEY_DIR, conf_dir=CONF_DIR, owner=0, passwd=_passwd, groups=_groups,
sudo=_sudo, hostname=None, log=_log):
self.key_dir = key_dir
self.conf_dir = conf_dir
self.owner = owner
self.passwd = passwd
self.groups = groups
self.sudo = sudo
self.hostname = hostname if hostname is not None else socket.gethostname().split('.')[0]
self.log = log
def fingerprint(blob64):
digest = hashlib.sha256(base64.b64decode(blob64, validate=True)).digest()
return 'SHA256:' + base64.b64encode(digest).decode().rstrip('=')
def parse(argv):
# The request's shape and every pattern, before any name reaches a path.
if not argv:
raise Refused('request')
verb, args = argv[0], argv[1:]
shapes = {'list': 1, 'add': 4, 'remove': 2}
if verb not in shapes or len(args) != shapes[verb]:
raise Refused('request')
if not USER.fullmatch(args[0]):
raise Refused('user-pattern')
req = {'verb': verb, 'user': args[0]}
if verb == 'add':
if not KEY_TYPE.fullmatch(args[1]) or not BASE64.fullmatch(args[2]):
raise Refused('key-format')
try:
req['fingerprint'] = fingerprint(args[2])
except ValueError:
raise Refused('key-format')
req['type'], req['base64'] = args[1], args[2]
if verb in ('add', 'remove'):
if not TAG.fullmatch(args[-1]):
raise Refused('tag-pattern')
req['tag'] = args[-1]
return req
def _owned(st, host, kind):
if st.st_uid != host.owner or st.st_mode & 0o022 or not kind(st.st_mode):
raise Refused('not-root-owned')
def read_conf(host, name):
# A root-owned regular file, not group or world writable, opened without following a symlink.
try:
fd = os.open(os.path.join(host.conf_dir, name), os.O_RDONLY | os.O_NOFOLLOW)
except OSError:
raise Refused('config-' + name)
try:
_owned(os.fstat(fd), host, stat.S_ISREG)
with os.fdopen(os.dup(fd), 'r', encoding='utf-8') as f:
text = f.read()
finally:
os.close(fd)
return [line.strip() for line in text.splitlines() if line.strip() and not line.strip().startswith('#')]
def check_user(req, host):
user = req['user']
users = read_conf(host, 'users')
privileged = read_conf(host, 'privileged')
if user in RESERVED_USERS or user not in users:
raise Refused('user-not-listed')
uid = host.passwd(user)
if uid is None:
raise Refused('user-unknown')
if uid == 0 or uid < MIN_UID:
raise Refused('user-system')
if user in privileged:
return
if set(REFUSED_GROUPS) & set(host.groups(user)):
raise Refused('user-group')
expected = 'User %s is not allowed to run sudo on %s.' % (user, host.hostname)
out = host.sudo(user)
if out not in (expected, expected + '\n'):
raise Refused('user-sudo')
def read_from(host):
lines = read_conf(host, 'from')
if len(lines) != 1 or not FROM.fullmatch(lines[0]):
raise Refused('config-from')
return lines[0]
def tag_of(line):
parts = line.split()
return parts[-1] if parts and TAG.fullmatch(parts[-1]) else None
def read_lines(dir_fd, user, host):
# ENOENT means an empty list: a first add finds no file (r6 N6).
try:
fd = os.open(user, os.O_RDONLY | os.O_NOFOLLOW, dir_fd=dir_fd)
except FileNotFoundError:
return []
except OSError:
raise Refused('user-file')
try:
_owned(os.fstat(fd), host, stat.S_ISREG)
with os.fdopen(os.dup(fd), 'r', encoding='utf-8') as f:
return f.read().splitlines()
finally:
os.close(fd)
def write_lines(dir_fd, user, lines, host):
tmp = '.' + user + '.tmp'
try:
st = os.stat(tmp, dir_fd=dir_fd, follow_symlinks=False)
except FileNotFoundError:
st = None
if st is not None:
# Under the flock a leftover tmp can only come from a writer that crashed.
if not stat.S_ISREG(st.st_mode) or st.st_uid != host.owner:
raise Refused('tmp-file')
os.unlink(tmp, dir_fd=dir_fd)
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o644, dir_fd=dir_fd)
try:
os.fchmod(fd, 0o644)
os.write(fd, ''.join(line + '\n' for line in lines).encode('utf-8'))
os.fsync(fd)
finally:
os.close(fd)
os.rename(tmp, user, src_dir_fd=dir_fd, dst_dir_fd=dir_fd)
os.fsync(dir_fd)
def run(req, host, out):
check_user(req, host)
line_from = read_from(host) if req['verb'] == 'add' else None
try:
dir_fd = os.open(host.key_dir, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
except OSError:
raise Refused('key-dir')
try:
_owned(os.fstat(dir_fd), host, stat.S_ISDIR)
lock = os.open(LOCK, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600, dir_fd=dir_fd)
try:
fcntl.flock(lock, fcntl.LOCK_EX)
user = req['user']
lines = read_lines(dir_fd, user, host)
if req['verb'] == 'list':
for line in lines:
if tag_of(line):
out.write(line + '\n')
return 'listed'
if req['verb'] == 'add':
if any(tag_of(line) == req['tag'] for line in lines):
return 'present'
entry = 'from="%s",restrict %s %s %s' % (line_from, req['type'], req['base64'], req['tag'])
write_lines(dir_fd, user, lines + [entry], host)
return 'added'
kept = [line for line in lines if tag_of(line) != req['tag']]
if len(kept) == len(lines):
return 'absent'
write_lines(dir_fd, user, kept, host)
return 'removed'
finally:
os.close(lock)
finally:
os.close(dir_fd)
def main(argv, host, out=sys.stdout, err=sys.stderr):
req = None
try:
req = parse(argv)
result = run(req, host, out)
code = 0
except Refused as e:
result = 'refused ' + str(e)
code = 1
except OSError as e:
result = 'refused os-' + (e.__class__.__name__)
code = 1
fields = ['result=' + result]
if req:
fields.append('user=' + req['user'])
if 'tag' in req:
fields.append('tag=' + req['tag'])
if 'fingerprint' in req:
fields.append('key=' + req['fingerprint'])
host.log(' '.join(fields))
if code:
err.write('mosaic-keys-write: ' + result + '\n')
return code
if __name__ == '__main__':
sys.exit(main(sys.argv[1:], Host()))
+3
View File
@@ -0,0 +1,3 @@
# Row 234 (#1740), design r6 E3. Installed as /etc/ssh/sshd_config.d/<nn>-mosaic-keys.conf, root, 0644.
# Keeps both paths the hosts use today and adds the root-owned per-user files mosaic-keys-write keeps.
AuthorizedKeysFile .ssh/authorized_keys .ssh/authorized_keys2 /etc/ssh/mosaic-keys/%u
+2
View File
@@ -0,0 +1,2 @@
# Row 234 (#1740), design r6 E3. Installed as /etc/sudoers.d/mosaic-keys, root, 0440.
mosaic-installer ALL=(root) NOPASSWD: /usr/local/sbin/mosaic-keys-write