Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
490430e9f2 | ||
|
|
37582e8205 | ||
|
|
83b32d8d86 | ||
|
|
53bb594621 | ||
|
|
5e01c36833 | ||
|
|
9f56d2098d | ||
|
|
0599b119f4 | ||
|
|
b416d2ede9 | ||
|
|
5fc73a729c | ||
|
|
99772421ab | ||
|
|
da230ca489 | ||
|
|
4f2d1be68a | ||
|
|
d55d86bb04 | ||
|
|
c56ccf916e | ||
|
|
817e8f51b0 | ||
|
|
5d019d8768 | ||
|
|
59475fc5a8 | ||
|
|
1a268249e6 | ||
|
|
def2e79327 | ||
|
|
c30b36fffb |
@@ -16,6 +16,11 @@ not fleet seats. Keep changes in `/mnt/storage/src/mosaic-stack`; do not modify
|
||||
| Rocko | General development, investigation, testing, and review | T3 session (Codex, gpt-6.1-sol, thread d99326cc, lead decision 122; Claude thread b84bb264 retired 2026-10-12); launcher Claude Code, Sonnet model | `agents/rocko/launch.sh` |
|
||||
| Filbert | General development, investigation, testing, and review | T3 session (Claude, Opus 5.5, thread 048f3c63, non-sandbox reviews only, lead decisions 130 and 134; Codex threads f712d8af and 50342f6a retired 2026-10-13, Claude thread 9cb9731e retired 2026-10-12); launcher Pi, `openai-codex/gpt-6-astra:low`, not to be used under R26 until it changes (lead decision 69) | `agents/filbert/launch.sh` |
|
||||
| Researcher | Source-grounded technical research and evidence | T3 session (Claude, Sonnet 5.5, thread 9bac0794, rows 97 and 98, lead decision 124); Haiku rows (71, 80, 84, 88, later 96) run as subagents Sage dispatches with model haiku, effort high and a worktree (lead decision 129); Haiku thread c98aaf4a retired 2026-10-13 unused; launcher Pi, configured Mosaic model | `agents/researcher/launch.sh` |
|
||||
| Launchpad | Builder, MVP T1 row 192 (Q175 SSO phase 1) | T3 session (Claude, Opus 5.5), started by Sage under mos-0974/0977 | none, T3 only |
|
||||
| Morgana | Builder, MVP T1 row 193 (human users and RBAC) | T3 session (Claude, Opus 5.5), started by Sage under mos-0974/0977 | none, T3 only |
|
||||
| Honker | Builder, MVP T1 row 194 (agent lifecycle) | T3 session (Claude, Opus 5.5), started by Sage under mos-0974/0977 | none, T3 only |
|
||||
| Webby | Reviewer: Case 2 designs, rows 192 and 194 | T3 session (Claude, Opus 5.5, thread 03086006), started by Mos (mos-0981) | none, T3 only |
|
||||
| Duckworth | Reviewer: federation designs, row 193 | T3 session (Claude, Opus 5.5, thread ffceafec), started by Mos (mos-0977) | none, T3 only |
|
||||
| Sage | Project lead: coordination, review, integration; earlier DYOR strategy records retained | T3 session (Claude Code); Pi launcher `zai/glm-5.3:high` retained | `agents/sage/launch.sh` |
|
||||
|
||||
Each script supports `--check` and `--fresh`. Normal launches resume the agent's
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# Duckworth
|
||||
|
||||
Reviewer seat, Claude Opus 5.5, started by Mos (mos-0977, thread ffceafec). Reviews federation designs and MVP T1 row 193.
|
||||
|
||||
No launcher in this directory. The seat runs as a T3 thread and works in its own worktree. It has no
|
||||
credentials of its own; until row 195 settles per-agent identity, it pushes only its own `duckworth/*` branches
|
||||
through the repository credential helper (mos-0981), never `refactor`, `main` or another seat's branch.
|
||||
@@ -0,0 +1,7 @@
|
||||
# Honker
|
||||
|
||||
Builder seat, Claude Opus 5.5, started by Sage for MVP T1 row 194 (agent lifecycle, #1692) under mos-0974 and mos-0977.
|
||||
|
||||
No launcher in this directory. The seat runs as a T3 thread and works in its own worktree. It has no
|
||||
credentials of its own; until row 195 settles per-agent identity, it pushes only its own `honker/*` branches
|
||||
through the repository credential helper (mos-0981), never `refactor`, `main` or another seat's branch.
|
||||
@@ -0,0 +1,7 @@
|
||||
# Launchpad
|
||||
|
||||
Builder seat, Claude Opus 5.5, started by Sage for MVP T1 row 192 (Q175 SSO phase 1, #1690) under mos-0974 and mos-0977.
|
||||
|
||||
No launcher in this directory. The seat runs as a T3 thread and works in its own worktree. It has no
|
||||
credentials of its own; until row 195 settles per-agent identity, it pushes only its own `launchpad/*` branches
|
||||
through the repository credential helper (mos-0981), never `refactor`, `main` or another seat's branch.
|
||||
@@ -0,0 +1,178 @@
|
||||
# Row 192: Q175 phase 1, the Console verifies sign-in itself (`--sso`)
|
||||
|
||||
Launchpad, 2026-10-20. Author Launchpad, reviewer Webby, gate owner Sage. Issue #1690. Brief
|
||||
`docs/plans/2026-10-20_sso-phase1.md`. Design `agents/sage/work/q175-sso/DESIGN.md` (blob 9e3eadb15c80).
|
||||
Rulings sage-1340 (F1 to F6) and sage-1347 (F7, F8). Plan launchpad-0001.
|
||||
|
||||
Base 53bb59462 (`base.txt`). Candidate: branch `launchpad/row192`, r1 83b32d8d8 and the round 2 commit on it.
|
||||
`candidate-manifest.sha256` lists the 18 changed code, test and doc paths; the packet files are not in it. Drift
|
||||
check at the r2 commit: `git log 53bb59462..origin/refactor` (origin/refactor ae3c36f24) touches none of the 18 paths.
|
||||
|
||||
No request in any test goes to auth.diversecanvas.com, mosaic.woltje.com or 127.0.0.1:7332. Every provider
|
||||
is the fake OP in `tests/helpers/fake-oidc.mjs`: https on 127.0.0.1 with a throwaway self-signed certificate,
|
||||
and a fetch that refuses any other origin. The public Host in the tests is `console.example.test`, sent as a
|
||||
header to the test's own loopback listener. No test reads or writes `~/.mosaic-dev`, `~/.config/mosaic-dev`,
|
||||
`~/.mosaic`, a slot file outside its temp dir, or a live unit.
|
||||
|
||||
## Files
|
||||
|
||||
| Path | Change |
|
||||
| --- | --- |
|
||||
| `package.json`, `package-lock.json` | `oauth4webapi` 3.8.8, exact, at the root (design: the copy runs `npm ci` there). No other dependency. |
|
||||
| `scripts/mosaic-config.mjs` | Accepts an optional `console.sso` key and checks it with `checkConsole`. Without the key, output is byte-identical (F2). |
|
||||
| `scripts/test-config.sh` | Test 25: a good `console.sso` passes; each refusal class refuses. 31/31. |
|
||||
| `packages/webui/src/sso-config.mjs` (new) | `checkConsole`: shape, `publicOrigin`, 1 to 8 providers, issuer https with a trailing slash, slot names, `ALGS` (asymmetric only), 1 to 64 subjects. |
|
||||
| `packages/webui/src/sso.mjs` (new) | The relying party: `readSlot`, discovery and JWKS with retry, `/auth/login`, `/auth/callback`, `/auth/logout`, sessions, `health()`, `summary()`, `formTargets()`. |
|
||||
| `packages/webui/src/serve.mjs` | Under `sso`: public Host only on the human listener (F4), `/auth/*` routes, session check before every page and API route, CSP `form-action` adds the end-session origins, tester listener refusals (F8). |
|
||||
| `packages/webui/src/cli.mjs` | `--sso`, its refusals, slot reads, the start lines, `close()` on stop. |
|
||||
| `packages/webui/src/chat-http.mjs` | Under `--sso` the session check already ran, so the header-user check is skipped; the Origin check uses `publicOrigin`. |
|
||||
| `packages/webui/src/public/app.js`, `bus.js`, `index.html` | 401 `sign-in required` signs the page out with a link to `/auth/login?return=...` (design test 24, F1). The Sign out form shows only when `/api/config` says `sso` (F7). |
|
||||
| `scripts/webui-service.sh` | Passes `--sso` through; `--send` refusal text names `--sso`. |
|
||||
| `packages/webui/tests/service.test.mjs` | The `--send` refusal regex follows the new text. |
|
||||
| `packages/webui/README.md` | New section "Sign-in in the Console (Q175 phase 1, #1690)". |
|
||||
| `packages/webui/tests/helpers/fake-oidc.mjs` (new) | The fake OP. |
|
||||
| `packages/webui/tests/sso.test.mjs` (new) | 28 tests: three positives and design tests 1 to 23 (some split in parts). |
|
||||
| `packages/webui/tests/sso-browser.test.mjs` (new) | 2 tests: design test 24 with F1, and F7. |
|
||||
|
||||
## Tests run (single files, this worktree, final tree)
|
||||
|
||||
| File | Pass | Fail |
|
||||
| --- | --- | --- |
|
||||
| sso | 28 | 0 |
|
||||
| sso-browser | 2 | 0 |
|
||||
| pinned-copy | 10 | 0 |
|
||||
| serve | 4 | 0 |
|
||||
| preview | 11 | 0 |
|
||||
| proof | 4 | 0 |
|
||||
| proof-cli | 3 | 0 |
|
||||
| send | 13 | 0 |
|
||||
| service | 31 | 0 |
|
||||
| tester-listener | 10 | 0 |
|
||||
| tester-cli | 2 | 0 |
|
||||
| board-held | 3 | 0 |
|
||||
| send-browser | 2 | 0 |
|
||||
| docs-browser | 1 | 0 |
|
||||
| chat | 23 | 0 |
|
||||
| chat-browser | 11 | 0 |
|
||||
| bus-browser | 2 | 0 |
|
||||
| settings-write | 4 | 0 |
|
||||
| project-file | 10 | 0 |
|
||||
| instances | 5 | 0 |
|
||||
| routes | 5 | 0 |
|
||||
| `scripts/test-config.sh` | 31 | 0 |
|
||||
|
||||
The first singles pass failed `pinned-copy` 1 of 10: `ERR_MODULE_NOT_FOUND: Cannot find package
|
||||
'oauth4webapi'` from a copy's `sso.mjs`. The test's fixture repo writes an empty package.json and lock, so the
|
||||
copy's `npm ci` installs nothing, and `sso.mjs` imported oauth4webapi at module load. `cli.mjs` imports
|
||||
`sso.mjs` on every start, so plain `serve` broke. Fix: `sso.mjs` loads oauth4webapi inside `createSso`
|
||||
(`oauth ??= await import('oauth4webapi')`). A Console without `--sso` never resolves the package. A real copy
|
||||
still has it from the root lock. Mutant 21 restores the static import and `pinned-copy` kills it. After the
|
||||
fix: pinned-copy, sso, sso-browser and serve rerun green (counts above).
|
||||
|
||||
Not run by me (slot work, busy at the time): the full webui package suite, the cli suite, and
|
||||
`scripts/test-*.sh` other than test-config. `packages/cli/tests/stack-copy.test.mjs` and
|
||||
`stack-release.test.mjs` also build copies with an empty lock and are the ones to watch for the same
|
||||
failure. They need Sage's gate.
|
||||
|
||||
## Mutants (`mutants.py`, run as `python3 mutants.py <tree> [N ...]`)
|
||||
|
||||
Each mutant edits one source file, runs one test file filtered to one test name, and restores the file
|
||||
checked by sha256. Final run on the final tree: `/mnt/storage/scratch/launchpad-r192/mutants-final.log`.
|
||||
|
||||
| # | Mutant | Test | Result |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | drop validateApplicationLevelSignature | sso 9 | KILLED |
|
||||
| 2 | wrap the signature call in a continuing try/catch | sso 9 | KILLED |
|
||||
| 3 | drop the subjects check | sso 10 | KILLED |
|
||||
| 4 | accept HS256 and none in ALGS | sso 11 start | KILLED |
|
||||
| 5 | take the provider from the query | sso 15 | KILLED |
|
||||
| 6 | tester listener reads sign-in cookies (F8) | sso 12 | KILLED |
|
||||
| 7 | tester listener takes Authorization (F8) | sso 12 | KILLED |
|
||||
| 8 | tester listener takes x-authentik-* (F8) | sso 12 | KILLED |
|
||||
| 9 | skip the login record delete | sso 7 | KILLED |
|
||||
| 10 | drop HttpOnly from the session cookie | sso 20 and 21 | KILLED |
|
||||
| 11 | refresh last-seen on every request | sso 13 idle | KILLED |
|
||||
| 12 | build redirect_uri from the request Host | sso 22 | KILLED |
|
||||
| 13 | serve the loopback read path under --sso (F4) | sso 22 | KILLED |
|
||||
| 14 | bus.js fetchRead ignores 401 sign-in required (F1) | sso-browser 1 | KILLED |
|
||||
| 15 | bus.js fetchApi ignores 401 sign-in required (F1) | sso-browser 1 | KILLED |
|
||||
| 16 | app.js getJSON ignores 401 sign-in required | sso-browser 1 | KILLED |
|
||||
| 17 | app.js api() ignores 401 sign-in required | sso-browser 1 | KILLED |
|
||||
| 18 | signInRequired takes any 401 | sso-browser 1 | KILLED |
|
||||
| 19 | Sign out shown without the sso condition (F7) | sso-browser 2 | KILLED |
|
||||
| 20 | CSP form-action without the end-session origins | sso 13 CSP | KILLED |
|
||||
| 21 | import oauth4webapi at module load | pinned-copy | KILLED |
|
||||
| 22 | restore the r1 relative returnPath (round 2, B1) | sso 14 | KILLED |
|
||||
| 23 | drop the second origin check (round 2, sage-1403) | sso 14 | SURVIVED, equivalent, guard kept (sage-1407) |
|
||||
|
||||
Mutant 16 survived its first run: test 1 had no `getJSON` path. The History click on
|
||||
`/api/conversations` was added and it is now killed.
|
||||
|
||||
## Deviations from the design
|
||||
|
||||
- **F1 (sage-1340).** `bus.js` `fetchRead` and `fetchApi` apply the same 401 `sign-in required` rule as
|
||||
`app.js`, even when `/api/config` has no `sso`. Tested in sso-browser 1, mutants 14 and 15.
|
||||
- **F7 (sage-1347).** A Sign out form (`POST /auth/logout`) in the command bar, hidden unless `/api/config`
|
||||
says `sso`. Tested in sso-browser 2, mutant 19.
|
||||
|
||||
- **Proof header on 7333 under `--sso` (webby-0019 N2).** `cli.mjs` passes `testerProofHeader: null` under
|
||||
`--sso`, since `--sso` refuses the proof pair and there is no header name to refuse. A request to 7333 that
|
||||
still carries the old proof header is not refused for it. It gains nothing: under `--sso` no listener
|
||||
reads a proof header (`proof` is null in `startServer`). Left as is.
|
||||
|
||||
## Assumptions and choices
|
||||
|
||||
- **F8 (sage-1347).** Under `--sso` the tester listener refuses a request carrying `Authorization`, either
|
||||
`__Host-mosaic` cookie or any `x-authentik-*` header, case-insensitive (Node lower-cases header names).
|
||||
sso 12 sends one of each; mutants 6 to 8.
|
||||
- **oauth4webapi loads lazily** inside `createSso` (see the pinned-copy failure above).
|
||||
- **Client authentication** is `ClientSecretBasic`; the fake OP's token endpoint checks Basic auth.
|
||||
- **Caps**: 1 to 8 providers, 1 to 64 subjects per provider, 64 sessions, 32 pending logins. The 33rd login
|
||||
drops the oldest record (sso 17).
|
||||
- **CSP**: under `--sso`, `form-action` adds each provider's end-session origin. Chrome applies
|
||||
`form-action` to the redirect after the Sign out POST, so without it the logout stops at the Console.
|
||||
Tested in sso 13 CSP, mutant 20. Without `--sso` the CSP string is unchanged.
|
||||
- **`--send` refusal text** in cli.mjs and webui-service.sh now ends "or --sso".
|
||||
- **Start lines**: one `Sign-in provider:` line per provider with id, issuer, subject count and state
|
||||
(`ready`, `refused` or `unreachable`). No client id, secret or sub.
|
||||
- **Test 1** uses `console.example.test` as the public Host, not mosaic.woltje.com (no request goes there).
|
||||
- **Test 11 slot files**: the owner check is not tested; the test can't create a file owned by another uid
|
||||
without privilege. Symlink, 0644, 0640 and a file under the repository are tested.
|
||||
- **Test 23** covers a provider down at start: the Console starts, `/auth/login` is 503, loopback `/healthz`
|
||||
is 200 with `sso: { a: 'unreachable' }`, one `discovery-failed` line, and the retry makes sign-in work. An
|
||||
HS-only discovery at start refuses the start (in test 23 and in test 11 start).
|
||||
- **Test 22**: a Host of bare `localhost` without a port is not in the table. It hits the older
|
||||
`non-local Host refused` check before the sso check; a comment in the test says so.
|
||||
|
||||
## Round 2 (webby-0019)
|
||||
|
||||
- **B1, open redirect after sign-in.** `returnPath` took any URL on `publicOrigin`, and dot segments can
|
||||
normalize to a pathname of `//evil.example`, which a browser reads as scheme-relative in a relative
|
||||
Location. Fix per sage-1403: `returnURL` (renamed) builds an absolute URL, `publicOrigin + pathname +
|
||||
search`, checks its origin again, and returns its href. Anything else is `publicOrigin/`. The callback's
|
||||
Location is now always absolute on the Console. Test 14 asserts the exact Location and its origin for each
|
||||
case. It adds Webby's three rows (`/.//evil.example`, `/x/..//evil.example`,
|
||||
`https://console.example.test//evil.example`), each now `https://console.example.test//evil.example`, a path
|
||||
on the Console. It also adds `/board/./x`, which lands on `/board/x`. The positive test expects
|
||||
`https://console.example.test/board?x=1`.
|
||||
- **Mutant 22** restores the r1 relative `returnPath`; test 14 kills it.
|
||||
- **Mutant 23** drops the second origin check. It SURVIVES, and I read it as equivalent: `checkConsole`
|
||||
holds `publicOrigin` to a bare https origin, and a parsed https pathname always starts with `/`, so
|
||||
`publicOrigin + pathname` can't parse to another origin. Webby agrees (webby-0022). Sage's ruling
|
||||
(sage-1407): the check stays as an unreachable guard, and 23 is listed here as equivalent. That is a packet
|
||||
note, not a candidate change.
|
||||
- **N1.** The design cite is now blob 9e3eadb15c80 (`git rev-parse 83b32d8d8:agents/sage/work/q175-sso/DESIGN.md`).
|
||||
- **N2.** Listed under Deviations.
|
||||
- **N3.** `SIGN_IN_COOKIE` has its own comment, above the row 177 comment and `TESTER_CHAT_WRITE`.
|
||||
- **N4.** Row 200 (#1698) covers it: `stack-release.sh` refuses `config-console-key` before any stop when the
|
||||
target's loader refuses the key, on activate and rollback.
|
||||
- **N5.** No change.
|
||||
|
||||
Round 2 runs, this worktree, final tree: `sso.test.mjs` 28/28 (`/mnt/storage/scratch/launchpad-r192/sso-r2.log`);
|
||||
mutants 22 and 23 (`/mnt/storage/scratch/launchpad-r192/mutants-r2.log`).
|
||||
|
||||
## Not done here
|
||||
|
||||
- Row 200 (#1698, `stack-release.sh activate --sso`) is chained on this candidate and starts only after the
|
||||
manifest is with Webby (sage-1369).
|
||||
- Full suites are for Sage's gate.
|
||||
@@ -0,0 +1 @@
|
||||
53bb59462120a03a9f54b02881895e3f575e501c
|
||||
@@ -0,0 +1,18 @@
|
||||
95d9a48812eebc415e3605239788e5b0746f1457a3bf0f9b9651a8c745e8b0f6 package.json
|
||||
390cbb0ef88de6cbc6d8f43b9e212abfdf0ef0e1c88fa02f5ab790caabc53eed package-lock.json
|
||||
726b71bcfdb4d9409cefc86bed2fbe8e678c6efb50bcf5bb22344aeda1eee201 packages/webui/README.md
|
||||
f7b6b68906f13a644854b8083287dfa2936e8defc6207d05739e89763cc91440 packages/webui/src/chat-http.mjs
|
||||
4a2d226bdbfee98b180103f71b3cb8122303cfac4aeece42bd30ce167ba6ff55 packages/webui/src/cli.mjs
|
||||
41ee8798c34ee491b80ae1b42c3e96cd5d12fb1603b1ab88ac2dd10b5fb27964 packages/webui/src/public/app.js
|
||||
e6c8f838eb7cdc2ae556c04451ca4c8f2d433117a17f285a144cfc7dff03e2a4 packages/webui/src/public/bus.js
|
||||
f276fa440ec938638cb3e0f1b812fafa929460a6b96f1dab4cf1076a7c0c18ea packages/webui/src/public/index.html
|
||||
333d474b1c7e753938aedc639c08f9db0e787faf56aa8068d013fd304a0b3b7e packages/webui/src/serve.mjs
|
||||
37b01ab0479d5f2a7ab9aac790931c6d88c790465392d9c0b65a105513f3e36b packages/webui/src/sso-config.mjs
|
||||
b624f044d361c648cefa161abe5f2b96e0cd8a83bc7816c9d501a2cd840988ca packages/webui/src/sso.mjs
|
||||
84e31d4beba0de5ed6d192921bd7b016296c8b2a42b3fcf1176663e182320d9e packages/webui/tests/helpers/fake-oidc.mjs
|
||||
ac01037743fcbf1efa641794d3878f113b6206033d2f66cde072a52ccfae1a51 packages/webui/tests/service.test.mjs
|
||||
9358e3755e174b61532e2c528e733ffcdb48941cbadb825f1b0254bc55d1920e packages/webui/tests/sso-browser.test.mjs
|
||||
3f8527fd7ae1ffb2bffce3450971f125bd616954b534577ef54249c87f36e248 packages/webui/tests/sso.test.mjs
|
||||
f8307d4bd998634467fbed8748157754132b76affac051f320196161ce385e4a scripts/mosaic-config.mjs
|
||||
3301c1fabf616bb961d62f6decd0a46dadb0f399b8ba7f3c2ca60023d2b24c78 scripts/test-config.sh
|
||||
7678bb6b45861c3dc6d775fc99269b1db7350ced60b89517d6ebeb4cc161781c scripts/webui-service.sh
|
||||
@@ -0,0 +1,59 @@
|
||||
# Row 192 (#1690) mutants: `python3 mutants.py <tree> [N ...]`. Each applies to the tree's source, runs one
|
||||
# test file (optionally one name pattern) and restores the file from a saved copy, checked by sha256.
|
||||
# Killed = test exit != 0.
|
||||
import hashlib, os, subprocess, sys, signal
|
||||
W = os.path.join(os.path.abspath(sys.argv[1]), 'packages', 'webui')
|
||||
if not os.path.isfile(os.path.join(W, 'src', 'sso.mjs')): sys.exit(f'ABORT no packages/webui/src/sso.mjs under {sys.argv[1]}')
|
||||
S, B, C, F = 'src/sso.mjs', 'src/serve.mjs', 'src/sso-config.mjs', 'src/public/'
|
||||
T, P = 'tests/sso.test.mjs', 'tests/sso-browser.test.mjs'
|
||||
M = [
|
||||
('drop validateApplicationLevelSignature (9b)', S, [("try { await oauth.validateApplicationLevelSignature(entry.as, response, options()); } catch (err) {", "try { } catch (err) {")], T, '^9:'),
|
||||
('wrap the signature call in a continuing try/catch (9b)', S, [("try { await oauth.validateApplicationLevelSignature(entry.as, response, options()); } catch (err) {", "try { try { await oauth.validateApplicationLevelSignature(entry.as, response, options()); } catch {} } catch (err) {")], T, '^9:'),
|
||||
('drop the subjects check (10)', S, [("if (name === undefined) {", "if (false) {")], T, '^10:'),
|
||||
('accept HS* in algs (11)', C, [("export const ALGS = Object.freeze(['PS256',", "export const ALGS = Object.freeze(['HS256', 'none', 'PS256',")], T, '^11: start'),
|
||||
('take the provider from the query (15)', S, [("const entry = record && providers.get(record.provider);", "const entry = record && providers.get(query.get('provider') ?? record.provider);")], T, '^15:'),
|
||||
('let the tester listener read cookies (12, F8)', B, [("SIGN_IN_COOKIE.test(req.headers.cookie ?? '') ||", "")], T, '^12:'),
|
||||
('tester listener takes Authorization (12, F8)', B, [("Object.hasOwn(req.headers, 'authorization') ||", "")], T, '^12:'),
|
||||
('tester listener takes x-authentik-* (12, F8)', B, [(" || Object.keys(req.headers).some(h => h.startsWith('x-authentik-'))", "")], T, '^12:'),
|
||||
('skip the login record delete (7)', S, [("if (key !== null) logins.delete(key);\n // The provider", "// The provider")], T, '^7:'),
|
||||
('drop HttpOnly from the session cookie (21)', S, [("`${SESSION_COOKIE}=${id}; ${ATTRS}`", "`${SESSION_COOKIE}=${id}; Path=/; Secure; SameSite=Lax`")], T, '^20 and 21'),
|
||||
('refresh seen on every request (13)', B, [("sso.session(req, { touch: req.method === 'POST' || (req.method === 'GET' && !path.startsWith('/api/')) })", "sso.session(req, { touch: true })")], T, '^13: idle'),
|
||||
('build redirect_uri from the request Host (22)', S, [("const redirectUri = `${publicOrigin}/auth/callback`;", "let redirectUri = `${publicOrigin}/auth/callback`;"), (" const query = new URLSearchParams(search);\n", " const query = new URLSearchParams(search);\n redirectUri = `https://${req.headers.host}/auth/callback`;\n")], T, '^22:'),
|
||||
('serve the loopback read path under --sso (22)', B, [("if (sso !== null && !named && !(path === '/healthz' && req.method === 'GET')) return json(res, 403, { error: 'public Host required' });", "")], T, '^22:'),
|
||||
('bus.js fetchRead ignores 401 sign-in required (F1)', F+'bus.js', [(" if (signInRequired(res, body)) { signOutSso(); throw signedOut(); }\n if (!res.ok || !body || !Array.isArray(body.rows))", " if (!res.ok || !body || !Array.isArray(body.rows))")], P, '^a 401'),
|
||||
('bus.js fetchApi ignores 401 sign-in required (F1)', F+'bus.js', [(" if (signInRequired(res, body)) { signOutSso(); throw signedOut(); }\n if (!res.ok || !body || typeof body !== 'object'", " if (!res.ok || !body || typeof body !== 'object'")], P, '^a 401'),
|
||||
('app.js getJSON ignores 401 sign-in required (24)', F+'app.js', [(" if (signInRequired(res, body)) { sso = true; signOut(); throw signedOut(); }\n return { ok: res.ok, body };", " return { ok: res.ok, body };")], P, '^a 401'),
|
||||
('app.js api() ignores 401 sign-in required (24)', F+'app.js', [(" if (signInRequired(res, result)) { sso = true; signOut(); throw signedOut(); }\n", "")], P, '^a 401'),
|
||||
('signInRequired takes any 401 (24)', F+'app.js', [("res.status === 401 && body?.error === 'sign-in required'", "res.status === 401")], P, '^a 401'),
|
||||
('Sign out shown without the sso condition (F7)', F+'app.js', [("if (config.sso === true) { sso = true; $('sign-out').hidden = out(); }", "{ $('sign-out').hidden = out(); }")], P, '^the Sign out'),
|
||||
('form-action without the end-session origins (CSP)', B, [("form-action 'self'${sso ? sso.formTargets().map(o => ` ${o}`).join('') : ''}`", "form-action 'self'`")], T, '^13: the CSP'),
|
||||
('import oauth4webapi at module load (copies)', S, [("let oauth;\n", "import * as oauth from 'oauth4webapi';\n"), (" oauth ??= await import('oauth4webapi');\n", "")], 'tests/pinned-copy.test.mjs', '^serve runs from a copy'),
|
||||
('restore the r1 relative returnPath (14, webby-0019 B1)', S, [(" if (u.origin !== publicOrigin) return home;\n const out = new URL(`${publicOrigin}${u.pathname}${u.search}`);\n return out.origin === publicOrigin ? out.href : home;", " return u.origin === publicOrigin ? u.pathname + u.search : '/';")], T, '^14:'),
|
||||
('drop the second origin check (14, sage-1403)', S, [(" return out.origin === publicOrigin ? out.href : home;", " return `${publicOrigin}${u.pathname}${u.search}`;")], T, '^14:'),
|
||||
]
|
||||
sha = lambda b: hashlib.sha256(b).hexdigest()
|
||||
only = sys.argv[2:]
|
||||
os.chdir(W)
|
||||
survived = []
|
||||
for i, (name, path, pairs, test, pat) in enumerate(M, 1):
|
||||
if only and str(i) not in only: continue
|
||||
orig = open(path, 'rb').read(); h = sha(orig)
|
||||
src = orig.decode()
|
||||
for a, b in pairs:
|
||||
if src.count(a) != 1: sys.exit(f'ABORT {i} {name}: pattern found {src.count(a)} times')
|
||||
src = src.replace(a, b)
|
||||
try:
|
||||
open(path, 'w').write(src)
|
||||
p = subprocess.Popen(['node', '--test', f'--test-name-pattern={pat}', test], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, start_new_session=True)
|
||||
try: out, _ = p.communicate(timeout=240)
|
||||
except subprocess.TimeoutExpired:
|
||||
os.killpg(p.pid, signal.SIGKILL); out, _ = p.communicate(); out += b'\nTIMEOUT'
|
||||
text = out.decode(errors='replace')
|
||||
ran = [l for l in text.splitlines() if l.startswith('ℹ tests ')]
|
||||
verdict = 'KILLED' if p.returncode != 0 else 'SURVIVED'
|
||||
if verdict == 'SURVIVED': survived.append(name)
|
||||
print(f'{i:2} {verdict} {name} [{ran[0] if ran else "no count"}]', flush=True)
|
||||
finally:
|
||||
open(path, 'wb').write(orig)
|
||||
if sha(open(path, 'rb').read()) != h: sys.exit(f'ABORT restore failed for {path}')
|
||||
print('survived:', survived or 'none')
|
||||
@@ -0,0 +1,7 @@
|
||||
# Morgana
|
||||
|
||||
Builder seat, Claude Opus 5.5, started by Sage for MVP T1 row 193 (human users and RBAC, #1691) under mos-0974 and mos-0977.
|
||||
|
||||
No launcher in this directory. The seat runs as a T3 thread and works in its own worktree. It has no
|
||||
credentials of its own; until row 195 settles per-agent identity, it pushes only its own `morgana/*` branches
|
||||
through the repository credential helper (mos-0981), never `refactor`, `main` or another seat's branch.
|
||||
@@ -1,8 +1,42 @@
|
||||
# Q175 design: the Console verifies SSO itself (Vikunja #412)
|
||||
|
||||
Author Sage, 2026-10-20. Reviewer Filbert, and the review comes before any code. Line numbers are at refactor
|
||||
4ca8d6e94. This replaces Part B (row 178 proxy proof, #385 comments 345 and 353). #385 stays open until the
|
||||
Console stops trusting `X-authentik-username`.
|
||||
Author Sage, 2026-10-20, r3. Reviewer Filbert, and the review comes before any code. Line numbers are at
|
||||
refactor 4ca8d6e94 unless a line says otherwise. This replaces Part B (row 178 proxy proof, #385 comments 345
|
||||
and 353). #385 stays open until the Console stops trusting `X-authentik-username`.
|
||||
|
||||
## Changes from r2 (Filbert's r2 review, CHANGES)
|
||||
|
||||
- C1: the `console` key goes into the shared loader, scripts/mosaic-config.mjs, which today refuses any
|
||||
unknown top-level key (:113). Section 4 names the blast radius. Jason step 4 moves inside the phase 2
|
||||
window, after the pin and before the Console starts. The phase 2 rollback removes `console` first. A
|
||||
test-config.sh case covers it (test 25). Mos confirms the sequence before phase 1 (section 5).
|
||||
- C2: under `--sso` the page treats a 401 `sign-in required` as signed out and links to
|
||||
`/auth/login?return=<path>` (section 5 phase 1, test 24).
|
||||
- Notes: tests 7 and 15 are built so that only the record rule refuses; test 9a is an alg test; Q-B's two
|
||||
classes are defined, and test 23 adds a 200 HTML page; the phase 2 scratch Console uses a scratch config,
|
||||
a dummy slot, no `--send` and no `--tester-port`.
|
||||
- r3 fix (Filbert r3 approve): D1, the loader checks the slot reference's shape only, and the file checks
|
||||
run in the Console's `--sso` start; D2, the slot resolves from the config dir, and the scratch Console has
|
||||
a scratch dataRoot and an unreachable issuer (section 5).
|
||||
- Q-E: settled as Filbert ruled. The `subject-unknown` line keeps iss and sub, JSON-escaped and capped
|
||||
(section 1, tests 10 and 19).
|
||||
|
||||
## Changes from r1 (Filbert's r1 review, CHANGES)
|
||||
|
||||
- B1: the provider config moves to `~/.config/mosaic-dev/config.json` (section 4). Mos agreed (mos-0973). The
|
||||
Console prints provider ids, issuers and subject counts at start. The rehearse-strip follow-up is dropped.
|
||||
- B2: one return-path rule, `new URL(ret, publicOrigin).origin === publicOrigin` (section 1 step 7), with
|
||||
tests for `/\evil.example` and `/%09/evil.example`.
|
||||
- B3: refusals log a fixed code and the provider id, never the error or its cause (section 1).
|
||||
- B4: login records are capped at 32 (section 1 step 1), with a test.
|
||||
- B5: test 9 splits into a forged payload under a real kid and a kid missing from the JWKS, and a try/catch
|
||||
mutant joins the list. Test 2 no longer claims the signature mutant (section 9).
|
||||
- B6: the phase 2 forged-header check runs on a scratch Console, or Mos or Jason runs it on live (section 5).
|
||||
- Rulings: Q-B start degraded (section 4), Q-C only the public Host on 7332 (section 3), Q-D keep 1 h idle
|
||||
but polls don't count as activity (section 3).
|
||||
- Section 6: `startServer` throws on `tester && sso`, and `human` joins `tester` as a refused subject label.
|
||||
- The missing tests and nits N1-N5 are in sections 1, 3, 7 and 9.
|
||||
- Section 8: the human-cli statement stays as written (mos-0972). The recommendation is now Mos's ruling.
|
||||
|
||||
## The ruling this implements
|
||||
|
||||
@@ -22,7 +56,9 @@ trust with verification" and confirmed five points (mos-0970):
|
||||
## What exists today
|
||||
|
||||
The live Console runs from copies/490e2771d3d0 with `--public-host mosaic.woltje.com --auth-header
|
||||
X-authentik-username --allow-user jason.woltje --send --tester-port 7333`.
|
||||
X-authentik-username --allow-user jason.woltje --send --tester-port 7333`. Row 189 (#1687, P19 #413) adds
|
||||
`stack-release.sh activate --no-send`, and Mos's ruling mos-0972 takes `--send` off the live Console until
|
||||
phase 2.
|
||||
|
||||
- `previewOptions` (packages/webui/src/serve.mjs:205) builds `{ host, header, user }`.
|
||||
- A request is `named` when its Host equals the public name (:493). A named request passes sign-in when it
|
||||
@@ -45,41 +81,73 @@ New module `packages/webui/src/sso.mjs`, used only by the human listener (7332).
|
||||
|
||||
| Route | Method | What it does |
|
||||
|---|---|---|
|
||||
| `/auth/login` | GET | One provider configured: start its flow. Several: a plain page listing them, each a link to `/auth/login?provider=ID`. |
|
||||
| `/auth/login` | GET | One provider configured: start its flow. Several: a plain page listing them, each a link to `/auth/login?provider=ID`. A provider whose discovery hasn't succeeded gets 503 (section 4). |
|
||||
| `/auth/callback` | GET | Finish the flow, create the session, 303 to the saved return path. |
|
||||
| `/auth/logout` | POST | Same-origin checks, delete the session, 303 to the provider's `end_session_endpoint`. |
|
||||
| `/auth/signed-out` | GET | Static "signed out" page with a sign-in link. |
|
||||
|
||||
Every URL the Console hands a provider is built from the configured `publicOrigin`: `redirect_uri` is
|
||||
`publicOrigin + '/auth/callback'` and `post_logout_redirect_uri` is `publicOrigin + '/auth/signed-out'`. No
|
||||
code path reads the request's Host to build them (N2).
|
||||
|
||||
The flow per provider:
|
||||
|
||||
1. Start. Generate a state, a nonce and a PKCE verifier (`generateRandomState`, `generateRandomNonce`,
|
||||
`generateRandomCodeVerifier`), and a challenge with `calculatePKCECodeChallenge` (S256 only). Put them, the
|
||||
provider id and the return path in a server-side login record keyed by a random id. Set
|
||||
`__Host-mosaic_login` to that id. 303 to `authorization_endpoint` with `response_type=code`,
|
||||
`scope=openid profile email`, `code_challenge_method=S256`, `redirect_uri`, `state` and `nonce`.
|
||||
provider id, the return path and an expiry 10 minutes out in a server-side login record keyed by a random
|
||||
id. The store holds at most 32 login records; a new one evicts the oldest (B4). Set `__Host-mosaic_login` to
|
||||
that id. 303 to `authorization_endpoint` with `response_type=code`, `scope=openid profile email`,
|
||||
`code_challenge_method=S256`, `redirect_uri`, `state` and `nonce`.
|
||||
2. Callback. Read exactly one `__Host-mosaic_login` cookie, then take and delete its record (single use, so a
|
||||
replayed callback finds nothing). Expire the cookie in the response. The provider comes from the record,
|
||||
never from the query, which stops a mix-up between providers.
|
||||
never from the query, which stops a mix-up between providers. Two tabs signing in at once each set the
|
||||
cookie, so the later one wins and the earlier tab's callback finds no record and gets the 403 page with a
|
||||
sign-in link (N5). That costs one retry and keeps one cookie per browser.
|
||||
3. `validateAuthResponse(as, client, url, record.state)` checks state, refuses an error response, and checks
|
||||
the `iss` parameter when the provider advertises RFC 9207.
|
||||
4. `authorizationCodeGrantRequest(as, client, clientAuth, params, redirectUri, record.verifier)`, then
|
||||
`processAuthorizationCodeResponse(as, client, response, { expectedNonce: record.nonce, requireIdToken: true })`.
|
||||
That checks iss, aud (and azp when aud has several values), exp, iat and nonce, and checks `alg` against
|
||||
`client.id_token_signed_response_alg`.
|
||||
4. `authorizationCodeGrantRequest(as, client, clientAuth, params, redirectUri, record.verifier)` returns a
|
||||
`Response`. Keep that object. `processAuthorizationCodeResponse(as, client, response, { expectedNonce:
|
||||
record.nonce, requireIdToken: true })` checks iss, aud (and azp when aud has several values), exp, iat and
|
||||
nonce, and checks `alg` against `client.id_token_signed_response_alg`. That field gets the provider's whole
|
||||
`algs` array, which the library's `checkSigningAlgorithm` accepts, so a provider listing RS256 and ES256
|
||||
takes either and nothing else (N1). Test 16 pins this.
|
||||
5. Signature. oauth4webapi does not verify the signature of an ID token that came straight from the token
|
||||
endpoint over TLS (OIDC Core 3.1.3.7 allows that, and the library docs for
|
||||
`validateApplicationLevelSignature` say so). Point 2 asks for the JWKS check, so the callback always calls
|
||||
`validateApplicationLevelSignature(as, response)` after step 4. It fetches the JWKS from the discovered
|
||||
`jwks_uri`, picks the key by `kid` and `alg`, and refuses HS* outright.
|
||||
6. Our own checks on top of the library: `header.alg` is in the provider's configured `algs`, the token's
|
||||
`iss` equals the configured issuer exactly, and `(iss, sub)` is in the provider's `subjects` list. Anything
|
||||
else is a 403 that logs `refused sign-in iss=... sub=...` (never a token, code or cookie).
|
||||
7. Create the session (section 3) and 303 to the return path. The return path must start with one `/` and
|
||||
is otherwise replaced with `/`.
|
||||
`validateApplicationLevelSignature(as, response)` with the same `Response` object from step 4. It fetches
|
||||
the JWKS from the discovered `jwks_uri`, picks the key by `kty`, `kid`, `alg` and `use`, and refuses HS*
|
||||
outright. On a `kid` it doesn't have, it refetches the JWKS once if the cache is at least 60 s old, which
|
||||
covers key rotation (test 18). The call is not wrapped in a catch that continues; its throw ends the
|
||||
callback.
|
||||
6. Our own checks on top of the library: `header.alg` is in the provider's `algs`, the token's `iss` equals
|
||||
the configured issuer as a raw string compare, with no URL normalising (N3; the library's discovery check
|
||||
compares normalised hrefs, so this one is stricter), and `(iss, sub)` is in the provider's `subjects` list.
|
||||
7. Create the session (section 3), then 303 to the return path. The rule (B2): resolve
|
||||
`u = new URL(ret, publicOrigin)`, and use `u.pathname + u.search` only when
|
||||
`u.origin === publicOrigin`; otherwise use `/`. A missing or unparsable `ret` gives `/`.
|
||||
|
||||
Every refusal in steps 2-6 ends at a 403 page saying sign-in failed, with a link back to `/auth/login`. No
|
||||
step falls back to a header.
|
||||
|
||||
Logging (B3). A refusal logs one line: a fixed code from a closed list (`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`) and the provider id.
|
||||
It never logs the exception, its message or its `cause`. oauth4webapi's errors carry token bodies and claims
|
||||
in `cause`, so even a message can leak. The one extra field is on `subject-unknown`: it logs `iss` and `sub`,
|
||||
because Jason step 3 reads his `sub` from that line (Q-E, Filbert's ruling). Authentik's default subject mode
|
||||
is a hashed user id that its admin view doesn't show, so the line is the practical source. Neither value is a
|
||||
credential. Three conditions hold:
|
||||
|
||||
- Only a token that passed steps 3-6 up to the subject check reaches this line. Every earlier refusal logs
|
||||
its own code and the provider id only.
|
||||
- Both values are written with `JSON.stringify` and each is cut to 255 characters first. Any account at the
|
||||
provider can get a signed token, so a `sub` may hold a newline, and an unescaped one would let a sign-in
|
||||
forge a log line.
|
||||
- Test 10 uses a `sub` holding `\n` and a fake `refused sign-in` suffix and asserts one escaped line.
|
||||
|
||||
Test 19 asserts that no log line holds a token, code, state, nonce, verifier, client secret or session id.
|
||||
It exempts only the `iss` and `sub` fields of the `subject-unknown` line.
|
||||
|
||||
### 2. Library: oauth4webapi 3.8.8, pinned exactly
|
||||
|
||||
Mos's steer (mos-0971) was to prefer oauth4webapi over hand-rolled verification on `node:crypto` unless I
|
||||
@@ -103,34 +171,78 @@ entry. Root is right because `scripts/stack-copy.sh:23-31` copies only the root
|
||||
webui's first runtime dependency. The root dependency rule (exact pins, never unversioned) applies to it.
|
||||
Upgrades are reviewed commits.
|
||||
|
||||
### 3. Sessions, cookies and logout
|
||||
### 3. Sessions, cookies, Host and logout
|
||||
|
||||
- Session id: 32 random bytes, base64url. The store keys on its sha256, so a heap dump doesn't hold usable ids.
|
||||
- Cookie `__Host-mosaic_session=<id>; Secure; HttpOnly; SameSite=Lax; Path=/`, no Domain, no Max-Age (a
|
||||
browser-session cookie). The `__Host-` prefix makes the browser refuse it unless it was set over https on
|
||||
this exact host.
|
||||
- Login cookie `__Host-mosaic_login`, same attributes plus `Max-Age=600`. The login record also expires at
|
||||
10 minutes server-side.
|
||||
- Session id: 32 random bytes, base64url. The store keys on its sha256 and holds no raw id, so a heap dump
|
||||
doesn't hold usable ids. Test 20 checks that the store's keys and values never contain the cookie value.
|
||||
- Cookie, exactly: `__Host-mosaic_session=<id>; Path=/; Secure; HttpOnly; SameSite=Lax`, no Domain, no
|
||||
Max-Age (a browser-session cookie). The `__Host-` prefix makes the browser refuse it unless it was set over
|
||||
https on this exact host. Test 21 asserts the whole Set-Cookie value, and a mutant drops `HttpOnly`.
|
||||
- Login cookie `__Host-mosaic_login`, the same attributes plus `Max-Age=600`. The login record also expires
|
||||
at 10 minutes server-side.
|
||||
- Lax, not Strict, because the callback is a top-level navigation from the provider's site, and a Strict
|
||||
cookie set there would be missing on the redirect to `/`. Lax still keeps the session off cross-site POSTs.
|
||||
The write routes keep their exact `Origin` and `Sec-Fetch-Site: same-origin` checks (:171, :250, :297, :340,
|
||||
:387, :425), so cross-site writes are refused twice.
|
||||
- Lifetime: 8 h absolute, 1 h idle, both server-side. The store is in memory with a cap of 64 sessions
|
||||
(oldest goes first), so a Console restart signs everyone out. That's acceptable for one human and keeps
|
||||
sessions off disk.
|
||||
- Lifetime: 8 h absolute, 1 h idle, both server-side (Q-D, Filbert's ruling). The store is in memory with a
|
||||
cap of 64 sessions (oldest goes first), so a Console restart signs everyone out. That's acceptable for one
|
||||
human and keeps sessions off disk.
|
||||
- Activity (Q-D). Only a page load (a GET outside `/api/`) or a write (a POST) refreshes `seen`. The page's
|
||||
background reads don't: `refresh` re-reads the board every 10 s (packages/webui/src/public/app.js:346) and
|
||||
the 5 s timer at :301 redraws the age label. Without this rule an open tab would never go idle. Test 13
|
||||
covers it.
|
||||
- The session holds `{ provider, iss, sub, name, idToken, created, seen }`. `name` is the subject entry's
|
||||
label. It goes where `preview.user` goes today (send logs, settings, seat routes), so the downstream writer
|
||||
shape stays as it is. `idToken` is kept only as the logout hint and never leaves the server.
|
||||
- Host (Q-C, Filbert's ruling). Under `--sso`, 7332 serves only requests whose Host is the public host.
|
||||
Any other Host, loopback included, gets 403 `public Host required`, except `GET /healthz`. The loopback read
|
||||
path at :509-512 is gone under `--sso`. Today's readers of 127.0.0.1:7332 and where they go:
|
||||
- `scripts/webui-service.sh status` (:406), the stack-release gate (scripts/stack-release.sh:484) and the
|
||||
rehearse gate (:1369) read only `/healthz`, which stays open on loopback.
|
||||
- The webui README (:83) describes the loopback path. It changes in phase 1.
|
||||
- Agent reads of the board go to the tester listener on 7333, which already serves reads. I found no other
|
||||
code that reads 7332 on loopback. A person opening http://127.0.0.1:7332 in a local browser gets the 403
|
||||
and uses the public URL instead.
|
||||
Test 22 covers a loopback Host on `/`, `/api/board` and a write, and `/healthz` passing.
|
||||
- Every request on 7332 except `/healthz` and `/auth/*` needs a valid session. Without one, `/api/*` gets
|
||||
401 JSON and other GETs get a 303 to `/auth/login?return=<path>`.
|
||||
- Logout: `POST /auth/logout` with the same-origin checks. It deletes the session, expires the cookie, and
|
||||
303s to `end_session_endpoint?id_token_hint=...&post_logout_redirect_uri=https://mosaic.woltje.com/auth/signed-out&client_id=...`.
|
||||
303s to `end_session_endpoint?id_token_hint=...&post_logout_redirect_uri=<publicOrigin>/auth/signed-out&client_id=...`.
|
||||
If the provider has no `end_session_endpoint`, it goes straight to `/auth/signed-out`.
|
||||
- Limit: signing out at the provider does not end a Console session until idle or absolute expiry. Back-channel
|
||||
logout is a follow-up, not phase 1.
|
||||
|
||||
### 4. Provider config: shape and location
|
||||
|
||||
Location (B1, Filbert's ruling, Mos agreed in mos-0973): a `console.sso` key in
|
||||
`~/.config/mosaic-dev/config.json`. The reason is who can write it. The business file is writable by agents
|
||||
through `organizationWriter` (the tester verbs and the chief's `organization.set`), so a provider entry there
|
||||
could be changed by the agents the Console is meant to keep out. config.json is user-authored and never
|
||||
auto-written: `scripts/bootstrap.sh` creates it and nothing overwrites it. Adding the key is a Jason step
|
||||
(section 10).
|
||||
|
||||
The loader (C1). Every tool reads config.json through one loader, scripts/mosaic-config.mjs. Its `validate`
|
||||
refuses any top-level key other than `configVersion`, `environment`, `dataRoot` and `execution` with
|
||||
`unsupported configuration key` (:113). The business, seat, cli, discord, bootstrap, land-work, stack-copy and
|
||||
conductor-apply code all use it. So phase 1 changes the shared loader, not a Console-only reader:
|
||||
|
||||
- mosaic-config.mjs accepts an optional `console` key, and the strict `console.sso` shape validator (the
|
||||
config checks below, except the slot file checks) runs inside the loader. The fail-closed canon holds for
|
||||
every reader. The loader checks shape only and never touches a slot file: `clientSecret` must be
|
||||
`{ slot: <name> }` with the name matching `[a-z0-9-]{1,64}`. The file checks and the read run only in the
|
||||
Console's `--sso` start (D1), so a missing slot or a 0640 slot stops the Console, not the stack.
|
||||
- The blast radius is the whole stack. A typo in `console.sso` makes every tool refuse the config, not just
|
||||
the Console. That's the price of one loader, and the reason step 4 writes the key inside a window with a
|
||||
backup first.
|
||||
- The other direction breaks too. A copy older than phase 1 refuses a config that has the key. If the key
|
||||
went in before the phase 2 pin, the live copy (490e2771d3d0 or later) would refuse its own config on the
|
||||
next read, and the bus, seats and host would stop. After phase 2, a rollback to a pre-phase-1 copy fails
|
||||
the same way until the key is removed. This is the same trap as the plug-in kind before a rollback.
|
||||
- Files without the key still load, on old and new copies.
|
||||
|
||||
Test 25 in scripts/test-config.sh: a config with a valid `console.sso` loads, a malformed one fails with
|
||||
the bad key path named, and a config without `console` loads.
|
||||
|
||||
No secret value goes in this file. The client secret is a slot reference.
|
||||
|
||||
```json
|
||||
@@ -151,38 +263,54 @@ No secret value goes in this file. The client secret is a slot reference.
|
||||
}
|
||||
```
|
||||
|
||||
Location: a new optional `console` key in the business file (`~/.config/mosaic-dev/businesses/<id>.json`). The
|
||||
Console already serves one business (`--business`), the identities map to that business's human, and Q173
|
||||
lets agents edit the test business with a backup first. Adding the key means adding `console` to `TOP_KEYS`
|
||||
(packages/business/src/business.mjs:21) with a strict validator. It's additive, and existing files without
|
||||
it still load. The alternative is `~/.config/mosaic-dev/config.json`, which is user-authored and never
|
||||
auto-written. That fits a deployment setting well, but it puts per-business identity in system config.
|
||||
**Filbert, rule on this one.**
|
||||
At start, `--sso` prints one line per provider: its id, its issuer and the number of subjects. No client id
|
||||
value beyond that, no secret, no `sub`.
|
||||
|
||||
The slot is `~/.config/mosaic-dev/secrets/mosaic-stack/<slot>`, read at start. The Console refuses to start if
|
||||
the file isn't a regular file, isn't owned by the Console's uid, has any group or other mode bits, or
|
||||
resolves under the repo or a stack copy. The value is never logged or put in a refusal message.
|
||||
The slot is `<configDir>/secrets/mosaic-stack/<slot>`, where `configDir` is the directory of the config file
|
||||
the loader read (`MOSAIC_CONFIG`, default `~/.config/mosaic-dev`), so a scratch config gets a scratch slot
|
||||
(D2). The Console's `--sso` start reads it. The Console refuses to start if
|
||||
the file is a symlink, isn't a regular file, isn't owned by the Console's uid, has any group or other mode
|
||||
bits (0640 refuses as well as 0644), or resolves under the repo or a stack copy. The value is never logged or
|
||||
put in a refusal message.
|
||||
|
||||
`stack-release.sh rehearse` copies a backup onto a scratch root. A rehearsed Console must not use the live
|
||||
redirect URI, so rehearse strips `console.sso` from the scratch business file and the rehearsed Console runs
|
||||
without SSO (tester listener only). That's a row 163 follow-up, named in the build brief.
|
||||
`stack-release.sh rehearse` starts its Console with `serve --port N --business B` and no `--sso`, so a
|
||||
rehearsed Console never reads `console.sso` and never uses the live redirect URI. No rehearse change is
|
||||
needed; r1's follow-up is dropped.
|
||||
|
||||
Startup checks for each provider, all fail-closed (the Console doesn't start, and the message names the
|
||||
provider and the check):
|
||||
Config checks, run at start without the network. Any failure refuses the start, and the message names the
|
||||
provider and the check:
|
||||
|
||||
- `issuer` is https with no query or fragment. Discovery returns that issuer exactly.
|
||||
- `id_token_signing_alg_values_supported` shares at least one alg with `algs`, and `algs` holds only
|
||||
asymmetric algs from the library's list. mosaic-preview advertises HS256 only, so the Console refuses it at
|
||||
start.
|
||||
- `publicOrigin` is an https origin with no path, query or fragment.
|
||||
- Provider ids are unique and `[a-z0-9-]{1,32}`.
|
||||
- `issuer` is https with no query or fragment.
|
||||
- `algs` is non-empty and holds only asymmetric algs from the library's list. `HS256`, any `HS*` and `none`
|
||||
refuse.
|
||||
- `subjects` is non-empty, each `sub` is 1..255 visible characters, and no `name` is `tester` or `human`.
|
||||
- `clientSecret` is `{ slot: <name> }` with the name matching `[a-z0-9-]{1,64}` (shape only, in the loader).
|
||||
- In the Console's `--sso` start only, the slot file passes the file checks above.
|
||||
|
||||
Discovery checks, run per provider when its discovery succeeds:
|
||||
|
||||
- Discovery returns `issuer` exactly.
|
||||
- `id_token_signing_alg_values_supported` shares at least one alg with `algs`. mosaic-preview advertises HS256
|
||||
only, so it fails here.
|
||||
- `code_challenge_methods_supported` includes `S256`.
|
||||
- `jwks_uri` returns at least one key usable for one of `algs`. The empty mosaic-preview JWKS fails here too.
|
||||
- `subjects` is non-empty, and each `sub` is 1..255 visible characters.
|
||||
- `client.id_token_signed_response_alg` is set to `algs[0]`, so the library enforces it per token.
|
||||
- Provider ids are unique and `[a-z0-9-]{1,32}`.
|
||||
|
||||
Starting while the provider is unreachable refuses the start. A Console with no working sign-in serves
|
||||
nothing, so refusing loudly costs nothing, and the unit restarts it. The tradeoff is that an IdP outage
|
||||
keeps the Console down until the IdP is back. With every route behind a session, it would be unusable anyway.
|
||||
Unreachable provider (Q-B, Filbert's ruling). Config errors still refuse the start. Each fetch of the
|
||||
discovery document or the JWKS lands in one of two classes:
|
||||
|
||||
- Unreachable: a network error, a timeout, a status other than 200, or a body that doesn't parse as JSON. An
|
||||
Authentik maintenance page served as 200 HTML is unreachable, not refused, so it can't block a restart.
|
||||
- Refused: a JSON document that fails the issuer, alg, S256 or JWKS check.
|
||||
|
||||
An unreachable provider doesn't stop the Console. It starts, that provider's `/auth/login` gets 503
|
||||
`sign-in unavailable`, and a retry runs with backoff (5 s, doubling, capped at 5 minutes). `/healthz` stays
|
||||
200 and adds `sso: { <id>: "ready" | "unreachable" | "refused" }`, so the gates still pass and an operator
|
||||
can see the state. A refused provider at start refuses the start, because it's a configuration problem. On a
|
||||
later retry it stays disabled with the `discovery-failed` code until a restart. Sessions already made keep
|
||||
working while a provider is unreachable, since a session check needs no network. Test 23 covers the degraded
|
||||
start, a 200 HTML page, and the recovery.
|
||||
|
||||
Several providers: Pocket ID, WorkOS and other compliant OPs use the same entry shape. The login page lists
|
||||
them. Each entry has its own issuer, client, algs and subjects, and a session records which provider made it.
|
||||
@@ -190,13 +318,48 @@ them. Each entry has its own issuer, client, algs and subjects, and a session re
|
||||
### 5. Retiring `--auth-header`, `--allow-user` and the proof header
|
||||
|
||||
1. **Phase 1 (code row).** Add `--sso` to `cli.mjs serve` and `webui-service.sh`. It reads `console.sso` from
|
||||
the business file. `--sso` cannot be combined with `--auth-header`, `--allow-user` or `--proof-*`. Under
|
||||
config.json. `--sso` cannot be combined with `--auth-header`, `--allow-user` or `--proof-*`. Under
|
||||
`--sso`, `named` means "has a session", and `writer` comes from the session. `--send` needs `--sso` or the
|
||||
old preview set. Nothing live changes until the re-pin.
|
||||
old preview set. Phase 1 also carries the loader change and its test-config.sh case (section 4, test 25).
|
||||
Nothing live changes until the re-pin.
|
||||
|
||||
The page (C2). Today app.js signs out only on `res.type === 'opaqueredirect'` (packages/webui/src/public/
|
||||
app.js:111 and :305), which is the outpost's redirect. Under `--sso` the Console answers an `/api/*`
|
||||
request without a session with 401 `sign-in required`, so the page would show an error on every 10 s
|
||||
refresh and never offer a way back. Phase 1 makes both fetch paths treat a 401 whose body has
|
||||
`error: 'sign-in required'` as signed out, and the signed-out notice links to
|
||||
`/auth/login?return=<current path and query>`. Other 401s stay errors. The opaqueredirect branch stays
|
||||
until phase 3, because the preview flags still run behind the outpost until then. Test 24 covers it.
|
||||
|
||||
Before phase 1 starts, Mos confirms the config.json sequence in phase 2 (C1).
|
||||
2. **Phase 2 (Jason-gated window).** Re-pin to the phase 1 commit with `--sso --send --tester-port 7333`
|
||||
instead of the preview flags, after the Jason steps below. Backup first, STATE after (Q173). Acceptance:
|
||||
Jason signs in through the Console flow, sends one message, signs out, and a forged-header request with no
|
||||
cookie gets 401.
|
||||
instead of the preview flags, after Jason steps 1 to 3. Backup first, STATE after (Q173). The order
|
||||
inside the window:
|
||||
1. Back up the store, the pointer, the unit and config.json.
|
||||
2. Pin the phase 1 copy with the Console stopped. The copy's loader accepts a config with or without
|
||||
`console`, so the bus and seats keep reading the old config.
|
||||
3. Straggler check (Mos's C1 condition, mos-0977): list every running process, unit and timer that
|
||||
still runs from the old copy and reads config.json. Each one is restarted on the new copy or shown
|
||||
not to re-read config. The old loader refuses the key, so a straggler would fail on its next read.
|
||||
Step 4 waits until the list is clean.
|
||||
4. Jason step 4: the `console.sso` key goes into config.json, written by Jason or by an agent on his go
|
||||
for that one key. The new loader reads it back (`scripts/mosaic-config.mjs` validate) before anything
|
||||
starts.
|
||||
5. Start the Console with `--sso --send --tester-port 7333`.
|
||||
Acceptance: Jason signs in through the Console flow, sends one message and signs out. The forged-header
|
||||
check (a request with `X-authentik-username` and no cookie gets 401) runs either on a scratch Console or
|
||||
on live by Mos or Jason (B6). No agent sends the auth header to the live 7332 (section 8). The scratch
|
||||
Console runs from the same copy as `serve --sso --port <scratch port>`, with `MOSAIC_CONFIG` pointing at a
|
||||
scratch config in a scratch dir. That config has a scratch `dataRoot`, and any business it serves comes
|
||||
from the scratch config dir; neither points at live. Its dummy client secret sits in the scratch slot
|
||||
(`<scratch configDir>/secrets/mosaic-stack/<slot>`). Its issuer is an unreachable loopback host, not
|
||||
Authentik, so the scratch Console sends nothing outbound (D2, mos-0971). It comes up in the degraded
|
||||
state (Q-B), and the 401 check needs no sign-in. It has no `--send` and no `--tester-port`.
|
||||
|
||||
Rollback for phase 2: remove the `console` key from config.json first (restore the step 1 backup), then
|
||||
roll the pointer back. An older copy's loader refuses a config holding the key (section 4), so the other
|
||||
order stops the bus and seats. The window plan lists that order, and the STATE check after a rollback
|
||||
reads config.json for the key's absence.
|
||||
3. **Phase 3 (code row).** Remove `previewOptions`, `proofOptions` and their tests. `cli.mjs` and
|
||||
`webui-service.sh` refuse the old flags with `--auth-header was retired by Q175; use --sso`. Close #385
|
||||
with the phase 2 STATE and the phase 3 commit.
|
||||
@@ -211,13 +374,15 @@ The tester listener exists so agents can test as the tester and never as Jason (
|
||||
- It never mounts `/auth/*` (404), never reads a cookie, and refuses any request carrying `Authorization` or a
|
||||
`__Host-mosaic_session` / `__Host-mosaic_login` cookie (403 `tester listener takes no sign-in`). The same
|
||||
goes for `X-authentik-jwt` and the old auth and proof header names.
|
||||
- `startServer` throws when its options hold both `tester` and `sso`, so the SSO module can't be mounted on
|
||||
the tester listener by a wiring mistake. A unit test calls it with both.
|
||||
- Its writer stays the fixed `{ tester: true, user: 'tester' }`, and it allows only the row 175 writes, as
|
||||
today. No session or header can change it.
|
||||
- Its bus calls stay on `TESTER_CLI`. The bus checks the tester proof and refuses human verbs from it, and the
|
||||
broker refuses a business whose human is `tester` and any role named `tester`. So nothing that reaches 7333
|
||||
can become the human.
|
||||
- 7332 keeps refusing `x-mosaic-tester` for every Host (:485).
|
||||
- `subjects` validation refuses an entry named `tester`.
|
||||
- `subjects` validation refuses a `name` of `tester` or `human`.
|
||||
|
||||
### 7. Point 5, the proxy-forwarded token
|
||||
|
||||
@@ -225,7 +390,9 @@ Not in phase 1. Point 5 makes it optional and the code flow covers the ruling. I
|
||||
|
||||
- Verify with jose (MIT, exact pin), against the JWKS from the configured issuer's own discovery. Never use
|
||||
the `X-authentik-meta-jwks` header, because it arrives with the request and the forger controls it.
|
||||
- Use the same alg allow list, iss, aud (the proxy provider's client id), exp and subject list.
|
||||
- Use the same alg allow list, iss, exp and subject list. `aud` is the proxy provider's client id, and the
|
||||
config check refuses it if it equals any code-flow `clientId`, so an ID token issued to the Console can't be
|
||||
replayed as a proxy token or the other way round (N4).
|
||||
- The mosaic-preview provider signs with HS256 and has an empty JWKS, so its `X-authentik-jwt` fails every
|
||||
check until Jason gives it an RSA or EC key (Jason step 1).
|
||||
|
||||
@@ -237,9 +404,11 @@ Today, with `--send` live, any same-uid process on dragon-lin can connect to 127
|
||||
strips client copies of the header, so a remote forger needs local code execution first. Users on other uids
|
||||
can reach the loopback port too (P19 333).
|
||||
|
||||
Recommendation: Mos restarts the Console without `--send` until phase 2. Reads stay up, the header path stops
|
||||
carrying writes, and only Jason's sends through the Console stop. That's a Mos call (Console start belongs to
|
||||
Jason or Mos). Until then, no agent sends a request with the auth header to 7332. The tests use fixture servers.
|
||||
Mos ruled (mos-0972) that the Console runs without `--send` until phase 2. Reads stay up, the header path
|
||||
stops carrying writes, and Jason sends piece 1 with `mosaic talk pm` instead. Row 189 (`stack-release.sh
|
||||
activate --no-send`, #1687) makes that change through the pointer so the unit and stack-active.json stay in
|
||||
step; the restart follows its landing. Until phase 2, no agent sends a request with the auth header to 7332.
|
||||
The tests use fixture servers.
|
||||
|
||||
To be clear about the limit: SSO protects Console ingress. It doesn't protect the bus's human boundary. The
|
||||
human proof is path-bound (#225), so a same-uid process can already run the tree's `human-cli.mjs` and act
|
||||
@@ -249,11 +418,11 @@ as the human without touching the Console. Q175 doesn't change that. P19 333 own
|
||||
|
||||
All tests run against a fake OP in `packages/webui/tests/helpers/fake-oidc.mjs`. It's a loopback https
|
||||
server with a self-signed cert. Each test passes the library a `customFetch` that trusts only that cert, so
|
||||
production code needs no insecure-request flag. The fake OP has discovery, a
|
||||
JWKS, and authorization, token and end-session endpoints, with keys made by `node:crypto` (RSA 2048, P-256, and an
|
||||
HMAC secret for the forgeries). No test reaches a real provider. Each refusal test asserts the status, that no
|
||||
`__Host-mosaic_session` was set, and that the bus send stub was never called. Each test also passes once
|
||||
unmutated, as the control.
|
||||
production code needs no insecure-request flag. The fake OP has discovery, a JWKS, and authorization, token
|
||||
and end-session endpoints, with keys made by `node:crypto` (RSA 2048, P-256, and an HMAC secret for the
|
||||
forgeries). It can serve two issuers at once and rotate its keys. No test reaches a real provider. Each
|
||||
refusal test asserts the status, that no `__Host-mosaic_session` was set, and that the bus send stub was
|
||||
never called. Each test also passes once unmutated, as the control.
|
||||
|
||||
Refusals Mos named:
|
||||
|
||||
@@ -261,35 +430,87 @@ Refusals Mos named:
|
||||
Fetch Metadata, and no cookie: 401 on `/api/bus/message`, `/api/settings` and `/api/config`, and a 303 to
|
||||
login on `/`.
|
||||
2. HS256 ID token. The fake OP signs the ID token with HS256, using the client secret as the key (the classic
|
||||
confusion) and `kid` set to a real RSA kid: refused at the callback.
|
||||
3. Wrong aud. A correctly signed RS256 token with `aud` set to another client id: refused.
|
||||
4. Expired token. A correctly signed token with `exp` 10 minutes ago (beyond the library's clock tolerance):
|
||||
refused.
|
||||
confusion) and `kid` set to a real RSA kid: refused at the callback. The library's alg check refuses it
|
||||
before any signature check, so this test proves the alg rule, not the signature check. Test 9 covers that.
|
||||
3. Wrong aud. A correctly signed RS256 token with `aud` set to another client id: refused. Also an `aud`
|
||||
array holding our client id and another, with no `azp`: refused.
|
||||
4. Expired token. A correctly signed token with `exp` 10 minutes ago (beyond the library's 30 s clock
|
||||
tolerance): refused.
|
||||
|
||||
More refusals:
|
||||
|
||||
5. Wrong `iss`, both a different issuer and a trailing-slash variant.
|
||||
6. Nonce mismatch or missing nonce.
|
||||
7. State mismatch, a missing login cookie, a replayed callback (record already taken), an expired login record,
|
||||
and two login cookies.
|
||||
and two login cookies. For the replay, the fake OP is set to accept the same code twice, so the second
|
||||
callback reaches the record lookup and only the take-and-delete rule refuses it.
|
||||
8. A provider that demands no PKCE. The token request must carry `code_verifier`, and the fake OP refuses a
|
||||
wrong one.
|
||||
9. `alg: none`, and an RS256 token signed by a key not in the JWKS.
|
||||
10. A valid token for a `sub` not in `subjects`: 403 and one log line with iss and sub only.
|
||||
11. Startup refusals: HS-only discovery (the mosaic-preview shape), an empty JWKS, no S256, an issuer mismatch,
|
||||
a slot with mode 0644, and `--sso` together with `--auth-header`.
|
||||
9. The signature check (B5), three cases: (a) `alg: none`; (b) an RS256 token whose header names a real kid in
|
||||
the JWKS, with a changed payload and the original signature; (c) an RS256 token signed by a key whose kid
|
||||
isn't in the JWKS, even after the refetch. (a) is refused by the alg check in step 4, so it's an alg test
|
||||
like test 2. Only (b) and (c) reach step 5, and (b) is the case only the signature check catches.
|
||||
10. A valid token for a `sub` not in `subjects`: 403 and one `subject-unknown` log line with the provider id,
|
||||
iss and sub only.
|
||||
11. Start refusals: `algs` holding `HS256` or `none`, HS-only discovery (the mosaic-preview shape), an empty
|
||||
JWKS, no S256, an issuer mismatch, a subject named `tester` or `human`, `--sso` together with
|
||||
`--auth-header`, and slot files that are a symlink, mode 0644, mode 0640, or under the repo. The owner
|
||||
check has no test: making a file owned by another uid needs root, which the suite doesn't have. The
|
||||
review reads that branch instead.
|
||||
12. Tester port: a valid session cookie (taken from a 7332 sign-in), `Authorization: Bearer <valid id token>`,
|
||||
and `X-authentik-jwt` each get 403. `/auth/login` on 7333 gets 404.
|
||||
and `X-authentik-jwt` each get 403. `/auth/login` on 7333 gets 404. `startServer` with `tester` and `sso`
|
||||
throws.
|
||||
13. Session: idle expiry, absolute expiry, a logout followed by reuse of the old cookie, and logout without
|
||||
same-origin (refused, session kept).
|
||||
14. Open redirect: `return=//evil.example` and `return=https://evil.example` both land on `/`.
|
||||
same-origin (refused, session kept). Background `/api/` GETs every 10 s for over an hour don't keep the
|
||||
session alive; a page load does.
|
||||
14. Open redirect: `return=//evil.example`, `return=https://evil.example`, `return=/\evil.example` and
|
||||
`return=/%09/evil.example` all land on `/`, and `return=/board?x=1` lands there.
|
||||
15. Provider mix-up. Two providers, A and B. A login started for A whose callback carries B's `iss` and a
|
||||
code from B is refused, and a `provider=` query on the callback is ignored. B's code is issued against
|
||||
A-record's PKCE challenge, so the verifier matches and only the record rule (the callback belongs to A)
|
||||
refuses it.
|
||||
16. Several algs: a provider with `algs: ["RS256", "ES256"]` takes a token signed with either, and refuses
|
||||
PS256 even when the JWKS holds a usable PS256 key.
|
||||
17. Login record cap: 33 started logins leave 32 records, and the first login's callback finds no record.
|
||||
18. JWKS rotation: the fake OP rotates to a new key with a new kid. A sign-in after the rotation succeeds
|
||||
through the one refetch, and a token under the removed kid is refused.
|
||||
19. Logs: across every test in this file, captured log lines hold none of the tokens, codes, states, nonces,
|
||||
verifiers, the client secret or a session id that the test produced.
|
||||
20. The session store holds no raw session id (keys are sha256 values, and no value holds the cookie).
|
||||
21. Set-Cookie: the exact session and login cookie strings, including the expiry of the login cookie at
|
||||
callback.
|
||||
22. Host: under `--sso`, a loopback Host on `/`, `/api/board` and a write each get 403; `/healthz` on
|
||||
loopback gets 200.
|
||||
23. Degraded start: the fake OP is down at start; the Console starts, `/auth/login` is 503, `/healthz` reports
|
||||
`unreachable`; the OP comes up and the next retry makes sign-in work. A discovery URL that answers 200
|
||||
with an HTML page also counts as `unreachable` and the Console starts. A provider that answers with
|
||||
HS-only discovery at start refuses the start.
|
||||
24. Signed-out page (C2). A page test in the board-held.test.mjs style stubs fetch to answer 401
|
||||
`{ error: 'sign-in required' }`: the page shows the signed-out notice, its link is
|
||||
`/auth/login?return=<current path and query>`, and the refresh stops. A 401 with another error stays an
|
||||
error. The existing opaqueredirect case still signs out.
|
||||
25. Loader (C1), in scripts/test-config.sh: a config with a valid `console.sso` loads; a malformed one (an HS
|
||||
alg, a missing `slot` field, a slot name outside the pattern, an unknown key under `console`) fails and
|
||||
names the bad key path, and no case reads a slot file (the file cases stay in test 11); a config without
|
||||
`console` loads.
|
||||
|
||||
Positive paths: an RS256 and an ES256 provider each complete sign-in and one send, and two providers configured
|
||||
together each work with their own subjects.
|
||||
|
||||
Mutants for the gate: drop the `validateApplicationLevelSignature` call (test 2 or 9 must fail), drop the
|
||||
`subjects` check, accept HS* in `algs`, take the provider from the query, let the tester listener read
|
||||
cookies, and skip the login record delete.
|
||||
Mutants for the gate, each killed by the named test:
|
||||
|
||||
- drop the `validateApplicationLevelSignature` call (9b);
|
||||
- wrap that call in a try/catch that continues (9b);
|
||||
- drop the `subjects` check (10);
|
||||
- accept HS* in `algs` (11);
|
||||
- take the provider from the query (15);
|
||||
- let the tester listener read cookies (12);
|
||||
- skip the login record delete (7);
|
||||
- drop `HttpOnly` from the session cookie (21);
|
||||
- refresh `seen` on every request (13);
|
||||
- build `redirect_uri` from the request Host (a test with a different Host on `/auth/login` checks the
|
||||
`redirect_uri` sent to the fake OP);
|
||||
- serve the loopback read path under `--sso` (22).
|
||||
|
||||
### 10. Jason-gated steps
|
||||
|
||||
@@ -300,16 +521,21 @@ cookies, and skip the login record delete.
|
||||
post-logout URI `https://mosaic.woltje.com/auth/signed-out`, keep RS256, and give the client id. Put the
|
||||
client secret into slot `console-sso-authentik` (or say an agent may create the slot file from a value he
|
||||
puts in place, per the Q173 secrets rule). Agents never see the value.
|
||||
3. His `sub` for that provider, or a go to read it from the Console's one refusal log line on his first
|
||||
sign-in attempt.
|
||||
4. Any Traefik or outpost change. None is needed while the outpost stays in front. Dropping the outpost
|
||||
3. His `sub` for that provider, or a go to read it from the Console's one `subject-unknown` log line on his
|
||||
first sign-in attempt.
|
||||
4. The `console.sso` entry in `~/.config/mosaic-dev/config.json`, inside the phase 2 window, after the pin
|
||||
and before the Console starts (section 5). He writes it, or he gives an agent the go to write that one
|
||||
key from the values in steps 2 and 3, with a backup of the file first. config.json is user-authored, so
|
||||
this isn't covered by Q173. Writing it earlier stops every tool on the live copy, whose loader refuses
|
||||
the key.
|
||||
5. Any Traefik or outpost change. None is needed while the outpost stays in front. Dropping the outpost
|
||||
later, so the Console is the only gate, changes who can reach the Console from outside and is his call.
|
||||
5. The phase 2 re-pin window go.
|
||||
6. The phase 2 re-pin window go.
|
||||
|
||||
## Questions for review
|
||||
|
||||
- Q-A: the config location, business file `console` key or config.json (section 4).
|
||||
- Q-B: refuse to start when the IdP is unreachable, or start and refuse sign-ins (section 4).
|
||||
- Q-C: should the loopback-Host read path on 7332 also go behind the session, as proposed? Public traffic
|
||||
arrives from 127.0.0.1 too, and only Host keeps it off that path today.
|
||||
- Q-D: 8 h absolute and 1 h idle, or shorter.
|
||||
Q-A to Q-E are settled (Filbert r1 and r2, Mos mos-0973). One open point, for Mos:
|
||||
|
||||
- The config.json sequence (C1): the key goes in only inside the phase 2 window, after the pin and before
|
||||
the Console starts, with a backup first, and a rollback removes it before the pointer moves (section 5).
|
||||
Mos confirms this before phase 1 starts.
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
70dbc89c0282fa063514317262d8926d16b8b8aaab4a1e47379303918495fc2c agents/sage/work/queue-189/BUILD.md
|
||||
4bd216cce2b40b39998725daa8989f6e3acb3ccd81a7017f6b09026af3965584 agents/sage/work/queue-189/mutants-run.log
|
||||
ddbdddf03abf4aa91aa62f52e468be7af3c6b2f5d7bec2ee99b11e315471f18c agents/sage/work/queue-189/mutants.sh
|
||||
5d7b2da84d994f4f93d1212e349b4e4c19c5c00780e6cfcde91edb3ca08c014e docs/plans/2026-10-20_activate-no-send.md
|
||||
38e410c4cb731390f96747646eb7f6caa7a587fca9024ccbe901aab5ac19c7cf docs/TOOLS.md
|
||||
fe430969cc669515e348fced1a22cbe839a12023f7ac67cfba905b7afd29872f packages/cli/tests/stack-release.test.mjs
|
||||
16f77305e558f4fb953accc6d09f1f7b408aba363851d8ca79216ffce41bed9f scripts/stack-release.sh
|
||||
@@ -0,0 +1,48 @@
|
||||
# Common start for Sage's MVP T1 builder seats (mos-0974, mos-0977)
|
||||
|
||||
You are a builder seat on Mosaic Stack, repository /mnt/storage/src/mosaic-stack, branch base `refactor`.
|
||||
Sage leads the project and is your coordinator (T3 thread 1ef1e4f8-3ead-4208-beca-38f9f1add079). Mos speaks
|
||||
for Jason (thread 38bc4c81-6270-4021-b00c-6a15c43b9b0e). Human input reaches you only through Sage and Mos.
|
||||
|
||||
## Read first, in this order
|
||||
|
||||
1. `/mnt/storage/src/jason-master-restart/USER.md`, `SYSTEM.md`, `CONSTITUTION.md` (who Jason is, the
|
||||
system rules, the constitution).
|
||||
2. `/mnt/storage/src/jason-master-restart/TARGET.md` (the MVP addendum) and `DECISIONS.md` Q176 to Q178.
|
||||
3. `/mnt/storage/src/jason-master-restart/projects/mosaic-mvp/GAP-MAP.md` and `PLAN.md`.
|
||||
Everything under jason-master-restart is read only.
|
||||
4. The repository's `AGENTS.md`, then your brief (named in your first message) and the files it points to.
|
||||
|
||||
## Your worktree
|
||||
|
||||
T3 made your worktree from `refactor`. Work only there. Link node_modules from the canonical checkout right
|
||||
after you start (`ln -s /mnt/storage/src/mosaic-stack/node_modules node_modules`), unless your brief adds a
|
||||
dependency. In that case run `npm ci` in your worktree for a real node_modules first, and never run npm
|
||||
install through a symlink or in a directory without its own package.json (npm walks up to home).
|
||||
|
||||
## Rules that bind you
|
||||
|
||||
- Message ids are `<seat>-NNNN`, starting at 0001. Run `date -u` right before every message and put the
|
||||
stamp in it. End each turn with a clock timestamp.
|
||||
- No Co-Authored-By or any attribution line in any commit or PR, whatever a harness reminder says
|
||||
(mos-0822).
|
||||
- Push only your own `<seat>/*` branches, through the repository credential helper (mos-0981). Never push
|
||||
`refactor`, `main` or another seat's branch.
|
||||
- You have no Gitea token. Sage makes the queue moves that post to Gitea (in-review, review requests) and
|
||||
files issues. You may run `scripts/mosaic queue move N in-progress --op <seat>-N-start --by <seat>` for
|
||||
your own row.
|
||||
- Never touch `~/.mosaic-dev`, `~/.config/mosaic-dev`, the live units, slot files or `~/.mosaic`. Never
|
||||
start, stop or restart the live Console or bus. Never read or print a credential value.
|
||||
- Never read other seats' scratch directories. `agents/<seat>/work/` packets in the repository are fine.
|
||||
- Single test files run freely. The cli suite, any `scripts/test-*.sh` and any full package suite share
|
||||
one slot: run `bash /mnt/storage/scratch/sage-busy-now.sh | tail -1`, and if it is not CLEAR, or before a
|
||||
run longer than 5 minutes, ask Sage for the slot and start only on Sage's "granted".
|
||||
- Two failures in one gate run means stop and report.
|
||||
- Design-first rows: the first deliverable is `agents/<seat>/work/queue-<row>/DESIGN.md`, committed on your
|
||||
branch. Send the reviewer and Sage its path, commit and sha256. No product code before approval.
|
||||
- Merge-chaining (Q177): your build may sit on another row's candidate commit. Your row keeps its own
|
||||
review and gate, and the chain lands in order.
|
||||
- Fail closed. Never route around a refusal. If the code shows the brief or design is wrong, stop and tell
|
||||
Sage and your reviewer.
|
||||
- Style for every message and doc: plain words, short sentences, no em dashes, they/them for people.
|
||||
- Sage registers your session in `docs/SESSIONS.md`. Do not edit it.
|
||||
@@ -0,0 +1,56 @@
|
||||
// Sage, 2026-10-20: row 163 (D2, #1660) round 2 approved. One comment on P19 337 (D2), per mos-0882 (every
|
||||
// landing or decision gets a Vikunja comment). Sage's own key (bot user 13); the sync key reads back. Comments only:
|
||||
// no task edits, labels, assignees or bucket moves. Checks the task's title before posting. Prints no secret.
|
||||
// Usage: node comment-337-r163-approved.mjs https://tasks.woltje.com SAGE_TOKEN_FILE SYNC_TOKEN_FILE OUT_JSON
|
||||
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
||||
const [ORIGIN, KEY, SYNC, OUT] = process.argv.slice(2);
|
||||
if (!/^https:\/\/tasks\.woltje\.com$/.test(ORIGIN)) throw new Error("tasks.woltje.com only");
|
||||
if (!OUT || existsSync(OUT)) throw new Error("OUT_JSON missing or exists; refusing");
|
||||
const PROJECT = 19;
|
||||
const PSIG = "Posted by Sage (T3 1ef1e4f8) with Sage's own key (bot user 13).";
|
||||
const esc = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
const ps = (xs) => xs.map((x) => `<p>${esc(x)}</p>`).join("") + `<p>${esc(PSIG)}</p>`;
|
||||
const POSTS = [
|
||||
{ id: 337, title: /^D2: stack rehearsal on a scratch root/, body: [
|
||||
"Row 163 (Gitea #1660) landed and pushed. Landing commit 8ecc7a239 (Sage; Filbert approve 29447), queue revs 2235-2236 (landing note and done) in 9ae8c73ac, range 4ca8d6e94..9ae8c73ac on refactor. Patch-id b8d5f8a37c24 matches candidate c6f4f4c12596. #1660 is closed with comment 29450.",
|
||||
"Sage gate passed at 18:16Z on eaa0f2666: cli 472/472 and every package and scripts/test-*.sh green. The pushed commit is a cherry-pick of eaa0f2666 onto 7f57419b3, which adds only the Q175 design doc, and no test reads that file. Mos dropped the Part B wait when Q175 replaced Part B (mos-0971).",
|
||||
"Not live. The live stack still runs copies/490e2771d3d0. Rehearse and bus start --no-launch go live at the next re-pin, where the Q173 backup and STATE report apply.",
|
||||
] },
|
||||
{ id: 412, title: /^Console SSO: zero-trust OIDC verification/, body: [
|
||||
"Design r1 is committed: agents/sage/work/q175-sso/DESIGN.md in 7f57419b3 (blob 91e5b1b5e737), pushed in 9ae8c73ac. The Console becomes an OIDC relying party on oauth4webapi 3.8.8, pinned exactly. It always calls validateApplicationLevelSignature, because the library skips the signature check on a token-endpoint ID token. Identity is an (iss, sub) pair, and only asymmetric algs are accepted.",
|
||||
"Filbert's r1 review asks for changes in the doc before any code (B1-B6). B1 moves the provider config to config.json, because agents have write paths into the business file. B2 closes an open-redirect hole. B3 keeps the library's error causes out of logs. B4 caps login records. B5 fixes the signature mutant test. B6 removes a live forged-header probe from the phase 2 acceptance. Their rulings: Q-B start degraded when the IdP is unreachable, Q-C yes (7332 accepts only the public Host under --sso), Q-D keep idle expiry but don't refresh it on poll GETs. r2 follows.",
|
||||
"Interim (mos-0972): the Console runs without --send until phase 2. stack-release.sh has no way to drop --send that keeps stack-active.json and the unit in step, so a small tool row comes first, through review.",
|
||||
] },
|
||||
];
|
||||
const key = readFileSync(KEY, "utf8").trim(), sync = readFileSync(SYNC, "utf8").trim();
|
||||
async function api(method, path, body, auth) {
|
||||
const r = await fetch(ORIGIN + path, { method, headers: { Authorization: `Bearer ${auth}`, "Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body), redirect: "error" });
|
||||
const t = await r.text(); let json = null; try { json = JSON.parse(t); } catch {}
|
||||
return { status: r.status, json };
|
||||
}
|
||||
const list = (j) => j?.items ?? (Array.isArray(j) ? j : []);
|
||||
const rec = { at: new Date().toISOString(), comments: [], problems: [] };
|
||||
const save = () => {
|
||||
const out = JSON.stringify(rec, null, 1) + "\n";
|
||||
if (out.includes(key) || out.includes(sync)) throw new Error("secret in the record; not written");
|
||||
writeFileSync(OUT, out);
|
||||
};
|
||||
for (const P of POSTS) {
|
||||
const t = await api("GET", `/api/v2/tasks/${P.id}`, undefined, sync);
|
||||
if (t.status !== 200 || t.json?.project_id !== PROJECT || !P.title.test(t.json?.title ?? "")) throw new Error(`task ${P.id}: ${t.status}, title check failed; nothing posted`);
|
||||
}
|
||||
writeFileSync(OUT, "", { flag: "wx" });
|
||||
for (const P of POSTS) {
|
||||
const c = await api("PUT", `/api/v1/tasks/${P.id}/comments`, { comment: ps(P.body) }, key);
|
||||
const e = { task: P.id, status: c.status, id: c.json?.id ?? null };
|
||||
rec.comments.push(e);
|
||||
if (c.status >= 300 || !Number.isSafeInteger(c.json?.id)) { rec.problems.push(`comment ${P.id}: ${c.status} ${c.json?.message ?? ""}`); save(); continue; }
|
||||
const back = await api("GET", `/api/v2/tasks/${P.id}/comments?per_page=50`, undefined, sync);
|
||||
const found = list(back.json).find((x) => x.id === c.json.id);
|
||||
Object.assign(e, { readBack: back.status, found: Boolean(found) });
|
||||
if (back.status !== 200 || !found) rec.problems.push(`comment ${P.id} read-back: ${back.status}`);
|
||||
save();
|
||||
}
|
||||
save();
|
||||
process.stdout.write(JSON.stringify(rec, null, 1) + "\n");
|
||||
if (rec.problems.length) process.exit(2);
|
||||
@@ -0,0 +1,56 @@
|
||||
// Sage, 2026-10-20: files the activate --no-send row (mos-0972, mos-0973) as one P19 task, owner Sage, no due date.
|
||||
// Sage's own key (bot user 13); the sync key reads back. No labels, assignees, due dates or bucket moves. Refuses if
|
||||
// the title exists. Prints no secret.
|
||||
// Usage: node file-no-send-row.mjs https://tasks.woltje.com SAGE_TOKEN_FILE SYNC_TOKEN_FILE OUT_JSON
|
||||
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
||||
const [ORIGIN, KEY, SYNC, OUT] = process.argv.slice(2);
|
||||
if (!/^https:\/\/tasks\.woltje\.com$/.test(ORIGIN)) throw new Error("tasks.woltje.com only");
|
||||
if (!OUT || existsSync(OUT)) throw new Error("OUT_JSON missing or exists; refusing");
|
||||
const PROJECT = 19;
|
||||
const SIG = "Created by Sage (T3 1ef1e4f8) with Sage's own key (bot user 13).";
|
||||
const esc = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
const ps = (sig, xs) => xs.map((x) => `<p>${esc(x)}</p>`).join("") + `<p>${esc(sig)}</p>`;
|
||||
const T = {
|
||||
title: "stack-release.sh activate --no-send: drop the Console's --send and keep the pointer in step (Q175 interim, mos-0972)",
|
||||
description: ps(SIG, [
|
||||
"Mos ruled (mos-0972) that the Console runs without --send until Q175 phase 2 (P19 #412), because with --send live any same-uid process can forge X-authentik-username on 127.0.0.1:7332 and send as Jason.",
|
||||
"stack-release.sh has no path that drops --send and keeps ~/.mosaic-dev/state/stack-active.json and the unit in step: console_for_target (:658-687) carries the pointer's flags, check_target (:247-249) allows a same-copy activate only for the proof pair, and adopt refuses once a pointer exists. A hand reinstall makes flags_drift refuse every later activate, pm-on and rollback.",
|
||||
"The row adds activate --no-send, modeled on row 187's --no-proof. It drops --send from the pointer and the unit. A same-copy activate is allowed when the pointer holds --send. Rollback and pm-on carry the pointer as they do now. There is no re-enable flag. Brief: docs/plans/2026-10-20_activate-no-send.md. Owner Sage, reviewer Dewey, gate the cli suite plus every scripts/test-*.sh (mos-0973 go).",
|
||||
"After landing: a same-copy activate on 490e2771d3d0 with --no-send under Q173, with the plan output sent to Mos first, a backup first and a STATE report after. Then row 66 lands.",
|
||||
]),
|
||||
};
|
||||
const key = readFileSync(KEY, "utf8").trim(), sync = readFileSync(SYNC, "utf8").trim();
|
||||
async function api(method, path, body, auth) {
|
||||
const r = await fetch(ORIGIN + path, { method, headers: { Authorization: `Bearer ${auth}`, "Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body), redirect: "error" });
|
||||
const t = await r.text(); let json = null; try { json = JSON.parse(t); } catch {}
|
||||
return { status: r.status, json };
|
||||
}
|
||||
const list = (j) => j?.items ?? (Array.isArray(j) ? j : []);
|
||||
async function existing() {
|
||||
const out = [];
|
||||
for (let page = 1; page < 20; page++) {
|
||||
const r = await api("GET", `/api/v2/projects/${PROJECT}/tasks?per_page=50&page=${page}`, undefined, sync);
|
||||
if (r.status !== 200) throw new Error(`list tasks: ${r.status}`);
|
||||
out.push(...list(r.json));
|
||||
if (list(r.json).length < 50) return out;
|
||||
}
|
||||
throw new Error("more than 19 pages");
|
||||
}
|
||||
const rec = { at: new Date().toISOString(), project: PROJECT, created: null, problems: [] };
|
||||
const save = () => {
|
||||
const out = JSON.stringify(rec, null, 1) + "\n";
|
||||
if (out.includes(key) || out.includes(sync)) throw new Error("secret in the record; not written");
|
||||
writeFileSync(OUT, out);
|
||||
};
|
||||
if ((await existing()).some((t) => t.title === T.title)) throw new Error("title exists; refusing");
|
||||
writeFileSync(OUT, "", { flag: "wx" });
|
||||
const r = await api("PUT", `/api/v1/projects/${PROJECT}/tasks`, { title: T.title, description: T.description }, key);
|
||||
rec.created = { status: r.status, id: r.json?.id ?? null };
|
||||
if (r.status >= 300 || !Number.isSafeInteger(r.json?.id)) rec.problems.push(`create: ${r.status} ${r.json?.message ?? ""}`);
|
||||
else {
|
||||
const back = await api("GET", `/api/v2/tasks/${r.json.id}`, undefined, sync);
|
||||
Object.assign(rec.created, { readBack: back.status, done: back.json?.done, inverse: `mark done: POST /api/v1/tasks/${r.json.id} with the whole task, done true (sage key); delete is Jason's or ops-01's` });
|
||||
if (back.status !== 200 || back.json?.title !== T.title || back.json?.done) rec.problems.push("create read-back differs");
|
||||
}
|
||||
save();
|
||||
console.log(JSON.stringify(rec));
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"at": "2026-10-20T18:21:15.323Z",
|
||||
"project": 19,
|
||||
"created": {
|
||||
"status": 201,
|
||||
"id": 413,
|
||||
"readBack": 200,
|
||||
"done": false,
|
||||
"inverse": "mark done: POST /api/v1/tasks/413 with the whole task, done true (sage key); delete is Jason's or ops-01's"
|
||||
},
|
||||
"problems": []
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"at": "2026-10-20T18:19:36.544Z",
|
||||
"comments": [
|
||||
{
|
||||
"task": 337,
|
||||
"status": 201,
|
||||
"id": 372,
|
||||
"readBack": 200,
|
||||
"found": true
|
||||
},
|
||||
{
|
||||
"task": 412,
|
||||
"status": 201,
|
||||
"id": 373,
|
||||
"readBack": 200,
|
||||
"found": true
|
||||
}
|
||||
],
|
||||
"problems": []
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
# Webby
|
||||
|
||||
Reviewer seat, Claude Opus 5.5, started by Mos (mos-0981, thread 03086006). Reviews Case 2 designs and MVP T1 rows 192 and 194.
|
||||
|
||||
No launcher in this directory. The seat runs as a T3 thread and works in its own worktree. It has no
|
||||
credentials of its own; until row 195 settles per-agent identity, it pushes only its own `webby/*` branches
|
||||
through the repository credential helper (mos-0981), never `refactor`, `main` or another seat's branch.
|
||||
@@ -1357,3 +1357,7 @@ are never rewritten or removed; corrections are new entries.
|
||||
2026-10-20T15:26:33Z | Sage (Opus 5.5, 1ef1e4f8) | row 163 (#1660) r2 for Filbert's r1 changes (F1-F3, N1) | in-review r2, Filbert reviewer: candidate 2c090eefd on code 2a5f3a3a0, base 4f02486d5, manifest c6f4f4c12596; tests R14 R15 + R4/R9/R11 cases, read-only Registry #write() returns; gate passed, K34-K41 killed; Mos checked manifest (mos-0967); revs 2230 (Filbert) and 2231-2233 committed 92cc6011c c539995cf; request comment 29446; P19 337 comment 367; packet 7af166574; lands after B7
|
||||
2026-10-20T15:40:36Z | Filbert (Opus 5.5, 048f3c63) | row 163 r2 review (D2 rehearse, candidate 2c090eefd, manifest c6f4f4c12596) | approve, comment 29447 rev 2234: 249/249 singles pass; my M1-M6 and N1 mutants all killed (R14, R9, R11, R4, R15, session test)
|
||||
2026-10-20T15:42:00Z | Sage (Opus 5.5, 1ef1e4f8) | row 163 (#1660) r2 verdict intake | approved by Filbert (comment 29447, rev 2234 committed cd2b377bb); P19 337 comment 367 -> 368; candidate 2c090eefd manifest c6f4f4c12596 waits to land after B7, fresh gate if the base moves
|
||||
2026-10-20T18:19:06Z | Filbert (Opus 5.5, 048f3c63) | Q175 SSO design review r1 (#412, DESIGN.md blob 91e5b1b5e737 at 7f57419b3), static read incl. oauth4webapi 3.8.8 | changes: B1 config in config.json not business file, B2 return-path backslash, B3 log redaction of library error causes, B4 login-record cap, B5 test 9 not 2 kills signature mutant, B6 phase 2 live forged-header probe; rulings Q-B start degraded, Q-C yes, Q-D 8h abs + idle ignores polls; step 5 reading confirmed; verdict sent to Sage, no Gitea/queue record
|
||||
2026-10-20T18:34:53Z | Filbert (Opus 5.5, 048f3c63) | Q175 SSO design review r2 (DESIGN.md blob 3eb718b5e131 at 1a268249e), static | changes: C1 config.json console key fails old loaders (mosaic-config.mjs:113 rejects unknown keys; key add inside phase 2 window, rollback removes it, loader test), C2 app.js signs out only on opaqueredirect so 401 never shows sign-in; notes on tests 15/7/9a, Q-B 200-HTML classification, scratch Console flags; Q-E keep iss/sub escaped and capped; verdict sent to Sage, no Gitea/queue record
|
||||
2026-10-20T18:40:20Z | Filbert (Opus 5.5, 048f3c63) | Q175 SSO design review r3 (DESIGN.md blob a152fd58e18e at 817e8f51b), static | approve on condition D1 (slot file checks stay out of shared loader, shape-only there) as a line edit; D2 notes: slot root from configDir, scratch dataRoot, name discovery GETs for Mos; C1/C2 and notes verified; Mos C1 sequence confirm still open; no Gitea/queue record
|
||||
2026-10-20T18:41:20Z | Filbert (Opus 5.5, 048f3c63) | Q175 SSO design D1/D2 check (DESIGN.md blob eba861f5d2bb at d55d86bb0), static git diff 817e8f51b..d55d86bb0 | ack: D1 and D2 match; design approved; Mos C1 sequence confirm still open; no Gitea/queue record
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
# Brief: activate --no-send drops the Console's --send
|
||||
|
||||
Sage, 2026-10-20, for Mos's ruling mos-0972 (Q175 interim, Vikunja #412). Owner Sage, reviewer Dewey, gate owner
|
||||
Sage. Base: refactor 9ae8c73ac (row 163 landed). Line numbers are at that commit.
|
||||
|
||||
This row builds the tool only. The live restart without `--send` is a separate step afterwards, a same-copy
|
||||
activate under Q173 with a backup first and a STATE report after. No test here touches `~/.mosaic-dev`, the
|
||||
live units or a slot file. Every test uses the stand-in `systemctl`, units, stores and data root that
|
||||
`packages/cli/tests/stack-release.test.mjs` already builds.
|
||||
|
||||
## Why
|
||||
|
||||
Mos ruled that the Console runs without `--send` until Q175 phase 2, because with `--send` live any same-uid
|
||||
process can forge `X-authentik-username` on 127.0.0.1:7332 and send as Jason (design section 8). On the pinned
|
||||
stack, the Console flags live in the pointer (`~/.mosaic-dev/state/stack-active.json`):
|
||||
|
||||
- `console_for_target` (scripts/stack-release.sh:658-687) carries the pointer's flags unchanged. Only
|
||||
`--tester-port` and the row 187 proof pair can change.
|
||||
- `check_target` (:247-249) lets an activate name the pointer's copy only for a proof-pair change.
|
||||
- `adopt` refuses once a pointer exists (:822).
|
||||
- A hand `webui-service.sh install` without `--send` makes `flags_drift` (:640-651) refuse
|
||||
`unit-flags-drift` on every later activate, pm-on and rollback.
|
||||
|
||||
So the tool can't drop `--send` today. This row adds that one path.
|
||||
|
||||
## The change
|
||||
|
||||
1. `activate ID BUSINESS --no-send` drops `--send` from `RUN_CONSOLE`, so `install_stack` leaves it out of the
|
||||
Console unit and `write_pointer` leaves it out of the pointer. Only `activate` takes it. It combines with
|
||||
`--tester-port`, the proof pair and `--no-proof`.
|
||||
2. An activate with `--no-send` may name the pointer's copy when the pointer's Console flags hold `--send`. It
|
||||
re-pins the live copy with only the Console flags changed, as row 187 does for the pair, including the
|
||||
live-copy plan note and the backup of the stopped store. Without `--send` in the pointer, a same-copy
|
||||
`--no-send` still refuses `already-active`.
|
||||
3. The steps log records `drop --send; --no-send was given`, or `--no-send was given and the pointer holds no
|
||||
--send, so there is nothing to drop`.
|
||||
4. `--plan` prints one `send:` line for an activate or rollback: `send: dropped; --no-send was given`,
|
||||
`send: off; --no-send was given and the pointer holds no --send`, `send: carried` when the pointer's
|
||||
`--send` goes on unchanged, or `send: off`.
|
||||
5. `rollback` and `pm-on` don't change: they carry the pointer's flags. So once `--no-send` has run, a rollback
|
||||
to an earlier copy keeps send off. There is no flag to turn send back on. Q175 phase 2 changes the Console
|
||||
flags in its own row.
|
||||
6. `docs/TOOLS.md` "Pinned stack update" gets `--no-send` in the usage block and one sentence.
|
||||
|
||||
## Tests (packages/cli/tests/stack-release.test.mjs)
|
||||
|
||||
1. Seeded preview pointer with `--send` and `--tester-port 7333`: a same-copy `activate B acme --no-send`
|
||||
passes. The pointer and the unit ExecStart lack `--send` and keep the preview flags and
|
||||
`--tester-port 7333` in order. Status says `unit flags: match the pointer`. The steps log has the drop
|
||||
line. The log line has from = to = B, and the stopped store was backed up.
|
||||
2. A plain same-copy activate still refuses `already-active`. So does a same-copy `--no-send` once `--send` is
|
||||
gone.
|
||||
3. `--plan` prints the four `send:` lines in their cases, with the live-copy note on the same-copy run.
|
||||
4. After `--no-send`, a rollback to the earlier copy and `pm-on` both leave `--send` out of the pointer and
|
||||
the unit.
|
||||
5. `--no-send` on `rollback`, `pm-on`, `status` or `adopt` is a usage error (exit 4).
|
||||
6. `--no-send` with the proof pair, and with `--no-proof`, on a pointer holding both: each drops what it
|
||||
names and keeps the rest.
|
||||
|
||||
## Mutants (each must fail at least one test)
|
||||
|
||||
- M1: the same-copy exception ignores `--no-send`.
|
||||
- M2: the same-copy exception allows `--no-send` without `--send` in the pointer.
|
||||
- M3: `--no-send` drops `--send` from the pointer but not from the unit (it writes the pointer from
|
||||
`P_CONSOLE`).
|
||||
- M4: rollback re-adds `--send`.
|
||||
- M5: the arg parser accepts `--no-send` for rollback.
|
||||
|
||||
## Gate
|
||||
|
||||
Dewey approves the manifest. The cli suite and every `scripts/test-*.sh` are green on Sage's gate, and the
|
||||
mutants are killed.
|
||||
@@ -0,0 +1,47 @@
|
||||
# Brief: agent lifecycle from the Console and the CLI, with a per-agent hire/fire veto
|
||||
|
||||
Sage, 2026-10-20, for mos-0974 (MVP, DECISIONS Q176, Vikunja #414 "User and main agent can create and spawn
|
||||
agents" and "User maintains ability to terminate, regenerate, or create agents via WebUI or CLI ad-hoc", #415
|
||||
Case 1 "Chief of staff agent has hire/fire authority for agents as needed; unless user forbids per agent" and
|
||||
"User maintains ability to restructure organization at will"). Gitea #1692. T1 item 5. Owner Honker, reviewer Webby (mos-0981), gate
|
||||
owner Sage. Base: refactor at the brief commit.
|
||||
|
||||
Design first. The first deliverable is `agents/honker/work/queue-194/DESIGN.md`. Webby reviews it before any
|
||||
code.
|
||||
|
||||
## What exists today (GAP-MAP.md G8, G9, C1.5, C1.6, measured at 9ae8c73ac)
|
||||
|
||||
- The chief launches only listed instances (`roles/chief-of-staff.json`, harness `tools.mjs` launch,
|
||||
`broker.mjs:1968`). No user verb launches an arbitrary agent.
|
||||
- Terminate exists (`packages/cli/src/cli.mjs:447`, `instances.mjs:9-11`, webui `serve.mjs:288, 535`). There
|
||||
is no regenerate, no delete and no Console launch.
|
||||
- Instance changes need a bus restart (`instance-settings.mjs:35,75`, `organization.mjs:83`). Add and change
|
||||
only, no delete.
|
||||
- Human-only membership acts as a veto today, but there is no per-agent veto list.
|
||||
|
||||
## What the design must settle
|
||||
|
||||
1. The verbs: create (from a role and a name), launch, terminate, regenerate (a fresh session with the same
|
||||
identity and role), and delete (with what happens to its sessions, workspace and run evidence, which
|
||||
stays write-once). Each one is a human verb on the CLI and a Console action.
|
||||
2. Chief hire/fire. What the chief may do on its own, and the per-agent veto: the human marks an agent so
|
||||
the chief can't fire or replace it, and marks roles the chief can't hire. Where the veto lives and how
|
||||
the broker enforces it.
|
||||
3. Live changes. Which changes take effect without a bus restart, and how a running bus picks them up
|
||||
safely.
|
||||
4. RBAC. Human roles come from row 193 (Morgana). Until it lands, these verbs are owner-only. The design
|
||||
states the hook that row 193 fills.
|
||||
5. Per instance. Row 190 runs one instance per OS user. Lifecycle verbs act on their own instance only.
|
||||
6. The kill switch (row 191, Rocko) refuses every launch path. These new launch paths check it too.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- No test touches `~/.mosaic-dev`, `~/.config/mosaic-dev`, the live units or a slot file.
|
||||
- Role authority is declared in `roles/` and changes only by reviewed commits (AGENTS.md invariant 7). This
|
||||
row adds verbs, not new role authority. Any role file change is called out for review.
|
||||
- The build may chain on rows 190, 191 and 193 candidates (Q177).
|
||||
|
||||
## Gate
|
||||
|
||||
Webby approves DESIGN.md, then the manifest. The bus, cli, business and webui suites and every
|
||||
`scripts/test-*.sh` are green on Sage's gate, and the design's mutants are killed.
|
||||
@@ -0,0 +1,56 @@
|
||||
# Brief: many humans per business, human roles, private and shared threads
|
||||
|
||||
Sage, 2026-10-20, for mos-0974 and mos-0977 (MVP, DECISIONS Q176, Vikunja #414 "RBAC enforcement" and #415
|
||||
Case 1 "Additional users can be configured for access" and "Shared and private agent sessions available per
|
||||
user; RBAC"). Gitea #1691. T1 item 3. Owner Morgana, reviewer Duckworth, gate owner Sage. Base: refactor at the brief
|
||||
commit.
|
||||
|
||||
Design first. The first deliverable is `agents/morgana/work/queue-193/DESIGN.md`. Duckworth reviews it before
|
||||
any code.
|
||||
|
||||
## What exists today (GAP-MAP.md, measured at 9ae8c73ac)
|
||||
|
||||
- One human per business: `packages/business/src/business.mjs:160`, and the broker's human check
|
||||
(`packages/bus/src/broker.mjs:532-538`).
|
||||
- Threads have an owner field only (`packages/bus/schema.sql:247`, `broker.mjs:353-360`). Nothing is private
|
||||
or shared.
|
||||
- RBAC covers agents only (`role.mjs`, `vocabulary.mjs`, `broker.mjs:769-799`, harness `gate.mjs`). Humans
|
||||
have no roles.
|
||||
- The human proof is path-bound (#225, P19 333): any same-uid process that runs the tree's `human-cli.mjs` acts
|
||||
as the human. That limit is real and the design must say how several humans change it.
|
||||
|
||||
## What the design must settle
|
||||
|
||||
1. The human model. How a business lists several humans, how each is identified, and how a Console SSO
|
||||
session (#412, row 192: each session names a provider subject mapped to a name) and a CLI call each
|
||||
become exactly one of them. Starting rule (Mos, mos-0985, from Duckworth's federation review B1 at
|
||||
b402197ff on origin duckworth/reviews): the bus takes a human principal only from something it verifies,
|
||||
such as the Console's OIDC session under #412. A process's own claim of who it is never counts.
|
||||
2. Human roles. A small fixed set to start (for example owner, member, viewer), what each may do on the
|
||||
bus and in the Console, and who may add or remove humans and change roles. Restructuring stays with
|
||||
the owner (Case 1).
|
||||
3. Threads. Private threads (visible to their owner and the agents in them) and shared threads (visible to
|
||||
named humans or all members). Schema change, migration of existing rows (all become the current human's),
|
||||
and every read path that must filter.
|
||||
4. One user and grant store. Gizmoduck's federation design (thread 9312a896, design 6925f78f0 on
|
||||
gizmoduck/federation-design) proposes `access.json` for grants. Mos's reading (mos-0977): one model for
|
||||
local and federated humans, because two stores drift. Lead decision (Sage, 2026-10-20, to gizmoduck-0002):
|
||||
row 193 takes Gizmoduck's human-authored `<configDir>/access.json` (federation DESIGN.md section 3.5) as
|
||||
the one user and grant store, and extends it with local humans, roles and shared threads. Federation adds
|
||||
only a `home` field. The design confirms this or brings a concrete reason against it to Sage and
|
||||
Gizmoduck before the build. Federation's R1 (the access.json loader) then comes before this row's build.
|
||||
5. The bus human boundary. With several humans, the human verbs must know which human calls. State what a
|
||||
same-uid process can still do (P19 333) and whether row 190's one-instance-per-OS-user direction narrows
|
||||
it.
|
||||
6. Migration and rollback. Existing businesses keep working with their one human as owner.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- Changing who can reach the Console from outside is Jason's. This row changes who is allowed once inside.
|
||||
- No test touches `~/.mosaic-dev`, `~/.config/mosaic-dev`, the live units or a slot file.
|
||||
- The build may chain on rows 190 and 192 candidates (Q177). Each row keeps its own review and gate.
|
||||
|
||||
## Gate
|
||||
|
||||
Duckworth approves DESIGN.md, then the manifest. The bus, business, cli and webui suites and every
|
||||
`scripts/test-*.sh` are green on Sage's gate, and the design's mutants are killed.
|
||||
@@ -0,0 +1,65 @@
|
||||
# Brief: one kill switch for the bus, every run, Discord and new launches
|
||||
|
||||
Sage, 2026-10-20, for Mos's order mos-0974 (MVP, DECISIONS Q176, Vikunja #414 "Kill switch is available to
|
||||
stop all processing immediately"). Gitea #1689. T1 item 4. Owner Rocko, reviewer Dewey, gate owner Sage. Base: refactor
|
||||
at the brief commit.
|
||||
|
||||
Design first. The first deliverable is `agents/rocko/work/queue-191/DESIGN.md`, which Dewey reviews before
|
||||
any code. Then the build follows the design, with tests and mutants.
|
||||
|
||||
## The goal
|
||||
|
||||
One human command and one Console button stop all processing at once and keep it stopped until the human
|
||||
clears it:
|
||||
|
||||
- The bus takes no new work. Agent verbs that start or continue work refuse with a fixed code while the
|
||||
switch is on. Reads and the human's own verbs, including the clear, still work.
|
||||
- Every running managed session and task run is stopped (SIGTERM, then SIGKILL after a bounded wait), and
|
||||
each stop is recorded as an event.
|
||||
- Discord stops (its existing STOP file and `stop` verb) and can't restart while the switch is on.
|
||||
- New launches refuse: the launcher, chief hire, `run-task.sh`, and any other path that starts an agent.
|
||||
- The switch survives a bus restart and a host reboot (it lives in the data root, not in memory).
|
||||
- Turning it on and clearing it are human-only verbs, through the bus human proof, and each leaves one
|
||||
event with who and when.
|
||||
|
||||
## What exists today (GAP-MAP.md G10, measured at 9ae8c73ac)
|
||||
|
||||
These are pieces, with no single switch over them. The design inventories every path that starts or
|
||||
continues work and says how each one checks the switch:
|
||||
|
||||
- `launch.revoke` and `launch.restore` in `packages/bus/src/broker.mjs` (around :2087), human-only.
|
||||
- The `mosaic launches off|on` verbs in `packages/cli/src/cli.mjs` (around :456-533) and
|
||||
`packages/cli/src/launcher.mjs` (around :769).
|
||||
- The Discord STOP file (`packages/discord/src/cli.mjs:18-27`).
|
||||
|
||||
## What the design must settle
|
||||
|
||||
1. Where the switch state lives, its format, and how each process reads it (the broker checks on every
|
||||
work verb; the launcher and Discord check before start). The race: a launch already past its check when
|
||||
the switch goes on must still be stopped.
|
||||
2. The CLI verb names (for example `mosaic halt` and `mosaic resume`) and the Console button. The button
|
||||
is a human write, so it goes through the same writer and same-origin checks as other Console writes, and
|
||||
it works under the Q175 SSO session (#412) once phase 2 lands. Until then, test it with fixture servers
|
||||
only.
|
||||
3. Per instance. Row 190 adds several instances per host. The switch is per instance, so it stops one
|
||||
instance and never another. The design names how it finds the instance's data root. This row may build
|
||||
on the row 190 candidate (Q177 merge-chaining).
|
||||
4. What a stopped run's record says, so run evidence stays write-once and honest.
|
||||
|
||||
## Tests
|
||||
|
||||
Each part of the goal gets a test: work verbs refuse, a running stand-in session and run are stopped,
|
||||
Discord refuses to start, every launch path refuses, the switch survives a restart, an agent can't set or
|
||||
clear it, and the clear lets work resume. Mutants: drop the check from one launch path, let an agent clear
|
||||
the switch, keep the switch in memory only, skip the SIGKILL after the wait. Each one must fail a test.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- No test touches `~/.mosaic-dev`, the live units, a real Discord bot or a slot file. Use the stand-in
|
||||
units, scratch data roots and fixture servers the suites already build.
|
||||
- Rocko has no git credentials. Sage commits the candidate and runs the queue moves Rocko can't.
|
||||
|
||||
## Gate
|
||||
|
||||
Dewey approves DESIGN.md, then the manifest. The cli suite, the bus, discord and webui package suites and
|
||||
every `scripts/test-*.sh` are green on Sage's gate, and the mutants are killed.
|
||||
@@ -0,0 +1,68 @@
|
||||
# Brief: several Mosaic Stack instances on one host
|
||||
|
||||
Sage, 2026-10-20, for Mos's order mos-0974 (MVP, DECISIONS Q176-Q178, Vikunja #414). Gitea #1688. T1 item 1. Owner
|
||||
Darkwing, reviewer Filbert, gate owner Sage. Base: refactor at the brief commit. This row blocks both MVP
|
||||
cases: Case 1 (Jason's software factory) and Case 2 (Melanie's creative writing hub) run as two instances
|
||||
on one homelab host, beside the current mosaic-stack test instance.
|
||||
|
||||
Design first. The first deliverable is `agents/darkwing/work/queue-190/DESIGN.md`. Filbert reviews it
|
||||
before any code, as Q175 did. Then the build follows the design, with tests and mutants.
|
||||
|
||||
## The goal
|
||||
|
||||
Two or more instances run side by side on dragon-lin. Each one has its own config file, data root (store,
|
||||
broker.sock, host.lock, state, runs, sessions, workspaces), businesses, bus unit, Console unit and port,
|
||||
tester port, Discord unit, Claude session env file, stack-active pointer and activation log. Nothing one
|
||||
instance does can read or change another's state. The current instance keeps working unchanged: its
|
||||
config path, data root, unit names and ports stay as they are, and no re-pin is needed for it.
|
||||
|
||||
## Known single-instance points (GAP-MAP.md, measured at 9ae8c73ac)
|
||||
|
||||
The design starts with a full inventory. Grep every script and package, not only these:
|
||||
|
||||
- `packages/cli/systemd/mosaic-bus.service.in`: `mosaic-bus@<business>` sets no `MOSAIC_CONFIG` and reads the
|
||||
fixed `EnvironmentFile=-%h/.config/mosaic-dev/secrets/claude-session.env`.
|
||||
- `packages/webui/systemd/mosaic-webui.service.in` and `scripts/webui-service.sh`: one `mosaic-webui.service`.
|
||||
- `packages/discord/systemd/[email protected]` and `scripts/discord-service.sh`.
|
||||
- `scripts/stack-copy.sh:8` hardcodes `BUSINESS=mosaic-stack`, and `:95` reads `mosaic-webui.service`.
|
||||
- `scripts/stack-release.sh`: unit names, the Console port and the tester port default, `COPIES` (:28), the
|
||||
pointer under `$DATA/state` (:1474), and `rehearse`.
|
||||
- `scripts/mosaic-config.mjs`, `scripts/bootstrap.sh`, `scripts/reset.sh`, `scripts/land-work.sh`,
|
||||
`packages/webui/src/cli.mjs`, `packages/business/src/{business,vars}.mjs`, and
|
||||
`packages/conversation/src/guard.mjs` each name the default config path or data root.
|
||||
|
||||
## What the design must settle
|
||||
|
||||
1. Instance identity. How a name maps to its config file and how every tool picks the instance (an
|
||||
`--instance NAME` flag, `MOSAIC_CONFIG`, or both), and what happens with neither (the current default,
|
||||
unchanged). Name rules, and refusal of a name that collides.
|
||||
2. Config canon. AGENTS.md invariant 2 says `~/.config/mosaic-dev/config.json` is the sole system config,
|
||||
created only by `scripts/bootstrap.sh`. Several instances mean several config files. The design proposes
|
||||
the exact wording change and how bootstrap creates an instance config without overwriting one. Mos rules
|
||||
on the canon change before the build.
|
||||
3. Unit names and templates. The bus, Console and Discord units per instance, how `MOSAIC_CONFIG` and the
|
||||
Claude env file reach each unit, and how the current unit names stay valid for the default instance.
|
||||
4. Ports. Each instance's Console and tester ports, with a refusal when two instances claim the same port.
|
||||
5. Stack copies and the pointer. Whether copies are shared between instances (read-only trees, so sharing
|
||||
may be fine) and how the pointer, activation log, backups and `flags_drift` stay per instance.
|
||||
`stack-release.sh` and `stack-copy.sh` take the instance.
|
||||
6. Secrets. The per-instance Claude session env file and the secrets slot root, resolved from the instance's
|
||||
config dir (the Q175 design resolves its slot the same way: `<configDir>/secrets/mosaic-stack/<slot>`).
|
||||
7. Isolation proof. What the tests show: two instances started side by side on stand-in units and scratch
|
||||
roots, each reads only its own config and data root, a command aimed at one never touches the other, and
|
||||
the default instance's paths and unit names are byte-identical to today.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- No test touches `~/.mosaic-dev`, `~/.config/mosaic-dev`, the live units or a slot file. Use the stand-in
|
||||
systemctl, units and scratch roots that `packages/cli/tests/stack-release.test.mjs` already builds.
|
||||
- Standing up a real second instance on dragon-lin is a later window under Q173, with a backup first and a
|
||||
STATE report after. Exposing a new Console through the tunnel or Traefik changes who can reach it from
|
||||
outside, so it waits for Jason. SSO (Q175, #412) comes first for any instance a second user can reach.
|
||||
- Merge-chaining (Q177): rows that need instances (human users, kill switch, lifecycle) may build on this
|
||||
row's candidate. This row keeps its own review and gate.
|
||||
|
||||
## Gate
|
||||
|
||||
Filbert approves DESIGN.md, then the manifest. The cli suite and every `scripts/test-*.sh` are green on
|
||||
Sage's gate, and the design's mutants are killed.
|
||||
@@ -0,0 +1,47 @@
|
||||
# Brief: Q175 phase 1, the Console verifies SSO itself (--sso)
|
||||
|
||||
Sage, 2026-10-20, for Q175 (Jason's ruling), mos-0971/0973/0977 and Vikunja #412. Gitea #1690. MVP T1 item 2. Owner
|
||||
Launchpad, reviewer Webby (mos-0981), gate owner Sage. Base: refactor at the brief commit.
|
||||
|
||||
The design is approved: `agents/sage/work/q175-sso/DESIGN.md` at d55d86bb0 (blob eba861f5d2bb), Filbert r3
|
||||
approve plus the D1/D2 ack, Mos C1 confirmation in mos-0977, whose straggler-check condition is in the phase 2 window at b416d2ede. Build phase 1 exactly as the design says. If
|
||||
the code shows the design is wrong somewhere, stop and tell Sage, Webby and Filbert before building around it.
|
||||
|
||||
## Scope (phase 1 only, design section 5 item 1)
|
||||
|
||||
- oauth4webapi 3.8.8, pinned exactly (design section 2). No other new dependency.
|
||||
- The OIDC relying party in the Console: `/auth/login`, `/auth/callback`, `/auth/logout`, `/auth/signed-out`,
|
||||
the callback steps 1-7, our own checks on top of the library, and sessions, cookies and Host rules
|
||||
(design sections 1 and 3).
|
||||
- The shared loader, `scripts/mosaic-config.mjs`, accepts an optional `console` key and checks only its
|
||||
shape. The slot file checks and the read run in the Console's `--sso` start, and the slot resolves from
|
||||
the config dir (section 4, D1, D2).
|
||||
- The degraded start for an unreachable provider, and the unreachable/refused classes (Q-B, section 4).
|
||||
- `--sso` for `cli.mjs serve` and `webui-service.sh`, with its refusals against the old flags.
|
||||
- The tester listener rules (section 6).
|
||||
- The page: both fetch paths in `packages/webui/src/public/app.js` (:111 and :305) treat a 401
|
||||
`sign-in required` as signed out, and the notice links to `/auth/login?return=<path>` (C2).
|
||||
- Logging as the design says, including the Q-E `subject-unknown` line.
|
||||
|
||||
Out of scope: the phase 2 window, the live config.json key, phase 3 removals, the proxy-forwarded token
|
||||
(section 7), and any change to the live Console.
|
||||
|
||||
## Tests and mutants
|
||||
|
||||
Design section 9: tests 1-25, the positive paths, and every listed mutant, each killed by its named test.
|
||||
All tests run against the fake OP in `packages/webui/tests/helpers/fake-oidc.mjs`. No test reaches a real
|
||||
provider or Authentik, and no test touches `~/.mosaic-dev`, `~/.config/mosaic-dev`, the live units or a
|
||||
slot file. Test 25 goes in `scripts/test-config.sh`.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- No request of any kind to auth.diversecanvas.com, mosaic.woltje.com or the live 127.0.0.1:7332 (mos-0971,
|
||||
design section 8).
|
||||
- No credential value is read, printed or logged.
|
||||
- `npm install` runs only inside the worktree that has its own package.json, never in a directory that would
|
||||
walk up to home.
|
||||
|
||||
## Gate
|
||||
|
||||
Webby approves the manifest. Filbert approved the design and answers design questions. The webui and cli suites, `scripts/test-config.sh` and every
|
||||
`scripts/test-*.sh` are green on Sage's gate, and the design's mutants are killed.
|
||||
+4
-1
@@ -33,7 +33,7 @@ Gaps found while working go to `docs/plans/DEFERRED.md`, not here.
|
||||
|
||||
<!-- mosaic-queue:begin -->
|
||||
|
||||
Generated from `docs/plans/queue.json` revision 2236 by `queue render`. Do not edit between the markers; change the queue with `scripts/mosaic queue`.
|
||||
Generated from `docs/plans/queue.json` revision 2247 by `queue render`. Do not edit between the markers; change the queue with `scripts/mosaic queue`.
|
||||
|
||||
| # | Piece | Owner | Issues | State | After | Gate | Brief | Note |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
@@ -225,6 +225,9 @@ Generated from `docs/plans/queue.json` revision 2236 by `queue render`. Do not e
|
||||
| 186 | stack-release --plan: the backup line says what the run will do (P19 404, row 185 N1/N2) | darkwing; reviewers filbert | #1684 | done | — | Filbert approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the three brief mutants killed (sage) | `docs/plans/2026-10-20_plan-backup-line.md` § Brief: the activate plan's backup line says what the run will do | Landed 269134d8d (author Darkwing) on 3c5da3a91. Patch-id c97b9f6e25cd matches the r1 candidate (manifest 449e9121, Filbert approve 29419). Sage gate on b367f5730+patch 09:00:20Z-09:08:59Z: node 2681/0, scripts 422/0, queue 156, network removed; 3c5da3a91 adds only a SESSIONS line. Not live: the stack runs copy 490e2771d3d0. Follow-ups on P19 404: Filbert M6 gap, store-unclean reading. |
|
||||
| 187 | stack-release activate carries the row 178 proof pair (mos-0928 Part B A0) | darkwing; reviewers filbert | #1685 | done | 186 done | Filbert approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the brief mutants killed (sage) | `docs/plans/2026-10-20_activate-proof-pair.md` § Brief: activate carries the row 178 proof pair | Landed 9a71e4e80 (author Darkwing) on d17328a55; patch-id 6b57c47bc661 matches candidate 52a5ecc1d143 and Filbert's r1 reading. Sage gate passed 10:21:55Z: cli 446/446 and every package and scripts/test-*.sh green. Filbert approve 29424, rev 2204; follow-ups N1-N4 filed on P19. |
|
||||
| 188 | stack-release tests: a carried proof pair and a proof file inside the repository (P19 409, 410; gates Part B) | darkwing; reviewers filbert | #1686 | done | 187 settled | Filbert approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the brief mutants killed (sage) | `docs/plans/2026-10-20_proof-test-gaps.md` § Brief: tests for the two proof-file paths row 187 left open | Landed 8fc0ee216 (author Darkwing) on 23a609c5c; patch-id c9a56b167c07 matches candidate 86aa549bad75 and Filbert's r1 reading; stack-release.sh blob 2586a2a37cd2 unchanged. Sage gate passed 10:58Z: cli 453/453 and every package and scripts/test-*.sh green. Filbert approve 29433, rev 2212; N1 (REPO logical path) to P19 after Part B (mos-0959). |
|
||||
| 189 | stack-release activate --no-send drops the Console's --send and keeps the pointer in step (Q175 interim, mos-0972, P19 413) | sage; reviewers dewey | #1687 | in-review, round 1, request posted | 163 done | Dewey approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the brief mutants killed (sage) | `docs/plans/2026-10-20_activate-no-send.md` § Brief: activate --no-send drops the Console's --send | — |
|
||||
| 190 | several Mosaic Stack instances on one host: per-instance config, data root, units and ports (MVP T1.1, mos-0974, Vikunja #414) | darkwing; reviewers filbert | #1688 | in-progress | — | Filbert approves DESIGN.md then the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the design mutants killed (sage) | `docs/plans/2026-10-20_multi-instance.md` § Brief: several Mosaic Stack instances on one host | — |
|
||||
| 191 | one kill switch for the bus, every run, Discord and new launches, from the CLI and the Console (MVP T1.4, mos-0974, Vikunja #414) | rocko; reviewers dewey | #1689 | in-progress | — | Dewey approves DESIGN.md then the manifest; cli, bus, discord and webui suites and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_kill-switch.md` § Brief: one kill switch for the bus, every run, Discord and new launches | — |
|
||||
|
||||
<!-- mosaic-queue:end -->
|
||||
|
||||
|
||||
+471
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"version": 1,
|
||||
"canonicalRoot": "/mnt/storage/src/mosaic-stack",
|
||||
"revision": 2236,
|
||||
"revision": 2247,
|
||||
"rows": [
|
||||
{
|
||||
"id": 1,
|
||||
@@ -19820,6 +19820,153 @@
|
||||
"createdAt": "2026-10-20T10:31:30.403Z",
|
||||
"updatedAt": "2026-10-20T11:11:25.098Z",
|
||||
"updatedBy": "sage"
|
||||
},
|
||||
{
|
||||
"id": 189,
|
||||
"piece": "stack-release activate --no-send drops the Console's --send and keeps the pointer in step (Q175 interim, mos-0972, P19 413)",
|
||||
"owner": "sage",
|
||||
"issues": [
|
||||
1687
|
||||
],
|
||||
"closes": [
|
||||
1687
|
||||
],
|
||||
"state": "in-review",
|
||||
"previousState": null,
|
||||
"required": false,
|
||||
"requiredSince": null,
|
||||
"gate": "Dewey approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the brief mutants killed",
|
||||
"gateOwner": "sage",
|
||||
"brief": {
|
||||
"path": "docs/plans/2026-10-20_activate-no-send.md",
|
||||
"anchor": "Brief: activate --no-send drops the Console's --send",
|
||||
"blob": "0444b774faf09084311357fdd59b0ef3a3d347eb"
|
||||
},
|
||||
"after": [
|
||||
{
|
||||
"id": 163,
|
||||
"when": "done"
|
||||
}
|
||||
],
|
||||
"reviewers": [
|
||||
"dewey"
|
||||
],
|
||||
"review": {
|
||||
"rounds": [
|
||||
{
|
||||
"n": 1,
|
||||
"op": "sage-189-inreview-1",
|
||||
"by": "sage",
|
||||
"at": "2026-10-20T18:46:06.124Z",
|
||||
"issue": 1687,
|
||||
"candidate": {
|
||||
"kind": "manifest",
|
||||
"digest": "67af69bd579561fd8c20be2a47695240a9f09f119dbfff88a8e72e2a7416e668",
|
||||
"text": "70dbc89c0282fa063514317262d8926d16b8b8aaab4a1e47379303918495fc2c agents/sage/work/queue-189/BUILD.md\n4bd216cce2b40b39998725daa8989f6e3acb3ccd81a7017f6b09026af3965584 agents/sage/work/queue-189/mutants-run.log\nddbdddf03abf4aa91aa62f52e468be7af3c6b2f5d7bec2ee99b11e315471f18c agents/sage/work/queue-189/mutants.sh\n5d7b2da84d994f4f93d1212e349b4e4c19c5c00780e6cfcde91edb3ca08c014e docs/plans/2026-10-20_activate-no-send.md\n38e410c4cb731390f96747646eb7f6caa7a587fca9024ccbe901aab5ac19c7cf docs/TOOLS.md\nfe430969cc669515e348fced1a22cbe839a12023f7ac67cfba905b7afd29872f packages/cli/tests/stack-release.test.mjs\n16f77305e558f4fb953accc6d09f1f7b408aba363851d8ca79216ffce41bed9f scripts/stack-release.sh\n"
|
||||
},
|
||||
"request": "comment",
|
||||
"attempts": [
|
||||
{
|
||||
"op": "sage-189-inreview-1",
|
||||
"by": "sage",
|
||||
"at": "2026-10-20T18:46:06.124Z",
|
||||
"state": "posted",
|
||||
"comment": 29457,
|
||||
"transport": {
|
||||
"outcome": "posted",
|
||||
"status": 201,
|
||||
"comment": 29457,
|
||||
"detail": "created"
|
||||
},
|
||||
"resolutions": []
|
||||
}
|
||||
],
|
||||
"duplicateRisk": false,
|
||||
"receipts": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"claim": {
|
||||
"seat": "sage",
|
||||
"op": "sage-189-start"
|
||||
},
|
||||
"note": null,
|
||||
"blockedReason": null,
|
||||
"createdAt": "2026-10-20T18:21:38.646Z",
|
||||
"updatedAt": "2026-10-20T18:46:24.267Z",
|
||||
"updatedBy": "sage"
|
||||
},
|
||||
{
|
||||
"id": 190,
|
||||
"piece": "several Mosaic Stack instances on one host: per-instance config, data root, units and ports (MVP T1.1, mos-0974, Vikunja #414)",
|
||||
"owner": "darkwing",
|
||||
"issues": [
|
||||
1688
|
||||
],
|
||||
"closes": [
|
||||
1688
|
||||
],
|
||||
"state": "in-progress",
|
||||
"previousState": null,
|
||||
"required": false,
|
||||
"requiredSince": null,
|
||||
"gate": "Filbert approves DESIGN.md then the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the design mutants killed",
|
||||
"gateOwner": "sage",
|
||||
"brief": {
|
||||
"path": "docs/plans/2026-10-20_multi-instance.md",
|
||||
"anchor": "Brief: several Mosaic Stack instances on one host",
|
||||
"blob": "79fa60fbf17a352edc5431d6aabc77c72ec5c48f"
|
||||
},
|
||||
"after": [],
|
||||
"reviewers": [
|
||||
"filbert"
|
||||
],
|
||||
"review": null,
|
||||
"claim": {
|
||||
"seat": "darkwing",
|
||||
"op": "darkwing-190-start"
|
||||
},
|
||||
"note": null,
|
||||
"blockedReason": null,
|
||||
"createdAt": "2026-10-20T18:42:48.052Z",
|
||||
"updatedAt": "2026-10-20T18:45:23.430Z",
|
||||
"updatedBy": "darkwing"
|
||||
},
|
||||
{
|
||||
"id": 191,
|
||||
"piece": "one kill switch for the bus, every run, Discord and new launches, from the CLI and the Console (MVP T1.4, mos-0974, Vikunja #414)",
|
||||
"owner": "rocko",
|
||||
"issues": [
|
||||
1689
|
||||
],
|
||||
"closes": [
|
||||
1689
|
||||
],
|
||||
"state": "in-progress",
|
||||
"previousState": null,
|
||||
"required": false,
|
||||
"requiredSince": null,
|
||||
"gate": "Dewey approves DESIGN.md then the manifest; cli, bus, discord and webui suites and every scripts/test-*.sh green on Sage's gate; the mutants killed",
|
||||
"gateOwner": "sage",
|
||||
"brief": {
|
||||
"path": "docs/plans/2026-10-20_kill-switch.md",
|
||||
"anchor": "Brief: one kill switch for the bus, every run, Discord and new launches",
|
||||
"blob": "aed33125fa67ae69af73c18ffc9e2a83481421f3"
|
||||
},
|
||||
"after": [],
|
||||
"reviewers": [
|
||||
"dewey"
|
||||
],
|
||||
"review": null,
|
||||
"claim": {
|
||||
"seat": "rocko",
|
||||
"op": "rocko-191-start"
|
||||
},
|
||||
"note": null,
|
||||
"blockedReason": null,
|
||||
"createdAt": "2026-10-20T18:43:05.222Z",
|
||||
"updatedAt": "2026-10-20T18:45:05.621Z",
|
||||
"updatedBy": "rocko"
|
||||
}
|
||||
],
|
||||
"log": [
|
||||
@@ -78756,6 +78903,329 @@
|
||||
"receipt": "ok sage-163-done rev 2236 row 163 in-review→done round 2"
|
||||
},
|
||||
"viewSha": "e44815d32d46b91ee37d4c3c4701716956ba35d29c16c24c89020cce345bd61b"
|
||||
},
|
||||
{
|
||||
"rev": 2237,
|
||||
"op": "sage-189-add",
|
||||
"verb": "add",
|
||||
"args": {
|
||||
"piece": "stack-release activate --no-send drops the Console's --send and keeps the pointer in step (Q175 interim, mos-0972, P19 413)",
|
||||
"gate": "Dewey approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the brief mutants killed",
|
||||
"brief": "docs/plans/2026-10-20_activate-no-send.md#Brief: activate --no-send drops the Console's --send",
|
||||
"issues": [
|
||||
1687
|
||||
],
|
||||
"note": null,
|
||||
"owner": "sage",
|
||||
"gateOwner": "sage",
|
||||
"after": [
|
||||
{
|
||||
"id": 163,
|
||||
"when": "done"
|
||||
}
|
||||
],
|
||||
"reviewers": [
|
||||
"dewey"
|
||||
],
|
||||
"required": null
|
||||
},
|
||||
"by": "sage",
|
||||
"at": "2026-10-20T18:21:38.646Z",
|
||||
"semantics": 2,
|
||||
"result": {
|
||||
"row": 189,
|
||||
"from": null,
|
||||
"to": "queued",
|
||||
"brief": {
|
||||
"path": "docs/plans/2026-10-20_activate-no-send.md",
|
||||
"anchor": "Brief: activate --no-send drops the Console's --send",
|
||||
"blob": "0444b774faf09084311357fdd59b0ef3a3d347eb"
|
||||
},
|
||||
"receipt": "ok sage-189-add rev 2237 row 189 none→queued"
|
||||
},
|
||||
"viewSha": "70bf9967156881afecaba9ccf4edf8cbc63a344eb7c6407b3453c950781f34c5"
|
||||
},
|
||||
{
|
||||
"rev": 2238,
|
||||
"op": "sage-189-briefed",
|
||||
"verb": "move",
|
||||
"args": {
|
||||
"id": 189,
|
||||
"to": "briefed",
|
||||
"reason": null,
|
||||
"candidate": null,
|
||||
"evidence": null,
|
||||
"issue": null
|
||||
},
|
||||
"by": "sage",
|
||||
"at": "2026-10-20T18:22:01.437Z",
|
||||
"semantics": 2,
|
||||
"result": {
|
||||
"row": 189,
|
||||
"from": "queued",
|
||||
"to": "briefed",
|
||||
"round": null,
|
||||
"issue": null,
|
||||
"candidate": null,
|
||||
"receipt": "ok sage-189-briefed rev 2238 row 189 queued→briefed"
|
||||
},
|
||||
"viewSha": "27ee8a26722d3eaf4c4b346e06ffdb853037aad2d1a7fe14f440cbe1bb9a2a27"
|
||||
},
|
||||
{
|
||||
"rev": 2239,
|
||||
"op": "sage-189-start",
|
||||
"verb": "move",
|
||||
"args": {
|
||||
"id": 189,
|
||||
"to": "in-progress",
|
||||
"reason": null,
|
||||
"candidate": null,
|
||||
"evidence": null,
|
||||
"issue": null
|
||||
},
|
||||
"by": "sage",
|
||||
"at": "2026-10-20T18:22:18.476Z",
|
||||
"semantics": 2,
|
||||
"result": {
|
||||
"row": 189,
|
||||
"from": "briefed",
|
||||
"to": "in-progress",
|
||||
"round": null,
|
||||
"issue": null,
|
||||
"candidate": null,
|
||||
"receipt": "ok sage-189-start rev 2239 row 189 briefed→in-progress"
|
||||
},
|
||||
"viewSha": "230578585341fcfc9429364332ca8b78988ffd669d3c1bb5e2e94a74d9916649"
|
||||
},
|
||||
{
|
||||
"rev": 2240,
|
||||
"op": "sage-190-add",
|
||||
"verb": "add",
|
||||
"args": {
|
||||
"piece": "several Mosaic Stack instances on one host: per-instance config, data root, units and ports (MVP T1.1, mos-0974, Vikunja #414)",
|
||||
"gate": "Filbert approves DESIGN.md then the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the design mutants killed",
|
||||
"brief": "docs/plans/2026-10-20_multi-instance.md#Brief: several Mosaic Stack instances on one host",
|
||||
"issues": [
|
||||
1688
|
||||
],
|
||||
"note": null,
|
||||
"owner": "darkwing",
|
||||
"gateOwner": "sage",
|
||||
"after": null,
|
||||
"reviewers": [
|
||||
"filbert"
|
||||
],
|
||||
"required": null
|
||||
},
|
||||
"by": "sage",
|
||||
"at": "2026-10-20T18:42:48.052Z",
|
||||
"semantics": 2,
|
||||
"result": {
|
||||
"row": 190,
|
||||
"from": null,
|
||||
"to": "queued",
|
||||
"brief": {
|
||||
"path": "docs/plans/2026-10-20_multi-instance.md",
|
||||
"anchor": "Brief: several Mosaic Stack instances on one host",
|
||||
"blob": "79fa60fbf17a352edc5431d6aabc77c72ec5c48f"
|
||||
},
|
||||
"receipt": "ok sage-190-add rev 2240 row 190 none→queued"
|
||||
},
|
||||
"viewSha": "b4319dbd993254b58e4db8be18769b04322028903e325c0c8cadcfdec527929f"
|
||||
},
|
||||
{
|
||||
"rev": 2241,
|
||||
"op": "sage-191-add",
|
||||
"verb": "add",
|
||||
"args": {
|
||||
"piece": "one kill switch for the bus, every run, Discord and new launches, from the CLI and the Console (MVP T1.4, mos-0974, Vikunja #414)",
|
||||
"gate": "Dewey approves DESIGN.md then the manifest; cli, bus, discord and webui suites and every scripts/test-*.sh green on Sage's gate; the mutants killed",
|
||||
"brief": "docs/plans/2026-10-20_kill-switch.md#Brief: one kill switch for the bus, every run, Discord and new launches",
|
||||
"issues": [
|
||||
1689
|
||||
],
|
||||
"note": null,
|
||||
"owner": "rocko",
|
||||
"gateOwner": "sage",
|
||||
"after": null,
|
||||
"reviewers": [
|
||||
"dewey"
|
||||
],
|
||||
"required": null
|
||||
},
|
||||
"by": "sage",
|
||||
"at": "2026-10-20T18:43:05.222Z",
|
||||
"semantics": 2,
|
||||
"result": {
|
||||
"row": 191,
|
||||
"from": null,
|
||||
"to": "queued",
|
||||
"brief": {
|
||||
"path": "docs/plans/2026-10-20_kill-switch.md",
|
||||
"anchor": "Brief: one kill switch for the bus, every run, Discord and new launches",
|
||||
"blob": "aed33125fa67ae69af73c18ffc9e2a83481421f3"
|
||||
},
|
||||
"receipt": "ok sage-191-add rev 2241 row 191 none→queued"
|
||||
},
|
||||
"viewSha": "2cdc7c37430226f1ea01ed9f3d60a2c12be16eddac38d18dc16f889c7b54b14e"
|
||||
},
|
||||
{
|
||||
"rev": 2242,
|
||||
"op": "sage-190-brief",
|
||||
"verb": "move",
|
||||
"args": {
|
||||
"id": 190,
|
||||
"to": "briefed",
|
||||
"reason": null,
|
||||
"candidate": null,
|
||||
"evidence": null,
|
||||
"issue": null
|
||||
},
|
||||
"by": "sage",
|
||||
"at": "2026-10-20T18:43:25.630Z",
|
||||
"semantics": 2,
|
||||
"result": {
|
||||
"row": 190,
|
||||
"from": "queued",
|
||||
"to": "briefed",
|
||||
"round": null,
|
||||
"issue": null,
|
||||
"candidate": null,
|
||||
"receipt": "ok sage-190-brief rev 2242 row 190 queued→briefed"
|
||||
},
|
||||
"viewSha": "6203315b484302369ce5ea7d854082a6bca4a95f9793e14ec123f57381234c95"
|
||||
},
|
||||
{
|
||||
"rev": 2243,
|
||||
"op": "sage-191-brief",
|
||||
"verb": "move",
|
||||
"args": {
|
||||
"id": 191,
|
||||
"to": "briefed",
|
||||
"reason": null,
|
||||
"candidate": null,
|
||||
"evidence": null,
|
||||
"issue": null
|
||||
},
|
||||
"by": "sage",
|
||||
"at": "2026-10-20T18:43:43.042Z",
|
||||
"semantics": 2,
|
||||
"result": {
|
||||
"row": 191,
|
||||
"from": "queued",
|
||||
"to": "briefed",
|
||||
"round": null,
|
||||
"issue": null,
|
||||
"candidate": null,
|
||||
"receipt": "ok sage-191-brief rev 2243 row 191 queued→briefed"
|
||||
},
|
||||
"viewSha": "9cc7dda5fa676258e864dedfdf2f4e3904c8723ba9c36474cff76757f52e5c07"
|
||||
},
|
||||
{
|
||||
"rev": 2244,
|
||||
"op": "rocko-191-start",
|
||||
"verb": "move",
|
||||
"args": {
|
||||
"id": 191,
|
||||
"to": "in-progress",
|
||||
"reason": null,
|
||||
"candidate": null,
|
||||
"evidence": null,
|
||||
"issue": null
|
||||
},
|
||||
"by": "rocko",
|
||||
"at": "2026-10-20T18:45:05.621Z",
|
||||
"semantics": 2,
|
||||
"result": {
|
||||
"row": 191,
|
||||
"from": "briefed",
|
||||
"to": "in-progress",
|
||||
"round": null,
|
||||
"issue": null,
|
||||
"candidate": null,
|
||||
"receipt": "ok rocko-191-start rev 2244 row 191 briefed→in-progress"
|
||||
},
|
||||
"viewSha": "cd2423e53ac82a667c737852bef6adfdafba82987f37cd090f5484b15b3a0e0c"
|
||||
},
|
||||
{
|
||||
"rev": 2245,
|
||||
"op": "darkwing-190-start",
|
||||
"verb": "move",
|
||||
"args": {
|
||||
"id": 190,
|
||||
"to": "in-progress",
|
||||
"reason": null,
|
||||
"candidate": null,
|
||||
"evidence": null,
|
||||
"issue": null
|
||||
},
|
||||
"by": "darkwing",
|
||||
"at": "2026-10-20T18:45:23.430Z",
|
||||
"semantics": 2,
|
||||
"result": {
|
||||
"row": 190,
|
||||
"from": "briefed",
|
||||
"to": "in-progress",
|
||||
"round": null,
|
||||
"issue": null,
|
||||
"candidate": null,
|
||||
"receipt": "ok darkwing-190-start rev 2245 row 190 briefed→in-progress"
|
||||
},
|
||||
"viewSha": "014db845fb2e380678ab1250f2cd3c0d7b192dc39909416515482c810440149b"
|
||||
},
|
||||
{
|
||||
"rev": 2246,
|
||||
"op": "sage-189-inreview-1",
|
||||
"verb": "move",
|
||||
"args": {
|
||||
"id": 189,
|
||||
"to": "in-review",
|
||||
"reason": null,
|
||||
"candidate": "agents/sage/work/queue-189/candidate-manifest.sha256",
|
||||
"evidence": null,
|
||||
"issue": null
|
||||
},
|
||||
"by": "sage",
|
||||
"at": "2026-10-20T18:46:06.124Z",
|
||||
"semantics": 2,
|
||||
"result": {
|
||||
"row": 189,
|
||||
"from": "in-progress",
|
||||
"to": "in-review",
|
||||
"round": 1,
|
||||
"issue": 1687,
|
||||
"candidate": {
|
||||
"kind": "manifest",
|
||||
"digest": "67af69bd579561fd8c20be2a47695240a9f09f119dbfff88a8e72e2a7416e668",
|
||||
"text": "70dbc89c0282fa063514317262d8926d16b8b8aaab4a1e47379303918495fc2c agents/sage/work/queue-189/BUILD.md\n4bd216cce2b40b39998725daa8989f6e3acb3ccd81a7017f6b09026af3965584 agents/sage/work/queue-189/mutants-run.log\nddbdddf03abf4aa91aa62f52e468be7af3c6b2f5d7bec2ee99b11e315471f18c agents/sage/work/queue-189/mutants.sh\n5d7b2da84d994f4f93d1212e349b4e4c19c5c00780e6cfcde91edb3ca08c014e docs/plans/2026-10-20_activate-no-send.md\n38e410c4cb731390f96747646eb7f6caa7a587fca9024ccbe901aab5ac19c7cf docs/TOOLS.md\nfe430969cc669515e348fced1a22cbe839a12023f7ac67cfba905b7afd29872f packages/cli/tests/stack-release.test.mjs\n16f77305e558f4fb953accc6d09f1f7b408aba363851d8ca79216ffce41bed9f scripts/stack-release.sh\n"
|
||||
},
|
||||
"receipt": "ok sage-189-inreview-1 rev 2246 row 189 in-progress→in-review round 1 on #1687; request sage-189-inreview-1 requesting"
|
||||
},
|
||||
"viewSha": "d8a3aa936a9a819329e86679d0bfcf658d773d0f7cfd29c10587c6447f78e968"
|
||||
},
|
||||
{
|
||||
"rev": 2247,
|
||||
"op": "sage-189-inreview-1.outcome",
|
||||
"verb": "review-outcome",
|
||||
"args": {
|
||||
"id": 189,
|
||||
"attempt": "sage-189-inreview-1",
|
||||
"outcome": "posted",
|
||||
"status": 201,
|
||||
"comment": 29457,
|
||||
"detail": "created"
|
||||
},
|
||||
"by": "sage",
|
||||
"at": "2026-10-20T18:46:24.267Z",
|
||||
"semantics": 2,
|
||||
"result": {
|
||||
"row": 189,
|
||||
"round": 1,
|
||||
"attempt": "sage-189-inreview-1",
|
||||
"state": "posted",
|
||||
"receipt": "ok sage-189-inreview-1.outcome rev 2247 row 189 round 1 request sage-189-inreview-1 posted comment 29457"
|
||||
},
|
||||
"viewSha": "6d7e91ae3cab0b76e5c5d791fb821b97c9da7db16389ff2d531d7b77d2a069f1"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Generated
+11
-1
@@ -9,7 +9,8 @@
|
||||
"version": "0.0.3",
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-coding-agent": "0.85.1"
|
||||
"@earendil-works/pi-coding-agent": "0.85.1",
|
||||
"oauth4webapi": "3.8.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/sdk": {
|
||||
@@ -1933,6 +1934,15 @@
|
||||
"url": "https://opencollective.com/node-fetch"
|
||||
}
|
||||
},
|
||||
"node_modules/oauth4webapi": {
|
||||
"version": "3.8.8",
|
||||
"resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.8.tgz",
|
||||
"integrity": "sha512-8N28E+a/oxfXWBgOMt+ZP/JUf/XR+IFbvkAEPP3gznXOMv9BpAAwiIj0TFNz3tGTPc0ZQ8zmWBNgN1nAys0gng==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/openai": {
|
||||
"version": "6.40.0",
|
||||
"resolved": "https://registry.npmjs.org/openai/-/openai-6.40.0.tgz",
|
||||
|
||||
+2
-1
@@ -5,6 +5,7 @@
|
||||
"description": "Minimal Mosaic Stack container proof of concept: one Pi agent, four local contract files, one real model request returning MOSAIC_HELLO_OK.",
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-coding-agent": "0.85.1"
|
||||
"@earendil-works/pi-coding-agent": "0.85.1",
|
||||
"oauth4webapi": "3.8.8"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,6 +129,45 @@ Switching the Console, bus or wrapper, changing any unit, and starting or
|
||||
stopping either service requires Mos's go for that live step. Agents build
|
||||
and test fixtures; they stop before the live sequence.
|
||||
|
||||
## Sign-in in the Console (Q175 phase 1, #1690)
|
||||
|
||||
`serve --sso [--send] [--tester-port N]` makes the Console an OpenID Connect
|
||||
relying party. It reads `console.sso` from the system config (`publicOrigin`
|
||||
and one or more providers, each with its issuer, client id, a client secret
|
||||
slot, the signing algorithms it accepts and the subjects allowed in). The
|
||||
design is `agents/sage/work/q175-sso/DESIGN.md`. `--sso` can't be combined
|
||||
with `--public-host`, `--auth-header`, `--allow-user` or `--proof-*`; `--send`
|
||||
takes `--sso` in place of the preview set.
|
||||
|
||||
- The client secret is read once at start from
|
||||
`<config dir>/secrets/mosaic-stack/<slot>`. The start refuses a slot that is
|
||||
a symbolic link, not a regular file, owned by another user, has any group or
|
||||
other mode bit, or sits in the repository, the `--checkout` directory or a
|
||||
stack copy. The value is never printed.
|
||||
- At start the Console fetches each provider's discovery document and JWKS. A
|
||||
provider that can't be reached (a network error, a timeout, a status other
|
||||
than 200, or a body that isn't JSON) leaves the Console up with that
|
||||
provider's `/auth/login` at 503 and a retry every 5 s, doubling, capped at 5
|
||||
minutes. A document that fails a check (issuer, algorithms, S256, a usable
|
||||
key) refuses the start. `/healthz` adds `sso: { <id>: "ready" | "unreachable"
|
||||
| "refused" }`.
|
||||
- The Console listener serves only the Host of `publicOrigin`, whatever port
|
||||
it listens on. Any other Host, a loopback one included, gets 403 `public
|
||||
Host required`, except `GET /healthz`, which stays open on loopback for the
|
||||
status and release gates. The loopback operator path in the preview section
|
||||
above does not exist under `--sso`.
|
||||
- Without a session, `/api/*` gets 401 `sign-in required` and a page load gets
|
||||
a 303 to `/auth/login?return=<path>`. The page then shows the signed-out line
|
||||
with a link back to sign-in. A session lasts at most 8 hours and ends after 1
|
||||
hour without a page load or a write; a restart ends every session.
|
||||
- The page shows Sign out only under `--sso`. It posts to `/auth/logout`, which
|
||||
ends the session and goes to the provider's end-session endpoint when it has
|
||||
one, or to `/auth/signed-out`.
|
||||
- With `--tester-port`, the tester listener refuses any request carrying an
|
||||
`Authorization` header, a `__Host-mosaic_session` or `__Host-mosaic_login`
|
||||
cookie, or an `x-authentik-*` header (403 `tester listener takes no
|
||||
sign-in`). It never serves `/auth/*`.
|
||||
|
||||
## Message a role (row 76, #1570)
|
||||
|
||||
```sh
|
||||
|
||||
@@ -58,8 +58,9 @@ export async function chatHTTP(req, res, { path, search, chat, named, send, prev
|
||||
if (one(req, 'origin') !== preview.origin) return refuse(403, 'cross-origin');
|
||||
} else {
|
||||
if (!send || !named || !preview) return refuse(403, 'read-only');
|
||||
if (one(req, preview.header) !== preview.user) return refuse(401, 'unauthenticated');
|
||||
if (one(req, 'origin') !== `https://${preview.host}`) return refuse(403, 'cross-origin');
|
||||
// Q175 phase 1 (#1690): under --sso the session was checked before this route.
|
||||
if (preview.sso !== true && one(req, preview.header) !== preview.user) return refuse(401, 'unauthenticated');
|
||||
if (one(req, 'origin') !== (preview.origin ?? `https://${preview.host}`)) return refuse(403, 'cross-origin');
|
||||
}
|
||||
if (one(req, 'sec-fetch-site') !== 'same-origin') return refuse(403, 'cross-site');
|
||||
}
|
||||
|
||||
@@ -7,20 +7,23 @@ import { views } from '../../bus/src/views.mjs';
|
||||
import { loadSystem, socketPath, REPO, rolesDir } from '../../cli/src/config.mjs';
|
||||
import { configDir } from '../../business/src/business.mjs';
|
||||
import { consoleReads, redactor } from './reads.mjs';
|
||||
import { createSso, readSlot } from './sso.mjs';
|
||||
import { statSync } from 'node:fs';
|
||||
import { isAbsolute } from 'node:path';
|
||||
import { ID_PATTERN } from '../../business/src/vocabulary.mjs';
|
||||
const args = process.argv.slice(2);
|
||||
const usage = 'node packages/webui/src/cli.mjs serve [--port N] [--tester-port N] [--checkout PATH] [--board http://127.0.0.1:7331] [--business ID] [--public-host NAME --auth-header HEADER --allow-user ID [--send] [--proof-header NAME --proof-file PATH]]';
|
||||
const usage = 'node packages/webui/src/cli.mjs serve [--port N] [--tester-port N] [--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 {
|
||||
if (args.length === 1 && ['--help', '-h'].includes(args[0])) { console.log(usage); process.exit(0); }
|
||||
if (args.shift() !== 'serve') throw new Error(usage);
|
||||
const options = { board: DEFAULT_BOARD, port: 7330 }, preview = {}, proofFlags = {};
|
||||
let sendOn = false, testerPort = null;
|
||||
let sendOn = false, ssoOn = false, testerPort = null;
|
||||
while (args.length) {
|
||||
const flag = args.shift();
|
||||
// Row 76 (#1570): the one flag without a value.
|
||||
if (flag === '--send') { sendOn = true; continue; }
|
||||
// Q175 phase 1 (#1690): the Console signs people in itself, from console.sso in config.json.
|
||||
if (flag === '--sso') { ssoOn = true; continue; }
|
||||
const value = args.shift();
|
||||
if (!value) throw new Error(`missing value for ${flag}`);
|
||||
if (flag === '--port') {
|
||||
@@ -51,9 +54,10 @@ try {
|
||||
else throw new Error(`unknown option: ${flag}`);
|
||||
}
|
||||
if (testerPort !== null && (testerPort === options.port || testerPort === 7332)) throw new Error('--tester-port must differ from --port and cannot be the forwarded port 7332');
|
||||
if (ssoOn && (Object.keys(preview).length || Object.keys(proofFlags).length)) throw new Error('--sso cannot be combined with --public-host, --auth-header, --allow-user or --proof-*');
|
||||
options.preview = previewOptions(preview);
|
||||
const proof = proofOptions(proofFlags, options.preview);
|
||||
if (sendOn && !options.preview) throw new Error('--send needs the preview mode (--public-host, --auth-header and --allow-user)');
|
||||
if (sendOn && !options.preview && !ssoOn) throw new Error('--send needs the preview mode (--public-host, --auth-header and --allow-user) or --sso');
|
||||
// The bus views (slice 1 S5) need the system config. Without it the
|
||||
// board still serves and the bus routes answer not-configured.
|
||||
// The Queue, Business and Settings reads (row 54) need no bus; without
|
||||
@@ -77,31 +81,46 @@ try {
|
||||
busLine = `Bus: ${options.business ?? 'the running bus host\'s business'}`;
|
||||
} catch (err) {
|
||||
const why = redactor({ configDir: configDir() })(err.message);
|
||||
if (ssoOn) throw new Error(`--sso needs the system config (${why})`);
|
||||
if (sendOn) throw new Error(`--send needs the bus configured (${why})`);
|
||||
if (testerPort !== null) throw new Error(`--tester-port needs the bus configured (${why})`);
|
||||
busLine = `Bus: not configured (${why})`;
|
||||
}
|
||||
const reads = consoleReads({ root: REPO, queueRoot: options.checkout ?? REPO, system, configDir: configDir(), rolesDir: rolesDir(), business: options.business ?? null });
|
||||
const server = await startServer({ board: options.board, port: options.port, bus, reads, preview: options.preview, proof, send, settingsWrite, projectFiles, projectFile, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite });
|
||||
// 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;
|
||||
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 });
|
||||
}
|
||||
let server;
|
||||
try {
|
||||
server = await startServer({ board: options.board, port: options.port, bus, reads, preview: options.preview, sso, proof, send, settingsWrite, projectFiles, projectFile, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite });
|
||||
} catch (err) { sso?.close(); throw err; }
|
||||
let testerServer = null;
|
||||
if (testerPort !== null) {
|
||||
const call = busCaller({ dataRoot: system.dataRoot, socket: socketPath(system.dataRoot), business: options.business ?? null, cli: TESTER_CLI });
|
||||
const testerBus = { ...views({ call }), names: () => call('names'), instances: () => call('instances'), routes: () => call('routes') };
|
||||
try {
|
||||
testerServer = await startServer({ host: '127.0.0.1', port: testerPort, board: options.board,
|
||||
tester: true, testerProofHeader: proof?.header ?? null, testerAuthHeader: options.preview?.header ?? null, bus: testerBus, reads,
|
||||
tester: true, testerNoSignIn: ssoOn, testerProofHeader: proof?.header ?? null, testerAuthHeader: options.preview?.header ?? null, bus: testerBus, reads,
|
||||
projectFiles: args => call('project.files', args), projectFile: args => call('project.file', args),
|
||||
instancesWrite: args => call('instances.set', args), routesWrite: args => call('routes.set', args), nameWrite: args => call('name.set', args),
|
||||
// Row 177 (#1675): the tester's own chat, through the tester CLI; 7332's stays the human's.
|
||||
chat: createChat({ call }),
|
||||
});
|
||||
} catch (err) { await new Promise(resolve => server.close(resolve)); throw err; }
|
||||
} catch (err) { sso?.close(); await new Promise(resolve => server.close(resolve)); throw err; }
|
||||
}
|
||||
const previewLine = options.preview ? `\nPreview: https://${options.preview.host}/ through the proxy, signed-in user from ${options.preview.header}, ${send ? 'reads and human messages to roles (--send); one line per send below' : 'read-only'}` : '';
|
||||
const proofLine = `\nProxy: proxy proof ${proof ? 'on' : 'off'}`;
|
||||
// One line per provider: id, issuer, subject count and state. No client id, secret or sub.
|
||||
const health = sso?.health() ?? {};
|
||||
const ssoLines = sso ? `\nSign-in: ${sso.publicOrigin}/ (--sso), ${send ? 'reads and human writes (--send)' : 'read-only'}` + sso.summary().map(p => `\nSign-in provider: ${p.id} issuer ${p.issuer} subjects ${p.subjects} (${health[p.id]})`).join('') : '';
|
||||
const queueLine = options.checkout ? '\nQueue: read from the --checkout directory' : '';
|
||||
console.log(`Mosaic Console: http://127.0.0.1:${server.address().port}/\nBoard: ${options.board}\n${busLine}${queueLine}${previewLine}${proofLine}${testerServer ? `\nTester Console: http://127.0.0.1:${testerServer.address().port}/ (binds the tester)` : ''}\nCtrl-C stops this server.`);
|
||||
for (const signal of ['SIGINT', 'SIGTERM']) process.once(signal, () => { server.close(); testerServer?.close(); });
|
||||
console.log(`Mosaic Console: http://127.0.0.1:${server.address().port}/\nBoard: ${options.board}\n${busLine}${queueLine}${previewLine}${ssoLines}${proofLine}${testerServer ? `\nTester Console: http://127.0.0.1:${testerServer.address().port}/ (binds the tester)` : ''}\nCtrl-C stops this server.`);
|
||||
for (const signal of ['SIGINT', 'SIGTERM']) process.once(signal, () => { sso?.close(); server.close(); testerServer?.close(); });
|
||||
} catch (err) {
|
||||
console.error(`refused: ${err.message}`);
|
||||
process.exitCode = 2;
|
||||
|
||||
@@ -16,6 +16,11 @@
|
||||
// The first one sets body.signed-out (the listener below); after that no Console fetch is made.
|
||||
const out = () => document.body.classList.contains('signed-out');
|
||||
const signOut = () => { if (!out()) document.dispatchEvent(new Event('mosaic-signed-out')); };
|
||||
// Q175 phase 1 (#1690): under --sso the Console itself answers a read without a session with 401
|
||||
// `sign-in required`; that signs the page out too, and the line links to the Console's sign-in.
|
||||
// Any other 401 stays an error. `sso` comes from /api/config.
|
||||
let sso = false;
|
||||
const signInRequired = (res, body) => res.status === 401 && body?.error === 'sign-in required';
|
||||
const signedOut = () => Object.assign(new Error('the sign-in proxy redirected it, so it never reached the Console'), { signedOut: true });
|
||||
const labels = { registration: 'registered', 'first-user-message': 'first message', 'tmux-pane': 'tmux pane', 'session-cwd': 'session cwd', 'workspace-git-root': 'git root' };
|
||||
const source = value => value ? `<span class="source">${esc(labels[value] || value)}</span>` : '';
|
||||
@@ -110,6 +115,7 @@
|
||||
const res = await fetch(path, { cache: 'no-store', redirect: 'manual' });
|
||||
if (res.type === 'opaqueredirect') { signOut(); throw signedOut(); }
|
||||
let body; try { body = await res.json(); } catch { body = { error: `HTTP ${res.status}` }; }
|
||||
if (signInRequired(res, body)) { sso = true; signOut(); throw signedOut(); }
|
||||
return { ok: res.ok, body };
|
||||
}
|
||||
function blockNode(b) {
|
||||
@@ -304,6 +310,7 @@
|
||||
const res = await fetch(path, value === undefined ? { cache: 'no-store', redirect: 'manual' } : { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(value), redirect: 'manual' });
|
||||
if (res.type === 'opaqueredirect') { signOut(); throw signedOut(); }
|
||||
const result = await res.json();
|
||||
if (signInRequired(res, result)) { sso = true; signOut(); throw signedOut(); }
|
||||
if (!res.ok) throw Object.assign(new Error(result.error || `HTTP ${res.status}`), { status: res.status });
|
||||
return result;
|
||||
}
|
||||
@@ -452,7 +459,7 @@
|
||||
});
|
||||
// Signed out: everything drawn from a Console read goes, because none of it can be refreshed. The
|
||||
// line says so once; the send form (bus.js) keeps its draft and receipt. Reload is the way back.
|
||||
document.addEventListener('mosaic-signed-out', () => {
|
||||
document.addEventListener('mosaic-signed-out', e => {
|
||||
if (out()) return;
|
||||
document.body.classList.add('signed-out');
|
||||
clearTimeout(timer); convGen++;
|
||||
@@ -461,9 +468,13 @@
|
||||
for (const id of ['projects', 'waiting', 'seen', 'sessions']) $(id).replaceChildren();
|
||||
$('refresh').disabled = $('pause').disabled = true;
|
||||
const line = $('signed-out');
|
||||
line.innerHTML = '<p><b>Signed out.</b> Reload the page to sign in.</p>'; line.hidden = false;
|
||||
line.innerHTML = sso || e.detail?.signIn
|
||||
? `<p><b>Signed out.</b> <a href="/auth/login?${esc(new URLSearchParams({ return: location.pathname + location.search }))}">Sign in again</a>.</p>`
|
||||
: '<p><b>Signed out.</b> Reload the page to sign in.</p>';
|
||||
line.hidden = false; $('sign-out').hidden = true;
|
||||
// Focus moves to the line only if what had it is gone or disabled, never out of a form being typed in.
|
||||
setTimeout(() => { const a = document.activeElement; if (!a || a === document.body || a.disabled || !a.getClientRects().length) line.focus(); });
|
||||
});
|
||||
api('/api/config').then(config => { board = config.board; $('board-url').textContent = `Board: ${board}`; }).catch(err => { if (machine) error(err); }).finally(refresh);
|
||||
// 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);
|
||||
})();
|
||||
|
||||
@@ -30,6 +30,10 @@
|
||||
const out = () => document.body.classList.contains('signed-out');
|
||||
const signOut = () => { if (!out()) document.dispatchEvent(new Event('mosaic-signed-out')); };
|
||||
const signedOut = () => new ReadError('signed-out', 'The sign-in proxy redirected the read.', 0);
|
||||
// Q175 phase 1 (#1690, sage-1340 F1): under --sso a read without a session gets 401 `sign-in
|
||||
// required` from the Console itself. That signs out the same way; any other 401 stays an error.
|
||||
const signInRequired = (res, body) => res.status === 401 && body?.error === 'sign-in required';
|
||||
const signOutSso = () => { if (!out()) document.dispatchEvent(new CustomEvent('mosaic-signed-out', { detail: { signIn: true } })); };
|
||||
// `replies` takes a message id as `id`; trail takes `subject`.
|
||||
async function fetchRead(verb, subject) {
|
||||
if (out()) throw signedOut();
|
||||
@@ -38,6 +42,7 @@
|
||||
catch { throw new ReadError('unreachable', 'The Console server did not answer.', 0); }
|
||||
if (res.type === 'opaqueredirect') { signOut(); throw signedOut(); }
|
||||
try { body = await res.json(); } catch { throw new ReadError('unreachable', 'The Console server did not answer.', 0); }
|
||||
if (signInRequired(res, body)) { signOutSso(); throw signedOut(); }
|
||||
if (!res.ok || !body || !Array.isArray(body.rows)) throw new ReadError(typeof body?.error === 'string' ? body.error : 'invalid-response', typeof body?.message === 'string' ? body.message : `HTTP ${res.status}`, res.status);
|
||||
return { ...body, rows: body.rows, at: body.at };
|
||||
}
|
||||
@@ -50,6 +55,7 @@
|
||||
try { res = await fetch(`/api/${path}`, { cache: 'no-store', redirect: 'manual' }); } catch { throw unreachable(); }
|
||||
if (res.type === 'opaqueredirect') { signOut(); throw signedOut(); }
|
||||
try { body = await res.json(); } catch { throw unreachable(); }
|
||||
if (signInRequired(res, body)) { signOutSso(); throw signedOut(); }
|
||||
if (!res.ok || !body || typeof body !== 'object' || Array.isArray(body)) throw Object.assign(new ReadError(typeof body?.error === 'string' ? body.error : 'invalid-response', typeof body?.message === 'string' ? body.message : `HTTP ${res.status}`, res.status), { module: true });
|
||||
return body;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<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>
|
||||
<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></div></header>
|
||||
<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 class="frame">
|
||||
<nav class="tree" aria-label="Console"><h2 class="tree-title">Console</h2><ul class="s1-sections" id="sections"></ul><div id="board-tree"><h2 class="tree-title">Projects</h2><div id="projects"><p class="muted">Loading projects…</p></div><p class="small muted">Select a project to filter sessions. Waiting on you always shows all projects.</p></div></nav>
|
||||
<main id="main" class="content" tabindex="-1"><div id="signed-out" class="banner ref" role="alert" tabindex="-1" hidden></div><div id="bus-down" class="banner err" role="alert" hidden></div><div class="page-head" id="board-head"><div><h1>Control board</h1><p id="status" class="small muted" role="status">Loading board…</p></div><div class="filters"><label><input id="show-machine" type="checkbox">Show machine sessions</label></div></div>
|
||||
|
||||
@@ -61,6 +61,8 @@ async function body(req, max = 4096) {
|
||||
// Row 65 (mos-0840) adds `routes`: each instance's harness, model, thinking and fallback.
|
||||
const BUS_VERBS = ['inbox', 'tasks', 'agents', 'trail', 'decisions', 'credentials', 'replies', 'messages', 'usage', 'limits', 'names', 'instances', 'routes'];
|
||||
const DECISION_LISTS = ['roles', 'recent'];
|
||||
// Q175 phase 1 (#1690, F8): either sign-in cookie, which the tester listener refuses under --sso.
|
||||
const SIGN_IN_COOKIE = /(?:^|;)\s*__host-mosaic_(?:session|login)\s*=/i;
|
||||
// Row 177 (#1675): the chat writes the tester listener lets through, exactly the four chatHTTP routes.
|
||||
const TESTER_CHAT_WRITE = /^\/api\/chat\/(?:projects|threads|threads\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\/(?:messages|open))$/i;
|
||||
async function busRead(res, bus, verb, search) {
|
||||
@@ -160,14 +162,18 @@ async function busRead(res, bus, verb, search) {
|
||||
// broker's to refuse. Nothing retries; an outcome the bus can't confirm is reported as unknown.
|
||||
const SEND_KEYS = ['to', 'body', 'in_reply_to'];
|
||||
const one = (req, name) => (req.headersDistinct[name]?.length === 1 ? req.headersDistinct[name][0] : null);
|
||||
// Q175 phase 1 (#1690): under --sso the writer comes from a session the handler already checked,
|
||||
// so it carries `sso: true` and the public origin; the preview writer still checks its header.
|
||||
const signedIn = (req, p) => p.sso === true || one(req, p.header) === p.user;
|
||||
const ownOrigin = p => p.origin ?? `https://${p.host}`;
|
||||
async function busSend(req, res, { send, preview, log }) {
|
||||
if (one(req, preview.header) !== preview.user) return json(res, 401, { error: 'sign-in required' });
|
||||
if (!signedIn(req, preview)) return json(res, 401, { error: 'sign-in required' });
|
||||
let to = null;
|
||||
// `to` is quoted and cut short so a crafted value can't forge a log line.
|
||||
const line = outcome => log(`${new Date().toISOString()} send user=${JSON.stringify(preview.user)} to=${typeof to === 'string' ? JSON.stringify(to.slice(0, 64)) : 'none'} ${outcome}`);
|
||||
// `sent` tells the page which it was: false, nothing was written; 'unknown', it may have been.
|
||||
const refuse = (status, error, message, sent = false) => { line(`error=${error}`); return json(res, status, { error, message, sent }); };
|
||||
if (one(req, 'origin') !== `https://${preview.host}`) return refuse(403, 'cross-origin request refused', 'a send needs the Console\'s own Origin');
|
||||
if (one(req, 'origin') !== ownOrigin(preview)) return refuse(403, 'cross-origin request refused', 'a send needs the Console\'s own Origin');
|
||||
if (one(req, 'sec-fetch-site') !== 'same-origin') return refuse(403, 'cross-site request refused', 'a send needs Sec-Fetch-Site: same-origin');
|
||||
let value;
|
||||
try { value = JSON.parse((await body(req, 16384)).toString('utf8')); } catch (err) { return refuse(400, 'invalid-request', err.message); }
|
||||
@@ -241,10 +247,10 @@ export function proofOptions({ proofHeader, proofFile } = {}, preview = null) {
|
||||
// REQ-WEB-1: same signed-in send boundary, a distinct strict settings payload.
|
||||
async function settingsSend(req, res, { settingsWrite, preview, log }) {
|
||||
const refuse = (status, error, message) => json(res, status, { error, message, sent: false });
|
||||
if (one(req, preview.header) !== preview.user) {
|
||||
if (!signedIn(req, preview)) {
|
||||
return refuse(401, 'sign-in required', 'sign in again');
|
||||
}
|
||||
if (one(req, 'origin') !== `https://${preview.host}`) {
|
||||
if (one(req, 'origin') !== ownOrigin(preview)) {
|
||||
return refuse(403, 'cross-origin request refused', 'settings need the Console origin');
|
||||
}
|
||||
if (one(req, 'sec-fetch-site') !== 'same-origin') {
|
||||
@@ -292,8 +298,8 @@ async function seatSend(req, res, { route, write, preview, log }) {
|
||||
// The instance is quoted and cut short so a crafted value can't forge a log line.
|
||||
const line = outcome => log(`${new Date().toISOString()} ${route.kind} user=${JSON.stringify(preview.user)} instance=${quoted(instance)} ${outcome}`);
|
||||
const refuse = (status, error, message, sent = false) => { line(`error=${error}`); return json(res, status, { error, message, sent }); };
|
||||
if (!preview.tester && one(req, preview.header) !== preview.user) return json(res, 401, { error: 'sign-in required', message: 'sign in again', sent: false });
|
||||
if (one(req, 'origin') !== (preview.origin ?? `https://${preview.host}`)) return refuse(403, 'cross-origin request refused', 'this needs the Console\'s own Origin');
|
||||
if (!preview.tester && !signedIn(req, preview)) return json(res, 401, { error: 'sign-in required', message: 'sign in again', sent: false });
|
||||
if (one(req, 'origin') !== ownOrigin(preview)) return refuse(403, 'cross-origin request refused', 'this needs the Console\'s own Origin');
|
||||
if (one(req, 'sec-fetch-site') !== 'same-origin') return refuse(403, 'cross-site request refused', 'this needs same-origin Fetch Metadata');
|
||||
let value;
|
||||
try { value = JSON.parse((await body(req)).toString('utf8')); } catch (err) { return refuse(400, 'invalid-request', err.message); }
|
||||
@@ -335,8 +341,8 @@ async function instanceSend(req, res, { instancesWrite, preview, log }) {
|
||||
// The instance is quoted and cut short so a crafted value can't forge a log line. Vars are never logged.
|
||||
const line = outcome => log(`${new Date().toISOString()} instance user=${JSON.stringify(preview.user)} instance=${quoted(instance)} ${outcome}`);
|
||||
const refuse = (status, error, message, sent = false) => { line(`error=${error}`); return json(res, status, { error, message, sent }); };
|
||||
if (!preview.tester && one(req, preview.header) !== preview.user) return json(res, 401, { error: 'sign-in required', message: 'sign in again', sent: false });
|
||||
if (one(req, 'origin') !== (preview.origin ?? `https://${preview.host}`)) return refuse(403, 'cross-origin request refused', 'this needs the Console\'s own Origin');
|
||||
if (!preview.tester && !signedIn(req, preview)) return json(res, 401, { error: 'sign-in required', message: 'sign in again', sent: false });
|
||||
if (one(req, 'origin') !== ownOrigin(preview)) return refuse(403, 'cross-origin request refused', 'this needs the Console\'s own Origin');
|
||||
if (one(req, 'sec-fetch-site') !== 'same-origin') return refuse(403, 'cross-site request refused', 'this needs same-origin Fetch Metadata');
|
||||
let value;
|
||||
try { value = JSON.parse((await body(req, 16384)).toString('utf8')); } catch (err) { return refuse(400, 'invalid-request', err.message); }
|
||||
@@ -382,8 +388,8 @@ async function routesSend(req, res, { routesWrite, preview, log }) {
|
||||
let names = [];
|
||||
const line = outcome => log(`${new Date().toISOString()} routes user=${JSON.stringify(preview.user)} instances=${quoted(names.join(','))} ${outcome}`);
|
||||
const refuse = (status, error, message, sent = false) => { line(`error=${error}`); return json(res, status, { error, message, sent }); };
|
||||
if (!preview.tester && one(req, preview.header) !== preview.user) return json(res, 401, { error: 'sign-in required', message: 'sign in again', sent: false });
|
||||
if (one(req, 'origin') !== (preview.origin ?? `https://${preview.host}`)) return refuse(403, 'cross-origin request refused', 'this needs the Console\'s own Origin');
|
||||
if (!preview.tester && !signedIn(req, preview)) return json(res, 401, { error: 'sign-in required', message: 'sign in again', sent: false });
|
||||
if (one(req, 'origin') !== ownOrigin(preview)) return refuse(403, 'cross-origin request refused', 'this needs the Console\'s own Origin');
|
||||
if (one(req, 'sec-fetch-site') !== 'same-origin') return refuse(403, 'cross-site request refused', 'this needs same-origin Fetch Metadata');
|
||||
let value;
|
||||
try { value = JSON.parse((await body(req, 16384)).toString('utf8')); } catch (err) { return refuse(400, 'invalid-request', err.message); }
|
||||
@@ -420,8 +426,8 @@ async function projectFileSend(req, res, { projectSave, preview, log }) {
|
||||
const line = outcome => log(`${new Date().toISOString()} project-file user=${JSON.stringify(preview.user)} project=${quoted(target?.project)} path=${quoted(target?.path)} ${outcome}`);
|
||||
// `sent` tells the page which it was: false, nothing was written; 'unknown', it may have been.
|
||||
const refuse = (status, error, message, sent = false) => { line(`error=${error}`); return json(res, status, { error, message, sent }); };
|
||||
if (one(req, preview.header) !== preview.user) return json(res, 401, { error: 'sign-in required', message: 'sign in again', sent: false });
|
||||
if (one(req, 'origin') !== `https://${preview.host}`) return refuse(403, 'cross-origin request refused', 'a save needs the Console\'s own Origin');
|
||||
if (!signedIn(req, preview)) return json(res, 401, { error: 'sign-in required', message: 'sign in again', sent: false });
|
||||
if (one(req, 'origin') !== ownOrigin(preview)) return refuse(403, 'cross-origin request refused', 'a save needs the Console\'s own Origin');
|
||||
if (one(req, 'sec-fetch-site') !== 'same-origin') return refuse(403, 'cross-site request refused', 'a save needs same-origin Fetch Metadata');
|
||||
let value;
|
||||
try { value = JSON.parse((await body(req, FILE_SAVE_MAX)).toString('utf8')); } catch (err) { return refuse(400, 'invalid-request', err.message); }
|
||||
@@ -460,13 +466,17 @@ async function projectFileRead(res, read, path, search) {
|
||||
return json(res, busStatus(code), { error: code, message: err instanceof BusReadError ? err.message : code });
|
||||
}
|
||||
}
|
||||
// `send` is the bus write (row 76): `args => Promise<{id}>`, only with the preview mode. `log`
|
||||
// `send` is the bus write (row 76): `args => Promise<{id}>`, only with the preview mode or `sso`.
|
||||
// `sso` is createSso() from sso.mjs (Q175 phase 1): the human listener signs people in itself.
|
||||
// `testerNoSignIn` makes the tester listener refuse anything that looks like a sign-in (--sso). `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.
|
||||
export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null, preview = null, tester = false, 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, 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, testerNoSignIn = false, 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, 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');
|
||||
if (tester && sso !== null) throw new Error('tester listener cannot use sso');
|
||||
if (sso !== null && (preview !== null || proof !== null)) throw new Error('--sso cannot be combined with --auth-header, --allow-user or --proof-*');
|
||||
if (tester && host !== '127.0.0.1') throw new Error('tester listener must bind 127.0.0.1');
|
||||
// Row 177 (#1675): chat is allowed. The tester listener's chat binds the tester, so the bus keeps it on tester threads.
|
||||
if (tester && [send, settingsWrite, projectSave, assistantStop].some(value => value !== null)) throw new Error('tester listener permits only instance, route, name and chat writes');
|
||||
@@ -476,21 +486,26 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
throw new Error('preview mode needs --public-host, --auth-header and --allow-user together');
|
||||
}
|
||||
if (proof !== null && (preview === null || tester)) throw new Error('proxy proof needs preview mode');
|
||||
if (send !== null && (preview === null || typeof send !== 'function')) throw new Error('--send needs the preview mode (--public-host, --auth-header and --allow-user)');
|
||||
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) => {
|
||||
res.setHeader('x-content-type-options', 'nosniff');
|
||||
res.setHeader('referrer-policy', 'no-referrer');
|
||||
res.setHeader('content-security-policy', "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; font-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'");
|
||||
// Under --sso the Sign out form's POST is redirected to the provider's end-session endpoint, and
|
||||
// browsers apply form-action to that redirect, so those origins (from discovery) are allowed too.
|
||||
res.setHeader('content-security-policy', `default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; font-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'${sso ? sso.formTargets().map(o => ` ${o}`).join('') : ''}`);
|
||||
// Presence, even with an empty value: never turn the public listener into a tester ingress.
|
||||
if (!tester && Object.hasOwn(req.headers, 'x-mosaic-tester')) return json(res, 403, { error: 'tester listener required' });
|
||||
if (tester && testerAuthHeader && Object.hasOwn(req.headers, testerAuthHeader.toLowerCase())) return json(res, 403, { error: 'proxy header refused' });
|
||||
if (tester && testerProofHeader && Object.hasOwn(req.headers, testerProofHeader.toLowerCase())) return json(res, 403, { error: 'proxy proof header refused' });
|
||||
// Under --sso the tester listener reads no cookie and takes no sign-in of any kind (design section 6).
|
||||
if (tester && testerNoSignIn && (Object.hasOwn(req.headers, 'authorization') || SIGN_IN_COOKIE.test(req.headers.cookie ?? '') || Object.keys(req.headers).some(h => h.startsWith('x-authentik-'))))
|
||||
return json(res, 403, { error: 'tester listener takes no sign-in' });
|
||||
let path, search;
|
||||
// Node keeps the first of repeated Host headers; a second one (a loopback Host, then NAME) is
|
||||
// never legitimate, so it is refused before any check reads the Host.
|
||||
if ((req.headersDistinct.host?.length ?? 0) > 1) return json(res, 400, { error: 'repeated Host refused' });
|
||||
// Preview mode: a request for the public name, exactly, as the proxy forwards it.
|
||||
const named = preview !== null && (req.headers.host ?? '').toLowerCase() === preview.host;
|
||||
const named = preview !== null ? (req.headers.host ?? '').toLowerCase() === preview.host : sso !== null && (req.headers.host ?? '').toLowerCase() === sso.publicHost;
|
||||
// The proof precedes every auth-header read, including the wrong-Host fence.
|
||||
if (proof !== null) {
|
||||
const values = req.headersDistinct[proof.header];
|
||||
@@ -512,19 +527,34 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
}
|
||||
({ pathname: path, search } = new URL(req.url, 'http://localhost'));
|
||||
} catch { return json(res, 400, { error: 'invalid URL' }); }
|
||||
// Under --sso the human listener serves only the public Host, whatever its port; GET /healthz
|
||||
// alone stays open on a loopback Host for the status and release gates (Q-C).
|
||||
if (sso !== null && !named && !(path === '/healthz' && req.method === 'GET')) return json(res, 403, { error: 'public Host required' });
|
||||
// No CORS. JSON content type and same-origin checks keep browser forms out.
|
||||
if (req.headers.origin && req.headers.origin !== (named ? `https://${preview.host}` : `http://${req.headers.host}`)) return json(res, 403, { error: 'cross-origin request refused' });
|
||||
if (named) {
|
||||
if (req.headers.origin && req.headers.origin !== (named ? (sso ? sso.publicOrigin : `https://${preview.host}`) : `http://${req.headers.host}`)) return json(res, 403, { error: 'cross-origin request refused' });
|
||||
let signer = preview;
|
||||
if (sso !== null && named) {
|
||||
if (path.startsWith('/auth/')) return sso.handle(req, res, path, search);
|
||||
// Only a page load or a write keeps a session alive; the page's background reads don't (Q-D).
|
||||
const session = path === '/healthz' ? null : sso.session(req, { touch: req.method === 'POST' || (req.method === 'GET' && !path.startsWith('/api/')) });
|
||||
if (!session && path !== '/healthz') {
|
||||
if (path.startsWith('/api/') || req.method !== 'GET') return json(res, 401, { error: 'sign-in required' });
|
||||
res.writeHead(303, { location: `/auth/login?return=${encodeURIComponent(path + search)}`, 'cache-control': 'no-store' });
|
||||
return res.end();
|
||||
}
|
||||
if (session) signer = { sso: true, user: session.name, host: sso.publicHost, origin: sso.publicOrigin };
|
||||
}
|
||||
if (named && preview !== null) {
|
||||
// One value, equal to the user id; the proxy strips client copies and sets it after sign-in.
|
||||
const values = req.headersDistinct[preview.header];
|
||||
if (!values || values.length !== 1 || values[0] !== preview.user) return json(res, 401, { error: 'sign-in required' });
|
||||
}
|
||||
const writer = tester ? { tester: true, user: 'tester', origin: `http://127.0.0.1:${server.address().port}` } : preview;
|
||||
const writer = tester ? { tester: true, user: 'tester', origin: `http://127.0.0.1:${server.address().port}` } : signer;
|
||||
if (tester && !['GET', 'HEAD'].includes(req.method) && !['/api/instances', '/api/routes', '/api/name'].includes(path) && !TESTER_CHAT_WRITE.test(path))
|
||||
return json(res, 403, { error: 'read-only', sent: false });
|
||||
if(path==='/api/settings'&&req.method==='POST'){
|
||||
if(!send||!settingsWrite||!named)return json(res,403,{error:'read-only',sent:false});
|
||||
return settingsSend(req,res,{settingsWrite,preview,log});
|
||||
return settingsSend(req,res,{settingsWrite,preview:signer,log});
|
||||
}
|
||||
if (Object.hasOwn(SEAT_ROUTES, path)) {
|
||||
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' });
|
||||
@@ -544,18 +574,18 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
}
|
||||
if (path === '/api/project-file' && req.method === 'POST') {
|
||||
if (!send || !projectSave || !named) return json(res, 403, { error: 'read-only', sent: false });
|
||||
return projectFileSend(req, res, { projectSave, preview, log });
|
||||
return projectFileSend(req, res, { projectSave, preview: signer, log });
|
||||
}
|
||||
// Message sending has its own bounded payload and repeats the identity check.
|
||||
if (send && path === '/api/bus/message') {
|
||||
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' });
|
||||
return busSend(req, res, { send, preview, log });
|
||||
return busSend(req, res, { send, preview: signer, log });
|
||||
}
|
||||
if (path === '/api/chat' || path.startsWith('/api/chat/')) {
|
||||
return chatHTTP(req, res, { path, search, chat, named, send, preview: writer, log });
|
||||
}
|
||||
// Nothing else but GET and HEAD gets past here in preview mode, whatever the Host.
|
||||
if (preview !== null && req.method !== 'GET' && req.method !== 'HEAD') return json(res, 403, { error: 'read-only', message: 'the Console preview is read-only' });
|
||||
if ((preview !== null || sso !== null) && req.method !== 'GET' && req.method !== 'HEAD') return json(res, 403, { error: 'read-only', message: 'the Console preview is read-only' });
|
||||
// The conversation routes (#1507, CHAT-02) keep their query; the board validates it.
|
||||
const conversation = path === '/api/conversations' || path === '/api/conversation';
|
||||
const allowed = path === '/api/board' || conversation ? 'GET' : ['/api/seen', '/api/reply'].includes(path) ? 'POST' : null;
|
||||
@@ -607,8 +637,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 } : {}), send: !tester && Boolean(send) && named, edit: !tester && Boolean(send) && Boolean(projectSave) && named });
|
||||
if (path === '/healthz') return json(res, 200, { ok: true });
|
||||
if (path === '/api/config') return json(res, 200, { board: upstream, ...(tester ? { tester: true } : {}), ...(sso ? { sso: true } : {}), send: !tester && Boolean(send) && named, edit: !tester && Boolean(send) && Boolean(projectSave) && named });
|
||||
if (path === '/healthz') return json(res, 200, { ok: true, ...(sso ? { sso: sso.health() } : {}) });
|
||||
if (path === '/favicon.ico') { res.writeHead(204); return res.end(); }
|
||||
const file = files.get(path);
|
||||
if (!file) return json(res, 404, { error: 'not found' });
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
// Q175 phase 1 (#1690): the shape of config.json's optional `console` key. The one loader,
|
||||
// scripts/mosaic-config.mjs, runs this on every read, so a bad key refuses the config for every
|
||||
// tool (design section 4, C1). The Console's --sso start runs it again before it uses the value.
|
||||
// Shape only: nothing here touches the network or a slot file. A refusal names the key path and
|
||||
// never quotes a value, so a refusal can't print a client id or a subject.
|
||||
|
||||
// The asymmetric algorithms oauth4webapi 3.8.8 verifies (its supported(), build/index.js). No HS*
|
||||
// and no none, so a client secret can never be a signing key.
|
||||
export const ALGS = Object.freeze(['PS256', 'ES256', 'RS256', 'PS384', 'ES384', 'RS384', 'PS512', 'ES512', 'RS512', 'Ed25519', 'EdDSA', 'ML-DSA-44', 'ML-DSA-65', 'ML-DSA-87']);
|
||||
const PROVIDER_ID = /^[a-z0-9-]{1,32}$/;
|
||||
const SLOT = /^[a-z0-9-]{1,64}$/;
|
||||
const SUB = /^[\x21-\x7e]{1,255}$/;
|
||||
const CLIENT_ID = /^[\x21-\x7e]{1,255}$/;
|
||||
// The name goes where the preview's --allow-user went (serve.mjs USER): visible ASCII, no comma.
|
||||
const NAME = /^[\x21-\x2b\x2d-\x7e]{1,64}$/;
|
||||
// The bus keeps these for the tester and the human role (section 6), so no subject can sign in as one.
|
||||
const RESERVED_NAMES = ['tester', 'human'];
|
||||
|
||||
const object = v => typeof v === 'object' && v !== null && !Array.isArray(v);
|
||||
function refuse(where, why) {
|
||||
throw new Error(`${where} ${why}`);
|
||||
}
|
||||
function keys(value, where, allowed, required = allowed) {
|
||||
if (!object(value)) refuse(where, 'must be a JSON object');
|
||||
for (const key of Object.keys(value)) if (!allowed.includes(key)) refuse(`${where}.${key}`, 'is not a supported key');
|
||||
for (const key of required) if (!Object.hasOwn(value, key)) refuse(`${where}.${key}`, 'is required');
|
||||
}
|
||||
function httpsURL(value, where) {
|
||||
let url;
|
||||
if (typeof value !== 'string' || value.length > 512) refuse(where, 'must be an https URL of at most 512 characters');
|
||||
try { url = new URL(value); } catch { refuse(where, 'must be an https URL'); }
|
||||
if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash || value.includes('?') || value.includes('#')) {
|
||||
refuse(where, 'must be https with no credentials, query or fragment');
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
// Returns the value unchanged when it passes; throws an Error naming the bad key path otherwise.
|
||||
export function checkConsole(value, where = 'console') {
|
||||
keys(value, where, ['sso']);
|
||||
const sso = value.sso, at = `${where}.sso`;
|
||||
keys(sso, at, ['publicOrigin', 'providers']);
|
||||
const origin = httpsURL(sso.publicOrigin, `${at}.publicOrigin`);
|
||||
if (origin.origin !== sso.publicOrigin) refuse(`${at}.publicOrigin`, 'must be an https origin with no path, query, fragment or trailing slash');
|
||||
if (!Array.isArray(sso.providers) || sso.providers.length === 0 || sso.providers.length > 8) refuse(`${at}.providers`, 'must be a list of 1 to 8 providers');
|
||||
const ids = new Set();
|
||||
sso.providers.forEach((p, i) => {
|
||||
const pat = `${at}.providers[${i}]`;
|
||||
keys(p, pat, ['id', 'issuer', 'clientId', 'clientSecret', 'algs', 'subjects']);
|
||||
if (typeof p.id !== 'string' || !PROVIDER_ID.test(p.id)) refuse(`${pat}.id`, 'must match [a-z0-9-]{1,32}');
|
||||
if (ids.has(p.id)) refuse(`${pat}.id`, 'repeats an earlier provider id');
|
||||
ids.add(p.id);
|
||||
httpsURL(p.issuer, `${pat}.issuer`);
|
||||
if (typeof p.clientId !== 'string' || !CLIENT_ID.test(p.clientId)) refuse(`${pat}.clientId`, 'must be 1 to 255 visible characters');
|
||||
keys(p.clientSecret, `${pat}.clientSecret`, ['slot']);
|
||||
if (typeof p.clientSecret.slot !== 'string' || !SLOT.test(p.clientSecret.slot)) refuse(`${pat}.clientSecret.slot`, 'must match [a-z0-9-]{1,64}');
|
||||
if (!Array.isArray(p.algs) || p.algs.length === 0) refuse(`${pat}.algs`, 'must be a non-empty list');
|
||||
p.algs.forEach((alg, j) => {
|
||||
if (!ALGS.includes(alg)) refuse(`${pat}.algs[${j}]`, `must be one of ${ALGS.join(', ')}; HS* and none are refused`);
|
||||
});
|
||||
if (new Set(p.algs).size !== p.algs.length) refuse(`${pat}.algs`, 'repeats an algorithm');
|
||||
if (!Array.isArray(p.subjects) || p.subjects.length === 0 || p.subjects.length > 64) refuse(`${pat}.subjects`, 'must be a list of 1 to 64 subjects');
|
||||
const subs = new Set();
|
||||
p.subjects.forEach((s, j) => {
|
||||
const sat = `${pat}.subjects[${j}]`;
|
||||
keys(s, sat, ['sub', 'name']);
|
||||
if (typeof s.sub !== 'string' || !SUB.test(s.sub)) refuse(`${sat}.sub`, 'must be 1 to 255 visible characters');
|
||||
if (subs.has(s.sub)) refuse(`${sat}.sub`, 'repeats an earlier subject');
|
||||
subs.add(s.sub);
|
||||
if (typeof s.name !== 'string' || !NAME.test(s.name)) refuse(`${sat}.name`, 'must be 1 to 64 visible characters');
|
||||
if (RESERVED_NAMES.includes(s.name.toLowerCase())) refuse(`${sat}.name`, `must not be ${RESERVED_NAMES.join(' or ')}`);
|
||||
});
|
||||
});
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
// Q175 phase 1 (#1690, design agents/sage/work/q175-sso/DESIGN.md): the Console as an OIDC relying
|
||||
// party. Used only by the human listener under --sso; startServer refuses it on the tester listener.
|
||||
// Every URL handed to a provider is built from the configured publicOrigin, never from a request's
|
||||
// Host. A refusal logs one fixed code and the provider id, never an exception, its message or its
|
||||
// cause: oauth4webapi's errors carry token bodies and claims there.
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, realpathSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join, relative, isAbsolute, resolve } from 'node:path';
|
||||
import { checkConsole } from './sso-config.mjs';
|
||||
|
||||
// oauth4webapi loads in createSso, not here: cli.mjs imports this module on every start, and a
|
||||
// Console without --sso must not need the package (pinned-copy.test.mjs builds copies whose lock
|
||||
// installs nothing). A real copy installs it from the root lock.
|
||||
let oauth;
|
||||
|
||||
export const SESSION_COOKIE = '__Host-mosaic_session';
|
||||
export const LOGIN_COOKIE = '__Host-mosaic_login';
|
||||
const ATTRS = 'Path=/; Secure; HttpOnly; SameSite=Lax';
|
||||
const LOGIN_MAX_AGE = 600;
|
||||
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 sha256 = value => createHash('sha256').update(value).digest('hex');
|
||||
const escape = text => String(text).replace(/[&<>"']/g, c => `&#${c.charCodeAt(0)};`);
|
||||
const under = (parent, child) => { const rel = relative(parent, child); return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel)); };
|
||||
|
||||
// The slot file holding a provider's client secret (design section 4, D1). Only the Console's --sso
|
||||
// start calls this. A refusal names the provider and the check, never the path's contents.
|
||||
export function readSlot(configDir, provider, { repo, checkout = null, copies = join(process.env.XDG_DATA_HOME || join(homedir(), '.local', 'share'), 'mosaic-stack', 'copies') } = {}) {
|
||||
const where = `console.sso provider ${provider.id} client secret slot`;
|
||||
const file = join(resolve(configDir), 'secrets', 'mosaic-stack', provider.clientSecret.slot);
|
||||
let st;
|
||||
try { st = lstatSync(file); } catch { throw new Error(`${where} is missing`); }
|
||||
if (st.isSymbolicLink()) throw new Error(`${where} must not be a symbolic link`);
|
||||
let fd, real, text;
|
||||
try {
|
||||
fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
||||
st = fstatSync(fd);
|
||||
if (!st.isFile()) throw new Error(`${where} must be a regular file`);
|
||||
if (st.uid !== process.getuid()) throw new Error(`${where} must be owned by the Console's user`);
|
||||
if (st.mode & 0o077) throw new Error(`${where} must have no group or other permission bits`);
|
||||
real = realpathSync(file);
|
||||
for (const root of [repo, checkout, copies].filter(Boolean)) {
|
||||
let base;
|
||||
try { base = realpathSync(root); } catch { base = resolve(root); }
|
||||
if (under(base, real)) throw new Error(`${where} must not be inside the repository or a stack copy`);
|
||||
}
|
||||
text = readFileSync(fd, 'utf8');
|
||||
} catch (err) {
|
||||
throw err.message?.startsWith(where) ? err : new Error(`${where} is not readable`);
|
||||
} finally {
|
||||
if (fd !== undefined) closeSync(fd);
|
||||
}
|
||||
const secret = text.endsWith('\n') ? text.slice(0, -1) : text;
|
||||
if (!secret || /[\r\n]/.test(secret)) throw new Error(`${where} must hold one non-empty line`);
|
||||
return secret;
|
||||
}
|
||||
|
||||
export function cookies(req, name) {
|
||||
const found = [];
|
||||
for (const part of (req.headers.cookie ?? '').split(';')) {
|
||||
const at = part.indexOf('=');
|
||||
if (at > 0 && part.slice(0, at).trim() === name) found.push(part.slice(at + 1).trim());
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
// The JWKS check at discovery, the same filter the library's key selection uses (kty, alg, use,
|
||||
// key_ops, crv; RSA at least 2048 bits), without a kid since no token is at hand yet.
|
||||
function usableKey(jwk, alg) {
|
||||
const kty = { RS: 'RSA', PS: 'RSA', ES: 'EC', Ed: 'OKP', ML: 'AKP' }[alg.slice(0, 2)];
|
||||
if (jwk === null || typeof jwk !== 'object' || jwk.kty !== kty) return false;
|
||||
if (jwk.alg !== undefined && jwk.alg !== alg) return false;
|
||||
if (jwk.use !== undefined && jwk.use !== 'sig') return false;
|
||||
if (Array.isArray(jwk.key_ops) && !jwk.key_ops.includes('verify')) return false;
|
||||
const crv = { ES256: 'P-256', ES384: 'P-384', ES512: 'P-521', Ed25519: 'Ed25519', EdDSA: 'Ed25519' }[alg];
|
||||
if (crv && jwk.crv !== crv) return false;
|
||||
if (kty === 'RSA' && (typeof jwk.n !== 'string' || Buffer.from(jwk.n, 'base64url').length * 8 < 2048)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
class Unreachable extends Error {}
|
||||
class Refused extends Error {}
|
||||
const https = value => { try { return new URL(value).protocol === 'https:'; } catch { return false; } };
|
||||
|
||||
// One provider's discovery and JWKS. Throws Unreachable (network, timeout, a status other than 200,
|
||||
// a body that isn't JSON) or Refused (a JSON document that fails a check). Q-B, design section 4.
|
||||
async function discover(p, fetchOptions) {
|
||||
let response, text;
|
||||
const options = () => ({ ...fetchOptions, signal: AbortSignal.timeout(FETCH_TIMEOUT) });
|
||||
try {
|
||||
response = await oauth.discoveryRequest(new URL(p.issuer), options());
|
||||
if (response.status !== 200 || !/^application\/json\b/i.test(response.headers.get('content-type') ?? '')) throw new Error();
|
||||
text = await response.text();
|
||||
JSON.parse(text);
|
||||
} catch { throw new Unreachable(); }
|
||||
let as;
|
||||
try {
|
||||
as = await oauth.processDiscoveryResponse(new URL(p.issuer), new Response(text, { status: 200, headers: { 'content-type': 'application/json' } }));
|
||||
} catch { throw new Refused(); }
|
||||
if (as.issuer !== p.issuer) throw new Refused();
|
||||
if (!Array.isArray(as.id_token_signing_alg_values_supported) || !p.algs.some(a => as.id_token_signing_alg_values_supported.includes(a))) throw new Refused();
|
||||
if (!Array.isArray(as.code_challenge_methods_supported) || !as.code_challenge_methods_supported.includes('S256')) throw new Refused();
|
||||
if (![as.authorization_endpoint, as.token_endpoint, as.jwks_uri].every(https)) throw new Refused();
|
||||
if (as.end_session_endpoint !== undefined && !https(as.end_session_endpoint)) throw new Refused();
|
||||
let jwks;
|
||||
try {
|
||||
const fetcher = fetchOptions[oauth.customFetch] ?? fetch;
|
||||
const res = await fetcher(as.jwks_uri, { method: 'GET', headers: { accept: 'application/json, application/jwk-set+json' }, redirect: 'manual', signal: AbortSignal.timeout(FETCH_TIMEOUT) });
|
||||
if (res.status !== 200) throw new Error();
|
||||
jwks = JSON.parse(await res.text());
|
||||
} catch { throw new Unreachable(); }
|
||||
if (jwks === null || typeof jwks !== 'object' || !Array.isArray(jwks.keys)) throw new Refused();
|
||||
const usable = p.algs.filter(alg => jwks.keys.some(k => usableKey(k, alg)));
|
||||
if (!usable.length) throw new Refused();
|
||||
return as;
|
||||
}
|
||||
|
||||
// `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 } = {}) {
|
||||
checkConsole({ sso: config });
|
||||
oauth ??= await import('oauth4webapi');
|
||||
const publicOrigin = config.publicOrigin;
|
||||
const publicHost = new URL(publicOrigin).host;
|
||||
const redirectUri = `${publicOrigin}/auth/callback`;
|
||||
const fetchOptions = customFetch ? { [oauth.customFetch]: customFetch } : {};
|
||||
const sessions = new Map(), logins = new Map();
|
||||
const timers = new Set();
|
||||
let closed = false;
|
||||
const refuse = (code, provider, extra = '') => {
|
||||
if (!CODES.has(code)) throw new Error(`unknown sso log code ${code}`);
|
||||
log(`${new Date().toISOString()} sso refused code=${code} provider=${provider}${extra}`);
|
||||
};
|
||||
|
||||
const providers = new Map(config.providers.map(p => {
|
||||
if (typeof secrets?.get(p.id) !== 'string') throw new Error(`console.sso provider ${p.id} has no client secret`);
|
||||
return [p.id, {
|
||||
config: p, state: 'unreachable', as: null, jwks: {},
|
||||
client: { client_id: p.clientId, id_token_signed_response_alg: [...p.algs] },
|
||||
auth: oauth.ClientSecretBasic(secrets.get(p.id)),
|
||||
subjects: new Map(p.subjects.map(s => [s.sub, s.name])),
|
||||
}];
|
||||
}));
|
||||
|
||||
async function attempt(entry, atStart) {
|
||||
try {
|
||||
entry.as = await discover(entry.config, fetchOptions);
|
||||
entry.jwks = {};
|
||||
entry.state = 'ready';
|
||||
} catch (err) {
|
||||
if (err instanceof Refused) {
|
||||
entry.state = 'refused';
|
||||
refuse('discovery-failed', entry.config.id);
|
||||
if (atStart) throw new Error(`console.sso provider ${entry.config.id} refused at discovery: the issuer, algorithms, S256 or JWKS check failed`);
|
||||
return;
|
||||
}
|
||||
entry.state = 'unreachable';
|
||||
refuse('discovery-failed', entry.config.id);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
function retry(entry, delay) {
|
||||
if (closed) return;
|
||||
const timer = setTimeout(async () => {
|
||||
timers.delete(timer);
|
||||
try { await attempt(entry, false); } catch { retry(entry, Math.min(delay * 2, RETRY_CAP)); }
|
||||
}, delay);
|
||||
timer.unref();
|
||||
timers.add(timer);
|
||||
}
|
||||
for (const entry of providers.values()) {
|
||||
try { await attempt(entry, true); } catch (err) {
|
||||
if (!(err instanceof Unreachable)) throw err;
|
||||
retry(entry, retryFirst);
|
||||
}
|
||||
}
|
||||
|
||||
const page = (res, status, title, html, headers = {}) => {
|
||||
res.writeHead(status, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', ...headers });
|
||||
res.end(`<!doctype html><meta charset="utf-8"><title>${escape(title)}</title><main style="font-family:sans-serif;max-width:32rem;margin:4rem auto"><h1>${escape(title)}</h1>${html}</main>\n`);
|
||||
};
|
||||
const redirect = (res, location, setCookie = []) => {
|
||||
res.writeHead(303, { location, 'cache-control': 'no-store', ...(setCookie.length ? { 'set-cookie': setCookie } : {}) });
|
||||
res.end();
|
||||
};
|
||||
const failed = (res, setCookie = []) => page(res, 403, 'Sign-in failed', '<p><a href="/auth/login">Sign in again</a></p>', setCookie.length ? { 'set-cookie': setCookie } : {});
|
||||
const expire = name => `${name}=; ${ATTRS}; Max-Age=0`;
|
||||
const remember = (map, key, value, cap) => {
|
||||
while (map.size >= cap) map.delete(map.keys().next().value);
|
||||
map.set(key, value);
|
||||
};
|
||||
|
||||
// B2: only a URL on publicOrigin itself; anything else, or nothing, is publicOrigin's `/`. The
|
||||
// Location is absolute and its origin checked again: dot segments can leave a pathname of `//host`,
|
||||
// which as a relative Location a browser reads as another origin (webby-0019 B1, sage-1403).
|
||||
function returnURL(ret) {
|
||||
const home = `${publicOrigin}/`;
|
||||
try {
|
||||
if (typeof ret !== 'string' || !ret) return home;
|
||||
const u = new URL(ret, publicOrigin);
|
||||
if (u.origin !== publicOrigin) return home;
|
||||
const out = new URL(`${publicOrigin}${u.pathname}${u.search}`);
|
||||
return out.origin === publicOrigin ? out.href : home;
|
||||
} catch { return home; }
|
||||
}
|
||||
|
||||
async function start(res, entry, ret) {
|
||||
const state = oauth.generateRandomState(), nonce = oauth.generateRandomNonce(), verifier = oauth.generateRandomCodeVerifier();
|
||||
const challenge = await oauth.calculatePKCECodeChallenge(verifier);
|
||||
const id = randomBytes(32).toString('base64url');
|
||||
remember(logins, sha256(id), { provider: entry.config.id, state, nonce, verifier, ret: typeof ret === 'string' ? ret.slice(0, 2048) : '/', expires: Date.now() + LOGIN_MAX_AGE * 1000 }, MAX_LOGINS);
|
||||
const url = new URL(entry.as.authorization_endpoint);
|
||||
for (const [k, v] of Object.entries({ client_id: entry.config.clientId, response_type: 'code', scope: 'openid profile email', redirect_uri: redirectUri, state, nonce, code_challenge: challenge, code_challenge_method: 'S256' })) url.searchParams.set(k, v);
|
||||
return redirect(res, url.href, [`${LOGIN_COOKIE}=${id}; ${ATTRS}; Max-Age=${LOGIN_MAX_AGE}`]);
|
||||
}
|
||||
|
||||
function login(res, query) {
|
||||
const asked = query.get('provider');
|
||||
const ret = query.get('return');
|
||||
if (asked === null && providers.size > 1) {
|
||||
const items = [...providers.values()].map(e => `<li><a href="/auth/login?${escape(new URLSearchParams({ provider: e.config.id, ...(ret ? { return: ret } : {}) }))}">${escape(e.config.id)}</a></li>`).join('');
|
||||
return page(res, 200, 'Sign in', `<ul>${items}</ul>`);
|
||||
}
|
||||
const entry = asked === null ? [...providers.values()][0] : providers.get(asked);
|
||||
if (!entry) return page(res, 404, 'Unknown sign-in provider', '<p><a href="/auth/login">Sign in</a></p>');
|
||||
if (entry.state !== 'ready') return page(res, 503, 'sign-in unavailable', '<p>The sign-in provider can\'t be reached. Try again shortly.</p>');
|
||||
return start(res, entry, ret);
|
||||
}
|
||||
|
||||
// Design section 1, steps 2-7. Each refusal ends here with one log line and the 403 page.
|
||||
async function callback(res, query, req) {
|
||||
const clear = [expire(LOGIN_COOKIE)];
|
||||
const ids = cookies(req, LOGIN_COOKIE);
|
||||
const key = ids.length === 1 ? sha256(ids[0]) : null;
|
||||
const record = key === null ? undefined : logins.get(key);
|
||||
if (key !== null) logins.delete(key);
|
||||
// The provider comes from the record, never from the query.
|
||||
const entry = record && providers.get(record.provider);
|
||||
if (!record || record.expires <= Date.now() || !entry) {
|
||||
refuse('login-record-missing', record?.provider ?? 'none');
|
||||
return failed(res, clear);
|
||||
}
|
||||
const pid = entry.config.id;
|
||||
if (entry.state !== 'ready') { refuse('discovery-failed', pid); return failed(res, clear); }
|
||||
let params;
|
||||
try { params = oauth.validateAuthResponse(entry.as, entry.client, query, record.state); } catch (err) {
|
||||
refuse(err instanceof oauth.AuthorizationResponseError ? 'auth-error-response' : 'state-mismatch', pid);
|
||||
return failed(res, clear);
|
||||
}
|
||||
const options = () => ({ ...fetchOptions, [oauth.jwksCache]: entry.jwks, signal: AbortSignal.timeout(FETCH_TIMEOUT) });
|
||||
let response;
|
||||
try { response = await oauth.authorizationCodeGrantRequest(entry.as, entry.client, entry.auth, params, redirectUri, record.verifier, options()); } catch {
|
||||
refuse('grant-failed', pid); return failed(res, clear);
|
||||
}
|
||||
let result, claims;
|
||||
try {
|
||||
result = await oauth.processAuthorizationCodeResponse(entry.as, entry.client, response, { expectedNonce: record.nonce, requireIdToken: true });
|
||||
claims = oauth.getValidatedIdTokenClaims(result);
|
||||
} catch (err) {
|
||||
refuse(err instanceof oauth.ResponseBodyError ? 'grant-failed' : 'id-token-invalid', pid);
|
||||
return failed(res, clear);
|
||||
}
|
||||
// Step 5: the library skips this for a token-endpoint ID token; point 2 needs it, on the same
|
||||
// Response object. Its throw ends the callback.
|
||||
try { await oauth.validateApplicationLevelSignature(entry.as, response, options()); } catch (err) {
|
||||
const fetching = !(err instanceof oauth.OperationProcessingError || err instanceof oauth.UnsupportedOperationError) || [oauth.RESPONSE_IS_NOT_CONFORM, oauth.RESPONSE_IS_NOT_JSON].includes(err.code);
|
||||
refuse(fetching ? 'jwks-failed' : 'signature-invalid', pid);
|
||||
return failed(res, clear);
|
||||
}
|
||||
// Step 6: our own checks on top of the library's.
|
||||
let header;
|
||||
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;
|
||||
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);
|
||||
return redirect(res, returnURL(record.ret), [...clear, `${SESSION_COOKIE}=${id}; ${ATTRS}`]);
|
||||
}
|
||||
|
||||
// The session for this request, or null. `touch` is true only for a page load or a write (Q-D).
|
||||
function session(req, { touch = false } = {}) {
|
||||
const ids = cookies(req, SESSION_COOKIE);
|
||||
if (ids.length !== 1) return null;
|
||||
const key = sha256(ids[0]);
|
||||
const s = sessions.get(key);
|
||||
if (!s) return null;
|
||||
const now = Date.now();
|
||||
if (now - s.created >= SESSION_ABSOLUTE || now - s.seen >= SESSION_IDLE) { sessions.delete(key); return null; }
|
||||
if (touch) s.seen = now;
|
||||
return s;
|
||||
}
|
||||
|
||||
function logout(req, res) {
|
||||
const one = name => (req.headersDistinct[name]?.length === 1 ? req.headersDistinct[name][0] : null);
|
||||
if (one('origin') !== publicOrigin || one('sec-fetch-site') !== 'same-origin') {
|
||||
return page(res, 403, 'Sign-out refused', '<p>Sign out from the Console page.</p>');
|
||||
}
|
||||
const ids = cookies(req, SESSION_COOKIE);
|
||||
const key = ids.length === 1 ? sha256(ids[0]) : null;
|
||||
const s = key === null ? undefined : sessions.get(key);
|
||||
if (key !== null) sessions.delete(key);
|
||||
const entry = s && providers.get(s.provider);
|
||||
let location = `${publicOrigin}/auth/signed-out`;
|
||||
if (entry?.as?.end_session_endpoint) {
|
||||
const url = new URL(entry.as.end_session_endpoint);
|
||||
url.searchParams.set('id_token_hint', s.idToken);
|
||||
url.searchParams.set('post_logout_redirect_uri', `${publicOrigin}/auth/signed-out`);
|
||||
url.searchParams.set('client_id', entry.config.clientId);
|
||||
location = url.href;
|
||||
}
|
||||
return redirect(res, location, [expire(SESSION_COOKIE)]);
|
||||
}
|
||||
|
||||
// `/auth/*` on the human listener; the caller has already checked the Host.
|
||||
async function handle(req, res, path, search) {
|
||||
const query = new URLSearchParams(search);
|
||||
if (path === '/auth/logout') {
|
||||
if (req.method !== 'POST') return page(res, 405, 'Method not allowed', '');
|
||||
return logout(req, res);
|
||||
}
|
||||
if (req.method !== 'GET') return page(res, 405, 'Method not allowed', '');
|
||||
if (path === '/auth/login') return login(res, query);
|
||||
if (path === '/auth/callback') return callback(res, query, req);
|
||||
if (path === '/auth/signed-out') return page(res, 200, 'Signed out', '<p><a href="/auth/login">Sign in</a></p>');
|
||||
return page(res, 404, 'Not found', '');
|
||||
}
|
||||
|
||||
return {
|
||||
publicOrigin, publicHost, sessions, logins, handle, session, returnURL,
|
||||
health: () => Object.fromEntries([...providers].map(([id, e]) => [id, e.state])),
|
||||
formTargets: () => [...new Set([...providers.values()].filter(e => e.as?.end_session_endpoint).map(e => new URL(e.as.end_session_endpoint).origin))],
|
||||
summary: () => config.providers.map(p => ({ id: p.id, issuer: p.issuer, subjects: p.subjects.length })),
|
||||
close() { closed = true; for (const t of timers) clearTimeout(t); timers.clear(); },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
// Q175 phase 1 (#1690, design section 9): a fake OpenID provider for the --sso tests. A loopback
|
||||
// https server with a self-signed certificate made in a temp dir; `op.fetch` is a fetch that trusts
|
||||
// only that certificate, so the code under test needs no insecure flag. It serves any number of
|
||||
// issuers (`/<name>/`) at once, each with discovery, a JWKS, authorization, token and end-session
|
||||
// endpoints. Keys come from node:crypto. Nothing here reaches any other host.
|
||||
import { constants, createHash, createHmac, generateKeyPairSync, randomBytes, sign } from 'node:crypto';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { createServer, request } from 'node:https';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
export const b64url = value => Buffer.from(typeof value === 'string' ? value : JSON.stringify(value)).toString('base64url');
|
||||
|
||||
export function rsaKey(kid = `rsa-${randomBytes(4).toString('hex')}`, bits = 2048) {
|
||||
const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: bits });
|
||||
return { kid, alg: 'RS256', privateKey, jwk: { ...publicKey.export({ format: 'jwk' }), kid, use: 'sig', alg: 'RS256' } };
|
||||
}
|
||||
export function ecKey(kid = `ec-${randomBytes(4).toString('hex')}`) {
|
||||
const { privateKey, publicKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' });
|
||||
return { kid, alg: 'ES256', privateKey, jwk: { ...publicKey.export({ format: 'jwk' }), kid, use: 'sig', alg: 'ES256' } };
|
||||
}
|
||||
export function psKey(kid = `ps-${randomBytes(4).toString('hex')}`) {
|
||||
const k = rsaKey(kid);
|
||||
return { ...k, alg: 'PS256', jwk: { ...k.jwk, alg: 'PS256' } };
|
||||
}
|
||||
|
||||
// A compact JWS. `alg` HS256 uses `hmac` as the key, `none` has an empty signature.
|
||||
export function jwt(header, payload, { key = null, hmac = null } = {}) {
|
||||
const input = `${b64url(header)}.${b64url(payload)}`;
|
||||
let sig = '';
|
||||
if (header.alg === 'HS256') sig = createHmac('sha256', hmac).update(input).digest('base64url');
|
||||
else if (header.alg === 'RS256') sig = sign('sha256', Buffer.from(input), key).toString('base64url');
|
||||
else if (header.alg === 'PS256') sig = sign('sha256', Buffer.from(input), { key, padding: constants.RSA_PKCS1_PSS_PADDING, saltLength: 32 }).toString('base64url');
|
||||
else if (header.alg === 'ES256') sig = sign('sha256', Buffer.from(input), { key, dsaEncoding: 'ieee-p1363' }).toString('base64url');
|
||||
return `${input}.${sig}`;
|
||||
}
|
||||
|
||||
function certificate() {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'fake-oidc-'));
|
||||
try {
|
||||
const r = spawnSync('openssl', ['req', '-x509', '-newkey', 'ec', '-pkeyopt', 'ec_paramgen_curve:P-256', '-nodes', '-days', '1',
|
||||
'-subj', '/CN=127.0.0.1', '-addext', 'subjectAltName=IP:127.0.0.1', '-keyout', join(dir, 'key.pem'), '-out', join(dir, 'cert.pem')], { encoding: 'utf8' });
|
||||
if (r.status !== 0) throw new Error(`openssl failed: ${r.stderr}`);
|
||||
return { key: readFileSync(join(dir, 'key.pem')), cert: readFileSync(join(dir, 'cert.pem')) };
|
||||
} finally { rmSync(dir, { recursive: true, force: true }); }
|
||||
}
|
||||
|
||||
// One issuer's state. `discovery` is merged over the defaults; `mode` is 'ok', 'down' (the socket is
|
||||
// destroyed) or 'html' (200 with an HTML page) for discovery.
|
||||
function issuerState(name, base, opts) {
|
||||
const keys = opts.keys ?? [rsaKey()];
|
||||
return {
|
||||
name, issuer: `${base}/${name}/`, keys, retired: [], clientId: opts.clientId ?? `client-${name}`, clientSecret: opts.clientSecret ?? `secret-${name}-${randomBytes(6).toString('hex')}`,
|
||||
discovery: opts.discovery ?? {}, mode: 'ok', jwksOverride: null, codes: new Map(), acceptTwice: false, requirePkce: true,
|
||||
tokenRequests: [], authorizeRequests: [], jwksFetches: 0, discoveryFetches: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fakeOidc() {
|
||||
const { key, cert } = certificate();
|
||||
const issuers = new Map();
|
||||
const server = createServer({ key, cert }, (req, res) => {
|
||||
const url = new URL(req.url, 'https://127.0.0.1');
|
||||
const [, name, ...rest] = url.pathname.split('/');
|
||||
const iss = issuers.get(name);
|
||||
const route = rest.join('/');
|
||||
const json = (status, body, type = 'application/json') => { res.writeHead(status, { 'content-type': type }); res.end(typeof body === 'string' ? body : JSON.stringify(body)); };
|
||||
if (!iss) return json(404, { error: 'no issuer' });
|
||||
if (route === '.well-known/openid-configuration') {
|
||||
iss.discoveryFetches++;
|
||||
if (iss.mode === 'down') return req.socket.destroy();
|
||||
if (iss.mode === 'html') return json(200, '<!doctype html><title>Sign in</title>', 'text/html');
|
||||
return json(200, discovery(iss));
|
||||
}
|
||||
if (route === 'jwks') {
|
||||
iss.jwksFetches++;
|
||||
return json(200, iss.jwksOverride ?? { keys: iss.keys.map(k => k.jwk) });
|
||||
}
|
||||
if (route === 'authorize') { iss.authorizeRequests.push(Object.fromEntries(url.searchParams)); return json(200, { ok: true }); }
|
||||
if (route === 'end-session') return json(200, { ok: true });
|
||||
if (route === 'token' && req.method === 'POST') {
|
||||
let text = '';
|
||||
req.on('data', c => (text += c));
|
||||
req.on('end', () => {
|
||||
const form = new URLSearchParams(text);
|
||||
const basic = Buffer.from((req.headers.authorization ?? '').replace(/^Basic /, ''), 'base64').toString();
|
||||
const [id, secret] = basic.split(':').map(decodeURIComponent);
|
||||
iss.tokenRequests.push({ form: Object.fromEntries(form), clientId: id });
|
||||
if (id !== iss.clientId || secret !== iss.clientSecret) return json(401, { error: 'invalid_client' });
|
||||
const grant = iss.codes.get(form.get('code'));
|
||||
if (!grant) return json(400, { error: 'invalid_grant' });
|
||||
if (!iss.acceptTwice) iss.codes.delete(form.get('code'));
|
||||
if (form.get('redirect_uri') !== grant.redirectUri) return json(400, { error: 'invalid_grant', error_description: 'redirect_uri' });
|
||||
if (iss.requirePkce && createHash('sha256').update(form.get('code_verifier') ?? '').digest('base64url') !== grant.challenge) return json(400, { error: 'invalid_grant', error_description: 'pkce' });
|
||||
return json(200, { access_token: randomBytes(16).toString('hex'), token_type: 'Bearer', expires_in: 300, id_token: grant.idToken });
|
||||
});
|
||||
return;
|
||||
}
|
||||
return json(404, { error: 'not found' });
|
||||
});
|
||||
await new Promise(r => server.listen(0, '127.0.0.1', r));
|
||||
const base = `https://127.0.0.1:${server.address().port}`;
|
||||
|
||||
const discovery = iss => ({
|
||||
issuer: iss.issuer, authorization_endpoint: `${iss.issuer}authorize`, token_endpoint: `${iss.issuer}token`, jwks_uri: `${iss.issuer}jwks`,
|
||||
end_session_endpoint: `${iss.issuer}end-session`, response_types_supported: ['code'], subject_types_supported: ['public'],
|
||||
id_token_signing_alg_values_supported: [...new Set(iss.keys.map(k => k.alg))], code_challenge_methods_supported: ['S256'],
|
||||
...iss.discovery,
|
||||
});
|
||||
|
||||
// A fetch over node:https that trusts only this certificate. oauth4webapi passes a Headers object
|
||||
// and a string or URLSearchParams body.
|
||||
const opFetch = (input, init = {}) => new Promise((resolve, reject) => {
|
||||
const url = new URL(typeof input === 'string' ? input : input.href ?? input.url);
|
||||
if (url.protocol !== 'https:' || url.hostname !== '127.0.0.1' || url.port !== String(server.address().port)) return reject(new TypeError(`fake OP fetch refuses ${url.origin}`));
|
||||
const headers = Object.fromEntries(new Headers(init.headers ?? {}));
|
||||
const body = init.body === undefined || init.body === null ? undefined : String(init.body);
|
||||
if (body !== undefined) headers['content-length'] = Buffer.byteLength(body);
|
||||
const req = request(url, { method: init.method ?? 'GET', headers, ca: cert, signal: init.signal }, res => {
|
||||
const chunks = [];
|
||||
res.on('data', c => chunks.push(c));
|
||||
res.on('end', () => {
|
||||
const h = new Headers();
|
||||
for (const [k, v] of Object.entries(res.headers)) h.set(k, Array.isArray(v) ? v.join(', ') : v);
|
||||
const response = new Response(res.statusCode === 204 ? null : Buffer.concat(chunks), { status: res.statusCode, headers: h });
|
||||
Object.defineProperty(response, 'url', { value: url.href });
|
||||
resolve(response);
|
||||
});
|
||||
res.on('error', reject);
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.end(body);
|
||||
});
|
||||
|
||||
return {
|
||||
base, fetch: opFetch, cert,
|
||||
issuer(name, opts = {}) { const iss = issuerState(name, base, opts); issuers.set(name, iss); return iss; },
|
||||
// A code for `iss` whose ID token is built from the start redirect's nonce, the claims and header
|
||||
// overrides. `challenge` defaults to the start redirect's; `idToken` replaces the token outright.
|
||||
grant(iss, authorizeUrl, { sub = `user-${iss.name}`, claims = {}, header = {}, key = iss.keys[0], idToken = null, challenge = null, redirectUri = null } = {}) {
|
||||
const q = new URL(authorizeUrl).searchParams;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const payload = { iss: iss.issuer, sub, aud: iss.clientId, iat: now, exp: now + 300, nonce: q.get('nonce'), ...claims };
|
||||
for (const [k, v] of Object.entries(payload)) if (v === undefined) delete payload[k];
|
||||
const token = idToken ?? jwt({ alg: key.alg, kid: key.kid, typ: 'JWT', ...header }, payload, { key: key.privateKey, hmac: iss.clientSecret });
|
||||
const code = randomBytes(12).toString('base64url');
|
||||
iss.codes.set(code, { idToken: token, challenge: challenge ?? q.get('code_challenge'), redirectUri: redirectUri ?? q.get('redirect_uri') });
|
||||
return { code, idToken: token, payload };
|
||||
},
|
||||
rotate(iss, next = rsaKey()) { iss.retired.push(...iss.keys); iss.keys = [next]; return next; },
|
||||
close: () => new Promise(r => { server.close(r); server.closeAllConnections(); }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ function fakeCopy(dir) {
|
||||
writeFileSync(join(copyRoot(dir), 'packages', 'webui', 'src', 'cli.mjs'), '');
|
||||
return copyRoot(dir);
|
||||
}
|
||||
const SEND_REFUSAL = /^webui-service: --send needs the preview mode \(--public-host, --auth-header and --allow-user\)$/m;
|
||||
const SEND_REFUSAL = /^webui-service: --send needs the preview mode \(--public-host, --auth-header and --allow-user\) or --sso$/m;
|
||||
const TEMPLATE = readFileSync(join(REPO, 'packages', 'webui', 'systemd', 'mosaic-webui.service.in'), 'utf8');
|
||||
|
||||
// The unit as the base script renders it (row 75): the template with its four
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
// Q175 phase 1 (#1690, design test 24, sage-1340 F1, sage-1347 F7): the page under --sso. A read
|
||||
// the Console answers 401 `sign-in required` signs the page out the way a proxy redirect does, and
|
||||
// the line links to /auth/login with the current path and query; any other 401 stays an error. Both
|
||||
// fetch paths in app.js and both in bus.js (fetchRead and fetchApi) do it. The Sign out form shows
|
||||
// only when /api/config says sso. The board-held.test.mjs stub, with two lists of paths: those in
|
||||
// window.__signIn answer 401 `sign-in required`, those in window.__other 401 `unauthenticated`. A
|
||||
// page URL holding `sso` gets `sso: true` added to /api/config. Poll timers fire after 150 ms.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { Store } from '../../bus/src/store.mjs';
|
||||
import { Broker } from '../../bus/src/broker.mjs';
|
||||
import { views } from '../../bus/src/views.mjs';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
import { browser } from './browser.mjs';
|
||||
import { fixture, close } from './fixture.mjs';
|
||||
|
||||
const businesses = { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'cto', delivery: 'pm' }, roles: { pm: { authority: { withinRole: ['message.send'], crossRole: [] } }, cto: { authority: { withinRole: ['message.send'], crossRole: [] } } } } };
|
||||
|
||||
const stub = `(() => {
|
||||
const real = window.fetch.bind(window);
|
||||
window.__reqs = []; window.__signIn = []; window.__other = [];
|
||||
const timeout = window.setTimeout.bind(window);
|
||||
window.setTimeout = (f, ms, ...rest) => timeout(f, ms === 10000 ? 150 : ms, ...rest);
|
||||
const answer = (status, body) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
|
||||
window.fetch = async (url, init = {}) => {
|
||||
const path = String(url).split('?')[0];
|
||||
window.__reqs.push(path);
|
||||
if (window.__signIn.includes(path)) return answer(401, { error: 'sign-in required' });
|
||||
if (window.__other.includes(path)) return answer(401, { error: 'unauthenticated' });
|
||||
if (path === '/api/config' && /sso/.test(location.search)) return answer(200, { ...(await (await real(url, init)).json()), sso: true });
|
||||
return real(url, init);
|
||||
};
|
||||
})();`;
|
||||
|
||||
async function setup() {
|
||||
const root = mkdtempSync(join(tmpdir(), 'webui-sso-browser-'));
|
||||
const store = new Store(root), b0 = new Broker({ store, businesses });
|
||||
b0.request(b0.bindLaunch({ business: 'demo', role: 'pm', run: 'pm-run', harness: 'pi', address: 'pm-run' }), { verb: 'role.claim' });
|
||||
const reader = b0.bindReader({ business: 'demo' });
|
||||
const bus = views({ call: async (verb, args = {}) => structuredClone(b0.request(reader, { verb, args })) });
|
||||
const f = await fixture(), b = await browser({ machine: true });
|
||||
const web = await startServer({ port: 0, board: f.boardURL, bus }), base = `http://127.0.0.1:${web.address().port}/`;
|
||||
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: stub });
|
||||
await b.viewport(1280);
|
||||
const wait = expr => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expr})return true;await new Promise(r=>setTimeout(r,50))}throw new Error('Condition timed out: '+${JSON.stringify(expr)})})()`);
|
||||
const text = sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.textContent ?? null`);
|
||||
const shown = sel => b.evaluate(`(()=>{const e=document.querySelector(${JSON.stringify(sel)});return !!e && !e.hidden && getComputedStyle(e).display !== 'none' && !!e.getClientRects().length})()`);
|
||||
const reqs = () => b.evaluate('window.__reqs.length');
|
||||
const settle = () => new Promise(r => setTimeout(r, 600));
|
||||
const done = async () => { await b.close(); await close(web); await f.close(); store.close(); rmSync(root, { recursive: true, force: true }); };
|
||||
return { b, base, wait, text, shown, reqs, settle, done };
|
||||
}
|
||||
|
||||
const LOADED = 'document.querySelector("table.sessions [data-open=\'proj/agent1\']")';
|
||||
const LINE = 'Signed out. Sign in again.';
|
||||
|
||||
test('a 401 sign-in required on each fetch path signs the page out with a sign-in link and stops the refresh; another 401 stays an error', { timeout: 120000 }, async () => {
|
||||
const { b, base, wait, text, shown, reqs, settle, done } = await setup();
|
||||
// The line, its link back to this page, the Sign out form gone, and no further fetch.
|
||||
const out = async ret => {
|
||||
await wait('!document.querySelector("#signed-out").hidden');
|
||||
assert.equal(await text('#signed-out'), LINE);
|
||||
assert.equal(await b.evaluate('document.querySelector("#signed-out a").getAttribute("href")'), `/auth/login?return=${encodeURIComponent(ret)}`);
|
||||
assert.equal(await shown('#sign-out'), false);
|
||||
assert.equal(await b.evaluate('document.querySelector("#refresh").disabled'), true);
|
||||
const n = await reqs();
|
||||
await b.evaluate('document.querySelector("#refresh").click(), true');
|
||||
await settle();
|
||||
assert.equal(await reqs(), n, 'the page fetched after signing out');
|
||||
};
|
||||
try {
|
||||
// app.js api(): the Board read, with sso on from /api/config.
|
||||
await b.navigate(`${base}?sso&x=1`);
|
||||
await wait(LOADED);
|
||||
await b.evaluate(`window.__signIn.push('/api/board'), document.querySelector('#refresh').click(), true`);
|
||||
await out('/?sso&x=1');
|
||||
|
||||
// app.js getJSON: the conversation catalogue behind History.
|
||||
await b.navigate(`${base}?sso&conv`);
|
||||
await wait(LOADED);
|
||||
await b.evaluate(`window.__signIn.push('/api/conversations'), document.querySelector('table.sessions [data-history="proj/agent1"]').click(), true`);
|
||||
await out('/?sso&conv');
|
||||
|
||||
// app.js api(): the board POST behind Seen.
|
||||
await b.navigate(`${base}?sso&seen`);
|
||||
await wait(LOADED);
|
||||
await b.evaluate(`document.querySelector('table.sessions [data-open="proj/agent1"]').click(), true`);
|
||||
await wait('document.querySelector("#inspection [data-seen]")');
|
||||
await b.evaluate(`window.__signIn.push('/api/seen'), document.querySelector('#inspection [data-seen]').click(), true`);
|
||||
await out('/?sso&seen');
|
||||
|
||||
// bus.js fetchRead and fetchApi, on a page whose /api/config has no sso: the link comes from
|
||||
// the 401 itself (F1).
|
||||
await b.navigate(`${base}?read#/inbox`);
|
||||
await wait(LOADED);
|
||||
await b.evaluate(`window.__signIn.push('/api/bus/inbox'), document.querySelector('#refresh').click(), true`);
|
||||
await out('/?read');
|
||||
await b.navigate(`${base}?module`);
|
||||
await wait(LOADED);
|
||||
await b.evaluate(`window.__signIn.push('/api/queue'), location.hash = '#/queue', true`);
|
||||
await out('/?module');
|
||||
|
||||
// Any other 401 is an error: no sign-out, and the polls go on.
|
||||
await b.navigate(`${base}?sso&other`);
|
||||
await wait(LOADED);
|
||||
await b.evaluate(`window.__other.push('/api/board', '/api/bus/inbox', '/api/queue'), document.querySelector('#refresh').click(), true`);
|
||||
await settle();
|
||||
assert.equal(await b.evaluate('document.querySelector("#signed-out").hidden'), true);
|
||||
assert.equal(await b.evaluate('document.body.classList.contains("signed-out")'), false);
|
||||
const n = await reqs();
|
||||
await settle();
|
||||
assert.ok(await reqs() > n, 'the refresh stopped on another 401');
|
||||
assert.equal(await shown('#sign-out'), true);
|
||||
} finally { await done(); }
|
||||
});
|
||||
|
||||
test('the Sign out form shows only when /api/config says sso, and posts to /auth/logout', { timeout: 120000 }, async () => {
|
||||
const { b, base, wait, shown, done } = await setup();
|
||||
try {
|
||||
await b.navigate(base);
|
||||
await wait(LOADED);
|
||||
await new Promise(r => setTimeout(r, 300));
|
||||
assert.equal(await shown('#sign-out'), false);
|
||||
await b.navigate(`${base}?sso`);
|
||||
await wait(`${LOADED} && !document.querySelector("#sign-out").hidden`);
|
||||
assert.equal(await shown('#sign-out'), true);
|
||||
assert.deepEqual(await b.evaluate('(() => { const f = document.querySelector("#sign-out"); return [f.method, f.getAttribute("action"), f.querySelector("button").textContent, f.querySelector("button").type]; })()'), ['post', '/auth/logout', 'Sign out', 'submit']);
|
||||
} finally { await done(); }
|
||||
});
|
||||
@@ -0,0 +1,589 @@
|
||||
// Q175 phase 1 (#1690, design agents/sage/work/q175-sso/DESIGN.md section 9): the Console as an
|
||||
// OIDC relying party (--sso). 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 or slot file. The public name
|
||||
// is console.example.test, sent only as a Host header to the test's own loopback listener. Each
|
||||
// refusal checks the status, that no session cookie was set and that the send stub never ran.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { request } from 'node:http';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { chmodSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
import { createSso, readSlot, SESSION_COOKIE, LOGIN_COOKIE } from '../src/sso.mjs';
|
||||
import { close } from './fixture.mjs';
|
||||
import { b64url, ecKey, fakeOidc, jwt, psKey, rsaKey } from './helpers/fake-oidc.mjs';
|
||||
|
||||
const HOST = 'console.example.test', ORIGIN = `https://${HOST}`;
|
||||
const CLI = fileURLToPath(new URL('../src/cli.mjs', import.meta.url));
|
||||
const MSG = JSON.stringify({ to: 'coder', body: 'Please look at row 192.' });
|
||||
// Test 19: every log line any test here captured, and every token, code, state, nonce, verifier,
|
||||
// client secret and session id any test produced. The last test checks the one against the other.
|
||||
const ALL_LINES = [], PRODUCED = new Set();
|
||||
|
||||
// node:http, not fetch: fetch can't set Host, Origin or Sec-Fetch-Site.
|
||||
function call(port, { method = 'GET', path = '/', headers = {}, body, host = HOST } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const length = body === undefined ? {} : { 'content-length': Buffer.byteLength(body) };
|
||||
const req = request({ host: '127.0.0.1', port, method, path, headers: { host, ...length, ...headers } }, res => {
|
||||
let text = '';
|
||||
res.on('data', c => (text += c));
|
||||
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: text && (res.headers['content-type'] ?? '').startsWith('application/json') ? JSON.parse(text) : text }));
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.end(body);
|
||||
});
|
||||
}
|
||||
const 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 ? { cookie } : {}), origin: ORIGIN, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' });
|
||||
|
||||
function providerFor(iss, extra = {}) {
|
||||
return { id: iss.name, issuer: iss.issuer, clientId: iss.clientId, clientSecret: { slot: iss.name }, algs: [...new Set(iss.keys.map(k => k.alg))], subjects: [{ sub: `user-${iss.name}`, name: `person-${iss.name}` }], ...extra };
|
||||
}
|
||||
|
||||
// A fake OP with `issuers`, createSso over them, and the human listener with a counting send.
|
||||
async function setup(t, { issuers = [{ name: 'a' }], provider = {}, before = null, retryFirst = 5000, server = {} } = {}) {
|
||||
const op = await fakeOidc();
|
||||
t.after(() => op.close());
|
||||
const iss = issuers.map(o => op.issuer(o.name, o));
|
||||
for (const i of iss) PRODUCED.add(i.clientSecret);
|
||||
before?.(op, iss);
|
||||
const config = { publicOrigin: ORIGIN, providers: iss.map(i => providerFor(i, provider[i.name] ?? {})) };
|
||||
const lines = [];
|
||||
const log = line => { lines.push(line); ALL_LINES.push(line); };
|
||||
const sso = await createSso(config, { secrets: new Map(iss.map(i => [i.name, i.clientSecret])), fetch: op.fetch, log, retryFirst });
|
||||
t.after(() => sso.close());
|
||||
const sends = [];
|
||||
const bus = Object.fromEntries(['inbox', 'tasks', 'agents', 'trail', 'decisions'].map(v => [v, async () => [{ verb: v }]]));
|
||||
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', bus, sso, send: async args => { sends.push(args); return { id: 'msg-0001' }; }, log, ...server });
|
||||
t.after(() => close(web));
|
||||
return { op, iss, sso, port: web.address().port, lines, sends, config, bus };
|
||||
}
|
||||
|
||||
// GET /auth/login; returns the authorize URL and the login cookie (name=value).
|
||||
async function begin(env, { provider = null, ret = null, query = null, host = HOST } = {}) {
|
||||
const q = query ?? new URLSearchParams({ ...(provider ? { provider } : {}), ...(ret !== null ? { return: ret } : {}) }).toString();
|
||||
const r = await call(env.port, { path: `/auth/login${q ? `?${q}` : ''}`, host });
|
||||
assert.equal(r.status, 303, `login start: ${r.status} ${r.body}`);
|
||||
const cookie = setCookies(r)[0].split(';')[0];
|
||||
const location = r.headers.location;
|
||||
const params = new URL(location).searchParams;
|
||||
PRODUCED.add(cookie.split('=')[1]);
|
||||
for (const k of ['state', 'nonce', 'code_challenge']) PRODUCED.add(params.get(k));
|
||||
for (const record of env.sso.logins.values()) PRODUCED.add(record.verifier);
|
||||
return { r, cookie, location, params };
|
||||
}
|
||||
function callback(env, begun, { code, state = begun.params.get('state'), extra = {}, cookie = begun.cookie } = {}) {
|
||||
const q = new URLSearchParams({ ...(code !== undefined ? { code } : {}), state, ...extra });
|
||||
return call(env.port, { path: `/auth/callback?${q}`, headers: cookie ? { cookie } : {} });
|
||||
}
|
||||
function grant(env, begun, iss, opts = {}) {
|
||||
const g = env.op.grant(iss, begun.location, opts);
|
||||
PRODUCED.add(g.code);
|
||||
PRODUCED.add(g.idToken);
|
||||
return g;
|
||||
}
|
||||
// A whole sign-in: start, a code at the OP, the callback. Returns the callback answer and the
|
||||
// session cookie (name=value) when one was set.
|
||||
async function signIn(env, { iss = env.iss[0], ret = null, opts = {} } = {}) {
|
||||
const begun = await begin(env, { provider: env.iss.length > 1 ? iss.name : null, ret });
|
||||
const g = grant(env, begun, iss, opts);
|
||||
const r = await callback(env, begun, { code: g.code });
|
||||
const session = sessionOf(r);
|
||||
if (session) PRODUCED.add(session.split('=')[1]);
|
||||
return { begun, g, r, session };
|
||||
}
|
||||
function refused(env, r, code) {
|
||||
assert.equal(r.status, 403, `expected 403, got ${r.status}`);
|
||||
assert.equal(sessionOf(r), null, 'a session cookie was set');
|
||||
assert.equal(env.sends.length, 0, 'the send stub ran');
|
||||
assert.ok(setCookies(r).includes(`${LOGIN_COOKIE}=; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=0`), 'the login cookie was not expired');
|
||||
if (code) assert.match(env.lines.at(-1), new RegExp(` sso refused code=${code} provider=`), env.lines.at(-1));
|
||||
}
|
||||
|
||||
test('positive: an RS256 provider signs in and sends one message, and the session reads', async t => {
|
||||
const env = await setup(t);
|
||||
assert.notEqual(env.port, 7332);
|
||||
const { r, session } = await signIn(env, { ret: '/board?x=1' });
|
||||
assert.equal(r.status, 303);
|
||||
assert.equal(r.headers.location, `${ORIGIN}/board?x=1`);
|
||||
assert.ok(session);
|
||||
const send = await call(env.port, { method: 'POST', path: '/api/bus/message', headers: writeHeaders(session), body: MSG });
|
||||
assert.equal(send.status, 200, JSON.stringify(send.body));
|
||||
assert.deepEqual(env.sends, [{ to: 'coder', body: 'Please look at row 192.' }]);
|
||||
assert.match(env.lines.at(-1), / send user="person-a" to="coder" id=msg-0001$/);
|
||||
const read = await call(env.port, { path: '/api/bus/inbox', headers: { cookie: session } });
|
||||
assert.equal(read.status, 200);
|
||||
const config = await call(env.port, { path: '/api/config', headers: { cookie: session } });
|
||||
assert.equal(config.body.sso, true);
|
||||
assert.equal(config.body.send, true);
|
||||
});
|
||||
|
||||
test('positive: an ES256 provider signs in and sends', async t => {
|
||||
const env = await setup(t, { issuers: [{ name: 'e', keys: [ecKey()] }] });
|
||||
assert.deepEqual(env.config.providers[0].algs, ['ES256']);
|
||||
const { session } = await signIn(env);
|
||||
assert.ok(session);
|
||||
assert.equal((await call(env.port, { method: 'POST', path: '/api/bus/message', headers: writeHeaders(session), body: MSG })).status, 200);
|
||||
assert.equal(env.sends.length, 1);
|
||||
});
|
||||
|
||||
test('positive: two providers each sign in their own subjects, and one provider\'s subject is unknown to the other', async t => {
|
||||
const env = await setup(t, { issuers: [{ name: 'a' }, { name: 'b', keys: [ecKey()] }] });
|
||||
const list = await call(env.port, { path: '/auth/login?return=%2Fboard' });
|
||||
assert.equal(list.status, 200);
|
||||
assert.match(list.body, /provider=a&return=%2Fboard/);
|
||||
assert.match(list.body, /provider=b&return=%2Fboard/);
|
||||
const [a, b] = env.iss;
|
||||
assert.ok((await signIn(env, { iss: a })).session);
|
||||
assert.ok((await signIn(env, { iss: b })).session);
|
||||
const names = [...env.sso.sessions.values()].map(s => s.name);
|
||||
assert.deepEqual(names, ['person-a', 'person-b']);
|
||||
const cross = await signIn(env, { iss: b, opts: { sub: 'user-a' } });
|
||||
refused({ ...env, sends: [] }, cross.r, 'subject-unknown');
|
||||
});
|
||||
|
||||
test('1: a forged x-authentik header without a cookie is 401 on the API and a sign-in redirect on a page', async t => {
|
||||
const env = await setup(t);
|
||||
const forged = { 'x-authentik-username': 'jason.woltje', origin: ORIGIN, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' };
|
||||
for (const [method, path, body] of [['POST', '/api/bus/message', MSG], ['POST', '/api/settings', '{}'], ['GET', '/api/config', undefined]]) {
|
||||
const r = await call(env.port, { method, path, headers: forged, body });
|
||||
assert.equal(r.status, 401, `${method} ${path}`);
|
||||
assert.deepEqual(r.body, { error: 'sign-in required' });
|
||||
}
|
||||
const page = await call(env.port, { path: '/', headers: forged });
|
||||
assert.equal(page.status, 303);
|
||||
assert.equal(page.headers.location, '/auth/login?return=%2F');
|
||||
const deep = await call(env.port, { path: '/board?x=1' });
|
||||
assert.equal(deep.headers.location, '/auth/login?return=%2Fboard%3Fx%3D1');
|
||||
assert.equal(env.sends.length, 0);
|
||||
});
|
||||
|
||||
test('2: an HS256 ID token keyed with the client secret and a real RSA kid is refused', async t => {
|
||||
const env = await setup(t);
|
||||
const [iss] = env.iss;
|
||||
const { r } = await signIn(env, { opts: { header: { alg: 'HS256', kid: iss.keys[0].kid } } });
|
||||
refused(env, r, 'id-token-invalid');
|
||||
});
|
||||
|
||||
test('3: a wrong aud, and an aud list holding another client with no azp, are refused', async t => {
|
||||
const env = await setup(t);
|
||||
refused(env, (await signIn(env, { opts: { claims: { aud: 'another-client' } } })).r, 'id-token-invalid');
|
||||
refused(env, (await signIn(env, { opts: { claims: { aud: [env.iss[0].clientId, 'another-client'] } } })).r, 'id-token-invalid');
|
||||
});
|
||||
|
||||
test('4: a token that expired 10 minutes ago is refused', async t => {
|
||||
const env = await setup(t);
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
refused(env, (await signIn(env, { opts: { claims: { iat: now - 900, exp: now - 600 } } })).r, 'id-token-invalid');
|
||||
});
|
||||
|
||||
test('5: another issuer, and the issuer without its trailing slash, are refused', async t => {
|
||||
const env = await setup(t);
|
||||
const [iss] = env.iss;
|
||||
refused(env, (await signIn(env, { opts: { claims: { iss: `${env.op.base}/other/` } } })).r, 'id-token-invalid');
|
||||
refused(env, (await signIn(env, { opts: { claims: { iss: iss.issuer.slice(0, -1) } } })).r, 'id-token-invalid');
|
||||
});
|
||||
|
||||
test('6: a nonce mismatch and a missing nonce are refused', async t => {
|
||||
const env = await setup(t);
|
||||
refused(env, (await signIn(env, { opts: { claims: { nonce: 'not-the-nonce' } } })).r, 'id-token-invalid');
|
||||
refused(env, (await signIn(env, { opts: { claims: { nonce: undefined } } })).r, 'id-token-invalid');
|
||||
});
|
||||
|
||||
test('7: state mismatch, no login cookie, a replay, an expired record and two login cookies are refused', async t => {
|
||||
const env = await setup(t);
|
||||
const [iss] = env.iss;
|
||||
let begun = await begin(env);
|
||||
refused(env, await callback(env, begun, { code: grant(env, begun, iss).code, state: 'wrong-state' }), 'state-mismatch');
|
||||
begun = await begin(env);
|
||||
refused(env, await callback(env, begun, { code: grant(env, begun, iss).code, cookie: null }), 'login-record-missing');
|
||||
// Replay: the OP takes the code twice, so only the take-and-delete rule refuses the second callback.
|
||||
iss.acceptTwice = true;
|
||||
begun = await begin(env);
|
||||
const g = grant(env, begun, iss);
|
||||
const first = await callback(env, begun, { code: g.code });
|
||||
assert.equal(first.status, 303);
|
||||
PRODUCED.add(sessionOf(first).split('=')[1]);
|
||||
refused(env, await callback(env, begun, { code: g.code }), 'login-record-missing');
|
||||
iss.acceptTwice = false;
|
||||
begun = await begin(env);
|
||||
for (const record of env.sso.logins.values()) record.expires = Date.now() - 1;
|
||||
refused(env, await callback(env, begun, { code: grant(env, begun, iss).code }), 'login-record-missing');
|
||||
begun = await begin(env);
|
||||
const other = await begin(env);
|
||||
refused(env, await callback(env, begun, { code: grant(env, begun, iss).code, cookie: `${begun.cookie}; ${other.cookie}` }), 'login-record-missing');
|
||||
});
|
||||
|
||||
test('8: the token request carries the PKCE verifier, and the OP refuses a wrong one', async t => {
|
||||
const env = await setup(t);
|
||||
const [iss] = env.iss;
|
||||
const { r, begun } = await signIn(env);
|
||||
assert.equal(r.status, 303);
|
||||
const { form } = iss.tokenRequests.at(-1);
|
||||
assert.equal(begun.params.get('code_challenge_method'), 'S256');
|
||||
const { createHash } = await import('node:crypto');
|
||||
assert.equal(createHash('sha256').update(form.code_verifier).digest('base64url'), begun.params.get('code_challenge'));
|
||||
refused(env, (await signIn(env, { opts: { challenge: 'not-the-challenge' } })).r, 'grant-failed');
|
||||
});
|
||||
|
||||
test('9: alg none, a changed payload under a real kid, and an unknown kid even after the refetch are refused', async t => {
|
||||
const env = await setup(t);
|
||||
const [iss] = env.iss;
|
||||
// (a) alg none: the alg rule, before any signature check.
|
||||
refused(env, (await signIn(env, { opts: { header: { alg: 'none' } } })).r, 'id-token-invalid');
|
||||
// (b) a real kid's signature over another payload: only the signature check catches it.
|
||||
let begun = await begin(env);
|
||||
const real = env.op.grant(iss, begun.location);
|
||||
PRODUCED.add(real.idToken);
|
||||
const [h, , s] = real.idToken.split('.');
|
||||
const forged = `${h}.${b64url({ ...real.payload, name: 'changed' })}.${s}`;
|
||||
refused(env, await callback(env, begun, { code: grant(env, begun, iss, { idToken: forged }).code }), 'signature-invalid');
|
||||
// (c) a key the JWKS never held, again after the cache is old enough to refetch.
|
||||
t.mock.timers.enable({ apis: ['Date'], now: Date.now() });
|
||||
const stranger = rsaKey('not-in-jwks');
|
||||
refused(env, (await signIn(env, { opts: { key: stranger } })).r, 'signature-invalid');
|
||||
const fetches = iss.jwksFetches;
|
||||
t.mock.timers.tick(61000);
|
||||
refused(env, (await signIn(env, { opts: { key: stranger } })).r, 'signature-invalid');
|
||||
assert.equal(iss.jwksFetches, fetches + 1, 'no refetch');
|
||||
});
|
||||
|
||||
test('10: a valid token for a subject not configured is 403 with one subject-unknown line', async t => {
|
||||
const env = await setup(t);
|
||||
const before = env.lines.length;
|
||||
const { r } = await signIn(env, { opts: { sub: 'stranger' } });
|
||||
refused(env, r, 'subject-unknown');
|
||||
const added = env.lines.slice(before);
|
||||
assert.equal(added.length, 1);
|
||||
assert.match(added[0], new RegExp(`^\\S+ sso refused code=subject-unknown provider=a iss=${JSON.stringify(env.iss[0].issuer).replace(/[.*+?^${}()|[\]\\/]/g, '\\$&')} sub="stranger"$`));
|
||||
});
|
||||
|
||||
test('11: start refusals: HS or none algs, reserved names, HS-only discovery, an empty JWKS, no S256, an issuer mismatch, a short RSA key', async t => {
|
||||
const op = await fakeOidc();
|
||||
t.after(() => op.close());
|
||||
const start = (iss, extra = {}) => createSso({ publicOrigin: ORIGIN, providers: [providerFor(iss, extra)] }, { secrets: new Map([[iss.name, iss.clientSecret]]), fetch: op.fetch, log: l => ALL_LINES.push(l) });
|
||||
const a = op.issuer('a');
|
||||
PRODUCED.add(a.clientSecret);
|
||||
for (const [extra, why] of [
|
||||
[{ algs: ['HS256'] }, /algs\[0\] must be one of .*HS\* and none are refused/],
|
||||
[{ algs: ['none'] }, /algs\[0\] must be one of/],
|
||||
[{ subjects: [{ sub: 'u', name: 'tester' }] }, /subjects\[0\]\.name must not be tester or human/],
|
||||
[{ subjects: [{ sub: 'u', name: 'Human' }] }, /subjects\[0\]\.name must not be tester or human/],
|
||||
]) await assert.rejects(start(a, extra), why);
|
||||
const refusedAtDiscovery = /console\.sso provider \w+ refused at discovery/;
|
||||
// The mosaic-preview shape: HS256 only.
|
||||
const hs = op.issuer('hs', { discovery: { id_token_signing_alg_values_supported: ['HS256'] } });
|
||||
await assert.rejects(start(hs, { algs: ['RS256'] }), refusedAtDiscovery);
|
||||
const empty = op.issuer('empty');
|
||||
empty.jwksOverride = { keys: [] };
|
||||
await assert.rejects(start(empty), refusedAtDiscovery);
|
||||
const plain = op.issuer('plain', { discovery: { code_challenge_methods_supported: ['plain'] } });
|
||||
await assert.rejects(start(plain), refusedAtDiscovery);
|
||||
const moved = op.issuer('moved');
|
||||
moved.discovery = { issuer: `${op.base}/elsewhere/` };
|
||||
await assert.rejects(start(moved), refusedAtDiscovery);
|
||||
const small = op.issuer('small', { keys: [rsaKey('small', 1024)] });
|
||||
await assert.rejects(start(small), refusedAtDiscovery);
|
||||
// The control: the same OP and provider start.
|
||||
const sso = await start(a);
|
||||
assert.deepEqual(sso.health(), { a: 'ready' });
|
||||
sso.close();
|
||||
await assert.rejects(startServer({ port: 0, sso, preview: { host: HOST, header: 'remote-user', user: 'jason' } }), /--sso cannot be combined/);
|
||||
});
|
||||
|
||||
test('11: slot files: a symlink, mode 0644, mode 0640 and a file under the repository are refused, and no message holds the value', t => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'webui-sso-slot-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const dir = join(root, 'config'), slots = join(dir, 'secrets', 'mosaic-stack');
|
||||
mkdirSync(slots, { recursive: true });
|
||||
const provider = slot => ({ id: 'a', clientSecret: { slot } });
|
||||
const value = 'slot-value-do-not-print';
|
||||
const opts = { repo: join(root, 'repo'), copies: join(root, 'copies') };
|
||||
writeFileSync(join(slots, 'good'), `${value}\n`, { mode: 0o600 });
|
||||
assert.equal(readSlot(dir, provider('good'), opts), value);
|
||||
writeFileSync(join(root, 'target'), value, { mode: 0o600 });
|
||||
symlinkSync(join(root, 'target'), join(slots, 'link'));
|
||||
for (const [slot, mode] of [['wide', 0o644], ['group', 0o640]]) { writeFileSync(join(slots, slot), value); chmodSync(join(slots, slot), mode); }
|
||||
writeFileSync(join(slots, 'two'), `${value}\nmore\n`, { mode: 0o600 });
|
||||
writeFileSync(join(slots, 'empty'), '\n', { mode: 0o600 });
|
||||
const cases = [
|
||||
['missing', /slot is missing$/, opts],
|
||||
['link', /must not be a symbolic link$/, opts],
|
||||
['wide', /must have no group or other permission bits$/, opts],
|
||||
['group', /must have no group or other permission bits$/, opts],
|
||||
['two', /must hold one non-empty line$/, opts],
|
||||
['empty', /must hold one non-empty line$/, opts],
|
||||
// The config dir itself under the repository, and under a --checkout.
|
||||
['good', /must not be inside the repository or a stack copy$/, { ...opts, repo: root }],
|
||||
['good', /must not be inside the repository or a stack copy$/, { ...opts, checkout: dir }],
|
||||
['good', /must not be inside the repository or a stack copy$/, { ...opts, copies: dir }],
|
||||
];
|
||||
for (const [slot, why, o] of cases) {
|
||||
let err = null;
|
||||
try { readSlot(dir, provider(slot), o); } catch (e) { err = e; }
|
||||
assert.ok(err, `${slot} was read`);
|
||||
assert.match(err.message, /^console\.sso provider a client secret slot /);
|
||||
assert.match(err.message, why, slot);
|
||||
assert.equal(err.message.includes(value), false);
|
||||
assert.equal(err.message.includes(root), false);
|
||||
}
|
||||
});
|
||||
|
||||
test('11: the CLI refuses --sso with the old flags and without console.sso, and starts with a good one', async t => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'wsso-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const env = { ...process.env, MOSAIC_CONFIG: join(root, 'cfg', 'config.json'), XDG_DATA_HOME: join(root, 'xdg') };
|
||||
for (const flags of [['--auth-header', 'remote-user'], ['--public-host', HOST], ['--allow-user', 'jason'], ['--proof-header', 'x-proof']]) {
|
||||
const r = spawnSync(process.execPath, [CLI, 'serve', '--port', '0', '--sso', ...flags], { encoding: 'utf8', timeout: 10000, env });
|
||||
assert.equal(r.status, 2, flags.join(' '));
|
||||
assert.match(r.stderr, /^refused: --sso cannot be combined with --public-host, --auth-header, --allow-user or --proof-\*$/m);
|
||||
}
|
||||
const missing = spawnSync(process.execPath, [CLI, 'serve', '--port', '0', '--sso'], { encoding: 'utf8', timeout: 10000, env });
|
||||
assert.equal(missing.status, 2);
|
||||
assert.match(missing.stderr, /^refused: --sso needs the system config \(/);
|
||||
const op = await fakeOidc();
|
||||
t.after(() => op.close());
|
||||
const a = op.issuer('a');
|
||||
PRODUCED.add(a.clientSecret);
|
||||
mkdirSync(join(root, 'cfg', 'secrets', 'mosaic-stack'), { recursive: true });
|
||||
writeFileSync(join(root, 'ca.pem'), op.cert);
|
||||
const base = { configVersion: 1, environment: 'development', dataRoot: join(root, 'data'), execution: { backend: 'docker', provider: 'zai', model: 'm' } };
|
||||
writeFileSync(env.MOSAIC_CONFIG, JSON.stringify(base));
|
||||
const noKey = spawnSync(process.execPath, [CLI, 'serve', '--port', '0', '--sso'], { encoding: 'utf8', timeout: 10000, env });
|
||||
assert.equal(noKey.status, 2);
|
||||
assert.match(noKey.stderr, /^refused: --sso needs console\.sso in the system config$/m);
|
||||
writeFileSync(env.MOSAIC_CONFIG, JSON.stringify({ ...base, console: { sso: { publicOrigin: ORIGIN, providers: [providerFor(a)] } } }));
|
||||
writeFileSync(join(root, 'cfg', 'secrets', 'mosaic-stack', 'a'), `${a.clientSecret}\n`, { mode: 0o644 });
|
||||
const wide = spawnSync(process.execPath, [CLI, 'serve', '--port', '0', '--sso'], { encoding: 'utf8', timeout: 10000, env });
|
||||
assert.equal(wide.status, 2);
|
||||
assert.match(wide.stderr, /^refused: console\.sso provider a client secret slot must have no group or other permission bits$/m);
|
||||
chmodSync(join(root, 'cfg', 'secrets', 'mosaic-stack', 'a'), 0o600);
|
||||
// The child trusts the fake OP's certificate through NODE_EXTRA_CA_CERTS; the code under test has no flag for it.
|
||||
const child = spawn(process.execPath, [CLI, 'serve', '--port', '0', '--sso'], { env: { ...env, NODE_EXTRA_CA_CERTS: join(root, 'ca.pem') } });
|
||||
t.after(() => child.kill('SIGKILL'));
|
||||
let out = '', err = '';
|
||||
child.stderr.on('data', c => (err += c));
|
||||
await new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error(`no start line: ${out} ${err}`)), 15000);
|
||||
child.stdout.on('data', c => { out += c; if (out.includes('Ctrl-C')) { clearTimeout(timer); resolve(); } });
|
||||
child.on('exit', () => { clearTimeout(timer); reject(new Error(`exited: ${err}`)); });
|
||||
});
|
||||
assert.match(out, new RegExp(`^Sign-in: ${ORIGIN}/ \\(--sso\\), read-only$`, 'm'));
|
||||
assert.match(out, new RegExp(`^Sign-in provider: a issuer ${a.issuer.replace(/[.]/g, '\\.')} subjects 1 \\(ready\\)$`, 'm'));
|
||||
assert.equal(out.includes(a.clientSecret) || err.includes(a.clientSecret), false);
|
||||
assert.equal(out.includes(a.clientId), false);
|
||||
const port = Number(out.match(/^Mosaic Console: http:\/\/127\.0\.0\.1:(\d+)\//m)[1]);
|
||||
const health = await call(port, { path: '/healthz', host: `127.0.0.1:${port}` });
|
||||
assert.deepEqual(health.body, { ok: true, sso: { a: 'ready' } });
|
||||
child.kill('SIGTERM');
|
||||
});
|
||||
|
||||
test('12: under --sso the tester listener refuses each kind of sign-in, has no /auth, and startServer refuses tester with sso', async t => {
|
||||
const env = await setup(t);
|
||||
const tester = await startServer({ port: 0, board: 'http://127.0.0.1:9', bus: env.bus, tester: true, testerNoSignIn: true, log: () => {} });
|
||||
t.after(() => close(tester));
|
||||
const port = tester.address().port, host = `127.0.0.1:${port}`;
|
||||
const { session, g, begun } = await signIn(env);
|
||||
assert.ok(session);
|
||||
const plain = await call(port, { path: '/api/config', host });
|
||||
assert.equal(plain.status, 200, 'control: a request with no sign-in is served');
|
||||
for (const headers of [
|
||||
{ cookie: session },
|
||||
{ cookie: `theme=dark; ${begun.cookie}` },
|
||||
{ cookie: `${SESSION_COOKIE.toLowerCase()}=x` },
|
||||
{ authorization: `Bearer ${g.idToken}` },
|
||||
{ 'x-authentik-jwt': g.idToken },
|
||||
{ 'X-Authentik-Username': 'jason.woltje' },
|
||||
]) {
|
||||
const r = await call(port, { path: '/api/config', host, headers });
|
||||
assert.equal(r.status, 403, JSON.stringify(Object.keys(headers)));
|
||||
assert.deepEqual(r.body, { error: 'tester listener takes no sign-in' });
|
||||
}
|
||||
assert.equal((await call(port, { path: '/auth/login', host })).status, 404);
|
||||
await assert.rejects(startServer({ port: 0, tester: true, sso: env.sso }), /tester listener cannot use sso/);
|
||||
});
|
||||
|
||||
test('13: idle and absolute expiry, logout then reuse, a cross-origin logout, and background reads that don\'t keep a session', async t => {
|
||||
const env = await setup(t);
|
||||
const read = cookie => call(env.port, { path: '/api/bus/inbox', headers: { cookie } });
|
||||
const record = () => [...env.sso.sessions.values()].at(-1);
|
||||
// Background reads don't move `seen`; a page load does.
|
||||
let { session } = await signIn(env);
|
||||
const old = Date.now() - 59 * 60 * 1000;
|
||||
record().seen = old;
|
||||
assert.equal((await read(session)).status, 200);
|
||||
assert.equal(record().seen, old, 'a background read refreshed the session');
|
||||
assert.equal((await call(env.port, { path: '/', headers: { cookie: session } })).status, 200);
|
||||
assert.ok(record().seen > old, 'a page load did not refresh the session');
|
||||
// Idle: 59 minutes of background reads, then one more past the hour.
|
||||
record().seen = Date.now() - 61 * 60 * 1000;
|
||||
assert.equal((await read(session)).status, 401);
|
||||
// Absolute.
|
||||
({ session } = await signIn(env));
|
||||
record().created = Date.now() - 8 * 3600 * 1000;
|
||||
assert.equal((await call(env.port, { path: '/', headers: { cookie: session } })).headers.location, '/auth/login?return=%2F');
|
||||
// Logout needs same-origin; refused, the session stays.
|
||||
({ session } = await signIn(env));
|
||||
const cross = await call(env.port, { method: 'POST', path: '/auth/logout', headers: { cookie: session, origin: ORIGIN, 'sec-fetch-site': 'cross-site' } });
|
||||
assert.equal(cross.status, 403);
|
||||
assert.equal((await read(session)).status, 200);
|
||||
const noOrigin = await call(env.port, { method: 'POST', path: '/auth/logout', headers: { cookie: session, 'sec-fetch-site': 'same-origin' } });
|
||||
assert.equal(noOrigin.status, 403);
|
||||
assert.equal((await read(session)).status, 200);
|
||||
const out = await call(env.port, { method: 'POST', path: '/auth/logout', headers: { cookie: session, origin: ORIGIN, 'sec-fetch-site': 'same-origin' } });
|
||||
assert.equal(out.status, 303);
|
||||
const end = new URL(out.headers.location);
|
||||
assert.equal(`${end.origin}${end.pathname}`, `${env.iss[0].issuer}end-session`);
|
||||
assert.equal(end.searchParams.get('post_logout_redirect_uri'), `${ORIGIN}/auth/signed-out`);
|
||||
assert.equal(end.searchParams.get('client_id'), env.iss[0].clientId);
|
||||
assert.ok(end.searchParams.get('id_token_hint'));
|
||||
assert.deepEqual(setCookies(out), [`${SESSION_COOKIE}=; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=0`]);
|
||||
assert.equal((await read(session)).status, 401);
|
||||
assert.equal((await call(env.port, { method: 'GET', path: '/auth/logout', headers: { cookie: session } })).status, 405);
|
||||
});
|
||||
|
||||
test('13: the CSP lets the Sign out form reach the provider\'s end-session origin, and only under --sso', async t => {
|
||||
const env = await setup(t);
|
||||
const r = await call(env.port, { path: '/healthz', host: `127.0.0.1:${env.port}` });
|
||||
assert.match(r.headers['content-security-policy'], new RegExp(`; form-action 'self' ${env.op.base.replace(/[.]/g, '\\.')}$`));
|
||||
const plain = await startServer({ port: 0, board: 'http://127.0.0.1:9' });
|
||||
t.after(() => close(plain));
|
||||
const p = await call(plain.address().port, { path: '/healthz', host: `127.0.0.1:${plain.address().port}` });
|
||||
assert.match(p.headers['content-security-policy'], /; form-action 'self'$/);
|
||||
});
|
||||
|
||||
test('14: open redirects land on /, and a path on the Console lands there', async t => {
|
||||
const env = await setup(t);
|
||||
for (const [query, where] of [
|
||||
['return=%2F%2Fevil.example', `${ORIGIN}/`],
|
||||
['return=https%3A%2F%2Fevil.example', `${ORIGIN}/`],
|
||||
['return=%2F%5Cevil.example', `${ORIGIN}/`],
|
||||
['return=/%09/evil.example', `${ORIGIN}/`],
|
||||
// Dot segments that leave a pathname of //evil.example stay on the Console (webby-0019 B1).
|
||||
['return=%2F.%2F%2Fevil.example', `${ORIGIN}//evil.example`],
|
||||
['return=%2Fx%2F..%2F%2Fevil.example', `${ORIGIN}//evil.example`],
|
||||
[`return=${encodeURIComponent(`${ORIGIN}//evil.example`)}`, `${ORIGIN}//evil.example`],
|
||||
['return=%2Fboard%2F.%2Fx', `${ORIGIN}/board/x`],
|
||||
['return=%2Fboard%3Fx%3D1', `${ORIGIN}/board?x=1`],
|
||||
['', `${ORIGIN}/`],
|
||||
]) {
|
||||
const begun = await begin(env, { query });
|
||||
const r = await callback(env, begun, { code: grant(env, begun, env.iss[0]).code });
|
||||
assert.equal(r.status, 303);
|
||||
assert.equal(r.headers.location, where, query);
|
||||
assert.equal(new URL(r.headers.location).origin, ORIGIN, query);
|
||||
PRODUCED.add(sessionOf(r).split('=')[1]);
|
||||
}
|
||||
});
|
||||
|
||||
test('15: a callback for A carrying B\'s code and iss, and a provider= query, is refused', async t => {
|
||||
const env = await setup(t, { issuers: [{ name: 'a' }, { name: 'b' }] });
|
||||
const [, b] = env.iss;
|
||||
for (const extra of [{ iss: b.issuer, provider: 'b' }, { provider: 'b' }]) {
|
||||
const begun = await begin(env, { provider: 'a' });
|
||||
// B's code against A's PKCE challenge and nonce, for B's own subject: only the record rule
|
||||
// (this callback belongs to A) stands in the way.
|
||||
const g = grant(env, begun, b, { sub: 'user-b' });
|
||||
const r = await callback(env, begun, { code: g.code, extra });
|
||||
refused(env, r);
|
||||
}
|
||||
assert.equal(b.tokenRequests.length, 0, 'B\'s token endpoint was called');
|
||||
});
|
||||
|
||||
test('16: a provider with RS256 and ES256 takes either, and refuses PS256 with a usable PS256 key in the JWKS', async t => {
|
||||
const rsa = rsaKey(), ec = ecKey(), ps = psKey();
|
||||
const env = await setup(t, { issuers: [{ name: 'a', keys: [rsa, ec, ps] }], provider: { a: { algs: ['RS256', 'ES256'] } } });
|
||||
assert.ok((await signIn(env, { opts: { key: rsa } })).session);
|
||||
assert.ok((await signIn(env, { opts: { key: ec } })).session);
|
||||
refused(env, (await signIn(env, { opts: { key: ps } })).r, 'id-token-invalid');
|
||||
});
|
||||
|
||||
test('17: 33 started logins leave 32 records, and the first one\'s callback finds none', async t => {
|
||||
const env = await setup(t);
|
||||
const first = await begin(env);
|
||||
for (let i = 0; i < 32; i++) await begin(env);
|
||||
assert.equal(env.sso.logins.size, 32);
|
||||
refused(env, await callback(env, first, { code: grant(env, first, env.iss[0]).code }), 'login-record-missing');
|
||||
});
|
||||
|
||||
test('18: after a key rotation a sign-in works through one refetch, and the removed kid is refused', async t => {
|
||||
const env = await setup(t);
|
||||
const [iss] = env.iss;
|
||||
t.mock.timers.enable({ apis: ['Date'], now: Date.now() });
|
||||
const old = iss.keys[0];
|
||||
assert.ok((await signIn(env)).session);
|
||||
const fetches = iss.jwksFetches;
|
||||
env.op.rotate(iss);
|
||||
t.mock.timers.tick(61000);
|
||||
assert.ok((await signIn(env)).session);
|
||||
assert.equal(iss.jwksFetches, fetches + 1);
|
||||
refused(env, (await signIn(env, { opts: { key: old } })).r, 'signature-invalid');
|
||||
});
|
||||
|
||||
test('20 and 21: the store holds no raw session id, and the cookie strings are exact', async t => {
|
||||
const env = await setup(t);
|
||||
const begun = await begin(env);
|
||||
assert.match(setCookies(begun.r)[0], new RegExp(`^${LOGIN_COOKIE}=[A-Za-z0-9_-]{43}; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=600$`));
|
||||
for (const key of env.sso.logins.keys()) assert.match(key, /^[0-9a-f]{64}$/);
|
||||
const r = await callback(env, begun, { code: grant(env, begun, env.iss[0]).code });
|
||||
const set = setCookies(r);
|
||||
assert.equal(set.length, 2);
|
||||
assert.equal(set[0], `${LOGIN_COOKIE}=; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=0`);
|
||||
assert.match(set[1], new RegExp(`^${SESSION_COOKIE}=[A-Za-z0-9_-]{43}; Path=/; Secure; HttpOnly; SameSite=Lax$`));
|
||||
const id = set[1].split(';')[0].split('=')[1];
|
||||
PRODUCED.add(id);
|
||||
for (const [key, value] of env.sso.sessions) {
|
||||
assert.match(key, /^[0-9a-f]{64}$/);
|
||||
assert.notEqual(key, id);
|
||||
assert.equal(JSON.stringify(value).includes(id), false);
|
||||
}
|
||||
});
|
||||
|
||||
test('22: a loopback Host is 403 on a page, a read and a write; /healthz on loopback is 200; a redirect_uri never comes from the Host', async t => {
|
||||
const env = await setup(t);
|
||||
const { session } = await signIn(env);
|
||||
const loop = `127.0.0.1:${env.port}`;
|
||||
for (const [method, path, body] of [['GET', '/', undefined], ['GET', '/api/board', undefined], ['GET', '/api/bus/inbox', undefined], ['POST', '/api/bus/message', MSG]]) {
|
||||
// A loopback Host on this listener's port; any other Host is already the older non-local refusal.
|
||||
for (const host of [loop, `localhost:${env.port}`]) {
|
||||
const r = await call(env.port, { method, path, host, body, headers: { cookie: session, 'content-type': 'application/json' } });
|
||||
assert.equal(r.status, 403, `${method} ${path} ${host}`);
|
||||
assert.deepEqual(r.body, { error: 'public Host required' });
|
||||
}
|
||||
}
|
||||
const health = await call(env.port, { path: '/healthz', host: loop });
|
||||
assert.equal(health.status, 200);
|
||||
assert.deepEqual(health.body, { ok: true, sso: { a: 'ready' } });
|
||||
assert.equal(env.sends.length, 0);
|
||||
const begun = await begin(env, { host: HOST.toUpperCase() });
|
||||
assert.equal(begun.params.get('redirect_uri'), `${ORIGIN}/auth/callback`);
|
||||
});
|
||||
|
||||
test('23: a provider down at start leaves the Console up and 503, and the retry makes sign-in work', async t => {
|
||||
let env = await setup(t, { retryFirst: 50, before: (op, [a]) => { a.mode = 'down'; } });
|
||||
assert.equal((await call(env.port, { path: '/auth/login' })).status, 503);
|
||||
assert.deepEqual((await call(env.port, { path: '/healthz', host: `127.0.0.1:${env.port}` })).body.sso, { a: 'unreachable' });
|
||||
assert.match(env.lines.at(-1), / sso refused code=discovery-failed provider=a$/);
|
||||
env.iss[0].mode = 'ok';
|
||||
for (let i = 0; i < 100 && env.sso.health().a !== 'ready'; i++) await new Promise(r => setTimeout(r, 20));
|
||||
assert.deepEqual(env.sso.health(), { a: 'ready' });
|
||||
assert.ok((await signIn(env)).session);
|
||||
// A 200 HTML page at the discovery URL is unreachable too, and the Console starts.
|
||||
env = await setup(t, { before: (op, [a]) => { a.mode = 'html'; } });
|
||||
assert.deepEqual(env.sso.health(), { a: 'unreachable' });
|
||||
// HS-only discovery at start refuses the start.
|
||||
await assert.rejects(setup(t, { before: (op, [a]) => { a.discovery = { id_token_signing_alg_values_supported: ['HS256'] }; } }), /refused at discovery/);
|
||||
});
|
||||
|
||||
test('19: no captured log line holds a token, code, state, nonce, verifier, client secret or session id', () => {
|
||||
assert.ok(ALL_LINES.length > 20, `only ${ALL_LINES.length} lines`);
|
||||
assert.ok(PRODUCED.size > 50, `only ${PRODUCED.size} values`);
|
||||
for (const line of ALL_LINES) {
|
||||
for (const value of PRODUCED) if (value) assert.equal(line.includes(value), false, `a log line holds a produced value: ${line.slice(0, 80)}`);
|
||||
assert.match(line, /^\S+ (sso refused code=[a-z-]+ provider=[a-z0-9-]+( iss="[^"]*" sub="[^"]*")?|send user="[^"]+" to="[^"]*" (id=msg-0001|error=[a-z -]+))$/, line);
|
||||
}
|
||||
});
|
||||
@@ -26,6 +26,7 @@ import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const SUPPORTED_CONFIG_VERSION = 1;
|
||||
const SUPPORTED_ENVIRONMENTS = new Set(["development", "production"]);
|
||||
@@ -106,13 +107,30 @@ function isAncestorOf(ancestor, candidate) {
|
||||
return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
|
||||
}
|
||||
|
||||
function validate(document, file) {
|
||||
// Q175 phase 1 (#1690): the optional `console` key, checked for shape only by the Console's own
|
||||
// checker. It is imported only when the key is there, so a config without it never needs the
|
||||
// Console's code, and a copy of this file alone still reads one.
|
||||
async function validateConsole(value) {
|
||||
let checkConsole;
|
||||
try {
|
||||
({ checkConsole } = await import(pathToFileURL(path.join(import.meta.dirname, "..", "packages", "webui", "src", "sso-config.mjs")).href));
|
||||
} catch {
|
||||
fail(2, 'the "console" key needs packages/webui/src/sso-config.mjs beside this loader');
|
||||
}
|
||||
try {
|
||||
return checkConsole(value);
|
||||
} catch (error) {
|
||||
fail(2, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function validate(document, file) {
|
||||
if (!isPlainObject(document)) {
|
||||
fail(2, "configuration must be a JSON object");
|
||||
}
|
||||
rejectUnknownKeys(
|
||||
document,
|
||||
["configVersion", "environment", "dataRoot", "execution"],
|
||||
["configVersion", "environment", "dataRoot", "execution", "console"],
|
||||
"configuration",
|
||||
);
|
||||
|
||||
@@ -146,6 +164,10 @@ function validate(document, file) {
|
||||
fail(2, `unsupported execution.adapter: ${JSON.stringify(adapter)} (supported: ${[...SUPPORTED_ADAPTERS].join(", ")})`);
|
||||
}
|
||||
|
||||
// Last, so the older checks keep their order. The key is in the output only when the file has
|
||||
// it, so a config without it prints what it always printed.
|
||||
const consoleKey = Object.hasOwn(document, "console") ? await validateConsole(document.console) : undefined;
|
||||
|
||||
return {
|
||||
configVersion: document.configVersion,
|
||||
environment: document.environment,
|
||||
@@ -156,10 +178,11 @@ function validate(document, file) {
|
||||
model: document.execution.model,
|
||||
adapter,
|
||||
},
|
||||
...(consoleKey === undefined ? {} : { console: consoleKey }),
|
||||
};
|
||||
}
|
||||
|
||||
function load(file) {
|
||||
async function load(file) {
|
||||
const raw = readRaw(file);
|
||||
if (raw === null) {
|
||||
fail(3, `configuration not found: ${file} (run scripts/bootstrap.sh to create it)`);
|
||||
@@ -170,7 +193,7 @@ function load(file) {
|
||||
} catch (error) {
|
||||
fail(2, `configuration is not valid JSON (${file}): ${error.message}`);
|
||||
}
|
||||
return validate(document, file);
|
||||
return await validate(document, file);
|
||||
}
|
||||
|
||||
function shellQuote(value) {
|
||||
@@ -194,7 +217,7 @@ const file = configPath();
|
||||
switch (operation) {
|
||||
case "bootstrap": {
|
||||
if (fs.existsSync(file)) {
|
||||
load(file); // validate only; never rewrite
|
||||
await load(file); // validate only; never rewrite
|
||||
process.stderr.write(`mosaic-config: configuration already present, validated without changes: ${file}\n`);
|
||||
process.exit(0);
|
||||
}
|
||||
@@ -206,26 +229,26 @@ switch (operation) {
|
||||
fs.writeFileSync(fd, `${JSON.stringify(DEFAULT_CONFIG, null, 2)}\n`);
|
||||
} catch (error) {
|
||||
if (error.code === "EEXIST") {
|
||||
load(file);
|
||||
await load(file);
|
||||
process.exit(0);
|
||||
}
|
||||
fail(1, `unable to create configuration: ${error.message}`);
|
||||
} finally {
|
||||
if (fd !== undefined) fs.closeSync(fd);
|
||||
}
|
||||
load(file);
|
||||
await load(file);
|
||||
process.stderr.write(`mosaic-config: created default configuration: ${file}\n`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
case "validate": {
|
||||
const resolved = load(file);
|
||||
const resolved = await load(file);
|
||||
process.stdout.write(`${JSON.stringify(resolved, null, 2)}\n`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
case "env": {
|
||||
const resolved = load(file);
|
||||
const resolved = await load(file);
|
||||
process.stdout.write(
|
||||
[
|
||||
`MOSAIC_DATA_ROOT=${shellQuote(resolved.dataRoot)}`,
|
||||
|
||||
@@ -151,6 +151,52 @@ else
|
||||
FAIL=$((FAIL+1)); echo "${C_FAIL}FAIL${C_RESET} env exports resolve correctly"
|
||||
fi
|
||||
|
||||
# --- Q175 phase 1 (#1690), design test 25: the optional console key ---
|
||||
# Shape only. The slot path is a FIFO, so a loader that opened the slot would block and the
|
||||
# timeout would fail the case; no case reads a slot file.
|
||||
mkdir -p "$SANDBOX/secrets/mosaic-stack"
|
||||
mkfifo "$SANDBOX/secrets/mosaic-stack/console-sso-test"
|
||||
sso_body() {
|
||||
printf '{"configVersion":1,"environment":"development","dataRoot":"%s","execution":{"backend":"docker","provider":"zai","model":"m"},"console":%s}' "$DATA_ROOT" "$1"
|
||||
}
|
||||
# provider [ALGS] [CLIENT_SECRET]: one provider entry; an empty argument takes the valid value.
|
||||
provider() {
|
||||
local algs='["RS256"]' secret='{"slot":"console-sso-test"}'
|
||||
[ -n "${1:-}" ] && algs="$1"
|
||||
[ -n "${2:-}" ] && secret="$2"
|
||||
printf '{"id":"fake","issuer":"https://op.test/app/","clientId":"client-1","clientSecret":%s,"algs":%s,"subjects":[{"sub":"user-1","name":"jason.woltje"}]}' "$secret" "$algs"
|
||||
}
|
||||
cfg sso-valid.json "$(sso_body '{"sso":{"publicOrigin":"https://console.test","providers":['"$(provider)"']}}')"
|
||||
OUT="$(MOSAIC_CONFIG="$SANDBOX/sso-valid.json" timeout 20 $CONFIG_OP validate 2>&1)"
|
||||
[ $? -eq 0 ] && printf '%s' "$OUT" | grep -q '"publicOrigin": "https://console.test"'
|
||||
check "valid console.sso loads and is in the output" $?
|
||||
|
||||
# expect_refusal NAME PATH-IN-MESSAGE BODY: exit 2, and the message names the bad key path.
|
||||
expect_refusal() {
|
||||
cfg sso-bad.json "$2"
|
||||
local err rc
|
||||
err="$(MOSAIC_CONFIG="$SANDBOX/sso-bad.json" timeout 20 $CONFIG_OP validate 2>&1 >/dev/null)"
|
||||
rc=$?
|
||||
[ "$rc" -eq 2 ] && printf '%s' "$err" | grep -qF -- "$3"
|
||||
check "$1 (exit $rc)" $?
|
||||
}
|
||||
expect_refusal "console.sso HS256 alg exits 2 naming the path" \
|
||||
"$(sso_body '{"sso":{"publicOrigin":"https://console.test","providers":['"$(provider '["HS256"]')"']}}')" 'console.sso.providers[0].algs[0] must be one of'
|
||||
expect_refusal "console.sso alg none exits 2 naming the path" \
|
||||
"$(sso_body '{"sso":{"publicOrigin":"https://console.test","providers":['"$(provider '["RS256","none"]')"']}}')" 'console.sso.providers[0].algs[1] must be one of'
|
||||
expect_refusal "console.sso missing slot field exits 2 naming the path" \
|
||||
"$(sso_body '{"sso":{"publicOrigin":"https://console.test","providers":['"$(provider '' '{}')"']}}')" 'console.sso.providers[0].clientSecret.slot is required'
|
||||
expect_refusal "console.sso slot name outside the pattern exits 2 naming the path" \
|
||||
"$(sso_body '{"sso":{"publicOrigin":"https://console.test","providers":['"$(provider '' '{"slot":"../x"}')"']}}')" 'console.sso.providers[0].clientSecret.slot must match'
|
||||
expect_refusal "unknown key under console exits 2 naming the path" \
|
||||
"$(sso_body '{"sso":{"publicOrigin":"https://console.test","providers":['"$(provider)"']},"extra":1}')" 'console.extra is not a supported key'
|
||||
|
||||
# Without the key: the output is exactly what the loader printed before phase 1 (sage-1340, F2).
|
||||
cfg no-console.json '{"configVersion":1,"environment":"development","dataRoot":"'$DATA_ROOT'","execution":{"backend":"docker","provider":"zai","model":"m"}}'
|
||||
EXPECTED="$(printf '{\n "configVersion": 1,\n "environment": "development",\n "dataRoot": "%s",\n "execution": {\n "backend": "docker",\n "provider": "zai",\n "model": "m",\n "adapter": "pi"\n }\n}' "$DATA_ROOT")"
|
||||
[ "$(MOSAIC_CONFIG="$SANDBOX/no-console.json" timeout 20 $CONFIG_OP validate)" = "$EXPECTED" ]
|
||||
check "config without console loads with unchanged output" $?
|
||||
|
||||
# --- validation must not modify the file ---
|
||||
SUM_INVALID_BEFORE=$(sha256sum "$SANDBOX/invalid.json" | cut -d' ' -f1)
|
||||
MOSAIC_CONFIG="$SANDBOX/invalid.json" $CONFIG_OP validate >/dev/null 2>&1
|
||||
|
||||
@@ -49,6 +49,9 @@
|
||||
# character set and refused otherwise. --proof-header NAME --proof-file PATH
|
||||
# go together under preview; only the path is carried here, never the secret.
|
||||
# render checks ownership, mode and location without opening the proof file.
|
||||
# --sso (Q175 phase 1, #1690): the Console signs people in itself from
|
||||
# console.sso in config.json. It refuses the preview set and the proof pair,
|
||||
# and --send takes it in place of the preview set. Nothing secret is carried.
|
||||
#
|
||||
# There is no start, stop, enable or restart verb. The Console reads the
|
||||
# main listener's bus through the human transport; the optional loopback
|
||||
@@ -68,7 +71,7 @@ REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
TEMPLATE="$REPO/packages/webui/systemd/mosaic-webui.service.in"
|
||||
UNIT_NAME="mosaic-webui.service"
|
||||
USAGE="usage: scripts/webui-service.sh copy COMMIT [--copies DIR] [--allow-worktree] | render [--copy ID [--copies DIR]] [FLAGS] | install --copy ID [--copies DIR] [--dir DIR] [--no-reload] [--allow-worktree] [FLAGS] | uninstall [--dir DIR] [--no-reload] | status [--dir DIR]
|
||||
FLAGS: [--port N] [--business ID] [--board URL] [--public-host NAME --auth-header HEADER --allow-user ID [--send]] [--tester-port N] [--proof-header NAME --proof-file PATH]
|
||||
FLAGS: [--port N] [--business ID] [--board URL] [--sso [--send] | --public-host NAME --auth-header HEADER --allow-user ID [--send]] [--tester-port N] [--proof-header NAME --proof-file PATH]
|
||||
--tester-port: 1024..65535, different from --port and never 7332; loopback tester listener.
|
||||
--allow-worktree is for the tests only: copy and install otherwise refuse outside the canonical checkout."
|
||||
|
||||
@@ -91,6 +94,7 @@ PUBLIC_HOST=""
|
||||
AUTH_HEADER=""
|
||||
ALLOW_USER=""
|
||||
SEND=0
|
||||
SSO=0
|
||||
PROOF_HEADER=""
|
||||
PROOF_FILE=""
|
||||
PROOF_SET=0
|
||||
@@ -114,6 +118,7 @@ parse_flags() {
|
||||
--proof-header) [ $# -ge 2 ] || usage; PROOF_HEADER="$2"; PROOF_SET=1; SERVE_SET=1; shift 2 ;;
|
||||
--proof-file) [ $# -ge 2 ] || usage; PROOF_FILE="$2"; PROOF_SET=1; SERVE_SET=1; shift 2 ;;
|
||||
--send) SEND=1; SERVE_SET=1; shift ;;
|
||||
--sso) SSO=1; SERVE_SET=1; shift ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
done
|
||||
@@ -166,9 +171,13 @@ serve_flags() {
|
||||
[ -z "$PUBLIC_HOST" ] || preview=$((preview + 1))
|
||||
[ -z "$AUTH_HEADER" ] || preview=$((preview + 1))
|
||||
[ -z "$ALLOW_USER" ] || preview=$((preview + 1))
|
||||
# serve refuses --send without the preview set; refuse it here first,
|
||||
# so no unit is written that would only exit 2 at start.
|
||||
[ "$SEND" = 0 ] || [ "$preview" -eq 3 ] || die "--send needs the preview mode (--public-host, --auth-header and --allow-user)"
|
||||
# serve refuses --sso with the preview set or the proof pair, and --send
|
||||
# without --sso or the preview set; refuse them here first, so no unit is
|
||||
# written that would only exit 2 at start.
|
||||
if [ "$SSO" = 1 ] && { [ "$preview" -ne 0 ] || [ "$PROOF_SET" = 1 ]; }; then
|
||||
die "--sso cannot be combined with --public-host, --auth-header, --allow-user or --proof-*"
|
||||
fi
|
||||
[ "$SEND" = 0 ] || [ "$preview" -eq 3 ] || [ "$SSO" = 1 ] || die "--send needs the preview mode (--public-host, --auth-header and --allow-user) or --sso"
|
||||
if [ "$preview" -ne 0 ]; then
|
||||
[ "$preview" -eq 3 ] || die "--public-host, --auth-header and --allow-user go together: all three or none"
|
||||
case "$PUBLIC_HOST" in [A-Za-z0-9]*) ;; *) die "public host must be a hostname, no port or scheme: $PUBLIC_HOST" ;; esac
|
||||
@@ -201,6 +210,7 @@ serve_flags() {
|
||||
{ [ "$proof_mode" = 600 ] || [ "$proof_mode" = 400 ]; } && [ "$proof_uid" = "$(id -u)" ] || die "invalid proxy proof file metadata"
|
||||
flags="$flags --proof-header $PROOF_HEADER --proof-file $PROOF_FILE"
|
||||
fi
|
||||
[ "$SSO" = 0 ] || flags="$flags --sso"
|
||||
[ "$SEND" = 0 ] || flags="$flags --send"
|
||||
[ "$TESTER_SET" = 0 ] || flags="$flags --tester-port $TESTER_PORT"
|
||||
echo "$flags"
|
||||
@@ -385,14 +395,17 @@ status_unit() {
|
||||
else
|
||||
echo "copy: none; the unit runs from $wd"
|
||||
fi
|
||||
# render puts --send after the allowed user and optional proof pair,
|
||||
# before the tester port. The allowed user
|
||||
# render puts --send after the allowed user and optional proof pair, or
|
||||
# after --sso, before the tester port. The allowed user
|
||||
# starts with a letter or digit (N5), so it is never the value --send.
|
||||
if grep -Eq '^ExecStart=.* --allow-user [^ ][^ ]*( --proof-header [^ ]+ --proof-file /[^ ]+)? --send( --tester-port [0-9]+)?$' "$target"; then
|
||||
if grep -Eq '^ExecStart=.*( --allow-user [^ ][^ ]*( --proof-header [^ ]+ --proof-file /[^ ]+)?| --sso) --send( --tester-port [0-9]+)?$' "$target"; then
|
||||
echo "send: on (--send; human messages to roles)"
|
||||
else
|
||||
echo "send: off (read-only)"
|
||||
fi
|
||||
if grep -Eq '^ExecStart=.* --sso( --send)?( --tester-port [0-9]+)?$' "$target"; then
|
||||
echo "sign-in: the Console's own (--sso)"
|
||||
fi
|
||||
local tester_port
|
||||
tester_port="$(sed -n 's/^ExecStart=.* --tester-port \([0-9][0-9]*\)$/\1/p' "$target")"
|
||||
if [ -n "$tester_port" ]; then
|
||||
|
||||
Reference in New Issue
Block a user