Compare commits
45
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
490430e9f2 | ||
|
|
37582e8205 | ||
|
|
83b32d8d86 | ||
|
|
53bb594621 | ||
|
|
5e01c36833 | ||
|
|
9f56d2098d | ||
|
|
0599b119f4 | ||
|
|
b416d2ede9 | ||
|
|
5fc73a729c | ||
|
|
99772421ab | ||
|
|
da230ca489 | ||
|
|
4f2d1be68a | ||
|
|
d55d86bb04 | ||
|
|
c56ccf916e | ||
|
|
817e8f51b0 | ||
|
|
5d019d8768 | ||
|
|
59475fc5a8 | ||
|
|
1a268249e6 | ||
|
|
def2e79327 | ||
|
|
c30b36fffb | ||
|
|
9ae8c73acb | ||
|
|
8ecc7a239d | ||
|
|
7f57419b38 | ||
|
|
4ca8d6e949 | ||
|
|
22e5e825b0 | ||
|
|
e6dea74b22 | ||
|
|
cd2b377bbf | ||
|
|
ceccb0ef26 | ||
|
|
10ab8ea624 | ||
|
|
c539995cf4 | ||
|
|
92cc6011c8 | ||
|
|
7af1665748 | ||
|
|
2244e8bafc | ||
|
|
bde0fdec9f | ||
|
|
def677bc76 | ||
|
|
51f67f1f87 | ||
|
|
5b298bd00b | ||
|
|
565bc32c2f | ||
|
|
5140aa9e88 | ||
|
|
973cb5a872 | ||
|
|
b82ec93536 | ||
|
|
3560c450a6 | ||
|
|
4f02486d56 | ||
|
|
263ea8a9a3 | ||
|
|
6020bb43ac |
No files matched your search
+1
-17
@@ -75,8 +75,7 @@ session's workspace and its own process group. Same contract, plus:
|
||||
| `MOSAIC_POLICY_FILE`, `MOSAIC_TOOLS_FILE` | The bundle's gate policy and typed tools. |
|
||||
| `MOSAIC_TOOL_SOCKET` | The runner's tool socket, which the typed tools call. |
|
||||
| `MOSAIC_TURN_MARKER` | `pi`: the extension writes it at `agent_end`; a pi turn that exits 0 without it failed. |
|
||||
| `MOSAIC_EXTENSIONS` | `pi` only: extension files loaded with `-e`; a missing one exits 2. The pi module names two: the gate and typed tools extension, then the unslop check's `tools/unslop-hook/extension.ts` (row 66). |
|
||||
| `MOSAIC_UNSLOP_LOG` | The unslop check's JSONL log, `<run>/unslop.jsonl`, which the runner sets for both adapters and reads after each turn (row 66). |
|
||||
| `MOSAIC_EXTENSIONS` | `pi` only: extension files loaded with `-e`; a missing one exits 2. |
|
||||
| `MOSAIC_CLAUDE_SETTINGS`, `MOSAIC_CLAUDE_MCP_CONFIG` | `claude` only: the bundle's gate hook and MCP server; required. |
|
||||
| `MOSAIC_THINKING` | The route's thinking level (row 65), which the harness module's `levels()` already checked. `pi` passes `--thinking` (`off`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`) and omits it when unset, as for `run-task.sh` workers. `claude` passes `--effort` (`low`, `medium`, `high`, `xhigh`, `max`) and requires it; unset exits 2. |
|
||||
| `MOSAIC_CLAUDE_BIN` | `claude` only: absolute path of the CLI to run, from the host's environment (the bus unit pins it). Unset or empty runs `claude` from `PATH`; a relative path or a non-executable file exits 2. |
|
||||
@@ -107,18 +106,3 @@ The cost is prompt size: a role's skill text is in every turn, where the
|
||||
other routes load a skill when it's used. The bundle caps a role's skills at
|
||||
64 KiB together, since `claude` passes `prompt.md` as one argument and Linux
|
||||
caps one argument at 128 KiB.
|
||||
|
||||
### The unslop check (row 66)
|
||||
|
||||
Both adapters run the mechanical writing check from `tools/unslop-hook`,
|
||||
driven by one `lists.json`. `pi` loads it as its second `-e` extension.
|
||||
`claude` runs `claude-hook.js` from the bundle's `settings.json`: the Stop
|
||||
hook checks the turn's replies, and the UserPromptSubmit hook adds the
|
||||
correction notice as context on the next turn when the last reply had
|
||||
tells. Each hook command ends `; exit 0`, so neither blocks. Both log to
|
||||
`MOSAIC_UNSLOP_LOG`, and the runner adds a note for the operator to the
|
||||
next RESULT it sends (`packages/harness/README.md`, the runner). The
|
||||
harness modules mount the three files read-only at their own paths, and
|
||||
the manifest records their digests. `MOSAIC_UNSLOP_HOOK` and `UNSLOP_LISTS`
|
||||
aren't in the session environment's allowlist, so a managed session can't
|
||||
switch the check off or swap its lists.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,541 @@
|
||||
# Q175 design: the Console verifies SSO itself (Vikunja #412)
|
||||
|
||||
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
|
||||
|
||||
Jason, DECISIONS Q175, verbatim: "Q1: yes. This should be a requirement for SSO usage. Whether we are using
|
||||
Pocket ID, Authentik, WorkOS, or another provider (we should support multiple), the system should use best
|
||||
practices for jwt or other methods of validation that do not rely on hacks to pass the auth." He added "Zero
|
||||
trust with verification" and confirmed five points (mos-0970):
|
||||
|
||||
1. The stack verifies every SSO login itself. It never takes identity from a proxy header or a shared secret.
|
||||
2. The Console is an OIDC relying party: discovery from the issuer URL, authorization code with PKCE, the ID
|
||||
token checked against the provider JWKS (signature, iss, aud, exp, nonce), then its own session cookie.
|
||||
3. Several providers can be configured side by side.
|
||||
4. Only asymmetric signing counts. HS256 is refused.
|
||||
5. A proxy-forwarded token such as `X-authentik-jwt` counts only through the same checks. It is optional and
|
||||
never the only way in.
|
||||
|
||||
## 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`. 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
|
||||
carries exactly one auth header value equal to the user (:517-521). That header value is the whole identity.
|
||||
- Requests with a loopback Host on 7332 get the unauthenticated read path (:509-512). Writes need `named` (:526-546).
|
||||
- `proofOptions` (:217) is the row 178 shared-secret header. Q175 point 1 rules it out, so it retires with the
|
||||
auth header.
|
||||
- The tester listener (`--tester-port`, row 176) binds 127.0.0.1. Its writer is fixed as
|
||||
`{ tester: true, user: 'tester' }` (:522), it allows only the instance, route, name and chat writes (:523),
|
||||
and it refuses the auth and proof headers (:486-487). 7332 refuses `x-mosaic-tester` (:485).
|
||||
- The public path is Traefik, then the Authentik proxy outpost (provider mosaic-preview, HS256, empty JWKS),
|
||||
then chisel `R:0.0.0.0:7332:127.0.0.1:7332`, then the Console. Every public request reaches the Console from
|
||||
127.0.0.1 with whatever Host the outpost forwards.
|
||||
|
||||
## Design
|
||||
|
||||
### 1. The Console becomes an OIDC relying party
|
||||
|
||||
New module `packages/webui/src/sso.mjs`, used only by the human listener (7332). Routes:
|
||||
|
||||
| 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`. 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, 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. 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)` 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)` 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
|
||||
could name a defect or a dependency cost that outweighs it. I found neither.
|
||||
|
||||
- MIT, no dependencies, one ESM file (build/index.js, 99,856 bytes). Tarball sha256 `bac088aecc7c...`,
|
||||
integrity `sha512-8N28E+a/oxfXW...` (the full values go in package-lock.json).
|
||||
- `supported()` covers PS/ES/RS 256/384/512, Ed25519/EdDSA and ML-DSA. No HS*. RSA keys under 2048 bits are
|
||||
refused.
|
||||
- It does discovery with an issuer equality check (`processDiscoveryResponse(expectedIssuer, ...)`), PKCE,
|
||||
state, the RFC 9207 `iss` parameter, nonce, and JWKS caching.
|
||||
- The one gap is the default skip of the token-endpoint signature check (section 1 step 5). Calling one
|
||||
exported function closes it, so it isn't a reason to hand-roll.
|
||||
|
||||
Hand-rolling would mean writing JOSE parsing, JWK import, key selection by `kid`, the claim checks and the
|
||||
discovery checks ourselves, with the same tests and more places to get it wrong.
|
||||
|
||||
Pin: `"oauth4webapi": "3.8.8"` in the root package.json `dependencies`, next to the pi pin, with the lockfile
|
||||
entry. Root is right because `scripts/stack-copy.sh:23-31` copies only the root package.json and lock and runs
|
||||
`npm ci` there (:55-56). packages/webui/src resolves the import from the copy's root node_modules. This is the
|
||||
webui's first runtime dependency. The root dependency rule (exact pins, never unversioned) applies to it.
|
||||
Upgrades are reviewed commits.
|
||||
|
||||
### 3. Sessions, cookies, Host and logout
|
||||
|
||||
- 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 (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=<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
|
||||
"console": {
|
||||
"sso": {
|
||||
"publicOrigin": "https://mosaic.woltje.com",
|
||||
"providers": [
|
||||
{
|
||||
"id": "authentik",
|
||||
"issuer": "https://auth.diversecanvas.com/application/o/mosaic-stack/",
|
||||
"clientId": "<from Jason>",
|
||||
"clientSecret": { "slot": "console-sso-authentik" },
|
||||
"algs": ["RS256"],
|
||||
"subjects": [ { "sub": "<from Jason>", "name": "jason.woltje" } ]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
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 `<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` 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.
|
||||
|
||||
Config checks, run at start without the network. Any failure refuses the start, and the message names the
|
||||
provider and the check:
|
||||
|
||||
- `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.
|
||||
|
||||
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.
|
||||
|
||||
### 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
|
||||
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. 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 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.
|
||||
|
||||
The Authentik proxy outpost can stay in front. It still sends `X-authentik-*` headers, and the Console never
|
||||
reads them. A test proves that a request carrying them gets nothing without a session cookie.
|
||||
|
||||
### 6. The tester port and the N3 tester identity
|
||||
|
||||
The tester listener exists so agents can test as the tester and never as Jason (mos-0870 N3). Under SSO:
|
||||
|
||||
- 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 a `name` of `tester` or `human`.
|
||||
|
||||
### 7. Point 5, the proxy-forwarded token
|
||||
|
||||
Not in phase 1. Point 5 makes it optional and the code flow covers the ruling. If it's built later:
|
||||
|
||||
- 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, 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).
|
||||
|
||||
### 8. Interim exposure until phase 2
|
||||
|
||||
Today, with `--send` live, any same-uid process on dragon-lin can connect to 127.0.0.1:7332 with
|
||||
`Host: mosaic.woltje.com`, `X-authentik-username: jason.woltje`, the matching Origin and
|
||||
`Sec-Fetch-Site: same-origin`, and send as Jason. That includes agent seats. Through the tunnel, the outpost
|
||||
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).
|
||||
|
||||
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
|
||||
as the human without touching the Console. Q175 doesn't change that. P19 333 owns it.
|
||||
|
||||
### 9. Tests
|
||||
|
||||
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). 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:
|
||||
|
||||
1. Forged header. `X-authentik-username: jason.woltje` with `Host: mosaic.woltje.com`, the right Origin and
|
||||
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. 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. 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. 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. `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). 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, 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
|
||||
|
||||
1. Authentik mosaic-preview: give it an RSA or EC signing key or retire it, and confirm by an admin read that
|
||||
client_id `GEQ84kBbt2RG6fhYQGgZl2J9jgjw6wRJ3oSWxqZQ` belongs to it. (Ask B matched it only on scopes and
|
||||
redirect_uri.)
|
||||
2. Authentik mosaic-stack provider: register redirect URI `https://mosaic.woltje.com/auth/callback` and
|
||||
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 `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.
|
||||
6. The phase 2 re-pin window go.
|
||||
|
||||
## Questions for review
|
||||
|
||||
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,147 @@
|
||||
# Row 163 (D2): `stack-release.sh rehearse` and `bus start --no-launch`
|
||||
|
||||
Gitea #1660. Brief: docs/plans/2026-10-18_stack-release.md, "Stack rehearsal on a scratch root
|
||||
(D2)", blob b1500d96ad27. Reviewer: Filbert. Rulings: mos-0961, mos-0962, mos-0963.
|
||||
Base: 4f02486d5 (origin/refactor). D2 lands after the Part B window (P19 385), because Part B's
|
||||
window file names the stack-release.sh blob (mos-0958, mos-0959).
|
||||
|
||||
## What it does
|
||||
|
||||
`rehearse ID BUSINESS --backup ACTIVATION|PATH --port N [--timeout S]` builds
|
||||
`<dataRoot>/rehearsals/<UTC second>-<copy 12>/` (0700) from a backup, runs a guard on the scratch
|
||||
config with the target copy's code, runs the migration checks when the schema differs, starts the
|
||||
target's `bus start BUSINESS --no-launch` and Console on the scratch root, gates them with D1's
|
||||
checks minus the PM's, checks that no session started, stops both, and compares the backup and the
|
||||
live config and business files with sums taken first. One PASS or FAIL line per check, then
|
||||
REHEARSAL_PASS (0) or REHEARSAL_FAIL (1). Usage exits 4. docs/TOOLS.md has the operator text.
|
||||
|
||||
`bus start --no-launch` (brief item 4): `start()` refuses `launches-disabled`, so every launch path
|
||||
spawns nothing; a session's launch request is refused and logged; `listen()` starts no thread
|
||||
ticker; `recover()` signals and ends nothing and logs what it left; the Registry is read-only, so
|
||||
sessions.json stays as an earlier host left it and `add()` refuses. The host-up line ends
|
||||
`, launches disabled`. `--pm` with it is a usage error. No unit passes it.
|
||||
|
||||
## Rulings carried
|
||||
|
||||
- mos-0961: `data-root-live` refuses a scratch dataRoot that resolves to the live data root;
|
||||
`sessions-live` refuses when a sessions.json in the scratch data root names a running pid. The run
|
||||
never deletes the file or retries. recover() under --no-launch is covered by the launcher test
|
||||
"bus start --no-launch leaves an earlier host's live session alone: no signal, no end,
|
||||
sessions.json as it was".
|
||||
- mos-0962: every tracker.* variable is stripped, business and role level; `business.diff` keeps the
|
||||
change; `tracker-configured` fails if bootConfig still sets trackers; notify.json is asserted to be
|
||||
exactly {"notifyVersion":1,"binding":null} at 0600.
|
||||
- mos-0963: every role credential reference and @sync's is moved to its own 0600 file under
|
||||
`<rehearsal>/placeholders/` holding `rehearsal-placeholder-token`; `credential-ref-live` fails for
|
||||
an env reference or a file whose real directory is not the placeholders directory. Test R9 leaves
|
||||
one role ref on a file outside placeholders and gets the FAIL; mutant K1 drops the check and R9
|
||||
fails.
|
||||
|
||||
## Outbound calls
|
||||
|
||||
The rehearsal reaches nothing off the host:
|
||||
|
||||
- reads: the live config.json, the live business file, the pointer, the backup, the copies
|
||||
directory, the target copy's roles/, git in the canonical checkout (`copy_ok`'s merge-base);
|
||||
- writes: only `<dataRoot>/rehearsals/<stamp>/`;
|
||||
- processes: the target's bus host (`--no-launch`) and Console (`serve --port N --business B`, no
|
||||
`--send`), both with `env -i HOME PATH MOSAIC_CONFIG=<scratch config>`;
|
||||
- network: 127.0.0.1:N for /healthz, and the host's sockets under the scratch data root.
|
||||
|
||||
No model call: launches are disabled and no session starts (`no-session`). No Vikunja: tracker.*
|
||||
is stripped and `tracker-configured` checks bootConfig. No Discord: notify binding null. No Gitea:
|
||||
every credential ref is a placeholder file, and the git socket serves only bound sessions, of
|
||||
which there are none.
|
||||
|
||||
`projects.stack.root` stays at the live checkout, /mnt/storage/src/mosaic-stack. The business
|
||||
file's projects are copied unchanged. Under --no-launch nothing makes a checkout from it, because
|
||||
checkouts are made only in `start()`, which refuses.
|
||||
|
||||
## Deviations from the brief
|
||||
|
||||
1. Item 1 says to copy "the business file, roles and organization file from the live config
|
||||
directory". Roles come from the target copy's `roles/`, as the live bus reads them (rolesDir is
|
||||
`<REPO>/roles` unless MOSAIC_ROLES_DIR is set, and `env -i` leaves it unset). The live config
|
||||
directory has no organization file; bootConfig's organization entry is the config dir itself,
|
||||
which here is the scratch config dir.
|
||||
2. The business file is rewritten, not copied (mos-0962, mos-0963), and `business.diff` records
|
||||
the change.
|
||||
3. The `tracker` key stays and `tracker.sync.credentials` becomes a placeholder: the business
|
||||
schema requires both, so stripping the key (mos-0962's wording) makes the loader refuse.
|
||||
bootConfig sets trackers only with tracker.baseUrl and a project's tracker.project, and both are
|
||||
stripped; `tracker-configured` checks the result. Authority: mos-0964 (2026-10-20T14:35:30Z),
|
||||
which accepts this and replaces the "@sync still present" clause of mos-0963 item 2:
|
||||
business.mjs:197 calls checkTracker on every load, and checkTracker requires tracker and
|
||||
tracker.sync (:57, :59) and checks sync.credentials (:63). @sync stays with its ref on a
|
||||
placeholder, and `credential-ref-live` covers it with the role refs. Evidence: R10 (a tracker
|
||||
still set fails `tracker-configured`), K2 (the check dropped, R10 fails), K10 (the tracker.*
|
||||
strip dropped, R1 fails on `tracker-configured`), and K9 (the @sync swap dropped, R1 fails on `credential-ref-live`).
|
||||
4. mosaic-config already refuses a scratch config that names the live data root lexically, because
|
||||
the rehearsal directory sits inside the live data root and a dataRoot may not contain the config
|
||||
dir. The guard's `data-root-live` catches the form that reaches the live root through a link
|
||||
(R11).
|
||||
5. Files owned: the brief lists stack-release.sh, its test, TOOLS.md and the packet. Item 4's flag
|
||||
adds packages/cli/src/cli.mjs, packages/cli/src/launcher.mjs, packages/seat/src/session.mjs and
|
||||
their tests (host.test.mjs, launcher.test.mjs, session.test.mjs).
|
||||
6. The brief's "free loopback port" is an operator-given `--port`, refused when reserved (7332,
|
||||
7333, the pointer's --port and --tester-port) or in use, never replaced by another port.
|
||||
7. Brief item 3's "in the foreground": the host runs as a child of the script for the length of
|
||||
the gate, then gets SIGTERM; it is not a unit and not detached.
|
||||
|
||||
## Tests
|
||||
|
||||
packages/cli/tests/stack-release.test.mjs, rehearse section (stand-in host, Console, migrate and
|
||||
Store in four fixture copies; real scripts/mosaic, mosaic-config, cli config and the business
|
||||
loader):
|
||||
|
||||
- R1 passes on the same schema: files, modes, placeholders, business.diff, the host's meta row in
|
||||
the scratch store and not in the backup, args logs, steps.log equal to the output, live files
|
||||
byte-identical.
|
||||
- R2 migration path 3g to 3h; R3 migrate fails.
|
||||
- R4 host fails, status fails, status names another pid, host launches, host keeps writer.lock.
|
||||
- R5 SIGTERM ignored: SIGKILL and FAIL host-stopped. R6 healthz. R7 copy-match.
|
||||
- R8 port in use, and 7332, 7333, the pointer's --port and --tester-port reserved.
|
||||
- R9 credential-ref-live (mos-0963). R10 tracker-configured (mos-0962). R11 data-root-live through
|
||||
a link and sessions-live on a running pid (mos-0961). R12 notify-off.
|
||||
- R13 old copy, backup missing, backup sums, no pointer, usage.
|
||||
|
||||
packages/cli/tests/launcher.test.mjs: recover() under --no-launch leaves an earlier host's live
|
||||
session alone; every launch is refused and logged; no thread tick (and the same host with
|
||||
launches runs it). packages/cli/tests/host.test.mjs: the host-up line, no sessions.json, --pm
|
||||
with --no-launch is usage, and no unit renders --no-launch. packages/seat/tests/session.test.mjs:
|
||||
a read-only Registry never writes and refuses add().
|
||||
|
||||
## Mutants
|
||||
|
||||
33, one per behaviour, each killed (mutants.py, mutants-run.log and mutants-rerun.log beside this file). The first run left K11
|
||||
(bus-status pid), K21 (scratch store linked to the backup), K27 (ticker under --no-launch) and
|
||||
K31 (cli drops launches); each got a test and the rerun killed all four. K21 now dies on
|
||||
FAIL backup-unchanged, the check it exists for.
|
||||
|
||||
## Round 2 (Filbert r1, comment 29445: F1-F3 changes, N1)
|
||||
|
||||
Filbert's six mutants M1-M6 survived the R tests. Each named a check that worked but that no test
|
||||
drove to FAIL. Round 2 adds those tests. stack-release.sh is unchanged (blob 0053f35d1360).
|
||||
|
||||
- F1 (M1, M2): R14. A new fixture fault, host-touches, has the host append to a file it lists.
|
||||
Three cases (the live config, the live business file, the backup's bus.sqlite) each give exactly
|
||||
one FAIL, live-config-unchanged or backup-unchanged, after the gate passed and the host stopped.
|
||||
- F2 (M3): R9 gains a pm gitea reference left on env REHEARSAL_GITEA_TOKEN, which fails
|
||||
credential-ref-live with "pm gitea is env REHEARSAL_GITEA_TOKEN" before the host starts.
|
||||
- F2 (M4): R11 gains a scratch config whose dataRoot is another existing directory ("resolves to
|
||||
X, not R/data") and one whose dataRoot is not there ("X does not exist"). Both fail
|
||||
data-root-scratch before the host starts.
|
||||
- F2 (M5): R4 gains host-launches-dir, a host that makes data/launches, which fails no-session
|
||||
with "found launches under".
|
||||
- F3 (M6): R15 sends TERM, INT and HUP to a rehearsal whose gate is still waiting on /healthz.
|
||||
Each gives FAIL signal, then console-stopped, host-stopped, host-released-store,
|
||||
backup-unchanged and live-config-unchanged all PASS, REHEARSAL_FAIL, exit 1, and the host's pid
|
||||
is gone. rehearse() gained an e.during hook that gets the child process.
|
||||
- N1: Registry.remove() wrote sessions.json under readOnly. #write() now returns at once when the
|
||||
registry is read-only, so the constructor, add() and remove() share one rule. The session test
|
||||
calls remove() on a read-only registry and checks that the file is unchanged.
|
||||
|
||||
Mutants K34-K41, one per finding and N1, are all killed (r2/mutants-run.log beside this file):
|
||||
K34 backup-unchanged always passes, K35 live-config-unchanged always passes, K36 env ref
|
||||
accepted, K37 data-root-scratch mismatch dropped, K38 missing root dropped, K39 launches dir
|
||||
ignored, K40 no INT/TERM/HUP trap, K41 read-only Registry writes.
|
||||
@@ -0,0 +1,10 @@
|
||||
04d72e5ef4d8d5fc85a4f11aa51331354696cc7cf66af2ed7540cf6cfc0153b9 agents/sage/work/queue-163/BUILD.md
|
||||
5bd1bc697cd56e57b00e60c0e80be72cd1043a55a6d2fc0b9e2ccdd0c5cf9802 docs/TOOLS.md
|
||||
52597d7fa1469510fcf8e6dfd63664ad4c492bc17039885aa839231798693058 packages/cli/src/cli.mjs
|
||||
5492ebb5aa3c9c85874e4c7de7b65fc37733e2db636690c11fe2d2fdf72c2dd0 packages/cli/src/launcher.mjs
|
||||
e9ce0e0f7368d3fb2e1edbd4ebc724513ec19d7bac0c993f8be0d9494427be07 packages/cli/tests/host.test.mjs
|
||||
42519898b5bcf84c21088647e161a86934d4fe351207f736b9b120b6e1c2b91b packages/cli/tests/launcher.test.mjs
|
||||
4dfeefa1bb3c11e8633d75df4f628497ebd427ebf8402ba5efc3e5c7fd030057 packages/cli/tests/stack-release.test.mjs
|
||||
5abdeab8bc6b63d291d0054124a4311a2260b590c68f758daa142a9deb2f4a72 packages/seat/src/session.mjs
|
||||
741883e399d77d429cc29a87beef6db9c51c1759669b025006f037001d101eb6 packages/seat/tests/session.test.mjs
|
||||
5bb3ac40102ce515f1acdcbc5670feb161ce40a193e94e2f8b104ebf98eddc38 scripts/stack-release.sh
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,28 @@
|
||||
base 580500699 dirty 0 DOCKER_HOST=unset
|
||||
pkg-bus rc=0 ℹ pass 451 ℹ fail 0 | ℹ duration_ms 3051.98676
|
||||
pkg-business rc=0 ℹ pass 149 ℹ fail 0 | ℹ duration_ms 1933.965446
|
||||
pkg-cli rc=0 ℹ pass 470 ℹ fail 0 | ℹ duration_ms 337885.04401
|
||||
pkg-control-board rc=0 ℹ pass 124 ℹ fail 0 | ℹ duration_ms 585.587135
|
||||
pkg-conversation rc=0 ℹ pass 182 ℹ fail 0 | ℹ duration_ms 33296.254589
|
||||
pkg-discord rc=0 ℹ pass 183 ℹ fail 0 | ℹ duration_ms 2624.332987
|
||||
pkg-harness rc=0 ℹ pass 133 ℹ fail 0 | ℹ duration_ms 11808.615207
|
||||
pkg-ledger rc=0 ℹ pass 89 ℹ fail 0 | ℹ duration_ms 12902.241615
|
||||
pkg-mosaic rc=0 ℹ pass 69 ℹ fail 0 | ℹ duration_ms 2051.760841
|
||||
pkg-plugins rc=0 ℹ pass 111 ℹ fail 0 | ℹ duration_ms 247.493374
|
||||
pkg-project rc=0 ℹ pass 40 ℹ fail 0 | ℹ duration_ms 1031.233595
|
||||
pkg-runs rc=0 ℹ pass 43 ℹ fail 0 | ℹ duration_ms 727.865986
|
||||
pkg-seat rc=0 ℹ pass 152 ℹ fail 0 | ℹ duration_ms 5858.066276
|
||||
pkg-tasks rc=0 ℹ pass 97 ℹ fail 0 | ℹ duration_ms 1147.333901
|
||||
pkg-webui rc=0 ℹ pass 269 ℹ fail 0 | ℹ duration_ms 46548.273469
|
||||
script-auth rc=0 | selftest: 15 passed, 0 failed
|
||||
script-conductor rc=0 | selftest: 17 passed, 0 failed
|
||||
script-config rc=0 | selftest: 24 passed, 0 failed
|
||||
script-discord rc=0 | discord suite: 78 passed, 0 failed
|
||||
script-extension-package rc=0 | extension package selftest: 18 passed, 0 failed
|
||||
script-foundation rc=0 | selftest: 44 passed, 0 failed
|
||||
script-land-work rc=0 | land-work selftests: 86 passed, 0 failed
|
||||
script-queue rc=0 ℹ pass 156 ℹ fail 0 | queue suite: 28 passed, 0 failed
|
||||
script-release rc=0 | selftest: 14 passed, 0 failed
|
||||
script-task rc=0 | selftest: 98 passed, 0 failed
|
||||
GATE_PASSED
|
||||
removed network sage-r163_default
|
||||
@@ -0,0 +1,5 @@
|
||||
KILLED K11 bus-status pid check dropped (rc 1)
|
||||
KILLED K21 scratch store links the backup (rc 1)
|
||||
KILLED K27 ticker runs under --no-launch (rc 1)
|
||||
KILLED K31 cli --no-launch not passed to the launcher (rc 1)
|
||||
ALL_KILLED
|
||||
@@ -0,0 +1,34 @@
|
||||
KILLED K1 credential-ref-live dropped (rc 1)
|
||||
KILLED K2 tracker-configured dropped (rc 1)
|
||||
KILLED K3 data-root-live dropped (rc 1)
|
||||
KILLED K4 sessions-live dropped (rc 1)
|
||||
KILLED K5 notify-off dropped (rc 1)
|
||||
KILLED K6 no-session dropped (rc 1)
|
||||
KILLED K7 copy-match dropped (rc 1)
|
||||
KILLED K8 role credential swap dropped (rc 1)
|
||||
KILLED K9 @sync swap dropped (rc 1)
|
||||
KILLED K10 business tracker strip dropped (rc 1)
|
||||
SURVIVED K11 bus-status pid check dropped (rc 0)
|
||||
KILLED K12 host-released-store dropped (rc 1)
|
||||
KILLED K13 SIGKILL counted as a pass (rc 1)
|
||||
KILLED K14 healthz always ok (rc 1)
|
||||
KILLED K15 port-reserved dropped (rc 1)
|
||||
KILLED K16 port-in-use dropped (rc 1)
|
||||
KILLED K17 backup-sums dropped (rc 1)
|
||||
KILLED K18 copy-without-no-launch dropped (rc 1)
|
||||
KILLED K19 migration skipped (rc 1)
|
||||
KILLED K20 migrate failure ignored (rc 1)
|
||||
SURVIVED K21 scratch store links the backup (rc 0)
|
||||
KILLED K22 scratch config keeps the live dataRoot (rc 1)
|
||||
KILLED K23 host started without --no-launch (rc 1)
|
||||
KILLED K24 start() launches under --no-launch (rc 1)
|
||||
KILLED K25 session launch request allowed under --no-launch (rc 1)
|
||||
KILLED K26 recover() ends sessions under --no-launch (rc 1)
|
||||
SURVIVED K27 ticker runs under --no-launch (rc 0)
|
||||
KILLED K28 registry writes under --no-launch (rc 1)
|
||||
KILLED K29 Registry readOnly writes anyway (rc 1)
|
||||
KILLED K30 Registry readOnly add() allowed (rc 1)
|
||||
SURVIVED K31 cli --no-launch not passed to the launcher (rc 0)
|
||||
KILLED K32 cli --pm with --no-launch allowed (rc 1)
|
||||
KILLED K33 cli host-up line drops launches disabled (rc 1)
|
||||
SOME_SURVIVED
|
||||
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env python3
|
||||
# Row 163 mutants: each one drops or weakens one behaviour; the named tests must then fail.
|
||||
import hashlib, os, shutil, subprocess, sys, signal
|
||||
W = '/mnt/storage/scratch/sage-b163'
|
||||
OUT = os.environ.get('MUT_OUT', '/mnt/storage/scratch/sage-b163-gate/mutants')
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
SR = 'scripts/stack-release.sh'
|
||||
T = 'packages/cli/tests/stack-release.test.mjs'
|
||||
M = [
|
||||
('K1 credential-ref-live dropped', SR, 'if(f===null||phr===null||path.dirname(f)!==phr)fail("credential-ref-live"', 'if(false)fail("credential-ref-live"', T, '^R9 '),
|
||||
('K2 tracker-configured dropped', SR, 'if(config.trackers!==undefined)fail(', 'if(false)fail(', T, '^R10 '),
|
||||
('K3 data-root-live dropped', SR, 'if(root===real(live))fail(', 'if(false)fail(', T, '^R11 '),
|
||||
('K4 sessions-live dropped', SR, 'if(running.length)fail(', 'if(false)fail(', T, '^R11 '),
|
||||
('K5 notify-off dropped', SR, 'fail("notify-off",`${nf} is not {', 'void("notify-off",`${nf} is not {', T, '^R12 '),
|
||||
('K6 no-session dropped', SR, 'if [ -z "$found" ]; then r_pass no-session', 'if true; then r_pass no-session', T, '^R4 '),
|
||||
('K7 copy-match dropped', SR, ' R_GATE=copy-match\n', ' R_GATE=""; return 0\n', T, '^R7 '),
|
||||
('K8 role credential swap dropped', SR, 'strip(r?.vars);swap(name,r?.credentials)}', 'strip(r?.vars)}', T, '^R1 '),
|
||||
('K9 @sync swap dropped', SR, 'swap("@sync",b.tracker?.sync?.credentials);', '', T, '^R1 '),
|
||||
('K10 business tracker strip dropped', SR, '\nstrip(b.vars);\n', '\n', T, '^R1 '),
|
||||
('K11 bus-status pid check dropped', SR, 'String(s.host.pid)!==main?`host pid ${s.host.pid} is not ${main}, the host this run', 'false?`host pid ${s.host.pid} is not ${main}, the host this run', T, '^R4 '),
|
||||
('K12 host-released-store dropped', SR, 'if [ -e "$R/data/bus/writer.lock" ]; then', 'if false; then', T, '^R4 '),
|
||||
('K13 SIGKILL counted as a pass', SR, 'kill -KILL "$pid" 2>/dev/null || true; r_fail "$name-stopped"', 'kill -KILL "$pid" 2>/dev/null || true; r_pass "$name-stopped"', T, '^R5 '),
|
||||
('K14 healthz always ok', SR, '.then((r)=>process.exit(r.status===200?0:1),()=>process.exit(1));\' -- "$R_PORT"', '.then(()=>process.exit(0),()=>process.exit(0));\' -- "$R_PORT"', T, '^R6 '),
|
||||
('K15 port-reserved dropped', SR, '[ "$((10#$R_PORT))" != "$i" ] || r_abort port-reserved', 'true || r_abort port-reserved', T, '^R8 '),
|
||||
('K16 port-in-use dropped', SR, 'r_port_free "$R_PORT" || r_abort port-in-use', 'true || r_abort port-in-use', T, '^R8 '),
|
||||
('K17 backup-sums dropped', SR, 'sha256sum -c --quiet ../SHA256SUMS) >/dev/null 2>&1 ||', 'true) >/dev/null 2>&1 ||', T, '^R13 '),
|
||||
('K18 copy-without-no-launch dropped', SR, 'grep -qF -- \'"--no-launch"\' "$COPIES/$TO/packages/cli/src/cli.mjs" 2>/dev/null ||', 'true ||', T, '^R13 '),
|
||||
('K19 migration skipped', SR, 'if [ "$v" = "$T_SCHEMA" ]; then r_pass migration', 'if true; then r_pass migration', T, '^R2 '),
|
||||
('K20 migrate failure ignored', SR, '[ "$rc" = 0 ] && [ "$(tail -n 1 <<< "$out")" = "bus schema $T_SCHEMA ready" ] || r_abort migrate "', 'true || r_abort migrate "', T, '^R3 '),
|
||||
('K21 scratch store links the backup', SR, 'cp "$R_BK"/bus/* "$R/data/bus/" &&', 'ln -s "$R_BK"/bus/* "$R/data/bus/" &&', T, '^R1 '),
|
||||
('K22 scratch config keeps the live dataRoot', SR, 'c.dataRoot=data;', '', T, '^R1 '),
|
||||
('K23 host started without --no-launch', SR, '"$COPIES/$TO/scripts/mosaic" bus start "$BUSINESS" --no-launch >', '"$COPIES/$TO/scripts/mosaic" bus start "$BUSINESS" >', T, '^R1 '),
|
||||
('K24 start() launches under --no-launch', 'packages/cli/src/launcher.mjs', ' if (!launches) throw new Refusal("launches-disabled", "this host was started with --no-launch");\n', '', 'packages/cli/tests/launcher.test.mjs', None),
|
||||
('K25 session launch request allowed under --no-launch', 'packages/cli/src/launcher.mjs', ' if (!launches) {\n await host.op({ op: "refuse", cap, code: "launches-disabled" })', ' if (false) {\n await host.op({ op: "refuse", cap, code: "launches-disabled" })', 'packages/cli/tests/launcher.test.mjs', None),
|
||||
('K26 recover() ends sessions under --no-launch', 'packages/cli/src/launcher.mjs', ' if (!launches) {\n for (const s of leftover) log(', ' if (false) {\n for (const s of leftover) log(', 'packages/cli/tests/launcher.test.mjs', None),
|
||||
('K27 ticker runs under --no-launch', 'packages/cli/src/launcher.mjs', 'if (b.launch && launches) ticker', 'if (b.launch) ticker', 'packages/cli/tests/launcher.test.mjs', None),
|
||||
('K28 registry writes under --no-launch', 'packages/cli/src/launcher.mjs', 'new Registry(dataRoot, { readOnly: !launches })', 'new Registry(dataRoot)', 'packages/cli/tests/launcher.test.mjs', None),
|
||||
('K29 Registry readOnly writes anyway', 'packages/seat/src/session.mjs', ' if (!readOnly) this.#write();', ' this.#write();', 'packages/seat/tests/session.test.mjs', None),
|
||||
('K30 Registry readOnly add() allowed', 'packages/seat/src/session.mjs', ' if (this.#readOnly) throw new SeatError("this host keeps no sessions (--no-launch)", 2);\n', '', 'packages/seat/tests/session.test.mjs', None),
|
||||
('K31 cli --no-launch not passed to the launcher', 'packages/cli/src/cli.mjs', 'tracker: Boolean(boot.trackers?.[businessId]), launches, ', 'tracker: Boolean(boot.trackers?.[businessId]), ', 'packages/cli/tests/host.test.mjs', None),
|
||||
('K32 cli --pm with --no-launch allowed', 'packages/cli/src/cli.mjs', ' if (!launches && parsed.flags.has("--pm")) throw usage(', ' if (false) throw usage(', 'packages/cli/tests/host.test.mjs', None),
|
||||
('K33 cli host-up line drops launches disabled', 'packages/cli/src/cli.mjs', '${launches ? "" : ", launches disabled"}', '', 'packages/cli/tests/host.test.mjs', None),
|
||||
('K34 backup-unchanged always passes', SR, 'if [ "$(r_tree_sums "$R_BK")" = "$(cat "$R/backup.before")" ]; then r_pass backup-unchanged', 'if true; then r_pass backup-unchanged', T, '^R14 '),
|
||||
('K35 live-config-unchanged always passes', SR, 'if [ "$(sha256sum "$LIVE_CFG" "$LIVE_BIZ" 2>&1)" = "$(cat "$R/live.before")" ]; then', 'if true; then', T, '^R14 '),
|
||||
('K36 credential-ref-live accepts an env ref', SR, 'if(ref?.env!==undefined)fail("credential-ref-live"', 'if(false)fail("credential-ref-live"', T, '^R9 '),
|
||||
('K37 data-root-scratch mismatch dropped', SR, 'if(root!==real(scratch))fail(', 'if(false)fail(', T, '^R11 '),
|
||||
('K38 data-root-scratch missing root dropped', SR, 'if(root===null)fail("data-root-scratch"', 'if(false)fail("data-root-scratch"', T, '^R11 '),
|
||||
('K39 no-session ignores a launches dir', SR, '[ ! -e "$R/data/launches" ] || found+=" launches"', 'true || found+=" launches"', T, '^R4 '),
|
||||
('K40 no INT/TERM/HUP trap', SR, ' trap \'r_fail signal "the rehearsal was interrupted"; r_end\' INT TERM HUP\n', '', T, '^R15 '),
|
||||
('K41 read-only Registry writes', 'packages/seat/src/session.mjs', ' if (this.#readOnly) return;\n', '', 'packages/seat/tests/session.test.mjs', None),
|
||||
]
|
||||
sha = lambda p: hashlib.sha256(open(p, 'rb').read()).hexdigest()
|
||||
env = dict(os.environ, TMPDIR='/mnt/storage/scratch/sb163t')
|
||||
results = []
|
||||
ONLY = set(sys.argv[1:])
|
||||
for name, f, old, new, test, pat in M:
|
||||
if ONLY and name.split()[0] not in ONLY: continue
|
||||
p = os.path.join(W, f); bak = os.path.join(OUT, os.path.basename(f) + '.orig')
|
||||
shutil.copy2(p, bak)
|
||||
if sha(bak) != sha(p): sys.exit(f'backup of {p} failed')
|
||||
src = open(p).read()
|
||||
if src.count(old) != 1: sys.exit(f'{name}: pattern count {src.count(old)} in {f}')
|
||||
open(p, 'w').write(src.replace(old, new))
|
||||
cmd = ['node', '--test'] + (['--test-name-pattern', pat] if pat else []) + [test]
|
||||
log = os.path.join(OUT, name.split()[0] + '.log')
|
||||
try:
|
||||
with open(log, 'w') as lf:
|
||||
pr = subprocess.Popen(cmd, cwd=W, env=env, stdout=lf, stderr=subprocess.STDOUT, start_new_session=True)
|
||||
try: rc = pr.wait(timeout=600)
|
||||
except subprocess.TimeoutExpired:
|
||||
os.killpg(pr.pid, signal.SIGKILL); pr.wait(); rc = 'timeout'
|
||||
finally:
|
||||
shutil.copy2(bak, p)
|
||||
if sha(bak) != sha(p): sys.exit(f'restore of {p} failed')
|
||||
killed = rc != 0
|
||||
results.append((name, killed, rc))
|
||||
print(f'{"KILLED" if killed else "SURVIVED"} {name} (rc {rc})', flush=True)
|
||||
print('ALL_KILLED' if all(k for _, k, _ in results) else 'SOME_SURVIVED')
|
||||
@@ -0,0 +1,48 @@
|
||||
✖ R14 a host that writes the live config, the live business file or the backup fails the end check by name (10205.967212ms)
|
||||
ℹ tests 1
|
||||
ℹ suites 0
|
||||
ℹ pass 0
|
||||
ℹ fail 1
|
||||
ℹ cancelled 0
|
||||
ℹ skipped 0
|
||||
ℹ todo 0
|
||||
ℹ duration_ms 10820.998717
|
||||
|
||||
✖ failing tests:
|
||||
|
||||
test at packages/cli/tests/stack-release.test.mjs:2062:1
|
||||
✖ R14 a host that writes the live config, the live business file or the backup fails the end check by name (10205.967212ms)
|
||||
AssertionError [ERR_ASSERTION]: /mnt/storage/scratch/sb163t/sr-V3yNfh/data/backups/stack/20261020T000000Z-2a52abd7922e/bus/bus.sqlitePASS copy: 2a52abd7922ec2ce073c2b481b490e984630e9ce, schema 3g
|
||||
PASS port: 127.0.0.1:38065 is free
|
||||
PASS backup: /mnt/storage/scratch/sb163t/sr-V3yNfh/data/backups/stack/20261020T000000Z-2a52abd7922e matches its SHA256SUMS
|
||||
PASS scratch-root: /mnt/storage/scratch/sb163t/sr-V3yNfh/data/rehearsals/20261020T150815Z-2a52abd7922e/data from the backup, config and business file under /mnt/storage/scratch/sb163t/sr-V3yNfh/data/rehearsals/20261020T150815Z-2a52abd7922e/config, business.diff beside them
|
||||
PASS data-root-live: /mnt/storage/scratch/sb163t/sr-V3yNfh/data/rehearsals/20261020T150815Z-2a52abd7922e/data is not the live data root
|
||||
PASS sessions-live: no running session named in the data root
|
||||
PASS data-root-scratch
|
||||
PASS notify-off: binding null, so no Discord bridge
|
||||
PASS tracker-configured: bootConfig sets no trackers
|
||||
PASS credential-ref-live: 9 reference(s), each a placeholder
|
||||
PASS migration: not needed: the backup is already 3g
|
||||
PASS gate: host-up, bus-status, console-up, healthz and copy-match on 2a52abd7922ec2ce073c2b481b490e984630e9ce
|
||||
PASS no-session: no sessions.json and no launches under /mnt/storage/scratch/sb163t/sr-V3yNfh/data/rehearsals/20261020T150815Z-2a52abd7922e/data
|
||||
PASS console-stopped: pid 3546679
|
||||
PASS host-stopped: pid 3546678
|
||||
PASS host-released-store
|
||||
PASS backup-unchanged
|
||||
PASS live-config-unchanged
|
||||
REHEARSAL_PASS
|
||||
rehearsal directory: /mnt/storage/scratch/sb163t/sr-V3yNfh/data/rehearsals/20261020T150815Z-2a52abd7922e
|
||||
|
||||
|
||||
0 !== 1
|
||||
|
||||
at TestContext.<anonymous> (file:///mnt/storage/scratch/sage-b163/packages/cli/tests/stack-release.test.mjs:2066:10)
|
||||
at async Test.run (node:internal/test_runner/test:1409:7)
|
||||
at async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3) {
|
||||
generatedMessage: false,
|
||||
code: 'ERR_ASSERTION',
|
||||
actual: 0,
|
||||
expected: 1,
|
||||
operator: 'strictEqual',
|
||||
diff: 'simple'
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
✖ R14 a host that writes the live config, the live business file or the backup fails the end check by name (2394.880127ms)
|
||||
ℹ tests 1
|
||||
ℹ suites 0
|
||||
ℹ pass 0
|
||||
ℹ fail 1
|
||||
ℹ cancelled 0
|
||||
ℹ skipped 0
|
||||
ℹ todo 0
|
||||
ℹ duration_ms 3000.67202
|
||||
|
||||
✖ failing tests:
|
||||
|
||||
test at packages/cli/tests/stack-release.test.mjs:2062:1
|
||||
✖ R14 a host that writes the live config, the live business file or the backup fails the end check by name (2394.880127ms)
|
||||
AssertionError [ERR_ASSERTION]: /mnt/storage/scratch/sb163t/sr-VQM0Df/cfg/config.jsonPASS copy: bf4bac79a205e5010c48897456fc6e665a3cf9aa, schema 3g
|
||||
PASS port: 127.0.0.1:45755 is free
|
||||
PASS backup: /mnt/storage/scratch/sb163t/sr-VQM0Df/data/backups/stack/20261020T000000Z-bf4bac79a205 matches its SHA256SUMS
|
||||
PASS scratch-root: /mnt/storage/scratch/sb163t/sr-VQM0Df/data/rehearsals/20261020T150820Z-bf4bac79a205/data from the backup, config and business file under /mnt/storage/scratch/sb163t/sr-VQM0Df/data/rehearsals/20261020T150820Z-bf4bac79a205/config, business.diff beside them
|
||||
PASS data-root-live: /mnt/storage/scratch/sb163t/sr-VQM0Df/data/rehearsals/20261020T150820Z-bf4bac79a205/data is not the live data root
|
||||
PASS sessions-live: no running session named in the data root
|
||||
PASS data-root-scratch
|
||||
PASS notify-off: binding null, so no Discord bridge
|
||||
PASS tracker-configured: bootConfig sets no trackers
|
||||
PASS credential-ref-live: 9 reference(s), each a placeholder
|
||||
PASS migration: not needed: the backup is already 3g
|
||||
PASS gate: host-up, bus-status, console-up, healthz and copy-match on bf4bac79a205e5010c48897456fc6e665a3cf9aa
|
||||
PASS no-session: no sessions.json and no launches under /mnt/storage/scratch/sb163t/sr-VQM0Df/data/rehearsals/20261020T150820Z-bf4bac79a205/data
|
||||
PASS console-stopped: pid 3547161
|
||||
PASS host-stopped: pid 3547160
|
||||
PASS host-released-store
|
||||
PASS backup-unchanged
|
||||
PASS live-config-unchanged
|
||||
REHEARSAL_PASS
|
||||
rehearsal directory: /mnt/storage/scratch/sb163t/sr-VQM0Df/data/rehearsals/20261020T150820Z-bf4bac79a205
|
||||
|
||||
|
||||
0 !== 1
|
||||
|
||||
at TestContext.<anonymous> (file:///mnt/storage/scratch/sage-b163/packages/cli/tests/stack-release.test.mjs:2066:10)
|
||||
at async Test.run (node:internal/test_runner/test:1409:7)
|
||||
at async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3) {
|
||||
generatedMessage: false,
|
||||
code: 'ERR_ASSERTION',
|
||||
actual: 0,
|
||||
expected: 1,
|
||||
operator: 'strictEqual',
|
||||
diff: 'simple'
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
✖ R9 mos-0963: a role reference left outside placeholders/, or left on an env variable, fails credential-ref-live before the host starts (609.420853ms)
|
||||
ℹ tests 1
|
||||
ℹ suites 0
|
||||
ℹ pass 0
|
||||
ℹ fail 1
|
||||
ℹ cancelled 0
|
||||
ℹ skipped 0
|
||||
ℹ todo 0
|
||||
ℹ duration_ms 1216.913248
|
||||
|
||||
✖ failing tests:
|
||||
|
||||
test at packages/cli/tests/stack-release.test.mjs:1971:1
|
||||
✖ R9 mos-0963: a role reference left outside placeholders/, or left on an env variable, fails credential-ref-live before the host starts (609.420853ms)
|
||||
AssertionError [ERR_ASSERTION]: PASS copy: f2cd6e0875411f82c798b2a541604410cc77d2c7, schema 3g
|
||||
PASS port: 127.0.0.1:41709 is free
|
||||
PASS backup: /mnt/storage/scratch/sb163t/sr-TRrjEx/data/backups/stack/20261020T000000Z-f2cd6e087541 matches its SHA256SUMS
|
||||
PASS scratch-root: /mnt/storage/scratch/sb163t/sr-TRrjEx/data/rehearsals/20261020T150823Z-f2cd6e087541/data from the backup, config and business file under /mnt/storage/scratch/sb163t/sr-TRrjEx/data/rehearsals/20261020T150823Z-f2cd6e087541/config, business.diff beside them
|
||||
PASS data-root-live: /mnt/storage/scratch/sb163t/sr-TRrjEx/data/rehearsals/20261020T150823Z-f2cd6e087541/data is not the live data root
|
||||
PASS sessions-live: no running session named in the data root
|
||||
PASS data-root-scratch
|
||||
PASS notify-off: binding null, so no Discord bridge
|
||||
PASS tracker-configured: bootConfig sets no trackers
|
||||
FAIL credential-ref-live: pm gitea is undefined, not a file in /mnt/storage/scratch/sb163t/sr-TRrjEx/data/rehearsals/20261020T150823Z-f2cd6e087541/placeholders
|
||||
PASS backup-unchanged
|
||||
PASS live-config-unchanged
|
||||
REHEARSAL_FAIL
|
||||
rehearsal directory: /mnt/storage/scratch/sb163t/sr-TRrjEx/data/rehearsals/20261020T150823Z-f2cd6e087541
|
||||
|
||||
at TestContext.<anonymous> (file:///mnt/storage/scratch/sage-b163/packages/cli/tests/stack-release.test.mjs:1990:9)
|
||||
at async Test.run (node:internal/test_runner/test:1409:7)
|
||||
at async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3) {
|
||||
generatedMessage: false,
|
||||
code: 'ERR_ASSERTION',
|
||||
actual: false,
|
||||
expected: true,
|
||||
operator: '==',
|
||||
diff: 'simple'
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
✖ R11 mos-0961: a scratch config on the live data root or on any root but the scratch one, or a sessions.json naming a running pid, refuses by name (605.865597ms)
|
||||
ℹ tests 1
|
||||
ℹ suites 0
|
||||
ℹ pass 0
|
||||
ℹ fail 1
|
||||
ℹ cancelled 0
|
||||
ℹ skipped 0
|
||||
ℹ todo 0
|
||||
ℹ duration_ms 1061.260755
|
||||
|
||||
✖ failing tests:
|
||||
|
||||
test at packages/cli/tests/stack-release.test.mjs:2006:1
|
||||
✖ R11 mos-0961: a scratch config on the live data root or on any root but the scratch one, or a sessions.json naming a running pid, refuses by name (605.865597ms)
|
||||
AssertionError [ERR_ASSERTION]: other-rootPASS copy: 236c2e27d28711316d96143968ae477b7fa7b7e7, schema 3g
|
||||
PASS port: 127.0.0.1:37319 is free
|
||||
PASS backup: /mnt/storage/scratch/sb163t/sr-aZQJrB/data/backups/stack/20261020T000000Z-236c2e27d287 matches its SHA256SUMS
|
||||
PASS scratch-root: /mnt/storage/scratch/sb163t/sr-aZQJrB/data/rehearsals/20261020T150824Z-236c2e27d287/data from the backup, config and business file under /mnt/storage/scratch/sb163t/sr-aZQJrB/data/rehearsals/20261020T150824Z-236c2e27d287/config, business.diff beside them
|
||||
PASS data-root-live: /mnt/storage/scratch/sb163t/sr-aZQJrB/other-root is not the live data root
|
||||
PASS sessions-live: no running session named in the data root
|
||||
PASS data-root-scratch
|
||||
FAIL notify-off: /mnt/storage/scratch/sb163t/sr-aZQJrB/other-root/notify/acme/notify.json is missing or not JSON
|
||||
PASS backup-unchanged
|
||||
PASS live-config-unchanged
|
||||
REHEARSAL_FAIL
|
||||
rehearsal directory: /mnt/storage/scratch/sb163t/sr-aZQJrB/data/rehearsals/20261020T150824Z-236c2e27d287
|
||||
|
||||
at TestContext.<anonymous> (file:///mnt/storage/scratch/sage-b163/packages/cli/tests/stack-release.test.mjs:2021:10)
|
||||
at async Test.run (node:internal/test_runner/test:1409:7)
|
||||
at async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3) {
|
||||
generatedMessage: false,
|
||||
code: 'ERR_ASSERTION',
|
||||
actual: false,
|
||||
expected: true,
|
||||
operator: '==',
|
||||
diff: 'simple'
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
✖ R11 mos-0961: a scratch config on the live data root or on any root but the scratch one, or a sessions.json naming a running pid, refuses by name (916.097165ms)
|
||||
ℹ tests 1
|
||||
ℹ suites 0
|
||||
ℹ pass 0
|
||||
ℹ fail 1
|
||||
ℹ cancelled 0
|
||||
ℹ skipped 0
|
||||
ℹ todo 0
|
||||
ℹ duration_ms 1239.364874
|
||||
|
||||
✖ failing tests:
|
||||
|
||||
test at packages/cli/tests/stack-release.test.mjs:2006:1
|
||||
✖ R11 mos-0961: a scratch config on the live data root or on any root but the scratch one, or a sessions.json naming a running pid, refuses by name (916.097165ms)
|
||||
AssertionError [ERR_ASSERTION]: no-rootPASS copy: 998d46411d6c1d3168396f7bb5932e1e200a9263, schema 3g
|
||||
PASS port: 127.0.0.1:43579 is free
|
||||
PASS backup: /mnt/storage/scratch/sb163t/sr-v0EeC4/data/backups/stack/20261020T000000Z-998d46411d6c matches its SHA256SUMS
|
||||
PASS scratch-root: /mnt/storage/scratch/sb163t/sr-v0EeC4/data/rehearsals/20261020T150825Z-998d46411d6c/data from the backup, config and business file under /mnt/storage/scratch/sb163t/sr-v0EeC4/data/rehearsals/20261020T150825Z-998d46411d6c/config, business.diff beside them
|
||||
PASS data-root-live: /mnt/storage/scratch/sb163t/sr-v0EeC4/no-root is not the live data root
|
||||
FAIL guard: The "path" argument must be of type string. Received null
|
||||
PASS backup-unchanged
|
||||
PASS live-config-unchanged
|
||||
REHEARSAL_FAIL
|
||||
rehearsal directory: /mnt/storage/scratch/sb163t/sr-v0EeC4/data/rehearsals/20261020T150825Z-998d46411d6c
|
||||
|
||||
at TestContext.<anonymous> (file:///mnt/storage/scratch/sage-b163/packages/cli/tests/stack-release.test.mjs:2021:10)
|
||||
at async Test.run (node:internal/test_runner/test:1409:7)
|
||||
at async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3) {
|
||||
generatedMessage: false,
|
||||
code: 'ERR_ASSERTION',
|
||||
actual: false,
|
||||
expected: true,
|
||||
operator: '==',
|
||||
diff: 'simple'
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
✖ R4 a host that does not start, does not answer status, reports another pid, launches anyway, makes a launches directory or keeps its lock each fails by name (18892.096194ms)
|
||||
ℹ tests 1
|
||||
ℹ suites 0
|
||||
ℹ pass 0
|
||||
ℹ fail 1
|
||||
ℹ cancelled 0
|
||||
ℹ skipped 0
|
||||
ℹ todo 0
|
||||
ℹ duration_ms 19506.873236
|
||||
|
||||
✖ failing tests:
|
||||
|
||||
test at packages/cli/tests/stack-release.test.mjs:1920:1
|
||||
✖ R4 a host that does not start, does not answer status, reports another pid, launches anyway, makes a launches directory or keeps its lock each fails by name (18892.096194ms)
|
||||
AssertionError [ERR_ASSERTION]: host-launches-dirPASS copy: 2cb8943db81ed8e914000449977627f0a9d42d00, schema 3g
|
||||
PASS port: 127.0.0.1:43729 is free
|
||||
PASS backup: /mnt/storage/scratch/sb163t/sr-D2Wt2k/data/backups/stack/20261020T000000Z-2cb8943db81e matches its SHA256SUMS
|
||||
PASS scratch-root: /mnt/storage/scratch/sb163t/sr-D2Wt2k/data/rehearsals/20261020T150843Z-2cb8943db81e/data from the backup, config and business file under /mnt/storage/scratch/sb163t/sr-D2Wt2k/data/rehearsals/20261020T150843Z-2cb8943db81e/config, business.diff beside them
|
||||
PASS data-root-live: /mnt/storage/scratch/sb163t/sr-D2Wt2k/data/rehearsals/20261020T150843Z-2cb8943db81e/data is not the live data root
|
||||
PASS sessions-live: no running session named in the data root
|
||||
PASS data-root-scratch
|
||||
PASS notify-off: binding null, so no Discord bridge
|
||||
PASS tracker-configured: bootConfig sets no trackers
|
||||
PASS credential-ref-live: 9 reference(s), each a placeholder
|
||||
PASS migration: not needed: the backup is already 3g
|
||||
PASS gate: host-up, bus-status, console-up, healthz and copy-match on 2cb8943db81ed8e914000449977627f0a9d42d00
|
||||
PASS no-session: no sessions.json and no launches under /mnt/storage/scratch/sb163t/sr-D2Wt2k/data/rehearsals/20261020T150843Z-2cb8943db81e/data
|
||||
PASS console-stopped: pid 3550603
|
||||
PASS host-stopped: pid 3550602
|
||||
PASS host-released-store
|
||||
PASS backup-unchanged
|
||||
PASS live-config-unchanged
|
||||
REHEARSAL_PASS
|
||||
rehearsal directory: /mnt/storage/scratch/sb163t/sr-D2Wt2k/data/rehearsals/20261020T150843Z-2cb8943db81e
|
||||
|
||||
|
||||
0 !== 1
|
||||
|
||||
at TestContext.<anonymous> (file:///mnt/storage/scratch/sage-b163/packages/cli/tests/stack-release.test.mjs:1924:10)
|
||||
at async Test.run (node:internal/test_runner/test:1409:7)
|
||||
at async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3) {
|
||||
generatedMessage: false,
|
||||
code: 'ERR_ASSERTION',
|
||||
actual: 0,
|
||||
expected: 1,
|
||||
operator: 'strictEqual',
|
||||
diff: 'simple'
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
✖ R15 a rehearsal stopped by TERM, INT or HUP fails signal and still stops the host and Console and runs the end checks (1289.845744ms)
|
||||
ℹ tests 1
|
||||
ℹ suites 0
|
||||
ℹ pass 0
|
||||
ℹ fail 1
|
||||
ℹ cancelled 0
|
||||
ℹ skipped 0
|
||||
ℹ todo 0
|
||||
ℹ duration_ms 1749.897862
|
||||
|
||||
✖ failing tests:
|
||||
|
||||
test at packages/cli/tests/stack-release.test.mjs:2073:1
|
||||
✖ R15 a rehearsal stopped by TERM, INT or HUP fails signal and still stops the host and Console and runs the end checks (1289.845744ms)
|
||||
AssertionError [ERR_ASSERTION]: SIGTERMPASS copy: ba3ad437eb120f80d3a1bdc438860968dd038962, schema 3g
|
||||
PASS port: 127.0.0.1:45847 is free
|
||||
PASS backup: /mnt/storage/scratch/sb163t/sr-Bin1lp/data/backups/stack/20261020T000000Z-ba3ad437eb12 matches its SHA256SUMS
|
||||
PASS scratch-root: /mnt/storage/scratch/sb163t/sr-Bin1lp/data/rehearsals/20261020T150846Z-ba3ad437eb12/data from the backup, config and business file under /mnt/storage/scratch/sb163t/sr-Bin1lp/data/rehearsals/20261020T150846Z-ba3ad437eb12/config, business.diff beside them
|
||||
PASS data-root-live: /mnt/storage/scratch/sb163t/sr-Bin1lp/data/rehearsals/20261020T150846Z-ba3ad437eb12/data is not the live data root
|
||||
PASS sessions-live: no running session named in the data root
|
||||
PASS data-root-scratch
|
||||
PASS notify-off: binding null, so no Discord bridge
|
||||
PASS tracker-configured: bootConfig sets no trackers
|
||||
PASS credential-ref-live: 9 reference(s), each a placeholder
|
||||
PASS migration: not needed: the backup is already 3g
|
||||
|
||||
|
||||
null !== 1
|
||||
|
||||
at TestContext.<anonymous> (file:///mnt/storage/scratch/sage-b163/packages/cli/tests/stack-release.test.mjs:2079:10)
|
||||
at async Test.run (node:internal/test_runner/test:1409:7)
|
||||
at async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3) {
|
||||
generatedMessage: false,
|
||||
code: 'ERR_ASSERTION',
|
||||
actual: null,
|
||||
expected: 1,
|
||||
operator: 'strictEqual',
|
||||
diff: 'simple'
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
✔ REQ-LIMIT-1: family substring matching is no longer exported (0.993829ms)
|
||||
✔ sessionEnv passes only the allowlist, the repo's bin on PATH, and the run id (0.643651ms)
|
||||
✔ sessionExit gives a runner's signal death back as the signal; a session credential is one whole set of nonempty variables (0.22563ms)
|
||||
✔ newRun: short ids, 0700 directories, and a refusal when the socket path won't fit (1.390202ms)
|
||||
✔ session file and launch log: 0600, the session file written once (0.890354ms)
|
||||
✔ endReason maps the runner's exit codes; a signal is killed (0.110674ms)
|
||||
✔ Registry mirrors to sessions.json; readSessions marks live entries; bad files refuse (1.151527ms)
|
||||
✖ a read-only Registry never writes sessions.json, refuses add() and leaves the file on remove() (1.357741ms)
|
||||
✔ REQ-THREAD-1 Registry.running keys a thread run by business, instance and thread (0.526184ms)
|
||||
✔ REQ-THREAD-2 REQ-LAUNCH-1 threadStore checks both segments before the join and refuses an unsafe directory (1.243195ms)
|
||||
✔ stopSession refuses an unknown run, reports a stale one, and won't signal a pid that isn't a runner (0.781332ms)
|
||||
✔ a session runs in its sandbox as pid 1 of its namespace, claims, answers, and stops on mosaic stop (192.679392ms)
|
||||
ℹ tests 12
|
||||
ℹ suites 0
|
||||
ℹ pass 11
|
||||
ℹ fail 1
|
||||
ℹ cancelled 0
|
||||
ℹ skipped 0
|
||||
ℹ todo 0
|
||||
ℹ duration_ms 266.973886
|
||||
|
||||
✖ failing tests:
|
||||
|
||||
test at packages/seat/tests/session.test.mjs:137:1
|
||||
✖ a read-only Registry never writes sessions.json, refuses add() and leaves the file on remove() (1.357741ms)
|
||||
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
|
||||
+ actual - expected
|
||||
|
||||
+ '{\n "sessionsVersion": 1,\n "sessions": []\n}\n'
|
||||
- '{\n' +
|
||||
- ' "sessionsVersion": 1,\n' +
|
||||
- ' "sessions": [\n' +
|
||||
- ' {\n' +
|
||||
- ' "business": "acme",\n' +
|
||||
- ' "instance": "chief",\n' +
|
||||
- ' "run": "r1",\n' +
|
||||
- ' "runnerPid": 3551219,\n' +
|
||||
- ' "runnerStartTime": "61011768"\n' +
|
||||
- ' }\n' +
|
||||
- ' ]\n' +
|
||||
- '}\n'
|
||||
|
||||
at TestContext.<anonymous> (file:///mnt/storage/scratch/sage-b163/packages/seat/tests/session.test.mjs:142:10)
|
||||
at Test.runInAsyncScope (node:async_hooks:226:14)
|
||||
at Test.run (node:internal/test_runner/test:1402:25)
|
||||
at Test.processPendingSubtests (node:internal/test_runner/test:974:18)
|
||||
at Test.postRun (node:internal/test_runner/test:1542:19)
|
||||
at Test.run (node:internal/test_runner/test:1467:12)
|
||||
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
|
||||
generatedMessage: true,
|
||||
code: 'ERR_ASSERTION',
|
||||
actual: '{\n "sessionsVersion": 1,\n "sessions": []\n}\n',
|
||||
expected: '{\n "sessionsVersion": 1,\n "sessions": [\n {\n "business": "acme",\n "instance": "chief",\n "run": "r1",\n "runnerPid": 3551219,\n "runnerStartTime": "61011768"\n }\n ]\n}\n',
|
||||
operator: 'strictEqual',
|
||||
diff: 'simple'
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
f04d12a963b3199420f1d9c41fbdae67faf46f710a805cb9358f1fb52f5faed9 agents/sage/work/queue-163/BUILD.md
|
||||
5bd1bc697cd56e57b00e60c0e80be72cd1043a55a6d2fc0b9e2ccdd0c5cf9802 docs/TOOLS.md
|
||||
52597d7fa1469510fcf8e6dfd63664ad4c492bc17039885aa839231798693058 packages/cli/src/cli.mjs
|
||||
5492ebb5aa3c9c85874e4c7de7b65fc37733e2db636690c11fe2d2fdf72c2dd0 packages/cli/src/launcher.mjs
|
||||
e9ce0e0f7368d3fb2e1edbd4ebc724513ec19d7bac0c993f8be0d9494427be07 packages/cli/tests/host.test.mjs
|
||||
42519898b5bcf84c21088647e161a86934d4fe351207f736b9b120b6e1c2b91b packages/cli/tests/launcher.test.mjs
|
||||
b01ff656962c6fdb5bc3554cfbb13645b7f25adae5f6cd9608af8de6d6091c6c packages/cli/tests/stack-release.test.mjs
|
||||
448138d12404346989a6751a01c747718cef7d2bec19a0639a29fa6a8b74a82d packages/seat/src/session.mjs
|
||||
cd6277c4e7155a7c93d09f5bc7cca1130708a35a6b75aa02d99af8360d6338b0 packages/seat/tests/session.test.mjs
|
||||
5bb3ac40102ce515f1acdcbc5670feb161ce40a193e94e2f8b104ebf98eddc38 scripts/stack-release.sh
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,28 @@
|
||||
base 2a5f3a3a0 dirty 0 DOCKER_HOST=unset
|
||||
pkg-bus rc=0 ℹ pass 451 ℹ fail 0 | ℹ duration_ms 2970.304395
|
||||
pkg-business rc=0 ℹ pass 149 ℹ fail 0 | ℹ duration_ms 1953.76274
|
||||
pkg-cli rc=0 ℹ pass 472 ℹ fail 0 | ℹ duration_ms 367258.38335
|
||||
pkg-control-board rc=0 ℹ pass 124 ℹ fail 0 | ℹ duration_ms 585.394845
|
||||
pkg-conversation rc=0 ℹ pass 182 ℹ fail 0 | ℹ duration_ms 33402.754981
|
||||
pkg-discord rc=0 ℹ pass 183 ℹ fail 0 | ℹ duration_ms 2610.108892
|
||||
pkg-harness rc=0 ℹ pass 133 ℹ fail 0 | ℹ duration_ms 11612.167166
|
||||
pkg-ledger rc=0 ℹ pass 89 ℹ fail 0 | ℹ duration_ms 12928.162376
|
||||
pkg-mosaic rc=0 ℹ pass 69 ℹ fail 0 | ℹ duration_ms 2092.404214
|
||||
pkg-plugins rc=0 ℹ pass 111 ℹ fail 0 | ℹ duration_ms 250.471164
|
||||
pkg-project rc=0 ℹ pass 40 ℹ fail 0 | ℹ duration_ms 1069.844547
|
||||
pkg-runs rc=0 ℹ pass 43 ℹ fail 0 | ℹ duration_ms 732.731695
|
||||
pkg-seat rc=0 ℹ pass 152 ℹ fail 0 | ℹ duration_ms 5889.377731
|
||||
pkg-tasks rc=0 ℹ pass 97 ℹ fail 0 | ℹ duration_ms 1159.016661
|
||||
pkg-webui rc=0 ℹ pass 269 ℹ fail 0 | ℹ duration_ms 54984.81726
|
||||
script-auth rc=0 | selftest: 15 passed, 0 failed
|
||||
script-conductor rc=0 | selftest: 17 passed, 0 failed
|
||||
script-config rc=0 | selftest: 24 passed, 0 failed
|
||||
script-discord rc=0 | discord suite: 78 passed, 0 failed
|
||||
script-extension-package rc=0 | extension package selftest: 18 passed, 0 failed
|
||||
script-foundation rc=0 | selftest: 44 passed, 0 failed
|
||||
script-land-work rc=0 | land-work selftests: 86 passed, 0 failed
|
||||
script-queue rc=0 ℹ pass 156 ℹ fail 0 | queue suite: 28 passed, 0 failed
|
||||
script-release rc=0 | selftest: 14 passed, 0 failed
|
||||
script-task rc=0 | selftest: 98 passed, 0 failed
|
||||
GATE_PASSED
|
||||
removed network sage-r163_default
|
||||
@@ -0,0 +1,9 @@
|
||||
KILLED K34 backup-unchanged always passes (rc 1)
|
||||
KILLED K35 live-config-unchanged always passes (rc 1)
|
||||
KILLED K36 credential-ref-live accepts an env ref (rc 1)
|
||||
KILLED K37 data-root-scratch mismatch dropped (rc 1)
|
||||
KILLED K38 data-root-scratch missing root dropped (rc 1)
|
||||
KILLED K39 no-session ignores a launches dir (rc 1)
|
||||
KILLED K40 no INT/TERM/HUP trap (rc 1)
|
||||
KILLED K41 read-only Registry writes (rc 1)
|
||||
ALL_KILLED
|
||||
@@ -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
|
||||
@@ -1,149 +0,0 @@
|
||||
# Row 66: managed sessions run the unslop check on both harnesses
|
||||
|
||||
Sage, 2026-10-20. Brief: `docs/plans/2026-10-11_managed-session-config.md`, section "Managed
|
||||
sessions run the unslop check on both harnesses", blob 21ae25a328d4. Issue #1559. Reviewers:
|
||||
Dewey and Filbert. Built in the worktree `/mnt/storage/scratch/sage-b66` (branch `sage/row66`)
|
||||
on base 5487ed39d.
|
||||
|
||||
Candidate: see the commit line in the review request. `candidate-manifest.sha256` in this
|
||||
directory has the sha256 of each changed file at the candidate.
|
||||
|
||||
## What changed
|
||||
|
||||
**Wiring.** Both harness modules mount `tools/unslop-hook/lists.json`, `unslop-check.js` and
|
||||
the harness's own entry (pi `extension.ts`, Claude Code `claude-hook.js`) read-only at their
|
||||
own paths, and the bundle manifest lists the three in `sources` with their digests. Pi loads
|
||||
`extension.ts` as the second entry of `MOSAIC_EXTENSIONS`, after the gate extension. Claude
|
||||
Code's `settings.json` gains a Stop hook and a UserPromptSubmit hook, each
|
||||
`timeout -k 2 10 '<node>' '<dir>claude-hook.js' stop|prompt; exit 0`, hook timeout 20, no
|
||||
matcher. Neither can block a reply or a prompt.
|
||||
|
||||
**claude-hook.js** (new). `stop` checks each assistant message with text since the turn's
|
||||
prompt and logs `checked` or `flagged`. `prompt` checks the last reply and, when it has tells,
|
||||
prints the extension's notice, word for word, as `additionalContext`, once per reply (it reads
|
||||
the log for an earlier `notice_injected` with the same entry id). A broken lists.json logs
|
||||
`broken_lists`; any other fault logs `hook_failed`; the exit is always 0.
|
||||
|
||||
**The operator channel.** The runner sets `MOSAIC_UNSLOP_LOG` to `<run>/unslop.jsonl` after the
|
||||
module's variables, so a module can't point it elsewhere. After the landing turn and after each message turn it reads what the
|
||||
log gained (whole lines only, from an offset). The next RESULT it sends ends with a note. The
|
||||
whole note, both parts and the blank line before them, is at most 600 characters (r2):
|
||||
|
||||
- `[unslop: N reply|replies of this session flagged since the last note: <details>. The session gets a correction notice on its next turn.]`
|
||||
- `[unslop: the check is broken, so this session's replies go unchecked: <reason>]`, once a run.
|
||||
|
||||
That RESULT is what `mosaic talk` and `mosaic follow` print. A turn that sends no reply (an
|
||||
incoming RESULT, the landing turn) keeps its note for the next one. `note()` doesn't change
|
||||
state and `sent()` runs only after `message.send` succeeds, so a failed send keeps the note.
|
||||
`clip()` caps the body at 32000 characters plus its 33-character cut line before the note, so the
|
||||
sum is at most 32633, under the bus limit of 32768. The broken reason is cut at 300 where the
|
||||
runner takes it, from the log or from its own read failure, so the broken part is at most 371
|
||||
characters and the flagged part is cut to what is left.
|
||||
|
||||
**Counts.** `unslopCounts(path)` reads the log after the session ended:
|
||||
`{checked, flagged, patterns: {<source>: n}, notices, broken}`. A missing log gives zeros.
|
||||
Findings now carry `source`, the entry's pattern id (`ms-unslop:7`), so `patterns` is per id.
|
||||
|
||||
**The stack copy (r2).** `scripts/stack-copy.sh` `COPY_PATHS` gains `tools/unslop-hook`. Both
|
||||
modules join their files under `REPO`, and a session launched from a pinned copy has
|
||||
`REPO = copies/<sha>`. Without it every managed session from a copy past this row would fail at
|
||||
bundle build with ENOENT (Dewey P1, comment 29440).
|
||||
|
||||
## Round 2
|
||||
|
||||
Round 1 (candidate 8ec03b45a, manifest e68c6bce5ec4) got changes from Filbert (29439) and Dewey
|
||||
(29440). Round 2 carries:
|
||||
|
||||
- F1 (both): a note with both parts wasn't bounded. A 300-character reason, a long flagged part
|
||||
and a clipped answer came to 32934 (Filbert) or 33004 (Dewey), over 32768, and the broker
|
||||
refused the send. `note()` now gives the flagged part only the room the broken part leaves, and
|
||||
`fresh()` cuts every reason at 300, the runner's own read-failure reason included. Tests: the
|
||||
runner test with an answer of 40000 characters, a 400-character `hook_failed` reason and 30
|
||||
flagged replies (note ≤ 600, body ≤ 32768, cut line at 32000), and a unit check that both
|
||||
parts come to exactly 600 and that a read failure on a 250-character path is cut.
|
||||
- F2 (Filbert; Dewey's M3): no test failed a send through `main()`. The new runner test has the
|
||||
broker refuse the first RESULT. The note then rides the next RESULT. One correction to the
|
||||
F1 consequence as written: the broker marks a received message delivered, so the refused
|
||||
message isn't received again in that run. It stays unread and its sender gets no reply; the
|
||||
turn doesn't rerun.
|
||||
- P1 (Dewey): `COPY_PATHS` gains `tools/unslop-hook`. The copy fixtures in
|
||||
`stack-copy.test.mjs` and `pinned-copy.test.mjs` copy `tools/unslop-hook` too, and the
|
||||
pinned-copy top-level list gains `tools`. A new `stack-copy.test.mjs` test makes a copy, loads
|
||||
both harness modules from it, and checks that the adapter, the six unslop paths and the mounts
|
||||
are under the copy and exist.
|
||||
- N1 (Filbert): the two session-test comments say `<dir>/unslop.jsonl`.
|
||||
- N2 (Filbert), doc-only follow-up: the brief's `<run>/session/unslop.jsonl` (:463) and "print
|
||||
mode notifies nobody" (:477) are stale. The brief is Jason's text for this row; the deviation
|
||||
below records the path.
|
||||
- Dewey's notes, no change: the Claude Code Stop timing (finding 3 below) gets a look in the
|
||||
landing session; `patterns` keys per entry source, which is the brief's "per pattern".
|
||||
|
||||
r2 mutants, each killed by its intended test (`mutants-r2.log`):
|
||||
|
||||
| Mutant | Killed by |
|
||||
|---|---|
|
||||
| K1 the r1 `note()` cut | the clipped-answer runner test (the send is refused and it times out) and the unit check |
|
||||
| K2 `fresh()` doesn't cut the reason | the unit check |
|
||||
| K3 `sent()` before `message.send` | the refused-send runner test |
|
||||
| K4 `COPY_PATHS` without `tools/unslop-hook` | the copy test |
|
||||
|
||||
## Ship list against the brief
|
||||
|
||||
| Brief item | Where |
|
||||
|---|---|
|
||||
| A reply with a tell gets a correction notice on the next turn, both harnesses | `pi-session.test.mjs`, `claude-session.test.mjs`, row 66 four-turn tests: notices per request `[0, 1, 1, 2]`, notice text matched in the second request |
|
||||
| A clean reply gets none | same tests: turn 2 is clean and turn 3's request carries no new notice |
|
||||
| Broken lists.json fails closed: no clean verdict, operator told once, session alive | the row 66 broken-lists tests per harness: checked 0, flagged 0, first note broken, second note empty, the session answers both turns |
|
||||
| Counts readable after the session ends | `unslopCounts` on each session's log: `{checked: 4, flagged: 2, notices: 2, broken: null}`, patterns non-empty |
|
||||
| Interface 1 and the export list unchanged | no harness module export added or removed; the runner gains `unslopLog`, `unslopCounts`, `unslopNotes` |
|
||||
|
||||
## Findings made while building
|
||||
|
||||
1. **extension.ts read the wrong reply.** Pi 0.85.1 `getBranch()` returns entries root first
|
||||
(`session-manager.js:958-967` reverses the leaf-to-root walk). `before_agent_start` took the
|
||||
first assistant entry it met, which was the session's first reply, every turn. In the
|
||||
four-turn test the old code gave notices `[0, 1, 2]` over three turns (turn 3 re-noticed
|
||||
turn 1); walking from the end gives `[0, 1, 1]`. Fixed in `extension.ts`; this also affects
|
||||
the fleet copy under `~/.mosaic/tools`, which I didn't touch.
|
||||
2. **claude-hook replies() merged replies.** Grouping by `message.id` across the transcript
|
||||
merged two replies that shared an id (the test API answers `msg_mock` for all). It now groups
|
||||
adjacent assistant lines only, keyed by the first line's uuid. Before the fix turn 4 got no
|
||||
notice (`[0, 1, 1, 1]`).
|
||||
3. **Claude Code Stop timing.** On the first turn of a new session the transcript didn't yet
|
||||
hold the reply when Stop ran, so the hook checked `last_assistant_message` with an empty
|
||||
`entryId`. On resumed turns the reply was in the transcript. The verdict is the same either
|
||||
way; only the entry id differs.
|
||||
4. The finding detail format is `banned word "delve" x1`.
|
||||
|
||||
## Deviations
|
||||
|
||||
- **The log path is `<run>/unslop.jsonl`, not the brief's `<run>/session/unslop.jsonl`.** A
|
||||
thread run's session directory is the thread's store, and
|
||||
`packages/cli/tests/thread-launch.test.mjs:179` pins that a thread run keeps no per-run
|
||||
`session/`. My first build made `<run>/session` for the log and failed that test in the gate
|
||||
(452/453). The brief's other line, "`unslop.jsonl` in the run directory", still holds, and each
|
||||
run keeps its own log.
|
||||
|
||||
- **`packages/seat/tests/sandbox.test.mjs`** isn't an owned file. Its pin
|
||||
`binds(args).length === list.length - 1` broke because the modules now mount three more files.
|
||||
It is now `- 4`, and the test asserts the three unslop read-only binds by name.
|
||||
- **`scripts/stack-copy.sh`** and the two copy tests aren't owned files. The copy change is P1
|
||||
above. PB3 of the Part B window (`repin-partB-proof.md`) holds `scripts/stack-copy.sh` still
|
||||
until B7, so this row lands after the Part B window has run.
|
||||
- `MOSAIC_EXTENSIONS` is set through the pi module's `env`, not in the runner.
|
||||
- `unslop-check.js` gained the `source` field on findings (needed for per-pattern counts).
|
||||
|
||||
## Limits
|
||||
|
||||
- The log is in the run directory, which the session can write. A session could forge or erase
|
||||
its own counts. They are the session's report, not host evidence.
|
||||
- The pi extension's dedupe is in memory: after a restart, or when a turn's final message is
|
||||
tool-only, the same earlier reply can get a second notice.
|
||||
- `hook_failed`, `skipped_broken` and `broken_lists` all count as broken for the operator note.
|
||||
- No blocking (Q84 option B), no list changes, no skill changes: out of scope by the brief.
|
||||
|
||||
## Gate
|
||||
|
||||
Recorded with the candidate in the review request: test-unslop-check, the harness, plugins,
|
||||
seat and cli suites, and every `scripts/test-*.sh`. At landing Sage runs one managed session per
|
||||
harness and attaches the notice, the correction and the counts.
|
||||
@@ -0,0 +1,23 @@
|
||||
b99e7f4bf58ac68c8f5fa58eb7b90c0f85b15ca9a80fd093f6aaeafeac931608 adapters/README.md
|
||||
c32d2a3276db92433d4e3c4f4b45fab048d9a46119d9f69cead6a11cfacfeadb agents/sage/work/queue-66/BUILD.md
|
||||
8777fac3acb89f3af4ac0db2e7c44e309408a8ab8d1b0a0942c81c54ad295fa9 docs/TOOLS.md
|
||||
ea11398dc5751a0f9415079e7c03f413e97f5443f6bd0fc1fc2319ec646c20cb packages/cli/tests/stack-copy.test.mjs
|
||||
b027dc74583217b4151173ecbabfcfbfc7b108ac6b7a8e93dd21a2834051714f packages/harness/README.md
|
||||
bfb2156c609eeaf87c9e209c562576a5ef47535eaeee61d841bb0bd75a76a69d packages/harness/src/runner.mjs
|
||||
0a27f2c8de7b9191f9c0d4a209331c63ff7630923e669838b41715924b0a64b0 packages/harness/tests/bundle.test.mjs
|
||||
81d0352f2dcd41eb2e2ef962365d9b97515d7e8d9668a35bb9ce9dce4f1451ea packages/harness/tests/claude-session.test.mjs
|
||||
b16f33442cefd5b5ae2af85edab33250ee6b31025e437edf0e4eba9fe0afa793 packages/harness/tests/fixtures/fake-adapter.mjs
|
||||
1e187a2e23a197640d139e6975dfaf83dd7e337c360d128833678dc60f3e2046 packages/harness/tests/pi-session.test.mjs
|
||||
37ca6ad6467573658c1ac6807b6cd00e3f9da289107fb689190a1872bff9b89c packages/harness/tests/runner.test.mjs
|
||||
04c24058f37260c9192d5036c8cf28b50faba8e3e3bf57f198d233485d0d57e6 packages/plugins/harness/claude-code/index.mjs
|
||||
2c1252e8e8db3ba4551bf1e34156ddf4e933cc664fc157105bef9a2705d6ce3b packages/plugins/harness/pi/index.mjs
|
||||
7e0bea27d4360a39874fa64b3506a85f83888613a285ec0b087d74bf22b76421 packages/plugins/README.md
|
||||
ec4ec9502ee8e766e3abd04d9f724c0d8ff4ef114394a0f76d5320c2dc715c5c packages/plugins/tests/harness.test.mjs
|
||||
f6141dac9a8c61573e4198480fed85e093992ee9c19fb55e9bc2752381b7b07a packages/seat/tests/sandbox.test.mjs
|
||||
e60bf54c23e04436a982174348857458039a22e1769fc9ad4db8d4f9b57f2e86 packages/webui/tests/pinned-copy.test.mjs
|
||||
3ba3dbd9ff8dd68f0aa85bac92a8960af10166130bbc16df74fd7340776bb3b1 scripts/stack-copy.sh
|
||||
7ee6ed6e6ac55b1348d40a358372e66c86fdebacd90001d447040133e1187882 tools/unslop-hook/claude-hook.js
|
||||
ce1dfb11d0a8e8efd3d6dae67cc259c2e7443a62c7416bdedd4c71306d8c6df1 tools/unslop-hook/extension.ts
|
||||
f07d67806f40763b4be87d31c2976448b5cfc34d7328519fae2d8313b1ca3e20 tools/unslop-hook/README.md
|
||||
dbe3d5c9ae654d2401e3c496a6b03757a438e4a551fd8e460df8c82902bb80a7 tools/unslop-hook/test-unslop-check.js
|
||||
76be78178a0703b9d20a7df2278ecb13f429dea90cea45180f22302e267ea25e tools/unslop-hook/unslop-check.js
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,31 @@
|
||||
base 8ec03b45a dirty 11 DOCKER_HOST=unset
|
||||
unslop rc=0 | 28/28 passed
|
||||
pkg-bus rc=0 ℹ pass 451 ℹ fail 0 | ℹ duration_ms 3039.978
|
||||
pkg-business rc=0 ℹ pass 149 ℹ fail 0 | ℹ duration_ms 2119.713943
|
||||
pkg-cli rc=0 ℹ pass 454 ℹ fail 0 | ℹ duration_ms 294959.155129
|
||||
pkg-control-board rc=0 ℹ pass 124 ℹ fail 0 | ℹ duration_ms 590.346847
|
||||
pkg-conversation rc=0 ℹ pass 182 ℹ fail 0 | ℹ duration_ms 33735.620305
|
||||
pkg-discord rc=0 ℹ pass 183 ℹ fail 0 | ℹ duration_ms 2607.173768
|
||||
pkg-harness rc=0 ℹ pass 142 ℹ fail 0 | ℹ duration_ms 11398.667142
|
||||
pkg-ledger rc=0 ℹ pass 89 ℹ fail 0 | ℹ duration_ms 12920.093134
|
||||
pkg-mosaic rc=0 ℹ pass 69 ℹ fail 0 | ℹ duration_ms 2060.508354
|
||||
pkg-plugins rc=0 ℹ pass 111 ℹ fail 0 | ℹ duration_ms 245.928975
|
||||
pkg-project rc=0 ℹ pass 40 ℹ fail 0 | ℹ duration_ms 1052.940331
|
||||
pkg-runs rc=0 ℹ pass 43 ℹ fail 0 | ℹ duration_ms 741.129125
|
||||
pkg-seat rc=0 ℹ pass 151 ℹ fail 0 | ℹ duration_ms 5795.650378
|
||||
pkg-tasks rc=0 ℹ pass 97 ℹ fail 0 | ℹ duration_ms 1148.870662
|
||||
pkg-webui rc=0 ℹ pass 269 ℹ fail 0 | ℹ duration_ms 55714.69432
|
||||
script-auth rc=0 | selftest: 15 passed, 0 failed
|
||||
script-conductor rc=0 | selftest: 17 passed, 0 failed
|
||||
script-config rc=0 | selftest: 24 passed, 0 failed
|
||||
script-discord rc=0 | discord suite: 78 passed, 0 failed
|
||||
script-extension-package rc=0 | extension package selftest: 18 passed, 0 failed
|
||||
script-foundation rc=0 | selftest: 44 passed, 0 failed
|
||||
script-land-work rc=0 | land-work selftests: 86 passed, 0 failed
|
||||
script-queue rc=0 ℹ pass 156 ℹ fail 0 | queue suite: 28 passed, 0 failed
|
||||
script-release rc=0 | selftest: 14 passed, 0 failed
|
||||
script-task rc=0 | selftest: 98 passed, 0 failed
|
||||
GATE_PASSED
|
||||
removed network sage-b66_default
|
||||
exit=0
|
||||
2026-10-20T13:41:45Z
|
||||
@@ -0,0 +1,5 @@
|
||||
K1-note-r1 rc=1 KILLED ℹ pass 37 ℹ fail 2
|
||||
K2-reason-uncut rc=1 KILLED ℹ pass 38 ℹ fail 1
|
||||
K3-sent-first rc=1 KILLED ℹ pass 38 ℹ fail 1
|
||||
K4-no-copy-path rc=1 KILLED ℹ pass 12 ℹ fail 1
|
||||
MUTANTS_DONE
|
||||
@@ -0,0 +1,10 @@
|
||||
packages/harness/tests/runner.test.mjs rc=0 ℹ pass 39 ℹ fail 0
|
||||
packages/harness/tests/pi-session.test.mjs rc=0 ℹ pass 10 ℹ fail 0
|
||||
packages/harness/tests/claude-session.test.mjs rc=0 ℹ pass 10 ℹ fail 0
|
||||
packages/cli/tests/stack-copy.test.mjs rc=0 ℹ pass 13 ℹ fail 0
|
||||
packages/webui/tests/pinned-copy.test.mjs rc=0 ℹ pass 10 ℹ fail 0
|
||||
packages/cli/tests/stack-release.test.mjs rc=0 ℹ pass 151 ℹ fail 0
|
||||
packages/cli/tests/thread-launch.test.mjs rc=0 ℹ pass 11 ℹ fail 0
|
||||
packages/seat/tests/sandbox.test.mjs rc=0 ℹ pass 10 ℹ fail 0
|
||||
unslop rc=0 28/28 passed
|
||||
SINGLES_DONE
|
||||
@@ -117,3 +117,82 @@ d. Before piece 1 opens, I check on the live pin which role takes a piece
|
||||
|
||||
Mos passes this to Jason when piece 1 opens, not before. Lead decisions 94,
|
||||
114 and 115 stand.
|
||||
|
||||
## 2026-10-20T12:54Z trigger (a) met; check (d), the part an agent can read
|
||||
|
||||
Trigger (a): the Part A window put 490e2771d3d0 live (pm true, schema 3i,
|
||||
proof off), checked by Mos (mos-0928). That is the first window verified
|
||||
since mos-0855, and it carries 3i, so piece 1 may open.
|
||||
|
||||
Check (d), what I can read from this seat at 12:54Z:
|
||||
- `scripts/mosaic launches list` prints one run, r34520a581318,
|
||||
mosaic-stack/chief, claude-code claude-opus-5-5, by jason, since
|
||||
2026-10-20T07:58:02Z, running.
|
||||
- The bus, the Console (7332) and that run's runner all run from
|
||||
copies/490e2771d3d0, and the bus runs `bus start mosaic-stack --pm`.
|
||||
- What I can't check: that a human message to the PM gets answered. Every
|
||||
other `scripts/mosaic` verb refuses an agent run ("this runs only from a
|
||||
human shell"), and the tester switch is Jason's. That half needs a human
|
||||
send, or Mos's Part A STATE evidence if it holds one.
|
||||
|
||||
Rule (c) still applies: Part B (P19 385) is a window, so if Jason gives J1
|
||||
while a piece is open, Part B waits for that piece to close.
|
||||
|
||||
## 2026-10-20T12:55Z mos-0960: the first message to pm is a reachability check
|
||||
|
||||
Mos accepted the correction (no date) and agreed trigger (a) is met. Check
|
||||
(d) item 3 is not settled: the Part A window (W9) tested only the tester
|
||||
thread d0ba7a66, and S1 had no human thread, so no talk or follow answer
|
||||
from pm exists on 490e2771d3d0. Mos read the live business file: launch.by
|
||||
is chief, and pm is in launch.instances with cto, assistant, coder and
|
||||
reviewer, with no threadIdleSeconds. Mos's reading of cli.mjs:345 (not a
|
||||
test) is that a talk to pm tells the chief to launch pm.
|
||||
|
||||
Ruling:
|
||||
- Jason's first message to pm is a reachability check, not a piece, like
|
||||
6b5b7aef and a8df1e12 on 10-12.
|
||||
- If pm answers, his next real Mosaic Stack request is piece 1.
|
||||
- If pm doesn't answer, I file the defect and piece 1 doesn't open.
|
||||
- Rule (c) stands. Mos recommends to Jason that J1 and Part B go before
|
||||
piece 1 if he gives both together, so the pieces run on the final Console
|
||||
auth.
|
||||
|
||||
Mos tells Jason in the morning summary.
|
||||
|
||||
## 2026-10-20T17:48Z correction: on a pinned host Jason uses plain `mosaic` (Mos mos-0968)
|
||||
|
||||
This supersedes the 10-12 line at 18:56Z, "Until row 85 lands, Jason uses
|
||||
the full path, `/mnt/storage/src/mosaic-stack/scripts/mosaic talk pm
|
||||
"..."`". Row 85 landed, and the host, bus and Console now run from the
|
||||
pinned copy 490e2771d3d0. The bus takes human commands only from the tree
|
||||
it runs from. The canonical checkout is not that tree.
|
||||
|
||||
Jason ran the full-path command Mos gave him from that line. It refused
|
||||
with human-required: "the bus accepts these commands only from the tree it
|
||||
runs from; on a pinned host run `mosaic` on PATH". ~/.npm-global/bin/mosaic
|
||||
now execs copies/490e2771d3d0/scripts/mosaic, which I read at 17:48Z, so
|
||||
Jason uses plain `mosaic talk pm "..."` or the Console. Friction is on
|
||||
Vikunja #205, comment 369.
|
||||
|
||||
Nothing else changes. The refused command never reached pm, so the
|
||||
reachability check (mos-0960) has not happened and piece 1 is not open.
|
||||
|
||||
## 2026-10-20T17:54Z S7 reachability pass (Mos mos-0969)
|
||||
|
||||
Jason ran the plain PATH command from the 17:48Z entry. His output, as Mos
|
||||
relayed it in mos-0969:
|
||||
|
||||
```
|
||||
[jwoltje@dragon-lin winA]$ mosaic talk pm "Can you hear me?"
|
||||
sent 51cebff7 to pm
|
||||
pm isn't running, chief has been told
|
||||
pm (RESULT, re 51cebff7) cff1e9ad:
|
||||
Yes, I'm getting your messages. I haven't changed anything on the tracker. What do you need?
|
||||
```
|
||||
|
||||
pm was not running when 51cebff7 arrived. The chief launched it, and pm
|
||||
answered with cff1e9ad. Under mos-0960 this is the reachability check, not
|
||||
a piece. It passed, so the "pm doesn't answer" branch does not apply and
|
||||
no defect is filed.
|
||||
|
||||
Piece 1 is Jason's next real Mosaic Stack request to pm. It is not open yet.
|
||||
@@ -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,50 @@
|
||||
// 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) round 2 is approved by Filbert (comment 29447, queue rev 2234, committed cd2b377bb). Candidate 2c090eefd on code 2a5f3a3a0, base 4f02486d5, manifest digest c6f4f4c12596. Filbert's own run: 249 of 249 single-file tests pass, and their seven mutants (M1-M6 from round 1, plus one for the read-only registry) are all killed.",
|
||||
"Not landed yet. Mos's landing order holds (mos-0965, mos-0967): the candidate moves scripts/stack-release.sh off blob 2586a2a37cd2, which the Part B A2 check rests on, so D2 lands only after the Part B window closes (B7). If refactor moves past 4f02486d5 on any manifest path or otherwise before then, the full gate runs again on the new base before landing.",
|
||||
] },
|
||||
];
|
||||
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,51 @@
|
||||
// Sage, 2026-10-20: row 163 (D2, #1660) in review, round 2. 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-inreview-2.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) is in review, round 2, with Filbert as reviewer (request comment 29446, queue revs 2231-2233, committed c539995cf). Round 1 got changes from Filbert (comment 29445, rev 2230): six mutants survived because no test drove those checks to fail, plus a non-blocking note that a read-only session registry still wrote on remove().",
|
||||
"Round 2 adds the tests: R14 (a host that writes the live config, business file or backup fails live-config-unchanged or backup-unchanged), R15 (TERM, INT and HUP during the gate fail signal and still stop the host and Console and run the end checks), an env-ref case in R9, other-root and missing-root cases in R11, and a launches directory case in R4. The read-only registry now never writes its file. stack-release.sh is unchanged from round 1.",
|
||||
"Candidate 2c090eefd on code 2a5f3a3a0, base 4f02486d5; manifest digest c6f4f4c12596, 10 files, +1024/-13 (round 1 to round 2: 4 files, +85/-7). The gate passed on 2a5f3a3a0; mutants K34-K41 are all killed. Mos checked the manifest before the request (mos-0967). Packet: agents/sage/work/queue-163/r2/ in refactor 7af166574. Landing order is unchanged: after the Part B window closes (B7), with a new gate if the base moves.",
|
||||
] },
|
||||
];
|
||||
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,51 @@
|
||||
// Sage, 2026-10-20: row 163 (D2, #1660) in review, round 1. 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-inreview.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) is in review, round 1, with Filbert as reviewer (request comment 29444, queue revs 2228-2229, committed def677bc7). Candidate 4be3f6aec on 580500699, base 4f02486d5; manifest digest 430212299beb, 10 files, +947/-14. It adds `stack-release.sh rehearse`, which runs the D2 rehearsal on a scratch root from a backup, and `bus start --no-launch`, a host that launches no sessions, runs no thread tick and writes no sessions.json.",
|
||||
"The gate passed on 580500699 (all package and script suites; Docker network removed, 0 containers left). Mutants K1-K33 are all killed; four survived the first run (K11, K21, K27, K31) and each got a test. Packet: agents/sage/work/queue-163/ in refactor 51f67f1f8.",
|
||||
"Mos accepted deviations 1-7 in mos-0965; deviation 3 (the scratch business keeps its tracker key, with @sync on a placeholder ref) rests on mos-0964, since business.mjs:197 refuses a file without it. Landing order (mos-0965): the candidate moves stack-release.sh off blob 2586a2a37cd2, which the Part B A2 check rests on, so D2 lands only after the Part B window closes (B7). If the base moves before then, the gate runs again.",
|
||||
] },
|
||||
];
|
||||
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,13 @@
|
||||
{
|
||||
"at": "2026-10-20T15:41:54.066Z",
|
||||
"comments": [
|
||||
{
|
||||
"task": 337,
|
||||
"status": 201,
|
||||
"id": 368,
|
||||
"readBack": 200,
|
||||
"found": true
|
||||
}
|
||||
],
|
||||
"problems": []
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"at": "2026-10-20T15:26:21.265Z",
|
||||
"comments": [
|
||||
{
|
||||
"task": 337,
|
||||
"status": 201,
|
||||
"id": 367,
|
||||
"readBack": 200,
|
||||
"found": true
|
||||
}
|
||||
],
|
||||
"problems": []
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"at": "2026-10-20T14:49:31.177Z",
|
||||
"comments": [
|
||||
{
|
||||
"task": 337,
|
||||
"status": 201,
|
||||
"id": 366,
|
||||
"readBack": 200,
|
||||
"found": true
|
||||
}
|
||||
],
|
||||
"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.
|
||||
@@ -1347,3 +1347,17 @@ are never rewritten or removed; corrections are new entries.
|
||||
2026-10-20T10:36:45Z | Sage (Opus 5.5, 1ef1e4f8) | Part B window file draft 2 (mos-0957 F1, F2) and row 188 brief (#1686, P19 409/410) | draft 2 blob 55da51c0462b (148a9503d); Mos A2 recheck passed (mos-0958, ruling: B6 (1) applies to whichever chief B0's launches list shows, id recorded at B0); brief 240f21650, row 188 added and briefed (revs 2207-2208, 525990ecb), pushed 742e98976..525990ecb; Darkwing launched (sage-1278); Vikunja comments 358 (P19 385), 359 (409), 360 (410); nothing live changed
|
||||
2026-10-20T11:08:34Z | Filbert (Opus 5.5, 048f3c63) | row 188 r1 review (#1686, manifest 86aa549bad75) | approve, comment 29433, rev 2212; stack-release 151/151 on my slot run; M8-M10 and my M11 killed by T54; N1 REPO logical-path gap in stack-release.sh and webui-service.sh (mos-0959, filed after Part B)
|
||||
2026-10-20T11:14:11Z | Sage (Opus 5.5, 1ef1e4f8) | row 188 landing (#1686, P19 409/410), N1 to P19 | landed 8fc0ee216 (patch-id c9a56b167c07, stack-release.sh blob 2586a2a37cd2 unchanged), queue 31e328958 revs 2209-2214, pushed b9c1c9fe2..31e328958 with Filbert's 23a609c5c; #1686 comment 29435, closed; P19 411 (N1, after Part B per mos-0959), comments 361-363, 409/410 done; my done update blanked 409/410 descriptions, restored 11:13Z (restore-409-410-desc.mjs), script fixed
|
||||
2026-10-20T13:14:25Z | Sage (Opus 5.5, 1ef1e4f8) | row 66 build (#1559), row 42 trigger (mos-0960), row 163 brief | row 66 r1 in review: candidate 8ec03b45a (sage/row66, base 5487ed39d), manifest e68c6bce5ec4, revs 2215-2217, request 29437, Dewey and Filbert told (sage-1286-1289); gate r1 failed cli 452/453 (thread-launch.test.mjs:179, <run>/session made for the log), fixed to <run>/unslop.jsonl, gate r2 GATE_PASSED; row 42 run log: trigger (a) met, mos-0960 first pm message is a reachability check; row 163 brief owner sage, lands after Part B (6020bb43a), revs 2218-2219 (263ea8a9a)
|
||||
2026-10-20T13:18:38Z | Filbert (Opus 5.5, 048f3c63) | row 66 (#1559) r1 review of 8ec03b45a (manifest e68c6bce5ec4) | changes, comment 29439, rev 2221: F1 note with broken+flagged parts overflows the 32768 bus limit (probe 32934), F2 mutant M2 sent-before-send survives; 402/402 suites, M1 and M3 killed
|
||||
2026-10-20T13:52:08Z | Dewey (Opus 5.5, 84fb346c) | row 66 (#1559) r1 review of 8ec03b45a (manifest e68c6bce5ec4) | changes, comment 29440, rev 2222: P1 stack copies lack tools/unslop-hook (12 paths missing at candidate, 0 at base); concur F1 (note 971, sum 33004 > 32768) and F2 (sent() before send survives); N1 stale session/ comments; M1 M2 M4 M5 killed; singles 109/109, harness 140/140, plugins 111/111
|
||||
2026-10-20T13:56:35Z | Filbert (Opus 5.5, 048f3c63) | row 66 (#1559) r2 review of 0f4a57956 (manifest cee63e06fdea) | approve, comment 29442, rev 2226: F1/F2/N1 fixed, P1 copy path checked; 427/427 suites, M1-M5 killed
|
||||
2026-10-20T14:11:02Z | Dewey (Opus 5.5, 84fb346c) | row 66 (#1559) r2 review of 0f4a57956 (manifest cee63e06fdea) | approve, comment 29443, rev 2227: P1/F1/F2/N1 fixed (copy probe 0 of 16 missing; note 600, sum 32633); mutants N1-N4 killed, N5 survives (copy test skips claude-code bundle sources, non-blocking follow-up); singles 285/285, harness 142, plugins 111, cli 454, webui 269 + 1 unrelated skip
|
||||
2026-10-20T14:49:39Z | Sage (Opus 5.5, 1ef1e4f8) | row 163 (#1660) D2 rehearse + bus --no-launch: build, gate, mutants, in-review r1 | in-review, Filbert reviewer: candidate 4be3f6aec on 580500699, manifest 430212299beb, gate passed, K1-K33 killed; request comment 29444, revs 2228-2229 (def677bc7); P19 337 comment 366; lands after Part B B7 (mos-0965)
|
||||
2026-10-20T15:06:03Z | Filbert (Opus 5.5, 048f3c63) | row 163 r1 review (D2 rehearse, candidate 4be3f6aec, manifest 430212299beb) | changes, comment 29445 rev 2230: 247/247 singles pass; my mutants M1-M6 survive (F1 backup/live-config after-checks, F2 env ref, data-root-scratch, launches dir, F3 signal trap); N1 Registry.remove read-only
|
||||
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
|
||||
+62
-1
@@ -334,7 +334,6 @@ One read-only tree supplies the bus, Console and human CLI (row 85,
|
||||
`copy` requires a canonical checkout (.git directory); --allow-worktree
|
||||
is a test override. COMMIT must be reachable from both local refactor and
|
||||
origin/refactor. It archives the runtime/import closure, adapters, roles,
|
||||
`tools/unslop-hook` (the unslop check both harness modules mount, row 66),
|
||||
RELEASE, package.json and package-lock.json; refuses missing manifests;
|
||||
runs npm ci inside the temporary copy; removes write permissions; and
|
||||
renames beside the target. A published existing copy stays unchanged.
|
||||
@@ -529,6 +528,68 @@ on PATH; the checkout's `scripts/mosaic` exits 3 with `human-required` or
|
||||
`tester-required`, and queue writes stay on it ([packages/cli/README.md, One
|
||||
pinned copy](../packages/cli/README.md#one-pinned-copy-row-85-1579)).
|
||||
|
||||
#### Rehearsal on a scratch root (D2, row 163)
|
||||
|
||||
```
|
||||
scripts/stack-release.sh rehearse ID BUSINESS --backup ACTIVATION|PATH --port N [--timeout S]
|
||||
```
|
||||
|
||||
`rehearse` starts the target copy's bus host and Console on a copy of a
|
||||
backup and runs `activate`'s gate checks on them, all but the PM's, while the
|
||||
live stack keeps running. It reads the live config and a backup of the live
|
||||
store, so Jason or Mos runs it, as a window's pre-step. No development seat
|
||||
runs it against live data. It stops and starts no unit and writes no pointer
|
||||
or log line.
|
||||
|
||||
`--backup` takes an activation id under `<dataRoot>/backups/stack/` or an
|
||||
absolute path. `--port` is the scratch Console's loopback port, 1024 to
|
||||
65535. The run refuses `port-reserved` for 7332, 7333 and the pointer's
|
||||
`--port` and `--tester-port`, and `port-in-use` for a port something holds;
|
||||
it never picks another port. `--timeout` (default 60 seconds) bounds the
|
||||
gate and each stop.
|
||||
|
||||
Before it makes anything, it checks the pointer, the copy (present,
|
||||
reachable from refactor, a readable schema, a bus host with `--no-launch`),
|
||||
the port and the backup's `SHA256SUMS`. Then it makes
|
||||
`<dataRoot>/rehearsals/<UTC second>-<copy's first 12>/`, mode 0700:
|
||||
|
||||
- `data/bus/` holds a copy of the backup's store files, and
|
||||
`data/notify/BUSINESS/notify.json` is `{"notifyVersion":1,"binding":null}`.
|
||||
- `config/config.json` is the live config with `dataRoot` set to `data/`.
|
||||
- `config/businesses/BUSINESS.json` is the live business file with every
|
||||
`tracker.*` variable removed and every credential reference, the roles'
|
||||
and `tracker.sync`'s, pointed at its own 0600 file under `placeholders/`
|
||||
holding `rehearsal-placeholder-token`. `business.diff` shows the changes.
|
||||
Roles come from the target copy's `roles/`, as the live bus reads them.
|
||||
- `steps.log` holds the same PASS and FAIL lines the run prints, with times;
|
||||
`host.log`, `console.log` and, after a migration, `migrate.log` sit beside it.
|
||||
|
||||
A guard then loads the scratch config with the target's code and refuses
|
||||
with a named check: `data-root-live` when the scratch data root resolves to
|
||||
the live one, `sessions-live` when a `sessions.json` there names a running
|
||||
pid (the run never deletes the file), `data-root-scratch`, `notify-off`,
|
||||
`tracker-configured` when `bootConfig` still sets a tracker, and
|
||||
`credential-ref-live` for an env reference or a file outside `placeholders/`.
|
||||
When the backup's schema differs from the target's, the run checks that the
|
||||
target refuses the store, migrates it, opens it, migrates it again as a
|
||||
no-op, and that the pointer's copy refuses the migrated store.
|
||||
|
||||
It starts `scripts/mosaic bus start BUSINESS --no-launch` and the Console's
|
||||
`serve --port N --business BUSINESS` from the target copy, without `--send`.
|
||||
The gate needs the host's `launches disabled` line, a live `bus status` host
|
||||
with this run's pid and a boot from this start, the Console's address line,
|
||||
`/healthz` 200, and both command lines on the target copy. Then it checks
|
||||
that the host wrote no `sessions.json` and no `launches`, stops the Console
|
||||
and the host (SIGTERM, then SIGKILL and a FAIL after the timeout), checks that
|
||||
the host released `writer.lock`, and compares the backup and the live config
|
||||
and business files with sums it took before. The last line is
|
||||
`REHEARSAL_PASS` (exit 0) or `REHEARSAL_FAIL` (exit 1), then the directory.
|
||||
Usage exits 4. Nothing prunes rehearsal directories.
|
||||
|
||||
`bus start --no-launch` refuses every launch with `launches-disabled`,
|
||||
starts no thread ticker, and leaves an earlier host's `sessions.json` and
|
||||
its runs alone. No unit passes it, and `--pm` with it is a usage error.
|
||||
|
||||
## Console service (`scripts/webui-service.sh`)
|
||||
|
||||
```bash
|
||||
|
||||
@@ -280,7 +280,7 @@ shows only at start is found with the live stack stopped.
|
||||
|
||||
### Owner and reviewer
|
||||
|
||||
- Owner: set when the row is briefed.
|
||||
- Owner: sage (row 163, briefed 2026-10-20).
|
||||
- Reviewer: filbert.
|
||||
|
||||
### Files owned
|
||||
@@ -347,3 +347,8 @@ Mutants: one per behavior above.
|
||||
|
||||
As D1. D2 lands after D1, because both edit `stack-release.sh` and its
|
||||
test file.
|
||||
|
||||
D2 also lands after the Part B window (P19 385) has run. The Part B window
|
||||
file names the `stack-release.sh` blob, and any change to that blob means
|
||||
Mos checks the window again (mos-0958, mos-0959). The build and review can
|
||||
go ahead before then.
|
||||
@@ -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.
|
||||
+6
-3
@@ -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 2214 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 |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
@@ -102,7 +102,7 @@ Generated from `docs/plans/queue.json` revision 2214 by `queue render`. Do not e
|
||||
| 63 | Multi-project verbs: task verbs across the tree | darkwing; reviewers dewey, filbert | #1556 | done | 62 done | dewey and filbert approve on the issue naming the candidate manifest; tasks, harness and every scripts/test-*.sh green on Sage's gate rerun (sage) | `docs/plans/2026-10-11_multi-project-sync.md` § Multi-project verbs: task verbs across the tree (row B) | lands before row 90 (decision 125): both share packages/tasks/README.md and tests/verbs.test.mjs, so row 90 rebases after this lands |
|
||||
| 64 | Managed sessions get their role's skills on both harnesses | sage; reviewers darkwing, filbert | #1557 | done | 65 done | darkwing and filbert approve on #1557 naming the candidate manifest; business, harness, cli and every test-*.sh green on Sage's gate rerun; Sage's landing session on each harness shows the role's skills (sage) | `docs/plans/2026-10-11_managed-session-config.md` § Managed sessions get their role's skills on both harnesses | Landed (mos-0863/0864): 9f0039711 = c6d7d538f (range-diff =, patch-id 7e1948e2f2e9), F1 doc 6b6567b8b, packet ebfc23c43; gate-land GATE_PASSED 2384/0 on 6b6567b8b. D2 on #1557 comment 29228: claude-code coder rc13bcd555652 replied with its 4 skills, pi coder r3455505dd5da launch only (no pi credential on host, to Mos); both manifests list the 4 coder skills, sha256 = landed files. D3 window step 46de60893, D4 #1672 row 174. |
|
||||
| 65 | Model routing table: sessions resolve harness, model and thinking from one table | sage; reviewers darkwing, dewey | #1558 | done | 49 done, 131 done | Mos clears the routing table's public/ diff (decision 123, mos-0840) before in-review; darkwing and dewey approve on #1558; plugins, bus, business, harness, seat, cli, webui, test-*.sh green on Sage's rerun; landing session shows module, route, counted model, thinking; report names the mos-0840 view (sage) | `docs/plans/2026-10-11_managed-session-config.md` § Model routing table: sessions resolve harness, model and thinking from one table | Decision 94 hold ended (cutover live 17:21Z); restarted under decision 111 and Mos sage-0091. |
|
||||
| 66 | Managed sessions run the unslop check on both harnesses | sage; reviewers dewey, filbert | #1559 | briefed | 64 done | dewey and filbert approve on #1559 naming the candidate manifest; unslop, harness, cli and every test-*.sh green on Sage's gate rerun; Sage's landing session on each harness shows the notice, the correction and the counts (sage) | `docs/plans/2026-10-11_managed-session-config.md` § Managed sessions run the unslop check on both harnesses | Decision 94 hold ended (cutover live 17:21Z); restarted under decision 111 and Mos sage-0091. |
|
||||
| 66 | Managed sessions run the unslop check on both harnesses | sage; reviewers dewey, filbert | #1559 | in-review, round 2, request posted | 64 done | dewey and filbert approve on #1559 naming the candidate manifest; unslop, harness, cli and every test-*.sh green on Sage's gate rerun; Sage's landing session on each harness shows the notice, the correction and the counts (sage) | `docs/plans/2026-10-11_managed-session-config.md` § Managed sessions run the unslop check on both harnesses | Decision 94 hold ended (cutover live 17:21Z); restarted under decision 111 and Mos sage-0091. |
|
||||
| 67 | Decisions read follow-up: seen-then-resolve, list-before-503 and full-row tests | filbert; reviewers darkwing, dewey | #1560 | done | — | darkwing and dewey approve on #1560 naming the candidate manifest; bus, webui and every scripts/test-*.sh green on Sage's gate rerun (sage) | `docs/plans/2026-10-11_decisions-read-follow-up.md` § Decisions read follow-up: seen-then-resolve, list-before-503 and full-row tests | — |
|
||||
| 68 | Bus host runs an explicit Claude Code binary | sage; reviewers dewey, filbert | #1561 | done | — | Filbert and Dewey approve; cli, seat, harness suites and every scripts/test-*.sh green on Sage's gate rerun; unit reinstalled and opus probe through MOSAIC_CLAUDE_BIN (sage) | `docs/plans/2026-10-11_bus-claude-binary.md` § Bus host runs an explicit Claude Code binary | — |
|
||||
| 69 | Terminal app follow-up: narrow-terminal guard test, hidden-box way out and width tests | darkwing; reviewers dewey, filbert | #1562 | done | 55 done | dewey and filbert approve on the issue naming the candidate manifest; cli and queue node suites plus every scripts/test-*.sh green on Sage's gate rerun (sage) | `docs/plans/2026-10-11_terminal-app-follow-up.md` § Terminal app follow-up: narrow-terminal guard test, hidden-box way out and width tests | Sage accepts the two shortened texts in round 1 (hidden box way out, status line), both forced by the 40x10 frame. Follow-up at landing: when help is cut to fit, draw() points at mosaic inbox --json though help has no whole text; open it as its own row (build.md Found, not fixed) |
|
||||
@@ -199,7 +199,7 @@ Generated from `docs/plans/queue.json` revision 2214 by `queue render`. Do not e
|
||||
| 160 | I5 managed sessions as containers | researcher; reviewers dewey, filbert | #1655 | queued | 158 done | Filbert and Dewey approve on #1655 naming the candidate manifest; Sage's full gate green on the landing tree (sage) | `docs/plans/2026-10-18_containerized-install-rows.md` § I5 managed sessions as containers | Decision 96 orders I2-I6 after D1 and D2, which have no rows yet; the goal order sets priority |
|
||||
| 161 | I6 install mode, the pi worker's move and the documented limits | dewey; reviewers darkwing, filbert | #1656 | queued | 159 done, 160 done | Filbert and Darkwing approve on #1656 naming the candidate manifest; Sage's full gate green on the landing tree (sage) | `docs/plans/2026-10-18_containerized-install-rows.md` § I6 install mode, the pi worker's move and the documented limits | Decision 96 orders I2-I6 after D1 and D2, which have no rows yet. B2 is decision 148. The documented-limits section waits on Jason's answer to P19 333 (mos-0657) |
|
||||
| 162 | D1: health-gated stack activate and rollback (stack-release.sh) | darkwing; reviewers filbert | #1659 | done | — | Filbert approves the candidate manifest; cli, bus and webui suites and every scripts/test-*.sh green on Sage's gate (sage) | `docs/plans/2026-10-18_stack-release.md` § Stack activate and rollback, health-gated (D1) | P19 task 336 (agents/sage/work/vikunja-setup/d1-d2-rows-2026-10-18.json) |
|
||||
| 163 | D2: stack rehearsal on a scratch root (stack-release.sh rehearse) | sage; reviewers filbert | #1660 | queued | 162 done | Filbert approves the candidate manifest; cli, bus and webui suites and every scripts/test-*.sh green on Sage's gate (sage) | `docs/plans/2026-10-18_stack-release.md` § Stack rehearsal on a scratch root (D2) | P19 task 337 (agents/sage/work/vikunja-setup/d1-d2-rows-2026-10-18.json) |
|
||||
| 163 | D2: stack rehearsal on a scratch root (stack-release.sh rehearse) | sage; reviewers filbert | #1660 | done | 162 done | Filbert approves the candidate manifest; cli, bus and webui suites and every scripts/test-*.sh green on Sage's gate (sage) | `docs/plans/2026-10-18_stack-release.md` § Stack rehearsal on a scratch root (D2) | Landed 8ecc7a239 (author Sage) on 7f57419b3; patch-id b8d5f8a37c24 matches candidate c6f4f4c12596 and eaa0f2666, the commit the gate ran on (base 4ca8d6e94; 7f57419b3 adds only agents/sage/work/q175-sso/DESIGN.md, which no test reads). Sage gate passed 18:16Z on eaa0f2666: cli 472/472 and every package and scripts/test-*.sh green. Filbert approve 29447, rev 2234. Not live until a re-pin; Q173 backup and STATE apply there (mos-0971). |
|
||||
| 164 | Channel plug-in kind: cli and webui as its first modules (Q156 item 4, decision 149) | rocko; reviewers dewey | #1661 | done | 129 done | Dewey approves the candidate manifest; plugins, cli, bus and webui suites and every scripts/test-*.sh green on Sage's gate (sage) | `docs/plans/2026-10-18_channel-kind.md` § The channel kind, with cli and webui as its first modules | Design packet first, Sage sends it to Mos (human proof adjacent). Nothing goes between row 129 and this row (Mos mos-0726). |
|
||||
| 165 | The human adds and changes role instances: a CLI command and a Console view (P19 332) | darkwing; reviewers dewey, filbert | #1662 | done | 155 done | Mos relays Jason's confirmation before briefed; Dewey and Filbert approve on #1662 naming the candidate manifest; Mos's decision 123 go on the public/ diff; business, cli and webui suites and every scripts/test-*.sh green on Sage's gate (sage) (sage) | `docs/plans/2026-10-18_role-instance-verb.md` § The human adds and changes role instances | Briefed after 155 (b2f57ca70); hold lifted by mos-0805. Chief of staff stays out (mos-0787). sage-0918: lead approved file expansion (bus broker/runtime, webui cli.mjs, scripts/mosaic, commented channel-proof pin bump); new verbs human-only. Credentials/tracker stay out; second-reviewer gap with Mos (sage-0920). Acceptance 314 keeps the hand edit until this lands. Mos's decision 123 go on public/. |
|
||||
| 166 | stack-release.sh hardening after D1: drop-in precheck, is-active race, copy check against the target, quoted Environment= (row 162 follow-up) | darkwing; reviewers filbert | #1663 | done | 162 done | Filbert approves the candidate manifest; cli suite and every scripts/test-*.sh green on Sage's gate (sage) | `docs/plans/2026-10-18_stack-release-hardening.md` § Brief: stack-release.sh hardening after D1 | Landed 4b738e2da on 64922106d (pushed). It is Darkwing r2 build.patch, manifest 4fc0613f OK, patch-id 7a4fdc3bf53b equal to build.patch. Sage gate GATE_PASSED: 15 pkg suites 2399/0 (cli 364), 10 script suites green. Mos R1 (mos-0867): no quotes or drop-ins on live units. stack-release.sh blob now ed5b41d4677e; Window B P3 reruns on it (mos-0867 R2). Row 169 rebases onto 4b738e2da. |
|
||||
@@ -225,6 +225,9 @@ Generated from `docs/plans/queue.json` revision 2214 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 -->
|
||||
|
||||
|
||||
+1212
-11
File diff suppressed because it is too large.
Load diff
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"
|
||||
}
|
||||
}
|
||||
@@ -57,7 +57,7 @@ export const USAGE = `usage:
|
||||
mosaic stop <run>
|
||||
mosaic launches off|on [--business <id>]
|
||||
mosaic launches list [--json]
|
||||
mosaic bus start <business> [--pm]
|
||||
mosaic bus start <business> [--pm | --no-launch]
|
||||
mosaic bus stop
|
||||
mosaic bus status [--json]`;
|
||||
|
||||
@@ -482,8 +482,11 @@ async function launches(rest, io, deps) {
|
||||
async function bus(rest, io, deps) {
|
||||
const [sub, ...args] = rest;
|
||||
if (sub === "start") {
|
||||
const parsed = parse(args, { flags: ["--pm"] });
|
||||
const parsed = parse(args, { flags: ["--pm", "--no-launch"] });
|
||||
if (parsed.positional.length !== 1) throw usage("bus start takes one business");
|
||||
// Row 163: --no-launch is the D2 rehearsal's host. No unit passes it.
|
||||
const launches = !parsed.flags.has("--no-launch");
|
||||
if (!launches && parsed.flags.has("--pm")) throw usage("bus start takes --pm or --no-launch, not both");
|
||||
const businessId = parsed.positional[0];
|
||||
const system = deps.system ?? loadSystem({ env: io.env });
|
||||
// Fail closed, and say so (vikunja:19/302): a refused load is recorded where the Console and
|
||||
@@ -500,9 +503,9 @@ async function bus(rest, io, deps) {
|
||||
const binding = readNotifyConfig(system.dataRoot, businessId);
|
||||
const host = await startHost({ boot, business: businessId, notifier: binding ? { binding } : null });
|
||||
try {
|
||||
const launcher = createLauncher({ host, system, env: io.env, tracker: Boolean(boot.trackers?.[businessId]), ...(deps.launcher ?? {}) });
|
||||
const launcher = createLauncher({ host, system, env: io.env, tracker: Boolean(boot.trackers?.[businessId]), launches, ...(deps.launcher ?? {}) });
|
||||
await launcher.listen();
|
||||
io.stdout.write(`bus host up: business ${businessId}, socket ${host.path}, notifier ${binding ?? "off"}\n`);
|
||||
io.stdout.write(`bus host up: business ${businessId}, socket ${host.path}, notifier ${binding ?? "off"}${launches ? "" : ", launches disabled"}\n`);
|
||||
if (parsed.flags.has("--pm")) {
|
||||
const pm = await launcher.launchPm();
|
||||
io.stdout.write(`launched ${pm.instance}: run ${pm.run}, ${pm.harness} ${pm.model}\n`);
|
||||
|
||||
@@ -153,7 +153,11 @@ function call(name, what, fn) {
|
||||
}
|
||||
|
||||
// host: startHost()'s handle. tracker: true when the broker has task verbs for
|
||||
// the business. makeLoader (the plug-in loader's factory, which tests point at a
|
||||
// the business. launches: false for `bus start --no-launch` (row 163, the D2
|
||||
// rehearsal): start() refuses launches-disabled, listen() starts no ticker, a
|
||||
// session's launch request is refused, recover() kills and ends nothing, and
|
||||
// sessions.json stays as it was.
|
||||
// makeLoader (the plug-in loader's factory, which tests point at a
|
||||
// scratch tree), bwrap, readOnly, sessionDefaults, harnessVersion,
|
||||
// stopTimeoutMs, threadTickMs and git (createGitSocket options) exist for
|
||||
// the tests; the command line never sets them.
|
||||
@@ -162,6 +166,7 @@ export function createLauncher({
|
||||
system,
|
||||
env = process.env,
|
||||
tracker = false,
|
||||
launches = true,
|
||||
makeLoader = createLoader,
|
||||
bwrap = BWRAP,
|
||||
readOnly = [],
|
||||
@@ -187,7 +192,7 @@ export function createLauncher({
|
||||
} catch (e) {
|
||||
throw new CliError(`${e.message}; check which sessions still run, then move the file aside`, 3);
|
||||
}
|
||||
const registry = new Registry(dataRoot);
|
||||
const registry = new Registry(dataRoot, { readOnly: !launches });
|
||||
const children = new Map();
|
||||
let chain = Promise.resolve();
|
||||
let closed = false;
|
||||
@@ -323,6 +328,8 @@ export function createLauncher({
|
||||
|
||||
// Bundle, spawn, bind, register, hand over the capability.
|
||||
async function start({ instance, launchedBy, resolved, model, account = null, thread = null }) {
|
||||
// Every launch path ends here, so a --no-launch host spawns nothing whichever path asked.
|
||||
if (!launches) throw new Refusal("launches-disabled", "this host was started with --no-launch");
|
||||
// A thread's store is checked first, so a bad id or an unsafe directory
|
||||
// launches nothing.
|
||||
let store = null;
|
||||
@@ -627,6 +634,11 @@ export function createLauncher({
|
||||
await host.op({ op: "refuse", cap, code: "not-a-role" }).catch(() => {});
|
||||
return { ok: false, error: "not-a-role" };
|
||||
}
|
||||
if (!launches) {
|
||||
await host.op({ op: "refuse", cap, code: "launches-disabled" }).catch(() => {});
|
||||
record({ event: "refused", by: who.role, instance, code: "launches-disabled" });
|
||||
return { ok: false, error: "launches-disabled" };
|
||||
}
|
||||
let checked;
|
||||
try {
|
||||
if (typeof instance !== "string") throw new Refusal("invalid-request");
|
||||
@@ -675,6 +687,13 @@ export function createLauncher({
|
||||
// This broker never bound those runs, so each is bound again first with
|
||||
// its identical launch record, which the broker accepts.
|
||||
async function recover() {
|
||||
// Row 163 (mos-0961): a --no-launch host never signals or ends an earlier host's session. Its
|
||||
// data root may hold a sessions.json that names live processes, so it only says what it left.
|
||||
if (!launches) {
|
||||
for (const s of leftover) log(`run ${s.run} (${s.instance}) from an earlier host: left as it is, this host was started with --no-launch`);
|
||||
leftover = [];
|
||||
return;
|
||||
}
|
||||
for (const s of leftover) {
|
||||
if (startTimeOf(s.pid) === s.startTime && (cmdlineOf(s.pid) ?? [])[0] === bwrap) {
|
||||
try {
|
||||
@@ -744,7 +763,7 @@ export function createLauncher({
|
||||
});
|
||||
chmodSync(path, 0o600);
|
||||
await gitSocket.listen();
|
||||
if (b.launch) ticker = setInterval(() => tick().catch((e) => log(`thread tick: ${e.message}`)), threadTickMs);
|
||||
if (b.launch && launches) ticker = setInterval(() => tick().catch((e) => log(`thread tick: ${e.message}`)), threadTickMs);
|
||||
}
|
||||
|
||||
// SIGTERM every runner, wait, then end what's left. Runs before the host
|
||||
|
||||
@@ -811,6 +811,29 @@ test("bus start runs until bus stop; status reports it while it runs", async (t)
|
||||
assert.match(again.stdout, /no host runs/);
|
||||
});
|
||||
|
||||
// Row 163 (mos-0961): the D2 rehearsal's host.
|
||||
test("bus start --no-launch says launches are disabled in its host-up line and stops cleanly; with --pm it is a usage error", async (t) => {
|
||||
const f = fixture(tmp(t));
|
||||
notifyConfig(f.dataRoot, "acme", null);
|
||||
const both = spawnSync(process.execPath, [CLI, "bus", "start", "acme", "--pm", "--no-launch"], { env: f.env, encoding: "utf8", timeout: 40000 });
|
||||
assert.equal(both.status, 4, both.stderr);
|
||||
assert.match(both.stderr, /bus start takes --pm or --no-launch, not both/);
|
||||
assert.equal(existsSync(join(f.dataRoot, "bus", "writer.lock")), false);
|
||||
const child = spawn(process.execPath, [CLI, "bus", "start", "acme", "--no-launch"], { env: f.env, stdio: ["ignore", "pipe", "pipe"] });
|
||||
t.after(() => child.exitCode === null && child.kill("SIGKILL"));
|
||||
let out = "";
|
||||
child.stdout.on("data", (b) => (out += b));
|
||||
child.stderr.on("data", (b) => (out += b));
|
||||
await until(() => /bus host up: business acme.*\n/.test(out), 30000);
|
||||
assert.match(out, /bus host up: business acme, socket \S+, notifier off, launches disabled\n/);
|
||||
// The launcher got launches false: its Registry is read-only, so no sessions.json appears.
|
||||
assert.equal(existsSync(join(f.dataRoot, "bus-host", "sessions.json")), false);
|
||||
const exit = once(child, "exit");
|
||||
const stop = spawnSync(process.execPath, [CLI, "bus", "stop"], { env: f.env, encoding: "utf8", timeout: 70000 });
|
||||
assert.equal(stop.status, 0, stop.stderr);
|
||||
assert.equal((await exit)[0], 0, out);
|
||||
});
|
||||
|
||||
test("bus-service.sh renders the unit and installs it into a given directory", (t) => {
|
||||
const dir = tmp(t);
|
||||
const script = join(REPO, "scripts", "bus-service.sh");
|
||||
@@ -818,6 +841,8 @@ test("bus-service.sh renders the unit and installs it into a given directory", (
|
||||
assert.equal(render.status, 0, render.stderr);
|
||||
assert.match(render.stdout, new RegExp(`ExecStart=${REPO.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/scripts/mosaic bus start %i`));
|
||||
assert.match(render.stdout, /RestartPreventExitStatus=2 3 4/);
|
||||
// Row 163: --no-launch is for the D2 rehearsal's own host only; no unit starts a bus with it.
|
||||
assert.doesNotMatch(render.stdout, /--no-launch/);
|
||||
// One session credential file, optional so Pi sessions start without it.
|
||||
// The README's paragraph names the same file; nothing else does.
|
||||
const files = render.stdout.match(/^EnvironmentFile=.*$/gm);
|
||||
|
||||
@@ -15,11 +15,12 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { Client } from "../../bus/src/client.mjs";
|
||||
import { gitSocketPath, launchLogFile, launchSocketPath, readSessions, Registry, SESSION_CREDENTIALS, sessionsFile, workspaceDir } from "../../seat/src/session.mjs";
|
||||
import { sandboxProblem } from "../../seat/src/sandbox.mjs";
|
||||
import { gitSocketPath, launchLogFile, launchSocketPath, readSessions, Registry, runsDir, SESSION_CREDENTIALS, sessionsFile, workspaceDir } from "../../seat/src/session.mjs";
|
||||
import { BWRAP, sandboxProblem } from "../../seat/src/sandbox.mjs";
|
||||
import { childrenOf } from "../../seat/src/proc.mjs";
|
||||
import { gitEnv } from "../../seat/src/checkout.mjs";
|
||||
import { bootConfig, loadSystem } from "../src/config.mjs";
|
||||
import { startHost, startTimeOf } from "../src/host.mjs";
|
||||
import { cmdlineOf, startHost, startTimeOf } from "../src/host.mjs";
|
||||
import { createLauncher } from "../src/launcher.mjs";
|
||||
import { mockAnthropic, REPO } from "../../harness/tests/helpers.mjs";
|
||||
import { fakeHarnesses, fixture, tmp } from "./helpers.mjs";
|
||||
@@ -901,6 +902,56 @@ for (const exited of [false, true]) {
|
||||
});
|
||||
}
|
||||
|
||||
// Row 163 (mos-0961): the D2 rehearsal host. A live bwrap process the test owns stands in for an
|
||||
// earlier host's session; its outer bwrap is what recover() would kill, and its child stands in
|
||||
// for the runner that close() would signal.
|
||||
test("bus start --no-launch leaves an earlier host's live session alone: no signal, no end, sessions.json as it was", { skip, timeout: 30000 }, async (t) => {
|
||||
const given = prepare(t);
|
||||
const { f } = given;
|
||||
const box = spawn(BWRAP, ["--ro-bind", "/", "/", "--dev", "/dev", "--proc", "/proc", "--unshare-pid", "--die-with-parent", "sleep", "60"], { stdio: "ignore" });
|
||||
t.after(() => box.kill("SIGKILL"));
|
||||
const pid = box.pid;
|
||||
const startTime = await until(() => startTimeOf(pid));
|
||||
const runnerPid = await until(() => childrenOf(pid)[0]);
|
||||
const runnerStartTime = await until(() => startTimeOf(runnerPid));
|
||||
assert.equal(cmdlineOf(pid)[0], BWRAP, "recover() would take it for a session");
|
||||
new Registry(f.dataRoot).add({ business: "acme", instance: "chief", run: "run-left", harness: "pi", pid, startTime, runnerPid, runnerStartTime });
|
||||
const before = readFileSync(sessionsFile(f.dataRoot), "utf8");
|
||||
|
||||
const s = await setup(t, undefined, given, { launches: false });
|
||||
assert.ok(s.logs.includes("run run-left (chief) from an earlier host: left as it is, this host was started with --no-launch"), s.logs.join("\n"));
|
||||
assert.equal(startTimeOf(pid), startTime, "listen() sent no signal");
|
||||
assert.equal(readFileSync(sessionsFile(f.dataRoot), "utf8"), before);
|
||||
assert.deepEqual((await s.client.call("trail", { subject: "run-left" })).filter((r) => r.kind === "session.ended"), []);
|
||||
assert.equal(await s.close(), 0);
|
||||
assert.equal(startTimeOf(pid), startTime, "close() sent no signal either");
|
||||
assert.equal(startTimeOf(runnerPid), runnerStartTime);
|
||||
assert.equal(readFileSync(sessionsFile(f.dataRoot), "utf8"), before);
|
||||
});
|
||||
|
||||
test("bus start --no-launch refuses every launch: the PM launch and a session's request, each logged, nothing spawned", { skip, timeout: 30000 }, async (t) => {
|
||||
const s = await setup(t, undefined, undefined, { launches: false });
|
||||
await assert.rejects(s.launcher.launchPm(), (e) => e.exitCode === 3 && /^PM launch refused: launches-disabled/.test(e.message));
|
||||
const sock = launchSocketPath(s.f.dataRoot);
|
||||
assert.deepEqual(await ask(sock, `${JSON.stringify({ cap: s.cto.cap, instance: "coder" })}\n`), { ok: false, error: "launches-disabled" });
|
||||
assert.deepEqual(log(s.f).map((e) => [e.event, e.instance, e.code]), [["refused", "chief", "launches-disabled"], ["refused", "coder", "launches-disabled"]]);
|
||||
assert.deepEqual(readSessions(s.f.dataRoot), []);
|
||||
const runs = runsDir(s.f.dataRoot, "acme");
|
||||
assert.deepEqual(existsSync(runs) ? readdirSync(runs) : [], [], "no run directory");
|
||||
assert.equal(await s.close(), 0);
|
||||
});
|
||||
|
||||
test("bus start --no-launch starts no thread tick; the same host with launches runs it", { skip, timeout: 30000 }, async (t) => {
|
||||
for (const launches of [false, true]) {
|
||||
const asked = [];
|
||||
const wrap = (host) => Object.create(host, { op: { value: async (m) => (["threadsState", "landHolders"].includes(m.op) && asked.push(m.op), host.op(m)) } });
|
||||
const s = await setup(t, undefined, undefined, { launches, threadTickMs: 50, wrap });
|
||||
await new Promise((r) => setTimeout(r, 400));
|
||||
assert.equal(asked.length > 0, launches, `launches ${launches}: ${asked.join(", ")}`);
|
||||
assert.equal(await s.close(), 0);
|
||||
}
|
||||
});
|
||||
|
||||
test("a malformed sessions.json refuses the host start with exit 3 and stays as it was", { timeout: 30000 }, async (t) => {
|
||||
const given = prepare(t);
|
||||
const file = sessionsFile(given.f.dataRoot);
|
||||
|
||||
@@ -14,7 +14,7 @@ export function setup(t, realPin=false){
|
||||
const root=mkdtempSync(join(tmpdir(),'stack-copy-')),repo=join(root,'repo'),copies=join(root,'copies'),bin=join(root,'bin');
|
||||
t.after(()=>{execFileSync('chmod',['-R','u+w',root]);rmSync(root,{recursive:true,force:true})});
|
||||
mkdirSync(repo);mkdirSync(bin);
|
||||
const tracked=execFileSync('git',['-C',ROOT,'ls-files','-co','--exclude-standard','--','scripts','packages','adapters','roles','tools/unslop-hook','RELEASE','package.json','package-lock.json',':!packages/*/tests',':!scripts/foundation'],{encoding:'utf8'}).trim().split('\n');
|
||||
const tracked=execFileSync('git',['-C',ROOT,'ls-files','-co','--exclude-standard','--','scripts','packages','adapters','roles','RELEASE','package.json','package-lock.json',':!packages/*/tests',':!scripts/foundation'],{encoding:'utf8'}).trim().split('\n');
|
||||
for(const f of tracked)if(existsSync(join(ROOT,f)))cpSync(join(ROOT,f),join(repo,f));
|
||||
// Lightweight npm-ci fixture; real pin/host/proof integration is tested separately.
|
||||
const pkg={name:'copy-fixture',version:'1.0.0'};
|
||||
@@ -31,20 +31,6 @@ export function setup(t, realPin=false){
|
||||
const fake=body=>{writeFileSync(join(bin,'systemctl'),'#!/bin/bash\n'+body);chmodSync(join(bin,'systemctl'),0o755)};
|
||||
return {root,repo,copies,bin,id,env,run,fake,search,analyze};
|
||||
}
|
||||
// Row 66: a session launched from a pinned copy reads each harness module's files from the copy,
|
||||
// so every path a module names under its REPO (adapter, mounts, bundle sources) is in COPY_PATHS.
|
||||
test('a copy carries every file the harness modules read from it',async t=>{
|
||||
const s=setup(t);assert.equal(s.run('stack-copy.sh',['copy',s.id,'--copies',s.copies]).status,0);
|
||||
const copy=join(s.copies,s.id),paths=[];
|
||||
for(const kind of ['pi','claude-code']){
|
||||
const m=await import(pathToFileURL(join(copy,'packages','plugins','harness',kind,'index.mjs')).href);
|
||||
// pi's mounts() resolves node_modules, which this fixture copy lacks; its unslop mounts are its
|
||||
// bundle sources, so the sources stand in for them.
|
||||
paths.push(m.adapter(),...(kind==='pi'?Object.values(m.bundle().sources):m.mounts({env:{}}).map(x=>x.src)));
|
||||
}
|
||||
assert.equal(paths.filter(p=>p.includes('/tools/unslop-hook/')).length,6,paths.join('\n'));
|
||||
for(const p of paths){assert.ok(p.startsWith(copy+'/'),p);assert.ok(existsSync(p),p);}
|
||||
});
|
||||
test('shared copy: npm ci, pushed reachability, readonly/existing copy, webui delegation',t=>{
|
||||
const s=setup(t),r=s.run('stack-copy.sh',['copy',s.id,'--copies',s.copies]);assert.equal(r.status,0,r.stderr);
|
||||
const copy=join(s.copies,s.id);assert.equal(statSync(copy).mode&0o777,0o555);
|
||||
|
||||
@@ -1705,3 +1705,382 @@ test('T54 row 188: a carried pair gets the proof file checks, and a proof file i
|
||||
await noLeak(e);
|
||||
});
|
||||
});
|
||||
|
||||
// --- rehearse (D2, row 163; the brief's "Stack rehearsal on a scratch root") ---
|
||||
// Copies of their own in RCOPIES: the real scripts/mosaic, config loader, business loader, roles and
|
||||
// mosaic-config.mjs, with stand-ins for the bus host (cli.mjs), the Console, the Store and
|
||||
// migrate-schema.mjs. A rehearsal starts its processes with only HOME, PATH and MOSAIC_CONFIG, so
|
||||
// the stand-ins take their faults from $HOME/faults and record what they did in $HOME.
|
||||
|
||||
// The bus host. bus start writes the store's writer.lock, a meta row in the store, and its state
|
||||
// beside the scratch store, and prints the host-up line; without --no-launch it starts the PM, which here means a line in
|
||||
// $HOME/starts.log and a sessions.json. bus status --json answers from that state.
|
||||
function rHost(){
|
||||
const fs=process.getBuiltinModule('node:fs'),path=process.getBuiltinModule('node:path');
|
||||
const id=path.basename(path.resolve(import.meta.dirname,'..','..','..')),home=process.env.HOME,a=process.argv.slice(2);
|
||||
const fault=(n)=>fs.existsSync(path.join(home,'faults',n));
|
||||
const alive=(pid)=>{try{const s=fs.readFileSync(`/proc/${pid}/stat`,'utf8');return s[s.lastIndexOf(')')+2]!=='Z'}catch{return false}};
|
||||
const data=JSON.parse(fs.readFileSync(process.env.MOSAIC_CONFIG,'utf8')).dataRoot,state=path.join(data,'fixture-host.json');
|
||||
const flags=["--pm","--no-launch"];
|
||||
if(a.join(' ')==='bus status --json'){
|
||||
if(fault(`status-${id}`))process.exit(1);
|
||||
let s={host:null,boot:null};try{s=JSON.parse(fs.readFileSync(state,'utf8'));s.host.live=alive(s.host.pid)}catch{}
|
||||
if(fault('status-other-host')&&s.host)s.host.pid=process.ppid;
|
||||
console.log(JSON.stringify(s));process.exit(0);
|
||||
}
|
||||
if(a[0]!=='bus'||a[1]!=='start'||!a.slice(3).every((f)=>flags.includes(f))){console.error(`fixture host: ${a.join(' ')}`);process.exit(4)}
|
||||
fs.appendFileSync(path.join(home,'host-args.log'),`${a.join(' ')}\n`);
|
||||
if(fault(`host-fails-${id}`)){console.error('fixture host: refused');process.exit(1)}
|
||||
const b=a[2],off=a.includes('--no-launch'),lock=path.join(data,'bus','writer.lock'),at=new Date().toISOString();
|
||||
fs.writeFileSync(lock,String(process.pid),{flag:'wx'});
|
||||
const db=new (process.getBuiltinModule('node:sqlite').DatabaseSync)(path.join(data,'bus','bus.sqlite'));
|
||||
db.prepare("INSERT OR REPLACE INTO meta(key,value) VALUES('fixture_host_start',?)").run(at);db.close();
|
||||
fs.writeFileSync(state,JSON.stringify({host:{business:b,pid:process.pid,live:true,startedAt:at,notifier:'off'},boot:{ok:true,business:b,at}}));
|
||||
if(fault('host-launches-dir'))fs.mkdirSync(path.join(data,'launches'));
|
||||
// host-touches lists files a careless host would write: the live config, business file or backup.
|
||||
if(fault('host-touches'))for(const f of fs.readFileSync(path.join(home,'faults','host-touches'),'utf8').split('\n').filter(Boolean))fs.appendFileSync(f,'x');
|
||||
if(!off||fault('host-launches')){
|
||||
fs.appendFileSync(path.join(home,'starts.log'),`pm-${b}\n`);
|
||||
fs.mkdirSync(path.join(data,'bus-host'),{recursive:true});
|
||||
fs.writeFileSync(path.join(data,'bus-host','sessions.json'),JSON.stringify({sessionsVersion:1,sessions:[{pid:process.pid}]}));
|
||||
}
|
||||
console.log(`bus host up: business ${b}, socket ${path.join(data,'bus','broker.sock')}, notifier off${off?', launches disabled':''}`);
|
||||
process.on('SIGTERM',()=>{if(fault('host-ignores-term'))return;if(!fault('host-keeps-lock'))fs.rmSync(lock,{force:true});process.exit(0)});
|
||||
setInterval(()=>{},1<<30);
|
||||
}
|
||||
|
||||
// The Console: serve on the given loopback port; /healthz answers 200 unless healthz-<copy> is set.
|
||||
function rConsole(){
|
||||
const fs=process.getBuiltinModule('node:fs'),path=process.getBuiltinModule('node:path'),http=process.getBuiltinModule('node:http');
|
||||
const id=path.basename(path.resolve(import.meta.dirname,'..','..','..')),home=process.env.HOME,a=process.argv.slice(2);
|
||||
const fault=(n)=>fs.existsSync(path.join(home,'faults',n));
|
||||
fs.appendFileSync(path.join(home,'console-args.log'),`${a.join(' ')}\n`);
|
||||
if(a[0]!=='serve'){console.error('fixture Console: refused');process.exit(1)}
|
||||
const s=http.createServer((q,r)=>{r.writeHead(q.url==='/healthz'&&!fault(`healthz-${id}`)?200:500);r.end()});
|
||||
s.listen(Number(a[a.indexOf('--port')+1]),'127.0.0.1',()=>console.log(`Mosaic Console: http://127.0.0.1:${s.address().port}/\nBoard: fixture`));
|
||||
process.on('SIGTERM',()=>process.exit(0));
|
||||
}
|
||||
|
||||
// migrate-schema.mjs: sets its copy's SCHEMA_VERSION, or exit 1 under migrate-fails.
|
||||
function rMigrate(){
|
||||
const fs=process.getBuiltinModule('node:fs'),path=process.getBuiltinModule('node:path'),{DatabaseSync}=process.getBuiltinModule('node:sqlite');
|
||||
const home=process.env.HOME,data=process.argv[2];
|
||||
const v=/^export const SCHEMA_VERSION = '([A-Za-z0-9]+)';$/m.exec(fs.readFileSync(path.join(import.meta.dirname,'store.mjs'),'utf8'))[1];
|
||||
fs.appendFileSync(path.join(home,'migrate.log'),`${data}\n`);
|
||||
if(fs.existsSync(path.join(home,'faults','migrate-fails'))){console.error('fixture migrate: failed');process.exit(1)}
|
||||
const db=new DatabaseSync(path.join(data,'bus','bus.sqlite'));db.prepare("UPDATE meta SET value=? WHERE key='schema_version'").run(v);db.close();
|
||||
console.log(`bus schema ${v} ready`);
|
||||
}
|
||||
|
||||
// The copy's Store: refuses a store at another version with schema-mismatch, as the real one does.
|
||||
const rStore=(v,froms)=>`${storeText(v,froms)}import {DatabaseSync} from 'node:sqlite';
|
||||
import {join} from 'node:path';
|
||||
export class Store {
|
||||
constructor(root) {
|
||||
this.db = new DatabaseSync(join(root, 'bus', 'bus.sqlite'));
|
||||
if (this.db.prepare("SELECT value FROM meta WHERE key='schema_version'").get()?.value !== SCHEMA_VERSION) { this.db.close(); throw Error('schema-mismatch'); }
|
||||
}
|
||||
get(sql, ...args) { return this.db.prepare(sql).get(...args); }
|
||||
close() { this.db.close(); }
|
||||
}
|
||||
`;
|
||||
|
||||
// B (3g) and C (3h) have --no-launch. A is a copy from before row 163, whose host has no such flag.
|
||||
// C1's scripts/mosaic runs B's host, as a copy-check failure needs.
|
||||
const RCOPIES=join(base,'rcopies');mkdirSync(RCOPIES);
|
||||
for(const [id,v,froms] of [[B,'3g',['3f']],[C,'3h',['3f','3g']],[A,'3g',['3f']],[C1,'3g',['3f']]]){
|
||||
const d=join(RCOPIES,id);
|
||||
for(const p of ['scripts/mosaic','scripts/mosaic-config.mjs','packages/cli/src/config.mjs','packages/cli/src/errors.mjs','packages/bus/src/business.mjs','packages/plugins/src/kinds.mjs','packages/business/src','roles'])cpSync(join(repo,p),join(d,p),{recursive:true});
|
||||
if(id===C1)writeFileSync(join(d,'scripts','mosaic'),`#!/usr/bin/env bash\nexec node "${join(RCOPIES,B,'packages','cli','src','cli.mjs')}" "$@"\n`,{mode:0o755});
|
||||
for(const [f,text] of [['packages/cli/src/cli.mjs',id===A?'// fixture host from before row 163\nprocess.exit(1);\n':script(rHost)],['packages/webui/src/cli.mjs',script(rConsole)],['packages/bus/src/store.mjs',rStore(v,froms)],['packages/bus/src/migrate-schema.mjs',script(rMigrate)],['packages/bus/src/broker.mjs','export class BusError extends Error { constructor(code) { super(code); this.code = code; } }\n']]){mkdirSync(dirname(join(d,f)),{recursive:true});writeFileSync(join(d,f),text)}
|
||||
execFileSync('chmod',['-R','a-w',d]);
|
||||
}
|
||||
|
||||
const TESTER='7499';
|
||||
const freePort=async()=>{const s=createServer();s.listen(0,'127.0.0.1');await once(s,'listening');const p=String(s.address().port);await new Promise((r)=>s.close(r));return p};
|
||||
const rh=(e,...p)=>join(e.env.HOME,...p);
|
||||
const rfault=(e,name)=>{mkdirSync(rh(e,'faults'),{recursive:true});writeFileSync(rh(e,'faults',name),'')};
|
||||
|
||||
// A root for one rehearsal: a stopped live store at `store`, a pointer by hand on `copy` with the
|
||||
// Console on e.port and the tester listener on TESTER, and a backup as backup_store writes one.
|
||||
// A rehearsal's leftovers, found by their scratch MOSAIC_CONFIG, are killed after the test.
|
||||
async function rmk(t,{copy=B,store='3g'}={}){
|
||||
const e=await mk(t);
|
||||
t.after(()=>{
|
||||
for(const pid of readdirSync('/proc').filter((n)=>/^\d+$/.test(n)))
|
||||
try{if(readFileSync(`/proc/${pid}/environ`,'utf8').includes(`MOSAIC_CONFIG=${e.data}/rehearsals/`))process.kill(Number(pid),'SIGKILL')}catch{}
|
||||
});
|
||||
const db=new DatabaseSync(e.store);
|
||||
db.exec('PRAGMA journal_mode=WAL');
|
||||
db.exec('CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)');
|
||||
db.exec('CREATE TABLE events (id INTEGER PRIMARY KEY, at TEXT NOT NULL)');
|
||||
db.prepare("INSERT INTO meta VALUES ('schema_version', ?)").run(store);
|
||||
for(let i=0;i<3;i++)db.prepare('INSERT INTO events (at) VALUES (?)').run(`2000-01-01T00:00:0${i}.000Z`);
|
||||
db.close();
|
||||
mkdirSync(e.state,{recursive:true});
|
||||
writeFileSync(e.pointer,JSON.stringify({copy,business:'acme',pm:true,claude:e.claude,console:['--port',e.port,'--business','acme','--tester-port',TESTER],schema:store,activation:'manual',at:'2026-10-20T00:00:00Z'})+'\n');
|
||||
e.act=`20261020T000000Z-${copy.slice(0,12)}`;e.backup=join(e.data,'backups','stack',e.act);
|
||||
mkdirSync(join(e.backup,'bus'),{recursive:true,mode:0o700});
|
||||
cpSync(e.store,join(e.backup,'bus','bus.sqlite'));
|
||||
writeFileSync(join(e.backup,'SHA256SUMS'),`${sha(e.store)} bus.sqlite\n`);
|
||||
for(const [f,text] of [['DATA_ROOT',e.data],['SCHEMA',store],['STAMP','2026-10-20T00:00:00.000Z']])writeFileSync(join(e.backup,f),`${text}\n`);
|
||||
e.biz=join(dirname(e.cfg),'businesses','acme.json');
|
||||
return e;
|
||||
}
|
||||
|
||||
// What a rehearsal may never change: the live store, config, business file, pointer and log, and
|
||||
// the backup, by sha256 of every file.
|
||||
const untouched=(e)=>JSON.stringify([...[e.store,e.cfg,e.biz,e.pointer].map((f)=>[f,sha(f)]),existsSync(e.log),...readdirSync(e.backup,{recursive:true}).sort().map((f)=>join(e.backup,f)).filter((f)=>statSync(f).isFile()).map((f)=>[f,sha(f)])]);
|
||||
|
||||
async function rehearse(e,id,...args){
|
||||
const ch=spawn('bash',[e.sr??SR,'rehearse',id,'acme',...args,'--copies',RCOPIES,'--bin',e.bin],{env:e.env,stdio:['ignore','pipe','pipe']});
|
||||
let out='',err='';ch.stdout.on('data',(d)=>{out+=d});ch.stderr.on('data',(d)=>{err+=d});
|
||||
e.during?.(ch);
|
||||
const timer=setTimeout(()=>ch.kill('SIGKILL'),120000);
|
||||
const [code]=await once(ch,'close');clearTimeout(timer);
|
||||
const m=/\nrehearsal directory: (.*)\n$/.exec(out);
|
||||
return {code,out,err,dir:m?m[1]:null,names:out.split('\n').filter((l)=>/^(PASS|FAIL) /.test(l)).map((l)=>l.replace(/^(PASS|FAIL) ([^:]*).*/,'$1 $2'))};
|
||||
}
|
||||
// A script of its own whose text has `from` replaced, for the guard tests. The replacement is a
|
||||
// function so a shell "$$" in `to` stays as written.
|
||||
function ownScript(e,from,to){
|
||||
const own=join(e.root,'repo');cpSync(repo,own,{recursive:true});
|
||||
e.sr=join(own,'scripts','stack-release.sh');
|
||||
const s=readFileSync(e.sr,'utf8');assert.ok(s.includes(from),`${e.sr} has no ${from}`);writeFileSync(e.sr,s.replace(from,()=>to));
|
||||
}
|
||||
const rDirs=(e)=>existsSync(join(e.data,'rehearsals'))?readdirSync(join(e.data,'rehearsals')):[];
|
||||
const PRE=['PASS copy','PASS port','PASS backup','PASS scratch-root','PASS data-root-live','PASS sessions-live','PASS data-root-scratch','PASS notify-off','PASS tracker-configured','PASS credential-ref-live'];
|
||||
const POST=['PASS console-stopped','PASS host-stopped','PASS host-released-store','PASS backup-unchanged','PASS live-config-unchanged'];
|
||||
|
||||
test('R1 rehearse passes on the same schema; the scratch root holds its files and the live files stay byte-identical',async(t)=>{
|
||||
const e=await rmk(t),before=untouched(e),port=await freePort();
|
||||
const r=await rehearse(e,B,'--backup',e.act,'--port',port,'--timeout','10');
|
||||
assert.equal(r.code,0,r.out+r.err);
|
||||
assert.deepEqual(r.names,[...PRE,'PASS migration','PASS gate','PASS no-session',...POST]);
|
||||
assert.ok(r.out.endsWith(`REHEARSAL_PASS\nrehearsal directory: ${r.dir}\n`),r.out);
|
||||
assert.equal(r.err,'');
|
||||
assert.equal(untouched(e),before);
|
||||
const R=r.dir;assert.equal(dirname(R),join(e.data,'rehearsals'));assert.match(basename(R),new RegExp(`^\\d{8}T\\d{6}Z-${B.slice(0,12)}$`));
|
||||
for(const d of [dirname(R),R,join(R,'placeholders'),join(R,'config')])assert.equal(statSync(d).mode&0o777,0o700,d);
|
||||
for(const f of ['steps.log','backup.before','live.before','business.diff','host.log','console.log','config/config.json','data/bus/bus.sqlite'])assert.ok(existsSync(join(R,f)),f);
|
||||
// The host wrote the scratch store, a copy: the backup has no such row.
|
||||
const metaOf=(f)=>{const db=new DatabaseSync(f,{readOnly:true});try{return db.prepare("SELECT value FROM meta WHERE key='fixture_host_start'").get()?.value}finally{db.close()}};
|
||||
assert.match(metaOf(join(R,'data','bus','bus.sqlite')),/^\d{4}-/);assert.equal(metaOf(join(e.backup,'bus','bus.sqlite')),undefined);
|
||||
// The scratch config differs from the live one only in dataRoot.
|
||||
assert.deepEqual(JSON.parse(readFileSync(join(R,'config','config.json'),'utf8')),{...JSON.parse(readFileSync(e.cfg,'utf8')),dataRoot:join(R,'data')});
|
||||
// The notifier config: binding null at 0600.
|
||||
const nf=join(R,'data','notify','acme','notify.json');
|
||||
assert.deepEqual(JSON.parse(readFileSync(nf,'utf8')),{notifyVersion:1,binding:null});assert.equal(statSync(nf).mode&0o777,0o600);
|
||||
// The scratch business: no tracker.* variable, and every reference a 0600 placeholder with its date kept.
|
||||
const live=JSON.parse(readFileSync(e.biz,'utf8')),sb=JSON.parse(readFileSync(join(R,'config','businesses','acme.json'),'utf8'));
|
||||
assert.ok(Object.keys(live.vars).some((k)=>k.startsWith('tracker.')));
|
||||
assert.deepEqual(Object.keys(sb.vars).filter((k)=>k.startsWith('tracker.')),[]);
|
||||
const refs=[...Object.entries(sb.roles).flatMap(([n,r])=>Object.entries(r.credentials??{}).map(([s,ref])=>[`${n}-${s}`,ref,live.roles[n].credentials[s]])),...Object.entries(sb.tracker.sync.credentials).map(([s,ref])=>[`@sync-${s}`,ref,live.tracker.sync.credentials[s]])];
|
||||
assert.ok(refs.length>=2);
|
||||
for(const [name,ref,old] of refs){
|
||||
const f=join(R,'placeholders',name);
|
||||
assert.deepEqual(ref,{...old,file:f});assert.equal(readFileSync(f,'utf8'),'rehearsal-placeholder-token');assert.equal(statSync(f).mode&0o777,0o600);
|
||||
}
|
||||
assert.deepEqual(readdirSync(join(R,'placeholders')).sort(),refs.map(([n])=>n).sort());
|
||||
// Everything else in the business is as it was.
|
||||
const strip=(b)=>{const c=structuredClone(b);for(const k of Object.keys(c.vars))if(k.startsWith('tracker.'))delete c.vars[k];for(const r of Object.values(c.roles))delete r.credentials;delete c.tracker.sync.credentials;return c};
|
||||
assert.deepEqual(strip(sb),strip(live));
|
||||
const diff=readFileSync(join(R,'business.diff'),'utf8');assert.match(diff,/^-.*"tracker\.baseUrl"/m);assert.match(diff,/^\+.*placeholders\/pm-/m);
|
||||
// The host ran with launches disabled and started no PM; the Console got no --send.
|
||||
assert.deepEqual(lines(rh(e,'host-args.log')),['bus start acme --no-launch']);
|
||||
assert.deepEqual(lines(rh(e,'console-args.log')),[`serve --port ${port} --business acme`]);
|
||||
assert.equal(existsSync(rh(e,'starts.log')),false);
|
||||
assert.equal(existsSync(join(R,'data','bus','writer.lock')),false);
|
||||
assert.deepEqual(lines(join(R,'steps.log')).map((l)=>l.replace(/^\S+ /,'')),r.out.split('\n').filter((l)=>/^(PASS|FAIL) |^REHEARSAL_/.test(l)));
|
||||
});
|
||||
|
||||
test('R2 rehearse runs the migration checks when the schema differs; the live store stays at 3g',async(t)=>{
|
||||
const e=await rmk(t),before=untouched(e),port=await freePort();
|
||||
const r=await rehearse(e,C,'--backup',e.backup,'--port',port,'--timeout','10');
|
||||
assert.equal(r.code,0,r.out+r.err);
|
||||
assert.deepEqual(r.names,[...PRE,'PASS new-refuses-old-store','PASS migrate','PASS new-opens-migrated','PASS migrate-again','PASS old-refuses-migrated','PASS gate','PASS no-session',...POST]);
|
||||
assert.ok(r.out.includes('PASS migrate: 3g to 3h\n'),r.out);
|
||||
assert.equal(untouched(e),before);assert.equal(storeVersion(e.store,false),'3g');
|
||||
assert.equal(storeVersion(join(r.dir,'data','bus','bus.sqlite'),false),'3h');
|
||||
assert.equal(storeVersion(join(r.dir,'refuse','bus','bus.sqlite'),false),'3g');
|
||||
assert.deepEqual(lines(rh(e,'migrate.log')),[join(r.dir,'data'),join(r.dir,'data')]);
|
||||
});
|
||||
|
||||
test('R3 a failing migration gives REHEARSAL_FAIL migrate and starts nothing',async(t)=>{
|
||||
const e=await rmk(t),before=untouched(e);rfault(e,'migrate-fails');
|
||||
const r=await rehearse(e,C,'--backup',e.act,'--port',await freePort(),'--timeout','3');
|
||||
assert.equal(r.code,1,r.out+r.err);
|
||||
assert.deepEqual(r.names,[...PRE,'PASS new-refuses-old-store','FAIL migrate','PASS backup-unchanged','PASS live-config-unchanged']);
|
||||
assert.ok(r.out.includes('FAIL migrate: migrate-schema.mjs exit 1: fixture migrate: failed\n'),r.out);
|
||||
assert.ok(r.out.endsWith(`REHEARSAL_FAIL\nrehearsal directory: ${r.dir}\n`),r.out);
|
||||
assert.equal(existsSync(rh(e,'host-args.log')),false);assert.equal(existsSync(rh(e,'console-args.log')),false);
|
||||
assert.equal(untouched(e),before);
|
||||
});
|
||||
|
||||
test('R4 a host that does not start, does not answer status, reports another pid, launches anyway, makes a launches directory or keeps its lock each fails by name',async(t)=>{
|
||||
for(const [f,name,why] of [[`host-fails-${B}`,'host-up','exited'],[`status-${B}`,'bus-status','bus status failed'],['status-other-host','bus-status','the host this run started'],['host-launches','no-session','bus-host/sessions.json'],['host-launches-dir','no-session','found launches under'],['host-keeps-lock','host-released-store','writer.lock is still there']]){
|
||||
const e=await rmk(t),before=untouched(e);rfault(e,f);
|
||||
const r=await rehearse(e,B,'--backup',e.act,'--port',await freePort(),'--timeout','3');
|
||||
assert.equal(r.code,1,f+r.out+r.err);
|
||||
assert.ok(r.names.includes(`FAIL ${name}`),f+r.out);assert.equal(r.names.filter((n)=>n.startsWith('FAIL')).length,1,f+r.out);
|
||||
assert.ok(r.out.split('\n').some((l)=>l.startsWith(`FAIL ${name}: `)&&l.includes(why)),f+r.out);
|
||||
assert.ok(r.names.includes('PASS console-stopped')&&r.names.includes('PASS backup-unchanged'),f+r.out);
|
||||
assert.ok(r.out.endsWith(`REHEARSAL_FAIL\nrehearsal directory: ${r.dir}\n`),r.out);
|
||||
assert.equal(untouched(e),before);
|
||||
}
|
||||
});
|
||||
|
||||
test('R5 a host that ignores SIGTERM gets SIGKILL and FAIL host-stopped',async(t)=>{
|
||||
const e=await rmk(t);rfault(e,'host-ignores-term');
|
||||
const r=await rehearse(e,B,'--backup',e.act,'--port',await freePort(),'--timeout','2');
|
||||
assert.equal(r.code,1,r.out+r.err);
|
||||
assert.ok(/^FAIL host-stopped: pid \d+ was still running 2s after SIGTERM; sent SIGKILL$/m.test(r.out),r.out);
|
||||
});
|
||||
|
||||
test('R6 a Console that does not answer /healthz gives REHEARSAL_FAIL healthz',async(t)=>{
|
||||
const e=await rmk(t);rfault(e,`healthz-${B}`);
|
||||
const r=await rehearse(e,B,'--backup',e.act,'--port',await freePort(),'--timeout','3');
|
||||
assert.equal(r.code,1,r.out+r.err);
|
||||
assert.ok(r.out.includes('FAIL healthz: no answer within 3s\n'),r.out);
|
||||
assert.deepEqual(r.names.filter((n)=>n.startsWith('FAIL')),['FAIL healthz']);
|
||||
});
|
||||
|
||||
test('R7 a host on another copy fails copy-match',async(t)=>{
|
||||
const e=await rmk(t);
|
||||
const r=await rehearse(e,C1,'--backup',e.act,'--port',await freePort(),'--timeout','3');
|
||||
assert.equal(r.code,1,r.out+r.err);
|
||||
assert.ok(r.out.includes(`FAIL copy-match: the host's command line is not ${join(RCOPIES,C1)}'s cli.mjs bus start acme --no-launch\n`),r.out);
|
||||
});
|
||||
|
||||
test('R8 a scratch port in use or reserved refuses, and the rehearsal takes no other port',async(t)=>{
|
||||
const e=await rmk(t),before=untouched(e);
|
||||
const held=createServer();held.listen(0,'127.0.0.1');await once(held,'listening');t.after(()=>held.close());
|
||||
const p=String(held.address().port);
|
||||
const r=await rehearse(e,B,'--backup',e.act,'--port',p);
|
||||
assert.equal(r.code,1,r.out+r.err);
|
||||
assert.equal(r.out,`PASS copy: ${B}, schema 3g\nFAIL port-in-use: 127.0.0.1:${p} is in use; the rehearsal takes no other port\nREHEARSAL_FAIL\n`);
|
||||
for(const q of ['7332','7333',e.port,TESTER]){
|
||||
const s=await rehearse(e,B,'--backup',e.act,'--port',q);
|
||||
assert.equal(s.code,1,s.out+s.err);
|
||||
assert.equal(s.out,`PASS copy: ${B}, schema 3g\nFAIL port-reserved: --port ${q} is a live Console port (7332, 7333 and the pointer's: ${e.port} ${TESTER})\nREHEARSAL_FAIL\n`);
|
||||
}
|
||||
assert.deepEqual(rDirs(e),[]);assert.equal(existsSync(rh(e,'host-args.log')),false);assert.equal(existsSync(rh(e,'console-args.log')),false);
|
||||
assert.equal(untouched(e),before);
|
||||
});
|
||||
|
||||
test('R9 mos-0963: a role reference left outside placeholders/, or left on an env variable, fails credential-ref-live before the host starts',async(t)=>{
|
||||
const e=await rmk(t);
|
||||
// The live pm gitea reference names a file that is there, as a live token would be.
|
||||
const tok=join(e.root,'live-gitea.token');writeFileSync(tok,'live-token-value',{mode:0o600});
|
||||
const b=JSON.parse(readFileSync(e.biz,'utf8'));b.roles.pm.credentials.gitea.file=tok;writeFileSync(e.biz,JSON.stringify(b,null,2)+'\n',{mode:0o600});
|
||||
const before=untouched(e);
|
||||
ownScript(e,'for(const [name,r] of Object.entries(b.roles??{})){strip(r?.vars);swap(name,r?.credentials)}','for(const [name,r] of Object.entries(b.roles??{})){strip(r?.vars);if(name!=="pm")swap(name,r?.credentials)}');
|
||||
const r=await rehearse(e,B,'--backup',e.act,'--port',await freePort(),'--timeout','3');
|
||||
assert.equal(r.code,1,r.out+r.err);
|
||||
assert.ok(r.out.includes(`FAIL credential-ref-live: pm gitea is ${tok}, not a file in ${join(r.dir,'placeholders')}\n`),r.out);
|
||||
assert.deepEqual(r.names.slice(-3),['FAIL credential-ref-live','PASS backup-unchanged','PASS live-config-unchanged']);
|
||||
assert.equal(existsSync(rh(e,'host-args.log')),false);assert.equal(existsSync(rh(e,'console-args.log')),false);
|
||||
assert.equal(untouched(e),before);
|
||||
// An env reference names no file at all; the guard says which variable it is.
|
||||
const f=await rmk(t);
|
||||
const fb=JSON.parse(readFileSync(f.biz,'utf8'));const {file,...rest}=fb.roles.pm.credentials.gitea;fb.roles.pm.credentials.gitea={...rest,env:'REHEARSAL_GITEA_TOKEN'};writeFileSync(f.biz,JSON.stringify(fb,null,2)+'\n',{mode:0o600});
|
||||
ownScript(f,'for(const [name,r] of Object.entries(b.roles??{})){strip(r?.vars);swap(name,r?.credentials)}','for(const [name,r] of Object.entries(b.roles??{})){strip(r?.vars);if(name!=="pm")swap(name,r?.credentials)}');
|
||||
const s=await rehearse(f,B,'--backup',f.act,'--port',await freePort(),'--timeout','3');
|
||||
assert.equal(s.code,1,s.out+s.err);
|
||||
assert.ok(s.out.includes('FAIL credential-ref-live: pm gitea is env REHEARSAL_GITEA_TOKEN\n'),s.out);
|
||||
assert.equal(existsSync(rh(f,'host-args.log')),false);
|
||||
});
|
||||
|
||||
test('R10 mos-0962: a scratch business that still sets a tracker fails tracker-configured',async(t)=>{
|
||||
const e=await rmk(t);
|
||||
const proj=join(e.root,'project');mkdirSync(join(proj,'.mosaic'),{recursive:true});
|
||||
writeFileSync(join(proj,'.mosaic','project.json'),JSON.stringify({projectVersion:1,id:'stack',vars:{'tracker.project':7}}),{mode:0o600});
|
||||
const b=JSON.parse(readFileSync(e.biz,'utf8'));b.projects.stack.root=proj;writeFileSync(e.biz,JSON.stringify(b,null,2)+'\n',{mode:0o600});
|
||||
ownScript(e,'if(k.startsWith("tracker."))delete vars[k]','if(k.startsWith("tracker-kept."))delete vars[k]');
|
||||
const r=await rehearse(e,B,'--backup',e.act,'--port',await freePort(),'--timeout','3');
|
||||
assert.equal(r.code,1,r.out+r.err);
|
||||
assert.ok(r.out.includes('FAIL tracker-configured: bootConfig sets trackers for acme\n'),r.out);
|
||||
assert.equal(existsSync(rh(e,'host-args.log')),false);
|
||||
});
|
||||
|
||||
test('R11 mos-0961: a scratch config on the live data root or on any root but the scratch one, or a sessions.json naming a running pid, refuses by name',async(t)=>{
|
||||
// mosaic-config already refuses the live path itself, which contains the scratch config directory;
|
||||
// the guard catches a path that reaches the live data root through a link.
|
||||
const e=await rmk(t),link=join(e.root,'live-link');symlinkSync(e.data,link);
|
||||
ownScript(e,'c.dataRoot=data;',`c.dataRoot=${JSON.stringify(link)};`);
|
||||
const r=await rehearse(e,B,'--backup',e.act,'--port',await freePort(),'--timeout','3');
|
||||
assert.equal(r.code,1,r.out+r.err);
|
||||
assert.ok(r.out.includes(`FAIL data-root-live: the scratch config names dataRoot ${link}, which resolves to the live data root ${e.data}\n`),r.out);
|
||||
assert.equal(existsSync(rh(e,'host-args.log')),false);
|
||||
// A root that is neither live nor this rehearsal's, and one that is not there.
|
||||
for(const [dir,made,msg] of [['other-root',true,(d,R)=>`the scratch config resolves to ${d}, not ${join(R,'data')}`],['no-root',false,(d)=>`${d} does not exist`]]){
|
||||
const g=await rmk(t),d=join(g.root,dir);if(made)mkdirSync(d);
|
||||
ownScript(g,'c.dataRoot=data;',`c.dataRoot=${JSON.stringify(d)};`);
|
||||
const q=await rehearse(g,B,'--backup',g.act,'--port',await freePort(),'--timeout','3');
|
||||
assert.equal(q.code,1,dir+q.out+q.err);
|
||||
assert.ok(q.out.includes(`FAIL data-root-scratch: ${msg(d,q.dir)}\n`),dir+q.out);
|
||||
assert.equal(existsSync(rh(g,'host-args.log')),false);
|
||||
}
|
||||
const f=await rmk(t);
|
||||
ownScript(f,`printf '{"notifyVersion":1,"binding":null}\\n' > "$R/data/notify/$BUSINESS/notify.json"`,`printf '{"notifyVersion":1,"binding":null}\\n' > "$R/data/notify/$BUSINESS/notify.json" && mkdir "$R/data/bus-host" && printf '{"sessionsVersion":1,"sessions":[{"pid":%s}]}\\n' "$$" > "$R/data/bus-host/sessions.json"`);
|
||||
const s=await rehearse(f,B,'--backup',f.act,'--port',await freePort(),'--timeout','3');
|
||||
assert.equal(s.code,1,s.out+s.err);
|
||||
assert.ok(/^FAIL sessions-live: \S+\/bus-host\/sessions\.json names running pid\(s\) \d+$/m.test(s.out),s.out);
|
||||
// The file is left as it was: the rehearsal never deletes it and retries.
|
||||
assert.ok(existsSync(join(s.dir,'data','bus-host','sessions.json')));
|
||||
assert.equal(existsSync(rh(f,'host-args.log')),false);
|
||||
});
|
||||
|
||||
test('R12 mos-0962: a notifier config with a binding fails notify-off',async(t)=>{
|
||||
const e=await rmk(t);
|
||||
ownScript(e,`printf '{"notifyVersion":1,"binding":null}\\n'`,`printf '{"notifyVersion":1,"binding":"discord"}\\n'`);
|
||||
const r=await rehearse(e,B,'--backup',e.act,'--port',await freePort(),'--timeout','3');
|
||||
assert.equal(r.code,1,r.out+r.err);
|
||||
assert.ok(r.out.includes('FAIL notify-off: '),r.out);
|
||||
assert.equal(existsSync(rh(e,'host-args.log')),false);
|
||||
});
|
||||
|
||||
test('R13 the pre-checks: an old copy, a backup that fails its sums, a missing backup or pointer, and usage',async(t)=>{
|
||||
const e=await rmk(t),before=untouched(e),port=await freePort();
|
||||
const old=await rehearse(e,A,'--backup',e.act,'--port',port);
|
||||
assert.equal(old.code,1);assert.equal(old.out,`FAIL copy-without-no-launch: ${A}'s bus host has no --no-launch flag\nREHEARSAL_FAIL\n`);
|
||||
const none=await rehearse(e,B,'--backup','20261020T000000Z-000000000000','--port',port);
|
||||
assert.equal(none.code,1);assert.ok(none.out.includes(`FAIL backup-missing: ${join(e.data,'backups','stack','20261020T000000Z-000000000000')} has no bus/bus.sqlite and SHA256SUMS\n`),none.out);
|
||||
const f=await rmk(t);appendFileSync(join(f.backup,'bus','bus.sqlite'),'x');
|
||||
const bad=await rehearse(f,B,'--backup',f.act,'--port',port);
|
||||
assert.equal(bad.code,1);assert.ok(bad.out.includes(`FAIL backup-sums: ${f.backup}/bus does not match its SHA256SUMS\n`),bad.out);
|
||||
const g=await rmk(t);rmSync(g.pointer);
|
||||
const np=await rehearse(g,B,'--backup',g.act,'--port',port);
|
||||
assert.equal(np.code,1);assert.match(np.out,/^FAIL no-pointer: /);
|
||||
for(const args of [['--port',port],['--backup',e.act],['--backup',e.act,'--port','80'],['--backup',e.act,'--port','70000'],['--backup','relative/dir','--port',port],['--backup',e.act,'--port',port,'--pm'],['--backup',e.act,'--port',port,'--plan']]){
|
||||
const u=await rehearse(e,B,...args);assert.equal(u.code,4,args.join(' ')+u.out+u.err);assert.match(u.err,/^usage: /);
|
||||
}
|
||||
assert.deepEqual([...rDirs(e),...rDirs(f),...rDirs(g)],[]);
|
||||
assert.equal(untouched(e),before);
|
||||
});
|
||||
|
||||
test('R14 a host that writes the live config, the live business file or the backup fails the end check by name',async(t)=>{
|
||||
for(const [pick,name] of [[(e)=>e.cfg,'live-config-unchanged'],[(e)=>e.biz,'live-config-unchanged'],[(e)=>join(e.backup,'bus','bus.sqlite'),'backup-unchanged']]){
|
||||
const e=await rmk(t),f=pick(e);rfault(e,'host-touches');writeFileSync(rh(e,'faults','host-touches'),`${f}\n`);
|
||||
const r=await rehearse(e,B,'--backup',e.act,'--port',await freePort(),'--timeout','10');
|
||||
assert.equal(r.code,1,f+r.out+r.err);
|
||||
assert.deepEqual(r.names.filter((n)=>n.startsWith('FAIL')),[`FAIL ${name}`],f+r.out);
|
||||
assert.ok(r.names.includes('PASS gate')&&r.names.includes('PASS host-stopped'),f+r.out);
|
||||
assert.ok(r.out.endsWith(`REHEARSAL_FAIL\nrehearsal directory: ${r.dir}\n`),r.out);
|
||||
}
|
||||
});
|
||||
|
||||
test('R15 a rehearsal stopped by TERM, INT or HUP fails signal and still stops the host and Console and runs the end checks',async(t)=>{
|
||||
for(const sig of ['SIGTERM','SIGINT','SIGHUP']){
|
||||
// The Console never answers /healthz, so the gate is still waiting when the signal comes.
|
||||
const e=await rmk(t);rfault(e,`healthz-${B}`);
|
||||
e.during=async(ch)=>{for(let i=0;i<200&&!existsSync(rh(e,'console-args.log'));i++)await new Promise((r)=>setTimeout(r,50));ch.kill(sig)};
|
||||
const r=await rehearse(e,B,'--backup',e.act,'--port',await freePort(),'--timeout','30');
|
||||
assert.equal(r.code,1,sig+r.out+r.err);
|
||||
assert.deepEqual(r.names,[...PRE,'PASS migration','FAIL signal','PASS console-stopped','PASS host-stopped','PASS host-released-store','PASS backup-unchanged','PASS live-config-unchanged'],sig+r.out);
|
||||
assert.ok(r.out.includes('FAIL signal: the rehearsal was interrupted\n'),r.out);
|
||||
assert.ok(r.out.endsWith(`REHEARSAL_FAIL\nrehearsal directory: ${r.dir}\n`),r.out);
|
||||
const pid=JSON.parse(readFileSync(join(r.dir,'data','fixture-host.json'),'utf8')).host.pid;
|
||||
assert.equal(existsSync(`/proc/${pid}`),false,`${sig}: host pid ${pid} is still there`);
|
||||
}
|
||||
});
|
||||
@@ -68,13 +68,9 @@ so an existing bundle is never overwritten):
|
||||
(`harnessVersion`), built-in and
|
||||
typed tools, the actions without a typed tool, the S0 lines, the gate
|
||||
timeout, and the sha256 of every bundle file and of the gate, extension,
|
||||
MCP server and tools sources, and of the unslop check's files (row 66:
|
||||
`lists.json`, the checker, and pi's extension or Claude Code's hook
|
||||
script).
|
||||
MCP server and tools sources.
|
||||
- Claude Code only: `settings.json` (the wrapped PreToolUse hook, matcher
|
||||
`*`, and the two unslop hooks, Stop and UserPromptSubmit, each ending
|
||||
`; exit 0` so it never blocks) and `mcp.json` (one stdio server, `mosaic`,
|
||||
serving the typed tools).
|
||||
`*`) and `mcp.json` (one stdio server, `mosaic`, serving the typed tools).
|
||||
|
||||
`harness` is the route's module from `packages/plugins/harness/`, which the
|
||||
launcher loaded; the bundle calls it for its own files and names and never
|
||||
@@ -262,28 +258,6 @@ it never touches the disk and isn't in the environment or argv.
|
||||
header after it names when the message was sent. A failed read fails the
|
||||
turn without running the adapter: the sender gets `The turn failed: could
|
||||
not read the current state: <code>`, and the message is read.
|
||||
|
||||
Row 66 (#1559): the unslop check. The runner sets `MOSAIC_UNSLOP_LOG` to
|
||||
`<run>/unslop.jsonl`, after the harness module's variables (in the run
|
||||
directory itself, since a thread run has no `<run>/session/`). Pi's second extension and
|
||||
Claude Code's Stop and UserPromptSubmit hooks (`tools/unslop-hook`) check
|
||||
each reply against `lists.json` and log `checked` or `flagged` (each
|
||||
finding names its pattern id, the entry's `source`), `notice_injected`
|
||||
when the next turn gets the correction notice, and `broken_lists`,
|
||||
`skipped_broken` or `hook_failed` when there is no verdict. After each
|
||||
turn the runner reads what the log gained, and the next RESULT it sends
|
||||
ends with a note naming the flagged replies; a broken check is told
|
||||
once a run. The whole note, both parts together, is at most 600
|
||||
characters, and the answer before it is clipped at 32000, so the RESULT
|
||||
stays under the bus's 32768. A turn that sends no reply (an
|
||||
incoming RESULT, a landing turn) keeps its note for the next reply, and
|
||||
so does a send that failed. That RESULT is what `mosaic talk` and
|
||||
`mosaic follow` print, so the operator sees the note there.
|
||||
`unslopCounts(path)` reads a log after the session ended: replies
|
||||
checked, flagged, findings per pattern id, notices, and the first reason
|
||||
the check broke. The check never blocks a reply (Q84 option B is a later
|
||||
decision), and the session can write its run directory, so the log is
|
||||
the session's own report, not the host's evidence.
|
||||
5. SIGTERM or SIGINT kills the turn's process group, releases the role and
|
||||
exits 0. The handlers go in before anything else, because pid 1 of a
|
||||
PID namespace never sees a signal it has no handler for; a stop before
|
||||
|
||||
@@ -21,12 +21,6 @@
|
||||
// no reply and no read. A malformed entry exits 2 before the claim.
|
||||
// 5. SIGTERM ends the turn, releases the role and exits 0; before the
|
||||
// claim it exits 0 without claiming.
|
||||
// 6. The unslop check (row 66): the harness logs each reply's check to
|
||||
// <run>/unslop.jsonl (MOSAIC_UNSLOP_LOG). After each turn the
|
||||
// runner reads what the log gained, and the next reply it sends ends
|
||||
// with a note naming the flagged replies, or, once a run, the broken
|
||||
// check. That reply is the RESULT `mosaic talk` and `mosaic follow`
|
||||
// print, so the operator sees it there.
|
||||
//
|
||||
// Exit codes: 0 stopped, 20 founder credentials, 21 claim refused, 22 the
|
||||
// capability or the claim stopped working (ended, revoked), 23 the broker
|
||||
@@ -126,113 +120,6 @@ function clip(text) {
|
||||
return text.length > BODY_MAX ? text.slice(0, BODY_MAX) + "\n[answer cut at 32000 characters]" : text;
|
||||
}
|
||||
|
||||
// Row 66 (#1559): the unslop check's log, which the harness writes and the runner only reads. Pi's
|
||||
// second extension and Claude Code's Stop and UserPromptSubmit hooks (tools/unslop-hook) write the
|
||||
// same events: checked, flagged (with findings, each naming its lists.json source, the pattern id),
|
||||
// notice_injected, and broken_lists, skipped_broken or hook_failed when there is no verdict. The
|
||||
// session can write the run directory, so these are its own report, not the host's evidence.
|
||||
// The log sits in the run directory itself: a thread run has no <run>/session/ (its sessionDir is
|
||||
// the thread's store), and one log a run keeps each run's counts apart.
|
||||
export const unslopLog = (runDir) => join(runDir, "unslop.jsonl");
|
||||
const UNSLOP_BROKEN = new Set(["broken_lists", "skipped_broken", "hook_failed"]);
|
||||
const UNSLOP_NOTE_MAX = 600;
|
||||
const cut = (text, n) => (text.length > n ? text.slice(0, n - 3) + "..." : text);
|
||||
|
||||
function unslopEvents(text) {
|
||||
const out = [];
|
||||
for (const line of text.split("\n")) {
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
const e = JSON.parse(line);
|
||||
if (e && typeof e === "object" && !Array.isArray(e) && typeof e.ev === "string") out.push(e);
|
||||
} catch {}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
const unslopReason = (e) => cut(typeof e.reason === "string" ? e.reason : "no reason given", 300);
|
||||
|
||||
// A session's counts from its log, readable after it ended: replies checked (clean or flagged),
|
||||
// replies flagged, findings per pattern id, correction notices, and the first reason the check
|
||||
// broke, or null. They are for the later blocking decision (Q84 option B).
|
||||
export function unslopCounts(path) {
|
||||
let text = "";
|
||||
try {
|
||||
text = readFileSync(path, "utf8");
|
||||
} catch (e) {
|
||||
if (e.code !== "ENOENT") throw e;
|
||||
}
|
||||
const out = { checked: 0, flagged: 0, patterns: {}, notices: 0, broken: null };
|
||||
for (const e of unslopEvents(text)) {
|
||||
if (e.ev === "checked" || e.ev === "flagged") out.checked++;
|
||||
if (e.ev === "flagged") {
|
||||
out.flagged++;
|
||||
for (const f of Array.isArray(e.findings) ? e.findings : []) {
|
||||
if (typeof f?.source === "string") out.patterns[f.source] = (out.patterns[f.source] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
if (e.ev === "notice_injected") out.notices++;
|
||||
if (UNSLOP_BROKEN.has(e.ev) && out.broken === null) out.broken = unslopReason(e);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// What the log gained since the last read, held until a reply carries it. read() runs after every
|
||||
// turn; note() is the text for the next reply, or "", and sent() drops it once that reply went out,
|
||||
// so a failed send keeps it for the next one. A broken check is told once a run.
|
||||
export function unslopNotes(path, log = () => {}) {
|
||||
let offset = 0;
|
||||
let toldBroken = false;
|
||||
let flagged = [];
|
||||
let broken = null;
|
||||
const fresh = (reason) => {
|
||||
if (toldBroken || broken !== null) return;
|
||||
broken = cut(reason, 300);
|
||||
log(`runner: unslop: the check is broken: ${broken}`);
|
||||
};
|
||||
return {
|
||||
read() {
|
||||
let text;
|
||||
try {
|
||||
text = readFileSync(path, "utf8");
|
||||
} catch (e) {
|
||||
if (e.code !== "ENOENT") fresh(`the runner can't read ${path}: ${e.code ?? e.message}`);
|
||||
return;
|
||||
}
|
||||
// Only whole lines: a line the harness is still writing waits for the next read.
|
||||
const end = text.lastIndexOf("\n") + 1;
|
||||
if (end <= offset) return;
|
||||
const events = unslopEvents(text.slice(offset, end));
|
||||
offset = end;
|
||||
for (const e of events) {
|
||||
if (e.ev === "flagged") {
|
||||
const details = (Array.isArray(e.findings) ? e.findings : []).map((f) => (typeof f?.detail === "string" ? f.detail : "")).filter(Boolean);
|
||||
flagged.push(details.join("; ") || "no findings given");
|
||||
log(`runner: unslop: a reply flagged: ${cut(details.join("; "), 300)}`);
|
||||
}
|
||||
if (UNSLOP_BROKEN.has(e.ev)) fresh(unslopReason(e));
|
||||
}
|
||||
},
|
||||
// The whole note, both parts and the blank line, is at most UNSLOP_NOTE_MAX, so a clipped body
|
||||
// plus its note stays under the bus's body limit. The broken part is at most 371 characters
|
||||
// (the reason is cut at 300), and the flagged part gets what is left.
|
||||
note() {
|
||||
const parts = [];
|
||||
if (broken !== null && !toldBroken) parts.push(`[unslop: the check is broken, so this session's replies go unchecked: ${broken}]`);
|
||||
if (flagged.length) {
|
||||
const n = flagged.length;
|
||||
const tail = ". The session gets a correction notice on its next turn.]";
|
||||
const room = UNSLOP_NOTE_MAX - 2 - parts.reduce((sum, p) => sum + p.length + 1, 0) - tail.length;
|
||||
parts.push(cut(`[unslop: ${n} ${n === 1 ? "reply" : "replies"} of this session flagged since the last note: ${[...new Set(flagged)].join("; ")}`, room) + tail);
|
||||
}
|
||||
return parts.length ? `\n\n${parts.join("\n")}` : "";
|
||||
},
|
||||
sent() {
|
||||
if (broken !== null) toldBroken = true;
|
||||
flagged = [];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const readLine = (stream) => new Promise((resolve, reject) => {
|
||||
let input = "";
|
||||
stream.setEncoding("utf8");
|
||||
@@ -387,8 +274,6 @@ export function adapterEnv(session, env) {
|
||||
MOSAIC_PROVIDER: session.provider ?? "",
|
||||
MOSAIC_MODEL: session.model,
|
||||
...session.harnessEnv,
|
||||
// Row 66: after the module's variables, so the log is always the run's own.
|
||||
...(session.runDir ? { MOSAIC_UNSLOP_LOG: unslopLog(session.runDir) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -473,7 +358,6 @@ export async function main(runDir, { stdin = process.stdin, env = process.env, l
|
||||
},
|
||||
});
|
||||
const turnEnv = adapterEnv(session, env);
|
||||
const unslop = unslopNotes(unslopLog(runDir), log);
|
||||
|
||||
let code = EXIT.stopped;
|
||||
let failures = 0;
|
||||
@@ -508,7 +392,6 @@ export async function main(runDir, { stdin = process.stdin, env = process.env, l
|
||||
try { await client.call("usage.begin", { turn: e.decision }); }
|
||||
catch (err) { log(`runner: usage baseline unavailable: ${err.code ?? "failed"}`); }
|
||||
const turn = await runTurn({ session, request: `${block}\n${landingRequest(e)}`, n, env: turnEnv, onChild: (c) => (child = c) });
|
||||
unslop.read();
|
||||
try { await client.call("usage.end", { turn: e.decision }); }
|
||||
catch (err) { log(`runner: usage outcome unavailable: ${err.code ?? "failed"}`); }
|
||||
log(`runner: landing turn ${n} for decision ${e.decision} (${e.task_ref}): ${turn.ok ? "ok" : `failed: ${turn.reason}`}`);
|
||||
@@ -556,7 +439,6 @@ export async function main(runDir, { stdin = process.stdin, env = process.env, l
|
||||
const turn = unread
|
||||
? { ok: false, reason: unread }
|
||||
: await runTurn({ session, request: `${block}\n${turnRequest(m)}`, n, env: turnEnv, onChild: (c) => (child = c) });
|
||||
unslop.read();
|
||||
// A failed/stopped harness can still have used tokens. Missing data remains unknown.
|
||||
try { await client.call("usage.end", { turn: m.id }); }
|
||||
catch (e) { log(`runner: usage outcome unavailable: ${e.code ?? "failed"}`); }
|
||||
@@ -565,8 +447,7 @@ export async function main(runDir, { stdin = process.stdin, env = process.env, l
|
||||
try {
|
||||
if (m.class !== "RESULT") {
|
||||
const body = turn.ok ? turn.text || "(the session gave no answer)" : `The turn failed: ${turn.reason}`;
|
||||
await client.call("message.send", { to: m.from_role, body: clip(body) + unslop.note(), class: "RESULT", in_reply_to: m.id });
|
||||
unslop.sent();
|
||||
await client.call("message.send", { to: m.from_role, body: clip(body), class: "RESULT", in_reply_to: m.id });
|
||||
}
|
||||
await client.call("message.read", { id: m.id });
|
||||
} catch (e) {
|
||||
|
||||
@@ -7,8 +7,6 @@ import { BundleError, buildBundle, sessionModel, SOURCES } from "../src/bundle.m
|
||||
import { HARNESS, REPO_SKILLS, resolvedFor, scratch } from "./helpers.mjs";
|
||||
import { decide } from "../src/gate.mjs";
|
||||
|
||||
const UNSLOP_DIR = new URL("../../../tools/unslop-hook/", import.meta.url).pathname;
|
||||
|
||||
const sha = (p) => createHash("sha256").update(readFileSync(p)).digest("hex");
|
||||
const mode = (p) => statSync(p).mode & 0o777;
|
||||
|
||||
@@ -82,11 +80,8 @@ test("a pi bundle: prompt, policy, tools and manifest, 0600 in a 0700 directory"
|
||||
assert.deepEqual(m.skills.map((x) => x.name), ["ms-unslop", "ms-goal", "ms-communications"]);
|
||||
assert.equal(m.reliesOn.length, 5);
|
||||
assert.ok(!m.typed.includes("launch"), "row 119: the PM no longer launches");
|
||||
assert.deepEqual(Object.keys(m.sources).sort(), ["gate", "piExtension", "tools", "unslopCheck", "unslopExtension", "unslopLists"]);
|
||||
assert.deepEqual(Object.keys(m.sources).sort(), ["gate", "piExtension", "tools"]);
|
||||
assert.equal(m.sources.piExtension.sha256, sha(SOURCES.piExtension));
|
||||
// Row 66 (#1559): the unslop check's files, as the session loads them.
|
||||
for (const [name, file] of [["unslopExtension", "extension.ts"], ["unslopCheck", "unslop-check.js"], ["unslopLists", "lists.json"]])
|
||||
assert.equal(m.sources[name].sha256, sha(join(UNSLOP_DIR, file)), name);
|
||||
for (const [name, digest] of Object.entries(m.files)) assert.equal(digest, sha(join(b.dir, "bundle", name)), name);
|
||||
assert.deepEqual(JSON.parse(readFileSync(b.files.manifest, "utf8")), JSON.parse(JSON.stringify(m)));
|
||||
});
|
||||
@@ -149,15 +144,7 @@ test("a claude-code bundle adds the wrapped gate hook and the MCP config", (t) =
|
||||
assert.deepEqual(m.gateTimeout, { n: 10, k: 2, hook: 20 });
|
||||
assert.ok(m.builtins.includes("Bash"));
|
||||
assert.ok(m.typed.every((n) => n.startsWith("mcp__mosaic__")));
|
||||
assert.deepEqual(Object.keys(m.sources).sort(), ["claudeGate", "gate", "mcpServer", "tools", "unslopCheck", "unslopHook", "unslopLists"]);
|
||||
// Row 66 (#1559): the unslop hooks run after a reply and before the next prompt, and never block.
|
||||
for (const [event, which] of [["Stop", "stop"], ["UserPromptSubmit", "prompt"]]) {
|
||||
assert.equal(settings.hooks[event].length, 1, event);
|
||||
assert.equal(settings.hooks[event][0].matcher, undefined, event);
|
||||
assert.deepEqual(settings.hooks[event][0].hooks, [{ type: "command", command: `timeout -k 2 10 '${process.execPath}' '${join(UNSLOP_DIR, "claude-hook.js")}' ${which}; exit 0`, timeout: 20 }], event);
|
||||
}
|
||||
for (const [name, file] of [["unslopHook", "claude-hook.js"], ["unslopCheck", "unslop-check.js"], ["unslopLists", "lists.json"]])
|
||||
assert.equal(m.sources[name].sha256, sha(join(UNSLOP_DIR, file)), name);
|
||||
assert.deepEqual(Object.keys(m.sources).sort(), ["claudeGate", "gate", "mcpServer", "tools"]);
|
||||
});
|
||||
|
||||
// Row 89 (#1583): the prompt names the built-ins as the session's harness names them.
|
||||
|
||||
@@ -10,7 +10,7 @@ import { spawn, spawnSync } from "node:child_process";
|
||||
import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { basename, dirname, join } from "node:path";
|
||||
import { buildBundle } from "../src/bundle.mjs";
|
||||
import { adapterEnv, unslopCounts, unslopLog, unslopNotes } from "../src/runner.mjs";
|
||||
import { adapterEnv } from "../src/runner.mjs";
|
||||
import { fakeToolSocket, HARNESS, mockAnthropic, REPO, REPO_SKILLS, resolvedFor, scratch } from "./helpers.mjs";
|
||||
|
||||
const ADAPTER = join(REPO, "adapters", "claude", "adapter.sh");
|
||||
@@ -18,14 +18,14 @@ const which = spawnSync("sh", ["-c", "command -v claude"], { encoding: "utf8" })
|
||||
const CLAUDE = which.status === 0 ? which.stdout.trim() : null;
|
||||
const skip = CLAUDE ? false : "claude is not on PATH";
|
||||
|
||||
async function session(t, script, tools = ["read", "bash"], done = (r) => `ANSWER ${JSON.stringify(r.map((x) => [x.isError, x.content.slice(0, 600)]))}`) {
|
||||
async function session(t, script, tools = ["read", "bash"]) {
|
||||
const dir = scratch(t);
|
||||
const workspace = join(dir, "ws");
|
||||
mkdirSync(workspace);
|
||||
writeFileSync(join(workspace, "notes.txt"), "inside\n");
|
||||
writeFileSync(join(dir, "secret.txt"), "outside\n");
|
||||
if (typeof script === "function") script = script(workspace);
|
||||
const api = await mockAnthropic(t, { script, done });
|
||||
const api = await mockAnthropic(t, { script, done: (r) => `ANSWER ${JSON.stringify(r.map((x) => [x.isError, x.content.slice(0, 600)]))}` });
|
||||
const resolved = resolvedFor(t, "coder", (d) => {
|
||||
d.roles.coder.vars = { harness: "claude-code", model: "claude-sonnet-5-5", "limits.tools": tools };
|
||||
});
|
||||
@@ -41,9 +41,6 @@ async function session(t, script, tools = ["read", "bash"], done = (r) => `ANSWE
|
||||
sessionDir: join(dir, "session"),
|
||||
toolSocket: sock.path,
|
||||
turnMarker: join(dir, "turn.done"),
|
||||
// Row 66: the run directory, so the unslop hooks log to <dir>/unslop.jsonl, which the
|
||||
// runner makes before the first turn.
|
||||
runDir: dir,
|
||||
};
|
||||
const { files, manifest, env: harnessEnv } = buildBundle({ dir: join(dir, "bundle"), resolved, contract: "# Coder\n", harness: HARNESS["claude-code"], skillsDir: REPO_SKILLS, session: { ...s, run: "run-1", launchedBy: "pm" } });
|
||||
s.bundle = files;
|
||||
@@ -306,55 +303,3 @@ test("claude: a missing hook or MCP file refuses before claude starts", { skip }
|
||||
}
|
||||
assert.equal(api.requests.length, 0);
|
||||
});
|
||||
|
||||
// Row 66 (#1559): the unslop check's Stop and UserPromptSubmit hooks. The scripted model's answers
|
||||
// are the session's replies; the requests show what the model was sent on the next turn.
|
||||
const TELL = "Let me delve into the build. It is a pivotal step.";
|
||||
const replying = (answers) => {
|
||||
let n = 0;
|
||||
return () => answers[n++] ?? "Done.";
|
||||
};
|
||||
const notices = (api) => api.requests.filter((r) => r.tools.length).map((r) => r.raw.split("UNSLOP NOTICE").length - 1);
|
||||
|
||||
test("claude: row 66 a reply with a tell gets the correction notice on the next turn, a clean reply gets none, and the counts read after", { skip }, async (t) => {
|
||||
const { dir, api, s, env } = await session(t, [], undefined, replying([TELL, "The build is green.", TELL, "Done."]));
|
||||
assert.equal(env.MOSAIC_UNSLOP_LOG, unslopLog(dir));
|
||||
const notes = unslopNotes(unslopLog(dir));
|
||||
for (const [i, request] of ["status?", "and now?", "and the tests?", "thanks"].entries()) {
|
||||
const r = await turn(env, `Message ${i + 1} from pm, class REQUEST:\n\n${request}`, s.workspace);
|
||||
assert.equal(r.code, 0, r.stderr);
|
||||
notes.read();
|
||||
// The runner's note for the operator: on each turn whose reply had the tell, and on no other.
|
||||
assert.equal(/1 reply of this session flagged since the last note: banned word "delve" x1; banned word "pivotal" x1\./.test(notes.note()), i % 2 === 0, `turn ${i + 1}: ${notes.note()}\n${readFileSync(unslopLog(dir), "utf8")}`);
|
||||
notes.sent();
|
||||
}
|
||||
// The request after each tell carries a notice; the one after a clean reply has no new one, and
|
||||
// a tell after a clean reply is still caught.
|
||||
assert.deepEqual(notices(api), [0, 1, 1, 2]);
|
||||
const second = api.requests.filter((r) => r.tools.length)[1].raw;
|
||||
assert.match(second, /UNSLOP NOTICE \(mechanical style check, not the user speaking\)[^"]*banned word \\"delve\\" x1/);
|
||||
const counts = unslopCounts(unslopLog(dir));
|
||||
assert.deepEqual({ ...counts, patterns: Object.keys(counts.patterns).length > 0 }, { checked: 4, flagged: 2, patterns: true, notices: 2, broken: null });
|
||||
});
|
||||
|
||||
test("claude: row 66 a broken lists.json gives no clean verdict, the operator is told once, and the session still answers", { skip }, async (t) => {
|
||||
const { dir, api, s, env } = await session(t, [], undefined, replying(["The build is green.", TELL]));
|
||||
writeFileSync(join(dir, "empty-lists.json"), "");
|
||||
const notes = unslopNotes(unslopLog(dir));
|
||||
const told = [];
|
||||
for (const [i, request] of ["status?", "and now?"].entries()) {
|
||||
const r = await turn({ ...env, UNSLOP_LISTS: join(dir, "empty-lists.json") }, `Message ${i + 1} from pm, class REQUEST:\n\n${request}`, s.workspace);
|
||||
assert.equal(r.code, 0, r.stderr);
|
||||
assert.ok(r.stdout.trim().length > 0, "the session answered");
|
||||
notes.read();
|
||||
told.push(notes.note());
|
||||
notes.sent();
|
||||
}
|
||||
assert.match(told[0], /^\n\n\[unslop: the check is broken, so this session's replies go unchecked: lists\.json invalid: empty file/);
|
||||
assert.equal(told[1], "");
|
||||
assert.deepEqual(notices(api), [0, 0]);
|
||||
const counts = unslopCounts(unslopLog(dir));
|
||||
assert.equal(counts.checked, 0);
|
||||
assert.equal(counts.flagged, 0);
|
||||
assert.match(counts.broken, /^lists\.json invalid: empty file/);
|
||||
});
|
||||
@@ -5,9 +5,6 @@
|
||||
// FAIL exit 3
|
||||
// NOMARKER answer without writing the turn marker
|
||||
// ENV <name> answer "env: <value of name>"
|
||||
// UNSLOP <json> append each event of the JSON array to $MOSAIC_UNSLOP_LOG, as the harness's
|
||||
// unslop check does (row 66), and answer "unslop: <count>"
|
||||
// UNSLOPLONG <json> the same, with an answer of 40000 more characters, over the runner's clip
|
||||
// anything else answer "echo: <request>"
|
||||
// Every turn appends its pid to $FAKE_PIDS when set.
|
||||
|
||||
@@ -36,12 +33,6 @@ if (body.startsWith("TOOL ")) {
|
||||
} else if (body.startsWith("ENV ")) {
|
||||
process.stdout.write(`env: ${process.env[body.slice(4)]}\n`);
|
||||
done();
|
||||
} else if (body.startsWith("UNSLOP ") || body.startsWith("UNSLOPLONG ")) {
|
||||
const long = body.startsWith("UNSLOPLONG ");
|
||||
const events = JSON.parse(body.slice(long ? 11 : 7));
|
||||
for (const e of events) appendFileSync(process.env.MOSAIC_UNSLOP_LOG, JSON.stringify({ ts: Date.now(), ...e }) + "\n");
|
||||
process.stdout.write(`unslop: ${events.length}${long ? " " + "x".repeat(40000) : ""}\n`);
|
||||
done();
|
||||
} else if (body === "NOMARKER") {
|
||||
process.stdout.write("half an answer");
|
||||
} else {
|
||||
|
||||
@@ -4,17 +4,17 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { spawn } from "node:child_process";
|
||||
import { existsSync, mkdirSync, readdirSync, readFileSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { existsSync, mkdirSync, readdirSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { buildBundle } from "../src/bundle.mjs";
|
||||
import { adapterEnv, unslopCounts, unslopLog, unslopNotes } from "../src/runner.mjs";
|
||||
import { adapterEnv } from "../src/runner.mjs";
|
||||
import { REPLY_CAP } from "../src/tools.mjs";
|
||||
import { fakeToolSocket, HARNESS, mockAnthropic, REPO, REPO_SKILLS, resolvedFor, scratch } from "./helpers.mjs";
|
||||
|
||||
const ADAPTER = join(REPO, "adapters", "pi", "adapter.sh");
|
||||
|
||||
// The chief of staff (row 119) has no write tool; tests that write use the PM.
|
||||
async function session(t, script, place = (dir) => join(dir, "ws"), instance = "chief", done = (r) => `ANSWER ${JSON.stringify(r.map((x) => [x.isError, x.content.slice(0, 80)]))}`) {
|
||||
async function session(t, script, place = (dir) => join(dir, "ws"), instance = "chief") {
|
||||
const dir = scratch(t);
|
||||
const workspace = place(dir);
|
||||
const agentDir = join(dir, "pi-agent");
|
||||
@@ -22,7 +22,7 @@ async function session(t, script, place = (dir) => join(dir, "ws"), instance = "
|
||||
mkdirSync(agentDir);
|
||||
writeFileSync(join(workspace, "notes.txt"), "inside\n");
|
||||
writeFileSync(join(dir, "secret.txt"), "outside\n");
|
||||
const api = await mockAnthropic(t, { script, done });
|
||||
const api = await mockAnthropic(t, { script, done: (r) => `ANSWER ${JSON.stringify(r.map((x) => [x.isError, x.content.slice(0, 80)]))}` });
|
||||
writeFileSync(join(agentDir, "models.json"), JSON.stringify({ providers: { probe: { baseUrl: api.url, api: "anthropic-messages", apiKey: "probe-dummy", models: [{ id: "probe-model" }] } } }));
|
||||
const resolved = resolvedFor(t, instance, (d) => {
|
||||
d.roles[instance].vars = { ...d.roles[instance].vars, harness: "pi", model: "probe-model" };
|
||||
@@ -42,9 +42,6 @@ async function session(t, script, place = (dir) => join(dir, "ws"), instance = "
|
||||
sessionDir: join(dir, "session"),
|
||||
toolSocket: sock.path,
|
||||
turnMarker: join(dir, "turn.done"),
|
||||
// Row 66: the run directory, so the unslop check logs to <dir>/unslop.jsonl, which the
|
||||
// runner makes before the first turn.
|
||||
runDir: dir,
|
||||
};
|
||||
const models = { "probe-model": { provider: "probe", model: "probe-model" } };
|
||||
const { files, env: harnessEnv } = buildBundle({ dir: join(dir, "bundle"), resolved, contract: "# PM\n", tracker: true, harness: HARNESS.pi, models, skillsDir: REPO_SKILLS, session: { ...s, run: "run-1", launchedBy: "jason" } });
|
||||
@@ -266,55 +263,3 @@ test("pi: an extension without its configuration fails pi's start", async (t) =>
|
||||
assert.equal(api.requests.length, 0);
|
||||
assert.ok(MOSAIC_POLICY_FILE);
|
||||
});
|
||||
|
||||
// Row 66 (#1559): the unslop check as a second extension. The scripted model's answers are the
|
||||
// session's replies; the requests show what the model was sent on the next turn.
|
||||
const TELL = "Let me delve into the build. It is a pivotal step.";
|
||||
const replying = (answers) => {
|
||||
let n = 0;
|
||||
return () => answers[n++] ?? "Done.";
|
||||
};
|
||||
const notices = (api) => api.requests.filter((r) => r.tools.length).map((r) => r.raw.split("UNSLOP NOTICE").length - 1);
|
||||
|
||||
test("pi: row 66 a reply with a tell gets the correction notice on the next turn, a clean reply gets none, and the counts read after", async (t) => {
|
||||
const { dir, api, s, env } = await session(t, [], undefined, undefined, replying([TELL, "The build is green.", TELL, "Done."]));
|
||||
assert.equal(env.MOSAIC_UNSLOP_LOG, unslopLog(dir));
|
||||
const notes = unslopNotes(unslopLog(dir));
|
||||
for (const [i, request] of ["status?", "and now?", "and the tests?", "thanks"].entries()) {
|
||||
const r = await turn(env, `Message ${i + 1} from jason, class REQUEST:\n\n${request}`, s.workspace);
|
||||
assert.equal(r.code, 0, r.stderr);
|
||||
notes.read();
|
||||
// The runner's note for the operator: on each turn whose reply had the tell, and on no other.
|
||||
assert.equal(/1 reply of this session flagged since the last note: banned word "delve" x1; banned word "pivotal" x1\./.test(notes.note()), i % 2 === 0, `turn ${i + 1}: ${notes.note()}\n${readFileSync(unslopLog(dir), "utf8")}`);
|
||||
notes.sent();
|
||||
}
|
||||
// The request after each tell carries a notice; the one after a clean reply has no new one, and
|
||||
// a tell after a clean reply is still caught.
|
||||
assert.deepEqual(notices(api), [0, 1, 1, 2]);
|
||||
const second = api.requests.filter((r) => r.tools.length)[1].raw;
|
||||
assert.match(second, /UNSLOP NOTICE \(mechanical style check, not the user speaking\)[^"]*banned word \\"delve\\" x1/);
|
||||
const counts = unslopCounts(unslopLog(dir));
|
||||
assert.deepEqual({ ...counts, patterns: Object.keys(counts.patterns).length > 0 }, { checked: 4, flagged: 2, patterns: true, notices: 2, broken: null });
|
||||
});
|
||||
|
||||
test("pi: row 66 a broken lists.json gives no clean verdict, the operator is told once, and the session still answers", async (t) => {
|
||||
const { dir, api, s, env } = await session(t, [], undefined, undefined, replying(["The build is green.", TELL]));
|
||||
writeFileSync(join(dir, "empty-lists.json"), "");
|
||||
const notes = unslopNotes(unslopLog(dir));
|
||||
const told = [];
|
||||
for (const [i, request] of ["status?", "and now?"].entries()) {
|
||||
const r = await turn({ ...env, UNSLOP_LISTS: join(dir, "empty-lists.json") }, `Message ${i + 1} from jason, class REQUEST:\n\n${request}`, s.workspace);
|
||||
assert.equal(r.code, 0, r.stderr);
|
||||
assert.ok(r.stdout.trim().length > 0, "the session answered");
|
||||
notes.read();
|
||||
told.push(notes.note());
|
||||
notes.sent();
|
||||
}
|
||||
assert.match(told[0], /^\n\n\[unslop: the check is broken, so this session's replies go unchecked: lists\.json invalid: empty file/);
|
||||
assert.equal(told[1], "");
|
||||
assert.deepEqual(notices(api), [0, 0]);
|
||||
const counts = unslopCounts(unslopLog(dir));
|
||||
assert.equal(counts.checked, 0);
|
||||
assert.equal(counts.flagged, 0);
|
||||
assert.match(counts.broken, /^lists\.json invalid: empty file/);
|
||||
});
|
||||
@@ -13,7 +13,7 @@ import { join } from "node:path";
|
||||
import { Store } from "../../bus/src/store.mjs";
|
||||
import { Broker, BusError } from "../../bus/src/broker.mjs";
|
||||
import { serve } from "../../bus/src/server.mjs";
|
||||
import { EXIT, founderCheck, landingRequest, stateBlock, turnRequest, unslopCounts, unslopLog, unslopNotes } from "../src/runner.mjs";
|
||||
import { EXIT, founderCheck, landingRequest, stateBlock, turnRequest } from "../src/runner.mjs";
|
||||
import { typedTools } from "../src/tools.mjs";
|
||||
import { resolvedFor, scratch } from "./helpers.mjs";
|
||||
|
||||
@@ -836,131 +836,3 @@ test("row 65 harnessEnv reaches the adapter, and an exit harness needs no turn m
|
||||
await until(() => replies(ctx)() && ctx.inbox.length >= 2);
|
||||
assert.deepEqual(ctx.inbox.map((m) => m.body.trim()), ["env: xhigh", "half an answer"]);
|
||||
});
|
||||
|
||||
// Row 66 (#1559): the harness's unslop check logs to <run>/unslop.jsonl. The fake adapter's
|
||||
// UNSLOP body writes the events a real check would; pi-session and claude-session tests run the real
|
||||
// extension and hooks.
|
||||
const tell = (detail, source = "ms-unslop:7") => ({ ev: "flagged", clean: false, charsChecked: 40, findings: [{ rule: "word", source, detail, count: 1 }] });
|
||||
const unslopAsk = (ctx, events, cls = "REQUEST") => ctx.call(ctx.pm, "message.send", { to: "coder", body: `UNSLOP ${JSON.stringify(events)}`, class: cls });
|
||||
|
||||
test("row 66 the adapter gets the run's unslop log, and a flagged reply's note rides that reply's RESULT, once", async (t) => {
|
||||
const ctx = await setup(t, { session: { harnessEnv: { MOSAIC_UNSLOP_LOG: "/elsewhere.jsonl" } } });
|
||||
ctx.inbox = [];
|
||||
// A thread run's sessionDir is elsewhere and the run has no session/; the log needs none.
|
||||
rmSync(join(ctx.runDir, "session"), { recursive: true, force: true });
|
||||
const r = startRunner(ctx.runDir, ctx.cap);
|
||||
t.after(() => r.child.kill("SIGKILL"));
|
||||
const env = await ask(ctx, "ENV MOSAIC_UNSLOP_LOG");
|
||||
assert.equal(env.body, `env: ${join(ctx.runDir, "unslop.jsonl")}`);
|
||||
assert.equal(existsSync(join(ctx.runDir, "session")), false);
|
||||
unslopAsk(ctx, [{ ev: "checked", clean: true, charsChecked: 10 }, tell('banned word "delve" x1')]);
|
||||
await until(() => replies(ctx)() && ctx.inbox.length >= 2);
|
||||
assert.equal(ctx.inbox[1].body, 'unslop: 2\n\n[unslop: 1 reply of this session flagged since the last note: banned word "delve" x1. The session gets a correction notice on its next turn.]');
|
||||
const clean = await ask(ctx, "ENV MOSAIC_MODEL");
|
||||
assert.equal(clean.body, "env: fake-model");
|
||||
assert.match(r.stderr(), /runner: unslop: a reply flagged: banned word "delve" x1\n/);
|
||||
});
|
||||
|
||||
test("row 66 a turn with no reply holds its note for the next reply; a broken check is told once", async (t) => {
|
||||
const ctx = await setup(t);
|
||||
ctx.inbox = [];
|
||||
const r = startRunner(ctx.runDir, ctx.cap);
|
||||
t.after(() => r.child.kill("SIGKILL"));
|
||||
await until(() => ctx.store.get("SELECT 1 FROM role_claims WHERE role='coder' AND op='claim'"));
|
||||
// A RESULT takes a turn and gets no reply, so its flagged reply waits.
|
||||
const res = unslopAsk(ctx, [tell('banned word "tapestry" x1', "ms-unslop:7")], "RESULT");
|
||||
await until(() => ctx.store.get("SELECT 1 FROM deliveries WHERE message=? AND op='read'", res.id));
|
||||
assert.equal(replies(ctx)(), null);
|
||||
unslopAsk(ctx, [{ ev: "broken_lists", reason: "lists.json invalid: empty file" }, tell('filler phrase "in order to" x1', "ms-unslop:23")]);
|
||||
await until(() => replies(ctx)() && ctx.inbox.length >= 1);
|
||||
assert.equal(ctx.inbox[0].body, "unslop: 2\n\n[unslop: the check is broken, so this session's replies go unchecked: lists.json invalid: empty file]\n" +
|
||||
'[unslop: 2 replies of this session flagged since the last note: banned word "tapestry" x1; filler phrase "in order to" x1. The session gets a correction notice on its next turn.]');
|
||||
// The next broken event isn't told again.
|
||||
unslopAsk(ctx, [{ ev: "skipped_broken", reason: "lists.json invalid: empty file" }]);
|
||||
await until(() => replies(ctx)() && ctx.inbox.length >= 2);
|
||||
assert.equal(ctx.inbox[1].body, "unslop: 1");
|
||||
assert.equal(r.stderr().match(/runner: unslop: the check is broken/g).length, 1);
|
||||
const counts = unslopCounts(unslopLog(ctx.runDir));
|
||||
assert.deepEqual(counts, { checked: 2, flagged: 2, patterns: { "ms-unslop:7": 1, "ms-unslop:23": 1 }, notices: 0, broken: "lists.json invalid: empty file" });
|
||||
});
|
||||
|
||||
test("row 66 unslopNotes keeps a note until sent(), reads only whole lines, and unslopCounts reads a log after the session", (t) => {
|
||||
const dir = scratch(t);
|
||||
const path = join(dir, "unslop.jsonl");
|
||||
const notes = unslopNotes(path);
|
||||
notes.read();
|
||||
assert.equal(notes.note(), "");
|
||||
writeFileSync(path, JSON.stringify(tell('banned word "pivotal" x1')) + "\n" + '{"ev":"flagged","findings":[{"detail":"half');
|
||||
notes.read();
|
||||
const first = notes.note();
|
||||
assert.match(first, /1 reply of this session flagged since the last note: banned word "pivotal" x1\./);
|
||||
// A send that failed calls no sent(), so the note is still there.
|
||||
assert.equal(notes.note(), first);
|
||||
notes.sent();
|
||||
assert.equal(notes.note(), "");
|
||||
// The half line completes; it is read once, from where the last read stopped.
|
||||
writeFileSync(path, readFileSync(path, "utf8") + ' x","source":"ms-unslop:9"}]}\nnot json\n[1]\n' + JSON.stringify({ ev: "notice_injected" }) + "\n");
|
||||
notes.read();
|
||||
assert.match(notes.note(), /1 reply of this session flagged since the last note: half x\./);
|
||||
assert.deepEqual(unslopCounts(path), { checked: 2, flagged: 2, patterns: { "ms-unslop:7": 1, "ms-unslop:9": 1 }, notices: 1, broken: null });
|
||||
assert.deepEqual(unslopCounts(join(dir, "none.jsonl")), { checked: 0, flagged: 0, patterns: {}, notices: 0, broken: null });
|
||||
// A note is bounded however many findings a reply had.
|
||||
const many = unslopNotes(join(dir, "many.jsonl"));
|
||||
writeFileSync(join(dir, "many.jsonl"), Array.from({ length: 50 }, (_, i) => JSON.stringify(tell(`banned word "w${i}" x1`.padEnd(40, "x")))).join("\n") + "\n");
|
||||
many.read();
|
||||
assert.ok(many.note().length <= 600, many.note().length);
|
||||
assert.match(many.note(), /^\n\n\[unslop: 50 replies .*\.\.\.\. The session gets a correction notice on its next turn\.\]$/s);
|
||||
// Both parts together are bounded too, and a reason the runner makes itself is cut like one the
|
||||
// log gives: here the log path is a directory with a long name, so the read fails with EISDIR.
|
||||
const deep = join(dir, "d".repeat(250));
|
||||
mkdirSync(deep);
|
||||
const both = unslopNotes(deep);
|
||||
both.read();
|
||||
assert.match(both.note(), /^\n\n\[unslop: the check is broken, so this session's replies go unchecked: the runner can't read .*\.\.\.\]$/);
|
||||
assert.equal(both.note().length, 2 + 71 + 300);
|
||||
const both2 = unslopNotes(join(dir, "both.jsonl"));
|
||||
writeFileSync(join(dir, "both.jsonl"), [JSON.stringify({ ev: "broken_lists", reason: "b".repeat(400) }), ...Array.from({ length: 50 }, (_, i) => JSON.stringify(tell(`banned word "w${i}" x1`.padEnd(40, "x"))))].join("\n") + "\n");
|
||||
both2.read();
|
||||
assert.equal(both2.note().length, 600);
|
||||
assert.match(both2.note(), /^\n\n\[unslop: the check is broken, so this session's replies go unchecked: b{297}\.\.\.\]\n\[unslop: 50 replies .*\.\.\.\. The session gets a correction notice on its next turn\.\]$/s);
|
||||
});
|
||||
|
||||
test("row 66 a clipped answer with both note parts stays under the bus's body limit, and the note under 600", async (t) => {
|
||||
const ctx = await setup(t);
|
||||
ctx.inbox = [];
|
||||
const r = startRunner(ctx.runDir, ctx.cap);
|
||||
t.after(() => r.child.kill("SIGKILL"));
|
||||
await until(() => ctx.store.get("SELECT 1 FROM role_claims WHERE role='coder' AND op='claim'"));
|
||||
const events = [{ ev: "hook_failed", reason: "r".repeat(400) }, ...Array.from({ length: 30 }, (_, i) => tell(`banned word "w${i}" x1`.padEnd(60, "x")))];
|
||||
ctx.call(ctx.pm, "message.send", { to: "coder", body: `UNSLOPLONG ${JSON.stringify(events)}`, class: "REQUEST" });
|
||||
await until(() => replies(ctx)() && ctx.inbox.length >= 1);
|
||||
const body = ctx.inbox[0].body;
|
||||
const at = body.indexOf("\n[answer cut at 32000 characters]");
|
||||
assert.equal(at, 32000);
|
||||
const note = body.slice(at + "\n[answer cut at 32000 characters]".length);
|
||||
assert.ok(note.length <= 600, note.length);
|
||||
assert.ok(body.length <= 32768, body.length);
|
||||
assert.match(note, /^\n\n\[unslop: the check is broken, so this session's replies go unchecked: r+\.\.\.\]\n\[unslop: 30 replies .*\.\.\.\. The session gets a correction notice on its next turn\.\]$/s);
|
||||
});
|
||||
|
||||
test("row 66 a refused send keeps the note: it rides the next RESULT the session sends", async (t) => {
|
||||
const ctx = await setup(t);
|
||||
ctx.inbox = [];
|
||||
// The broker refuses the session's first RESULT once, as it would a body it can't take.
|
||||
const request = ctx.broker.request.bind(ctx.broker);
|
||||
let refused = 0;
|
||||
ctx.broker.request = (cap, r) => {
|
||||
if (r.verb === "message.send" && r.args?.class === "RESULT" && refused++ === 0) throw new BusError("invalid-request");
|
||||
return request(cap, r);
|
||||
};
|
||||
const r = startRunner(ctx.runDir, ctx.cap);
|
||||
t.after(() => r.child.kill("SIGKILL"));
|
||||
await until(() => ctx.store.get("SELECT 1 FROM role_claims WHERE role='coder' AND op='claim'"));
|
||||
const sent = unslopAsk(ctx, [tell('banned word "delve" x1')]);
|
||||
await until(() => r.stderr().includes(`runner: reply to ${sent.id} failed: invalid-request\n`));
|
||||
// The refused message was delivered and isn't read; its reply never reached the bus.
|
||||
assert.ok(!ctx.store.get("SELECT 1 FROM deliveries WHERE message=? AND op='read'", sent.id));
|
||||
const next = await ask(ctx, "ENV MOSAIC_MODEL");
|
||||
assert.equal(next.body, 'env: fake-model\n\n[unslop: 1 reply of this session flagged since the last note: banned word "delve" x1. The session gets a correction notice on its next turn.]');
|
||||
assert.equal(ctx.inbox.length, 1);
|
||||
});
|
||||
@@ -153,11 +153,6 @@ export returns:
|
||||
`gateTimeout`, `bundle`, `env`, `turnEnd` and `transcript` give its
|
||||
sandbox binds, web, tool names, S0 lines, bundle files, adapter variables,
|
||||
how a turn ends and where its transcripts land.
|
||||
- Row 66: both modules wire the unslop check from `tools/unslop-hook`.
|
||||
`mounts` adds its three files read-only and `bundle` names them in the
|
||||
manifest's sources. Pi's `env` loads `extension.ts` as the second entry of
|
||||
`MOSAIC_EXTENSIONS`; Claude Code's `bundle` adds Stop and UserPromptSubmit
|
||||
hooks that run `claude-hook.js` and never block.
|
||||
|
||||
`plugins.harness` switches harnesses on, and an instance's `harness` and
|
||||
`fallback.harness` vars each name one. A launch on a harness that isn't
|
||||
|
||||
@@ -16,13 +16,6 @@ export const interfaceVersion = 1;
|
||||
const REPO = fileURLToPath(new URL("../../../../", import.meta.url));
|
||||
const GATE = fileURLToPath(new URL("../../../harness/src/claude-gate.mjs", import.meta.url));
|
||||
const MCP_SERVER = fileURLToPath(new URL("../../../harness/src/mcp-server.mjs", import.meta.url));
|
||||
// Row 66 (#1559): the unslop check's hook script, which the Stop and UserPromptSubmit hooks run. It
|
||||
// logs to MOSAIC_UNSLOP_LOG, which the runner sets, and the runner tells the operator.
|
||||
const UNSLOP = Object.freeze({
|
||||
unslopHook: join(REPO, "tools", "unslop-hook", "claude-hook.js"),
|
||||
unslopCheck: join(REPO, "tools", "unslop-hook", "unslop-check.js"),
|
||||
unslopLists: join(REPO, "tools", "unslop-hook", "lists.json"),
|
||||
});
|
||||
// `claude --effort <level>` on 2.1.302 (probed 2026-10-19, agents/sage/work/queue-65/BUILD.md).
|
||||
// It has no off or minimal.
|
||||
const LEVELS = Object.freeze(["low", "medium", "high", "xhigh", "max"]);
|
||||
@@ -76,13 +69,11 @@ export function version({ env }) {
|
||||
// Its launches spend the business's launch.account (row 126).
|
||||
export const countsAccount = () => true;
|
||||
|
||||
// The pinned binary, when it lives outside /usr (which the sandbox already binds), and the unslop
|
||||
// hook's files.
|
||||
// The pinned binary, when it lives outside /usr (which the sandbox already binds).
|
||||
export function mounts({ env }) {
|
||||
const unslop = Object.values(UNSLOP).map((src) => ({ src, why: "the unslop check the session's Stop and UserPromptSubmit hooks run" }));
|
||||
if (!env.MOSAIC_CLAUDE_BIN) return unslop;
|
||||
if (!env.MOSAIC_CLAUDE_BIN) return [];
|
||||
const real = realpathSync(env.MOSAIC_CLAUDE_BIN);
|
||||
return [...(real.startsWith("/usr/") ? [] : [{ src: real, dest: env.MOSAIC_CLAUDE_BIN, why: "the pinned Claude Code binary (MOSAIC_CLAUDE_BIN)" }]), ...unslop];
|
||||
return real.startsWith("/usr/") ? [] : [{ src: real, dest: env.MOSAIC_CLAUDE_BIN, why: "the pinned Claude Code binary (MOSAIC_CLAUDE_BIN)" }];
|
||||
}
|
||||
|
||||
// WebSearch and WebFetch are its own, so it serves harness-builtin web.
|
||||
@@ -92,28 +83,22 @@ export const typedName = (name) => MCP_PREFIX + name;
|
||||
export const reliesOn = () => RELIES_ON;
|
||||
export const gateTimeout = () => ({ ...GATE_TIMEOUT });
|
||||
|
||||
// settings.json holds the gate hook and the two unslop hooks (row 66), and mcp.json the typed
|
||||
// tools' server. An unslop hook ends with `; exit 0`, so it never blocks a reply or a prompt.
|
||||
// files: the bundle's own (policy, tools). write(name, text) puts a file in the bundle directory
|
||||
// and returns [name, sha256]; quote(path) refuses a single quote.
|
||||
// settings.json holds the gate hook and mcp.json the typed tools' server. files: the bundle's own
|
||||
// (policy, tools). write(name, text) puts a file in the bundle directory and returns
|
||||
// [name, sha256]; quote(path) refuses a single quote.
|
||||
export function bundle({ dir, files: core, session, write, quote }) {
|
||||
const files = { settings: join(dir, "settings.json"), mcp: join(dir, "mcp.json") };
|
||||
const node = process.execPath;
|
||||
const command = `timeout -k ${GATE_TIMEOUT.k} ${GATE_TIMEOUT.n} ${quote(node)} ${quote(GATE)} ${quote(core.policy)} || exit 2`;
|
||||
const unslop = (which) => [{ hooks: [{ type: "command", command: `timeout -k ${GATE_TIMEOUT.k} ${GATE_TIMEOUT.n} ${quote(node)} ${quote(UNSLOP.unslopHook)} ${which}; exit 0`, timeout: GATE_TIMEOUT.hook }] }];
|
||||
const digests = [
|
||||
write("settings.json", JSON.stringify({
|
||||
hooks: {
|
||||
PreToolUse: [{ matcher: "*", hooks: [{ type: "command", command, timeout: GATE_TIMEOUT.hook }] }],
|
||||
Stop: unslop("stop"),
|
||||
UserPromptSubmit: unslop("prompt"),
|
||||
},
|
||||
hooks: { PreToolUse: [{ matcher: "*", hooks: [{ type: "command", command, timeout: GATE_TIMEOUT.hook }] }] },
|
||||
}, null, 2) + "\n"),
|
||||
write("mcp.json", JSON.stringify({
|
||||
mcpServers: { mosaic: { type: "stdio", command: node, args: [MCP_SERVER, core.tools, session.toolSocket] } },
|
||||
}, null, 2) + "\n"),
|
||||
];
|
||||
return { files, digests, sources: { claudeGate: GATE, mcpServer: MCP_SERVER, ...UNSLOP } };
|
||||
return { files, digests, sources: { claudeGate: GATE, mcpServer: MCP_SERVER } };
|
||||
}
|
||||
|
||||
export function env({ files, policy, session }) {
|
||||
|
||||
@@ -13,13 +13,6 @@ const REPO = fileURLToPath(new URL("../../../../", import.meta.url));
|
||||
const PACKAGE = "@earendil-works/pi-coding-agent";
|
||||
const BIN = join(REPO, "node_modules", ".bin", "pi");
|
||||
const EXTENSION = fileURLToPath(new URL("../../../harness/src/pi-extension.mjs", import.meta.url));
|
||||
// Row 66 (#1559): the unslop check, loaded as a second extension. It logs to MOSAIC_UNSLOP_LOG,
|
||||
// which the runner sets, and the runner tells the operator.
|
||||
const UNSLOP = Object.freeze({
|
||||
unslopExtension: join(REPO, "tools", "unslop-hook", "extension.ts"),
|
||||
unslopCheck: join(REPO, "tools", "unslop-hook", "unslop-check.js"),
|
||||
unslopLists: join(REPO, "tools", "unslop-hook", "lists.json"),
|
||||
});
|
||||
// `pi --thinking <level>` on 0.85.1 (probed 2026-10-19, agents/sage/work/queue-65/BUILD.md).
|
||||
const LEVELS = Object.freeze(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
|
||||
// docs/plans/2026-10-04_slice-1.md, "What S6 can rely on", lines 1-5.
|
||||
@@ -47,23 +40,20 @@ export function version() {
|
||||
|
||||
// Pi sessions run on a provider key, never the Claude account limit.
|
||||
export const countsAccount = () => false;
|
||||
export const mounts = () => [
|
||||
{ src: realpathSync(join(REPO, "node_modules")), dest: join(REPO, "node_modules"), why: "pi and its packages, on the session's PATH" },
|
||||
...Object.values(UNSLOP).map((src) => ({ src, why: "the unslop check the session's second extension runs" })),
|
||||
];
|
||||
export const mounts = () => [{ src: realpathSync(join(REPO, "node_modules")), dest: join(REPO, "node_modules"), why: "pi and its packages, on the session's PATH" }];
|
||||
// Pi has no web tool of its own, so a granted role under harness-builtin is refused.
|
||||
export const builtinWeb = () => false;
|
||||
export const builtins = ({ tools }) => [...tools];
|
||||
export const typedName = (name) => name;
|
||||
export const reliesOn = () => RELIES_ON;
|
||||
export const gateTimeout = () => null;
|
||||
export const bundle = () => ({ files: {}, digests: [], sources: { piExtension: EXTENSION, ...UNSLOP } });
|
||||
export const bundle = () => ({ files: {}, digests: [], sources: { piExtension: EXTENSION } });
|
||||
|
||||
// The tool limit is the built-ins plus the typed tools, which the extension serves.
|
||||
export function env({ policy, tools, session }) {
|
||||
return {
|
||||
MOSAIC_TOOLS: [...policy.tools, ...tools.map((t) => t.name)].join(","),
|
||||
MOSAIC_EXTENSIONS: [EXTENSION, UNSLOP.unslopExtension].join(","),
|
||||
MOSAIC_EXTENSIONS: EXTENSION,
|
||||
PI_PROVIDER: session.provider ?? "",
|
||||
PI_MODEL: session.model,
|
||||
MOSAIC_THINKING: session.thinking,
|
||||
|
||||
@@ -71,11 +71,9 @@ test("row 65 claude-code preflight: a missing credential, then a missing or rela
|
||||
assert.equal(claude.preflight({ env: { ...token, MOSAIC_CLAUDE_BIN: bin } }), null);
|
||||
assert.equal(claude.preflight({ env: { ...token, PATH: root } }), null);
|
||||
assert.equal(claude.version({ env: { MOSAIC_CLAUDE_BIN: bin, PATH: process.env.PATH } }), "2.1.302");
|
||||
// The pinned binary outside /usr is mounted where the adapter looks for it, and the unslop hook's
|
||||
// files where the hook commands name them (row 66).
|
||||
const unslop = ["claude-hook.js", "unslop-check.js", "lists.json"].map(f => [join(REPO, "tools", "unslop-hook", f), undefined]);
|
||||
assert.deepEqual(claude.mounts({ env: { MOSAIC_CLAUDE_BIN: bin } }).map(m => [m.src, m.dest]), [[bin, bin], ...unslop]);
|
||||
assert.deepEqual(claude.mounts({ env: {} }).map(m => [m.src, m.dest]), unslop);
|
||||
// The pinned binary outside /usr is mounted where the adapter looks for it.
|
||||
assert.deepEqual(claude.mounts({ env: { MOSAIC_CLAUDE_BIN: bin } }).map(m => [m.src, m.dest]), [[bin, bin]]);
|
||||
assert.deepEqual(claude.mounts({ env: {} }), []);
|
||||
});
|
||||
|
||||
// Row 68's claudeVersion precedence, moved here with the version (row 65): the manifest records
|
||||
@@ -102,8 +100,7 @@ test("row 65 pi preflight passes with the repository's pi, and its version is th
|
||||
assert.equal(pi.preflight({ env: {} }), null);
|
||||
const pkg = JSON.parse(readFileSync(join(REPO, "package.json"), "utf8"));
|
||||
assert.equal(pi.version({ env: {} }), pkg.dependencies?.["@earendil-works/pi-coding-agent"] ?? pkg.devDependencies?.["@earendil-works/pi-coding-agent"]);
|
||||
// Row 66: the unslop extension's files, bound read-only at their own paths.
|
||||
assert.deepEqual(pi.mounts({ env: {} }).map(m => m.dest ?? m.src), [join(REPO, "node_modules"), ...["extension.ts", "unslop-check.js", "lists.json"].map(f => join(REPO, "tools", "unslop-hook", f))]);
|
||||
assert.deepEqual(pi.mounts({ env: {} }).map(m => m.dest), [join(REPO, "node_modules")]);
|
||||
});
|
||||
|
||||
test("row 65 env passes the thinking level to both adapters, and claude-code's bundle writes its hook and MCP files", async t => {
|
||||
@@ -111,8 +108,6 @@ test("row 65 env passes the thinking level to both adapters, and claude-code's b
|
||||
const session = { provider: "fixture", model: "m", thinking: "high", toolSocket: "/run/tools.sock" };
|
||||
const piEnv = pi.env({ policy: { tools: ["read"] }, tools: [{ name: "list_tasks" }], session });
|
||||
assert.deepEqual([piEnv.MOSAIC_THINKING, piEnv.PI_MODEL, piEnv.MOSAIC_TOOLS], ["high", "m", "read,list_tasks"]);
|
||||
// Row 66: pi loads the unslop check as its second extension.
|
||||
assert.deepEqual(piEnv.MOSAIC_EXTENSIONS.split(","), [join(REPO, "packages", "harness", "src", "pi-extension.mjs"), join(REPO, "tools", "unslop-hook", "extension.ts")]);
|
||||
const dir = scratch(t);
|
||||
const written = [];
|
||||
const write = (name, text) => { writeFileSync(join(dir, name), text); written.push(name); return [name, "0".repeat(64)]; };
|
||||
@@ -122,9 +117,6 @@ test("row 65 env passes the thinking level to both adapters, and claude-code's b
|
||||
const settings = JSON.parse(readFileSync(join(dir, "settings.json"), "utf8"));
|
||||
assert.match(settings.hooks.PreToolUse[0].hooks[0].command, /^timeout -k 2 10 .* \|\| exit 2$/);
|
||||
assert.equal(settings.hooks.PreToolUse[0].hooks[0].timeout, 20);
|
||||
// Row 66: the unslop hooks can't block: each command ends with `; exit 0`.
|
||||
assert.match(settings.hooks.Stop[0].hooks[0].command, /^timeout -k 2 10 .*claude-hook\.js' stop; exit 0$/);
|
||||
assert.match(settings.hooks.UserPromptSubmit[0].hooks[0].command, /^timeout -k 2 10 .*claude-hook\.js' prompt; exit 0$/);
|
||||
assert.equal(JSON.parse(readFileSync(join(dir, "mcp.json"), "utf8")).mcpServers.mosaic.args.at(-1), "/run/tools.sock");
|
||||
const claudeEnv = claude.env({ files: out.files, policy: { tools: ["read"], typed: [] }, session });
|
||||
assert.deepEqual([claudeEnv.MOSAIC_THINKING, claudeEnv.MOSAIC_CLAUDE_SETTINGS, claudeEnv.MOSAIC_CLAUDE_MCP_CONFIG], ["high", out.files.settings, out.files.mcp]);
|
||||
|
||||
@@ -186,17 +186,22 @@ export function endReason(code, signal) {
|
||||
|
||||
// The host's view of its running sessions, kept in memory and mirrored to
|
||||
// sessions.json for `mosaic stop` and `mosaic launches list`.
|
||||
// readOnly (row 163, a --no-launch host): sessions.json stays as an earlier host left it, so a
|
||||
// later host still finds and ends those runs. add() refuses, and nothing writes the file.
|
||||
export class Registry {
|
||||
#dataRoot;
|
||||
#sessions = [];
|
||||
constructor(dataRoot) {
|
||||
#readOnly;
|
||||
constructor(dataRoot, { readOnly = false } = {}) {
|
||||
this.#dataRoot = dataRoot;
|
||||
this.#readOnly = readOnly;
|
||||
this.#write();
|
||||
}
|
||||
get sessions() {
|
||||
return this.#sessions.map((s) => ({ ...s }));
|
||||
}
|
||||
add(entry) {
|
||||
if (this.#readOnly) throw new SeatError("this host keeps no sessions (--no-launch)", 2);
|
||||
this.#sessions.push({ ...entry });
|
||||
this.#write();
|
||||
}
|
||||
@@ -213,6 +218,7 @@ export class Registry {
|
||||
return this.#sessions.find((s) => s.business === business && s.instance === instance && (s.thread ?? null) === thread) ?? null;
|
||||
}
|
||||
#write() {
|
||||
if (this.#readOnly) return;
|
||||
const file = sessionsFile(this.#dataRoot);
|
||||
mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
|
||||
const tmp = `${file}.${process.pid}.tmp`;
|
||||
|
||||
@@ -93,12 +93,7 @@ test("row 65 a harness module's mounts bind read-only after the adapter, each wi
|
||||
assert.ok(at(join(root, "a.sh")) < at(modules.src) && at(modules.src) < at(join(root, "bus.sock")));
|
||||
const fixture = binds({ ...args, harnessMounts: [{ src: join(root, "tool"), why: "a fixture harness's tool" }] });
|
||||
assert.deepEqual(fixture.find((b) => b.src === join(root, "tool")), { kind: "ro", src: join(root, "tool"), why: "a fixture harness's tool" });
|
||||
// Row 66 (#1559): pi also mounts the unslop extension's three files, each read-only at its own path.
|
||||
for (const f of ["extension.ts", "unslop-check.js", "lists.json"]) {
|
||||
const src = join(REPO, "tools", "unslop-hook", f);
|
||||
assert.deepEqual(list.find((b) => b.src === src), { kind: "ro", src, why: "the unslop check the session's second extension runs" }, f);
|
||||
}
|
||||
assert.equal(binds(args).length, list.length - 4);
|
||||
assert.equal(binds(args).length, list.length - 1);
|
||||
});
|
||||
|
||||
// Row 121 (#1615): a thread run gets only its own thread's two directories,
|
||||
|
||||
@@ -133,6 +133,23 @@ test("Registry mirrors to sessions.json; readSessions marks live entries; bad fi
|
||||
assert.throws(() => readSessions(root), (e) => e instanceof SeatError && /malformed/.test(e.message));
|
||||
});
|
||||
|
||||
// Row 163: a --no-launch host's registry leaves an earlier host's sessions.json as it was.
|
||||
test("a read-only Registry never writes sessions.json, refuses add() and leaves the file on remove()", (t) => {
|
||||
const root = scratch(t);
|
||||
new Registry(root).add({ business: "acme", instance: "chief", run: "r1", runnerPid: process.pid, runnerStartTime: startTimeOf(process.pid) });
|
||||
const before = readFileSync(sessionsFile(root), "utf8");
|
||||
const r = new Registry(root, { readOnly: true });
|
||||
assert.equal(readFileSync(sessionsFile(root), "utf8"), before);
|
||||
assert.deepEqual(r.sessions, []);
|
||||
assert.throws(() => r.add({ business: "acme", instance: "coder", run: "r2" }), (e) => e instanceof SeatError && /keeps no sessions/.test(e.message));
|
||||
assert.equal(readFileSync(sessionsFile(root), "utf8"), before);
|
||||
r.remove("acme", "r1");
|
||||
assert.equal(readFileSync(sessionsFile(root), "utf8"), before);
|
||||
const empty = scratch(t);
|
||||
new Registry(empty, { readOnly: true });
|
||||
assert.equal(existsSync(sessionsFile(empty)), false);
|
||||
});
|
||||
|
||||
// Row 121 (#1615): a thread instance runs once per thread, so the registry
|
||||
// keys a thread run by its thread too.
|
||||
test("REQ-THREAD-1 Registry.running keys a thread run by business, instance and thread", (t) => {
|
||||
|
||||
@@ -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(); }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ function tmp(t, prefix) {
|
||||
function fixtureRepo(t) {
|
||||
const dir = tmp(t, 'webui-copy-');
|
||||
const repo = join(dir, 'repo');
|
||||
const files = execFileSync('git', ['-C', REPO, 'ls-files', '-z', '-co', '--exclude-standard', '--', 'packages', 'scripts', 'roles', 'adapters', 'tools/unslop-hook', 'RELEASE', ':!packages/*/tests', ':!scripts/foundation'], { encoding: 'utf8' }).split('\0').filter(Boolean);
|
||||
const files = execFileSync('git', ['-C', REPO, 'ls-files', '-z', '-co', '--exclude-standard', '--', 'packages', 'scripts', 'roles', 'adapters', 'RELEASE', ':!packages/*/tests', ':!scripts/foundation'], { encoding: 'utf8' }).split('\0').filter(Boolean);
|
||||
for (const f of files) if (existsSync(join(REPO, f))) cpSync(join(REPO, f), join(repo, f));
|
||||
writeFileSync(join(repo, 'RELEASE'), '0.0.0-fixture\n');
|
||||
writeFileSync(join(repo,'package.json'),JSON.stringify({name:'copy-fixture',version:'1.0.0'}));
|
||||
@@ -72,7 +72,7 @@ test('copy writes the Console paths at the full id, read-only, and leaves an exi
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.match(r.stdout, new RegExp(`^copied: ${target}$`, 'm'));
|
||||
assert.deepEqual(readdirSync(copies), [head], 'no temp directory left behind');
|
||||
assert.deepEqual(readdirSync(target).sort(), ['RELEASE', 'adapters', 'package-lock.json', 'package.json', 'packages', 'roles', 'scripts', 'tools']);
|
||||
assert.deepEqual(readdirSync(target).sort(), ['RELEASE', 'adapters', 'package-lock.json', 'package.json', 'packages', 'roles', 'scripts']);
|
||||
assert.deepEqual(readdirSync(join(target, 'scripts')), ['mosaic', 'mosaic-config.mjs']);
|
||||
assert.ok(existsSync(join(target, 'packages', 'webui', 'README.md')), "the unit's Documentation=");
|
||||
assert.equal(statSync(join(target, 'packages', 'webui', 'README.md')).mode & 0o222, 0, 'README.md is read-only');
|
||||
|
||||
@@ -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)}`,
|
||||
|
||||
@@ -30,8 +30,6 @@ COPY_PATHS=(
|
||||
packages/queue/src
|
||||
# Whole directory: every in-repo plug-in module ships with the copy (row 131).
|
||||
packages/plugins
|
||||
# The unslop check both harness modules mount and list in the bundle (row 66).
|
||||
tools/unslop-hook
|
||||
)
|
||||
# stack-release.sh sources this file and calls reachable before an activate (D1).
|
||||
reachable() {
|
||||
|
||||
+304
-2
@@ -15,6 +15,8 @@
|
||||
# 4. this script never prunes backups, rollback sets, post-attempt directories or copies.
|
||||
# Decisions 94 and 102 still decide who starts the Console; webui-service.sh keeps having no start
|
||||
# or stop verb. status and --plan only read, and every seat may run them.
|
||||
# rehearse (D2, row 163) stops and starts no unit and writes only its own rehearsal directory, but
|
||||
# it reads the live config and a backup of the live store, so Jason or Mos runs it.
|
||||
#
|
||||
# Most functions below run as `f || handler`, and bash ignores set -e inside such a call, so
|
||||
# every step that can fail carries its own `|| return 1`.
|
||||
@@ -28,7 +30,7 @@ BIN="$HOME/.npm-global/bin"
|
||||
UNIT_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
|
||||
CONSOLE_UNIT=mosaic-webui.service
|
||||
TIMEOUT=60 WANT_PM=0 AFTER_HARNESS=0 MIGRATE=0 PLAN=0 RESTORE="" EVENTS="" TESTER_PORT=""
|
||||
PROOF_HEADER="" PROOF_FILE="" NO_PROOF=0
|
||||
PROOF_HEADER="" PROOF_FILE="" NO_PROOF=0 R_BACKUP="" R_PORT=""
|
||||
|
||||
usage() {
|
||||
cat >&2 <<'USAGE'
|
||||
@@ -38,6 +40,7 @@ usage: stack-release.sh adopt BUSINESS
|
||||
stack-release.sh pm-on BUSINESS [--timeout S]
|
||||
stack-release.sh rollback BUSINESS [--restore ACTIVATION [--events N]] [--timeout S] [--plan]
|
||||
stack-release.sh status BUSINESS
|
||||
stack-release.sh rehearse ID BUSINESS --backup ACTIVATION|PATH --port N [--timeout S]
|
||||
common options: --copies DIR, --bin DIR
|
||||
--events N is the total of the per-table counts the post-backup-events refusal prints: events and
|
||||
every table in COUNTED.
|
||||
@@ -84,11 +87,14 @@ parse_opts() {
|
||||
--no-proof) [ "$OP" = activate ] || usage; NO_PROOF=1; shift ;;
|
||||
--migrate) [ "$OP" = activate ] || usage; MIGRATE=1; shift ;;
|
||||
--restore) [ "$OP" = rollback ] && [ $# -ge 2 ] && [[ "$2" =~ ^[0-9]{8}T[0-9]{6}Z-[0-9a-f]{12}(-[1-9])?$ ]] || usage; RESTORE="$2"; shift 2 ;;
|
||||
--backup) [ "$OP" = rehearse ] && [ $# -ge 2 ] && { [[ "$2" =~ ^[0-9]{8}T[0-9]{6}Z-[0-9a-f]{12}(-[1-9])?$ ]] || [[ "$2" =~ ^/[A-Za-z0-9/._+-]*$ ]]; } || usage; R_BACKUP="$2"; shift 2 ;;
|
||||
--port) [ "$OP" = rehearse ] && [ $# -ge 2 ] && [[ "$2" =~ ^[0-9]{1,5}$ ]] && [ "$((10#$2))" -ge 1024 ] && [ "$((10#$2))" -le 65535 ] || usage; R_PORT="$((10#$2))"; shift 2 ;;
|
||||
--events) [ "$OP" = rollback ] && [ $# -ge 2 ] && [[ "$2" =~ ^(0|[1-9][0-9]*)$ ]] || usage; EVENTS="$2"; shift 2 ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
done
|
||||
[ -z "$EVENTS" ] || [ -n "$RESTORE" ] || usage
|
||||
[ "$OP" != rehearse ] || { [ -n "$R_BACKUP" ] && [ -n "$R_PORT" ]; } || usage
|
||||
# A bad tester port is a usage error; --plan lists it as tester-port-invalid instead (row 180).
|
||||
[ -z "$TESTER_PORT" ] || [ "$PLAN" = 1 ] || valid_tester_port "$TESTER_PORT" 7332 || usage
|
||||
# --pm-after-harness replaces --pm (row 169 item 1), so the two never go together.
|
||||
@@ -1155,13 +1161,307 @@ do_status() {
|
||||
fi
|
||||
}
|
||||
|
||||
# --- The rehearsal (D2, row 163) ---
|
||||
|
||||
# rehearse runs the new copy's bus host (--no-launch) and Console on a scratch root built from a
|
||||
# backup, under <dataRoot>/rehearsals/<stamp>/, and gates them as activate does. It never writes the
|
||||
# live store, the live config files, the backup, the pointer, the log or a unit. Nothing prunes
|
||||
# the rehearsal directory. Design, rulings (mos-0961, mos-0962, mos-0963) and the deviations from
|
||||
# the brief: agents/sage/work/queue-163/BUILD.md.
|
||||
|
||||
# One PASS or FAIL line per check, on stdout and in steps.log. Lines from before the directory
|
||||
# exists wait in R_EARLY and go in first.
|
||||
r_line() {
|
||||
echo "$*"
|
||||
if [ -z "$STEPS" ]; then R_EARLY+=("$(now) $*"); else echo "$(now) $*" >> "$STEPS" || true; fi
|
||||
}
|
||||
r_pass() { r_line "PASS $1${2:+: $2}"; }
|
||||
r_fail() { r_line "FAIL $1${2:+: $2}"; R_RC=1; }
|
||||
r_abort() { r_fail "$@"; r_end; }
|
||||
|
||||
r_alive() { local s; s="$(cat "/proc/$1/stat" 2>/dev/null)" || return 1; s="${s##*) }"; [ "${s:0:1}" != Z ]; }
|
||||
r_gone() {
|
||||
local deadline=$((SECONDS + TIMEOUT))
|
||||
while r_alive "$1"; do [ "$SECONDS" -lt "$deadline" ] || return 1; sleep 1; done
|
||||
}
|
||||
|
||||
# Stops the Console, then the host, with SIGTERM within --timeout; one still there gets SIGKILL
|
||||
# and a FAIL line. The host has to leave the store's writer.lock behind it.
|
||||
r_stop() {
|
||||
local name pid
|
||||
for name in console host; do
|
||||
if [ "$name" = console ]; then pid="$R_CONSOLE_PID"; R_CONSOLE_PID=""; else pid="$R_HOST_PID"; R_HOST_PID=""; fi
|
||||
[ -n "$pid" ] || continue
|
||||
kill -TERM "$pid" 2>/dev/null || true
|
||||
if r_gone "$pid"; then r_pass "$name-stopped" "pid $pid"
|
||||
else kill -KILL "$pid" 2>/dev/null || true; r_fail "$name-stopped" "pid $pid was still running ${TIMEOUT}s after SIGTERM; sent SIGKILL"
|
||||
fi
|
||||
wait "$pid" 2>/dev/null || true
|
||||
done
|
||||
[ -n "$R_STARTED" ] || return 0
|
||||
R_STARTED=""
|
||||
if [ -e "$R/data/bus/writer.lock" ]; then r_fail host-released-store "$R/data/bus/writer.lock is still there"; else r_pass host-released-store; fi
|
||||
}
|
||||
|
||||
r_tree_sums() { (cd "$1" && find . -type f -print0 | sort -z | xargs -0 -r sha256sum); }
|
||||
|
||||
# The last checks, then the last line. The backup and the live config files are compared with the
|
||||
# sums taken before the rehearsal directory was made.
|
||||
r_end() {
|
||||
trap - INT TERM HUP
|
||||
r_stop
|
||||
if [ -n "$R" ]; then
|
||||
if [ "$(r_tree_sums "$R_BK")" = "$(cat "$R/backup.before")" ]; then r_pass backup-unchanged; else r_fail backup-unchanged "$R_BK differs from $R/backup.before"; fi
|
||||
if [ "$(sha256sum "$LIVE_CFG" "$LIVE_BIZ" 2>&1)" = "$(cat "$R/live.before")" ]; then r_pass live-config-unchanged; else r_fail live-config-unchanged "$LIVE_CFG or $LIVE_BIZ differs from $R/live.before"; fi
|
||||
fi
|
||||
if [ "$R_RC" = 0 ]; then r_line REHEARSAL_PASS; else r_line REHEARSAL_FAIL; fi
|
||||
[ -z "$R" ] || echo "rehearsal directory: $R"
|
||||
exit "$R_RC"
|
||||
}
|
||||
|
||||
r_port_free() {
|
||||
node -e 'const s=require("node:net").createServer();s.once("error",()=>process.exit(1));s.listen(Number(process.argv[1]),"127.0.0.1",()=>s.close(()=>process.exit(0)));' -- "$1"
|
||||
}
|
||||
|
||||
# The scratch config: the live one with dataRoot set to the scratch data root.
|
||||
R_CONFIG_JS='
|
||||
const fs=require("node:fs");const [file,data]=process.argv.slice(1);
|
||||
const c=JSON.parse(fs.readFileSync(file,"utf8"));c.dataRoot=data;process.stdout.write(JSON.stringify(c,null,2)+"\n");'
|
||||
|
||||
# The scratch business file (mos-0962, mos-0963): the live file without its tracker.* variables,
|
||||
# business and role, and with every credential reference, the roles and tracker.sync, moved to its
|
||||
# own 0600 placeholder file. The service and its rotateBy or expires date stay. The tracker key
|
||||
# stays because the loader requires it. Mode `normalize` prints the live file as this writes it,
|
||||
# for the diff.
|
||||
R_BUSINESS_JS='
|
||||
const fs=require("node:fs"),path=require("node:path");const [mode,live,out,ph]=process.argv.slice(1);
|
||||
const b=JSON.parse(fs.readFileSync(live,"utf8"));
|
||||
if(mode==="normalize"){process.stdout.write(JSON.stringify(b,null,2)+"\n");process.exit(0)}
|
||||
const DATE={gitea:"rotateBy",vikunja:"expires"};
|
||||
const strip=(vars)=>{if(vars&&typeof vars==="object")for(const k of Object.keys(vars))if(k.startsWith("tracker."))delete vars[k]};
|
||||
const swap=(name,refs)=>{
|
||||
if(!refs||typeof refs!=="object")return;
|
||||
for(const s of Object.keys(refs)){
|
||||
const file=path.join(ph,`${name}-${s}`),old=refs[s],ref={file};
|
||||
fs.writeFileSync(file,"rehearsal-placeholder-token",{mode:0o600,flag:"wx"});
|
||||
if(old&&typeof old==="object"&&Object.hasOwn(DATE,s)&&old[DATE[s]]!==undefined)ref[DATE[s]]=old[DATE[s]];
|
||||
refs[s]=ref;
|
||||
}
|
||||
};
|
||||
strip(b.vars);
|
||||
for(const [name,r] of Object.entries(b.roles??{})){strip(r?.vars);swap(name,r?.credentials)}
|
||||
swap("@sync",b.tracker?.sync?.credentials);
|
||||
fs.writeFileSync(out,JSON.stringify(b,null,2)+"\n",{mode:0o600,flag:"wx"});'
|
||||
|
||||
# The guard, before the host starts. It reads the scratch config through the new copy's own
|
||||
# loadSystem and bootConfig, as the host will, and prints one line per check:
|
||||
# data-root-live the data root is not the live one (mos-0961)
|
||||
# sessions-live no sessions.json there names a running process (mos-0961)
|
||||
# data-root-scratch the data root is the scratch one
|
||||
# notify-off the notifier config is {"notifyVersion":1,"binding":null}, 0600 (mos-0962)
|
||||
# tracker-configured bootConfig sets no trackers (mos-0962)
|
||||
# credential-ref-live every credential reference is a file under placeholders/ (mos-0963)
|
||||
# Exit 0 when all pass, 1 on the first FAIL.
|
||||
R_GUARD_JS='
|
||||
const fs=require("node:fs"),path=require("node:path");const {pathToFileURL}=require("node:url");
|
||||
const [copy,id,live,scratch,ph]=process.argv.slice(1);
|
||||
const real=(p)=>{try{return fs.realpathSync(p)}catch{return null}};
|
||||
const alive=(pid)=>{try{const s=fs.readFileSync(`/proc/${pid}/stat`,"utf8");return s[s.lastIndexOf(")")+2]!=="Z"}catch{return false}};
|
||||
const pass=(n,w)=>console.log(`PASS ${n}${w?": "+w:""}`);
|
||||
const fail=(n,w)=>{console.log(`FAIL ${n}: ${w}`);process.exit(1)};
|
||||
(async()=>{
|
||||
let c,system,config;
|
||||
try{c=await import(pathToFileURL(`${copy}/packages/cli/src/config.mjs`).href);system=c.loadSystem({env:process.env})}
|
||||
catch(e){fail("scratch-config",String(e?.message??e).split("\n")[0])}
|
||||
const root=real(system.dataRoot);
|
||||
if(root===null)fail("data-root-scratch",`${system.dataRoot} does not exist`);
|
||||
if(root===real(live))fail("data-root-live",`the scratch config names dataRoot ${system.dataRoot}, which resolves to the live data root ${live}`);
|
||||
pass("data-root-live",`${system.dataRoot} is not the live data root`);
|
||||
const sf=path.join(root,"bus-host","sessions.json");
|
||||
if(fs.existsSync(sf)){
|
||||
let doc;try{doc=JSON.parse(fs.readFileSync(sf,"utf8"))}catch{fail("sessions-live",`${sf} is unreadable`)}
|
||||
if(!Array.isArray(doc?.sessions))fail("sessions-live",`${sf} is malformed`);
|
||||
const running=doc.sessions.flatMap((s)=>[s?.pid,s?.runnerPid]).filter((p)=>Number.isSafeInteger(p)&&p>0&&alive(p));
|
||||
if(running.length)fail("sessions-live",`${sf} names running pid(s) ${running.join(", ")}`);
|
||||
}
|
||||
pass("sessions-live","no running session named in the data root");
|
||||
if(root!==real(scratch))fail("data-root-scratch",`the scratch config resolves to ${system.dataRoot}, not ${scratch}`);
|
||||
pass("data-root-scratch");
|
||||
const nf=path.join(root,"notify",id,"notify.json");
|
||||
let st,doc;try{st=fs.lstatSync(nf);doc=JSON.parse(fs.readFileSync(nf,"utf8"))}catch{fail("notify-off",`${nf} is missing or not JSON`)}
|
||||
if(!st.isFile()||(st.mode&0o777)!==0o600||JSON.stringify(doc)!==JSON.stringify({notifyVersion:1,binding:null}))fail("notify-off",`${nf} is not {"notifyVersion":1,"binding":null} at mode 0600`);
|
||||
pass("notify-off","binding null, so no Discord bridge");
|
||||
try{config=c.bootConfig({system,businessId:id,env:process.env})}catch(e){fail("boot-config",String(e?.message??e).split("\n")[0])}
|
||||
if(config.trackers!==undefined)fail("tracker-configured",`bootConfig sets trackers for ${Object.keys(config.trackers).join(", ")}`);
|
||||
pass("tracker-configured","bootConfig sets no trackers");
|
||||
const b=config.businesses?.[id],phr=real(ph),refs=[];
|
||||
for(const [role,r] of Object.entries(b?.roles??{}))for(const [s,ref] of Object.entries(r?.credentials??{}))refs.push([`${role} ${s}`,ref]);
|
||||
for(const [s,ref] of Object.entries(b?.tracker?.sync?.credentials??{}))refs.push([`@sync ${s}`,ref]);
|
||||
for(const [what,ref] of refs){
|
||||
if(ref?.env!==undefined)fail("credential-ref-live",`${what} is env ${ref.env}`);
|
||||
const f=typeof ref?.file==="string"?real(ref.file):null;
|
||||
if(f===null||phr===null||path.dirname(f)!==phr)fail("credential-ref-live",`${what} is ${ref?.file}, not a file in ${ph}`);
|
||||
}
|
||||
pass("credential-ref-live",`${refs.length} reference(s), each a placeholder`);
|
||||
})().catch((e)=>fail("guard",String(e?.message??e).split("\n")[0]));'
|
||||
|
||||
# Opens a data root's store with a copy's Store, as the bus does at start, with no migration
|
||||
# (de352dfaf's verify-open.mjs). Prints OPEN_OK <version> or REFUSED <message>.
|
||||
R_OPEN_JS='
|
||||
const {pathToFileURL}=require("node:url");const [copy,root]=process.argv.slice(1);
|
||||
(async()=>{
|
||||
const {Store}=await import(pathToFileURL(`${copy}/packages/bus/src/store.mjs`).href);
|
||||
let s;
|
||||
try{s=new Store(root);process.stdout.write(`OPEN_OK ${s.get("SELECT value FROM meta WHERE key=?","schema_version").value}\n`)}
|
||||
catch(e){process.stdout.write(`REFUSED ${e.message}\n`)}
|
||||
finally{s?.close()}
|
||||
})();'
|
||||
r_open() { node -e "$R_OPEN_JS" -- "$1" "$2" 2>&1 || true; }
|
||||
|
||||
# Every process this rehearsal starts gets only HOME, PATH and the scratch MOSAIC_CONFIG.
|
||||
r_env() { env -i HOME="$HOME" PATH="$PATH" MOSAIC_CONFIG="$R/config/config.json" "$@"; }
|
||||
|
||||
# The de352dfaf checks, when the backup's schema is not the new copy's.
|
||||
r_migration() {
|
||||
local copy="$COPIES/$TO" old="$COPIES/$P_COPY" v out rc
|
||||
v="$(node -e "$STORE_JS" -- version "$R/data/bus/bus.sqlite" 2>&1)" || r_abort store-unreadable "$v"
|
||||
if [ "$v" = "$T_SCHEMA" ]; then r_pass migration "not needed: the backup is already $v"; return 0; fi
|
||||
in_chain "$v" || r_abort store-newer "the backup is $v, which $TO's migration chain does not start from"
|
||||
(umask 077; mkdir "$R/refuse" "$R/refuse/bus" && cp "$R_BK"/bus/* "$R/refuse/bus/") || r_abort scratch-build "cannot copy the backup to $R/refuse"
|
||||
out="$(r_open "$copy" "$R/refuse")"
|
||||
[ "$out" = "REFUSED schema-mismatch" ] || r_abort new-refuses-old-store "$TO opened the $v store: $out"
|
||||
r_pass new-refuses-old-store "$TO refuses the $v store at a normal open"
|
||||
rc=0; out="$(r_env node "$copy/packages/bus/src/migrate-schema.mjs" "$R/data" 2>&1)" || rc=$?
|
||||
printf '%s\n' "$out" >> "$R/migrate.log"
|
||||
[ "$rc" = 0 ] && [ "$(tail -n 1 <<< "$out")" = "bus schema $T_SCHEMA ready" ] || r_abort migrate "migrate-schema.mjs exit $rc: $(tail -n 1 <<< "$out")"
|
||||
r_pass migrate "$v to $T_SCHEMA"
|
||||
out="$(r_open "$copy" "$R/data")"
|
||||
[ "$out" = "OPEN_OK $T_SCHEMA" ] || r_abort new-opens-migrated "$out"
|
||||
r_pass new-opens-migrated
|
||||
rc=0; out="$(r_env node "$copy/packages/bus/src/migrate-schema.mjs" "$R/data" 2>&1)" || rc=$?
|
||||
printf '%s\n' "$out" >> "$R/migrate.log"
|
||||
[ "$rc" = 0 ] && [ "$(tail -n 1 <<< "$out")" = "bus schema $T_SCHEMA ready" ] || r_abort migrate-again "second migrate-schema.mjs exit $rc: $(tail -n 1 <<< "$out")"
|
||||
r_pass migrate-again "a second run is a no-op"
|
||||
out="$(r_open "$old" "$R/data")"
|
||||
[ "$out" = "REFUSED schema-mismatch" ] || r_abort old-refuses-migrated "the pointer's copy $P_COPY opened the $T_SCHEMA store: $out"
|
||||
r_pass old-refuses-migrated "$P_COPY refuses the $T_SCHEMA store"
|
||||
}
|
||||
|
||||
# One pass of D1's gate checks but the PM's, against the processes this run started. Sets R_GATE
|
||||
# to the first failing check and R_WHY to what it saw.
|
||||
r_gate_once() {
|
||||
local copy="$COPIES/$TO" out
|
||||
R_GATE=host-up R_WHY=""
|
||||
r_alive "$R_HOST_PID" || { R_WHY="the host (pid $R_HOST_PID) exited; see $R/host.log"; return 1; }
|
||||
grep -q "^bus host up: business $BUSINESS, .*, launches disabled\$" "$R/host.log" || { R_WHY="no \"bus host up ... launches disabled\" line in $R/host.log"; return 1; }
|
||||
R_GATE=bus-status
|
||||
out="$(r_env "$copy/scripts/mosaic" bus status --json 2>/dev/null)" || { R_WHY="bus status failed"; return 1; }
|
||||
R_WHY="$(node -e '
|
||||
const [b,main,since]=process.argv.slice(2),s=JSON.parse(process.argv[1]);
|
||||
const why=!s.host?"no host":s.host.business!==b?`host for ${s.host.business}`:s.host.live!==true?"host not live":String(s.host.pid)!==main?`host pid ${s.host.pid} is not ${main}, the host this run started`:!s.boot||s.boot.ok!==true?"boot status not ok":s.boot.business!==b?`boot status for ${s.boot.business}`:!(Date.parse(s.boot.at)>=Number(since))?"boot status is from before this start":"";
|
||||
process.stdout.write(why);' -- "$out" "$BUSINESS" "$R_HOST_PID" "$START_MS" 2>&1)" || { R_WHY="bus status unreadable"; return 1; }
|
||||
[ -z "$R_WHY" ] || return 1
|
||||
R_GATE=console-up
|
||||
r_alive "$R_CONSOLE_PID" || { R_WHY="the Console (pid $R_CONSOLE_PID) exited; see $R/console.log"; return 1; }
|
||||
grep -qxF "Mosaic Console: http://127.0.0.1:$R_PORT/" "$R/console.log" || { R_WHY="no \"Mosaic Console: http://127.0.0.1:$R_PORT/\" line in $R/console.log"; return 1; }
|
||||
R_GATE=healthz
|
||||
node -e '
|
||||
fetch(`http://127.0.0.1:${process.argv[1]}/healthz`,{signal:AbortSignal.timeout(5000)}).then((r)=>process.exit(r.status===200?0:1),()=>process.exit(1));' -- "$R_PORT" || return 1
|
||||
R_GATE=copy-match
|
||||
tr '\0' ' ' < "/proc/$R_HOST_PID/cmdline" | grep -qF " $copy/packages/cli/src/cli.mjs bus start $BUSINESS --no-launch" || { R_WHY="the host's command line is not $copy's cli.mjs bus start $BUSINESS --no-launch"; return 1; }
|
||||
tr '\0' ' ' < "/proc/$R_CONSOLE_PID/cmdline" | grep -qF " $copy/packages/webui/src/cli.mjs serve " || { R_WHY="the Console's command line is not $copy's webui cli.mjs serve"; return 1; }
|
||||
R_GATE=""
|
||||
}
|
||||
|
||||
r_gate() {
|
||||
local deadline=$((SECONDS + TIMEOUT))
|
||||
until r_gate_once; do
|
||||
[ "$SECONDS" -lt "$deadline" ] || { r_fail "$R_GATE" "${R_WHY:-no answer within ${TIMEOUT}s}"; return 1; }
|
||||
sleep 1
|
||||
done
|
||||
r_pass gate "host-up, bus-status, console-up, healthz and copy-match on $TO"
|
||||
}
|
||||
|
||||
# No role session: the host wrote no sessions.json and no launches directory or log.
|
||||
r_no_session() {
|
||||
local found=""
|
||||
[ ! -e "$R/data/bus-host/sessions.json" ] || found+=" bus-host/sessions.json"
|
||||
[ ! -e "$R/data/launches" ] || found+=" launches"
|
||||
if [ -z "$found" ]; then r_pass no-session "no sessions.json and no launches under $R/data"; else r_fail no-session "found$found under $R/data"; fi
|
||||
}
|
||||
|
||||
do_rehearse() {
|
||||
TO="$ID"
|
||||
trap 'r_fail signal "the rehearsal was interrupted"; r_end' INT TERM HUP
|
||||
read_pointer || r_abort no-pointer "no $POINTER; the pointer's copy is the old copy the checks use"
|
||||
[ "$P_BUSINESS" = "$BUSINESS" ] || r_abort business-mismatch "the pointer is for $P_BUSINESS"
|
||||
copy_exists "$TO" 2>/dev/null || r_abort copy-missing "no copy $TO in $COPIES"
|
||||
copy_ok "$TO" 2>/dev/null || r_abort copy-unreachable "$TO is not reachable from refactor and origin/refactor"
|
||||
target_schema "$TO" || r_abort copy-schema-unknown "cannot read SCHEMA_VERSION and its chain from $COPIES/$TO/packages/bus/src/store.mjs"
|
||||
# mos-0961: an older copy's host has no --no-launch and would start the PM on its own.
|
||||
grep -qF -- '"--no-launch"' "$COPIES/$TO/packages/cli/src/cli.mjs" 2>/dev/null || r_abort copy-without-no-launch "$TO's bus host has no --no-launch flag"
|
||||
r_pass copy "$TO, schema $T_SCHEMA"
|
||||
local i
|
||||
local -a reserved=(7332 7333 "$(console_port)")
|
||||
for ((i = 0; i < ${#P_CONSOLE[@]}; i++)); do [ "${P_CONSOLE[i]}" != --tester-port ] || reserved+=("${P_CONSOLE[i + 1]:-}"); done
|
||||
for i in "${reserved[@]}"; do [ "$((10#$R_PORT))" != "$i" ] || r_abort port-reserved "--port $R_PORT is a live Console port (7332, 7333 and the pointer's: ${reserved[*]:2})"; done
|
||||
r_port_free "$R_PORT" || r_abort port-in-use "127.0.0.1:$R_PORT is in use; the rehearsal takes no other port"
|
||||
r_pass port "127.0.0.1:$R_PORT is free"
|
||||
case "$R_BACKUP" in /*) R_BK="$R_BACKUP" ;; *) R_BK="$DATA/backups/stack/$R_BACKUP" ;; esac
|
||||
[ -f "$R_BK/bus/bus.sqlite" ] && [ -f "$R_BK/SHA256SUMS" ] || r_abort backup-missing "$R_BK has no bus/bus.sqlite and SHA256SUMS"
|
||||
(cd "$R_BK/bus" && sha256sum -c --quiet ../SHA256SUMS) >/dev/null 2>&1 || r_abort backup-sums "$R_BK/bus does not match its SHA256SUMS"
|
||||
r_pass backup "$R_BK matches its SHA256SUMS"
|
||||
LIVE_CFG="$(realpath -m "${MOSAIC_CONFIG:-$HOME/.config/mosaic-dev/config.json}")"
|
||||
LIVE_BIZ="$(dirname "$LIVE_CFG")/businesses/$BUSINESS.json"
|
||||
[ -f "$LIVE_BIZ" ] || r_abort business-missing "no $LIVE_BIZ"
|
||||
|
||||
local stamp before live
|
||||
before="$(r_tree_sums "$R_BK")" && live="$(sha256sum "$LIVE_CFG" "$LIVE_BIZ" 2>&1)" || r_abort sums "cannot read $R_BK or the live config files"
|
||||
stamp="$(date -u +%Y%m%dT%H%M%SZ)-${TO:0:12}"
|
||||
(umask 077; mkdir -p "$DATA/rehearsals" && mkdir "$DATA/rehearsals/$stamp") || r_abort rehearsal-dir "cannot make $DATA/rehearsals/$stamp (one rehearsal per second and copy)"
|
||||
R="$DATA/rehearsals/$stamp" STEPS="$DATA/rehearsals/$stamp/steps.log"
|
||||
printf '%s\n' "${R_EARLY[@]}" > "$STEPS" && printf '%s\n' "$before" > "$R/backup.before" && printf '%s\n' "$live" > "$R/live.before" ||
|
||||
r_abort scratch-build "cannot write the sums to $R"
|
||||
(
|
||||
umask 077
|
||||
mkdir "$R/config" "$R/config/businesses" "$R/placeholders" "$R/data" "$R/data/bus" "$R/data/notify" "$R/data/notify/$BUSINESS" &&
|
||||
cp "$R_BK"/bus/* "$R/data/bus/" &&
|
||||
node -e "$R_CONFIG_JS" -- "$LIVE_CFG" "$R/data" > "$R/config/config.json" &&
|
||||
node -e "$R_BUSINESS_JS" -- write "$LIVE_BIZ" "$R/config/businesses/$BUSINESS.json" "$R/placeholders" &&
|
||||
printf '{"notifyVersion":1,"binding":null}\n' > "$R/data/notify/$BUSINESS/notify.json"
|
||||
) 2>> "$STEPS" || r_abort scratch-build "cannot build the scratch root under $R; see $STEPS"
|
||||
local rc=0
|
||||
diff -u --label "$LIVE_BIZ (as JSON.stringify writes it)" --label "$R/config/businesses/$BUSINESS.json" \
|
||||
<(node -e "$R_BUSINESS_JS" -- normalize "$LIVE_BIZ") "$R/config/businesses/$BUSINESS.json" > "$R/business.diff" || rc=$?
|
||||
[ "$rc" -le 1 ] || r_abort scratch-build "cannot write $R/business.diff"
|
||||
r_pass scratch-root "$R/data from the backup, config and business file under $R/config, business.diff beside them"
|
||||
|
||||
local out
|
||||
rc=0; out="$(r_env node -e "$R_GUARD_JS" -- "$COPIES/$TO" "$BUSINESS" "$DATA" "$R/data" "$R/placeholders" 2>&1)" || rc=$?
|
||||
while IFS= read -r i; do r_line "$i"; done <<< "$out"
|
||||
[ "$rc" = 0 ] || { R_RC=1; r_end; }
|
||||
|
||||
r_migration
|
||||
|
||||
START_MS="$(date +%s%3N)"
|
||||
R_STARTED=1
|
||||
# env, not r_env: a function in the background is a subshell, and $! has to be the process
|
||||
# itself (env execs scripts/mosaic, which execs node), for the bus-status pid check and the stop.
|
||||
env -i HOME="$HOME" PATH="$PATH" MOSAIC_CONFIG="$R/config/config.json" "$COPIES/$TO/scripts/mosaic" bus start "$BUSINESS" --no-launch > "$R/host.log" 2>&1 &
|
||||
R_HOST_PID=$!
|
||||
env -i HOME="$HOME" PATH="$PATH" MOSAIC_CONFIG="$R/config/config.json" node "$COPIES/$TO/packages/webui/src/cli.mjs" serve --port "$R_PORT" --business "$BUSINESS" > "$R/console.log" 2>&1 &
|
||||
R_CONSOLE_PID=$!
|
||||
if r_gate; then r_no_session; fi
|
||||
r_end
|
||||
}
|
||||
|
||||
# --- Dispatch ---
|
||||
|
||||
[ $# -gt 0 ] || usage
|
||||
OP="$1"; shift
|
||||
ID=""
|
||||
case "$OP" in
|
||||
activate) [ $# -ge 2 ] || usage; ID="$1"; BUSINESS="$2"; shift 2; [[ "$ID" =~ ^[0-9a-f]{40}$ ]] || usage ;;
|
||||
activate|rehearse) [ $# -ge 2 ] || usage; ID="$1"; BUSINESS="$2"; shift 2; [[ "$ID" =~ ^[0-9a-f]{40}$ ]] || usage ;;
|
||||
adopt|pm-on|rollback|status) [ $# -ge 1 ] || usage; BUSINESS="$1"; shift ;;
|
||||
-h|--help) usage 0 ;;
|
||||
*) usage ;;
|
||||
@@ -1179,10 +1479,12 @@ BUS_UNIT="mosaic-bus@$BUSINESS.service"
|
||||
FROM="" TO="" ACT="" RS="" POST="" BACKUP="" TAKEN="" RESTORED="" STEPS="" COUNT="" UNCLEAN="" FAIL=""
|
||||
STORE_SCHEMA="" START_EPOCH="" START_MS=0 GATE="" GATE_WHY="" S_CHANGED=""
|
||||
P_COPY="" P_PM=0 P_PENDING=0 P_SCHEMA="" P_CLAUDE="" P_CONSOLE=() RUN_PM=0 RUN_PENDING=0 RUN_CONSOLE=() DROP_TESTER="" PROOF_PLAN="" PROOF_STEP="" PLAN_REFUSE=()
|
||||
R="" R_RC=0 R_EARLY=() R_BK="" R_HOST_PID="" R_CONSOLE_PID="" R_STARTED="" LIVE_CFG="" LIVE_BIZ=""
|
||||
case "$OP" in
|
||||
adopt) do_adopt ;;
|
||||
activate) do_activate ;;
|
||||
pm-on) do_pm_on ;;
|
||||
rollback) do_rollback ;;
|
||||
status) do_status ;;
|
||||
rehearse) do_rehearse ;;
|
||||
esac
|
||||
Loaded 100 of 107 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user