Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee77b36359 | ||
|
|
ca710eabdb | ||
|
|
9450774cbe | ||
|
|
77514b8b9a | ||
|
|
338e93185c | ||
|
|
c866a8a695 |
No files matched your search
@@ -0,0 +1,297 @@
|
||||
# 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
|
||||
@@ -0,0 +1,31 @@
|
||||
# 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).
|
||||
@@ -0,0 +1,17 @@
|
||||
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
|
||||
@@ -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. 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.
|
||||
// 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.
|
||||
export const ACCESS_HELP = `${ACCESS_USAGE}
|
||||
|
||||
A grant gives a user exactly what the granted instances can do. The instances
|
||||
@@ -37,16 +37,16 @@ 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.
|
||||
user remove refuses while the user has a grant at any business: revoke those first.
|
||||
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
|
||||
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
|
||||
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 is empty until row 203. A business file that
|
||||
// fails to load fails the whole command.
|
||||
// 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.
|
||||
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,7 +59,8 @@ export function accessContextFor(system, env) {
|
||||
businesses[id] = { human: doc.human, instances: Object.keys(doc.roles) };
|
||||
}
|
||||
}
|
||||
return { providers: providers.map((p) => p.id), peers: [], businesses, ssoSubjects };
|
||||
const peers = (system.federation?.peers ?? []).map((p) => p.id);
|
||||
return { providers: providers.map((p) => p.id), peers, businesses, ssoSubjects };
|
||||
}
|
||||
|
||||
// Every verb needs --business: the human proof runs through that business's bus.
|
||||
|
||||
@@ -4,13 +4,14 @@
|
||||
// Everything here reads and refuses; nothing writes.
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { existsSync, lstatSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { BusinessError, configDir, loadBusiness, loadProject, projectFilePath, resolveInstance, systemVars } from "../../business/src/index.mjs";
|
||||
import { BusinessError, accessFilePath, configDir, loadAccess, 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)), "..", "..", "..");
|
||||
|
||||
@@ -83,6 +84,36 @@ 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.
|
||||
@@ -101,6 +132,8 @@ 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;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
// 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,6 +17,8 @@ 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,9 +6,14 @@ import { createChannelCaller } from '../../plugins/src/channel.mjs';
|
||||
import { PluginError } from '../../plugins/src/kinds.mjs';
|
||||
import { BusReadError } from './bus.mjs';
|
||||
|
||||
export function createChat({ call }) {
|
||||
// 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 }) {
|
||||
if (typeof call !== 'function') throw new TypeError('chat needs a bus transport function');
|
||||
const dispatch = createChannelCaller({ name: 'webui', call });
|
||||
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 channel = async (verb, args) => {
|
||||
try { return await dispatch(verb, args); }
|
||||
catch (err) {
|
||||
|
||||
+21
-26
@@ -3,6 +3,11 @@
|
||||
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';
|
||||
@@ -13,14 +18,6 @@ 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 {
|
||||
@@ -73,24 +70,13 @@ 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;
|
||||
let processing = null, ownerCall = null;
|
||||
try {
|
||||
system = loadSystem();
|
||||
const { dataRoot } = system;
|
||||
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);}
|
||||
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 }));
|
||||
busLine = `Bus: ${options.business ?? 'the running bus host\'s business'}`;
|
||||
} catch (err) {
|
||||
const why = redactor({ configDir: configDir() })(err.message);
|
||||
@@ -102,15 +88,24 @@ 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;
|
||||
let sso = null, userOptions = 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 })]));
|
||||
sso = await createSso(system.console.sso, { secrets });
|
||||
// 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 }) });
|
||||
}
|
||||
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 });
|
||||
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 });
|
||||
} catch (err) { sso?.close(); throw err; }
|
||||
let testerServer = null;
|
||||
if (testerSocket) {
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
// 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}`; 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}`; window.MosaicFederation?.render(config.federation, $('federation')); if (config.sso === true) { sso = true; $('sign-out').hidden = out(); } }).catch(err => { if (machine) error(err); }).finally(refresh);
|
||||
})();
|
||||
@@ -0,0 +1,26 @@
|
||||
/* 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,11 +4,12 @@
|
||||
<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="/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="/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>
|
||||
<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>
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<!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>
|
||||
@@ -0,0 +1,23 @@
|
||||
/* 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'].map(f => ['/' + f, [f, 'text/javascript; 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']]),
|
||||
...['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,6 +35,26 @@ 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));
|
||||
@@ -579,7 +599,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, 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, userOptions = null, federation = null, log = line => console.log(line), fieldsFile = undefined } = {}) {
|
||||
if (!isLoopback(host)) throw new Error('refusing to bind to non-loopback host');
|
||||
if (host === 'localhost') host = '127.0.0.1';
|
||||
if (tester && preview !== null) throw new Error('tester listener cannot use preview mode');
|
||||
@@ -591,6 +611,11 @@ 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');
|
||||
@@ -598,6 +623,8 @@ 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
|
||||
@@ -653,6 +680,14 @@ 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.
|
||||
@@ -715,6 +750,7 @@ 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 }); }
|
||||
@@ -763,7 +799,8 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') return json(res, 405, { error: 'method not allowed' });
|
||||
// `send`: the page shows the composer only where a send can pass (the public name, --send on).
|
||||
// `edit`: the editor shows Save only where a save can pass, the same test as the route's.
|
||||
if (path === '/api/config') return json(res, 200, { board: upstream, ...(tester ? { tester: true } : {}), ...(sso ? { sso: true } : {}), send: !tester && Boolean(send) && named, edit: !tester && Boolean(send) && Boolean(projectSave) && named });
|
||||
// `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 === '/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', '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', 'user-revoked', '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,7 +123,9 @@ 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.
|
||||
export async function createSso(config, { secrets, fetch: customFetch = null, log = line => console.log(line), retryFirst = 5000 } = {}) {
|
||||
// `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 } = {}) {
|
||||
checkConsole({ sso: config });
|
||||
oauth ??= await import('oauth4webapi');
|
||||
const publicOrigin = config.publicOrigin;
|
||||
@@ -278,14 +280,18 @@ 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); }
|
||||
const name = typeof claims.sub === 'string' ? entry.subjects.get(claims.sub) : undefined;
|
||||
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;
|
||||
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, idToken: result.id_token, created: now, seen: now }, MAX_SESSIONS);
|
||||
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);
|
||||
return redirect(res, returnURL(record.ret), [...clear, `${SESSION_COOKIE}=${id}; ${ATTRS}`]);
|
||||
}
|
||||
|
||||
@@ -298,6 +304,13 @@ 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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
// 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);
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
// 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