row 252: Console inbox Approve and Deny for grant requests, human-only through a console owner proof, confirmation then resolve, via console with human.input before the executor (#1753, Webby N1-N5); tests not yet run
This commit is contained in:
1 parent
30e6bb6803
commit
24672fd9d6
24 files changed
+1694
-69
No files matched your search
@@ -0,0 +1,122 @@
|
||||
# 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)
|
||||
|
||||
- **`webui/tests/shell.test.mjs`**, write-boundary test. Gating `done` waits on Mos's ruling.
|
||||
- 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.
|
||||
|
||||
## 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.
|
||||
+140
-24
@@ -137,6 +137,9 @@ export const TESTER_VERBS = Object.freeze(['message.send', 'message.receive', 'm
|
||||
// collaborator grant, so a viewer gets access-refused on each.
|
||||
export const USER_VERBS = Object.freeze(['project.list', 'thread.create', 'thread.list', 'thread.open', 'thread.send', 'thread.messages', 'names',
|
||||
'thread.share', 'thread.unshare', 'thread.shares']);
|
||||
// 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']);
|
||||
// Row 275 (193b1b, #1780, row 193 design 4.3 and 9): the user verbs a viewer grant reaches. They
|
||||
// read, so a live grant at any level covers them. thread.create, thread.open and thread.send need a
|
||||
// collaborator grant on the project and instance, so a viewer gets access-refused on each.
|
||||
@@ -1456,6 +1459,26 @@ 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 });
|
||||
@@ -1504,10 +1527,61 @@ 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.via !== 'cli' || r.by !== this.#business(business).human)
|
||||
return null;
|
||||
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;
|
||||
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) {
|
||||
@@ -4551,6 +4625,9 @@ export class Broker {
|
||||
grants = this.#userGrants(s, doc);
|
||||
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);
|
||||
@@ -5081,6 +5158,7 @@ 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');
|
||||
@@ -5119,6 +5197,7 @@ 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');
|
||||
@@ -5178,6 +5257,7 @@ 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');
|
||||
@@ -5501,15 +5581,38 @@ 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': {
|
||||
keys(a, ['id', 'choice', 'note'], ['id']);
|
||||
const viaConsole = verb === 'decision.resolve.console';
|
||||
if (viaConsole) keys(a, ['id', 'choice', 'digest'], ['id', 'choice', 'digest']);
|
||||
else 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');
|
||||
if (verb === 'decision.withdraw') {
|
||||
// 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 (s.human) {
|
||||
this.#human(s);
|
||||
} else {
|
||||
@@ -5521,32 +5624,39 @@ export class Broker {
|
||||
this.#agent(s);
|
||||
if (s.role !== d.route_to) fail('not-resolver');
|
||||
}
|
||||
const op = verb === 'decision.resolve' ? 'resolved' : verb === 'decision.seen' ? 'seen' : 'withdrawn';
|
||||
const op = verb === 'decision.resolve' || viaConsole ? 'resolved' : verb === 'decision.seen' ? 'seen' : 'withdrawn';
|
||||
if (op === 'resolved' && !JSON.parse(d.options).some((o) => o.key === a.choice))
|
||||
fail('invalid-choice');
|
||||
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',
|
||||
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,
|
||||
);
|
||||
// Row 234 (#1740, r6 X1): the human's cli approve of a grant mint starts the executor once this
|
||||
// transaction commits. It rechecks all of this itself, so a rollback starts nothing.
|
||||
const grant = op === 'resolved' ? this.#approvedGrant(s.business, d.id) : null;
|
||||
if (grant?.method === 'host-enroll') setImmediate(() => this.#runEnrollment(s.business, d.id));
|
||||
if (grant?.method === 'host') setImmediate(() => this.#runHostGrant(s.business, d.id));
|
||||
if (grant?.method === 'gitea') setImmediate(() => this.#startGitea(s.business, d.id));
|
||||
// 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',
|
||||
{ kind: 'answer', decision: d.id, op, choice: a.choice ?? null },
|
||||
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.
|
||||
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));
|
||||
// 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
|
||||
@@ -5573,7 +5683,7 @@ export class Broker {
|
||||
this.#now(),
|
||||
s.business,
|
||||
s.human ? 'human' : s.role,
|
||||
s.human ? 'human-cli' : s.run,
|
||||
s.console ? 'human-console' : s.human ? 'human-cli' : s.run,
|
||||
d.raised_by_role,
|
||||
'DECISION',
|
||||
null,
|
||||
@@ -5583,7 +5693,8 @@ export class Broker {
|
||||
);
|
||||
this.#route(s.business, mid, d.raised_by_role, session);
|
||||
}
|
||||
return grant ? { id: d.id, op, grant: 'started' } : { id: d.id, op };
|
||||
const answer = viaConsole ? { id: d.id, op, choice: a.choice } : { id: d.id, op };
|
||||
return grant ? { ...answer, grant: 'started' } : answer;
|
||||
}
|
||||
case 'message.send': {
|
||||
keys(a, ['to', 'body', 'class', 'in_reply_to', 'corrects', 'decision'], ['to', 'body']);
|
||||
@@ -5939,7 +6050,12 @@ export class Broker {
|
||||
s.business,
|
||||
s.human || s.reader ? 'human' : s.role,
|
||||
)
|
||||
.map((d) => this.#decisionView(d));
|
||||
.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);
|
||||
});
|
||||
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,18 +1,19 @@
|
||||
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, timeout = 5000 }) {
|
||||
constructor({ path, cap, human, tester, user, console: owner, 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 } : { cap: this.cap }),
|
||||
...(this.human ? { human: this.human } : this.tester ? { tester: this.tester } : this.user ? { user: this.user } : this.console ? { console: this.console } : { cap: this.cap }),
|
||||
verb,
|
||||
args,
|
||||
}) + '\n';
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
// 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,6 +10,10 @@ 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');
|
||||
};
|
||||
@@ -31,7 +35,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 (['MOSAIC_BUS_CLI_NONCE', 'MOSAIC_BUS_USER_NONCE'].includes(key) || MARKERS.includes(key)) env[key] = entry.slice(i + 1);
|
||||
if (NONCE_KEYS.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 }) {
|
||||
const kinds = ['human', 'tester', 'user'].filter(k => request?.[k]);
|
||||
export function verifyPrincipal(request, { peer, paths, businesses, launches = [], readProcess = defaultRead, readTesterNonce = testerNonce, consoleOwners = [] }) {
|
||||
const kinds = ['human', 'tester', 'user', 'console'].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,6 +16,7 @@ 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 ||
|
||||
@@ -42,3 +43,38 @@ 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 } };
|
||||
}
|
||||
@@ -106,7 +106,15 @@ export async function startBroker({
|
||||
// Without it no user binds. R6 and R8 pass it; the live boot config doesn't yet. FED R4 (#1801):
|
||||
// intervalMs, the revocation timer's period (design 3.7, 30 s), in-process only like librarySync's.
|
||||
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'
|
||||
|| (Object.hasOwn(access, 'intervalMs') && !(Number.isSafeInteger(access.intervalMs) && access.intervalMs > 0))))
|
||||
throw new BusError('invalid-host-request');
|
||||
@@ -408,6 +416,7 @@ 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
|
||||
@@ -439,6 +448,11 @@ 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 } 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, CONSOLE_VERBS } from './broker.mjs';
|
||||
import { inspectInterpreter } from './interpreter.mjs';
|
||||
import { createPeerReader } from './peercred.mjs';
|
||||
const LIMIT = 65536;
|
||||
@@ -13,8 +13,9 @@ 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) 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 }) {
|
||||
// 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 }) {
|
||||
try {
|
||||
lstatSync(path);
|
||||
throw new BusError('socket-exists');
|
||||
@@ -64,15 +65,17 @@ 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', 'verb', 'args'].includes(k)) ||
|
||||
Object.keys(r).some((k) => !['cap', 'human', 'tester', 'user', 'console', 'verb', 'args'].includes(k)) ||
|
||||
typeof r.verb !== 'string' ||
|
||||
[r.cap, r.human, r.tester, r.user].filter(Boolean).length !== 1
|
||||
[r.cap, r.human, r.tester, r.user, r.console].filter(Boolean).length !== 1
|
||||
)
|
||||
throw new BusError('invalid-envelope');
|
||||
let cap = r.cap;
|
||||
const kind = r.human ? 'human' : r.tester ? 'tester' : r.user ? 'user' : null;
|
||||
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');
|
||||
if (kind) {
|
||||
const authenticate = kind === 'human' ? authenticateHuman : kind === 'tester' ? authenticateTester : authenticateUser;
|
||||
const authenticate = kind === 'human' ? authenticateHuman : kind === 'tester' ? authenticateTester : kind === 'user' ? authenticateUser : authenticateConsole;
|
||||
if (!authenticate) throw new BusError(kind === 'tester' ? 'tester-required' : 'human-required');
|
||||
socket.pause();
|
||||
let peer;
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
// 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";
|
||||
@@ -15,7 +18,7 @@ import { provedRuntime } from "../../cli/tests/channel-fixture.mjs";
|
||||
import { Client } from "../src/client.mjs";
|
||||
|
||||
const baselines = {
|
||||
"packages/bus/src/human.mjs": "0c6d96abe4607cba0b02b0638a2fc217ff8135bac9a47761a0ef015fbe9d3079",
|
||||
"packages/bus/src/human.mjs": "bb13c146717cdc46d75d157177e400edac4d3e10f14fe511027a25fd44cec885",
|
||||
"packages/bus/src/human-cli.mjs": "271b9781d73cf31cc562a56180e3ebcd725b5fde148a83347a201fdef2fc28af",
|
||||
"packages/cli/src/transport.mjs": "5129c44b4d9f642e825344df65c9bfb4bac4fc8d6b25343b42d39853dbd40774",
|
||||
// Row 165 (#1662): seven STATUS entries for the role instance routes (organization-instance-exists,
|
||||
@@ -26,7 +29,7 @@ const baselines = {
|
||||
// Row 194 (#1692, brief item 7, sage-1610 a): organization-tracker-required 400 and
|
||||
// organization-tracker-bot-taken 409; no proof or transport change.
|
||||
// Row 301 (#1808, mos-1449): the user transport moved from user-bus.mjs into bus.mjs; humanCall unchanged.
|
||||
"packages/webui/src/bus.mjs": "39fcb27c82e917ef05e1a7a650d522c9f3c656bf8dbade87b8ddb3661b67ad6c",
|
||||
"packages/webui/src/bus.mjs": "604092cb706b152d458f299ff1071f6cd488774629012f96b672f7e9a16c0b2c",
|
||||
};
|
||||
const repoFile = path => new URL("../../../" + path, import.meta.url);
|
||||
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
// 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);
|
||||
});
|
||||
@@ -46,3 +46,52 @@ 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/);
|
||||
});
|
||||
@@ -115,6 +115,9 @@ function accessFor(system, env, warn) {
|
||||
return { file, context };
|
||||
}
|
||||
|
||||
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.
|
||||
@@ -135,6 +138,9 @@ export function bootConfig({ system, businessId, env = process.env, warn = () =>
|
||||
if (tracker) config.trackers = { [b.id]: tracker };
|
||||
const access = accessFor(system, env, warn);
|
||||
if (access) config.access = access;
|
||||
// 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;
|
||||
});
|
||||
}
|
||||
@@ -69,3 +69,19 @@ 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" },
|
||||
]);
|
||||
});
|
||||
@@ -335,13 +335,28 @@ 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"). A decision shows `mosaic decide <id> <key>` for each choice,
|
||||
"Assistant settings" and "Spec and docs editor"), and the decision Approve and Deny below.
|
||||
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,
|
||||
|
||||
+33
-11
@@ -14,6 +14,7 @@ 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; }
|
||||
@@ -27,10 +28,13 @@ 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 = {}) => new Promise((resolve, reject) => {
|
||||
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) => {
|
||||
let child;
|
||||
try {
|
||||
child = spawn(process.execPath, [cli, socket], { env: cli === HUMAN_CLI ? { ...env, MOSAIC_BUS_CLI_NONCE: randomBytes(32).toString('hex') } : env, stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
child = spawn(process.execPath, [cli, socket], { 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;
|
||||
@@ -53,24 +57,42 @@ export function humanCall({ socket, business, env = process.env, cli = HUMAN_CLI
|
||||
catch { done(reject, new BusReadError('invalid-response')); }
|
||||
});
|
||||
child.stdin.on('error', () => {}); // A child that exits early reports through 'close'.
|
||||
child.stdin.end(`${JSON.stringify({ business, verb, args })}\n`);
|
||||
child.stdin.end(`${JSON.stringify(input)}\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) => {
|
||||
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);
|
||||
return humanCall({ socket, business: business || hostBusiness(dataRoot), 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) });
|
||||
|
||||
// FED R6 (#1800): a granted user's bus transport, R3's user-cli.mjs (#414, design 3.6). Same
|
||||
|
||||
@@ -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, busBusiness, userCaller } from './bus.mjs';
|
||||
import { busCaller, busBusiness, userCaller, consoleCall } from './bus.mjs';
|
||||
import { consoleBus, userOptionsFor, agentCaller, TESTER_AGENT_VERBS } from './console-bus.mjs';
|
||||
import { consoleAccess } from './access.mjs';
|
||||
import { accessContextFor } from '../../cli/src/access.mjs';
|
||||
@@ -68,7 +68,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, 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, decisionWrite = null, busLine, system = null;
|
||||
let processing = null, ownerCall = null;
|
||||
try {
|
||||
system = loadSystem();
|
||||
@@ -76,6 +76,9 @@ try {
|
||||
const call = ownerCall = busCaller({ dataRoot, socket: socketPath(dataRoot), business: options.business ?? null });
|
||||
// FED R6 (#1800): the bus-backed options, shared with each granted user's session (console-bus.mjs).
|
||||
({ processing, chat, bus, projectFiles, projectFile, libraryFile, libraryStatus, send, librarySync, settingsWrite, projectSave, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite } = consoleBus(call, { send: sendOn }));
|
||||
// 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);
|
||||
@@ -104,7 +107,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, userOptions, federation: system?.federation ?? null });
|
||||
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, userOptions, federation: system?.federation ?? null });
|
||||
} catch (err) { sso?.close(); throw err; }
|
||||
let testerServer = null;
|
||||
if (testerSocket) {
|
||||
@@ -128,7 +131,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)' : '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), 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 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(); });
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
// 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,8 +190,9 @@
|
||||
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); every other row is the broker's.
|
||||
out.kind = `decision.${r.op}`; out.decision = r.decision; out.by = r.via === 'cli' ? 'human' : 'broker';
|
||||
// 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';
|
||||
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}`;
|
||||
@@ -227,8 +228,9 @@
|
||||
// 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 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>`;
|
||||
// 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>`;
|
||||
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;
|
||||
@@ -256,7 +258,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)}</p>
|
||||
<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="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>`;
|
||||
}
|
||||
@@ -314,12 +316,101 @@
|
||||
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.');
|
||||
return `${head('Inbox', `${readNote()} Decisions are answered in a terminal with <code>mosaic decide</code>; Console only shows them.`)}
|
||||
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}`)}
|
||||
${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>`;
|
||||
@@ -343,6 +434,13 @@
|
||||
// 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))
|
||||
@@ -352,11 +450,18 @@
|
||||
}).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>`] : [])];
|
||||
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>'
|
||||
// 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>`
|
||||
: !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>`;
|
||||
@@ -1098,6 +1203,7 @@
|
||||
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 || '',
|
||||
};
|
||||
}
|
||||
@@ -1105,7 +1211,10 @@
|
||||
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'));
|
||||
if (el && (k.href || k.copy || k.retry || k.mirror || k.field || k.send)) {
|
||||
// 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)) {
|
||||
el.focus({ preventScroll: true });
|
||||
if (k.caret && el.tagName === 'TEXTAREA') { el.setSelectionRange(k.caret[0], k.caret[1]); el.scrollTop = k.caret[2]; }
|
||||
}
|
||||
@@ -1197,6 +1306,9 @@
|
||||
}
|
||||
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; }
|
||||
@@ -1318,12 +1430,14 @@
|
||||
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) return;
|
||||
if (!canSend && !isTester && !ssoOn) 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(() => {});
|
||||
|
||||
@@ -11,6 +11,7 @@ import { ReadError, readStatus, fieldTable } from './reads.mjs';
|
||||
import { THREAD_DEFINITIONS } from '../../business/src/vocabulary.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) {
|
||||
@@ -600,7 +601,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, userOptions = null, federation = 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, decisionWrite = null, userOptions = null, federation = 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');
|
||||
@@ -611,6 +612,9 @@ 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);
|
||||
// `userOptions` (FED R6, #1800) is `session => options`, the bus-backed options over that session
|
||||
// user's own transport (console-bus.mjs). Only a signed-in listener has user sessions.
|
||||
@@ -669,18 +673,23 @@ 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.
|
||||
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;
|
||||
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 (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 };
|
||||
// Row 252: only the owner's session answers a decision. A granted user stops at userRoute below.
|
||||
if (session && session.user === undefined) ssoSession = session;
|
||||
// FED R6 (#1800): a granted user. The Console's gate first; behind it no human-transport
|
||||
// object is left, so a route past the gate reaches the broker only as this user.
|
||||
if (session?.user !== undefined) {
|
||||
@@ -696,6 +705,13 @@ 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 });
|
||||
@@ -820,7 +836,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
// `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.
|
||||
// `user` and `federation` (FED R6) only when there is one, so the owner's config is unchanged.
|
||||
if (path === '/api/config') return json(res, 200, { board: upstream, ...(tester ? { tester: true } : {}), ...(sso ? { sso: true } : {}), ...(granted ? { user: true } : {}), ...(hub ? { federation: hub } : {}), send: !tester && Boolean(send) && named, edit: !tester && !granted && Boolean(send) && Boolean(projectSave) && named });
|
||||
if (path === '/api/config') return json(res, 200, { board: upstream, ...(tester ? { tester: true } : {}), ...(sso ? { sso: true } : {}), ...(granted ? { user: true } : {}), ...(hub ? { federation: hub } : {}), send: !tester && Boolean(send) && named, decide: !tester && !granted && Boolean(send) && Boolean(decisionWrite) && named && sso !== null, edit: !tester && !granted && 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);
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
// 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(); }
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
// 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/);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,205 @@
|
||||
// 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);
|
||||
});
|
||||
@@ -54,10 +54,14 @@ test('shipped strings are unchanged, and the web keeps its write boundary', () =
|
||||
// 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.
|
||||
// Row 252 (#1753) adds 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, so no
|
||||
// button carries a literal Resolve, Decide or Approve.
|
||||
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('/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);
|
||||
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`']);
|
||||
// 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,3 +183,14 @@ 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