Compare commits

..
Author SHA1 Message Date
jason.woltje ed41933168 row 235 (#1741): machine grant row b, raise binding, executor, reconcile, revoke and sweep, with N10 (Gizmoduck, round 1)
Steps 1-6 squashed onto 4ced5e63d (sage-1812, sage-1831), then moved onto 62ddc96cc after row 213
landed (sage-1922). broker.mjs keeps 213's two hunks; packages/cli/README.md is row 228's blob plus
the row 235 lines. Creative roles get no host grant: #hostDefinition refuses host-creative-role at the
raise and the step 1 recheck, and #hostAccess refuses it at host.run, keeping the record (mos-1163,
sage-1908). BUILD.md lists the drift and the tests.
2026-10-21 05:15:33 -05:00
18 changed files with 2695 additions and 242 deletions
-8
View File
@@ -151,11 +151,3 @@ every run behind my busy check and Sage's, chained with `&&`.
Summed: 818 node tests passed with 0 failed, and 429 selftests passed with 0 failed. The compose network
was removed afterwards, and the sweep found no process left in the worktree, the output directory or the
TMPDIR. This file's results are a doc-only commit on top of the build, so nothing that ran has changed.
## Correction to deviation 6 (follow-up, 2026-10-21)
Deviation 6 says the node F9 runs is root's own. At 37171f65b the script didn't check that. F9 ran
`--node` as root before F10's path check, so the header's "root itself only reads" was false
(Duckworth's N1, `agents/duckworth/work/row195/REVIEW-m1.md` at 6afcee2a5). A host installed as designed
was safe, because `case2-unit.sh` refused a non-root-owned `--node`. Row 245 carries the fix and its
tests: `agents/darkwing/work/queue-245/BUILD.md`.
-91
View File
@@ -1,91 +0,0 @@
# Row 245 build: Duckworth's N1-N3 on `scripts/case1-accept.sh` (row 195 follow-up)
Duckworth approved row 195's manifest round 1 (duckworth-0088, `agents/duckworth/work/row195/REVIEW-m1.md`
at 6afcee2a5) with three notes that don't block. Row 195's code landed as b65014647, and the notes are in
`docs/plans/DEFERRED.md` (d6cff0cdd). Row 245 carries them: brief `docs/plans/2026-10-20_hub-own-uid.md` §
"Row 195 follow-up" at 62ddc96cc, owner Darkwing, reviewer Duckworth, gate owner Sage (sage-1923).
The branch is `darkwing/row245`, on 62ddc96cc. Its first commit is a cherry-pick of `darkwing/row195-n1`
1e3c4767c, which was written on 37171f65b and never run. The script, test and TOOLS.md blobs are the same
as in 1e3c4767c, and none of those paths changed between 37171f65b and 62ddc96cc.
## What changes
N1. F9 ran `$ROOT$NODE` as root (`case1-accept.sh:298` at 37171f65b) before F10 checked that `--node` is
the pinned path. Duckworth offered two fixes: F9's parse through `AS_USER`, or F10's path check first. I
took the path check, plus one more:
- F9 now runs node only when `--node` equals `/opt/mosaic-stack/node-<--node-pin>/bin/node`.
- Then `not_root_only` walks from that file up to `/`. Each entry must be the running user's (root's on the
host) with no group or other write, the rule `case2-unit.sh` `root_only` applied when it rendered the
units. A symlink fails it, because its mode is 777. The first entry that breaks the rule goes in the
evidence.
I didn't run the parse through `AS_USER`. That would hand a check about a root file to the stack's user,
and a process running as `mosaic` shares that node's uid. Whether it could change what node prints then
turns on the host's ptrace setting, and the root-only check doesn't depend on that. Sage agreed in
sage-1877. Rechecking ownership costs one loop and doesn't depend on
how the host was installed.
N2. The header (:9-15) and the `docs/TOOLS.md` entry now list what root runs: `systemctl show` and
`is-enabled` (F1, P11), `curl` on the loopback healthz (F2), `sudo -l` (P1, P3), `sudo -u jarvis bwrap
--version` and `hostcheck.sh --check` (P9), and node for F9 under the rule above.
N3. F6 matched `--claude` by prefix, so `/opt/mosaic-stack/claude-<pin>/../claude-2.0.9/...` passed the
path check. F6 now fails on any `..` segment, before it runs `--version`. F10 compares for equality, so
it needed no change.
Row 195's BUILD.md gets a correction entry for deviation 6. Row 195's DESIGN.md is unchanged. Its "either
works" wording at `DESIGN.md:336-338` is superseded by gosalyn-0087 (root writes `dev.args` at W10), and
it stays in place: an approved design isn't edited (sage-1877).
## Tests
`packages/cli/tests/case1-accept.test.mjs`:
- The node stub touches `node-as-root` in the state directory when something runs it outside the fake
`sudo` (which sets HOME to the stack's home). `host()` sets the node file's directories and the scratch
root to 0755, so the umask can't fail F9.
- "every check passing" also asserts that root ran the pinned node.
- New: "F9 won't run --node as root off the pin path or with a writable directory above it". Another node
path fails F9 and F10, with no root run. A group-writable `bin/` and a symlinked node each fail F9
alone, with the bad path in the evidence and no root run.
- The F6 test gains a `..` case: a path under `claude-<pin>/` by prefix that names 2.0.9's binary, which
prints the pin. F6 alone fails, with "has a .. segment".
That makes 23 tests. All 23 pass on Sage's slot (results below).
## Mutants
The twelve from row 195 still apply, unchanged. These four are new, each one exact replacement:
| # | Mutant | Expected killer |
|---|---|---|
| 13 | F9 runs any `--node` (the pin check becomes `true`) | F9 won't run (another node) |
| 14 | F9 skips the ownership walk (`bad=""`) | F9 won't run (writable `bin/`, symlink) |
| 15 | `not_root_only` checks only the file, not the directories above it | F9 won't run (writable `bin/`) |
| 16 | F6 accepts a `..` segment | F6 stub, path and `..` |
The brief's gate asks for a mutant for each of N1 and N3. Mutants 13-15 are N1's, and 16 is N3's.
## Slot plan
Same shape as row 195's steps, on this row's tip: the single test file, then the sixteen mutants, then
the gate (the cli suite and every `scripts/test-*.sh`), each behind the busy checks and chained with
`&&`. The scripts are in `/mnt/storage/scratch/darkwing-245-out/`.
## Slot results (grant sage-1961, 2026-10-21)
All three steps ran on 0590a29fc with node v26.8.1, launched from `/`. Both busy checks were CLEAR
before each step.
- Singles, 11:27:46-11:27:59Z: `case1-accept.test.mjs` passed 23, failed 0.
- Mutants, 11:28:10-11:31:20Z: all 16 killed, each with exit 1. The runner restored the script, and
its sha256 875bb5b7 matches the committed blob. Rows 1-12 died as in row 195. 13, 14 and 15 died on
"F9 won't run --node as root off the pin path or with a writable directory above it". 16 died on
"F6 fails on a stub that prints nothing, on a path outside claude-<pin> and on a .. segment".
- Gate, 11:31:20-11:42:51Z: the cli suite passed 664, failed 0, skipped 0. Every `scripts/test-*.sh`
exited 0: auth 15, conductor 17, config 31, discord 78, extension-package 18, foundation 44,
land-work 86, queue 28 (its node part 156/0), release 14, task 98. That's 429 selftests, none failed.
- Afterwards no process had its cwd in the worktree, the out dir or TMPDIR, and the
`darkwing-r245_default` network was gone.
Logs: `/mnt/storage/scratch/darkwing-245-out/` (`step1.log`, `mut.out`, `mut/m1-16.log`, `gate.out`).
+598
View File
@@ -0,0 +1,598 @@
# Row 235 build: machine grant row b (#436, Gitea #1741)
Brief: `docs/plans/2026-10-20_host-credentials.md`, "Machine grant row b: raise binding, executor,
reconcile and revoke". Design r6 f9039682e. Built on row 234's landing c771a36f4 (sage-1775), branch
gizmoduck/row235-b. Line numbers below were read on b0320d395, whose src files are byte-identical to the landing's.
## Squash onto 4ced5e63d (sage-1812, sage-1831)
Steps 1-6 were six local commits on c771a36f4 (gizmoduck/row235-b, tip f9fff4300). Sage named
4ced5e63d as the base, so this branch, gizmoduck/row235-c, holds one commit on it: the diff
c771a36f4..f9fff4300 applied with `git apply --index`. N10 is in that commit. N12 needs no change,
because row b doesn't touch schema.sql.
Rows 240, 228 and 238 landed between c771a36f4 and 4ced5e63d and changed 18 paths. One of them is
also mine:
- `packages/cli/README.md`. Row 228 (86249ac55) changed the session credential paragraphs at about
lines 432 and 706. My hunk adds three lines on `hosts revoke` and `hosts sweep` at about line 1052.
The two don't overlap, so the blob here is row 228's blob plus my three lines, with no landed line
reverted. cli tests/host.test.mjs:855 reads this README, and run 1's cli suite covers it.
The other 12 paths are byte-identical to f9fff4300. The line numbers in the step notes below were read
on the old base.
## Run 1 (sage-1831, sage-1833, sage-1838)
Run 1 on 4d4bb4af6, 07:45-07:54Z, failed: 1351 tests, 1285 pass, 66 fail. The 66 are 33 tests counted
twice, once in the files run and once in the bus suite. The cli suite, the cli hosts test, seat
sandbox.test and bus project-runtime.test passed. Two defects, both mine:
- grant-executor.test.mjs, 32 tests. setup() never made `dir/data`, and `new Store` lstats its root
(store.mjs:137), so every test stopped at setup with ENOENT. setup() now makes it, mode 0700.
- credential-claim.test.mjs:76 (X4), 1 test. A register after a revoke left the key in `#revoked`, so
status() said `revoked` for a new grant's reference that has no token yet. register now clears
`#revoked`, as unregister does. `#revoked` feeds only status(), and issue clears it as well.
Both fixes are amended into the one commit. The rerun covers the five bus files, the bus suite, and
cli credentials.test.mjs and verbs.test.mjs, which read credential state strings.
## Run 1 rerun (sage-1842, sage-1843)
The rerun on ac7c45975, 08:06-08:07Z, failed: 701 tests, 695 pass, 6 fail, which are 3 grant-executor
tests counted in both the files run and the bus suite. All three were test defects that run 1's setup
failure had hidden. No src file changed for them:
- G10. The two fixture decisions were built after `before`, and grantDecision (host-fixtures.mjs)
INSERTs one events row each, so the count read 13 for 11. The test now builds them first, asserts
they added exactly 2 rows, and then pins the executor's 0 against that count.
- G27. setup's t.after removes the scratch dir before a hook the test adds later runs, so the test's
chmod hook hit ENOENT after the body had passed. The body now restores the mode in a try/finally.
- G22. The slow run's promise had no handler until `refusesWith`, four lines on. The revoke rejected it
first, and node failed the test on the unhandled rejection. The handler now goes on at creation.
The fix is one more amend of the single commit. The rerun is grant-executor.test.mjs whole plus the bus
suite.
Rerun 1c on 5df4b52c5, 08:15Z, was green: 623 of 623, grant-executor 34 of 34 and the bus suite 589 of 589.
## Mutants B1-B37 (sage-1894, sage-1899, sage-1904)
Three chunks ran on 5df4b52c5, 09:22-09:37Z. Restores matched HEAD after each. 33 were killed and four
survived. Two of the misses were mine, in the file lists:
- B2 and B23 listed only grant-executor, and that file never makes the raise either mutant changes.
grant-enroll.test.mjs:227 (A2, A4) resolves a plain `credential.mint` yes and a host grant no, and
asserts no grant events for either. mutants.py now lists grant-enroll and grant-executor for both.
B2 then ran alone and A2 killed it. B23 runs again with this round's tests.
The other two survivors needed tests, and Sage ruled on all four (sage-1904):
- B16 drops the abort check after `verify`. The check after `step('commit')` still ends the grant
revoked, so G23 X5's asserts held. The run then logs a commit step for a grant that never committed.
G23 X5 now asserts that its only `grant.step` is `install`.
- B17 adds `await null` after the commit check. Sage accepted it as equivalent. `await null` yields
only to microtasks. Every revoke starts in a macrotask of its own (a socket request, or a test's
call), so none can land in that gap. There is no test for it.
- B27 makes issue's commit callback async. issue() doesn't await it, so the executor returns `applied`
before the INSERT runs. The two differ only when the commit transaction throws, and a real store can
throw there with a full disk, an I/O error or a busy timeout. Under the mutant that throw becomes an
unhandled rejection after the key file is written, and the catch's undo never runs. The new G8 test
makes the store's `run` throw SQLITE_FULL once on the host_grants INSERT. It asserts host-failed, no
record, key file, reference or line, and the steps install, commit, failed. The store object comes
from the test's setup, so src gains no seam.
These two test changes went in as a new commit on gizmoduck/row235-d (8b13d0ca0). That branch then
moved onto row 213's landing as gizmoduck/row235-e, below. B16, B23 and B27 run there, after the touched
test files run whole.
## Rebase onto 62ddc96cc (sage-1922)
Row 213 landed as 361ba2387, and refactor's tip is 62ddc96cc, with only docs past it. gizmoduck/row235-e
starts at 62ddc96cc and cherry-picks 8b13d0ca0's one commit. The patch-id is unchanged (c062b73fa9f1).
row235-c and row235-d stay as they were.
Between 4ced5e63d and 62ddc96cc, 361ba2387 is the only commit that touches a file of mine. The one
drifted path is `packages/bus/src/broker.mjs`:
- 213 adds `CREATIVE_DEFINITIONS` to the vocabulary import at :6, and refuses `decision.raise` from a
creative role with `decision-not-allowed` at :2176. Both hunks stay as landed.
- My 31 removed broker lines read the same before and after the cherry-pick, so no landed line is
reverted.
`packages/cli/README.md` isn't in 361ba2387. Every other path of mine is byte-identical to 8b13d0ca0.
Line numbers in the section below were read on the row235-e working tree; earlier sections keep theirs.
## Creative roles get no host grant (mos-1163, sage-1905, sage-1908, sage-1916)
Brief blob b16e236188f6 (4364dddb0, rev 2784) requires two refusals: the writer, at the raise and at the
step 1 recheck, and host.run at spend time, with the record kept. Webby's N2 on row 213 r4 asks whether
a creative role can spend a decision another role raised.
The spend answer. Mos confirmed it read-only (mos-1164, sage-1916) on 8b13d0ca0 and 01e1fe512; the
row235-e line follows each:
- Only the chief raises a host grant. `#raiseGrant` refuses `grant-chief-only` for any other
definition: :1232 on 8b13d0ca0, :870 on 01e1fe512, :1238 here. The hosts verbs check the same at
:1307 on 8b13d0ca0, :896 on 01e1fe512, :1313 here. `chief-of-staff` isn't in `CREATIVE_DEFINITIONS`.
- No agent spends one. `#consumeGrant` (:697) is called for a host grant only from `#runHostGrant`
(:902) and from the restart reconcile (:1106), which ends it interrupted. `#runHostGrant` starts from
the resolve hook (:2881) only when `#approvedGrant` returns a grant, and that needs a close by the
business's human, via cli, with the raise's approval choice (:691). An agent's resolve records via
`broker`, so it starts nothing (grant-enroll A3). The public `runHostGrant` (:891) is a trusted API
that no verb reaches.
So a creative role can't raise, approve or spend a host grant. The gap was a different one: the chief
could raise a grant whose `agent` is a creative role, and the human could approve it. The only check on
that agent was `#hostDefinition`, which refuses `host-not-declared` (:864) unless the definition's
`credentials` names `host`. Row 213's `checkCreative` (business/src/role.mjs:143) limits a creative
role's tools, authority and messaging, not its `credentials`, so a creative role file naming `host`
passed both the raise and the step 1 recheck.
As built:
- Writer. `#hostDefinition` refuses `host-creative-role` (:862) before the `host-not-declared` check.
The raise binding (:1266) and the step 1 recheck (:879) both call it, so a grant raised before its
agent's instance moved to a creative definition ends at step 1, spent, with no remote step.
- Spend. `#hostAccess` refuses `host-creative-role` (:714) when the agent's current definition is
creative, before it reads the record. Both callers go through it: host.run (:2543) and the host.sudo
raise (:2778). The record stays, because revoke still owns the cleanup (sage-1908). This goes past
the brief's writer line in the same direction; Sage tells Mos.
Tests:
- grant-host.test.mjs:115. A `muse` role on a `research` definition that declares host. The raise
refuses `host-creative-role` and adds no decision.
- grant-executor.test.mjs:947. The restart moves `ops` to a creative definition that declares host,
then a fixture grant runs. It ends `host-creative-role` at step 1, the decision is spent, and there is
no remote request and no record. The fixture comes after the restart, because the restart's reconcile
ends any resolved, unspent host grant as interrupted.
- grant-executor.test.mjs:958. An applied grant, then a restart with `ops` creative, then host.run
refuses `host-creative-role`. No remote request is added, sudo.log has no `systemctl`, and the record
is still there.
The broker clones `businesses` when it's built, so a definition change needs a new Broker. The test's
`restart()` gains a `business` parameter, default unchanged, for that.
Mutants: B38 removes the `#hostDefinition` line, run against grant-host and grant-executor. B39 removes
the `#hostAccess` line, run against grant-executor.
## Run 2 (sage-1928)
Run 2 on 534f2254a, 10:12:52-10:14:51Z, was green. grant-host.test.mjs 9 of 9, grant-executor.test.mjs
37 of 37 and grant-enroll.test.mjs 24 of 24, each run whole. grant-enroll is unchanged, but it drives the
broker, and broker.mjs changed. Then the five mutants, all killed, with the restore matching HEAD:
| Id | Killed by |
|---|---|
| B16 | G23 (X5), the revoke as verify returns |
| B23 | A2, A4 |
| B27 | G8, the store fault in the commit transaction |
| B38 | both mos-1163 tests |
| B39 | the sage-1908 test |
## Mutant results, B1-B39
| Run | Head | Killed | Survived |
|---|---|---|---|
| B1-B13, 09:22Z | 5df4b52c5 | 12 | B2 (file list) |
| B2 alone, B14-B37, 09:28-09:37Z | 5df4b52c5 | 21 | B16, B17, B23, B27 |
| B16 B23 B27 B38 B39, 10:13Z | 534f2254a | 5 | none |
38 of 39 are killed. B17 is equivalent (sage-1904), for the macrotask reason in the B1-B37 section.
B1-B37 ran on 5df4b52c5, before the move onto row 213's landing, and weren't rerun on 534f2254a.
`--check` matches all 39 anchors once on 534f2254a.
## Carry list
- N10 (duckworth-0084, row 234 r1): `cli/src/hosts.mjs:30-31` reads `shape[verb]` off a plain object,
so `mosaic hosts constructor` passes usage and the bus refuses it with exit 2 instead of 4. Row b adds
`hosts revoke` and `hosts sweep` to the same table (design r6 :562, :680), so the fix goes in with
them: `Object.hasOwn(shape, verb)` and a usage test for an `Object.prototype` name (sage-1726).
- Untimed tables (sage-1745, row 234 r3): stack-release T46 lists hosts and host_grants as untimed
because every write to them shares one store transaction with a `grant.*` events row. Row a writes only
hosts. Every host_grants INSERT, UPDATE and DELETE row b adds must do the same, and BUILD.md lists each
with its event line. A write without one is a stop to raise with Sage.
- N12 (duckworth-0086, row 234 r3, doc-only): row 234's round 3 list of other schema hits leaves out
`packages/cli/tests/stack-release-fixture.mjs:214` and `scripts/test-land-work.sh:254`. Both look up
one table by name and need no change. If row b changes the schema, its grep list names them.
## What row a already gives row b (b0320d395)
- `#approvedGrant`, `#consumeGrant(business, decision, method)` and `GRANT_EVIDENCE` (broker.mjs:667-690).
Row b calls `#consumeGrant` with `'host'`.
- `#openGrantDecisions(business, method)` and `reconcileGrants()` (broker.mjs:823-860), which today handle
`host-enroll` only. Row b adds the `host` method, the `grant.step` undo table (r6 X3) and the
revoke-marker reconcile (r6 section 6, N4).
- The resolve hook at broker.mjs:2444-2445 starts `#runEnrollment` for `host-enroll`. Row b adds the same
start for `host`, under the same human, via cli and choice checks.
- `host.run` already reads the store grant record and checks the Approve record (broker.mjs:691-703).
- The raise check `grant-chief-only` (broker.mjs:870, :896). `host-grant.mjs:55` refuses every method but
`host-enroll`, and row b opens it to `host`.
- `Credentials` has `#inflight` with the `host-busy` claim inside `runHost` (credentials.mjs:118, :355).
It has no `claim`, `abortClaim`, `register` or `unregister`. Row b adds those (r6 X4, X5).
## Plan, in build order
1. Raise binding (r6 R1-R4): the `grant` argument for `method: 'host'`, `host-not-declared`,
`host-not-ready`, the 210 D6 argv rules, `rotateBy`, the rendered block and the 4096 cap, and the raise
body hashes. Tests G1-G5, G19.
2. `Credentials.claim`, `release`, `abortClaim`, `register`, `unregister`, with the shared reference check.
3. The executor in `bus/src/host-grants.mjs`, steps 1-6 (r6 X2), with failure undo (X3) and the abort rule
(X5, C1). Tests G7-G10, G15, G23, G24.
4. Restart reconcile for `grant.step`, unspent `host` decisions, and the start sweep of `host.<id>` key
files with no record (r6 X3, N1). Tests G11, G27, G28.
5. `hosts.revoke` and `hosts.sweep` verbs, plus the CLI `revoke` and `sweep` with N10. Tests G12, G13,
G26, G27.
6. G22 end to end on one bus. Then the mutant list from r6 section 10 for row b, numbered B1 onward in
this file, with the upsert mutant and the abort-after-commit mutant included.
There is no schema step. If one turns out to be needed, I stop and ask Sage to re-brief first.
## Step 1 notes (raise binding, read on b0320d395)
Where the code goes:
- `#raiseGrant` (broker.mjs:866-885) runs the checks and keyscan for `host-enroll` today, and every raise
with `grant` reaches it (:1738-1739). Row b branches on `a.grant?.method`. `host` takes no keyscan, so
its checks can all run inside the one raise transaction, as R4 asks.
- `enrollCheck` (host-grant.mjs:50-69) refuses every method but `host-enroll` (:55). Row b adds a sibling
`hostGrantCheck` for the R4 shape `{method, agent, host, commands, rotateBy}` and keeps `enrollCheck`
as it is, so the 234 tests don't move.
- The raise body is at broker.mjs:2270, :2345-2353 and :2391. `enroll` becomes a generic `grant`
binding. The question block for `host` follows the `host.sudo` block (:2337-2341): agent, host id,
address, user, sudo, each prefix JSON-quoted, rotate date, and new key or changed commands. The
4096 cap comes from `string(question)` (:2354).
- The argv rules come from `checkCommands` (business/src/hosts.mjs:67-87): no wildcard, no empty prefix,
no glob characters, a bare name or an absolute path, and `refusedCommand` for shells and runners. It
throws `refuse(...)` with a message, so the bus maps any throw to `invalid-request`, as `enrollCheck`
maps `checkAddress` (host-grant.mjs:57-61).
- `host-not-declared`: the agent's definition must list `{"service": "host"}`. business.mjs:126-129 reads
`definition.credentials` and filters out `HOST_SERVICE` (vocabulary.mjs:113). The bus needs the
definition's service list. Read how the running business exposes the definition before choosing.
- Both lookups are answered. `loadBusiness` returns `definitions`, each role file's validated document
(business/src/business.mjs:114-124, :159, returned at :267). `busBusiness` clones the whole business
(bus/src/business.mjs:7), so the broker has `b.definitions`. `#definitions` already reads it
(broker.mjs:1027-1029), and the comment there says the broker doesn't read `roles/` (sage-0590).
- `host-not-declared` checks `b.definitions[b.roles[agent].definition].credentials` for
`{service: 'host'}`. role.mjs:118 normalizes a host entry to exactly that.
- Deviation from R4: `definitionSha256` is sha256 over `canonicalJson` of that validated definition,
not over the file's bytes. The broker doesn't read `roles/`. A whitespace-only edit to the file no
longer voids an approval, but any change to a field the loader keeps still does. The bus sees a
definition change only after a business reload or restart, the same as every other role field.
This goes in the round 1 note.
Assumption, accepted for round 1 by Sage (sage-1782), for Duckworth to check in review: design R4 says `rotateBy` is "a date inside 210's rotation window", but 210
defines no window in code. It checks only that the value is a real calendar day (bus
credentials.mjs:16-20, issue at :245). The business load calls a past `rotateBy` a problem
(business/src/credentials.mjs:114). So the raise takes a real day strictly after today, UTC, and refuses
`invalid-request` otherwise. There is no upper bound, because no requirement states one and 210
checks none.
## Step 1 as built (not run yet; no slot)
- `hostGrantCheck(grant, today)` in host-grant.mjs takes exactly `method`, `agent`, `host`, `commands` and
`rotateBy`. `agent` must match ID_PATTERN and `host` HOST_ID. `commands` goes through `checkCommands`,
and any throw becomes `invalid-request`. `rotateBy` must pass 210's `day` (now exported from bus
credentials.mjs) and be later than the bus clock's UTC day.
- `#raiseGrant` branches on `a.grant?.method === 'host'` after the chief check and before `enrollCheck`.
So a host-enroll shape with `method: 'host'` still refuses `invalid-request`, and the 234 G1 list holds.
- `#hostGrantBinding` runs inside the raise transaction. It refuses `unknown-role` for an agent that
isn't a role instance (the code bindLaunch uses, broker.mjs:522). It refuses `host-not-declared`, and
`host-unknown` for a host with no record, as `#hostAccess` does. A record in `pending-install` refuses
`host-not-ready`. R4 names only `host-not-ready`. I picked `host-unknown` for a missing record so
"not enrolled" and "enrolled but not checked" stay apart.
- `commandsSha256` is sha256 over `JSON.stringify(commands)`. For arrays of strings this equals
canonicalJson, and step 3's executor recomputes it the same way.
- `#dispatch` takes `binding = {body, block}` in place of `enroll`. The enrollment block moved into
`enrollBinding` at module level with its text unchanged, so the 234 G4 test reads the same question.
- No new import in broker.mjs, so RUNNER_FILES doesn't change. host-grant.mjs now imports
business/src/util.mjs for canonicalJson. Nothing in the sandbox loads host-grant.mjs. Before in-review
I still run seat sandbox.test.mjs and bus project-runtime.test.mjs whole.
- The new-key line reads host_grants (a SELECT). Step 1 writes no host_grants row.
- Tests: bus/tests/grant-host.test.mjs, G1 (host), G2, G3, rotateBy, G19, R4/G5 body hashes, G4 block.
The G5 tester-thread case: a thread run is always THREAD_DEFINITION, never chief-of-staff, so
`grant-chief-only` refuses it first. The test covers the tester's own cap (`tester-refused`).
## Step 2 as built (not run yet)
- The constructor's per-reference checks moved into `#checkRef`, in the same order, and `register` calls
it (r6 X4). `register` reads no file. A key file that a failed revoke unlink left at the path stays
unused until the start sweep (step 4) removes it. `register` refuses `credential-reference` while the
entry holds a live token. An entry with no token, revoked or never issued, is replaced, so a new grant
after a revoke registers again.
- `unregister` drops an entry with no token and clears its revoked mark. A live token refuses.
- `claim` refuses `host-busy` when the slot is held, by a claim or a `host.run`, and returns the signal.
It needs no reference, because the executor claims at step 1 and registers at step 5.
`release(instance, service, signal)` frees the slot only for its own signal. I added the signal
argument so a late release can't free a later claim.
- `abortClaim` aborts whatever holds the slot and returns `{pending}`. An aborted claim keeps the slot
until the executor releases it in its `finally` (X2).
- Tests: bus/tests/credential-claim.test.mjs. The D5 `use(` allowlist is unchanged.
## Step 3 as built (not run yet)
Where the code goes. The remote steps are in host-grant.mjs (`grantKey`, `install`, `verify`, `remove`)
and the orchestration is `#runHostGrant` in broker.mjs, next to `#runEnrollment`. BUILD.md's plan said a
new `bus/src/host-grants.mjs`. I kept row 234's split instead: the broker holds every store write and
host-grant.mjs holds the network and key files. A new broker import would also change what the sandbox
must mount (RUNNER_FILES), and this way broker.mjs gains none.
The broker gets `hostKeys`, made by `grantKeys({credentials, storeRoot})` in host-grant.mjs. It wraps
only `claim`, `release`, `abortClaim`, `register`, `unregister`, `issue` and `revoke`, keyed by
(business, agent, hostId). The broker still never gets `use()`. runtime.mjs builds it and passes it in.
`hostReference` gives the store reference, and the runtime's start load uses the same function.
Every host_grants write in step 3, with its event (sage-1745):
| Write | Where | Event in the same transaction |
|---|---|---|
| INSERT, a new key | the `commit` callback that `issue` calls | `grant.applied` |
| UPDATE commands and decision, a commands change | step 5's transaction | `grant.applied` |
No other step 3 code writes host_grants.
Deviations from r6, for the round 1 note:
- `grant.applied` commits with the record, inside `commit`, not after `issue` at step 6. The untimed-table
rule needs an event in the record's transaction. `grant.step commit` can't be that event, because X2
writes it before the key file. So X3's "5 after commit" row has nothing to undo in the executor: once the
record commits, `grant.applied` has too. What's left in `issue` after `commit` is the memory fill, which
writes no file and no store row. The executor sets `committed` after the transaction returns and from
then ignores an abort (C1). The restart reconcile (step 4) keeps the commit row, because a crash can fall
between the key file write and `commit`.
- Event bodies name the decision as `grantDecision`, as row 234's enrollment events do. The consume record
is the only grant body with `decision`, so `#spent` counts it and nothing else.
- For a commands change, `grant.applied` has `fingerprint: null`. The executor holds no key on that path.
The record's `rotate_by` stays. X2 names only commands and the decision id.
- The keygen dir goes as soon as `grantKey` has read the key back into memory, not in step 5's `finally`.
- Step 4 runs `true` as the remote command as is, not through the sudo argv, since a user with no sudo
must pass it too.
- `hostGrant()` takes `installerUser`, `mosaic-installer` by default. Only the tests change it, because
the scratch sshd logs in only as the test's own user.
- A commands-change UPDATE that changes no row ends `host-changed`. Only a revoke deletes a live record,
and a revoke aborts the claim first, so this is a guard.
Step 1 codes, after the spend and in its transaction: `host-not-configured`, `unknown-role`,
`host-not-declared`, `decision-mismatch` (definition, registry or commands hash), `host-unknown`,
`host-not-ready`, `revoked` (a `grant.revoked` of the agent and host after the raise). The definition
check moved into `#hostDefinition`, which the raise binding uses too.
Tests: bus/tests/grant-executor.test.mjs, a scratch sshd on 127.0.0.1 with stub `list`, `add` and
`remove` that edit a second authorized-keys file. G7 (new key, and a commands change), G8 (install, verify,
a failed remove, step 5 before commit, a failed step 1 check), G9, G10, G15, G23 (a revoke during install
through `abortClaim`, a `grant.revoked` before start, and a held slot), G24's tag part. G23's held slot
uses `claim` in place of a running `host.run`. Both hold the same `#inflight` entry. The `revoke` verb
comes in step 5, so G23 here writes `grant.revoked` through the fixture writer and aborts through
`abortClaim`. Step 5 adds the verb's own path.
## Step 4 as built (not run yet)
The restart reconcile has three parts, in start order:
1. `sweepHostKeys` (host-grant.mjs), in runtime.mjs right before `Credentials` loads. In each loaded
business's agent store it unlinks every `host.<id>` file that has no `host_grants` record, and logs
one `hosts: {"event":"host-key-swept",...}` line per file to stderr. A store dir that isn't private
refuses `credential-file`, and so does the start. A name that isn't `host.` plus a valid host id
stays, so ssh-keygen's `.pub` stays too. A business the bus didn't load is left alone.
2. `reconcileGrants`, where row 234 already calls it, after `Credentials` loads and before the socket
opens. For a `host` grant it ends a spent decision that has no `grant.applied` and no `grant.failed`.
It also spends and ends an unspent one the human resolved yes. Each ends `grant.failed interrupted`,
with the tag and a notice. When the last step is `install`, or `commit` with `previous: null`, a line
may be left. For those, it claims the (agent, `host.<id>`) slot and queues the decision for part 3.
3. `reconcileHosts`, which runtime.mjs calls once `serve` returns, with no await, so a start doesn't
wait on the network. Each queued decision runs `remove <user> <tag>` through the installer, as the
step's `user`. Then it ends `grant.failed interrupted`, with `hostLineLeft` if the remove failed or
there's no host record, and releases the claim. The claim makes a new grant for the same agent and
host refuse `host-busy` while the remove runs. Otherwise its new line, with the same tag, could be
removed.
Deviations from r6's restart table, for the round 1 note:
- No record write on restart. Step 3 commits the record with `grant.applied` in one transaction. So a
decision with no `grant.applied` has no record. For a new key, the record undo has nothing to delete.
For a commands change, the "back to `previous`" undo has nothing to put back. What's left of the
`commit, new key` row is the key file, written before `commit`, and the start sweep unlinks it.
The `commit, commands change` row ends interrupted with nothing undone. G28's "a revoke before
`grant.applied`" can't happen, because they commit together. Step 5 tests G28 as a change, then a
revoke, then a restart.
- The local undo of row 234's enrollments stays after `Credentials` loads, where row 234 put it. That
undo touches only the installer key and the host record, and `Credentials` reads neither.
- The host grant part writes no `host_grants` row, so the untimed-table rule (sage-1745) adds nothing
here.
Fixture change: `hostGrantRecord` (tests/host-fixtures.mjs) writes `grant.applied` after a `grant.host`
spend, as the executor always does. Without it, a start over a fixture record ends that record's
decision `interrupted`. Row 234's M3 start test (host-run.test.mjs:602) does that start. No test counts
the fixture's grant events (checked by grep across the bus tests).
Tests in grant-executor.test.mjs, through a `restart` helper that follows runtime.mjs's order on the
same store:
- G11 after `install`: the line goes, the slot is held between the two parts, and the grant ends
interrupted. A second start adds nothing, and `runHostGrant` refuses `decision-consumed`.
- G11 after a new key's `commit` with the key file written: no key file, record, reference or line.
- G11 after a change's `commit`: the record, key and line are unchanged, and the host gets no request.
- G11 (B2) before step 1, spent and unspent: each ends interrupted with a notice, and the unspent one
is spent exactly once.
- A failed remote undo, and a missing host record: each ends with `hostLineLeft` and the sweep hint.
- A finished grant: the reconcile adds nothing.
- N1 (G27's sweep part): only files with no record in loaded businesses go, and a dir that isn't private
refuses.
The revoke-marker part of the reconcile (section 6, N4) comes with the revoke verb in step 5.
## Step 5 as built (not run yet)
Two new bus verbs, `hosts.revoke` and `hosts.sweep`, join `HOSTS_VERBS`. The CLI gets
`mosaic hosts revoke AGENT HOST --reason R` and `mosaic hosts sweep HOST`.
`hosts.revoke {agent, host, reason}` follows r6 section 6:
1. One transaction deletes the `host_grants` record and writes `grant.revoked` with a new `revoke` id
(a UUID), the computed tag, `hostLine: "pending"`, `granted`, `reason` and `by`. With no record it
still writes the marker, with `granted: false`, so a stale line can be named (N4). A host with no
record refuses `host-unknown` and writes nothing.
2. With no await between, `Credentials.revoke` drops the key from memory, aborts a `host.run` in flight
and unlinks the file. With no reference (`credential-unknown`), an executor holds only a claim, so
`abortClaim` stops it. Any other throw, such as an unlink that fails, comes back as `keyFile: "left"`.
The record is already gone, so the next start's sweep unlinks that file.
3. `remove <user> <tag>` through the installer, as the host record's user (the executor's user too).
Then `grant.revoke.closed` with the same `revoke` id and `hostLine: "removed"`, or `hostLineLeft`.
Callers (G26): the human, and the assistant of the session's business, checked with `#threaded`. The
tester can call it when `bus.tester` is exactly `true`, read again at the call. Any other agent gets
`revoke-refused`, a code new in this row. A tester session can't hold a business whose `bus.tester`
isn't `true`, since `bindTester` refuses `tester-off`, and the broker's businesses don't change while
it runs. So `tester-dev-only` can't be reached from a test. The check stays for N8, in case a later
row reloads businesses.
`hosts.sweep {host}` is the human's or the tester's; an agent gets `human-required`. It runs
`list <user>`, then removes each listed tag that this business recorded for the host in a `grant.step`
or `grant.revoked` and that no live grant holds. Another business's line and an unrecorded `mosaic-`
line stay (G24's sweep part). It writes `grant.host.swept {host, removed, left, by}`, where `left` is
each earlier left tag still listed and not removed. `#hostLinesLeft` now starts from the last
`grant.host.swept` and adds only later `hostLineLeft` bodies (F4).
The restart reconcile adds the marker part: `reconcileGrants` collects each `grant.revoked` with
`hostLine: "pending"`, a text `revoke` id and no `grant.revoke.closed` of that id. `reconcileHosts`
runs step 3 for each. A `grant.revoked` with no `revoke` id, as G23's fixtures write, isn't reopened.
`hosts.status` adds `revoking`, the tags of the open markers on the host.
Other changes:
- N10: the CLI checks the verb with `Object.hasOwn`, so `constructor`, `toString` or `__proto__` is a
usage error.
- `--reason` is required with `revoke` and refused with any other verb.
- The reason goes through the broker's `string(x, 500)`, as `credentials.revoke`'s does, so a reason of
only spaces is accepted there too.
- Correction to step 4: the first G11 test expected `list` then `remove`. `reconcileHosts` sends only
`remove`, so the test now expects that.
Residuals for the round 1 note:
- A new grant whose line goes in while a revoke's step 3 or a sweep runs can lose that line, if it has
the revoked tag. Revoke holds no claim during step 3. If the remove lands after the new grant's
verify, the grant is recorded but its line is gone, so its logins fail. I didn't add a claim, because
a revoke must not wait on a slot.
- The enrollment's installer tag is never in the sweep's set, so a `hostLineLeft` naming it stays
until the human removes it by hand.
Tests in grant-executor.test.mjs:
- G12: the human's revoke of a live grant, and the bodies of both markers. A restart adds nothing.
- G12 (N4): a revoke with no record removes the computed tag's line. Bad args refuse
`invalid-request` and an unknown host `host-unknown`.
- G13: a revoke during `install` aborts the executor, which ends `revoked`. A raise before a revoke
and started after it ends `revoked` at step 1.
- G26: who may revoke and who may sweep.
- G27: a restart after step 1 (the sweep takes the key file, then step 3 runs), after step 2 with the
host down (`hostLineLeft`, then a sweep clears it), and a file a revoke couldn't unlink.
- G28: a commands change, then a revoke, then a restart.
- G24 (B4): the sweep's tag set, a failing `list`, and the left list it records.
The CLI tests in packages/cli/tests/hosts.test.mjs cover revoke and sweep, the N10 usage cases and the
`revoking` field in a status line.
## Step 6 as built (not run yet)
New tests in grant-executor.test.mjs:
- G22 (B1), one bus with no restart. The chief enrolls web1, the human approves, and `hosts.check`
marks it ready. A grant of `echo` follows, then a `host.run` of `echo hi` that prints `hi`. A
`sleep` raise refuses `command-not-granted`, and after a commands change to `echo` and `sleep` the
next `host.run` of `sleep 30` starts. A revoke kills that run with `credential-unavailable`. The next
call refuses `host-not-granted`.
- G12: the local steps finish before the host step. With the host's `remove` held, the record and key
file are already gone and `hosts.status` shows the tag under `revoking`.
- G13: a revoke whose host `remove` fails returns `hostLineLeft`, drops the record and key, and closes
the marker, so a restart adds nothing.
- G23 (X5): a revoke right after the executor's spend reaches its claim and the grant ends `revoked`.
- G23 (X5): a revoke that lands as `verify` returns ends `revoked` with no record, key file or line.
Only the abort check after `verify` catches it.
The fixture gains a `sudo` stub (logs to sudo.log, then runs the command as the test's user), a
`hold-remove` flag, `opts.enroll` (no host record and no installer key) and `opts.wrap` (changes the
host API the broker gets). `hostGrant` gets a fake `scan` that returns the scratch sshd's host key.
G22 deviations, for the round 1 note:
- `checkAddress` (business/src/hosts.mjs:62) refuses this machine's address, so the test enrolls
192.0.2.10 with the fake keyscan. It then points the record at the scratch sshd with an SQL update
and adds the enrolled installer key to the sshd's authorized keys. Enrollment, check, grant, run and
revoke all go through the real code after that.
- r6 B1 says the next `host.run` after a revoke refuses `credential-unavailable`. Here it refuses
`host-not-granted`, because step 1 deletes the record and `requestHost` checks the record
(`#hostAccess`) before it reaches the key. The call in flight does refuse `credential-unavailable`.
### Mutants
`/mnt/storage/scratch/gizmo-235-out/mutants.py` holds each mutant as anchored edits. `--check` applies
them in memory and runs no test. All 39 anchors match once. Each mutant runs its named test files.
From r6 section 10, as they apply to row b:
| Id | Mutant | Test file |
|---|---|---|
| B1 | drop the chief check on a host grant raise | grant-host (G1) |
| B2 | start the executor on a `credential.mint` with no `grant` | grant-enroll (A2), grant-executor |
| B3 | skip the `ready` check at the raise | grant-host (G19) |
| B4 | skip the `ready` check at step 1 | grant-executor |
| B5 | skip the definition hash compare | grant-executor (G9) |
| B6 | drop the definition check (`host-not-declared`) | grant-host (G2), grant-executor (G9) |
| B7 | skip the commands hash compare | grant-executor (G9) |
| B8 | skip the host record hash compare | grant-executor (G9) |
| B9 | write the grant record before install | grant-executor (G8) |
| B10 | skip `remove` on a verify failure | grant-executor (G8) |
| B11 | skip the start sweep of key files with no record | grant-executor (N1, G27) |
| B12 | skip the reconcile's remote `remove` | grant-executor (G11) |
| B13 | skip the `grant.step` before install | grant-executor (G11) |
| B14 | revoke leaves the grant record | grant-executor (G12) |
| B15 | skip `register` before `issue` | grant-executor (G7) |
| B16 | drop the abort check after `verify`, the last one before `issue` | grant-executor (G23 X5) |
| B17 | add an `await` between the abort check and `issue` | grant-executor |
| B18 | revoke ignores a claim with no reference | grant-executor (G13, G23 X5) |
| B19 | build the tag without the business | grant-executor (G24) |
| B20 | `sweep` removes any `mosaic-` line | grant-executor (G24 B4) |
| B21 | run revoke's host step before the local steps | grant-executor (G12) |
| B22 | start the executor on an arbiter resolve | grant-executor (G10) |
| B23 | start it on `no` | grant-enroll (A2, A4), grant-executor |
| B24 | write the private key into the event body | grant-executor (G15) |
| B25 | put an `await` between the spend and the claim | grant-executor (G23 X5) |
| B26 | skip the start check for a resolved, unspent grant decision | grant-executor (G11 B2) |
| B27 | make `commit` async | grant-executor (G8) |
| B28 | route `hosts.revoke` to `Credentials.revoke` alone, as `credential-verbs.mjs` would | grant-executor (G12, G27) |
| B29 | skip the revoke marker reconcile | grant-executor (G27) |
Row 235's own:
| Id | Mutant | Test file |
|---|---|---|
| B30 | skip the `grant.revoked` check at step 1 | grant-executor (G13, G23) |
| B31 | the sweep set keeps a live grant's tag | grant-executor (G24 B4) |
| B32 | reopen a `grant.revoked` with no revoke id | grant-executor (G27) |
| B33 | step 3 reports `removed` when the host remove fails | grant-executor (G13, G27) |
| B34 | any agent revokes, not just the assistant | grant-executor (G26) |
| B35 | `hostLineLeft` ignores the last sweep | grant-executor (G24 B4, G27) |
| B36 | a stale `release` frees a later claim | credential-claim (X5), grant-executor |
| B37 | `register` replaces an entry with a live token | credential-claim (X4) |
| B38 | drop the creative check in `#hostDefinition` | grant-host (mos-1163), grant-executor (mos-1163) |
| B39 | drop the creative check in `#hostAccess` | grant-executor (sage-1908) |
B17 may survive. `await null` yields only to microtasks, and every revoke starts in a macrotask of its
own (a socket request, or a test's call), so none can land in that gap. If it survives I'll report it
as equivalent under this build, not add a test that fakes a microtask revoke.
Section 10 items not run as row b mutants, and why:
- The upsert mutant ("let the undo to `previous` insert a record") and "run past a newer
`grant.revoked`". Row 235 has no undo to `previous`. A commands change commits the UPDATE and
`grant.applied` in one transaction (sage-1745), so a restart after it leaves the record as it is
(G11). The UPDATE can't meet a missing record either: step 1 reads the live record, and nothing
awaits between there and the UPDATE. An upsert there would be equivalent.
- "let the executor act on an abort after step 5 commits". The record and `grant.applied` commit
together, and no abort check follows the commit, so no code path reaches the `if (committed)` guard
with an abort. Removing the guard is equivalent. C1's case is covered by G28 with a later revoke.
- "treat a missing `bus.tester` as dev". The tester check can't be reached from a test (step 5 notes).
- Row a's: the keyscan compare, the writer items, the "not yet enrolled" check, the `hosts` primary key
and `host.run` reading the business file. Row 234 ran those as A5, A6, A8, A9, A11 and A13-A19.
+3 -7
View File
@@ -905,13 +905,9 @@ args file (`/etc/mosaic-stack/dev.args` on .53), which must pass
`stack-upgrade.sh`'s rules, say `--profile factory` and name `--business`.
Every command that runs the stack's code runs as
`sudo -u USER -H env PATH=<node dir>:/usr/local/bin:/usr/bin:/bin` from `/`.
Root itself reads `ss`, `/proc`, the account database and file metadata. It
runs `systemctl show` and `is-enabled`, `curl` on the Console's loopback
`/healthz`, `sudo -l`, `sudo -u jarvis bwrap --version`, `hostcheck.sh
--check`, and node to parse `managed-settings.json` for F9. F9 runs node only
when `--node` is the `--node-pin` path, and the file and every directory above
it are root's with no group or other write. The one write is F3's message and
its reply, and it never opens `claude-session.env`.
Root only reads systemd, `ss`, `/proc`, the account database and file
metadata. The one write is F3's message and its reply, and it never opens
`claude-session.env`.
It prints one line per check, `<id> <path> <mark> <evidence>`, then
`count <mark> N` for each mark and a result line. The functional checks
+3 -1
View File
@@ -218,9 +218,11 @@ 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.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,revoking}]` 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`. |
| `hosts.revoke` | `{agent,host,reason}`; the human, the business's assistant, or the tester when `bus.tester` is exactly `true` (row 235, #1741, r6 section 6). Anyone else gets `revoke-refused`. The tester check reads `bus.tester` again at the call, `tester-dev-only`. One transaction deletes the `host_grants` record and writes `grant.revoked` with `hostLine:"pending"`. With no await between, the key leaves memory and its file goes, and a `host.run` or executor in flight aborts. Then `remove <user> <tag>` runs on the host and `grant.revoke.closed` records `hostLine:"removed"` or `hostLineLeft`. A revoke with no record still runs, with `granted:false`. Returns `{agent,host,tag,granted,keyFile,hostLine or hostLineLeft}`. A restart with a pending marker runs the remove once the bus is up; `hosts.status` lists its tag under `revoking`. |
| `hosts.sweep` | `{host}`; the human or the tester (r6 X3). Runs `list <user>` and removes each listed tag this business recorded for the host in a `grant.step` or `grant.revoked` that no live grant holds. Another business's line and an unrecorded `mosaic-` line stay. Writes `grant.host.swept {host,removed,left}`; `hostLineLeft` then counts from it. Returns `{host,removed,hostLineLeft}`. |
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
+462 -31
View File
@@ -221,11 +221,23 @@ export function writeGrantEvent(store, at, business, body, subject = null) {
);
return eid;
}
export const HOSTS_VERBS = Object.freeze(['hosts.status', 'hosts.check', 'hosts.remove']);
export const HOSTS_VERBS = Object.freeze(['hosts.status', 'hosts.check', 'hosts.remove', 'hosts.revoke', 'hosts.sweep']);
// 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(/=+$/, '');
// Row 234 (#1740, r6 E1): an enrollment's raise binding. The human sees each scanned key with its
// fingerprint, and the sudo mark in plain words.
const enrollBinding = (enroll) => ({
body: enroll,
block:
`\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.`),
});
// 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
@@ -272,11 +284,17 @@ export class Broker {
#harnessOn;
// Row 234 (#1740): the trusted enrollment half (host-grant.mjs), or null where hosts are off.
#hostGrant;
// Row 235 (#1741, r6 X2 to X5): grantKeys from host-grant.mjs, the executor's calls on Credentials, or null.
#hostKeys;
// r6 X3: interrupted host grants whose remote undo waits for reconcileHosts, each with its claim.
#hostUndo = [];
// r6 section 6 (N4): revokes a restart cut short, whose step 3 waits for reconcileHosts.
#revokeUndo = [];
// 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, hostGrant = null }) {
constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, usage = null, limits = null, harnessOn = null, credentialVerbs = null, hostGrant = null, hostKeys = null }) {
if (!object(businesses) || !Object.keys(businesses).length) fail('invalid-business');
this.#businesses = structuredClone(businesses);
this.#store = store;
@@ -286,6 +304,7 @@ export class Broker {
// Row 199: the runtime's credential lifecycle (credential-verbs.mjs); it holds the store, not the broker.
this.#credentialVerbs = credentialVerbs;
this.#hostGrant = hostGrant;
this.#hostKeys = hostKeys;
this.#project = project;
this.#usage = usage;
this.#limits = limits;
@@ -690,6 +709,9 @@ export class Broker {
// 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) {
// mos-1164 (sage-1908): a creative role has no path to host.run, even through a record written before
// its instance became creative. The record stays, so hosts.revoke still finds it.
if (CREATIVE_DEFINITIONS.includes(this.#business(s.business).roles[s.role]?.definition)) fail('host-creative-role');
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);
@@ -818,6 +840,193 @@ export class Broker {
return code;
}
}
// Row 235 (#1741, r6 X2): a host grant's end, in the caller's transaction. The tag is listed every time,
// so sweep and status can match a line left on the host.
#hostGrantFailed(business, decision, g, code, extra = {}) {
const tag = this.#hostGrant?.grantTag(business, g.agent, g.host) ?? null;
this.#grantEvent(business, { action: 'grant.failed', grantDecision: decision, agent: g.agent, host: g.host, code, tag, ...extra }, g.host);
this.#grantNotice(
business,
decision,
`Grant of host ${g.host} to ${g.agent} (decision ${decision}) failed: ${code}.` +
(extra.hostLineLeft ? ` A line tagged ${extra.hostLineLeft} may be left on the host. Run: mosaic hosts sweep ${g.host}` : ''),
);
}
// r6 R1: the agent's definition, if the agent is a role of the business and the definition declares host.
// mos-1163: a creative role gets no host grant, whatever its definition declares. The raise binding and
// the step 1 recheck both come here.
#hostDefinition(business, agent) {
const b = this.#business(business);
if (!Object.hasOwn(b.roles, agent)) fail('unknown-role');
const name = b.roles[agent].definition;
if (CREATIVE_DEFINITIONS.includes(name)) fail('host-creative-role');
const definition = Object.hasOwn(b.definitions ?? {}, name) ? b.definitions[name] : null;
if (!definition?.credentials?.some((c) => c.service === 'host')) fail('host-not-declared');
return definition;
}
// r6 X2 step 1, X5: a grant.revoked of (agent, host) recorded after the decision's raise.
#revokedSince(business, decision, agent, host) {
return Boolean(
this.#store.get(
`SELECT 1 FROM events WHERE business=? AND ${GRANT_EVIDENCE} AND json_extract(body,'$.action')='grant.revoked' AND json_extract(body,'$.agent')=? AND json_extract(body,'$.host')=? AND seq > (SELECT seq FROM events WHERE business=? AND kind='action.allowed' AND json_extract(body,'$.operation')='decision.raise' AND json_extract(body,'$.decision')=? ORDER BY seq LIMIT 1) LIMIT 1`,
business, agent, host, business, decision,
),
);
}
// r6 X2 step 1's checks after the spend, R4's binding again. Throws the code that ends the grant.
#hostGrantRecheck(business, decision, g) {
if (!this.#hostGrant || !this.#hostKeys) fail('host-not-configured');
const definition = this.#hostDefinition(business, g.agent);
if (this.#hostGrant.definitionSha256(definition) !== g.definitionSha256) fail('decision-mismatch');
const r = this.#hostRecord(business, g.host);
if (!r) fail('host-unknown');
if (registrySha256(r) !== g.registrySha256) fail('decision-mismatch');
if (r.state !== 'ready') fail('host-not-ready');
if (sha256(JSON.stringify(g.commands)) !== g.commandsSha256) fail('decision-mismatch');
if (this.#revokedSince(business, decision, g.agent, g.host)) fail('revoked');
return r;
}
// Trusted host API, as runEnrollment is, and no verb reaches it: the grant executor's entry for one
// decision. A second start ends decision-consumed with nothing written (r6 G10).
runHostGrant(business, decision) {
return this.#runHostGrant(business, decision);
}
// r6 X2, started by the human's cli yes. Never throws: the outcome is in the store and the notices, and
// the return value is 'applied' or the code grant.failed records. Every host_grants write shares its
// transaction with grant.applied (sage-1745).
async #runHostGrant(business, decision) {
let start;
try {
// Step 1, one transaction: the spend, then R4's checks again. A failed check still spends.
start = this.#store.transaction(() => {
const g = this.#consumeGrant(business, decision, 'host');
let record;
try {
record = this.#hostGrantRecheck(business, decision, g);
} catch (e) {
if (!(e instanceof BusError)) throw e;
this.#hostGrantFailed(business, decision, g, e.code);
return { g, code: e.code };
}
const live = this.#store.get('SELECT commands FROM host_grants WHERE business=? AND agent=? AND host=?', business, g.agent, g.host);
return { g, record, previous: live ? JSON.parse(live.commands) : null };
});
} 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 (start.code) return start.code;
const { g, record, previous } = start;
// Right after the commit, with no await between, so no revoke or host.run falls in between (X2).
let signal;
try {
signal = this.#hostKeys.claim(business, g.agent, g.host);
} catch (e) {
const code = e instanceof BusError ? e.code : 'host-failed';
try {
this.#store.transaction(() => this.#hostGrantFailed(business, decision, g, code));
} catch {}
return code;
}
const tag = this.#hostGrant.grantTag(business, g.agent, g.host);
const change = previous !== null;
const step = (name, extra = {}) =>
this.#store.transaction(() =>
this.#grantEvent(business, { action: 'grant.step', grantDecision: decision, agent: g.agent, host: g.host, user: record.user, tag, step: name, ...extra }, g.host),
);
// X5: checked after each await. A revoke during steps 2 to 5 ends the grant revoked.
const stop = () => {
if (signal.aborted) fail('revoked');
};
let installing = false,
registered = false,
committed = false;
try {
let key = null;
if (!change) {
// Step 2. A commands change keeps its key and line and skips to step 5.
key = await this.#hostGrant.grantKey(tag);
stop();
// Steps 3 and 4.
step('install');
installing = true;
await this.#hostGrant.install(business, g.host, record, tag, key.publicLine, signal);
stop();
await this.#hostGrant.verify(g.host, record, key, signal);
stop();
}
// Step 5. Nothing from the abort check to the end of commit awaits (X5, N2).
step('commit', { previous });
stop();
const applied = () => {
this.#grantEvent(
business,
{ action: 'grant.applied', grantDecision: decision, agent: g.agent, host: g.host, user: record.user, tag, fingerprint: key?.fingerprint ?? null, commandsSha256: g.commandsSha256 },
g.host,
);
this.#grantNotice(
business,
decision,
change
? `The commands of ${g.agent}'s grant on host ${g.host} changed (decision ${decision}).`
: `${g.agent} has access to host ${g.host} as ${record.user} (decision ${decision}), tag ${tag}.`,
);
};
if (change) {
this.#store.transaction(() => {
const out = this.#store.run(
'UPDATE host_grants SET commands=?, decision=? WHERE business=? AND agent=? AND host=?',
JSON.stringify(g.commands), decision, business, g.agent, g.host,
);
if (out.changes !== 1) fail('host-changed');
applied();
});
committed = true;
} else {
this.#hostKeys.register(business, g.agent, g.host, g.rotateBy);
registered = true;
this.#hostKeys.issue(business, g.agent, g.host, key.text, g.rotateBy, () => {
this.#store.transaction(() => {
this.#store.run(
'INSERT INTO host_grants(business,agent,host,commands,rotate_by,tag,decision) VALUES(?,?,?,?,?,?,?)',
business, g.agent, g.host, JSON.stringify(g.commands), g.rotateBy, tag, decision,
);
applied();
});
committed = true;
});
}
return 'applied';
} catch (e) {
// Once the record and grant.applied commit, an abort is an ordinary revoke's to handle (C1).
if (committed) return 'applied';
const code = signal.aborted ? 'revoked' : e instanceof BusError ? e.code : 'host-failed';
// X3's table: a key file and its reference go, then the line, best effort.
if (registered) {
try {
this.#hostKeys.revoke(business, g.agent, g.host);
} catch {}
try {
this.#hostKeys.unregister(business, g.agent, g.host);
} catch {}
}
let extra = {};
if (installing) {
try {
await this.#hostGrant.remove(business, g.host, record, tag);
} catch {
extra = { hostLineLeft: tag };
}
}
try {
this.#store.transaction(() => this.#hostGrantFailed(business, decision, g, code, extra));
} catch {}
return code;
} finally {
this.#hostKeys.release(business, g.agent, g.host, signal);
}
}
// r6 X3 and duckworth-0067 N1: credential.mint decisions for `method`, resolved yes by the human via
// cli and never spent.
#openGrantDecisions(business, method) {
@@ -859,23 +1068,190 @@ export class Broker {
});
ended.push(decision);
}
// Row 235 (r6 X3): a host grant spent with no grant.applied or grant.failed died mid-executor. The
// record and grant.applied commit in one transaction (X2 step 5), so no record names it and the
// start sweep has unlinked any key file it wrote. A last step of install, or commit for a new key,
// may have left a line: the slot is claimed here, before anyone can call, and reconcileHosts
// removes the line. Any other ends interrupted now.
const hostSpent = this.#store.all(
`SELECT json_extract(body,'$.decision') AS decision FROM events WHERE business=? AND ${GRANT_EVIDENCE} AND json_extract(body,'$.operation')='grant.host' ORDER BY seq`,
business,
);
for (const { decision } of hostSpent) {
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.applied','grant.failed') LIMIT 1`,
business,
decision,
);
if (end) continue;
const g = this.#raised(business, decision).grant;
const last = this.#store.get(
`SELECT body FROM events WHERE business=? AND ${GRANT_EVIDENCE} AND json_extract(body,'$.grantDecision')=? AND json_extract(body,'$.action')='grant.step' ORDER BY seq DESC LIMIT 1`,
business,
decision,
);
const step = last ? JSON.parse(last.body) : null;
if (step && (step.step === 'install' || (step.step === 'commit' && step.previous === null))) {
let signal = null;
try {
signal = this.#hostKeys.claim(business, g.agent, g.host);
} catch {}
this.#hostUndo.push({ business, decision, g, user: step.user, tag: step.tag, signal });
continue;
}
this.#store.transaction(() => this.#hostGrantFailed(business, decision, g, 'interrupted'));
ended.push(decision);
}
for (const decision of this.#openGrantDecisions(business, 'host')) {
this.#store.transaction(() => this.#hostGrantFailed(business, decision, this.#consumeGrant(business, decision, 'host'), 'interrupted'));
ended.push(decision);
}
// r6 section 6 (N4): a grant.revoked still pending has done step 1 and maybe step 2. The record is
// gone, so the start sweep took any key file left. reconcileHosts runs step 3.
for (const m of this.#openRevokes(business)) this.#revokeUndo.push({ business, ...m });
}
return ended;
}
// r6 E1, R3: the raise checks and the keyscan, then the raise transaction.
// Trusted host API, after the socket opens (r6 X3): the remote undo of each grant reconcileGrants left
// claimed. `remove <user> <tag>` through the installer, then grant.failed interrupted, with
// hostLineLeft if the remove failed, and the claim released. It only takes access away, so it needs no
// decision. Never throws, and returns the decisions it ended.
async reconcileHosts() {
const ended = [];
for (const u of this.#hostUndo.splice(0)) {
let extra = {};
try {
const record = this.#hostRecord(u.business, u.g.host);
if (!record || !this.#hostGrant || !u.signal) fail('host-failed');
await this.#hostGrant.remove(u.business, u.g.host, { ...record, user: u.user }, u.tag);
} catch {
extra = { hostLineLeft: u.tag };
}
try {
this.#store.transaction(() => this.#hostGrantFailed(u.business, u.decision, u.g, 'interrupted', extra));
ended.push(u.decision);
} catch {}
try {
if (u.signal) this.#hostKeys.release(u.business, u.g.agent, u.g.host, u.signal);
} catch {}
}
for (const m of this.#revokeUndo.splice(0)) {
try {
await this.#closeRevoke(m.business, m);
ended.push(m.revoke);
} catch {}
}
return ended;
}
// The grant.revoked bodies still pending, with no grant.revoke.closed of the same revoke id. A body
// with no revoke id predates row 235 and has no step 3.
#openRevokes(business, host = null) {
return this.#store
.all(
`SELECT body FROM events WHERE business=? AND ${GRANT_EVIDENCE} AND json_extract(body,'$.action')='grant.revoked' AND json_extract(body,'$.hostLine')='pending' AND json_type(body,'$.revoke')='text' AND (? IS NULL OR json_extract(body,'$.host')=?) ORDER BY seq`,
business,
host,
host,
)
.map((e) => JSON.parse(e.body))
.filter(
(m) =>
!this.#store.get(
`SELECT 1 FROM events WHERE business=? AND ${GRANT_EVIDENCE} AND json_extract(body,'$.action')='grant.revoke.closed' AND json_extract(body,'$.revoke')=? LIMIT 1`,
business,
m.revoke,
),
);
}
// r6 section 6 step 3: `remove <user> <tag>` through the installer, then grant.revoke.closed, with
// hostLineLeft if the host can't be reached or has no record. The user is the host record's, as the
// executor's. Returns the closed body.
async #closeRevoke(business, m) {
let line = { hostLine: 'removed' };
try {
const record = this.#hostRecord(business, m.host);
if (!record || !this.#hostGrant) fail('host-failed');
await this.#hostGrant.remove(business, m.host, record, m.tag);
} catch {
line = { hostLineLeft: m.tag };
}
const body = { action: 'grant.revoke.closed', revoke: m.revoke, agent: m.agent, host: m.host, tag: m.tag, ...line, by: m.by };
this.#store.transaction(() => this.#grantEvent(business, body, m.host));
return body;
}
// r6 section 6: local first, so the part that matters can't wait on the network. Step 1 is one
// transaction: the record goes, if there is one, and grant.revoked records the pending marker. The tag
// is computed, so a revoke with no record still names the line (N4). Step 2 follows with no await
// between: the key leaves memory, its file goes, and a host.run or executor in flight aborts (X5).
// Step 3 removes the line.
async #revokeHost(s, a, by) {
keys(a, ['agent', 'host', 'reason'], ['agent', 'host', 'reason']);
if (typeof a.agent !== 'string' || !ID_PATTERN.test(a.agent) || typeof a.host !== 'string' || !HOST_ID.test(a.host)) fail('invalid-request');
string(a.reason, 500);
if (!this.#hostGrant) fail('host-not-configured');
const tag = this.#hostGrant.grantTag(s.business, a.agent, a.host);
const revoke = randomUUID();
const granted = this.#store.transaction(() => {
if (!this.#hostRecord(s.business, a.host)) fail('host-unknown');
const out = this.#store.run('DELETE FROM host_grants WHERE business=? AND agent=? AND host=?', s.business, a.agent, a.host);
this.#grantEvent(
s.business,
{ action: 'grant.revoked', revoke, agent: a.agent, host: a.host, tag, hostLine: 'pending', granted: out.changes === 1, reason: a.reason, by },
a.host,
);
return out.changes === 1;
});
let keyFile = 'removed';
if (this.#hostKeys) {
try {
this.#hostKeys.revoke(s.business, a.agent, a.host);
} catch (e) {
// No reference: only a claim can be held, an executor's before its register.
if (e instanceof BusError && e.code === 'credential-unknown') this.#hostKeys.abortClaim(s.business, a.agent, a.host);
else keyFile = 'left';
}
}
const closed = await this.#closeRevoke(s.business, { revoke, agent: a.agent, host: a.host, tag, by });
return { agent: a.agent, host: a.host, tag, granted, keyFile, ...(closed.hostLineLeft ? { hostLineLeft: closed.hostLineLeft } : { hostLine: 'removed' }) };
}
// r6 X3 sweep (B4): the tags this business recorded on the host, from grant.step and grant.revoked,
// less those of live grants.
#sweepTags(business, host) {
const tags = new Set(
this.#store
.all(
`SELECT json_extract(body,'$.tag') AS tag FROM events WHERE business=? AND ${GRANT_EVIDENCE} AND json_extract(body,'$.host')=? AND json_extract(body,'$.action') IN ('grant.step','grant.revoked') AND json_type(body,'$.tag')='text'`,
business,
host,
)
.map((e) => e.tag),
);
for (const { tag } of this.#store.all('SELECT tag FROM host_grants WHERE business=? AND host=?', business, host)) tags.delete(tag);
return tags;
}
// r6 E1, R3: the raise checks and the keyscan, then the raise transaction. Row 235 (#1741, r6 R4): a
// host grant has no keyscan, so its store checks all run inside 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');
if (a.grant?.method === 'host') {
const g = this.#hostGrant.hostGrantCheck(a.grant, this.#now().slice(0, 10));
return this.#store.transaction(() => {
const result = this.#dispatch(s, 'decision.raise', a, null, this.#hostGrantBinding(s.business, g));
this.#secretCheck(result);
return result;
});
}
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 });
const result = this.#dispatch(s, 'decision.raise', a, null, enrollBinding({ ...enroll, hostKeys }));
this.#secretCheck(result);
return result;
});
@@ -883,20 +1259,62 @@ export class Broker {
throw this.#refused(s, e, { verb: 'decision.raise', args: a });
}
}
// r6 R4: a host grant's store checks and its raise binding. The caller owns the transaction. The body
// is R4's eight fields. The block shows the human the host as enrolled, each prefix and whether the
// key is new, and the chief can't shape it.
#hostGrantBinding(business, g) {
const definition = this.#hostDefinition(business, g.agent);
const h = this.#hostRecord(business, g.host);
if (!h) fail('host-unknown');
if (h.state !== 'ready') fail('host-not-ready');
const live = this.#store.get('SELECT 1 FROM host_grants WHERE business=? AND agent=? AND host=?', business, g.agent, g.host);
const body = {
method: 'host',
agent: g.agent,
host: g.host,
commands: g.commands,
commandsSha256: sha256(JSON.stringify(g.commands)),
rotateBy: g.rotateBy,
registrySha256: registrySha256(h),
definitionSha256: this.#hostGrant.definitionSha256(definition),
};
const block =
`\n\nGrant ${g.agent} access to host ${g.host}: user ${h.user} at ${h.address} port ${h.port}.` +
(h.sudo ? `\n${h.user} has sudo on this host.` : `\n${h.user} has no sudo on this host.`) +
`\nCommands, each an argv prefix:` +
g.commands.map((prefix) => `\n${prefix.map((x) => JSON.stringify(x)).join(' ')}`).join('') +
`\nRotate by ${g.rotateBy}.` +
(live
? `\nThis changes the commands of ${g.agent}'s grant on ${g.host}. The key stays.`
: `\nThis makes a new key for ${g.agent} on ${g.host}.`);
return { body, block };
}
#hostsCaller(s, verb) {
if (s.reader) fail('read-only');
if (s.tester) return 'tester';
if (s.tester) {
// r6 section 6 (N8): a tester revoke needs bus.tester exactly true on the session's business, read
// again here. Missing or any other value isn't dev.
if (verb === 'hosts.revoke' && this.#business(s.business).vars?.['bus.tester'] !== true) fail('tester-dev-only');
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');
// hosts.remove and hosts.sweep take access away, as a revoke does: the human or the tester (r6 E4, X3).
if (verb === 'hosts.remove' || verb === 'hosts.sweep') fail('human-required');
this.#store.transaction(() => this.#agent(s));
// r6 section 6 (G26): the assistant revokes in its own business. The verb acts on the session's
// business only, so another business's grant can't be named.
if (verb === 'hosts.revoke') {
if (!this.#threaded(s.business, s.role)) fail('revoke-refused');
return s.role;
}
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.
// r6 E4: hosts.status, hosts.check and hosts.remove. Async, because check runs ssh. Row 235 adds
// hosts.revoke and hosts.sweep (r6 section 6, X3).
async #requestHosts(s, verb, a) {
try {
const by = this.#hostsCaller(s, verb);
@@ -917,12 +1335,25 @@ export class Broker {
installerFingerprint: r.installer_fingerprint,
checkedAt: r.checked_at,
hostLineLeft: this.#hostLinesLeft(s.business, host),
// r6 section 6: the tags of revokes still in progress.
revoking: this.#openRevokes(s.business, host).map((m) => m.tag),
};
});
}
if (verb === 'hosts.revoke') return await this.#revokeHost(s, a, by);
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.sweep') {
const r = this.#hostRecord(s.business, a.host);
if (!r) fail('host-unknown');
const left = this.#hostLinesLeft(s.business, a.host);
const { listed, removed } = await this.#hostGrant.sweep(s.business, a.host, r, this.#sweepTags(s.business, a.host));
// A left tag still listed and not removed stays left.
const still = left.filter((tag) => listed.includes(tag) && !removed.includes(tag));
this.#store.transaction(() => this.#grantEvent(s.business, { action: 'grant.host.swept', host: a.host, removed, left: still, by }, a.host));
return { host: a.host, removed, hostLineLeft: still };
}
if (verb === 'hosts.remove') {
return this.#store.transaction(() => {
const r = this.#hostRecord(s.business, a.host);
@@ -960,15 +1391,23 @@ export class Broker {
}
}
// 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).
// Row 235 adds the sweep clear (F4): the last grant.host.swept carries what it left, and only later
// bodies add to it.
#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);
const swept = this.#store.get(
`SELECT seq, body FROM events WHERE business=? AND ${GRANT_EVIDENCE} AND json_extract(body,'$.action')='grant.host.swept' AND json_extract(body,'$.host')=? ORDER BY seq DESC LIMIT 1`,
business,
host,
);
const left = swept ? JSON.parse(swept.body).left : [];
for (const e of 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 AND seq > ? ORDER BY seq`,
business,
host,
swept?.seq ?? 0,
))
if (!left.includes(e.tag)) left.push(e.tag);
return left;
}
#hardWait(s, action, target) {
if (GATED.has(action)) return true;
@@ -2162,7 +2601,7 @@ export class Broker {
});
return out;
}
#dispatch(s, verb, a, spec = null, enroll = null) {
#dispatch(s, verb, a, spec = null, binding = null) {
const b = this.#business(s.business),
by = s.human || s.run;
switch (verb) {
@@ -2271,8 +2710,8 @@ export class Broker {
],
['action', 'question', 'options', 'recommendation', 'blocking'],
);
// Only #raiseGrant passes `enroll`, after its checks and keyscan.
if (('grant' in a) !== (enroll !== null)) fail('invalid-request');
// Only #raiseGrant passes `binding`, after its checks.
if (('grant' in a) !== (binding !== 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);
@@ -2345,17 +2784,8 @@ export class Broker {
`\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.`);
}
// Rows 234 and 235: the grant's block, built by the broker. The chief can't shape it.
if (binding) question += binding.block;
string(question);
const did = randomUUID();
this.#store.run(
@@ -2393,7 +2823,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 } : {}),
...(binding ? { grant: binding.body } : {}),
},
a.task_ref ?? (taskRef(a.target) ? a.target : did),
);
@@ -2448,6 +2878,7 @@ export class Broker {
// 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 (grant?.method === 'host') setImmediate(() => this.#runHostGrant(s.business, d.id));
if (s.human)
this.#event(
s,
+56 -14
View File
@@ -13,7 +13,7 @@ const inside = (root, path) => {
const r = relative(root, path);
return r === '' || (!r.startsWith('..') && !isAbsolute(r));
};
const day = (value) =>
export const day = (value) =>
typeof value === 'string' &&
/^\d{4}-\d{2}-\d{2}$/.test(value) &&
!Number.isNaN(Date.parse(value)) &&
@@ -128,19 +128,7 @@ export class Credentials {
try {
for (const [instance, services] of Object.entries(references))
for (const [service, ref] of Object.entries(services)) {
if (!credentialKind(service) || !ref || typeof ref !== 'object')
deny('credential-reference');
const date = service === 'vikunja' ? ref.expires : ref.rotateBy;
if (!day(date)) deny('credential-date');
if (
(ref.service !== undefined && ref.service !== service) ||
Object.keys(ref).some(
(k) => !['service', 'file', 'env', 'store', service === 'vikunja' ? 'expires' : 'rotateBy'].includes(k),
) ||
Boolean(ref.file) === Boolean(ref.env) ||
(ref.store !== undefined && (ref.store !== true || ref.file !== this.#storePath(instance, service)))
)
deny('credential-reference');
const date = this.#checkRef(instance, service, ref);
this.#refs.set(instance + ':' + service, { ref, date });
const value = this.#read(ref, env, service);
if (value !== null) this.#tokens.set(instance + ':' + service, { ...value, date });
@@ -150,6 +138,60 @@ export class Credentials {
throw e;
}
}
// One reference's checks, for the constructor and register (row 235, r6 X4). Returns its date.
#checkRef(instance, service, ref) {
if (!credentialKind(service) || !ref || typeof ref !== 'object') deny('credential-reference');
const date = service === 'vikunja' ? ref.expires : ref.rotateBy;
if (!day(date)) deny('credential-date');
if (
(ref.service !== undefined && ref.service !== service) ||
Object.keys(ref).some(
(k) => !['service', 'file', 'env', 'store', service === 'vikunja' ? 'expires' : 'rotateBy'].includes(k),
) ||
Boolean(ref.file) === Boolean(ref.env) ||
(ref.store !== undefined && (ref.store !== true || ref.file !== this.#storePath(instance, service)))
)
deny('credential-reference');
return date;
}
// Row 235 (#1741, r6 X4): the grant executor adds a reference before `issue`, which refuses an unknown
// one. It reads no file, so a key file a failed revoke left can't come back. An entry with a live
// token refuses, and an entry with none, revoked or never issued, is replaced.
register(instance, service, ref) {
const k = instance + ':' + service;
if (this.#tokens.has(k)) deny('credential-reference');
const date = this.#checkRef(instance, service, ref);
this.#refs.set(k, { ref, date });
this.#revoked.delete(k);
}
// r6 X3, X4: the executor's undo for a reference it registered. An entry with a live token refuses.
unregister(instance, service) {
const k = instance + ':' + service;
if (this.#tokens.has(k)) deny('credential-reference');
this.#refs.delete(k);
this.#revoked.delete(k);
}
// r6 X5: the executor holds the entry's #inflight slot for its steps, so a host.run refuses host-busy
// and revoke's abort() reaches the executor. A slot already held refuses host-busy. Returns the signal.
claim(instance, service) {
const k = instance + ':' + service;
if (this.#inflight.has(k)) deny('host-busy');
const controller = new AbortController();
this.#inflight.set(k, controller);
return controller.signal;
}
// Frees the slot only if `signal` is the claim's own, so a late release can't free a later claim.
release(instance, service, signal) {
const k = instance + ':' + service;
if (this.#inflight.get(k)?.signal === signal) this.#inflight.delete(k);
}
// r6 X5: hosts.revoke with no reference to revoke. Aborts a live claim or call and says whether there
// was one.
abortClaim(instance, service) {
const controller = this.#inflight.get(instance + ':' + service);
controller?.abort();
return { pending: Boolean(controller) };
}
#storePath(instance, service) {
if (!this.#storeRoot) deny('credential-reference');
const [business, agent, ...rest] = instance.split('/');
+148 -8
View File
@@ -1,22 +1,25 @@
// 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).
// `list` for hosts.check (E4). Row 235 (#1741, #436 row b) adds the host grant raise binding (R4), the
// grant executor's remote steps (X2 steps 2 to 4 and the `remove` undo) and `grantKeys`, the broker's
// narrow handle on Credentials.
//
// 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,
closeSync, constants, fsyncSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, 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 { day, 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';
import { checkAddress, checkCommands, HOST_KEY, USER } from '../../business/src/hosts.mjs';
import { canonicalJson } from '../../business/src/util.mjs';
import { HOST_ID, ID_PATTERN } from '../../business/src/vocabulary.mjs';
const deny = (code) => {
throw new BusError(code);
@@ -68,6 +71,86 @@ export function enrollCheck(grant) {
return { method: 'host-enroll', host: grant.host, address: grant.address, port: grant.port, user: grant.user, sudo: grant.sudo, fingerprints: [...prints] };
}
// R4's binding for a host grant. Anything off refuses invalid-request. `today` is the bus clock's UTC
// day. rotateBy must be a real calendar day after it (210's day check, credentials.mjs; sage-1782). The
// broker checks the agent, its definition and the host inside the raise transaction. Returns a fresh
// object with only the bound keys.
export function hostGrantCheck(grant, today) {
const allowed = ['method', 'agent', 'host', 'commands', 'rotateBy'];
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') deny('invalid-request');
if (typeof grant.agent !== 'string' || !ID_PATTERN.test(grant.agent)) deny('invalid-request');
if (typeof grant.host !== 'string' || !HOST_ID.test(grant.host)) deny('invalid-request');
let commands;
try {
commands = checkCommands(grant.commands, 'grant.commands');
} catch {
deny('invalid-request');
}
if (!day(grant.rotateBy) || grant.rotateBy <= today) deny('invalid-request');
return { method: 'host', agent: grant.agent, host: grant.host, commands, rotateBy: grant.rotateBy };
}
// R4's definitionSha256: sha256 over the canonical JSON of the definition the business loaded, since
// the broker doesn't read roles/ (sage-0590). A change to any field the loader keeps changes it.
export const definitionSha256 = (definition) => createHash('sha256').update(canonicalJson(definition)).digest('hex');
// r6 X4: the store reference for an agent's host.<id> key, as Credentials checks it. The runtime's start
// load and the executor's register both use it.
export const hostReference = (storeRoot, business, agent, hostId, rotateBy) => ({
store: true,
file: storeRoot ? join(storeRoot, business, agent, 'host.' + hostId) : null,
rotateBy,
});
// Row 235 (r6 X2 to X5): the grant executor's calls on Credentials, keyed by (business, agent, hostId).
// The broker gets this and never Credentials itself, so it still has no use().
export function grantKeys({ credentials, storeRoot }) {
const at = (business, agent) => business + '/' + agent;
const service = (hostId) => 'host.' + hostId;
return {
claim: (business, agent, hostId) => credentials.claim(at(business, agent), service(hostId)),
release: (business, agent, hostId, signal) => credentials.release(at(business, agent), service(hostId), signal),
abortClaim: (business, agent, hostId) => credentials.abortClaim(at(business, agent), service(hostId)),
register: (business, agent, hostId, rotateBy) =>
credentials.register(at(business, agent), service(hostId), hostReference(storeRoot, business, agent, hostId, rotateBy)),
unregister: (business, agent, hostId) => credentials.unregister(at(business, agent), service(hostId)),
issue: (business, agent, hostId, text, rotateBy, commit) => credentials.issue(at(business, agent), service(hostId), text, rotateBy, commit),
revoke: (business, agent, hostId) => credentials.revoke(at(business, agent), service(hostId)),
};
}
// r6 section 6 and N1 of duckworth-0063: at start, before Credentials loads, unlink each host.<id> key
// file in a loaded business's agent store that has no host_grants record. `granted` holds
// 'business/agent/hostId' for each record. A file an interrupted executor wrote before its commit, or one
// a revoke failed to unlink, goes here. No reference loads for a file with no record (X4), so nothing
// holds it. A store dir that isn't private refuses credential-file, as Credentials would.
export function sweepHostKeys(storeRoot, businesses, granted) {
const removed = [];
if (!storeRoot || !privateDir(storeRoot, { missing: true })) return removed;
for (const business of businesses) {
const dir = join(storeRoot, business);
if (!privateDir(dir, { missing: true })) continue;
for (const agent of readdirSync(dir)) {
const agentDir = join(dir, agent);
if (!lstatSync(agentDir).isDirectory()) continue;
privateDir(agentDir);
for (const name of readdirSync(agentDir)) {
const hostId = name.startsWith('host.') ? name.slice(5) : null;
if (!hostId || !HOST_ID.test(hostId) || granted.has(business + '/' + agent + '/' + hostId)) continue;
unlinkSync(join(agentDir, name));
removed.push({ instance: business + '/' + agent, service: name });
}
}
}
return removed;
}
// The tags in `list` output, one per line, the last field of each.
export const listedTags = (stdout) => stdout.split('\n').filter(Boolean).map((line) => line.trim().split(/\s+/).at(-1));
// 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) {
@@ -122,7 +205,9 @@ const run = (bin, args) =>
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' }) {
// `installerUser` is the installer's login. Only the tests change it, because a scratch sshd can only
// log in as the test's own user.
export function hostGrant({ configDir, runRoot, limits = HOST_LIMITS, scan = keyscan, keygen = 'ssh-keygen', installerUser = INSTALLER_USER }) {
const keyRoot = join(configDir, 'secrets', 'hosts');
const keyPath = (business, hostId) => join(keyRoot, business, hostId, 'installer');
const readKey = (business, hostId) => {
@@ -139,11 +224,24 @@ export function hostGrant({ configDir, runRoot, limits = HOST_LIMITS, scan = key
if (fd !== undefined) closeSync(fd);
}
};
// One installer request, `list`, `add` or `remove`, pinned to the record's host keys (E3).
const installer = (business, hostId, record, command, signal = null) => {
const key = readKey(business, hostId);
const entry = { address: record.address, port: record.port, user: installerUser, hostKeys: record.hostKeys };
return runHost({ root: runRoot, hostId, entry, key, timeout: 30, limits, command, signal });
};
const ok = (out) => {
if (out.exit !== 0) deny('host-failed');
return out;
};
return {
enrollCheck,
hostGrantCheck,
definitionSha256,
sameFingerprints,
fingerprint,
installerTag,
grantTag,
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) {
@@ -209,9 +307,51 @@ export function hostGrant({ configDir, runRoot, limits = HOST_LIMITS, scan = key
// 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 entry = { address: record.address, port: record.port, user: installerUser, 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 };
},
// X2 step 2: a new ed25519 pair with the tag as its comment, made in a 0700 dir under the run root
// and read back into memory. The dir goes before this returns, so no private half stays on disk.
async grantKey(tag) {
mkdirSync(runRoot, { recursive: true, mode: 0o700 });
privateDir(runRoot);
const dir = mkdtempSync(join(runRoot, 'h-'));
try {
await run(keygen, ['-q', '-t', 'ed25519', '-N', '', '-C', tag, '-f', join(dir, 'key')]);
return parseHostKey(readFileSync(join(dir, 'key'), 'utf8'));
} finally {
rmSync(dir, { recursive: true, force: true });
}
},
// X2 step 3: `list`, `remove` the tag if it's there, then `add`. Any request that doesn't exit 0
// refuses host-failed. `signal` is the executor's claim, so a revoke stops the call.
async install(business, hostId, record, tag, publicLine, signal) {
const listed = ok(await installer(business, hostId, record, `list ${record.user}`, signal));
if (listedTags(listed.stdout).includes(tag)) ok(await installer(business, hostId, record, `remove ${record.user} ${tag}`, signal));
ok(await installer(business, hostId, record, `add ${record.user} ${publicLine} ${tag}`, signal));
},
// X2 step 4: log in as the record's user with the new key and run `true`.
async verify(hostId, record, key, signal) {
const entry = { address: record.address, port: record.port, user: record.user, hostKeys: record.hostKeys };
ok(await runHost({ root: runRoot, hostId, entry, key, timeout: 30, limits, command: 'true', signal }));
},
// X3's undo and sweep: `remove <user> <tag>`, with no signal, since it runs after an abort too.
async remove(business, hostId, record, tag) {
ok(await installer(business, hostId, record, `remove ${record.user} ${tag}`));
},
// X3's sweep (B4): `list <user>`, then `remove` for each listed tag in `tags`. A line with any other
// tag stays, so another business's line and an unrecorded one are left alone. Returns the listed
// tags and the removed ones.
async sweep(business, hostId, record, tags) {
const listed = listedTags(ok(await installer(business, hostId, record, `list ${record.user}`)).stdout);
const removed = [];
for (const tag of listed) {
if (!tags.has(tag) || removed.includes(tag)) continue;
ok(await installer(business, hostId, record, `remove ${record.user} ${tag}`));
removed.push(tag);
}
return { listed, removed };
},
};
}
+12 -2
View File
@@ -16,7 +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 { grantKeys, hostGrant, hostReference, sweepHostKeys } from './host-grant.mjs';
import { verifyHuman } from './human.mjs';
import { createUsage } from './usage.mjs';
import { createLimits } from './limits.mjs';
@@ -100,11 +100,16 @@ export async function startBroker({
// 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.
const granted = new Set();
for (const g of store.all('SELECT business, agent, host, rotate_by FROM host_grants ORDER BY business, agent, host')) {
granted.add(g.business + '/' + g.agent + '/' + g.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 };
refs['host.' + g.host] = hostReference(storeRoot, g.business, g.agent, g.host, g.rotate_by);
}
// Row 235 (r6 section 6, N1): a host.<id> key file with no record goes before Credentials loads.
for (const entry of sweepHostKeys(storeRoot, Object.keys(businesses), granted))
process.stderr.write(`hosts: ${JSON.stringify({ event: 'host-key-swept', ...entry })}\n`);
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.
@@ -125,6 +130,8 @@ export async function startBroker({
const runRoot = organization ? join(organization.dir, 'secrets', 'run') : null;
const broker = new Broker({
hostGrant: runRoot ? hostGrant({ configDir: organization.dir, runRoot }) : null,
// Row 235 (#1741, r6 X4, X5): the grant executor's claim, register and issue, never use().
hostKeys: storeRoot ? grantKeys({ credentials, storeRoot }) : null,
usage,
limits,
store,
@@ -222,6 +229,9 @@ export async function startBroker({
return broker.bindTester({ business });
},
});
// Row 235 (r6 X3): the remote undo of interrupted host grants, once the bus is up, so a start doesn't
// wait on the network.
broker.reconcileHosts();
// S6 launcher operations. The host decides capacity and the instance list; the broker
// authorizes role.launch and keeps the evidence, refusals included.
// A refusal is recorded against the caller, as request() records one.
@@ -0,0 +1,152 @@
// Row 235 (#1741, #436 row b, r6 X4, X5): the grant executor's Credentials calls. register and
// unregister add and drop one store reference in the running bus, and claim, release and abortClaim
// hold the (agent, host.<id>) slot that host.run and revoke share. Keys are made by ssh-keygen in a
// scratch root, and nothing connects to any host.
import test from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Credentials } from '../src/credentials.mjs';
const AGENT = 'demo/ops';
const SERVICE = 'host.web1';
function setup(t) {
const root = mkdtempSync(join(tmpdir(), 'bus-claim-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const storeRoot = join(root, 'secrets', 'agents');
mkdirSync(storeRoot, { recursive: true, mode: 0o700 });
chmodSync(join(root, 'secrets'), 0o700);
const file = join(storeRoot, 'demo', 'ops', SERVICE);
const ref = { service: SERVICE, store: true, file, rotateBy: '2099-01-01' };
const key = (name) => {
execFileSync('ssh-keygen', ['-q', '-t', 'ed25519', '-N', '', '-C', 'test', '-f', join(root, name)]);
return readFileSync(join(root, name), 'utf8');
};
const c = new Credentials({ references: {}, storeRoot, repoRoots: [], dataRoot: '/nonexistent-data' });
t.after(() => c.close());
const state = () => c.status().find((e) => e.instance === AGENT && e.service === SERVICE)?.state;
const run = () => c.use(AGENT, SERVICE, async () => ({ ok: true }));
return { root, storeRoot, file, ref, key, c, state, run };
}
test('X4: register adds a store reference to the running bus, and issue then writes the key', async (t) => {
const s = setup(t);
assert.throws(() => s.c.issue(AGENT, SERVICE, s.key('k1'), '2099-01-01'), /credential-unknown/);
s.c.register(AGENT, SERVICE, s.ref);
assert.equal(s.state(), 'not-issued');
await assert.rejects(s.run(), /credential-unavailable/);
assert.equal(s.c.issue(AGENT, SERVICE, s.key('k2'), '2099-01-01').state, 'valid');
assert.deepEqual(await s.run(), { ok: true });
});
test('X4: register runs the constructor\'s reference checks', (t) => {
const s = setup(t);
const other = join(s.storeRoot, 'demo', 'pm', SERVICE);
for (const [ref, code] of [
[null, /credential-reference/],
[{ ...s.ref, rotateBy: '2099-02-30' }, /credential-date/],
[{ ...s.ref, file: other }, /credential-reference/],
[{ ...s.ref, extra: 1 }, /credential-reference/],
[{ ...s.ref, service: 'host.web2' }, /credential-reference/],
[{ ...s.ref, env: 'X' }, /credential-reference/],
])
assert.throws(() => s.c.register(AGENT, SERVICE, ref), code);
assert.throws(() => s.c.register(AGENT, 'nope', s.ref), /credential-reference/);
assert.throws(() => s.c.register('demo/@tester', SERVICE, { ...s.ref, file: join(s.storeRoot, 'demo', '@tester', SERVICE) }), /credential-reference/);
assert.equal(s.state(), undefined);
});
test('X4: register reads no file, so a key file left at the path stays unused, and a live token refuses', async (t) => {
const s = setup(t);
mkdirSync(join(s.storeRoot, 'demo', 'ops'), { recursive: true, mode: 0o700 });
writeFileSync(s.file, s.key('left'), { mode: 0o600 });
s.c.register(AGENT, SERVICE, s.ref);
assert.equal(s.state(), 'not-issued');
await assert.rejects(s.run(), /credential-unavailable/);
s.c.issue(AGENT, SERVICE, s.key('k1'), '2099-01-01');
assert.throws(() => s.c.register(AGENT, SERVICE, s.ref), /credential-reference/);
assert.equal(s.state(), 'valid');
// A revoked entry has no token, so a new grant registers again.
s.c.revoke(AGENT, SERVICE);
assert.equal(s.state(), 'revoked');
s.c.register(AGENT, SERVICE, s.ref);
assert.equal(s.state(), 'not-issued');
});
test('X3, X4: unregister drops an entry with no token and refuses one with a live token', (t) => {
const s = setup(t);
s.c.unregister(AGENT, SERVICE);
s.c.register(AGENT, SERVICE, s.ref);
s.c.unregister(AGENT, SERVICE);
assert.equal(s.state(), undefined);
assert.throws(() => s.c.issue(AGENT, SERVICE, s.key('k1'), '2099-01-01'), /credential-unknown/);
s.c.register(AGENT, SERVICE, s.ref);
s.c.issue(AGENT, SERVICE, s.key('k2'), '2099-01-01');
assert.throws(() => s.c.unregister(AGENT, SERVICE), /credential-reference/);
assert.equal(s.state(), 'valid');
s.c.revoke(AGENT, SERVICE);
assert.equal(existsSync(s.file), false);
s.c.unregister(AGENT, SERVICE);
assert.equal(s.state(), undefined);
});
test('X5: a claim holds the slot, so a second claim and a host.run refuse host-busy until release', async (t) => {
const s = setup(t);
s.c.register(AGENT, SERVICE, s.ref);
s.c.issue(AGENT, SERVICE, s.key('k1'), '2099-01-01');
const signal = s.c.claim(AGENT, SERVICE);
assert.equal(signal.aborted, false);
assert.equal(s.c.busy(AGENT, SERVICE), true);
assert.throws(() => s.c.claim(AGENT, SERVICE), /host-busy/);
await assert.rejects(s.run(), /host-busy/);
// A claim needs no reference: the executor claims before it registers a new key.
const other = s.c.claim('demo/pm', SERVICE);
s.c.release('demo/pm', SERVICE, other);
s.c.release(AGENT, SERVICE, signal);
assert.equal(s.c.busy(AGENT, SERVICE), false);
assert.deepEqual(await s.run(), { ok: true });
});
test('X5: a claim made while a host.run is in flight refuses host-busy', async (t) => {
const s = setup(t);
s.c.register(AGENT, SERVICE, s.ref);
s.c.issue(AGENT, SERVICE, s.key('k1'), '2099-01-01');
let finish;
const running = s.c.use(AGENT, SERVICE, () => new Promise((r) => (finish = r)));
assert.throws(() => s.c.claim(AGENT, SERVICE), /host-busy/);
finish({ ok: true });
assert.deepEqual(await running, { ok: true });
s.c.release(AGENT, SERVICE, s.c.claim(AGENT, SERVICE));
});
test('X5: a stale release leaves a later claim in place', (t) => {
const s = setup(t);
const first = s.c.claim(AGENT, SERVICE);
s.c.release(AGENT, SERVICE, first);
const second = s.c.claim(AGENT, SERVICE);
s.c.release(AGENT, SERVICE, first);
s.c.release(AGENT, SERVICE);
assert.equal(s.c.busy(AGENT, SERVICE), true);
s.c.release(AGENT, SERVICE, second);
assert.equal(s.c.busy(AGENT, SERVICE), false);
});
test('X5: abortClaim aborts a live claim and says pending, and revoke\'s abort reaches a claim too', (t) => {
const s = setup(t);
assert.deepEqual(s.c.abortClaim(AGENT, SERVICE), { pending: false });
const signal = s.c.claim(AGENT, SERVICE);
assert.deepEqual(s.c.abortClaim(AGENT, SERVICE), { pending: true });
assert.equal(signal.aborted, true);
// The slot stays held until the executor releases it.
assert.equal(s.c.busy(AGENT, SERVICE), true);
s.c.release(AGENT, SERVICE, signal);
s.c.register(AGENT, SERVICE, s.ref);
s.c.issue(AGENT, SERVICE, s.key('k1'), '2099-01-01');
const live = s.c.claim(AGENT, SERVICE);
s.c.revoke(AGENT, SERVICE);
assert.equal(live.aborted, true);
s.c.release(AGENT, SERVICE, live);
});
+970
View File
@@ -0,0 +1,970 @@
// Row 235 (#1741, #436 row b): the host grant executor (r6 X2, X3 failure table, X5) over a scratch sshd
// on 127.0.0.1 that this test starts. The sshd logs in only as the test's own user, so the installer
// and the granted agent share it. The installer's `list`, `add` and `remove` are stubs on the sshd's
// PATH that edit a second authorized-keys file, so the new key's login works only once `add` has run.
// The host writer itself is tested in host-tools.test.mjs (G16). Nothing runs as root and nothing
// reaches another host.
import test from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync, spawn } from 'node:child_process';
import { createHash } from 'node:crypto';
import { connect } from 'node:net';
import { appendFileSync, chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { tmpdir, userInfo } from 'node:os';
import { join } from 'node:path';
import { Broker, registrySha256, writeGrantEvent } from '../src/broker.mjs';
import { Credentials, parseHostKey } from '../src/credentials.mjs';
import { definitionSha256, fingerprint, grantKeys, grantTag, hostGrant, hostReference, sweepHostKeys } from '../src/host-grant.mjs';
import { hostExecutor, runHost } from '../src/host-run.mjs';
import { Store } from '../src/store.mjs';
import { grantDecision, hostRecord } from './host-fixtures.mjs';
const SSHD = '/usr/bin/sshd';
const USER = userInfo().username;
const AT = '2026-10-21T00:00:00.000Z';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const sha256 = (x) => createHash('sha256').update(x).digest('hex');
const dayFrom = (n) => new Date(Date.now() + n * 86400000).toISOString().slice(0, 10);
const keygen = (file) => execFileSync('ssh-keygen', ['-q', '-t', 'ed25519', '-N', '', '-C', 'test', '-f', file]);
const pubLine = (file) => readFileSync(file + '.pub', 'utf8').trim().split(' ').slice(0, 2).join(' ');
const readOr = (file, fallback) => (existsSync(file) ? readFileSync(file, 'utf8') : fallback);
const options = [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }];
const none = { authority: { withinRole: ['message.send'], crossRole: [] } };
const DEFINITIONS = {
'chief-of-staff': { roleVersion: 2, credentials: [] },
pm: { roleVersion: 2, credentials: [] },
ops: { roleVersion: 2, credentials: [{ service: 'host' }] },
};
const listening = (port) =>
new Promise((resolve) => {
const s = connect(port, '127.0.0.1');
s.on('connect', () => (s.destroy(), resolve(true)));
s.on('error', () => resolve(false));
});
async function until(fn, ms = 10000) {
const end = Date.now() + ms;
while (!fn()) {
if (Date.now() > end) throw new Error('timed out');
await sleep(20);
}
}
// The stubs read flag files in the test dir: fail-<verb> exits 1, noop-add exits 0 and writes nothing,
// hold-add waits for `release` (then exits 1 if `skip` is there), and hold-remove waits for `release`.
// wrap.sh logs every request. The sudo stub logs its arguments to sudo.log and runs the command as the
// test's user.
async function sshd(t, dir) {
if (!existsSync(SSHD)) throw new Error(`${SSHD} is missing: the executor tests need it and fail, never skip`);
keygen(join(dir, 'hostkey'));
keygen(join(dir, 'installer'));
writeFileSync(join(dir, 'authorized_keys'), readFileSync(join(dir, 'installer.pub')), { mode: 0o600 });
mkdirSync(join(dir, 'fake'));
const stub = (name, body) => writeFileSync(join(dir, 'fake', name), `#!/bin/sh\nD='${dir}'\n${body}\n`, { mode: 0o755 });
stub('list', '[ -e "$D/fail-list" ] && exit 1\n[ -e "$D/granted" ] && grep " mosaic-[0-9a-f]*$" "$D/granted"\nexit 0');
stub(
'add',
'if [ -e "$D/hold-add" ]; then : > "$D/holding"; while [ ! -e "$D/release" ]; do sleep 0.05; done; [ -e "$D/skip" ] && exit 1; fi\n' +
'[ -e "$D/fail-add" ] && exit 1\n[ -e "$D/noop-add" ] && exit 0\n' +
'grep -q " $4\\$" "$D/granted" 2>/dev/null && exit 0\necho "restrict $2 $3 $4" >> "$D/granted"',
);
stub('sudo', 'printf "%s\\n" "$*" >> "$D/sudo.log"\nshift 2\nexec "$@"');
stub('remove', 'if [ -e "$D/hold-remove" ]; then : > "$D/holding-remove"; while [ ! -e "$D/release" ]; do sleep 0.05; done; fi\n' +
'[ -e "$D/fail-remove" ] && exit 1\n[ -e "$D/granted" ] || exit 0\ngrep -v " $2\\$" "$D/granted" > "$D/granted.tmp"\nmv "$D/granted.tmp" "$D/granted"');
writeFileSync(
join(dir, 'wrap.sh'),
`#!/bin/sh\nprintf '%s\\n' "$SSH_ORIGINAL_COMMAND" >> '${dir}/requests.log'\nPATH=${dir}/fake:/usr/bin:/bin; export PATH\nexec /bin/sh -c "$SSH_ORIGINAL_COMMAND"\n`,
{ mode: 0o755 },
);
for (let attempt = 0; attempt < 5; attempt++) {
const port = 20000 + Math.floor(Math.random() * 20000);
writeFileSync(
join(dir, 'sshd_config'),
[`Port ${port}`, 'ListenAddress 127.0.0.1', `HostKey ${dir}/hostkey`, `AuthorizedKeysFile ${dir}/authorized_keys ${dir}/granted`,
`PidFile ${dir}/sshd.pid`, 'UsePAM no', 'StrictModes no', 'PasswordAuthentication no', 'KbdInteractiveAuthentication no',
`ForceCommand ${dir}/wrap.sh`, ''].join('\n'),
);
const daemon = spawn(SSHD, ['-D', '-f', join(dir, 'sshd_config'), '-E', join(dir, 'sshd.log')], { stdio: 'ignore' });
t.after(() => daemon.kill('SIGKILL'));
const deadline = Date.now() + 3000;
while (Date.now() < deadline && daemon.exitCode === null) {
if (await listening(port)) return port;
await sleep(30);
}
daemon.kill('SIGKILL');
}
throw new Error('scratch sshd did not start');
}
async function setup(t, opts = {}) {
const dir = mkdtempSync(join(tmpdir(), 'g235x-'));
t.after(() => rmSync(dir, { recursive: true, force: true }));
const port = await sshd(t, dir);
const configDir = join(dir, 'cfg');
const keyDir = join(configDir, 'secrets', 'hosts', 'demo', 'web1');
mkdirSync(keyDir, { recursive: true, mode: 0o700 });
for (const d of [configDir, join(configDir, 'secrets'), join(configDir, 'secrets', 'hosts'), join(configDir, 'secrets', 'hosts', 'demo'), keyDir]) chmodSync(d, 0o700);
// opts.enroll: no host record and no installer key, so the test enrolls the host itself (G22). opts.wrap
// changes the host API the broker gets.
if (!opts.enroll) writeFileSync(join(keyDir, 'installer'), readFileSync(join(dir, 'installer')), { mode: 0o600 });
const runRoot = join(configDir, 'secrets', 'run');
const storeRoot = join(configDir, 'secrets', 'agents');
mkdirSync(join(dir, 'data'), { mode: 0o700 });
const store = new Store(join(dir, 'data'));
const credentials = new Credentials({ references: {}, storeRoot, dataRoot: join(dir, 'data') });
t.after(() => {
credentials.close();
store.close();
});
const business = {
id: 'demo',
human: 'jason',
arbiters: { technical: 'pm', delivery: 'pm' },
roles: {
chief: { definition: 'chief-of-staff', ...none }, pm: { definition: 'pm', ...none }, ops: { definition: 'ops', ...none },
...(opts.assistant ? { assistant: { definition: 'assistant', ...none } } : {}),
},
definitions: { ...structuredClone(DEFINITIONS), ...(opts.assistant ? { assistant: { roleVersion: 2, credentials: [] } } : {}) },
vars: { 'bus.tester': true },
};
const broker = new Broker({
store,
businesses: { demo: business },
secretCheck: (value) => credentials.assertClean(value),
hostGrant: (opts.wrap ?? ((h) => h))(hostGrant({ configDir, runRoot, installerUser: USER, scan: async () => [pubLine(join(dir, 'hostkey'))] })),
hostKeys: grantKeys({ credentials, storeRoot }),
});
const caps = {};
for (const role of ['chief', 'pm']) {
caps[role] = broker.bindLaunch({ business: 'demo', role, run: role + '-run', harness: 'pi', address: role });
broker.request(caps[role], { verb: 'role.claim' });
}
caps.human = broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const entry = { address: '127.0.0.1', port, user: USER, hostKeys: [pubLine(join(dir, 'hostkey'))] };
if (!opts.enroll) hostRecord(store, { host: 'web1', entry });
const call = (who, verb, args = {}) => broker.request(caps[who], { verb, args });
const raise = (grant = {}) =>
call('chief', 'decision.raise', {
action: 'credential.mint', question: 'Grant ops web1?', options, recommendation: 'yes', blocking: false,
grant: { method: 'host', agent: 'ops', host: 'web1', commands: [['systemctl', 'status']], rotateBy: dayFrom(30), ...grant },
});
const grantEvents = () =>
store
.all("SELECT body FROM events WHERE business='demo' AND kind='action.allowed' AND actor_run='grant' ORDER BY seq")
.map((e) => JSON.parse(e.body))
.filter((b) => b.action?.startsWith('grant.'));
const ended = (decision) => grantEvents().find((e) => e.grantDecision === decision && ['grant.applied', 'grant.failed'].includes(e.action));
// The human's cli yes starts the executor; this waits for its end.
const approve = async (grant) => {
const d = await raise(grant);
call('human', 'decision.resolve', { id: d.id, choice: 'yes' });
await until(() => ended(d.id));
return { id: d.id, end: ended(d.id) };
};
// A decision put in by SQL, with R4's body built here so one field can be broken.
const fixtureGrant = (over = {}, close) => {
const commands = over.commands ?? [['uptime']];
const grant = {
method: 'host', agent: 'ops', host: 'web1', commands, commandsSha256: sha256(JSON.stringify(commands)), rotateBy: dayFrom(30),
registrySha256: registrySha256({ ...entry, sudo: true }), definitionSha256: definitionSha256(DEFINITIONS.ops), ...over,
};
return grantDecision(store, { grant, ...(close !== undefined ? { close } : {}) });
};
const tag = grantTag('demo', 'ops', 'web1');
const keyFile = join(storeRoot, 'demo', 'ops', 'host.web1');
const record = () => store.get("SELECT * FROM host_grants WHERE business='demo' AND agent='ops' AND host='web1'");
const requests = () => readOr(join(dir, 'requests.log'), '').split('\n').filter(Boolean);
const granted = () => readOr(join(dir, 'granted'), '').split('\n').filter(Boolean);
const state = () => credentials.status().find((e) => e.instance === 'demo/ops' && e.service === 'host.web1')?.state;
// The executor's notices, apart from the close notice the resolve sends the chief.
const messages = (decision) =>
store.all("SELECT to_role, body FROM messages WHERE business=? AND decision=? AND from_role='_bus' AND instr(body, ?) ORDER BY rowid", 'demo', decision, `(decision ${decision})`);
const flag = (name) => writeFileSync(join(dir, name), '');
return { caps, dir, port, entry, store, broker, credentials, call, raise, approve, fixtureGrant, grantEvents, ended, tag, keyFile, record, requests, granted, state, messages, flag, runRoot, storeRoot, configDir, business };
}
// A restart as runtime.mjs does it, on the same store: the key sweep, Credentials with a reference per
// record, a new broker, reconcileGrants before the socket opens, then reconcileHosts. `busy` is the
// slot between the two.
async function restart(t, f, business = f.business) {
const granted = new Set();
const references = {};
for (const g of f.store.all('SELECT business, agent, host, rotate_by FROM host_grants')) {
granted.add(`${g.business}/${g.agent}/${g.host}`);
(references[`${g.business}/${g.agent}`] ??= {})['host.' + g.host] = hostReference(f.storeRoot, g.business, g.agent, g.host, g.rotate_by);
}
const swept = sweepHostKeys(f.storeRoot, ['demo'], granted);
const credentials = new Credentials({ references, storeRoot: f.storeRoot, dataRoot: join(f.dir, 'data') });
t.after(() => credentials.close());
const broker = new Broker({
store: f.store,
businesses: { demo: business },
secretCheck: (value) => credentials.assertClean(value),
hostGrant: hostGrant({ configDir: f.configDir, runRoot: f.runRoot, installerUser: USER }),
hostKeys: grantKeys({ credentials, storeRoot: f.storeRoot }),
});
const now = broker.reconcileGrants();
const busy = credentials.busy('demo/ops', 'host.web1');
const later = await broker.reconcileHosts();
const state = credentials.status().find((e) => e.instance === 'demo/ops' && e.service === 'host.web1')?.state;
return { swept, credentials, broker, now, busy, later, state };
}
// The business after the human moved ops's instance to the creative `research` definition, which has
// ops's content, so the grant's definitionSha256 still matches and only the creative check refuses.
const creativeOps = (business) => ({
...structuredClone(business),
roles: { ...structuredClone(business.roles), ops: { ...structuredClone(business.roles.ops), definition: 'research' } },
definitions: { ...structuredClone(business.definitions), research: structuredClone(DEFINITIONS.ops) },
});
// A decision the human approved and a killed bus child spent, with its grant.step events up to `steps`.
function interrupted(f, steps, over = {}) {
const id = f.fixtureGrant(over);
writeGrantEvent(f.store, AT, 'demo', { operation: 'grant.host', decision: id }, id);
for (const [step, extra = {}] of steps)
writeGrantEvent(f.store, AT, 'demo', { action: 'grant.step', grantDecision: id, agent: 'ops', host: 'web1', user: USER, tag: f.tag, step, ...extra }, 'web1');
return id;
}
// The line an interrupted add left: a key nobody holds, under the grant's tag.
function leftLine(f) {
keygen(join(f.dir, 'orphan'));
writeFileSync(join(f.dir, 'granted'), `restrict ${pubLine(join(f.dir, 'orphan'))} ${f.tag}\n`);
}
test('G7: an Approve installs the tagged line, logs in with the new key, and writes the key file, record and grant.applied', async (t) => {
const f = await setup(t);
const { id, end } = await f.approve();
assert.equal(end.action, 'grant.applied');
const key = parseHostKey(readFileSync(f.keyFile, 'utf8'));
assert.deepEqual(end, {
action: 'grant.applied', grantDecision: id, agent: 'ops', host: 'web1', user: USER, tag: f.tag,
fingerprint: key.fingerprint, commandsSha256: sha256(JSON.stringify([['systemctl', 'status']])),
});
assert.equal(f.tag.length, 39);
assert.deepEqual(f.requests(), [`list ${USER}`, `add ${USER} ${key.publicLine} ${f.tag}`, 'true']);
assert.deepEqual(f.granted(), [`restrict ${key.publicLine} ${f.tag}`]);
assert.equal(statSync(f.keyFile).mode & 0o777, 0o600);
assert.deepEqual({ ...f.record() }, {
business: 'demo', agent: 'ops', host: 'web1', commands: '[["systemctl","status"]]', rotate_by: dayFrom(30), tag: f.tag, decision: id,
});
assert.equal(f.state(), 'valid');
assert.deepEqual(
f.grantEvents().filter((e) => e.grantDecision === id).map((e) => e.action + (e.step ? ' ' + e.step : '')),
['grant.step install', 'grant.step commit', 'grant.applied'],
);
assert.equal(f.grantEvents().find((e) => e.step === 'commit').previous, null);
assert.deepEqual(f.messages(id).map((m) => m.to_role), ['human', 'chief']);
// The key file logs in as the user, and the slot is free again.
const out = await runHost({ root: f.runRoot, hostId: 'web1', entry: f.entry, key, timeout: 10, command: 'true' });
assert.equal(out.exit, 0);
f.credentials.release('demo/ops', 'host.web1', f.credentials.claim('demo/ops', 'host.web1'));
});
test('G7: a commands change on a live grant updates the record in one transaction and keeps the key and line', async (t) => {
const f = await setup(t);
const first = await f.approve();
const before = readFileSync(f.keyFile, 'utf8');
const asked = f.requests().length;
const { id, end } = await f.approve({ commands: [['uptime'], ['systemctl', 'status']] });
assert.equal(end.action, 'grant.applied');
assert.equal(end.fingerprint, null);
assert.equal(f.requests().length, asked);
assert.equal(readFileSync(f.keyFile, 'utf8'), before);
assert.equal(f.record().commands, '[["uptime"],["systemctl","status"]]');
assert.equal(f.record().decision, id);
assert.deepEqual(f.grantEvents().find((e) => e.grantDecision === id && e.step === 'commit').previous, [['systemctl', 'status']]);
assert.equal(f.grantEvents().some((e) => e.grantDecision === id && e.step === 'install'), false);
assert.equal(f.state(), 'valid');
assert.notEqual(first.id, id);
});
test('G8: a failure at install or verify removes the line and leaves no record, key file or reference', async (t) => {
for (const flag of ['fail-list', 'fail-add', 'noop-add']) {
const f = await setup(t);
f.flag(flag);
const { id, end } = await f.approve();
assert.equal(end.code, 'host-failed', flag);
assert.equal(end.tag, f.tag);
assert.equal('hostLineLeft' in end, false);
assert.equal(f.requests().at(-1), `remove ${USER} ${f.tag}`, flag);
assert.deepEqual(f.granted(), []);
assert.equal(f.record(), undefined);
assert.equal(existsSync(f.keyFile), false);
assert.equal(f.state(), undefined);
assert.deepEqual(f.grantEvents().filter((e) => e.grantDecision === id).map((e) => e.action), ['grant.step', 'grant.failed']);
assert.match(f.messages(id)[0].body, /failed: host-failed\.$/);
}
});
test('G8: a failed remove adds hostLineLeft and tells the human to sweep', async (t) => {
const f = await setup(t);
f.flag('noop-add');
f.flag('fail-remove');
const { id, end } = await f.approve();
assert.equal(end.code, 'host-failed');
assert.equal(end.hostLineLeft, f.tag);
assert.match(f.messages(id)[0].body, new RegExp(`tagged ${f.tag} may be left on the host\\. Run: mosaic hosts sweep web1$`));
});
test('G8: a failure in step 5 before commit unlinks the key file, drops the reference and removes the line', async (t) => {
const f = await setup(t);
// A file where the agent's store dir goes makes issue refuse credential-file.
mkdirSync(f.storeRoot, { recursive: true, mode: 0o700 });
writeFileSync(join(f.storeRoot, 'demo'), '');
const { id, end } = await f.approve();
assert.equal(end.code, 'credential-file');
assert.equal(f.requests().at(-1), `remove ${USER} ${f.tag}`);
assert.deepEqual(f.granted(), []);
assert.equal(f.record(), undefined);
assert.equal(f.state(), undefined);
assert.deepEqual(f.grantEvents().filter((e) => e.grantDecision === id).map((e) => e.action + (e.step ? ' ' + e.step : '')), [
'grant.step install', 'grant.step commit', 'grant.failed',
]);
});
// A store fault at commit (a full disk, an I/O error, a busy timeout): the record's INSERT throws once,
// after issue wrote the key file. The undo unlinks it, drops the reference and removes the line.
test('G8: a store fault in the commit transaction ends host-failed with no record, key file, reference or line', async (t) => {
const f = await setup(t);
const run = f.store.run.bind(f.store);
let faults = 0;
f.store.run = (sql, ...args) => {
if (sql.startsWith('INSERT INTO host_grants') && faults++ === 0) throw Object.assign(new Error('database or disk is full'), { code: 'SQLITE_FULL' });
return run(sql, ...args);
};
const { id, end } = await f.approve();
assert.equal(faults, 1);
assert.equal(end.code, 'host-failed');
assert.equal(f.record(), undefined);
assert.equal(existsSync(f.keyFile), false);
assert.equal(f.state(), undefined);
assert.equal(f.requests().at(-1), `remove ${USER} ${f.tag}`);
assert.deepEqual(f.granted(), []);
assert.deepEqual(f.grantEvents().filter((e) => e.grantDecision === id).map((e) => e.action + (e.step ? ' ' + e.step : '')), [
'grant.step install', 'grant.step commit', 'grant.failed',
]);
});
test('G8: a failed step 1 check spends the decision and runs nothing remote', async (t) => {
const f = await setup(t);
const d = await f.raise();
f.store.run("UPDATE hosts SET state='pending-install' WHERE business='demo' AND host='web1'");
f.call('human', 'decision.resolve', { id: d.id, choice: 'yes' });
await until(() => f.ended(d.id));
assert.equal(f.ended(d.id).code, 'host-not-ready');
assert.deepEqual(f.requests(), []);
assert.equal(await f.broker.runHostGrant('demo', d.id), 'decision-consumed');
});
test('G9: a registry, definition or commands change between raise and Approve ends decision-mismatch before any remote step', async (t) => {
const f = await setup(t);
for (const over of [
{ registrySha256: registrySha256({ ...f.entry, port: f.entry.port + 1, sudo: true }) },
{ registrySha256: registrySha256({ ...f.entry, sudo: false }) },
{ definitionSha256: definitionSha256({ ...DEFINITIONS.ops, roleVersion: 3 }) },
{ commandsSha256: sha256(JSON.stringify([['uptime'], ['reboot']])) },
]) {
const id = f.fixtureGrant(over);
assert.equal(await f.broker.runHostGrant('demo', id), 'decision-mismatch');
assert.equal(f.ended(id).code, 'decision-mismatch');
}
// The control passes step 1 and goes remote.
const id = f.fixtureGrant();
assert.equal(await f.broker.runHostGrant('demo', id), 'applied');
assert.deepEqual(f.requests().slice(0, 1), [`list ${USER}`]);
assert.equal(f.requests().length, 3);
});
test('G9: an agent no longer a role or no longer declaring host ends with its code, spent', async (t) => {
const f = await setup(t);
for (const [over, code] of [[{ agent: 'nobody' }, 'unknown-role'], [{ agent: 'pm' }, 'host-not-declared'], [{ host: 'web9' }, 'host-unknown']]) {
const id = f.fixtureGrant(over);
assert.equal(await f.broker.runHostGrant('demo', id), code);
assert.equal(f.ended(id).code, code);
assert.equal(await f.broker.runHostGrant('demo', id), 'decision-consumed');
}
assert.deepEqual(f.requests(), []);
});
test('G10: a second start refuses decision-consumed, and an unresolved or agent-resolved decision not-authorized, writing nothing', async (t) => {
const f = await setup(t);
const { id } = await f.approve();
const count = () => f.store.get("SELECT count(*) AS n FROM events WHERE business='demo'").n;
// Each fixture decision writes one events row of its own, so the count starts after both.
const fixtures = count();
const open = f.fixtureGrant({}, null);
const byAgent = f.fixtureGrant({}, { op: 'resolved', by: 'pm', choice: 'yes', via: 'broker' });
assert.equal(count(), fixtures + 2);
const before = count();
assert.equal(await f.broker.runHostGrant('demo', id), 'decision-consumed');
assert.equal(await f.broker.runHostGrant('demo', open), 'not-authorized');
assert.equal(await f.broker.runHostGrant('demo', byAgent), 'not-authorized');
assert.equal(count(), before);
// The enrollment executor won't take a host grant either.
assert.equal(await f.broker.runEnrollment('demo', f.fixtureGrant()), 'not-authorized');
});
test('G15: no event, message or decision holds the private key', async (t) => {
const f = await setup(t);
await f.approve();
const pem = readFileSync(f.keyFile, 'utf8');
const body = pem.split('\n').slice(1, -2).join('');
const text = [
...f.store.all('SELECT body FROM events').map((r) => r.body),
...f.store.all('SELECT body FROM messages').map((r) => r.body),
...f.store.all('SELECT question FROM decisions').map((r) => r.question),
].join('\n');
assert.ok(body.length > 64);
for (const line of pem.split('\n').slice(1, -2).filter((l) => l.length >= 20)) assert.equal(text.includes(line), false);
assert.equal(text.includes('PRIVATE KEY'), false);
assert.equal(readOr(join(f.dir, 'requests.log'), '').includes('PRIVATE KEY'), false);
});
test('G23: a revoke during install ends grant.failed revoked with no key file, no grant and no line', async (t) => {
const f = await setup(t);
f.flag('hold-add');
const d = await f.raise();
f.call('human', 'decision.resolve', { id: d.id, choice: 'yes' });
await until(() => existsSync(join(f.dir, 'holding')));
assert.deepEqual(f.credentials.abortClaim('demo/ops', 'host.web1'), { pending: true });
await until(() => f.ended(d.id));
// The held add then exits without writing, as a killed session's would.
f.flag('skip');
f.flag('release');
assert.equal(f.ended(d.id).code, 'revoked');
assert.equal(f.requests().at(-1), `remove ${USER} ${f.tag}`);
assert.deepEqual(f.granted(), []);
assert.equal(f.record(), undefined);
assert.equal(existsSync(f.keyFile), false);
assert.equal(f.state(), undefined);
assert.equal(f.credentials.busy('demo/ops', 'host.web1'), false);
});
test('G23: a revoke recorded after the raise and before the start ends step 1 revoked, and a held slot ends host-busy', async (t) => {
const f = await setup(t);
const revoked = f.fixtureGrant();
writeGrantEvent(f.store, AT, 'demo', { action: 'grant.revoked', agent: 'ops', host: 'web1', tag: f.tag, hostLine: 'pending', by: 'jason' }, 'web1');
assert.equal(await f.broker.runHostGrant('demo', revoked), 'revoked');
// A revoke of another agent or host doesn't count, and one before the raise doesn't either.
writeGrantEvent(f.store, AT, 'demo', { action: 'grant.revoked', agent: 'pm', host: 'web1', tag: f.tag, hostLine: 'pending', by: 'jason' }, 'web1');
const busy = f.fixtureGrant();
const signal = f.credentials.claim('demo/ops', 'host.web1');
assert.equal(await f.broker.runHostGrant('demo', busy), 'host-busy');
assert.equal(f.ended(busy).code, 'host-busy');
assert.match(f.messages(busy)[0].body, /failed: host-busy\.$/);
assert.equal(await f.broker.runHostGrant('demo', busy), 'decision-consumed');
f.credentials.release('demo/ops', 'host.web1', signal);
assert.deepEqual(f.requests(), []);
assert.equal(await f.broker.runHostGrant('demo', f.fixtureGrant()), 'applied');
});
test('G24: names at 64 characters with . and _ give distinct tags of the fixed length', () => {
const long = (c) => ('a' + c).repeat(32);
const tags = [
grantTag('demo', long('.'), 'web1'),
grantTag('demo', long('_'), 'web1'),
grantTag('demo.' + 'x'.repeat(59), 'ops', 'web1'),
grantTag('demo', 'ops', 'web1'),
grantTag('demo', 'ops', 'web2'),
grantTag('other', 'ops', 'web1'),
];
assert.equal(new Set(tags).size, tags.length);
for (const tag of tags) assert.match(tag, /^mosaic-[0-9a-f]{32}$/);
});
test('G11: a restart after install removes the line, then ends interrupted with the tag, holding the slot until then', async (t) => {
const f = await setup(t);
const id = interrupted(f, [['install']]);
leftLine(f);
const r = await restart(t, f);
assert.deepEqual(r.now, []);
assert.equal(r.busy, true);
assert.deepEqual(r.later, [id]);
assert.equal(r.credentials.busy('demo/ops', 'host.web1'), false);
assert.deepEqual(f.requests(), [`remove ${USER} ${f.tag}`]);
assert.deepEqual(f.granted(), []);
assert.deepEqual(f.ended(id), { action: 'grant.failed', grantDecision: id, agent: 'ops', host: 'web1', code: 'interrupted', tag: f.tag });
assert.deepEqual(f.messages(id).map((m) => m.to_role), ['human', 'chief']);
assert.match(f.messages(id)[0].body, /failed: interrupted\.$/);
assert.equal(f.record(), undefined);
assert.equal(r.state, undefined);
// It never runs late, and a second start finds it ended.
assert.equal(await r.broker.runHostGrant('demo', id), 'decision-consumed');
const again = await restart(t, f);
assert.deepEqual([again.now, again.later], [[], []]);
});
test('G11: a restart after a new key\'s commit, with the key file written, ends with no key file, record, reference or line', async (t) => {
const f = await setup(t);
const id = interrupted(f, [['install'], ['commit', { previous: null }]]);
leftLine(f);
mkdirSync(join(f.storeRoot, 'demo', 'ops'), { recursive: true, mode: 0o700 });
chmodSync(f.storeRoot, 0o700);
chmodSync(join(f.storeRoot, 'demo'), 0o700);
keygen(f.keyFile);
const r = await restart(t, f);
assert.deepEqual(r.swept, [{ instance: 'demo/ops', service: 'host.web1' }]);
assert.equal(existsSync(f.keyFile), false);
// ssh-keygen's .pub isn't a host.<id> name, so the sweep leaves it.
assert.equal(existsSync(f.keyFile + '.pub'), true);
assert.deepEqual(r.later, [id]);
assert.deepEqual(f.granted(), []);
assert.equal(f.record(), undefined);
assert.equal(r.state, undefined);
assert.equal(f.ended(id).code, 'interrupted');
});
test('G11: a restart after a commands change\'s commit leaves the record as it was and asks nothing of the host', async (t) => {
const f = await setup(t);
const first = await f.approve();
const before = { record: { ...f.record() }, key: readFileSync(f.keyFile, 'utf8'), granted: f.granted(), asked: f.requests().length };
const id = interrupted(f, [['commit', { previous: [['systemctl', 'status']] }]], { commands: [['uptime']] });
const r = await restart(t, f);
assert.deepEqual(r.now, [id]);
assert.equal(r.busy, false);
assert.deepEqual(r.later, []);
assert.deepEqual({ ...f.record() }, before.record);
assert.equal(f.record().decision, first.id);
assert.equal(readFileSync(f.keyFile, 'utf8'), before.key);
assert.deepEqual(f.granted(), before.granted);
assert.equal(f.requests().length, before.asked);
assert.equal(r.state, 'valid');
assert.deepEqual(f.ended(id), { action: 'grant.failed', grantDecision: id, agent: 'ops', host: 'web1', code: 'interrupted', tag: f.tag });
// The applied grant isn't touched.
assert.equal(f.ended(first.id).action, 'grant.applied');
});
test('G11 (B2): a restart before step 1, spent or not yet spent, ends interrupted and nothing runs later', async (t) => {
const f = await setup(t);
const spent = interrupted(f, []);
const open = f.fixtureGrant();
const r = await restart(t, f);
assert.deepEqual(r.now, [spent, open]);
assert.deepEqual(r.later, []);
assert.deepEqual(f.requests(), []);
for (const id of [spent, open]) {
assert.equal(f.ended(id).code, 'interrupted');
assert.equal(f.ended(id).tag, f.tag);
assert.equal(f.messages(id).length, 2);
assert.equal(await r.broker.runHostGrant('demo', id), 'decision-consumed');
}
// The open one was spent once, by the reconcile.
assert.equal(f.store.get("SELECT count(*) AS n FROM events WHERE kind='action.allowed' AND actor_run='grant' AND json_extract(body,'$.operation')='grant.host' AND json_extract(body,'$.decision')=?", open).n, 1);
assert.equal(f.record(), undefined);
});
test('G11: a remote undo that fails ends interrupted with hostLineLeft and the sweep hint', async (t) => {
const f = await setup(t);
const id = interrupted(f, [['install']]);
leftLine(f);
f.flag('fail-remove');
const r = await restart(t, f);
assert.deepEqual(r.later, [id]);
assert.deepEqual(f.ended(id), { action: 'grant.failed', grantDecision: id, agent: 'ops', host: 'web1', code: 'interrupted', tag: f.tag, hostLineLeft: f.tag });
assert.match(f.messages(id)[0].body, new RegExp(`tagged ${f.tag} may be left on the host\\. Run: mosaic hosts sweep web1$`));
assert.equal(f.granted().length, 1);
assert.equal(r.credentials.busy('demo/ops', 'host.web1'), false);
// The same with the host record gone: nothing to reach, so the line is left.
const g = await setup(t);
const id2 = interrupted(g, [['install']]);
g.store.transaction(() => {
g.store.run("DELETE FROM hosts WHERE business='demo' AND host='web1'");
writeGrantEvent(g.store, AT, 'demo', { action: 'grant.host.removed', host: 'web1', by: 'jason' }, 'web1');
});
const r2 = await restart(t, g);
assert.deepEqual(r2.later, [id2]);
assert.equal(g.ended(id2).hostLineLeft, g.tag);
assert.deepEqual(g.requests(), []);
});
test('G11: a restart leaves a finished grant alone, and the reconcile adds nothing for it', async (t) => {
const f = await setup(t);
const { id } = await f.approve();
const events = f.grantEvents().length;
const asked = f.requests().length;
const r = await restart(t, f);
assert.deepEqual([r.swept, r.now, r.later], [[], [], []]);
assert.equal(f.grantEvents().length, events);
assert.equal(f.requests().length, asked);
assert.equal(existsSync(f.keyFile), true);
assert.equal(r.state, 'valid');
assert.equal(f.ended(id).action, 'grant.applied');
});
test('N1 (G27): the start sweep unlinks host.<id> files with no record in loaded businesses only, and refuses a store dir that is not private', (t) => {
const root = mkdtempSync(join(tmpdir(), 'g235s-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const storeRoot = join(root, 'agents');
assert.deepEqual(sweepHostKeys(storeRoot, ['demo'], new Set()), []);
assert.deepEqual(sweepHostKeys(null, ['demo'], new Set()), []);
const put = (business, agent, name) => {
const d = join(storeRoot, business, agent);
mkdirSync(d, { recursive: true, mode: 0o700 });
for (const x of [storeRoot, join(storeRoot, business), d]) chmodSync(x, 0o700);
writeFileSync(join(d, name), 'k', { mode: 0o600 });
return join(d, name);
};
const kept = put('demo', 'ops', 'host.web1');
const gone = put('demo', 'ops', 'host.web2');
const goneToo = put('demo', 'pm', 'host.web1');
const other = [put('demo', 'ops', 'gitea'), put('demo', 'ops', 'host.Web3'), put('demo', 'ops', 'host.'), put('other', 'ops', 'host.web1')];
const swept = sweepHostKeys(storeRoot, ['demo'], new Set(['demo/ops/web1']));
assert.deepEqual(swept.sort((a, b) => a.instance.localeCompare(b.instance) || a.service.localeCompare(b.service)), [
{ instance: 'demo/ops', service: 'host.web2' },
{ instance: 'demo/pm', service: 'host.web1' },
]);
assert.equal(existsSync(kept), true);
assert.equal(existsSync(gone), false);
assert.equal(existsSync(goneToo), false);
for (const file of other) assert.equal(existsSync(file), true, file);
chmodSync(join(storeRoot, 'demo', 'ops'), 0o750);
assert.throws(() => sweepHostKeys(storeRoot, ['demo'], new Set()), /credential-file/);
assert.equal(existsSync(kept), true);
});
// Row 235 step 5 (r6 section 6): an assistant thread in the business, for G26.
function assistant(f) {
const project = f.call('human', 'project.create', { name: 'hosts' }).id;
const thread = f.call('human', 'thread.create', { project, name: 'main' }).id;
f.caps.assistant = f.broker.bindLaunch({ business: 'demo', role: 'assistant', run: 'a-run', harness: 'pi', thread });
f.broker.request(f.caps.assistant, { verb: 'role.claim' });
}
const refusesWith = async (p, code) => assert.rejects(Promise.resolve().then(() => p), (e) => (assert.equal(e.code, code), true));
const revokeBodies = (f) => f.grantEvents().filter((e) => ['grant.revoked', 'grant.revoke.closed'].includes(e.action));
test('G12: the human\'s hosts.revoke deletes the record, the key file, the reference and the line, with a closed marker', async (t) => {
const f = await setup(t);
await f.approve();
const asked = f.requests().length;
const r = await f.call('human', 'hosts.revoke', { agent: 'ops', host: 'web1', reason: 'done' });
assert.deepEqual(r, { agent: 'ops', host: 'web1', tag: f.tag, granted: true, keyFile: 'removed', hostLine: 'removed' });
assert.equal(f.record(), undefined);
assert.equal(existsSync(f.keyFile), false);
assert.notEqual(f.state(), 'valid');
assert.deepEqual(f.granted(), []);
assert.deepEqual(f.requests().slice(asked), [`remove ${USER} ${f.tag}`]);
const [open, closed] = revokeBodies(f);
assert.equal(typeof open.revoke, 'string');
assert.deepEqual(open, { action: 'grant.revoked', revoke: open.revoke, agent: 'ops', host: 'web1', tag: f.tag, hostLine: 'pending', granted: true, reason: 'done', by: 'jason' });
assert.deepEqual(closed, { action: 'grant.revoke.closed', revoke: open.revoke, agent: 'ops', host: 'web1', tag: f.tag, hostLine: 'removed', by: 'jason' });
const [row] = await f.call('human', 'hosts.status');
assert.deepEqual([row.revoking, row.hostLineLeft], [[], []]);
// A restart finds the revoke closed and does nothing for it.
const again = await restart(t, f);
assert.deepEqual([again.swept, again.now, again.later], [[], [], []]);
assert.equal(again.state, undefined);
});
test('G12 (N4): a revoke with no record still removes the computed tag\'s line, with granted false; bad args refuse', async (t) => {
const f = await setup(t);
leftLine(f);
const r = await f.call('human', 'hosts.revoke', { agent: 'ops', host: 'web1', reason: 'stale line' });
assert.deepEqual(r, { agent: 'ops', host: 'web1', tag: f.tag, granted: false, keyFile: 'removed', hostLine: 'removed' });
assert.deepEqual(f.granted(), []);
for (const args of [{ agent: 'ops', host: 'web1' }, { agent: 'ops', host: 'web1', reason: '' }, { agent: 'ops', host: 'web1', reason: 'x'.repeat(501) },
{ agent: 'Ops!', host: 'web1', reason: 'r' }, { agent: 'ops', host: 'Web1', reason: 'r' }, { agent: 'ops', host: 'web1', reason: 'r', extra: 1 }])
await refusesWith(f.call('human', 'hosts.revoke', args), 'invalid-request');
await refusesWith(f.call('human', 'hosts.revoke', { agent: 'ops', host: 'web9', reason: 'r' }), 'host-unknown');
assert.equal(revokeBodies(f).length, 2);
});
test('G13: a revoke during install aborts the executor, which ends revoked, and the line goes', async (t) => {
const f = await setup(t);
f.flag('hold-add');
const d = await f.raise();
f.call('human', 'decision.resolve', { id: d.id, choice: 'yes' });
await until(() => existsSync(join(f.dir, 'holding')));
const r = await f.call('human', 'hosts.revoke', { agent: 'ops', host: 'web1', reason: 'stop' });
assert.equal(r.granted, false);
await until(() => f.ended(d.id));
f.flag('skip');
f.flag('release');
assert.equal(f.ended(d.id).code, 'revoked');
assert.deepEqual(f.granted(), []);
assert.equal(f.record(), undefined);
assert.equal(existsSync(f.keyFile), false);
assert.equal(f.credentials.busy('demo/ops', 'host.web1'), false);
// A decision raised before the revoke and started after it ends revoked at step 1.
const late = await f.raise();
writeGrantEvent(f.store, AT, 'demo', { action: 'grant.revoked', revoke: 'x', agent: 'ops', host: 'web1', tag: f.tag, hostLine: 'pending', by: 'jason' }, 'web1');
f.call('human', 'decision.resolve', { id: late.id, choice: 'yes' });
await until(() => f.ended(late.id));
assert.equal(f.ended(late.id).code, 'revoked');
});
test('G26: the human, the assistant and the tester revoke; the chief and a pm refuse; sweep is the human\'s or the tester\'s', async (t) => {
const f = await setup(t, { assistant: true });
assistant(f);
f.caps.tester = f.broker.bindTester({ business: 'demo' });
f.caps.reader = f.broker.bindReader({ business: 'demo' });
const args = { agent: 'ops', host: 'web1', reason: 'r' };
await refusesWith(f.call('chief', 'hosts.revoke', args), 'revoke-refused');
await refusesWith(f.call('pm', 'hosts.revoke', args), 'revoke-refused');
await refusesWith(f.call('reader', 'hosts.revoke', args), 'read-only');
for (const who of ['chief', 'pm', 'assistant']) await refusesWith(f.call(who, 'hosts.sweep', { host: 'web1' }), 'human-required');
assert.equal(revokeBodies(f).length, 0);
assert.equal((await f.call('assistant', 'hosts.revoke', args)).agent, 'ops');
assert.equal((await f.call('tester', 'hosts.revoke', args)).agent, 'ops');
assert.deepEqual(revokeBodies(f).filter((e) => e.action === 'grant.revoked').map((e) => e.by), ['assistant', 'tester']);
assert.deepEqual((await f.call('tester', 'hosts.sweep', { host: 'web1' })).removed, []);
});
test('G27: a restart after step 1 sweeps the key file and runs step 3 once the bus is up; status shows the revoke until then', async (t) => {
const f = await setup(t);
await f.approve();
const revoke = 'b6d0f0c4-6c3e-4c35-9d55-1f3c2b8f0a11';
// Step 1 as hosts.revoke writes it, then the bus dies.
f.store.transaction(() => {
f.store.run("DELETE FROM host_grants WHERE business='demo' AND agent='ops' AND host='web1'");
writeGrantEvent(f.store, AT, 'demo', { action: 'grant.revoked', revoke, agent: 'ops', host: 'web1', tag: f.tag, hostLine: 'pending', granted: true, reason: 'r', by: 'jason' }, 'web1');
});
assert.deepEqual((await f.call('human', 'hosts.status'))[0].revoking, [f.tag]);
const r = await restart(t, f);
assert.deepEqual(r.swept, [{ instance: 'demo/ops', service: 'host.web1' }]);
assert.equal(existsSync(f.keyFile), false);
assert.deepEqual(r.now, []);
assert.deepEqual(r.later, [revoke]);
assert.deepEqual(f.granted(), []);
assert.equal(r.state, undefined);
assert.deepEqual(revokeBodies(f).at(-1), { action: 'grant.revoke.closed', revoke, agent: 'ops', host: 'web1', tag: f.tag, hostLine: 'removed', by: 'jason' });
assert.deepEqual((await r.broker.request(r.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }), { verb: 'hosts.status', args: {} }))[0].revoking, []);
const again = await restart(t, f);
assert.deepEqual([again.swept, again.later], [[], []]);
});
test('G27: after step 2 with the host down, the restart closes the marker with hostLineLeft; a marker without a revoke id is not reopened', async (t) => {
const f = await setup(t);
await f.approve();
const revoke = 'c1d2e3f4-0000-4000-8000-000000000001';
f.store.transaction(() => {
f.store.run("DELETE FROM host_grants WHERE business='demo' AND agent='ops' AND host='web1'");
writeGrantEvent(f.store, AT, 'demo', { action: 'grant.revoked', revoke, agent: 'ops', host: 'web1', tag: f.tag, hostLine: 'pending', granted: true, reason: 'r', by: 'jason' }, 'web1');
});
rmSync(f.keyFile);
writeGrantEvent(f.store, AT, 'demo', { action: 'grant.revoked', agent: 'pm', host: 'web1', tag: f.tag, hostLine: 'pending', by: 'jason' }, 'web1');
f.flag('fail-remove');
const r = await restart(t, f);
assert.deepEqual(r.swept, []);
assert.deepEqual(r.later, [revoke]);
assert.equal(revokeBodies(f).at(-1).hostLineLeft, f.tag);
assert.equal(f.granted().length, 1);
const human = r.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
assert.deepEqual((await r.broker.request(human, { verb: 'hosts.status', args: {} }))[0].hostLineLeft, [f.tag]);
// The sweep then removes it and clears the left line.
rmSync(join(f.dir, 'fail-remove'));
assert.deepEqual(await r.broker.request(human, { verb: 'hosts.sweep', args: { host: 'web1' } }), { host: 'web1', removed: [f.tag], hostLineLeft: [] });
assert.deepEqual(f.granted(), []);
assert.deepEqual((await r.broker.request(human, { verb: 'hosts.status', args: {} }))[0].hostLineLeft, []);
});
test('G27: a key file a revoke could not unlink is reported left and goes at the next start', async (t) => {
const f = await setup(t);
await f.approve();
const agentDir = join(f.storeRoot, 'demo', 'ops');
// setup's t.after removes the scratch dir before any hook added here runs, so the body restores the mode.
chmodSync(agentDir, 0o500);
try {
const r = await f.call('human', 'hosts.revoke', { agent: 'ops', host: 'web1', reason: 'r' });
assert.equal(r.keyFile, 'left');
assert.equal(r.hostLine, 'removed');
assert.equal(existsSync(f.keyFile), true);
assert.notEqual(f.state(), 'valid');
} finally {
chmodSync(agentDir, 0o700);
}
const again = await restart(t, f);
assert.deepEqual(again.swept, [{ instance: 'demo/ops', service: 'host.web1' }]);
assert.equal(existsSync(f.keyFile), false);
assert.deepEqual(again.later, []);
});
test('G28: a commands change, then a revoke, then a restart leave no record, key, reference or line', async (t) => {
const f = await setup(t);
await f.approve();
await f.approve({ commands: [['uptime']] });
assert.equal(f.record().commands, '[["uptime"]]');
await f.call('human', 'hosts.revoke', { agent: 'ops', host: 'web1', reason: 'r' });
const r = await restart(t, f);
assert.deepEqual([r.swept, r.now, r.later], [[], [], []]);
assert.equal(f.record(), undefined);
assert.equal(existsSync(f.keyFile), false);
assert.equal(r.state, undefined);
assert.deepEqual(f.granted(), []);
});
test('G24 (B4): hosts.sweep removes only this business\'s recorded tags no live grant holds, and records what it left', async (t) => {
const f = await setup(t);
await f.approve();
const live = f.granted();
const pmTag = grantTag('demo', 'pm', 'web1');
const otherTag = grantTag('other', 'ops', 'web1');
const unrecorded = 'mosaic-' + 'f'.repeat(32);
keygen(join(f.dir, 'k2'));
const line = (tag) => `restrict ${pubLine(join(f.dir, 'k2'))} ${tag}`;
writeFileSync(join(f.dir, 'granted'), [...live, line(pmTag), line(otherTag), line(unrecorded), ''].join('\n'));
writeGrantEvent(f.store, AT, 'demo', { action: 'grant.step', grantDecision: 'x', agent: 'pm', host: 'web1', user: USER, tag: pmTag, step: 'install' }, 'web1');
writeGrantEvent(f.store, AT, 'demo', { action: 'grant.failed', grantDecision: 'x', agent: 'pm', host: 'web1', code: 'host-failed', tag: pmTag, hostLineLeft: pmTag }, 'web1');
// A left tag no longer on the host drops out at the sweep.
writeGrantEvent(f.store, AT, 'demo', { action: 'grant.failed', grantDecision: 'y', agent: 'pm', host: 'web1', code: 'host-failed', tag: 'mosaic-' + '1'.repeat(32), hostLineLeft: 'mosaic-' + '1'.repeat(32) }, 'web1');
assert.equal((await f.call('human', 'hosts.status'))[0].hostLineLeft.length, 2);
f.flag('fail-list');
await refusesWith(f.call('human', 'hosts.sweep', { host: 'web1' }), 'host-failed');
assert.equal(f.grantEvents().some((e) => e.action === 'grant.host.swept'), false);
rmSync(join(f.dir, 'fail-list'));
await refusesWith(f.call('human', 'hosts.sweep', { host: 'web9' }), 'host-unknown');
const asked = f.requests().length;
assert.deepEqual(await f.call('human', 'hosts.sweep', { host: 'web1' }), { host: 'web1', removed: [pmTag], hostLineLeft: [] });
assert.deepEqual(f.requests().slice(asked), [`list ${USER}`, `remove ${USER} ${pmTag}`]);
assert.deepEqual(f.granted(), [...live, line(otherTag), line(unrecorded)]);
assert.deepEqual(f.grantEvents().at(-1), { action: 'grant.host.swept', host: 'web1', removed: [pmTag], left: [], by: 'jason' });
assert.deepEqual((await f.call('human', 'hosts.status'))[0].hostLineLeft, []);
// A later failure adds to the cleared list.
writeGrantEvent(f.store, AT, 'demo', { action: 'grant.failed', grantDecision: 'z', agent: 'pm', host: 'web1', code: 'host-failed', tag: pmTag, hostLineLeft: pmTag }, 'web1');
assert.deepEqual((await f.call('human', 'hosts.status'))[0].hostLineLeft, [pmTag]);
assert.equal(f.state(), 'valid');
});
test('G12: the local steps finish before the host step, so a host that hangs leaves the agent no access', async (t) => {
const f = await setup(t);
await f.approve();
f.flag('hold-remove');
const revoking = f.call('human', 'hosts.revoke', { agent: 'ops', host: 'web1', reason: 'r' });
await until(() => existsSync(join(f.dir, 'holding-remove')));
assert.equal(f.record(), undefined);
assert.equal(existsSync(f.keyFile), false);
assert.notEqual(f.state(), 'valid');
assert.deepEqual((await f.call('human', 'hosts.status'))[0].revoking, [f.tag]);
f.flag('release');
assert.equal((await revoking).hostLine, 'removed');
assert.deepEqual(f.granted(), []);
});
// X5: the executor claims the slot in the same tick as its spend, so a revoke right after the start
// call returns finds the claim and aborts it.
test('G23 (X5): a revoke right after the spend reaches the executor\'s claim, so the grant ends revoked', async (t) => {
const f = await setup(t);
const id = f.fixtureGrant();
const run = f.broker.runHostGrant('demo', id);
const r = await f.call('human', 'hosts.revoke', { agent: 'ops', host: 'web1', reason: 'r' });
assert.equal(r.granted, false);
assert.equal(await run, 'revoked');
assert.equal(f.ended(id).code, 'revoked');
assert.equal(f.record(), undefined);
assert.equal(existsSync(f.keyFile), false);
assert.deepEqual(f.requests(), [`remove ${USER} ${f.tag}`]);
});
// X5: the abort check after verify. The revoke lands once the login has worked and before the commit,
// so only that check stops the record and key file.
test('G23 (X5): a revoke that lands as verify returns ends revoked with no record, key file or line', async (t) => {
let revoking;
const f = await setup(t, {
wrap: (hg) => ({
...hg,
verify: async (...args) => {
const out = await hg.verify(...args);
revoking = f.call('human', 'hosts.revoke', { agent: 'ops', host: 'web1', reason: 'r' });
return out;
},
}),
});
const { id, end } = await f.approve();
assert.equal(end.code, 'revoked');
assert.equal((await revoking).granted, false);
assert.equal(f.record(), undefined);
assert.equal(existsSync(f.keyFile), false);
assert.equal(f.state(), undefined);
assert.deepEqual(f.granted(), []);
// The check stops the grant before step 5, so no commit step is logged.
assert.deepEqual(f.grantEvents().filter((e) => e.grantDecision === id && e.action === 'grant.step').map((e) => e.step), ['install']);
});
test('G13: a revoke whose host remove fails still drops the record and key, and reports the line left for a sweep', async (t) => {
const f = await setup(t);
await f.approve();
f.flag('fail-remove');
const r = await f.call('human', 'hosts.revoke', { agent: 'ops', host: 'web1', reason: 'r' });
assert.deepEqual(r, { agent: 'ops', host: 'web1', tag: f.tag, granted: true, keyFile: 'removed', hostLineLeft: f.tag });
assert.equal(f.record(), undefined);
assert.equal(existsSync(f.keyFile), false);
assert.notEqual(f.state(), 'valid');
assert.equal(f.granted().length, 1);
assert.equal(revokeBodies(f).at(-1).hostLineLeft, f.tag);
const [row] = await f.call('human', 'hosts.status');
assert.deepEqual([row.revoking, row.hostLineLeft], [[], [f.tag]]);
// The marker is closed, so a restart doesn't run step 3 again.
const again = await restart(t, f);
assert.deepEqual(again.later, []);
});
// G22 (B1) on one bus with no restart. Enrollment refuses this machine's address, so the chief enrolls
// 192.0.2.10 through the fake keyscan. The test then points the record at the scratch sshd and lets the
// enrolled installer key log in there. Everything after that is the real path.
test('G22: enroll, check, grant, host.run, a commands change the next host.run uses, and a revoke that kills a run in flight', async (t) => {
const f = await setup(t, { enroll: true });
const enroll = await f.call('chief', 'decision.raise', {
action: 'credential.mint', question: 'Enroll web1?', options, recommendation: 'yes', blocking: false,
grant: { method: 'host-enroll', host: 'web1', address: '192.0.2.10', port: 22, user: USER, sudo: true, fingerprints: [fingerprint(pubLine(join(f.dir, 'hostkey')))] },
});
f.call('human', 'decision.resolve', { id: enroll.id, choice: 'yes' });
const enrolled = () => f.grantEvents().find((e) => e.grantDecision === enroll.id && ['grant.host.enrolled', 'grant.failed'].includes(e.action));
await until(enrolled);
assert.equal(enrolled().action, 'grant.host.enrolled');
const installer = parseHostKey(readFileSync(join(f.configDir, 'secrets', 'hosts', 'demo', 'web1', 'installer'), 'utf8'));
appendFileSync(join(f.dir, 'authorized_keys'), installer.publicLine + '\n');
f.store.run("UPDATE hosts SET address='127.0.0.1', port=? WHERE business='demo' AND host='web1'", f.port);
await refusesWith(f.raise(), 'host-not-ready');
assert.equal((await f.call('chief', 'hosts.check', { host: 'web1' })).state, 'ready');
assert.equal((await f.approve({ commands: [['echo']] })).end.action, 'grant.applied');
f.caps.ops = f.broker.bindLaunch({ business: 'demo', role: 'ops', run: 'ops-run', harness: 'pi', address: 'ops' });
f.broker.request(f.caps.ops, { verb: 'role.claim' });
const executor = hostExecutor({ credentials: f.credentials, root: join(f.dir, 'host-run') });
const run = async (argv) => {
const d = await f.call('ops', 'decision.raise', { action: 'host.sudo', target: 'web1', argv, question: 'Run it?', options, recommendation: 'yes', blocking: false });
f.call('human', 'decision.resolve', { id: d.id, choice: 'yes' });
return f.broker.requestHost(f.caps.ops, { verb: 'host.run', args: { host: 'web1', argv, decision: d.id } }, executor);
};
const out = await run(['echo', 'hi']);
assert.deepEqual([out.exit, out.stdout], [0, 'hi\n']);
await refusesWith(run(['sleep', '30']), 'command-not-granted');
assert.equal((await f.approve({ commands: [['echo'], ['sleep']] })).end.action, 'grant.applied');
// The handler goes on at creation: the revoke rejects the run while the test still awaits the revoke.
const slow = refusesWith(run(['sleep', '30']), 'credential-unavailable');
await until(() => readOr(join(f.dir, 'sudo.log'), '').includes('sleep 30'));
const r = await f.call('human', 'hosts.revoke', { agent: 'ops', host: 'web1', reason: 'done' });
assert.deepEqual([r.granted, r.keyFile, r.hostLine], [true, 'removed', 'removed']);
await slow;
// The record goes at step 1, so the next call refuses at the record check, before the key (B1 names
// credential-unavailable; BUILD.md records the difference).
await refusesWith(f.broker.requestHost(f.caps.ops, { verb: 'host.run', args: { host: 'web1', argv: ['echo', 'hi'] } }, executor), 'host-not-granted');
assert.equal(f.record(), undefined);
assert.equal(existsSync(f.keyFile), false);
assert.deepEqual(f.granted(), []);
});
// The decision goes in after the restart, so the reconcile doesn't end it first.
test('mos-1163: a grant whose agent became a creative role after the raise ends host-creative-role at step 1, spent, with nothing remote', async (t) => {
const f = await setup(t);
const r = await restart(t, f, creativeOps(f.business));
const id = f.fixtureGrant();
assert.equal(await r.broker.runHostGrant('demo', id), 'host-creative-role');
assert.equal(f.ended(id).code, 'host-creative-role');
assert.equal(await r.broker.runHostGrant('demo', id), 'decision-consumed');
assert.deepEqual(f.requests(), []);
assert.equal(f.record(), undefined);
});
test('sage-1908: once the agent of an applied grant is a creative role, host.run refuses host-creative-role, runs nothing remote and keeps the record', async (t) => {
const f = await setup(t);
assert.equal((await f.approve()).end.action, 'grant.applied');
const r = await restart(t, f, creativeOps(f.business));
const cap = r.broker.bindLaunch({ business: 'demo', role: 'ops', run: 'ops-run', harness: 'pi', address: 'ops' });
r.broker.request(cap, { verb: 'role.claim' });
const executor = hostExecutor({ credentials: r.credentials, root: join(f.dir, 'host-run') });
const before = f.requests().length;
await refusesWith(r.broker.requestHost(cap, { verb: 'host.run', args: { host: 'web1', argv: ['systemctl', 'status'] } }, executor), 'host-creative-role');
assert.equal(f.requests().length, before);
assert.equal(readOr(join(f.dir, 'sudo.log'), '').includes('systemctl'), false);
assert.notEqual(f.record(), undefined);
});
+228
View File
@@ -0,0 +1,228 @@
// Row 235 (#1741, #436 row b): the host grant raise (r6 R1-R4). A credential.mint raise whose `grant`
// has method `host` binds an agent, an enrolled host, argv prefixes and a rotate date. The host record
// comes from the fixture, and nothing here connects to any host.
import test from 'node:test';
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Broker, registrySha256 } from '../src/broker.mjs';
import { Store } from '../src/store.mjs';
import { hostGrant, definitionSha256 } from '../src/host-grant.mjs';
import { hostRecord, hostGrantRecord } from './host-fixtures.mjs';
import { canonicalJson } from '../../business/src/util.mjs';
const options = [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }];
const refusesWith = async (p, code) => assert.rejects(Promise.resolve().then(() => p), (e) => (assert.equal(e.code, code), true));
const dayFrom = (n) => new Date(Date.now() + n * 86400000).toISOString().slice(0, 10);
const ENTRY = { address: '192.0.2.10', port: 22, user: 'jarvis', hostKeys: ['ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHostKeyForTests'] };
const none = { authority: { withinRole: ['message.send'], crossRole: [] } };
const DEFINITIONS = {
'chief-of-staff': { roleVersion: 2, credentials: [] },
pm: { roleVersion: 2, credentials: [] },
ops: { roleVersion: 2, credentials: [{ service: 'gitea', scopes: ['read:repository'] }, { service: 'host' }] },
};
function setup(t) {
const root = mkdtempSync(join(tmpdir(), 'g235-'));
const store = new Store(root);
t.after(() => {
store.close();
rmSync(root, { recursive: true, force: true });
});
const configDir = join(root, 'config');
const business = {
id: 'demo',
human: 'jason',
arbiters: { technical: 'pm', delivery: 'pm' },
roles: {
chief: { definition: 'chief-of-staff', ...none },
pm: { definition: 'pm', ...none },
ops: { definition: 'ops', ...none },
odd: { definition: 'constructor', ...none },
// A creative role whose definition declares host, so only the creative check refuses it.
muse: { definition: 'research', ...none },
},
definitions: { ...structuredClone(DEFINITIONS), research: structuredClone(DEFINITIONS.ops) },
vars: { 'bus.tester': true },
};
const broker = new Broker({ store, businesses: { demo: business }, hostGrant: hostGrant({ configDir, runRoot: join(configDir, 'secrets', 'run') }) });
const caps = {};
for (const role of ['chief', 'pm']) {
caps[role] = broker.bindLaunch({ business: 'demo', role, run: role + '-run', harness: 'pi', address: role });
broker.request(caps[role], { verb: 'role.claim' });
}
caps.human = broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
caps.tester = broker.bindTester({ business: 'demo' });
hostRecord(store, { host: 'web1', entry: ENTRY });
const grant = (over = {}) => ({ method: 'host', agent: 'ops', host: 'web1', commands: [['systemctl', 'status']], rotateBy: dayFrom(30), ...over });
const call = (who, verb, args = {}) => broker.request(caps[who], { verb, args });
const raise = (over = {}, who = 'chief', extra = {}) =>
call(who, 'decision.raise', { action: 'credential.mint', question: 'Grant ops web1?', options, recommendation: 'yes', blocking: false, grant: grant(over), ...extra });
const decisions = () => store.get('SELECT count(*) AS n FROM decisions').n;
const question = (id) => store.get('SELECT question FROM decisions WHERE id=?', id).question;
const raiseBody = (id) =>
JSON.parse(store.get("SELECT body FROM events WHERE kind='action.allowed' AND json_extract(body,'$.operation')='decision.raise' AND json_extract(body,'$.decision')=?", id).body);
const refusals = () => store.all("SELECT body FROM events WHERE business='demo' AND kind='action.refused' ORDER BY seq").map((e) => JSON.parse(e.body).code);
return { store, broker, caps, call, raise, grant, decisions, question, raiseBody, refusals };
}
test('G1 (host): only the chief raises a host grant, a grant on any other action refuses, and nothing is stored', async (t) => {
const f = setup(t);
const before = f.decisions();
await refusesWith(f.raise({}, 'pm'), 'grant-chief-only');
await refusesWith(f.raise({}, 'chief', { action: 'deploy' }), 'invalid-request');
await refusesWith(f.raise({}, 'tester'), 'tester-refused');
assert.equal(f.decisions(), before);
assert.deepEqual(f.refusals(), ['grant-chief-only', 'invalid-request', 'tester-refused']);
});
test('G1, R4: the grant takes exactly method, agent, host, commands and rotateBy, and an unknown method refuses', async (t) => {
const f = setup(t);
const before = f.decisions();
const g = f.grant();
const { rotateBy, ...noRotate } = g;
for (const bad of [
noRotate,
{ ...g, extra: 1 },
{ ...g, method: 'gitea' },
{ ...g, method: 'Host' },
{ ...g, agent: 'Ops' },
{ ...g, agent: '' },
{ ...g, agent: 7 },
{ ...g, host: 'Web_1' },
{ ...g, host: 'a'.repeat(33) },
])
await refusesWith(f.call('chief', 'decision.raise', { action: 'credential.mint', question: 'q', options, recommendation: 'yes', blocking: false, grant: bad }), 'invalid-request');
for (const bad of [null, [], 'host'])
await refusesWith(f.call('chief', 'decision.raise', { action: 'credential.mint', question: 'q', options, recommendation: 'yes', blocking: false, grant: bad }), 'invalid-request');
assert.equal(f.decisions(), before);
assert.ok(rotateBy);
});
test('G2: an agent whose definition lacks host refuses host-not-declared, and an unknown agent unknown-role', async (t) => {
const f = setup(t);
const before = f.decisions();
await refusesWith(f.raise({ agent: 'pm' }), 'host-not-declared');
await refusesWith(f.raise({ agent: 'chief' }), 'host-not-declared');
await refusesWith(f.raise({ agent: 'nobody' }), 'unknown-role');
// An inherited name is not a definition.
await refusesWith(f.raise({ agent: 'odd' }), 'host-not-declared');
assert.equal(f.decisions(), before);
});
test('mos-1163: a grant whose agent is a creative role refuses host-creative-role, even when its definition declares host', async (t) => {
const f = setup(t);
const before = f.decisions();
await refusesWith(f.raise({ agent: 'muse' }), 'host-creative-role');
assert.equal(f.decisions(), before);
});
test('G3: wildcard, shell, empty and other 210 D6 argv prefixes refuse invalid-request', async (t) => {
const f = setup(t);
const before = f.decisions();
for (const commands of [
'any',
'*',
{},
[],
[[]],
[['*']],
[['systemctl', 'status*']],
[['ls', '[a]']],
[['bash']],
[['/bin/sh', '-c']],
[['sudo', 'ls']],
[['python3']],
[['env', 'ls']],
[['bin/ls']],
[['']],
[['ls', 'a\0b']],
[['ls', 3]],
[['ls'], ['ls']],
[Array.from({ length: 257 }, () => 'x')],
])
await refusesWith(f.raise({ commands }), 'invalid-request');
assert.equal(f.decisions(), before);
});
test('R4 rotateBy (sage-1782): a real calendar day after today UTC, or invalid-request', async (t) => {
const f = setup(t);
const before = f.decisions();
for (const rotateBy of [dayFrom(0), dayFrom(-1), '2026-02-30', '2026-13-01', '20991231', '2099-1-01', 20991231, null])
await refusesWith(f.raise({ rotateBy }), 'invalid-request');
assert.equal(f.decisions(), before);
await f.raise({ rotateBy: dayFrom(1) });
await f.raise({ rotateBy: '2999-12-31' });
assert.equal(f.decisions(), before + 2);
});
test('G19: a grant naming a host that is pending-install refuses host-not-ready, and an unknown host host-unknown', async (t) => {
const f = setup(t);
hostRecord(f.store, { host: 'web2', entry: ENTRY });
f.store.run("UPDATE hosts SET state='pending-install' WHERE business='demo' AND host='web2'");
const before = f.decisions();
await refusesWith(f.raise({ host: 'web2' }), 'host-not-ready');
await refusesWith(f.raise({ host: 'web9' }), 'host-unknown');
assert.equal(f.decisions(), before);
});
test('R4, G5: the raise routes to the human and its event body binds the eight fields with their hashes', async (t) => {
const f = setup(t);
const commands = [['systemctl', 'status'], ['/usr/bin/journalctl', '-u', 'nginx']];
const d = await f.raise({ commands });
const row = f.store.get('SELECT action, class, route_to FROM decisions WHERE id=?', d.id);
assert.deepEqual({ ...row }, { action: 'credential.mint', class: 'gated', route_to: 'human' });
const body = f.raiseBody(d.id);
assert.deepEqual(Object.keys(body.grant), ['method', 'agent', 'host', 'commands', 'commandsSha256', 'rotateBy', 'registrySha256', 'definitionSha256']);
assert.deepEqual(body.grant, {
method: 'host',
agent: 'ops',
host: 'web1',
commands,
commandsSha256: createHash('sha256').update(JSON.stringify(commands)).digest('hex'),
rotateBy: f.grant().rotateBy,
registrySha256: registrySha256({ ...ENTRY, sudo: true }),
definitionSha256: createHash('sha256').update(canonicalJson(DEFINITIONS.ops)).digest('hex'),
});
// Key order in the definition doesn't change the hash, and a field change does.
const reordered = { credentials: DEFINITIONS.ops.credentials, roleVersion: 2 };
assert.equal(definitionSha256(reordered), body.grant.definitionSha256);
assert.notEqual(definitionSha256({ ...DEFINITIONS.ops, credentials: [{ service: 'host' }] }), body.grant.definitionSha256);
assert.deepEqual(f.call('human', 'inbox').map((x) => x.id), [d.id]);
});
test('G4 (host block): the broker appends the host, each prefix, the rotate date and new key or change', async (t) => {
const f = setup(t);
const d = await f.raise({ commands: [['systemctl', 'status'], ['echo', 'a b"c']] });
const rotateBy = f.grant().rotateBy;
assert.equal(
f.question(d.id),
'Grant ops web1?' +
'\n\nGrant ops access to host web1: user jarvis at 192.0.2.10 port 22.' +
'\njarvis has sudo on this host.' +
'\nCommands, each an argv prefix:' +
'\n"systemctl" "status"' +
'\n"echo" "a b\\"c"' +
`\nRotate by ${rotateBy}.` +
'\nThis makes a new key for ops on web1.',
);
// A host without sudo, and an agent with a grant record already: the block says so.
hostRecord(f.store, { host: 'web2', entry: ENTRY, sudo: false });
hostGrantRecord(f.store, { agent: 'ops', host: 'web2', commands: [['uptime']] });
const d2 = await f.raise({ host: 'web2' });
const q2 = f.question(d2.id);
assert.ok(q2.includes('\njarvis has no sudo on this host.\n'));
assert.ok(q2.endsWith("\nThis changes the commands of ops's grant on web2. The key stays."));
// A forged block in the chief's question still gets the broker's block after it.
const forged = 'x\n\nGrant ops access to host web1: user other at 192.0.2.99 port 22.\nCommands, each an argv prefix:\n"bash"';
const d3 = await f.raise({}, 'chief', { question: forged });
assert.ok(f.question(d3.id).startsWith(forged + '\n\nGrant ops access to host web1: user jarvis at 192.0.2.10 port 22.\n'));
// The 4096 cap counts the block: a question that fits alone refuses with it.
const before = f.decisions();
await refusesWith(f.raise({}, 'chief', { question: 'q'.repeat(4000) }), 'invalid-request');
const long = Array.from({ length: 64 }, (_, i) => ['/usr/bin/tool' + i, 'x'.repeat(60)]);
await refusesWith(f.raise({ commands: long }), 'invalid-request');
assert.equal(f.decisions(), before);
});
+4
View File
@@ -54,6 +54,10 @@ export function hostGrantRecord(store, { business = 'demo', agent, host = 'web1'
grant: grant === null ? undefined : { method: 'host', agent, host, commands, rotateBy, ...grant },
});
if (spend) writeGrantEvent(store, AT, business, { operation: spend, decision }, decision);
// Row 235: the executor commits each record with its grant.applied (r6 X2 step 5), so the start
// reconcile reads a fixture record as a finished grant.
if (spend === 'grant.host')
writeGrantEvent(store, AT, business, { action: 'grant.applied', grantDecision: decision, agent, host, tag: grantTag(business, agent, host) }, host);
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,
+3
View File
@@ -1055,6 +1055,9 @@ the old token in Gitea or Vikunja yourself; the bus only forgets it.
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.
`revoke AGENT HOST --reason R` runs `hosts.revoke`, which takes the agent's grant on the host away
at once, and `sweep HOST` runs `hosts.sweep`, which removes the host lines a failure left (row 235).
A status line shows `revoking:` with the tags of a revoke still in progress.
Enrollment itself is a `host-enroll` grant the chief raises and the human approves, not a CLI
command. A usage error exits 4 and a bus refusal 2.
+16 -8
View File
@@ -1,17 +1,19 @@
// `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.
// reads a key: the answers carry fingerprints only. Row 235 (#1741, r6 section 6, X3) adds revoke, which
// takes an agent's grant on a host away at once, and sweep, which removes the lines a failure left.
import { CliError } from "./errors.mjs";
export const HOSTS_USAGE = "mosaic hosts [status] [--json] [--tester] [--business ID] | check|remove HOST [--tester] [--business ID]";
export const HOSTS_USAGE =
"mosaic hosts [status] [--json] [--tester] [--business ID] | check|remove|sweep HOST [--tester] [--business ID] | revoke AGENT HOST --reason R [--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") {
else if (arg === "--business" || arg === "--reason") {
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);
@@ -22,13 +24,18 @@ function parse(args) {
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");
...(h.hostLineLeft.length ? [`line left: ${h.hostLineLeft.join(" ")}`] : []),
...(h.revoking?.length ? [`revoking: ${h.revoking.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);
const [verb = "status", first, second, ...extra] = parsed.positional;
const one = first !== undefined && second === undefined;
const shape = { status: first === undefined, check: one, remove: one, sweep: one, revoke: second !== undefined };
// N10: own keys only, so `constructor` or `toString` is a usage error, not a verb.
if (extra.length || !Object.hasOwn(shape, verb) || !shape[verb] || (parsed.flags.has("--json") && verb !== "status") ||
(verb === "revoke") !== Object.hasOwn(parsed.values, "--reason"))
throw new CliError(HOSTS_USAGE, 4);
try {
const call = callFor(parsed);
if (verb === "status") {
@@ -36,7 +43,8 @@ export async function hostsCommand(args, io, callFor) {
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");
const args = verb === "revoke" ? { agent: first, host: second, reason: parsed.values["--reason"] } : { host: first };
io.stdout.write(JSON.stringify(await call(`hosts.${verb}`, args), null, 2) + "\n");
} catch (error) {
if (error instanceof CliError) throw error;
throw new CliError(error.message, 2);
+5 -45
View File
@@ -7,7 +7,7 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { tmp } from "./helpers.mjs";
@@ -77,13 +77,10 @@ args=(); for a in "$@"; do if [[ "$a" == /* ]] && [ -e "$ROOT_DIR$a" ]; then arg
HOME="$ROOT_DIR/srv/mosaic" PATH="$np$PATH" exec "$ROOT_DIR$cmd" "\${args[@]}"`,
};
// Executables inside the tree, run by the fake sudo or by the script itself. Node marks a run outside
// the fake sudo, which sets HOME to the stack's home, so a test can tell whether root ran it.
const NODE_STUB = `[ "$HOME" = "$ROOT_DIR/srv/mosaic" ] || touch "$HC/node-as-root"
[ "\${1:-}" = --version ] && { cat "$HC/node-version"; exit 0; }
exec "$REAL_NODE" "$@"`;
// Executables inside the tree, run by the fake sudo or by the script itself.
const TREE = {
[NODE]: NODE_STUB,
[NODE]: `[ "\${1:-}" = --version ] && { cat "$HC/node-version"; exit 0; }
exec "$REAL_NODE" "$@"`,
[CLAUDE]: `[ "\${1:-}" = --version ] && cat "$HC/claude-version"; exit 0`,
[CLI]: `${log("mosaic")}
case "$1" in
@@ -154,9 +151,6 @@ function host(t) {
for (const [name, text] of Object.entries(state)) put(join(hc, name), text);
for (const [name, body] of Object.entries(FAKES)) put(join(bin, name), `#!/usr/bin/env bash\n${name === "stat" ? "" : log(name)}\n${body}\n`, 0o755);
for (const [path, body] of Object.entries(TREE)) put(join(root, path), `#!/usr/bin/env bash\n${body}\n`, 0o755);
// F9 runs node only when it and each directory above it have no group or other write, whatever the umask.
for (let d = dirname(NODE); d !== "/"; d = dirname(d)) chmodSync(join(root, d), 0o755);
chmodSync(root, 0o755);
return { dir, root, hc, bin, args };
}
@@ -210,7 +204,6 @@ test("row 195 accept: every check passing exits 0, with each P mark and counts t
assert.match(r.out, /^F5 bwrap-label pass bwrap 300 reads 'bwrap \(enforce\)' and its claude descendant 302 reads/m);
assert.match(r.out, /^F7 pm-live pass one live launch: pm run r-0002$/m);
assert.ok(!(r.out + r.err).includes(CRED_VALUE));
assert.ok(existsSync(join(h.hc, "node-as-root")), "F9 runs the pinned, root-only node as root");
});
test("row 195 accept: exits 1 with each F check failing alone, and its line names it", (t) => {
@@ -281,7 +274,7 @@ test("row 195 accept: F5 fails with no bwrap of mosaic's, and passes on comm alo
assert.deepEqual(failed(mustRun(h)), []);
});
test("row 195 accept: F6 fails on a stub that prints nothing, on a path outside claude-<pin> and on a .. segment", (t) => {
test("row 195 accept: F6 fails on a stub that prints nothing and on a path outside claude-<pin>", (t) => {
let h = host(t);
put(join(h.hc, "claude-version"), "");
let r = mustRun(h);
@@ -293,14 +286,6 @@ test("row 195 accept: F6 fails on a stub that prints nothing, on a path outside
r = mustRun(h);
assert.deepEqual(failed(r), ["F6"]);
assert.match(r.out, /^F6 claude-pin FAIL --claude \S+claude-2\.0\.9\S+ is not under \/opt\/mosaic-stack\/claude-2\.1\.0\/$/m);
// Under claude-<pin>/ by prefix, but the binary it names is 2.0.9's, which answers with the pin.
h = host(t);
const dotdot = CLAUDE.replace(`claude-${CLAUDE_PIN}/`, `claude-${CLAUDE_PIN}/../claude-2.0.9/`);
put(h.args, ARGS.map((a) => (a === CLAUDE ? dotdot : a)).join("\n") + "\n", 0o644);
put(join(h.root, other), `#!/usr/bin/env bash\necho "${CLAUDE_PIN} (Claude Code)"\n`, 0o755);
r = mustRun(h);
assert.deepEqual(failed(r), ["F6"], r.out);
assert.match(r.out, /^F6 claude-pin FAIL --claude \S+\/claude-2\.1\.0\/\.\.\/claude-2\.0\.9\/\S+ has a \.\. segment$/m);
});
test("row 195 accept: F7 fails on two live PM launches, one for another business, and no launch.by", (t) => {
@@ -343,31 +328,6 @@ test("row 195 accept: F9 fails on another owner, a group-writable mode and a num
}
});
test("row 195 accept: F9 won't run --node as root off the pin path or with a writable directory above it", (t) => {
// Another node that works for the stack's user: F9 refuses to run it, and F10 fails on the path.
let h = host(t);
const other = NODE.replace(NODE_PIN, "v24.0.9");
put(h.args, ARGS.map((a) => (a === NODE ? other : a)).join("\n") + "\n", 0o644);
put(join(h.root, other), `#!/usr/bin/env bash\n${NODE_STUB}\n`, 0o755);
let r = mustRun(h);
assert.deepEqual(failed(r), ["F9", "F10"], r.out);
assert.match(r.out, /^F9 autoupdater-off FAIL root won't run --node \/opt\/mosaic-stack\/node-v24\.0\.9\/bin\/node: it is not the --node-pin path$/m);
assert.ok(!existsSync(join(h.hc, "node-as-root")));
// The pinned path, under a group-writable directory and then a symlinked node: F9 alone fails, and
// root doesn't run it.
for (const [fault, bad] of [
[(x) => chmodSync(join(x.root, dirname(NODE)), 0o775), "/opt/mosaic-stack/node-v24\\.1\\.0/bin \\(uid \\d+, mode 775\\)"],
[(x) => { const f = join(x.root, NODE); renameSync(f, `${f}.real`); symlinkSync(`${f}.real`, f); }, "/opt/mosaic-stack/node-v24\\.1\\.0/bin/node \\(uid \\d+, mode 777\\)"],
]) {
h = host(t);
fault(h);
r = mustRun(h);
assert.deepEqual(failed(r), ["F9"], r.out);
assert.match(r.out, new RegExp(`^F9 autoupdater-off FAIL root won't run --node \\S+: ${bad} is not root's alone$`, "m"));
assert.ok(!existsSync(join(h.hc, "node-as-root")));
}
});
test("row 195 accept: F11 fails on one *.json under discord/ and passes on an empty directory", (t) => {
let h = host(t);
put(join(h.root, HOME, ".mosaic-dev", "discord", "chief.json"), "{}");
+31 -1
View File
@@ -1,4 +1,5 @@
// Row 234 (#1740, r6 E4): `mosaic hosts status|check|remove`. A fake transport records the calls; no bus
// Row 234 (#1740, r6 E4): `mosaic hosts status|check|remove`, and row 235 (#1741) revoke and sweep. 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";
@@ -72,3 +73,32 @@ test("row 234: scripts/mosaic sends hosts to packages/cli, not the seat CLI", (t
assert.match(r.stderr, /^mosaic: mosaic hosts \[status\]/);
assert.doesNotMatch(r.stderr, /mosaic launch </);
});
test("row 235: hosts revoke sends agent, host and reason, and sweep the host, as the human or the tester", async () => {
const calls = [];
const x = io();
await main(["hosts", "revoke", "ops", "web1", "--reason", "left the team", "--business", "acme"], x, deps(calls));
await main(["hosts", "sweep", "web1", "--tester", "--business", "acme"], x, deps(calls));
assert.deepEqual(calls.map(({ who, verb, args }) => ({ who, verb, args })), [
{ who: "human", verb: "hosts.revoke", args: { agent: "ops", host: "web1", reason: "left the team" } },
{ who: "tester", verb: "hosts.sweep", args: { host: "web1" } },
]);
});
test("row 235 (N10): revoke without --reason, --reason on another verb, and inherited names are usage errors", async () => {
const calls = [];
for (const argv of [["hosts", "revoke", "ops", "web1"], ["hosts", "revoke", "web1", "--reason", "r"], ["hosts", "revoke", "ops", "web1", "x", "--reason", "r"],
["hosts", "sweep"], ["hosts", "sweep", "web1", "web2"], ["hosts", "check", "web1", "--reason", "r"], ["hosts", "revoke", "ops", "web1", "--reason"],
["hosts", "revoke", "ops", "web1", "--reason", "a", "--reason", "b"], ["hosts", "constructor", "web1"], ["hosts", "toString", "web1"],
["hosts", "hasOwnProperty"], ["hosts", "__proto__", "web1"]])
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 235: a status line shows the tags of a revoke in progress", async () => {
const rows = [{ ...ROWS[0], revoking: ["mosaic-" + "a".repeat(32)] }];
const x = io();
const d = { ...deps([]), transport: () => async () => rows };
await main(["hosts", "--business", "acme"], x, d);
assert.equal(x.out.text, `web1\tready\t[email protected]:22\tsudo\t2026-10-21T00:00:00.000Z\trevoking: mosaic-${"a".repeat(32)}\n`);
});
+4 -26
View File
@@ -7,13 +7,9 @@
# Root runs it on the Case 1 host after the install window, from a root-owned checkout. It reads the
# user, home, copy, node, claude and both ports from the args file, which must pass stack-upgrade.sh's
# rules and be a factory file for --business. Every command that runs the stack's code runs as
# `sudo -u USER -H env PATH=<node dir>:/usr/local/bin:/usr/bin:/bin` from /. Root itself reads ss,
# /proc, the account database and file metadata, and runs these: systemctl show and is-enabled (F1,
# P11), curl on the Console's loopback healthz (F2), sudo -l (P1, P3), sudo -u jarvis bwrap --version
# and hostcheck.sh --check (P9), and node to parse managed-settings.json (F9). F9 runs node only when
# --node is the pinned path and it and every directory above it are root's with no group or other
# write. The script changes nothing on the host but the message F3 sends and its reply, and it never
# opens claude-session.env.
# `sudo -u USER -H env PATH=<node dir>:/usr/local/bin:/usr/bin:/bin` from /. Root itself only reads
# systemd, ss, /proc, the account database and file metadata. It changes nothing on the host but the
# message F3 sends and its reply, and it never opens claude-session.env.
#
# One line per check, `<id> <path> <mark> <evidence>`, then `count <mark> N` for each mark and a
# result line. F checks are `pass` or `FAIL`. P checks are `closed`, `accepted <ruling>` or
@@ -263,7 +259,6 @@ check_f4() {
check_f6() {
local want="/opt/mosaic-stack/claude-$CLAUDE_PIN/" v
[[ "$CLAUDE" == "$want"* ]] || { ffail F6 claude-pin "--claude $CLAUDE is not under $want"; return; }
[[ "$CLAUDE/" != */../* ]] || { ffail F6 claude-pin "--claude $CLAUDE has a .. segment"; return; }
v="$("${AS_USER[@]}" "$CLAUDE" --version 2>/dev/null | sed -n 1p)"
if [ "$v" = "$CLAUDE_PIN (Claude Code)" ]; then fpass F6 claude-pin "$CLAUDE prints '$v'"
else ffail F6 claude-pin "$CLAUDE --version prints '$(clean "$v")', not '$CLAUDE_PIN (Claude Code)'"; fi
@@ -296,27 +291,10 @@ check_f8() {
else ffail F8 credential-file "$CRED is '$s', not '$S_USER 600 regular file'"; fi
}
# Empty when $1 and every directory above it, up to /, are the running user's (root's on the host) with
# no group or other write: case2-unit.sh's root_only rule for --node, checked again before root runs it.
# Otherwise the first path that breaks the rule. A symlink breaks it, because its mode is 777.
not_root_only() {
local p="$1" st
while :; do
st="$(stat -c '%u %a' -- "$ROOT$p" 2>/dev/null)" || { echo "$p (cannot stat)"; return; }
[ "${st% *}" = "$(id -u)" ] && (( (8#${st#* } & 8#022) == 0 )) || { echo "$p (uid ${st% *}, mode ${st#* })"; return; }
[ "$p" = / ] && return
p="$(dirname -- "$p")"
done
}
check_f9() {
local s v bad
local s v
s="$(stat -c '%U:%G %a %F' -- "$ROOT$SETTINGS" 2>/dev/null)" || s="missing"
[ ! -L "$ROOT$SETTINGS" ] && [ "$s" = "root:root 644 regular file" ] || { ffail F9 autoupdater-off "$SETTINGS is '$s', not 'root:root 644 regular file'"; return; }
# Root runs node here, so it runs only the pinned path, and only when nothing but root can replace it.
[ "$NODE" = "/opt/mosaic-stack/node-$NODE_PIN/bin/node" ] || { ffail F9 autoupdater-off "root won't run --node $NODE: it is not the --node-pin path"; return; }
bad="$(not_root_only "$NODE")"
[ -z "$bad" ] || { ffail F9 autoupdater-off "root won't run --node $NODE: $bad is not root's alone"; return; }
v="$("$ROOT$NODE" -e 'const s=JSON.parse(require("node:fs").readFileSync(process.argv[1],"utf8"));process.stdout.write(JSON.stringify(s?.env?.DISABLE_AUTOUPDATER??null))' -- "$ROOT$SETTINGS" 2>/dev/null)" || v="unreadable"
if [ "$v" = '"1"' ]; then fpass F9 autoupdater-off "$SETTINGS is root:root 0644 with env.DISABLE_AUTOUPDATER \"1\""
else ffail F9 autoupdater-off "$SETTINGS env.DISABLE_AUTOUPDATER is $v, not \"1\""; fi