Compare commits

..
28 changed files with 940 additions and 1182 deletions

No files matched your search

-297
View File
@@ -1,297 +0,0 @@
# Row 294 build notes (Launchpad): FED R6, Console granted-user sign-in (#1800)
Brief: docs/plans/2026-10-23_mvp-readiness-rows.md, "Brief: FED R6, Console granted-user sign-in".
Design: agents/gizmoduck/work/federation/DESIGN.md at b93f098f0 (r4.11, sha256 1bcee8df…c8e6),
sections 3.1, 3.4, 3.7 and the R6 row of section 7. Assigned in sage-3136. Reviewer Duckworth.
## Base and chain
Branch launchpad/row294 on origin/refactor 253346e3c (270+271, schema 3n in packages/bus, and 571).
Parents done: 192 (#412 phase 1), R1 (201), R2, R3 (274). Row 208 and R7 (296) wait on this row.
## Plan
### What the Console does today
- `sso.mjs` callback: a `sub` that is not in `console.sso.providers[].subjects` refuses
`subject-unknown`. A session record is `{provider, iss, sub, name, ...}` and every signed-in
request runs as the business human.
- `serve.mjs`: every bus route, chat route and write uses objects built once in `cli.mjs` over the
human transport (`busCaller` over `HUMAN_CLI`).
- `accessContextFor` (packages/cli/src/access.mjs:62) returns `peers: []`.
### Build items
1. **Granted-user sign-in (sso.mjs).** `createSso` takes an optional `users` hook:
`users.find({provider, sub})` returns a user id with a live grant, or null. The callback asks it
only after the owner subjects miss, so the owner path is unchanged. A user session record carries
`user: <id>` and `name: <id>`. A miss on both refuses `subject-unknown`, as today (the log line
names the provider, iss and sub; no new code is needed for the refusal).
2. **The access hook (new `packages/webui/src/access.mjs`).** `consoleAccess({ load, business })`:
- `load` is `() => loadAccess(accessFilePath(configDir()), accessContextFor(system, env))`, R1's
loader, called on every use, never cached.
- `find({provider, sub})`: the user whose `subjects` hold that pair, with at least one grant in
the Console's business that `covers` at level `viewer` (the bus's own liveness test,
`#userGrants`). A loader throw finds nobody (fail closed).
- `check(session)`: the same test for the session's user and its own `(provider, sub)`. A user
whose subject was removed, whose grants were all removed or expired, or whose file now refuses
fails it.
- `business` resolves as `busCaller` does: `--business`, else the live host state.
3. **Per-request re-read (sso.mjs `session()`).** For a user session, `users.check(session)` runs
after the expiry check, before any route. A failure deletes the session record and logs
`sso refused code=user-revoked provider=<id> user="<id>"`. The request then goes the
signed-out way: 401 (`sign-in required`) for /api/ and writes, or the sign-in redirect for a
page GET. The cookie is not cleared on that response; it names a session that no longer exists,
so it signs nothing in, and the next sign-in replaces it.
4. **A user-cli transport per session (new user-bus.mjs, cli.mjs).** `userCall({ socket,
business, user })` spawns `USER_CLI` (packages/bus/src/user-cli.mjs) as a
direct child of the Console with `MOSAIC_BUS_USER_NONCE` set, so user-cli does not re-exec and
the broker's parent check (principal.mjs: parent argv[1] is webui/src/cli.mjs, argv[2] `serve`)
sees the Console. The request on stdin is `{business, user, verb, args}`. It never sets
`MOSAIC_BUS_CLI_NONCE` (it strips one from the inherited env) and never runs `HUMAN_CLI`.
`startServer` takes `userOptions(session)`, a factory that builds the whole bus-backed option
set (`consoleBus`, new console-bus.mjs) over that session's user transport, per request.
5. **Owner-only controls hidden (serve.mjs and a user page).** A user session reaches only:
- the chat routes (`/api/chat/*`), over its own user transport, and
- `GET /api/config`, `/healthz`, `/auth/*`, and the user page's static files.
Every other path answers 403 `owner-only` before any bus call. Behind that gate, a user request
never holds a human-transport object: the handler swaps every bus-backed option (`bus`, `chat`,
`send` and each write, `processing`, project files) for the same object built over that
session's user transport, and nulls the ones with no bus behind them (`reads`, the board proxy),
so with the gate removed an owner route still reaches the broker only as the user, or answers
`not-configured`. `GET /` serves a separate page,
`user.html` with `user.js`, that renders only projects, threads and messages (no board, inbox,
agents, settings, editor, processing or Sign-in-as-owner controls). The owner page's assets are
not served to a user session. The gate is one function, `userRoute(path, method)`, so the M1
mutant removes one call.
6. **The notice.** `user.html` holds row 193's sentence word for word in the thread view:
"The business owner sees your thread's agent's decisions, inbox entries, claims, tasks and
project files, but not your messages."
7. **Hub label and peer links.** `/api/config` returns `federation: {self, peers: [{label,
origin}]}` from `system.federation` when set, for owner and user sessions alike. Both pages show
"This Console: <self>" and one plain `<a href="<origin>">label</a>` per peer, built with
textContent and `setAttribute('href')`; no script, no fetch to a peer, no proxy (the hub proxy
route is R8's).
8. **F8 peers wiring.** `accessContextFor` returns
`peers: (system.federation?.peers ?? []).map((p) => p.id)`, and the ACCESS_USAGE text and the
function's comment drop "peer homes are not read yet".
9. **Bus boot wiring (packages/cli/src/config.mjs).** `bootConfig` adds
`access: { file: accessFilePath(configDir(env)), context: accessContextFor(system, env) }` when
the file exists (lstat). No file: no `access` key, so no user binds, as before R6. A context
that throws: a warning and no `access`. A file the loader refuses at boot (sage-3153): one
warning line, which the host writes to stderr as `mosaic-bus: warning: access file refused at
boot (<code>), no granted user binds until it is fixed: <path>`. The access stays wired, so
the broker refuses each user bind and the owner's bus boots. R1's loader has no refusal codes,
and its messages can quote the file (a JSON parse error, a `got "<value>"`), so the line never
carries the message. The code comes from the loader's exit code instead: 4 (the file could not
be opened as a regular file, including a symbolic link) is `access-unreadable`, and anything
else is `access-invalid`.
config.mjs now imports access.mjs, which imports config.mjs (`loadSystem`). The cycle is safe
because neither module touches the other's exports at module top level, only inside
functions (sage-3153).
### Choices and departures (for Sage and Duckworth)
- **C1. A separate user page, not the owner SPA with sections hidden.** "Hidden" here means never
sent: the owner page's markup and scripts are not served to a user session. Hiding sections in
app.js would ship the owner controls and rely on CSS. Tradeoff: a second small page to keep.
- **C2. The Console's gate is an allowlist of user routes**, so a new owner route added later is
owner-only by default.
- **C2b. No human transport behind a user session.** Without the swap, M1 would send
`/api/bus/message` through the owner's `send` over human-cli, and the bus would accept it as the
owner. The swap is what makes "the bus refusal stands on its own" true.
- **C3. Liveness is the bus's test** (`covers` at `viewer`, in the Console's business), so the
Console and the broker agree on who is live. The broker still decides every verb, project,
instance and thread (design 3.1 D4).
- **C4. The bus's `access` boot option is wired here (sage-3142, Q2; accepted, sage-3153).** Build item 9 above.
Sage's ruling: with the file, users bind; without it, none; with a bad file, it "refuses as R1's
loader does"; a boot never invents a grant. My reading of "refuses": the broker's `access` is
R1's loader, read on every user request, so a bad file refuses every user bind
(`access-revoked`) and the owner's bus still boots. Refusing the whole boot would take the
owner's bus down over a user file. Tests B3 pin this reading. No live unit or config changes.
- **C5. Tests use an in-process Broker behind the user transport seam.** The broker's user
principal needs the Console as the user-cli's parent process, which a node:test process is not.
So the webui tests inject a `userOptions` factory over a real `Broker` with `bindUser` and an
`access` function, and a separate transport test checks the spawn: the `USER_CLI` path, the user
nonce variable, no human nonce, and the `{business, user, verb, args}` request. R7 runs the real
Console process end to end.
- **C6. The chat's `plugins` read goes over the owner transport (accepted, sage-3153).**
`createChat` asks the bus `plugins` first (createChannelCaller), and `plugins` is not a user
verb, so a user chat would refuse before its first read. `createChat({ call, owner })` sends
`owner` exactly `plugins` with `{}`, and every other verb goes over `call`. consoleBus passes
`owner` through, and cli.mjs gives it `ownerCall`. createChannelCaller
(plugins/src/channel.mjs:16-20) uses the answer only to gate the webui channel, server-side.
Sage's conditions, each pinned by G9:
- the plugins answer never appears in a user's response;
- in a user's chat the owner transport carries only `plugins` with `{}`;
- every chat verb goes over the user transport.
Mutant M8 routes one more chat verb over the owner transport, and G9 kills it. A new broker
user verb would have been a policy change, so it is out.
- **C7. The user transport is a new file, user-bus.mjs, not a mode in bus.mjs.**
bus/tests/channel-proof.test.mjs pins the sha256 of webui/src/bus.mjs (with human-cli.mjs,
human.mjs and cli/src/transport.mjs). Changing bus.mjs would mean an old-test change through
Mos. user-bus.mjs repeats the spawn and framing code (about 40 lines). bus.mjs is byte-identical
to the base. Tradeoff: two copies of the spawn plumbing. Accepted (sage-3153).
**Follow-up (not this row):** the next row that re-pins the channel-proof hash folds userCall
back into bus.mjs and drops the copy.
- **C8. console-bus.mjs.** The bus-backed option set moved out of cli.mjs into `consoleBus(call,
{send, plugins})`, so the owner and each user session build the same set the same way. The
agent verb lists and `agentCaller` moved with it; the tester socket still builds its own.
- **C9. 401 and 403 from the broker.** chat-http's STATUS maps `access-refused` to 403 and
`access-revoked` to 401, so a broker refusal reaches the browser as the right status.
## Exception: the owner transport in a user's chat (C6; mos-1394, sage-3157)
Duckworth rules on this directly. The same text goes in the manifest notes.
> In a granted user's chat the Console uses the owner's transport for one call: `plugins` with
> `{}`, the channel gate read. Nothing else goes over it. Its answer stays in the Console and is
> never sent to the user. No value from the user's session or request reaches the call. Mos ruled
> in mos-1394 that this is not an access-rule change, because the Console reads its own channel
> gate, and that it holds only while nothing from the user reaches the call. Every chat verb goes
> over the user's own transport. Where: createChat's `owner` (chat.mjs) and userOptionsFor
> (console-bus.mjs), wired with `ownerCall` in cli.mjs. Pinned by G9 and mutants M8, M10 and M11.
> If this reads as owner authority used for the user, it goes back to Mos.
## Rulings (sage-3153)
1. Boot wiring is accepted: a bad file refuses every user bind and the owner's bus boots. Added:
one stderr line at boot naming the path and a code, never the contents (item 9, B3, M9). The
mutual import is fine with no top-level use (item 9).
2. C6 is accepted under conditions: the plugins answer stays server-side, the owner transport
carries only `plugins` with `{}`, one G test (G9) records both transports, and a mutant (M8)
routing one more chat verb over the owner is killed.
3. C7 is accepted, with the follow-up note under C7.
4. Owned files are accepted. packages/cli/src/access.mjs overlaps Morgana's row 295 (527002f97),
and merge-tree is clean. 294 lands first and 295 rebases after it. packages/cli/tests/
access.test.mjs is now unchanged: the peer test moved to access-boot.test.mjs, so the only
overlap is access.mjs.
Ruling sage-3157 (mos-1394): the boot line is accepted as built, with no codes from R1. For C6,
G9 asserts the owner call is exactly `plugins` with exactly `{}`, and that no value from the
session or the request reaches it. Mutants M10 (session value) and M11 (request value) are killed
by G9. The exception is written above and goes in the manifest notes. The per-session builder
moved into console-bus.mjs (`userOptionsFor`), so the wiring G9 runs is the same code cli.mjs
uses, and a session value added there is a mutant G9 can kill.
## Owned files
- cli: src/config.mjs, src/access.mjs, tests/access-boot.test.mjs (new)
- webui src: sso.mjs, access.mjs (new), console-bus.mjs (new), user-bus.mjs (new), chat.mjs,
chat-http.mjs, cli.mjs, serve.mjs
- webui public: user.html (new), user.js (new), federation.js (new), index.html, app.js
- webui tests: granted-user.test.mjs (new)
- this BUILD.md
No old test changes. Hash pins checked: `git grep` over 'packages/*/tests/*' for each touched
path with a 64-hex pin found only channel-proof's bus.mjs pin, which C7 avoids.
## Tests
New file packages/webui/tests/granted-user.test.mjs, on Sage's fake OP (tests/helpers/fake-oidc.mjs)
and an in-process Broker with an access function, like bus/tests/user-principal.test.mjs:
- G1 a granted user signs in: session cookie set, `/api/config` says `user`, `GET /` is the user
page, the chat lists reach the broker as `user:<id>`.
- G2 an ungranted `sub` is refused: a user in the file with no grant, a grant for another business,
an expired grant, and a sub in no file; each is `subject-unknown`, no cookie, nothing bound.
- G3 revoked mid-session: sign in, then remove the grant, remove the subject, expire the grant,
or make the loader refuse. The next chat read is 401 with a `user-revoked` log line and no
transport call. Restoring the grant does not bring the session back, and `GET /` redirects to
sign-in.
- G4 owner verbs: for each owner route (board reads under /api/bus/*, settings, names, instances,
routes, agents, processing, project files, /api/bus/message, /api/board), a user session gets
403 `owner-only` and the transport ran zero times.
- G5 the bus refusal stands alone: the same session's user transport, called directly with each
owner verb, gets `access-refused` from the broker and nothing is written.
- G9 the C6 seam (sage-3153, sage-3157), with the test's userOptions built by userOptionsFor as
cli.mjs builds them: a user session runs the chat's project list, thread create, list,
open, send and messages. The owner transport (a recording `ownerCall`) saw only
`['plugins', {}]`. Each chat verb went over the user transport, and `plugins` never did. A
marker in the owner's answer appears in no response. Every owner call is exactly
`['plugins', {}]`, and none of alice, alice-sub, the thread name, the message body, the project
id or the thread id appears in what the owner transport received.
- G6 the notice: the user page's notice element text equals the sentence exactly.
- G7 hub label and peer links render from config: `/api/config` carries self and the peers'
labels and origins; the user and owner pages show "This Console: hub" and an `<a>` per peer with
the configured href and label (browser test, sso-browser.test.mjs style).
- G8 the transport: `userCall` over a recording unix socket in a scratch dir. While the child
waits for the answer, the test reads /proc/<pid>/cmdline (argv[1] is USER_CLI), environ
(MOSAIC_BUS_USER_NONCE set; MOSAIC_BUS_CLI_NONCE absent though the parent env passed one) and
stat (ppid is the test process, so user-cli did not re-exec). The request line is
`{verb, args, user: {business, user, nonce, pid}}`, which user-cli builds from
`{business, user, verb, args}`.
packages/cli/tests/access-boot.test.mjs (sage-3142), over bootConfig and startBroker in a scratch
root, the boot config through a JSON round trip as over the host IPC:
- B1 with access.json: `access.file` and `context.peers` set; alice (home a configured peer)
binds; bob gets `access-revoked`.
- B2 without the file: no `access` key; alice gets `access-revoked`.
- B3 with text that is not JSON, a home that is not a configured peer, and a symbolic link: the
bus boots and alice gets `access-revoked`. The warnings are exactly one line, with
`access-invalid`, `access-invalid` and `access-unreadable` and the path, and none quotes the
file (a marker string in the file is absent from the line).
access-boot.test.mjs also holds the F8 peer test: a grant whose user's home is a configured peer id
validates; an unconfigured peer id refuses `home must be a configured peer`.
Old tests: the sso.test.mjs and serve tests stay as they are. If one needs a change, it goes to
Mos through Sage first (mos-1272).
## Mutants
| # | Change | Killed by | Expected |
|---|---|---|---|
| M1 | remove the `userRoute` gate call in serve.mjs | G4 (:239, first route answers 403 `access-refused`, not `owner-only`) | G4 red at :239 on its first route; G5 green in the same run |
| M2 | skip `access.check(session)` per request | G3 | red |
| M3 | change one word of the notice in user.html | G6 | red |
| M4 | `peers: []` back in accessContextFor | the F8 peer test and B1 (access-boot.test.mjs) | red |
| M5 | sign-in skips the grant test (any listed user signs in) | G2 | red |
| M6 | userCall sets MOSAIC_BUS_CLI_NONCE or runs HUMAN_CLI | G8 | red |
| M7 | bootConfig drops `access` | B1 | red |
| M8 | chat.mjs routes `thread.list` over `owner` too | G9 | red |
| M9 | accessFor drops the boot refusal line | B3 | red |
| M10 | userOptionsFor passes `owner: (v, a) => owner(v, { ...a, sub: session.sub })` | G9 | red |
| M11 | chat.mjs keeps the last request's args in `channel()` and passes them to `owner('plugins', …)` | G9 (thread create's `{project, name}` reaches owner) | red |
M1 is the brief's "owner verb still refused by the bus": with the Console gate gone, the owner
verb reaches the broker over the user transport and the broker refuses it (`access-refused`, 403).
G4 kills M1 at :239 on its first route, because it pins the Console's own code `owner-only`. The
count and "nothing written" asserts at :242-244 come after the loop, so under M1 they never run;
the run records G4's failure with the actual value (`access-refused`). The bus refusing on its own
is shown by G5, green in the same M1 run. (Corrected per Duckworth C1, duckworth-0249b; the earlier
row said G4's "refused, nothing written" stayed green, which cannot be observed under M1.)
## Run limits
Each `timeout -k 10 LIMIT`, TMPDIR from `mktemp -d /mnt/storage/scratch/tmp/gXXXXXX`. A timeout with
no failed test goes to Sage first; a timeout in the shell phase goes to Mos unplaced (mos-1275).
| Run | LIMIT |
|---|---|
| each touched or new file alone | 300 s |
| webui suite | 1800 s |
| bus suite | 900 s |
| cli suite | 1800 s |
| each scripts/test-*.sh | 900 s |
| each mutant probe | 300 s |
## Review
Round 1, manifest 0b3f9c9d…94f3 (ca710eabd): Duckworth APPROVE (static), duckworth-0249b, note
agents/duckworth/work/row294/ROUND1-ca710eabd.md on duckworth/reviews 5b7089ea1. The owner-transport
exception is accepted and does not go to Mos.
- C1: the M1 row and note above are corrected. The M1 run records G4's :239 failure value and G5.
- C2: the gate runs on Sage's CLEAR.
- N1 (no test reaches the C2b swap): no M12 in this row. Adding one needs a new test and a new
manifest round after approval; the brief does not require it. Left as a follow-up for Sage to
place: a test that a user session's options carry no owner caller besides `plugins`, with an M12
where serve.mjs keeps the owner's `send` for a user session.
- N2, N3: accepted as they stand; no change.
- N4: the user-bus.mjs fold-back (C7) is proposed to Sage as a queue row, for the next
channel-proof re-pin.
## Runs
@@ -1,31 +0,0 @@
# Row 294 (FED R6, #1800) candidate manifest, round 1
Branch launchpad/row294, base 253346e3c, pushed tip 9450774cbe7986d80150b27a4da33b43d93bf026.
MANIFEST.sha256 lists the 17 code and test paths changed from the base, hashed from the tip's
blobs (`sha256sum -c` passes on the tip). BUILD.md is the row note and is not in the manifest.
No old test changes. webui/src/bus.mjs is byte-identical to the base (channel-proof hash pin, C7).
packages/cli/tests/access.test.mjs is unchanged; packages/cli/src/access.mjs overlaps row 295,
which rebases after this row (sage-3153).
Nothing has run. Runs follow Duckworth's approval and Sage's test CLEAR, in slot order: each
touched file alone, mutants M1-M11, the webui, cli and bus suites and every scripts/test-*.sh,
with the limits in BUILD.md.
## Exception for Duckworth to rule on: the owner transport in a user's chat (C6; mos-1394, sage-3157)
> In a granted user's chat the Console uses the owner's transport for one call: `plugins` with
> `{}`, the channel gate read. Nothing else goes over it. Its answer stays in the Console and is
> never sent to the user. No value from the user's session or request reaches the call. Mos ruled
> in mos-1394 that this is not an access-rule change, because the Console reads its own channel
> gate, and that it holds only while nothing from the user reaches the call. Every chat verb goes
> over the user's own transport. Where: createChat's `owner` (chat.mjs) and userOptionsFor
> (console-bus.mjs), wired with `ownerCall` in cli.mjs. Pinned by G9 and mutants M8, M10 and M11.
> If this reads as owner authority used for the user, it goes back to Mos.
## Other choices
C1-C9 and rulings sage-3142, sage-3153 and sage-3157 are in BUILD.md. In short: a separate user
page (C1); an allowlist gate of user routes (C2); every bus-backed option swapped to the user
transport behind the gate (C2b); liveness by the broker's own `covers` test (C3); the bus boot
`access` wiring, where a bad file refuses each user bind and writes one stderr line with the path
and a code, never the contents (C4, item 9); and user-bus.mjs as a copy of the spawn code (C7).
@@ -1,17 +0,0 @@
52206bd92b0f223bf9310d50db650f36e12836691d739b499192e3eedd81401c packages/cli/src/access.mjs
a948886a8c57d700b3d116097c9597c2b29dc7cc89ff6e18ae57436754195853 packages/cli/src/config.mjs
abb5ccb77b8d5c97c81e84fb1fb32367bf69890e8ea1f1912d739a3dd1678738 packages/cli/tests/access-boot.test.mjs
1be61ba66c3b747decdb3e12de743265b8cc7ba7cd06c033393b157ce1bc0b70 packages/webui/src/access.mjs
954dee1535b258d9590e4d102423d081303b640b3bea750bd3653883e67bf309 packages/webui/src/chat-http.mjs
230a5759f5027f9208c716b176f9b0a30310d5f433493bfcd8862c71abe182ae packages/webui/src/chat.mjs
7fcb28a7a67418a8d3fd26dd157860ec1a89fe1891887f661e53a17d3b3bbc7d packages/webui/src/cli.mjs
941c8f91ad40264c5622d60af85e00f6198db31a0504b4b0caa48312d8f57c3e packages/webui/src/console-bus.mjs
692c5eabb0fc866240b12931418c17d3392b6097575dfacf7425a6f0127dd291 packages/webui/src/public/app.js
c41a34a32f23f742e30ca408bc7deda0fa8feb4f8c25c7036604c336bb1545eb packages/webui/src/public/federation.js
e7673188c1d76ecc63cdc24e7d1470922b1d69bedeb994a862e94924edfc27d2 packages/webui/src/public/index.html
969a52284b667e075b04e3fa63d45415f3517080784ee372ee4725caf51d15d9 packages/webui/src/public/user.html
571ce191c0b5f545e3a65f7b388f754e2ca6396a68038ccc57321d8f490cb86e packages/webui/src/public/user.js
9343ea9edb862c1545df2886754244ffd76b714cc90d7791fc32aee4ecfec594 packages/webui/src/serve.mjs
70b243ed9a83ff822a4c7a148528ff5153574cc87d5c776fd2e12f340762ed76 packages/webui/src/sso.mjs
8cc1185beda9085ced62470f1b9e39d95dcca2dcc8bf9325c18c2f0b2da618dc packages/webui/src/user-bus.mjs
8f89a1cc853ad275e62a518eb4f5567b5aaa629d32060c11daaaa671aded29db packages/webui/tests/granted-user.test.mjs
+139
View File
@@ -0,0 +1,139 @@
# Row 295: FED R4 revocation (#1801)
Brief: docs/plans/2026-10-23_mvp-readiness-rows.md, "Brief: FED R4, revocation".
Design: agents/gizmoduck/work/federation/DESIGN.md at b93f098f0 (r4.11), sections 3.7 and 7.
Base: origin/refactor 253346e3c (rows 206, 271 and 275 are in it). Branch morgana/row295.
Reviewer Dewey, gate owner Sage.
Row 193's viewer level landed with row 275 (3410df7c3), so it is on this base. The downgrade test and
its mutant build here, not on a 193 candidate (Q177 not needed).
## Plan
### Broker (packages/bus/src/broker.mjs)
1. `#revoked(business, t)`: true when `t.owner` is `user:<id>` and no grant in one read of the access
file covers the thread at collaborator level (`covers(g, {business, project: t.project, instance:
t.instance}, now, {level: 'collaborator'})`). A read that throws, a null document or no `access`
function covers nothing, as `#covered` does (fail closed). A thread the business human or the tester
owns is never revoked.
2. `#revokeLost(business, by)`: one read of the file, then one transaction that ends, with
`#claimEnd(..., 'revoke', null, 'access-revoked', by)`, the held claim of every session of every
user-owned thread `#revoked` names. Returns the ended runs. The claim is ended, so the run's next
bus call refuses `not-holder` (F10) and a later `role.claim` for it refuses `run-revoked`.
3. Triggers (design 3.7):
- a trusted method `accessSweep()`, not a socket verb, that runs `#revokeLost` for each business with
`by: 'access-timer'`. The runtime calls it on a 30 s `setInterval` (unref'd, cleared in close()),
only when `access` is configured.
- `access.reload`, human-only (`#human(s)`, so not the tester and not a reader), runs `#revokeLost`
for `s.business` with `by: s.human` and returns `{ ended: [runs] }`.
- every user request (`s.user`), any user's: `#revokeLost(by: 'access-change')` before the verb,
once per stamp change (sage-3139 Q1, D2). threadsState sweeps the same way (D3).
4. `threadsState` rows gain `revoked: true|false`, from one access read per call. `#threadRows`
selects `t.owner, t.project` for it. `threadIdle` ignores the field.
5. Launch check: `bindLaunch` refuses `thread-revoked` for a new run (`!old`) of a revoked thread, the
way `!old && !harnessOn` refuses `invalid-harness`, and `role.claim` refuses `thread-revoked` too.
A rebind (`old`, recover() at launcher.mjs:1174) is not refused, so recover() can still end the
earlier host's run. bindLaunch comes after spawn and a refusal there SIGKILLs the child
(launcher.mjs:608, :620), which covers a start racing the revocation; the claim check covers a run
bound just before it.
### Runtime (packages/bus/src/runtime.mjs)
6. With `access` set: `setInterval(() => broker.accessSweep(), access.intervalMs ?? 30_000)`, unref'd,
cleared in close() and on a failed start. A sweep that throws logs one line to stderr and the next
tick tries again. `access.intervalMs` follows `librarySync.intervalMs` (C2-03a): in-process only.
### Launcher (packages/cli/src/launcher.mjs)
7. threadPass: a `revoked` row never starts. A revoked row with a running session, not already
stopping, gets `stopping.add`, a `thread-revoked` record event and the idle stop's start-time-checked
SIGTERM at once, with no `threadIdle` call (the claim is already ended).
8. A bindLaunch refusal `thread-revoked` is a start refusal, logged once per code as today.
### CLI (packages/cli/src/access.mjs)
9. grant and revoke call `access.reload` after the write (project.list stays the human proof, since
grant needs its rows for project names; D6 for `user remove`). A refused reload after a good write:
stdout still reports the write, stderr says `access.reload refused: <code>; the bus's timer
applies the change within 30 s`, exit 0, because the file is written and a retry would hit
revision-conflict (sage-3139 Q2).
10. ACCESS_HELP keeps the provider-removal order; a test pins the sentence.
## Tests (each with a mutant)
Timers: `t.mock.timers` drives `setInterval` and `Date`; no test sleeps 30 s. The bus file is
packages/bus/tests/access-revoke.test.mjs (B), the launcher file packages/cli/tests/thread-revoke.test.mjs
(L), and the CLI tests are appended to packages/cli/tests/access.test.mjs (C).
| # | Test | File | Mutant |
|---|---|---|---|
| M1 | timer sweep after a hand edit ends only alice's run (by access-timer, reason access-revoked); its next call refuses not-holder; bob's and the human's runs stay | B | `#revokeLost` ends every user thread (drop the `#revoked` filter) |
| M2 | an expiry with no write and no request ends the run at the next sweep; bob's request on the same stamp doesn't | B | `accessSweep` not forced (`#revokeLost('access-timer')`) |
| M3 | a downgrade to viewer is a loss | B | level `'viewer'` in `#revoked` |
| M4 | bob's request after a hand edit ends alice's run, and nothing ends before it (sage-3139 Q1) | B | the per-request `#revokeLost` call removed |
| M5 | one read per stamp change, none while unchanged | B | `#accessDoc` ignores the stamp |
| M6 | access.reload ends at once for the human; tester, reader, agent refused human-required/read-only, user access-refused | B | drop `#human(s)` in access.reload |
| M7 | threadsState marks `revoked: true` on the lost thread only, its pending message kept | B | threadsState never adds `revoked` |
| M8 | bindLaunch and role.claim refuse thread-revoked; another broker's rebind is allowed | B | drop the bindLaunch check |
| M9 | the runtime timer ends the run at 30 000 ms and not at 29 999; intervalMs is checked; close stops it | B | the timer callback never calls `accessSweep` |
| M10 | pending message + launcher restart starts no run, logs no launch and no refused start | L | threadPass ignores `revoked` |
| M11 | a running revoked thread's run gets SIGTERM, ends `stopped` exit 0, with no thread-idle; its last claim row is revoke/access-revoked/access-change, not a release (dewey-0871 B1) | L | the revoked branch drops the kill |
| M12 | recover() rebinds an earlier host's run on a revoked thread and ends it host-lost (sage-3139 D1) | L | the bindLaunch check without `!old` |
| M13 | grant and revoke call access.reload after the write; list and a refused write don't | C | drop the `reload` call |
| M14 | a refused reload: exit 0, stdout as before, stderr names the code and the 30 s timer (sage-3139 Q2) | C | `reload` rethrows |
| M15 | help keeps the provider-removal order with the reload sentence after it | C | drop the help sentence |
| M16 | an expiry with no write and no sweep: threadsState ends the claim (revoke, access-revoked, by access-expiry) before it returns the row revoked with holder null; one revoke row only (dewey-0871 B1) | B | drop threadsState's `#revokeLost('access-expiry', true, doc)` call |
M11 relies on the runner's poll interval (set to 600 s in L), so only the signal ends the run: without
the kill, a run that polls would exit 22 on not-holder and hide the mutant.
## Sage's rulings (sage-3139)
- Q1 resolved, yes: a request from any user counts. The cost rule: a request only stats the file;
`#revokeLost` runs only on a stat change and reuses the read R3's check makes. Tests M4 and M5.
- Q2 resolved, exit 0: stderr names the refusal code and the 30 s timer, stdout reports the write as
today. Help text next to the provider-removal order. Tests M14 and M15.
- D1 accepted with a test (M12).
- Base 253346e3c; rebase before the gate if broker.mjs or launcher.mjs change on refactor.
## Decisions
- D1: bindLaunch's revoked check skips a rebind. recover() binds the earlier host's run and then ends
it; a refusal there would leave the run unended in the broker (launcher.mjs:1173-1179).
- D2, the stamp: the runtime passes `accessStamp`, an lstat key (dev, ino, size, mtimeNs, ctimeNs,
mode, uid; a failed stat is `unreadable:<code>`). `#accessDoc` reuses the last read while the
stamp is unchanged, and the sweep runs once per stamp (`#accessSwept`). The timer and
access.reload force a read, so an expiry with no write is caught by the timer. With no stamp
function (the bus tests' old fixtures), every read reads, as R3 did, so user-principal.test's
reads+2 pin holds: a request reads once and hands the document to both the sweep and the grants.
- D3: threadsState also sweeps on a stamp change, so the launcher's tick sees the claim already
ended when it gets `revoked: true`. `revoked` is added only when true, so end-launch.test's
deepEqual of a threadsState row still holds.
- D4: `#threadLost` (bindLaunch, role.claim) only reads. role.claim runs inside the verb's
transaction and store.transaction refuses nesting, so the claim end waits for the next sweep.
- D5: a session in a clear is skipped in threadPass before the revoked branch (clearPass owns it).
Its successor's start goes through bindLaunch, which refuses thread-revoked, so no run starts.
A drained run of a revoked thread already had its claim ended by the sweep.
- D6: `user remove` doesn't call access.reload: it refuses while the user holds a grant, so it
never removes coverage. The brief names grant and revoke.
- D7 (Dewey's B1, dewey-0871): an expiry changes no stamp, so the stamp check skips the sweep while
`#revoked` with the current time already marks the row. threadsState therefore ends the lost
claims itself when a lost row still has a holder, forced, from the cached document (no read, so
sage-3139's cost rule holds), `by: 'access-expiry'`, and re-reads the rows. The launcher (2 s
tick) then never sees `revoked: true` with a held claim, and the run's end is never a release.
The request path is unchanged.
- D8 (Dewey's N1): access.reload ends lost claims in every business, since the access file is
shared, and writes the calling business's human as `by` on each. It returns only the caller's
business's ended runs, so one business's human learns nothing of another's threads.
## Review round 1 (dewey-0871, CHANGES on 527002f97)
- B1 fixed: D7, test M16, M11 assertion. N1: D8. N2: packages/bus/README.md gets the access.reload
row, `revoked` in threadsState, a revocation paragraph (timer, accessSweep, access.intervalMs,
the four triggers) and the stamp-cached wording.
- N3, at the rebase on 294 (c866a8a69): 294's accessFor passes `access` to the live boot, so the
timer and request sweep go live with both rows; fix 294's "reads the file on every user request"
comment to stamp-cached, and check its ACCESS_HELP edit against M15's ordering assertion.
- N4: the touched whole files (user-principal, viewer, thread-shares) run whole in the bus suite;
a first-run red goes per mos-1272.
+20 -3
View File
@@ -211,6 +211,7 @@ an operator inspect content and delivery evidence after an uncertain result.
| `review.verdict` | `{task_ref,request,verdict,body,decision?}`; action `review.verdict`. `verdict` is `approve` or `changes`. `request` must be a `review.requested` event on the task addressed to the caller's role (`request-not-found`), with no verdict yet (`verdict-exists`). Writes a `review.verdict` event `{task_ref,request,commit,verdict,message}` and sends the requesting role a REVIEW-RESULT in reply to the request's message. Returns `{verdict,message,commit}`. |
| `event.emit` | `{kind:'action.allowed',body:{action:'routine',target?},subject?}`; routine observations only. |
| `launch.revoke`, `launch.restore` | No args; outside-agent human CLI only. |
| `access.reload` | No args; the outside-agent human CLI only (row 295, #1801, FED R4): the tester and an agent get `human-required`, a reader `read-only`, a user `access-refused`. Reads the access file whatever its stamp and ends the lost claims of every business, since the file is shared, with `by` the human. Returns `{ended:[{thread,session,run}]}` for the caller's business only. `mosaic access grant` and `revoke` call it after the write. |
| `inbox` | No args; plain JSON projection scoped to capability's business. |
| `tasks` | Optional args, scoped to capability's business (row 89, #1583). None: every row of the tasks projection (below). `{task_ref}` alone: that one row, or `task-not-found`. Any of `{done,assignee,label,requirement,limit,after}`: `{tasks,total,next}`, the short form (below). An unknown arg or bad value refuses `invalid-request`. |
| `agents` | No args; each held role's latest claim row in the capability's business. Human and reader sessions also get `state`, `working`, `inputNeeded` and `stranded` (Agent state, below); a role session gets the claim rows only. |
@@ -305,8 +306,9 @@ Only a human principal matches a share: the business human, a reader session as
a user. An agent session matches none, `*` included, even when it is bound to the thread.
A reader session reads as the business human. A sharee can't call `session.list` on the
thread; it stays owner-only until row 193's share wiring. The check runs on every request against the
access document the broker reads then, so a removed, expired or unreadable grant or an
unshare ends reads on the next request. Only `thread.messages` admits a sharee; `thread.open`,
access document as of that request: the broker stats the file each time and reads it again only
when the stat changed (row 295), so a removed, expired or unreadable grant or an unshare ends
reads on the next request. Only `thread.messages` admits a sharee; `thread.open`,
`thread.send` and the share verbs refuse it `thread-not-owned`. `trail` and `replies` show a
user's thread messages to the owner and readers while the check holds. The tester can't call
the share verbs (`tester-refused`), and a viewer is refused them `access-refused`; a viewer
@@ -316,7 +318,22 @@ reads a thread shared with it. Tests: `tests/thread-shares.test.mjs`.
Per thread it returns `{thread,instance,pending,unread,lastMessage,lastOpen,lastClaim,holder}`:
messages to the instance not yet delivered, messages delivered to the current holder and
not yet read, the latest message, open and claim times, and the holding run or `null`. The
launcher reads it to start and stop thread sessions (`packages/cli/README.md`).
launcher reads it to start and stop thread sessions (`packages/cli/README.md`). A row adds
`revoked: true` when its thread's user owner has lost the grant (below); the launcher never
starts such a thread and sends a running one SIGTERM.
Revocation (row 295, #1801, FED R4, federation design 3.7). A user thread is lost when no grant
in the access file covers it at collaborator level, so a downgrade to viewer is a loss. One
transaction ends the held claim of every session of every lost thread, op `revoke`, reason
`access-revoked`; the run's next bus call refuses `not-holder`, and a new run of the thread
refuses `thread-revoked` at `bindLaunch` and at `role.claim`. A rebind of an earlier host's run is
allowed, so the launcher's recovery can end it. Four triggers end the claims, each with its `by`:
the runtime's timer, every 30 s (`access.intervalMs` sets it in process, as `librarySync.intervalMs`
does), which reads the file whatever its stamp (`access-timer`); a user's request or a
`threadsState` call after the file's stat changed (`access-change`); a `threadsState` row that is
lost with its claim still held, as when a grant expired with no write, from the same read
(`access-expiry`); and `access.reload` (the human). The timer calls `accessSweep()`, a trusted
broker method, not a verb. Tests: `tests/access-revoke.test.mjs`.
`threadIdle({business,thread,run})` is the launcher's idle stop, also trusted and not a
verb. It rechecks the thread and releases the run's claim in one call, and returns
+105 -13
View File
@@ -396,6 +396,12 @@ export class Broker {
// FED R3: () => the checked access document (R1's loadAccess), read once per user request. Null,
// a throw or no document leaves every user with no grant (design 3.5, fail closed).
#access;
// FED R4 (#1801, design 3.7): () => a key that changes when the access file does (dev, inode,
// size, mtime, ctime), or null. With it a request only stats the file; without it every read reads.
#accessStamp;
// The last read, { stamp, doc }, and the stamp whose losses #revokeLost has already ended.
#accessSeen = null;
#accessSwept;
#compactions;
// r6 X3: interrupted host grants whose remote undo waits for reconcileHosts, each with its claim.
#hostUndo = [];
@@ -422,13 +428,14 @@ export class Broker {
#clock = 0;
#processing;
// credentialStatus is the runtime's metadata-only Credentials.status(); the broker never gets use().
constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, library = null, usage = null, limits = null, harnessOn = null, credentialVerbs = null, hostGrant = null, hostKeys = null, processing = null, testerOn = null, roleFile = null, giteaGrant = null, giteaKeys = null, access = null, compactions = null }) {
constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, library = null, usage = null, limits = null, harnessOn = null, credentialVerbs = null, hostGrant = null, hostKeys = null, processing = null, testerOn = null, roleFile = null, giteaGrant = null, giteaKeys = null, access = null, accessStamp = null, compactions = null }) {
if (!object(businesses) || !Object.keys(businesses).length) fail('invalid-business');
// Row 194 b15 (sage-2501): the function-argument checks come before anything reads the store.
if (harnessOn !== null && typeof harnessOn !== 'function') throw new TypeError('harnessOn must be a function');
if (testerOn !== null && typeof testerOn !== 'function') throw new TypeError('testerOn must be a function');
if (roleFile !== null && typeof roleFile !== 'function') throw new TypeError('roleFile must be a function');
if (access !== null && typeof access !== 'function') throw new TypeError('access must be a function');
if (accessStamp !== null && typeof accessStamp !== 'function') throw new TypeError('accessStamp must be a function');
if (compactions !== null && typeof compactions !== 'function') throw new TypeError('compactions must be a function');
this.#businesses = structuredClone(businesses);
this.#store = store;
@@ -449,6 +456,7 @@ export class Broker {
this.#giteaGrant = giteaGrant;
this.#giteaKeys = giteaKeys;
this.#access = access;
this.#accessStamp = accessStamp;
this.#compactions = compactions;
this.#project = project;
this.#library = library;
@@ -903,13 +911,7 @@ export class Broker {
// FED R3 (design 3.5): the user's live grants in the session's business, from one read of the
// access file. Any failure to read leaves none. Row 275: a grant at any level counts here; each
// verb's own check asks the level it needs (#granted).
#userGrants(s) {
let doc = null;
try {
doc = this.#access?.() ?? null;
} catch {
doc = null;
}
#userGrants(s, doc = this.#accessDoc()) {
const now = new Date();
return (doc?.grants ?? []).filter((g) => g.user === s.user && g.business === s.business &&
covers(g, { business: s.business, project: g.projects[0], instance: g.instances[0] }, now, { level: 'viewer' }));
@@ -965,15 +967,72 @@ export class Broker {
// Does a live grant of any level cover thread `t` for `user`? One read of the access file, as
// #userGrants does; any failure to read covers nothing (design 5.6, H3).
#covered(user, business, t) {
const doc = this.#accessDoc();
const now = new Date();
return (doc?.grants ?? []).some((g) => g.user === user &&
covers(g, { business, project: t.project, instance: t.instance }, now, { level: 'viewer' }));
}
// FED R4 (#1801, design 3.7): the access document, from one read per change of the file. A stamp
// equal to the last read's reuses that read; `force` (the timer, access.reload) reads regardless.
// A read that throws, or no access function, is no document: every user loses every grant.
#accessDoc(force = false) {
let stamp = null;
try {
stamp = this.#accessStamp?.() ?? null;
} catch {
stamp = null;
}
if (!force && stamp !== null && this.#accessSeen?.stamp === stamp) return this.#accessSeen.doc;
let doc = null;
try {
doc = this.#access?.() ?? null;
} catch {
doc = null;
}
this.#accessSeen = { stamp, doc };
return doc;
}
// Is thread `t` owned by a user whom no grant in `doc` covers at collaborator level any more? A
// downgrade to viewer is a loss (design 3.7, morgana-0005). The human's and the tester's never are.
#revoked(t, doc, now) {
if (!t.owner.startsWith('user:')) return false;
const user = t.owner.slice(5);
return !(doc?.grants ?? []).some((g) => g.user === user &&
covers(g, { business: t.business, project: t.project, instance: t.instance }, now, { level: 'collaborator' }));
}
// Design 3.7: on a loss, one transaction ends the held claim of every session of every user thread
// the access file no longer covers, in every business. The run's next bus call refuses not-holder
// (F10), and a later claim refuses thread-revoked. Runs only when the read is new: a forced read,
// a stamp it hasn't swept, or no stamp at all. `doc` is a read the caller just made, so a request
// reads once. Returns the ended claims.
#revokeLost(by, force = false, doc = this.#accessDoc(force)) {
const stamp = this.#accessSeen.stamp;
if (!force && stamp !== null && stamp === this.#accessSwept) return [];
const now = new Date();
return (doc?.grants ?? []).some((g) => g.user === user &&
covers(g, { business, project: t.project, instance: t.instance }, now, { level: 'viewer' }));
const lost = this.#store.all("SELECT * FROM threads WHERE substr(owner,1,5)='user:' ORDER BY seq").filter((t) => this.#revoked(t, doc, now));
const ended = [];
if (lost.length) this.#store.transaction(() => {
for (const t of lost)
for (const h of this.#threadHolders(t.business, t.instance, t.owner))
if (h.thread === t.id) {
this.#claimEnd({ business: t.business }, h, 'revoke', null, 'access-revoked', by);
ended.push(h);
}
});
this.#accessSwept = stamp;
return ended;
}
// Is thread `id` of `business` revoked, from the file as of now? Only reads: role.claim runs it
// inside the verb's transaction, so the claim end is left to the next sweep.
#threadLost(business, id) {
const t = this.#store.get('SELECT * FROM threads WHERE id=? AND business=?', id, business);
// Only a user's thread reads the file, so a human's or tester's run never does.
return Boolean(t?.owner.startsWith('user:')) && this.#revoked(t, this.#accessDoc(), new Date());
}
// Trusted host API, not a socket verb: the runtime's 30 s timer (design 3.7 trigger 2). It reads
// the file whatever its stamp, so an expiry with no write and no request ends its runs too.
accessSweep() {
return this.#revokeLost('access-timer', true).map((h) => ({ business: h.business, thread: h.thread, session: h.session, run: h.holder_run }));
}
// The threads another principal owns that the session may read, oldest first.
#readableThreads(s, project) {
@@ -1033,9 +1092,23 @@ export class Broker {
// (a turn in progress), and the latest message, open and claim times. A message delivered
// to an earlier run and never read (it crashed mid-turn) isn't the holder's turn, so it
// doesn't keep the holder from idling out.
// FED R4 (design 3.7): a row adds revoked: true for a thread the access file no longer covers, as
// of this call, so a launcher restart never starts one. A changed file also ends its lost runs here.
// A grant that expired with no change to the file (Dewey's B1 in dewey-0871) leaves a revoked row
// whose claim the stamp check didn't end: that row ends the lost claims from the same read, by
// access-expiry, before the launcher sees it, so the run's end is never a release.
threadsState(business) {
this.#business(identifier(business));
return this.#threadRows('t.business=?', business);
this.#revokeLost('access-change');
const doc = this.#accessSeen?.doc ?? null;
const now = new Date();
const lost = (r, owner, project) => this.#revoked({ business, owner, project, instance: r.instance }, doc, now);
let rows = this.#threadRows('t.business=?', business);
if (rows.some(({ owner, project, ...r }) => r.holder !== null && lost(r, owner, project))) {
this.#revokeLost('access-expiry', true, doc);
rows = this.#threadRows('t.business=?', business);
}
return rows.map(({ owner, project, ...r }) => (lost(r, owner, project) ? { ...r, revoked: true } : r));
}
// Row 121 round 2 (#1615, Dewey's F3 in 28591): the launcher's idle stop. The tick decides
// from a threadsState snapshot, and a message the run took after it would be delivered to a
@@ -1203,7 +1276,7 @@ export class Broker {
#threadRows(where, ...params) {
return this.#store
.all(
`SELECT t.id, t.instance, se.id AS session, se.op AS session_op,
`SELECT t.id, t.instance, t.owner, t.project, se.id AS session, se.op AS session_op,
(SELECT count(*) FROM messages m JOIN message_threads mt ON mt.message=m.id WHERE mt.session=se.id AND m.to_role=t.instance
AND NOT EXISTS(SELECT 1 FROM deliveries d WHERE d.message=m.id AND d.op IN ('delivered','read'))
AND NOT EXISTS(SELECT 1 FROM message_cancels x WHERE x.message=m.id)) AS pending,
@@ -1221,6 +1294,8 @@ export class Broker {
.map((r) => ({
thread: r.id,
instance: r.instance,
owner: r.owner,
project: r.project,
pending: r.pending,
unread: r.unread,
lastMessage: r.last_message,
@@ -1285,6 +1360,9 @@ export class Broker {
// harness was switched off, so it can be ended.
if (!old) this.#processingWork();
if (!old && !this.#harnessOn(record.business, record.harness)) fail('invalid-harness');
// FED R4 (design 3.7, D1): a new run of a revoked thread refuses, which covers a start racing the
// revocation. recover()'s rebind of an earlier run isn't refused, so it can still end that run.
if (!old && record.thread !== undefined && this.#threadLost(record.business, record.thread)) fail('thread-revoked');
if (old) {
if (old.actor_role !== record.role || old.body !== JSON.stringify(body)) fail('launch-record-mismatch');
// An ended run stays ended across broker restarts.
@@ -4450,8 +4528,12 @@ export class Broker {
// request reads the access file again, so a removed or expired grant refuses the next one.
let grants = null;
if (s.user) {
// FED R4 (sage-3139): any user's request that finds the file changed ends the lost runs. One
// read serves the sweep and the grants, and none at all while the file's stamp is unchanged.
const doc = this.#accessDoc();
this.#revokeLost('access-change', false, doc);
if (!USER_VERBS.includes(request.verb)) fail('access-refused');
grants = this.#userGrants(s);
grants = this.#userGrants(s, doc);
if (!grants.length) fail('access-revoked');
}
const args = request.args ?? {};
@@ -4463,6 +4545,14 @@ export class Broker {
keys(args, []);
return this.#processingState();
}
// FED R4 (#1801, design 3.7 trigger 3): the human's re-read of the access file, which R5's grant
// and revoke call after the write. Human-only, so not a tester, a reader, an agent or a user.
if (request.verb === 'access.reload') {
this.#human(s);
keys(args, []);
const ended = this.#revokeLost(s.human, true).filter((h) => h.business === s.business);
return { ended: ended.map((h) => ({ thread: h.thread, session: h.session, run: h.holder_run })) };
}
this.#processingGate(s, request.verb);
// Row 213 (C2-08, Case 2 design 5.1 M6): a creative role has no free-text decision channel. It
// runs before the spec-cited branch below, so a cited raise refuses too.
@@ -5154,6 +5244,8 @@ export class Broker {
if (s.thread) {
if (this.#store.get("SELECT 1 FROM thread_claims WHERE session=? AND holder_run=? AND op='revoke' LIMIT 1", s.session, s.run))
fail('run-revoked');
// FED R4 (design 3.7): no run takes a thread the access file no longer covers.
if (this.#threadLost(s.business, s.thread)) fail('thread-revoked');
// Row 271 (264d, B2): no run takes a session being cleared, or a retired one.
this.#sessionFence(s.session);
if (this.#sessionHolder(s.session)?.op === 'claim') fail('thread-already-held');
+32 -4
View File
@@ -1,4 +1,4 @@
import { readdirSync } from 'node:fs';
import { lstatSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { Store } from './store.mjs';
@@ -37,6 +37,8 @@ import { createContainerStopper } from '../../processing-control/src/containers.
// Row 194 (D3): the verbs the instance writer commits, whose answers carry restart.
const INSTANCE_VERBS = new Set(['instances.set', 'veto.set', 'instance.hire', 'instance.fire']);
// FED R4 (#1801, design 3.7): the revocation timer's period.
export const ACCESS_SWEEP_MS = 30_000;
// Trusted host API. S1 supplies resolved definitions, S6 supplies launch records and ends them.
// Human limits writes use the shared organization writer behind the verified transport.
// `tasks` is S3's adapter factory: ({broker, credentials, businesses}) => {handle, timeout, close}.
@@ -101,16 +103,18 @@ export async function startBroker({
// a librarySync without one is refused.
librarySync = null,
// FED R3 (#414): the access file and its loader context, { file, context } (R1's loadAccess).
// Without it no user binds. R6 and R8 pass it; the live boot config doesn't yet.
// 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,
}) {
if (access !== null && (typeof access?.file !== 'string' || access.context === null || typeof access.context !== 'object'))
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');
if (librarySync !== null && (typeof librarySync?.command !== 'function'
|| (Object.hasOwn(librarySync, 'intervalMs') && !(Number.isSafeInteger(librarySync.intervalMs) && librarySync.intervalMs > 0))
|| (Object.hasOwn(librarySync, 'configDir') && typeof librarySync.configDir !== 'string')))
throw new BusError('invalid-host-request');
let store, credentials, server, adapter, processing, syncTimer = null, syncPass = null;
let store, credentials, server, adapter, processing, syncTimer = null, syncPass = null, accessTimer = null;
try {
const references = {};
for (const [business, b] of Object.entries(businesses)) {
@@ -262,6 +266,16 @@ export async function startBroker({
broker = new Broker({
// FED R3: the access file is read per user request; a refusal leaves the user no grant.
access: access ? () => loadAccess(access.file, access.context) : null,
// FED R4 (#1801, design 3.7 trigger 1): a request stats the file and reads it only when this
// changes. A failed stat is a stamp of its own, so a removed file reads (and refuses) once.
accessStamp: access ? () => {
try {
const st = lstatSync(access.file, { bigint: true });
return `${st.dev}:${st.ino}:${st.size}:${st.mtimeNs}:${st.ctimeNs}:${st.mode}:${st.uid}`;
} catch (e) {
return `unreadable:${e.code ?? 'error'}`;
}
} : null,
// Row 270 (264c): session.list's compaction count, read from the session store's ledger.
compactions: ({ business, thread, session, legacy }) => {
const dir = storeDir(dataRoot, business, thread, session, { legacy });
@@ -558,6 +572,18 @@ export async function startBroker({
syncTimer.unref();
}
const stopSync = async () => { clearInterval(syncTimer); syncTimer = null; await syncPass; };
// FED R4 (#1801, design 3.7 trigger 2): every 30 s the broker re-reads the access file, so a hand
// edit or an expiry with no request ends the lost runs within one period.
if (access) {
accessTimer = setInterval(() => {
try {
broker.accessSweep();
} catch (e) {
process.stderr.write(`access: ${JSON.stringify({ event: 'access-sweep-failed', code: e.code ?? 'error' })}\n`);
}
}, access.intervalMs ?? ACCESS_SWEEP_MS);
accessTimer.unref();
}
return {
broker,
credentials,
@@ -605,6 +631,7 @@ export async function startBroker({
launches: bound,
readers: readCaps,
async close() {
clearInterval(accessTimer);
await stopSync();
await server.close();
await adapter?.close?.();
@@ -614,6 +641,7 @@ export async function startBroker({
},
};
} catch (e) {
clearInterval(accessTimer);
await server?.close();
await adapter?.close?.();
await processing?.close();
+272
View File
@@ -0,0 +1,272 @@
// FED R4 (#1801, federation design 3.7 and the R4 row of section 7): revocation. A loss of a user's
// grant ends the claim of every run on that user's threads, from one of three triggers: the timer's
// accessSweep, the human's access.reload, and the next request of any user once the file changed.
// The access document and its stamp come in as functions, as the runtime passes loadAccess and an
// lstat key; `edit` changes the document and the stamp together, as a hand edit does. BUILD.md
// (agents/morgana/work/queue-295) names the mutant each test kills.
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { Store } from '../src/store.mjs';
import { Broker } from '../src/broker.mjs';
import { ACCESS_SWEEP_MS, startBroker } from '../src/runtime.mjs';
const shipped = (name) =>
JSON.parse(readFileSync(fileURLToPath(new URL(`../../../roles/${name}.json`, import.meta.url)), 'utf8'));
const grant = (over = {}) => ({
id: `g-${over.user ?? 'alice'}`, user: 'alice', business: 'demo', projects: ['*'], instances: ['assistant'], level: 'collaborator', ...over,
});
const BUSINESSES = {
demo: {
id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, vars: { 'bus.tester': true },
roles: {
chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send'], crossRole: [] } },
assistant: { definition: 'assistant', authority: { withinRole: ['message.send'], crossRole: [] } },
},
definitions: { 'chief-of-staff': {}, assistant: { messaging: shipped('assistant').messaging } },
},
};
// Alice and Bob each hold a collaborator grant and own a thread; the human owns thread H. With
// `runs`, each thread has a claimed run: a-run on A, b-run on B, h-run on H.
function setup(t, { runs = true, grants = [grant(), grant({ user: 'bob' })] } = {}) {
const root = mkdtempSync(join(tmpdir(), 'bus-access-revoke-'));
const store = new Store(root);
t.after(() => {
store.close();
rmSync(root, { recursive: true, force: true });
});
// `reads` counts loader calls; `rev` is the stamp, which a hand edit changes.
const access = { doc: { accessVersion: 1, revision: 1, users: [], grants }, reads: 0, rev: 1 };
const broker = () => new Broker({
store,
businesses: BUSINESSES,
access: () => {
access.reads++;
return access.doc;
},
accessStamp: () => String(access.rev),
});
const b = broker();
const caps = {
human: b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }),
tester: b.bindTester({ business: 'demo' }),
reader: b.bindReader({ business: 'demo' }),
};
const call = (who, verb, args = {}) => b.request(caps[who], { verb, args });
const code = (who, verb, args = {}) => {
try {
call(who, verb, args);
} catch (e) {
return e.code;
}
return null;
};
const edit = (fn) => {
access.doc = { ...access.doc, revision: access.doc.revision + 1, grants: fn(access.doc.grants) };
access.rev++;
};
const record = (name, thread) => ({ business: 'demo', role: 'assistant', run: `${name}-run`, harness: 'pi', thread });
const launch = (name, thread) => {
caps[name] = b.bindLaunch(record(name, thread));
return call(name, 'role.claim');
};
// The latest claim row of a run: its op, reason and by.
const claim = (run) => store.get('SELECT op, reason, by FROM thread_claims WHERE holder_run=? ORDER BY seq DESC LIMIT 1', run);
const P = call('human', 'project.create', { name: 'shared' }).id;
caps.alice = b.bindUser({ business: 'demo', user: 'alice' });
caps.bob = b.bindUser({ business: 'demo', user: 'bob' });
const A = call('alice', 'thread.create', { project: P, name: 'alice work' }).id;
const B = call('bob', 'thread.create', { project: P, name: 'bob work' }).id;
const H = call('human', 'thread.create', { project: P, name: 'owner work' }).id;
if (runs) {
launch('a', A);
launch('b', B);
launch('h', H);
}
return { b, store, access, caps, call, code, edit, record, launch, claim, broker, P, A, B, H };
}
const withoutAlice = (gs) => gs.filter((g) => g.user !== 'alice');
test('R4: the timer sweep after a hand edit ends only the lost user\'s run; its next call refuses not-holder', (t) => {
const { b, code, edit, claim, A } = setup(t);
edit(withoutAlice);
const ended = b.accessSweep();
assert.equal(ended.length, 1);
assert.deepEqual({ ...ended[0], session: undefined }, { business: 'demo', thread: A, session: undefined, run: 'a-run' });
assert.equal(typeof ended[0].session, 'string');
assert.deepEqual(claim('a-run'), { op: 'revoke', reason: 'access-revoked', by: 'access-timer' });
assert.equal(code('a', 'message.receive'), 'not-holder', 'the ended run');
assert.equal(code('b', 'message.receive'), null, 'another user\'s run stays');
assert.equal(code('h', 'message.receive'), null, 'the human\'s run stays');
assert.deepEqual([claim('b-run').op, claim('h-run').op], ['claim', 'claim']);
// Ended once: a second sweep finds nothing to end.
assert.deepEqual(b.accessSweep(), []);
});
test('R4: an expiry with no write and no request ends the run at the next timer sweep', (t) => {
t.mock.timers.enable({ apis: ['Date'], now: Date.parse('2026-10-23T00:00:00Z') });
const { b, code, claim } = setup(t, { grants: [grant({ expires: '2026-10-23T00:01:00Z' }), grant({ user: 'bob' })] });
assert.deepEqual(b.accessSweep(), [], 'live');
t.mock.timers.tick(60_001);
// Bob's request sees the same stamp, so it doesn't sweep: the timer is what catches an expiry.
assert.equal(code('bob', 'thread.list'), null);
assert.equal(claim('a-run').op, 'claim');
assert.deepEqual(b.accessSweep().map((h) => h.run), ['a-run']);
assert.equal(code('a', 'message.receive'), 'not-holder');
assert.equal(code('b', 'message.receive'), null);
});
test('R4: a downgrade to viewer is a loss', (t) => {
const { b, code, edit, claim } = setup(t);
edit((gs) => gs.map((g) => (g.user === 'alice' ? { ...g, level: 'viewer' } : g)));
assert.deepEqual(b.accessSweep().map((h) => h.run), ['a-run']);
assert.deepEqual(claim('a-run'), { op: 'revoke', reason: 'access-revoked', by: 'access-timer' });
assert.equal(code('b', 'message.receive'), null);
});
// sage-3139 Q1: no timer runs here, so only the request trigger can end alice's run.
test('R4: another user\'s request after a hand edit ends the lost user\'s run, and its own run stays', (t) => {
const { code, edit, claim } = setup(t);
edit(withoutAlice);
assert.equal(claim('a-run').op, 'claim', 'nothing ends before a request');
assert.equal(code('a', 'message.receive'), null, 'an agent request is not a trigger');
assert.equal(code('bob', 'thread.list'), null);
assert.deepEqual(claim('a-run'), { op: 'revoke', reason: 'access-revoked', by: 'access-change' });
assert.equal(code('a', 'message.receive'), 'not-holder');
assert.equal(code('b', 'message.receive'), null);
assert.equal(code('h', 'message.receive'), null);
});
// sage-3139 Q1, the cost rule: a request only stats the file, and reads it once per change.
test('R4: a request reads the access file only when its stamp changed, once per change', (t) => {
const { access, code, edit } = setup(t);
const reads = access.reads;
assert.equal(code('bob', 'thread.list'), null);
assert.equal(code('alice', 'project.list'), null);
assert.equal(access.reads, reads, 'unchanged: no read');
edit((gs) => gs);
assert.equal(code('bob', 'thread.list'), null);
assert.equal(code('alice', 'thread.list'), null);
assert.equal(access.reads, reads + 1, 'changed: one read for both requests');
});
test('R4: access.reload ends the lost runs at once for the human; the tester, a reader, an agent and a user are refused', (t) => {
const { b, code, call, edit, claim, A } = setup(t);
assert.equal(code('tester', 'access.reload'), 'human-required', 'tester');
assert.equal(code('reader', 'access.reload'), 'read-only', 'reader');
assert.equal(code('b', 'access.reload'), 'human-required', 'agent');
assert.equal(code('bob', 'access.reload'), 'access-refused', 'user');
assert.deepEqual(call('human', 'access.reload'), { ended: [] }, 'nothing lost');
assert.equal(code('human', 'access.reload', { force: true }), 'invalid-request');
edit(withoutAlice);
const { ended } = call('human', 'access.reload');
assert.deepEqual(ended.map((h) => [h.thread, h.run]), [[A, 'a-run']]);
assert.equal(typeof ended[0].session, 'string');
assert.deepEqual(claim('a-run'), { op: 'revoke', reason: 'access-revoked', by: 'jason' });
assert.equal(code('a', 'message.receive'), 'not-holder');
assert.equal(code('b', 'message.receive'), null);
assert.deepEqual(b.accessSweep(), []);
});
test('R4: threadsState marks revoked: true on the lost user\'s thread only, a pending message included', (t) => {
const { b, call, edit, A, B, H } = setup(t, { runs: false });
call('alice', 'thread.send', { thread: A, body: 'for A' });
call('bob', 'thread.send', { thread: B, body: 'for B' });
const row = (id) => b.threadsState('demo').find((x) => x.thread === id);
assert.equal(row(A).revoked, undefined, 'covered');
edit(withoutAlice);
assert.equal(row(A).revoked, true);
assert.equal(row(A).pending, 1, 'its message is still pending');
assert.equal(Object.hasOwn(row(B), 'revoked'), false);
assert.equal(Object.hasOwn(row(H), 'revoked'), false);
assert.equal(Object.hasOwn(row(A), 'owner'), false, 'the row keeps its old fields');
});
// Dewey's B1 (dewey-0871): the launcher ticks faster than the timer, so a grant that expires with no
// write must not reach it as revoked: true with the claim still held.
test('R4: threadsState after an expiry with no write and no sweep ends the claim before it marks the row revoked', (t) => {
t.mock.timers.enable({ apis: ['Date'], now: Date.parse('2026-10-23T00:00:00Z') });
const { b, code, claim, store, A, B } = setup(t, { grants: [grant({ expires: '2026-10-23T00:01:00Z' }), grant({ user: 'bob' })] });
const row = (id) => b.threadsState('demo').find((x) => x.thread === id);
assert.equal(row(A).revoked, undefined, 'live');
t.mock.timers.tick(60_001);
const a = row(A);
assert.deepEqual([a.revoked, a.holder], [true, null]);
assert.deepEqual(claim('a-run'), { op: 'revoke', reason: 'access-revoked', by: 'access-expiry' });
assert.equal(code('a', 'message.receive'), 'not-holder');
assert.deepEqual([row(B).holder, claim('b-run').op], ['b-run', 'claim']);
// Ended once: the next call writes no second revoke.
row(A);
assert.equal(store.get("SELECT count(*) AS n FROM thread_claims WHERE holder_run='a-run' AND op='revoke'").n, 1);
});
test('R4: a new run of a revoked thread refuses thread-revoked at bind and at claim; a rebind is allowed', (t) => {
const { b, call, edit, record, broker, A, B } = setup(t, { runs: false });
const late = b.bindLaunch(record('late', A));
edit(withoutAlice);
assert.throws(() => b.request(late, { verb: 'role.claim' }), { code: 'thread-revoked' }, 'bound before, claimed after');
assert.throws(() => b.bindLaunch(record('new', A)), { code: 'thread-revoked' }, 'bound after');
assert.ok(b.bindLaunch(record('other', B)), 'bob\'s thread binds');
call('bob', 'thread.list');
// D1: a later broker over the same store rebinds the run an earlier one bound (recover()), so the
// host can end it.
const again = broker();
assert.ok(again.bindLaunch(record('late', A)));
assert.equal(again.endLaunch({ business: 'demo', run: 'late-run', reason: 'recovered' }).released, false);
});
// The runtime's timer (design 3.7 trigger 2) on a real 0600 access file read by loadAccess. The mocked
// setInterval stands in for 30 s; the run is watched through its own calls, since threadsState and a
// user request would sweep by themselves.
test('R4 runtime: a hand edit ends the run at the first timer period, intervalMs is checked, and close stops the timer', async (t) => {
t.mock.timers.enable({ apis: ['setInterval'] });
const root = mkdtempSync(join(tmpdir(), 'bus-access-timer-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const dataRoot = join(root, 'data');
mkdirSync(dataRoot, { mode: 0o700 });
const file = join(root, 'access.json');
const write = (grants) => writeFileSync(file, JSON.stringify({
accessVersion: 1, revision: 1, users: [{ id: 'alice', home: 'local', subjects: [{ provider: 'authentik', sub: 'alice-sub' }] }], grants,
}), { mode: 0o600 });
write([grant()]);
const context = { providers: ['authentik'], peers: [], businesses: { demo: { human: 'jason', instances: ['chief', 'assistant'] } }, ssoSubjects: [] };
for (const intervalMs of [0, -1, 1.5, '30000'])
await assert.rejects(startBroker({ dataRoot, businesses: BUSINESSES, access: { file, context, intervalMs } }), { code: 'invalid-host-request' }, String(intervalMs));
const runtime = await startBroker({ dataRoot, businesses: BUSINESSES, access: { file, context } });
let closed = false;
t.after(() => closed || runtime.close());
const b = runtime.broker;
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const alice = b.bindUser({ business: 'demo', user: 'alice' });
const P = b.request(human, { verb: 'project.create', args: { name: 'shared' } }).id;
const A = b.request(alice, { verb: 'thread.create', args: { project: P, name: 'alice work' } }).id;
const run = b.bindLaunch({ business: 'demo', role: 'assistant', run: 'a-run', harness: 'pi', thread: A });
b.request(run, { verb: 'role.claim' });
const receive = () => {
try {
b.request(run, { verb: 'message.receive' });
} catch (e) {
return e.code;
}
return null;
};
write([]);
t.mock.timers.tick(ACCESS_SWEEP_MS - 1);
assert.equal(receive(), null, 'before the first period');
t.mock.timers.tick(1);
assert.equal(receive(), 'not-holder', 'at the first period');
// After close the timer is gone: the sweep is never called again.
let sweeps = 0;
b.accessSweep = () => void sweeps++;
t.mock.timers.tick(ACCESS_SWEEP_MS);
assert.equal(sweeps, 1, 'one more period, one sweep');
closed = true;
await runtime.close();
t.mock.timers.tick(ACCESS_SWEEP_MS * 3);
assert.equal(sweeps, 1, 'closed');
});
+24 -10
View File
@@ -26,9 +26,9 @@ export const ACCESS_USAGE = [
// The help text. A grant's `instances` is the real limit: the granted instance
// runs with the owner's role, so a collaborator drives it with that authority.
// Removing a user acts at once. Removing a provider from console.sso waits for a
// Console restart, so take the users out of access.json first. A user's home is
// local or a peer named in federation.peers (FED R6, #1800). A grant naming a
// subject needs console.sso.
// Console restart, so take the users out of access.json first. Peer homes are
// empty until the federation key lands (row 203), so a grant naming a peer
// home refuses until then. A grant naming a subject needs console.sso.
export const ACCESS_HELP = `${ACCESS_USAGE}
A grant gives a user exactly what the granted instances can do. The instances
@@ -36,17 +36,20 @@ list is the real limit: a collaborator's message drives a granted instance with
the owner's role.
To cut a user off, revoke them here first. It acts at once. Removing a provider
from console.sso takes effect only after a Console restart, so do that second.
grant and revoke ask the bus to re-read the file after the write. If the bus
refuses, the write still stands (exit 0), stderr names the refusal, and the bus's
timer applies the change within 30 s.
user remove refuses while the user has a grant at any business: revoke those first.
A user's home is local or a peer id from federation.peers in the system config;
any other home refuses. A grant that names a sign-in subject needs console.sso
Peer homes are not read yet (row 203 adds the federation key), so a grant that
names a peer home refuses. A grant that names a sign-in subject needs console.sso
in the system config; without it every such grant refuses.
The command runs as the instance's uid: access.json must belong to it and must
not be writable by group or other, or the command refuses (exit 3) and writes nothing.`;
// The instance's context for checkAccess, read from the system config and the business
// files. providers and ssoSubjects come from console.sso (row 192's shape: each provider
// has an id and a subjects list). peers are the ids in federation.peers (FED R6, F8),
// empty without the key. A business file that fails to load fails the whole command.
// has an id and a subjects list). peers is empty until row 203. A business file that
// fails to load fails the whole command.
export function accessContextFor(system, env) {
const providers = system.console?.sso?.providers ?? [];
const ssoSubjects = providers.flatMap((p) => p.subjects.map((s) => ({ provider: p.id, sub: s.sub })));
@@ -59,8 +62,7 @@ export function accessContextFor(system, env) {
businesses[id] = { human: doc.human, instances: Object.keys(doc.roles) };
}
}
const peers = (system.federation?.peers ?? []).map((p) => p.id);
return { providers: providers.map((p) => p.id), peers, businesses, ssoSubjects };
return { providers: providers.map((p) => p.id), peers: [], businesses, ssoSubjects };
}
// Every verb needs --business: the human proof runs through that business's bus.
@@ -179,7 +181,7 @@ export function withLock(file, fn, fs = realFs) {
// The one human-proof call: project.list, which the bus gates with #human
// (broker.mjs:2319, and #human at :377). It also returns the rows that resolve
// project names. Row R4 swaps this call for access.reload.
// project names. R4 (#1801) keeps it, since grant needs those rows, and calls access.reload after the write.
async function proveHuman(transport, business) {
return transport(business)("project.list", {});
}
@@ -283,6 +285,18 @@ async function accessVerb(args, io, deps) {
grants: result.touched,
});
io.stdout.write(`access ${sub}: ${result.touched.join(", ")} at ${business}, revision ${result.checked.revision}\n`);
await reload(deps.transport, business, io);
}
// FED R4 (#1801, design 3.7 trigger 3): after a grant or revoke, the bus re-reads the file and ends
// the runs a loss leaves uncovered, at once. The write already stands, so a refusal here is exit 0
// (sage-3139): a nonzero exit invites a retry that would hit revision-conflict.
async function reload(transport, business, io) {
try {
await transport(business)("access.reload", {});
} catch (error) {
io.stderr.write(`access.reload refused: ${error.code ?? error.message}; the bus's timer applies the change within 30 s\n`);
}
}
// `user add ID` and `user remove ID` (row 193c): the same lock, revision check, file checks,
+2 -35
View File
@@ -4,14 +4,13 @@
// Everything here reads and refuses; nothing writes.
import { spawnSync } from "node:child_process";
import { existsSync, lstatSync } from "node:fs";
import { existsSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { BusinessError, accessFilePath, configDir, loadAccess, loadBusiness, loadProject, projectFilePath, resolveInstance, systemVars } from "../../business/src/index.mjs";
import { BusinessError, configDir, loadBusiness, loadProject, projectFilePath, resolveInstance, systemVars } from "../../business/src/index.mjs";
import { busBusiness } from "../../bus/src/business.mjs";
import { BusError } from "../../bus/src/broker.mjs";
import { CliError } from "./errors.mjs";
import { accessContextFor } from "./access.mjs";
export const REPO = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
@@ -84,36 +83,6 @@ function trackerFor(system, b, warn) {
};
}
// FED R6 (#1800, sage-3142): the runtime's `access`, { file, context }. The broker reads the file
// with R1's loadAccess on every user request, so a bad file leaves every user with no grant and
// the owner's bus still boots (sage-3153). The context is the cli's accessContextFor, peers included. No file,
// or a context that can't be built, means no `access`: no user binds, as before R6.
function accessFor(system, env, warn) {
const file = accessFilePath(configDir(env));
try {
lstatSync(file);
} catch {
return null;
}
let context;
try {
context = accessContextFor(system, env);
} catch (e) {
warn(`access.json is present but its context can't be read, so no granted user binds: ${e.message}`);
return null;
}
// sage-3153: one line at boot when the loader refuses the file, so the owner sees why no user
// binds. The loader's messages can quote the file, so the line names only the path and a code
// from its exit code: 4 is a file it could not open as a regular file, anything else invalid.
try {
loadAccess(file, context);
} catch (e) {
const code = e instanceof BusinessError && e.exitCode !== 4 ? "access-invalid" : "access-unreadable";
warn(`access file refused at boot (${code}), no granted user binds until it is fixed: ${file}`);
}
return { file, context };
}
// 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.
@@ -132,8 +101,6 @@ export function bootConfig({ system, businessId, env = process.env, warn = () =>
};
const tracker = trackerFor(vars, b, warn);
if (tracker) config.trackers = { [b.id]: tracker };
const access = accessFor(system, env, warn);
if (access) config.access = access;
return config;
});
}
+16
View File
@@ -70,6 +70,9 @@
// after the snapshot gets a turn from the same run or waits for the
// next one, instead of going to the stopping run. An idle thread holds
// no session and counts against no limit.
// - FED R4 (#1801): never starts a thread the broker marks revoked, and
// SIGTERMs its running session at once (the broker already ended the
// claim). bindLaunch refuses a new run of one with thread-revoked.
//
// A thread run's sessionDir is the thread's store (session.mjs
// threadStore()), so the next run for the thread resumes the harness
@@ -698,6 +701,19 @@ export function createLauncher({
const key = `${t.thread}/${t.session}`;
if (clearing.has(t.session)) continue;
const s = registry.running(b.id, t.instance, t.thread, t.session);
// FED R4 (#1801, design 3.7): the broker marks a thread whose user owner lost the grant, and has
// ended its claim before it returns the row, whether the file changed or a grant expired. A revoked thread never starts, and a running one gets the idle stop's
// start-time-checked SIGTERM at once, with no threadIdle: there is no claim left to release.
if (t.revoked) {
if (s && !stopping.has(s.run)) {
stopping.add(s.run);
record({ event: "thread-revoked", instance: t.instance, thread: t.thread, session: t.session, run: s.run });
try {
if (startTimeOf(s.runnerPid) === s.runnerStartTime) process.kill(s.runnerPid, "SIGTERM");
} catch {}
}
continue;
}
if (s) {
const last = Math.max(ms(s.startedAt), ms(t.lastMessage), ms(t.lastOpen));
if (!stopping.has(s.run) && now - last >= b.launch.threadIdleSeconds * 1000) {
-94
View File
@@ -1,94 +0,0 @@
// FED R6 (#1800, sage-3142, sage-3153): the bus host's boot config carries the runtime's `access`
// ({ file, context }) when <configDir>/access.json exists, with the cli's accessContextFor as the
// context. The broker then binds a granted user; with no file, or a file R1's loader refuses, no
// user binds and the owner's bus still boots. A refused file gives one boot warning naming the path
// and a code, never the file's contents. Scratch roots only. The boot config goes through a JSON
// round trip first, as it does over the host's IPC.
import test from "node:test";
import assert from "node:assert/strict";
import { chmodSync, symlinkSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { startBroker } from "../../bus/src/runtime.mjs";
import { checkAccess } from "../../business/src/index.mjs";
import { accessContextFor } from "../src/access.mjs";
import { bootConfig, loadSystem } from "../src/config.mjs";
import { fixture, tmp } from "./helpers.mjs";
const SSO = { sso: { providers: [{ id: "authentik", subjects: [{ sub: "jason-sub" }] }] } };
const FEDERATION = { self: "hub", peers: [{ id: "factory", origin: "https://factory.example.test", label: "Software factory" }] };
const DOC = {
accessVersion: 1,
revision: 1,
users: [{ id: "alice", home: "factory", subjects: [{ provider: "authentik", sub: "alice-sub" }] }],
grants: [{ id: "g-1", user: "alice", business: "acme", projects: ["*"], instances: ["*"], level: "collaborator" }],
};
// F8: the context's peers are federation.peers' ids, so a user whose home is a configured peer
// validates and one whose home is any other id, the hub's own included, refuses.
test("R6 a configured peer id validates as a user's home and an unconfigured one refuses", (t) => {
const root = tmp(t, "mosaic-access-peers-");
fixture(root, "acme");
const ctx = accessContextFor({ console: SSO, federation: FEDERATION }, { MOSAIC_CONFIG: join(root, "config", "config.json") });
assert.deepEqual(ctx.peers, ["factory"]);
const doc = (home) => ({ ...DOC, users: [{ ...DOC.users[0], home }] });
assert.equal(checkAccess(doc("factory"), ctx).users[0].home, "factory");
assert.throws(() => checkAccess(doc("elsewhere"), ctx), /home must be a configured peer/);
assert.throws(() => checkAccess(doc("hub"), ctx), /home must be a configured peer/);
});
// A scratch instance whose system config names the sign-in provider and one peer, and the boot
// config bootConfig makes for it. `file` is the access file's text, null for none, or a function
// that makes the file at the path.
async function boot(t, file) {
const f = fixture(tmp(t, "mosaic-access-boot-"), "acme");
const system = { ...loadSystem({ env: f.env }), console: SSO, federation: FEDERATION };
const path = join(dirname(f.config), "access.json");
if (typeof file === "function") file(path);
else if (file !== null) {
writeFileSync(path, file);
chmodSync(path, 0o600);
}
const warnings = [];
const { trackers, ...config } = JSON.parse(JSON.stringify(bootConfig({ system, businessId: "acme", env: f.env, warn: (w) => warnings.push(w) })));
const runtime = await startBroker(config);
t.after(() => runtime.close());
const bind = (user) => {
try {
return runtime.broker.bindUser({ business: "acme", user }) ? "bound" : "none";
} catch (e) {
return e.code;
}
};
return { config, path, bind, warnings };
}
test("R6 B1 a boot with access.json binds a granted user, whose home is a configured peer", async (t) => {
const s = await boot(t, JSON.stringify(DOC));
assert.equal(s.config.access.file, s.path);
assert.deepEqual(s.config.access.context.peers, ["factory"]);
assert.deepEqual(s.config.access.context.ssoSubjects, [{ provider: "authentik", sub: "jason-sub" }]);
assert.equal(s.bind("alice"), "bound");
assert.equal(s.bind("bob"), "access-revoked");
assert.deepEqual(s.warnings, []);
});
test("R6 B2 a boot without access.json has no access and binds no user", async (t) => {
const s = await boot(t, null);
assert.equal(Object.hasOwn(s.config, "access"), false);
assert.equal(s.bind("alice"), "access-revoked");
assert.deepEqual(s.warnings, []);
});
test("R6 B3 a boot with a file R1's loader refuses still boots, binds no user and says so once", async (t) => {
for (const [why, file, code] of [
["not JSON", '{"secret-marker"', "access-invalid"],
["a home that is not a configured peer", JSON.stringify({ ...DOC, users: [{ ...DOC.users[0], home: "secret-marker" }] }), "access-invalid"],
["a symbolic link", (path) => { writeFileSync(`${path}.real`, JSON.stringify(DOC), { mode: 0o600 }); symlinkSync(`${path}.real`, path); }, "access-unreadable"],
]) {
const s = await boot(t, file);
assert.equal(s.config.access.file, s.path, why);
assert.equal(s.bind("alice"), "access-revoked", why);
assert.deepEqual(s.warnings, [`access file refused at boot (${code}), no granted user binds until it is fixed: ${s.path}`], why);
assert.doesNotMatch(s.warnings[0], /secret-marker/, why);
}
});
+59
View File
@@ -449,3 +449,62 @@ test("T9 a bus that refuses the human proof blocks both user verbs", async (t) =
assert.equal(readFileSync(s.file, "utf8"), before);
assert.equal(s.receipts(), "");
});
// FED R4 (#1801, design 3.7 trigger 3): grant and revoke ask the bus to re-read the file after the
// write. This stand-in CLI logs every call, one line each, and with FAKE_MODE "refuse-reload"
// refuses access.reload alone, as a bus would refuse a call it can't take. BUILD.md
// (agents/morgana/work/queue-295) names the mutant each test kills.
const RELOAD_BODY = `
import { appendFileSync, readFileSync } from "node:fs";
const input = JSON.parse(readFileSync(0, "utf8"));
appendFileSync(process.env.FAKE_LOG, JSON.stringify(input) + "\\n");
if (input.verb === "access.reload") {
if (process.env.FAKE_MODE === "refuse-reload") { process.stderr.write("human-required\\n"); process.exit(3); }
process.stdout.write(JSON.stringify({ ended: [] }));
} else process.stdout.write(JSON.stringify(${JSON.stringify(ROWS)}));
`;
function reloading(t, mode = "ok") {
const s = setup(t);
mkdirSync(join(s.root, "reload"));
const cli = fakeCli(join(s.root, "reload"), RELOAD_BODY);
const env = humanEnv({ FAKE_LOG: s.log, FAKE_MODE: mode });
s.deps.transport = (business) => humanTransport({ socket: join(s.root, "bus.sock"), business, env, cli, timeoutMs: 5000 });
const calls = () => readFileSync(s.log, "utf8").trim().split("\n").map((l) => JSON.parse(l));
return { ...s, calls };
}
test("R4 grant and revoke call access.reload after the write, and nothing else does", async (t) => {
const s = reloading(t);
await run([...GRANT, "--revision", "1"], s.deps);
assert.deepEqual(s.calls().map((c) => [c.verb, c.business, c.args]), [["project.list", "acme", {}], ["access.reload", "acme", {}]]);
await run(["revoke", "--business", "acme", "--user", "alice", "--revision", "2"], s.deps);
await run(["list", "--business", "acme"], s.deps);
assert.deepEqual(s.calls().map((c) => c.verb), ["project.list", "access.reload", "project.list", "access.reload", "project.list"]);
// A refused write never asks.
await refusal(run([...GRANT, "--revision", "1"], s.deps), /revision-conflict/);
assert.equal(s.calls().at(-1).verb, "project.list");
assert.equal(s.calls().filter((c) => c.verb === "access.reload").length, 2);
});
// sage-3139 Q2: the write stands, so a refused reload is exit 0. stdout reports the write as before,
// and stderr names the refusal and the timer.
test("R4 a refused access.reload after a good write exits 0, stdout reports the write, stderr names the code and the 30 s timer", async (t) => {
const s = reloading(t, "refuse-reload");
const i = io(humanEnv());
assert.equal(await accessCommand([...GRANT, "--revision", "1"], i, s.deps), undefined, "no error: exit 0");
assert.match(i.out.text, /^access grant: g-[0-9a-f]{8} at acme, revision 2\n$/);
assert.equal(i.err.text, "access.reload refused: human-required; the bus's timer applies the change within 30 s\n");
assert.equal(s.read().revision, 2, "the write stands");
assert.equal(s.receipts().trim().split("\n").length, 1);
});
test("R4 the help keeps the provider-removal order and says what a refused reload means", async (t) => {
const s = setup(t);
const err = await refusal(run(["help"], s.deps), /revoke them here first/);
assert.equal(err.exitCode, 4);
const text = err.message.replace(/\n/g, " ");
assert.match(text, /To cut a user off, revoke them here first\. It acts at once\. Removing a provider from console\.sso takes effect only after a Console restart, so do that second\./);
assert.match(text, /grant and revoke ask the bus to re-read the file after the write\. If the bus refuses, the write still stands \(exit 0\), stderr names the refusal, and the bus's timer applies the change within 30 s\./);
assert.ok(text.indexOf("so do that second") < text.indexOf("re-read the file"), "next to the provider-removal order");
});
+234
View File
@@ -0,0 +1,234 @@
// FED R4 (#1801, federation design 3.7): the launcher half of revocation. A thread whose user owner
// lost the grant never starts, even with a message waiting across a restart; a running one gets
// SIGTERM at once; and recover() still rebinds and ends an earlier host's run on such a thread
// (sage-3139, D1). Real runners in the bwrap sandbox, with thread-adapter.mjs as the turn.
// The bus half is packages/bus/tests/access-revoke.test.mjs; BUILD.md
// (agents/morgana/work/queue-295) names the mutant each test kills.
//
// The host is thread-launch.test.mjs's, with an access file passed to startBroker, which the live
// boot config doesn't pass yet.
import { test } from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { launchDoc } from "../../business/tests/helpers.mjs";
import { BusError } from "../../bus/src/broker.mjs";
import { startBroker } from "../../bus/src/runtime.mjs";
import { Store } from "../../bus/src/store.mjs";
import { gitEnv } from "../../seat/src/checkout.mjs";
import { sandboxProblem } from "../../seat/src/sandbox.mjs";
import { launchLogFile, readSessions, Registry, SESSION_CREDENTIALS } from "../../seat/src/session.mjs";
import { bootConfig, loadSystem } from "../src/config.mjs";
import { createLauncher } from "../src/launcher.mjs";
import { fakeHarnesses, fixture, tmp } from "./helpers.mjs";
const ADAPTER = fileURLToPath(new URL("./fixtures/thread-adapter.mjs", import.meta.url));
const skip = sandboxProblem() ?? false;
// A mutant's kill is an assertion failure, not a timeout (sage-2707): the wait returns what it last
// saw and the assertions after it decide.
async function settle(fn, ms = 20000) {
const end = Date.now() + ms;
let v;
while (Date.now() < end) {
v = await fn();
if (v) return v;
await new Promise((r) => setTimeout(r, 50));
}
return v;
}
function gitIn(cwd, ...args) {
const r = spawnSync("git", ["-c", "user.name=test", "-c", "[email protected]", "-C", cwd, ...args], { env: gitEnv(), encoding: "utf8" });
assert.equal(r.status, 0, r.stderr);
}
// The assistant is the thread instance. Alice holds a collaborator grant on it in the access file;
// `grant(false)` is the hand edit that removes it.
function prepare(t) {
const root = tmp(t, "mosaic-revoke-");
const f = fixture(root, "acme", (doc) => {
doc.roles.chief.vars = { harness: "pi", model: "claude-opus-5-5", thinking: "high" };
doc.roles.assistant = { definition: "assistant", vars: { harness: "pi", model: "claude-sonnet-5-5", thinking: "high" } };
doc.launch = launchDoc("chief", ["coder", "assistant"], { opus: 1, sonnet: 2 });
doc.launch.threadIdleSeconds = 3600;
return doc;
});
for (const k of SESSION_CREDENTIALS.flat()) delete f.env[k];
const project = f.doc.projects.stack.root;
gitIn(project, "init", "-q", "-b", "main");
writeFileSync(join(project, "file.txt"), "one\n");
gitIn(project, "add", "file.txt");
gitIn(project, "commit", "-q", "-m", "one");
const adapter = join(root, "adapter.sh");
writeFileSync(adapter, `exec '${process.execPath}' '${ADAPTER}'\n`, { mode: 0o644 });
const file = join(root, "access.json");
let revision = 0;
const grant = (on) =>
writeFileSync(file, JSON.stringify({
accessVersion: 1,
revision: ++revision,
users: [{ id: "alice", home: "local", subjects: [{ provider: "authentik", sub: "alice-sub" }] }],
grants: on ? [{ id: "g-alice", user: "alice", business: "acme", projects: ["*"], instances: ["assistant"], level: "collaborator" }] : [],
}), { mode: 0o600 });
grant(true);
const context = { providers: ["authentik"], peers: [], businesses: { acme: { human: f.doc.human, instances: Object.keys(f.doc.roles) } }, ssoSubjects: [] };
return { root, f, adapter, access: { file, context }, grant };
}
// A host and its launcher, ticking only when the test calls tick(). pollInterval is long, so a run
// with nothing to do sleeps rather than ask the bus, and only a signal ends it.
async function start(t, { f, adapter, access }) {
const system = loadSystem({ env: f.env });
const { trackers, ...boot } = bootConfig({ system, businessId: "acme", env: f.env });
assert.equal(trackers, undefined);
const runtime = await startBroker({ ...boot, access });
const ops = {
identity: (m) => runtime.identity(m.cap),
authorizeLaunch: (m) => runtime.authorizeLaunch(m.cap, m.instance),
refuse: (m) => runtime.refuse(m.cap, m.code, m.subject),
endLaunch: (m) => runtime.endLaunch(m.record),
credentialStatus: (m) => runtime.credentialStatus(m.business, m.role),
threadsState: (m) => runtime.threadsState(m.business),
threadIdle: (m) => runtime.threadIdle(m.record),
mayLaunch: (m) => runtime.mayLaunch(m),
landHeld: (m) => runtime.landHeld(m.record),
landHolders: (m) => runtime.landHolders(m.business),
landRelease: (m) => runtime.landRelease(m.record),
sessionClears: (m) => runtime.sessionClears(m.business),
};
const refusing = (what, fn) => async (m) => {
try {
return fn(m);
} catch (e) {
const code = e instanceof BusError ? e.code : `${what}-refused`;
throw Object.assign(new Error(`${what} refused: ${code}`), { code });
}
};
const hooks = [];
let closing = null;
const host = {
business: "acme",
path: runtime.path,
dataRoot: f.dataRoot,
op: refusing("launch-op", (m) => {
if (!Object.hasOwn(ops, m.op)) throw new BusError("invalid-host-request");
return ops[m.op](m);
}),
bindLaunch: refusing("launch bind", (r) => runtime.bindLaunch(r)),
beforeClose: (fn) => void hooks.push(fn),
};
const logs = [];
const launcher = createLauncher({
host,
system,
env: f.env,
makeLoader: await fakeHarnesses(t, { pi: adapter, "claude-code": adapter }),
sessionDefaults: { pollInterval: 600000 },
harnessVersion: "0.85.1",
readOnly: [ADAPTER],
threadTickMs: 3600000,
log: (l) => logs.push(l),
});
const close = () =>
(closing ??= (async () => {
for (const fn of hooks) await fn(0);
await runtime.close();
})());
t.after(close);
await launcher.listen();
const human = runtime.broker.bindHuman({ business: "acme", human: f.doc.human, via: "cli", outsideAgent: true });
const alice = runtime.broker.bindUser({ business: "acme", user: "alice" });
const call = (verb, args = {}) => runtime.broker.request(human, { verb, args });
const as = (verb, args = {}) => runtime.broker.request(alice, { verb, args });
return { runtime, launcher, call, as, logs, close };
}
const launchLog = (f) =>
existsSync(launchLogFile(f.dataRoot, "acme"))
? readFileSync(launchLogFile(f.dataRoot, "acme"), "utf8").trim().split("\n").map((l) => JSON.parse(l))
: [];
const events = (f, event) => launchLog(f).filter((e) => e.event === event);
// Alice's thread A in the stack project.
function thread(host) {
const project = host.call("project.create", { name: "stack work", repo: "stack" }).id;
return host.as("thread.create", { project, name: "alice work" }).id;
}
test("REQ-LAUNCH-1 R4: a pending message on a revoked thread starts no run after a launcher restart", { skip, timeout: 60000 }, async (t) => {
const prepared = prepare(t);
const { f } = prepared;
const one = await start(t, prepared);
const A = thread(one);
one.as("thread.send", { thread: A, body: "for A" });
await one.close();
// The hand edit, while no host runs.
prepared.grant(false);
const two = await start(t, prepared);
for (let i = 0; i < 3; i++) await two.launcher.tick();
const row = two.runtime.threadsState("acme").find((x) => x.thread === A);
assert.deepEqual([row.revoked, row.pending, row.holder], [true, 1, null]);
assert.deepEqual(launchLog(f), [], "no launch and no refused start");
assert.deepEqual(readSessions(f.dataRoot), []);
});
test("REQ-LAUNCH-1 R4: a running thread's run gets SIGTERM at the first tick after the hand edit, with no idle check", { skip, timeout: 60000 }, async (t) => {
const prepared = prepare(t);
const { f } = prepared;
const host = await start(t, prepared);
const A = thread(host);
const sent = host.as("thread.send", { thread: A, body: "hello" });
await host.launcher.tick();
const reply = await settle(() => host.as("thread.messages", { thread: A }).find((m) => m.in_reply_to === sent.id && m.class === "RESULT"));
assert.ok(reply, JSON.stringify(launchLog(f)));
const [launch] = events(f, "launch");
assert.equal(launch.thread, A);
prepared.grant(false);
await host.launcher.tick();
assert.deepEqual(events(f, "thread-revoked").map((e) => [e.thread, e.run]), [[A, launch.run]]);
// The runner sleeps for its poll interval, so only the signal ends it, and it exits 0, stopped.
const end = await settle(() => events(f, "end").find((e) => e.run === launch.run));
assert.deepEqual([end?.reason, end?.exitCode, end?.signal], ["stopped", 0, null], JSON.stringify(launchLog(f)));
assert.deepEqual(events(f, "thread-idle"), []);
assert.deepEqual(readSessions(f.dataRoot), []);
const row = host.runtime.threadsState("acme").find((x) => x.thread === A);
assert.deepEqual([row.revoked, row.holder], [true, null]);
// More ticks start nothing.
for (let i = 0; i < 3; i++) await host.launcher.tick();
assert.equal(events(f, "launch").length, 1);
// Dewey's B1: the broker ended the claim before the launcher's kill, so the run's end didn't release it.
await host.close();
const store = new Store(f.dataRoot);
t.after(() => store.close());
assert.deepEqual(
store.get("SELECT op, reason, by FROM thread_claims WHERE holder_run=? ORDER BY seq DESC LIMIT 1", launch.run),
{ op: "revoke", reason: "access-revoked", by: "access-change" },
);
});
// sage-3139, D1: the bind refusal skips a rebind, so recover() ends the earlier host's run.
test("REQ-LAUNCH-1 R4 D1: recover() rebinds an earlier host's run on a revoked thread and ends it, which ends its claim", { skip, timeout: 60000 }, async (t) => {
const prepared = prepare(t);
const { f } = prepared;
const one = await start(t, prepared);
const A = thread(one);
const D = one.runtime.threadsState("acme").find((x) => x.thread === A).session;
// A stand-in for the earlier host's run, with pids that aren't running.
const record = { business: "acme", role: "assistant", run: "run-left", harness: "pi", pid: 2 ** 22 + 7, startTime: "1", thread: A, session: D };
const left = one.runtime.bindLaunch(record);
one.runtime.broker.request(left.cap, { verb: "role.claim" });
await one.close();
prepared.grant(false);
new Registry(f.dataRoot).add({ business: "acme", instance: "assistant", thread: A, session: D, run: "run-left", harness: "pi", pid: record.pid, startTime: "1", runnerPid: 2 ** 22 + 9, runnerStartTime: "1" });
const two = await start(t, prepared);
assert.ok(two.logs.includes("run run-left (assistant) from an earlier host ended: host-lost"), two.logs.join("\n"));
assert.deepEqual(events(f, "end").map((e) => [e.run, e.reason]), [["run-left", "host-lost"]]);
const row = two.runtime.threadsState("acme").find((x) => x.thread === A);
assert.deepEqual([row.revoked, row.holder], [true, null]);
for (let i = 0; i < 3; i++) await two.launcher.tick();
assert.deepEqual(events(f, "launch"), []);
});
-26
View File
@@ -1,26 +0,0 @@
// FED R6 (#1800, federation design 3.1 D3 and D5): who besides the owner may sign in to this
// Console. `load` is R1's loadAccess over this instance's access.json and context, called on every
// use and never cached, so a revoke, a removed subject or an expired grant counts on the next
// request. A user is live while one grant in the Console's business covers at `viewer`, the same
// test as the broker's #userGrants. The broker still decides every verb, project, instance and
// thread (D4). Any failure to load, or to name the business, finds nobody (fail closed).
import { covers } from '../../business/src/vocabulary.mjs';
export function consoleAccess({ load, business }) {
if (typeof load !== 'function' || typeof business !== 'function') throw new TypeError('consoleAccess needs load and business functions');
const live = (doc, id, b) => doc.grants.some(g => g.user === id && g.business === b &&
covers(g, { business: b, project: g.projects[0], instance: g.instances[0] }, new Date(), { level: 'viewer' }));
const lookup = (provider, sub) => {
try {
const doc = load(), b = business();
const user = doc.users.find(u => u.subjects.some(s => s.provider === provider && s.sub === sub));
return user && live(doc, user.id, b) ? user.id : null;
} catch { return null; }
};
return Object.freeze({
// The user id this (provider, sub) signs in as, or null.
find: ({ provider, sub }) => lookup(provider, sub),
// Is this session's user still the holder of its own subject, with a live grant?
check: session => typeof session?.user === 'string' && lookup(session.provider, session.sub) === session.user,
});
}
-2
View File
@@ -17,8 +17,6 @@ const STATUS = {
'unknown-business': 403, 'read-only': 403, 'not-configured': 503, 'no-bus-host': 503,
'thread-instance-missing': 503, 'thread-instance-ambiguous': 503, 'outcome-unknown': 503,
'tester-off': 403, 'tester-required': 403, 'tester-refused': 403,
// FED R6 (#1800): the broker's refusals of a granted user.
'access-refused': 403, 'access-revoked': 401,
};
function safeMessage(code) {
if (code === 'channel-disabled') return 'Run mosaic plugins to read the revision, then mosaic plugins set --file PATCH with plugins.channel set to ["cli","webui"].';
+2 -7
View File
@@ -6,14 +6,9 @@ import { createChannelCaller } from '../../plugins/src/channel.mjs';
import { PluginError } from '../../plugins/src/kinds.mjs';
import { BusReadError } from './bus.mjs';
// FED R6 (#1800, sage-3153): `owner`, when given, is the owner's transport, and it carries only
// the channel settings read, `plugins` with {}. A granted user's chat needs it since `plugins` is
// not a user verb. The result gates the webui channel server-side and is never sent to the user.
// Every chat verb goes over the user's own `call`.
export function createChat({ call, owner = null }) {
export function createChat({ call }) {
if (typeof call !== 'function') throw new TypeError('chat needs a bus transport function');
if (owner !== null && typeof owner !== 'function') throw new TypeError('chat owner must be a bus transport function');
const dispatch = createChannelCaller({ name: 'webui', call: owner === null ? call : (verb, args) => verb === 'plugins' ? owner('plugins', {}) : call(verb, args) });
const dispatch = createChannelCaller({ name: 'webui', call });
const channel = async (verb, args) => {
try { return await dispatch(verb, args); }
catch (err) {
+26 -21
View File
@@ -3,11 +3,6 @@
import { createChat } from './chat.mjs';
import { startServer, previewOptions, proofOptions, DEFAULT_BOARD } from './serve.mjs';
import { busCaller } from './bus.mjs';
import { busBusiness, userCaller } from './user-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';
import { accessFilePath, loadAccess } from '../../business/src/access.mjs';
import { startTesterSocket } from './tester-socket.mjs';
import { TESTER_CLI } from '../../cli/src/transport.mjs';
import { views } from '../../bus/src/views.mjs';
@@ -18,6 +13,14 @@ import { createSso, readSlot } from './sso.mjs';
import { statSync } from 'node:fs';
import { isAbsolute } from 'node:path';
import { ID_PATTERN } from '../../business/src/vocabulary.mjs';
// Row 194 (#1692, D7): the Agents section's verbs. The caller refuses any other, so the server's
// agentWrite reaches only these.
const AGENT_VERBS = ['instance.launch', 'instance.regenerate', 'instance.end', 'veto.set', 'instance.request'];
const TESTER_AGENT_VERBS = ['instance.launch', 'instance.end', 'instance.request'];
const agentCaller = (call, verbs) => async (verb, args) => {
if (!verbs.includes(verb)) throw new Error(`the Console does not call ${verb}`);
return call(verb, args);
};
const args = process.argv.slice(2);
const usage = 'node packages/webui/src/cli.mjs serve [--port N] [--tester-socket] [--checkout PATH] [--board http://127.0.0.1:7331] [--business ID] [--sso [--send]] [--public-host NAME --auth-header HEADER --allow-user ID [--send] [--proof-header NAME --proof-file PATH]]';
try {
@@ -70,13 +73,24 @@ try {
// 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 processing = null, ownerCall = null;
let processing = null;
try {
system = loadSystem();
const { dataRoot } = system;
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 }));
const call = busCaller({ dataRoot, socket: socketPath(dataRoot), business: options.business ?? null });
processing = { read: () => call('processing.status'), halt: args => call('processing.halt', args), resume: args => call('processing.resume', args) };
chat = createChat({ call });
// Row 155 (#1649): `names` is a webui read only, so it sits beside the shared views. Row 165
// (#1662): so is `instances`, and its write `instances.set` is only under --send. Row 65
// (mos-0840): so is `routes`, and its write `routes.set`.
// Row 278: this owner/reader read belongs to the Console, not the shared
// role views or the tester adapter. The bus decides caller eligibility.
bus = { ...views({ call }), names: () => call('names'), instances: () => call('instances'), routes: () => call('routes'), 'thread.claims': () => call('thread.claims') };
// Row 148: the spec and docs editor reads like the other reads and saves only under --send.
projectFiles = args => call('project.files', args);
projectFile = args => call('project.file', args);
libraryFile = args => call('library.file', args); libraryStatus = args => call('library.status', args);
if (sendOn) {send = args => call('message.send', args);librarySync=args=>call('library.sync',args);settingsWrite=args=>call('limits.set',args);projectSave=args=>call('project.file.save',args);nameWrite=args=>call('name.set',args);assistantStop=args=>call('assistant.stop',args);instancesWrite=args=>call('instances.set',args);routesWrite=args=>call('routes.set',args);agentWrite=agentCaller(call,AGENT_VERBS);}
busLine = `Bus: ${options.business ?? 'the running bus host\'s business'}`;
} catch (err) {
const why = redactor({ configDir: configDir() })(err.message);
@@ -88,24 +102,15 @@ try {
const reads = consoleReads({ root: REPO, queueRoot: options.checkout ?? REPO, system, configDir: configDir(), rolesDir: rolesDir(), business: options.business ?? null });
// The sign-in config is checked again here, then each slot file, before anything listens. A
// provider that can't be reached starts degraded; one whose discovery is refused stops the start.
let sso = null, userOptions = null;
let sso = null;
if (ssoOn) {
if (!system.console?.sso) throw new Error('--sso needs console.sso in the system config');
const secrets = new Map(system.console.sso.providers.map(p => [p.id, readSlot(configDir(), p, { repo: REPO, checkout: options.checkout ?? null })]));
// FED R6 (#1800): access.json users sign in too. R1's loader reads the file on every check,
// with the same context as `mosaic access`; a missing or refused file signs nobody in.
const users = consoleAccess({
load: () => loadAccess(accessFilePath(configDir()), accessContextFor(system, process.env)),
business: busBusiness({ dataRoot: system.dataRoot, business: options.business ?? null }),
});
sso = await createSso(system.console.sso, { secrets, users });
// Each request of a granted user's session gets the bus options over that user's own
// user-cli transport. The human transport above serves only the chat's `plugins` read (C6).
userOptions = userOptionsFor({ owner: ownerCall, send: sendOn, transport: user => userCaller({ dataRoot: system.dataRoot, socket: socketPath(system.dataRoot), business: options.business ?? null, user }) });
sso = await createSso(system.console.sso, { secrets });
}
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 });
} catch (err) { sso?.close(); throw err; }
let testerServer = null;
if (testerSocket) {
-49
View File
@@ -1,49 +0,0 @@
// FED R6 (#1800): the Console's bus-backed options for startServer, built over one transport
// `call`. cli.mjs builds them once over the human transport. A granted user's session gets the same
// set over its own user transport (user-bus.mjs userCaller), so no route a user reaches holds a
// human-transport object, and the broker decides every verb the user sends (design 3.1 D4).
import { createChat } from './chat.mjs';
import { views } from '../../bus/src/views.mjs';
// Row 194 (#1692, D7): the Agents section's verbs. The caller refuses any other, so the server's
// agentWrite reaches only these.
export const AGENT_VERBS = ['instance.launch', 'instance.regenerate', 'instance.end', 'veto.set', 'instance.request'];
export const TESTER_AGENT_VERBS = ['instance.launch', 'instance.end', 'instance.request'];
export const agentCaller = (call, verbs) => async (verb, args) => {
if (!verbs.includes(verb)) throw new Error(`the Console does not call ${verb}`);
return call(verb, args);
};
// `send` is the --send flag: without it every write is null. `owner` is the owner's transport for
// a granted user's options; it goes to createChat, which sends it only the `plugins` read.
export function consoleBus(call, { send: sendOn = false, owner = null } = {}) {
const o = {
processing: { read: () => call('processing.status'), halt: args => call('processing.halt', args), resume: args => call('processing.resume', args) },
chat: createChat({ call, owner }),
// Row 155 (#1649): `names` is a webui read only, so it sits beside the shared views. Row 165
// (#1662): so is `instances`, and its write `instances.set` is only under --send. Row 65
// (mos-0840): so is `routes`, and its write `routes.set`.
// Row 278: this owner/reader read belongs to the Console, not the shared
// role views or the tester adapter. The bus decides caller eligibility.
bus: { ...views({ call }), names: () => call('names'), instances: () => call('instances'), routes: () => call('routes'), 'thread.claims': () => call('thread.claims') },
// Row 148: the spec and docs editor reads like the other reads and saves only under --send.
projectFiles: args => call('project.files', args),
projectFile: args => call('project.file', args),
libraryFile: args => call('library.file', args), libraryStatus: args => call('library.status', args),
send: null, librarySync: null, settingsWrite: null, projectSave: null, nameWrite: null, assistantStop: null, instancesWrite: null, routesWrite: null, agentWrite: null,
};
if (sendOn) {
o.send = args => call('message.send', args); o.librarySync = args => call('library.sync', args); o.settingsWrite = args => call('limits.set', args);
o.projectSave = args => call('project.file.save', args); o.nameWrite = args => call('name.set', args); o.assistantStop = args => call('assistant.stop', args);
o.instancesWrite = args => call('instances.set', args); o.routesWrite = args => call('routes.set', args); o.agentWrite = agentCaller(call, AGENT_VERBS);
}
return o;
}
// A granted user's options per session: consoleBus over `transport(session.user)`, that user's own
// transport, with the owner's transport `owner` passed as it is. Nothing from the session or the
// request reaches `owner` (mos-1394, sage-3157): createChat sends it only `plugins` with {}.
export function userOptionsFor({ owner, transport, send = false }) {
if (typeof owner !== 'function' || typeof transport !== 'function') throw new TypeError('userOptionsFor needs owner and transport functions');
return session => consoleBus(transport(session.user), { send, owner });
}
+1 -1
View File
@@ -476,5 +476,5 @@
setTimeout(() => { const a = document.activeElement; if (!a || a === document.body || a.disabled || !a.getClientRects().length) line.focus(); });
});
// Sign out (sage-1347, F7): a plain same-origin form POST to /auth/logout, shown only under --sso.
api('/api/config').then(config => { board = config.board; $('board-url').textContent = `Board: ${board}`; window.MosaicFederation?.render(config.federation, $('federation')); if (config.sso === true) { sso = true; $('sign-out').hidden = out(); } }).catch(err => { if (machine) error(err); }).finally(refresh);
api('/api/config').then(config => { board = config.board; $('board-url').textContent = `Board: ${board}`; if (config.sso === true) { sso = true; $('sign-out').hidden = out(); } }).catch(err => { if (machine) error(err); }).finally(refresh);
})();
-26
View File
@@ -1,26 +0,0 @@
/* FED R6 (#1800): the hub label and plain peer links from /api/config's `federation`. Text and
href only: no script, no fetch to a peer and no proxy (the hub proxy route is R8's). */
(() => {
'use strict';
function render(federation, container) {
if (!container) return;
container.replaceChildren();
if (!federation || typeof federation !== 'object') { container.hidden = true; return; }
if (typeof federation.self === 'string') {
const label = document.createElement('span');
label.id = 'hub-label';
label.textContent = `This Console: ${federation.self}`;
container.append(label);
}
for (const peer of Array.isArray(federation.peers) ? federation.peers : []) {
if (typeof peer?.origin !== 'string' || !/^https?:\/\//.test(peer.origin)) continue;
const a = document.createElement('a');
a.className = 'peer-link';
a.setAttribute('href', peer.origin);
a.textContent = typeof peer.label === 'string' && peer.label ? peer.label : peer.origin;
container.append(' ', a);
}
container.hidden = !container.childNodes.length;
}
window.MosaicFederation = Object.freeze({ render });
})();
+1 -2
View File
@@ -4,12 +4,11 @@
<link rel="stylesheet" href="/shared/app.css"><link rel="stylesheet" href="/tokens.css"><link rel="stylesheet" href="/console.css"><link rel="stylesheet" href="/live.css"><link rel="stylesheet" href="/bus.css"><link rel="stylesheet" href="/chat.css"><link rel="stylesheet" href="/shell.css">
<link rel="stylesheet" href="/editor.css"><script src="/editor.js" defer></script>
<link rel="stylesheet" href="/processing.css"><script src="/processing.js" defer></script>
<script src="/federation.js" defer></script><script src="/brand.js" defer></script><script src="/app.js" defer></script><script src="/markdown-preview.js" defer></script><script src="/chat.js" defer></script><script src="/agents.js" defer></script><script src="/bus.js" defer></script></head>
<script src="/brand.js" defer></script><script src="/app.js" defer></script><script src="/markdown-preview.js" defer></script><script src="/chat.js" defer></script><script src="/agents.js" defer></script><script src="/bus.js" defer></script></head>
<body>
<a class="skip" href="#main">Skip to content</a>
<header class="cmdbar"><a href="/" class="wordmark"><svg class="mark" viewBox="0 0 16 17" aria-hidden="true"><use href="/icons.svg#mark"/></svg>Mosaic<small>Console</small></a>
<button type="button" class="cmdk" id="cmdk" aria-haspopup="dialog" aria-controls="cmdk-dialog"><svg class="i" aria-hidden="true"><use href="/icons.svg#i-search"/></svg><span>Go to a view, decision or task</span><kbd>Ctrl K</kbd></button>
<nav id="federation" class="federation small" aria-label="Consoles" hidden></nav>
<p class="fresh" id="fresh"><span id="fresh-board"></span><span id="fresh-bus"></span></p>
<div class="cmd-right"><label>Palette<select id="palette"></select></label><label>Appearance<select id="mode"><option value="system">system</option><option value="light">light</option><option value="dim">dim</option><option value="dark">dark</option></select></label><button class="btn" id="refresh" type="button">Refresh</button><button class="btn" id="pause" type="button" aria-pressed="false">Pause</button><form id="sign-out" method="post" action="/auth/logout" hidden><button class="btn" type="submit">Sign out</button></form></div></header>
<div id="processing-banner" class="processing-banner" role="alert" hidden></div>
-17
View File
@@ -1,17 +0,0 @@
<!doctype html>
<html lang="en" data-design="console" data-palette="harbor">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Mosaic Console</title>
<link rel="stylesheet" href="/tokens.css"><link rel="stylesheet" href="/console.css"><link rel="stylesheet" href="/chat.css">
<script src="/federation.js" defer></script><script src="/chat.js" defer></script><script src="/user.js" defer></script></head>
<body>
<a class="skip" href="#main">Skip to content</a>
<header class="cmdbar"><a href="/" class="wordmark"><svg class="mark" viewBox="0 0 16 17" aria-hidden="true"><use href="/icons.svg#mark"/></svg>Mosaic<small>Console</small></a>
<nav id="federation" class="federation small" aria-label="Consoles" hidden></nav>
<div class="cmd-right"><form id="sign-out" method="post" action="/auth/logout"><button class="btn" type="submit">Sign out</button></form></div></header>
<main id="main" class="content" tabindex="-1">
<div id="signed-out" class="banner ref" role="alert" tabindex="-1" hidden>You are signed out. <a href="/">Sign in again</a>.</div>
<p id="user-notice" class="banner ref">The business owner sees your thread's agent's decisions, inbox entries, claims, tasks and project files, but not your messages.</p>
<div id="chat"></div>
</main>
<footer class="foot"><span id="footer">Mosaic Stack</span></footer>
</body></html>
-23
View File
@@ -1,23 +0,0 @@
/* FED R6 (#1800): a granted user's page. Projects, threads and messages through chat.js; the
broker decides what this user reads and sends. No owner control is on this page. */
(() => {
'use strict';
const $ = id => document.getElementById(id);
let canSend = false, ready = false;
const route = () => {
const path = location.hash.slice(1).split('?')[0];
return path.startsWith('/chat') ? path : '/chat';
};
function show(navigated) {
if (ready && window.MosaicChat) void window.MosaicChat.render({ path: route(), container: $('chat'), canSend, navigated });
}
document.addEventListener('mosaic-signed-out', () => { $('signed-out').hidden = false; $('sign-out').hidden = true; });
window.addEventListener('hashchange', () => show(true));
fetch('/api/config', { cache: 'no-store', redirect: 'manual' }).then(async res => {
if (res.type === 'opaqueredirect' || res.status === 401) { document.dispatchEvent(new Event('mosaic-signed-out')); return; }
const config = await res.json();
canSend = config.send === true;
window.MosaicFederation?.render(config.federation, $('federation'));
ready = true; show(true);
}).catch(() => { $('chat').textContent = 'The Console could not be read. Refresh to try again.'; });
})();
+3 -40
View File
@@ -27,7 +27,7 @@ export function boardURL(value) {
const root = resolve(import.meta.dirname, 'public');
const files = new Map([
['/', ['index.html', 'text/html; charset=utf-8']],
...['app.js', 'brand.js', 'bus.js', 'markdown-preview.js', 'chat.js', 'processing.js', 'agents.js', 'federation.js'].map(f => ['/' + f, [f, 'text/javascript; charset=utf-8']]),
...['app.js', 'brand.js', 'bus.js', 'markdown-preview.js', 'chat.js', 'processing.js', 'agents.js'].map(f => ['/' + f, [f, 'text/javascript; charset=utf-8']]),
...['shared/app.css', 'tokens.css', 'console.css', 'live.css', 'bus.css', 'shell.css', 'chat.css', 'processing.css'].map(f => ['/' + f, [f, 'text/css; charset=utf-8']]),
['/editor.js', ['editor.js', 'text/javascript; charset=utf-8']],
['/editor.css', ['editor.css', 'text/css; charset=utf-8']],
@@ -35,26 +35,6 @@ const files = new Map([
...[400, 500, 600, 700].map(w => [`/assets/fonts/manrope-${w}.woff2`, [`assets/fonts/manrope-${w}.woff2`, 'font/woff2']]),
...[400, 500, 700].map(w => [`/assets/fonts/jetbrains-mono-${w}.woff2`, [`assets/fonts/jetbrains-mono-${w}.woff2`, 'font/woff2']]),
]);
// FED R6 (#1800): a granted user's page. The owner page's markup and scripts are never served to a
// user session, so its controls are not sent at all (design 3.1 D5).
const USER_FILES = new Map([
['/', ['user.html', 'text/html; charset=utf-8']],
...['user.js', 'chat.js', 'federation.js'].map(f => ['/' + f, [f, 'text/javascript; charset=utf-8']]),
...['tokens.css', 'console.css', 'chat.css'].map(f => ['/' + f, [f, 'text/css; charset=utf-8']]),
...[...files].filter(([p]) => p.startsWith('/assets/fonts/') || p === '/icons.svg'),
]);
// The only routes a granted user's session reaches: the chat, the page config and the user page.
// Anything else is the owner's, so a route added later is owner-only until it is added here.
export function userRoute(path, method) {
if (path === '/api/chat' || path.startsWith('/api/chat/')) return true;
if (method !== 'GET' && method !== 'HEAD') return false;
return path === '/api/config' || path === '/favicon.ico' || USER_FILES.has(path);
}
// The hub label and plain peer links, from system.federation: ids, labels and origins only.
function federationView(federation) {
if (federation === null || typeof federation !== 'object') return null;
return { self: federation.self ?? null, peers: (federation.peers ?? []).map(({ id, label, origin }) => ({ id, label, origin })) };
}
function json(res, status, body) {
res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' });
res.end(JSON.stringify(body));
@@ -599,7 +579,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, 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,11 +591,6 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
if (tester && !testerSocket) throw new Error('tester listener requires private socket');
if (!tester && testerSocket) throw new Error('tester socket requires tester listener');
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.
if (userOptions !== null && (sso === null || typeof userOptions !== 'function')) throw new Error('user sessions need --sso');
const owner = { bus, reads, send, settingsWrite, projectFiles, projectFile, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing, upstream, files };
const hub = federationView(federation);
// An object with none of the three is refused too: asking for the preview never serves without it.
if (preview !== null && !(preview = previewOptions({ publicHost: preview.host, authHeader: preview.header, allowUser: preview.user }))) {
throw new Error('preview mode needs --public-host, --auth-header and --allow-user together');
@@ -623,8 +598,6 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
if (proof !== null && (preview === null || tester)) throw new Error('proxy proof needs preview mode');
if (send !== null && ((preview === null && sso === null) || typeof send !== 'function')) throw new Error('--send needs the preview mode (--public-host, --auth-header and --allow-user) or --sso');
const server = createServer(async (req, res) => {
// A granted user's request rebinds these below; every other request keeps the owner's.
let { bus, reads, send, settingsWrite, projectFiles, projectFile, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing, upstream, files } = owner, granted = false;
res.setHeader('x-content-type-options', 'nosniff');
res.setHeader('referrer-policy', 'no-referrer');
// Under --sso the Sign out form's POST is redirected to the provider's end-session endpoint, and
@@ -680,14 +653,6 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
return res.end();
}
if (session) signer = { sso: true, user: session.name, host: sso.publicHost, origin: sso.publicOrigin };
// 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) {
if (!userRoute(path, req.method)) return json(res, 403, { error: 'owner-only', message: 'this part of the Console is the business owner\'s' });
if (userOptions === null) return json(res, 503, { error: 'not-configured', message: 'the Console has no user transport' });
({ bus, send, settingsWrite, projectFiles, projectFile, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing } = userOptions(session));
reads = null; upstream = null; files = USER_FILES; granted = true;
}
}
if (named && preview !== null) {
// One value, equal to the user id; the proxy strips client copies and sets it after sign-in.
@@ -750,7 +715,6 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
const allowed = path === '/api/board' || conversation ? 'GET' : ['/api/seen', '/api/reply'].includes(path) ? 'POST' : null;
if (allowed) {
if (req.method !== allowed) return json(res, 405, { error: 'method not allowed' });
if (upstream === null) return json(res, 503, { error: 'not-configured', message: 'the Console has no board for this session' });
let bytes;
if (allowed === 'POST') {
try { bytes = await body(req); } catch (err) { return json(res, 400, { error: err.message }); }
@@ -799,8 +763,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
if (req.method !== 'GET' && req.method !== 'HEAD') return json(res, 405, { error: 'method not allowed' });
// `send`: the page shows the composer only where a send can pass (the public name, --send on).
// `edit`: the editor shows Save only where a save can pass, the same test as the route's.
// `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 } : {}), send: !tester && Boolean(send) && named, edit: !tester && Boolean(send) && Boolean(projectSave) && named });
if (path === '/healthz') return json(res, 200, { ok: true, ...(sso ? { sso: sso.health() } : {}) });
if (path === '/favicon.ico') { res.writeHead(204); return res.end(); }
const file = files.get(path);
+4 -17
View File
@@ -22,7 +22,7 @@ const SESSION_ABSOLUTE = 8 * 3600 * 1000, SESSION_IDLE = 3600 * 1000;
const MAX_SESSIONS = 64, MAX_LOGINS = 32;
const FETCH_TIMEOUT = 10000;
const RETRY_CAP = 300000;
const CODES = new Set(['login-record-missing', 'state-mismatch', 'auth-error-response', 'grant-failed', 'id-token-invalid', 'signature-invalid', 'alg-refused', 'issuer-mismatch', 'subject-unknown', 'user-revoked', 'discovery-failed', 'jwks-failed']);
const CODES = new Set(['login-record-missing', 'state-mismatch', 'auth-error-response', 'grant-failed', 'id-token-invalid', 'signature-invalid', 'alg-refused', 'issuer-mismatch', 'subject-unknown', 'discovery-failed', 'jwks-failed']);
const sha256 = value => createHash('sha256').update(value).digest('hex');
const escape = text => String(text).replace(/[&<>"']/g, c => `&#${c.charCodeAt(0)};`);
@@ -123,9 +123,7 @@ async function discover(p, fetchOptions) {
// `config` is console.sso, checked again here; `secrets` maps provider id to its client secret
// (readSlot). `fetch` is for tests: a fetch that trusts only the fake OP's certificate.
// `retryFirst` is for tests too; production waits 5 s, doubling, capped at 5 minutes.
// `users` (FED R6, #1800) is consoleAccess() from access.mjs, or null: a subject that is not the
// owner's signs in as the access-file user with a live grant, and each request checks it again.
export async function createSso(config, { secrets, fetch: customFetch = null, log = line => console.log(line), retryFirst = 5000, users = null } = {}) {
export async function createSso(config, { secrets, fetch: customFetch = null, log = line => console.log(line), retryFirst = 5000 } = {}) {
checkConsole({ sso: config });
oauth ??= await import('oauth4webapi');
const publicOrigin = config.publicOrigin;
@@ -280,18 +278,14 @@ export async function createSso(config, { secrets, fetch: customFetch = null, lo
try { header = JSON.parse(Buffer.from(result.id_token.split('.')[0], 'base64url').toString('utf8')); } catch { header = null; }
if (!header || !entry.config.algs.includes(header.alg)) { refuse('alg-refused', pid); return failed(res, clear); }
if (claims.iss !== entry.config.issuer) { refuse('issuer-mismatch', pid); return failed(res, clear); }
let name = typeof claims.sub === 'string' ? entry.subjects.get(claims.sub) : undefined;
// FED R6: console.sso subjects and access-file subjects are disjoint (R1), so the owner's
// list is asked first and a user is asked only on a miss.
const user = name === undefined && typeof claims.sub === 'string' ? users?.find({ provider: pid, sub: claims.sub }) ?? null : null;
if (user !== null) name = user;
const name = typeof claims.sub === 'string' ? entry.subjects.get(claims.sub) : undefined;
if (name === undefined) {
refuse('subject-unknown', pid, ` iss=${JSON.stringify(String(claims.iss).slice(0, 255))} sub=${JSON.stringify(String(claims.sub).slice(0, 255))}`);
return failed(res, clear);
}
// Step 7: the session, then the return path.
const id = randomBytes(32).toString('base64url'), now = Date.now();
remember(sessions, sha256(id), { provider: pid, iss: claims.iss, sub: claims.sub, name, ...(user !== null ? { user } : {}), idToken: result.id_token, created: now, seen: now }, MAX_SESSIONS);
remember(sessions, sha256(id), { provider: pid, iss: claims.iss, sub: claims.sub, name, idToken: result.id_token, created: now, seen: now }, MAX_SESSIONS);
return redirect(res, returnURL(record.ret), [...clear, `${SESSION_COOKIE}=${id}; ${ATTRS}`]);
}
@@ -304,13 +298,6 @@ export async function createSso(config, { secrets, fetch: customFetch = null, lo
if (!s) return null;
const now = Date.now();
if (now - s.created >= SESSION_ABSOLUTE || now - s.seen >= SESSION_IDLE) { sessions.delete(key); return null; }
// FED R6 (design 3.1 D5): a user's grant is read again on every request, so a user revoked,
// removed or expired since sign-in has no session from the next request on.
if (s.user !== undefined && !users?.check(s)) {
sessions.delete(key);
refuse('user-revoked', s.provider, ` user=${JSON.stringify(s.user)}`);
return null;
}
if (touch) s.seen = now;
return s;
}
-63
View File
@@ -1,63 +0,0 @@
// FED R6 (#1800): a granted user's bus transport, R3's user-cli.mjs (#414, design 3.6). Kept apart
// from bus.mjs so the human transport's reviewed bytes stay as row 164 froze them
// (bus/tests/channel-proof.test.mjs). Same protocol as humanCall: the request on stdin, JSON on
// stdout, or a bus error code as the last `^[a-z-]{1,64}$` line of stderr.
import { spawn } from 'node:child_process';
import { randomBytes } from 'node:crypto';
import { fileURLToPath } from 'node:url';
import { readHostState } from '../../cli/src/host.mjs';
import { BusReadError } from './bus.mjs';
export const USER_CLI = fileURLToPath(new URL('../../bus/src/user-cli.mjs', import.meta.url));
// The Console sets the user nonce itself, so user-cli.mjs doesn't re-exec and the broker sees the
// Console (`cli.mjs serve`) as its parent (principal.mjs). The human nonce is dropped, so this child
// can never pass as human-cli, and it only ever runs USER_CLI.
export function userCall({ socket, business, user, env = process.env, timeoutMs = 30000, maxBytes = 8 * 1024 * 1024 }) {
const { MOSAIC_BUS_CLI_NONCE, ...rest } = env;
return (verb, args = {}) => new Promise((resolve, reject) => {
let child;
try {
child = spawn(process.execPath, [USER_CLI, socket], { env: { ...rest, MOSAIC_BUS_USER_NONCE: randomBytes(32).toString('hex') }, 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;
const done = (fn, value) => { if (!settled) { settled = true; clearTimeout(timer); fn(value); } };
const timer = setTimeout(() => { child.kill('SIGKILL'); done(reject, new BusReadError('outcome-unknown', 'the bus transport did not finish in time')); }, timeoutMs);
child.stdout.on('data', chunk => {
size += chunk.length;
if (size > maxBytes) { child.kill('SIGKILL'); return done(reject, new BusReadError('response-too-large')); }
out.push(chunk);
});
child.stderr.on('data', chunk => { if (err.length < 64) err.push(chunk); });
child.on('error', () => done(reject, new BusReadError('outcome-unknown', 'the bus transport did not start')));
child.on('close', status => {
if (status === null) return done(reject, new BusReadError('outcome-unknown', 'the bus transport did not finish'));
if (status !== 0) {
const code = Buffer.concat(err).toString('utf8').trim().split('\n').reverse().find(l => /^[a-z-]{1,64}$/.test(l)) ?? 'invalid-response';
return done(reject, new BusReadError(code));
}
try { done(resolve, JSON.parse(Buffer.concat(out).toString('utf8'))); }
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, user, verb, args })}\n`);
});
}
// The business is the --business flag, or else the live bus host's, read on every call, as busCaller does.
export function busBusiness({ dataRoot, business = null }) {
return () => {
if (business) return business;
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;
};
}
// One granted user's caller, for that user's session only.
export function userCaller({ dataRoot, socket, business = null, user, env = process.env, timeoutMs = 30000 }) {
const which = busBusiness({ dataRoot, business });
return async (verb, args) => userCall({ socket, business: which(), user, env, timeoutMs })(verb, args);
}
-384
View File
@@ -1,384 +0,0 @@
// FED R6 (#1800, federation design 3.1 D3-D5, 3.4 and 3.7): a granted user signs in to the
// Console. Every provider is the fake OP in helpers/fake-oidc.mjs on loopback; no test reaches a
// real provider, the live Console or a real config, slot or access file. The broker is an
// in-process Broker with an access function (bus/tests/user-principal.test.mjs), reached through a
// per-session user transport that binds the user on every call, as user-cli.mjs does. The real
// user-cli spawn is checked on its own in G8 against a recording socket.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { request, createServer as createHttpServer } from 'node:http';
import { createServer as createNetServer } from 'node:net';
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { Store } from '../../bus/src/store.mjs';
import { Broker } from '../../bus/src/broker.mjs';
import { HUMAN_CLI } from '../../cli/src/transport.mjs';
import { startServer, userRoute } from '../src/serve.mjs';
import { createSso, SESSION_COOKIE } from '../src/sso.mjs';
import { consoleAccess } from '../src/access.mjs';
import { consoleBus, userOptionsFor } from '../src/console-bus.mjs';
import { BusReadError } from '../src/bus.mjs';
import { USER_CLI, userCall } from '../src/user-bus.mjs';
import { close, fixture } from './fixture.mjs';
import { browser } from './browser.mjs';
import { fakeOidc } from './helpers/fake-oidc.mjs';
const HOST = 'console.example.test', ORIGIN = `https://${HOST}`;
const PUBLIC = new URL('../src/public/', import.meta.url);
const NOTICE = "The business owner sees your thread's agent's decisions, inbox entries, claims, tasks and project files, but not your messages.";
const OWNER_MARKER = 'owner-plugins-marker-7f3a';
const FEDERATION = { self: 'hub', peers: [{ id: 'factory', origin: 'https://factory.example.test', label: 'Software factory' }] };
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 setCookies = r => [r.headers['set-cookie'] ?? []].flat();
const sessionOf = r => setCookies(r).find(c => c.startsWith(`${SESSION_COOKIE}=`) && !c.includes('Max-Age=0'))?.split(';')[0] ?? null;
const writeHeaders = cookie => ({ cookie, origin: ORIGIN, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' });
const grant = (over = {}) => ({ id: 'g-1', user: 'alice', business: 'demo', projects: ['*'], instances: ['assistant'], level: 'collaborator', ...over });
const ALICE = { id: 'alice', home: 'local', subjects: [{ provider: 'a', sub: 'alice-sub' }] };
// The fake OP, an in-process broker for business `demo`, createSso with the access hook, and the
// human listener. The owner's options are counting stubs; each user session's options are
// consoleBus over that user's transport. `counts` records every owner stub and user transport call.
async function setup(t, { users = [ALICE], grants = [grant()] } = {}) {
const op = await fakeOidc();
t.after(() => op.close());
const iss = op.issuer('a');
const root = mkdtempSync(join(tmpdir(), 'webui-granted-user-'));
const store = new Store(root);
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
const access = { doc: { accessVersion: 1, revision: 1, users, grants }, fail: false };
const load = () => { if (access.fail) throw new Error('access file refused'); return access.doc; };
const broker = new Broker({
store,
businesses: {
demo: {
id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, vars: {},
roles: {
chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send'], crossRole: [] } },
assistant: { definition: 'assistant', authority: { withinRole: ['message.send'], crossRole: [] } },
},
definitions: { 'chief-of-staff': {}, assistant: { messaging: JSON.parse(readFileSync(new URL('../../../roles/assistant.json', import.meta.url), 'utf8')).messaging } },
},
},
access: load,
});
const human = broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const counts = { owner: 0, user: 0 };
// As user-cli.mjs: a fresh bind per call, and the broker's refusal code as the transport's.
// `userVerbs` and `ownerVerbs` record what each transport carried for a user session.
const userVerbs = [], ownerVerbs = [];
const userTransport = user => async (verb, args = {}) => {
counts.user++; userVerbs.push(verb);
try { return broker.request(broker.bindUser({ business: 'demo', user }), { verb, args }); }
catch (e) { throw new BusReadError(e.code ?? 'invalid-response'); }
};
// The owner's transport as cli.mjs passes it (ownerCall). Its answer carries a marker that must
// never reach a user's response.
const ownerCall = async (verb, args) => { ownerVerbs.push([verb, args]); return { plugins: { channel: ['webui'] }, marker: OWNER_MARKER }; };
const opened = [];
const userOptions = userOptionsFor({ owner: ownerCall, send: true, transport: user => { opened.push(user); return userTransport(user); } });
const lines = [];
const log = line => lines.push(line);
const config = { publicOrigin: ORIGIN, providers: [{ id: 'a', issuer: iss.issuer, clientId: iss.clientId, clientSecret: { slot: 'a' }, algs: ['RS256'], subjects: [{ sub: 'owner-sub', name: 'jason' }] }] };
const sso = await createSso(config, { secrets: new Map([['a', iss.clientSecret]]), fetch: op.fetch, log, users: consoleAccess({ load, business: () => 'demo' }) });
t.after(() => sso.close());
const stub = name => async () => { counts.owner++; return name === 'send' ? { id: 'msg-0001' } : [{ verb: name }]; };
const ownerBus = Object.fromEntries(['inbox', 'tasks', 'agents', 'trail', 'decisions', 'names', 'instances', 'routes', 'thread.claims'].map(v => [v, stub(v)]));
const owner = {
bus: ownerBus, send: stub('send'), settingsWrite: stub('settings'), projectFiles: stub('files'), projectFile: stub('file'), projectSave: stub('save'),
nameWrite: stub('name'), assistantStop: stub('stop'), instancesWrite: stub('instances'), routesWrite: stub('routes'), agentWrite: stub('agent'),
processing: { read: stub('processing'), halt: stub('halt'), resume: stub('resume') },
reads: Object.fromEntries(['settings', 'business', 'busStatus', 'queue', 'row'].map(v => [v, stub(v)])),
chat: Object.fromEntries(['projects', 'threads', 'messages', 'send', 'open', 'createProject', 'createThread'].map(v => [v, stub(v)])),
};
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', sso, log, ...owner, userOptions, federation: FEDERATION });
t.after(() => close(web));
const p1 = broker.request(human, { verb: 'project.create', args: { name: 'shared' } }).id;
const p2 = broker.request(human, { verb: 'project.create', args: { name: 'private' } }).id;
return { op, iss, broker, human, store, access, counts, opened, lines, port: web.address().port, p1, p2, userTransport, ownerCall, userVerbs, ownerVerbs };
}
// A whole sign-in with `sub`: start, a code at the OP, the callback.
async function signIn(env, sub) {
const start = await call(env.port, { path: '/auth/login' });
assert.equal(start.status, 303);
const cookie = setCookies(start)[0].split(';')[0];
const location = start.headers.location;
const g = env.op.grant(env.iss, location, { sub });
const q = new URLSearchParams({ code: g.code, state: new URL(location).searchParams.get('state') });
const r = await call(env.port, { path: `/auth/callback?${q}`, headers: { cookie } });
return { r, session: sessionOf(r) };
}
const events = env => env.store.get('SELECT count(*) n FROM events').n;
test('R6 G1 a granted user signs in, gets the user page and reaches the broker as itself', async t => {
const env = await setup(t);
env.access.doc.grants = [grant({ projects: [env.p1] })];
const { r, session } = await signIn(env, 'alice-sub');
assert.equal(r.status, 303, JSON.stringify(r.body));
assert.ok(session, 'a session cookie was set');
const config = await call(env.port, { path: '/api/config', headers: { cookie: session } });
assert.equal(config.status, 200);
assert.equal(config.body.user, true);
assert.equal(config.body.edit, false);
assert.deepEqual(config.body.federation, FEDERATION);
const page = await call(env.port, { path: '/', headers: { cookie: session } });
assert.equal(page.status, 200);
assert.match(page.body, /<script src="\/user\.js" defer><\/script>/);
assert.doesNotMatch(page.body, /app\.js|bus\.js|agents\.js|processing\.js|editor\.js/);
// Only the granted project shows: the broker answered as alice, not as the owner.
const projects = await call(env.port, { path: '/api/chat/projects', headers: { cookie: session } });
assert.equal(projects.status, 200, JSON.stringify(projects.body));
assert.deepEqual(projects.body.rows.map(p => p.id), [env.p1]);
const made = await call(env.port, { method: 'POST', path: '/api/chat/threads', headers: writeHeaders(session), body: JSON.stringify({ project: env.p1, name: 'alice work' }) });
assert.equal(made.status, 200, JSON.stringify(made.body));
const threads = await call(env.port, { path: '/api/chat/threads', headers: { cookie: session } });
assert.deepEqual(threads.body.rows.map(x => x.id), [made.body.id]);
assert.equal(env.counts.owner, 0, 'an owner object ran for a user session');
assert.ok(env.opened.length > 0 && env.opened.every(u => u === 'alice'));
assert.ok(env.lines.some(l => / chat action=createThread user="alice" id=/.test(l)));
// The owner signs in as before and gets the owner page and config.
const ownerSession = (await signIn(env, 'owner-sub')).session;
const ownerConfig = await call(env.port, { path: '/api/config', headers: { cookie: ownerSession } });
assert.equal(Object.hasOwn(ownerConfig.body, 'user'), false);
assert.deepEqual(ownerConfig.body.federation, FEDERATION);
assert.match((await call(env.port, { path: '/', headers: { cookie: ownerSession } })).body, /<script src="\/app\.js" defer><\/script>/);
});
test('R6 G2 a subject with no live grant in this business is refused subject-unknown', async t => {
const past = new Date(Date.now() - 60000).toISOString();
for (const [why, users, grants, sub] of [
['a listed user with no grant', [ALICE], [], 'alice-sub'],
['a grant in another business', [ALICE], [grant({ business: 'other' })], 'alice-sub'],
['an expired grant', [ALICE], [grant({ expires: past })], 'alice-sub'],
["another user's grant", [ALICE, { id: 'bob', home: 'local', subjects: [{ provider: 'a', sub: 'bob-sub' }] }], [grant({ user: 'bob' })], 'alice-sub'],
['a sub in no file', [ALICE], [grant()], 'stranger-sub'],
]) {
await t.test(why, async t => {
const env = await setup(t, { users, grants });
const { r, session } = await signIn(env, sub);
assert.equal(r.status, 403, why);
assert.equal(session, null, `${why}: a session cookie was set`);
assert.match(env.lines.at(-1), / sso refused code=subject-unknown provider=a /);
assert.deepEqual(env.opened, []);
assert.equal(env.counts.user + env.counts.owner, 0);
});
}
await t.test('a file the loader refuses', async t => {
const env = await setup(t);
env.access.fail = true;
const { r, session } = await signIn(env, 'alice-sub');
assert.equal(r.status, 403);
assert.equal(session, null);
});
});
test('R6 G3 a revoke counts on the next request: 401 and the session is gone', async t => {
for (const [why, revoke] of [
['grant removed', env => { env.access.doc.grants = []; }],
['subject removed', env => { env.access.doc.users = [{ ...ALICE, subjects: [] }]; }],
['grant expired', env => { env.access.doc.grants = [grant({ expires: new Date(Date.now() - 1000).toISOString() })]; }],
['file now refused', env => { env.access.fail = true; }],
]) {
await t.test(why, async t => {
const env = await setup(t);
const { session } = await signIn(env, 'alice-sub');
assert.equal((await call(env.port, { path: '/api/chat/projects', headers: { cookie: session } })).status, 200);
revoke(env);
const before = env.counts.user;
const r = await call(env.port, { path: '/api/chat/projects', headers: { cookie: session } });
assert.equal(r.status, 401, `${why}: ${JSON.stringify(r.body)}`);
assert.equal(r.body.error, 'sign-in required');
assert.match(env.lines.at(-1), / sso refused code=user-revoked provider=a user="alice"$/);
assert.equal(env.counts.user, before, 'the transport ran after the revoke');
// Restoring the grant does not bring the session back: it was deleted.
env.access.fail = false; env.access.doc.users = [ALICE]; env.access.doc.grants = [grant()];
assert.equal((await call(env.port, { path: '/api/chat/projects', headers: { cookie: session } })).status, 401);
const page = await call(env.port, { path: '/', headers: { cookie: session } });
assert.equal(page.status, 303);
assert.match(page.headers.location, /^\/auth\/login\?return=/);
});
}
});
// Every owner route a user session might try; none is a chat route.
const OWNER_ROUTES = [
['GET', '/api/bus/inbox'], ['GET', '/api/bus/tasks'], ['GET', '/api/bus/names'], ['GET', '/api/bus/thread.claims'],
['POST', '/api/bus/message', { to: 'coder', body: 'hi' }],
['GET', '/api/settings'], ['POST', '/api/settings', { revision: 'x' }],
['POST', '/api/name', { role: 'assistant', name: 'x' }], ['POST', '/api/assistant/stop', {}],
['POST', '/api/instances', { instance: 'x' }], ['POST', '/api/routes', { routes: [] }],
['POST', '/api/agent/launch', { instance: 'x' }], ['GET', '/api/agent/request?id=x'],
['GET', '/api/processing'], ['POST', '/api/processing/halt', {}],
['GET', '/api/project-files?project=p'], ['GET', '/api/project-file?project=p&path=a'], ['POST', '/api/project-file', { project: 'p' }],
['GET', '/api/board'], ['GET', '/api/conversations'], ['POST', '/api/seen', {}], ['POST', '/api/reply', {}],
['GET', '/api/queue'], ['GET', '/api/queue/1'], ['GET', '/api/business'], ['GET', '/api/bus-status'], ['GET', '/api/task-fields'],
['GET', '/app.js'], ['GET', '/bus.js'], ['GET', '/agents.js'], ['GET', '/editor.js'], ['GET', '/shared/app.css'], ['GET', '/index.html'],
];
test('R6 G4 every owner route answers 403 owner-only before any bus call', async t => {
const env = await setup(t);
const { session } = await signIn(env, 'alice-sub');
const before = events(env);
for (const [method, path, value] of OWNER_ROUTES) {
const r = await call(env.port, { method, path, headers: value === undefined ? { cookie: session } : writeHeaders(session), body: value === undefined ? undefined : JSON.stringify(value) });
assert.equal(r.status, 403, `${method} ${path}: ${r.status} ${JSON.stringify(r.body)}`);
assert.equal(r.body.error, 'owner-only', `${method} ${path}`);
assert.equal(userRoute(new URL(path, 'http://x').pathname, method), false, `${method} ${path}`);
}
assert.deepEqual(env.counts, { owner: 0, user: 0 });
assert.deepEqual(env.opened, [], 'a user transport was built for an owner route');
assert.equal(events(env), before, 'something was written');
});
test('R6 G5 the bus refuses every owner verb over the user transport on its own', async t => {
const env = await setup(t);
const o = consoleBus(env.userTransport('alice'), { send: true, owner: env.ownerCall });
const before = events(env);
for (const [why, run] of [
['inbox', () => o.bus.inbox()], ['tasks', () => o.bus.tasks()], ['agents', () => o.bus.agents()], ['trail', () => o.bus.trail()],
['decisions', () => o.bus.decisions()], ['instances', () => o.bus.instances()], ['routes', () => o.bus.routes()], ['thread.claims', () => o.bus['thread.claims']()],
['message.send', () => o.send({ to: 'chief', body: 'as the owner' })], ['limits.set', () => o.settingsWrite({ revision: 'x' })],
['project.files', () => o.projectFiles({ project: env.p1 })], ['project.file', () => o.projectFile({ project: env.p1, path: 'a' })],
['project.file.save', () => o.projectSave({ project: env.p1 })], ['name.set', () => o.nameWrite({ role: 'assistant', name: 'x' })],
['assistant.stop', () => o.assistantStop({})], ['instances.set', () => o.instancesWrite({})], ['routes.set', () => o.routesWrite({})],
['instance.launch', () => o.agentWrite('instance.launch', {})], ['processing.status', () => o.processing.read()],
['processing.halt', () => o.processing.halt({})], ['library.status', () => o.libraryStatus({})],
['project.create', () => o.chat.createProject({ name: 'mine' })],
]) await assert.rejects(Promise.resolve().then(run), e => e.code === 'access-refused', why);
assert.equal(events(env), before, 'a refused verb wrote');
});
// sage-3153 (C6): in a user's chat the owner transport carries `plugins` with {} and nothing else,
// every chat verb goes over the user transport, and the owner's answer never reaches the user.
test('R6 G9 a user chat sends only the plugins read over the owner transport', async t => {
const env = await setup(t);
const { session } = await signIn(env, 'alice-sub');
const bodies = [];
const go = async (method, path, value) => {
const r = await call(env.port, { method, path, headers: value === undefined ? { cookie: session } : writeHeaders(session), body: value === undefined ? undefined : JSON.stringify(value) });
assert.equal(r.status, 200, `${method} ${path}: ${JSON.stringify(r.body)}`);
bodies.push(JSON.stringify(r.body));
return r.body;
};
await go('GET', '/api/chat/projects');
const thread = (await go('POST', '/api/chat/threads', { project: env.p1, name: 'alice work' })).id;
await go('GET', `/api/chat/threads?project=${env.p1}`);
await go('POST', `/api/chat/threads/${thread}/open`, {});
await go('POST', `/api/chat/threads/${thread}/messages`, { body: 'hello' });
await go('GET', `/api/chat/threads/${thread}/messages`);
assert.ok(env.ownerVerbs.length > 0, 'the chat never read plugins');
// Exactly the verb `plugins` and exactly {}: no value from the session or a request reaches it.
for (const entry of env.ownerVerbs) assert.deepEqual(entry, ['plugins', {}]);
const sent = JSON.stringify(env.ownerVerbs);
for (const value of ['alice', 'alice-sub', 'alice work', 'hello', env.p1, thread]) assert.equal(sent.includes(value), false, `${value} reached the owner transport`);
for (const verb of ['project.list', 'thread.create', 'thread.list', 'thread.open', 'thread.send', 'thread.messages']) assert.ok(env.userVerbs.includes(verb), `${verb} did not go over the user transport`);
assert.equal(env.userVerbs.includes('plugins'), false);
for (const body of bodies) assert.doesNotMatch(body, new RegExp(OWNER_MARKER));
assert.equal(env.counts.owner, 0);
});
test('R6 G6 the user page holds the notice word for word', async t => {
const html = readFileSync(new URL('user.html', PUBLIC), 'utf8');
const notice = /<p id="user-notice"[^>]*>([^<]*)<\/p>/.exec(html);
assert.ok(notice, 'no #user-notice paragraph');
assert.equal(notice[1], NOTICE);
const env = await setup(t);
const { session } = await signIn(env, 'alice-sub');
assert.ok((await call(env.port, { path: '/', headers: { cookie: session } })).body.includes(NOTICE));
});
// The user page outside a session: a small static server over the public files, /api/config stubbed.
async function userPage(t, config) {
const types = { html: 'text/html', js: 'text/javascript', css: 'text/css', svg: 'image/svg+xml' };
const server = createHttpServer((req, res) => {
const path = new URL(req.url, 'http://x').pathname;
if (path === '/api/config') { res.writeHead(200, { 'content-type': 'application/json' }); return res.end(JSON.stringify(config)); }
if (path.startsWith('/api/chat/')) { res.writeHead(200, { 'content-type': 'application/json' }); return res.end(JSON.stringify({ rows: [], at: new Date().toISOString() })); }
const name = path === '/' ? 'user.html' : path.slice(1);
if (!/^[a-z.]+$/.test(name)) { res.writeHead(404); return res.end(); }
try { const body = readFileSync(new URL(name, PUBLIC)); res.writeHead(200, { 'content-type': types[name.split('.').pop()] ?? 'text/plain' }); res.end(body); }
catch { res.writeHead(404); res.end(); }
});
await new Promise(r => server.listen(0, '127.0.0.1', r));
t.after(() => close(server));
return server.address().port;
}
test('R6 G7 the hub label and plain peer links render from config on both pages', async t => {
const links = 'JSON.stringify({label:document.querySelector("#hub-label")?.textContent,hidden:document.querySelector("#federation").hidden,peers:[...document.querySelectorAll("#federation a")].map(a=>[a.getAttribute("href"),a.textContent,a.attributes.length])})';
const expected = { label: 'This Console: hub', hidden: false, peers: [['https://factory.example.test', 'Software factory', 2]] };
const wait = (b, expression) => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expression})return;await new Promise(r=>setTimeout(r,30));}throw new Error(${JSON.stringify(expression)});})()`);
const b = await browser(); t.after(() => b.close());
// The owner page, over the real server.
const f = await fixture(); t.after(() => f.close());
const owner = await startServer({ port: 0, board: f.boardURL, federation: FEDERATION }); t.after(() => close(owner));
await b.navigate(`http://127.0.0.1:${owner.address().port}/`);
await wait(b, '!document.querySelector("#federation").hidden');
assert.deepEqual(JSON.parse(await b.evaluate(links)), expected);
// The user page, with a hostile label kept as text.
const hostile = { self: 'hub', peers: [...FEDERATION.peers, { id: 'x', origin: 'https://x.example.test', label: '<img src=x onerror="window.injected=1">' }, { id: 'y', origin: 'javascript:alert(1)', label: 'bad' }] };
await b.navigate(`http://127.0.0.1:${await userPage(t, { user: true, send: true, federation: hostile })}/`);
await wait(b, '!document.querySelector("#federation").hidden');
const user = JSON.parse(await b.evaluate(links));
assert.equal(user.label, 'This Console: hub');
assert.deepEqual(user.peers, [...expected.peers, ['https://x.example.test', '<img src=x onerror="window.injected=1">', 2]]);
assert.equal(await b.evaluate('window.injected'), undefined);
assert.equal(await b.evaluate('document.querySelector("#user-notice").textContent'), NOTICE);
// No federation in config: nothing is shown.
await b.navigate(`http://127.0.0.1:${await userPage(t, { user: true, send: true })}/`);
await wait(b, 'document.querySelector(".chat-area")');
assert.equal(await b.evaluate('document.querySelector("#federation").hidden'), true);
});
test('R6 G8 userCall spawns user-cli directly with the user nonce, no human nonce, and the user request', async t => {
const dir = mkdtempSync(join(tmpdir(), 'webui-user-call-'));
t.after(() => rmSync(dir, { recursive: true, force: true }));
const socket = join(dir, 'bus.sock');
const seen = [];
const server = createNetServer(conn => {
let input = '';
conn.on('data', chunk => {
input += chunk;
if (!input.includes('\n')) return;
const message = JSON.parse(input);
const pid = message.user?.pid;
// Read the child while it waits on this answer.
const proc = name => readFileSync(`/proc/${pid}/${name}`, 'utf8');
seen.push({ message, argv: proc('cmdline').split('\0'), env: proc('environ').split('\0').map(e => e.split('=')[0]), ppid: Number(proc('stat').replace(/^.*\) /s, '').split(' ')[1]) });
conn.end(JSON.stringify({ ok: true, result: { answered: true } }) + '\n');
});
});
await new Promise(r => server.listen(socket, r));
t.after(() => server.close());
const result = await userCall({ socket, business: 'demo', user: 'alice', env: { ...process.env, MOSAIC_BUS_CLI_NONCE: 'f'.repeat(64) } })('thread.list', { project: 'p' });
assert.deepEqual(result, { answered: true });
assert.equal(seen.length, 1);
const [{ message, argv, env, ppid }] = seen;
assert.equal(argv[1], USER_CLI);
assert.notEqual(argv[1], HUMAN_CLI);
assert.equal(argv[2], socket);
assert.equal(ppid, process.pid, 'user-cli re-executed: the Console is not its parent');
assert.ok(env.includes('MOSAIC_BUS_USER_NONCE'));
assert.equal(env.includes('MOSAIC_BUS_CLI_NONCE'), false);
assert.deepEqual(Object.keys(message).sort(), ['args', 'user', 'verb']);
assert.equal(message.verb, 'thread.list');
assert.deepEqual(message.args, { project: 'p' });
assert.equal(message.user.business, 'demo');
assert.equal(message.user.user, 'alice');
assert.match(message.user.nonce, /^[0-9a-f]{64}$/);
assert.equal(USER_CLI, fileURLToPath(new URL('../../bus/src/user-cli.mjs', import.meta.url)));
});