Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44b778e09a | ||
|
|
527002f975 | ||
|
|
44f9dd51fd |
No files matched your search
@@ -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
|
||||
@@ -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
@@ -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
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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"), []);
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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"].';
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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);
|
||||
})();
|
||||
@@ -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 });
|
||||
})();
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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.'; });
|
||||
})();
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)));
|
||||
});
|
||||
Reference in new issue
Block a user