Compare commits

..
20 changed files with 410 additions and 542 deletions

No files matched your search

-120
View File
@@ -1,120 +0,0 @@
# Row 239 (#1745) build notes (append only)
## 2026-10-22 b1 (Honker)
Design d1 5da5f28f6 was approved by Duckworth with C1 and C2 and no blockers (duckworth-0165, note
agents/duckworth/work/row239/REVIEW-design-d1.md). The code is on honker/row239 from aca16404c. No test
has run yet: every run waits for Sage's CLEAR.
### Duckworth's conditions and answers
- C1 (option a): `outcome-unknown-revoked` is in the Console's `OPEN` list (webui bus.mjs), so
`sendOutcome` answers it as `outcome-unknown` with `sent: 'unknown'` and never "Nothing changed". T6
covers it and M3f is its mutant.
- C2: `write()` sets `revoked = true` after `beforeWrite()` returns, and a catch around the whole
`withLock(...)` call marks the error. So a throw from the lock's release in its finally is marked as
well. This is structural and has no test, as Duckworth said.
- Q1: `beforeWrite` runs after both validations. Q2: two codes. Q3: M2 removes the `:188` check (now
organization.mjs:195).
### Notes N1-N5
- N1: `beforeWrite` must not write the same business, because it would find its own lock and refuse
`organization-busy`. This is said in the comments on `write()` and `commit()`. Today's revokes (the
bus's credential revoke) write no business file.
- N2: the lock is held across the revoke. A fire or a delete now keeps other writers of that business
out for as long as the revoke takes. They refuse `organization-busy`, as they do for any held lock.
- N3: a revoke that fails partway (some tokens revoked, then `credential-revoke-failed`) behaves as
today. The error carries no `revoked` mark, because `beforeWrite` didn't return, and nothing is
written. This row doesn't change that.
- N4: a delete closed with `outcome-unknown-revoked` exits 3 in the CLI, as every closed refusal does.
Exit 1 (transport EXIT) applies only to a direct verb answer.
- N5: I grepped for "before the write" and similar comments. The ones left (lifecycle-revoke.test:2,
instance-settings:56, cli instances.mjs:53, serve.mjs:365) are still true and are unchanged. T21 in
bus lifecycle-revoke.test:173 is unchanged.
### Differences from the design
- The business tests are in lifecycle-settings.test.mjs, not instance-settings.test.mjs. The fire tests
and their fixture already live there. The fixture now returns `dir` and `rolesDir` too.
- T3 covers T3b (the delete) in the same test, looping over fire and delete. It also checks that a
revoke throwing `credential-revoke-failed` carries no mark and writes nothing.
- T4b is the plain-code check. The design's "T4b, outcome-unknown-revoked for an fs-style error" is in
T4, which checks EIO with and without the mark for both a fire and a delete.
- The bus test's fake org gets `deleteError`, thrown from its `instances.set` branch, so T4 can drive a
delete.
- transport.test gets one assertion, `busExit("outcome-unknown-revoked") === 1`, for M3g.
- A latent Console text: a direct (not closed-request) `revision-conflict-revoked` answer in the
Console would still get " Nothing changed." appended, because only `OPEN` codes skip it. That sentence
is true there: the file is unchanged. No direct human path gives this code today, since a delete
reaches the Console as a closed request's refusal.
- The Console's STATUS entry for `outcome-unknown-revoked` (503) can't be reached while the code is in
`OPEN`. It stays for symmetry with the CLI and is harmless.
### What changed
- packages/business/src/organization.mjs: `write()` takes `beforeWrite`, runs it under the lock after
the revision check and both validations, and marks any later error `revoked`. The body is reindented
inside the try.
- packages/business/src/instance-settings.mjs: `instanceSettings` takes an optional `writer`. `commit()`
passes `beforeWrite` to `writer.write` instead of calling it. The early check stays, and its comment
is rewritten.
- packages/bus/src/broker.mjs: `#organizationFail` maps a marked error to `revision-conflict-revoked` or
`outcome-unknown-revoked` before the existing mappings. `#fireJob`'s notice adds "Its credential
tokens may already be revoked." for a `-revoked` code.
- packages/bus/src/runtime.mjs: comment only.
- packages/cli/src/instances.mjs: two REASONS entries. packages/cli/src/transport.mjs: EXIT
`outcome-unknown-revoked` 1.
- packages/webui/src/serve.mjs: two AGENT_REASONS entries. packages/webui/src/bus.mjs: `OPEN` entry and
two STATUS entries.
- Tests:
- business lifecycle-settings.test: "row 239 T1", "row 239 T2", "row 239 T3".
- bus fire-revokes-grants.test: "row 239 T4", "row 239 T4b".
- cli instances.test: "row 239 T5 CLI". cli transport.test: one assertion in "busExit and
refuseInsideAgent".
- webui agents.test: "row 239 T6".
- bus channel-proof.test re-pins packages/cli/src/transport.mjs (662642a8...) and
packages/webui/src/bus.mjs (1eba9774...), with a comment on each. Neither changes a proof or the
transport. The design's file list missed this. The pin grep found it before the first run.
### Mutants for the gate (exact-once replacements)
- M1 (instance-settings.mjs, beforeWrite before the lock): replace
` const receipt = writer.write(business, next, { revision, actor, context, beforeWrite });` with
` if (beforeWrite) beforeWrite();\n const receipt = writer.write(business, next, { revision, actor, context });`.
The kill is lifecycle-settings "row 239 T1" (the revoke spy has a call).
- M2 (organization.mjs, the fire refusal dropped): delete the line
` if (revision !== before) fail("revision-conflict", "organization revision changed; read again");`.
The kill is "row 239 T1" (the write lands over the hand edit).
- M3a (organization.mjs, the mark dropped): ` if (revoked && e instanceof Error) e.revoked = true;`
becomes ` void revoked;`. The kills are "row 239 T2" and "row 239 T3" (business). T4 drives the mark
through a fake org, so it doesn't see M3a.
- M3b (broker.mjs): delete the line
` if (e.revoked) fail(e.code === "revision-conflict" ? "revision-conflict-revoked" : "outcome-unknown-revoked");`.
The kill is fire-revokes-grants "row 239 T4".
- M3c (cli instances.mjs): delete the five lines from ` "revision-conflict-revoked": "the business file`
through ` "business file again with \`mosaic business instances\` before trying again",`. The kill is
instances.test "row 239 T5 CLI".
- M3d (webui serve.mjs): delete the two lines starting ` 'revision-conflict-revoked': 'the business file`
and ` 'outcome-unknown-revoked': 'the agent\'s tokens`. The kill is agents.test "row 239 T6".
- M3e (broker.mjs): ` const tokens = code.endsWith('-revoked') ? ' Its credential tokens may already be revoked.' : '';`
becomes ` const tokens = '';`. The kill is "row 239 T4".
- M3f (webui bus.mjs, C1): the `OPEN` line loses `, 'outcome-unknown-revoked'`. The kill is "row 239 T6"
(the launch POST answers 503 outcome-unknown, sent unknown).
- M3g (cli transport.mjs): delete the line ` "outcome-unknown-revoked": 1,`. The kill is transport.test
"busExit and refuseInsideAgent".
A kill counts only as a failed assertion in the named test, not a load error or a different test.
### Gate plan (mos-1275 timeouts)
- My row 256 gate (2026-10-22 10:34Z, same machine) took 590s for the cli package, 57s for webui and
38s for test-foundation, the longest shell script.
- Packages: `timeout -k 10 1800` per package (3x the cli run).
- Each scripts/test-*.sh: `timeout -k 10 300` (more than 3x the 38s longest).
- Single files and mutants: `timeout -k 10 600` per file run (cli instances.test whole is the longest
single file).
- Order on Sage's CLEAR: the touched test files whole (business lifecycle-settings, bus
fire-revokes-grants and channel-proof, cli instances and transport, webui agents), then M1-M3g, then
the full gate.
- Before the in-review move: grep packages/*/tests for the changed comment and error text and for
64-hex pins on the changed files, and drift-check BASE..refactor against them.
-159
View File
@@ -1,159 +0,0 @@
# Row 239 design: the revoke under the writer's lock, and a fire test
Brief: docs/plans/2026-10-20_agent-lifecycle.md, "Brief: row 194 follow-up, the revoke under the writer's
lock and a fire test" (blob 3b3b3b2b9), from Duckworth's row 194 b3 N1 and mos-1158. Base aca16404c (row
194 landed as fd6dd327c, row 281 as e479521d3). Builder Honker, reviewer Duckworth, gate owner Sage.
Draft 1, 2026-10-22.
## Today
- `instance-settings.mjs` `commit()` runs the checks, then compares the caller's revision with a fresh
`writer.read` (row 194 b3, sage-1634), then calls `beforeWrite` (the fire's or the delete's credential
revoke), then `writer.write`.
- `writer.write` (`organization.mjs`) takes the lock, runs `recover`, checks the revision again (`:188`),
validates, prepares, checks the bytes once more before publication (`:221`), renames and commits.
- So two windows are left.
- A write that lands between `commit()`'s read and the writer's lock still gets a revoke and then a
`revision-conflict`.
- Any refusal after the revoke ran (the `:221` check, or an I/O failure the bus reports as
`outcome-unknown`) uses the same text as one before it, so nothing says the tokens may be gone.
- Row 281 left this path alone. Its fire job (`broker.mjs` `#fireJob`) runs step G (grants), then step B,
which is this same `write('instance.fire')`, and reports a step B failure as `fire.failed` along with a
human notice that names the code. Its delete job only does step G. The delete itself still commits
through `lifecycleDelete` → `instances.set` op delete → `commit()`.
## What changes
### 1. `beforeWrite` runs inside `writer.write`, under the lock (organization.mjs)
`write(id, document, { revision, actor, detail, context, beforeWrite = null })`. The order under the lock:
1. `recover`, read, then the `:188` revision check. A refusal here revokes nothing.
2. Projects-protected and both validations. A refusal here revokes nothing either. I put `beforeWrite`
after these rather than straight after `:188`, so a document the writer would refuse anyway never
costs a revoke. Q1 asks Duckworth to confirm this.
3. `beforeWrite()`. If it throws (today `credential-revoke-failed`), the error propagates as it is and
nothing has been written, as now.
4. Prepare, the `:221` check, rename and commit, wrapped so that any error thrown after step 3 gets
`revoked = true` set on it before it is rethrown. That covers `revision-conflict` from `:221`, an
`organization-refused` from `log()`, a barrier throw, and an fs error.
`record()` and the other writers (`launch-settings`, `plugin-settings`, `credentialVerbs`) pass no
`beforeWrite` and behave as before.
### 2. commit() passes beforeWrite through (instance-settings.mjs)
`commit()` passes `beforeWrite` to `writer.write` and no longer calls it itself. The row 194 b3 early check
stays. It costs one read, keeps T21's refusal before any validation work, and is no longer what protects
the revoke. Its comment is rewritten to say so, and the "(BUILD.md follow-up)" sentence goes.
`instanceSettings({ ..., writer = organizationWriter({ dir, rolesDir }) })` takes an optional writer, so a
test can inject one (the brief's "injected writer"). `runtime.mjs` passes none.
### 3. The bus names a refusal after a revoke (broker.mjs `#organizationFail`)
The bus answers with a code only, and the CLI and Console look the text up by code. So the "may already be
revoked" signal has to be a code. Two new codes:
- `revision-conflict-revoked`: `e.revoked && e.code === 'revision-conflict'`.
- `outcome-unknown-revoked`: `e.revoked` with any other code. The write may or may not have landed, since a
failure after the rename is recovered as committed.
Both are checked before the existing mappings. An error without `revoked` maps exactly as today, so a
refusal before any revoke keeps today's code and text. Q2 asks about the names.
### 4. The texts
- CLI `instances.mjs` REASONS:
- `revision-conflict-revoked`: "the business file changed before the write, after the agent's tokens
were revoked; the tokens may already be revoked and the instance can't use them; read the instances
again and run the same command again, which is safe".
- `outcome-unknown-revoked`: "the write's outcome is unknown and the agent's tokens may already be
revoked; read the instances before trying again".
- Console `serve.mjs` AGENT_REASONS: the same two, in the Console's wording ("delete again").
- `#fireJob`'s `fire.failed` notice: when the code ends in `-revoked`, it adds "Its credential tokens may
already be revoked." The chief's synchronous fire (no grants) answers with the code, as every chief verb
does.
- `transport.mjs` EXIT: `outcome-unknown-revoked` exits 1, as `outcome-unknown` does. Console
`bus.mjs` STATUS: `revision-conflict-revoked` 409, `outcome-unknown-revoked` 503. A delete's code
reaches both as a closed request's `refusal`, so these two only matter for a direct verb answer.
## Tests
- **T1, the brief's fire test** (business `instance-settings.test.mjs`). The fire uses an injected writer
that wraps the real one. Its `write` makes one hand edit of the business file (a vars change on another
instance) and then calls the real `write`, so the edit lands after `commit()`'s early check and before
the lock's `:188` check. Expected:
- the fire refuses `revision-conflict` without `revoked`;
- the revoke spy has no calls;
- the business file's bytes are the hand edit's;
- changes.jsonl has no new prepared entry.
- **T2** (same file). A fire on a real writer built with `barrier: stage => stage === 'prepared' && edit()`.
The revoke runs, then `:221` refuses. Expected: `revision-conflict` with `revoked === true`, one revoke
call, the file's bytes are the edit's, and the last receipt is `aborted`.
- **T3** (same file). The same, with a `prepared` barrier that throws. Expected: the error has
`revoked === true`, one revoke call, and the file unchanged. A delete through the same writer gives the
same result (T3b).
- **T4** (bus `fire-revokes-grants.test.mjs`, its injected `f.org`). `f.org.fireError` is a
`revision-conflict` with `revoked: true`. Expected:
- the job's `fire.failed` carries `revision-conflict-revoked`, and the inbox message says "may already
be revoked";
- the same with no `grants` (the synchronous fire) refuses `revision-conflict-revoked`;
- a plain `revision-conflict` still maps to `revision-conflict` and the message lacks the sentence.
- T4b: `outcome-unknown-revoked` for an fs-style error with `revoked`.
- **T5** (cli `lifecycle.test.mjs` or `instances.test.mjs`, whichever already reads a closed delete's
refusal). A delete closed with `revision-conflict-revoked` prints the new text, and one closed with
`revision-conflict` prints today's.
- **T6** (webui `agents.test.mjs`). The same pair for the Console's reason.
- T21 (bus `lifecycle-revoke.test.mjs`) stays as it is, for delete.
## Mutants
| Mutant | Change | Killed by |
| --- | --- | --- |
| M1 | `beforeWrite` before the lock: `commit()` calls `beforeWrite()` itself and passes none to `write` | T1 (the revoke spy has a call) |
| M2 | The fire refusal dropped: the `:188` revision check removed | T1 (the write lands over the hand edit) |
| M3a | The mark dropped: no `revoked = true` in the catch | T2, T3, and T4 through the codes |
| M3b | `#organizationFail` ignores `revoked` | T4 |
| M3c | CLI text: the two REASONS entries removed | T5 |
| M3d | Console text: the two AGENT_REASONS entries removed | T6 |
| M3e | The notice: the `fire.failed` sentence removed | T4 |
M3a-M3e together are the brief's "drop the may already be revoked text".
## Files
- `packages/business/src/organization.mjs`
- `packages/business/src/instance-settings.mjs`
- `packages/business/tests/instance-settings.test.mjs`
- `packages/bus/src/broker.mjs`
- `packages/bus/tests/fire-revokes-grants.test.mjs`
- `packages/cli/src/instances.mjs`
- `packages/cli/src/transport.mjs`
- one cli test file
- `packages/webui/src/serve.mjs`
- `packages/webui/src/bus.mjs`
- `packages/webui/tests/agents.test.mjs`
- This packet.
## Gate
As the brief says:
- Duckworth approves the manifest.
- The bus, business, cli and webui suites and every `scripts/test-*.sh` are green on Sage's slot, with
the touched test files run whole.
- M1-M3e are killed.
Before the in-review move I grep the tests for the changed comment and error text, and for hash pins on the
changed files.
## Questions for Duckworth
- **Q1.** Should `beforeWrite` run after the writer's validations (my pick) or straight after `:188` (the
brief's literal place)? Running it later means a document the writer would refuse revokes nothing.
- **Q2.** Are two new codes, `revision-conflict-revoked` and `outcome-unknown-revoked`, the right way to
carry the signal? The alternative is to give `BusError` a detail field, which every CLI and Console
reader would then have to learn. The codes are additive and no existing code changes meaning.
- **Q3.** The brief's mutant "drop the fire refusal": I read it as removing the `:188` check (M2). If you
meant the fire test's own refusal, say so and I'll redefine it.
+138
View File
@@ -0,0 +1,138 @@
# Row 207 build notes: 193c, `mosaic access user` verbs and `--level viewer` (#1705)
Builder Megavolt, reviewer Duckworth, gate owner Sage (sage-2790, Q220, mos-1301). Branch megavolt/row207 off
aca16404c. The brief is "Brief: 193c, mosaic access user verbs and --level viewer" in
docs/plans/2026-10-20_human-users-build.md (blob c74af2510). Its gate: Duckworth approves the manifest, the cli
suite and every `scripts/test-*.sh` are green on Sage's gate, and the mutants are killed.
## Sources
- The brief, quoted: "`mosaic access user add/remove` and `grant --level viewer`, only where R5 lacks them. They
refuse tester and agent callers, write a receipt line and call `access.reload` (section 8, CLI). If R5 already
has all of them, this row records that and closes with no code."
- Morgana's row 193 design, section 4.5 and section 8 (agents/morgana/work/queue-193/DESIGN.md at e2d161e2e on
morgana/row193): `user add <id> --home local --subject <provider>:<sub>`, `user remove <id>`, `--level viewer`
on `grant`, a receipt line in `<dataRoot>/state/access-log.jsonl` and the `access.reload` call.
- R5 as it stands at aca16404c: packages/cli/src/access.mjs, packages/cli/tests/access.test.mjs,
packages/business/src/access.mjs (checkAccess, read only here), packages/bus/src/broker.mjs and runtime.mjs.
## 1. What R5 already has
- `grant --level viewer`: present. LEVELS is `["collaborator", "viewer"]` (packages/business/src/vocabulary.mjs),
grant checks `--level` against it, and access.test.mjs already grants at viewer. This row adds no code for it
and records it here.
- `user add` and `user remove`: absent. accessVerb takes only list, grant and revoke, and the only way to add a
user today is to hand-edit access.json. This row adds them.
- `access.reload`: absent everywhere. No bus verb has that name. The R5 brief says "R4 later makes these verbs
call `access.reload`, so R5 leaves no other reload path", FED R3's brief leaves it to R4, and access.mjs's
proveHuman says "Row R4 swaps this call for access.reload". FED R4 is not filed.
## 2. Decisions (Q items for Sage and Duckworth, stated as assumptions, not blockers)
- Q1, the reload call. 193c adds no `access.reload` call, because there is no verb to call. The user verbs take
R5's path exactly: the same `project.list` human proof, and R4 swaps the proof for `access.reload` on all five
verbs at once. Nothing is lost meanwhile: runtime.mjs hands the broker `() => loadAccess(...)`, and the broker
reads the file once per user request (broker.mjs:384), so a written change acts on the next request. If Sage
or Duckworth reads the brief as "add `access.reload` here", that is R4's bus verb and a scope change.
- Q2, remove with grants. `user remove` refuses with `user-has-grants`, exit 2, naming the grant ids, while any
grant at any business names the user. The other reading drops the user's grants in the same revision. Refusing
is the smaller, fail-closed choice: nothing disappears that the owner did not name, and the help text already
says to revoke first. checkAccess would also refuse the file (a grant naming a missing user), but with a key
path, not a reason, and exit 3.
- Q3, the subject. `--subject PROVIDER:SUB` splits at the first colon, so a sub may hold colons. One subject per
add, because parse keeps one value per flag. A second subject is a later row if anyone needs it; checkAccess
allows several.
- Q4, `--business`. Required on the user verbs too. The user list is instance-wide, but the human proof runs
through one business's bus, and the receipt names it.
- Q5, the receipt. `{at, verb: "user.add" | "user.remove", business, user, actor: "human", revisionBefore,
revisionAfter, grants: []}`: grant and revoke's shape, with an empty grants list.
- `user remove` of an id not in the file refuses, exit 2, and writes nothing, as revoke of nothing does.
- `user add` of a duplicate id, a reserved id (human, tester), a business's human, an unknown provider, a
non-local home, an owner sso subject or a subject another user holds refuses through checkAccess, exit 3,
before any write. The verb does not repeat those checks.
### Rulings (sage-2810, 16:40Z, cc Duckworth)
- Q1 agreed: no reload call. The reason is broker.mjs:384-386, the `#access` field's comment: "() => the checked
access document (R1's loadAccess), read once per user request", wired in runtime.mjs:263 as
`() => loadAccess(access.file, access.context)`. A write acts on the next request. FED R4 owns any reload verb.
- Q2 agreed: `user remove` refuses with `user-has-grants`, exit 2, naming the grant ids, and writes nothing.
Dropping the grants silently would make removal revoke access without saying so.
- Usage and help pins: grep the tests for the changed strings, sha256 and snapshot pins included, and run every
reader whole (section 6).
- Row 203 (Quackerjack) does not touch access.mjs; its stale help lines stay a follow-up, so no overlap.
### The usage and help pin sweep (sage-2810), on aca16404c plus this row
- Source readers of ACCESS_USAGE and ACCESS_HELP: packages/cli/src/access.mjs only (cli.mjs imports
accessCommand and accessContextFor, not the strings).
- `git grep` over 'packages/*/tests/*' and 'scripts/*' for ACCESS_USAGE, ACCESS_HELP, "mosaic access",
"access list", "access grant", "access revoke", "Access (FED" and the help's "revoke them here first": the only
hits are packages/cli/tests/access.test.mjs (positive control: `accessCommand` hits it twice).
- Hash pins: no test file names packages/cli/README.md or packages/cli/src/access.mjs next to a sha256 or a
64-hex value. The one 64-hex file that mentions a README (packages/queue/tests/fixtures/queue-marked.md) names
packages/queue/README.md and packages/ledger/README.md, not this one. scripts/stack-copy.sh copies
packages/cli/README.md; stack-copy.test.mjs builds its own fake repo and is in the cli package, run whole.
- packages/business/tests/access*.test.mjs import packages/business/src/access.mjs, a different file, unchanged.
- So the readers are access.test.mjs, run whole in step 1, and the cli package whole in step 2.
## 3. The change (code and docs)
- packages/cli/src/access.mjs: two ACCESS_USAGE lines and one help line; accessVerb accepts `user add ID` and
`user remove ID` (three positionals) and hands off to `userVerb` after the shared checks (tester, business,
proof, file, context, revision). `userVerb` takes the lock, checks the revision, builds the new users list,
runs checkAccess on the whole document, writes atomically and appends the receipt.
- packages/cli/README.md: the usage block and one paragraph.
- No change to packages/business, packages/bus or cli.mjs (it already routes `access` to accessCommand).
## 4. Tests (packages/cli/tests/access.test.mjs, whole file)
Manifest check against Morgana's row 275 (sage-2790), 10-22: morgana/row275 has no commits past aca16404c, and
row 275's brief ("193b1b, viewer verbs and board refusals") is bus only: the viewer verb list, `covers` level
checks and board refusals, gated on the bus suite. This row touches only packages/cli (access.mjs, README,
access.test.mjs) and this file, so no path is shared on the briefs. I recheck the paths when Morgana pushes.
- T1 `user add` writes the user (id, home, one subject), bumps the revision by one, prints the line and appends
one receipt with verb `user.add` and `grants: []`. A sub with a colon (`authentik:bob:x`) keeps `bob:x`.
- T2 `user add` refusals before any write, file and receipts unchanged: missing `--home` or `--subject`, a
subject with no colon, an empty provider or sub (exit 4); an unknown provider, a duplicate id, `tester`, the
business's human, an owner sso subject, a subject alice already holds, a non-local home (exit 3).
- T3 `user remove` of a user with no grants drops them, bumps the revision, receipt `user.remove`.
- T4 `user remove` of a user with a grant refuses `user-has-grants` naming the grant id, exit 2, nothing written.
- T5 `user remove` of an id not in the file refuses, exit 2, nothing written.
- T6 a stale `--revision` refuses `revision-conflict` on both user verbs, exit 3.
- T7 `--tester` and an agent env (MOSAIC_RUN_ID) refuse both user verbs, exit 3, and the fake human CLI is never
called.
- T8 a bad shape (`user`, `user add`, `user list x`, `user add a b`) prints the help, exit 4.
- T9 a bus that refuses the human proof (the fixture's `mode: "refuse"`) blocks both user verbs: exit 3, the
file and receipts unchanged.
## 5. Mutants
Each runs the whole access.test.mjs and counts as killed only on an assertion in the named test.
- M1 drop the `user-has-grants` refusal. Second fence: checkAccess then refuses the file with exit 3, so T4 must
assert the `user-has-grants` text and exit 2, not only "nothing written". Killed by T4.
- M2 drop the "no user to remove" check. The write then lands with an unchanged user list. Killed by T5.
- M3 split the subject at the last colon. Killed by T1's colon case.
- M4 skip checkAccess in userVerb (write the unchecked document). Killed by T2's exit-3 cases.
- M5 drop the receipt in userVerb. Killed by T1 and T3.
- M6 drop the revision check in userVerb. Killed by T6.
- M7 exempt the user verbs from the `--tester` check (`&& sub !== "user"`). Killed by T7's tester case.
- M8 skip proveHuman for the user verbs (`if (sub !== "user")`). Killed by T9. T7's agent case does not reach it: accessCommand
calls refuseInsideAgent before accessVerb, so an agent env is refused whatever the order inside.
## 6. Gate plan (mos-1275)
On Sage's CLEAR, from a rebase onto 253's landing under a new branch name, stopping at the first red:
1. access.test.mjs whole, then M1-M8, each under `timeout -k 10 180`.
2. The cli package whole.
3. Every `scripts/test-*.sh` as `timeout -k 10 LIMIT bash scripts/test-X.sh`, no script edited. LIMIT is row
281's table (Gander's row 278 limits, 3x the longest of three records plus 120 s): auth 126, conductor 145,
config 130, discord 147, extension-package 130, foundation 240, land-work 158, queue 191, release 157,
task 231 seconds. The base has the same ten scripts. Any timeout goes to Sage, then Mos, unplaced.
## Runs
Filled in after the runs.
+1 -6
View File
@@ -1947,13 +1947,11 @@ export class Broker {
} catch (e) {
const code = e instanceof BusError ? e.code : 'outcome-unknown';
const list = out.grants.map((g) => g.grant).join(', ') || 'none';
// Row 239 (#1745): a step B refusal after the writer's revoke ran.
const tokens = code.endsWith('-revoked') ? ' Its credential tokens may already be revoked.' : '';
this.#store.transaction(() => {
this.#event(sc, 'action.allowed', { action: 'fire.failed', job, step, code }, name);
this.#humanNotice(
sc.business,
`Fire of ${name}: job ${job} revoked its grants (${list}) but stopped at step ${step} (${code}).${tokens} The instance is still firing. Run the fire again once ${code} is cleared.`,
`Fire of ${name}: job ${job} revoked its grants (${list}) but stopped at step ${step} (${code}). The instance is still firing. Run the fire again once ${code} is cleared.`,
);
});
}
@@ -4407,9 +4405,6 @@ export class Broker {
// The bus code for an organization writer's refusal. Anything the writer didn't refuse by name is
// outcome-unknown: the write may have committed.
#organizationFail(e, verb) {
// Row 239 (#1745): the writer marks an error raised after a fire's or a delete's revoke ran, so
// the human reads that the tokens may already be revoked. Without the mark, today's code.
if (e.revoked) fail(e.code === "revision-conflict" ? "revision-conflict-revoked" : "outcome-unknown-revoked");
if (e.code === "revision-conflict") fail("revision-conflict");
if (e.code?.startsWith("organization-")) fail(e.code);
if (e.code === "invalid-plugins" || e.code === "unknown-plugin") fail(e.code);
+5 -5
View File
@@ -148,11 +148,11 @@ export async function startBroker({
const levels=organization?await harnessLevels():()=>null;
const settings=organization?launchSettings({...organization,sessions:()=>readSessions(dataRoot),levels}):null;
// Row 194 (#1692, brief item 8, row 199 D13): a fire or a delete revokes the agent's credentials
// after the writer's checks and before its write, under the writer's lock (row 239). Fire revokes
// each service of the entry and keeps the store directory, delete calls remove (sage-1593). A
// service the running bus doesn't know (an agent created since the start) has nothing in memory,
// so it goes on, and then no token or tmp file may remain in the agent's store directory
// (sage-1592). Any failure stops the verb with the file unchanged.
// after the writer's checks and before its write. Fire revokes each service of the entry and keeps
// the store directory, delete calls remove (sage-1593). A service the running bus doesn't know (an
// agent created since the start) has nothing in memory, so it goes on, and then no token or tmp
// file may remain in the agent's store directory (sage-1592). Any failure stops the verb with the
// file unchanged.
const revokeFailed = (message) => new OrganizationError('credential-revoke-failed', message);
function revoke(business, agent, how, services) {
const instance = business + '/' + agent;
+2 -5
View File
@@ -17,8 +17,7 @@ import { Client } from "../src/client.mjs";
const baselines = {
"packages/bus/src/human.mjs": "0c6d96abe4607cba0b02b0638a2fc217ff8135bac9a47761a0ef015fbe9d3079",
"packages/bus/src/human-cli.mjs": "271b9781d73cf31cc562a56180e3ebcd725b5fde148a83347a201fdef2fc28af",
// Row 239 (#1745): EXIT outcome-unknown-revoked 1; no proof or transport change.
"packages/cli/src/transport.mjs": "662642a8ce739fcb6c188ad07125a94e008c8a305eced127c8041d472c56e55b",
"packages/cli/src/transport.mjs": "5129c44b4d9f642e825344df65c9bfb4bac4fc8d6b25343b42d39853dbd40774",
// Row 165 (#1662): seven STATUS entries for the role instance routes (organization-instance-exists,
// organization-unknown-instance, organization-credentials-out-of-scope, organization-launch-not-configured,
// organization-no-change, organization-project-mismatch, organization-unresolvable-instance), no proof or
@@ -26,9 +25,7 @@ const baselines = {
// Row 171 (#1668): project-name-is-repo maps to 409; no proof or transport change.
// Row 194 (#1692, brief item 7, sage-1610 a): organization-tracker-required 400 and
// organization-tracker-bot-taken 409; no proof or transport change.
// Row 239 (#1745): outcome-unknown-revoked joins OPEN (duckworth-0165 C1), and STATUS gets
// revision-conflict-revoked 409 and outcome-unknown-revoked 503; no proof or transport change.
"packages/webui/src/bus.mjs": "1eba977443b3e308dbc5b08567b11e1059bb238a5f57939c6917dd775156e5cf",
"packages/webui/src/bus.mjs": "15e8123ffdbb6d067e8a516f5823efdb5de3c869963764f27cd14bb9e4ce3313",
};
const repoFile = path => new URL("../../../" + path, import.meta.url);
@@ -133,9 +133,8 @@ async function setup(t) {
},
};
// The fake organization writer. A fire delists the name and a delete drops the role, each with a
// reload, as the runtime reloads after a commit. `fireError` makes the fire's write refuse, and
// `deleteError` the delete's (row 239).
const org = { log: [], fireError: null, deleteError: null };
// reload, as the runtime reloads after a commit. `fireError` makes the fire's write refuse.
const org = { log: [], fireError: null };
const sw = { state: { version: 1, halted: false, generation: GENERATION } };
let broker;
const organization = (business, op, args) => {
@@ -147,7 +146,6 @@ async function setup(t) {
return { revision: REV, transaction: 't-' + org.log.length };
}
if (op === 'instances.set') {
if (org.deleteError) throw org.deleteError;
delete doc.roles[args.instance];
doc.launch.instances = doc.launch.instances.filter((n) => n !== args.instance);
broker.reloadBusiness('demo', doc);
@@ -954,44 +952,3 @@ test("row 281 T14 (N1, sage-2653): a host removed under an undecided decision st
assert.ok(!f.doc.launch.instances.includes('coder'));
assert.equal(f.grantEvents().filter((e) => e.action === 'grant.revoke.refused').length, 1);
});
// Row 239 (#1745): the writer marks an error raised after its own revoke ran (revoked: true), and the
// bus names it, so the human reads that the tokens may already be revoked. Unmarked, today's code.
test("row 239 T4: a refusal after the writer's revoke is named -revoked, in the job's fire.failed and notice, a synchronous fire and a delete", async (t) => {
const f = await setup(t);
await f.approveHost('coder', 'web1');
f.org.fireError = Object.assign(new Error('refused'), { code: 'revision-conflict', revoked: true });
const out = f.fire('coder');
await f.broker.grantsIdle();
const [failed] = f.allowed('fire.failed');
assert.deepEqual([failed.body.job, failed.body.step, failed.body.code], [out.job, 'B', 'revision-conflict-revoked']);
const [message] = f.inbox();
assert.ok(message.includes('(revision-conflict-revoked). Its credential tokens may already be revoked. The instance is still firing.'), message);
// helper has no grant, so its fire writes synchronously and answers with the code.
await rejects(() => f.fire('helper'), 'revision-conflict-revoked');
// Any other marked error: the write may have landed.
f.org.fireError = Object.assign(new Error('EIO: i/o error'), { code: 'EIO', revoked: true });
await rejects(() => f.fire('helper'), 'outcome-unknown-revoked');
f.org.fireError = Object.assign(new Error('refused'), { code: 'revision-conflict' });
await rejects(() => f.fire('helper'), 'revision-conflict');
f.org.fireError = null;
f.org.deleteError = Object.assign(new Error('refused'), { code: 'revision-conflict', revoked: true });
assert.equal(f.remove('helper').code, 'revision-conflict-revoked');
f.org.deleteError = Object.assign(new Error('EIO: i/o error'), { code: 'EIO', revoked: true });
assert.equal(f.remove('helper').code, 'outcome-unknown-revoked');
f.org.deleteError = Object.assign(new Error('refused'), { code: 'revision-conflict' });
assert.equal(f.remove('helper').code, 'revision-conflict');
});
test("row 239 T4b: an unmarked step B refusal keeps its code, and the notice says nothing of the tokens", async (t) => {
const f = await setup(t);
await f.approveHost('coder', 'web1');
f.org.fireError = Object.assign(new Error('refused'), { code: 'revision-conflict' });
const out = f.fire('coder');
await f.broker.grantsIdle();
const [failed] = f.allowed('fire.failed');
assert.deepEqual([failed.body.job, failed.body.code], [out.job, 'revision-conflict']);
const [message] = f.inbox();
assert.ok(message.includes('(revision-conflict). The instance is still firing.'), message);
assert.ok(!message.includes('may already be revoked'), message);
});
+10 -9
View File
@@ -144,8 +144,9 @@ function createdCredentials(old, instance, definition, args) {
// checkRoutes, row 65). Row 194 (brief item 8, row 199 D13): revoke(business, instance, how,
// services) revokes the instance's credentials for a fire or a delete and throws
// credential-revoke-failed when it can't. Null in a writer with no bus (the CLI's), which revokes
// nothing. Row 239 (#1745): writer is the organization writer, injected only by tests.
export function instanceSettings({ dir, rolesDir, system, levels = () => null, revoke = null, writer = organizationWriter({ dir, rolesDir }) }) {
// nothing.
export function instanceSettings({ dir, rolesDir, system, levels = () => null, revoke = null }) {
const writer = organizationWriter({ dir, rolesDir });
// The definitions in rolesDir and whether add and set accept each one.
function definitions() {
@@ -378,9 +379,8 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null, r
}
}
// The checks every write runs, then the write, which runs beforeWrite (a fire's or a delete's
// revoke) under its lock (row 239). A check that refuses revokes nothing, and a revoke that throws
// writes nothing. beforeWrite must not write this business: the writer holds its lock.
// The checks every write runs, then beforeWrite (a fire's or a delete's revoke), then the write. A
// check that refuses revokes nothing, and a revoke that throws writes nothing.
function commit(business, old, next, { revision, actor, context, beforeWrite = null }) {
if (canonicalJson(next) === canonicalJson(old)) throw new OrganizationError("organization-no-change");
@@ -410,11 +410,12 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null, r
}
}
// Row 194 N1 (sage-1634): a delete writes on the revision its request read when it was raised,
// so a write since then makes it stale. This early check refuses it without taking the lock. The
// writer checks again under its lock, before the revoke (row 239), and that check is what keeps
// a stale fire or delete from revoking.
// so a write since then makes it stale. The revision is checked here, before the revoke, because
// the writer's own check comes after it. A write between this read and the writer's lock still
// revokes and then refuses (BUILD.md follow-up).
if (writer.read(business).revision !== revision) throw new OrganizationError("revision-conflict", "organization revision changed; read again");
const receipt = writer.write(business, next, { revision, actor, context, beforeWrite });
if (beforeWrite) beforeWrite();
const receipt = writer.write(business, next, { revision, actor, context });
return { ...read(business, actor), transaction: receipt.transaction, restart: false };
}
+47 -62
View File
@@ -177,70 +177,55 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
// Row 199 (#1697, design section 6): `detail` says what a credential verb did (verb, agent, kind,
// date, reason), never a value; the other setters pass none. context, when given, is logged with
// the change: row 194's hire and fire carry the chief's run and the decision there (D2.2).
// Row 239 (#1745): beforeWrite (a fire's or a delete's credential revoke) runs under the lock,
// after the revision check and the validations, so a refusal there revokes nothing. An error
// after it returns carries revoked: true, the lock's release included (duckworth-0165 C2), and the
// bus names it so the human reads that the tokens may already be revoked. beforeWrite must not
// write this business itself: it would find its own lock and refuse organization-busy.
function write(id, document, { revision, actor, detail, context, beforeWrite = null }) {
function write(id, document, { revision, actor, detail, context }) {
requireId(actor, "authenticated actor");
requireId(id, "organization id");
let revoked = false;
try {
return withLock(id, ({ home, parent }) => {
recover(id);
const file = businessFilePath(id, dir);
const oldBytes = regular(file);
const before = sha256(oldBytes);
if (revision !== before) fail("revision-conflict", "organization revision changed; read again");
const old = JSON.parse(oldBytes);
// C2-04 M9: every organization setter preserves the whole project
// mapping, including library playgrounds and hybrid universes.
if (canonicalJson(document?.projects) !== canonicalJson(old.projects))
fail('organization-projects-protected');
validateBusinessDocument(document, file, { rolesDir });
const bytes = JSON.stringify(document, null, 2) + "\n";
// Check and log exactly what lands on disk, for every setter: a value JSON
// can't carry (a Date, a Map) serializes as something else (row 131, sage-0314).
const written = JSON.parse(bytes);
if (canonicalJson(written.projects) !== canonicalJson(old.projects))
fail('organization-projects-protected');
validateBusinessDocument(written, file, { rolesDir });
if (beforeWrite) {
beforeWrite();
revoked = true;
}
const after = sha256(bytes);
const transaction = randomUUID();
const tx = join(home, transaction);
directory(tx);
const previous = join(tx, "previous.json");
durable(previous, oldBytes);
const candidate = join(parent, "." + id + "." + transaction + ".tmp");
durable(candidate, bytes);
syncDir(tx);
const event = {
transaction, actor, ...(context === undefined ? {} : { context }), business: id, before, after,
changes: changes(old, written), ...(detail === undefined ? {} : { detail }), at: new Date().toISOString(),
};
log(id, { ...event, state: "prepared" });
syncDir(home);
barrier("prepared");
if (sha256(regular(file)) !== before) {
log(id, { ...event, state: "aborted" });
rmSync(candidate);
fail("revision-conflict", "organization revision changed before publication");
}
renameSync(candidate, file);
syncDir(parent);
barrier("renamed");
log(id, { ...event, state: "committed" });
return { revision: after, transaction, previous };
});
} catch (e) {
if (revoked && e instanceof Error) e.revoked = true;
throw e;
}
return withLock(id, ({ home, parent }) => {
recover(id);
const file = businessFilePath(id, dir);
const oldBytes = regular(file);
const before = sha256(oldBytes);
if (revision !== before) fail("revision-conflict", "organization revision changed; read again");
const old = JSON.parse(oldBytes);
// C2-04 M9: every organization setter preserves the whole project
// mapping, including library playgrounds and hybrid universes.
if (canonicalJson(document?.projects) !== canonicalJson(old.projects))
fail('organization-projects-protected');
validateBusinessDocument(document, file, { rolesDir });
const bytes = JSON.stringify(document, null, 2) + "\n";
// Check and log exactly what lands on disk, for every setter: a value JSON
// can't carry (a Date, a Map) serializes as something else (row 131, sage-0314).
const written = JSON.parse(bytes);
if (canonicalJson(written.projects) !== canonicalJson(old.projects))
fail('organization-projects-protected');
validateBusinessDocument(written, file, { rolesDir });
const after = sha256(bytes);
const transaction = randomUUID();
const tx = join(home, transaction);
directory(tx);
const previous = join(tx, "previous.json");
durable(previous, oldBytes);
const candidate = join(parent, "." + id + "." + transaction + ".tmp");
durable(candidate, bytes);
syncDir(tx);
const event = {
transaction, actor, ...(context === undefined ? {} : { context }), business: id, before, after,
changes: changes(old, written), ...(detail === undefined ? {} : { detail }), at: new Date().toISOString(),
};
log(id, { ...event, state: "prepared" });
syncDir(home);
barrier("prepared");
if (sha256(regular(file)) !== before) {
log(id, { ...event, state: "aborted" });
rmSync(candidate);
fail("revision-conflict", "organization revision changed before publication");
}
renameSync(candidate, file);
syncDir(parent);
barrier("renamed");
log(id, { ...event, state: "committed" });
return { revision: after, transaction, previous };
});
}
// Row 199: a receipt with no document change, for a credential verb that changes only the agents'
@@ -4,10 +4,9 @@
import test, { after } from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { readFileSync, rmSync } from "node:fs";
import { join } from "node:path";
import { instanceSettings } from "../src/instance-settings.mjs";
import { OrganizationError, organizationWriter } from "../src/organization.mjs";
import { CREATIVE_DEFINITIONS } from "../src/vocabulary.mjs";
import { businessDoc, REPO, rolesCopy, systemConfig, systemFor, tmp, writeJson } from "./helpers.mjs";
@@ -37,7 +36,7 @@ function fixture(mutate) {
const log = () => readFileSync(settings.writer.logFile("acme"), "utf8").trim().split("\n").map(line => JSON.parse(line));
const onDisk = () => JSON.parse(readFileSync(file, "utf8"));
const revision = () => settings.read("acme").revision;
return { root, dir, rolesDir, file, settings, validate, log, onDisk, revision };
return { root, file, settings, validate, log, onDisk, revision };
}
// Each refusal: its code (or the validator's BusinessError), and the file's bytes unchanged.
@@ -245,73 +244,3 @@ test("row 256 checkDelete passes the human's creative delete and refuses another
assert.throws(() => f.settings.checkFire("acme", "bean"), e => e.code === "organization-definition-refused");
assert.equal(readFileSync(f.file, "utf8"), bytes);
});
// Row 239 (#1745): a fire's or a delete's revoke runs inside the writer, under its lock, after the
// revision check. revoking(f, writer, fail) builds settings on f's files with a spy revoke and that writer.
function revoking(f, writer, fail = null) {
const calls = [];
const revoke = (business, agent, how, services) => {
calls.push([business, agent, how, services]);
if (fail) throw fail;
};
const settings = instanceSettings({ dir: f.dir, rolesDir: f.rolesDir, system: systemFor(f.root), revoke, ...(writer ? { writer } : {}) });
return { settings, calls };
}
const receipts = f => (existsSync(f.settings.writer.logFile("acme")) ? f.log() : []);
// A hand edit: the same document in other bytes, so the revision changes and the file still validates.
const handEdit = f => {
const bytes = JSON.stringify(f.onDisk()) + "\n";
writeFileSync(f.file, bytes);
return bytes;
};
test("row 239 T1 a fire on a revision that goes stale before the writer's lock refuses there and revokes nothing", () => {
const f = fixture();
const real = organizationWriter({ dir: f.dir, rolesDir: f.rolesDir });
let edited = null;
// The edit lands after commit()'s early check and before the writer takes its lock.
const writer = { ...real, write: (id, document, options) => { edited = handEdit(f); return real.write(id, document, options); } };
const { settings, calls } = revoking(f, writer);
const before = receipts(f).length;
assert.throws(() => settings.fire("acme", { name: "sec-1" }, { actor: "chief", context: CONTEXT }),
e => e.code === "revision-conflict" && e.revoked === undefined);
assert.deepEqual(calls, []);
assert.equal(readFileSync(f.file, "utf8"), edited);
assert.equal(receipts(f).length, before);
});
test("row 239 T2 a write after the revoke refused before publication carries revoked, and the file keeps the edit", () => {
const f = fixture();
let edited = null;
const writer = organizationWriter({ dir: f.dir, rolesDir: f.rolesDir, barrier: stage => { if (stage === "prepared") edited = handEdit(f); } });
const { settings, calls } = revoking(f, writer);
assert.throws(() => settings.fire("acme", { name: "sec-1" }, { actor: "chief", context: CONTEXT }),
e => e.code === "revision-conflict" && e.revoked === true);
assert.deepEqual(calls, [["acme", "sec-1", "fire", []]]);
assert.equal(readFileSync(f.file, "utf8"), edited);
assert.equal(receipts(f).at(-1).state, "aborted");
});
test("row 239 T3 any error after the revoke carries revoked, for a fire and a delete; a revoke that throws carries nothing", () => {
for (const [how, act] of [
["fire", settings => settings.fire("acme", { name: "sec-1" }, { actor: "chief", context: CONTEXT })],
["delete", (settings, f) => settings.set("acme", { revision: f.revision(), instance: "sec-1", op: "delete" }, "jason")],
]) {
const f = fixture();
const bytes = readFileSync(f.file, "utf8");
const writer = organizationWriter({ dir: f.dir, rolesDir: f.rolesDir, barrier: stage => { if (stage === "prepared") throw new Error("stopped at prepared"); } });
const { settings, calls } = revoking(f, writer);
assert.throws(() => act(settings, f), e => e.message === "stopped at prepared" && e.revoked === true, how);
assert.equal(calls.length, 1, how);
assert.equal(calls[0][2], how);
assert.equal(readFileSync(f.file, "utf8"), bytes, how);
const g = fixture();
const gBytes = readFileSync(g.file, "utf8");
const failing = revoking(g, null, new OrganizationError("credential-revoke-failed"));
assert.throws(() => act(failing.settings, g), e => e.code === "credential-revoke-failed" && e.revoked === undefined, how);
assert.equal(failing.calls.length, 1, how);
assert.equal(readFileSync(g.file, "utf8"), gBytes, how);
assert.deepEqual(receipts(g), [], how);
}
});
+10
View File
@@ -1084,6 +1084,8 @@ prints a token. A usage error exits 4 and a bus refusal 2.
mosaic access list --business ID [--json]
mosaic access grant --business ID --user ID --projects UUID|NAME,...|* --instances NAME,...|* --level collaborator|viewer [--expires ISO] --revision N
mosaic access revoke --business ID --user ID [--grant GID] --revision N
mosaic access user add ID --business ID --home local --subject PROVIDER:SUB --revision N
mosaic access user remove ID --business ID --revision N
```
These edit `<configDir>/access.json` (design section 3.5). The human proof is one `project.list`
@@ -1107,6 +1109,14 @@ once. Removing a provider from `console.sso` takes effect only after a Console r
Peer homes are not read yet, so a grant that names a peer home refuses until row 203 adds the
federation key. A grant that names a sign-in subject needs `console.sso` in the system config.
`user add` and `user remove` (row 193c, #1705) take the same proof, lock, revision check, file
checks and receipt. `user add` takes one sign-in subject, `PROVIDER:SUB`, split at the first colon;
the provider must be in `console.sso`. `user remove` refuses with `user-has-grants` while any grant,
at any business, names the user: revoke those first. `--business` names the business whose bus
takes the proof; the user list itself is instance-wide. Their receipt verbs are `user.add` and
`user.remove`. The bus has no `access.reload` yet (FED R4). As with grant and revoke, the broker
reads the file once per user request (`packages/bus/src/broker.mjs`), so the change acts at once.
## Token usage
`mosaic usage [--business ID] [--json] [--tester]` reads the bus's accounting, without
+55 -2
View File
@@ -1,4 +1,5 @@
// `mosaic access list|grant|revoke` (federation design section 3.5, FED R5, #414).
// `mosaic access list|grant|revoke|user` (federation design section 3.5, FED R5, #414;
// `user add|remove`, row 193c, #1705).
// The human edits <configDir>/access.json here. Each write is checked against the
// instance's context before it lands, bumps the revision by one, and appends one
// receipt line to <dataRoot>/state/access-log.jsonl. The human proof is one call
@@ -18,6 +19,8 @@ export const ACCESS_USAGE = [
"mosaic access list --business ID [--json]",
"mosaic access grant --business ID --user ID --projects UUID|NAME,...|* --instances NAME,...|* --level collaborator|viewer [--expires ISO] --revision N",
"mosaic access revoke --business ID --user ID [--grant GID] --revision N",
"mosaic access user add ID --business ID --home local --subject PROVIDER:SUB --revision N",
"mosaic access user remove ID --business ID --revision N",
].join("\n");
// The help text. A grant's `instances` is the real limit: the granted instance
@@ -33,6 +36,7 @@ list is the real limit: a collaborator's message drives a granted instance with
the owner's role.
To cut a user off, revoke them here first. It acts at once. Removing a provider
from console.sso takes effect only after a Console restart, so do that second.
user remove refuses while the user has a grant at any business: revoke those first.
Peer homes are not read yet (row 203 adds the federation key), so a grant that
names a peer home refuses. A grant that names a sign-in subject needs console.sso
in the system config; without it every such grant refuses.
@@ -206,7 +210,8 @@ export async function accessCommand(args, io, deps) {
async function accessVerb(args, io, deps) {
const parsed = parse(args);
const sub = parsed.positional[0];
if (!["list", "grant", "revoke"].includes(sub) || parsed.positional.length !== 1) throw new CliError(ACCESS_HELP, 4);
const shape = sub === "user" ? ["add", "remove"].includes(parsed.positional[1]) && parsed.positional.length === 3 : ["list", "grant", "revoke"].includes(sub) && parsed.positional.length === 1;
if (!shape) throw new CliError(ACCESS_HELP, 4);
if (parsed.flags.has("--tester")) throw new CliError("refused: access writes take the human's shell, not --tester", 3);
const business = parsed.values["--business"];
if (!business) throw new CliError(`--business is required\n${ACCESS_USAGE}`, 4);
@@ -224,6 +229,7 @@ async function accessVerb(args, io, deps) {
const expected = parsed.values["--revision"];
if (!/^\d+$/.test(expected ?? "")) throw new CliError(`--revision takes the revision you read\n${ACCESS_USAGE}`, 4);
if (sub === "user") return userVerb(parsed, io, deps, { business, file, context, expected });
const user = parsed.values["--user"];
if (!user) throw new CliError(`${sub} needs --user\n${ACCESS_USAGE}`, 4);
@@ -278,6 +284,53 @@ async function accessVerb(args, io, deps) {
io.stdout.write(`access ${sub}: ${result.touched.join(", ")} at ${business}, revision ${result.checked.revision}\n`);
}
// `user add ID` and `user remove ID` (row 193c): the same lock, revision check, file checks,
// atomic write and receipt as grant and revoke. add takes one subject, PROVIDER:SUB, split at the
// first colon, so a sub may hold colons. remove refuses while any grant names the user, at any
// business, so a user is never removed with live grants (the owner revokes first, as the help says).
async function userVerb(parsed, io, deps, { business, file, context, expected }) {
const [, action, user] = parsed.positional;
let entry;
if (action === "add") {
const home = parsed.values["--home"];
const subject = parsed.values["--subject"] ?? "";
const colon = subject.indexOf(":");
if (home === undefined || colon < 1 || colon === subject.length - 1) {
throw new CliError(`user add needs --home and --subject PROVIDER:SUB\n${ACCESS_USAGE}`, 4);
}
entry = { id: user, home, subjects: [{ provider: subject.slice(0, colon), sub: subject.slice(colon + 1) }] };
}
const result = withLock(file, () => {
const { document, revision } = readCurrent(file, context);
if (Number(expected) !== revision) {
throw new CliError(`refused: revision-conflict: the access file is at revision ${revision}, not ${expected}; run list again`, 3);
}
let users;
if (action === "add") users = [...document.users, entry];
else {
if (!document.users.some((u) => u.id === user)) throw new CliError(`refused: no user ${user} to remove`, 2);
const held = document.grants.filter((g) => g.user === user).map((g) => g.id);
if (held.length) throw new CliError(`refused: user-has-grants: ${user} still holds ${held.join(", ")}; revoke them first`, 2);
users = document.users.filter((u) => u.id !== user);
}
const checked = checkAccess({ ...document, revision: revision + 1, users }, context);
writeAtomic(file, `${JSON.stringify(checked, null, 2)}\n`, deps.fs);
return { checked, revision };
}, deps.fs);
receipt(deps.system.dataRoot, {
at: (deps.now ?? (() => new Date()))().toISOString(),
verb: `user.${action}`,
business,
user,
actor: "human",
revisionBefore: result.revision,
revisionAfter: result.checked.revision,
grants: [],
});
io.stdout.write(`access user ${action}: ${user}, revision ${result.checked.revision}\n`);
}
function formatList(doc) {
const lines = [`access file revision ${doc.revision ?? "none"}`];
for (const u of doc.users) lines.push(`user ${u.id} home ${u.home}`);
-6
View File
@@ -55,12 +55,6 @@ export const REASONS = {
"credential-revoke-failed": "a credential couldn't be revoked, so the business file is unchanged; tokens already revoked " +
"stay revoked and the instance can't use them; clear what else the agent's store directory holds and run the same " +
"command again, which is safe",
// Row 239 (#1745): a fire's or a delete's revoke runs under the writer's lock; these follow one that ran.
"revision-conflict-revoked": "the business file changed after the agent's tokens were revoked and before the write, so " +
"the file is unchanged; the tokens may already be revoked and the instance can't use them; read it again with " +
"`mosaic business instances` and run the same command again, which is safe",
"outcome-unknown-revoked": "the agent's tokens may already be revoked and the write's outcome is unknown; read the " +
"business file again with `mosaic business instances` before trying again",
"capacity-full": "a launch limit is reached; `mosaic limits --business BUSINESS` shows them",
"limit-reached": "a launch limit is reached; `mosaic limits --business BUSINESS` shows them",
"unknown-role": "the business has no such instance",
-1
View File
@@ -42,7 +42,6 @@ const EXIT = {
"tester-off": 3,
"tester-refused": 3,
"outcome-unknown": 1,
"outcome-unknown-revoked": 1,
"response-too-large": 1,
"invalid-response": 1,
};
+137
View File
@@ -312,3 +312,140 @@ test("the real human-cli, run from agent ancestry, refuses and writes nothing",
assert.equal(readFileSync(file, "utf8"), before);
assert.equal(existsSync(join(f.dataRoot, "state", "access-log.jsonl")), false);
});
// Row 193c (#1705): `user add` and `user remove`. BUILD.md (agents/megavolt/work/queue-207) names
// each test T1-T9 and the mutant it kills.
const USER_ADD = (id, { subject = "authentik:bob-sub", home = "local", revision = "1" } = {}) => [
"user", "add", id, "--business", "acme", "--home", home, "--subject", subject, "--revision", revision,
];
const USER_REMOVE = (id, revision = "1") => ["user", "remove", id, "--business", "acme", "--revision", revision];
test("T1 user add writes the user one revision up, prints it and appends a user.add receipt", async (t) => {
const s = setup(t);
const out = await run(USER_ADD("bob", { subject: "authentik:bob:x" }), s.deps);
assert.match(out, /access user add: bob, revision 2/);
const doc = s.read();
assert.equal(doc.revision, 2);
assert.deepEqual(doc.users[1], { id: "bob", home: "local", subjects: [{ provider: "authentik", sub: "bob:x" }] });
assert.deepEqual(doc.users[0], DOC.users[0]);
const lines = s.receipts().trim().split("\n").map((l) => JSON.parse(l));
assert.equal(lines.length, 1);
assert.deepEqual(lines[0], {
at: "2026-10-20T20:00:00.000Z", verb: "user.add", business: "acme", user: "bob", actor: "human",
revisionBefore: 1, revisionAfter: 2, grants: [],
});
});
test("T2 a bad user add refuses before any write", async (t) => {
const s = setup(t);
const before = readFileSync(s.file, "utf8");
const usage = [
["user", "add", "bob", "--business", "acme", "--subject", "authentik:bob-sub", "--revision", "1"],
["user", "add", "bob", "--business", "acme", "--home", "local", "--revision", "1"],
USER_ADD("bob", { subject: "authentik" }),
USER_ADD("bob", { subject: ":bob-sub" }),
USER_ADD("bob", { subject: "authentik:" }),
];
for (const args of usage) {
const err = await refusal(run(args, s.deps), /user add needs --home and --subject PROVIDER:SUB/);
assert.equal(err.exitCode, 4, args.join(" "));
}
const checked = [
[USER_ADD("bob", { subject: "github:bob-sub" }), /provider must be a configured provider/],
[USER_ADD("alice", { subject: "authentik:alice-2" }), /duplicate user id: alice/],
[USER_ADD("tester"), /a bus principal/],
[USER_ADD("jason"), /a business's human/],
[USER_ADD("bob", { subject: "authentik:jason-sub" }), /owner sign-in subject/],
[USER_ADD("bob", { subject: "authentik:alice-sub" }), /appears more than once/],
[USER_ADD("bob", { home: "peer-one" }), /home must be a configured peer/],
];
for (const [args, pattern] of checked) {
const err = await refusal(run(args, s.deps), pattern);
assert.equal(err.exitCode, 3, args.join(" "));
}
assert.equal(readFileSync(s.file, "utf8"), before);
assert.equal(s.receipts(), "");
});
test("T3 user remove of a user with no grant drops them one revision up, with a user.remove receipt", async (t) => {
const s = setup(t);
const out = await run(USER_REMOVE("alice"), s.deps);
assert.match(out, /access user remove: alice, revision 2/);
const doc = s.read();
assert.equal(doc.revision, 2);
assert.deepEqual(doc.users, []);
const [line] = s.receipts().trim().split("\n").map((l) => JSON.parse(l));
assert.equal(line.verb, "user.remove");
assert.equal(line.user, "alice");
assert.equal(line.revisionBefore, 1);
assert.equal(line.revisionAfter, 2);
assert.deepEqual(line.grants, []);
});
// M1's second fence: without the user-has-grants refusal, checkAccess refuses the file with exit 3
// (a grant naming a missing user). The test pins the reason and exit 2, so only the verb's refusal passes.
test("T4 user remove refuses with user-has-grants while the user holds a grant, and writes nothing", async (t) => {
const s = setup(t);
await run([...GRANT, "--revision", "1"], s.deps);
const id = s.read().grants[0].id;
const before = readFileSync(s.file, "utf8");
const err = await refusal(run(USER_REMOVE("alice", "2"), s.deps), /user-has-grants: alice still holds /);
assert.equal(err.exitCode, 2);
assert.ok(err.message.includes(id));
assert.equal(readFileSync(s.file, "utf8"), before);
assert.equal(s.receipts().trim().split("\n").length, 1);
});
test("T5 user remove of an id not in the file refuses and writes nothing", async (t) => {
const s = setup(t);
const before = readFileSync(s.file, "utf8");
const err = await refusal(run(USER_REMOVE("carol"), s.deps), /no user carol to remove/);
assert.equal(err.exitCode, 2);
assert.equal(readFileSync(s.file, "utf8"), before);
assert.equal(s.receipts(), "");
});
test("T6 a stale revision refuses both user verbs with revision-conflict", async (t) => {
const s = setup(t);
const before = readFileSync(s.file, "utf8");
for (const args of [USER_ADD("bob", { revision: "2" }), USER_REMOVE("alice", "5")]) {
const err = await refusal(run(args, s.deps), /revision-conflict/);
assert.equal(err.exitCode, 3);
}
assert.equal(readFileSync(s.file, "utf8"), before);
assert.equal(s.receipts(), "");
});
test("T7 --tester and an agent caller are refused on both user verbs before the bus is asked", async (t) => {
const s = setup(t);
const before = readFileSync(s.file, "utf8");
for (const args of [USER_ADD("bob"), USER_REMOVE("alice")]) {
const tester = await refusal(run([...args, "--tester"], s.deps), /not --tester/);
assert.equal(tester.exitCode, 3);
const agent = await refusal(run(args, s.deps, { ...humanEnv(), MOSAIC_RUN_ID: "run-1" }), /human shell/);
assert.equal(agent.exitCode, 3);
}
assert.equal(existsSync(s.log), false);
assert.equal(readFileSync(s.file, "utf8"), before);
assert.equal(s.receipts(), "");
});
test("T8 a user verb of the wrong shape prints the help", async (t) => {
const s = setup(t);
for (const shape of [["user"], ["user", "add"], ["user", "list", "x"], ["user", "add", "a", "b"]]) {
const err = await refusal(run([...shape, "--business", "acme", "--revision", "1"], s.deps), /mosaic access user add ID/);
assert.equal(err.exitCode, 4, shape.join(" "));
}
assert.equal(existsSync(s.log), false);
});
test("T9 a bus that refuses the human proof blocks both user verbs", async (t) => {
const s = setup(t, { mode: "refuse" });
const before = readFileSync(s.file, "utf8");
for (const args of [USER_ADD("bob"), USER_REMOVE("alice")]) {
const err = await refusal(run(args, s.deps), /human-required/);
assert.equal(err.exitCode, 3);
}
assert.equal(readFileSync(s.file, "utf8"), before);
assert.equal(s.receipts(), "");
});
-22
View File
@@ -316,28 +316,6 @@ test("row 194 CLI V5: delete polls to the transaction and the read lists the nam
assert.match(failed.err, new RegExp(`^mosaic: workspace-rename-failed: .*run the same delete again to retry the rename \\(request [^,]+, transaction ${s.log().at(-1).transaction}\\)$`, "m"));
});
// Row 239 (#1745): a fire's or a delete's revoke runs under the writer's lock. A delete closed after
// that revoke ran says the tokens may already be revoked; a plain revision-conflict keeps its text.
test("row 239 T5 CLI: a delete closed -revoked says the tokens may already be revoked; a plain revision-conflict doesn't", async (t) => {
let refusal = "revision-conflict-revoked";
const s = await setup(t, { hook: closing(() => ({ refusal })) });
const added = await s.mosaic(["instance", "add", "acme", "sec-1", "--definition", "security-reviewer", "--revision", await s.revision()]);
assert.equal(added.code, 0, added.err);
const del = async () => s.mosaic(["instance", "delete", "acme", "sec-1", "--revision", await s.revision()]);
let r = await del();
assert.equal(r.code, 3, r.err);
assert.match(r.err, /^mosaic: revision-conflict-revoked: the business file changed after the agent's tokens were revoked .*; the tokens may already be revoked .*which is safe \(request [^)]+\)$/m);
refusal = "outcome-unknown-revoked";
r = await del();
assert.equal(r.code, 3, r.err);
assert.match(r.err, /^mosaic: outcome-unknown-revoked: the agent's tokens may already be revoked and the write's outcome is unknown; .* \(request [^)]+\)$/m);
refusal = "revision-conflict";
r = await del();
assert.equal(r.code, 3, r.err);
assert.match(r.err, /^mosaic: revision-conflict: the business file changed since that revision; .* \(request [^)]+\)$/m);
assert.doesNotMatch(r.err, /may already be revoked/);
});
test("row 194 CLI: lifecycle and veto usage errors exit 4 before any bus call", async (t) => {
const s = await setup(t);
const rev = "a".repeat(64);
-2
View File
@@ -45,8 +45,6 @@ test("a bus code on stderr becomes the exit code; garbage and timeouts are outco
test("busExit and refuseInsideAgent", () => {
assert.equal(busExit("unauthenticated"), 3);
assert.equal(busExit("invalid-request"), 2);
// Row 239 (#1745): a write whose outcome is unknown after its revoke exits as outcome-unknown does.
assert.equal(busExit("outcome-unknown-revoked"), 1);
assert.doesNotThrow(() => refuseInsideAgent(humanEnv()));
assert.throws(() => refuseInsideAgent({ PI_AGENT_DIR: "/x" }), (e) => e.exitCode === 3 && /PI_AGENT_DIR/.test(e.message));
});
+1 -4
View File
@@ -75,8 +75,7 @@ export const busReader = options => views({ call: busCaller(options) });
// A send's error, sorted. The broker writes in one transaction, so any code it
// returns means nothing was written. A transport that lost the answer, or an
// answer that can't be read, leaves the outcome open: the message may exist.
// Row 239 (duckworth-0165 C1): so does a write that failed after its revoke.
const OPEN = ['outcome-unknown', 'invalid-response', 'response-too-large', 'outcome-unknown-revoked'];
const OPEN = ['outcome-unknown', 'invalid-response', 'response-too-large'];
export function sendOutcome(err) {
const code = err instanceof BusReadError ? err.code : 'outcome-unknown';
return OPEN.includes(code) ? { code: 'outcome-unknown', sent: 'unknown' } : { code, sent: false };
@@ -88,8 +87,6 @@ const STATUS = {
'human-required': 403, 'peercred-required': 403, unauthenticated: 403, 'unknown-business': 403, 'read-only': 403,
'outcome-unknown': 503, 'no-bus-host': 503, 'not-configured': 503,
'revision-conflict':409,'organization-busy':409,'organization-uncertain':503,
// Row 239 (#1745): a fire's or a delete's write refused after its revoke ran.
'revision-conflict-revoked': 409, 'outcome-unknown-revoked': 503,
'invalid-request': 400, 'unknown-role': 400, 'message-not-found': 400, 'request-too-large': 400,
// Row 148: the spec and docs editor. A stale base, a busy or dirty repository and an unchanged
// file are the user's to resolve by reloading; a path or content the project refuses is a 400.
-3
View File
@@ -364,9 +364,6 @@ const AGENT_REASONS = {
'workspace-rename-failed': 'the business file is changed and the name retired, but the workspace stayed where it was; delete it again to retry the rename',
// Brief item 8, deviation 11 (sage-1620): the revoke runs before the write, so a retry is safe.
'credential-revoke-failed': 'a credential couldn\'t be revoked, so the business file is unchanged. Tokens already revoked stay revoked and the instance can\'t use them. Clear what else the agent\'s store directory holds and delete again, which is safe',
// Row 239 (#1745): a fire's or a delete's revoke runs under the writer's lock; these follow one that ran.
'revision-conflict-revoked': 'the business file changed after the agent\'s tokens were revoked and before the write, so the file is unchanged. The tokens may already be revoked and the instance can\'t use them. Read the role instances again and delete again, which is safe',
'outcome-unknown-revoked': 'the agent\'s tokens may already be revoked and the write\'s outcome is unknown. Read the role instances again before trying',
'capacity-full': 'a launch limit is reached',
'limit-reached': 'a launch limit is reached',
'no-route': 'the instance has no harness, model and thinking yet; set its route first',
-18
View File
@@ -220,21 +220,3 @@ test('row 194 N5: agents.js is served as JavaScript and index.html loads it once
const html = await get(s, '/', { host: 'console.example', 'x-user': 'jason' });
assert.equal(html.text.match(/<script src="\/agents\.js" defer><\/script>/g)?.length, 1);
});
// Row 239 (#1745): a fire's or a delete's revoke runs under the writer's lock. A delete closed after
// that revoke ran says the tokens may already be revoked; a plain revision-conflict keeps its text.
// duckworth-0165 C1: a direct answer of outcome-unknown-revoked is an open outcome, never "Nothing changed".
test('row 239 T6: a delete closed -revoked says the tokens may already be revoked, and outcome-unknown-revoked is an open outcome', async t => {
let refusal = 'revision-conflict-revoked';
const s = await setup(t, { agent: async (verb, args) => { if (verb !== 'instance.request') throw new BusReadError('outcome-unknown-revoked'); return { ...ANSWERS['instance.request'](args), done: { run: null, transaction: null, refusal } }; } });
const reason = async () => (await get(s, `/api/agent/request?id=${ID}`)).body.reason;
assert.match(await reason(), /business file changed after the agent's tokens were revoked .*\. The tokens may already be revoked .* delete again, which is safe$/);
refusal = 'outcome-unknown-revoked';
assert.match(await reason(), /tokens may already be revoked and the write's outcome is unknown\. Read the role instances again before trying$/);
refusal = 'revision-conflict';
assert.match(await reason(), /the business file changed since you read it/);
assert.doesNotMatch(await reason(), /may already be revoked/);
const unknown = await send(s, '/api/agent/launch', { instance: 'coder' });
assert.deepEqual([unknown.status, unknown.body.error, unknown.body.sent], [503, 'outcome-unknown', 'unknown']);
assert.doesNotMatch(unknown.body.message, /Nothing changed/);
});