Compare commits

..
19 changed files with 388 additions and 566 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.
+90
View File
@@ -0,0 +1,90 @@
# Row 275 (193b1b, #1780): viewer verbs and board refusals
Morgana, 2026-10-22, for Duckworth (reviewer) and Sage (gate owner). Brief: docs/plans/2026-10-20_human-users-build.md,
"Brief: 193b1b, viewer verbs and board refusals" (blob de3a6310b). Design: row 193 DESIGN.md r5 e2d161e2e
(morgana/row193), sections 4.3, 8 and 9. Base: refactor aca16404c, branch morgana/row275. Assignment: sage-2788.
## What the code did before
- `#userGrants` (broker.mjs, FED R3) kept only grants that cover at level `collaborator` (`USER_LEVEL`).
A viewer-only user was refused `access-revoked` at `bindUser` and on every request.
- `#granted` and `#grantedThread` asked `collaborator` for every user verb, reads included.
- The reader session's board list was an inline array at the request gate.
## The change
1. `VIEWER_VERBS = ['project.list', 'thread.list', 'thread.messages', 'names']`, exported. `userLevel(verb)`
gives `viewer` for these and `collaborator` for the other user verbs (thread.create, thread.open,
thread.send). `USER_LEVEL` is gone.
2. `#userGrants` keeps a live grant at any level (`covers(..., {level: 'viewer'})`), so a viewer binds and
passes the request gate. The verb check against `USER_VERBS` is unchanged and still comes first.
3. `#granted(s, verb, project, instance)` and `#grantedThread(s, verb, t)` take the verb and ask
`userLevel(verb)`. Each handler passes its own verb name. A viewer gets `access-refused` on create,
open and send, the code design 4.3 names.
4. `BOARD_VERBS` exports the reader list unchanged (inbox, agents, thread.claims, tasks, trail, decisions,
credentials, replies, messages, state, usage), and the reader gate uses it. None is in `USER_VERBS`, so
every user is refused them `access-refused`. The test reads the same constant and pins it written out.
Downgrade path (design 4.3 note 2): a collaborator lowered to viewer still lists and reads its own threads
and can't open or send. The runtime's user transport calls `bindUser`, so a viewer can now bind over the
socket too. That is the intended effect; the verbs it reaches are the four above.
Not in this row: the share verbs, `thread.list {shared: true}` and `#readable`'s share rules (193b2, row 206);
the revocation timer, `access.reload` and the level-loss test (FED R4, which mutant 5 moved to). FED R4 has
not landed; this row checks the level per request only, which is what R3 already reads per request.
## Existing test changes (mos-1311 ruling)
Mos approved both changes in mos-1311 (16:44:56Z), under mos-1272. They follow row 193 DESIGN r5
(e2d161e2e, approved by Duckworth) section 4.3: the thread.list row at DESIGN.md:125 ("own threads still
covered (from an earlier collaborator grant)") and note 2 at :139 ("A viewer can still read its own
threads when a collaborator grant is lowered to viewer, but can't send or open them"). Condition 3: Duckworth
confirms in review that both match section 4.3.
All in `packages/bus/tests/user-principal.test.mjs` (FED R3, row 274):
| Test | Assertion | Old expectation | New expectation | Design line |
|---|---|---|---|---|
| "R3: bindUser binds a granted user and refuses one with no live grant" (title was "...no live collaborator grant") | `bindUser` with `grant({ level: 'viewer' })` | refused `access-revoked` (case `'viewer level'` in the refused list) | binds: `assert.ok(user('alice'), 'viewer level')`; the case leaves the refused list | :125 viewer column; 4.3 viewer verbs need a bound viewer |
| "R3: a removed or expired grant refuses the next request access-revoked..." | `thread.list` after `level: 'viewer'` ('downgraded') | `access-revoked` | `null` (answers) | :125 and :139 |
| same test, added (mos-1311 condition 1) | `thread.send` and `thread.open` on A after the downgrade | (not asserted) | each `access-refused`; `messages` and `thread_opens` counts unchanged | :139 "can't send or open them" |
The header comment of the file is updated to say a viewer grant binds. Every other R3 refusal (no grant,
another user, another business, expired, unreadable file, no file, removed, file refused) is unchanged.
No other test names a viewer grant on the bus (git grep for `level: 'viewer'` outside packages/business).
## Tests, packages/bus/tests/viewer.test.mjs (new)
1. VIEWER_VERBS is the ruled list and sits inside USER_VERBS.
2. A viewer binds; project.list, names, thread.list and thread.messages answer what its grant covers
(alice downgraded, bob viewer from the start).
3. A viewer is refused thread.create, thread.open and thread.send `access-refused`, and messages, threads
and thread_opens counts are unchanged. Kills mutant 6 at alice's send. r3 (duckworth-0166 B1): bob is
asked create only, as `#thread` refuses a non-owner `thread-not-owned` before the level check; open and
send are asked on alice's own thread. Bob stays the viewer who never held a collaborator grant.
4. Every verb off USER_VERBS refuses a viewer `access-refused` before its own checks.
5. Mixed grants: collaborator on project a, viewer on b. Create in a works, create in b refuses; send in b's
old thread refuses, open refuses, messages reads the message sent while collaborator (r3, N1).
6. Board reads (BOARD_VERBS, the project file verbs, limits, plugins, instances, routes) refuse alice
(downgraded viewer), bob (viewer) and carol (collaborator) `access-refused`. Kills mutant 10.
7. Governance pin, user side: one row per item of the 5.7 sentence (decisions, inbox entries, claims as
`agents` and `thread.claims`, tasks as `tasks` and `state`, project files as `project.files` and
`project.file`), each refused to a collaborator and a viewer while a run in carol's thread has raised a
decision the owner sees; carol still reads her own message with thread.messages. The owner side stays
in message-filter.test.mjs (row 205).
## Mutants (design 8)
| # | Mutation | Killed by |
|---|---|---|
| 6 | `'thread.send'` added to `VIEWER_VERBS` | viewer.test 3 (send answers); test 1 pin fails too |
| 10 | `'trail'` added to `USER_VERBS` | viewer.test 6 (trail no longer access-refused); R3's exact USER_VERBS pin fails too |
The kill rule for each mutant names the behaviour test above, not the pin.
## Gate (brief)
Duckworth approves the manifest. On Sage's gate: the touched test files whole (viewer, user-principal,
message-filter), the bus suite and every `scripts/test-*.sh` green, mutants 6 and 10 killed. broker.mjs
is imported by the cli, harness, business and seat suites as well; I'll propose them for the gate as in
row 277 (sage-2590). Nothing has run yet: built during Gander's 253 slot with no node.
+31 -26
View File
@@ -132,8 +132,15 @@ export const TESTER_VERBS = Object.freeze(['message.send', 'message.receive', 'm
// refuses a user access-refused before its own checks: owner verbs, board reads, message.*,
// decision.*, project files, spec.read, assistant.stop, launch.*, name.set and usage.*.
export const USER_VERBS = Object.freeze(['project.list', 'thread.create', 'thread.list', 'thread.open', 'thread.send', 'thread.messages', 'names']);
// The level a user's grant must reach for USER_VERBS (R1's covers, morgana-0005).
const USER_LEVEL = 'collaborator';
// Row 275 (193b1b, #1780, row 193 design 4.3 and 9): the user verbs a viewer grant reaches. They
// read, so a live grant at any level covers them. thread.create, thread.open and thread.send need a
// collaborator grant on the project and instance, so a viewer gets access-refused on each.
export const VIEWER_VERBS = Object.freeze(['project.list', 'thread.list', 'thread.messages', 'names']);
// The least level a user verb needs (R1's covers, morgana-0005; LEVELS runs from most to least).
const userLevel = (verb) => (VIEWER_VERBS.includes(verb) ? 'viewer' : 'collaborator');
// Row 275 (row 193 design 4.3): the owner's board reads, which a reader session may call. A user,
// collaborator or viewer, is refused every one at the USER_VERBS gate.
export const BOARD_VERBS = Object.freeze(['inbox', 'agents', 'thread.claims', 'tasks', 'trail', 'decisions', 'credentials', 'replies', 'messages', 'state', 'usage']);
// Row 175: the organization verbs a tester may call. Its writes run every check the human's do
// and log actor `tester`; row 176 adds routes reads; limits.set, organization.set and organization.revision refuse it.
export const TESTER_ORGANIZATION_VERBS = Object.freeze(['limits', 'plugins', 'instances', 'plugins.set', 'instances.set', 'routes.set', 'routes']);
@@ -764,7 +771,8 @@ export class Broker {
return s.tester ? 'tester' : s.user ? 'user:' + s.user : s.human;
}
// FED R3 (design 3.5): the user's live grants in the session's business, from one read of the
// access file. Any failure to read leaves none.
// access file. Any failure to read leaves none. Row 275: a grant at any level counts here; each
// verb's own check asks the level it needs (#granted).
#userGrants(s) {
let doc = null;
try {
@@ -774,21 +782,22 @@ export class Broker {
}
const now = new Date();
return (doc?.grants ?? []).filter((g) => g.user === s.user && g.business === s.business &&
covers(g, { business: s.business, project: g.projects[0], instance: g.instances[0] }, now, { level: USER_LEVEL }));
covers(g, { business: s.business, project: g.projects[0], instance: g.instances[0] }, now, { level: 'viewer' }));
}
// Does one of the request's grants cover a thread of `instance` in `project`? A null `instance`
// asks about the project alone, a null `project` about the instance alone.
#granted(s, project, instance) {
// Does one of the request's grants cover a thread of `instance` in `project`, at the level `verb`
// needs? A null `instance` asks about the project alone, a null `project` about the instance alone.
#granted(s, verb, project, instance) {
const now = new Date();
return s.grants.some((g) => covers(g, {
business: s.business,
project: project ?? g.projects[0],
instance: instance ?? g.instances[0],
}, now, { level: USER_LEVEL }));
}, now, { level: userLevel(verb) }));
}
// A thread the request's grants still cover, else access-refused (design 3.5, thread.open/send/messages).
#grantedThread(s, t) {
if (s.user && !this.#granted(s, t.project, t.instance)) fail('access-refused');
// A thread the request's grants still cover at the level `verb` needs, else access-refused
// (design 3.5, thread.open/send/messages; row 193 design 4.3 for the level).
#grantedThread(s, verb, t) {
if (s.user && !this.#granted(s, verb, t.project, t.instance)) fail('access-refused');
return t;
}
// Row 205 (193b1a, #1703, row 193 design 5.7): a message in a thread a user other than the caller
@@ -1011,7 +1020,8 @@ export class Broker {
}
// FED R3 (#414, design 3.6): the trusted user transport invokes this only after row 196's
// verifyPrincipal checked the user CLI and its Console parent. It binds only a user the access
// file gives a live collaborator grant in the business (check 3), else access-revoked. A user id
// file gives a live grant in the business (check 3), else access-revoked. Row 275: a viewer grant
// binds too; the verbs it reaches are VIEWER_VERBS. A user id
// is never the bus's own principals' or the business human's, whatever the file says.
bindUser(record) {
keys(record, ['business', 'user'], ['business', 'user']);
@@ -1947,13 +1957,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.`,
);
});
}
@@ -4228,7 +4236,7 @@ export class Broker {
return result;
}
return this.#store.transaction(() => {
if (s.reader && !['inbox', 'agents', 'thread.claims', 'tasks', 'trail', 'decisions', 'credentials', 'replies', 'messages', 'state', 'usage'].includes(request.verb))
if (s.reader && !BOARD_VERBS.includes(request.verb))
fail('read-only');
if (s.tester && !TESTER_VERBS.includes(request.verb)) fail('tester-refused');
// A tester reads the human's views, as a reader does, and its message verbs are its own.
@@ -4407,9 +4415,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);
@@ -5684,7 +5689,7 @@ export class Broker {
return this.#store.all(
'SELECT instance,name,at FROM instance_names n WHERE business=? AND seq=(SELECT max(seq) FROM instance_names WHERE business=n.business AND instance=n.instance) ORDER BY instance',
s.business,
).filter((r) => !s.user || this.#granted(s, null, r.instance));
).filter((r) => !s.user || this.#granted(s, 'names', null, r.instance));
}
// Row 121 (#1615): projects group the user's threads (REQ-ORG-2). `repo` names a
// project from the business file; the rename to repos is a P19 follow-up.
@@ -5714,7 +5719,7 @@ export class Broker {
// FED R3 (design 3.5): a user lists granted projects only.
return this.#store
.all('SELECT id,at,name,repo FROM projects WHERE business=? ORDER BY seq', s.business)
.filter((p) => !s.user || this.#granted(s, p.id, null));
.filter((p) => !s.user || this.#granted(s, 'project.list', p.id, null));
}
// A thread belongs to the user who made it and to the business's one thread instance.
// Row 177 (#1675): a tester's thread is owned by `tester`, so neither reaches the other's.
@@ -5726,7 +5731,7 @@ export class Broker {
// before the project is looked up, so an ungranted id reads the same whether it exists or not.
if (s.user) {
const threaded = Object.keys(b.roles).filter((r) => this.#threaded(s.business, r));
if (threaded.length !== 1 || !this.#granted(s, identifier(a.project), threaded[0])) fail('access-refused');
if (threaded.length !== 1 || !this.#granted(s, 'thread.create', identifier(a.project), threaded[0])) fail('access-refused');
}
if (!this.#store.get('SELECT 1 FROM projects WHERE id=? AND business=?', identifier(a.project), s.business))
fail('project-not-found');
@@ -5764,14 +5769,14 @@ export class Broker {
s.business,
this.#owner(s),
...(a.project === undefined ? [] : [a.project]),
).filter((t) => !s.user || this.#granted(s, t.project, t.instance));
).filter((t) => !s.user || this.#granted(s, 'thread.list', t.project, t.instance));
}
// Opening a thread asks the launcher to start its session (REQ-LAUNCH-1). Row 264a: a thread
// verb names one session of the thread, or works on its default (D3).
case 'thread.open': {
if (!s.tester && !s.user) this.#human(s);
keys(a, ['thread', 'session'], ['thread']);
const t = this.#grantedThread(s, this.#thread(s, a.thread));
const t = this.#grantedThread(s, 'thread.open', this.#thread(s, a.thread));
const session = this.#threadSession(t, a.session);
this.#store.run('INSERT INTO thread_opens(at,thread,session,by) VALUES(?,?,?,?)', this.#now(), t.id, session, this.#owner(s));
return { opened: t.id };
@@ -5781,7 +5786,7 @@ export class Broker {
case 'thread.send': {
if (!s.tester && !s.user) this.#human(s);
keys(a, ['thread', 'session', 'body', 'class'], ['thread', 'body']);
const t = this.#grantedThread(s, this.#thread(s, a.thread));
const t = this.#grantedThread(s, 'thread.send', this.#thread(s, a.thread));
const session = this.#threadSession(t, a.session);
// Row 120's lists hold here as on message.send: the instance must hear from the human.
this.#checkMessaging(s.business, 'human', t.instance);
@@ -5810,7 +5815,7 @@ export class Broker {
case 'thread.messages': {
if (!s.tester && !s.user) this.#human(s);
keys(a, ['thread', 'session', 'after', 'limit'], ['thread']);
const t = this.#grantedThread(s, this.#thread(s, a.thread));
const t = this.#grantedThread(s, 'thread.messages', this.#thread(s, a.thread));
const session = this.#threadSession(t, a.session);
const limit = a.limit ?? THREAD_PAGE;
if (!Number.isInteger(limit) || limit < 1 || limit > THREAD_PAGE_MAX) fail('invalid-request');
+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);
});
+16 -6
View File
@@ -1,6 +1,7 @@
// FED R3 (#414, federation design 3.5, 3.6 and the R3 row of section 7): the broker's user principal.
// A user binds only with a live collaborator grant, reaches the exact collaborator verb list, sees
// only what its grants cover, and talks to the granted instance in its own threads. The access
// A user binds only with a live grant (row 275: a viewer grant binds too; viewer.test.mjs has the
// viewer cases), reaches the exact collaborator verb list, sees only what its grants cover, and
// talks to the granted instance in its own threads. The access
// document comes in as a function, as the runtime passes R1's loadAccess; each test changes it.
import test from 'node:test';
import assert from 'node:assert/strict';
@@ -83,15 +84,17 @@ function granted(t, options) {
return { ...ctx, p1, p2, A };
}
test('R3: bindUser binds a granted user and refuses one with no live collaborator grant', (t) => {
test('R3: bindUser binds a granted user and refuses one with no live grant', (t) => {
const { b, access, user } = setup(t);
assert.ok(user('alice'));
// Row 275 (193b1b): a viewer grant binds; it used to refuse here as the case 'viewer level'.
access.doc.grants = [grant({ level: 'viewer' })];
assert.ok(user('alice'), 'viewer level');
for (const [what, grants] of [
['no grant', []],
['another user', [grant({ user: 'bob' })]],
['another business', [grant({ business: 'other' })]],
['expired', [grant({ expires: '2000-01-01T00:00:00Z' })]],
['viewer level', [grant({ level: 'viewer' })]],
]) {
access.doc.grants = grants;
assert.throws(() => b.bindUser({ business: 'demo', user: 'alice' }), { code: 'access-revoked' }, what);
@@ -297,7 +300,7 @@ test('R3: a user thread run cannot message the owner, while its uncited gated ra
});
test('R3: a removed or expired grant refuses the next request access-revoked, and the file is read per request', (t) => {
const { call, code, access } = granted(t);
const { call, code, access, store, A } = granted(t);
const reads = access.reads;
call('alice', 'thread.list');
call('alice', 'project.list');
@@ -307,8 +310,15 @@ test('R3: a removed or expired grant refuses the next request access-revoked, an
assert.equal(code('alice', 'thread.list'), 'access-revoked', 'removed');
access.doc.grants = kept.map((g) => ({ ...g, expires: '2000-01-01T00:00:00Z' }));
assert.equal(code('alice', 'project.list'), 'access-revoked', 'expired');
// Row 275 (193b1b, design 4.3): a grant lowered to viewer still reads the user's own threads.
access.doc.grants = kept.map((g) => ({ ...g, level: 'viewer' }));
assert.equal(code('alice', 'thread.list'), 'access-revoked', 'downgraded');
assert.equal(code('alice', 'thread.list'), null, 'downgraded');
// mos-1311 condition 1: the downgraded viewer still can't send or open, and nothing is stored.
const stored = () => ['messages', 'thread_opens'].map((x) => store.get(`SELECT count(*) n FROM ${x}`).n);
const before = stored();
assert.equal(code('alice', 'thread.send', { thread: A, body: 'still mine?' }), 'access-refused', 'downgraded send');
assert.equal(code('alice', 'thread.open', { thread: A }), 'access-refused', 'downgraded open');
assert.deepEqual(stored(), before, 'downgraded: nothing stored');
access.fail = true;
access.doc.grants = kept;
assert.equal(code('alice', 'thread.list'), 'access-revoked', 'file refused');
+182
View File
@@ -0,0 +1,182 @@
// Row 275 (193b1b, #1780, row 193 design 4.3, 8 and 9): the viewer level on the bus. A viewer grant
// binds and reaches VIEWER_VERBS only; thread.create, thread.open and thread.send need a collaborator
// grant on the project and instance. Board reads refuse every user, collaborator or viewer, and the
// user-side governance pin names each item of the 5.7 user sentence. The fixture is user-principal's.
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { Store } from '../src/store.mjs';
import {
Broker, ACTIONS, USER_VERBS, VIEWER_VERBS, BOARD_VERBS, TESTER_VERBS, TESTER_ORGANIZATION_VERBS, PROJECT_FILE_VERBS,
} from '../src/broker.mjs';
const shipped = (name) =>
JSON.parse(readFileSync(fileURLToPath(new URL(`../../../roles/${name}.json`, import.meta.url)), 'utf8'));
const options = [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }];
const grant = (over = {}) => ({
id: 'g-1', user: 'alice', business: 'demo', projects: ['*'], instances: ['assistant'], level: 'collaborator', ...over,
});
function setup(t) {
const root = mkdtempSync(join(tmpdir(), 'bus-viewer-'));
const store = new Store(root);
t.after(() => {
store.close();
rmSync(root, { recursive: true, force: true });
});
const access = { doc: { accessVersion: 1, revision: 1, users: [], grants: [grant()] } };
const b = new Broker({
store,
businesses: {
demo: {
id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, vars: { 'bus.tester': true },
roles: {
chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send'], crossRole: [] } },
assistant: { definition: 'assistant', authority: { withinRole: ['message.send'], crossRole: [] } },
},
definitions: { 'chief-of-staff': {}, assistant: { messaging: shipped('assistant').messaging } },
},
},
access: () => access.doc,
});
const caps = { human: b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }) };
const call = (who, verb, args = {}) => b.request(caps[who], { verb, args });
const code = (who, verb, args = {}) => {
try {
call(who, verb, args);
} catch (e) {
return e.code;
}
return null;
};
const user = (name) => (caps[name] = b.bindUser({ business: 'demo', user: name }));
const launch = (name, thread) => {
caps[name] = b.bindLaunch({ business: 'demo', role: 'assistant', run: `${name}-run`, harness: 'pi', thread });
return call(name, 'role.claim');
};
return { b, store, access, caps, call, code, user, launch };
}
// Two projects. Alice, a collaborator on p1, made thread A there and sent one message; then her grant
// is lowered to viewer (the downgrade path, design 4.3). Bob is a viewer on p1 from the start.
function viewers(t) {
const ctx = setup(t);
const p1 = ctx.call('human', 'project.create', { name: 'shared' }).id;
const p2 = ctx.call('human', 'project.create', { name: 'private' }).id;
ctx.access.doc.grants = [grant({ projects: [p1] })];
ctx.user('alice');
const A = ctx.call('alice', 'thread.create', { project: p1, name: 'alice work' }).id;
const sent = ctx.call('alice', 'thread.send', { thread: A, body: 'hello' }).id;
ctx.access.doc.grants = [
grant({ projects: [p1], level: 'viewer' }),
grant({ id: 'g-b', user: 'bob', projects: [p1], level: 'viewer' }),
];
ctx.user('bob');
return { ...ctx, p1, p2, A, sent };
}
test('275: VIEWER_VERBS is the ruled read list, inside USER_VERBS', () => {
assert.deepEqual(VIEWER_VERBS, ['project.list', 'thread.list', 'thread.messages', 'names']);
for (const v of VIEWER_VERBS) assert.ok(USER_VERBS.includes(v), v);
});
test('275: a viewer binds, and each viewer verb answers with what its grant covers', (t) => {
const { call, p1, A, sent } = viewers(t);
call('human', 'name.set', { instance: 'assistant', name: 'ada' });
call('human', 'name.set', { instance: 'chief', name: 'cleo' });
for (const who of ['alice', 'bob']) {
assert.deepEqual(call(who, 'project.list').map((p) => p.id), [p1], who);
assert.deepEqual(call(who, 'names').map((n) => n.instance), ['assistant'], who);
}
// Alice's own old thread still lists and reads; bob has none.
assert.deepEqual(call('alice', 'thread.list').map((x) => x.id), [A]);
assert.deepEqual(call('alice', 'thread.messages', { thread: A }).map((m) => m.id), [sent]);
assert.deepEqual(call('bob', 'thread.list'), []);
});
// Mutant 6 (row 193 design 8): a viewer allowed thread.send. This test kills it.
test('275: a viewer is refused thread.create, thread.open and thread.send access-refused, and nothing is stored', (t) => {
const { store, code, p1, A } = viewers(t);
const counts = () => ['messages', 'threads', 'thread_opens'].map((x) => store.get(`SELECT count(*) n FROM ${x}`).n);
const before = counts();
for (const who of ['alice', 'bob'])
assert.equal(code(who, 'thread.create', { project: p1, name: `${who} new` }), 'access-refused', `${who} create`);
// Only alice owns a thread: #thread refuses a non-owner thread-not-owned before the level check
// (duckworth-0166 B1), so open and send are asked on her own A.
assert.equal(code('alice', 'thread.open', { thread: A }), 'access-refused', 'alice open');
assert.equal(code('alice', 'thread.send', { thread: A, body: 'still here?' }), 'access-refused', 'alice send');
assert.deepEqual(counts(), before);
});
test('275: every verb off the user list refuses a viewer access-refused before its own checks', (t) => {
const { b, caps } = viewers(t);
const others = new Set([...ACTIONS, ...TESTER_VERBS, ...TESTER_ORGANIZATION_VERBS, ...PROJECT_FILE_VERBS, ...BOARD_VERBS, 'no.such.verb']);
for (const v of USER_VERBS) others.delete(v);
assert.ok(others.size > 40);
for (const verb of others)
assert.throws(() => b.request(caps.bob, { verb, args: { junk: true } }), { code: 'access-refused' }, verb);
});
test('275: mixed grants, collaborator on one project and viewer on another, take the level per project', (t) => {
const { call, code, access, user } = setup(t);
const pa = call('human', 'project.create', { name: 'a' }).id;
const pb = call('human', 'project.create', { name: 'b' }).id;
access.doc.grants = [grant({ projects: [pa] }), grant({ id: 'g-2', projects: [pb] })];
user('alice');
const B = call('alice', 'thread.create', { project: pb, name: 'b work' }).id;
const inB = call('alice', 'thread.send', { thread: B, body: 'in b, before' }).id;
access.doc.grants = [grant({ projects: [pa] }), grant({ id: 'g-2', projects: [pb], level: 'viewer' })];
const A = call('alice', 'thread.create', { project: pa, name: 'a work' }).id;
assert.ok(A, 'create in the collaborator project works');
assert.equal(code('alice', 'thread.create', { project: pb, name: 'b more' }), 'access-refused', 'create in the viewer project');
assert.deepEqual(call('alice', 'project.list').map((p) => p.id), [pa, pb]);
assert.deepEqual(call('alice', 'thread.list').map((x) => x.id), [B, A]);
assert.ok(call('alice', 'thread.send', { thread: A, body: 'in a' }).id);
assert.equal(code('alice', 'thread.send', { thread: B, body: 'in b' }), 'access-refused');
assert.equal(code('alice', 'thread.open', { thread: B }), 'access-refused');
assert.deepEqual(call('alice', 'thread.messages', { thread: B }).map((m) => m.id), [inB]);
});
// Mutant 10 (row 193 design 8): a user allowed `trail`. This test kills it; R3's exact USER_VERBS
// pin in user-principal.test.mjs fails too. Carol is a collaborator, so the refusal isn't the level's.
test('275: board reads refuse users and viewers access-refused', (t) => {
const { code, access, user, p1, A } = viewers(t);
assert.deepEqual(BOARD_VERBS, ['inbox', 'agents', 'thread.claims', 'tasks', 'trail', 'decisions', 'credentials', 'replies', 'messages', 'state', 'usage']);
access.doc.grants = [...access.doc.grants, grant({ id: 'g-c', user: 'carol', projects: [p1] })];
user('carol');
for (const who of ['alice', 'bob', 'carol']) {
for (const verb of [...BOARD_VERBS, ...PROJECT_FILE_VERBS, 'limits', 'plugins', 'instances', 'routes'])
assert.equal(code(who, verb, verb === 'trail' ? { subject: A } : {}), 'access-refused', `${who} ${verb}`);
}
});
// Row 193's user sentence, from the user's side (mos-1023 item 2, design 8): "The business owner sees
// your thread's agent's decisions, inbox entries, claims, tasks and project files, but not your
// messages." The owner side is message-filter.test.mjs's pin. Here each item the owner sees is a
// board read the user is refused, at either level, while the user still reads its own thread.
test('governance pin, user side: decisions, inbox entries, claims, tasks and project files are the owner\'s, the user reads its messages', (t) => {
const { call, code, access, user, launch, p1 } = viewers(t);
access.doc.grants = [...access.doc.grants, grant({ id: 'g-c', user: 'carol', projects: [p1] })];
user('carol');
const C = call('carol', 'thread.create', { project: p1, name: 'carol work' }).id;
const sent = call('carol', 'thread.send', { thread: C, body: 'please plan it' }).id;
launch('a', C);
const d = call('a', 'decision.raise', { action: 'deploy', question: 'Ship?', options, recommendation: 'yes', blocking: false }).id;
assert.ok(call('human', 'inbox').some((x) => x.id === d), 'the owner sees the raise');
const items = [
['decisions', 'decisions', { list: 'recent' }],
['inbox entries', 'inbox', {}],
['claims', 'agents', {}],
['claims', 'thread.claims', {}],
['tasks', 'tasks', {}],
['tasks', 'state', {}],
['project files', 'project.files', { project: 'stack' }],
['project files', 'project.file', { project: 'stack', path: 'docs/plan.md' }],
];
for (const who of ['carol', 'bob'])
for (const [item, verb, args] of items) assert.equal(code(who, verb, args), 'access-refused', `${item}: ${who} ${verb}`);
assert.deepEqual(call('carol', 'thread.messages', { thread: C }).map((m) => m.id), [sent], 'messages: the user reads its own');
});
+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);
}
});
-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,
};
-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/);
});