Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0c28366f6 | ||
|
|
5da5f28f6c |
No files matched your search
@@ -0,0 +1,120 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,159 @@
|
||||
# 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.
|
||||
@@ -1,139 +0,0 @@
|
||||
# Row 252 build notes: Console inbox Approve and Deny for grant requests
|
||||
|
||||
Seat: Scrooge. Reviewer: Webby. Issue #1753, Vikunja #435 (J2).
|
||||
Brief: `docs/plans/2026-10-21_console-inbox-approve.md` (blob b41676d06).
|
||||
Design: Gander's DESIGN.md at d218e14bd, approved by Webby at 5d8f0f4b5 (webby-0082) with manifest checks N1-N5.
|
||||
Base: aca16404c on branch `scrooge/row252`. Rebase onto the row 253 tip under a new branch name once 253 lands.
|
||||
|
||||
## Status
|
||||
|
||||
- Code, tests and docs are written. No test has been run yet, because Gander holds the gate slot for row 253 run 5 and Sage ruled no node until a CLEAR.
|
||||
- Everything in the Results section is empty until that run. Nothing below claims a pass.
|
||||
|
||||
## Q188
|
||||
|
||||
No agent creates a grant. Approve and Deny are human-only Console actions.
|
||||
|
||||
- Only a console cap reaches `decision.preview.console` and `decision.resolve.console`. A console cap reaches nothing else (console-decide `J1, Q188`).
|
||||
- The socket refuses a console proof for any other verb before the proof check (`server.mjs`).
|
||||
- A console cap binds only through `authenticateConsole`, which needs the console proof (section 2): the same-tree `console-cli.mjs` wrapper, its own nonce, a direct same-tree `serve` parent, and a configured owner subject from the spawn environment.
|
||||
- `recordEvent` keeps `human.input` reserved, so no agent writes the record that lets a console close start the executor (console-decide `recordEvent keeps human.input reserved`).
|
||||
- A `via console` close without its matching `human.input` approves nothing (console-decide `J14`).
|
||||
|
||||
## Manifest checks N1-N5
|
||||
|
||||
- **N1.** Every `console.sso` subject is an owner of the business. Name is display only.
|
||||
- `cli/src/config.mjs` carries `{provider, iss, sub}` per subject into the boot as `consoleOwners`. No sso means no key.
|
||||
- `bus/src/runtime.mjs` validates the list (array, at most 512, exactly three non-empty strings each) and passes it to `verifyPrincipal`.
|
||||
- Tests: cli config.test `Row 252 (Webby N1)` and principal.test.
|
||||
- **N2.** `human.input` is written in the same transaction, before `#approvedGrant`. The executor starts once, and a deny starts none.
|
||||
- Tests: console-decide `N2, J13` checks the order by seq and that the grant runs once. `J15` checks that a deny starts nothing.
|
||||
- **N3.** The close notice comes from `from_run 'human-console'`, and `public/bus.js` shows "via console". `landing` and `#protectedEnd` stay CLI-only.
|
||||
- Tests: console-decide `N2, J13` checks the notice. decide-browser `J17` checks the close-elsewhere line.
|
||||
- **N4.** `decision.resolve` is not a Console verb.
|
||||
- `CONSOLE_VERBS` is exactly the two console verbs. The webui serve `BUS_VERBS` read list has no decision verb, and webui `cli.mjs` has no `decision.` string. `consoleCall` refuses anything outside `CONSOLE_VERBS`.
|
||||
- Tests: console-decide `N4`, and decide-sso `N4`, which hits `/api/bus/decision.*` for 404 and covers the cli.mjs scan and the consoleCall refusals.
|
||||
- **N5.** `readProcess` keeps the five `MOSAIC_BUS_CONSOLE_*` keys in the env allowlist (`bus/src/human.mjs`). The human proof and its spawn are unchanged.
|
||||
|
||||
## Design choices and tradeoffs
|
||||
|
||||
- **Webui imports from the broker.** `webui/src/bus.mjs` imports `CONSOLE_VERBS` from `bus/src/broker.mjs`, which loads the broker module into the Console at import.
|
||||
- I chose one source for the verb list over a second copy that could drift.
|
||||
- If Webby prefers the copy, the alternative is a literal list plus a test pinning it equal to `CONSOLE_VERBS`.
|
||||
- **The Console transport shares humanCall's spawn.** `consoleCall` and `humanCall` share it as `transport()`, so `webui/src/bus.mjs` changed. The busCaller `business` default keeps `||` (the old falsy semantics), not `??`.
|
||||
- **Confirmations are held in memory in the serve process.** Their lifetime is 2 minutes, they are single use, and they bind the session, decision, choice and digest. The cap is a limit that never drops a live confirmation.
|
||||
- A restart loses all held confirmations. The user clicks again and gets a new preview. Nothing is written until the resolve.
|
||||
- **No retry anywhere.** A resolve whose bus reply is lost answers `outcome-unknown`, the confirmation is spent, and the page says "Nothing was sent again."
|
||||
|
||||
## Old tests changed (for Mos, per mos-1272)
|
||||
|
||||
Mos ruled both changes allowed in mos-1321 (17:12:28Z), each with one condition.
|
||||
- **shell.test condition:** reword the comment that opened "No Resolve control", since it is false once the row lands. It now says row 252's Approve and Deny is the one decision control, that its labels come from ACTIONS, and that the doesNotMatch keeps any literal label out of the markup. Webby checks the wording in review.
|
||||
- **channel-proof condition:** a header comment is not enough, because human.mjs changes what readProcess keeps. In the review, Webby reads the human.mjs and webui bus.mjs diffs and states two things:
|
||||
- the human proof's checks are unchanged
|
||||
- only the console verifier reads the five MOSAIC_BUS_CONSOLE_* keys, and each case is covered by a named test in console-decide or decide-sso
|
||||
- **Second row to land (252 or 239):** it recomputes the webui bus.mjs hash, adds its own header line, and its reviewer re-reads bus.mjs from the base to the combined tip.
|
||||
|
||||
- **`webui/tests/shell.test.mjs`**, write-boundary test. Allowed under mos-1321, with the comment reworded.
|
||||
- The `writes` list gains the new `fetch(url, ...)` in `decidePost`. It is first in file order (bus.js:362).
|
||||
- The `method:` count goes from 7 to 8.
|
||||
- A new assertion pins `decidePost`'s callers to `url` plus the two literal `/api/decisions/${enc(a.id)}/...` routes.
|
||||
- No existing entry was loosened.
|
||||
- **`bus/tests/channel-proof.test.mjs`**, sha256 re-pins with a header comment:
|
||||
- `bus/src/human.mjs` (N5 env keys) to bb13c146…c885.
|
||||
- `webui/src/bus.mjs` (consoleCall and `transport()`) to 701f009e…5272.
|
||||
- Any later edit to either file needs a re-pin.
|
||||
- `bus/tests/principal.test.mjs`, `webui/tests/tester-listener.test.mjs` and `cli/tests/config.test.mjs` have new tests appended only.
|
||||
|
||||
## Overlap with row 239 (Honker, b0c28366f, sage-2835)
|
||||
|
||||
- Row 239 also re-pins `channel-proof.test.mjs`:
|
||||
- `packages/webui/src/bus.mjs`, to 1eba9774…e5cf. That is the pin both rows change.
|
||||
- `packages/cli/src/transport.mjs`, which is 239 only.
|
||||
- `packages/bus/src/human.mjs` is a 252-only pin.
|
||||
- The two rows also share source files: `bus/src/broker.mjs`, `bus/src/runtime.mjs`, `webui/src/bus.mjs` and `webui/src/serve.mjs`.
|
||||
- Whichever row lands second rebases onto the first and recomputes the webui `bus.mjs` pin from the merged file. Both header comments are kept. It then reruns every touched test file whole.
|
||||
- Mos needs to see that re-pin (re-pins need Mos).
|
||||
|
||||
## Mutant plan (design section 6, J1-J17)
|
||||
|
||||
Each mutant is one edit to the named source. Run the killing test, then restore and compare the sha256 against the pre-mutant hash with `git status` clean apart from the build. Kill rules are written before the runs.
|
||||
|
||||
| J | Mutant | Source | Expected kill |
|
||||
|---|---|---|---|
|
||||
| J1 | eligibility from the request, `#consoleResolution` returns non-null for any decision | broker.mjs | console-decide `J1, J2` (ineligible) |
|
||||
| J2 | hardcode `approve = yes`, `deny = no` | broker.mjs | console-decide `J2` (go/stop, deny listed first) |
|
||||
| J3 | owner from name or header, skip the owner match in verifyPrincipal | principal.mjs | principal.test `console requires…` |
|
||||
| J4 | skip the serve-parent walk | principal.mjs | principal.test `console requires…` |
|
||||
| J5 | accept the human nonce namespace | principal.mjs | principal.test `console requires…` |
|
||||
| J6 | reuse the session from the confirmation, skip the live session check at resolve | decision-http.mjs | decision-http `session that ends`, decide-sso `listener gates` (new session) |
|
||||
| J7 | keep the console cap bound after the call (skip `release` for kind console) | server.mjs | **open**: no new test pins release for a console proof. Before the run, check whether an existing server test kills it. If not, add one and name it in the report. |
|
||||
| J8 | skip the same-origin check | decision-http.mjs | decision-http `gates`, decide-sso `listener gates` |
|
||||
| J9 | resolve without a confirmation token | decision-http.mjs | decision-http `a confirmation then a resolve`, `bad body` |
|
||||
| J10 | drop the choice, decision or session binding | decision-http.mjs | decision-http `binds the session, decision and choice` |
|
||||
| J11 | skip the digest, closed or business check | broker.mjs | console-decide `J11`, `N2, J13` (second answer closed) |
|
||||
| J12 | do not spend the confirmation | decision-http.mjs | decision-http `a second resolve refuses`, `confirmations… one use` |
|
||||
| J13 | write via cli, or omit the subject in human.input | broker.mjs | console-decide `N2, J13` |
|
||||
| J14 | `#approvedGrant` accepts via console without the human.input | broker.mjs | console-decide `J14` |
|
||||
| J15 | start the executor on any resolved choice | broker.mjs | console-decide `J15` and `J2` (stop starts nothing) |
|
||||
| J16 | `decide` true without sso or send | serve.mjs, public/bus.js | decide-sso `J16`, decide-browser `J16` |
|
||||
| J17 | retry on outcome-unknown or auto-submit after preview | public/bus.js | decide-browser `J17` (post count stays at 7) |
|
||||
|
||||
## Manifest
|
||||
|
||||
Source:
|
||||
- packages/bus/src/broker.mjs
|
||||
- packages/bus/src/client.mjs
|
||||
- packages/bus/src/console-cli.mjs (new)
|
||||
- packages/bus/src/human.mjs
|
||||
- packages/bus/src/principal.mjs
|
||||
- packages/bus/src/runtime.mjs
|
||||
- packages/bus/src/server.mjs
|
||||
- packages/cli/src/config.mjs
|
||||
- packages/webui/src/bus.mjs
|
||||
- packages/webui/src/cli.mjs
|
||||
- packages/webui/src/decision-http.mjs (new)
|
||||
- packages/webui/src/public/bus.js
|
||||
- packages/webui/src/serve.mjs
|
||||
|
||||
Tests:
|
||||
- packages/bus/tests/channel-proof.test.mjs (re-pins)
|
||||
- packages/bus/tests/console-decide.test.mjs (new)
|
||||
- packages/bus/tests/principal.test.mjs
|
||||
- packages/cli/tests/config.test.mjs
|
||||
- packages/webui/tests/decide-browser.test.mjs (new)
|
||||
- packages/webui/tests/decide-sso.test.mjs (new)
|
||||
- packages/webui/tests/decision-http.test.mjs (new)
|
||||
- packages/webui/tests/shell.test.mjs (old test changed, see above)
|
||||
- packages/webui/tests/tester-listener.test.mjs
|
||||
|
||||
Docs:
|
||||
- packages/webui/README.md
|
||||
- agents/scrooge/work/queue-252/BUILD.md (this file)
|
||||
|
||||
## Results
|
||||
|
||||
Not run. At the first CLEAR:
|
||||
1. Run each touched test file whole, with TMPDIR under /mnt/storage/scratch/tmp.
|
||||
2. Run the bus, webui and cli suites.
|
||||
3. Run the mutants above, then the drift check against refactor.
|
||||
+31
-142
@@ -132,9 +132,6 @@ 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']);
|
||||
// Row 252 (#1753, design sections 2-3): the only verbs a Console owner cap reaches. Every other verb
|
||||
// refuses it human-required before its own checks, and these refuse every other cap.
|
||||
export const CONSOLE_VERBS = Object.freeze(['decision.preview.console', 'decision.resolve.console']);
|
||||
// The level a user's grant must reach for USER_VERBS (R1's covers, morgana-0005).
|
||||
const USER_LEVEL = 'collaborator';
|
||||
// Row 175: the organization verbs a tester may call. Its writes run every check the human's do
|
||||
@@ -1025,26 +1022,6 @@ export class Broker {
|
||||
if (!this.#userGrants(s).length) fail('access-revoked');
|
||||
return this.#cap(s);
|
||||
}
|
||||
// Row 252 (#1753, design section 2): the trusted Console transport invokes this only after
|
||||
// verifyPrincipal checked the console-cli wrapper, its webui `serve` parent and the signed-in owner.
|
||||
// The cap is the business human's with via 'console' and the owner subject. request() lets it reach
|
||||
// CONSOLE_VERBS only, and #human refuses it, so no cli verb accepts it.
|
||||
bindConsole(record) {
|
||||
keys(record, ['business', 'human', 'subject'], ['business', 'human', 'subject']);
|
||||
const b = this.#business(record.business);
|
||||
const x = record.subject;
|
||||
if (record.human !== b.human || !object(x) || Object.keys(x).length !== 3 ||
|
||||
!['provider', 'iss', 'sub'].every((k) => typeof x[k] === 'string' && x[k].length > 0 && x[k].length <= 512))
|
||||
fail('human-required');
|
||||
return this.#cap({
|
||||
business: record.business,
|
||||
human: b.human,
|
||||
via: 'console',
|
||||
console: Object.freeze({ provider: x.provider, iss: x.iss, sub: x.sub }),
|
||||
role: null,
|
||||
run: null,
|
||||
});
|
||||
}
|
||||
bindReader({ business }) {
|
||||
this.#business(business);
|
||||
return this.#cap({ business, reader: true, role: null, run: null, human: false });
|
||||
@@ -1093,61 +1070,10 @@ export class Broker {
|
||||
const raise = this.#raised(business, decision);
|
||||
if (d?.action !== 'credential.mint' || d.route_to !== 'human' || !raise?.grant) return null;
|
||||
const r = this.#closed(decision);
|
||||
if (r?.op !== 'resolved' || r.choice !== raise.approvalChoice || r.by !== this.#business(business).human) return null;
|
||||
// Row 252 (#1753, design section 3, J14): a console close counts only with its own human.input,
|
||||
// which only the broker writes (recordEvent reserves the kind).
|
||||
if (!(r.via === 'console' ? this.#consoleInput(business, decision, r) : r.via === 'cli')) return null;
|
||||
if (r?.op !== 'resolved' || r.choice !== raise.approvalChoice || r.via !== 'cli' || r.by !== this.#business(business).human)
|
||||
return null;
|
||||
return raise.grant;
|
||||
}
|
||||
// Row 252: the console answer's human.input for close row r. Same decision, same seq, same choice,
|
||||
// no role or run, and a three-string owner subject.
|
||||
#consoleInput(business, decision, r) {
|
||||
const e = this.#store.get(
|
||||
`SELECT body FROM events WHERE business=? AND kind='human.input' AND subject=? AND actor_role IS NULL AND actor_run IS NULL
|
||||
AND json_extract(body,'$.via')='console' AND json_extract(body,'$.decisionSeq')=? ORDER BY seq LIMIT 1`,
|
||||
business,
|
||||
decision,
|
||||
r.seq,
|
||||
);
|
||||
const x = e && JSON.parse(e.body);
|
||||
return (
|
||||
!!x &&
|
||||
x.kind === 'answer' &&
|
||||
x.decision === decision &&
|
||||
x.op === 'resolved' &&
|
||||
x.choice === r.choice &&
|
||||
object(x.subject) &&
|
||||
['provider', 'iss', 'sub'].every((k) => typeof x.subject[k] === 'string' && x.subject[k].length > 0)
|
||||
);
|
||||
}
|
||||
// Row 252 (design section 1): a Console-eligible grant request, or null. Open is the caller's
|
||||
// check. Eligible is route_to human, action credential.mint, a raise with a validated grant binding
|
||||
// and exactly two distinct option keys, one of them approvalChoice. Approve is that key and deny is
|
||||
// the other. The summary carries only the binding's method, agent, host and target projects. The
|
||||
// digest binds the id, business, options, approval key and binding, so any change reads stale.
|
||||
#consoleResolution(d) {
|
||||
if (d.route_to !== 'human' || d.action !== 'credential.mint') return null;
|
||||
const raise = this.#raised(d.business, d.id);
|
||||
if (!object(raise?.grant) || typeof raise.grant.method !== 'string') return null;
|
||||
const options = JSON.parse(d.options);
|
||||
if (options.length !== 2 || options[0].key === options[1].key) return null;
|
||||
const approve = options.find((o) => o.key === raise.approvalChoice);
|
||||
if (!approve) return null;
|
||||
const deny = options.find((o) => o.key !== raise.approvalChoice);
|
||||
const g = raise.grant;
|
||||
const grant = {
|
||||
method: g.method,
|
||||
...(typeof g.agent === 'string' ? { agent: g.agent } : {}),
|
||||
...(typeof g.host === 'string' ? { host: g.host } : {}),
|
||||
...(Array.isArray(g.target?.projects) ? { projects: g.target.projects.filter((p) => typeof p === 'string') } : {}),
|
||||
};
|
||||
return {
|
||||
approve: { key: approve.key, text: approve.text },
|
||||
deny: { key: deny.key, text: deny.text },
|
||||
grant,
|
||||
digest: sha256(JSON.stringify([d.id, d.business, options, raise.approvalChoice, raise.grant])),
|
||||
};
|
||||
}
|
||||
// r6 X1: the executor's own spend. The caller owns the transaction. The consume record is the only
|
||||
// guarded body with `decision` (#436b section 7), so #spent counts it.
|
||||
#consumeGrant(business, decision, method) {
|
||||
@@ -2021,11 +1947,13 @@ 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}). 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}).${tokens} The instance is still firing. Run the fire again once ${code} is cleared.`,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -4186,9 +4114,6 @@ export class Broker {
|
||||
grants = this.#userGrants(s);
|
||||
if (!grants.length) fail('access-revoked');
|
||||
}
|
||||
// Row 252 (design section 2, N4): a console cap reaches the two console verbs only, and nothing
|
||||
// else reaches them. decision.resolve stays off the list.
|
||||
if (!!s.console !== CONSOLE_VERBS.includes(request.verb)) fail('human-required');
|
||||
const args = request.args ?? {};
|
||||
if (!object(args)) fail('invalid-request');
|
||||
this.#secretCheck(args);
|
||||
@@ -4482,6 +4407,9 @@ 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);
|
||||
@@ -4711,7 +4639,6 @@ export class Broker {
|
||||
const s = this.#session(cap);
|
||||
try {
|
||||
if (s.user) fail('access-refused');
|
||||
if (s.console) fail('human-required');
|
||||
this.#processingWork();
|
||||
keys(request, ['verb', 'args'], ['verb']);
|
||||
if (!TASK_VERBS.includes(request.verb)) fail('unknown-verb');
|
||||
@@ -4750,7 +4677,6 @@ export class Broker {
|
||||
let args;
|
||||
try {
|
||||
if (s.user) fail('access-refused');
|
||||
if (s.console) fail('human-required');
|
||||
keys(request, ['verb', 'args'], ['verb']);
|
||||
this.#processingWork();
|
||||
if (!verb) fail('unknown-verb');
|
||||
@@ -4810,7 +4736,6 @@ export class Broker {
|
||||
let a, entry, timeout, bound;
|
||||
try {
|
||||
if (s.user) fail('access-refused');
|
||||
if (s.console) fail('human-required');
|
||||
this.#processingWork();
|
||||
keys(request, ['verb', 'args'], ['verb']);
|
||||
if (!HOST_VERBS.includes(request.verb)) fail('unknown-verb');
|
||||
@@ -5127,38 +5052,15 @@ export class Broker {
|
||||
}
|
||||
return this.#decisionView(this.#decision(s, did));
|
||||
}
|
||||
// Row 252 (#1753, design section 3): the Console's read of one grant request before its
|
||||
// confirmation. It returns the two answers, a safe grant summary and the digest the answer must
|
||||
// carry. Anything not eligible refuses, so the browser never decides eligibility (J1).
|
||||
case 'decision.preview.console': {
|
||||
keys(a, ['id'], ['id']);
|
||||
identifier(a.id);
|
||||
if (!s.console || s.human !== b.human) fail('human-required');
|
||||
const d = this.#decision(s, a.id);
|
||||
if (this.#closed(d.id)) fail('decision-closed');
|
||||
const c = this.#consoleResolution(d);
|
||||
if (!c) fail('decision-not-eligible');
|
||||
return { id: d.id, business: d.business, question: d.question, ...c };
|
||||
}
|
||||
case 'decision.resolve.console':
|
||||
case 'decision.resolve':
|
||||
case 'decision.seen':
|
||||
case 'decision.withdraw': {
|
||||
const viaConsole = verb === 'decision.resolve.console';
|
||||
if (viaConsole) keys(a, ['id', 'choice', 'digest'], ['id', 'choice', 'digest']);
|
||||
else keys(a, ['id', 'choice', 'note'], ['id']);
|
||||
keys(a, ['id', 'choice', 'note'], ['id']);
|
||||
identifier(a.id);
|
||||
if (a.note !== undefined) string(a.note);
|
||||
const d = this.#decision(s, a.id);
|
||||
if (this.#closed(d.id)) fail('decision-closed');
|
||||
// Row 252 (design section 3): the Console answer rechecks eligibility, the digest and the choice
|
||||
// in this transaction. It answers one of the two keys, never a key the browser names (J2).
|
||||
if (viaConsole) {
|
||||
if (!s.console || s.human !== b.human) fail('human-required');
|
||||
const c = this.#consoleResolution(d);
|
||||
if (!c) fail('decision-not-eligible');
|
||||
if (a.digest !== c.digest || ![c.approve.key, c.deny.key].includes(a.choice)) fail('decision-stale');
|
||||
} else if (verb === 'decision.withdraw') {
|
||||
if (verb === 'decision.withdraw') {
|
||||
if (s.human) {
|
||||
this.#human(s);
|
||||
} else {
|
||||
@@ -5170,39 +5072,32 @@ export class Broker {
|
||||
this.#agent(s);
|
||||
if (s.role !== d.route_to) fail('not-resolver');
|
||||
}
|
||||
const op = verb === 'decision.resolve' || viaConsole ? 'resolved' : verb === 'decision.seen' ? 'seen' : 'withdrawn';
|
||||
const op = verb === 'decision.resolve' ? 'resolved' : verb === 'decision.seen' ? 'seen' : 'withdrawn';
|
||||
if (op === 'resolved' && !JSON.parse(d.options).some((o) => o.key === a.choice))
|
||||
fail('invalid-choice');
|
||||
const seq = Number(
|
||||
this.#store.run(
|
||||
'INSERT INTO decision_events(decision,at,op,by,choice,note,via) VALUES(?,?,?,?,?,?,?)',
|
||||
d.id,
|
||||
this.#now(),
|
||||
op,
|
||||
by,
|
||||
op === 'resolved' ? a.choice : null,
|
||||
a.note ?? null,
|
||||
s.console ? 'console' : s.human ? 'cli' : 'broker',
|
||||
).lastInsertRowid,
|
||||
this.#store.run(
|
||||
'INSERT INTO decision_events(decision,at,op,by,choice,note,via) VALUES(?,?,?,?,?,?,?)',
|
||||
d.id,
|
||||
this.#now(),
|
||||
op,
|
||||
by,
|
||||
op === 'resolved' ? a.choice : null,
|
||||
a.note ?? null,
|
||||
s.human ? 'cli' : 'broker',
|
||||
);
|
||||
// Row 252 (N2): the human.input comes before #approvedGrant, which needs a console answer's
|
||||
// record to start the executor. Its body names the owner subject and the close's seq.
|
||||
if (s.human)
|
||||
this.#event(
|
||||
s,
|
||||
'human.input',
|
||||
s.console
|
||||
? { kind: 'answer', decision: d.id, op, choice: a.choice, via: 'console', subject: s.console, decisionSeq: seq }
|
||||
: { kind: 'answer', decision: d.id, op, choice: a.choice ?? null },
|
||||
d.id,
|
||||
);
|
||||
// Row 234 (#1740, r6 X1): the human's cli approve of a grant mint starts the executor once this
|
||||
// transaction commits. It rechecks all of this itself, so a rollback starts nothing. Row 252: a
|
||||
// Console approve does too. A deny closes the decision with the other key and starts nothing.
|
||||
// transaction commits. It rechecks all of this itself, so a rollback starts nothing.
|
||||
const grant = op === 'resolved' ? this.#approvedGrant(s.business, d.id) : null;
|
||||
if (grant?.method === 'host-enroll') setImmediate(() => this.#runEnrollment(s.business, d.id));
|
||||
if (grant?.method === 'host') setImmediate(() => this.#runHostGrant(s.business, d.id));
|
||||
if (grant?.method === 'gitea') setImmediate(() => this.#startGitea(s.business, d.id));
|
||||
if (s.human)
|
||||
this.#event(
|
||||
s,
|
||||
'human.input',
|
||||
{ kind: 'answer', decision: d.id, op, choice: a.choice ?? null },
|
||||
d.id,
|
||||
);
|
||||
// Row 115 (#1609): a close by anyone but the raising run tells the raising role, routed like message.send's.
|
||||
// Row 133 (#1627): no messaging check here. decision.raise refused a route that can't message the
|
||||
// raiser, definitions change only with a pinned copy and arbiters only through the business
|
||||
@@ -5229,7 +5124,7 @@ export class Broker {
|
||||
this.#now(),
|
||||
s.business,
|
||||
s.human ? 'human' : s.role,
|
||||
s.console ? 'human-console' : s.human ? 'human-cli' : s.run,
|
||||
s.human ? 'human-cli' : s.run,
|
||||
d.raised_by_role,
|
||||
'DECISION',
|
||||
null,
|
||||
@@ -5239,8 +5134,7 @@ export class Broker {
|
||||
);
|
||||
this.#route(s.business, mid, d.raised_by_role, session);
|
||||
}
|
||||
const answer = viaConsole ? { id: d.id, op, choice: a.choice } : { id: d.id, op };
|
||||
return grant ? { ...answer, grant: 'started' } : answer;
|
||||
return grant ? { id: d.id, op, grant: 'started' } : { id: d.id, op };
|
||||
}
|
||||
case 'message.send': {
|
||||
keys(a, ['to', 'body', 'class', 'in_reply_to', 'corrects', 'decision'], ['to', 'body']);
|
||||
@@ -5583,12 +5477,7 @@ export class Broker {
|
||||
s.business,
|
||||
s.human || s.reader ? 'human' : s.role,
|
||||
)
|
||||
.map((d) => {
|
||||
// Row 252: a grant request the Console can answer carries its two answers. The Console
|
||||
// shows buttons from this, and the answer path rechecks it at the bus.
|
||||
const c = s.human || s.reader ? this.#consoleResolution(d) : null;
|
||||
return c ? { ...this.#decisionView(d), console: c } : this.#decisionView(d);
|
||||
});
|
||||
.map((d) => this.#decisionView(d));
|
||||
if (verb === 'agents') {
|
||||
const claims = this.#store.all(
|
||||
"SELECT * FROM role_claims c WHERE business=? AND op='claim' AND seq=(SELECT max(seq) FROM role_claims WHERE business=c.business AND role=c.role) ORDER BY role",
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
import { connect } from 'node:net';
|
||||
import { BusError, PROJECT_SAVE_VERB, PROJECT_SAVE_REQUEST_MAX } from './broker.mjs';
|
||||
export class Client {
|
||||
constructor({ path, cap, human, tester, user, console: owner, timeout = 5000 }) {
|
||||
constructor({ path, cap, human, tester, user, timeout = 5000 }) {
|
||||
this.path = path;
|
||||
this.cap = cap;
|
||||
this.human = human;
|
||||
this.tester = tester;
|
||||
this.user = user;
|
||||
this.console = owner;
|
||||
this.timeout = timeout;
|
||||
}
|
||||
call(verb, args = {}) {
|
||||
const request =
|
||||
JSON.stringify({
|
||||
...(this.human ? { human: this.human } : this.tester ? { tester: this.tester } : this.user ? { user: this.user } : this.console ? { console: this.console } : { cap: this.cap }),
|
||||
...(this.human ? { human: this.human } : this.tester ? { tester: this.tester } : this.user ? { user: this.user } : { cap: this.cap }),
|
||||
verb,
|
||||
args,
|
||||
}) + '\n';
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
// Row 252 (#1753, design section 2): the Console owner's transport shim, spawned only by the webui
|
||||
// `cli.mjs serve` process for one preview or one answer. Not a second product CLI. The Console sets
|
||||
// the nonce, the business and the signed-in (provider, iss, sub) in the spawn environment, and the
|
||||
// bus reads them back from this process's initial environment. Nothing respawns here, so the
|
||||
// Console stays the direct parent. Input is a verb and its args only: a body that names a business,
|
||||
// a subject or any other key is refused, never merged.
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { Client } from './client.mjs';
|
||||
import { readProcess } from './human.mjs';
|
||||
import { CONSOLE_VERBS } from './broker.mjs';
|
||||
const env = process.env;
|
||||
try {
|
||||
if (!/^[a-f0-9]{64}$/.test(env.MOSAIC_BUS_CONSOLE_NONCE ?? '')) throw Object.assign(Error(), { code: 'human-required' });
|
||||
const request = JSON.parse(readFileSync(0, 'utf8'));
|
||||
if (
|
||||
!request ||
|
||||
typeof request !== 'object' ||
|
||||
Array.isArray(request) ||
|
||||
Object.keys(request).some((k) => !['verb', 'args'].includes(k)) ||
|
||||
!CONSOLE_VERBS.includes(request.verb)
|
||||
)
|
||||
throw Error();
|
||||
const me = readProcess(process.pid);
|
||||
const client = new Client({
|
||||
path: process.argv[2],
|
||||
console: {
|
||||
business: env.MOSAIC_BUS_CONSOLE_BUSINESS,
|
||||
provider: env.MOSAIC_BUS_CONSOLE_PROVIDER,
|
||||
iss: env.MOSAIC_BUS_CONSOLE_ISS,
|
||||
sub: env.MOSAIC_BUS_CONSOLE_SUB,
|
||||
pid: me.pid,
|
||||
startTime: me.startTime,
|
||||
nonce: env.MOSAIC_BUS_CONSOLE_NONCE,
|
||||
},
|
||||
});
|
||||
const result = await client.call(request.verb, request.args ?? {});
|
||||
process.stdout.write(JSON.stringify(result) + '\n');
|
||||
} catch (e) {
|
||||
process.stderr.write((/^[a-z-]{1,64}$/.test(e.code ?? '') ? e.code : 'invalid-request') + '\n');
|
||||
process.exitCode = 2;
|
||||
}
|
||||
@@ -10,10 +10,6 @@ const MARKERS = [
|
||||
'CODEX_THREAD_ID',
|
||||
'PI_AGENT_DIR',
|
||||
];
|
||||
// Row 252 (#1753, Webby N5): the Console wrapper's nonce and the subject its spawn bound. The
|
||||
// verifier reads each from the wrapper's initial environment, never from its proof alone.
|
||||
export const CONSOLE_ENV = Object.freeze(['MOSAIC_BUS_CONSOLE_NONCE', 'MOSAIC_BUS_CONSOLE_BUSINESS', 'MOSAIC_BUS_CONSOLE_PROVIDER', 'MOSAIC_BUS_CONSOLE_ISS', 'MOSAIC_BUS_CONSOLE_SUB']);
|
||||
const NONCE_KEYS = ['MOSAIC_BUS_CLI_NONCE', 'MOSAIC_BUS_USER_NONCE', ...CONSOLE_ENV];
|
||||
const refuse = () => {
|
||||
throw new BusError('human-required');
|
||||
};
|
||||
@@ -35,7 +31,7 @@ export function readProcess(pid, { readFile = readFileSync, stat = lstatSync } =
|
||||
for (const entry of environment.split('\0')) {
|
||||
const i = entry.indexOf('=');
|
||||
const key = entry.slice(0, i);
|
||||
if (NONCE_KEYS.includes(key) || MARKERS.includes(key)) env[key] = entry.slice(i + 1);
|
||||
if (['MOSAIC_BUS_CLI_NONCE', 'MOSAIC_BUS_USER_NONCE'].includes(key) || MARKERS.includes(key)) env[key] = entry.slice(i + 1);
|
||||
}
|
||||
return {
|
||||
pid,
|
||||
|
||||
@@ -5,8 +5,8 @@ import { verifyTester, testerNonce } from './tester.mjs';
|
||||
|
||||
// Trusted transport API. Identity is held only by this connection's transient session.
|
||||
// User mapping and live grant authorization belong to R3, not to this verifier.
|
||||
export function verifyPrincipal(request, { peer, paths, businesses, launches = [], readProcess = defaultRead, readTesterNonce = testerNonce, consoleOwners = [] }) {
|
||||
const kinds = ['human', 'tester', 'user', 'console'].filter(k => request?.[k]);
|
||||
export function verifyPrincipal(request, { peer, paths, businesses, launches = [], readProcess = defaultRead, readTesterNonce = testerNonce }) {
|
||||
const kinds = ['human', 'tester', 'user'].filter(k => request?.[k]);
|
||||
if (kinds.length !== 1 || request.cap) throw new BusError('invalid-envelope');
|
||||
const kind = kinds[0], proof = request[kind];
|
||||
const error = kind === 'tester' ? 'tester-required' : 'human-required';
|
||||
@@ -16,7 +16,6 @@ export function verifyPrincipal(request, { peer, paths, businesses, launches = [
|
||||
const { business } = verifyTester(proof, { cliPath: paths.tester, launches, readProcess, readNonce: readTesterNonce });
|
||||
return { kind: 'tester', business, id: 'tester' };
|
||||
}
|
||||
if (kind === 'console') return verifyConsole(proof, { peer, paths, businesses, launches, readProcess, consoleOwners });
|
||||
let checked = proof, parent;
|
||||
if (kind === 'user') {
|
||||
if (!proof || typeof proof.user !== 'string' || !proof.user ||
|
||||
@@ -43,38 +42,3 @@ export function verifyPrincipal(request, { peer, paths, businesses, launches = [
|
||||
if (!id) throw new BusError('unknown-business');
|
||||
return { kind: 'owner', business, id };
|
||||
}
|
||||
|
||||
// Row 252 (#1753, design section 2): the Console owner. The same-tree console-cli.mjs wrapper, its own
|
||||
// nonce namespace and the human walk, with the exact same-tree webui `cli.mjs serve` as its direct
|
||||
// parent, read again after the walk as the user kind does. Business and (provider, iss, sub) must be
|
||||
// the wrapper's initial spawn environment and one of the checked console.sso subjects the runtime
|
||||
// was started with (Webby N1: every configured subject is an owner). The Console's mapping is trusted
|
||||
// as row 196 permits; the bus does not exchange an ID token itself.
|
||||
const SUBJECT_KEYS = ['provider', 'iss', 'sub'];
|
||||
const SUBJECT_ENV = { business: 'MOSAIC_BUS_CONSOLE_BUSINESS', provider: 'MOSAIC_BUS_CONSOLE_PROVIDER', iss: 'MOSAIC_BUS_CONSOLE_ISS', sub: 'MOSAIC_BUS_CONSOLE_SUB' };
|
||||
function verifyConsole(proof, { peer, paths, businesses, launches, readProcess, consoleOwners }) {
|
||||
const refuse = () => { throw new BusError('human-required'); };
|
||||
if (!proof || typeof proof !== 'object' ||
|
||||
Object.keys(proof).some(k => !['pid', 'startTime', 'nonce', 'business', ...SUBJECT_KEYS].includes(k)) ||
|
||||
SUBJECT_KEYS.some(k => typeof proof[k] !== 'string' || !proof[k] || proof[k].length > 512)) refuse();
|
||||
if (typeof paths.consoleCli !== 'string' || typeof paths.console !== 'string') refuse();
|
||||
const { provider, iss, sub, ...checked } = proof;
|
||||
let child, parent;
|
||||
try {
|
||||
child = readProcess(proof.pid);
|
||||
parent = readProcess(child.ppid);
|
||||
if (parent.uid !== peer.uid || parent.argv[1] !== resolve(paths.console) || parent.argv[2] !== 'serve') refuse();
|
||||
for (const [key, name] of Object.entries(SUBJECT_ENV)) if (child.env?.[name] !== proof[key]) refuse();
|
||||
} catch { refuse(); }
|
||||
const { business } = verifyHuman(checked, { cliPath: paths.consoleCli, launches, readProcess, nonceKey: 'MOSAIC_BUS_CONSOLE_NONCE' });
|
||||
try {
|
||||
const again = readProcess(proof.pid), up = readProcess(parent.pid);
|
||||
if (again.ppid !== parent.pid || again.startTime !== child.startTime || up.startTime !== parent.startTime ||
|
||||
up.ppid !== parent.ppid || up.uid !== parent.uid || up.argv[1] !== parent.argv[1] || up.argv[2] !== 'serve') refuse();
|
||||
for (const [key, name] of Object.entries(SUBJECT_ENV)) if (again.env?.[name] !== proof[key]) refuse();
|
||||
} catch { refuse(); }
|
||||
if (!Array.isArray(consoleOwners) || !consoleOwners.some(o => o && o.provider === provider && o.iss === iss && o.sub === sub)) refuse();
|
||||
const id = businesses[business]?.human;
|
||||
if (!id) throw new BusError('unknown-business');
|
||||
return { kind: 'console', business, id, via: 'console', subject: { provider, iss, sub } };
|
||||
}
|
||||
@@ -102,15 +102,7 @@ export async function startBroker({
|
||||
// FED R3 (#414): the access file and its loader context, { file, context } (R1's loadAccess).
|
||||
// Without it no user binds. R6 and R8 pass it; the live boot config doesn't yet.
|
||||
access = null,
|
||||
// Row 252 (#1753, design section 2): the checked console.sso subjects, [{provider, iss, sub}], each an
|
||||
// owner of the business (Webby N1). The boot config carries them; no socket verb sets them. Empty, no
|
||||
// Console owner binds and the terminal owner is unchanged.
|
||||
consoleOwners = [],
|
||||
}) {
|
||||
if (!Array.isArray(consoleOwners) || consoleOwners.length > 512 || consoleOwners.some((o) => o === null || typeof o !== 'object' || Array.isArray(o) ||
|
||||
Object.keys(o).length !== 3 || ['provider', 'iss', 'sub'].some((k) => typeof o[k] !== 'string' || !o[k] || o[k].length > 512)))
|
||||
throw new BusError('invalid-host-request');
|
||||
const owners = Object.freeze(consoleOwners.map((o) => Object.freeze({ provider: o.provider, iss: o.iss, sub: o.sub })));
|
||||
if (access !== null && (typeof access?.file !== 'string' || access.context === null || typeof access.context !== 'object'))
|
||||
throw new BusError('invalid-host-request');
|
||||
if (librarySync !== null && (typeof librarySync?.command !== 'function'
|
||||
@@ -156,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. 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, 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.
|
||||
const revokeFailed = (message) => new OrganizationError('credential-revoke-failed', message);
|
||||
function revoke(business, agent, how, services) {
|
||||
const instance = business + '/' + agent;
|
||||
@@ -396,7 +388,6 @@ export async function startBroker({
|
||||
tester: fileURLToPath(new URL('./tester-cli.mjs', import.meta.url)),
|
||||
user: fileURLToPath(new URL('./user-cli.mjs', import.meta.url)),
|
||||
console: fileURLToPath(new URL('../../webui/src/cli.mjs', import.meta.url)),
|
||||
consoleCli: fileURLToPath(new URL('./console-cli.mjs', import.meta.url)),
|
||||
};
|
||||
const path = join(store.directory, 'broker.sock');
|
||||
// The existing host executor owns SSH and its key lifecycle. Combine its revocation
|
||||
@@ -428,11 +419,6 @@ export async function startBroker({
|
||||
const p = verifyPrincipal({ user: proof }, { peer, paths: principalPaths, businesses, launches: launchRecords });
|
||||
return broker.bindUser({ business: p.business, user: p.id });
|
||||
},
|
||||
// Row 252 (#1753): a fresh proof per connection, a cap for the Console verbs only.
|
||||
authenticateConsole: (proof, peer) => {
|
||||
const p = verifyPrincipal({ console: proof }, { peer, paths: principalPaths, businesses, launches: launchRecords, consoleOwners: owners });
|
||||
return broker.bindConsole({ business: p.business, human: p.id, subject: p.subject });
|
||||
},
|
||||
});
|
||||
// Row 235 (r6 X3): the remote undo of interrupted host grants, once the bus is up, so a start doesn't
|
||||
// wait on the network.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createServer } from 'node:net';
|
||||
import { lstatSync, chmodSync, unlinkSync } from 'node:fs';
|
||||
import { BusError, TASK_VERBS, WEB_VERBS, HOST_VERBS, HOSTS_VERBS, GRANTS_VERBS, PROJECT_FILE_VERBS, LIBRARY_VERBS, PROJECT_SAVE_VERB, PROJECT_SAVE_REQUEST_MAX, CONSOLE_VERBS } from './broker.mjs';
|
||||
import { BusError, TASK_VERBS, WEB_VERBS, HOST_VERBS, HOSTS_VERBS, GRANTS_VERBS, PROJECT_FILE_VERBS, LIBRARY_VERBS, PROJECT_SAVE_VERB, PROJECT_SAVE_REQUEST_MAX } from './broker.mjs';
|
||||
import { inspectInterpreter } from './interpreter.mjs';
|
||||
import { createPeerReader } from './peercred.mjs';
|
||||
const LIMIT = 65536;
|
||||
@@ -13,9 +13,8 @@ const SAVE_VERB_AT_START = new RegExp(`"verb":"${PROJECT_SAVE_VERB.replaceAll('.
|
||||
// the web verbs reach it, and without one they refuse web-not-configured (or web-not-granted).
|
||||
// `host` (row 210) is the trusted host.run executor (host-run.mjs), or null: host.run then refuses
|
||||
// host-not-configured.
|
||||
// A request carries exactly one of cap, human (row S4), tester (row 110), user (FED R3) and console
|
||||
// (row 252) proof.
|
||||
export async function serve({ broker, path, authenticateHuman = null, authenticateTester = null, authenticateUser = null, authenticateConsole = null, peerReader = null, timeout = 5000, tasks = null, web = null, host = null }) {
|
||||
// A request carries exactly one of cap, human (row S4) and tester (row 110) proof.
|
||||
export async function serve({ broker, path, authenticateHuman = null, authenticateTester = null, authenticateUser = null, peerReader = null, timeout = 5000, tasks = null, web = null, host = null }) {
|
||||
try {
|
||||
lstatSync(path);
|
||||
throw new BusError('socket-exists');
|
||||
@@ -65,17 +64,15 @@ export async function serve({ broker, path, authenticateHuman = null, authentica
|
||||
!r ||
|
||||
Array.isArray(r) ||
|
||||
typeof r !== 'object' ||
|
||||
Object.keys(r).some((k) => !['cap', 'human', 'tester', 'user', 'console', 'verb', 'args'].includes(k)) ||
|
||||
Object.keys(r).some((k) => !['cap', 'human', 'tester', 'user', 'verb', 'args'].includes(k)) ||
|
||||
typeof r.verb !== 'string' ||
|
||||
[r.cap, r.human, r.tester, r.user, r.console].filter(Boolean).length !== 1
|
||||
[r.cap, r.human, r.tester, r.user].filter(Boolean).length !== 1
|
||||
)
|
||||
throw new BusError('invalid-envelope');
|
||||
let cap = r.cap;
|
||||
const kind = r.human ? 'human' : r.tester ? 'tester' : r.user ? 'user' : r.console ? 'console' : null;
|
||||
// Row 252 (#1753): a Console owner proof reaches its two verbs only, refused before any proof check.
|
||||
if (kind === 'console' && !CONSOLE_VERBS.includes(r.verb)) throw new BusError('human-required');
|
||||
const kind = r.human ? 'human' : r.tester ? 'tester' : r.user ? 'user' : null;
|
||||
if (kind) {
|
||||
const authenticate = kind === 'human' ? authenticateHuman : kind === 'tester' ? authenticateTester : kind === 'user' ? authenticateUser : authenticateConsole;
|
||||
const authenticate = kind === 'human' ? authenticateHuman : kind === 'tester' ? authenticateTester : authenticateUser;
|
||||
if (!authenticate) throw new BusError(kind === 'tester' ? 'tester-required' : 'human-required');
|
||||
socket.pause();
|
||||
let peer;
|
||||
|
||||
@@ -6,9 +6,6 @@
|
||||
// bus.mjs for six library-* STATUS entries; no proof or transport change.
|
||||
// Row 236 (#1742) re-pins human-cli.mjs for the same 130 s wait on the grants verbs; no proof or
|
||||
// transport change.
|
||||
// Row 252 (#1753, Webby N5) re-pins human.mjs, whose readProcess also keeps the five
|
||||
// MOSAIC_BUS_CONSOLE_* keys for the Console wrapper's proof, and webui bus.mjs for consoleCall,
|
||||
// which shares humanCall's spawn as transport(). The human proof and its spawn are unchanged.
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
@@ -18,9 +15,10 @@ import { provedRuntime } from "../../cli/tests/channel-fixture.mjs";
|
||||
import { Client } from "../src/client.mjs";
|
||||
|
||||
const baselines = {
|
||||
"packages/bus/src/human.mjs": "bb13c146717cdc46d75d157177e400edac4d3e10f14fe511027a25fd44cec885",
|
||||
"packages/bus/src/human.mjs": "0c6d96abe4607cba0b02b0638a2fc217ff8135bac9a47761a0ef015fbe9d3079",
|
||||
"packages/bus/src/human-cli.mjs": "271b9781d73cf31cc562a56180e3ebcd725b5fde148a83347a201fdef2fc28af",
|
||||
"packages/cli/src/transport.mjs": "5129c44b4d9f642e825344df65c9bfb4bac4fc8d6b25343b42d39853dbd40774",
|
||||
// Row 239 (#1745): EXIT outcome-unknown-revoked 1; no proof or transport change.
|
||||
"packages/cli/src/transport.mjs": "662642a8ce739fcb6c188ad07125a94e008c8a305eced127c8041d472c56e55b",
|
||||
// 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
|
||||
@@ -28,7 +26,9 @@ 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.
|
||||
"packages/webui/src/bus.mjs": "701f009e488a261558a22d19f581b052ffd26ce99266f2022dc5d178c12f5272",
|
||||
// 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",
|
||||
};
|
||||
const repoFile = path => new URL("../../../" + path, import.meta.url);
|
||||
|
||||
|
||||
@@ -1,291 +0,0 @@
|
||||
// Row 252 (#1753, Gander's design approved by Webby at 5d8f0f4b5, N1-N5): the broker side of the
|
||||
// Console's Approve and Deny for a grant request. A console cap, made by bindConsole after the
|
||||
// transport checked the wrapper and the signed-in owner, reaches only decision.preview.console and
|
||||
// decision.resolve.console, and no other cap reaches those. The answer records via 'console' and a
|
||||
// human.input that names the owner, written before the grant check (N2), and only that record lets
|
||||
// an approve start the executor (J13, J14). Q188: no agent caller answers. The grant uses row 234's
|
||||
// host-enroll raise with a fake keyscan and fake enroll and check steps, so nothing connects anywhere.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { Broker, CONSOLE_VERBS } from '../src/broker.mjs';
|
||||
import { Store } from '../src/store.mjs';
|
||||
import { hostGrant, fingerprint } from '../src/host-grant.mjs';
|
||||
import { grantDecision } from './host-fixtures.mjs';
|
||||
|
||||
const options = [{ key: 'yes', text: 'Yes, enroll it' }, { key: 'no', text: 'No' }];
|
||||
const SUBJECT = { provider: 'fake-oidc', iss: 'https://idp.test', sub: 'owner-1' };
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const keyLine = (dir, name) => {
|
||||
execFileSync('ssh-keygen', ['-q', '-t', 'ed25519', '-N', '', '-C', 'x', '-f', join(dir, name)]);
|
||||
return readFileSync(join(dir, name + '.pub'), 'utf8').trim().split(' ').slice(0, 2).join(' ');
|
||||
};
|
||||
const refusesSync = (fn, code) => assert.throws(fn, (e) => (assert.equal(e.code, code), true));
|
||||
|
||||
function setup(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'c252-'));
|
||||
const store = new Store(root);
|
||||
t.after(() => {
|
||||
store.close();
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
const hostKeys = [keyLine(root, 'hk1'), keyLine(root, 'hk2')];
|
||||
const configDir = join(root, 'config');
|
||||
const real = hostGrant({ configDir, runRoot: join(configDir, 'secrets', 'run'), scan: async () => hostKeys });
|
||||
const enrolls = [];
|
||||
const hg = {
|
||||
...real,
|
||||
enroll: async (...args) => (enrolls.push(args), real.enroll(...args)),
|
||||
check: async (...args) => ({ installerFingerprint: args[2].installer_fingerprint, exit: 0 }),
|
||||
};
|
||||
const business = {
|
||||
id: 'demo',
|
||||
human: 'jason',
|
||||
arbiters: { technical: 'pm', delivery: 'pm' },
|
||||
roles: {
|
||||
chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
pm: { definition: 'pm', authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
},
|
||||
vars: { 'bus.tester': true },
|
||||
};
|
||||
const make = () => new Broker({ store, businesses: { demo: business }, hostGrant: hg });
|
||||
const broker = make();
|
||||
const caps = {};
|
||||
for (const role of ['chief', 'pm']) {
|
||||
caps[role] = broker.bindLaunch({ business: 'demo', role, run: role + '-run', harness: 'pi', address: role });
|
||||
broker.request(caps[role], { verb: 'role.claim' });
|
||||
}
|
||||
caps.human = broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
caps.tester = broker.bindTester({ business: 'demo' });
|
||||
caps.reader = broker.bindReader({ business: 'demo' });
|
||||
caps.console = broker.bindConsole({ business: 'demo', human: 'jason', subject: SUBJECT });
|
||||
const grant = { method: 'host-enroll', host: 'web1', address: '192.0.2.10', port: 22, user: 'jarvis', sudo: true, fingerprints: hostKeys.map(fingerprint) };
|
||||
const call = (who, verb, args = {}) => broker.request(caps[who], { verb, args });
|
||||
const raise = (extra = {}) =>
|
||||
call('chief', 'decision.raise', { action: 'credential.mint', question: 'Enroll web1?', options, recommendation: 'yes', blocking: false, grant, ...extra });
|
||||
const events = () =>
|
||||
store
|
||||
.all("SELECT seq, kind, actor_role, actor_run, subject, body FROM events WHERE business='demo' ORDER BY seq")
|
||||
// The row's subject is `about`, since a console answer's body has its own `subject`.
|
||||
.map((e) => ({ seq: e.seq, kind: e.kind, actor_role: e.actor_role, actor_run: e.actor_run, about: e.subject, ...JSON.parse(e.body) }));
|
||||
const grantEvents = (decision) => events().filter((e) => e.actor_run === 'grant' && (e.decision === decision || e.grantDecision === decision));
|
||||
const settle = async (decision) => {
|
||||
for (let i = 0; i < 250; i++) {
|
||||
if (grantEvents(decision).some((e) => ['grant.host.enrolled', 'grant.failed'].includes(e.action))) return;
|
||||
await sleep(20);
|
||||
}
|
||||
throw new Error('enrollment did not end');
|
||||
};
|
||||
const closes = (decision) => store.all('SELECT seq, op, by, choice, via FROM decision_events WHERE decision=? ORDER BY seq', decision);
|
||||
return { store, broker, make, caps, call, raise, events, grantEvents, settle, closes, enrolls, grant };
|
||||
}
|
||||
|
||||
test('N4: the console verbs are exactly the preview and the console resolve, and decision.resolve is not one', () => {
|
||||
assert.deepEqual([...CONSOLE_VERBS], ['decision.preview.console', 'decision.resolve.console']);
|
||||
assert.equal(CONSOLE_VERBS.includes('decision.resolve'), false);
|
||||
assert.ok(Object.isFrozen(CONSOLE_VERBS));
|
||||
});
|
||||
|
||||
test('bindConsole takes only the business human and a three-string owner subject', (t) => {
|
||||
const f = setup(t);
|
||||
const bind = (record) => () => f.broker.bindConsole(record);
|
||||
refusesSync(bind({ business: 'demo', human: 'someone', subject: SUBJECT }), 'human-required');
|
||||
refusesSync(bind({ business: 'demo', human: 'jason', subject: { ...SUBJECT, name: 'Jason' } }), 'human-required');
|
||||
refusesSync(bind({ business: 'demo', human: 'jason', subject: { provider: 'p', iss: 'i' } }), 'human-required');
|
||||
refusesSync(bind({ business: 'demo', human: 'jason', subject: { ...SUBJECT, sub: '' } }), 'human-required');
|
||||
refusesSync(bind({ business: 'demo', human: 'jason', subject: { ...SUBJECT, sub: 'x'.repeat(513) } }), 'human-required');
|
||||
refusesSync(bind({ business: 'demo', human: 'jason', subject: { ...SUBJECT, sub: 7 } }), 'human-required');
|
||||
refusesSync(bind({ business: 'demo', human: 'jason', subject: 'owner-1' }), 'human-required');
|
||||
assert.throws(bind({ business: 'demo', human: 'jason', subject: SUBJECT, via: 'cli' }));
|
||||
assert.throws(bind({ business: 'demo', human: 'jason' }));
|
||||
assert.throws(bind({ business: 'other', human: 'jason', subject: SUBJECT }));
|
||||
assert.deepEqual(f.broker.identity(f.caps.console), { business: 'demo', role: null, run: null, human: 'jason' });
|
||||
});
|
||||
|
||||
test('J1, Q188: only a console cap reaches the console verbs, and a console cap reaches nothing else', async (t) => {
|
||||
const f = setup(t);
|
||||
const d = await f.raise();
|
||||
const preview = f.call('console', 'decision.preview.console', { id: d.id });
|
||||
const args = { id: d.id, choice: 'yes', digest: preview.digest };
|
||||
// Agents, the cli human, the tester and the reader are refused before the verb runs.
|
||||
for (const who of ['chief', 'pm', 'human', 'tester', 'reader']) {
|
||||
refusesSync(() => f.call(who, 'decision.preview.console', { id: d.id }), 'human-required');
|
||||
refusesSync(() => f.call(who, 'decision.resolve.console', args), 'human-required');
|
||||
}
|
||||
// N4: the console cap answers nothing through decision.resolve, and reaches no other verb.
|
||||
for (const [verb, a] of [
|
||||
['decision.resolve', { id: d.id, choice: 'yes' }],
|
||||
['decision.seen', { id: d.id }],
|
||||
['decision.withdraw', { id: d.id }],
|
||||
['decision.raise', { action: 'credential.mint', question: 'q', options, recommendation: 'yes', blocking: false, grant: f.grant }],
|
||||
['inbox', {}],
|
||||
['message.send', { to: 'chief', body: 'hi' }],
|
||||
['role.claim', {}],
|
||||
])
|
||||
refusesSync(() => f.call('console', verb, a), 'human-required');
|
||||
assert.deepEqual(f.closes(d.id), []);
|
||||
assert.equal(f.enrolls.length, 0);
|
||||
});
|
||||
|
||||
test('preview: the two real answers, a grant summary and the digest, for an open grant request only', async (t) => {
|
||||
const f = setup(t);
|
||||
const d = await f.raise();
|
||||
const p = f.call('console', 'decision.preview.console', { id: d.id });
|
||||
assert.equal(p.id, d.id);
|
||||
assert.equal(p.business, 'demo');
|
||||
assert.match(p.question, /^Enroll web1\?/);
|
||||
assert.deepEqual(p.approve, { key: 'yes', text: 'Yes, enroll it' });
|
||||
assert.deepEqual(p.deny, { key: 'no', text: 'No' });
|
||||
assert.deepEqual(p.grant, { method: 'host-enroll', host: 'web1' });
|
||||
assert.match(p.digest, /^[a-f0-9]{64}$/);
|
||||
// Read twice, the digest is the same; a preview writes nothing.
|
||||
assert.equal(f.call('console', 'decision.preview.console', { id: d.id }).digest, p.digest);
|
||||
assert.deepEqual(f.closes(d.id), []);
|
||||
refusesSync(() => f.call('console', 'decision.preview.console', { id: d.id, choice: 'yes' }), 'invalid-request');
|
||||
refusesSync(() => f.call('console', 'decision.preview.console', { id: 'no-such-decision' }), 'decision-not-found');
|
||||
// The inbox row says the same, for the human and the reader. The agent-facing views do not.
|
||||
for (const who of ['human', 'reader']) {
|
||||
const row = f.call(who, 'inbox').find((r) => r.id === d.id);
|
||||
assert.deepEqual(row.console, { approve: p.approve, deny: p.deny, grant: p.grant, digest: p.digest });
|
||||
}
|
||||
});
|
||||
|
||||
test('J1, J2: a decision that is not an eligible grant request refuses both console verbs', async (t) => {
|
||||
const f = setup(t);
|
||||
const grant = { method: 'host', agent: 'pm', host: 'web1', commands: ['uptime'], rotateBy: '2099-01-01' };
|
||||
const ineligible = [
|
||||
grantDecision(f.store, { action: 'deploy', grant, close: null }),
|
||||
grantDecision(f.store, { route: 'pm', grant, close: null }),
|
||||
grantDecision(f.store, { grant: undefined, close: null }),
|
||||
];
|
||||
for (const id of ineligible) {
|
||||
refusesSync(() => f.call('console', 'decision.preview.console', { id }), 'decision-not-eligible');
|
||||
refusesSync(() => f.call('console', 'decision.resolve.console', { id, choice: 'yes', digest: '0'.repeat(64) }), 'decision-not-eligible');
|
||||
assert.equal(f.call('human', 'inbox').find((r) => r.id === id)?.console, undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test('N2, J13: a Console approve records via console and the owner, before the executor starts, and the grant runs once', async (t) => {
|
||||
const f = setup(t);
|
||||
const d = await f.raise();
|
||||
const { digest } = f.call('console', 'decision.preview.console', { id: d.id });
|
||||
const r = f.call('console', 'decision.resolve.console', { id: d.id, choice: 'yes', digest });
|
||||
assert.deepEqual(r, { id: d.id, op: 'resolved', choice: 'yes', grant: 'started' });
|
||||
const [close] = f.closes(d.id);
|
||||
assert.deepEqual({ ...close, seq: undefined }, { seq: undefined, op: 'resolved', by: 'jason', choice: 'yes', via: 'console' });
|
||||
const input = f.events().filter((e) => e.kind === 'human.input' && e.about === d.id);
|
||||
assert.equal(input.length, 1);
|
||||
assert.deepEqual(
|
||||
{ ...input[0], seq: undefined },
|
||||
{ seq: undefined, kind: 'answer', actor_role: null, actor_run: null, about: d.id, decision: d.id, op: 'resolved', choice: 'yes', via: 'console', subject: SUBJECT, decisionSeq: close.seq },
|
||||
);
|
||||
await f.settle(d.id);
|
||||
assert.equal(f.enrolls.length, 1);
|
||||
const spend = f.grantEvents(d.id).find((e) => e.operation === 'grant.host-enroll');
|
||||
assert.ok(spend && input[0].seq < spend.seq, 'the human.input precedes the executor spend');
|
||||
assert.ok(f.grantEvents(d.id).some((e) => e.action === 'grant.host.enrolled'));
|
||||
// N3: the close notice to the raiser comes from the human through the Console.
|
||||
const notices = f.store.all("SELECT from_run, body FROM messages WHERE business='demo' AND decision=? AND class='DECISION' AND from_role='human'", d.id);
|
||||
assert.equal(notices.length, 1);
|
||||
const [notice] = notices;
|
||||
assert.equal(notice.from_run, 'human-console');
|
||||
assert.match(notice.body, /resolved by jason: yes \(Yes, enroll it\)/);
|
||||
// A second answer of either kind finds it closed, and nothing starts again.
|
||||
refusesSync(() => f.call('console', 'decision.resolve.console', { id: d.id, choice: 'yes', digest }), 'decision-closed');
|
||||
refusesSync(() => f.call('console', 'decision.preview.console', { id: d.id }), 'decision-closed');
|
||||
refusesSync(() => f.call('human', 'decision.resolve', { id: d.id, choice: 'yes' }), 'decision-closed');
|
||||
await sleep(50);
|
||||
assert.equal(f.enrolls.length, 1);
|
||||
assert.equal(f.closes(d.id).length, 1);
|
||||
});
|
||||
|
||||
test('J15: a Console deny closes with the other key and starts nothing', async (t) => {
|
||||
const f = setup(t);
|
||||
const d = await f.raise();
|
||||
const { digest } = f.call('console', 'decision.preview.console', { id: d.id });
|
||||
const r = f.call('console', 'decision.resolve.console', { id: d.id, choice: 'no', digest });
|
||||
assert.deepEqual(r, { id: d.id, op: 'resolved', choice: 'no' });
|
||||
assert.deepEqual(f.closes(d.id).map((c) => [c.op, c.choice, c.via]), [['resolved', 'no', 'console']]);
|
||||
await sleep(100);
|
||||
assert.equal(f.enrolls.length, 0);
|
||||
assert.deepEqual(f.grantEvents(d.id), []);
|
||||
assert.deepEqual(f.make().reconcileGrants(), []);
|
||||
});
|
||||
|
||||
test('J2: approve and deny come from approvalChoice and the options, not a fixed yes and no', async (t) => {
|
||||
const f = setup(t);
|
||||
// Deny listed first, so a build that takes options[0] as approve fails too.
|
||||
const keyed = { options: [{ key: 'stop', text: 'Stop' }, { key: 'go', text: 'Go ahead' }], approvalChoice: 'go', recommendation: 'go' };
|
||||
const denied = await f.raise({ ...keyed, question: 'Enroll web1? (deny)' });
|
||||
const p = f.call('console', 'decision.preview.console', { id: denied.id });
|
||||
assert.deepEqual([p.approve, p.deny], [{ key: 'go', text: 'Go ahead' }, { key: 'stop', text: 'Stop' }]);
|
||||
for (const choice of ['yes', 'no']) refusesSync(() => f.call('console', 'decision.resolve.console', { id: denied.id, choice, digest: p.digest }), 'decision-stale');
|
||||
assert.deepEqual(f.call('console', 'decision.resolve.console', { id: denied.id, choice: 'stop', digest: p.digest }), { id: denied.id, op: 'resolved', choice: 'stop' });
|
||||
await sleep(100);
|
||||
assert.equal(f.enrolls.length, 0);
|
||||
const approved = await f.raise({ ...keyed, question: 'Enroll web1? (approve)' });
|
||||
const { digest } = f.call('console', 'decision.preview.console', { id: approved.id });
|
||||
assert.deepEqual(f.call('console', 'decision.resolve.console', { id: approved.id, choice: 'go', digest }), { id: approved.id, op: 'resolved', choice: 'go', grant: 'started' });
|
||||
await f.settle(approved.id);
|
||||
assert.equal(f.enrolls.length, 1);
|
||||
});
|
||||
|
||||
test('J11: a stale digest or a key outside the two refuses decision-stale and writes nothing', async (t) => {
|
||||
const f = setup(t);
|
||||
const d = await f.raise();
|
||||
const { digest } = f.call('console', 'decision.preview.console', { id: d.id });
|
||||
refusesSync(() => f.call('console', 'decision.resolve.console', { id: d.id, choice: 'yes', digest: 'f'.repeat(64) }), 'decision-stale');
|
||||
refusesSync(() => f.call('console', 'decision.resolve.console', { id: d.id, choice: 'maybe', digest }), 'decision-stale');
|
||||
refusesSync(() => f.call('console', 'decision.resolve.console', { id: d.id, choice: 'yes' }), 'invalid-request');
|
||||
refusesSync(() => f.call('console', 'decision.resolve.console', { id: d.id, choice: 'yes', digest, note: 'x' }), 'invalid-request');
|
||||
// Another decision's digest does not answer this one.
|
||||
const other = await f.raise({ question: 'Enroll web1 again?' });
|
||||
const od = f.call('console', 'decision.preview.console', { id: other.id }).digest;
|
||||
assert.notEqual(od, digest);
|
||||
refusesSync(() => f.call('console', 'decision.resolve.console', { id: d.id, choice: 'yes', digest: od }), 'decision-stale');
|
||||
assert.deepEqual(f.closes(d.id), []);
|
||||
assert.equal(f.events().filter((e) => e.kind === 'human.input').length, 0);
|
||||
// A refusal is recorded, as for every verb.
|
||||
assert.ok(f.events().some((e) => e.kind === 'action.refused' && e.code === 'decision-stale'));
|
||||
// The decision closed by the cli meanwhile: the console answer finds it closed.
|
||||
f.call('human', 'decision.resolve', { id: d.id, choice: 'no' });
|
||||
refusesSync(() => f.call('console', 'decision.resolve.console', { id: d.id, choice: 'yes', digest }), 'decision-closed');
|
||||
});
|
||||
|
||||
test('J14: a via console close with no matching human.input approves nothing', (t) => {
|
||||
const f = setup(t);
|
||||
const grant = { method: 'host-enroll', host: 'web2', address: '192.0.2.11', port: 22, user: 'jarvis', sudo: true, fingerprints: [] };
|
||||
const cli = grantDecision(f.store, { grant });
|
||||
const bare = grantDecision(f.store, { grant: { ...grant, host: 'web3' }, close: { op: 'resolved', by: 'jason', choice: 'yes', via: 'console' } });
|
||||
const wrong = grantDecision(f.store, { grant: { ...grant, host: 'web4' }, close: { op: 'resolved', by: 'jason', choice: 'yes', via: 'console' } });
|
||||
const good = grantDecision(f.store, { grant: { ...grant, host: 'web5' }, close: { op: 'resolved', by: 'jason', choice: 'yes', via: 'console' } });
|
||||
const seqOf = (id) => f.store.get('SELECT seq FROM decision_events WHERE decision=?', id).seq;
|
||||
const input = (id, body, role = null) =>
|
||||
f.store.run(
|
||||
"INSERT INTO events(id,at,business,kind,actor_role,actor_run,subject,body) VALUES(?,?,?,'human.input',?,?,?,?)",
|
||||
'e-' + id + '-' + Math.random(), '2026-10-22T00:00:00.000Z', 'demo', role, role && role + '-run', id, JSON.stringify(body),
|
||||
);
|
||||
const answer = (id) => ({ kind: 'answer', decision: id, op: 'resolved', choice: 'yes', via: 'console', subject: SUBJECT, decisionSeq: seqOf(id) });
|
||||
// `bare` has no human.input at all. `wrong` has records that each break one part: another seq, choice, empty subject, an agent actor.
|
||||
input(wrong, { ...answer(wrong), decisionSeq: seqOf(wrong) + 1000 });
|
||||
input(wrong, { ...answer(wrong), choice: 'no' });
|
||||
input(wrong, { ...answer(wrong), subject: { ...SUBJECT, sub: '' } });
|
||||
input(wrong, answer(wrong), 'chief');
|
||||
input(good, answer(good));
|
||||
// reconcileGrants ends every approved, unspent host-enroll: exactly the cli one and the good console one.
|
||||
assert.deepEqual(f.make().reconcileGrants().sort(), [cli, good].sort());
|
||||
assert.equal(f.enrolls.length, 0);
|
||||
assert.equal(f.store.get("SELECT count(*) AS n FROM events WHERE business='demo' AND actor_run='grant' AND json_extract(body,'$.decision') IN (?,?)", bare, wrong).n, 0);
|
||||
});
|
||||
|
||||
test('recordEvent keeps human.input reserved, so no agent forges a console answer', async (t) => {
|
||||
const f = setup(t);
|
||||
const d = await f.raise();
|
||||
const forged = { kind: 'human.input', subject: d.id, body: { kind: 'answer', decision: d.id, op: 'resolved', choice: 'yes', via: 'console', subject: SUBJECT, decisionSeq: 1 } };
|
||||
for (const who of ['chief', 'pm']) refusesSync(() => f.broker.recordEvent(f.caps[who], forged), 'reserved-event');
|
||||
refusesSync(() => f.broker.recordEvent(f.caps.console, forged), 'agent-required');
|
||||
assert.equal(f.events().filter((e) => e.kind === 'human.input').length, 0);
|
||||
});
|
||||
@@ -133,8 +133,9 @@ 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.
|
||||
const org = { log: [], fireError: null };
|
||||
// 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 };
|
||||
const sw = { state: { version: 1, halted: false, generation: GENERATION } };
|
||||
let broker;
|
||||
const organization = (business, op, args) => {
|
||||
@@ -146,6 +147,7 @@ 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);
|
||||
@@ -952,3 +954,44 @@ 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);
|
||||
});
|
||||
@@ -46,52 +46,3 @@ test('human leaf identity is reread after walking the parents',()=>{
|
||||
c.readProcess=pid=>{const p=read(pid);if(pid===50&&n++>0)p.startTime='101';return p;};
|
||||
assert.throws(()=>verifyPrincipal({human:proof},c),/human-required/);
|
||||
});
|
||||
|
||||
// Row 252 (#1753, design section 2, J3-J5): the Console owner. The same-tree console-cli wrapper with
|
||||
// its own nonce, a direct same-tree `serve` parent, the subject from the wrapper's spawn environment,
|
||||
// and that subject one of the configured owners (N1).
|
||||
const owner={provider:'fake-oidc',iss:'https://idp.test',sub:'owner-1'};
|
||||
function consoleContext() {
|
||||
const c=context('user');c.paths={...paths,consoleCli:'/tree/console-cli.mjs'};c.consoleOwners=[{provider:'other',iss:'https://idp.test',sub:'owner-2'},owner];
|
||||
Object.assign(c.table.get(50),{argv:['node','/tree/console-cli.mjs'],env:{MOSAIC_BUS_CONSOLE_NONCE:nonce,MOSAIC_BUS_CONSOLE_BUSINESS:'demo',MOSAIC_BUS_CONSOLE_PROVIDER:owner.provider,MOSAIC_BUS_CONSOLE_ISS:owner.iss,MOSAIC_BUS_CONSOLE_SUB:owner.sub}});
|
||||
return c;
|
||||
}
|
||||
const consoleProof={...proof,...owner};
|
||||
test('console requires its own wrapper, nonce and serve parent, and a configured owner from the spawn env',()=>{
|
||||
assert.deepEqual(verifyPrincipal({console:consoleProof},consoleContext()),{kind:'console',business:'demo',id:'owner',via:'console',subject:owner});
|
||||
// J3: the subject is the spawn's, never the body's alone, and must be a configured owner.
|
||||
for(const change of [{sub:'owner-2'},{iss:'https://evil.test'},{provider:'other'}])
|
||||
assert.throws(()=>verifyPrincipal({console:{...consoleProof,...change}},consoleContext()),/human-required/);
|
||||
const unlisted=consoleContext();unlisted.consoleOwners=[];
|
||||
assert.throws(()=>verifyPrincipal({console:consoleProof},unlisted),/human-required/);
|
||||
for(const extra of [{name:'Jason'},{human:'owner'},{user:'owner'},{sub:''},{sub:'x'.repeat(513)}])
|
||||
assert.throws(()=>verifyPrincipal({console:{...consoleProof,...extra}},consoleContext()),/human-required/);
|
||||
// J4: the parent is the same-tree serve, direct, and not an agent.
|
||||
for(const change of [{argv:['bash']},{argv:['node','/other/console.mjs','serve']},{argv:['node',paths.console,'other']},{env:{CODEX_THREAD_ID:'agent'}},{uid:uid+1}]) {
|
||||
const c=consoleContext();Object.assign(c.table.get(40),change);
|
||||
assert.throws(()=>verifyPrincipal({console:consoleProof},c),/human-required/);
|
||||
}
|
||||
const launched=consoleContext();launched.launches=[{pid:40,startTime:'90'}];
|
||||
assert.throws(()=>verifyPrincipal({console:consoleProof},launched),/human-required/);
|
||||
// J5: another nonce namespace, or the wrapper path of another kind, refuses.
|
||||
for(const env of [{MOSAIC_BUS_CLI_NONCE:nonce},{MOSAIC_BUS_USER_NONCE:nonce}]) {
|
||||
const c=consoleContext();const t=c.table.get(50);t.env={...t.env,...env};delete t.env.MOSAIC_BUS_CONSOLE_NONCE;
|
||||
assert.throws(()=>verifyPrincipal({console:consoleProof},c),/human-required/);
|
||||
}
|
||||
for(const argv of [['node',paths.human],['node',paths.user],['node','/other/console-cli.mjs']]) {
|
||||
const c=consoleContext();c.table.get(50).argv=argv;
|
||||
assert.throws(()=>verifyPrincipal({console:consoleProof},c),/human-required/);
|
||||
}
|
||||
// The Console's own nonce does not answer as the cli human or the user.
|
||||
assert.throws(()=>verifyPrincipal({human:proof},consoleContext()),/human-required/);
|
||||
assert.throws(()=>verifyPrincipal({user:{...proof,user:'owner'}},consoleContext()),/human-required/);
|
||||
// Each piece is read again after the walk: a parent or a spawn subject that changed refuses.
|
||||
for(const [pid,mutate] of [[40,p=>{p.startTime='91';}],[50,p=>{p.env={...p.env,MOSAIC_BUS_CONSOLE_SUB:'owner-2'};}],[50,p=>{p.ppid=41;}]]) {
|
||||
const c=consoleContext(),read=c.readProcess;let n=0;
|
||||
// The last read of each pid is verifyConsole's own reread: 40 is read 3 times, 50 is read 4 times.
|
||||
c.readProcess=x=>{const p=read(x);if(x===pid&&n++===(pid===40?2:3))mutate(p);return p;};
|
||||
assert.throws(()=>verifyPrincipal({console:consoleProof},c),/human-required/);
|
||||
}
|
||||
for(const peer of [{pid:51,uid,gid:uid},{pid:50,uid:uid+1,gid:uid}])assert.throws(()=>verifyPrincipal({console:consoleProof},{...consoleContext(),peer}),/human-required/);
|
||||
assert.throws(()=>verifyPrincipal({console:consoleProof,human:proof},consoleContext()),/invalid-envelope/);
|
||||
});
|
||||
@@ -144,9 +144,8 @@ 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.
|
||||
export function instanceSettings({ dir, rolesDir, system, levels = () => null, revoke = null }) {
|
||||
const writer = organizationWriter({ dir, rolesDir });
|
||||
// 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 }) }) {
|
||||
|
||||
// The definitions in rolesDir and whether add and set accept each one.
|
||||
function definitions() {
|
||||
@@ -379,8 +378,9 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null, r
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
function commit(business, old, next, { revision, actor, context, beforeWrite = null }) {
|
||||
if (canonicalJson(next) === canonicalJson(old)) throw new OrganizationError("organization-no-change");
|
||||
|
||||
@@ -410,12 +410,11 @@ 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. 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).
|
||||
// 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.
|
||||
if (writer.read(business).revision !== revision) throw new OrganizationError("revision-conflict", "organization revision changed; read again");
|
||||
if (beforeWrite) beforeWrite();
|
||||
const receipt = writer.write(business, next, { revision, actor, context });
|
||||
const receipt = writer.write(business, next, { revision, actor, context, beforeWrite });
|
||||
return { ...read(business, actor), transaction: receipt.transaction, restart: false };
|
||||
}
|
||||
|
||||
|
||||
@@ -177,55 +177,70 @@ 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).
|
||||
function write(id, document, { revision, actor, detail, context }) {
|
||||
// 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 }) {
|
||||
requireId(actor, "authenticated actor");
|
||||
requireId(id, "organization id");
|
||||
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 };
|
||||
});
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// Row 199: a receipt with no document change, for a credential verb that changes only the agents'
|
||||
|
||||
@@ -4,9 +4,10 @@
|
||||
import test, { after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { readFileSync, rmSync } from "node:fs";
|
||||
import { existsSync, readFileSync, rmSync, writeFileSync } 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";
|
||||
|
||||
@@ -36,7 +37,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, file, settings, validate, log, onDisk, revision };
|
||||
return { root, dir, rolesDir, file, settings, validate, log, onDisk, revision };
|
||||
}
|
||||
|
||||
// Each refusal: its code (or the validator's BusinessError), and the file's bytes unchanged.
|
||||
@@ -244,3 +245,73 @@ 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);
|
||||
}
|
||||
});
|
||||
@@ -83,9 +83,6 @@ function trackerFor(system, b, warn) {
|
||||
};
|
||||
}
|
||||
|
||||
export const consoleOwners = (sso) =>
|
||||
sso.providers.flatMap((p) => p.subjects.map((s) => ({ provider: p.id, iss: p.issuer, sub: s.sub })));
|
||||
|
||||
// The `{op: 'boot'}` config for packages/bus/src/process.mjs, for one
|
||||
// business. The host holds a reader capability for the notifier and binds
|
||||
// launches later through bindLaunch, so `launches` starts empty.
|
||||
@@ -104,9 +101,6 @@ export function bootConfig({ system, businessId, env = process.env, warn = () =>
|
||||
};
|
||||
const tracker = trackerFor(vars, b, warn);
|
||||
if (tracker) config.trackers = { [b.id]: tracker };
|
||||
// Row 252 (#1753, Webby N1): every console.sso subject is an owner, by provider id, issuer and sub.
|
||||
// The loader checked console.sso; no new config field names owners.
|
||||
if (system.console?.sso) config.consoleOwners = consoleOwners(system.console.sso);
|
||||
return config;
|
||||
});
|
||||
}
|
||||
@@ -55,6 +55,12 @@ 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",
|
||||
|
||||
@@ -42,6 +42,7 @@ const EXIT = {
|
||||
"tester-off": 3,
|
||||
"tester-refused": 3,
|
||||
"outcome-unknown": 1,
|
||||
"outcome-unknown-revoked": 1,
|
||||
"response-too-large": 1,
|
||||
"invalid-response": 1,
|
||||
};
|
||||
|
||||
@@ -69,19 +69,3 @@ test("an unknown business and a broken system config refuse with exit 3", (t) =>
|
||||
assert.throws(() => bootConfig({ system: loadSystem({ env: f.env }), businessId: "nope", env: f.env }), (e) => e.exitCode === 3);
|
||||
assert.throws(() => loadSystem({ env: { ...f.env, MOSAIC_CONFIG: join(f.dataRoot, "missing.json") } }), (e) => e.exitCode === 3);
|
||||
});
|
||||
|
||||
test("Row 252 (Webby N1): every console.sso subject reaches the boot as a console owner, and no sso means no consoleOwners key", (t) => {
|
||||
const f = fixture(tmp(t));
|
||||
const system = loadSystem({ env: f.env });
|
||||
assert.equal("consoleOwners" in bootConfig({ system, businessId: "acme", env: f.env }), false);
|
||||
const sso = { providers: [
|
||||
{ id: "authentik", issuer: "https://auth.example/app/", subjects: [{ sub: "jason-sub" }, { sub: "second-sub" }] },
|
||||
{ id: "other", issuer: "https://idp.example", subjects: [{ sub: "jason-sub" }] },
|
||||
] };
|
||||
const boot = bootConfig({ system: { ...system, console: { sso } }, businessId: "acme", env: f.env });
|
||||
assert.deepEqual(boot.consoleOwners, [
|
||||
{ provider: "authentik", iss: "https://auth.example/app/", sub: "jason-sub" },
|
||||
{ provider: "authentik", iss: "https://auth.example/app/", sub: "second-sub" },
|
||||
{ provider: "other", iss: "https://idp.example", sub: "jason-sub" },
|
||||
]);
|
||||
});
|
||||
@@ -316,6 +316,28 @@ 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);
|
||||
|
||||
@@ -45,6 +45,8 @@ 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));
|
||||
});
|
||||
@@ -335,28 +335,13 @@ note is `agents/dewey/work/wui/SLICE1-VIEWS.md`.
|
||||
same functions, so both show the same broker data.
|
||||
- Nothing writes but the "Message a role" box, the Settings update, the Assistant
|
||||
section's three buttons and the spec and docs editor's Save, all under `--send` (above,
|
||||
"Assistant settings" and "Spec and docs editor"), and the decision Approve and Deny below.
|
||||
A decision shows `mosaic decide <id> <key>` for each choice,
|
||||
"Assistant settings" and "Spec and docs editor"). A decision shows `mosaic decide <id> <key>` for each choice,
|
||||
with Copy, which only puts the command on the clipboard. It is answered in a
|
||||
terminal (REQ-DEC-3, Q4). Seen is not set from the browser either. The
|
||||
command has no `--business`, so `mosaic decide` uses the running bus host's
|
||||
business, as the Console does by default. From a Console started with
|
||||
`--business` for another business the copied command refuses (no bus host,
|
||||
or no such decision) rather than answering elsewhere.
|
||||
- Row 252 (#1753, PRD 1.9 REQ-WEB-2): a grant request (`credential.mint` routed
|
||||
to the human, raised with a grant and two options) can also be approved or
|
||||
denied on its decision page, only when the Console runs with both `--sso` and
|
||||
`--send` and the page is signed in as a `console.sso` subject. The first click
|
||||
POSTs `/api/decisions/<id>/confirmation` and shows what the answer does; only
|
||||
Confirm POSTs `/resolve` with that one-use confirmation (two minutes, bound to
|
||||
the session, decision and choice). Each step spawns
|
||||
`packages/bus/src/console-cli.mjs`, which the bus verifies as the Console's
|
||||
child holding the signed-in subject, and the bus records the answer `via
|
||||
console` with a `human.input` naming that subject. Nothing retries; an unknown
|
||||
outcome says to refresh. Without `--sso` the page has no buttons and says why;
|
||||
under `--sso` without `--send` it says the Console is read-only for decisions.
|
||||
The tester listener has no decision routes. Every other decision stays
|
||||
terminal-only.
|
||||
- What no Q1 verb returns is labelled where it would show, for example
|
||||
"Bot names: not in the Q1 module" (lead decision 63). Nothing is guessed.
|
||||
- A task's list row can only say its current state came from a Vikunja read,
|
||||
|
||||
+15
-35
@@ -10,11 +10,9 @@
|
||||
|
||||
import { spawn } from 'node:child_process';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { HUMAN_CLI } from '../../cli/src/transport.mjs';
|
||||
import { readHostState } from '../../cli/src/host.mjs';
|
||||
import { views } from '../../bus/src/views.mjs';
|
||||
import { CONSOLE_VERBS } from '../../bus/src/broker.mjs';
|
||||
|
||||
export class BusReadError extends Error {
|
||||
constructor(code, message = code) { super(message); this.code = code; }
|
||||
@@ -28,13 +26,10 @@ export const subjectOk = s => typeof s === 'string' && s.length > 0 && s.length
|
||||
// request on stdin, JSON on stdout, or a bus error code as the last
|
||||
// `^[a-z-]{1,64}$` line of stderr.
|
||||
export function humanCall({ socket, business, env = process.env, cli = HUMAN_CLI, timeoutMs = 30000, maxBytes = 8 * 1024 * 1024 }) {
|
||||
return (verb, args = {}) => transport({ cli, socket, env: cli === HUMAN_CLI ? { ...env, MOSAIC_BUS_CLI_NONCE: randomBytes(32).toString('hex') } : env, input: { business, verb, args }, timeoutMs, maxBytes });
|
||||
}
|
||||
function transport({ cli, socket, env, input, timeoutMs, maxBytes }) {
|
||||
return new Promise((resolve, reject) => {
|
||||
return (verb, args = {}) => new Promise((resolve, reject) => {
|
||||
let child;
|
||||
try {
|
||||
child = spawn(process.execPath, [cli, socket], { env, stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
child = spawn(process.execPath, [cli, socket], { env: cli === HUMAN_CLI ? { ...env, MOSAIC_BUS_CLI_NONCE: randomBytes(32).toString('hex') } : env, stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
} catch { return reject(new BusReadError('outcome-unknown', 'the bus transport did not start')); }
|
||||
const out = [], err = [];
|
||||
let size = 0, settled = false;
|
||||
@@ -57,48 +52,31 @@ function transport({ cli, socket, env, input, timeoutMs, maxBytes }) {
|
||||
catch { done(reject, new BusReadError('invalid-response')); }
|
||||
});
|
||||
child.stdin.on('error', () => {}); // A child that exits early reports through 'close'.
|
||||
child.stdin.end(`${JSON.stringify(input)}\n`);
|
||||
child.stdin.end(`${JSON.stringify({ business, verb, args })}\n`);
|
||||
});
|
||||
}
|
||||
|
||||
// Row 252 (#1753, design section 2): the Console owner's answer to a grant request. Each call spawns
|
||||
// a fresh console-cli with a fresh nonce, and the business and the signed-in (provider, iss, sub) go
|
||||
// in its spawn environment, where the bus reads them back. Inherited console keys are dropped first,
|
||||
// so only this call's values reach the wrapper. Only the two console verbs pass (N4). A resolve pins
|
||||
// the business its confirmation was issued for.
|
||||
export const CONSOLE_CLI = fileURLToPath(new URL('../../bus/src/console-cli.mjs', import.meta.url));
|
||||
export function consoleCall({ dataRoot, socket, business = null, env = process.env, cli = CONSOLE_CLI, timeoutMs = 30000 }) {
|
||||
return async (verb, args, { subject, business: pinned = null }) => {
|
||||
if (!CONSOLE_VERBS.includes(verb)) throw new Error(`the Console does not call ${verb}`);
|
||||
const id = pinned ?? (business || hostBusiness(dataRoot));
|
||||
const own = Object.fromEntries(Object.entries(env).filter(([k]) => !k.startsWith('MOSAIC_BUS_CONSOLE_')));
|
||||
return transport({
|
||||
cli, socket, timeoutMs, maxBytes: 1024 * 1024, input: { verb, args },
|
||||
env: { ...own, MOSAIC_BUS_CONSOLE_NONCE: randomBytes(32).toString('hex'), MOSAIC_BUS_CONSOLE_BUSINESS: id,
|
||||
MOSAIC_BUS_CONSOLE_PROVIDER: subject.provider, MOSAIC_BUS_CONSOLE_ISS: subject.iss, MOSAIC_BUS_CONSOLE_SUB: subject.sub },
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
// The business is the --business flag, or else the live bus host's, read
|
||||
// on every request so a host started after the Console is picked up.
|
||||
export function busCaller({ dataRoot, socket, business = null, env = process.env, cli = HUMAN_CLI, timeoutMs = 30000 }) {
|
||||
return async (verb, args) => {
|
||||
return humanCall({ socket, business: business || hostBusiness(dataRoot), env, cli, timeoutMs })(verb, args);
|
||||
let id = business;
|
||||
if (!id) {
|
||||
let state;
|
||||
try { state = readHostState(dataRoot); } catch { throw new BusReadError('no-bus-host', 'the bus host state file is unreadable'); }
|
||||
if (!state?.live) throw new BusReadError('no-bus-host', 'no bus host is running and no --business was given');
|
||||
id = state.business;
|
||||
}
|
||||
return humanCall({ socket, business: id, env, cli, timeoutMs })(verb, args);
|
||||
};
|
||||
}
|
||||
function hostBusiness(dataRoot) {
|
||||
let state;
|
||||
try { state = readHostState(dataRoot); } catch { throw new BusReadError('no-bus-host', 'the bus host state file is unreadable'); }
|
||||
if (!state?.live) throw new BusReadError('no-bus-host', 'no bus host is running and no --business was given');
|
||||
return state.business;
|
||||
}
|
||||
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.
|
||||
const OPEN = ['outcome-unknown', 'invalid-response', 'response-too-large'];
|
||||
// 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'];
|
||||
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 };
|
||||
@@ -110,6 +88,8 @@ 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.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// MOSAIC_TESTER_SOCKET_V1: private tester ingress, no tester TCP.
|
||||
import { createChat } from './chat.mjs';
|
||||
import { startServer, previewOptions, proofOptions, DEFAULT_BOARD } from './serve.mjs';
|
||||
import { busCaller, consoleCall } from './bus.mjs';
|
||||
import { busCaller } from './bus.mjs';
|
||||
import { startTesterSocket } from './tester-socket.mjs';
|
||||
import { TESTER_CLI } from '../../cli/src/transport.mjs';
|
||||
import { views } from '../../bus/src/views.mjs';
|
||||
@@ -72,7 +72,7 @@ try {
|
||||
// the system config, Business can't resolve authority and Settings has
|
||||
// no notifier. A refusal printed here names no path (redactor).
|
||||
// --send without a bus refuses to start rather than serve a composer that can't send.
|
||||
let bus = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, libraryFile = null, libraryStatus = null, librarySync = null, projectSave = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, decisionWrite = null, busLine, system = null;
|
||||
let bus = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, libraryFile = null, libraryStatus = null, librarySync = null, projectSave = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, busLine, system = null;
|
||||
let processing = null;
|
||||
try {
|
||||
system = loadSystem();
|
||||
@@ -91,9 +91,6 @@ try {
|
||||
projectFile = args => call('project.file', args);
|
||||
libraryFile = args => call('library.file', args); libraryStatus = args => call('library.status', args);
|
||||
if (sendOn) {send = args => call('message.send', args);librarySync=args=>call('library.sync',args);settingsWrite=args=>call('limits.set',args);projectSave=args=>call('project.file.save',args);nameWrite=args=>call('name.set',args);assistantStop=args=>call('assistant.stop',args);instancesWrite=args=>call('instances.set',args);routesWrite=args=>call('routes.set',args);agentWrite=agentCaller(call,AGENT_VERBS);}
|
||||
// Row 252 (#1753): Approve and Deny on grant requests, through the console wrapper, only with
|
||||
// --sso and --send.
|
||||
if (ssoOn && sendOn) decisionWrite = consoleCall({ dataRoot, socket: socketPath(dataRoot), business: options.business ?? null });
|
||||
busLine = `Bus: ${options.business ?? 'the running bus host\'s business'}`;
|
||||
} catch (err) {
|
||||
const why = redactor({ configDir: configDir() })(err.message);
|
||||
@@ -113,7 +110,7 @@ try {
|
||||
}
|
||||
let server;
|
||||
try {
|
||||
server = await startServer({ board: options.board, port: options.port, bus, reads, preview: options.preview, sso, proof, send, settingsWrite, projectFiles, projectFile, libraryFile, libraryStatus, librarySync, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing, decisionWrite });
|
||||
server = await startServer({ board: options.board, port: options.port, bus, reads, preview: options.preview, sso, proof, send, settingsWrite, projectFiles, projectFile, libraryFile, libraryStatus, librarySync, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing });
|
||||
} catch (err) { sso?.close(); throw err; }
|
||||
let testerServer = null;
|
||||
if (testerSocket) {
|
||||
@@ -137,7 +134,7 @@ try {
|
||||
const proofLine = `\nProxy: proxy proof ${proof ? 'on' : 'off'}`;
|
||||
// One line per provider: id, issuer, subject count and state. No client id, secret or sub.
|
||||
const health = sso?.health() ?? {};
|
||||
const ssoLines = sso ? `\nSign-in: ${sso.publicOrigin}/ (--sso), ${send ? 'reads and human writes (--send), grant Approve and Deny' : 'read-only'}` + sso.summary().map(p => `\nSign-in provider: ${p.id} issuer ${p.issuer} subjects ${p.subjects} (${health[p.id]})`).join('') : '';
|
||||
const ssoLines = sso ? `\nSign-in: ${sso.publicOrigin}/ (--sso), ${send ? 'reads and human writes (--send)' : 'read-only'}` + sso.summary().map(p => `\nSign-in provider: ${p.id} issuer ${p.issuer} subjects ${p.subjects} (${health[p.id]})`).join('') : '';
|
||||
const queueLine = options.checkout ? '\nQueue: read from the --checkout directory' : '';
|
||||
console.log(`Mosaic Console: http://127.0.0.1:${server.address().port}/\nBoard: ${options.board}\n${busLine}${queueLine}${previewLine}${ssoLines}${proofLine}${testerServer ? `\nTester Console: unix:${testerServer.address()} (binds the tester)` : ''}\nCtrl-C stops this server.`);
|
||||
for (const signal of ['SIGINT', 'SIGTERM']) process.once(signal, () => { sso?.close(); server.close(); testerServer?.close(); });
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
// Row 252 (#1753, Gander's design approved by Webby at 5d8f0f4b5): Approve and Deny for a grant
|
||||
// request, from the signed-in Console only. Two POSTs. /confirmation asks the bus for the decision's
|
||||
// two answers and digest and returns a one-use confirmation bound to this session, business,
|
||||
// decision, choice and digest. /resolve takes only that choice and confirmation, consumes the
|
||||
// confirmation before it awaits the bus, and asks the bus to resolve with the digest. The bus
|
||||
// rechecks everything in the resolve transaction. Nothing here retries.
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import { BusReadError, subjectOk } from './bus.mjs';
|
||||
|
||||
export const DECISION_ROUTE = /^\/api\/decisions\/([^/]*)\/(confirmation|resolve)$/;
|
||||
// The endpoint contract, design section 4. Each refusal is one of these, never a 200.
|
||||
export const DECISION_REFUSALS = Object.freeze({
|
||||
'sign-in-required': [401, 'Sign in again before answering.'],
|
||||
'read-only': [403, 'Console decision answers are not enabled. Use mosaic decide.'],
|
||||
'cross-origin': [403, 'Answer from the signed-in Console page.'],
|
||||
'not-found': [404, 'Not found.'],
|
||||
'human-required': [403, 'The bus refused this human identity.'],
|
||||
'peercred-required': [403, 'The bus could not verify this connection.'],
|
||||
'method-not-allowed': [405, 'Use POST.'],
|
||||
'invalid-request': [400, 'Invalid decision answer.'],
|
||||
'request-too-large': [413, 'Decision answer is too large.'],
|
||||
'decision-not-found': [404, 'This decision is unavailable.'],
|
||||
'decision-not-eligible': [403, 'Answer this decision with mosaic decide.'],
|
||||
'decision-closed': [409, 'This decision is no longer open. Refresh the inbox.'],
|
||||
'decision-stale': [409, 'The choices changed. Review the decision again.'],
|
||||
'confirmation-required': [409, 'Review and confirm this answer again.'],
|
||||
'confirmation-unavailable': [503, 'Confirmation is unavailable. Try again later.'],
|
||||
'outcome-unknown': [503, 'The answer may have reached the bus. Refresh before trying again.'],
|
||||
});
|
||||
// Bus codes by name. A bus that refuses this caller is human-required, whatever its code. Any
|
||||
// other code, or no answer, is confirmation-unavailable at the preview and outcome-unknown at the
|
||||
// resolve, which the page reads as "refresh first".
|
||||
const BUS_CODES = {
|
||||
'human-required': 'human-required', unauthenticated: 'human-required', 'unknown-business': 'human-required',
|
||||
'access-refused': 'human-required', 'access-revoked': 'human-required', 'agent-required': 'human-required',
|
||||
'peercred-required': 'peercred-required',
|
||||
'decision-not-found': 'decision-not-found', 'decision-not-eligible': 'decision-not-eligible',
|
||||
'decision-closed': 'decision-closed', 'decision-stale': 'decision-stale', 'invalid-choice': 'decision-stale',
|
||||
'invalid-request': 'invalid-request',
|
||||
};
|
||||
const BODY_MAX = 4096;
|
||||
const TOKEN = /^[a-f0-9]{64}$/;
|
||||
const DIGEST = /^[a-f0-9]{64}$/;
|
||||
const one = (req, name) => (req.headersDistinct[name]?.length === 1 ? req.headersDistinct[name][0] : null);
|
||||
const hash = (token) => createHash('sha256').update(token).digest('hex');
|
||||
export function decisionRefuse(res, code) {
|
||||
const [status, message] = DECISION_REFUSALS[code];
|
||||
res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' });
|
||||
res.end(JSON.stringify({ error: code, message }));
|
||||
}
|
||||
|
||||
// The confirmations, in this process's memory only and stored by hash. 120 seconds, at most 1024
|
||||
// live. A full store refuses a new one rather than drop a live one. An expired one is swept.
|
||||
export function createConfirmations({ ttlMs = 120000, max = 1024, now = Date.now } = {}) {
|
||||
const live = new Map();
|
||||
const sweep = () => { const t = now(); for (const [k, v] of live) if (v.expires <= t) live.delete(k); };
|
||||
return {
|
||||
issue(binding) {
|
||||
sweep();
|
||||
if (live.size >= max) return null;
|
||||
const token = randomBytes(32).toString('hex');
|
||||
live.set(hash(token), { ...binding, expires: now() + ttlMs });
|
||||
return token;
|
||||
},
|
||||
// One use: a token found is gone, whether or not it matches.
|
||||
consume(token, { session, decision, choice }) {
|
||||
sweep();
|
||||
if (typeof token !== 'string' || !TOKEN.test(token)) return null;
|
||||
const k = hash(token), v = live.get(k);
|
||||
if (!v) return null;
|
||||
live.delete(k);
|
||||
return v.session === session && v.decision === decision && v.choice === choice ? v : null;
|
||||
},
|
||||
get size() { sweep(); return live.size; },
|
||||
};
|
||||
}
|
||||
|
||||
async function readBody(req) {
|
||||
if (one(req, 'content-type')?.split(';')[0].trim().toLowerCase() !== 'application/json') return { code: 'invalid-request' };
|
||||
const chunks = [];
|
||||
let size = 0;
|
||||
for await (const chunk of req) {
|
||||
size += chunk.length;
|
||||
if (size > BODY_MAX) return { code: 'request-too-large' };
|
||||
chunks.push(chunk);
|
||||
}
|
||||
try {
|
||||
const value = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? { value } : { code: 'invalid-request' };
|
||||
} catch { return { code: 'invalid-request' }; }
|
||||
}
|
||||
const preview = (v, id) =>
|
||||
v && typeof v === 'object' && v.id === id && typeof v.business === 'string' && DIGEST.test(v.digest ?? '') &&
|
||||
['approve', 'deny'].every((k) => typeof v[k]?.key === 'string' && typeof v[k]?.text === 'string') &&
|
||||
v.approve.key !== v.deny.key && v.grant && typeof v.grant.method === 'string';
|
||||
|
||||
// `session` is the live sign-in session object this request passed, and `live()` reads it again
|
||||
// right before each spawn. `call(verb, args, { subject, business })` is the console transport.
|
||||
export async function decisionHTTP(req, res, { path, decide, session, live, confirmations, log }) {
|
||||
const [, raw, step] = DECISION_ROUTE.exec(path);
|
||||
let id = null;
|
||||
try { id = decodeURIComponent(raw); } catch { /* refused below */ }
|
||||
if (req.method !== 'POST') return decisionRefuse(res, 'method-not-allowed');
|
||||
if (!decide) return decisionRefuse(res, 'read-only');
|
||||
if (!session) return decisionRefuse(res, 'sign-in-required');
|
||||
if (one(req, 'origin') !== decide.origin || one(req, 'sec-fetch-site') !== 'same-origin') return decisionRefuse(res, 'cross-origin');
|
||||
const line = (outcome) => log(`${new Date().toISOString()} decide user=${JSON.stringify(String(session.name ?? '').slice(0, 64))} decision=${JSON.stringify(String(id ?? '').slice(0, 64))} step=${step} ${outcome}`);
|
||||
const refuse = (code) => { line(`error=${code}`); return decisionRefuse(res, code); };
|
||||
const { value, code } = await readBody(req);
|
||||
if (code) return refuse(code);
|
||||
if (!subjectOk(id)) return refuse('decision-not-found');
|
||||
const subject = { provider: session.provider, iss: session.iss, sub: session.sub };
|
||||
const busCode = (err, open) => {
|
||||
const c = err instanceof BusReadError ? BUS_CODES[err.code] : undefined;
|
||||
return c ?? open;
|
||||
};
|
||||
if (step === 'confirmation') {
|
||||
if (Object.keys(value).length !== 1 || typeof value.choice !== 'string') return refuse('invalid-request');
|
||||
if (live() !== session) return refuse('sign-in-required');
|
||||
let v;
|
||||
try { v = await decide.call('decision.preview.console', { id }, { subject }); } catch (err) { return refuse(busCode(err, 'confirmation-unavailable')); }
|
||||
if (!preview(v, id)) return refuse('confirmation-unavailable');
|
||||
const action = value.choice === v.approve.key ? 'approve' : value.choice === v.deny.key ? 'deny' : null;
|
||||
if (!action) return refuse('decision-stale');
|
||||
if (live() !== session) return refuse('sign-in-required');
|
||||
const confirmation = confirmations.issue({ session, business: v.business, decision: id, choice: value.choice, digest: v.digest });
|
||||
if (!confirmation) return refuse('confirmation-unavailable');
|
||||
line(`action=${action}`);
|
||||
return json(res, 200, { id, question: typeof v.question === 'string' ? v.question : '', action, choice: value.choice, text: v[action].text, grant: v.grant, confirmation });
|
||||
}
|
||||
const keys = Object.keys(value);
|
||||
if (keys.length !== 2 || !keys.includes('choice') || !keys.includes('confirmation') || typeof value.choice !== 'string') return refuse('invalid-request');
|
||||
// Consumed before any await on the bus, so a second click finds nothing (J12).
|
||||
const held = confirmations.consume(value.confirmation, { session, decision: id, choice: value.choice });
|
||||
if (!held) return refuse('confirmation-required');
|
||||
if (live() !== session) return refuse('sign-in-required');
|
||||
let r;
|
||||
try {
|
||||
r = await decide.call('decision.resolve.console', { id, choice: held.choice, digest: held.digest }, { subject, business: held.business });
|
||||
} catch (err) { return refuse(busCode(err, 'outcome-unknown')); }
|
||||
if (r?.id !== id || r.op !== 'resolved' || r.choice !== held.choice) return refuse('outcome-unknown');
|
||||
line(`choice=${JSON.stringify(held.choice.slice(0, 64))}${r.grant ? ` grant=${r.grant}` : ''}`);
|
||||
return json(res, 200, { id, op: 'resolved', choice: r.choice, ...(r.grant === 'started' ? { grant: 'started' } : {}) });
|
||||
}
|
||||
function json(res, status, body) {
|
||||
res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' });
|
||||
res.end(JSON.stringify(body));
|
||||
}
|
||||
@@ -190,9 +190,8 @@
|
||||
out.kind = 'decision.raised'; out.decision = r.id; out.actor = { role: r.raised_by_role, run: r.raised_by_run };
|
||||
out.text = `Raised a ${r.class} decision for ${r.route_to}: ${String(r.question).split('\n')[0]}`;
|
||||
} else if (r.table === 'decision_events') {
|
||||
// The human answers from the CLI (via cli) or, since row 252 (#1753), a grant request from the
|
||||
// signed-in Console (via console); every other row is the broker's.
|
||||
out.kind = `decision.${r.op}`; out.decision = r.decision; out.by = r.via === 'cli' ? 'human' : r.via === 'console' ? 'human-console' : 'broker';
|
||||
// The human answers from the CLI (via cli); every other row is the broker's.
|
||||
out.kind = `decision.${r.op}`; out.decision = r.decision; out.by = r.via === 'cli' ? 'human' : 'broker';
|
||||
out.text = r.op === 'resolved' ? `${r.by} chose “${r.choice}” via ${r.via}` : r.op === 'seen' ? `${r.by} saw it${r.via ? ` via ${r.via}` : ''}` : `${r.op[0].toUpperCase()}${r.op.slice(1)} by ${r.by}${r.note ? `: ${r.note}` : ''}`;
|
||||
} else if (r.table === 'task_snapshots') {
|
||||
out.kind = `snapshot.${r.source}`;
|
||||
@@ -228,9 +227,8 @@
|
||||
// An empty state says what would appear and where it comes from.
|
||||
const empty = (what, from) => `<div class="empty"><b>${what}</b>${from ? `<p>${from}</p>` : ''}</div>`;
|
||||
const taskLink = ref => ref ? `<a href="#/tasks/${enc(ref)}">#${esc(String(ref).split('/')[1])}</a>` : '';
|
||||
// Rows without an actor role come from the human (CLI outside any run, or the signed-in Console)
|
||||
// or from the broker itself.
|
||||
const actor = (a, by) => `<span class="source">${a ? `${txt(a.role)} · ${txt(a.run)}` : by === 'human' ? 'human, CLI outside any run' : by === 'human-console' ? 'human, signed-in Console' : 'broker'}</span>`;
|
||||
// Rows without an actor role come from the human (CLI, outside any run) or from the broker itself.
|
||||
const actor = (a, by) => `<span class="source">${a ? `${txt(a.role)} · ${txt(a.run)}` : by === 'human' ? 'human, CLI outside any run' : 'broker'}</span>`;
|
||||
const PRIORITY = ['unset', 'low', 'medium', 'high', 'urgent', 'do now'];
|
||||
const head = (title, sub) => `<div class="page-head"><div><h1 tabindex="-1">${title}</h1>${sub ? `<p class="small muted" id="bus-status">${sub}</p>` : ''}</div></div>`;
|
||||
let readAt = null;
|
||||
@@ -258,7 +256,7 @@
|
||||
// Inbox
|
||||
function decisionCard(d) {
|
||||
return `<li class="wait-item s1-dec${d.blocking ? ' is-attn' : ''}"><div class="wait-head"><a href="#/inbox/${enc(d.id)}" class="s1-dec-open">${txt(first(d.question))}</a></div>
|
||||
<p class="s1-badges">${d.blocking ? blockChip('blocking') : ''}${chip(d.class, d.action)}${canDecide && d.console ? ` ${badge('Approve or Deny here', 'ok')}` : ''}</p>
|
||||
<p class="s1-badges">${d.blocking ? blockChip('blocking') : ''}${chip(d.class, d.action)}</p>
|
||||
<p class="small s1-auth">${approves(d.authorization)}</p>
|
||||
<p class="small muted">Raised by ${txt(d.raised_by_role)} ${when(d.at)}${d.task_ref ? ` · task ${taskLink(d.task_ref)}` : ''}${d.requirement_ref ? ` · ${txt(d.requirement_ref)}` : ''}</p></li>`;
|
||||
}
|
||||
@@ -316,101 +314,12 @@
|
||||
el.outerHTML = composerHtml();
|
||||
restore(k);
|
||||
}
|
||||
// Row 252 (#1753, design section 5): Approve and Deny for an open grant request, drawn only when
|
||||
// the server says this Console answers decisions (`decide` in /api/config: signed in through SSO
|
||||
// and started with --send) and the bus marked the inbox row eligible (its `console` field). The
|
||||
// first click asks the Console for a one-use confirmation that names what the answer does. Only
|
||||
// the second click answers. Nothing here retries: a refusal drops the confirmation, and an
|
||||
// unknown outcome says to refresh first. The state lives in `answer`, not the DOM, so the 10 s
|
||||
// refresh redraws it. The server and the bus check everything again; this is presentation only.
|
||||
let canDecide = false, ssoOn = false, answer = null;
|
||||
const ACTIONS = { approve: 'Approve', deny: 'Deny' };
|
||||
const grantText = g => [`method ${g.method}`, g.agent && `agent ${g.agent}`, g.host && `host ${g.host}`, Array.isArray(g.projects) && g.projects.length && `projects ${g.projects.join(', ')}`].filter(Boolean).map(s => txt(String(s))).join(' · ');
|
||||
function decideHtml() {
|
||||
const a = answer;
|
||||
if (!a) return '';
|
||||
const off = a.pending ? ' disabled' : '';
|
||||
let body;
|
||||
if (a.result) {
|
||||
const r = a.result;
|
||||
body = `<p class="receipt" id="decide-status" tabindex="-1">${r.action === 'approve' ? 'Approved' : 'Denied'}: you chose “${txt(r.choice)}”, ${txt(r.text)}. ${r.grant === 'started' ? 'The grant executor started.' : r.action === 'approve' ? 'The bus started no grant for it.' : 'Nothing starts for a denial.'} History below shows it once the bus is read again.</p>`;
|
||||
} else if (a.confirm) {
|
||||
const c = a.confirm, label = `Confirm ${c.action}`;
|
||||
body = `<div class="s1-confirm" role="group" aria-labelledby="b-confirm"><h3 id="b-confirm" class="small">${label}</h3>
|
||||
<dl class="kv"><dt>Id</dt><dd><code>${txt(c.id)}</code></dd><dt>Question</dt><dd class="s1-q">${txt(c.question)}</dd><dt>Answer</dt><dd><b class="s1-key">${txt(c.choice)}</b> ${txt(c.text)}</dd><dt>Grant</dt><dd>${grantText(c.grant)}</dd></dl>
|
||||
<p class="small muted">${c.action === 'approve' ? 'Approving starts this grant.' : 'Denying starts nothing.'} This confirmation works once and lasts two minutes.</p>
|
||||
<div class="s1-cmd"><button type="button" class="btn primary" data-decide="confirm"${off}>${a.pending ? 'Answering…' : label}</button><button type="button" class="btn" data-decide="cancel"${off}>Cancel</button></div></div>`;
|
||||
} else {
|
||||
body = `<ul class="s1-plain">${Object.entries(ACTIONS).map(([k, l]) => `<li><b>${l}</b> chooses <b class="s1-key">${txt(a.console[k].key)}</b> ${txt(a.console[k].text)}</li>`).join('')}</ul>
|
||||
<p class="small muted">Grant: ${grantText(a.console.grant)}. Either button opens a confirmation first. Nothing is answered until you confirm.</p>
|
||||
<div class="s1-cmd">${Object.entries(ACTIONS).map(([k, l]) => `<button type="button" class="btn${k === 'approve' ? ' primary' : ''}" data-decide="${k}"${off}>${a.pending === k ? 'Checking…' : l}</button>`).join('')}</div>`;
|
||||
}
|
||||
const e = a.error;
|
||||
const said = e ? `<p class="receipt failed" id="decide-status" tabindex="-1">Not answered here: ${txt(e.message)} (<code>${txt(e.code)}</code>).${e.code === 'outcome-unknown' ? ' Nothing was sent again.' : ''}</p>` : '';
|
||||
return `<section class="panel s1-decide" id="decide-section" aria-labelledby="b-decide"><h2 id="b-decide">Answer in Console</h2>${body}${said}</section>`;
|
||||
}
|
||||
// Redraw only the answer panel. `focus` names what takes focus after it.
|
||||
function paintDecide(focus) {
|
||||
const el = $('decide-section');
|
||||
if (!el) return;
|
||||
el.outerHTML = decideHtml();
|
||||
(focus ? $('decide-section')?.querySelector(focus) : null)?.focus();
|
||||
}
|
||||
async function decidePost(url, body) {
|
||||
let res = null, json = null;
|
||||
try {
|
||||
res = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), cache: 'no-store', redirect: 'manual' });
|
||||
try { json = await res.json(); } catch { json = null; }
|
||||
} catch { res = null; }
|
||||
return { res, json };
|
||||
}
|
||||
// A refusal the Console wrote carries its code and message (design section 4). Anything else, a
|
||||
// 408 or no answer included, gets the fallback: at the resolve step that is outcome-unknown.
|
||||
const DECIDE_FALLBACK = {
|
||||
'confirmation-unavailable': 'Confirmation is unavailable. Try again later.',
|
||||
'outcome-unknown': 'The answer may have reached the bus. Refresh before trying again.',
|
||||
};
|
||||
const decideRefusal = (res, json, fallback) => res && !res.ok && res.status !== 408 && typeof json?.error === 'string' && typeof json?.message === 'string'
|
||||
? { code: json.error, message: json.message } : { code: fallback, message: DECIDE_FALLBACK[fallback] };
|
||||
async function decide(step) {
|
||||
const a = answer;
|
||||
if (!a || a.pending || a.result) return;
|
||||
if (step === 'cancel') { a.confirm = null; a.error = null; paintDecide('[data-decide="approve"]'); announce('Cancelled. Nothing was answered.'); return; }
|
||||
if (out()) { a.confirm = null; a.error = { code: 'sign-in-required', message: 'Sign in again before answering.' }; paintDecide('#decide-status'); return; }
|
||||
if (step === 'approve' || step === 'deny') {
|
||||
const choice = a.console[step].key;
|
||||
a.pending = step; a.error = null; a.confirm = null; paintDecide();
|
||||
const { res, json } = await decidePost(`/api/decisions/${enc(a.id)}/confirmation`, { choice });
|
||||
a.pending = null;
|
||||
// The page moved to another decision meanwhile: its confirmation is dropped, never shown.
|
||||
if (answer !== a) { if (res?.type === 'opaqueredirect') signOut(); return; }
|
||||
if (res?.type === 'opaqueredirect') { a.error = { code: 'sign-in-required', message: 'Sign in again before answering.' }; paintDecide('#decide-status'); signOut(); return; }
|
||||
if (res?.ok && json?.id === a.id && json.action === step && json.choice === choice && typeof json.confirmation === 'string' && json.grant && typeof json.grant.method === 'string') {
|
||||
a.confirm = json; paintDecide('[data-decide="confirm"]'); announce(`Confirm ${step}: ${json.text}`);
|
||||
} else { a.error = decideRefusal(res, json, 'confirmation-unavailable'); paintDecide('#decide-status'); announce(`Not answered: ${a.error.message}`); }
|
||||
return;
|
||||
}
|
||||
if (step !== 'confirm' || !a.confirm) return;
|
||||
const c = a.confirm;
|
||||
a.pending = 'confirm'; a.error = null; paintDecide();
|
||||
const { res, json } = await decidePost(`/api/decisions/${enc(a.id)}/resolve`, { choice: c.choice, confirmation: c.confirmation });
|
||||
// One use, whatever came back.
|
||||
a.pending = null; a.confirm = null;
|
||||
if (answer !== a) { if (res?.type === 'opaqueredirect') signOut(); else render(false); return; }
|
||||
if (res?.type === 'opaqueredirect') { a.error = { code: 'sign-in-required', message: 'Sign in again before answering. The sign-in proxy redirected the answer, so it never reached the Console.' }; paintDecide('#decide-status'); signOut(); return; }
|
||||
if (res?.ok && json?.id === a.id && json.op === 'resolved' && json.choice === c.choice) {
|
||||
a.result = { action: c.action, choice: c.choice, text: c.text, grant: json.grant ?? null };
|
||||
paintDecide('#decide-status'); announce(c.action === 'approve' ? 'Approved' : 'Denied'); render(false);
|
||||
} else { a.error = decideRefusal(res, json, 'outcome-unknown'); paintDecide('#decide-status'); announce(`Not answered: ${a.error.message}`); }
|
||||
}
|
||||
async function inboxView(get) {
|
||||
// The broker orders gated first, then oldest; lift blocking ones to the top.
|
||||
const rows = (await get('inbox')).slice().sort((a, b) => (b.blocking - a.blocking) || String(a.at).localeCompare(String(b.at)));
|
||||
countInbox(rows);
|
||||
const body = rows.length ? `<ul class="waiting s1-decs">${rows.map(decisionCard).join('')}</ul>` : empty('Nothing is waiting on you.', 'A decision shows here when an agent raises one routed to you through the bus.');
|
||||
const answered = canDecide ? 'A grant request can be approved or denied here, after a confirmation. Every other decision is answered in a terminal with <code>mosaic decide</code>.'
|
||||
: 'Decisions are answered in a terminal with <code>mosaic decide</code>; Console only shows them.';
|
||||
return `${head('Inbox', `${readNote()} ${answered}`)}
|
||||
return `${head('Inbox', `${readNote()} Decisions are answered in a terminal with <code>mosaic decide</code>; Console only shows them.`)}
|
||||
${await composer(get)}
|
||||
<h2 id="b-for-you">For you <span class="count">${rows.length}</span></h2>${body}
|
||||
<p class="small muted">Not shown here yet: ${gap('Decisions routed to roles')} ${gap('Closed decisions')} ${gap('Seen and DM state per row')} Open a decision for its seen, DM and closing history.</p>`;
|
||||
@@ -434,13 +343,6 @@
|
||||
// A command is offered only for an open decision routed to the human, and only when
|
||||
// the id and key pass the broker's identifier rule, so the copied text is safe to paste.
|
||||
const answerable = !c && d.routeTo === 'human';
|
||||
// Row 252: the answer panel follows this decision. A refresh keeps a confirmation open; a
|
||||
// decision no longer eligible (closed elsewhere, say) drops it, unless an answer is in flight
|
||||
// or this page just answered it.
|
||||
const eligible = canDecide && answerable && open?.console ? open.console : null;
|
||||
if (answer?.id !== d.id) answer = eligible ? { id: d.id, console: eligible, pending: null, confirm: null, error: null, result: null } : null;
|
||||
else if (eligible && !answer.pending && !answer.confirm) answer.console = eligible;
|
||||
else if (!eligible && !answer.pending && !answer.result) answer = null;
|
||||
const options = `<ol class="s1-opts">${d.options.map(o => {
|
||||
const cmd = `mosaic decide ${d.id} ${o.key}`, chosen = c?.choice === o.key, approving = d.authorization?.approvalChoice === o.key;
|
||||
const copy = !answerable ? '' : SAFE.test(String(d.id)) && SAFE.test(String(o.key))
|
||||
@@ -450,18 +352,11 @@
|
||||
}).join('')}</ol>`;
|
||||
const earlier = await chain(get, d);
|
||||
const history = [...d.seen.map(s => `<li>Seen by ${txt(s.by)}${s.via ? ` via ${txt(s.via)}` : ''} ${when(s.at)}</li>`), ...(c ? [`<li>${esc(c.op)} by ${txt(c.by)}${c.via ? ` via ${txt(c.via)}` : ''} ${when(c.at)}</li>`] : [])];
|
||||
// Without SSO, or under SSO without --send, the Console says why it has no buttons and never
|
||||
// draws a disabled one (design section 5).
|
||||
const why = !answerable || isTester || canDecide ? ''
|
||||
: ssoOn ? ' This Console is read-only for decisions: it was started without writes enabled (--send), so answers go through a terminal.'
|
||||
: ' Answering a grant request from the Console needs sign-in (--sso) and writes enabled (--send).';
|
||||
const help = eligible ? '<p class="small muted">Approve or Deny in the panel above, or run one of these in a terminal. Copy only puts the command on your clipboard.</p>'
|
||||
: answerable ? `<p class="small muted">To answer, run one of these in a terminal. Copy only puts the command on your clipboard. Console sends nothing.${why}</p>`
|
||||
const help = answerable ? '<p class="small muted">To answer, run one of these in a terminal. Copy only puts the command on your clipboard. Console sends nothing.</p>'
|
||||
: !c ? `<p class="small muted">This decision is routed to ${txt(d.routeTo)}, not to you, so Console offers no command for it.</p>` : '';
|
||||
return `${head(`Decision ${txt(short(d.id))}`, status)}
|
||||
<section aria-labelledby="b-q" class="panel"><h2 id="b-q">Question</h2><p class="s1-q">${txt(d.question)}</p>
|
||||
<dl class="kv"><dt>Class</dt><dd>${chip(d.class, d.action)}${d.blocking ? ` ${blockChip('blocking')}` : ''}</dd><dt>Approval</dt><dd class="s1-auth">${approves(d.authorization)}</dd><dt>Routed to</dt><dd>${txt(d.routeTo)}</dd><dt>Raised by</dt><dd>${txt(d.raisedBy.role)} <span class="source">${txt(d.raisedBy.run)}</span></dd><dt>Raised</dt><dd>${when(d.at)}</dd>${d.taskRef ? `<dt>Task</dt><dd>${taskLink(d.taskRef)} <span class="source">${txt(d.taskRef)}</span></dd>` : ''}${d.requirementRef ? `<dt>Requirement</dt><dd>${txt(d.requirementRef)}</dd>` : ''}<dt>DM</dt><dd>${d.dm ? `sent ${when(d.dm.at)}` : d.blocking ? 'not sent' : 'not needed (not blocking)'}</dd><dt>Id</dt><dd><code>${txt(d.id)}</code></dd></dl></section>
|
||||
${answer?.id === d.id ? decideHtml() : ''}
|
||||
<section aria-labelledby="b-o"><h2 id="b-o">Options</h2>${help}${options}<p class="small muted" id="copy-status"></p></section>
|
||||
<section aria-labelledby="b-c"><h2 id="b-c">Earlier versions</h2>${earlier.length ? `<ul class="s1-plain">${earlier.map(x => `<li><a href="#/inbox/${enc(x.id)}">${txt(short(x.id))}</a> ${badge(x.closed ? x.closed.op : 'open')} ${txt(first(x.question))}${x.closed?.note ? `<span class="source">${txt(x.closed.note)}</span>` : ''}</li>`).join('')}</ul>` : '<p class="muted">None.</p>'}<p class="small muted">${gap('A later version that replaces this one')}</p></section>
|
||||
<section aria-labelledby="b-h"><h2 id="b-h">History</h2>${history.length ? `<ul class="s1-plain">${history.join('')}</ul>` : '<p class="muted">Not seen yet.</p>'}<p><a href="#/trail/decision/${enc(d.id)}">Full trail for this decision</a></p></section>`;
|
||||
@@ -1203,7 +1098,6 @@
|
||||
return {
|
||||
href: view.contains(a) ? a.getAttribute('href') : null, copy: view.contains(a) ? a.dataset?.copy : null, retry: view.contains(a) && a.hasAttribute?.('data-retry'), mirror: view.contains(a) ? a.dataset?.mirror : null, h1: view.contains(a) && a.tagName === 'H1',
|
||||
field: view.contains(a) ? a.dataset?.field : null, caret: view.contains(a) && a.tagName === 'TEXTAREA' ? [a.selectionStart, a.selectionEnd, a.scrollTop] : null, send: view.contains(a) && a.hasAttribute?.('data-send'),
|
||||
decide: view.contains(a) ? a.dataset?.decide ?? (a.id === 'decide-status' ? 'status' : null) : null,
|
||||
open: [...view.querySelectorAll('details[id]')].filter(d => d.open).map(d => d.id), status: $('copy-status')?.textContent || '',
|
||||
};
|
||||
}
|
||||
@@ -1211,10 +1105,7 @@
|
||||
for (const id of k.open) { const d = $(id); if (d) d.open = true; }
|
||||
if ($('copy-status')) $('copy-status').textContent = k.status;
|
||||
const el = [...$('bus-view').querySelectorAll('a[href],[data-copy],[data-retry],[data-mirror],[data-field],[data-send]')].find(e => k.href ? e.getAttribute('href') === k.href : k.copy ? e.dataset.copy === k.copy : k.mirror ? e.dataset.mirror === k.mirror : k.field ? e.dataset.field === k.field : k.send ? e.hasAttribute('data-send') : k.retry && e.hasAttribute('data-retry'));
|
||||
// Row 252: the answer panel's buttons and its status line.
|
||||
const dec = k.decide ? $('bus-view').querySelector(k.decide === 'status' ? '#decide-status' : `[data-decide="${k.decide}"]`) : null;
|
||||
if (dec) dec.focus({ preventScroll: true });
|
||||
else if (el && (k.href || k.copy || k.retry || k.mirror || k.field || k.send)) {
|
||||
if (el && (k.href || k.copy || k.retry || k.mirror || k.field || k.send)) {
|
||||
el.focus({ preventScroll: true });
|
||||
if (k.caret && el.tagName === 'TEXTAREA') { el.setSelectionRange(k.caret[0], k.caret[1]); el.scrollTop = k.caret[2]; }
|
||||
}
|
||||
@@ -1306,9 +1197,6 @@
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Row 252: Approve, Deny, Confirm and Cancel. One click is one step; none repeats itself.
|
||||
const dec = e.target.closest('#decide-section [data-decide]');
|
||||
if (dec) { if (!dec.disabled) decide(dec.dataset.decide); return; }
|
||||
if (e.target.closest('#toast [data-dismiss]')) { toast(null); return; }
|
||||
const item = e.target.closest('#cmdk-list [data-href]');
|
||||
if (item) { pkGo(item.dataset.href); return; }
|
||||
@@ -1430,14 +1318,12 @@
|
||||
isTester = c?.tester === true;
|
||||
canEdit = !isTester && c?.edit === true;
|
||||
canSend = !isTester && c?.send === true;
|
||||
ssoOn = c?.sso === true;
|
||||
canDecide = !isTester && c?.decide === true;
|
||||
if (isTester) {
|
||||
const banner = document.createElement('div'); banner.id = 'tester-banner'; banner.className = 'banner ref';
|
||||
banner.setAttribute('role', 'status'); banner.textContent = 'Tester Console. Changes are recorded as tester, never as the human.';
|
||||
$('main').prepend(banner);
|
||||
}
|
||||
if (!canSend && !isTester && !ssoOn) return;
|
||||
if (!canSend && !isTester) return;
|
||||
const path = routeOf().split('?')[0];
|
||||
if (path.startsWith('/chat')||path.startsWith('/inbox')||path==='/settings'||path.startsWith('/docs/')) render(false); else if (path === '/' || path === '') paintHeld(heldErr);
|
||||
}).catch(() => {});
|
||||
|
||||
@@ -10,7 +10,6 @@ const busStatus = code => ['tester-off', 'tester-required'].includes(code) ? 403
|
||||
import { ReadError, readStatus, fieldTable } from './reads.mjs';
|
||||
import { chatHTTP } from './chat-http.mjs';
|
||||
import { processingHTTP } from './processing-http.mjs';
|
||||
import { DECISION_ROUTE, decisionHTTP, decisionRefuse, createConfirmations } from './decision-http.mjs';
|
||||
|
||||
export const DEFAULT_BOARD = 'http://127.0.0.1:7331';
|
||||
export function isLoopback(host) {
|
||||
@@ -365,6 +364,9 @@ 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',
|
||||
@@ -580,7 +582,7 @@ async function projectFileRead(res, read, path, search) {
|
||||
// `log` takes one line per send attempt that got past sign-in; never the body. `fieldsFile` is for
|
||||
// tests; the field table is otherwise the committed packages/tasks/fields.json.
|
||||
// `agentWrite` (row 194, D7) is `(verb, args) => Promise` for the Agents section's verbs and instance.request.
|
||||
export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null, preview = null, sso = null, tester = false, testerSocket = null, testerAuthHeader = null, proof = null, testerProofHeader = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, processing = null, decisionWrite = null, log = line => console.log(line), fieldsFile = undefined } = {}) {
|
||||
export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null, preview = null, sso = null, tester = false, testerSocket = null, testerAuthHeader = null, proof = null, testerProofHeader = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, processing = null, log = line => console.log(line), fieldsFile = undefined } = {}) {
|
||||
if (!isLoopback(host)) throw new Error('refusing to bind to non-loopback host');
|
||||
if (host === 'localhost') host = '127.0.0.1';
|
||||
if (tester && preview !== null) throw new Error('tester listener cannot use preview mode');
|
||||
@@ -591,9 +593,6 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
if (tester && [send, settingsWrite, projectSave, assistantStop].some(value => value !== null)) throw new Error('tester listener permits only instance, route, name and chat writes');
|
||||
if (tester && !testerSocket) throw new Error('tester listener requires private socket');
|
||||
if (!tester && testerSocket) throw new Error('tester socket requires tester listener');
|
||||
// Row 252 (#1753, design section 4): Console decision answers need --sso and --send, never the tester.
|
||||
if (decisionWrite !== null && (tester || sso === null || send === null || typeof decisionWrite !== 'function')) throw new Error('Console decision answers need --sso and --send on the main listener');
|
||||
const confirmations = createConfirmations();
|
||||
const upstream = boardURL(board);
|
||||
// An object with none of the three is refused too: asking for the preview never serves without it.
|
||||
if (preview !== null && !(preview = previewOptions({ publicHost: preview.host, authHeader: preview.header, allowUser: preview.user }))) {
|
||||
@@ -645,21 +644,18 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
// alone stays open on a loopback Host for the status and release gates (Q-C).
|
||||
if (sso !== null && !named && !(path === '/healthz' && req.method === 'GET')) return json(res, 403, { error: 'public Host required' });
|
||||
// No CORS. JSON content type and same-origin checks keep browser forms out.
|
||||
const decisionPath = DECISION_ROUTE.test(path);
|
||||
if (req.headers.origin && req.headers.origin !== (named ? (sso ? sso.publicOrigin : `https://${preview.host}`) : `http://${req.headers.host}`))
|
||||
return decisionPath ? decisionRefuse(res, 'cross-origin') : json(res, 403, { error: 'cross-origin request refused' });
|
||||
let signer = preview, ssoSession = null;
|
||||
if (req.headers.origin && req.headers.origin !== (named ? (sso ? sso.publicOrigin : `https://${preview.host}`) : `http://${req.headers.host}`)) return json(res, 403, { error: 'cross-origin request refused' });
|
||||
let signer = preview;
|
||||
if (sso !== null && named) {
|
||||
if (path.startsWith('/auth/')) return sso.handle(req, res, path, search);
|
||||
// Only a page load or a write keeps a session alive; the page's background reads don't (Q-D).
|
||||
const session = path === '/healthz' ? null : sso.session(req, { touch: req.method === 'POST' || (req.method === 'GET' && !path.startsWith('/api/')) });
|
||||
if (!session && path !== '/healthz') {
|
||||
if (decisionPath) return decisionRefuse(res, 'sign-in-required');
|
||||
if (path.startsWith('/api/') || req.method !== 'GET') return json(res, 401, { error: 'sign-in required' });
|
||||
res.writeHead(303, { location: `/auth/login?return=${encodeURIComponent(path + search)}`, 'cache-control': 'no-store' });
|
||||
return res.end();
|
||||
}
|
||||
if (session) { signer = { sso: true, user: session.name, host: sso.publicHost, origin: sso.publicOrigin }; ssoSession = session; }
|
||||
if (session) signer = { sso: true, user: session.name, host: sso.publicHost, origin: sso.publicOrigin };
|
||||
}
|
||||
if (named && preview !== null) {
|
||||
// One value, equal to the user id; the proxy strips client copies and sets it after sign-in.
|
||||
@@ -667,13 +663,6 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
if (!values || values.length !== 1 || values[0] !== preview.user) return json(res, 401, { error: 'sign-in required' });
|
||||
}
|
||||
const writer = tester ? { tester: true, user: 'tester', origin: TESTER_ORIGIN } : signer;
|
||||
// Row 252: Approve and Deny. The tester listener never offers them. Anywhere else without a signed-in
|
||||
// session on the public name under --sso and --send, they are read-only.
|
||||
if (decisionPath) {
|
||||
if (tester) return decisionRefuse(res, 'not-found');
|
||||
const decide = decisionWrite && send && named && sso ? { call: decisionWrite, origin: sso.publicOrigin } : null;
|
||||
return decisionHTTP(req, res, { path, decide, session: ssoSession, live: () => sso.session(req), confirmations, log });
|
||||
}
|
||||
// Row 194 (#1692, D4.1): the tester also launches and terminates.
|
||||
if (tester && !['GET', 'HEAD'].includes(req.method) && !['/api/instances', '/api/routes', '/api/name', '/api/agent/launch', '/api/agent/terminate'].includes(path) && !TESTER_CHAT_WRITE.test(path))
|
||||
return json(res, 403, { error: 'read-only', sent: false });
|
||||
@@ -777,7 +766,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') return json(res, 405, { error: 'method not allowed' });
|
||||
// `send`: the page shows the composer only where a send can pass (the public name, --send on).
|
||||
// `edit`: the editor shows Save only where a save can pass, the same test as the route's.
|
||||
if (path === '/api/config') return json(res, 200, { board: upstream, ...(tester ? { tester: true } : {}), ...(sso ? { sso: true } : {}), send: !tester && Boolean(send) && named, decide: !tester && Boolean(send) && Boolean(decisionWrite) && named && sso !== null, edit: !tester && Boolean(send) && Boolean(projectSave) && named });
|
||||
if (path === '/api/config') return json(res, 200, { board: upstream, ...(tester ? { tester: true } : {}), ...(sso ? { sso: true } : {}), send: !tester && Boolean(send) && named, edit: !tester && Boolean(send) && Boolean(projectSave) && named });
|
||||
if (path === '/healthz') return json(res, 200, { ok: true, ...(sso ? { sso: sso.health() } : {}) });
|
||||
if (path === '/favicon.ico') { res.writeHead(204); return res.end(); }
|
||||
const file = files.get(path);
|
||||
|
||||
@@ -220,3 +220,21 @@ 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/);
|
||||
});
|
||||
@@ -1,231 +0,0 @@
|
||||
// Row 252 (#1753, design section 5, J16 and J17): Approve and Deny on a decision page, in a browser.
|
||||
// A loopback page has no sign-in, so a script added before the page loads sets `sso` and `decide`
|
||||
// in /api/config from the page URL, and answers each POST /api/decisions/... from a queue, recording
|
||||
// what the page sent. Server-side checks are decision-http.test.mjs's and the bus side is
|
||||
// packages/bus/tests/console-decide.test.mjs's. The decisions come from an in-process broker
|
||||
// through its reader session. Refresh timers fire after 150 ms.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { Store } from '../../bus/src/store.mjs';
|
||||
import { Broker } from '../../bus/src/broker.mjs';
|
||||
import { views } from '../../bus/src/views.mjs';
|
||||
import { grantDecision } from '../../bus/tests/host-fixtures.mjs';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
import { browser } from './browser.mjs';
|
||||
import { fixture, close } from './fixture.mjs';
|
||||
|
||||
const HOSTILE = '<script>window.injected=1</script><img src=x onerror="window.injected=2">';
|
||||
const businesses = { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'pm', delivery: 'pm' }, roles: { chief: { authority: { withinRole: ['message.send'], crossRole: [] } }, pm: { authority: { withinRole: ['message.send'], crossRole: [] } } } } };
|
||||
|
||||
// Answers in order; `hold` waits for window.__release(), `reject` is a network failure, `redirect`
|
||||
// what fetch gives for the sign-in proxy's redirect under redirect: 'manual'.
|
||||
const STUB = `(() => {
|
||||
const real = window.fetch.bind(window);
|
||||
window.__posts = []; window.__answers = [];
|
||||
const timeout = window.setTimeout.bind(window);
|
||||
window.setTimeout = (f, ms, ...rest) => timeout(f, ms === 10000 ? 150 : ms, ...rest);
|
||||
const q = new URLSearchParams(location.search);
|
||||
const answer = (status, body) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
|
||||
window.fetch = async (url, init = {}) => {
|
||||
const u = String(url);
|
||||
if (u === '/api/config') {
|
||||
const c = await (await real(url, init)).json();
|
||||
return answer(200, { ...c, ...(q.has('sso') ? { sso: true } : {}), ...(q.has('send') ? { send: true } : {}), ...(q.has('decide') ? { send: true, decide: true } : {}), ...(q.has('tester') ? { tester: true } : {}) });
|
||||
}
|
||||
if (!u.startsWith('/api/decisions/')) return real(url, init);
|
||||
window.__posts.push({ url: u, method: init.method, headers: init.headers, body: init.body, cache: init.cache, redirect: init.redirect });
|
||||
const a = window.__answers.shift();
|
||||
if (!a) throw new TypeError('no answer queued');
|
||||
if (a.hold) await new Promise(r => { window.__release = r; });
|
||||
if (a.reject) throw new TypeError('Failed to fetch');
|
||||
if (a.redirect) return { type: 'opaqueredirect', ok: false, status: 0, json: async () => { throw new SyntaxError('no body'); } };
|
||||
return answer(a.status, a.body);
|
||||
};
|
||||
})();`;
|
||||
|
||||
async function setup() {
|
||||
const root = mkdtempSync(join(tmpdir(), 'webui-decide-browser-'));
|
||||
const store = new Store(root), b0 = new Broker({ store, businesses });
|
||||
const grant = { method: 'host-enroll', host: 'web1', address: '192.0.2.10', port: 22, user: 'jarvis', sudo: true, fingerprints: [] };
|
||||
const open = grantDecision(store, { grant, close: null });
|
||||
store.run('UPDATE decisions SET question=? WHERE id=?', `Enroll web1? ${HOSTILE}`, open);
|
||||
const deploy = grantDecision(store, { action: 'deploy', grant, close: null });
|
||||
const bare = grantDecision(store, { close: null });
|
||||
const reader = b0.bindReader({ business: 'demo' });
|
||||
const bus = views({ call: async (verb, args = {}) => structuredClone(b0.request(reader, { verb, args })) });
|
||||
const f = await fixture(), b = await browser();
|
||||
const web = await startServer({ port: 0, board: f.boardURL, bus }), base = `http://127.0.0.1:${web.address().port}/`;
|
||||
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: STUB });
|
||||
await b.viewport(1280);
|
||||
const wait = expr => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expr})return true;await new Promise(r=>setTimeout(r,50))}throw new Error('Condition timed out: '+${JSON.stringify(expr)})})()`);
|
||||
const text = sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.textContent ?? null`);
|
||||
const has = sel => b.evaluate(`!!document.querySelector(${JSON.stringify(sel)})`);
|
||||
const posts = () => b.evaluate('window.__posts');
|
||||
const queue = a => b.evaluate(`window.__answers.push(${JSON.stringify(a)}), true`);
|
||||
const click = step => b.evaluate(`document.querySelector('#decide-section [data-decide="${step}"]').click(), true`);
|
||||
const page = async (query, id) => {
|
||||
await b.navigate(`${base}?${query}#/inbox/${id}`);
|
||||
await wait('document.querySelector("#bus-view h1")?.textContent.startsWith("Decision") && !document.querySelector("#bus-view [aria-busy]")');
|
||||
// The config read redraws once it lands; a refresh tick after it settles the page.
|
||||
await new Promise(r => setTimeout(r, 400));
|
||||
};
|
||||
const settle = () => new Promise(r => setTimeout(r, 600));
|
||||
const done = async () => { await b.close(); await close(web); await f.close(); store.close(); rmSync(root, { recursive: true, force: true }); };
|
||||
return { b, store, base, open, deploy, bare, wait, text, has, posts, queue, click, page, settle, done };
|
||||
}
|
||||
|
||||
const CONFIRM = (s, action, choice, text) => ({ status: 200, body: { id: s.open, question: `Enroll web1? ${HOSTILE}`, action, choice, text, grant: { method: 'host-enroll', host: 'web1' }, confirmation: 'c'.repeat(64) } });
|
||||
|
||||
test('J16: no controls without sso and decide, read-only reasons, terminal lines kept, and only an eligible grant gets the panel', { timeout: 120000 }, async () => {
|
||||
const s = await setup();
|
||||
try {
|
||||
// Without SSO: no panel, no button, and the reason names both flags.
|
||||
await s.page('x', s.open);
|
||||
assert.equal(await s.has('#decide-section'), false);
|
||||
assert.equal(await s.has('[data-decide]'), false);
|
||||
assert.match(await s.text('#bus-view'), /needs sign-in \(--sso\) and writes enabled \(--send\)/);
|
||||
assert.equal(await s.b.evaluate('document.querySelectorAll("#bus-view [data-copy]").length'), 2);
|
||||
// SSO without --send: read-only, still the terminal lines.
|
||||
await s.page('sso', s.open);
|
||||
assert.equal(await s.has('#decide-section'), false);
|
||||
assert.match(await s.text('#bus-view'), /read-only for decisions: it was started without writes enabled \(--send\)/);
|
||||
// send alone, without decide, still draws nothing.
|
||||
await s.page('sso&send', s.open);
|
||||
assert.equal(await s.has('#decide-section'), false);
|
||||
// A tester Console never answers, whatever the config says.
|
||||
await s.page('sso&decide&tester', s.open);
|
||||
assert.equal(await s.has('#decide-section'), false);
|
||||
// Eligible: the panel, both buttons, the escaped question and the terminal lines too.
|
||||
await s.page('sso&decide', s.open);
|
||||
assert.equal(await s.text('#decide-section h2'), 'Answer in Console');
|
||||
assert.deepEqual(await s.b.evaluate('[...document.querySelectorAll("#decide-section [data-decide]")].map(e => [e.dataset.decide, e.textContent, e.disabled])'), [['approve', 'Approve', false], ['deny', 'Deny', false]]);
|
||||
assert.match(await s.text('#decide-section'), /Grant: method host-enroll · host web1\./);
|
||||
assert.equal(await s.b.evaluate('document.querySelectorAll("#bus-view [data-copy]").length'), 2);
|
||||
assert.equal(await s.b.evaluate('window.injected ?? null'), null);
|
||||
assert.ok((await s.text('#bus-view .s1-q')).includes(HOSTILE));
|
||||
// A grant raise with another action, and a credential.mint with no grant, keep the terminal only.
|
||||
for (const id of [s.deploy, s.bare]) {
|
||||
await s.page('sso&decide', id);
|
||||
assert.equal(await s.has('#decide-section'), false);
|
||||
assert.equal(await s.has('[data-decide]'), false);
|
||||
}
|
||||
// The Inbox says so, and marks only the eligible row.
|
||||
await s.b.navigate(`${s.base}?sso&decide#/inbox`);
|
||||
await s.wait('document.querySelector("#bus-view h1")?.textContent.startsWith("Inbox") && document.querySelectorAll("#bus-view .s1-decs > li").length === 3');
|
||||
await s.settle();
|
||||
assert.match(await s.text('#bus-view'), /A grant request can be approved or denied here, after a confirmation\./);
|
||||
assert.equal(await s.b.evaluate('[...document.querySelectorAll("#bus-view .s1-decs > li")].filter(li => li.textContent.includes("Approve or Deny here")).length'), 1);
|
||||
assert.deepEqual(await s.posts(), []);
|
||||
} finally { await s.done(); }
|
||||
});
|
||||
|
||||
test('J17: one POST per click, confirm then answer, cancel, refusals and an unknown outcome never retry', { timeout: 120000 }, async () => {
|
||||
const s = await setup();
|
||||
const enc = encodeURIComponent;
|
||||
try {
|
||||
await s.page('sso&decide', s.open);
|
||||
|
||||
// Approve asks for a confirmation and answers nothing. The button holds while the request is out.
|
||||
await s.queue({ ...CONFIRM(s, 'approve', 'yes', 'Yes'), hold: true });
|
||||
await s.click('approve');
|
||||
await s.wait('document.querySelector("#decide-section [data-decide=approve]")?.textContent === "Checking…"');
|
||||
assert.equal(await s.b.evaluate('[...document.querySelectorAll("#decide-section [data-decide]")].every(e => e.disabled)'), true);
|
||||
await s.click('approve');
|
||||
await s.b.evaluate('window.__release(), true');
|
||||
await s.wait('document.querySelector("#decide-section [data-decide=confirm]")');
|
||||
let sent = await s.posts();
|
||||
assert.equal(sent.length, 1);
|
||||
assert.deepEqual(sent[0], { url: `/api/decisions/${enc(s.open)}/confirmation`, method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ choice: 'yes' }), cache: 'no-store', redirect: 'manual' });
|
||||
assert.equal(await s.text('#decide-section [data-decide=confirm]'), 'Confirm approve');
|
||||
assert.match(await s.text('#decide-section .s1-confirm'), /Approving starts this grant\./);
|
||||
assert.equal(await s.b.evaluate('document.activeElement?.dataset.decide ?? null'), 'confirm');
|
||||
assert.equal(await s.b.evaluate('window.injected ?? null'), null);
|
||||
|
||||
// A refresh keeps the confirmation open.
|
||||
await s.settle();
|
||||
assert.equal(await s.has('#decide-section [data-decide=confirm]'), true);
|
||||
|
||||
// Cancel drops it without a request.
|
||||
await s.click('cancel');
|
||||
await s.wait('document.querySelector("#decide-section [data-decide=approve]")');
|
||||
assert.equal((await s.posts()).length, 1);
|
||||
|
||||
// A refusal the Console wrote shows its code and message; a 408 or a lost answer is unavailable.
|
||||
await s.queue({ status: 409, body: { error: 'decision-stale', message: 'The choices changed. Review the decision again.' } });
|
||||
await s.click('deny');
|
||||
await s.wait('document.querySelector("#decide-status")');
|
||||
assert.equal(await s.text('#decide-status'), 'Not answered here: The choices changed. Review the decision again. (decision-stale).');
|
||||
await s.queue({ status: 408, body: { error: 'x', message: 'y' } });
|
||||
await s.click('deny');
|
||||
await s.wait('document.querySelector("#decide-status code")?.textContent === "confirmation-unavailable"');
|
||||
await s.queue({ reject: true });
|
||||
await s.click('deny');
|
||||
await s.wait('document.querySelectorAll("#decide-section [data-decide]:not([disabled])").length === 2');
|
||||
assert.equal(await s.b.evaluate('document.querySelector("#decide-status code")?.textContent'), 'confirmation-unavailable');
|
||||
assert.equal((await s.posts()).length, 4);
|
||||
|
||||
// A reply that doesn't match what was asked is not a confirmation.
|
||||
await s.queue({ status: 200, body: { ...CONFIRM(s, 'approve', 'yes', 'Yes').body } });
|
||||
await s.click('deny');
|
||||
await s.wait('document.querySelector("#decide-status code")?.textContent === "confirmation-unavailable" && document.querySelectorAll("#decide-section [data-decide]:not([disabled])").length === 2');
|
||||
assert.equal(await s.has('#decide-section [data-decide=confirm]'), false);
|
||||
|
||||
// An unknown outcome at the answer says to refresh, drops the confirmation and sends nothing again.
|
||||
await s.queue(CONFIRM(s, 'deny', 'no', 'No'));
|
||||
await s.click('deny');
|
||||
await s.wait('document.querySelector("#decide-section [data-decide=confirm]")');
|
||||
assert.match(await s.text('#decide-section .s1-confirm'), /Denying starts nothing\./);
|
||||
await s.queue({ status: 503, body: { error: 'outcome-unknown', message: 'The answer may have reached the bus. Refresh before trying again.' } });
|
||||
await s.click('confirm');
|
||||
await s.wait('document.querySelector("#decide-status code")?.textContent === "outcome-unknown"');
|
||||
assert.match(await s.text('#decide-status'), /Nothing was sent again\.$/);
|
||||
sent = await s.posts();
|
||||
assert.equal(sent.length, 7);
|
||||
assert.deepEqual(sent[6], { url: `/api/decisions/${enc(s.open)}/resolve`, method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ choice: 'no', confirmation: 'c'.repeat(64) }), cache: 'no-store', redirect: 'manual' });
|
||||
assert.equal(await s.has('#decide-section [data-decide=confirm]'), false);
|
||||
await s.settle();
|
||||
await s.settle();
|
||||
assert.equal((await s.posts()).length, 7, 'the page sent again by itself');
|
||||
|
||||
// A confirmed approve: one answer, a receipt, and no buttons after it, across refreshes.
|
||||
await s.queue(CONFIRM(s, 'approve', 'yes', 'Yes'));
|
||||
await s.click('approve');
|
||||
await s.wait('document.querySelector("#decide-section [data-decide=confirm]")');
|
||||
await s.queue({ status: 200, body: { id: s.open, op: 'resolved', choice: 'yes', grant: 'started' } });
|
||||
await s.click('confirm');
|
||||
await s.wait('document.querySelector("#decide-status")?.textContent.startsWith("Approved")');
|
||||
assert.equal(await s.text('#decide-status'), 'Approved: you chose “yes”, Yes. The grant executor started. History below shows it once the bus is read again.');
|
||||
await s.settle();
|
||||
assert.equal(await s.has('[data-decide]'), false);
|
||||
assert.equal((await s.posts()).length, 9);
|
||||
|
||||
// Closed elsewhere: the next read drops the panel and shows the recorded answer with its provenance.
|
||||
s.store.run('INSERT INTO decision_events(decision,at,op,by,choice,via) VALUES(?,?,?,?,?,?)', s.open, '2026-10-22T00:00:00.000Z', 'resolved', 'jason', 'yes', 'console');
|
||||
await s.page('sso&decide', s.open);
|
||||
assert.equal(await s.has('#decide-section'), false);
|
||||
assert.match(await s.text('#bus-view'), /Resolved: jason chose “yes” via console/);
|
||||
assert.equal(await s.b.evaluate('document.querySelectorAll("#bus-view [data-copy]").length'), 0);
|
||||
} finally { await s.done(); }
|
||||
});
|
||||
|
||||
test('a sign-in redirect at either step signs the page out and answers nothing', { timeout: 120000 }, async () => {
|
||||
const s = await setup();
|
||||
try {
|
||||
await s.page('sso&decide', s.open);
|
||||
await s.queue({ redirect: true });
|
||||
await s.click('approve');
|
||||
await s.wait('document.body.classList.contains("signed-out")');
|
||||
assert.equal((await s.posts()).length, 1);
|
||||
await s.page('sso&decide', s.open);
|
||||
await s.queue(CONFIRM(s, 'approve', 'yes', 'Yes'));
|
||||
await s.click('approve');
|
||||
await s.wait('document.querySelector("#decide-section [data-decide=confirm]")');
|
||||
await s.queue({ redirect: true });
|
||||
await s.click('confirm');
|
||||
await s.wait('document.body.classList.contains("signed-out")');
|
||||
assert.equal((await s.posts()).length, 2);
|
||||
} finally { await s.done(); }
|
||||
});
|
||||
@@ -1,154 +0,0 @@
|
||||
// Row 252 (#1753, design sections 4 and 5): the Console decision routes on the human listener, signed
|
||||
// in through the fake OP in helpers/fake-oidc.mjs on loopback. The bus transport is a recording stub,
|
||||
// so this covers the listener's gates and the session it passes; decision-http.test.mjs covers the
|
||||
// endpoint's own table and console-decide.test.mjs the bus. The public name is console.example.test,
|
||||
// sent only as a Host header to the test's own listener. sso.test.mjs pins its own log lines, so the
|
||||
// decide lines are checked here.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { request } from 'node:http';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
import { createSso, SESSION_COOKIE } from '../src/sso.mjs';
|
||||
import { DECISION_REFUSALS } from '../src/decision-http.mjs';
|
||||
import { consoleCall } from '../src/bus.mjs';
|
||||
import { close } from './fixture.mjs';
|
||||
import { fakeOidc } from './helpers/fake-oidc.mjs';
|
||||
|
||||
const HOST = 'console.example.test', ORIGIN = `https://${HOST}`;
|
||||
const DIGEST = 'b'.repeat(64);
|
||||
const PREVIEW = { id: 'd1', business: 'demo', question: 'Enroll web1?', digest: DIGEST, approve: { key: 'yes', text: 'Yes' }, deny: { key: 'no', text: 'No' }, grant: { method: 'host-enroll', host: 'web1' } };
|
||||
|
||||
// node:http, not fetch: fetch can't set Host, Origin or Sec-Fetch-Site.
|
||||
function call(port, { method = 'GET', path = '/', headers = {}, body, host = HOST } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const length = body === undefined ? {} : { 'content-length': Buffer.byteLength(body) };
|
||||
const req = request({ host: '127.0.0.1', port, method, path, headers: { host, ...length, ...headers } }, res => {
|
||||
let text = '';
|
||||
res.on('data', c => (text += c));
|
||||
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: text && (res.headers['content-type'] ?? '').startsWith('application/json') ? JSON.parse(text) : text }));
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.end(body);
|
||||
});
|
||||
}
|
||||
const writeHeaders = cookie => ({ ...(cookie ? { cookie } : {}), origin: ORIGIN, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' });
|
||||
|
||||
// The fake OP, createSso over it, and the human listener. `server` overrides startServer options.
|
||||
// The decision transport records each call and answers a preview or a resolve.
|
||||
async function setup(t, server = {}) {
|
||||
const op = await fakeOidc();
|
||||
t.after(() => op.close());
|
||||
const iss = op.issuer('a');
|
||||
const config = { publicOrigin: ORIGIN, providers: [{ id: 'a', issuer: iss.issuer, clientId: iss.clientId, clientSecret: { slot: 'a' }, algs: ['RS256'], subjects: [{ sub: 'user-a', name: 'person-a' }] }] };
|
||||
const lines = [];
|
||||
const sso = await createSso(config, { secrets: new Map([['a', iss.clientSecret]]), fetch: op.fetch, log: l => lines.push(l) });
|
||||
t.after(() => sso.close());
|
||||
const calls = [];
|
||||
const decisionWrite = async (verb, args, ctx) => {
|
||||
calls.push({ verb, args, ctx });
|
||||
return verb === 'decision.preview.console' ? { ...PREVIEW } : { id: args.id, op: 'resolved', choice: args.choice };
|
||||
};
|
||||
const bus = Object.fromEntries(['inbox', 'tasks', 'agents', 'trail', 'decisions'].map(v => [v, async () => []]));
|
||||
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', bus, sso, send: async () => ({ id: 'msg-0001' }), decisionWrite, log: l => lines.push(l), ...server });
|
||||
t.after(() => close(web));
|
||||
const port = web.address().port;
|
||||
const signIn = async () => {
|
||||
const start = await call(port, { path: '/auth/login' });
|
||||
assert.equal(start.status, 303);
|
||||
const cookie = [start.headers['set-cookie']].flat()[0].split(';')[0];
|
||||
const g = op.grant(iss, start.headers.location);
|
||||
const q = new URLSearchParams({ code: g.code, state: new URL(start.headers.location).searchParams.get('state') });
|
||||
const r = await call(port, { path: `/auth/callback?${q}`, headers: { cookie } });
|
||||
return [r.headers['set-cookie'] ?? []].flat().find(c => c.startsWith(`${SESSION_COOKIE}=`) && !c.includes('Max-Age=0')).split(';')[0];
|
||||
};
|
||||
const post = (cookie, path, body, headers = {}) => call(port, { method: 'POST', path, headers: { ...writeHeaders(cookie), ...headers }, body: JSON.stringify(body) });
|
||||
return { port, iss, calls, lines, signIn, post };
|
||||
}
|
||||
const refused = (r, code) => {
|
||||
assert.equal(r.status, DECISION_REFUSALS[code][0], `${code}: ${r.status} ${JSON.stringify(r.body)}`);
|
||||
assert.deepEqual(r.body, { error: code, message: DECISION_REFUSALS[code][1] });
|
||||
};
|
||||
|
||||
test('a signed-in owner confirms and answers, with the session\'s provider, issuer and sub as the subject', async t => {
|
||||
const env = await setup(t);
|
||||
const session = await env.signIn();
|
||||
const config = await call(env.port, { path: '/api/config', headers: { cookie: session } });
|
||||
assert.equal(config.body.decide, true);
|
||||
assert.equal(config.body.sso, true);
|
||||
const c = await env.post(session, '/api/decisions/d1/confirmation', { choice: 'yes' });
|
||||
assert.equal(c.status, 200, JSON.stringify(c.body));
|
||||
const r = await env.post(session, '/api/decisions/d1/resolve', { choice: 'yes', confirmation: c.body.confirmation });
|
||||
assert.equal(r.status, 200, JSON.stringify(r.body));
|
||||
assert.deepEqual(r.body, { id: 'd1', op: 'resolved', choice: 'yes' });
|
||||
const subject = { provider: 'a', iss: env.iss.issuer, sub: 'user-a' };
|
||||
assert.deepEqual(env.calls, [
|
||||
{ verb: 'decision.preview.console', args: { id: 'd1' }, ctx: { subject } },
|
||||
{ verb: 'decision.resolve.console', args: { id: 'd1', choice: 'yes', digest: DIGEST }, ctx: { subject, business: 'demo' } },
|
||||
]);
|
||||
assert.match(env.lines.at(-1), / decide user="person-a" decision="d1" step=resolve choice="yes"$/);
|
||||
assert.equal(env.lines.some(l => l.includes(c.body.confirmation) || l.includes(session.split('=')[1])), false);
|
||||
});
|
||||
|
||||
test('the listener gates: no session, a cross-origin page, a loopback Host, a GET, and a sign-out between the steps', async t => {
|
||||
const env = await setup(t);
|
||||
refused(await env.post(null, '/api/decisions/d1/confirmation', { choice: 'yes' }), 'sign-in-required');
|
||||
const session = await env.signIn();
|
||||
refused(await env.post(session, '/api/decisions/d1/confirmation', { choice: 'yes' }, { origin: 'https://evil.test' }), 'cross-origin');
|
||||
refused(await env.post(session, '/api/decisions/d1/confirmation', { choice: 'yes' }, { 'sec-fetch-site': 'cross-site' }), 'cross-origin');
|
||||
const loop = await call(env.port, { method: 'POST', path: '/api/decisions/d1/confirmation', host: `127.0.0.1:${env.port}`, headers: { cookie: session, 'content-type': 'application/json' }, body: '{"choice":"yes"}' });
|
||||
assert.equal(loop.status, 403);
|
||||
refused(await call(env.port, { path: '/api/decisions/d1/confirmation', headers: { cookie: session } }), 'method-not-allowed');
|
||||
assert.equal(env.calls.length, 0);
|
||||
const c = await env.post(session, '/api/decisions/d1/confirmation', { choice: 'yes' });
|
||||
assert.equal(c.status, 200);
|
||||
const out = await call(env.port, { method: 'POST', path: '/auth/logout', headers: { cookie: session, origin: ORIGIN, 'sec-fetch-site': 'same-origin' } });
|
||||
assert.equal(out.status, 303);
|
||||
refused(await env.post(session, '/api/decisions/d1/resolve', { choice: 'yes', confirmation: c.body.confirmation }), 'sign-in-required');
|
||||
assert.equal(env.calls.length, 1);
|
||||
// A new session can't use the old session's confirmation.
|
||||
const again = await env.signIn();
|
||||
refused(await env.post(again, '/api/decisions/d1/resolve', { choice: 'yes', confirmation: c.body.confirmation }), 'confirmation-required');
|
||||
assert.equal(env.calls.length, 1);
|
||||
});
|
||||
|
||||
test('J16: without --send or a decision transport the routes are read-only and /api/config says no; startServer refuses decide without sso or send', async t => {
|
||||
for (const server of [{ send: null }, { decisionWrite: null }]) {
|
||||
const env = await setup(t, server);
|
||||
const session = await env.signIn();
|
||||
const config = await call(env.port, { path: '/api/config', headers: { cookie: session } });
|
||||
assert.equal(config.body.decide, false);
|
||||
assert.equal(config.body.sso, true);
|
||||
refused(await env.post(session, '/api/decisions/d1/confirmation', { choice: 'yes' }), 'read-only');
|
||||
assert.equal(env.calls.length, 0);
|
||||
}
|
||||
const write = async () => ({});
|
||||
await assert.rejects(startServer({ port: 0, decisionWrite: write, send: async () => ({}) }), /need --sso and --send/);
|
||||
await assert.rejects(startServer({ port: 0, tester: true, testerSocket: {}, decisionWrite: write }), /need --sso and --send/);
|
||||
const plain = await startServer({ port: 0, board: 'http://127.0.0.1:9' });
|
||||
t.after(() => close(plain));
|
||||
const port = plain.address().port, host = `127.0.0.1:${port}`;
|
||||
const r = await call(port, { method: 'POST', path: '/api/decisions/d1/confirmation', host, headers: { origin: `http://${host}`, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' }, body: '{"choice":"yes"}' });
|
||||
refused(r, 'read-only');
|
||||
const config = await call(port, { path: '/api/config', host });
|
||||
assert.equal(config.body.decide, false);
|
||||
assert.equal('sso' in config.body, false);
|
||||
});
|
||||
|
||||
test('N4: decision.resolve has no Console route, the --send caller names no decision verb, and consoleCall takes only the two console verbs', async t => {
|
||||
const env = await setup(t);
|
||||
const session = await env.signIn();
|
||||
for (const verb of ['decision.resolve', 'decision.resolve.console', 'decision.preview.console']) {
|
||||
for (const method of ['GET', 'POST']) {
|
||||
const r = await call(env.port, { method, path: `/api/bus/${verb}`, headers: writeHeaders(session), ...(method === 'POST' ? { body: '{}' } : {}) });
|
||||
assert.equal(r.status, 404, `${method} ${verb}`);
|
||||
}
|
||||
}
|
||||
assert.equal(env.calls.length, 0);
|
||||
const cli = readFileSync(new URL('../src/cli.mjs', import.meta.url), 'utf8');
|
||||
assert.doesNotMatch(cli, /['"]decision\./);
|
||||
const c = consoleCall({ dataRoot: '/nonexistent', socket: '/nonexistent', business: 'demo', cli: '/nonexistent/console-cli.mjs', timeoutMs: 1000 });
|
||||
for (const verb of ['decision.resolve', 'decision.seen', 'message.send', 'inbox']) {
|
||||
await assert.rejects(c(verb, { id: 'd1', choice: 'yes' }, { subject: { provider: 'a', iss: 'i', sub: 's' } }), /the Console does not call/);
|
||||
}
|
||||
});
|
||||
@@ -1,205 +0,0 @@
|
||||
// Row 252 (#1753): the Console decision endpoint, driven with a fake request, response and bus
|
||||
// transport. The real bus side is in packages/bus/tests/console-decide.test.mjs and the page side
|
||||
// in the browser tests.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { BusReadError } from '../src/bus.mjs';
|
||||
import { DECISION_REFUSALS, createConfirmations, decisionHTTP } from '../src/decision-http.mjs';
|
||||
|
||||
const ORIGIN = 'https://console.test';
|
||||
const DIGEST = 'a'.repeat(64);
|
||||
const PREVIEW = {
|
||||
id: 'd1', business: 'demo', question: 'Enroll host?', digest: DIGEST,
|
||||
approve: { key: 'yes', text: 'Yes, enroll it' }, deny: { key: 'no', text: 'No' },
|
||||
grant: { method: 'host-enroll', host: 'h1' },
|
||||
};
|
||||
|
||||
function request({ method = 'POST', body = {}, headers = {}, raw } = {}) {
|
||||
const h = { origin: [ORIGIN], 'sec-fetch-site': ['same-origin'], 'content-type': ['application/json'], ...headers };
|
||||
for (const k of Object.keys(h)) if (h[k] === null) delete h[k];
|
||||
const data = Buffer.from(raw ?? JSON.stringify(body));
|
||||
return { method, headersDistinct: h, async *[Symbol.asyncIterator]() { yield data; } };
|
||||
}
|
||||
function response() {
|
||||
const r = { status: null, headers: null, text: '' };
|
||||
r.writeHead = (s, h) => { r.status = s; r.headers = h; };
|
||||
r.end = (t = '') => { r.text += t; };
|
||||
r.json = () => JSON.parse(r.text);
|
||||
return r;
|
||||
}
|
||||
function setup({ answer = () => ({ ...PREVIEW }), resolve = (a) => ({ id: a.id, op: 'resolved', choice: a.choice, grant: 'started' }), now } = {}) {
|
||||
const session = { name: 'jason', provider: 'fake-oidc', iss: 'https://idp.test', sub: 'owner-1' };
|
||||
const calls = [], lines = [];
|
||||
const s = {
|
||||
session, calls, lines, current: session,
|
||||
confirmations: createConfirmations(now ? { now } : {}),
|
||||
decide: {
|
||||
origin: ORIGIN,
|
||||
call: async (verb, args, ctx) => {
|
||||
calls.push({ verb, args, ctx });
|
||||
return verb === 'decision.preview.console' ? answer(args) : resolve(args);
|
||||
},
|
||||
},
|
||||
};
|
||||
s.run = async (path, req) => {
|
||||
const res = response();
|
||||
await decisionHTTP(req, res, { path, decide: s.decide, session: s.session, live: () => s.current, confirmations: s.confirmations, log: (l) => lines.push(l) });
|
||||
return res;
|
||||
};
|
||||
s.confirm = (choice = 'yes', id = 'd1') => s.run(`/api/decisions/${id}/confirmation`, request({ body: { choice } }));
|
||||
s.resolve = (body, id = 'd1') => s.run(`/api/decisions/${id}/resolve`, request({ body }));
|
||||
return s;
|
||||
}
|
||||
const refused = (res, code) => {
|
||||
assert.equal(res.status, DECISION_REFUSALS[code][0], `${code}: ${res.text}`);
|
||||
assert.deepEqual(res.json(), { error: code, message: DECISION_REFUSALS[code][1] });
|
||||
assert.equal(res.headers['cache-control'], 'no-store');
|
||||
};
|
||||
|
||||
test('a confirmation then a resolve answers once with the held choice and digest, and a second resolve refuses', async () => {
|
||||
const s = setup();
|
||||
const c = await s.confirm('yes');
|
||||
assert.equal(c.status, 200);
|
||||
const body = c.json();
|
||||
assert.match(body.confirmation, /^[a-f0-9]{64}$/);
|
||||
assert.deepEqual({ ...body, confirmation: 'x' }, { id: 'd1', question: 'Enroll host?', action: 'approve', choice: 'yes', text: 'Yes, enroll it', grant: PREVIEW.grant, confirmation: 'x' });
|
||||
assert.deepEqual(s.calls[0], { verb: 'decision.preview.console', args: { id: 'd1' }, ctx: { subject: { provider: 'fake-oidc', iss: 'https://idp.test', sub: 'owner-1' } } });
|
||||
const r = await s.resolve({ choice: 'yes', confirmation: body.confirmation });
|
||||
assert.equal(r.status, 200);
|
||||
assert.deepEqual(r.json(), { id: 'd1', op: 'resolved', choice: 'yes', grant: 'started' });
|
||||
assert.deepEqual(s.calls[1], { verb: 'decision.resolve.console', args: { id: 'd1', choice: 'yes', digest: DIGEST }, ctx: { subject: { provider: 'fake-oidc', iss: 'https://idp.test', sub: 'owner-1' }, business: 'demo' } });
|
||||
// J12: the confirmation is spent, so a repeat never reaches the bus.
|
||||
refused(await s.resolve({ choice: 'yes', confirmation: body.confirmation }), 'confirmation-required');
|
||||
assert.equal(s.calls.length, 2);
|
||||
assert.equal(s.lines.length, 3);
|
||||
assert.match(s.lines[0], / decide user="jason" decision="d1" step=confirmation action=approve$/);
|
||||
assert.match(s.lines[1], / step=resolve choice="yes" grant=started$/);
|
||||
assert.doesNotMatch(s.lines.join('\n'), new RegExp(body.confirmation));
|
||||
});
|
||||
|
||||
test('deny resolves without a grant field', async () => {
|
||||
const s = setup({ resolve: (a) => ({ id: a.id, op: 'resolved', choice: a.choice }) });
|
||||
const c = (await s.confirm('no')).json();
|
||||
assert.equal(c.action, 'deny');
|
||||
assert.equal(c.text, 'No');
|
||||
const r = await s.resolve({ choice: 'no', confirmation: c.confirmation });
|
||||
assert.deepEqual(r.json(), { id: 'd1', op: 'resolved', choice: 'no' });
|
||||
});
|
||||
|
||||
test('the gates before the body: method, read-only, sign-in and same origin', async () => {
|
||||
const s = setup();
|
||||
refused(await s.run('/api/decisions/d1/confirmation', request({ method: 'GET' })), 'method-not-allowed');
|
||||
const res = response();
|
||||
await decisionHTTP(request({ body: { choice: 'yes' } }), res, { path: '/api/decisions/d1/confirmation', decide: null, session: s.session, live: () => s.session, confirmations: s.confirmations, log: () => {} });
|
||||
refused(res, 'read-only');
|
||||
const out = response();
|
||||
await decisionHTTP(request({ body: { choice: 'yes' } }), out, { path: '/api/decisions/d1/confirmation', decide: s.decide, session: null, live: () => null, confirmations: s.confirmations, log: () => {} });
|
||||
refused(out, 'sign-in-required');
|
||||
for (const headers of [{ origin: ['https://evil.test'] }, { origin: null }, { origin: [ORIGIN, ORIGIN] }, { 'sec-fetch-site': ['same-site'] }, { 'sec-fetch-site': null }]) {
|
||||
refused(await s.run('/api/decisions/d1/confirmation', request({ body: { choice: 'yes' }, headers })), 'cross-origin');
|
||||
}
|
||||
assert.equal(s.calls.length, 0);
|
||||
});
|
||||
|
||||
test('a bad body, id or content type refuses before the bus', async () => {
|
||||
const s = setup();
|
||||
const path = '/api/decisions/d1/confirmation';
|
||||
refused(await s.run(path, request({ body: { choice: 'yes' }, headers: { 'content-type': ['text/plain'] } })), 'invalid-request');
|
||||
refused(await s.run(path, request({ raw: '{' })), 'invalid-request');
|
||||
refused(await s.run(path, request({ raw: '[]' })), 'invalid-request');
|
||||
refused(await s.run(path, request({ body: { choice: 'yes', extra: 1 } })), 'invalid-request');
|
||||
refused(await s.run(path, request({ body: { choice: 1 } })), 'invalid-request');
|
||||
refused(await s.run(path, request({ raw: JSON.stringify({ choice: 'y'.repeat(5000) }) })), 'request-too-large');
|
||||
refused(await s.run('/api/decisions/..%2Fx/confirmation', request({ body: { choice: 'yes' } })), 'decision-not-found');
|
||||
refused(await s.run('/api/decisions/%E0%A4%A/confirmation', request({ body: { choice: 'yes' } })), 'decision-not-found');
|
||||
refused(await s.resolve({ choice: 'yes' }), 'invalid-request');
|
||||
refused(await s.resolve({ choice: 'yes', confirmation: 'a'.repeat(64), more: 1 }), 'invalid-request');
|
||||
assert.equal(s.calls.length, 0);
|
||||
});
|
||||
|
||||
test('a choice that is neither key is decision-stale, and a malformed preview is confirmation-unavailable', async () => {
|
||||
refused(await setup().confirm('maybe'), 'decision-stale');
|
||||
for (const bad of [{ ...PREVIEW, id: 'd2' }, { ...PREVIEW, digest: 'short' }, { ...PREVIEW, deny: { key: 'yes', text: 'No' } }, { ...PREVIEW, grant: null }, null]) {
|
||||
const s = setup({ answer: () => bad });
|
||||
refused(await s.confirm('yes'), 'confirmation-unavailable');
|
||||
assert.equal(s.confirmations.size, 0);
|
||||
}
|
||||
});
|
||||
|
||||
test('bus refusals map by code, and an unknown or missing code is unavailable at preview and outcome-unknown at resolve', async () => {
|
||||
const cases = [
|
||||
['human-required', 'human-required'], ['agent-required', 'human-required'], ['access-revoked', 'human-required'],
|
||||
['peercred-required', 'peercred-required'], ['decision-not-eligible', 'decision-not-eligible'],
|
||||
['decision-closed', 'decision-closed'], ['decision-stale', 'decision-stale'], ['invalid-choice', 'decision-stale'],
|
||||
['decision-not-found', 'decision-not-found'], ['outcome-unknown', 'confirmation-unavailable'], ['weird', 'confirmation-unavailable'],
|
||||
];
|
||||
for (const [bus, code] of cases) {
|
||||
const s = setup({ answer: () => { throw new BusReadError(bus); } });
|
||||
refused(await s.confirm('yes'), code);
|
||||
}
|
||||
refused(await setup({ answer: () => { throw new Error('boom'); } }).confirm('yes'), 'confirmation-unavailable');
|
||||
for (const [bus, code] of [['decision-closed', 'decision-closed'], ['outcome-unknown', 'outcome-unknown'], ['weird', 'outcome-unknown']]) {
|
||||
const s = setup({ resolve: () => { throw new BusReadError(bus); } });
|
||||
const c = (await s.confirm('yes')).json();
|
||||
refused(await s.resolve({ choice: 'yes', confirmation: c.confirmation }), code);
|
||||
// Nothing retries, and the confirmation stays spent.
|
||||
assert.equal(s.calls.length, 2);
|
||||
refused(await s.resolve({ choice: 'yes', confirmation: c.confirmation }), 'confirmation-required');
|
||||
}
|
||||
for (const reply of [null, { id: 'd2', op: 'resolved', choice: 'yes' }, { id: 'd1', op: 'refused', choice: 'yes' }, { id: 'd1', op: 'resolved', choice: 'no' }]) {
|
||||
const s = setup({ resolve: () => reply });
|
||||
const c = (await s.confirm('yes')).json();
|
||||
refused(await s.resolve({ choice: 'yes', confirmation: c.confirmation }), 'outcome-unknown');
|
||||
}
|
||||
});
|
||||
|
||||
test('a confirmation binds the session, decision and choice, and any mismatch spends it', async () => {
|
||||
const s = setup();
|
||||
let c = (await s.confirm('yes')).json();
|
||||
refused(await s.resolve({ choice: 'no', confirmation: c.confirmation }), 'confirmation-required');
|
||||
refused(await s.resolve({ choice: 'yes', confirmation: c.confirmation }), 'confirmation-required');
|
||||
c = (await s.confirm('yes')).json();
|
||||
refused(await s.resolve({ choice: 'yes', confirmation: c.confirmation }, 'd2'), 'confirmation-required');
|
||||
c = (await s.confirm('yes')).json();
|
||||
const other = setup();
|
||||
other.confirmations = s.confirmations;
|
||||
other.session = { ...s.session };
|
||||
other.current = other.session;
|
||||
refused(await other.resolve({ choice: 'yes', confirmation: c.confirmation }), 'confirmation-required');
|
||||
refused(await s.resolve({ choice: 'yes', confirmation: 'not-a-token' }), 'confirmation-required');
|
||||
assert.equal(s.calls.filter((x) => x.verb === 'decision.resolve.console').length, 0);
|
||||
assert.equal(other.calls.length, 0);
|
||||
});
|
||||
|
||||
test('a session that ends between the steps refuses before the bus', async () => {
|
||||
const s = setup();
|
||||
s.current = null;
|
||||
refused(await s.confirm('yes'), 'sign-in-required');
|
||||
assert.equal(s.calls.length, 0);
|
||||
s.current = s.session;
|
||||
const c = (await s.confirm('yes')).json();
|
||||
s.current = { ...s.session };
|
||||
refused(await s.resolve({ choice: 'yes', confirmation: c.confirmation }), 'sign-in-required');
|
||||
assert.equal(s.calls.length, 1);
|
||||
// A sign-out during the preview also refuses, and no confirmation is issued.
|
||||
const t = setup({ answer: () => { t.current = null; return { ...PREVIEW }; } });
|
||||
refused(await t.confirm('yes'), 'sign-in-required');
|
||||
assert.equal(t.confirmations.size, 0);
|
||||
});
|
||||
|
||||
test('confirmations expire after the TTL, cap at the limit without dropping a live one, and are one use', () => {
|
||||
let t = 0;
|
||||
const store = createConfirmations({ ttlMs: 1000, max: 2, now: () => t });
|
||||
const bind = { session: 's', business: 'demo', decision: 'd1', choice: 'yes', digest: DIGEST };
|
||||
const a = store.issue(bind), b = store.issue(bind);
|
||||
assert.equal(store.issue(bind), null);
|
||||
assert.equal(store.size, 2);
|
||||
assert.equal(store.consume(a, bind).digest, DIGEST);
|
||||
assert.equal(store.consume(a, bind), null);
|
||||
t = 1000;
|
||||
assert.equal(store.consume(b, bind), null);
|
||||
assert.equal(store.size, 0);
|
||||
assert.match(store.issue(bind), /^[a-f0-9]{64}$/);
|
||||
assert.equal(store.consume(undefined, bind), null);
|
||||
assert.equal(store.consume('A'.repeat(64), bind), null);
|
||||
});
|
||||
@@ -50,18 +50,14 @@ test('shipped strings are unchanged, and the web keeps its write boundary', () =
|
||||
['bus.js', 'No open tasks.'], ['bus.js', 'No role is held right now.'], ['bus.js', 'Nothing of this kind in the trail.'],
|
||||
['bus.js', 'Console shows nothing rather than a guess. Nothing was written.'],
|
||||
]) assert.equal(plain[file].split(s).length - 1, n, `${file}: ${s}`);
|
||||
// Row 252 (#1753, mos-1321): the one decision control is the Console's grant Approve and Deny, one
|
||||
// fetch(url, ...) in decidePost, called only for a decision's confirmation and its resolve. Its labels
|
||||
// come from ACTIONS, and the doesNotMatch below keeps any literal Resolve, Decide or Approve label out
|
||||
// of the markup. The board's Seen and reply are app.js's POSTs; bus.js's other explicit writes are
|
||||
// No Resolve control. The board's Seen and reply are app.js's POSTs; bus.js has four explicit writes:
|
||||
// row 76's message to a role, row 122's human Settings update, row 125's token-limit reset (one path,
|
||||
// to the same Settings route) and row 148's spec and docs save. Row 165 (#1662) adds the role
|
||||
// instance add or change to /api/instances, and row 65 the routing table's save to /api/routes.
|
||||
assert.doesNotMatch(src, /<button[^>]*>[^<]*(Resolve|Decide|Approve)/i);
|
||||
const writes = read('bus.js').toString().match(/fetch\([^;]*method:\s*['"](POST|PUT|PATCH|DELETE)[^;]*;/g) ?? [];
|
||||
assert.deepEqual(writes, ["fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), cache: 'no-store', redirect: 'manual' });", "fetch('/api/settings',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/settings',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({revision,reset:[JSON.parse(path)]}),cache:'no-store',redirect:'manual'});", "fetch(url,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/instances',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/routes',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/project-file', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(sent), cache: 'no-store', redirect: 'manual' });", "fetch('/api/bus/message', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ to, body: text }), cache: 'no-store', redirect: 'manual' });"]);
|
||||
assert.equal(read('bus.js').toString().split(/method:/).length - 1, 8);
|
||||
assert.deepEqual([...read('bus.js').toString().matchAll(/decidePost\(([^,]*),/g)].map(m => m[1]), ['url', '`/api/decisions/${enc(a.id)}/confirmation`', '`/api/decisions/${enc(a.id)}/resolve`']);
|
||||
assert.deepEqual(writes, ["fetch('/api/settings',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/settings',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({revision,reset:[JSON.parse(path)]}),cache:'no-store',redirect:'manual'});", "fetch(url,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/instances',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/routes',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/project-file', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(sent), cache: 'no-store', redirect: 'manual' });", "fetch('/api/bus/message', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ to, body: text }), cache: 'no-store', redirect: 'manual' });"]);
|
||||
assert.equal(read('bus.js').toString().split(/method:/).length - 1, 7);
|
||||
// Row 155 (#1649): the one fetch(url, ...) posts only to these three routes, each a literal.
|
||||
// Row 176 (#1674): the tester name form adds a second /api/name caller.
|
||||
assert.deepEqual([...read('bus.js').toString().matchAll(/assistantPost\(([^,]*),/g)].map(m => m[1]), ['url', "'/api/settings'", "'/api/name'", "'/api/assistant-stop'", "'/api/name'"]);
|
||||
|
||||
@@ -183,14 +183,3 @@ test('row 177 W4: the main listener refuses x-mosaic-tester on chat reads and wr
|
||||
assert.equal((await http(web, '/api/chat/threads', { headers: signed })).status, 200);
|
||||
assert.deepEqual(calls, ['threads']);
|
||||
});
|
||||
|
||||
test('Row 252: the tester listener has no decision answer routes, and its config never offers them', async t => {
|
||||
const s = await testerFixture(t);
|
||||
for (const step of ['confirmation', 'resolve']) {
|
||||
const r = await post(s.web, `/api/decisions/d1/${step}`, step === 'confirmation' ? { choice: 'yes' } : { choice: 'yes', confirmation: 'a'.repeat(64) });
|
||||
assert.equal(r.status, 404, r.text);
|
||||
assert.equal(r.body.error, 'not-found');
|
||||
}
|
||||
assert.equal((await http(s.web, '/api/config')).body.decide, false);
|
||||
assert.deepEqual(s.calls, []);
|
||||
});
|
||||
Reference in new issue
Block a user