Compare commits

..
Author SHA1 Message Date
jason.woltje df65a3d4c8 stack: row 190 round 3 (#1688): rebased on row 192 (Q175 SSO), the loader's bootstrap, selected and loadSelected await row 192's async load inside the Failure try; T-I3 checks a named --sso render carries --sso and MOSAIC_CONFIG (Filbert 29685 note 4) 2026-10-20 16:32:05 -05:00
jason.woltje cdac7ba92b stack: row 190 round 2 (#1688): T56 after row 189's T55, N1 copy and usage, N3 Console comment, N5 and N6
Filbert's round 1 (comment 29634) and Sage's N3 ruling (sage-1474).
Row 189 also named a test T55, so row 190's is T56. webui-service.sh copy
refuses --instance like stack-copy.sh copy; TOOLS.md shows --instance on
the bus and discord usage. A named Console unit's comment names
MOSAIC_CONFIG, and the default keeps the template's line. check
--instance needs the wrapper's MOSAIC_CONFIG line. TOOLS.md says an
existing directory keeps its mode.
2026-10-20 16:30:41 -05:00
jason.woltje ab1d2ee9e6 stack: several Mosaic Stack instances on one host (row 190, #1688)
--instance NAME on the shell entry points through scripts/instance.sh;
per-instance config, data root and marker, units, wrapper and ports;
the claim checks in mosaic-config.mjs; the guard protects every
instance's config and data root. Brief blob 4e8f1e0528, design
agents/darkwing/work/queue-190/DESIGN.md rev 3 (Filbert approved).
2026-10-20 16:30:34 -05:00
jason.woltje 176a6bf4b9 sessions: Filbert row 190 manifest round 2 CHANGES (base drift on row 192) 2026-10-20 16:29:20 -05:00
jason.woltje 5b8def2f14 plans: Case 2 row briefs from Gosalyn's eac92d956 (design r11, approved webby-0038: C2-13 split into C2-13a unit render and host precheck and C2-13b install script and library precheck, C2-04 owner Herb, Console unit without [Install] per mos-1097) 2026-10-20 16:22:47 -05:00
jason.woltje 4429a104f6 webui: Q175 phase 1, the Console verifies SSO itself under --sso, OIDC RP with oauth4webapi 3.8.8 (row 192, #1690)
Squash of Launchpad's launchpad/row192 53bb59462..37582e820, manifest round 2
6f8cee147713c53652b58d47c5efd15ed47da1efbef6a126464857269b0b7787 (18 paths).
Sage's gate on 088087ac9: package suites 2758 pass 0 fail, scripts/test-*.sh
429 pass 0 fail; mutants 22 of 23 killed, #23 (second origin check) declared
equivalent by Launchpad (sage-1407). New dependency oauth4webapi 3.8.8: the
canonical checkout and any stack copy need npm ci. No live change; the live
re-pin is a window with Mos (mos-1099 item 3).
2026-10-20 16:21:29 -05:00
jason.woltje 32b54ac3b0 plans: Case 2 row briefs from Gosalyn's 51006710a (webby-0034 N1/N2 and webby-0035: row A README :711 one line per variable, C2-13 owns README :713 and says the precheck prints only variable names) 2026-10-20 15:57:07 -05:00
jason.woltje 6025ddd9cf plans: Case 2 row briefs from Gosalyn's a2d94b2c2 (Webby r9 notes, webby-0032: row A README line on claude-session.env and the dist/providers/data/zai.json path, C2-13 XDG_DATA_HOME precheck and last-assignment-wins key file parse) 2026-10-20 15:55:52 -05:00
jason.woltje ecbfd6deb6 plans: Case 2 row briefs from Gosalyn's 29e85196b (design r9 for mos-1090: row A out of scope points at design 10 step 2d, C2-13 precheck checks the hub key file by name and mode, claude marker check only for claude-code harness, EnvironmentFile= fixed in case2-unit.sh) 2026-10-20 15:44:09 -05:00
jason.woltje 0498c0613f agents: Gosalyn seat dir, README from gosalyn/case2-design, so Case 2 rows can name the seat as owner (#416) 2026-10-20 15:43:18 -05:00
jason.woltje 49d1f3a231 plans: Case 2 slice row briefs plus rows A and B, from Gosalyn's f325657c7 on gosalyn/case2-design (#416, design r8, C2-13 copy id and profile asks for row 195, C2-20 count-only note, row A ZAI_API_KEY per mos-1086/mos-1089, row B #428 after 10-27) 2026-10-20 15:41:39 -05:00
jason.woltje 628e2ca5cd sessions: Filbert row 190 manifest round 1 CHANGES (base drift on row 189) 2026-10-20 15:40:15 -05:00
43 changed files with 4227 additions and 1141 deletions
+11 -6
View File
@@ -25,10 +25,15 @@ green at every step. Not production software — a proven foundation.
1. **Root is bootstrap-only.** First-class system configuration lives at the
repository root; everything else gets a dedicated directory (`roles/`,
`contracts/`, `missions/`, `tasks/`, `docs/`). Do not add new files to root.
2. **Configuration**: `~/.config/mosaic-dev/config.json` is the sole system
config — created only by `scripts/bootstrap.sh`, never overwritten,
fail-closed on any problem. Repo-scoped role authority lives in
`roles/*.json` (versioned, reviewed commits only).
2. **Configuration**: each stack instance has exactly one system config. The
default instance's is `~/.config/mosaic-dev/config.json`. Instance NAME's
is `~/.config/mosaic-NAME/config.json`, and its data root carries a marker
that names it. `~` is the home of the OS user that runs the instance. A
process reads only the config `MOSAIC_CONFIG` names, or the default's when
that is unset. Configs are created only by
`scripts/bootstrap.sh [--instance NAME]`, never overwritten, fail-closed on
any problem. Repo-scoped role authority lives in `roles/*.json`
(versioned, reviewed commits only).
3. **Secrets** never enter the repository or container images; auth is
runtime-only (read-only mount or environment variable).
4. **Contracts** (`contracts/`) are immutable and image-baked. Missions and
@@ -177,8 +182,8 @@ Full reference — usage, fields, exit codes, safety notes:
## Data map (canon)
- `~/.config/mosaic-dev/config.json` — system config (user-authored; never
auto-written).
- `~/.config/mosaic-dev/config.json`, or `~/.config/mosaic-NAME/config.json`
per named instance: system config (user-authored; never auto-written).
- `<dataRoot>` (from config; default `~/.mosaic-dev`):
- `runs/` — write-once run evidence (`result.json`, snapshots, `stderr.txt`)
- `sessions/` — pi JSONL session trees, one directory per named session
-872
View File
@@ -1,872 +0,0 @@
# Row 195 design: the Case 1 install on 10.1.1.53 (r6)
Darkwing, 2026-10-20. Brief: `docs/plans/2026-10-20_hub-own-uid.md`, blob 45be7f09afc1 (f24f2cd40,
sage-1439). Gitea #1693. Reviewer Duckworth, gate owner Sage. Chained on row 190's candidate (round 3,
df65a3d4c on `darkwing/row190-r3`, rebased on row 192; the config canon) and on row 230's candidate
(C2-13a, #1732, Gosalyn: `scripts/case2-unit.sh` and `case2-precheck.sh`), Q177, sage-1499. Row 230's
brief is `docs/plans/2026-10-20_case2-rows.md` "C2-13a" at 5b8def2f1, from Gosalyn's design r11
(eac92d956 on `gosalyn/case2-design`, approved webby-0038). It has no candidate yet, so I read that
brief as the interface and expect a rebase. Section 2 lists what Case 1 needs from it that the brief
doesn't give.
Changes from r5 (Duckworth's REVIEW-r4.md at 62200362b on `duckworth/reviews`, duckworth-0054). This
round changes only B1's and B2's lines, their section 7 cases and the notes:
- B1. The result line `confirm` writes said op `confirm`, because `log_line` (:58) writes `$OP` and
`OP="$1"` (:1493). `from_log` (:169) reads only `adopt`, `activate` and `rollback`, so it would have
skipped every .53 activation: after adopt C0 and re-pins to C1 and C2, `from_log previous` returns
C0. Now A5's `ok` line and R4's line carry the op, from and to of the activation's `started` line,
and the backup that A3 records in the rollback set. Real sequence 7 and a mutant that writes op
`confirm`. Section 4.
- B2. r5 recorded the migration after the migrate, while today's run sets TAKEN before
`migrate-schema.mjs` (:1004-1005). A migrate that changed the store and then exited 1 would have
left no record, so R2 would skip the restore and R4 would gate OLD on a half-migrated store. A3 now
writes `MIGRATING` after `backup_store` returns and before the migrate. Real sequence 8 and a mutant
that records after the migrate. Section 4.
- N1. The marker step makes `$RS` with capture's exclusive `mkdir` (:416), and capture under
`--units external` makes none. N2. `gate` takes `--since T`, root's clock before its A2f start, as
its start floor. N3. Section 0 now has a read-only grep of `/etc/apparmor.d` for the word `bwrap`,
and item 3 matches the word against a list of exactly one file. N4. Item 4 runs under
`systemd-run` with the units' `NoNewPrivileges=yes`, as 2a' does. N5. A1's plan takes
`--expect-from OLD` and refuses `pointer-differs`.
- Not in this round: claude's path. gosalyn-0058 proposes that `--claude`, like `--node`, takes only a
root-owned path outside the home, and `/srv/mosaic/.local/bin/claude` fails that. I asked Sage to
rule and recommended moving claude under `/opt/mosaic-stack`. That change gets its own round.
Changes from r4 (Duckworth's REVIEW-r3.md at 9f4d5556b on `duckworth/reviews`; mos-1098; mos-1101;
gosalyn-0049):
- B1. An activation now has exactly one result line, written at the end. A5's gate failure and A3's
failure after its capture write no log line. Each writes a `FAILED` file next to `AWAITING-UNITS` and
exits 1, so the activation stays open for R2 and R4, and R4 writes the result. A3 writes
`AWAITING-UNITS` right after `started`. Refusals by `confirm`, `revert` and `gate` log no activation
id, so a refused `confirm` can't close one either. There are real-sequence tests on one log, and
mutants that put back r4's interim line, r4's marker order and a refusal that names the activation.
Section 4 and section 7.
- B2. The bwrap profile has an owner and a stop. `scripts/case1-hostcheck.sh` checks the profile file
against the pin in `scripts/case1-pins.sh`, the two loaded profiles, the live label of a process
bwrap runs, the statoverride and mode, that no other profile names bwrap, two sysctls and the port
floor. It has three callers:
1. A1 of every re-pin refuses `profile-drift`.
2. A root apt hook runs it after every package run, unattended ones included.
3. A boot unit runs it before the bus starts.
On drift, callers 2 and 3 unload the profile and close `/usr/bin/bwrap` to `mosaic`, so every role
session refuses to start. They write `DRIFT` and log it, and only root clears it. ops-01 owns the host
side under a Mos go: it reads the log in each STATE report and, before each re-pin window, compares
the upstream profile to the pin. A changed upstream profile becomes a new pin, which is its own review
round. Section 1 has the details. The facts behind it are in section 0: unattended-upgrades runs daily
on .53 with the security pocket, and the pinned bubblewrap is in that pocket.
- N1 and mos-1098 point 2. P9 has a seventh item: no `mosaic-bwrap` profile is loaded, and no profile
other than Ubuntu's attaches to `/usr/bin/bwrap`. r4 never installed `mosaic-bwrap`. F5 now compares
exact strings. P9 reads `accepted mos-1096` only while all seven items hold.
- N2: a role session gets no capabilities, so a nested bwrap fails. Section 1. N3: A3 refuses "before
it logs `started`". N4: the stale-start rule and its fixtures handle `ActiveEnterTimestamp`'s whole
seconds.
- The port squat (mos-1101 item 5, gosalyn-0049). It applies: role sessions share the host's network
namespace (`packages/seat/src/sandbox.mjs:67` unshares no net), so while the Console is down a
session can bind its loopback port. The Console moves to 127.0.0.1:732 with
`CAP_NET_BIND_SERVICE` on its unit (row 230's line), so no session can take it. The tester port stays
7333, because serve refuses a tester port below 1024. That residue is P10, accepted under Mos's
reading. Case 1 can't meet the preview trio or the proof pair, because .53 has no proxy. Section 2
names the factory serve set for row 230.
- Jason's Q9 (Vikunja #429) is still open. If install and upgrade become product commands, these
steps become that installer's configuration and row 195 rebases on its row (sage-1491). Section 6.
## 0. What I checked on the host
Read only, as `localadmin` over ssh, 2026-10-20 between 20:40Z and 21:00Z, and again at 21:27Z for the
last seven rows (the brief allows this):
| Item | 10.1.1.53 today | Needed |
|---|---|---|
| OS | Ubuntu 24.04.5, kernel 6.8.0-124, x86_64, 12 cores, 94 GiB, 217 GiB free on `/` | fits |
| `mosaic` user, group, `/srv/mosaic` | absent | step 2a creates them |
| node | `/usr/local/bin/node` v22.22.2, root-owned | 24 or later (every `packages/*/package.json` engines line); dragon-lin runs 26.8.1 |
| bwrap | absent, no statoverride; apt candidate `bubblewrap 0.9.0-1ubuntu0.3` | every role session runs under `/usr/bin/bwrap` (`packages/seat/src/sandbox.mjs:43`) |
| user namespaces | `kernel.apparmor_restrict_unprivileged_userns = 1`, apparmor 4.0.1, no bwrap profile loaded | bwrap needs a user namespace, which this setting refuses unless a profile allows it |
| `apparmor-profiles` | not installed; candidate `4.0.1really4.0.1-0ubuntu0.24.04.9`, whose `/usr/share/apparmor/extra-profiles/bwrap-userns-restrict` has sha256 `11d39094f044f0cda0febb3ad517b830301da6b2ce929664af09ee9e4dd264f9` (read from the deb on dragon-lin) | section 1 |
| `fs.protected_hardlinks`, `fs.protected_symlinks` | 1, 1 | mos-1096 item 2 |
| journald | `SplitMode=uid` (the default) | section 4, B2 |
| `useradd` | `USERGROUPS_ENAB yes` | 2a passes `--user-group` anyway |
| claude | not on localadmin's PATH | the factory's harness; dragon-lin runs 2.1.304 |
| ports 7330-7333 | free (no listener) | tester |
| `localadmin` | NOPASSWD sudo (#423 step 1, applied 20:05:47Z, mos-1066, mos-1077) | the root steps run through it |
| `jarvis` | groups sudo, docker, lxd, adm | #423 step 4 removes all four (section 5, P5) |
| unattended-upgrades | on: `APT::Periodic::Unattended-Upgrade "1"`, `apt-daily-upgrade.timer` enabled. Allowed origins `${distro_id}:${distro_codename}`, `-security`, ESM apps-security and ESM infra-security. No `Package-Blacklist` | B2: packages change daily without a window |
| bubblewrap's pocket | `0.9.0-1ubuntu0.3` is in both `noble-updates` and `noble-security`, so its later security updates install unattended | B2 |
| apparmor | installed `4.0.1really4.0.1-0ubuntu0.24.04.8`. The `.9` candidate is in `noble-updates` only, which unattended-upgrades doesn't take. `aa-status --json` works | B2 |
| profiles that name bwrap | at 21:51:46Z `grep -rlw bwrap /etc/apparmor.d` and `grep -rlF /usr/bin/bwrap /etc/apparmor.d` both find nothing (exit 1). After 2a' the word grep lists only `bwrap-userns-restrict` | N1, P9 item 7, hostcheck item 3 |
| `net.ipv4.ip_unprivileged_port_start` | 1024 | the port squat |
| listeners below 1024 | 22 (sshd) and 53 (the resolver) only; 732 is free | the Console's port (section 1) |
| other listeners | 4003, 6052, 6055, 8123, 8765, 14242, 18555, and on loopback 1933, 5905, 6085, 6379, 6380, 9119, 9225, 18554, 18789, 38147 | none in 7330-7333 |
## 1. The install as inputs to the Case 2 scripts
Instance. Row 190's canon with the default name, `dev`, as Case 2 uses it (Gosalyn design 9). `~` is
`mosaic`'s home, `/srv/mosaic`, so:
| Input | Value |
|---|---|
| user | `mosaic`, primary group `mosaic` with no other member |
| home | `/srv/mosaic`, 0700, owner `mosaic` |
| config dir | `/srv/mosaic/.config/mosaic-dev` |
| data root | `/srv/mosaic/.mosaic-dev`, marker `instance.json` `{"instanceVersion":1,"name":"dev"}` |
| business | `mosaic-stack`, file `<configDir>/businesses/mosaic-stack.json` (section 2) |
| profile | `--profile factory` (row 230, C2-13b) |
| roles | the factory set in section 2 |
| human id | Jason's id, given in the window's go. Not `tester` (`broker.mjs:245`) |
| model credential | `<configDir>/secrets/claude-session.env`, 0600 (row 190's `SESSION_ENV` for the default instance) |
| token slot path | none for this row (section 2) |
| Console port | 127.0.0.1:732, below the port floor, bound through the Console unit's `CAP_NET_BIND_SERVICE` (row 230). The port squat below says why |
| tester port | 127.0.0.1:7333 (P10) |
| board | none. UNITS has no board unit, and the Console gets no `--board` |
| harness | claude at `/srv/mosaic/.local/bin/claude`, pinned through `MOSAIC_CLAUDE_BIN` (decision 91), which the bus unit's `@CLAUDE_ENV@` line sets (section 2) |
| node | `/opt/mosaic-stack/node-<version>/bin/node`, a pinned 26.x release, `root:root`. Row 230 refuses a `--node` under the home or not owned by root |
| PM | on: the bus unit's `@PM_FLAGS@` renders ` --pm` (section 2, section 4) |
| source | `/srv/mosaic/src/mosaic-stack` (`mosaic`'s checkout, section 4) and `/root/mosaic-stack` (root's) |
Why `dev` and not a named instance: one instance per OS user, and Case 2's rows already assume
`/srv/mosaic/.config/mosaic-dev`. A second instance under `mosaic` on .53 would need a name, and row 190
makes that a flag, not a move.
Why node lives under `/opt/mosaic-stack`: changing `/usr/local/bin/node` would change what Hermes and
anything else on the host runs, and Q181 keeps Hermes running. r4 put node under `/srv/mosaic`, but row
230 requires a root-owned `--node` outside the home. Nothing else on the host puts `/opt/mosaic-stack`
on its PATH.
PATH for `mosaic`'s steps. `sudo -u mosaic -H` resets PATH to sudo's `secure_path`, which finds
`/usr/local/bin/node`, v22. The precheck would then refuse, and `scripts/mosaic` would run v22 (it runs
`exec node` from PATH, `scripts/mosaic:22-40`). So every `mosaic` step in the run sheet and in
`case1-repin.sh` runs as `sudo -u mosaic -H env PATH=<node dir>:/usr/local/bin:/usr/bin:/bin ...`, the
list row 230 renders as the units' `@PATH@`. `case1-repin.sh` builds it from the `--node` in
`case1-unit.args`.
The units (row 230, design r11). Two system units, from the existing templates, in start order:
`[email protected]` from `packages/cli/systemd/mosaic-bus.service.in`, then
`mosaic-webui.service` from `packages/webui/systemd/mosaic-webui.service.in`. Root stops them in the
reverse order. `@REPO@` is the copy dir, and `%h` becomes the literal home. Row 230 adds
`ProtectSystem=strict`, `ReadWritePaths=/srv/mosaic`, `ProtectHome=yes` and `PrivateTmp=yes` to both, and
`AmbientCapabilities=CAP_NET_BIND_SERVICE` with `CapabilityBoundingSet=CAP_NET_BIND_SERVICE` to the
Console only. The Console unit has no `[Install]`, so root starts it by hand after a reboot until Jason
rules (mos-1097). The install call is:
```
case2-unit.sh --profile factory --user mosaic --home /srv/mosaic --business mosaic-stack --copy ID \
--node /opt/mosaic-stack/node-<version>/bin/node --pm --claude /srv/mosaic/.local/bin/claude \
-- --port 732 --business mosaic-stack --tester-port 7333
```
`--pm` and `--claude` are the two inputs row 230's brief doesn't have yet (section 2). A re-install
must pass the same `--node`, flags and serve flags, and only the copy id may change (row 230's
re-install check). So 2c writes the whole argument list, without `--copy`, to
`/etc/mosaic-stack/case1-unit.args`, `root:root` 0644, one argument per line. Every later
`case2-unit.sh` call in section 4 reads that file and adds `--copy`. `mosaic` can't write it. Changing
a serve flag is a re-install with its own go, not a re-pin.
The port squat (mos-1101 item 5). Role sessions share the host's network namespace:
`packages/seat/src/sandbox.mjs:67` unshares the user, PID and IPC namespaces and not the network. So
while the Console is down, which A2 to A4 of every re-pin and any crash before `Restart=on-failure`
brings it back both cause, a session can bind 127.0.0.1:7332. It would then receive Jason's `ssh -L`
traffic, and it would answer the gate's `/healthz` (`stack-release.sh:485-487`) in the Console's place.
At 732 that can't happen. The floor is 1024 (section 0), a session has no capabilities (below), and
`mosaic`'s own processes outside the sandbox have none either. Only the Console unit holds
`CAP_NET_BIND_SERVICE`. Its children, the human-cli and read scripts it spawns
(`packages/webui/src/bus.mjs:31`, `reads.mjs:79`), inherit the ambient capability. No role session
descends from the Console: sessions are children of the bus unit, which has no capability line. The
gate needs no change, because `console_port` (`stack-release.sh:213-215`) reads the Console's `--port`
from the pointer. Row 230's precheck refuses when the floor is at or below the Console's port, and
`case1-hostcheck.sh` checks it after every package run (B2). The tester port can't go below 1024,
because serve refuses that (`packages/webui/src/cli.mjs:33`), so P10 records it.
Node download (N4). The run sheet pins one release, its tarball name and its sha256. As root, in a
root-owned temp dir: fetch the tarball, `SHASUMS256.txt` and `SHASUMS256.txt.asc`; `gpg --verify` the
`.asc` against the Node release keys listed in the nodejs/release-keys repository, imported into a
keyring under `/etc/mosaic-stack/node-keys`; check that the tarball's line in the verified file is the
pinned sha256; then `sha256sum -c` the tarball and unpack it to `/opt/mosaic-stack/node-<version>`,
`root:root`, no file writable by group or other. Any mismatch stops the step. Rollback: `rm -r` that
dir.
Claude (N5). The run sheet pins the version, 2.1.304 unless the window's plan names a newer one. As
`mosaic`: `npm install --prefix /srv/mosaic/.local @anthropic-ai/claude-code@<pin>`, with `@<pin>`
exact. `/srv/mosaic/.local/bin/claude` is the path `MOSAIC_CLAUDE_BIN` names. The autoupdater stays off
through `/etc/claude-code/managed-settings.json`, `root:root` 0644, holding
`{"env":{"DISABLE_AUTOUPDATER":"1"}}`. Root writes it in 2a'. Why there:
- `ENV_ALLOW` (`packages/seat/src/session.mjs:83-88`) doesn't pass `DISABLE_AUTOUPDATER`, so a unit
`Environment=` line never reaches a role session (gosalyn-0046 item 3).
- A role session's HOME is the run's own `home/`, and the sandbox doesn't show `~/.claude`
(`session.mjs:92-93`, `sandbox.mjs:16`). So `mosaic`'s claude settings file doesn't reach one either.
- The sandbox mounts `/etc` read-only (`sandbox.mjs:85`), and Claude Code reads managed settings from
`/etc/claude-code/` whatever HOME is. `mosaic`'s own CLI runs, like the precheck's marker prompt
and F6, read it too.
The pinned binary is a read-only bind inside the sandbox (`sandbox.mjs:9-10`), so a session can't
rewrite it anyway. The managed file covers `mosaic`'s runs outside the sandbox. If the installed
version doesn't honour `env` there, F6 catches the drift. Rollback: `rm` the file.
bwrap and the user namespace (mos-1091 4a, 4b; mos-1096 item 2; N1). Three root steps in 2a', in this
order, each with its rollback. The `mosaic` group must exist first (2a).
1. `apt-get install bubblewrap=0.9.0-1ubuntu0.3`, the version the run sheet pins. Then record
`stat -c '%U:%G %a' /usr/bin/bwrap` to `/etc/mosaic-stack/bwrap.orig` (I expect `root:root 755`).
Rollback: `apt-get remove bubblewrap`.
2. Ubuntu's own `bwrap-userns-restrict`, one file, not the package. `apt-get download
apparmor-profiles=4.0.1really4.0.1-0ubuntu0.24.04.9` into a root-owned temp dir, extract
`./usr/share/apparmor/extra-profiles/bwrap-userns-restrict` with `dpkg-deb --fsys-tarfile`, check
its sha256 is `11d39094f044f0cda0febb3ad517b830301da6b2ce929664af09ee9e4dd264f9`, and install it as
`/etc/apparmor.d/bwrap-userns-restrict`, `root:root` 0644. Load it with `apparmor_parser -r
/etc/apparmor.d/bwrap-userns-restrict`. `aa-status` then lists `bwrap` and `unpriv_bwrap` in
enforce mode. Why one file: the package also installs 20 profiles under `/etc/apparmor.d`
(`usr.sbin.dnsmasq`, `bin.ping`, `usr.sbin.smbd` and more), which would load at the next boot and
could change services on the host that have nothing to do with Mosaic.
What the profile does: `bwrap` gets `userns`, `capability` and `mount` for its own setup, and
every program it runs is stacked under `unpriv_bwrap`, which has the same file and network rules
with `audit deny capability`. So a role session gets a user namespace and no capabilities in it.
The sysctl stays at 1, so nothing else on the host gains user namespaces.
One consequence (N2): a bwrap that a session starts inside its own sandbox fails, because it needs
capabilities in the namespace. Claude Code's own sandbox is such a bwrap, and it's off by default,
so a role session doesn't use it. F3 shows a session still answers under the profile.
Rollback: `apparmor_parser -R /etc/apparmor.d/bwrap-userns-restrict`, then `rm` it.
3. `dpkg-statoverride --update --add root mosaic 0750 /usr/bin/bwrap` (Mos). Only root and the
`mosaic` group can read or run bwrap, and package upgrades keep the mode. Rollback:
`dpkg-statoverride --remove /usr/bin/bwrap`, then `chown` and `chmod` to the values in
`bwrap.orig`.
4. The drift stop (B2, below): `case1-hostcheck.sh` and `case1-pins.sh` from `/root/mosaic-stack` to
`/usr/local/lib/mosaic-stack/`, `root:root` 0755 and 0644; the apt hook
`/etc/apt/apt.conf.d/80mosaic-hostcheck`; the boot unit `mosaic-hostcheck.service`, enabled. Then
`case1-hostcheck.sh --check` exits 0. Rollback: disable and remove the unit, `rm` the hook file and
the dir.
Full rollback runs 4, 3, 2, 1. Tests after 2a' and again in section 5: as `mosaic`, `bwrap --unshare-all
--ro-bind / / true` exits 0 (row 230's precheck line, F4); as `jarvis`, `/usr/bin/bwrap --version`
fails with "Permission denied" (P9); `getent group mosaic` lists no member and no other account has
gid `mosaic` (P9); `fs.protected_hardlinks` is 1 (P9). A role session running under the profile is F5.
One more test runs in 2a', because I can't settle it from here. Row 230's units keep the templates'
`NoNewPrivileges=yes`, and AppArmor limits profile changes for a task with no-new-privs set. The
`bwrap` attach happens on exec from an unconfined task, and the child's change is a stack
(`bwrap//&unpriv_bwrap`), which only narrows. I expect both to pass under no-new-privs, since bwrap
sets no-new-privs on itself before it runs the child, and Ubuntu's profile works with that. But I
haven't seen it run, and .53 is read-only to me. So as root, after step 4: `systemd-run --wait --pipe
--uid=mosaic --gid=mosaic -p NoNewPrivileges=yes -p ProtectSystem=strict -p ProtectHome=yes -p
PrivateTmp=yes /usr/bin/bwrap --unshare-user --unshare-pid --ro-bind / / cat /proc/self/attr/current`
prints `bwrap//&unpriv_bwrap (enforce)`. If it fails, the window stops at 2a' and rolls back 4 to 1,
before any `mosaic` step. That beats finding it at F5.
The drift stop (B2, Duckworth's REVIEW-r3 B2, mos-1098 point 1). r4 installed a profile file that no
package owns, from a pinned deb, onto a host where unattended-upgrades changes packages every day
(section 0). Three things can move under it: bubblewrap (its security updates install unattended), the
`apparmor` package and its parser, and any new profile a package ships that attaches to
`/usr/bin/bwrap`. Two more host values matter to this design: the user-namespace sysctl and the port
floor. So:
- `scripts/case1-pins.sh` holds the pins, sourced by the scripts below: the `apparmor-profiles` version
the file came from, the file's sha256, and the bubblewrap version 2a' installs. Changing a pin is a
reviewed commit and its own round. The bubblewrap version is an install pin, not a drift item:
a security update to bubblewrap keeps the statoverride, and the profile attaches by path.
- `scripts/case1-hostcheck.sh` checks seven items and prints one line each:
1. `/etc/apparmor.d/bwrap-userns-restrict` has the pinned sha256, and `dpkg -S` finds no package
that owns it.
2. `aa-status --json` lists `bwrap` and `unpriv_bwrap` in enforce mode, and no loaded profile named
`mosaic-bwrap`.
3. `grep -rlw bwrap /etc/apparmor.d` lists exactly `/etc/apparmor.d/bwrap-userns-restrict`. The word
match also catches a path written as `/{usr/,}bin/bwrap`. Section 0's read found no file today.
4. The live label: 2a' line `systemd-run --wait --pipe --uid=mosaic --gid=mosaic -p NoNewPrivileges=yes
-p ProtectSystem=strict -p ProtectHome=yes -p PrivateTmp=yes /usr/bin/bwrap --unshare-user
--unshare-pid --ro-bind / / cat /proc/self/attr/current` prints exactly `bwrap//&unpriv_bwrap
(enforce)`. It runs under the units' no-new-privs, so it reads the label a role session gets.
This catches a profile that attaches by a glob that item 3's search misses.
5. `dpkg-statoverride --list /usr/bin/bwrap` reads `root mosaic 0750`, and `stat` agrees.
6. `kernel.apparmor_restrict_unprivileged_userns` is 1 and `fs.protected_hardlinks` is 1.
7. `net.ipv4.ip_unprivileged_port_start` is above the Console's `--port` in the installed unit.
`--check` exits 0 when all hold and 1 otherwise. `--enforce` runs the same check and always exits 0.
On a failure, or while `/etc/mosaic-stack/DRIFT` exists, it applies the stop:
`apparmor_parser -R` on the profile if loaded, then `chown root:root` and `chmod 0700` on
`/usr/bin/bwrap`. It writes the failed items to `DRIFT` and appends a line to
`/var/log/mosaic-stack/hostcheck.log`. With bwrap closed to `mosaic`, every role session refuses
to start. That's the fail-closed state, and the stack needs no change for it. The chmod is undone by
the next bubblewrap upgrade, which reapplies the statoverride. But the apt hook runs again right
after that upgrade and finds `DRIFT`, so it closes bwrap again. Only root removes `DRIFT`, in a
reviewed step with a new pin or a Mos go. I don't call `dpkg-statoverride` from inside the hook,
because I haven't confirmed it can take dpkg's lock while apt holds the frontend lock.
- Three callers. A1 of every re-pin runs `--check` and refuses `profile-drift` on exit 1. The apt hook
is `DPkg::Post-Invoke { "/usr/local/lib/mosaic-stack/case1-hostcheck.sh --enforce || true"; };`, so it
runs after every package run, unattended ones included, and can't fail one. The boot unit,
`mosaic-hostcheck.service` (`Type=oneshot`, `After=apparmor.service`,
`Before=mosaic-bus@mosaic-stack.service`, `WantedBy=multi-user.target`), runs `--enforce` before the
bus starts, which covers a kernel or parser change that takes effect at boot.
- Owner. ops-01 owns the host side under a Mos go. It reads `hostcheck.log` in each STATE report and
sends any `DRIFT` to Mos. Before each re-pin window it also runs `--upstream`, which downloads the
current `apparmor-profiles` candidate into a temp dir and compares its `bwrap-userns-restrict` to the
pin. A difference goes to Mos as a new-pin request and refuses nothing. The pins and the scripts are
this row's, and later changes go through review like any other.
Who runs what:
| Step | Runs as | Through |
|---|---|---|
| 2a: `useradd --system --user-group --home-dir /srv/mosaic --shell /usr/sbin/nologin mosaic`, `/srv/mosaic` 0700, `/etc/mosaic-stack` `root:root` 0755, `/var/log/mosaic-stack` `root:root` 0750 | root | ops-01, `ssh localadmin@10.1.1.53`, `sudo` (#423 step 1) |
| 2a': node under `/opt/mosaic-stack`; bubblewrap, the AppArmor profile, the statoverride, the drift stop and the managed settings file, each with the test and rollback above; the no-new-privs test | root | ops-01, same path, R27 (mos-1091) |
| 2a'': a `git bundle` of the reviewed commit copied to .53; root clones it to `/root/mosaic-stack` and checks HEAD is the commit the go names; it installs the bundle as `/srv/mosaic/incoming/<sha>.bundle`, owner `mosaic`, 0600 | root | ops-01, same path |
| 2b: claude under `~/.local` (above); `git clone` of the bundle to `/srv/mosaic/src/mosaic-stack`; `scripts/bootstrap.sh`; `stack-copy.sh copy`; the install with the inputs above (C2-13b's `case2-install.sh --profile factory` if it has landed, else the run sheet's steps) | `mosaic` | `sudo -u mosaic -H env PATH=...` from `localadmin` (PATH above). `mosaic` gets no key and no login shell |
| 2b': place `claude-session.env` | Jason, or who he names (Q173 secrets rule) | section 3 |
| 2c: write `case1-unit.args`; `case2-unit.sh` with those arguments and `--copy <id>`, from `/root/mosaic-stack`; then `case2-precheck.sh --profile factory` as `mosaic`, which reads the Console's `--port` from the installed unit for its port-floor line, so it runs after the render; `systemctl enable` the bus unit; the first start of UNITS. The Console unit has no `[Install]` until Jason rules (C2-13a), so it isn't enabled, and after a host reboot root starts it by hand until then. The run sheet says so | root | ops-01, same path |
| 2c': `stack-release.sh adopt mosaic-stack --units external`, which records the running copy as the pointer (`do_adopt`, `scripts/stack-release.sh:856`) | `mosaic` | `sudo -u mosaic -H` |
| every later re-pin or rollback | root, with its `mosaic` steps through `sudo -u mosaic -H` | ops-01 runs `case1-repin.sh` (section 4) |
| acceptance (section 5) | `localadmin`, with `sudo -u mosaic -H` for the in-instance checks | the agent the window names |
No git credential lands on .53. Both checkouts come from bundles that ops-01 copies in, and a re-pin
brings a new bundle that `mosaic` fetches into its checkout.
## 2. Where Case 1 differs from Case 2
| Case 2 item (row 230 brief) | Case 1 | Reason |
|---|---|---|
| business file with the library mapping | business `mosaic-stack` with no `library` key; `--profile factory` refuses a `kind: "library"` project (`profile-library-business`) | the factory has no books repo |
| `bean`, `chet`, persona vars and files, specialists | factory roles from `roles/`, with a chief that has a harness var | Jason names the set in the go. I haven't read his live business file and won't. The acceptance needs only the chief (F3), since a chief with no harness var answers `no-route`. Others are added later by the human with `instances.set`, no new row |
| token slot `library-gitea` | none | the factory's per-agent git identity is row 199's credential store (Megavolt, #1697). Until it lands, git work stays on dragon-lin (Q182) |
| precheck: `bus.tester` is not on | `bus.tester` is on; `factory` requires it | the acceptance and the Q173 test system need the tester (agents act as tester, never Jason) |
| precheck: persona file modes and digests | skipped under `factory` | no persona files |
| precheck: `library.sync`, `library.status` | skipped under `factory` | no library |
| precheck: `ZAI_API_KEY` in the key file | doesn't run | no instance has provider `zai` |
| precheck: no Discord binding | kept | the factory's Discord connector stays on dragon-lin. Two connectors on one bot token would split its events |
| precheck: claude marker prompt with `--system-prompt-file` | runs, since the chief's harness is claude-code | |
| precheck: `sudo -n true` fails, `id -G` one entry, run root 0700, config validates, node 24 or later, `bwrap` exits 0, free memory, HOME | kept | node and bwrap are in row 230 (gosalyn-0034) |
| Console serve flags: `--port` 1..1023, `--business`, the preview trio, `--send`, the proof pair, no `--tester-port` | `--port 732`, `--business mosaic-stack`, `--tester-port 7333`. No preview trio, no `--send`, no proof pair | below |
| bus: `@PM_FLAGS@` and `@CLAUDE_ENV@` render empty | ` --pm`, and `Environment=MOSAIC_CLAUDE_BIN=/srv/mosaic/.local/bin/claude` | below |
All of these are inputs. Nothing in the Case 2 scripts hardcodes a Case 1 value (gosalyn-0034 item 4).
Row 230's brief says the factory profile takes the library serve set plus an optional `--tester-port`,
and that row 195 names any other difference (gosalyn-0049). .53 can't meet the library set, and the bus
needs two lines the brief renders empty. The factory profile I need from row 230:
1. Serve flags. `--port N` with N in 1..1023 and `--business ID` are required, and `--tester-port N` in
1024..65535, not equal to `--port`, is optional. The preview trio (`--public-host`, `--auth-header`,
`--allow-user`) and the proof pair (`--proof-header`, `--proof-file`) are refused. Both exist for a
proxy in front of the Console: the proxy signs the user in and adds the header, and the proof shows
the request came through it. .53 has no proxy. Jason reaches the Console with `ssh -L` (sage-1451
Q3), so there is no header to trust and no proxy to hold the proof. `--send` is refused too, since
serve takes it only with the trio or `--sso` (`packages/webui/src/cli.mjs:15`). When Q175 SSO reaches
.53, `--sso` with an optional `--send` (row 192) would be the factory's sign-in, and adding it is a
re-install with its own go. Row 230 can accept `--sso [--send]` under factory now or later. Either
way it needs a `console.sso` block in `mosaic`'s config, which this row doesn't write. `--checkout`
and `--board` stay refused, as in the library set.
2. `--pm` (factory only) renders the bus unit's `@PM_FLAGS@` as ` --pm`. The factory's PM dispatches its
work on .53 as on dragon-lin (section 4). With the token empty the bus never launches the PM, and
A5's PM check fails every re-pin.
3. `--claude PATH` (factory only) renders `@CLAUDE_ENV@` as `Environment=MOSAIC_CLAUDE_BIN=PATH`, the
line `scripts/bus-service.sh:79` renders today. PATH must be absolute. Without it the bus runs
`claude` from the unit's PATH, which is the node dir, `/usr/local/bin`, `/usr/bin` and `/bin`, and
holds no claude on .53. The chief's first launch would then fail. The precheck's claude marker
prompt should run the same binary, so it reads the path from the installed bus unit.
4. Row 190's two template tokens, if row 190 lands before row 230. Row 190 adds `@CONFIG_ENV@` to both
templates and `@SESSION_ENV@` to the bus template's `EnvironmentFile=` line. For the default
instance, `@CONFIG_ENV@` is deleted (`scripts/instance.sh`) and `@SESSION_ENV@` is
`<home>/.config/mosaic-dev/secrets/claude-session.env`. Row 230's "every token has one value" list
and its `EnvironmentFile=` check need both. This applies to the library profile too.
I've sent these to Gosalyn. If row 230's candidate lacks any of them, row 195's manifest names the
change to `case2-unit.sh` or `case2-precheck.sh`, reviewed by Duckworth with Webby told.
## 3. Model access
Q185: Case 1 uses the account the factory uses today, the Claude subscription behind dragon-lin's
`claude-session.env`. The install never copies that file from dragon-lin. In the window, Jason (or the
person he names under the Q173 secrets rule) makes a credential for .53 from that account and writes it
to `/srv/mosaic/.config/mosaic-dev/secrets/claude-session.env` as `mosaic`, mode 0600. Nobody prints it,
and no script reads its value. The precheck checks only its mode, owner and that the variable name is
present (a `grep -c` on the name, output a count).
Quota: the factory on dragon-lin and on .53 share one subscription. Q185's "use existing" accepts that.
Case 2 shares nothing with it (Mos's reading of Q185).
The pi harness stays off on .53 until P19 #383 settles (mos-0865). The business file names claude only.
## 4. Re-pin, rollback and backup on .53
Unit form (ruled, sage-1451 Q1): system units that root writes, with `User=mosaic`. `mosaic` can't
start or stop them, and root never runs code that `mosaic` can write. `stack-release.sh activate` today
does everything in one run under `systemctl --user` and puts the old stack back inside that run
(`rollback_in_run`, `scripts/stack-release.sh:561`). On .53 the same run needs two users. So one
re-pin is one ordered list, and one root command drives all of it: `scripts/case1-repin.sh`, run by
ops-01 from `/root/mosaic-stack`. Each `mosaic` step is `sudo -u mosaic -H` running
`/srv/mosaic/src/mosaic-stack/scripts/stack-release.sh` with `--units external`. Copies carry no
`stack-release.sh` (`COPY_PATHS`, `scripts/stack-copy.sh:23-30`), so the `mosaic` steps run from that
checkout, as dragon-lin's run from the canonical one.
What root holds, and where each value comes from (r4's B1):
| Value | Source |
|---|---|
| ID, the target copy | the go |
| TO, a rollback's target | the go, as `--to ID` |
| `--restore ACT --events N` | the go, when the rollback restores |
| OLD, the running copy | the `ExecStart=` of the two unit files root installed. If the bus and Console units name different copies, A1 refuses `units-disagree` |
| the `case2-unit.sh` arguments | `/etc/mosaic-stack/case1-unit.args` (section 1) |
| each `mosaic` step's result | its exit code |
Root parses nothing `mosaic` wrote and runs nothing under `/srv/mosaic` except through `sudo -u mosaic
-H`. It never needs the activation id: the `mosaic` steps find it themselves.
One result line per activation (B1). The stack log closes an activation on the first line after
`started` that names it, whatever the result (`interrupted`, :146-156). r4 wrote an interim
`... rollback pending` line at A3's and A5's failures. That line closed the activation, so R2 and R4
then refused `open-activation` against an activation the log no longer held open. The rollback could
never run as designed, and nothing tested the steps in sequence. r5 keeps an activation open from A3's
`started` line until its single result line, and A5 or R4 writes that line:
| Activation ends at | Result line | Written by |
|---|---|---|
| A3 fails before its rollback set exists (marker or capture) | `refused code marker-failed` or `refused code capture-failed`, as `capture || refuse` does today (:987). Nothing has changed, so there is nothing to roll back | A3 itself, exit 3, after it removes the marker. Root runs A2f |
| A5 passes | `ok` | A5 |
| A3 (after capture), A4 or A5 fails, and the rollback passes | `failed code <c> rollback ok` for A3 and A4, `gate-failed code <c> rollback ok` for A5 | R4 |
| the rollback fails at R4's gate | `rollback-failed code <c> rollbackCode <r>` | R4 |
| R1, R2 or R3 fails, or the run dies | none. The activation stays open, and the next activate refuses `interrupted-run` (:603-606), which Jason or Mos settles as on dragon-lin | |
What the result line carries (r5's B1). `log_line` (:58) writes `op "$OP"`, and `from_log` (:169)
reads only lines whose op is `adopt`, `activate` or `rollback`. So `confirm` doesn't log under its own
op. Before A5's `ok` line or any line R4 writes, it sets OP, FROM and TO from the open activation's
`started` line, ACT to that activation, and BACKUP from the rollback set's `BACKUP` file, which A3
writes when `backup_store` returns. A re-pin's lines then read `op activate`, a rollback's `op
rollback`, each with the from, to and backup a dragon-lin run would log. `from_log previous` and
`from_log backup` read .53's log as they read dragon-lin's. `confirm` reads only the line A3 wrote for
that activation, which `interrupted` already names.
A failing `mosaic` step after `started` writes no log line. It writes its code to `FAILED` beside
`AWAITING-UNITS` and exits 1. R4 reads that code for the result line. Root passes R4 `--cause
A3|A4|A5`, the step it saw fail, from its own exit codes. A4 is root's own step and has no `FAILED`
file, so R4 writes `failed code units-start` for it. A cause with no `FAILED` file where one should be
writes code `unrecorded`.
The open activation. A3 writes `$STATE/stack-rollback/<ACT>/AWAITING-UNITS` holding the target copy
(renamed from r3's `PENDING`, which read like the pointer's `pmPending`, `P_PENDING` at :128; N3).
`confirm` and `revert` take the copy root passed, not an activation id. Each lists the
`AWAITING-UNITS` files and the `interrupted` set (:146), and goes on only when there is exactly one
file, its activation is the one `interrupted` names, and the copy in it is the copy root passed.
Anything else refuses `open-activation` with exit 3 and the list it found, and changes nothing.
`confirm` without `--rollback` also refuses `activation-failed` when `FAILED` exists, so a second
confirm can't pass an activation that already failed. Whichever `confirm` writes the result line
removes `AWAITING-UNITS` and `FAILED` after it.
Refusals of the three external verbs. `confirm`, `revert` and `gate` act on an open activation by
design, so they skip the `interrupted-run` refusal (:605). Their refusals log `result refused` with no
activation id: they set ACT only after every check has passed. So a refused `confirm`, a wrong copy
say, leaves the open activation open. Today's `refuse` (:62-67) adds the activation id whenever ACT is
set, and the external verbs keep that order. A test and a mutant hold it (section 7).
Root's step log. Every step `case1-repin.sh` runs appends one line to `/var/log/mosaic-stack/case1-repin.log`,
`root:root` 0640: the time from `date -u`, the step, its argv, its exit code. `mosaic` can't write it,
so it stays readable after a run that dies before `mosaic` logs anything.
### Activate: `case1-repin.sh activate ID [--migrate] [--timeout S]`
| # | Step | Runner | On failure |
|---|---|---|---|
| A1 | `/root/mosaic-stack` HEAD is the commit the go names. ID is 40 lowercase hex and names a copy under `/srv/mosaic/.local/share/mosaic-stack/copies` that holds `scripts/mosaic` (row 230's copy-id rule). `case1-hostcheck.sh --check` exits 0, else `profile-drift` (B2). Root reads OLD from the unit files. `stack-release.sh activate ID mosaic-stack --units external --plan --expect-from OLD` exits 0. The plan refuses `pointer-differs` when the pointer's copy isn't OLD (N5), so a pointer and units that disagree stop before any stop | root; the plan as `mosaic` | refuse, exit 3. Nothing has changed |
| A2 | `systemctl stop` UNITS in reverse order, then wait until each is inactive, within `--timeout` | root | A2f below |
| A3 | `stack-release.sh activate ID mosaic-stack --units external [--migrate]`. Before it logs `started` (N3), it refuses `units-running` unless each of UNITS is inactive (system-manager reads, which need no privilege) and the store is stopped (`stopped`, :359), and `interrupted-run` while any activation is open. Then, in this order: logs `result started`; makes `$RS` with capture's `mkdir -p` of `stack-rollback` and exclusive `mkdir -m 0700` (:415-416), and writes `AWAITING-UNITS` in it at once (N1); captures the rollback set into that directory, making none (pointer, wrapper, and the sha256 and `ExecStart=` of each installed unit, read only); takes `backup_store` (:372) into `$DATA/backups/stack/<ACT>` and writes its name to `BACKUP` in the rollback set; under `--migrate`, writes `MIGRATING` in the rollback set and sets TAKEN, both before `migrate-schema.mjs` runs, where today's run sets TAKEN (:1004-1005, r5's B2), then migrates; installs the wrapper for ID. It writes no pointer | `mosaic` | a refusal before `started` exits 3 with nothing changed. A marker or capture failure, an existing `$RS` included, removes the marker if it wrote one, logs its `refused` line and exits 3. Root runs A2f for both. Any later failure writes `FAILED` with its code, logs nothing, cleans up nothing and exits 1. Root runs R1-R4 with `--cause A3` |
| A4 | `case2-unit.sh` with `case1-unit.args` and `--copy ID`, `systemctl daemon-reload`, `systemctl start` UNITS in order | root | R1-R4 with `--cause A4` |
| A5 | `stack-release.sh confirm ID mosaic-stack --units external --timeout S`. This is `gate` (:522), which polls `gate_once` (:471) every second until `--timeout`, with the external checks below. On a pass it writes the pointer, logs `result ok`, removes `AWAITING-UNITS`, exit 0. When the timeout runs out it writes `FAILED` with the last gate code, logs nothing and exits 1 | `mosaic` | R1-R4 with `--cause A5` |
Why A3 writes the marker first and does no cleanup of its own. With the marker written last, as in r4,
an A3 failure after `started` left an open activation with no marker, and R2 and R4 refused it. With
the marker first, every A3 failure after the marker leaves exactly what R2 needs: the open activation,
its marker and whatever part of the rollback set exists. R2 is then the one cleanup path, for A3, A4
and A5 alike. Two cleanup paths, A3's own and R2, would each need their own tests for each failure point.
A2f, a stop that fails or times out, or an A3 that exits 3. This run has no open activation: A2 logs nothing, and
A3's exit 3 comes either before `started` or after its own `refused` line. Nothing of `mosaic`'s has
changed. Root reads `date -u +%s` as T, runs `systemctl start` UNITS in order, then `stack-release.sh gate
OLD mosaic-stack --units external --since T --timeout S`, a new verb that runs `gate` on OLD and writes no
log line, no pointer and no file. A2f has no `started` line to take a start floor from (the first
re-pin after adopt has none at all), so `--since` is the floor, and `gate` refuses `usage` without it
(r5's N2). T is root's own clock, so root still parses nothing `mosaic` wrote. A unit that never
stopped keeps its old `ActiveEnterTimestamp`, fails `stale-start` and takes the exit 2 path below.
If that passes, root logs `A2 failed, OLD running` (or `A3 refused`) in its step log and exits
1. If it fails, root stops UNITS, logs `OLD gate failed <code>` and exits 2. The next run's
`interrupted` check finds nothing open either way.
The external gate. `gate_once` keeps its order and codes, with these changes under `--units external`:
- `systemctl` without `--user` for `is-active` and `show`. Both work unprivileged against the system
manager.
- This start's time is the bus unit's `ActiveEnterTimestamp`, read with `systemctl show --timestamp=unix
-p ActiveEnterTimestamp`, in place of the `START_MS` that `start_both` (:451) sets on dragon-lin.
It is whole seconds (`@1792540800`), and the `started` line has milliseconds (N4). So the rule
compares seconds: the start passes when its second is at or after the floor of `started`'s second.
A unit that was active across A3 can't reach this check, because A3 refuses `units-running`, so the
equal second can only be a start after A3. An earlier second fails `gate_once` with the gate code
`stale-start`. It's a gate failure like any other, not a refusal (r4 had it as a refusal, which
would have logged against the open activation): `gate` keeps polling, since `Restart=on-failure`
can bring a fresh start, and at the timeout `FAILED` holds `stale-start`. The PM check's
`startedAt` bound uses the same second.
- `copy-check` runs `stack-copy.sh check --units external`, which reads the system units.
- The PM check is below.
The PM (r4's B2). .53 runs the PM: the factory's PM dispatches its work, as it does on dragon-lin, and the
serve flags carry `--pm`. The pointer's `pm` comes from the installed bus unit's `ExecStart=`, as the
unit parse at :195 already reads it. Today's `pm_launched` (:505) takes the run id from the
`launched ...` line in `journalctl --user -u BUS_UNIT`. On .53 that read fails twice over: the unit is
a system unit, and under `SplitMode=uid` the logs of a system-range uid land in the system journal,
which `mosaic` (no `adm` or `systemd-journal` group) can't read. Under `--units external` the check
reads `launches list --json` alone. It passes when exactly one entry has business `mosaic-stack`,
instance equal to the business file's `launch.by` (the PM's instance, the one `bus start --pm` launches
at `packages/cli/src/launcher.mjs:675`), `live` true, and `startedAt` at or after this start's time.
Zero entries, two, a stale one or one for another business fail with `pm-launch-unconfirmed` and what
it saw, as today. A run the PM starts for another role has another instance, so it doesn't count.
Why not the other reads. A regular uid for `mosaic` would put its logs in a per-user journal it can
read, but it changes the account Case 2's install creates. Root reading the journal and passing the run id in
would make root parse output from `mosaic`'s process, which section 4's rule forbids.
Under `--units external`, `pm-on` (:1137) and `activate --pm-after-harness` refuse `external-pm`. Both
flip the PM on dragon-lin by restarting the user bus with new arguments. On .53 the PM's state is a
serve flag in a root-owned unit, so changing it is a re-install with its own go.
### Rollback inside the run (R1-R4)
| # | Step | Runner | On failure |
|---|---|---|---|
| R1 | `systemctl stop` UNITS, then wait as in A2 | root | rollback-failed |
| R2 | `stack-release.sh revert ID mosaic-stack --units external`. It finds the open activation as above, puts the wrapper back from the rollback set and checks its sha256, and restores TAKEN (`restore_store`, :396) only if the rollback set holds `MIGRATING`. Each part is idempotent, so it works for an A3 that stopped partway: no `MIGRATING` means no restore, a migrate that failed partway is restored from the backup taken before it, and copying the saved wrapper over an unchanged one changes nothing. It writes no log line | `mosaic` | rollback-failed, activation left open |
| R3 | `case2-unit.sh` with `case1-unit.args` and `--copy OLD`, `systemctl daemon-reload`, `systemctl start` UNITS | root | rollback-failed, activation left open |
| R4 | `stack-release.sh confirm ID mosaic-stack --units external --rollback OLD --cause A3\|A4\|A5 --timeout S`. It finds the open activation, checks OLD is its `from`, gates OLD with `gate`, and writes the activation's one result line from the cause and the `FAILED` code: `failed code <c> rollback ok` (A3, A4), `gate-failed code <c> rollback ok` (A5), or `rollback-failed code <c> rollbackCode <gate>` when OLD's gate fails | `mosaic` | rollback-failed, logged |
rollback-failed: root stops UNITS and exits 2, as `rollback_failed` (:587) does. When R4's gate fails,
the log has the `rollback-failed` line and the activation is closed. When R1, R2 or R3 fails, no
`mosaic` step writes a result, so the activation stays open with its marker. Root's step log has the
codes, the next activate refuses `interrupted-run` until Jason or Mos settles it (:605), and status
shows it. I chose that over a log-only verb for root to call after an R2 failure: nothing could gate
the old stack at that point, and a closed activation would let the next activate start against a
wrapper and store in an unknown state. Exit codes match `stack-release.sh`: 0 ok, 1 failed with the old
stack running (rolled back, or A2f), 2 rollback-failed with UNITS stopped, 3 refused with nothing
changed.
### Rollback to the previous copy: `case1-repin.sh rollback --to ID [--restore ACT --events N]`
The same list with TO in place of ID. The go names TO, from Mos's or Sage's read of `stack-release.sh
status` as `mosaic`. Root passes it. A1's plan, `stack-release.sh rollback mosaic-stack --units external
--to ID --plan --expect-from OLD`, refuses `pointer-differs` as in activate. It also exits 3 unless ID
is `from_log previous` (:162), the previous distinct copy, so a
stale or mistyped go refuses before anything stops. With `--restore`, the plan also refuses unless
`$DATA/backups/stack/ACT` exists. `--to` is a new flag, accepted only with `--units external`;
dragon-lin's rollback keeps reading TO from the log.
The post-backup event count runs in A1's plan as `mosaic` while the bus still holds the store (the
`heldcount` read, :1074), and again in A3 on the stopped store. A difference refuses
`post-backup-events`, as `run_steps` does (:1013).
### Migrations (N4)
`--migrate` under `--units external` runs only inside A3, and A3 refuses `units-running` before it logs
or migrates anything while any of UNITS is active. So a migration never runs against a store a live bus
holds. The test is in section 7.
### Backup
A3's `backup_store` is today's. It copies the stopped store into `$DATA/backups/stack/<ACT>`, so
`rollback --restore <ACT>` and row 181's guard find it. As on dragon-lin, the config dir isn't in the
backup, and the credential isn't either. PBS backups of VM 1008 cover the disk (P4). A restore swaps
only `bus/` and leaves `secrets/claude-session.env` in place (N6, in the run sheet).
The install's adopt (2c') takes no backup. The store holds only what the acceptance wrote, and the first
re-pin's A3 takes the first backup.
### The log
Every `mosaic` line goes to `$DATA/state/stack-activation-log.jsonl`, as today, with `units external`
added. So `from_log previous` reads .53's history the same way it reads dragon-lin's. A3 writes
`started`. A5 or R4 writes the one result line, with the op, from, to and backup of that activation,
or A3 its `refused` line before any change. Refusals of
`confirm`, `revert` and `gate` log no activation id. If a run dies between `started` and the result,
the log keeps `started` with no result, the next run's `interrupted` check reports it, and root's step
log shows the last step that ran.
### Who
Mos or Sage plans each re-pin. ops-01 runs `case1-repin.sh` on Jason's or Mos's go, and a STATE report
follows. The gate and the in-run rollback have one owner, the root command, as they do on dragon-lin.
### dragon-lin's path
`--units external` is a new value. Without it, `stack-release.sh` and `stack-copy.sh check` run as they
do today, `pm_launched` still reads the user journal, and rollback still takes TO from the log.
`confirm`, `revert`, `gate` and `--to` refuse without it. Section 7 names the tests that show this.
### Not taken
- Row 190's user units under `mosaic`, with root running `loginctl enable-linger mosaic` once. Every
existing re-pin tool would work unchanged and root would be needed only once. But the unit form
would differ from Case 2, and a user manager would run as `mosaic`.
- A sudoers rule that lets `mosaic` start and stop the units. P1 and row 230's precheck require that
`mosaic` has no sudo.
- `confirm` and `revert` taking ACT from root. Root would have to read it from `mosaic`'s output or
log, which the rule above forbids (r4's B1).
## 5. Acceptance checks
A new script, `scripts/case1-accept.sh`, in this row's manifest. It runs on .53 as `localadmin` and prints
one line per check: `<id> <path> <mark> <evidence>`. The marks are `closed`, `accepted Q182`,
`accepted Q183`, `accepted mos-1096`, and `unnamed`, which means "not named in Q182, Q183 or a Mos
ruling". The script ends with a count of each mark. It exits non-zero only when a functional check (F)
fails. A P check never changes the exit status. Each `unnamed` line goes to Mos, who takes it to Jason
(R8 N1).
Functional checks. Any failure fails the row:
| Id | Check | How |
|---|---|---|
| F1 | the units run as `mosaic` | `systemctl show <unit> -p User -p MainPID` for each of UNITS; each MainPID's uid in `/proc/<pid>/status` is `mosaic`'s, and each unit is active |
| F2 | the Console listens on 127.0.0.1:732 on .53 and answers there | `curl -fsS http://127.0.0.1:732/healthz` on .53 succeeds, and the listener table shows 732 bound to 127.0.0.1 only, by the Console unit's MainPID. Jason reaches it with `ssh -L 7332:127.0.0.1:732 localadmin@10.1.1.53` and his browser at 127.0.0.1:7332 as today. A LAN listener waits for Q175 SSO (sage-1451 Q3) |
| F3 | one tester message gets a reply | as `mosaic`: `mosaic talk chief <marker> --tester`, then `mosaic follow --tester`, with a 300 s limit. A stored reply to the marked message passes. The inbox alone shows decisions only, so it isn't used (N3) |
| F4 | the precheck passes | `case2-precheck.sh --profile factory` exits 0 as `mosaic` |
| F5 | a role session runs under bwrap with the profile | while F3's reply is pending: a `/usr/bin/bwrap` process owned by `mosaic` has a claude process under it. `/proc/<bwrap pid>/attr/current` equals `bwrap (enforce)` and the claude process's equals `bwrap//&unpriv_bwrap (enforce)`, each compared as an exact string after the trailing newline is cut (N1). If `launches list --json` records the sandbox, the build reads that for the first part, since a short session can end before `ps` sees it |
| F6 | claude is the pinned version | `/srv/mosaic/.local/bin/claude --version` as `mosaic`, after F3, equals the run sheet's pin |
| F7 | the PM run is live | `launches list --json` as `mosaic` has exactly one live entry for the PM's instance, the check `confirm` runs (section 4) |
Privilege and host paths. These report and never fail the row:
| Id | Path | Mark | Evidence the script prints |
|---|---|---|---|
| P1 | `mosaic` has sudo | closed only when `sudo -l -U mosaic` prints "is not allowed to run sudo" (N2); any other output is unnamed | the `sudo -l` result |
| P2 | `mosaic` has supplementary groups or a key | closed when `id -G mosaic` is one entry and `~mosaic/.ssh/authorized_keys` is absent or empty | `id`, file state |
| P3 | `localadmin` NOPASSWD sudo, reached with the dragon-lin key | accepted Q183 (agents keep ssh to .53; localadmin from #423 step 1) | `sudo -l -U localadmin` |
| P4 | root on pve over VM 1008, and PBS backups of VM 1008, which hold `/srv/mosaic` including the credential | accepted Q182 and Q183 (agents keep root on pve and PBS) | a fixed line; the script can't see pve from inside |
| P5 | `jarvis`, which runs Hermes, can reach `/srv/mosaic` through sudo, or through the docker or lxd groups, which are root-equivalent | closed when `jarvis` is in none of sudo, docker and lxd; unnamed otherwise. #423 step 4 lands before the install (mos-1091 4c), and its `step4.sh` removes `jarvis` from sudo, docker, lxd and adm, so after it this reads closed (Duckworth N2) | `id jarvis` |
| P6 | other members of sudo, docker, lxd | closed when sudo holds only `localadmin` and docker and lxd are empty. Every other member is its own unnamed line, `jarvis` included (N1) | `getent group` lines |
| P7 | a file under `/etc/sudoers.d` naming `mosaic` | closed when none | the file names |
| P8 | the Console or tester reachable from the LAN | closed when 732 and 7333 are bound to 127.0.0.1 only, each by the Console's MainPID, and nothing listens on 7331 | listener table |
| P9 | a user namespace through `/usr/bin/bwrap` | accepted mos-1096 only while all seven hold, unnamed otherwise: (1) `dpkg-statoverride --list /usr/bin/bwrap` reads `root mosaic 0750`; (2) `aa-status --json` lists `bwrap` and `unpriv_bwrap` in enforce mode; (3) `/etc/apparmor.d/bwrap-userns-restrict` has the pinned sha256 and no package owns it; (4) `sudo -u jarvis /usr/bin/bwrap --version` fails with "Permission denied"; (5) `getent group mosaic` lists no member and no other account in `/etc/passwd` has its gid; (6) `fs.protected_hardlinks` is 1; (7) no `mosaic-bwrap` profile is loaded, no other file under `/etc/apparmor.d` names `bwrap`, and the live label of a process bwrap runs is exactly `bwrap//&unpriv_bwrap (enforce)` (N1, mos-1098 point 2). Items 1-3, 6 and 7 are `case1-hostcheck.sh --check`'s, so P9 reuses its lines | each item's line, and Mos's reason: "Case 1's agents are the factory's agents. In Q182 and Q183 Jason accepted that the factory reaches root on pve and, through it, every VM. So a user-namespace path to root on .53 gives that class no new reach." With the profile, what's left is bwrap's own setup, run by `mosaic` |
| P10 | a session binds the tester port, 7333, while the Console is down | accepted mos-1101 while `ip_unprivileged_port_start` is above 732 and 7333 is held by the Console's MainPID at the check. Serve refuses a tester port below 1024 (`cli.mjs:33`), so the Console-port fix can't cover it. A squatter would get the tester's writes and could answer them. The tester's users are the factory's agents, the class Mos's reading covers (mos-1101 item 5). Mos can object, and then this line reads unnamed | the floor, and the owner pid of 7333 |
Tests, in `packages/cli/tests/case1-accept.test.mjs`. The script takes a fake root (`--root DIR`) for
`/proc`, `/etc` and `getent` output, and fake `systemctl`, `curl`, `ss`, `sudo`, `bwrap`, `aa-status`,
`dpkg-statoverride`, `sysctl` and `claude` on PATH. Cases:
- each F check failing gives a non-zero exit with its line;
- each P check in each of its marks, including P1 with output that grants nothing but lacks the refusal
text (unnamed), P9 unnamed once for each of its seven items missing, and P10 unnamed with a floor of
700 and with 7333 held by another pid;
- F5 fails on a label of `unpriv_bwrap (enforce)`, `bwrap//&unpriv_bwrap (complain)` and
`bwrap (enforce)` with a trailing space;
- a run with every P check `unnamed` still exits 0;
- the summary counts match the lines.
Mutants: one per F check (the check made to pass on failure), one that maps P5 to `closed`, one that
closes P1 on any output without a rule line, one that drops P9's jarvis item, one that drops P9's item
7, and one that makes F5 a substring match.
Nothing in the tests reaches .53 or dragon-lin's live state.
## 6. Rulings
sage-1451:
- Q1, unit form: the brief's system unit. C2-13's `ExecStart=` check accepts a varying copy id, and
C2-13 took that (gosalyn-0034 item 3).
- Q3, the Console: it listens on 127.0.0.1 on .53 (`packages/webui/src/serve.mjs:466`; `cli.mjs` passes no
host), and Jason reaches it with `ssh -L`. F2 says so. A LAN listener waits for Q175 SSO.
- Q4, the date: if C2-13's candidate is late, I build on C2-13's latest commit and rebase (Q177). Under
the #426(a) push rules (mos-1086) a rebase goes to a new branch name.
mos-1091 (none goes to Jason):
- Q2 4a: installing bubblewrap is an install step, run by ops-01 as root under R27. Rollback is
`apt-get remove`.
- Q2 4b: a per-binary AppArmor profile for `/usr/bin/bwrap`, the sysctl left at 1. r4 takes Ubuntu's
`bwrap-userns-restrict` for it (mos-1096). Section 1 has the steps.
- 4c: P5 ends at #423 step 4, which Mos sent to Duckworth so it lands in Jason's VM 1008 boot session,
before the install. The P5 check stays.
mos-1096:
- B1 and B2 are mine, and so is the PM answer: .53 runs the PM (section 4).
- The `dpkg-statoverride` on `/usr/bin/bwrap`, the hardlink and group checks, the jarvis refusal and F5
under the profile (sections 1 and 5).
- P9 is Mos's acceptance, with the reason quoted in section 5.
- Jason's Q9 on install and upgrade is open. r4 goes ahead, since B1's re-pin and rollback are needed
whatever he answers. If his answer changes how upgrades reach .53, the change lands in section 4 and
the run sheet as a new round.
mos-1098 and mos-1101 (on Duckworth's REVIEW-r3):
- Duckworth's CHANGES stand. B1 and B2 are fixed in this round, with B1's real-sequence test, and all
three of B2's asks: an owner across upgrades, what fails closed, and the pins as reviewed values.
- r4's PM, statoverride and P9 stand. mos-1098's two questions are answered in section 1: the profile's
owner across upgrades is ops-01 with `case1-hostcheck.sh`, and the stop is bwrap closed to `mosaic`.
Both profiles attach to `/usr/bin/bwrap`: `bwrap` to the binary and `unpriv_bwrap` stacked on what it
runs. P9 item 7 checks that no third one does.
- P9 has seven items and F5 compares exact strings (N1).
- The port squat applies (section 1). The Console takes Case 2's fix, a port below 1024 with
`CAP_NET_BIND_SERVICE` and the floor checked. Only the tester port can't take it, and P10 records it
under Mos's reading that Case 1's sessions are the factory class. I've told Mos, who can object.
- Work on r5 goes ahead while Jason's Q9 (Vikunja #429) is open. If install and upgrade become
product commands, these steps become that installer's configuration and row 195 rebases on its row
(sage-1481, sage-1491).
sage-1499: C2-13a is row 230 (#1732, Gosalyn), and row 195 chains on row 230's candidate.
gosalyn-0049: the factory profile is the library set plus an optional `--tester-port`, and row 195 names
any other difference. Section 2 names four, with the reason .53 can't meet the preview trio, `--send` or
the proof pair. The managed-settings route needs no Case 2 change.
## 7. Manifest (planned)
- `scripts/case1-repin.sh` (new) and `packages/cli/tests/case1-repin.test.mjs` (new). Fake `systemctl`,
`sudo` and `case2-unit.sh` on PATH record their argv, with a scratch home standing in for `/srv/mosaic`,
a scratch dir for `/etc/mosaic-stack` and one for `/var/log/mosaic-stack`. Cases:
- a pass runs A1-A5 in order;
- a failure at A3, A4 or A5 runs R1-R4 with `--cause` set to that step, and exits 1;
- an A3 exit 3 runs A2f, not R1-R4;
- a failure at R2 or R4 exits 2 with UNITS stopped;
- A1 refuses `profile-drift` when a fake `case1-hostcheck.sh --check` exits 1, before any stop;
- every `mosaic` step's argv carries `env PATH=<node dir>:/usr/local/bin:/usr/bin:/bin`, with the node
dir from `case1-unit.args`;
- a HEAD mismatch, an ID that fails the copy-id rule, or unit files that name two copies refuses with
exit 3 before any stop;
- A1's plan argv carries `--expect-from` with OLD from the unit files;
- A2f passes `gate OLD --since T`, with T from a fake `date` that root calls before its start;
- A2f: a fake `systemctl` that never reports inactive. The script starts UNITS, runs `gate OLD`, exits
1 with OLD's gate passing and 2 with it failing, and the stack log is byte-identical before and
after;
- `rollback --to` with an ID that isn't `from_log previous` exits 3 before any stop;
- every exec of a path under the home goes through `sudo -u mosaic -H` (from the recorded argv);
- no argv root passes holds an activation id, and OLD comes from the unit files, not from anything
the `mosaic` steps print;
- every `case2-unit.sh` call carries the arguments in `case1-unit.args` and differs only in `--copy`;
- the step log has one line per step with its exit code.
- `scripts/stack-release.sh`: `--units external` for `adopt`, `activate`, `rollback` and `status`, the
`--to` flag for an external rollback, and the new `confirm`, `revert` and `gate` verbs, which refuse
without it. `scripts/stack-copy.sh`: `check --units external`, which reads the system units. Tests in
`packages/cli/tests/stack-release-external.test.mjs` (new), against a fake system-scope `systemctl`
and a fake `mosaic` for `bus status` and `launches list`:
- `units-running` refuses while any unit is active, and `activate --migrate` with a unit active
leaves the store, the log and TAKEN as they were (N4);
- activate leaves the pointer as it was, writes `AWAITING-UNITS`, and writes a backup of the stopped
store under `$DATA/backups/stack/<ACT>`;
- `confirm` and `revert` refuse `open-activation` with zero `AWAITING-UNITS` files, with two, with
one whose copy isn't the one passed, and with one whose activation isn't in the `interrupted` set;
- `confirm` polls: a fake bus whose status turns good after 3 s passes with `--timeout 10` and fails
with `--timeout 1`;
- `stale-start` (N4): with `started` at 1792540800.900, an `ActiveEnterTimestamp` of `@1792540800`
passes and `@1792540799` fails `gate_once` with `stale-start`. A fake that turns to `@1792540805`
after 2 s passes with `--timeout 10`, which shows it's a gate code that polling can clear, not a
refusal;
- `confirm` on a pass writes the pointer, logs `ok` and removes `AWAITING-UNITS`. On a timeout it
writes `FAILED` with the gate code, leaves the pointer, the log and the marker as they were, and
exits 1;
- `confirm` without `--rollback` refuses `activation-failed` when `FAILED` exists;
- every line `confirm` writes has op `activate` or `rollback`, never `confirm`, and the from, to and
activation of its `started` line, and backup equal to the rollback set's `BACKUP`;
- `gate` (N2): with `--since 1792540800`, `@1792540800` passes and `@1792540799` fails `stale-start`;
without `--since` it refuses `usage`;
- `--plan --expect-from` (N5) refuses `pointer-differs` when the pointer names another copy, for
activate and for `rollback --to`;
- A3's order: a fake that fails the capture leaves the log with `started` and `refused code
capture-failed` for that activation, no marker and no `FAILED`, and exit 3. An existing `$RS`
gives `refused code marker-failed` and exit 3, and a capture under `--units external` makes no
directory of its own. A fake that fails the
wrapper install after a migration leaves `started` only, the marker, `FAILED`, TAKEN as migrated,
and exit 1;
- refusals by `confirm`, `revert` and `gate` log with no activation id, and the `interrupted` set
is the same before and after each one;
- the PM check passes with one live entry for the PM's instance since the start, and fails
`pm-launch-unconfirmed` with none, with two, with one whose `startedAt` is before the start, with one
not live, and with one for another business. A second live entry for another instance doesn't
count. A fake `journalctl` on PATH records any call, and no external run makes one;
- `pm-on` and `activate --pm-after-harness` refuse `external-pm`;
- `revert` puts the wrapper back, and restores TAKEN only after a migration;
- `confirm --rollback OLD` writes the line for each `--cause` (A3 and A4 `failed`, A5 `gate-failed`,
with the `FAILED` code, `units-start` for A4, `unrecorded` when the file is missing), or
`rollback-failed` when OLD's gate fails. It removes the marker and `FAILED` after the line, and
refuses when OLD isn't the open activation's `from`;
- real sequences on one log (B1). The fake `sudo` execs the real `stack-release.sh` as the test's
user, so each step reads the log and state the last one wrote. Fakes stand in only for
`systemctl`, `mosaic` and `case2-unit.sh`. Each sequence ends with the log holding exactly one
`started` and one result line for its activation, an empty `interrupted` set, no marker or
`FAILED`, and a following `activate --plan` that doesn't refuse `interrupted-run`:
1. activate, confirm times out, revert, `confirm --rollback --cause A5` passes:
`gate-failed code <c> rollback ok`;
2. activate with `--migrate`, the wrapper install fails, revert restores TAKEN, `confirm --rollback
--cause A3`: `failed code <c> rollback ok`;
3. activate, root's A4 fails, revert, `confirm --rollback --cause A4`: `failed code units-start
rollback ok`;
4. activate whose capture fails: `refused code capture-failed` and nothing open, then A2f's
`gate OLD` writes nothing;
5. activate, then `confirm` with the wrong copy is refused, then revert and `confirm --rollback
--cause A5` still close the activation, because the refusal didn't;
6. activate, revert fails (a wrapper whose saved copy is missing): the activation stays open, and
the next `activate --plan` refuses `interrupted-run`;
7. adopt C0, activate C1 and confirm, activate C2 and confirm (r5's B1): both `ok` lines read
`op activate` with their `started` line's from and to and the backup each took. `rollback --to C1
--plan` exits 0 and `--to C0 --plan` refuses. A rollback to C1, confirmed, logs `op rollback`, and
`from_log previous` then names C2;
8. activate with `--migrate`, where the copy's `migrate-schema.mjs` is a fake that writes to the
store and exits 1 (r5's B2): `FAILED` holds `migrate-failed` and `MIGRATING` exists. revert
restores the store byte-identical to the backup, and `confirm --rollback --cause A3` gates OLD on
it: `failed code migrate-failed rollback ok`;
- `rollback --to` refuses when ID isn't `from_log previous`, and `rollback --restore` finds a backup
that an external activate took;
- `gate` writes nothing: the log, the pointer and the state dir are byte-identical before and after;
- `confirm`, `revert`, `gate` and `--to` refuse without `--units external`.
- dragon-lin unchanged. `stack-release.test.mjs` and `stack-copy.test.mjs` run unchanged and green. A
new case in `stack-release-external.test.mjs` runs a default activate, rollback and check against a
fake `systemctl` that records argv, and every call carries `--user`. A default `activate --plan`
prints the same bytes on the candidate as on its base, with the base output as a fixture.
- Mutants for the re-pin:
- `confirm` writes the pointer on a gate failure;
- `confirm` calls `gate_once` once in place of `gate`;
- the external PM check counts entries of any instance;
- the external PM check drops the `startedAt` bound;
- `confirm` takes the newest `AWAITING-UNITS` instead of refusing two;
- `rollback --to` skips the `from_log previous` comparison;
- `case1-repin.sh` skips R1-R4;
- `case1-repin.sh` exits 0 after A2f;
- `case1-repin.sh` runs `mosaic`'s `stack-release.sh` without `sudo -u mosaic`;
- the default path drops `--user` from one `systemctl` call;
- `confirm`'s timeout writes r4's interim `gate-failed ... rollback pending` line (B1; killed by
sequences 1 and 5);
- A3 writes `AWAITING-UNITS` last, after the wrapper (killed by sequence 2);
- an external verb's refusal logs with the open activation id (ACT set before the checks; killed by
sequence 5 and the refusal test);
- `stale-start` is a refusal again (killed by the polling case);
- `revert` restores TAKEN without `MIGRATING` (killed by the revert case);
- `confirm` logs with its own op, `confirm` (killed by sequence 7 and the op case);
- A3 writes `MIGRATING` after `migrate-schema.mjs` (killed by sequence 8);
- `gate` takes its floor from the newest `started` line when `--since` is missing (killed by the
`gate` case).
- `scripts/case1-pins.sh` and `scripts/case1-hostcheck.sh` (new),
`packages/cli/systemd/mosaic-hostcheck.service` (new) and `packages/cli/tests/case1-hostcheck.test.mjs`
(new). The script takes `--root DIR` for `/etc`, `/proc/sys` and `/usr/bin/bwrap`'s stat, and fake
`aa-status`, `dpkg`, `dpkg-statoverride`, `apparmor_parser`, `systemd-run`, `chown` and `chmod`
on PATH record their argv. Cases:
- `--check` exits 0 with all seven items and 1 with each one failing alone, its line naming it;
item 3 fails on a second file naming `bwrap` as a word, one that writes `/{usr/,}bin/bwrap` included,
and passes with `bwrap-userns-restrict` alone. Item 4 fails on a label of `unpriv_bwrap (enforce)`
or one with a trailing space, and its `systemd-run` argv carries `-p NoNewPrivileges=yes` and the
other 2a' properties. Item 7 fails with the floor at 732;
- `--enforce` exits 0 on a pass and on every failure. On a failure it unloads the profile, closes
bwrap to `root:root` 0700, writes `DRIFT` and one log line. With `DRIFT` present and every item
passing, it still closes bwrap;
- `--enforce` never calls `dpkg-statoverride` with `--update`, `--add` or `--remove`;
- a bubblewrap version other than the install pin is a printed line, not a failure;
- `--upstream` against a fake deb whose file differs prints the difference and exits 0;
- the service unit has `Type=oneshot`, `After=apparmor.service`,
`Before=mosaic-bus@mosaic-stack.service` and runs `--enforce`.
Mutants: `--enforce` exits 1 on a drift; `--enforce` ignores `DRIFT` when the items pass; item 1
drops the `dpkg -S` owner check; item 3 matches only the `/usr/bin/bwrap` path; item 4 matches a
substring; item 4 drops `-p NoNewPrivileges=yes`; item 7 compares with `>=`.
- `scripts/case1-accept.sh` (new) and `packages/cli/tests/case1-accept.test.mjs` (new), section 5.
- Row 230 (C2-13a). `--profile`, the node, bwrap and port-floor precheck lines, the copy-id rule, the
two units and the Console's capability lines are in its brief at 5b8def2f1. Section 2's four items
are not. Any of them that row 230's candidate lacks becomes a named change here, reviewed by
Duckworth with Webby told. I told Gosalyn earlier that the settings-file route for
`DISABLE_AUTOUPDATER` doesn't reach a role session, and that Case 1 uses the managed settings file.
- Docs: the Case 1 run sheet `docs/guides/case1-install.md` (new). It lists the window's steps in order,
each with who runs it and its go, the pinned node sha256 and claude version, a pointer to
`case1-pins.sh` for the bubblewrap and profile pins, the statoverride, the drift stop and the managed
settings file, each with its rollback, how ops-01 clears a `DRIFT`, the `ssh -L` line for port 732,
and a note that a restore leaves the credential in place.
- No change to `~/.mosaic-dev`, `~/.config/mosaic-dev`, dragon-lin's units or `~/.mosaic`.
+11
View File
@@ -0,0 +1,11 @@
# Gosalyn
Worker seat for Mosaic Stack, MVP track T2 (Case 2, the creative writing hub, Vikunja #416).
- T3 thread: 7604438a-0ded-41c8-bd3f-26f57fdb5cd2
- Model: Opus 5.5 (Claude Code, T3)
- Launched by Mos (Jason's main agent, T3 38bc4c81-6270-4021-b00c-6a15c43b9b0e), who briefs the seat
- Repository lead and queue: Sage (T3 1ef1e4f8-3ead-4208-beca-38f9f1add079)
- Branch: gosalyn/case2-design, pushed only by this seat, landed on refactor by Sage
Work lives in `work/`. The Case 2 design is `work/case2/DESIGN.md`.
+2
View File
@@ -1363,3 +1363,5 @@ are never rewritten or removed; corrections are new entries.
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
2026-10-20T18:57:10Z | Filbert (Opus 5.5, 048f3c63) | row 190 design review r2 (#1688, DESIGN.md sha256 5493c9df3ec0) | CHANGES to Darkwing, cc Sage: B1 claim check on MOSAIC_CONFIG-only selection, B2 shared prefix drop-in dirs; N1-N7 notes; static read, no slot, no Gitea or queue record
2026-10-20T19:00:04Z | Filbert (Opus 5.5, 048f3c63) | row 190 design review r3 (#1688, DESIGN.md sha256 98236a1aff98) | APPROVE to Darkwing, cc Sage: B1 and B2 closed with M13 and M14, N7 settled by Sage's 48ebee0ff; static read, no slot; manifest round gets a queue review record (sage-1337)
2026-10-20T20:40:15Z | Filbert (Opus 5.5, 048f3c63) | row 190 (#1688) manifest round 1 review, candidate 8862feccc (static) | CHANGES: B1 base drift, row 189 ace27dc5e conflicts in stack-release.sh, rebase + full gate rerun; N1-N7 notes; Gitea 29634, queue rev 2362 (uncommitted, left for Sage)
2026-10-20T21:29:20Z | Filbert (Opus 5.5, 048f3c63) | row 190 (#1688) manifest round 2 review, candidate 537a02392 (static) | CHANGES: B1 base drift, row 192 4429a104f conflicts in mosaic-config.mjs and webui-service.sh (async load); round 2 content fine; Gitea 29685, queue rev 2447 (uncommitted, left for Sage)
+89 -10
View File
@@ -15,7 +15,7 @@ and changes nothing.
| Command | Purpose | Notes |
|---|---|---|
| `scripts/bootstrap.sh` | Create `~/.config/mosaic-dev/config.json` if absent | Idempotent; existing config validated, never rewritten |
| `scripts/bootstrap.sh [--instance NAME]` | Create `~/.config/mosaic-dev/config.json` if absent; with `--instance NAME`, `~/.config/mosaic-NAME/config.json` and the claimed root `~/.mosaic-NAME` | Idempotent; existing config validated, never rewritten. See [Stack instances](#stack-instances---instance-name-row-190) |
| `scripts/build.sh` | Build the release image | Tag derived from `RELEASE` + pinned pi version |
| `scripts/hello.sh` | One-shot startup request | Prints model response on stdout |
| `scripts/verify.sh` | Full gated test | Exit 0 only on exact `MOSAIC_HELLO_OK`; `EXPECTED_MARKER` overrides for negative drills |
@@ -270,9 +270,9 @@ scripts/mosaic follow --thread <thread> [--tester]
scripts/mosaic stop <run>
scripts/mosaic launches off | on [--business <id>]
scripts/mosaic launches list [--json]
scripts/bus-service.sh render [--copy ID [--copies DIR]] [--pm] [--claude PATH]
scripts/bus-service.sh install [--copy ID [--copies DIR]] [--pm] [--dir DIR] [--no-reload] [--claude PATH]
scripts/bus-service.sh uninstall | status <business>
scripts/bus-service.sh [--instance NAME] render [--copy ID [--copies DIR]] [--pm] [--claude PATH]
scripts/bus-service.sh [--instance NAME] install [--copy ID [--copies DIR]] [--pm] [--dir DIR] [--no-reload] [--claude PATH]
scripts/bus-service.sh [--instance NAME] uninstall | status <business>
```
The human commands read and resolve through the broker's human transport
@@ -321,12 +321,89 @@ stdin and stdout it exits 4 with the usage.
Exit codes: `0` ok · `1` failed or outcome unknown · `2` invalid input ·
`3` refused or config problem · `4` usage. Details: `packages/cli/README.md`.
## Stack instances (`--instance NAME`, row 190)
Several stacks can run on one host, each with its own config, data root,
units, Console port and CLI wrapper. The default instance is today's stack
and keeps every name it had. A named instance is created once:
```
scripts/bootstrap.sh --instance NAME
```
NAME matches `^[a-z][a-z0-9]{1,15}$`; anything else exits 4
(`instance-name-invalid`), and `--instance dev` is the default. Bootstrap
writes `~/.config/mosaic-NAME/config.json` (0644) and claims `~/.mosaic-NAME`
with the marker `instance.json`, `{"instanceVersion":1,"name":"NAME"}`. It
creates either directory 0700; one that already exists keeps its mode. It refuses with exit 3 and writes
nothing when the root exists, is not empty and holds no marker naming NAME.
A second run changes nothing. `~` is the home of the OS user that runs the
instance.
`--instance NAME` is taken, once and in any position, by `bootstrap.sh`,
`bus-service.sh`, `webui-service.sh` (all but `copy`), `discord-service.sh`,
`stack-copy.sh` (`wrapper` and `check`) and `stack-release.sh`. Copies serve
every instance, so both `copy` verbs refuse it with exit 4. Inside a process the config
travels as `MOSAIC_CONFIG`. Selection, checked before any read or write:
| Given | Config | Names |
|---|---|---|
| neither | `~/.config/mosaic-dev/config.json` | the default's |
| `MOSAIC_CONFIG` only | that file | the default's |
| `--instance NAME` only | `~/.config/mosaic-NAME/config.json` | NAME's |
| both | must be one file (a symlink to it agrees), else exit 4 `instance-config-mismatch` | NAME's |
Every selection checks the data root's marker. A named instance whose root
lacks its own marker refuses `instance-root-unclaimed`; a root whose marker
names another instance refuses `instance-root-mismatch`, also when a config
reaches it with no flag. Both exit 3 before anything is read or changed. A
missing config exits 3 with the bootstrap command that creates it.
| | Default | Instance NAME |
|---|---|---|
| bus unit | `[email protected]` | `[email protected]` |
| Console unit | `mosaic-webui.service` | `mosaic-webui-NAME.service` |
| Discord unit | `[email protected]` | `[email protected]` |
| CLI wrapper | `$BIN/mosaic` (Q81 marker) | `$BIN/mosaic-NAME`, marker `# mosaic-cli wrapper, instance NAME (row 190)`, exports `MOSAIC_CONFIG` |
| bus session env | `%h/.config/mosaic-dev/secrets/claude-session.env` | `~/.config/mosaic-NAME/secrets/claude-session.env` |
| Console port | `--port`, 7332 when absent | `--port` required; 7330, 7332 and 7333 exit 4 `port-reserved` |
A named instance's units add `Environment=MOSAIC_CONFIG=<its config>` and
` [NAME]` after the Description, and the Console unit's comment on the
variables it sets names `MOSAIC_CONFIG`. The default's renders are
byte-identical to the templates' renders before row 190. `stack-copy.sh
wrapper --instance NAME` creates `$BIN/mosaic-NAME` or replaces one that
carries NAME's marker, and refuses any other file. `check --instance NAME`
needs `--business ID`, and fails with `CLI: <copy> (MOSAIC_CONFIG mismatch)`
when the wrapper does not export NAME's config.
For every instance, a Console `--port` or `--tester-port` that another
installed Console unit in the unit directory already takes refuses
`port-claimed`, naming that unit: `webui-service.sh install` exits 1, and
`stack-release.sh activate` and `rollback` exit 3 before anything stops
(`--plan` lists it). A unit with no `--port` holds 7332.
`stack-release.sh --instance NAME` reads only NAME's pointer, log, store and
backups. Its busy check sees only NAME's bus units. Its drop-in scan adds
`mosaic-bus-NAME@*.service.d` and the shared `mosaic-bus-.service.d`. Its
rollback set holds NAME's two unit files, their own `.d` directories and
`$BIN/mosaic-NAME`; it never captures or restores the shared prefix
directories (`mosaic-bus-.service.d`, `mosaic-webui-.service.d`,
`mosaic-discord-.service.d`). The live-session guard refuses a path inside
any named instance's config directory or `~/.mosaic-NAME`, even when that
instance's config is unreadable.
All instances run under one OS user for now. That user's processes can
read and control every instance; a separate user per instance is row 195.
A real second instance on this host is a Q173 window, and tunnel or Traefik
exposure of a named Console waits for Jason after Q175 SSO.
## Stack copies (`scripts/stack-copy.sh`)
```
scripts/stack-copy.sh copy COMMIT [--copies DIR] [--allow-worktree]
scripts/stack-copy.sh wrapper ID [--copies DIR] [--bin DIR]
scripts/stack-copy.sh check [--copies DIR] [--bin DIR] [--business ID]
scripts/stack-copy.sh wrapper ID [--instance NAME] [--copies DIR] [--bin DIR]
scripts/stack-copy.sh check [--instance NAME] [--copies DIR] [--bin DIR] [--business ID]
```
One read-only tree supplies the bus, Console and human CLI (row 85,
@@ -372,7 +449,7 @@ scripts/stack-release.sh activate ID BUSINESS [--pm | --pm-after-harness] [--mig
scripts/stack-release.sh pm-on BUSINESS [--timeout S]
scripts/stack-release.sh rollback BUSINESS [--restore ACTIVATION [--events N]] [--timeout S] [--plan]
scripts/stack-release.sh status BUSINESS
common options: --copies DIR, --bin DIR
common options: --copies DIR, --bin DIR, --instance NAME
--events N is the total of the per-table counts the post-backup-events refusal prints: events and
every table in COUNTED.
```
@@ -602,6 +679,7 @@ its runs alone. No unit passes it, and `--pm` with it is a usage error.
```bash
scripts/webui-service.sh copy COMMIT [--copies DIR] # make a pinned copy
# render, install, uninstall and status also take --instance NAME (row 190); copy refuses it (exit 4)
scripts/webui-service.sh render [--copy ID [--copies DIR]] [FLAGS] # print the unit
scripts/webui-service.sh install --copy ID [--copies DIR] [--dir DIR] [--no-reload] [FLAGS]
scripts/webui-service.sh uninstall [--dir DIR] [--no-reload]
@@ -679,8 +757,9 @@ Each value is held to a plain character set the unit can carry (no space,
`%`, `$`, quote, backslash, `&` or `|`), and the preview values must start
with a letter or digit; anything else is refused. With `--auth-header
X-authentik-uid`, in any letter case, `--allow-user` must be the Authentik
uid, `^[0-9a-f]{64}$` (lead decision 101). The unit sets no `MOSAIC_*`,
`CLAUDE*`, `CODEX*` or `PI_*` variable. Serve's exit 2 (usage, config, or a
uid, `^[0-9a-f]{64}$` (lead decision 101). The default's unit sets no `MOSAIC_*`,
`CLAUDE*`, `CODEX*` or `PI_*` variable; a named instance's sets only
`MOSAIC_CONFIG` (row 190). Serve's exit 2 (usage, config, or a
port already in use) is not restarted.
The update/rollback sequence is [Pinned stack update](#pinned-stack-update)
@@ -705,7 +784,7 @@ scripts/discord.sh stop <binding>
scripts/discord.sh unlock <binding>
scripts/discord.sh recover <binding>
scripts/discord.sh reload <binding>
scripts/discord-service.sh render | install [--dir DIR] [--no-reload] | uninstall [--dir DIR] [--no-reload] | status <binding>
scripts/discord-service.sh [--instance NAME] render | install [--dir DIR] [--no-reload] | uninstall [--dir DIR] [--no-reload] | status <binding>
```
One seat's conversation reachable from listed channels of one Discord
File diff suppressed because it is too large Load Diff
+11 -1
View File
@@ -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
View File
@@ -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"
}
}
+13
View File
@@ -717,6 +717,19 @@ without it, so Pi sessions still launch and a Claude Code launch refuses
(or this line and this paragraph), then restart the unit; a running host
keeps the environment it started with.
A named stack instance (row 190) takes `--instance NAME` with any verb, in
any position. The unit is then `[email protected]`, it sets
`Environment=MOSAIC_CONFIG=` to `~/.config/mosaic-NAME/config.json`, its
Description ends in ` [NAME]`, and its `EnvironmentFile=` is
`secrets/claude-session.env` in that config directory, not mosaic-dev's.
Install's busy and drop-in checks cover only `mosaic-bus-NAME@*` units and
their `.d` directories, plus the prefix directories systemd applies to them:
`mosaic-bus-.service.d`, `mosaic-.service.d` and `service.d`. A data root
that another instance claims, or a named root with no `instance.json`, exits
3 before anything is read. Without the flag the unit renders byte for byte
as before. Selection, names and refusals are in
[TOOLS.md, Stack instances](../../docs/TOOLS.md#stack-instances---instance-name-row-190).
The unit has no `network-online.target` ordering, since a user unit cannot
order on that system target. The notifier needs no network at start: a
send that fails is journaled and retried.
+2 -1
View File
@@ -13,6 +13,7 @@ StartLimitBurst=5
Type=simple
WorkingDirectory=@REPO@
Environment=PATH=@PATH@
@CONFIG_ENV@
# The Claude Code binary that managed sessions and the manifest's
# claudeVersion run, pinned at install (`--claude PATH`, default
# `command -v claude`). Without it they run `claude` from PATH above.
@@ -21,7 +22,7 @@ Environment=PATH=@PATH@
# operator writes: CLAUDE_CODE_OAUTH_TOKEN, ANTHROPIC_API_KEY, or a proxy's
# ANTHROPIC_AUTH_TOKEN with ANTHROPIC_BASE_URL. The `-` lets the unit start
# without it, and a Claude Code launch then refuses.
EnvironmentFile=-%h/.config/mosaic-dev/secrets/claude-session.env
EnvironmentFile=-@SESSION_ENV@
# The host reads the system config and the business file, boots the broker
# (packages/bus/src/process.mjs) and starts the notifier. Config problems and
# refusals exit 3, usage 4, invalid input 2: none of those is retried. A
+9 -4
View File
@@ -837,7 +837,11 @@ test("bus start --no-launch says launches are disabled in its host-up line and s
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");
const render = spawnSync(script, ["render"], { encoding: "utf8" });
// Row 190: the script selects an instance from the system config first; a
// scratch path keeps it off ~/.config/mosaic-dev. A missing config selects
// the default names, as before.
const cfgEnv = { ...process.env, MOSAIC_CONFIG: join(dir, "absent", "config.json") };
const render = spawnSync(script, ["render"], { encoding: "utf8", env: cfgEnv });
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/);
@@ -855,7 +859,7 @@ test("bus-service.sh renders the unit and installs it into a given directory", (
const fakeBin=join(dir,"bin");mkdirSync(fakeBin);
writeFileSync(join(fakeBin,"systemctl"),"#!/bin/sh\ncase \"$*\" in *list-units*) exit 0;; *)exit 99;; esac\n");chmodSync(join(fakeBin,"systemctl"),0o755);
writeFileSync(join(fakeBin,"systemd-analyze"),`#!/bin/sh\nprintf '%s\\n' '${dir}/unit-search'\n`);chmodSync(join(fakeBin,"systemd-analyze"),0o755);
const unitEnv={...process.env,PATH:`${fakeBin}:${process.env.PATH}`};
const unitEnv={...cfgEnv,PATH:`${fakeBin}:${process.env.PATH}`};
const first = spawnSync(script, ["install", "--dir", dir, "--no-reload"], { encoding: "utf8",env:unitEnv });
assert.equal(first.status, 0, first.stderr);
assert.match(first.stdout, /written: /);
@@ -863,7 +867,7 @@ test("bus-service.sh renders the unit and installs it into a given directory", (
assert.equal(readFileSync(join(dir, "[email protected]"), "utf8"), render.stdout);
assert.match(spawnSync(script, ["install", "--dir", dir, "--no-reload"], { encoding: "utf8",env:unitEnv }).stdout, /unchanged: /);
assert.match(spawnSync(script, ["uninstall", "--dir", dir, "--no-reload"], { encoding: "utf8",env:unitEnv }).stdout, /removed: /);
assert.equal(spawnSync(script, ["bogus"], { encoding: "utf8" }).status, 4);
assert.equal(spawnSync(script, ["bogus"], { encoding: "utf8", env: cfgEnv }).status, 4);
});
// Row 68: the unit pins the Claude Code binary as MOSAIC_CLAUDE_BIN, from
@@ -882,7 +886,8 @@ test("bus-service.sh pins the claude binary, refuses one the unit can't carry, a
const onPath = stand(join(dir, "bin", "claude"));
const named = stand(join(dir, "claude-named"));
symlinkSync(named, join(dir, "claude-link"));
const env = { ...process.env, PATH: `${join(dir, "bin")}:${dirname(process.execPath)}:/usr/bin:/bin` };
// Row 190: a scratch MOSAIC_CONFIG keeps the instance selection off ~/.config/mosaic-dev.
const env = { ...process.env, MOSAIC_CONFIG: join(dir, "absent", "config.json"), PATH: `${join(dir, "bin")}:${dirname(process.execPath)}:/usr/bin:/bin` };
const run = (args, e = env) => spawnSync(script, args, { encoding: "utf8", env: e });
const pinned = (out) => out.match(/^Environment=MOSAIC_CLAUDE_BIN=(.*)$/m)?.[1];
+17
View File
@@ -66,6 +66,23 @@ test('wrapper preserves Q81 lease and marker, rejects foreign/symlink; check use
assert.notEqual(s.run('stack-copy.sh',['wrapper',s.id,'--copies',s.copies,'--bin',s.bin]).status,0);assert.equal(readFileSync(wrapper,'utf8'),foreign);
const backing=join(s.bin,'backing');writeFileSync(backing,`#!/usr/bin/env bash\n${marker}\n${lease}\nexec \"/old/scripts/mosaic\" \"$@\"\n`);unlinkSync(wrapper);symlinkSync(backing,wrapper);assert.notEqual(s.run('stack-copy.sh',['wrapper',s.id,'--copies',s.copies,'--bin',s.bin]).status,0);
});
test('row 190 N6: check --instance passes only when the wrapper exports the instance\'s MOSAIC_CONFIG',t=>{
const s=setup(t);assert.equal(s.run('stack-copy.sh',['copy',s.id,'--copies',s.copies]).status,0);
const boot=s.run('bootstrap.sh',['--instance','alpha']);assert.equal(boot.status,0,boot.stderr);
const copy=join(s.copies,s.id),wrapper=join(s.bin,'mosaic-alpha'),cfg=join(s.root,'.config','mosaic-alpha','config.json');
const w=s.run('stack-copy.sh',['wrapper',s.id,'--instance','alpha','--copies',s.copies,'--bin',s.bin]);assert.equal(w.status,0,w.stderr);
s.fake(`echo 'LoadState=loaded';echo 'WorkingDirectory=${copy}';case "$*" in *mosaic-bus-alpha@demo*) echo 'ExecStart={ path=${copy}/scripts/mosaic ; argv[]=${copy}/scripts/mosaic bus start demo ; }';; *mosaic-webui-alpha*) echo 'ExecStart={ path=/usr/bin/node ; argv[]=/usr/bin/node ${copy}/packages/webui/src/cli.mjs serve ; }';; *) exit 1;; esac\n`);
const check=()=>s.run('stack-copy.sh',['check','--instance','alpha','--copies',s.copies,'--bin',s.bin,'--business','demo']);
const good=readFileSync(wrapper,'utf8');assert.equal(good.split('\n')[3],`export MOSAIC_CONFIG='${cfg}'`);
let r=check();assert.equal(r.status,0,r.stdout+r.stderr);assert.equal(r.stdout,`bus: ${copy}\nConsole: ${copy}\nCLI: ${copy}\n`);
for(const [label,text] of [
['another config',good.replace(`export MOSAIC_CONFIG='${cfg}'`,`export MOSAIC_CONFIG='${join(s.root,'.config','mosaic-beta','config.json')}'`)],
['no config line',good.replace(`export MOSAIC_CONFIG='${cfg}'\n`,'')],
]){
writeFileSync(wrapper,text);r=check();
assert.equal(r.status,1,label);assert.equal(r.stdout,`bus: ${copy}\nConsole: ${copy}\nCLI: ${copy} (MOSAIC_CONFIG mismatch)\n`,label);
}
});
test('bus unit copy/pm pins, install refuses all busy states and failed reads, drop-in refusal',t=>{
const s=setup(t);assert.equal(s.run('stack-copy.sh',['copy',s.id,'--copies',s.copies]).status,0);
const args=['--copy',s.id,'--copies',s.copies,'--pm'];
+469
View File
@@ -0,0 +1,469 @@
// Row 190 (#1688): several stack instances on one host. The config and bootstrap cases (T-I1, T-I2,
// the config part of T-I7), two real bus hosts side by side (T-I5) and the guard (T-I10). Design and
// T table: agents/darkwing/work/queue-190/DESIGN.md D7. The unit, port and release cases are T56 in
// stack-release.test.mjs. HOME is a scratch directory in every test, so ~/.config/mosaic-* and
// ~/.mosaic-* resolve inside it; nothing here reads or writes ~/.mosaic-dev or ~/.config/mosaic-dev.
import { test } from "node:test";
import assert from "node:assert/strict";
import { spawn, spawnSync } from "node:child_process";
import { once } from "node:events";
import { chmodSync, cpSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
import { createHash } from "node:crypto";
import { join, relative } from "node:path";
import { fileURLToPath } from "node:url";
import { LiveSessionGuard } from "../../conversation/src/guard.mjs";
import { businessDoc, rolesCopy, writeJson } from "../../business/tests/helpers.mjs";
import { REPO_SKILLS } from "../../harness/tests/helpers.mjs";
import { humanEnv, notifyConfig, tmp } from "./helpers.mjs";
const SCRIPTS = fileURLToPath(new URL("../../../scripts/", import.meta.url));
const CONFIG = join(SCRIPTS, "mosaic-config.mjs");
const BOOTSTRAP = join(SCRIPTS, "bootstrap.sh");
const BUS_SERVICE = join(SCRIPTS, "bus-service.sh");
const WEBUI_SERVICE = join(SCRIPTS, "webui-service.sh");
const STACK_COPY = join(SCRIPTS, "stack-copy.sh");
const MOSAIC = join(SCRIPTS, "mosaic");
// The environment for a scratch home: no MOSAIC_CONFIG unless a test sets one.
function homeEnv(home, extra = {}) {
const env = humanEnv({ HOME: home, ...extra });
if (!("MOSAIC_CONFIG" in extra)) delete env.MOSAIC_CONFIG;
delete env.XDG_CONFIG_HOME;
return env;
}
function run(cmd, args, env) {
const r = spawnSync(cmd, args, { env, encoding: "utf8", timeout: 30000 });
return { code: r.status, out: r.stdout, err: r.stderr };
}
const bootstrap = (home, ...args) => run(BOOTSTRAP, args, homeEnv(home));
const config = (home, extra, ...args) => run(process.execPath, [CONFIG, ...args], homeEnv(home, extra));
const cfgOf = (home, name) => join(home, ".config", `mosaic-${name}`, "config.json");
// The instance op's exports as an object.
function exportsOf(out) {
return Object.fromEntries(out.trim().split("\n").map((line) => {
const m = line.match(/^(\w+)='(.*)'$/);
assert.ok(m, `not a shell-safe export: ${line}`);
return [m[1], m[2]];
}));
}
// Every entry under `dir`: relative path, type, mode, size, mtime and content hash.
function snapshot(dir) {
const out = {};
const walk = (d) => {
for (const name of readdirSync(d).sort()) {
const p = join(d, name);
const s = lstatSync(p);
const key = relative(dir, p);
if (s.isDirectory()) {
out[key] = { dir: true, mode: s.mode, mtime: s.mtimeMs };
walk(p);
} else {
out[key] = { mode: s.mode, size: s.size, mtime: s.mtimeMs, sha: createHash("sha256").update(readFileSync(p)).digest("hex") };
}
}
};
if (existsSync(dir)) walk(dir);
return out;
}
const mode = (p) => statSync(p).mode & 0o777;
test("T-I1 bootstrap --instance creates each instance's config, root and marker once, and refuses what it must not claim", async (t) => {
await t.test("alpha and beta get a 0700 config dir, a config naming ~/.mosaic-NAME, a 0700 root and a 0644 marker", (t) => {
const home = tmp(t, "si-");
for (const name of ["alpha", "beta"]) {
const r = bootstrap(home, "--instance", name);
assert.equal(r.code, 0, r.err);
const cfg = cfgOf(home, name), root = join(home, `.mosaic-${name}`);
assert.equal(r.err, `mosaic-config: created configuration for instance ${name}: ${cfg}, data root ${root}\n`);
assert.equal(mode(join(home, ".config", `mosaic-${name}`)), 0o700);
assert.equal(mode(cfg), 0o644);
assert.equal(JSON.parse(readFileSync(cfg, "utf8")).dataRoot, root);
assert.equal(mode(root), 0o700);
const marker = join(root, "instance.json");
assert.equal(readFileSync(marker, "utf8"), `{"instanceVersion":1,"name":"${name}"}\n`);
assert.equal(mode(marker), 0o644);
assert.deepEqual(readdirSync(root), ["instance.json"]);
assert.deepEqual(readdirSync(join(home, ".config", `mosaic-${name}`)), ["config.json"]);
}
assert.equal(existsSync(join(home, ".config", "mosaic-dev")), false);
assert.equal(existsSync(join(home, ".mosaic-dev")), false);
});
await t.test("a second bootstrap of each changes no byte, mode or mtime", (t) => {
const home = tmp(t, "si-");
for (const name of ["alpha", "beta"]) assert.equal(bootstrap(home, "--instance", name).code, 0);
const before = snapshot(home);
for (const name of ["alpha", "beta"]) {
const r = bootstrap(home, "--instance", name);
assert.equal(r.code, 0, r.err);
assert.equal(r.err, `mosaic-config: configuration already present, validated without changes: ${cfgOf(home, name)}\n`);
}
assert.deepEqual(snapshot(home), before);
});
await t.test("a non-empty root without its own marker refuses before anything is written, worktrees included", (t) => {
const home = tmp(t, "si-");
mkdirSync(join(home, ".mosaic-gamma"));
writeFileSync(join(home, ".mosaic-gamma", "notes.txt"), "someone's data\n");
mkdirSync(join(home, ".mosaic-worktrees", "row190"), { recursive: true });
mkdirSync(join(home, ".mosaic-delta"));
writeFileSync(join(home, ".mosaic-delta", "instance.json"), `{"instanceVersion":1,"name":"alpha"}\n`);
writeFileSync(join(home, ".mosaic-delta", "other"), "x");
const before = snapshot(home);
for (const name of ["gamma", "worktrees", "delta"]) {
const r = bootstrap(home, "--instance", name);
assert.equal(r.code, 3, r.err);
assert.match(r.err, new RegExp(`instance-root-unclaimed: ${join(home, `.mosaic-${name}`)} exists, is not empty and holds no instance.json naming ${name}; nothing was written`));
assert.equal(existsSync(join(home, ".config", `mosaic-${name}`)), false);
}
assert.deepEqual(snapshot(home), before);
// A root that is a file refuses the same way.
writeFileSync(join(home, ".mosaic-file"), "x");
const r = bootstrap(home, "--instance", "file");
assert.equal(r.code, 3);
assert.match(r.err, /instance-root-unclaimed: .* exists and is not a directory; nothing was written/);
assert.equal(existsSync(join(home, ".config", "mosaic-file")), false);
});
await t.test("an empty existing root is claimed, and an existing config whose root lost its marker refuses, not repairs", (t) => {
const home = tmp(t, "si-");
mkdirSync(join(home, ".mosaic-alpha"), { mode: 0o700 });
assert.equal(bootstrap(home, "--instance", "alpha").code, 0);
assert.equal(readFileSync(join(home, ".mosaic-alpha", "instance.json"), "utf8"), `{"instanceVersion":1,"name":"alpha"}\n`);
rmSync(join(home, ".mosaic-alpha", "instance.json"));
const before = snapshot(home);
const r = bootstrap(home, "--instance", "alpha");
assert.equal(r.code, 3);
assert.match(r.err, /instance-root-unclaimed: .*\.mosaic-alpha holds no instance\.json naming alpha/);
assert.deepEqual(snapshot(home), before);
});
await t.test("a dangling symlink at the config path is never written through, named or default", (t) => {
const home = tmp(t, "si-");
for (const [name, args] of [["alpha", ["--instance", "alpha"]], ["dev", []]]) {
const dir = join(home, ".config", `mosaic-${name}`), target = join(home, `elsewhere-${name}.json`);
mkdirSync(dir, { recursive: true, mode: 0o700 });
symlinkSync(target, join(dir, "config.json"));
const r = bootstrap(home, ...args);
assert.equal(r.code, 2, `${name}: ${r.err}`);
assert.match(r.err, /configuration path must be a regular, non-symbolic-link file/);
assert.equal(existsSync(target), false, `${name}: the link's target was created`);
assert.deepEqual(readdirSync(dir), ["config.json"]);
assert.ok(lstatSync(join(dir, "config.json")).isSymbolicLink());
}
assert.deepEqual(readdirSync(home).sort(), [".config"]);
});
await t.test("bad names and stray arguments exit 4 and write nothing", (t) => {
const home = tmp(t, "si-");
for (const name of ["A", "a-b", "x", "a", "abcdefghijklmnopq", "../x", "1ab", "a.b", ""]) {
const r = bootstrap(home, "--instance", name);
assert.equal(r.code, 4, `${name}: ${r.err}`);
assert.match(r.err, /instance-name-invalid/);
}
assert.equal(bootstrap(home, "--instance", "abcdefghijklmnop").code, 0, "16 characters pass");
rmSync(join(home, ".config"), { recursive: true });
rmSync(join(home, ".mosaic-abcdefghijklmnop"), { recursive: true });
for (const args of [["--instance"], ["--instance", "alpha", "extra"], ["extra"], ["--name", "alpha"]]) {
const r = bootstrap(home, ...args);
assert.equal(r.code, 4, `${args.join(" ")}: ${r.err}`);
assert.match(r.err, /usage: mosaic-config\.mjs bootstrap \[--instance NAME\]/);
}
assert.deepEqual(readdirSync(home), []);
});
await t.test("--instance dev writes exactly what no flag writes", (t) => {
const plain = tmp(t, "si-"), dev = tmp(t, "si-");
const a = bootstrap(plain), b = bootstrap(dev, "--instance", "dev");
assert.equal(a.code, 0, a.err);
assert.equal(b.code, 0, b.err);
assert.equal(b.err.replaceAll(dev, "HOME"), a.err.replaceAll(plain, "HOME"));
const shape = (home) => Object.fromEntries(Object.entries(snapshot(home)).map(([k, v]) => [k, { ...v, mtime: 0, sha: v.sha && createHash("sha256").update(readFileSync(join(home, k), "utf8").replaceAll(home, "HOME")).digest("hex") }]));
assert.deepEqual(shape(dev), shape(plain));
assert.deepEqual(Object.keys(snapshot(plain)), [".config", ".config/mosaic-dev", ".config/mosaic-dev/config.json"]);
});
});
test("T-I2 selection: the four rows of D1 through the instance op and through an entry point", async (t) => {
const home = tmp(t, "si-");
for (const name of ["alpha", "beta"]) assert.equal(bootstrap(home, "--instance", name).code, 0);
const scratch = join(home, "scratch-config.json");
writeJson(scratch, { configVersion: 1, environment: "development", dataRoot: join(home, "scratch-data"), execution: { backend: "docker", provider: "zai", model: "glm-5.3-flash" } });
await t.test("row 1: no flag and no MOSAIC_CONFIG is the default; a missing default config leaves the data root empty", () => {
const r = config(home, {}, "instance");
assert.equal(r.code, 0, r.err);
assert.deepEqual(exportsOf(r.out), {
MOSAIC_INSTANCE: "dev",
MOSAIC_INSTANCE_CONFIG: cfgOf(home, "dev"),
MOSAIC_INSTANCE_CONFIG_DIR: join(home, ".config", "mosaic-dev"),
MOSAIC_INSTANCE_DATA_ROOT: "",
MOSAIC_UNIT_SUFFIX: "",
});
assert.deepEqual(exportsOf(config(home, {}, "instance", "--instance", "dev").out), exportsOf(r.out));
});
await t.test("row 2: MOSAIC_CONFIG alone is that config with the default names", () => {
const r = config(home, { MOSAIC_CONFIG: scratch }, "instance");
assert.equal(r.code, 0, r.err);
assert.deepEqual(exportsOf(r.out), {
MOSAIC_INSTANCE: "dev",
MOSAIC_INSTANCE_CONFIG: scratch,
MOSAIC_INSTANCE_CONFIG_DIR: home,
MOSAIC_INSTANCE_DATA_ROOT: join(home, "scratch-data"),
MOSAIC_UNIT_SUFFIX: "",
});
});
await t.test("row 3: --instance NAME alone is NAME's config, root and suffix", () => {
for (const name of ["alpha", "beta"]) {
const r = config(home, {}, "instance", "--instance", name);
assert.equal(r.code, 0, r.err);
assert.deepEqual(exportsOf(r.out), {
MOSAIC_INSTANCE: name,
MOSAIC_INSTANCE_CONFIG: cfgOf(home, name),
MOSAIC_INSTANCE_CONFIG_DIR: join(home, ".config", `mosaic-${name}`),
MOSAIC_INSTANCE_DATA_ROOT: join(home, `.mosaic-${name}`),
MOSAIC_UNIT_SUFFIX: `-${name}`,
});
}
});
await t.test("row 4: both must name one file; a symlink to it agrees, any other file exits 4", () => {
const link = join(home, "alpha-link.json");
symlinkSync(cfgOf(home, "alpha"), link);
for (const given of [cfgOf(home, "alpha"), link]) {
const r = config(home, { MOSAIC_CONFIG: given }, "instance", "--instance", "alpha");
assert.equal(r.code, 0, r.err);
assert.equal(exportsOf(r.out).MOSAIC_INSTANCE_CONFIG, cfgOf(home, "alpha"));
}
for (const given of [cfgOf(home, "beta"), scratch, join(home, "missing.json")]) {
for (const op of ["instance", "validate", "env", "bootstrap"]) {
const r = config(home, { MOSAIC_CONFIG: given }, op, "--instance", "alpha");
assert.equal(r.code, 4, `${op} ${given}: ${r.err}`);
assert.equal(r.out, "");
assert.match(r.err, new RegExp(`instance-config-mismatch: --instance alpha names ${cfgOf(home, "alpha")}, but MOSAIC_CONFIG names ${given}`));
}
}
});
await t.test("a named instance with no config exits 3 with its own bootstrap hint", () => {
const r = config(home, {}, "instance", "--instance", "gamma");
assert.equal(r.code, 3);
assert.match(r.err, /configuration not found: .*mosaic-gamma\/config\.json \(run scripts\/bootstrap\.sh --instance gamma to create it\)/);
});
await t.test("an entry point takes --instance in any position, once, and refuses a mismatch before it prints", () => {
const first = run(BUS_SERVICE, ["--instance", "alpha", "render"], homeEnv(home));
assert.equal(first.code, 0, first.err);
assert.match(first.out, new RegExp(`^Environment=MOSAIC_CONFIG=${cfgOf(home, "alpha")}$`, "m"));
assert.equal(run(BUS_SERVICE, ["render", "--instance", "alpha"], homeEnv(home)).out, first.out);
assert.equal(run(BUS_SERVICE, ["render", "--instance", "alpha"], homeEnv(home, { MOSAIC_CONFIG: cfgOf(home, "alpha") })).out, first.out);
const mismatch = run(BUS_SERVICE, ["--instance", "alpha", "render"], homeEnv(home, { MOSAIC_CONFIG: cfgOf(home, "beta") }));
assert.equal(mismatch.code, 4);
assert.equal(mismatch.out, "");
assert.match(mismatch.err, /instance-config-mismatch/);
for (const args of [["--instance", "alpha", "render", "--instance", "alpha"], ["render", "--instance"]]) {
const r = run(BUS_SERVICE, args, homeEnv(home));
assert.equal(r.code, 4, args.join(" "));
assert.equal(r.out, "");
assert.match(r.err, /instance: usage: --instance NAME, given once/);
}
const bad = run(BUS_SERVICE, ["--instance", "a-b", "render"], homeEnv(home));
assert.equal(bad.code, 4);
assert.match(bad.err, /instance-name-invalid/);
});
await t.test("copy takes no --instance in either script: every instance shares the copies", () => {
const copies = join(home, "copies-probe");
const before = snapshot(home);
for (const [cmd, args, err] of [
[STACK_COPY, ["--instance", "alpha", "copy", "HEAD", "--copies", copies], /^usage: stack-copy\.sh copy COMMIT/],
[STACK_COPY, ["copy", "HEAD", "--copies", copies, "--instance", "alpha"], /^usage: stack-copy\.sh copy COMMIT/],
[WEBUI_SERVICE, ["--instance", "alpha", "copy", "HEAD", "--copies", copies], /^webui-service: copy takes no --instance; every instance shares the copies\n$/],
[WEBUI_SERVICE, ["copy", "HEAD", "--copies", copies, "--instance", "alpha"], /^webui-service: copy takes no --instance; every instance shares the copies\n$/],
]) {
const r = run(cmd, args, homeEnv(home));
assert.equal(r.code, 4, `${args.join(" ")}: ${r.err}`);
assert.equal(r.out, "");
assert.match(r.err, err, args.join(" "));
}
assert.deepEqual(snapshot(home), before);
});
});
test("T-I7 claims: the instance op refuses a root another instance claims, and a named root with no marker", async (t) => {
const home = tmp(t, "si-");
for (const name of ["alpha", "beta"]) assert.equal(bootstrap(home, "--instance", name).code, 0);
const edit = (name, dataRoot) => writeJson(cfgOf(home, name), { ...JSON.parse(readFileSync(cfgOf(home, name), "utf8")), dataRoot });
const scratch = join(home, "scratch-config.json");
const scratchRoot = join(home, "scratch-data");
writeJson(scratch, { configVersion: 1, environment: "development", dataRoot: scratchRoot, execution: { backend: "docker", provider: "zai", model: "glm-5.3-flash" } });
await t.test("alpha's config pointing at beta's root refuses instance-root-mismatch in every op", () => {
edit("alpha", join(home, ".mosaic-beta"));
for (const op of ["instance", "validate", "env", "bootstrap"]) {
const r = config(home, {}, op, "--instance", "alpha");
assert.equal(r.code, 3, `${op}: ${r.err}`);
assert.equal(r.out, "");
assert.match(r.err, new RegExp(`instance-root-mismatch: ${join(home, ".mosaic-beta")} is claimed by instance beta, not alpha`));
}
edit("alpha", join(home, ".mosaic-alpha"));
assert.equal(config(home, {}, "instance", "--instance", "alpha").code, 0);
});
await t.test("MOSAIC_CONFIG naming alpha's config with no flag refuses: the root is alpha's", () => {
for (const op of ["instance"]) {
const r = config(home, { MOSAIC_CONFIG: cfgOf(home, "alpha") }, op);
assert.equal(r.code, 3, r.err);
assert.match(r.err, new RegExp(`instance-root-mismatch: ${join(home, ".mosaic-alpha")} is claimed by instance alpha; select it with --instance alpha`));
}
});
await t.test("a default config whose root carries alpha's marker refuses; one naming dev, or none, passes", () => {
mkdirSync(scratchRoot, { recursive: true });
assert.equal(config(home, { MOSAIC_CONFIG: scratch }, "instance").code, 0, "no marker passes as today");
writeFileSync(join(scratchRoot, "instance.json"), `{"instanceVersion":1,"name":"alpha"}\n`);
const r = config(home, { MOSAIC_CONFIG: scratch }, "instance");
assert.equal(r.code, 3);
assert.match(r.err, /claimed by instance alpha; select it with --instance alpha/);
writeFileSync(join(scratchRoot, "instance.json"), `{"instanceVersion":1,"name":"dev"}\n`);
assert.equal(config(home, { MOSAIC_CONFIG: scratch }, "instance").code, 0);
rmSync(join(scratchRoot, "instance.json"));
});
await t.test("a named root whose marker is missing, a symlink, unparsable or the wrong shape refuses instance-root-unclaimed", () => {
const marker = join(home, ".mosaic-alpha", "instance.json");
const good = readFileSync(marker, "utf8");
const cases = [
["missing", () => {}, /holds no instance\.json naming alpha/],
["a symlink", () => { writeFileSync(join(home, "real-marker.json"), good); symlinkSync(join(home, "real-marker.json"), marker); }, /must be a regular, non-symbolic-link file/],
["a directory", () => mkdirSync(marker), /must be a regular, non-symbolic-link file/],
["unparsable", () => writeFileSync(marker, "{"), /is not a valid marker/],
["an extra key", () => writeFileSync(marker, `{"instanceVersion":1,"name":"alpha","x":1}\n`), /is not a valid marker/],
["version 2", () => writeFileSync(marker, `{"instanceVersion":2,"name":"alpha"}\n`), /is not a valid marker/],
["a bad name", () => writeFileSync(marker, `{"instanceVersion":1,"name":"Alpha"}\n`), /is not a valid marker/],
];
for (const [name, change, why] of cases) {
rmSync(marker, { recursive: true, force: true });
change();
const r = config(home, {}, "instance", "--instance", "alpha");
assert.equal(r.code, 3, `${name}: ${r.err}`);
assert.match(r.err, /instance-root-unclaimed/, name);
assert.match(r.err, why, name);
rmSync(marker, { recursive: true, force: true });
writeFileSync(marker, good);
}
assert.equal(config(home, {}, "instance", "--instance", "alpha").code, 0);
});
});
async function until(check, ms) {
const end = Date.now() + ms;
while (!check()) {
if (Date.now() > end) throw new Error("timed out");
await new Promise((r) => setTimeout(r, 50));
}
}
// One named instance in `home`, ready for `bus start acme --no-launch`: bootstrap, a business file in
// its config dir, its own roles copy, the skills its roles name, and a notifier with no binding.
function hostInstance(home, root, name) {
assert.equal(bootstrap(home, "--instance", name).code, 0);
const cfg = cfgOf(home, name), dataRoot = join(home, `.mosaic-${name}`);
const files = join(root, `${name}-files`);
const roles = rolesCopy(files);
const doc = businessDoc(files, "acme");
for (const r of Object.values(doc.roles)) r.vars = { harness: "pi", model: "glm-5.3-flash", thinking: "high", ...r.vars };
writeJson(join(home, ".config", `mosaic-${name}`, "businesses", "acme.json"), { ...doc, plugins: { ...doc.plugins, harness: ["pi", "claude-code"] } });
cpSync(REPO_SKILLS, join(dataRoot, "skills-enabled"), { recursive: true });
notifyConfig(dataRoot, "acme", null);
return { name, cfg, dataRoot, env: homeEnv(home, { MOSAIC_CONFIG: cfg, MOSAIC_ROLES_DIR: roles }) };
}
test("T-I5 two real bus hosts, one per instance, run at once and each answers from its own socket under its own root", async (t) => {
const root = tmp(t, "si-");
const home = join(root, "h");
mkdirSync(home);
const both = ["alpha", "beta"].map((name) => hostInstance(home, root, name));
for (const inst of both) {
const sock = join(inst.dataRoot, "bus", "broker.sock");
// The kernel's limit for a Unix socket path is 107 bytes (the row 177 lesson).
assert.ok(Buffer.byteLength(sock) <= 107, `socket path is ${Buffer.byteLength(sock)} bytes: ${sock}`);
inst.sock = sock;
inst.child = spawn(MOSAIC, ["bus", "start", "acme", "--no-launch"], { env: inst.env, stdio: ["ignore", "pipe", "pipe"] });
t.after(() => inst.child.exitCode === null && inst.child.kill("SIGKILL"));
inst.out = "";
inst.child.stdout.on("data", (b) => (inst.out += b));
inst.child.stderr.on("data", (b) => (inst.out += b));
}
for (const inst of both) await until(() => /bus host up: business acme.*\n/.test(inst.out) || inst.child.exitCode !== null, 30000);
for (const inst of both) {
assert.match(inst.out, new RegExp(`bus host up: business acme, socket ${inst.sock}, notifier off, launches disabled\n`), inst.out);
assert.ok(lstatSync(inst.sock).isSocket());
}
for (const inst of both) {
const r = run(MOSAIC, ["bus", "status", "--json"], inst.env);
assert.equal(r.code, 0, r.err);
const s = JSON.parse(r.out);
assert.equal(s.host.business, "acme");
assert.equal(s.host.pid, inst.child.pid);
assert.equal(s.host.live, true);
assert.equal(s.socket, true);
// The broker process under the host holds the writer lock.
assert.equal(s.writerLock.live, true);
inst.lockPid = s.writerLock.pid;
}
assert.notEqual(both[0].child.pid, both[1].child.pid);
assert.notEqual(both[0].lockPid, both[1].lockPid);
// Stopping alpha leaves beta up.
const [alpha, beta] = both;
const exitA = once(alpha.child, "exit");
const stopA = run(MOSAIC, ["bus", "stop"], alpha.env);
assert.equal(stopA.code, 0, stopA.err);
assert.match(stopA.out, new RegExp(`stopped bus host for acme \\(pid ${alpha.child.pid}\\)`));
assert.equal((await exitA)[0], 0, alpha.out);
assert.equal(beta.child.exitCode, null);
const still = JSON.parse(run(MOSAIC, ["bus", "status", "--json"], beta.env).out);
assert.equal(still.host.pid, beta.child.pid);
assert.equal(still.writerLock.pid, beta.lockPid);
assert.equal(still.writerLock.live, true);
assert.equal(existsSync(join(alpha.dataRoot, "bus", "writer.lock")), false);
const exitB = once(beta.child, "exit");
assert.equal(run(MOSAIC, ["bus", "stop"], beta.env).code, 0);
assert.equal((await exitB)[0], 0, beta.out);
});
test("T-I10 the guard protects every instance's config dir and data roots, even under a corrupt config", (t) => {
const root = tmp(t, "si-");
const home = join(root, "h");
mkdirSync(home);
for (const name of ["alpha", "beta"]) assert.equal(bootstrap(home, "--instance", name).code, 0);
// alpha's root moved by its operator; beta's config is broken.
const moved = join(root, "alpha-data");
mkdirSync(moved);
writeJson(cfgOf(home, "alpha"), { ...JSON.parse(readFileSync(cfgOf(home, "alpha"), "utf8")), dataRoot: moved });
writeFileSync(cfgOf(home, "beta"), "{ not json");
for (const dir of ["mosaic.bak", "mosaic", "mosaic-a-b", "mosaic-Alpha", "mosaic-x"]) mkdirSync(join(home, ".config", dir));
const guard = new LiveSessionGuard({ fixtureRoot: root, homes: [home] });
const refuses = [
[join(home, ".config", "mosaic-alpha", "x"), "stack instance alpha's config dir"],
[join(home, ".mosaic-alpha", "x"), "stack instance alpha's default data root"],
[join(moved, "sessions", "x"), "stack instance alpha's configured data root"],
[join(home, ".config", "mosaic-beta", "x"), "stack instance beta's config dir"],
[join(home, ".mosaic-beta", "x"), "stack instance beta's default data root"],
[join(home, ".config"), "stack instance alpha's config dir"],
];
for (const [p, why] of refuses) {
assert.throws(() => guard.check({ workspace: p }, "bind"), (e) => e.message === `workspace overlaps ${why} (bind)`, p);
}
for (const dir of ["mosaic.bak", "mosaic", "mosaic-a-b", "mosaic-Alpha", "mosaic-x"]) {
assert.equal(guard.check({ workspace: join(home, ".config", dir, "x") }, "bind"), true, dir);
}
assert.equal(guard.check({ workspace: join(root, "work") }, "bind"), true);
});
+234 -39
View File
@@ -25,7 +25,8 @@ const script=(fn)=>`#!${NODE}\n(${fn})();\n`;
// systemctl --user: unit state in $SD_STATE/units.json, every call in calls.log. A bus start runs
// holder.cjs (the store held open in WAL mode, writer.lock, host.json) and, with --pm in the unit's
// ExecStart, writes the `launched` journal line and the launches entry unless a pm-<copy> fault
// names another case. Faults are files in $SD_STATE/faults named for the copy they hit.
// names another case. Faults are files in $SD_STATE/faults named for the copy they hit. Units of a
// named instance (row 190) run on their own data root and host files beside the default's.
function systemctl(){
const fs=process.getBuiltinModule('node:fs'),path=process.getBuiltinModule('node:path'),cp=process.getBuiltinModule('node:child_process'),crypto=process.getBuiltinModule('node:crypto');
const sd=process.env.SD_STATE,data=process.env.SD_DATA,unitDir=path.join(process.env.XDG_CONFIG_HOME,'systemd','user');
@@ -37,12 +38,16 @@ function systemctl(){
const save=(s)=>{fs.writeFileSync(stFile+'.tmp',JSON.stringify(s));fs.renameSync(stFile+'.tmp',stFile)};
const sleep=(ms)=>Atomics.wait(new Int32Array(new SharedArrayBuffer(4)),0,0,ms);
const alive=(pid)=>{try{const s=fs.readFileSync(`/proc/${pid}/stat`,'utf8');return s[s.lastIndexOf(')')+2]!=='Z'}catch{return false}};
const isBus=(u)=>/^mosaic-bus@[a-z0-9-]+\.service$/.test(u),inst=(u)=>u.slice('mosaic-bus@'.length,-'.service'.length);
// Row 190: a bus unit is the default's mosaic-bus@B or instance NAME's mosaic-bus-NAME@B; %i is B.
const isBus=(u)=>/^mosaic-bus(-[a-z][a-z0-9]{1,15})?@[a-z0-9-]+\.service$/.test(u),inst=(u)=>u.slice(u.indexOf('@')+1,-'.service'.length);
const unitFile=(u)=>{
const main=isBus(u)?'mosaic-bus@.service':u,file=path.join(unitDir,main);
const main=isBus(u)?u.replace(/@[^@]*$/,'@.service'):u,file=path.join(unitDir,main);
if(!fs.existsSync(file))return null;
const drops=[];
for(const d of isBus(u)?[main+'.d',u+'.d']:[u+'.d']){const dir=path.join(unitDir,d);if(fs.existsSync(dir))for(const f of fs.readdirSync(dir).sort())if(f.endsWith('.conf'))drops.push(path.join(dir,f))}
// The drop-in directories in systemd's order: service.d, the name cut after each hyphen, then
// the template's and the unit's own.
const stem=main.slice(0,main.search(/@?\.service$/)),dirs=['service.d'],drops=[];
for(let i=0;i<stem.length;i++)if(stem[i]==='-')dirs.push(stem.slice(0,i+1)+'.service.d');
for(const d of [...dirs,...(isBus(u)?[main+'.d',u+'.d']:[u+'.d'])]){const dir=path.join(unitDir,d);if(fs.existsSync(dir))for(const f of fs.readdirSync(dir).sort())if(f.endsWith('.conf'))drops.push(path.join(dir,f))}
const r={wd:'',exec:'',env:[],drops};
for(const f of [file,...drops])for(let line of fs.readFileSync(f,'utf8').split('\n')){
line=line.replace(/%i/g,isBus(u)?inst(u):'').replace(/%h/g,process.env.HOME);
@@ -51,17 +56,21 @@ function systemctl(){
}
return r;
};
const snap=(ev)=>{
const dir=path.join(data,'bus'),files={};
// A named instance's unit sets MOSAIC_CONFIG, and its bus works on that config's data root. A unit
// without it is the default's, on $SD_DATA. Each data root has its own host and launches files.
const dataOf=(f)=>{const c=f&&f.env.find((v)=>v.startsWith('MOSAIC_CONFIG='));return c?JSON.parse(fs.readFileSync(c.slice('MOSAIC_CONFIG='.length),'utf8')).dataRoot:data};
const perData=(d,n)=>path.join(sd,d===data?`${n}.json`:`${n}-${path.basename(d)}.json`);
const snap=(ev,d)=>{
const dir=path.join(d,'bus'),files={};
try{for(const n of fs.readdirSync(dir).sort()){const p=path.join(dir,n),st=fs.statSync(p);if(st.isFile())files[n]=[st.size,st.mtimeMs,crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex')]}}catch{}
fs.appendFileSync(path.join(sd,'snap.jsonl'),JSON.stringify({ev,files})+'\n');
};
const pm=(b,id,pid,off)=>{
const pm=(unit,d,id,pid,off)=>{
let mode='';try{mode=fs.readFileSync(faultFile(`pm-${id}`),'utf8').trim()}catch{}
const now=Date.now()-off*1000,epoch=Math.floor(now/1000),at=new Date(now).toISOString(),run=`run-${pid}`,unit=`mosaic-bus@${b}.service`;
const b=inst(unit),now=Date.now()-off*1000,epoch=Math.floor(now/1000),at=new Date(now).toISOString(),run=`run-${pid}`;
const line=(text)=>fs.appendFileSync(path.join(sd,'journal.jsonl'),JSON.stringify({epoch,unit,text})+'\n');
const ok=()=>line(`launched pm-${b}: run ${run}, claude opus`);
const lf=path.join(sd,'launches.json');let list=[];try{list=JSON.parse(fs.readFileSync(lf,'utf8'))}catch{}
const lf=perData(d,'launches');let list=[];try{list=JSON.parse(fs.readFileSync(lf,'utf8'))}catch{}
const entry=(o)=>list.push({run,business:b,live:true,startedAt:at,...o});
line(`bus host up: business ${b}, socket /fixture/bus.sock, notifier off`);
switch(mode){
@@ -107,26 +116,26 @@ function systemctl(){
}
if(verb==='start'){
const u=rest[0],f=unitFile(u);if(!f){console.error(`Unit ${u} not found.`);process.exit(5)}
const s=load(),id=path.basename(f.wd);
const s=load(),id=path.basename(f.wd),d=dataOf(f);
if(!isBus(u)){s[u]={state:'active',pid:null,wd:f.wd};save(s);process.exit(0)}
if(fault(`start-dies-${id}`)){s[u]={state:'failed',pid:null,wd:f.wd};save(s);process.exit(0)}
// Row 185 (T48): the copy's start writes one event before the holder opens the store, so the
// store differs from a backup the run took before the start. The close leaves no -wal.
if(fault(`start-writes-row-${id}`)){
const db=new (process.getBuiltinModule('node:sqlite').DatabaseSync)(path.join(data,'bus','bus.sqlite'));
const db=new (process.getBuiltinModule('node:sqlite').DatabaseSync)(path.join(d,'bus','bus.sqlite'));
db.prepare('INSERT INTO events (at) VALUES (?)').run('2999-01-01T00:00:00.000Z');db.close();
}
snap('start');
snap('start',d);
const off=process.env.SD_CLOCK_OFFSET||'0';
// The holder must not inherit stack-release.sh's lock (fd 9) or any other fd above 2.
const close='for fd in /proc/self/fd/*; do n=${fd##*/}; [ "$n" -gt 2 ] && eval "exec $n>&-" 2>/dev/null; done; exec "$@"';
const ch=cp.spawn('bash',['-c',close,'holder',process.execPath,path.join(__dirname,'..','holder.cjs'),data,inst(u),sd,off],{detached:true,stdio:'ignore'});
const ch=cp.spawn('bash',['-c',close,'holder',process.execPath,path.join(__dirname,'..','holder.cjs'),d,inst(u),sd,off,perData(d,'host')],{detached:true,stdio:'ignore'});
ch.unref();
fs.appendFileSync(path.join(sd,'pids.log'),ch.pid+'\n');
const ready=path.join(sd,`ready-${ch.pid}`),end=Date.now()+10000;
while(!fs.existsSync(ready)){if(Date.now()>end){console.error('fixture holder did not start');process.exit(1)}sleep(20)}
s[u]={state:'active',pid:ch.pid,wd:f.wd};save(s);
if(f.exec.split(' ').slice(1).includes('--pm'))pm(inst(u),id,ch.pid,Number(off));
if(f.exec.split(' ').slice(1).includes('--pm'))pm(u,d,id,ch.pid,Number(off));
process.exit(0);
}
if(verb==='stop'){
@@ -138,17 +147,18 @@ function systemctl(){
}
if(st){s[u]={...st,state:'inactive',pid:null};save(s)}
if(isBus(u)){
const d=dataOf(unitFile(u));
// One-shot: the stop leaves an empty -wal or -shm beside the store, as an unclean close would.
for(const [name,ext] of [['stop-leaves-wal','-wal'],['stop-leaves-shm','-shm']])if(fault(name)){fs.rmSync(faultFile(name));fs.writeFileSync(path.join(data,'bus','bus.sqlite'+ext),'')}
for(const [name,ext] of [['stop-leaves-wal','-wal'],['stop-leaves-shm','-shm']])if(fault(name)){fs.rmSync(faultFile(name));fs.writeFileSync(path.join(d,'bus','bus.sqlite'+ext),'')}
// One-shot (T45): the stop writes one row after any STAMP into the table the fault names, as a
// write the bus made between rollback's count and its stop would. The close leaves no -wal.
if(fault('stop-writes-row')){
const t=fs.readFileSync(faultFile('stop-writes-row'),'utf8');fs.rmSync(faultFile('stop-writes-row'));
const db=new (process.getBuiltinModule('node:sqlite').DatabaseSync)(path.join(data,'bus','bus.sqlite'));
const db=new (process.getBuiltinModule('node:sqlite').DatabaseSync)(path.join(d,'bus','bus.sqlite'));
db.exec(`CREATE TABLE IF NOT EXISTS ${t} (seq INTEGER PRIMARY KEY, at TEXT NOT NULL)`);
db.prepare(`INSERT INTO ${t} (at) VALUES (?)`).run('2999-01-01T00:00:00.000Z');db.close();
}
snap('stop');
snap('stop',d);
}
process.exit(0);
}
@@ -178,10 +188,11 @@ function systemdAnalyze(){
if(fs.existsSync(extra))console.log(fs.readFileSync(extra,'utf8'));
}
// The bus host: holds the store open in WAL mode until SIGTERM, as the broker does.
// The bus host: holds the store open in WAL mode until SIGTERM, as the broker does. It writes its
// status to the host file systemctl names for its data root.
function holder(){
const fs=process.getBuiltinModule('node:fs'),path=process.getBuiltinModule('node:path'),{DatabaseSync}=process.getBuiltinModule('node:sqlite');
const [data,business,sd,offset]=process.argv.slice(2),bus=path.join(data,'bus');
const [data,business,sd,offset,host]=process.argv.slice(2),bus=path.join(data,'bus');
fs.mkdirSync(bus,{recursive:true,mode:0o700});
const db=new DatabaseSync(path.join(bus,'bus.sqlite'));
if(!db.prepare("SELECT count(*) AS n FROM sqlite_master WHERE name='meta'").get().n){
@@ -191,7 +202,7 @@ function holder(){
db.prepare("INSERT INTO meta VALUES ('schema_version', '3g')").run();
}
fs.writeFileSync(path.join(bus,'writer.lock'),String(process.pid));
const at=new Date(Date.now()-Number(offset||0)*1000).toISOString(),host=path.join(sd,'host.json');
const at=new Date(Date.now()-Number(offset||0)*1000).toISOString();
fs.writeFileSync(host+'.tmp',JSON.stringify({host:{business,pid:process.pid,live:true,startedAt:at,notifier:'ok'},boot:{ok:true,business,at}}));
fs.renameSync(host+'.tmp',host);
process.on('SIGTERM',()=>{db.close();fs.rmSync(path.join(bus,'writer.lock'),{force:true});process.exit(0)});
@@ -199,21 +210,24 @@ function holder(){
setInterval(()=>{},1<<30);
}
// A copy's scripts/mosaic: bus status and launches list from what the holder and systemctl wrote.
// A copy's scripts/mosaic: bus status and launches list from what the holder and systemctl wrote
// for the data root of the config it runs under (row 190), the default's being $SD_DATA.
function mosaicStub(){
const fs=process.getBuiltinModule('node:fs'),path=process.getBuiltinModule('node:path');
const id=path.basename(path.resolve(__dirname,'..')),sd=process.env.SD_STATE,a=process.argv.slice(2).join(' ');
const fault=(n)=>fs.existsSync(path.join(sd,'faults',n));
let d=process.env.SD_DATA;try{d=JSON.parse(fs.readFileSync(process.env.MOSAIC_CONFIG,'utf8')).dataRoot}catch{}
const perData=(n)=>path.join(sd,d===process.env.SD_DATA?`${n}.json`:`${n}-${path.basename(d)}.json`);
const alive=(pid)=>{try{const s=fs.readFileSync(`/proc/${pid}/stat`,'utf8');return s[s.lastIndexOf(')')+2]!=='Z'}catch{return false}};
if(a==='bus status --json'){
if(fault(`status-${id}`))process.exit(1);
let s={host:null,boot:null};
try{s=JSON.parse(fs.readFileSync(path.join(sd,'host.json'),'utf8'));s.host.live=alive(s.host.pid)}catch{}
try{s=JSON.parse(fs.readFileSync(perData('host'),'utf8'));s.host.live=alive(s.host.pid)}catch{}
console.log(JSON.stringify(s));
}else if(a==='bus status')console.log(`bus: fixture status for ${id}`);
else if(a==='launches list --json'){
if(fault(`launches-${id}`))process.exit(1);
let l=[];try{l=JSON.parse(fs.readFileSync(path.join(sd,'launches.json'),'utf8'))}catch{}
let l=[];try{l=JSON.parse(fs.readFileSync(perData('launches'),'utf8'))}catch{}
console.log(JSON.stringify(l));
}else{console.error(`fixture mosaic: ${a}`);process.exit(4)}
}
@@ -289,7 +303,7 @@ const posts=(e)=>readdirSync(e.data).filter((n)=>n.startsWith('bus.post-attempt-
const backupSum=(e,act)=>readFileSync(join(e.data,'backups','stack',act,'SHA256SUMS'),'utf8').split(' ')[0];
const commitLines=(range)=>git(repo,'log','--oneline','--no-merges','--max-count=200',range).split('\n').map((l)=>` ${l}`);
// The units, the wrapper, the pointer and the store, by sha256.
const fp=(e)=>({bus:sha(join(e.units,'[email protected]')),console:sha(join(e.units,CON)),wrapper:sha(join(e.bin,'mosaic')),pointer:existsSync(e.pointer)?sha(e.pointer):null,store:sha(e.store)});
const fp=(e)=>({bus:sha(join(e.units,e.tmpl)),console:sha(join(e.units,e.con)),wrapper:sha(join(e.bin,e.wrapper)),pointer:existsSync(e.pointer)?sha(e.pointer):null,store:sha(e.store)});
// The schema version: readOnly while the holder has the store open, immutable when it is stopped.
function storeVersion(file,held){
const db=held?new DatabaseSync(file,{readOnly:true}):new DatabaseSync(pathToFileURL(file).href+'?immutable=1',{readOnly:true});
@@ -317,6 +331,8 @@ function biz(e,plugins){
async function mk(t){
const root=realpathSync(mkdtempSync(join(tmpdir(),'sr-')));
const e={root,data:join(root,'data'),bin:join(root,'bin'),sd:join(root,'sd'),units:join(root,'xcfg','systemd','user'),cfg:join(root,'cfg','config.json'),claude:join(root,'claude-bin','claude')};
// The default instance's names; named() sets NAME's (row 190).
Object.assign(e,{flags:[],bus:BUS,tmpl:'[email protected]',con:CON,wrapper:'mosaic'});
Object.assign(e,{state:join(e.data,'state'),pointer:join(e.data,'state','stack-active.json'),log:join(e.data,'state','stack-activation-log.jsonl'),store:join(e.data,'bus','bus.sqlite'),lock:join(e.data,'state','stack-release.lock')});
for(const d of [join(root,'home'),dirname(e.cfg),join(e.data,'bus'),e.bin,dirname(e.claude),join(e.sd,'faults'),e.units,join(root,'xdata')])mkdirSync(d,{recursive:true});
chmodSync(join(e.data,'bus'),0o700);
@@ -326,25 +342,47 @@ async function mk(t){
writeFileSync(join(e.bin,'mosaic-old'),'#!/bin/sh\nexit 0\n',{mode:0o755});
writeFileSync(e.claude,'#!/bin/sh\necho "9.9.9 (Claude Code fixture)"\n',{mode:0o755});
e.env={PATH:`${SBIN}:${dirname(NODE)}:/usr/bin:/bin`,HOME:join(root,'home'),XDG_CONFIG_HOME:join(root,'xcfg'),XDG_DATA_HOME:join(root,'xdata'),MOSAIC_CONFIG:e.cfg,SD_STATE:e.sd,SD_DATA:e.data,TMPDIR:tmpdir()};
// The Console's /healthz: 200 while the stand-in has it active and no healthz-<copy> fault names its copy.
const server=createServer((q,r)=>{
let st={};try{st=units(e)[CON]||{}}catch{}
const ok=q.url==='/healthz'&&st.state==='active'&&!existsSync(join(e.sd,'faults',`healthz-${basename(st.wd||'')}`));
r.writeHead(ok?200:500);r.end();
});
server.listen(0,'127.0.0.1');await once(server,'listening');e.port=String(server.address().port);
const server=await healthz(e);
t.after(()=>{
for(const pid of lines(join(e.sd,'pids.log')).map(Number)){
try{if(readFileSync(`/proc/${pid}/cmdline`,'utf8').includes(join(base,'holder.cjs')))process.kill(pid,'SIGKILL')}catch{}
}
server.close();execFileSync('chmod',['-R','u+w',root]);rmSync(root,{recursive:true,force:true});
for(const s of [server,...(e.servers??[])])s.close();
execFileSync('chmod',['-R','u+w',root]);rmSync(root,{recursive:true,force:true});
});
return e;
}
// The Console's /healthz on e.port: 200 while the stand-in has e.con active and no healthz-<copy>
// fault names its copy.
async function healthz(e){
const server=createServer((q,r)=>{
let st={};try{st=units(e)[e.con]||{}}catch{}
const ok=q.url==='/healthz'&&st.state==='active'&&!existsSync(join(e.sd,'faults',`healthz-${basename(st.wd||'')}`));
r.writeHead(ok?200:500);r.end();
});
server.listen(0,'127.0.0.1');await once(server,'listening');e.port=String(server.address().port);
return server;
}
// Row 190: stack instance NAME on e's host. It shares e's stand-ins, unit directory and $BIN, and has
// its own config and data root from bootstrap --instance, its own Console port and business acme.
// Its runs pass --instance NAME and no MOSAIC_CONFIG.
async function named(e,name){
const env={...e.env};delete env.MOSAIC_CONFIG;
execFileSync('bash',[join(repo,'scripts','bootstrap.sh'),'--instance',name],{env,stdio:'ignore'});
const data=join(env.HOME,`.mosaic-${name}`);
const n={...e,env,data,cfg:join(env.HOME,'.config',`mosaic-${name}`,'config.json'),flags:['--instance',name],bus:`mosaic-bus-${name}@acme.service`,tmpl:`mosaic-bus-${name}@.service`,con:`mosaic-webui-${name}.service`,wrapper:`mosaic-${name}`,name};
Object.assign(n,{state:join(data,'state'),pointer:join(data,'state','stack-active.json'),log:join(data,'state','stack-activation-log.jsonl'),store:join(data,'bus','bus.sqlite'),lock:join(data,'state','stack-release.lock')});
mkdirSync(join(data,'bus'),{mode:0o700});
biz(n);
(e.servers??=[]).push(await healthz(n));
return n;
}
async function run(e,...args){
const before=existsSync(e.data)?kept(e):[];
const ch=spawn('bash',[e.sr??SR,...args,'--copies',COPIES,'--bin',e.bin],{env:e.env,stdio:['ignore','pipe','pipe']});
const ch=spawn('bash',[e.sr??SR,...args,...e.flags,'--copies',COPIES,'--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});
const timer=setTimeout(()=>ch.kill('SIGKILL'),120000);
const [code]=await once(ch,'close');clearTimeout(timer);
@@ -364,12 +402,12 @@ async function seed(e,{copy=B,pm=false,store='3g',pointer:how='adopt'}={}){
for(let i=0;i<3;i++)db.prepare('INSERT INTO events (at) VALUES (?)').run(`2000-01-01T00:00:0${i}.000Z`);
db.close();
const sh=(name,...a)=>execFileSync('bash',[join(repo,'scripts',name),...a],{env:e.env,encoding:'utf8',stdio:['ignore','pipe','pipe']});
sh('bus-service.sh','install','--copy',copy,'--copies',COPIES,...(pm?['--pm']:[]),'--claude',e.claude);
sh('webui-service.sh','install','--copy',copy,'--copies',COPIES,'--port',e.port,'--business','acme');
sh('stack-copy.sh','wrapper',copy,'--copies',COPIES,'--bin',e.bin);
sh('bus-service.sh','install',...e.flags,'--copy',copy,'--copies',COPIES,...(pm?['--pm']:[]),'--claude',e.claude);
sh('webui-service.sh','install',...e.flags,'--copy',copy,'--copies',COPIES,'--port',e.port,'--business','acme');
sh('stack-copy.sh','wrapper',copy,...e.flags,'--copies',COPIES,'--bin',e.bin);
// The seeded host started two minutes ago, so its boot status and PM run predate any run's start.
execFileSync(join(SBIN,'systemctl'),['--user','start',BUS],{env:{...e.env,SD_CLOCK_OFFSET:'120'}});
sysctl(e,'start',CON);
execFileSync(join(SBIN,'systemctl'),['--user','start',e.bus],{env:{...e.env,SD_CLOCK_OFFSET:'120'}});
sysctl(e,'start',e.con);
if(how==='adopt'){const r=await run(e,'adopt','acme');assert.equal(r.code,0,r.err)}
else if(how==='hand'){
mkdirSync(e.state,{recursive:true});
@@ -2191,3 +2229,160 @@ test('R15 a rehearsal stopped by TERM, INT or HUP fails signal and still stops t
assert.equal(existsSync(`/proc/${pid}`),false,`${sig}: host pid ${pid} is still there`);
}
});
// --- Row 190 (#1688): stack instances on one host. Design and the T-I table:
// agents/darkwing/work/queue-190/DESIGN.md. T-I1, T-I2, T-I5, T-I10 and the loader half of T-I7 are in
// stack-instances.test.mjs; these need the units, the stand-ins and stack-release.sh. Every existing
// test above is D1's "no flag, no marker" case and passes unchanged.
const shOut=(env,name,...a)=>spawnSync('bash',[join(repo,'scripts',name),...a],{env,encoding:'utf8'});
// The Console template's comment on the variables its unit sets, as the default and a named instance render it.
const CONSOLE_VARS={
default:'# and 94). The unit sets no MOSAIC_*, CLAUDE*, CODEX* or PI_* variable.',
named:'# and 94). The unit sets MOSAIC_CONFIG and no other MOSAIC_*, CLAUDE*, CODEX* or PI_* variable.',
};
const SHARED_DROPINS=['mosaic-bus-.service.d','mosaic-webui-.service.d','mosaic-discord-.service.d'];
// Every path under each of ps with its mode, size, mtime and, for a file, its sha256.
function stamp(...ps){
const out=[];
const walk=(q)=>{let st;try{st=lstatSync(q)}catch{out.push(`${q} absent`);return}
out.push(`${q} ${st.mode.toString(8)} ${st.size} ${st.mtimeMs}${st.isFile()?' '+sha(q):''}`);
if(st.isDirectory())for(const n of readdirSync(q).sort())walk(join(q,n))};
for(const p of ps)walk(p);return out;
}
const dirsUnder=(p)=>stamp(p).filter((l)=>!l.endsWith(' absent')).map((l)=>l.split(' ')[0]).filter((q)=>lstatSync(q).isDirectory());
// The T53 inotify watch on a directory: it counts each open or read of the directory or a file in it.
async function watchDir(e,d){
const ch=spawn('python3',['-I','-c',WATCH,d],{stdio:['pipe','pipe','inherit']});
let out='';ch.stdout.on('data',(b)=>{out+=b});
await new Promise((res,rej)=>{ch.stdout.on('data',()=>{if(out.startsWith('ready\n'))res()});ch.on('error',rej);ch.on('close',(c)=>rej(new Error(`inotify watch exited ${c}`)))});
WATCHERS.add(ch);ch.on('close',()=>WATCHERS.delete(ch));
(e.watches??=[]).push(async()=>{ch.stdin.end();if(ch.exitCode===null)await once(ch,'close');return [d,Number(out.split('\n')[1])]});
}
test('T56 row 190: named instances side by side on one unit directory, $BIN and set of stand-ins',async(t)=>{
await t.test('T-I3 each instance renders its own config, session env and Description, and nothing of another',async(t)=>{
const e=await mk(t),a=await named(e,'alpha'),b=await named(e,'beta');
for(const [x,y] of [[a,b],[b,a]]){
const renders={
bus:shOut(x.env,'bus-service.sh','render','--instance',x.name,'--copy',B,'--copies',COPIES,'--claude',e.claude),
console:shOut(x.env,'webui-service.sh','render','--instance',x.name,'--copy',B,'--copies',COPIES,'--port',x.port,'--business','acme'),
discord:shOut(x.env,'discord-service.sh','render','--instance',x.name),
};
for(const [k,r] of Object.entries(renders)){
assert.equal(r.status,0,`${x.name} ${k}: ${r.stderr}`);
assert.ok(r.stdout.includes(`\nEnvironment=MOSAIC_CONFIG=${x.cfg}\n`),`${x.name} ${k}`);
assert.match(r.stdout,new RegExp(`\\nDescription=[^\\n]* \\[${x.name}\\]\\n`),`${x.name} ${k}`);
for(const not of [y.name,'mosaic-dev'])assert.ok(!r.stdout.includes(not),`${x.name} ${k} holds ${not}`);
}
assert.ok(renders.bus.stdout.includes(`\nEnvironmentFile=-${dirname(x.cfg)}/secrets/claude-session.env\n`),x.name);
assert.ok(renders.console.stdout.includes(`\n${CONSOLE_VARS.named}\n`),x.name);
assert.ok(!renders.console.stdout.includes('sets no MOSAIC_'),x.name);
assert.equal(renders.bus.stdout.match(/^Environment(File)?=.*$/gm).filter((l)=>l.includes('MOSAIC_CONFIG')||l.startsWith('EnvironmentFile')).length,2,x.name);
}
// Sage's N3 ruling: the default Console keeps the template's comment byte for byte.
const dflt=shOut(e.env,'webui-service.sh','render','--copy',B,'--copies',COPIES,'--business','acme');
assert.equal(dflt.status,0,dflt.stderr);
assert.ok(dflt.stdout.includes(`\n${CONSOLE_VARS.default}\n`),dflt.stdout);
assert.ok(!dflt.stdout.includes('MOSAIC_CONFIG'),dflt.stdout);
// Filbert round 2: under --sso the Console reads console.sso from the config MOSAIC_CONFIG names (row 192),
// so a named --sso render carries both.
const sso=shOut(a.env,'webui-service.sh','render','--instance',a.name,'--copy',B,'--copies',COPIES,'--port',a.port,'--business','acme','--sso');
assert.equal(sso.status,0,sso.stderr);
assert.match(sso.stdout,/^ExecStart=.* --sso(?: |$)/m,sso.stdout);
assert.ok(sso.stdout.includes(`\nEnvironment=MOSAIC_CONFIG=${a.cfg}\n`),sso.stdout);
await seed(a);await seed(b);
for(const x of [a,b]){
const r=shOut(x.env,'discord-service.sh','install','--instance',x.name,'--dir',e.units,'--no-reload');assert.equal(r.status,0,r.stderr);
for(const u of [x.tmpl,x.con,`mosaic-discord-${x.name}@.service`])assert.ok(existsSync(join(e.units,u)),u);
}
assert.deepEqual(readdirSync(e.units).sort(),[a.tmpl,a.con,`[email protected]`,b.tmpl,b.con,`[email protected]`].sort());
for(const x of [a,b])assert.equal(readFileSync(join(e.bin,x.wrapper),'utf8').split('\n')[1],`# mosaic-cli wrapper, instance ${x.name} (row 190)`);
});
await t.test('T-I4 a Console port another installed Console takes refuses port-claimed, and a named instance keeps off the default ports',async(t)=>{
const e=await mk(t),a=await named(e,'alpha'),b=await named(e,'beta');await seed(a);
const held=`port ${a.port} is taken by ${join(e.units,a.con)}`,free=await freePort();
const install=(x,...flags)=>shOut(x.env,'webui-service.sh','install',...x.flags,'--copy',B,'--copies',COPIES,...flags,'--business','acme');
const unitsBefore=stamp(e.units),binBefore=stamp(e.bin);
for(const [label,x,flags,code,msg] of [
['beta on alpha\'s port',b,['--port',a.port],1,`webui-service: refusing port-claimed: ${held}; nothing was changed\n`],
['beta\'s tester port on alpha\'s port',b,['--port',free,'--tester-port',a.port],1,`webui-service: refusing port-claimed: ${held}; nothing was changed\n`],
['the default on alpha\'s port',e,['--port',a.port],1,`webui-service: refusing port-claimed: ${held}; nothing was changed\n`],
['beta with no --port',b,[],4,'webui-service: stack instance beta needs --port; it has no default port\n'],
...['7330','7332','7333'].map((p)=>[`beta on ${p}`,b,['--port',p],4,`webui-service: port-reserved: ${p} is reserved for the default instance (7330 7332 7333)\n`]),
['beta\'s tester port on 7333',b,['--port',free,'--tester-port','7333'],4,'webui-service: port-reserved: 7333 is reserved for the default instance (7330 7332 7333)\n'],
]){
const r=install(x,...flags);assert.equal(r.status,code,`${label}: ${r.stderr}`);assert.equal(r.stderr,msg,label);
assert.deepEqual(stamp(e.units),unitsBefore,label);assert.deepEqual(stamp(e.bin),binBefore,label);
}
// beta on its own port passes: the seed installs both its units and adopts.
await seed(b);assert.ok(existsSync(join(e.units,b.con)));
// stack-release: a target Console port another Console takes refuses before anything stops.
const refused=async(label,...args)=>{
const before=fp(b),n=calls(e).length,logs=logLines(b).length;
const r=await run(b,'activate',C1,'acme',...args);assert.equal(r.code,3,`${label}: ${r.err}`);
assert.equal(r.err,`stack-release: refusing port-claimed: ${held}\n`,label);
assert.deepEqual(fp(b),before,label);assert.deepEqual(startsStops(e,n),[],label);
assert.equal(logLines(b).length,logs+1,label);assert.deepEqual([logLines(b).at(-1).result,logLines(b).at(-1).code],['refused','port-claimed'],label);
};
await refused('--tester-port','--tester-port',a.port);
const p=pointer(b);p.console[1]=a.port;writeFileSync(b.pointer,JSON.stringify(p)+'\n');
await refused('the pointer\'s --port');
const plan=await run(b,'activate',C1,'acme','--plan');assert.equal(plan.code,3,plan.err);
assert.ok(plan.out.split('\n').includes(`refuses port-claimed: ${held}`),plan.out);
});
// T-I6: activate and rollback on x leave y's every file, directory and the shared prefix drop-in
// directories as they were, and open nothing of y's config dir or data root.
const noCrossReach=async(t,xn,yn)=>{
const e=await mk(t),x=await named(e,xn),y=await named(e,yn);
for(const d of [...SHARED_DROPINS,`${x.tmpl}.d`,`${x.con}.d`,`${y.tmpl}.d`,`${y.con}.d`]){mkdirSync(join(e.units,d));writeFileSync(join(e.units,d,'t-i6.conf'),`# row 190 T-I6: ${d}\n`)}
await seed(x);await seed(y);
const mine=()=>stamp(dirname(y.cfg),y.data,...[y.tmpl,y.con,`${y.tmpl}.d`,`${y.con}.d`,...SHARED_DROPINS].map((u)=>join(e.units,u)),join(e.bin,y.wrapper),join(e.bin,'mosaic'));
const before=mine(),n=calls(e).length;
const watched=[...dirsUnder(dirname(y.cfg)),...dirsUnder(y.data)];
for(const d of [dirname(y.cfg),y.data,join(y.data,'bus'),y.state])assert.ok(watched.includes(d),d);
for(const d of watched)await watchDir(e,d);
const act=await run(x,'activate',C1,'acme');assert.equal(act.code,0,act.out+act.err);
const back=await run(x,'rollback','acme');assert.equal(back.code,0,back.out+back.err);
assert.deepEqual((await Promise.all(e.watches.map((s)=>s()))).filter(([,c])=>c!==0),[],`${xn}'s runs opened ${yn}'s files`);
assert.deepEqual(mine(),before);
assert.deepEqual(calls(e).slice(n).filter((c)=>c.includes(yn)),[]);
const sets=readdirSync(join(x.state,'stack-rollback')).sort();assert.equal(sets.length,2,sets.join(' '));
for(const s of sets){
const rs=join(x.state,'stack-rollback',s);
assert.deepEqual(readdirSync(rs).sort(),[x.tmpl,`${x.tmpl}.d`,x.con,`${x.con}.d`,'wrapper','WRAPPER_MODE',`show-${x.bus}.txt`,`show-${x.con}.txt`,'SHA256SUMS','steps.log'].sort(),s);
assert.equal(readFileSync(join(rs,'wrapper'),'utf8').split('\n')[1],`# mosaic-cli wrapper, instance ${xn} (row 190)`);
}
assert.equal(pointer(x).copy,B);assert.equal(pointer(y).copy,B);
};
await t.test('T-I6 alpha\'s activate and rollback reach nothing of beta\'s',(t)=>noCrossReach(t,'alpha','beta'));
await t.test('T-I6 swapped: beta\'s reach nothing of alpha\'s',(t)=>noCrossReach(t,'beta','alpha'));
await t.test('T-I7 MOSAIC_CONFIG naming alpha\'s config with no flag refuses instance-root-mismatch and changes nothing',async(t)=>{
const e=await mk(t),a=await named(e,'alpha');await seed(a);
const x={...a,env:{...a.env,MOSAIC_CONFIG:a.cfg},flags:[]};
const before=fp(a),n=calls(e).length,logs=logLines(a).length,unitsBefore=stamp(e.units),binBefore=stamp(e.bin);
const r=await run(x,'activate',C1,'acme');assert.equal(r.code,3,r.err);assert.match(r.err,/instance-root-mismatch/);assert.equal(r.out,'');
const i=shOut(x.env,'bus-service.sh','install','--copy',C1,'--copies',COPIES,'--claude',e.claude);
assert.equal(i.status,3,i.stderr);assert.match(i.stderr,/instance-root-mismatch/);
assert.deepEqual(calls(e).slice(n),[]);assert.deepEqual(fp(a),before);assert.equal(logLines(a).length,logs);
assert.deepEqual(stamp(e.units),unitsBefore);assert.deepEqual(stamp(e.bin),binBefore);
});
await t.test('T-I9 another instance\'s bus active refuses nothing; one of this instance\'s does',async(t)=>{
const e=await mk(t);await seed(e);
const a=await named(e,'alpha'),b=await named(e,'beta');await seed(a);await seed(b);
for(const x of [e,b]){const r=await run(x,'activate',C1,'acme');assert.equal(r.code,0,`${x.name??'default'}: ${r.out+r.err}`)}
const setUnit=(u,state)=>{const s=units(e);if(state)s[u]={...(s[u]||{pid:null,wd:''}),state};else delete s[u];writeFileSync(join(e.sd,'units.json'),JSON.stringify(s))};
for(const [u,state,msg] of [['[email protected]','active','[email protected] is active; the copy and wrapper switch reach it too'],[a.bus,'activating',`${a.bus} is activating`]]){
const was=units(e)[u];setUnit(u,state);
const before=fp(a),n=calls(e).length;
const r=await run(a,'activate',C1,'acme');assert.equal(r.code,3,r.err);
assert.equal(r.err,`stack-release: refusing instance-busy: ${msg}\n`);assert.deepEqual(fp(a),before);assert.deepEqual(startsStops(e,n),[]);
setUnit(u,was&&was.state);if(was){const s=units(e);s[u]=was;writeFileSync(join(e.sd,'units.json'),JSON.stringify(s))}
}
const r=await run(a,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);
});
});
+28 -4
View File
@@ -4,8 +4,9 @@
// every session path are constructor arguments, with no default. At
// construction and again at bind, their real paths must lie inside the
// explicit fixture root and outside every protected location: the
// repository's .pi/state/, ~/.pi, ~/.claude, the configured data root and any
// path a seat registration names. A path is refused when it is inside a
// repository's .pi/state/, ~/.pi, ~/.claude, the configured data root, every
// stack instance's config dir and data roots (row 190) and any path a seat
// registration names. A path is refused when it is inside a
// protected location or contains one. The real protections always apply;
// options only add to them, so a test can't switch them off.
//
@@ -44,9 +45,9 @@ export function realPath(p) {
const inside = (child, parent) => child === parent || child.startsWith(parent.endsWith(sep) ? parent : parent + sep);
const overlaps = (a, b) => inside(a, b) || inside(b, a);
function configuredDataRoot(home) {
function configuredDataRoot(home, name = "dev") {
try {
const cfg = JSON.parse(readFileSync(join(home, ".config", "mosaic-dev", "config.json"), "utf8"));
const cfg = JSON.parse(readFileSync(join(home, ".config", `mosaic-${name}`, "config.json"), "utf8"));
if (typeof cfg?.dataRoot === "string" && cfg.dataRoot) return cfg.dataRoot.replace(/^~(?=$|\/)/, home);
} catch {
// An unreadable config adds no location; the default data root still applies.
@@ -54,6 +55,21 @@ function configuredDataRoot(home) {
return null;
}
// Row 190: the stack instances in `home`, one per ~/.config/mosaic-NAME whose
// NAME passes the instance name rule (scripts/mosaic-config.mjs), so
// ~/.config/mosaic.bak adds nothing. Only the names are read here.
const INSTANCE_NAME = /^[a-z][a-z0-9]{1,15}$/;
function instanceNames(home) {
try {
return readdirSync(join(home, ".config"))
.filter((entry) => entry.startsWith("mosaic-") && INSTANCE_NAME.test(entry.slice("mosaic-".length)))
.map((entry) => entry.slice("mosaic-".length))
.sort();
} catch {
return [];
}
}
// Registrations under <dataRoot>/seats/<layout>/<seat>/registration.json. An
// unreadable one adds nothing; the data root itself is protected anyway.
function registrationsUnder(dataRoot) {
@@ -111,6 +127,14 @@ export class LiveSessionGuard {
protectedPaths.push({ path: join(h, ".mosaic-dev"), why: "the default data root" });
const configured = configuredDataRoot(h);
if (configured) protectedPaths.push({ path: configured, why: "the configured data root" });
// Each instance's config dir and ~/.mosaic-NAME whatever its config
// says, so a broken config can't drop them, then its config's dataRoot.
for (const name of instanceNames(h)) {
protectedPaths.push({ path: join(h, ".config", `mosaic-${name}`), why: `stack instance ${name}'s config dir` });
protectedPaths.push({ path: join(h, `.mosaic-${name}`), why: `stack instance ${name}'s default data root` });
const root = configuredDataRoot(h, name);
if (root) protectedPaths.push({ path: root, why: `stack instance ${name}'s configured data root` });
}
}
for (const d of dataRoots) protectedPaths.push({ path: d, why: "the data root" });
const roots = protectedPaths.filter((p) => p.why.includes("data root")).map((p) => p.path);
+6
View File
@@ -133,6 +133,12 @@ the copy, and retains the unit's restart, brake and stop behavior. Without
pinning. Status reads effective WorkingDirectory, including drop-ins, and
prints the tree and copy id or checkout path.
`--instance NAME` (row 190, any verb) renders `[email protected]`,
which sets `Environment=MOSAIC_CONFIG=` to NAME's config, and status reads
that config. Uninstall's active check covers only `mosaic-discord-NAME@*`.
Without the flag the unit is as before. See
[TOOLS.md, Stack instances](../../docs/TOOLS.md#stack-instances---instance-name-row-190).
Every live step requires Mos's go after the SetSpark freeze lifts. Re-pin:
make the new pushed copy with stack-copy.sh; run
`node <copy>/packages/discord/src/cli.mjs check <binding> --repo <copy> --offline`;
@@ -14,6 +14,7 @@ StartLimitBurst=5
Type=simple
WorkingDirectory=@REPO@
Environment=PATH=@PATH@
@CONFIG_ENV@
# A supervised run first clears a lock left by a crash or a reboot, and
# refuses with exit 3 while STOP is present or the binding is held. Exit 3
# is never retried: the brake belongs to the operator. This happens inside
+2 -1
View File
@@ -14,7 +14,8 @@ test('pinned Discord render/install and missing prerequisites; checkout bytes un
for(const line of r.stdout.split('\n').filter(l=>/^(ExecStart|WorkingDirectory)=/.test(l)))assert.ok(!line.includes(ROOT),line);
const legacy=s.run(['render']);assert.equal(legacy.status,0,legacy.stderr);
const nodeDir=dirname(process.execPath),originalPath='/usr/local/bin:/usr/bin:/bin',path=originalPath.split(':').includes(nodeDir)?originalPath:nodeDir+':'+originalPath;
const expected=readFileSync(join(ROOT,'packages/discord/systemd/[email protected]'),'utf8').replaceAll('@REPO@',ROOT).replaceAll('@PATH@',path);assert.equal(legacy.stdout,expected);
// Row 190: the default instance deletes the @CONFIG_ENV@ line.
const expected=readFileSync(join(ROOT,'packages/discord/systemd/[email protected]'),'utf8').replace('@CONFIG_ENV@\n','').replaceAll('@REPO@',ROOT).replaceAll('@PATH@',path);assert.equal(legacy.stdout,expected);
assert.equal(r.stdout.split('\n').filter(l=>!/^(ExecStart|WorkingDirectory)=/.test(l)).join('\n'),legacy.stdout.split('\n').filter(l=>!/^(ExecStart|WorkingDirectory)=/.test(l)).join('\n'));
const installed=s.run(['install',...args,'--dir',s.units,'--no-reload']);assert.equal(installed.status,0,installed.stderr);assert.equal(readFileSync(join(s.units,'[email protected]'),'utf8'),r.stdout);
for(const id of ['','short','b'.repeat(40)])assert.equal(s.run(['install','--copy',id,'--copies',s.copies,'--dir',join(s.root,'missing'),'--no-reload']).status,1);
+49
View File
@@ -122,6 +122,16 @@ allow-user must be the 64-character Authentik uid (decision 101). Status
names the copy and refactor drift. Files and directories are read-only;
copies are retained. Existing Console-only copies are not migrated or pruned.
With `--instance NAME` (row 190) every verb works on
`mosaic-webui-NAME.service`, which sets NAME's `MOSAIC_CONFIG`; install
checks NAME's `mosaic-bus-NAME@` units for the copy. render and install then
need `--port`, and 7330, 7332 or 7333 as `--port` or `--tester-port` exits 4
with port-reserved. For every instance, install refuses a `--port` or
`--tester-port` that another installed Console unit in the unit directory
already takes. That refusal is port-claimed, exit 1, and writes nothing. A
unit with no `--port` holds 7332. See
[TOOLS.md, Stack instances](../../docs/TOOLS.md#stack-instances---instance-name-row-190).
The single update and rollback sequence is
[TOOLS.md, Pinned stack update](../../docs/TOOLS.md#pinned-stack-update).
A bus restart ends managed sessions, so no PM turn may be in flight.
@@ -129,6 +139,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
+3 -2
View File
@@ -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');
}
+27 -8
View File
@@ -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;
+14 -3
View File
@@ -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);
})();
+6
View File
@@ -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;
}
+1 -1
View File
@@ -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>
+56 -26
View File
@@ -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' });
+75
View File
@@ -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;
}
+347
View File
@@ -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(); },
};
}
@@ -23,6 +23,7 @@ StartLimitBurst=5
Type=simple
WorkingDirectory=@REPO@
Environment=PATH=@PATH@
@CONFIG_ENV@
# The server binds 127.0.0.1 only. Usage, config and listen refusals
# (a port already in use included) exit 2 and are not retried; fix the
# flags or free the port, then start it again.
+155
View File
@@ -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(); }),
};
}
+4 -2
View File
@@ -146,8 +146,10 @@ test('copy refuses a commit off refactor, an unknown one, a worktree and a bad c
test('copy writes nothing when an archived path is missing at the commit', async (t) => {
const { dir, repo, script } = fixtureRepo(t);
const copies = join(dir, 'copies');
git(repo, 'rm', '-q', 'scripts/mosaic-config.mjs');
git(repo, 'commit', '-q', '-m', 'drop the config check');
// Row 190: the entry point runs scripts/mosaic-config.mjs to select the
// instance before it copies, so the missing path is one only the copy reads.
git(repo, 'rm', '-q', 'packages/webui/README.md');
git(repo, 'commit', '-q', '-m', 'drop the Console README');
git(repo,'update-ref','refs/remotes/origin/refactor',git(repo,'rev-parse','HEAD'));
const r = await run(dir, script, ['copy', 'refactor', '--copies', copies]);
assert.equal(r.status, 1);
+4 -3
View File
@@ -30,16 +30,17 @@ 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
// placeholders filled in, nothing else. Row 83 adds --send and must leave
// every unit rendered without it byte for byte as this.
// every unit rendered without it byte for byte as this. Row 190: the
// default instance deletes the @CONFIG_ENV@ line, so the unit is unchanged.
function filled(flags, root = REPO) {
const base = '/usr/local/bin:/usr/bin:/bin';
const path = `:${base}:`.includes(`:${NODE_DIR}:`) ? base : `${NODE_DIR}:${base}`;
return TEMPLATE.replaceAll('@REPO@', root).replaceAll('@PATH@', path).replaceAll('@NODE@', process.execPath).replaceAll('@FLAGS@', flags);
return TEMPLATE.replace('@CONFIG_ENV@\n', '').replaceAll('@REPO@', root).replaceAll('@PATH@', path).replaceAll('@NODE@', process.execPath).replaceAll('@FLAGS@', flags);
}
// A loopback port with nothing listening, so status's /healthz request
+132
View File
@@ -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(); }
});
+589
View File
@@ -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&#38;return=%2Fboard/);
assert.match(list.body, /provider=b&#38;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);
}
});
+3 -1
View File
@@ -3,9 +3,11 @@
#
# Creates ~/.config/mosaic-dev/config.json (or $MOSAIC_CONFIG) only when
# absent. An existing configuration is validated, never modified.
# `--instance NAME` (row 190) creates ~/.config/mosaic-NAME/config.json
# instead, with the data root ~/.mosaic-NAME claimed by its instance.json.
# Normal run paths (build/hello/verify) deliberately do NOT auto-bootstrap:
# missing configuration is an error there, not something to invent.
set -euo pipefail
cd "$(dirname "$0")/.."
exec node scripts/mosaic-config.mjs bootstrap
exec node scripts/mosaic-config.mjs bootstrap "$@"
+31 -14
View File
@@ -19,12 +19,24 @@
# (MOSAIC_CLAUDE_BIN); without it, `command -v claude` here. The path is
# kept as given, so a symlink that tracks updates keeps tracking them.
#
# Exit codes: 0 ok, 1 operation failed, 4 usage.
# --instance NAME (row 190, any position) selects stack instance NAME: the
# unit is [email protected], it sets MOSAIC_CONFIG to
# ~/.config/mosaic-NAME/config.json, and its session env file sits in that
# config directory. Busy and drop-in checks then cover only NAME's bus units
# and the prefix directories systemd applies to them. See scripts/instance.sh.
#
# Exit codes: 0 ok, 1 operation failed, 3 the data root is claimed by another
# instance or not claimed (scripts/instance.sh), 4 usage.
set -euo pipefail
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TEMPLATE="$REPO/packages/cli/systemd/mosaic-bus.service.in"
UNIT_NAME="[email protected]"
USAGE="usage: scripts/bus-service.sh render [--copy ID [--copies DIR]] [--pm] [--claude PATH] | install [--copy ID [--copies DIR]] [--pm] [--dir DIR] [--no-reload] [--claude PATH] | uninstall [--dir DIR] [--no-reload] | status <business>"
# shellcheck source=scripts/instance.sh
. "$REPO/scripts/instance.sh"
instance_args "$@"
set -- ${INSTANCE_ARGS[@]+"${INSTANCE_ARGS[@]}"}
instance_select
UNIT_NAME="$BUS_TEMPLATE"
USAGE="usage: scripts/bus-service.sh [--instance NAME] render [--copy ID [--copies DIR]] [--pm] [--claude PATH] | install [--copy ID [--copies DIR]] [--pm] [--dir DIR] [--no-reload] [--claude PATH] | uninstall [--dir DIR] [--no-reload] | status <business>"
die() { echo "bus-service: $*" >&2; exit 1; }
usage() { echo "$USAGE" >&2; exit 4; }
@@ -76,7 +88,7 @@ render() {
case "$REPO" in *@*|*'|'*) die "repository path contains a character the template cannot carry: $REPO" ;; esac
local claude
claude="$(claude_line)" || exit 1
sed -e "s|@REPO@|$root|g" -e "s|@PATH@|$path|g" -e "s|@PM_FLAGS@|$pm_flags|g" -e "s|^@CLAUDE_ENV@$|$claude|" "$TEMPLATE"
sed -e "s|@REPO@|$root|g" -e "s|@PATH@|$path|g" -e "s|@PM_FLAGS@|$pm_flags|g" -e "s|^@CLAUDE_ENV@$|$claude|" "${INSTANCE_SED[@]}" "$TEMPLATE"
}
UNIT_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
@@ -99,9 +111,10 @@ parse_flags() {
# Any failed read refuses before a write, including --no-reload installations.
install_guard() {
local out unit file
out="$(systemctl --user list-units --all --no-legend --plain 'mosaic-bus@*.service' 2>/dev/null)" || die "refusing: systemctl --user list-units failed, so the bus state is unknown; nothing was changed"
out="$(systemctl --user list-units --all --no-legend --plain "$BUS_GLOB" 2>/dev/null)" || die "refusing: systemctl --user list-units failed, so the bus state is unknown; nothing was changed"
while read -r unit load state rest; do
[[ "$unit" == mosaic-bus@*.service ]] || continue
# shellcheck disable=SC2053
[[ "$unit" == $BUS_GLOB ]] || continue
case "$state" in inactive|failed) ;; *) die "refusing: $unit is running ($state); Jason or Mos stops it first, then install, then start it" ;; esac
done <<< "$out"
# systemctl show rejects a template name. The lookup-path scan also
@@ -114,8 +127,12 @@ install_guard() {
case "$directory" in /*) ;; *) die "refusing: invalid systemd unit path $directory; nothing was changed" ;; esac
[ -e "$directory" ] || [ -L "$directory" ] || continue
[ -d "$directory" ] && [ -r "$directory" ] && [ -x "$directory" ] || die "refusing: cannot inspect unit path $directory; nothing was changed"
# * includes the empty instance, i.e. [email protected].
for dropdir in "$directory"/mosaic-bus@*.service.d "$directory"/mosaic-.service.d "$directory"/service.d; do
# * includes the empty instance, i.e. [email protected] (or
# mosaic-bus-NAME@.service.d). The globs come from scripts/instance.sh.
local glob dropdirs=()
# shellcheck disable=SC2206
for glob in "${BUS_DROPIN_GLOBS[@]}"; do dropdirs+=("$directory"/$glob); done
for dropdir in "${dropdirs[@]}"; do
[ -e "$dropdir" ] || [ -L "$dropdir" ] || continue
[ -d "$dropdir" ] && [ -r "$dropdir" ] && [ -x "$dropdir" ] || die "refusing: cannot inspect drop-ins $dropdir; nothing was changed"
for file in "$dropdir"/*.conf; do
@@ -161,10 +178,10 @@ next, for one business (one per data root):
mkdir -m 0700 -p <dataRoot>/notify/<business> the notifier refuses a looser directory
write <dataRoot>/notify/<business>/notify.json, mode 0600:
{"notifyVersion": 1, "binding": "<discord binding>"} or "binding": null for no DMs
systemctl --user enable --now mosaic-bus@<business> start now and at login
systemctl --user status mosaic-bus@<business>
journalctl --user -u mosaic-bus@<business> -f the host's log
systemctl --user stop mosaic-bus@<business> SIGTERM; restartable
systemctl --user enable --now ${BUS_TEMPLATE%.service}<business> start now and at login
systemctl --user status ${BUS_TEMPLATE%.service}<business>
journalctl --user -u ${BUS_TEMPLATE%.service}<business> -f the host's log
systemctl --user stop ${BUS_TEMPLATE%.service}<business> SIGTERM; restartable
survive logout and reboot only with lingering on: loginctl enable-linger ${USER:-$(id -un)}
MSG
}
@@ -174,7 +191,7 @@ uninstall_unit() {
[ "$CLAUDE_SET" = 0 ] && [ -z "$COPY_ID" ] && [ "$PM" = 0 ] || usage
local target="$UNIT_DIR/$UNIT_NAME"
local out active
out="$(systemctl --user list-units --all --no-legend --plain 'mosaic-bus@*.service' 2>/dev/null)" || die "refusing: systemctl read failed; nothing was changed"
out="$(systemctl --user list-units --all --no-legend --plain "$BUS_GLOB" 2>/dev/null)" || die "refusing: systemctl read failed; nothing was changed"
active="$(awk '$3 != "inactive" && $3 != "failed" {print $1}' <<< "$out")"
[ -z "$active" ] || die "refusing: instance(s) still running: $active; Jason or Mos stops them first"
if [ -f "$target" ]; then
@@ -193,7 +210,7 @@ status_unit() {
[ $# -eq 1 ] || usage
local business="$1"
case "$business" in ''|*[!a-z0-9-]*) die "business id must be [a-z0-9-]+" ;; esac
local unit="mosaic-bus@$business.service"
local unit="${BUS_TEMPLATE%.service}$business.service"
echo "unit: $unit"
echo " enabled: $(systemctl --user is-enabled "$unit" 2>&1 || true)"
echo " active: $(systemctl --user is-active "$unit" 2>&1 || true)"
+20 -10
View File
@@ -14,12 +14,21 @@
# remove the unit; refuses while an instance is active
# status <binding> unit state, STOP and run.lock for one binding
#
# Exit codes: 0 ok, 1 operation failed, 4 usage.
# Every verb takes --instance NAME (row 190, scripts/instance.sh): the unit is
# then [email protected], it sets NAME's MOSAIC_CONFIG, and status
# reads NAME's config. Without it, everything is as before.
#
# Exit codes: 0 ok, 1 operation failed, 3 instance claim refused, 4 usage.
set -euo pipefail
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TEMPLATE="$REPO/packages/discord/systemd/[email protected]"
UNIT_NAME="[email protected]"
USAGE="usage: scripts/discord-service.sh render [--copy ID [--copies DIR]] | install [--copy ID [--copies DIR]] [--dir DIR] [--no-reload] | uninstall [--dir DIR] [--no-reload] | status <binding>"
. "$REPO/scripts/instance.sh"
instance_args "$@"
set -- ${INSTANCE_ARGS[@]+"${INSTANCE_ARGS[@]}"}
instance_select
UNIT_NAME="$DISCORD_TEMPLATE"
UNIT_PREFIX="${DISCORD_TEMPLATE%.service}"
USAGE="usage: scripts/discord-service.sh [--instance NAME] render [--copy ID [--copies DIR]] | install [--copy ID [--copies DIR]] [--dir DIR] [--no-reload] | uninstall [--dir DIR] [--no-reload] | status <binding>"
die() { echo "discord-service: $*" >&2; exit 1; }
usage() { echo "$USAGE" >&2; exit 4; }
@@ -48,7 +57,7 @@ render() {
case ":$path:" in *":$node_dir:"*) ;; *) path="$node_dir:$path" ;; esac
case "$REPO" in *@*|*'|'*) die "repository path contains a character the template cannot carry: $REPO" ;; esac
local unit
unit="$(sed -e "s|@REPO@|$REPO|g" -e "s|@PATH@|$path|g" "$TEMPLATE")" || return 1
unit="$(sed -e "s|@REPO@|$REPO|g" -e "s|@PATH@|$path|g" "${INSTANCE_SED[@]}" "$TEMPLATE")" || return 1
if [ -n "$root" ]; then
local node
node="$(command -v node)"
@@ -101,10 +110,10 @@ install_unit() {
fi
cat <<MSG
next, for one binding:
systemctl --user enable --now mosaic-discord@<binding> start now and at login
systemctl --user status mosaic-discord@<binding>
journalctl --user -u mosaic-discord@<binding> -f the connector's log
systemctl --user stop mosaic-discord@<binding> SIGTERM; restartable
systemctl --user enable --now $UNIT_PREFIX<binding> start now and at login
systemctl --user status $UNIT_PREFIX<binding>
journalctl --user -u $UNIT_PREFIX<binding> -f the connector's log
systemctl --user stop $UNIT_PREFIX<binding> SIGTERM; restartable
scripts/discord.sh stop <binding> the brake: writes STOP, the unit stays down until STOP is removed
survive logout and reboot only with lingering on: loginctl enable-linger ${USER:-$(id -un)}
MSG
@@ -115,7 +124,7 @@ uninstall_unit() {
local target="$UNIT_DIR/$UNIT_NAME"
if [ "$RELOAD" = 1 ]; then
local active
active="$(systemctl --user list-units --no-legend --plain 'mosaic-discord@*' 2>/dev/null | awk '$3 == "active" || $3 == "activating" || $3 == "deactivating" {print $1}')"
active="$(systemctl --user list-units --no-legend --plain "$UNIT_PREFIX*" 2>/dev/null | awk '$3 == "active" || $3 == "activating" || $3 == "deactivating" {print $1}')"
[ -z "$active" ] || die "refusing: instance(s) still running: $(echo "$active" | tr '\n' ' ')stop them first"
fi
if [ -f "$target" ]; then
@@ -134,7 +143,7 @@ status_unit() {
[ $# -eq 1 ] || usage
local binding="$1"
case "$binding" in ''|*[!A-Za-z0-9_-]*) die "binding name must be [A-Za-z0-9_-]+" ;; esac
local unit="mosaic-discord@$binding.service"
local unit="$UNIT_PREFIX$binding.service"
echo "unit: $unit"
echo " enabled: $(systemctl --user is-enabled "$unit" 2>&1 || true)"
echo " active: $(systemctl --user is-active "$unit" 2>&1 || true)"
@@ -151,6 +160,7 @@ status_unit() {
echo " checkout: $working"
fi
local config="${XDG_CONFIG_HOME:-$HOME/.config}/mosaic-dev/config.json"
[ -z "$MOSAIC_UNIT_SUFFIX" ] || config="$MOSAIC_INSTANCE_CONFIG"
local data_root
data_root="$(node -e 'const c=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")); process.stdout.write(String(c.dataRoot||""))' "$config" 2>/dev/null || true)"
if [ -z "$data_root" ]; then
+142
View File
@@ -0,0 +1,142 @@
# Sourced, not run: stack instance selection for the shell entry points (row 190).
#
# instance_args "$@" take `--instance NAME` out of the arguments. Sets
# INSTANCE_FLAG (NAME, or empty) and INSTANCE_ARGS (the
# rest, in order). A second --instance or a missing
# NAME exits 4.
# instance_select run `mosaic-config.mjs instance [--instance NAME]`,
# which maps NAME to ~/.config/mosaic-NAME/config.json
# and checks the data root's instance.json claim, then
# set the names below. A refusal exits with the
# loader's code (3 claim or missing config, 4 name or
# mismatch) before the caller reads or writes anything.
#
# With no flag, every name is today's and MOSAIC_CONFIG is left as the caller
# had it. With --instance NAME (other than dev), MOSAIC_CONFIG is exported as
# NAME's config, so everything the entry point runs reads that config.
#
# BUS_TEMPLATE [email protected] | [email protected]
# BUS_GLOB 'mosaic-bus@*.service' | 'mosaic-bus-NAME@*.service'
# BUS_DROPIN_GLOBS unit-path globs whose .conf files can override the bus
# ExecStart: mosaic-bus@*.service.d for the default;
# mosaic-bus-NAME@*.service.d and the shared prefix
# mosaic-bus-.service.d for NAME; then mosaic-.service.d
# and service.d for both
# CONSOLE_UNIT mosaic-webui.service | mosaic-webui-NAME.service
# DISCORD_TEMPLATE [email protected] | [email protected]
# WRAPPER_NAME mosaic | mosaic-NAME
# SESSION_ENV %h/.config/mosaic-dev/secrets/claude-session.env
# | <config dir>/secrets/claude-session.env
# INSTANCE_FLAGS () or (--instance NAME), for the entry points this one runs
# INSTANCE_RESERVED ports a named instance's Console never takes: 7330 (the
# operator's bare Console), 7332 (the tunnel) and 7333
# (the default's tester); empty for the default
# INSTANCE_SED sed arguments that fill @CONFIG_ENV@ and @SESSION_ENV@
# in a unit template: the default deletes the
# @CONFIG_ENV@ line, so its render is byte-identical to
# the template before row 190; NAME gets a comment and
# Environment=MOSAIC_CONFIG=<config>, " [NAME]" after
# the Description, and the Console comment's "sets no
# MOSAIC_*" says it sets MOSAIC_CONFIG (Sage, N3)
#
# Also set: MOSAIC_INSTANCE (dev or NAME), MOSAIC_INSTANCE_CONFIG,
# MOSAIC_INSTANCE_CONFIG_DIR, MOSAIC_INSTANCE_DATA_ROOT (empty when no flag
# was given and the config is missing or invalid; the caller's own load
# refuses that as before) and MOSAIC_UNIT_SUFFIX (empty or -NAME).
INSTANCE_FLAG=""
INSTANCE_ARGS=()
INSTANCE_FLAGS=()
INSTANCE_RESERVED=()
instance_args() {
local seen=0
INSTANCE_FLAG=""
INSTANCE_ARGS=()
while [ $# -gt 0 ]; do
if [ "$1" = "--instance" ]; then
if [ "$seen" = 1 ] || [ $# -lt 2 ]; then
echo "instance: usage: --instance NAME, given once" >&2
exit 4
fi
INSTANCE_FLAG="$2"
seen=1
shift 2
else
INSTANCE_ARGS+=("$1")
shift
fi
done
}
instance_select() {
local here out
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
if [ -n "$INSTANCE_FLAG" ]; then
out="$(node "$here/mosaic-config.mjs" instance --instance "$INSTANCE_FLAG")" || exit $?
else
out="$(node "$here/mosaic-config.mjs" instance)" || exit $?
fi
eval "$out"
local s="$MOSAIC_UNIT_SUFFIX"
BUS_TEMPLATE="mosaic-bus$s@.service"
BUS_GLOB="mosaic-bus$s@*.service"
CONSOLE_UNIT="mosaic-webui$s.service"
DISCORD_TEMPLATE="mosaic-discord$s@.service"
WRAPPER_NAME="mosaic$s"
if [ -z "$s" ]; then
BUS_DROPIN_GLOBS=('mosaic-bus@*.service.d' 'mosaic-.service.d' 'service.d')
SESSION_ENV='%h/.config/mosaic-dev/secrets/claude-session.env'
INSTANCE_SED=(-e '/^@CONFIG_ENV@$/d' -e "s|@SESSION_ENV@|$SESSION_ENV|g")
return 0
fi
# systemd expands % and splits Environment= on spaces, and the sed
# expressions use |, so a named instance's paths keep to a plain set.
case "$MOSAIC_INSTANCE_CONFIG" in
*[!A-Za-z0-9/._+-]*)
echo "instance: config path contains a character a unit cannot carry: $MOSAIC_INSTANCE_CONFIG" >&2
exit 4 ;;
esac
export MOSAIC_CONFIG="${MOSAIC_CONFIG:-$MOSAIC_INSTANCE_CONFIG}"
INSTANCE_FLAGS=(--instance "$MOSAIC_INSTANCE")
INSTANCE_RESERVED=(7330 7332 7333)
BUS_DROPIN_GLOBS=("mosaic-bus$s@*.service.d" 'mosaic-bus-.service.d' 'mosaic-.service.d' 'service.d')
SESSION_ENV="$MOSAIC_INSTANCE_CONFIG_DIR/secrets/claude-session.env"
INSTANCE_SED=(
-e "s|^@CONFIG_ENV@\$|# Stack instance $MOSAIC_INSTANCE (row 190): the system config its processes read.\\nEnvironment=MOSAIC_CONFIG=$MOSAIC_INSTANCE_CONFIG|"
-e "s|@SESSION_ENV@|$SESSION_ENV|g"
-e "s|^\\(Description=.*\\)\$|\\1 [$MOSAIC_INSTANCE]|"
-e 's|The unit sets no MOSAIC_\*, CLAUDE\*, CODEX\* or PI_\* variable\.$|The unit sets MOSAIC_CONFIG and no other MOSAIC_*, CLAUDE*, CODEX* or PI_* variable.|'
)
}
# instance_port_holder DIR PORT: the installed Console unit in DIR, other than
# this instance's, whose ExecStart takes PORT as --port or --tester-port.
# A unit with no --port holds 7332, serve's default. Prints the unit and
# returns 0, or prints nothing and returns 1. Unit files are host
# configuration, so reading another instance's is not reading its state.
instance_port_holder() {
local dir="$1" want="$2" file unit line tok next port
want="$((10#$want))"
for file in "$dir"/mosaic-webui.service "$dir"/mosaic-webui-*.service; do
[ -f "$file" ] || continue
unit="${file##*/}"
[ "$unit" != "$CONSOLE_UNIT" ] || continue
line="$(sed -n 's/^ExecStart=//p' "$file" | tail -n 1)"
port=7332 next=""
local -a ports=() toks=()
read -r -a toks <<< "$line"
for tok in ${toks[@]+"${toks[@]}"}; do
case "$next" in
--port) [[ "$tok" =~ ^[0-9]{1,5}$ ]] && port="$((10#$tok))" ;;
--tester-port) [[ "$tok" =~ ^[0-9]{1,5}$ ]] && ports+=("$((10#$tok))") ;;
esac
next="$tok"
done
ports+=("$port")
for port in "${ports[@]}"; do
if [ "$port" = "$want" ]; then echo "$unit"; return 0; fi
done
done
return 1
}
+316 -68
View File
@@ -8,13 +8,30 @@
* validate Load and strictly validate; print resolved config on stdout.
* env Print shell-safe exports for launcher scripts:
* MOSAIC_DATA_ROOT, MOSAIC_PROVIDER, MOSAIC_MODEL.
* instance Print shell-safe exports naming the selected stack instance
* (row 190): MOSAIC_INSTANCE, MOSAIC_INSTANCE_CONFIG,
* MOSAIC_INSTANCE_CONFIG_DIR, MOSAIC_INSTANCE_DATA_ROOT and
* MOSAIC_UNIT_SUFFIX. scripts/instance.sh reads them.
*
* Config location: $MOSAIC_CONFIG or ~/.config/mosaic-dev/config.json
* Each operation takes an optional `--instance NAME`. NAME matches
* ^[a-z][a-z0-9]{1,15}$ and selects ~/.config/mosaic-NAME/config.json;
* `dev` is the default instance, so `--instance dev` is the same as no flag.
* Without the flag the config is $MOSAIC_CONFIG or
* ~/.config/mosaic-dev/config.json, as before.
*
* A named instance's data root carries <dataRoot>/instance.json, written by
* `bootstrap --instance NAME`, naming the instance. With the flag every
* operation refuses a root whose marker is missing or names another
* instance; `instance` without the flag refuses a root that a named
* instance claims.
*
* Exit codes:
* 0 success
* 2 configuration exists but is invalid (never modified by this tool)
* 3 configuration is missing for a read operation (validate/env)
* 3 configuration is missing for a read operation (validate/env/instance),
* or the data root's instance marker refuses the selection
* 4 usage: a bad instance name (instance-name-invalid), or --instance and
* $MOSAIC_CONFIG naming different files (instance-config-mismatch)
*
* Invariants (docs/plans/2026-09-02_atomic-mosaic-foundation.md):
* - Existing configuration is never overwritten.
@@ -26,22 +43,119 @@ 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"]);
const SUPPORTED_BACKENDS = new Set(["docker"]);
const SUPPORTED_ADAPTERS = new Set(["pi", "mock"]); // mock: test-only, see adapters/README.md
const NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,199}$/;
// Row 190: stack instance names. No hyphen, so systemd's prefix drop-ins
// for mosaic-bus-NAME@ stay a fixed list and no name prefixes another's units.
const INSTANCE_PATTERN = /^[a-z][a-z0-9]{1,15}$/;
const DEFAULT_INSTANCE = "dev";
const MARKER = "instance.json";
function fail(exitCode, message) {
process.stderr.write(`mosaic-config: ${message}\n`);
process.exit(exitCode);
class Failure extends Error {
constructor(exitCode, message) {
super(message);
this.exitCode = exitCode;
}
}
function configPath() {
return process.env.MOSAIC_CONFIG
? path.resolve(process.env.MOSAIC_CONFIG)
: path.join(os.homedir(), ".config", "mosaic-dev", "config.json");
function fail(exitCode, message) {
throw new Failure(exitCode, message);
}
function instancePath(name) {
return path.join(os.homedir(), ".config", `mosaic-${name}`, "config.json");
}
function realOrNull(file) {
try {
return fs.realpathSync(file);
} catch {
return null;
}
}
// The config file for a selection. Without --instance, as before. With it,
// NAME's file; a $MOSAIC_CONFIG beside it must be the same file.
function configPath(instance) {
if (instance === null) {
return process.env.MOSAIC_CONFIG
? path.resolve(process.env.MOSAIC_CONFIG)
: instancePath(DEFAULT_INSTANCE);
}
const named = instancePath(instance);
if (process.env.MOSAIC_CONFIG) {
const given = path.resolve(process.env.MOSAIC_CONFIG);
const same = given === named || (realOrNull(given) !== null && realOrNull(given) === realOrNull(named));
if (!same) {
fail(4, `instance-config-mismatch: --instance ${instance} names ${named}, but MOSAIC_CONFIG names ${given}`);
}
}
return named;
}
// Operation arguments: nothing, or `--instance NAME` once.
function parseArgs(args) {
if (args.length === 0) return null;
if (args.length !== 2 || args[0] !== "--instance") {
fail(4, `usage: mosaic-config.mjs ${operation ?? "OP"} [--instance NAME]`);
}
const name = args[1];
if (!INSTANCE_PATTERN.test(name)) {
fail(4, `instance-name-invalid: ${JSON.stringify(name)} must match ${INSTANCE_PATTERN}`);
}
return name;
}
function isNamed(instance) {
return instance !== null && instance !== DEFAULT_INSTANCE;
}
// The marker in a data root: null when absent, else its name. Anything else
// at the path refuses.
function readMarker(dataRoot) {
const file = path.join(dataRoot, MARKER);
let stat;
try {
stat = fs.lstatSync(file);
} catch (error) {
if (error.code === "ENOENT") return null;
fail(3, `instance-root-unclaimed: cannot read ${file} (${error.code})`);
}
if (!stat.isFile() || stat.isSymbolicLink()) {
fail(3, `instance-root-unclaimed: ${file} must be a regular, non-symbolic-link file`);
}
let marker;
try {
marker = JSON.parse(fs.readFileSync(file, "utf8"));
} catch (error) {
fail(3, `instance-root-unclaimed: ${file} is not a valid marker (${error.message})`);
}
if (!isPlainObject(marker) || Object.keys(marker).sort().join(",") !== "instanceVersion,name"
|| marker.instanceVersion !== 1 || typeof marker.name !== "string" || !INSTANCE_PATTERN.test(marker.name)) {
fail(3, `instance-root-unclaimed: ${file} is not a valid marker ({"instanceVersion":1,"name":NAME})`);
}
return marker.name;
}
// The claim check (row 190 D2). A named instance needs its own marker; the
// default needs none, or one naming dev.
function checkClaim(dataRoot, instance) {
const owner = readMarker(dataRoot);
if (isNamed(instance)) {
if (owner === null) {
fail(3, `instance-root-unclaimed: ${dataRoot} holds no ${MARKER} naming ${instance} (scripts/bootstrap.sh --instance ${instance} claims a new root)`);
}
if (owner !== instance) {
fail(3, `instance-root-mismatch: ${dataRoot} is claimed by instance ${owner}, not ${instance}`);
}
} else if (owner !== null && owner !== DEFAULT_INSTANCE) {
fail(3, `instance-root-mismatch: ${dataRoot} is claimed by instance ${owner}; select it with --instance ${owner}`);
}
}
function readRaw(file) {
@@ -106,13 +220,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 +277,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,13 +291,15 @@ function validate(document, file) {
model: document.execution.model,
adapter,
},
...(consoleKey === undefined ? {} : { console: consoleKey }),
};
}
function load(file) {
async function load(file, instance = null) {
const raw = readRaw(file);
if (raw === null) {
fail(3, `configuration not found: ${file} (run scripts/bootstrap.sh to create it)`);
const bootstrap = isNamed(instance) ? `scripts/bootstrap.sh --instance ${instance}` : "scripts/bootstrap.sh";
fail(3, `configuration not found: ${file} (run ${bootstrap} to create it)`);
}
let document;
try {
@@ -170,74 +307,185 @@ 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) {
return `'${String(value).replaceAll("'", `'\\''`)}'`;
}
const DEFAULT_CONFIG = {
configVersion: 1,
environment: "development",
dataRoot: path.join(os.homedir(), ".mosaic-dev"),
execution: {
backend: "docker",
provider: "zai",
model: "glm-5.3-flash",
},
};
function defaultConfig(dataRoot) {
return {
configVersion: 1,
environment: "development",
dataRoot,
execution: {
backend: "docker",
provider: "zai",
model: "glm-5.3-flash",
},
};
}
const DEFAULT_CONFIG = defaultConfig(path.join(os.homedir(), ".mosaic-dev"));
// 'wx': creation is exclusive; an existing file is never overwritten, and a
// symbolic link at the path is never followed.
function createExclusive(file, text, label = file) {
let fd;
try {
fd = fs.openSync(file, "wx", 0o644);
fs.writeFileSync(fd, text);
return true;
} catch (error) {
if (error.code === "EEXIST") return false;
fail(1, `unable to create ${label}: ${error.message}`);
} finally {
if (fd !== undefined) fs.closeSync(fd);
}
}
async function bootstrapDefault(file) {
if (fs.existsSync(file)) {
await load(file); // validate only; never rewrite
process.stderr.write(`mosaic-config: configuration already present, validated without changes: ${file}\n`);
return;
}
fs.mkdirSync(path.dirname(file), { recursive: true });
if (!createExclusive(file, `${JSON.stringify(DEFAULT_CONFIG, null, 2)}\n`, "configuration")) {
await load(file);
return;
}
await load(file);
process.stderr.write(`mosaic-config: created default configuration: ${file}\n`);
}
// Row 190 D2: NAME's config dir (0700), its config, then the claimed data
// root. The root is checked before anything is written, so a refusal
// writes nothing.
async function bootstrapNamed(file, instance) {
if (fs.existsSync(file)) {
const resolved = await load(file, instance); // validate only; never rewrite
checkClaim(resolved.dataRoot, instance);
process.stderr.write(`mosaic-config: configuration already present, validated without changes: ${file}\n`);
return;
}
const dataRoot = path.join(os.homedir(), `.mosaic-${instance}`);
let entries = null;
try {
const stat = fs.lstatSync(dataRoot);
if (!stat.isDirectory()) fail(3, `instance-root-unclaimed: ${dataRoot} exists and is not a directory; nothing was written`);
entries = fs.readdirSync(dataRoot);
} catch (error) {
if (error instanceof Failure) throw error;
if (error.code !== "ENOENT") fail(3, `instance-root-unclaimed: cannot read ${dataRoot} (${error.code}); nothing was written`);
}
if (entries !== null && entries.length > 0 && readMarker(dataRoot) !== instance) {
fail(3, `instance-root-unclaimed: ${dataRoot} exists, is not empty and holds no ${MARKER} naming ${instance}; nothing was written`);
}
const configDir = path.dirname(file);
fs.mkdirSync(path.dirname(configDir), { recursive: true });
try {
fs.mkdirSync(configDir, { mode: 0o700 });
} catch (error) {
if (error.code !== "EEXIST") fail(1, `unable to create ${configDir}: ${error.message}`);
}
if (!createExclusive(file, `${JSON.stringify(defaultConfig(dataRoot), null, 2)}\n`)) {
const resolved = await load(file, instance);
checkClaim(resolved.dataRoot, instance);
return;
}
const resolved = await load(file, instance);
try {
fs.mkdirSync(resolved.dataRoot, { mode: 0o700 });
} catch (error) {
if (error.code !== "EEXIST") fail(1, `unable to create ${resolved.dataRoot}: ${error.message}`);
}
createExclusive(path.join(resolved.dataRoot, MARKER), `${JSON.stringify({ instanceVersion: 1, name: instance })}\n`);
checkClaim(resolved.dataRoot, instance);
process.stderr.write(`mosaic-config: created configuration for instance ${instance}: ${file}, data root ${resolved.dataRoot}\n`);
}
// Without --instance, a missing or invalid config is left to the caller's
// own load, as before; only a loaded config's root is checked.
async function selected(instance) {
const file = configPath(instance);
let dataRoot = "";
if (isNamed(instance)) {
dataRoot = (await load(file, instance)).dataRoot;
checkClaim(dataRoot, instance);
} else {
let resolved = null;
try {
resolved = readRaw(file) === null ? null : await load(file);
} catch (error) {
if (!(error instanceof Failure)) throw error;
}
if (resolved !== null) {
dataRoot = resolved.dataRoot;
checkClaim(dataRoot, instance);
}
}
const name = isNamed(instance) ? instance : DEFAULT_INSTANCE;
return [
`MOSAIC_INSTANCE=${shellQuote(name)}`,
`MOSAIC_INSTANCE_CONFIG=${shellQuote(path.resolve(file))}`,
`MOSAIC_INSTANCE_CONFIG_DIR=${shellQuote(path.dirname(path.resolve(file)))}`,
`MOSAIC_INSTANCE_DATA_ROOT=${shellQuote(dataRoot)}`,
`MOSAIC_UNIT_SUFFIX=${shellQuote(isNamed(instance) ? `-${instance}` : "")}`,
"",
].join("\n");
}
async function loadSelected(instance) {
const file = configPath(instance);
const resolved = await load(file, instance);
if (isNamed(instance)) checkClaim(resolved.dataRoot, instance);
return resolved;
}
const operation = process.argv[2];
const file = configPath();
switch (operation) {
case "bootstrap": {
if (fs.existsSync(file)) {
load(file); // validate only; never rewrite
process.stderr.write(`mosaic-config: configuration already present, validated without changes: ${file}\n`);
try {
if (!["bootstrap", "validate", "env", "instance"].includes(operation)) {
fail(1, `unknown operation: ${JSON.stringify(operation ?? "")} (expected bootstrap | validate | env | instance)`);
}
const instance = parseArgs(process.argv.slice(3));
switch (operation) {
case "bootstrap": {
const file = configPath(instance);
if (isNamed(instance)) await bootstrapNamed(file, instance);
else await bootstrapDefault(file);
process.exit(0);
}
fs.mkdirSync(path.dirname(file), { recursive: true });
let fd;
try {
// 'wx': creation is exclusive; an existing file is never overwritten.
fd = fs.openSync(file, "wx", 0o644);
fs.writeFileSync(fd, `${JSON.stringify(DEFAULT_CONFIG, null, 2)}\n`);
} catch (error) {
if (error.code === "EEXIST") {
load(file);
process.exit(0);
}
fail(1, `unable to create configuration: ${error.message}`);
} finally {
if (fd !== undefined) fs.closeSync(fd);
case "validate": {
const resolved = await loadSelected(instance);
process.stdout.write(`${JSON.stringify(resolved, null, 2)}\n`);
process.exit(0);
}
load(file);
process.stderr.write(`mosaic-config: created default configuration: ${file}\n`);
process.exit(0);
}
case "validate": {
const resolved = load(file);
process.stdout.write(`${JSON.stringify(resolved, null, 2)}\n`);
process.exit(0);
}
case "env": {
const resolved = await loadSelected(instance);
process.stdout.write(
[
`MOSAIC_DATA_ROOT=${shellQuote(resolved.dataRoot)}`,
`MOSAIC_PROVIDER=${shellQuote(resolved.execution.provider)}`,
`MOSAIC_MODEL=${shellQuote(resolved.execution.model)}`,
`MOSAIC_ADAPTER=${shellQuote(resolved.execution.adapter)}`,
"",
].join("\n"),
);
process.exit(0);
}
case "env": {
const resolved = load(file);
process.stdout.write(
[
`MOSAIC_DATA_ROOT=${shellQuote(resolved.dataRoot)}`,
`MOSAIC_PROVIDER=${shellQuote(resolved.execution.provider)}`,
`MOSAIC_MODEL=${shellQuote(resolved.execution.model)}`,
`MOSAIC_ADAPTER=${shellQuote(resolved.execution.adapter)}`,
"",
].join("\n"),
);
process.exit(0);
case "instance": {
process.stdout.write(await selected(instance));
process.exit(0);
}
}
default:
fail(1, `unknown operation: ${JSON.stringify(operation ?? "")} (expected bootstrap | validate | env)`);
} catch (error) {
if (!(error instanceof Failure)) throw error;
process.stderr.write(`mosaic-config: ${error.message}\n`);
process.exit(error.exitCode);
}
+36 -9
View File
@@ -1,20 +1,22 @@
#!/usr/bin/env bash
# One immutable pushed tree for the bus, Console and human CLI (#1579).
# copy creates files only; wrapper is a separately Mos-gated live step.
# wrapper and check take --instance NAME (row 190): the units mosaic-bus-NAME@ and mosaic-webui-NAME
# and the wrapper $BIN/mosaic-NAME, which sets NAME's MOSAIC_CONFIG. check needs --business for NAME.
set -euo pipefail
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
COPIES="${XDG_DATA_HOME:-$HOME/.local/share}/mosaic-stack/copies"
BIN="$HOME/.npm-global/bin"
BUSINESS=mosaic-stack
BUSINESS=mosaic-stack BUSINESS_SET=0
ALLOW_WORKTREE=0
MARKER='# mosaic-cli wrapper (Q81, agents/sage/work/q81/mosaic-cli.sh)'
usage() { echo 'usage: stack-copy.sh copy COMMIT [--copies DIR] [--allow-worktree] | wrapper ID [--copies DIR] [--bin DIR] | check [--copies DIR] [--bin DIR] [--business ID]' >&2; exit "${1:-4}"; }
usage() { echo 'usage: stack-copy.sh copy COMMIT [--copies DIR] [--allow-worktree] | wrapper ID [--instance NAME] [--copies DIR] [--bin DIR] | check [--instance NAME] [--copies DIR] [--bin DIR] [--business ID]' >&2; exit "${1:-4}"; }
die() { echo "stack-copy: $*" >&2; exit 1; }
plain_path() { case "$1" in /*) ;; *) die "$2 must be an absolute path: $1" ;; esac; case "$1" in *[!A-Za-z0-9/._+-]*) die "$2 contains a character the unit cannot carry: $1" ;; esac; }
parse() { while [ $# -gt 0 ]; do case "$1" in
--copies) [ $# -ge 2 ] || usage; COPIES="$2"; shift 2;;
--bin) [ "$CMD" != copy ] && [ $# -ge 2 ] || usage; BIN="$2";shift 2;;
--business) [ "$CMD" = check ] && [ $# -ge 2 ] || usage; BUSINESS="$2";shift 2;;
--business) [ "$CMD" = check ] && [ $# -ge 2 ] || usage; BUSINESS="$2";BUSINESS_SET=1;shift 2;;
--allow-worktree) [ "$CMD" = copy ] || usage; ALLOW_WORKTREE=1;shift;;
*) usage;; esac; done; plain_path "$COPIES" 'copies directory'; }
copy_root() { [[ "$ID" =~ ^[0-9a-f]{40}$ ]] || die '--copy must be a full 40-character commit id'; [ -f "$COPIES/$ID/scripts/mosaic" ] || die "no copy $ID in $COPIES"; echo "$COPIES/$ID"; }
@@ -64,6 +66,7 @@ copy_commit() {
echo "copied: $target"
}
wrapper() {
[ -z "$MOSAIC_UNIT_SUFFIX" ] || { wrapper_instance; return; }
plain_path "$BIN" 'bin directory'
local root file lease tmp
root="$(copy_root)";file="$BIN/mosaic"
@@ -77,6 +80,22 @@ wrapper() {
chmod 0755 "$tmp";mv -f "$tmp" "$file";trap - EXIT
echo "wrapper: $root"
}
# Row 190: $BIN/mosaic-NAME. It creates the file or replaces one with its own marker, never another.
# NAME's config path is plain (instance.sh refuses any other), so it goes in single quotes as is.
wrapper_instance() {
plain_path "$BIN" 'bin directory'
local root file tmp
root="$(copy_root)";file="$BIN/$WRAPPER_NAME"
if [ -e "$file" ] || [ -L "$file" ]; then
[ -f "$file" ] && [ ! -L "$file" ] || die "refusing: wrapper is not regular, or a symlink: $file"
[ "$(sed -n '2p' "$file")" = "$INSTANCE_MARKER" ] || die "refusing: foreign wrapper (missing the instance $MOSAIC_INSTANCE marker on line 2): $file"
fi
tmp="$(mktemp "$BIN/.$WRAPPER_NAME.XXXXXX")"
trap 'rm -f "$tmp"' EXIT
printf '#!/usr/bin/env bash\n%s\n# CLI runs from pinned copy: %s\nexport MOSAIC_CONFIG='"'"'%s'"'"'\nexec "%s/scripts/mosaic" "$@"\n' "$INSTANCE_MARKER" "$root" "$MOSAIC_INSTANCE_CONFIG" "$root" > "$tmp"
chmod 0755 "$tmp";mv -f "$tmp" "$file";trap - EXIT
echo "wrapper: $root"
}
unit_root() {
local out load root exec_start
out="$(systemctl --user show "$1" -p LoadState -p WorkingDirectory -p ExecStart 2>/dev/null)" || { echo 'absent (systemctl read failed)';return; }
@@ -84,29 +103,37 @@ unit_root() {
exec_start="$(sed -n 's/^ExecStart=//p' <<< "$out")"
if [ "$load" != loaded ] || [ -z "$root" ];then echo absent;return;fi
case "$1" in
mosaic-bus@*) [[ "$exec_start" == *"path=$root/scripts/mosaic ;"* ]] || { echo "$root (ExecStart mismatch)";return; };;
mosaic-bus@*|mosaic-bus-*@*) [[ "$exec_start" == *"path=$root/scripts/mosaic ;"* ]] || { echo "$root (ExecStart mismatch)";return; };;
mosaic-webui*) [[ "$exec_start" == *"$root/packages/webui/src/cli.mjs serve"* ]] || { echo "$root (ExecStart mismatch)";return; };;
esac
echo "$root"
}
check() {
plain_path "$BIN" 'bin directory';[[ "$BUSINESS" =~ ^[a-z0-9][a-z0-9-]*$ ]] || die 'invalid business id'
local bus console cli=absent line path
bus="$(unit_root "mosaic-bus@$BUSINESS.service")";console="$(unit_root mosaic-webui.service)"
if [ -f "$BIN/mosaic" ] && [ ! -L "$BIN/mosaic" ] && [ "$(sed -n '2p' "$BIN/mosaic")" = "$MARKER" ]; then
line="$(tail -n 1 "$BIN/mosaic")"
local bus console cli=absent line path marker="$MARKER" wfile="$BIN/$WRAPPER_NAME"
[ -z "$MOSAIC_UNIT_SUFFIX" ] || marker="$INSTANCE_MARKER"
bus="$(unit_root "${BUS_TEMPLATE%.service}$BUSINESS.service")";console="$(unit_root "$CONSOLE_UNIT")"
if [ -f "$wfile" ] && [ ! -L "$wfile" ] && [ "$(sed -n '2p' "$wfile")" = "$marker" ]; then
line="$(tail -n 1 "$wfile")"
path="${line#exec \"}";path="${path%\" \"\$@\"}"
if [ "$line" = "exec \"$path\" \"\$@\"" ] && [[ "$path" == */scripts/mosaic ]];then cli="${path%/scripts/mosaic}";fi
# A named wrapper must also carry NAME's config, as wrapper_instance writes it.
if [ -n "$MOSAIC_UNIT_SUFFIX" ] && [ "$cli" != absent ] && [ "$(sed -n '4p' "$wfile")" != "export MOSAIC_CONFIG='$MOSAIC_INSTANCE_CONFIG'" ];then cli="$cli (MOSAIC_CONFIG mismatch)";fi
fi
printf 'bus: %s\nConsole: %s\nCLI: %s\n' "$bus" "$console" "$cli"
[[ "$bus" == "$COPIES/"* ]] && [[ "${bus#"$COPIES/"}" =~ ^[0-9a-f]{40}$ ]] && [ -f "$bus/scripts/mosaic" ] && [ "$bus" = "$console" ] && [ "$bus" = "$cli" ]
}
# Sourced (stack-release.sh): define the functions, run nothing.
[ "${BASH_SOURCE[0]}" = "$0" ] || return 0
. "$REPO/scripts/instance.sh"
instance_args "$@"
set -- ${INSTANCE_ARGS[@]+"${INSTANCE_ARGS[@]}"}
[ $# -gt 0 ] || usage
CMD="$1";shift
case "$CMD" in check|wrapper) instance_select;; *) [ -z "$INSTANCE_FLAG" ] || usage;; esac
INSTANCE_MARKER="# mosaic-cli wrapper, instance ${MOSAIC_INSTANCE:-dev} (row 190)"
case "$CMD" in
copy|wrapper) [ $# -gt 0 ] || usage;ID="$1";shift;case "$ID" in -*)usage;;esac;parse "$@";if [ "$CMD" = copy ];then copy_commit;else wrapper;fi;;
check) parse "$@";check;;
check) parse "$@";[ -z "$MOSAIC_UNIT_SUFFIX" ] || [ "$BUSINESS_SET" = 1 ] || usage;check;;
-h|--help) usage 0;;
*) usage;;esac
+67 -31
View File
@@ -24,11 +24,11 @@ set -euo pipefail
cd "$(dirname "$0")/.."
REPO="$(pwd)"
source scripts/common.sh
source scripts/instance.sh
COPIES="${XDG_DATA_HOME:-$HOME/.local/share}/mosaic-stack/copies"
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 NO_SEND=0 R_BACKUP="" R_PORT=""
@@ -41,7 +41,9 @@ usage: stack-release.sh adopt BUSINESS
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
common options: --copies DIR, --bin DIR, --instance NAME
--instance NAME (row 190) runs on stack instance NAME: its config, data root, units and the wrapper
mosaic-NAME. Without it, the default instance, as before.
--events N is the total of the per-table counts the post-backup-events refusal prints: events and
every table in COUNTED.
USAGE
@@ -69,7 +71,10 @@ refuse() {
# Reject tunnel/main ports and anything other than a whole high port (exit 4 via usage).
valid_tester_port() {
[[ "$1" =~ ^[0-9]{1,5}$ ]] && [ "$((10#$1))" -ge 1024 ] && [ "$((10#$1))" -le 65535 ] &&
[ "$((10#$1))" -ne 7332 ] && [ "$((10#$1))" -ne "$2" ]
[ "$((10#$1))" -ne 7332 ] && [ "$((10#$1))" -ne "$2" ] || return 1
# Row 190: a named instance's Console never takes 7330, 7332 or 7333.
local r
for r in "${INSTANCE_RESERVED[@]}"; do [ "$((10#$1))" -ne "$r" ] || return 1; done
}
parse_opts() {
@@ -416,16 +421,17 @@ capture() {
mkdir -m 0700 "$RS" 2>/dev/null || return 1
STEPS="$RS/steps.log"
local u d
for u in [email protected] "$CONSOLE_UNIT"; do
for u in "$BUS_TEMPLATE" "$CONSOLE_UNIT"; do
[ -f "$UNIT_DIR/$u" ] || { say "capture: no $UNIT_DIR/$u"; return 1; }
cp -p "$UNIT_DIR/$u" "$RS/$u" || return 1
done
for d in [email protected] "mosaic-bus@$BUSINESS.service.d" "$CONSOLE_UNIT.d"; do
# This instance's own directories only; the shared prefix ones are never captured (row 190).
for d in "$BUS_TEMPLATE.d" "$BUS_UNIT.d" "$CONSOLE_UNIT.d"; do
[ ! -d "$UNIT_DIR/$d" ] || cp -pr "$UNIT_DIR/$d" "$RS/$d" || return 1
done
[ -f "$BIN/mosaic" ] || { say "capture: no wrapper $BIN/mosaic"; return 1; }
cp -p "$BIN/mosaic" "$RS/wrapper" || return 1
stat -c %a "$BIN/mosaic" > "$RS/WRAPPER_MODE" || return 1
[ -f "$BIN/$WRAPPER_NAME" ] || { say "capture: no wrapper $BIN/$WRAPPER_NAME"; return 1; }
cp -p "$BIN/$WRAPPER_NAME" "$RS/wrapper" || return 1
stat -c %a "$BIN/$WRAPPER_NAME" > "$RS/WRAPPER_MODE" || return 1
for u in "$BUS_UNIT" "$CONSOLE_UNIT"; do show "$u" > "$RS/show-$u.txt" || return 1; done
sums "$RS" > "$RS/SHA256SUMS" || return 1
say "captured the rollback set: $RS"
@@ -458,9 +464,9 @@ install_stack() {
local id="$1" pm="$2" claude="$3"; shift 3
local -a pmflag=()
[ "$pm" = 0 ] || pmflag=(--pm)
scripts/bus-service.sh install --copy "$id" --copies "$COPIES" "${pmflag[@]}" --claude "$claude" >> "$STEPS" 2>&1 || { FAIL=install-bus; return 1; }
scripts/webui-service.sh install --copy "$id" --copies "$COPIES" "$@" >> "$STEPS" 2>&1 || { FAIL=install-console; return 1; }
scripts/stack-copy.sh wrapper "$id" --copies "$COPIES" --bin "$BIN" >> "$STEPS" 2>&1 || { FAIL=wrapper; return 1; }
scripts/bus-service.sh install "${INSTANCE_FLAGS[@]}" --copy "$id" --copies "$COPIES" "${pmflag[@]}" --claude "$claude" >> "$STEPS" 2>&1 || { FAIL=install-bus; return 1; }
scripts/webui-service.sh install "${INSTANCE_FLAGS[@]}" --copy "$id" --copies "$COPIES" "$@" >> "$STEPS" 2>&1 || { FAIL=install-console; return 1; }
scripts/stack-copy.sh wrapper "$id" "${INSTANCE_FLAGS[@]}" --copies "$COPIES" --bin "$BIN" >> "$STEPS" 2>&1 || { FAIL=wrapper; return 1; }
say "installed $id"
}
@@ -486,7 +492,7 @@ process.stdout.write(why);' -- "$out" "$BUSINESS" "$main" "$START_MS" 2>&1)" ||
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));' -- "$(console_port)" || return 1
GATE=copy-check
out="$(scripts/stack-copy.sh check --copies "$COPIES" --bin "$BIN" --business "$BUSINESS" 2>/dev/null)" || return 1
out="$(scripts/stack-copy.sh check "${INSTANCE_FLAGS[@]}" --copies "$COPIES" --bin "$BIN" --business "$BUSINESS" 2>/dev/null)" || return 1
# check passes when the three agree on one copy; this run needs it to be the target (row 166 item 3).
out="$(sed -n 's/^bus: //p' <<< "$out")"
[ "$out" = "$copy" ] || { GATE_WHY="the bus, Console and CLI agree on $out, not $copy"; return 1; }
@@ -533,10 +539,10 @@ gate() {
# Puts back the captured unit files, drop-ins and wrapper, checked against SHA256SUMS.
put_back() {
local u f rel tmp
for u in [email protected] "$CONSOLE_UNIT"; do
for u in "$BUS_TEMPLATE" "$CONSOLE_UNIT"; do
tmp="$(mktemp "$UNIT_DIR/.$u.XXXXXX")" && cp -p "$RS/$u" "$tmp" && mv -f "$tmp" "$UNIT_DIR/$u" || return 1
done
for u in [email protected] "mosaic-bus@$BUSINESS.service.d" "$CONSOLE_UNIT.d"; do
for u in "$BUS_TEMPLATE.d" "$BUS_UNIT.d" "$CONSOLE_UNIT.d"; do
[ -d "$RS/$u" ] || continue
while IFS= read -r -d '' f; do
rel="${f#"$RS/$u/"}"
@@ -545,12 +551,12 @@ put_back() {
tmp="$(mktemp "$UNIT_DIR/$u/.restore.XXXXXX")" && cp -p "$f" "$tmp" && mv -f "$tmp" "$UNIT_DIR/$u/$rel" || return 1
done < <(find "$RS/$u" -type f -print0)
done
tmp="$(mktemp "$BIN/.mosaic.XXXXXX")" && cp -p "$RS/wrapper" "$tmp" && chmod "$(cat "$RS/WRAPPER_MODE")" "$tmp" && mv -f "$tmp" "$BIN/mosaic" || return 1
for u in [email protected] "$CONSOLE_UNIT"; do
tmp="$(mktemp "$BIN/.$WRAPPER_NAME.XXXXXX")" && cp -p "$RS/wrapper" "$tmp" && chmod "$(cat "$RS/WRAPPER_MODE")" "$tmp" && mv -f "$tmp" "$BIN/$WRAPPER_NAME" || return 1
for u in "$BUS_TEMPLATE" "$CONSOLE_UNIT"; do
[ "$(sha256sum < "$UNIT_DIR/$u" | cut -d' ' -f1)" = "$(sum_of "$RS" "$u")" ] || return 1
done
[ "$(sha256sum < "$BIN/mosaic" | cut -d' ' -f1)" = "$(sum_of "$RS" wrapper)" ] || return 1
[ "$(stat -c %a "$BIN/mosaic")" = "$(cat "$RS/WRAPPER_MODE")" ] || return 1
[ "$(sha256sum < "$BIN/$WRAPPER_NAME" | cut -d' ' -f1)" = "$(sum_of "$RS" wrapper)" ] || return 1
[ "$(stat -c %a "$BIN/$WRAPPER_NAME")" = "$(cat "$RS/WRAPPER_MODE")" ] || return 1
systemctl --user daemon-reload || return 1
say "put back the unit files and the wrapper from $RS"
}
@@ -607,9 +613,9 @@ lock() {
instance_busy() {
local out unit load state rest
out="$(systemctl --user list-units --all --no-legend --plain 'mosaic-bus@*.service' 2>/dev/null)" || refuse instance-busy "systemctl --user list-units failed, so the bus state is unknown"
out="$(systemctl --user list-units --all --no-legend --plain "$BUS_GLOB" 2>/dev/null)" || refuse instance-busy "systemctl --user list-units failed, so the bus state is unknown"
while read -r unit load state rest; do
[[ "$unit" == mosaic-bus@*.service ]] || continue
[[ "$unit" == $BUS_GLOB ]] || continue
if [ "$unit" = "$BUS_UNIT" ]; then
case "$state" in activating|deactivating|reloading) refuse instance-busy "$unit is $state" ;; esac
else
@@ -621,14 +627,17 @@ instance_busy() {
# install_guard's drop-in scan (bus-service.sh), done before anything stops. A path it can't read
# refuses here, as install_guard would refuse it after the stop (row 166 item 1).
drop_in_override() {
local paths directory dropdir file content
local paths directory dropdir file content glob
local -a dropdirs
paths="$(systemd-analyze --user unit-paths 2>/dev/null)" && [ -n "$paths" ] || refuse drop-in-override "systemd unit paths are unknown"
while IFS= read -r directory; do
[ -n "$directory" ] || continue
case "$directory" in /*) ;; *) refuse drop-in-override "invalid systemd unit path $directory" ;; esac
[ -e "$directory" ] || [ -L "$directory" ] || continue
[ -d "$directory" ] && [ -r "$directory" ] && [ -x "$directory" ] || refuse drop-in-override "cannot inspect unit path $directory"
for dropdir in "$directory"/mosaic-bus@*.service.d "$directory"/mosaic-.service.d "$directory"/service.d; do
dropdirs=()
for glob in "${BUS_DROPIN_GLOBS[@]}"; do dropdirs+=("$directory"/$glob); done
for dropdir in "${dropdirs[@]}"; do
[ -e "$dropdir" ] || [ -L "$dropdir" ] || continue
[ -d "$dropdir" ] && [ -r "$dropdir" ] && [ -x "$dropdir" ] || refuse drop-in-override "cannot inspect drop-ins $dropdir"
for file in "$dropdir"/*.conf; do
@@ -684,6 +693,7 @@ console_for_target() {
[ "$PLAN" = 1 ] || usage
local other=""
[ "$main" = 7332 ] || other=" and the Console's --port $main"
[ "${#INSTANCE_RESERVED[@]}" = 0 ] || other+=" and instance $MOSAIC_INSTANCE's reserved ${INSTANCE_RESERVED[*]}"
check_refuse tester-port-invalid "--tester-port $TESTER_PORT is not a whole port from 1024 to 65535 other than 7332$other"
fi
[ "$supported" = 1 ] || check_refuse tester-port-target "$TO's Console does not support --tester-port"
@@ -813,10 +823,30 @@ proof_file_problem() {
[ "$uid" = "$(id -u)" ] || echo "is owned by uid $uid, not $(id -u), the uid this script runs as"
}
# Row 190: the target Console's --port (7332 when absent) and --tester-port against every other
# installed Console unit in UNIT_DIR, the default's and each named instance's.
ports_unclaimed() {
local main=7332 i holder
local -a ports=()
for ((i = 0; i < ${#RUN_CONSOLE[@]}; i++)); do
case "${RUN_CONSOLE[i]}" in
--port) main="${RUN_CONSOLE[i + 1]:-7332}" ;;
--tester-port) ports+=("${RUN_CONSOLE[i + 1]:-}") ;;
esac
done
for i in "$main" "${ports[@]}"; do
[[ "$i" =~ ^[0-9]{1,5}$ ]] || continue
if holder="$(instance_port_holder "$UNIT_DIR" "$i")"; then
check_refuse port-claimed "port $i is taken by $UNIT_DIR/$holder"
fi
done
}
# The item 3 checks after the pointer, shared by activate and rollback, for target $TO.
precheck() {
check_target
console_for_target
ports_unclaimed
instance_busy
drop_in_override
read_units || refuse unit-flags-drift "cannot read $BUS_UNIT or $CONSOLE_UNIT"
@@ -857,7 +887,7 @@ do_adopt() {
lock
[ ! -f "$POINTER" ] || { read_pointer; FROM="$P_COPY"; refuse already-adopted "$POINTER exists (copy $P_COPY)"; }
local out
out="$(scripts/stack-copy.sh check --copies "$COPIES" --bin "$BIN" --business "$BUSINESS" 2>&1)" || refuse check-failed "stack-copy.sh check did not pass:"$'\n'"$out"
out="$(scripts/stack-copy.sh check "${INSTANCE_FLAGS[@]}" --copies "$COPIES" --bin "$BIN" --business "$BUSINESS" 2>&1)" || refuse check-failed "stack-copy.sh check did not pass:"$'\n'"$out"
TO="$(sed -n 's/^bus: //p' <<< "$out")"
TO="${TO#"$COPIES/"}"
read_units || refuse check-failed "cannot read $BUS_UNIT or $CONSOLE_UNIT"
@@ -970,12 +1000,13 @@ plan() {
plan_commits
[ "$P_PENDING" = 0 ] || echo "pm: the pointer is pending (activate --pm-after-harness); the bus runs without its PM"
[ "$RUN_PENDING" = 0 ] || echo "pm: the bus starts without --pm and the PM stays pending; next: Jason's plugins set of harness, then pm-on"
echo "bus: scripts/bus-service.sh install --copy $TO --copies $COPIES$([ "$RUN_PM" = 0 ] || echo ' --pm') --claude $P_CLAUDE"
local flags="${INSTANCE_FLAGS[*]:+${INSTANCE_FLAGS[*]} }"
echo "bus: scripts/bus-service.sh install $flags--copy $TO --copies $COPIES$([ "$RUN_PM" = 0 ] || echo ' --pm') --claude $P_CLAUDE"
[ -z "$DROP_TESTER" ] || echo "drop $DROP_TESTER; the target Console does not support it"
echo "$PROOF_PLAN"
echo "$SEND_PLAN"
echo "Console: scripts/webui-service.sh install --copy $TO --copies $COPIES${RUN_CONSOLE[*]:+ ${RUN_CONSOLE[*]}}"
echo "CLI: scripts/stack-copy.sh wrapper $TO --copies $COPIES --bin $BIN"
echo "Console: scripts/webui-service.sh install $flags--copy $TO --copies $COPIES${RUN_CONSOLE[*]:+ ${RUN_CONSOLE[*]}}"
echo "CLI: scripts/stack-copy.sh wrapper $TO $flags--copies $COPIES --bin $BIN"
local r
for r in "${PLAN_REFUSE[@]}"; do echo "refuses $r"; done
[ "${#PLAN_REFUSE[@]}" = 0 ] || exit 3
@@ -1047,7 +1078,7 @@ do_activate() {
pointer_for_business
run_pm
[ "$P_PENDING" = 0 ] || check_refuse pm-pending "the pointer is pending (activate --pm-after-harness); run pm-on or rollback first"
if [ "$PLAN" = 1 ]; then check_target; console_for_target; plugin_checks; plan; exit 0; fi
if [ "$PLAN" = 1 ]; then check_target; console_for_target; ports_unclaimed; plugin_checks; plan; exit 0; fi
precheck
plugin_checks
new_activation
@@ -1059,7 +1090,7 @@ do_rollback() {
pointer_for_business
TO="$(from_log previous)" || refuse no-previous "the log names no earlier copy than $P_COPY"
run_pm
if [ "$PLAN" = 1 ]; then check_target; console_for_target; plugin_checks; plan; exit 0; fi
if [ "$PLAN" = 1 ]; then check_target; console_for_target; ports_unclaimed; plugin_checks; plan; exit 0; fi
precheck
plugin_checks
[ "$P_SCHEMA" = "$T_SCHEMA" ] || [ -n "$RESTORE" ] || refuse schema-differs "the store is at $P_SCHEMA and $TO expects $T_SCHEMA; pass --restore ACTIVATION"
@@ -1153,7 +1184,7 @@ do_pm_on() {
capture || refuse capture-failed "could not write the rollback set $RS; nothing was stopped"
trap 'pm_on_fail signal' INT TERM HUP
stop_bus || pm_on_fail stop-timeout
scripts/bus-service.sh install --copy "$P_COPY" --copies "$COPIES" --pm --claude "$P_CLAUDE" >> "$STEPS" 2>&1 || pm_on_fail install-bus
scripts/bus-service.sh install "${INSTANCE_FLAGS[@]}" --copy "$P_COPY" --copies "$COPIES" --pm --claude "$P_CLAUDE" >> "$STEPS" 2>&1 || pm_on_fail install-bus
say "installed $BUS_UNIT for $P_COPY with --pm"
start_bus || pm_on_fail start
gate "$P_COPY" 1 || pm_on_fail "$GATE"
@@ -1173,7 +1204,7 @@ do_status() {
open="$(interrupted | paste -sd' ' -)"
[ -z "$open" ] || echo "interrupted run: $open (started, no final line; Jason or Mos settles it)"
echo "check:"
if scripts/stack-copy.sh check --copies "$COPIES" --bin "$BIN" --business "$BUSINESS" 2>&1 | sed 's/^/ /'; then echo " result: pass"; else echo " result: FAIL"; fi
if scripts/stack-copy.sh check "${INSTANCE_FLAGS[@]}" --copies "$COPIES" --bin "$BIN" --business "$BUSINESS" 2>&1 | sed 's/^/ /'; then echo " result: pass"; else echo " result: FAIL"; fi
[ -n "$P_COPY" ] || return 0
echo "bus status:"
"$COPIES/$P_COPY/scripts/mosaic" bus status 2>&1 | sed 's/^/ /' || true
@@ -1437,6 +1468,7 @@ do_rehearse() {
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
if i="$(instance_port_holder "$UNIT_DIR" "$R_PORT")"; then r_abort port-reserved "--port $R_PORT is $UNIT_DIR/$i's Console port"; fi
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
@@ -1489,6 +1521,9 @@ do_rehearse() {
# --- Dispatch ---
# Row 190: --instance NAME may come anywhere; instance.sh takes it out and sets the names.
instance_args "$@"
set -- ${INSTANCE_ARGS[@]+"${INSTANCE_ARGS[@]}"}
[ $# -gt 0 ] || usage
OP="$1"; shift
ID=""
@@ -1501,13 +1536,14 @@ esac
[[ "$BUSINESS" =~ ^[a-z0-9][a-z0-9-]*$ ]] || usage
parse_opts "$@"
COPIES_DIR="$COPIES"
instance_select
load_config
DATA="$MOSAIC_DATA_ROOT"
STATE="$DATA/state"
POINTER="$STATE/stack-active.json"
LOG="$STATE/stack-activation-log.jsonl"
STORE="$DATA/bus/bus.sqlite"
BUS_UNIT="mosaic-bus@$BUSINESS.service"
BUS_UNIT="${BUS_TEMPLATE%.service}$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="" SEND_PLAN="" SEND_STEP="" PLAN_REFUSE=()
+46
View File
@@ -151,6 +151,52 @@ else
FAIL=$((FAIL+1)); echo "${C_FAIL}FAIL${C_RESET} env exports resolve correctly"
fi
# --- Q175 phase 1 (#1690), design test 25: the optional console key ---
# Shape only. The slot path is a FIFO, so a loader that opened the slot would block and the
# timeout would fail the case; no case reads a slot file.
mkdir -p "$SANDBOX/secrets/mosaic-stack"
mkfifo "$SANDBOX/secrets/mosaic-stack/console-sso-test"
sso_body() {
printf '{"configVersion":1,"environment":"development","dataRoot":"%s","execution":{"backend":"docker","provider":"zai","model":"m"},"console":%s}' "$DATA_ROOT" "$1"
}
# provider [ALGS] [CLIENT_SECRET]: one provider entry; an empty argument takes the valid value.
provider() {
local algs='["RS256"]' secret='{"slot":"console-sso-test"}'
[ -n "${1:-}" ] && algs="$1"
[ -n "${2:-}" ] && secret="$2"
printf '{"id":"fake","issuer":"https://op.test/app/","clientId":"client-1","clientSecret":%s,"algs":%s,"subjects":[{"sub":"user-1","name":"jason.woltje"}]}' "$secret" "$algs"
}
cfg sso-valid.json "$(sso_body '{"sso":{"publicOrigin":"https://console.test","providers":['"$(provider)"']}}')"
OUT="$(MOSAIC_CONFIG="$SANDBOX/sso-valid.json" timeout 20 $CONFIG_OP validate 2>&1)"
[ $? -eq 0 ] && printf '%s' "$OUT" | grep -q '"publicOrigin": "https://console.test"'
check "valid console.sso loads and is in the output" $?
# expect_refusal NAME PATH-IN-MESSAGE BODY: exit 2, and the message names the bad key path.
expect_refusal() {
cfg sso-bad.json "$2"
local err rc
err="$(MOSAIC_CONFIG="$SANDBOX/sso-bad.json" timeout 20 $CONFIG_OP validate 2>&1 >/dev/null)"
rc=$?
[ "$rc" -eq 2 ] && printf '%s' "$err" | grep -qF -- "$3"
check "$1 (exit $rc)" $?
}
expect_refusal "console.sso HS256 alg exits 2 naming the path" \
"$(sso_body '{"sso":{"publicOrigin":"https://console.test","providers":['"$(provider '["HS256"]')"']}}')" 'console.sso.providers[0].algs[0] must be one of'
expect_refusal "console.sso alg none exits 2 naming the path" \
"$(sso_body '{"sso":{"publicOrigin":"https://console.test","providers":['"$(provider '["RS256","none"]')"']}}')" 'console.sso.providers[0].algs[1] must be one of'
expect_refusal "console.sso missing slot field exits 2 naming the path" \
"$(sso_body '{"sso":{"publicOrigin":"https://console.test","providers":['"$(provider '' '{}')"']}}')" 'console.sso.providers[0].clientSecret.slot is required'
expect_refusal "console.sso slot name outside the pattern exits 2 naming the path" \
"$(sso_body '{"sso":{"publicOrigin":"https://console.test","providers":['"$(provider '' '{"slot":"../x"}')"']}}')" 'console.sso.providers[0].clientSecret.slot must match'
expect_refusal "unknown key under console exits 2 naming the path" \
"$(sso_body '{"sso":{"publicOrigin":"https://console.test","providers":['"$(provider)"']},"extra":1}')" 'console.extra is not a supported key'
# Without the key: the output is exactly what the loader printed before phase 1 (sage-1340, F2).
cfg no-console.json '{"configVersion":1,"environment":"development","dataRoot":"'$DATA_ROOT'","execution":{"backend":"docker","provider":"zai","model":"m"}}'
EXPECTED="$(printf '{\n "configVersion": 1,\n "environment": "development",\n "dataRoot": "%s",\n "execution": {\n "backend": "docker",\n "provider": "zai",\n "model": "m",\n "adapter": "pi"\n }\n}' "$DATA_ROOT")"
[ "$(MOSAIC_CONFIG="$SANDBOX/no-console.json" timeout 20 $CONFIG_OP validate)" = "$EXPECTED" ]
check "config without console loads with unchanged output" $?
# --- validation must not modify the file ---
SUM_INVALID_BEFORE=$(sha256sum "$SANDBOX/invalid.json" | cut -d' ' -f1)
MOSAIC_CONFIG="$SANDBOX/invalid.json" $CONFIG_OP validate >/dev/null 2>&1
+5
View File
@@ -170,6 +170,10 @@ check "scripts/discord.sh reload without a binding exits 4" $?
# --- the service unit: rendered from the template, never touching systemd here ---
UNITS="$SANDBOX/units"
# Row 190: discord-service.sh reads the selected config first, so these runs name a scratch one
# that does not exist (the default's names, no data root) instead of ~/.config/mosaic-dev.
CALLER_MOSAIC_CONFIG="${MOSAIC_CONFIG-}"
export MOSAIC_CONFIG="$SANDBOX/absent/config.json"
scripts/discord-service.sh >/dev/null 2>&1
[ $? -eq 4 ]
check "scripts/discord-service.sh without a command exits 4" $?
@@ -246,6 +250,7 @@ HOME="$SANDBOX/home" XDG_CONFIG_HOME="$SANDBOX/config-home" PATH="$SANDBOX/fake-
check "status names the copy from fake systemctl effective cwd" $?
grep -qF "checkout: $REPO_DIR" "$SANDBOX/install.1" && grep -qF -- '--copy' "$SANDBOX/install.1"
check "checkout install explicitly recommends --copy" $?
if [ -n "$CALLER_MOSAIC_CONFIG" ]; then export MOSAIC_CONFIG="$CALLER_MOSAIC_CONFIG"; else unset MOSAIC_CONFIG; fi
# Offline wrapper entrypoint. Only a synthetic token/binding/config are used.
node - "$SANDBOX" <<'EOF_OFFLINE'
+88 -24
View File
@@ -49,6 +49,9 @@
# character set and refused otherwise. --proof-header NAME --proof-file PATH
# go together under preview; only the path is carried here, never the secret.
# render checks ownership, mode and location without opening the proof file.
# --sso (Q175 phase 1, #1690): the Console signs people in itself from
# console.sso in config.json. It refuses the preview set and the proof pair,
# and --send takes it in place of the preview set. Nothing secret is carried.
#
# There is no start, stop, enable or restart verb. The Console reads the
# main listener's bus through the human transport; the optional loopback
@@ -62,13 +65,34 @@
# --no-reload: a unit rewritten under a running Console would take the new
# ExecStart at its next Restart=on-failure, with nobody starting it.
#
# Exit codes: 0 ok, 1 operation failed, 4 usage.
# --instance NAME (row 190, any position) selects stack instance NAME: the
# unit is mosaic-webui-NAME.service and sets MOSAIC_CONFIG to
# ~/.config/mosaic-NAME/config.json, its bus units are mosaic-bus-NAME@,
# and render and install need --port, which may not be 7330, 7332 or 7333
# (exit 4, port-reserved). For every instance, install refuses a --port or
# --tester-port that another installed Console unit in the unit directory
# already takes (port-claimed). copy refuses --instance (exit 4), as
# stack-copy.sh copy does: every instance shares the copies. See
# scripts/instance.sh.
#
# Exit codes: 0 ok, 1 operation failed, 3 the data root is claimed by another
# instance or not claimed (scripts/instance.sh), 4 usage.
set -euo pipefail
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TEMPLATE="$REPO/packages/webui/systemd/mosaic-webui.service.in"
UNIT_NAME="mosaic-webui.service"
USAGE="usage: scripts/webui-service.sh copy COMMIT [--copies DIR] [--allow-worktree] | render [--copy ID [--copies DIR]] [FLAGS] | install --copy ID [--copies DIR] [--dir DIR] [--no-reload] [--allow-worktree] [FLAGS] | uninstall [--dir DIR] [--no-reload] | status [--dir DIR]
FLAGS: [--port N] [--business ID] [--board URL] [--public-host NAME --auth-header HEADER --allow-user ID [--send]] [--tester-port N] [--proof-header NAME --proof-file PATH]
# shellcheck source=scripts/instance.sh
. "$REPO/scripts/instance.sh"
instance_args "$@"
set -- ${INSTANCE_ARGS[@]+"${INSTANCE_ARGS[@]}"}
if [ "${1:-}" = copy ] && [ -n "$INSTANCE_FLAG" ]; then
echo "webui-service: copy takes no --instance; every instance shares the copies" >&2
exit 4
fi
instance_select
UNIT_NAME="$CONSOLE_UNIT"
BUS_UNIT_PREFIX="${BUS_TEMPLATE%.service}"
USAGE="usage: scripts/webui-service.sh copy COMMIT [--copies DIR] [--allow-worktree] | [--instance NAME] render [--copy ID [--copies DIR]] [FLAGS] | install --copy ID [--copies DIR] [--dir DIR] [--no-reload] [--allow-worktree] [FLAGS] | uninstall [--dir DIR] [--no-reload] | status [--dir DIR]
FLAGS: [--port N] [--business ID] [--board URL] [--sso [--send] | --public-host NAME --auth-header HEADER --allow-user ID [--send]] [--tester-port N] [--proof-header NAME --proof-file PATH]
--tester-port: 1024..65535, different from --port and never 7332; loopback tester listener.
--allow-worktree is for the tests only: copy and install otherwise refuse outside the canonical checkout."
@@ -83,6 +107,7 @@ ALLOW_WORKTREE=0
RELOAD=1
DIR_FLAGS=0
PORT=7332
PORT_SET=0
TESTER_PORT=""
TESTER_SET=0
BUSINESS=""
@@ -91,6 +116,7 @@ PUBLIC_HOST=""
AUTH_HEADER=""
ALLOW_USER=""
SEND=0
SSO=0
PROOF_HEADER=""
PROOF_FILE=""
PROOF_SET=0
@@ -104,7 +130,7 @@ parse_flags() {
--copies) [ $# -ge 2 ] || usage; COPIES="$2"; COPY_FLAGS=1; shift 2 ;;
--allow-worktree) ALLOW_WORKTREE=1; shift ;;
--no-reload) RELOAD=0; DIR_FLAGS=1; shift ;;
--port) [ $# -ge 2 ] || usage; PORT="$2"; SERVE_SET=1; shift 2 ;;
--port) [ $# -ge 2 ] || usage; PORT="$2"; PORT_SET=1; SERVE_SET=1; shift 2 ;;
--tester-port) [ $# -ge 2 ] || usage; TESTER_PORT="$2"; TESTER_SET=1; SERVE_SET=1; shift 2 ;;
--business) [ $# -ge 2 ] || usage; BUSINESS="$2"; SERVE_SET=1; shift 2 ;;
--board) [ $# -ge 2 ] || usage; BOARD="$2"; SERVE_SET=1; shift 2 ;;
@@ -114,6 +140,7 @@ parse_flags() {
--proof-header) [ $# -ge 2 ] || usage; PROOF_HEADER="$2"; PROOF_SET=1; SERVE_SET=1; shift 2 ;;
--proof-file) [ $# -ge 2 ] || usage; PROOF_FILE="$2"; PROOF_SET=1; SERVE_SET=1; shift 2 ;;
--send) SEND=1; SERVE_SET=1; shift ;;
--sso) SSO=1; SERVE_SET=1; shift ;;
*) usage ;;
esac
done
@@ -122,6 +149,31 @@ parse_flags() {
[ "$TESTER_SET" = 0 ] || tester_port_ok
}
# Row 190: a named instance's Console names its own port, outside the
# default's ports. Called by render and install only.
instance_ports_ok() {
[ "${#INSTANCE_RESERVED[@]}" -gt 0 ] || return 0
[ "$PORT_SET" = 1 ] || { echo "webui-service: stack instance $MOSAIC_INSTANCE needs --port; it has no default port" >&2; exit 4; }
local p r
for p in "$PORT" ${TESTER_PORT:+"$TESTER_PORT"}; do
[[ "$p" =~ ^[0-9]{1,5}$ ]] || continue
for r in "${INSTANCE_RESERVED[@]}"; do
[ "$((10#$p))" != "$r" ] || { echo "webui-service: port-reserved: $p is reserved for the default instance (${INSTANCE_RESERVED[*]})" >&2; exit 4; }
done
done
}
# Row 190: refuse a port another installed Console unit already takes.
ports_unclaimed() {
local p holder
for p in "$PORT" ${TESTER_PORT:+"$TESTER_PORT"}; do
[[ "$p" =~ ^[0-9]{1,5}$ ]] || continue
if holder="$(instance_port_holder "$UNIT_DIR" "$p")"; then
die "refusing port-claimed: port $p is taken by $UNIT_DIR/$holder; nothing was changed"
fi
done
}
tester_port_usage() {
echo "webui-service: --tester-port must be 1024..65535, different from --port and never 7332: $TESTER_PORT" >&2
exit 4
@@ -166,9 +218,13 @@ serve_flags() {
[ -z "$PUBLIC_HOST" ] || preview=$((preview + 1))
[ -z "$AUTH_HEADER" ] || preview=$((preview + 1))
[ -z "$ALLOW_USER" ] || preview=$((preview + 1))
# serve refuses --send without the preview set; refuse it here first,
# so no unit is written that would only exit 2 at start.
[ "$SEND" = 0 ] || [ "$preview" -eq 3 ] || die "--send needs the preview mode (--public-host, --auth-header and --allow-user)"
# serve refuses --sso with the preview set or the proof pair, and --send
# without --sso or the preview set; refuse them here first, so no unit is
# written that would only exit 2 at start.
if [ "$SSO" = 1 ] && { [ "$preview" -ne 0 ] || [ "$PROOF_SET" = 1 ]; }; then
die "--sso cannot be combined with --public-host, --auth-header, --allow-user or --proof-*"
fi
[ "$SEND" = 0 ] || [ "$preview" -eq 3 ] || [ "$SSO" = 1 ] || die "--send needs the preview mode (--public-host, --auth-header and --allow-user) or --sso"
if [ "$preview" -ne 0 ]; then
[ "$preview" -eq 3 ] || die "--public-host, --auth-header and --allow-user go together: all three or none"
case "$PUBLIC_HOST" in [A-Za-z0-9]*) ;; *) die "public host must be a hostname, no port or scheme: $PUBLIC_HOST" ;; esac
@@ -201,6 +257,7 @@ serve_flags() {
{ [ "$proof_mode" = 600 ] || [ "$proof_mode" = 400 ]; } && [ "$proof_uid" = "$(id -u)" ] || die "invalid proxy proof file metadata"
flags="$flags --proof-header $PROOF_HEADER --proof-file $PROOF_FILE"
fi
[ "$SSO" = 0 ] || flags="$flags --sso"
[ "$SEND" = 0 ] || flags="$flags --send"
[ "$TESTER_SET" = 0 ] || flags="$flags --tester-port $TESTER_PORT"
echo "$flags"
@@ -249,7 +306,7 @@ render() {
local flags
flags="$(serve_flags)" || exit 1
[ -z "$COPY_ID" ] || flags="--checkout $REPO $flags"
sed -e "s|@REPO@|$root|g" -e "s|@PATH@|$path|g" -e "s|@NODE@|$node|g" -e "s|@FLAGS@|$flags|g" "$TEMPLATE"
sed -e "s|@REPO@|$root|g" -e "s|@PATH@|$path|g" -e "s|@NODE@|$node|g" -e "s|@FLAGS@|$flags|g" "${INSTANCE_SED[@]}" "$TEMPLATE"
}
# The unit's active state from list-units, printed when it is anything but
@@ -269,16 +326,17 @@ running() {
# only with a bus on that same copy: install refuses unless every bus unit
# it would call has an ExecStart path inside the copy. With --business the
# unit is [email protected]; without it, every mosaic-bus@ unit systemd
# has loaded, and none loaded refuses. Like the state read, a failed call
# has loaded, and none loaded refuses. A named instance reads its own
# mosaic-bus-NAME@ units only. Like the state read, a failed call
# refuses with one line.
bus_on_copy() {
local copy="$1" units unit out load paths p working
if [ -n "$BUSINESS" ]; then
units="mosaic-bus@$BUSINESS.service"
units="$BUS_UNIT_PREFIX$BUSINESS.service"
else
out="$(systemctl --user list-units --all --no-legend --plain 'mosaic-bus@*.service' 2>/dev/null)" || die "refusing: systemctl --user list-units --all --no-legend --plain mosaic-bus@*.service failed, so the bus unit is unknown; nothing was changed"
units="$(printf '%s\n' "$out" | awk '$1 ~ /^mosaic-bus@.+\.service$/ {print $1}')"
[ -n "$units" ] || die "refusing: no mosaic-bus@ unit is loaded, and the Console on the copy $copy needs its bus on that copy; name the business with --business ID"
out="$(systemctl --user list-units --all --no-legend --plain "$BUS_GLOB" 2>/dev/null)" || die "refusing: systemctl --user list-units --all --no-legend --plain $BUS_GLOB failed, so the bus unit is unknown; nothing was changed"
units="$(printf '%s\n' "$out" | awk -v p="$BUS_UNIT_PREFIX" 'index($1, p) == 1 && length($1) > length(p) + 8 && substr($1, length($1) - 7) == ".service" {print $1}')"
[ -n "$units" ] || die "refusing: no $BUS_UNIT_PREFIX unit is loaded, and the Console on the copy $copy needs its bus on that copy; name the business with --business ID"
fi
for unit in $units; do
out="$(systemctl --user show "$unit" -p LoadState -p WorkingDirectory -p ExecStart 2>/dev/null)" || die "refusing: systemctl --user show $unit failed, so the bus unit is unknown; nothing was changed"
@@ -298,13 +356,15 @@ install_unit() {
parse_flags "$@"
canonical
[ -n "$COPY_ID" ] || die "install needs --copy ID, a copy made with: scripts/webui-service.sh copy COMMIT"
instance_ports_ok
local unit
unit="$(render)" || exit 1
ports_unclaimed
bus_on_copy "$COPIES/$COPY_ID"
# N1: a running unit would take the new ExecStart at its next restart.
local state
state="$(running)" || exit 1
[ -z "$state" ] || die "refusing: $UNIT_NAME is running ($state); Jason or Mos stops it first (systemctl --user stop mosaic-webui), then install, then start it"
[ -z "$state" ] || die "refusing: $UNIT_NAME is running ($state); Jason or Mos stops it first (systemctl --user stop ${UNIT_NAME%.service}), then install, then start it"
mkdir -p "$UNIT_DIR"
local target="$UNIT_DIR/$UNIT_NAME" tmp
tmp="$(mktemp "$UNIT_DIR/.$UNIT_NAME.XXXXXX")"
@@ -326,11 +386,11 @@ install_unit() {
this script starts nothing: the Console holds the human's capability. Jason
starts and restarts it, or Mos under lead decision 102 (sends then enter the
trail as Jason); no other agent does:
systemctl --user enable --now mosaic-webui start now and at login
systemctl --user status mosaic-webui
journalctl --user -u mosaic-webui -f the Console's log
systemctl --user stop mosaic-webui SIGTERM; restartable
scripts/webui-service.sh status unit state and /healthz
systemctl --user enable --now ${UNIT_NAME%.service} start now and at login
systemctl --user status ${UNIT_NAME%.service}
journalctl --user -u ${UNIT_NAME%.service} -f the Console's log
systemctl --user stop ${UNIT_NAME%.service} SIGTERM; restartable
scripts/webui-service.sh ${INSTANCE_FLAGS[*]:+${INSTANCE_FLAGS[*]} }status unit state and /healthz
survive logout and reboot only with lingering on: loginctl enable-linger ${USER:-$(id -un)}
MSG
}
@@ -342,7 +402,7 @@ uninstall_unit() {
# N3: checked with --no-reload too, which skips only daemon-reload.
local state
state="$(running)" || exit 1
[ -z "$state" ] || die "refusing: $UNIT_NAME is still running ($state); Jason or Mos stops it first (systemctl --user stop mosaic-webui)"
[ -z "$state" ] || die "refusing: $UNIT_NAME is still running ($state); Jason or Mos stops it first (systemctl --user stop ${UNIT_NAME%.service})"
if [ -f "$target" ]; then
rm -f "$target"
echo "removed: $target"
@@ -385,14 +445,17 @@ status_unit() {
else
echo "copy: none; the unit runs from $wd"
fi
# render puts --send after the allowed user and optional proof pair,
# before the tester port. The allowed user
# render puts --send after the allowed user and optional proof pair, or
# after --sso, before the tester port. The allowed user
# starts with a letter or digit (N5), so it is never the value --send.
if grep -Eq '^ExecStart=.* --allow-user [^ ][^ ]*( --proof-header [^ ]+ --proof-file /[^ ]+)? --send( --tester-port [0-9]+)?$' "$target"; then
if grep -Eq '^ExecStart=.*( --allow-user [^ ][^ ]*( --proof-header [^ ]+ --proof-file /[^ ]+)?| --sso) --send( --tester-port [0-9]+)?$' "$target"; then
echo "send: on (--send; human messages to roles)"
else
echo "send: off (read-only)"
fi
if grep -Eq '^ExecStart=.* --sso( --send)?( --tester-port [0-9]+)?$' "$target"; then
echo "sign-in: the Console's own (--sso)"
fi
local tester_port
tester_port="$(sed -n 's/^ExecStart=.* --tester-port \([0-9][0-9]*\)$/\1/p' "$target")"
if [ -n "$tester_port" ]; then
@@ -426,6 +489,7 @@ case "$cmd" in
parse_flags "$@"
[ "$DIR_FLAGS" = 0 ] && [ "$ALLOW_WORKTREE" = 0 ] || usage
{ [ -n "$COPY_ID" ] || [ "$COPY_FLAGS" = 0 ]; } || usage
instance_ports_ok
render ;;
install) install_unit "$@" ;;
uninstall) uninstall_unit "$@" ;;