Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df65a3d4c8 | ||
|
|
cdac7ba92b | ||
|
|
ab1d2ee9e6 | ||
|
|
176a6bf4b9 | ||
|
|
5b8def2f14 | ||
|
|
4429a104f6 | ||
|
|
32b54ac3b0 | ||
|
|
6025ddd9cf | ||
|
|
ecbfd6deb6 | ||
|
|
0498c0613f | ||
|
|
49d1f3a231 | ||
|
|
628e2ca5cd |
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
@@ -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`.
|
||||
@@ -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
@@ -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
Generated
+11
-1
@@ -9,7 +9,8 @@
|
||||
"version": "0.0.3",
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-coding-agent": "0.85.1"
|
||||
"@earendil-works/pi-coding-agent": "0.85.1",
|
||||
"oauth4webapi": "3.8.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/sdk": {
|
||||
@@ -1933,6 +1934,15 @@
|
||||
"url": "https://opencollective.com/node-fetch"
|
||||
}
|
||||
},
|
||||
"node_modules/oauth4webapi": {
|
||||
"version": "3.8.8",
|
||||
"resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.8.tgz",
|
||||
"integrity": "sha512-8N28E+a/oxfXWBgOMt+ZP/JUf/XR+IFbvkAEPP3gznXOMv9BpAAwiIj0TFNz3tGTPc0ZQ8zmWBNgN1nAys0gng==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/openai": {
|
||||
"version": "6.40.0",
|
||||
"resolved": "https://registry.npmjs.org/openai/-/openai-6.40.0.tgz",
|
||||
|
||||
+2
-1
@@ -5,6 +5,7 @@
|
||||
"description": "Minimal Mosaic Stack container proof of concept: one Pi agent, four local contract files, one real model request returning MOSAIC_HELLO_OK.",
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-coding-agent": "0.85.1"
|
||||
"@earendil-works/pi-coding-agent": "0.85.1",
|
||||
"oauth4webapi": "3.8.8"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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];
|
||||
|
||||
|
||||
@@ -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'];
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -58,8 +58,9 @@ export async function chatHTTP(req, res, { path, search, chat, named, send, prev
|
||||
if (one(req, 'origin') !== preview.origin) return refuse(403, 'cross-origin');
|
||||
} else {
|
||||
if (!send || !named || !preview) return refuse(403, 'read-only');
|
||||
if (one(req, preview.header) !== preview.user) return refuse(401, 'unauthenticated');
|
||||
if (one(req, 'origin') !== `https://${preview.host}`) return refuse(403, 'cross-origin');
|
||||
// Q175 phase 1 (#1690): under --sso the session was checked before this route.
|
||||
if (preview.sso !== true && one(req, preview.header) !== preview.user) return refuse(401, 'unauthenticated');
|
||||
if (one(req, 'origin') !== (preview.origin ?? `https://${preview.host}`)) return refuse(403, 'cross-origin');
|
||||
}
|
||||
if (one(req, 'sec-fetch-site') !== 'same-origin') return refuse(403, 'cross-site');
|
||||
}
|
||||
|
||||
@@ -7,20 +7,23 @@ import { views } from '../../bus/src/views.mjs';
|
||||
import { loadSystem, socketPath, REPO, rolesDir } from '../../cli/src/config.mjs';
|
||||
import { configDir } from '../../business/src/business.mjs';
|
||||
import { consoleReads, redactor } from './reads.mjs';
|
||||
import { createSso, readSlot } from './sso.mjs';
|
||||
import { statSync } from 'node:fs';
|
||||
import { isAbsolute } from 'node:path';
|
||||
import { ID_PATTERN } from '../../business/src/vocabulary.mjs';
|
||||
const args = process.argv.slice(2);
|
||||
const usage = 'node packages/webui/src/cli.mjs serve [--port N] [--tester-port N] [--checkout PATH] [--board http://127.0.0.1:7331] [--business ID] [--public-host NAME --auth-header HEADER --allow-user ID [--send] [--proof-header NAME --proof-file PATH]]';
|
||||
const usage = 'node packages/webui/src/cli.mjs serve [--port N] [--tester-port N] [--checkout PATH] [--board http://127.0.0.1:7331] [--business ID] [--sso [--send]] [--public-host NAME --auth-header HEADER --allow-user ID [--send] [--proof-header NAME --proof-file PATH]]';
|
||||
try {
|
||||
if (args.length === 1 && ['--help', '-h'].includes(args[0])) { console.log(usage); process.exit(0); }
|
||||
if (args.shift() !== 'serve') throw new Error(usage);
|
||||
const options = { board: DEFAULT_BOARD, port: 7330 }, preview = {}, proofFlags = {};
|
||||
let sendOn = false, testerPort = null;
|
||||
let sendOn = false, ssoOn = false, testerPort = null;
|
||||
while (args.length) {
|
||||
const flag = args.shift();
|
||||
// Row 76 (#1570): the one flag without a value.
|
||||
if (flag === '--send') { sendOn = true; continue; }
|
||||
// Q175 phase 1 (#1690): the Console signs people in itself, from console.sso in config.json.
|
||||
if (flag === '--sso') { ssoOn = true; continue; }
|
||||
const value = args.shift();
|
||||
if (!value) throw new Error(`missing value for ${flag}`);
|
||||
if (flag === '--port') {
|
||||
@@ -51,9 +54,10 @@ try {
|
||||
else throw new Error(`unknown option: ${flag}`);
|
||||
}
|
||||
if (testerPort !== null && (testerPort === options.port || testerPort === 7332)) throw new Error('--tester-port must differ from --port and cannot be the forwarded port 7332');
|
||||
if (ssoOn && (Object.keys(preview).length || Object.keys(proofFlags).length)) throw new Error('--sso cannot be combined with --public-host, --auth-header, --allow-user or --proof-*');
|
||||
options.preview = previewOptions(preview);
|
||||
const proof = proofOptions(proofFlags, options.preview);
|
||||
if (sendOn && !options.preview) throw new Error('--send needs the preview mode (--public-host, --auth-header and --allow-user)');
|
||||
if (sendOn && !options.preview && !ssoOn) throw new Error('--send needs the preview mode (--public-host, --auth-header and --allow-user) or --sso');
|
||||
// The bus views (slice 1 S5) need the system config. Without it the
|
||||
// board still serves and the bus routes answer not-configured.
|
||||
// The Queue, Business and Settings reads (row 54) need no bus; without
|
||||
@@ -77,31 +81,46 @@ try {
|
||||
busLine = `Bus: ${options.business ?? 'the running bus host\'s business'}`;
|
||||
} catch (err) {
|
||||
const why = redactor({ configDir: configDir() })(err.message);
|
||||
if (ssoOn) throw new Error(`--sso needs the system config (${why})`);
|
||||
if (sendOn) throw new Error(`--send needs the bus configured (${why})`);
|
||||
if (testerPort !== null) throw new Error(`--tester-port needs the bus configured (${why})`);
|
||||
busLine = `Bus: not configured (${why})`;
|
||||
}
|
||||
const reads = consoleReads({ root: REPO, queueRoot: options.checkout ?? REPO, system, configDir: configDir(), rolesDir: rolesDir(), business: options.business ?? null });
|
||||
const server = await startServer({ board: options.board, port: options.port, bus, reads, preview: options.preview, proof, send, settingsWrite, projectFiles, projectFile, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite });
|
||||
// The sign-in config is checked again here, then each slot file, before anything listens. A
|
||||
// provider that can't be reached starts degraded; one whose discovery is refused stops the start.
|
||||
let sso = null;
|
||||
if (ssoOn) {
|
||||
if (!system.console?.sso) throw new Error('--sso needs console.sso in the system config');
|
||||
const secrets = new Map(system.console.sso.providers.map(p => [p.id, readSlot(configDir(), p, { repo: REPO, checkout: options.checkout ?? null })]));
|
||||
sso = await createSso(system.console.sso, { secrets });
|
||||
}
|
||||
let server;
|
||||
try {
|
||||
server = await startServer({ board: options.board, port: options.port, bus, reads, preview: options.preview, sso, proof, send, settingsWrite, projectFiles, projectFile, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite });
|
||||
} catch (err) { sso?.close(); throw err; }
|
||||
let testerServer = null;
|
||||
if (testerPort !== null) {
|
||||
const call = busCaller({ dataRoot: system.dataRoot, socket: socketPath(system.dataRoot), business: options.business ?? null, cli: TESTER_CLI });
|
||||
const testerBus = { ...views({ call }), names: () => call('names'), instances: () => call('instances'), routes: () => call('routes') };
|
||||
try {
|
||||
testerServer = await startServer({ host: '127.0.0.1', port: testerPort, board: options.board,
|
||||
tester: true, testerProofHeader: proof?.header ?? null, testerAuthHeader: options.preview?.header ?? null, bus: testerBus, reads,
|
||||
tester: true, testerNoSignIn: ssoOn, testerProofHeader: proof?.header ?? null, testerAuthHeader: options.preview?.header ?? null, bus: testerBus, reads,
|
||||
projectFiles: args => call('project.files', args), projectFile: args => call('project.file', args),
|
||||
instancesWrite: args => call('instances.set', args), routesWrite: args => call('routes.set', args), nameWrite: args => call('name.set', args),
|
||||
// Row 177 (#1675): the tester's own chat, through the tester CLI; 7332's stays the human's.
|
||||
chat: createChat({ call }),
|
||||
});
|
||||
} catch (err) { await new Promise(resolve => server.close(resolve)); throw err; }
|
||||
} catch (err) { sso?.close(); await new Promise(resolve => server.close(resolve)); throw err; }
|
||||
}
|
||||
const previewLine = options.preview ? `\nPreview: https://${options.preview.host}/ through the proxy, signed-in user from ${options.preview.header}, ${send ? 'reads and human messages to roles (--send); one line per send below' : 'read-only'}` : '';
|
||||
const proofLine = `\nProxy: proxy proof ${proof ? 'on' : 'off'}`;
|
||||
// One line per provider: id, issuer, subject count and state. No client id, secret or sub.
|
||||
const health = sso?.health() ?? {};
|
||||
const ssoLines = sso ? `\nSign-in: ${sso.publicOrigin}/ (--sso), ${send ? 'reads and human writes (--send)' : 'read-only'}` + sso.summary().map(p => `\nSign-in provider: ${p.id} issuer ${p.issuer} subjects ${p.subjects} (${health[p.id]})`).join('') : '';
|
||||
const queueLine = options.checkout ? '\nQueue: read from the --checkout directory' : '';
|
||||
console.log(`Mosaic Console: http://127.0.0.1:${server.address().port}/\nBoard: ${options.board}\n${busLine}${queueLine}${previewLine}${proofLine}${testerServer ? `\nTester Console: http://127.0.0.1:${testerServer.address().port}/ (binds the tester)` : ''}\nCtrl-C stops this server.`);
|
||||
for (const signal of ['SIGINT', 'SIGTERM']) process.once(signal, () => { server.close(); testerServer?.close(); });
|
||||
console.log(`Mosaic Console: http://127.0.0.1:${server.address().port}/\nBoard: ${options.board}\n${busLine}${queueLine}${previewLine}${ssoLines}${proofLine}${testerServer ? `\nTester Console: http://127.0.0.1:${testerServer.address().port}/ (binds the tester)` : ''}\nCtrl-C stops this server.`);
|
||||
for (const signal of ['SIGINT', 'SIGTERM']) process.once(signal, () => { sso?.close(); server.close(); testerServer?.close(); });
|
||||
} catch (err) {
|
||||
console.error(`refused: ${err.message}`);
|
||||
process.exitCode = 2;
|
||||
|
||||
@@ -16,6 +16,11 @@
|
||||
// The first one sets body.signed-out (the listener below); after that no Console fetch is made.
|
||||
const out = () => document.body.classList.contains('signed-out');
|
||||
const signOut = () => { if (!out()) document.dispatchEvent(new Event('mosaic-signed-out')); };
|
||||
// Q175 phase 1 (#1690): under --sso the Console itself answers a read without a session with 401
|
||||
// `sign-in required`; that signs the page out too, and the line links to the Console's sign-in.
|
||||
// Any other 401 stays an error. `sso` comes from /api/config.
|
||||
let sso = false;
|
||||
const signInRequired = (res, body) => res.status === 401 && body?.error === 'sign-in required';
|
||||
const signedOut = () => Object.assign(new Error('the sign-in proxy redirected it, so it never reached the Console'), { signedOut: true });
|
||||
const labels = { registration: 'registered', 'first-user-message': 'first message', 'tmux-pane': 'tmux pane', 'session-cwd': 'session cwd', 'workspace-git-root': 'git root' };
|
||||
const source = value => value ? `<span class="source">${esc(labels[value] || value)}</span>` : '';
|
||||
@@ -110,6 +115,7 @@
|
||||
const res = await fetch(path, { cache: 'no-store', redirect: 'manual' });
|
||||
if (res.type === 'opaqueredirect') { signOut(); throw signedOut(); }
|
||||
let body; try { body = await res.json(); } catch { body = { error: `HTTP ${res.status}` }; }
|
||||
if (signInRequired(res, body)) { sso = true; signOut(); throw signedOut(); }
|
||||
return { ok: res.ok, body };
|
||||
}
|
||||
function blockNode(b) {
|
||||
@@ -304,6 +310,7 @@
|
||||
const res = await fetch(path, value === undefined ? { cache: 'no-store', redirect: 'manual' } : { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(value), redirect: 'manual' });
|
||||
if (res.type === 'opaqueredirect') { signOut(); throw signedOut(); }
|
||||
const result = await res.json();
|
||||
if (signInRequired(res, result)) { sso = true; signOut(); throw signedOut(); }
|
||||
if (!res.ok) throw Object.assign(new Error(result.error || `HTTP ${res.status}`), { status: res.status });
|
||||
return result;
|
||||
}
|
||||
@@ -452,7 +459,7 @@
|
||||
});
|
||||
// Signed out: everything drawn from a Console read goes, because none of it can be refreshed. The
|
||||
// line says so once; the send form (bus.js) keeps its draft and receipt. Reload is the way back.
|
||||
document.addEventListener('mosaic-signed-out', () => {
|
||||
document.addEventListener('mosaic-signed-out', e => {
|
||||
if (out()) return;
|
||||
document.body.classList.add('signed-out');
|
||||
clearTimeout(timer); convGen++;
|
||||
@@ -461,9 +468,13 @@
|
||||
for (const id of ['projects', 'waiting', 'seen', 'sessions']) $(id).replaceChildren();
|
||||
$('refresh').disabled = $('pause').disabled = true;
|
||||
const line = $('signed-out');
|
||||
line.innerHTML = '<p><b>Signed out.</b> Reload the page to sign in.</p>'; line.hidden = false;
|
||||
line.innerHTML = sso || e.detail?.signIn
|
||||
? `<p><b>Signed out.</b> <a href="/auth/login?${esc(new URLSearchParams({ return: location.pathname + location.search }))}">Sign in again</a>.</p>`
|
||||
: '<p><b>Signed out.</b> Reload the page to sign in.</p>';
|
||||
line.hidden = false; $('sign-out').hidden = true;
|
||||
// Focus moves to the line only if what had it is gone or disabled, never out of a form being typed in.
|
||||
setTimeout(() => { const a = document.activeElement; if (!a || a === document.body || a.disabled || !a.getClientRects().length) line.focus(); });
|
||||
});
|
||||
api('/api/config').then(config => { board = config.board; $('board-url').textContent = `Board: ${board}`; }).catch(err => { if (machine) error(err); }).finally(refresh);
|
||||
// Sign out (sage-1347, F7): a plain same-origin form POST to /auth/logout, shown only under --sso.
|
||||
api('/api/config').then(config => { board = config.board; $('board-url').textContent = `Board: ${board}`; if (config.sso === true) { sso = true; $('sign-out').hidden = out(); } }).catch(err => { if (machine) error(err); }).finally(refresh);
|
||||
})();
|
||||
|
||||
@@ -30,6 +30,10 @@
|
||||
const out = () => document.body.classList.contains('signed-out');
|
||||
const signOut = () => { if (!out()) document.dispatchEvent(new Event('mosaic-signed-out')); };
|
||||
const signedOut = () => new ReadError('signed-out', 'The sign-in proxy redirected the read.', 0);
|
||||
// Q175 phase 1 (#1690, sage-1340 F1): under --sso a read without a session gets 401 `sign-in
|
||||
// required` from the Console itself. That signs out the same way; any other 401 stays an error.
|
||||
const signInRequired = (res, body) => res.status === 401 && body?.error === 'sign-in required';
|
||||
const signOutSso = () => { if (!out()) document.dispatchEvent(new CustomEvent('mosaic-signed-out', { detail: { signIn: true } })); };
|
||||
// `replies` takes a message id as `id`; trail takes `subject`.
|
||||
async function fetchRead(verb, subject) {
|
||||
if (out()) throw signedOut();
|
||||
@@ -38,6 +42,7 @@
|
||||
catch { throw new ReadError('unreachable', 'The Console server did not answer.', 0); }
|
||||
if (res.type === 'opaqueredirect') { signOut(); throw signedOut(); }
|
||||
try { body = await res.json(); } catch { throw new ReadError('unreachable', 'The Console server did not answer.', 0); }
|
||||
if (signInRequired(res, body)) { signOutSso(); throw signedOut(); }
|
||||
if (!res.ok || !body || !Array.isArray(body.rows)) throw new ReadError(typeof body?.error === 'string' ? body.error : 'invalid-response', typeof body?.message === 'string' ? body.message : `HTTP ${res.status}`, res.status);
|
||||
return { ...body, rows: body.rows, at: body.at };
|
||||
}
|
||||
@@ -50,6 +55,7 @@
|
||||
try { res = await fetch(`/api/${path}`, { cache: 'no-store', redirect: 'manual' }); } catch { throw unreachable(); }
|
||||
if (res.type === 'opaqueredirect') { signOut(); throw signedOut(); }
|
||||
try { body = await res.json(); } catch { throw unreachable(); }
|
||||
if (signInRequired(res, body)) { signOutSso(); throw signedOut(); }
|
||||
if (!res.ok || !body || typeof body !== 'object' || Array.isArray(body)) throw Object.assign(new ReadError(typeof body?.error === 'string' ? body.error : 'invalid-response', typeof body?.message === 'string' ? body.message : `HTTP ${res.status}`, res.status), { module: true });
|
||||
return body;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<header class="cmdbar"><a href="/" class="wordmark"><svg class="mark" viewBox="0 0 16 17" aria-hidden="true"><use href="/icons.svg#mark"/></svg>Mosaic<small>Console</small></a>
|
||||
<button type="button" class="cmdk" id="cmdk" aria-haspopup="dialog" aria-controls="cmdk-dialog"><svg class="i" aria-hidden="true"><use href="/icons.svg#i-search"/></svg><span>Go to a view, decision or task</span><kbd>Ctrl K</kbd></button>
|
||||
<p class="fresh" id="fresh"><span id="fresh-board"></span><span id="fresh-bus"></span></p>
|
||||
<div class="cmd-right"><label>Palette<select id="palette"></select></label><label>Appearance<select id="mode"><option value="system">system</option><option value="light">light</option><option value="dim">dim</option><option value="dark">dark</option></select></label><button class="btn" id="refresh" type="button">Refresh</button><button class="btn" id="pause" type="button" aria-pressed="false">Pause</button></div></header>
|
||||
<div class="cmd-right"><label>Palette<select id="palette"></select></label><label>Appearance<select id="mode"><option value="system">system</option><option value="light">light</option><option value="dim">dim</option><option value="dark">dark</option></select></label><button class="btn" id="refresh" type="button">Refresh</button><button class="btn" id="pause" type="button" aria-pressed="false">Pause</button><form id="sign-out" method="post" action="/auth/logout" hidden><button class="btn" type="submit">Sign out</button></form></div></header>
|
||||
<div class="frame">
|
||||
<nav class="tree" aria-label="Console"><h2 class="tree-title">Console</h2><ul class="s1-sections" id="sections"></ul><div id="board-tree"><h2 class="tree-title">Projects</h2><div id="projects"><p class="muted">Loading projects…</p></div><p class="small muted">Select a project to filter sessions. Waiting on you always shows all projects.</p></div></nav>
|
||||
<main id="main" class="content" tabindex="-1"><div id="signed-out" class="banner ref" role="alert" tabindex="-1" hidden></div><div id="bus-down" class="banner err" role="alert" hidden></div><div class="page-head" id="board-head"><div><h1>Control board</h1><p id="status" class="small muted" role="status">Loading board…</p></div><div class="filters"><label><input id="show-machine" type="checkbox">Show machine sessions</label></div></div>
|
||||
|
||||
@@ -61,6 +61,8 @@ async function body(req, max = 4096) {
|
||||
// Row 65 (mos-0840) adds `routes`: each instance's harness, model, thinking and fallback.
|
||||
const BUS_VERBS = ['inbox', 'tasks', 'agents', 'trail', 'decisions', 'credentials', 'replies', 'messages', 'usage', 'limits', 'names', 'instances', 'routes'];
|
||||
const DECISION_LISTS = ['roles', 'recent'];
|
||||
// Q175 phase 1 (#1690, F8): either sign-in cookie, which the tester listener refuses under --sso.
|
||||
const SIGN_IN_COOKIE = /(?:^|;)\s*__host-mosaic_(?:session|login)\s*=/i;
|
||||
// Row 177 (#1675): the chat writes the tester listener lets through, exactly the four chatHTTP routes.
|
||||
const TESTER_CHAT_WRITE = /^\/api\/chat\/(?:projects|threads|threads\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\/(?:messages|open))$/i;
|
||||
async function busRead(res, bus, verb, search) {
|
||||
@@ -160,14 +162,18 @@ async function busRead(res, bus, verb, search) {
|
||||
// broker's to refuse. Nothing retries; an outcome the bus can't confirm is reported as unknown.
|
||||
const SEND_KEYS = ['to', 'body', 'in_reply_to'];
|
||||
const one = (req, name) => (req.headersDistinct[name]?.length === 1 ? req.headersDistinct[name][0] : null);
|
||||
// Q175 phase 1 (#1690): under --sso the writer comes from a session the handler already checked,
|
||||
// so it carries `sso: true` and the public origin; the preview writer still checks its header.
|
||||
const signedIn = (req, p) => p.sso === true || one(req, p.header) === p.user;
|
||||
const ownOrigin = p => p.origin ?? `https://${p.host}`;
|
||||
async function busSend(req, res, { send, preview, log }) {
|
||||
if (one(req, preview.header) !== preview.user) return json(res, 401, { error: 'sign-in required' });
|
||||
if (!signedIn(req, preview)) return json(res, 401, { error: 'sign-in required' });
|
||||
let to = null;
|
||||
// `to` is quoted and cut short so a crafted value can't forge a log line.
|
||||
const line = outcome => log(`${new Date().toISOString()} send user=${JSON.stringify(preview.user)} to=${typeof to === 'string' ? JSON.stringify(to.slice(0, 64)) : 'none'} ${outcome}`);
|
||||
// `sent` tells the page which it was: false, nothing was written; 'unknown', it may have been.
|
||||
const refuse = (status, error, message, sent = false) => { line(`error=${error}`); return json(res, status, { error, message, sent }); };
|
||||
if (one(req, 'origin') !== `https://${preview.host}`) return refuse(403, 'cross-origin request refused', 'a send needs the Console\'s own Origin');
|
||||
if (one(req, 'origin') !== ownOrigin(preview)) return refuse(403, 'cross-origin request refused', 'a send needs the Console\'s own Origin');
|
||||
if (one(req, 'sec-fetch-site') !== 'same-origin') return refuse(403, 'cross-site request refused', 'a send needs Sec-Fetch-Site: same-origin');
|
||||
let value;
|
||||
try { value = JSON.parse((await body(req, 16384)).toString('utf8')); } catch (err) { return refuse(400, 'invalid-request', err.message); }
|
||||
@@ -241,10 +247,10 @@ export function proofOptions({ proofHeader, proofFile } = {}, preview = null) {
|
||||
// REQ-WEB-1: same signed-in send boundary, a distinct strict settings payload.
|
||||
async function settingsSend(req, res, { settingsWrite, preview, log }) {
|
||||
const refuse = (status, error, message) => json(res, status, { error, message, sent: false });
|
||||
if (one(req, preview.header) !== preview.user) {
|
||||
if (!signedIn(req, preview)) {
|
||||
return refuse(401, 'sign-in required', 'sign in again');
|
||||
}
|
||||
if (one(req, 'origin') !== `https://${preview.host}`) {
|
||||
if (one(req, 'origin') !== ownOrigin(preview)) {
|
||||
return refuse(403, 'cross-origin request refused', 'settings need the Console origin');
|
||||
}
|
||||
if (one(req, 'sec-fetch-site') !== 'same-origin') {
|
||||
@@ -292,8 +298,8 @@ async function seatSend(req, res, { route, write, preview, log }) {
|
||||
// The instance is quoted and cut short so a crafted value can't forge a log line.
|
||||
const line = outcome => log(`${new Date().toISOString()} ${route.kind} user=${JSON.stringify(preview.user)} instance=${quoted(instance)} ${outcome}`);
|
||||
const refuse = (status, error, message, sent = false) => { line(`error=${error}`); return json(res, status, { error, message, sent }); };
|
||||
if (!preview.tester && one(req, preview.header) !== preview.user) return json(res, 401, { error: 'sign-in required', message: 'sign in again', sent: false });
|
||||
if (one(req, 'origin') !== (preview.origin ?? `https://${preview.host}`)) return refuse(403, 'cross-origin request refused', 'this needs the Console\'s own Origin');
|
||||
if (!preview.tester && !signedIn(req, preview)) return json(res, 401, { error: 'sign-in required', message: 'sign in again', sent: false });
|
||||
if (one(req, 'origin') !== ownOrigin(preview)) return refuse(403, 'cross-origin request refused', 'this needs the Console\'s own Origin');
|
||||
if (one(req, 'sec-fetch-site') !== 'same-origin') return refuse(403, 'cross-site request refused', 'this needs same-origin Fetch Metadata');
|
||||
let value;
|
||||
try { value = JSON.parse((await body(req)).toString('utf8')); } catch (err) { return refuse(400, 'invalid-request', err.message); }
|
||||
@@ -335,8 +341,8 @@ async function instanceSend(req, res, { instancesWrite, preview, log }) {
|
||||
// The instance is quoted and cut short so a crafted value can't forge a log line. Vars are never logged.
|
||||
const line = outcome => log(`${new Date().toISOString()} instance user=${JSON.stringify(preview.user)} instance=${quoted(instance)} ${outcome}`);
|
||||
const refuse = (status, error, message, sent = false) => { line(`error=${error}`); return json(res, status, { error, message, sent }); };
|
||||
if (!preview.tester && one(req, preview.header) !== preview.user) return json(res, 401, { error: 'sign-in required', message: 'sign in again', sent: false });
|
||||
if (one(req, 'origin') !== (preview.origin ?? `https://${preview.host}`)) return refuse(403, 'cross-origin request refused', 'this needs the Console\'s own Origin');
|
||||
if (!preview.tester && !signedIn(req, preview)) return json(res, 401, { error: 'sign-in required', message: 'sign in again', sent: false });
|
||||
if (one(req, 'origin') !== ownOrigin(preview)) return refuse(403, 'cross-origin request refused', 'this needs the Console\'s own Origin');
|
||||
if (one(req, 'sec-fetch-site') !== 'same-origin') return refuse(403, 'cross-site request refused', 'this needs same-origin Fetch Metadata');
|
||||
let value;
|
||||
try { value = JSON.parse((await body(req, 16384)).toString('utf8')); } catch (err) { return refuse(400, 'invalid-request', err.message); }
|
||||
@@ -382,8 +388,8 @@ async function routesSend(req, res, { routesWrite, preview, log }) {
|
||||
let names = [];
|
||||
const line = outcome => log(`${new Date().toISOString()} routes user=${JSON.stringify(preview.user)} instances=${quoted(names.join(','))} ${outcome}`);
|
||||
const refuse = (status, error, message, sent = false) => { line(`error=${error}`); return json(res, status, { error, message, sent }); };
|
||||
if (!preview.tester && one(req, preview.header) !== preview.user) return json(res, 401, { error: 'sign-in required', message: 'sign in again', sent: false });
|
||||
if (one(req, 'origin') !== (preview.origin ?? `https://${preview.host}`)) return refuse(403, 'cross-origin request refused', 'this needs the Console\'s own Origin');
|
||||
if (!preview.tester && !signedIn(req, preview)) return json(res, 401, { error: 'sign-in required', message: 'sign in again', sent: false });
|
||||
if (one(req, 'origin') !== ownOrigin(preview)) return refuse(403, 'cross-origin request refused', 'this needs the Console\'s own Origin');
|
||||
if (one(req, 'sec-fetch-site') !== 'same-origin') return refuse(403, 'cross-site request refused', 'this needs same-origin Fetch Metadata');
|
||||
let value;
|
||||
try { value = JSON.parse((await body(req, 16384)).toString('utf8')); } catch (err) { return refuse(400, 'invalid-request', err.message); }
|
||||
@@ -420,8 +426,8 @@ async function projectFileSend(req, res, { projectSave, preview, log }) {
|
||||
const line = outcome => log(`${new Date().toISOString()} project-file user=${JSON.stringify(preview.user)} project=${quoted(target?.project)} path=${quoted(target?.path)} ${outcome}`);
|
||||
// `sent` tells the page which it was: false, nothing was written; 'unknown', it may have been.
|
||||
const refuse = (status, error, message, sent = false) => { line(`error=${error}`); return json(res, status, { error, message, sent }); };
|
||||
if (one(req, preview.header) !== preview.user) return json(res, 401, { error: 'sign-in required', message: 'sign in again', sent: false });
|
||||
if (one(req, 'origin') !== `https://${preview.host}`) return refuse(403, 'cross-origin request refused', 'a save needs the Console\'s own Origin');
|
||||
if (!signedIn(req, preview)) return json(res, 401, { error: 'sign-in required', message: 'sign in again', sent: false });
|
||||
if (one(req, 'origin') !== ownOrigin(preview)) return refuse(403, 'cross-origin request refused', 'a save needs the Console\'s own Origin');
|
||||
if (one(req, 'sec-fetch-site') !== 'same-origin') return refuse(403, 'cross-site request refused', 'a save needs same-origin Fetch Metadata');
|
||||
let value;
|
||||
try { value = JSON.parse((await body(req, FILE_SAVE_MAX)).toString('utf8')); } catch (err) { return refuse(400, 'invalid-request', err.message); }
|
||||
@@ -460,13 +466,17 @@ async function projectFileRead(res, read, path, search) {
|
||||
return json(res, busStatus(code), { error: code, message: err instanceof BusReadError ? err.message : code });
|
||||
}
|
||||
}
|
||||
// `send` is the bus write (row 76): `args => Promise<{id}>`, only with the preview mode. `log`
|
||||
// `send` is the bus write (row 76): `args => Promise<{id}>`, only with the preview mode or `sso`.
|
||||
// `sso` is createSso() from sso.mjs (Q175 phase 1): the human listener signs people in itself.
|
||||
// `testerNoSignIn` makes the tester listener refuse anything that looks like a sign-in (--sso). `log`
|
||||
// takes one line per send attempt that got past sign-in; never the body. `fieldsFile` is for
|
||||
// tests; the field table is otherwise the committed packages/tasks/fields.json.
|
||||
export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null, preview = null, tester = false, testerAuthHeader = null, proof = null, testerProofHeader = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, log = line => console.log(line), fieldsFile = undefined } = {}) {
|
||||
export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null, preview = null, sso = null, tester = false, testerNoSignIn = false, testerAuthHeader = null, proof = null, testerProofHeader = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, log = line => console.log(line), fieldsFile = undefined } = {}) {
|
||||
if (!isLoopback(host)) throw new Error('refusing to bind to non-loopback host');
|
||||
if (host === 'localhost') host = '127.0.0.1';
|
||||
if (tester && preview !== null) throw new Error('tester listener cannot use preview mode');
|
||||
if (tester && sso !== null) throw new Error('tester listener cannot use sso');
|
||||
if (sso !== null && (preview !== null || proof !== null)) throw new Error('--sso cannot be combined with --auth-header, --allow-user or --proof-*');
|
||||
if (tester && host !== '127.0.0.1') throw new Error('tester listener must bind 127.0.0.1');
|
||||
// Row 177 (#1675): chat is allowed. The tester listener's chat binds the tester, so the bus keeps it on tester threads.
|
||||
if (tester && [send, settingsWrite, projectSave, assistantStop].some(value => value !== null)) throw new Error('tester listener permits only instance, route, name and chat writes');
|
||||
@@ -476,21 +486,26 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
throw new Error('preview mode needs --public-host, --auth-header and --allow-user together');
|
||||
}
|
||||
if (proof !== null && (preview === null || tester)) throw new Error('proxy proof needs preview mode');
|
||||
if (send !== null && (preview === null || typeof send !== 'function')) throw new Error('--send needs the preview mode (--public-host, --auth-header and --allow-user)');
|
||||
if (send !== null && ((preview === null && sso === null) || typeof send !== 'function')) throw new Error('--send needs the preview mode (--public-host, --auth-header and --allow-user) or --sso');
|
||||
const server = createServer(async (req, res) => {
|
||||
res.setHeader('x-content-type-options', 'nosniff');
|
||||
res.setHeader('referrer-policy', 'no-referrer');
|
||||
res.setHeader('content-security-policy', "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; font-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'");
|
||||
// Under --sso the Sign out form's POST is redirected to the provider's end-session endpoint, and
|
||||
// browsers apply form-action to that redirect, so those origins (from discovery) are allowed too.
|
||||
res.setHeader('content-security-policy', `default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; font-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'${sso ? sso.formTargets().map(o => ` ${o}`).join('') : ''}`);
|
||||
// Presence, even with an empty value: never turn the public listener into a tester ingress.
|
||||
if (!tester && Object.hasOwn(req.headers, 'x-mosaic-tester')) return json(res, 403, { error: 'tester listener required' });
|
||||
if (tester && testerAuthHeader && Object.hasOwn(req.headers, testerAuthHeader.toLowerCase())) return json(res, 403, { error: 'proxy header refused' });
|
||||
if (tester && testerProofHeader && Object.hasOwn(req.headers, testerProofHeader.toLowerCase())) return json(res, 403, { error: 'proxy proof header refused' });
|
||||
// Under --sso the tester listener reads no cookie and takes no sign-in of any kind (design section 6).
|
||||
if (tester && testerNoSignIn && (Object.hasOwn(req.headers, 'authorization') || SIGN_IN_COOKIE.test(req.headers.cookie ?? '') || Object.keys(req.headers).some(h => h.startsWith('x-authentik-'))))
|
||||
return json(res, 403, { error: 'tester listener takes no sign-in' });
|
||||
let path, search;
|
||||
// Node keeps the first of repeated Host headers; a second one (a loopback Host, then NAME) is
|
||||
// never legitimate, so it is refused before any check reads the Host.
|
||||
if ((req.headersDistinct.host?.length ?? 0) > 1) return json(res, 400, { error: 'repeated Host refused' });
|
||||
// Preview mode: a request for the public name, exactly, as the proxy forwards it.
|
||||
const named = preview !== null && (req.headers.host ?? '').toLowerCase() === preview.host;
|
||||
const named = preview !== null ? (req.headers.host ?? '').toLowerCase() === preview.host : sso !== null && (req.headers.host ?? '').toLowerCase() === sso.publicHost;
|
||||
// The proof precedes every auth-header read, including the wrong-Host fence.
|
||||
if (proof !== null) {
|
||||
const values = req.headersDistinct[proof.header];
|
||||
@@ -512,19 +527,34 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
}
|
||||
({ pathname: path, search } = new URL(req.url, 'http://localhost'));
|
||||
} catch { return json(res, 400, { error: 'invalid URL' }); }
|
||||
// Under --sso the human listener serves only the public Host, whatever its port; GET /healthz
|
||||
// alone stays open on a loopback Host for the status and release gates (Q-C).
|
||||
if (sso !== null && !named && !(path === '/healthz' && req.method === 'GET')) return json(res, 403, { error: 'public Host required' });
|
||||
// No CORS. JSON content type and same-origin checks keep browser forms out.
|
||||
if (req.headers.origin && req.headers.origin !== (named ? `https://${preview.host}` : `http://${req.headers.host}`)) return json(res, 403, { error: 'cross-origin request refused' });
|
||||
if (named) {
|
||||
if (req.headers.origin && req.headers.origin !== (named ? (sso ? sso.publicOrigin : `https://${preview.host}`) : `http://${req.headers.host}`)) return json(res, 403, { error: 'cross-origin request refused' });
|
||||
let signer = preview;
|
||||
if (sso !== null && named) {
|
||||
if (path.startsWith('/auth/')) return sso.handle(req, res, path, search);
|
||||
// Only a page load or a write keeps a session alive; the page's background reads don't (Q-D).
|
||||
const session = path === '/healthz' ? null : sso.session(req, { touch: req.method === 'POST' || (req.method === 'GET' && !path.startsWith('/api/')) });
|
||||
if (!session && path !== '/healthz') {
|
||||
if (path.startsWith('/api/') || req.method !== 'GET') return json(res, 401, { error: 'sign-in required' });
|
||||
res.writeHead(303, { location: `/auth/login?return=${encodeURIComponent(path + search)}`, 'cache-control': 'no-store' });
|
||||
return res.end();
|
||||
}
|
||||
if (session) signer = { sso: true, user: session.name, host: sso.publicHost, origin: sso.publicOrigin };
|
||||
}
|
||||
if (named && preview !== null) {
|
||||
// One value, equal to the user id; the proxy strips client copies and sets it after sign-in.
|
||||
const values = req.headersDistinct[preview.header];
|
||||
if (!values || values.length !== 1 || values[0] !== preview.user) return json(res, 401, { error: 'sign-in required' });
|
||||
}
|
||||
const writer = tester ? { tester: true, user: 'tester', origin: `http://127.0.0.1:${server.address().port}` } : preview;
|
||||
const writer = tester ? { tester: true, user: 'tester', origin: `http://127.0.0.1:${server.address().port}` } : signer;
|
||||
if (tester && !['GET', 'HEAD'].includes(req.method) && !['/api/instances', '/api/routes', '/api/name'].includes(path) && !TESTER_CHAT_WRITE.test(path))
|
||||
return json(res, 403, { error: 'read-only', sent: false });
|
||||
if(path==='/api/settings'&&req.method==='POST'){
|
||||
if(!send||!settingsWrite||!named)return json(res,403,{error:'read-only',sent:false});
|
||||
return settingsSend(req,res,{settingsWrite,preview,log});
|
||||
return settingsSend(req,res,{settingsWrite,preview:signer,log});
|
||||
}
|
||||
if (Object.hasOwn(SEAT_ROUTES, path)) {
|
||||
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' });
|
||||
@@ -544,18 +574,18 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
}
|
||||
if (path === '/api/project-file' && req.method === 'POST') {
|
||||
if (!send || !projectSave || !named) return json(res, 403, { error: 'read-only', sent: false });
|
||||
return projectFileSend(req, res, { projectSave, preview, log });
|
||||
return projectFileSend(req, res, { projectSave, preview: signer, log });
|
||||
}
|
||||
// Message sending has its own bounded payload and repeats the identity check.
|
||||
if (send && path === '/api/bus/message') {
|
||||
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' });
|
||||
return busSend(req, res, { send, preview, log });
|
||||
return busSend(req, res, { send, preview: signer, log });
|
||||
}
|
||||
if (path === '/api/chat' || path.startsWith('/api/chat/')) {
|
||||
return chatHTTP(req, res, { path, search, chat, named, send, preview: writer, log });
|
||||
}
|
||||
// Nothing else but GET and HEAD gets past here in preview mode, whatever the Host.
|
||||
if (preview !== null && req.method !== 'GET' && req.method !== 'HEAD') return json(res, 403, { error: 'read-only', message: 'the Console preview is read-only' });
|
||||
if ((preview !== null || sso !== null) && req.method !== 'GET' && req.method !== 'HEAD') return json(res, 403, { error: 'read-only', message: 'the Console preview is read-only' });
|
||||
// The conversation routes (#1507, CHAT-02) keep their query; the board validates it.
|
||||
const conversation = path === '/api/conversations' || path === '/api/conversation';
|
||||
const allowed = path === '/api/board' || conversation ? 'GET' : ['/api/seen', '/api/reply'].includes(path) ? 'POST' : null;
|
||||
@@ -607,8 +637,8 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') return json(res, 405, { error: 'method not allowed' });
|
||||
// `send`: the page shows the composer only where a send can pass (the public name, --send on).
|
||||
// `edit`: the editor shows Save only where a save can pass, the same test as the route's.
|
||||
if (path === '/api/config') return json(res, 200, { board: upstream, ...(tester ? { tester: true } : {}), send: !tester && Boolean(send) && named, edit: !tester && Boolean(send) && Boolean(projectSave) && named });
|
||||
if (path === '/healthz') return json(res, 200, { ok: true });
|
||||
if (path === '/api/config') return json(res, 200, { board: upstream, ...(tester ? { tester: true } : {}), ...(sso ? { sso: true } : {}), send: !tester && Boolean(send) && named, edit: !tester && Boolean(send) && Boolean(projectSave) && named });
|
||||
if (path === '/healthz') return json(res, 200, { ok: true, ...(sso ? { sso: sso.health() } : {}) });
|
||||
if (path === '/favicon.ico') { res.writeHead(204); return res.end(); }
|
||||
const file = files.get(path);
|
||||
if (!file) return json(res, 404, { error: 'not found' });
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
// Q175 phase 1 (#1690): the shape of config.json's optional `console` key. The one loader,
|
||||
// scripts/mosaic-config.mjs, runs this on every read, so a bad key refuses the config for every
|
||||
// tool (design section 4, C1). The Console's --sso start runs it again before it uses the value.
|
||||
// Shape only: nothing here touches the network or a slot file. A refusal names the key path and
|
||||
// never quotes a value, so a refusal can't print a client id or a subject.
|
||||
|
||||
// The asymmetric algorithms oauth4webapi 3.8.8 verifies (its supported(), build/index.js). No HS*
|
||||
// and no none, so a client secret can never be a signing key.
|
||||
export const ALGS = Object.freeze(['PS256', 'ES256', 'RS256', 'PS384', 'ES384', 'RS384', 'PS512', 'ES512', 'RS512', 'Ed25519', 'EdDSA', 'ML-DSA-44', 'ML-DSA-65', 'ML-DSA-87']);
|
||||
const PROVIDER_ID = /^[a-z0-9-]{1,32}$/;
|
||||
const SLOT = /^[a-z0-9-]{1,64}$/;
|
||||
const SUB = /^[\x21-\x7e]{1,255}$/;
|
||||
const CLIENT_ID = /^[\x21-\x7e]{1,255}$/;
|
||||
// The name goes where the preview's --allow-user went (serve.mjs USER): visible ASCII, no comma.
|
||||
const NAME = /^[\x21-\x2b\x2d-\x7e]{1,64}$/;
|
||||
// The bus keeps these for the tester and the human role (section 6), so no subject can sign in as one.
|
||||
const RESERVED_NAMES = ['tester', 'human'];
|
||||
|
||||
const object = v => typeof v === 'object' && v !== null && !Array.isArray(v);
|
||||
function refuse(where, why) {
|
||||
throw new Error(`${where} ${why}`);
|
||||
}
|
||||
function keys(value, where, allowed, required = allowed) {
|
||||
if (!object(value)) refuse(where, 'must be a JSON object');
|
||||
for (const key of Object.keys(value)) if (!allowed.includes(key)) refuse(`${where}.${key}`, 'is not a supported key');
|
||||
for (const key of required) if (!Object.hasOwn(value, key)) refuse(`${where}.${key}`, 'is required');
|
||||
}
|
||||
function httpsURL(value, where) {
|
||||
let url;
|
||||
if (typeof value !== 'string' || value.length > 512) refuse(where, 'must be an https URL of at most 512 characters');
|
||||
try { url = new URL(value); } catch { refuse(where, 'must be an https URL'); }
|
||||
if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash || value.includes('?') || value.includes('#')) {
|
||||
refuse(where, 'must be https with no credentials, query or fragment');
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
// Returns the value unchanged when it passes; throws an Error naming the bad key path otherwise.
|
||||
export function checkConsole(value, where = 'console') {
|
||||
keys(value, where, ['sso']);
|
||||
const sso = value.sso, at = `${where}.sso`;
|
||||
keys(sso, at, ['publicOrigin', 'providers']);
|
||||
const origin = httpsURL(sso.publicOrigin, `${at}.publicOrigin`);
|
||||
if (origin.origin !== sso.publicOrigin) refuse(`${at}.publicOrigin`, 'must be an https origin with no path, query, fragment or trailing slash');
|
||||
if (!Array.isArray(sso.providers) || sso.providers.length === 0 || sso.providers.length > 8) refuse(`${at}.providers`, 'must be a list of 1 to 8 providers');
|
||||
const ids = new Set();
|
||||
sso.providers.forEach((p, i) => {
|
||||
const pat = `${at}.providers[${i}]`;
|
||||
keys(p, pat, ['id', 'issuer', 'clientId', 'clientSecret', 'algs', 'subjects']);
|
||||
if (typeof p.id !== 'string' || !PROVIDER_ID.test(p.id)) refuse(`${pat}.id`, 'must match [a-z0-9-]{1,32}');
|
||||
if (ids.has(p.id)) refuse(`${pat}.id`, 'repeats an earlier provider id');
|
||||
ids.add(p.id);
|
||||
httpsURL(p.issuer, `${pat}.issuer`);
|
||||
if (typeof p.clientId !== 'string' || !CLIENT_ID.test(p.clientId)) refuse(`${pat}.clientId`, 'must be 1 to 255 visible characters');
|
||||
keys(p.clientSecret, `${pat}.clientSecret`, ['slot']);
|
||||
if (typeof p.clientSecret.slot !== 'string' || !SLOT.test(p.clientSecret.slot)) refuse(`${pat}.clientSecret.slot`, 'must match [a-z0-9-]{1,64}');
|
||||
if (!Array.isArray(p.algs) || p.algs.length === 0) refuse(`${pat}.algs`, 'must be a non-empty list');
|
||||
p.algs.forEach((alg, j) => {
|
||||
if (!ALGS.includes(alg)) refuse(`${pat}.algs[${j}]`, `must be one of ${ALGS.join(', ')}; HS* and none are refused`);
|
||||
});
|
||||
if (new Set(p.algs).size !== p.algs.length) refuse(`${pat}.algs`, 'repeats an algorithm');
|
||||
if (!Array.isArray(p.subjects) || p.subjects.length === 0 || p.subjects.length > 64) refuse(`${pat}.subjects`, 'must be a list of 1 to 64 subjects');
|
||||
const subs = new Set();
|
||||
p.subjects.forEach((s, j) => {
|
||||
const sat = `${pat}.subjects[${j}]`;
|
||||
keys(s, sat, ['sub', 'name']);
|
||||
if (typeof s.sub !== 'string' || !SUB.test(s.sub)) refuse(`${sat}.sub`, 'must be 1 to 255 visible characters');
|
||||
if (subs.has(s.sub)) refuse(`${sat}.sub`, 'repeats an earlier subject');
|
||||
subs.add(s.sub);
|
||||
if (typeof s.name !== 'string' || !NAME.test(s.name)) refuse(`${sat}.name`, 'must be 1 to 64 visible characters');
|
||||
if (RESERVED_NAMES.includes(s.name.toLowerCase())) refuse(`${sat}.name`, `must not be ${RESERVED_NAMES.join(' or ')}`);
|
||||
});
|
||||
});
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
// Q175 phase 1 (#1690, design agents/sage/work/q175-sso/DESIGN.md): the Console as an OIDC relying
|
||||
// party. Used only by the human listener under --sso; startServer refuses it on the tester listener.
|
||||
// Every URL handed to a provider is built from the configured publicOrigin, never from a request's
|
||||
// Host. A refusal logs one fixed code and the provider id, never an exception, its message or its
|
||||
// cause: oauth4webapi's errors carry token bodies and claims there.
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, realpathSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join, relative, isAbsolute, resolve } from 'node:path';
|
||||
import { checkConsole } from './sso-config.mjs';
|
||||
|
||||
// oauth4webapi loads in createSso, not here: cli.mjs imports this module on every start, and a
|
||||
// Console without --sso must not need the package (pinned-copy.test.mjs builds copies whose lock
|
||||
// installs nothing). A real copy installs it from the root lock.
|
||||
let oauth;
|
||||
|
||||
export const SESSION_COOKIE = '__Host-mosaic_session';
|
||||
export const LOGIN_COOKIE = '__Host-mosaic_login';
|
||||
const ATTRS = 'Path=/; Secure; HttpOnly; SameSite=Lax';
|
||||
const LOGIN_MAX_AGE = 600;
|
||||
const SESSION_ABSOLUTE = 8 * 3600 * 1000, SESSION_IDLE = 3600 * 1000;
|
||||
const MAX_SESSIONS = 64, MAX_LOGINS = 32;
|
||||
const FETCH_TIMEOUT = 10000;
|
||||
const RETRY_CAP = 300000;
|
||||
const CODES = new Set(['login-record-missing', 'state-mismatch', 'auth-error-response', 'grant-failed', 'id-token-invalid', 'signature-invalid', 'alg-refused', 'issuer-mismatch', 'subject-unknown', 'discovery-failed', 'jwks-failed']);
|
||||
|
||||
const sha256 = value => createHash('sha256').update(value).digest('hex');
|
||||
const escape = text => String(text).replace(/[&<>"']/g, c => `&#${c.charCodeAt(0)};`);
|
||||
const under = (parent, child) => { const rel = relative(parent, child); return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel)); };
|
||||
|
||||
// The slot file holding a provider's client secret (design section 4, D1). Only the Console's --sso
|
||||
// start calls this. A refusal names the provider and the check, never the path's contents.
|
||||
export function readSlot(configDir, provider, { repo, checkout = null, copies = join(process.env.XDG_DATA_HOME || join(homedir(), '.local', 'share'), 'mosaic-stack', 'copies') } = {}) {
|
||||
const where = `console.sso provider ${provider.id} client secret slot`;
|
||||
const file = join(resolve(configDir), 'secrets', 'mosaic-stack', provider.clientSecret.slot);
|
||||
let st;
|
||||
try { st = lstatSync(file); } catch { throw new Error(`${where} is missing`); }
|
||||
if (st.isSymbolicLink()) throw new Error(`${where} must not be a symbolic link`);
|
||||
let fd, real, text;
|
||||
try {
|
||||
fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
||||
st = fstatSync(fd);
|
||||
if (!st.isFile()) throw new Error(`${where} must be a regular file`);
|
||||
if (st.uid !== process.getuid()) throw new Error(`${where} must be owned by the Console's user`);
|
||||
if (st.mode & 0o077) throw new Error(`${where} must have no group or other permission bits`);
|
||||
real = realpathSync(file);
|
||||
for (const root of [repo, checkout, copies].filter(Boolean)) {
|
||||
let base;
|
||||
try { base = realpathSync(root); } catch { base = resolve(root); }
|
||||
if (under(base, real)) throw new Error(`${where} must not be inside the repository or a stack copy`);
|
||||
}
|
||||
text = readFileSync(fd, 'utf8');
|
||||
} catch (err) {
|
||||
throw err.message?.startsWith(where) ? err : new Error(`${where} is not readable`);
|
||||
} finally {
|
||||
if (fd !== undefined) closeSync(fd);
|
||||
}
|
||||
const secret = text.endsWith('\n') ? text.slice(0, -1) : text;
|
||||
if (!secret || /[\r\n]/.test(secret)) throw new Error(`${where} must hold one non-empty line`);
|
||||
return secret;
|
||||
}
|
||||
|
||||
export function cookies(req, name) {
|
||||
const found = [];
|
||||
for (const part of (req.headers.cookie ?? '').split(';')) {
|
||||
const at = part.indexOf('=');
|
||||
if (at > 0 && part.slice(0, at).trim() === name) found.push(part.slice(at + 1).trim());
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
// The JWKS check at discovery, the same filter the library's key selection uses (kty, alg, use,
|
||||
// key_ops, crv; RSA at least 2048 bits), without a kid since no token is at hand yet.
|
||||
function usableKey(jwk, alg) {
|
||||
const kty = { RS: 'RSA', PS: 'RSA', ES: 'EC', Ed: 'OKP', ML: 'AKP' }[alg.slice(0, 2)];
|
||||
if (jwk === null || typeof jwk !== 'object' || jwk.kty !== kty) return false;
|
||||
if (jwk.alg !== undefined && jwk.alg !== alg) return false;
|
||||
if (jwk.use !== undefined && jwk.use !== 'sig') return false;
|
||||
if (Array.isArray(jwk.key_ops) && !jwk.key_ops.includes('verify')) return false;
|
||||
const crv = { ES256: 'P-256', ES384: 'P-384', ES512: 'P-521', Ed25519: 'Ed25519', EdDSA: 'Ed25519' }[alg];
|
||||
if (crv && jwk.crv !== crv) return false;
|
||||
if (kty === 'RSA' && (typeof jwk.n !== 'string' || Buffer.from(jwk.n, 'base64url').length * 8 < 2048)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
class Unreachable extends Error {}
|
||||
class Refused extends Error {}
|
||||
const https = value => { try { return new URL(value).protocol === 'https:'; } catch { return false; } };
|
||||
|
||||
// One provider's discovery and JWKS. Throws Unreachable (network, timeout, a status other than 200,
|
||||
// a body that isn't JSON) or Refused (a JSON document that fails a check). Q-B, design section 4.
|
||||
async function discover(p, fetchOptions) {
|
||||
let response, text;
|
||||
const options = () => ({ ...fetchOptions, signal: AbortSignal.timeout(FETCH_TIMEOUT) });
|
||||
try {
|
||||
response = await oauth.discoveryRequest(new URL(p.issuer), options());
|
||||
if (response.status !== 200 || !/^application\/json\b/i.test(response.headers.get('content-type') ?? '')) throw new Error();
|
||||
text = await response.text();
|
||||
JSON.parse(text);
|
||||
} catch { throw new Unreachable(); }
|
||||
let as;
|
||||
try {
|
||||
as = await oauth.processDiscoveryResponse(new URL(p.issuer), new Response(text, { status: 200, headers: { 'content-type': 'application/json' } }));
|
||||
} catch { throw new Refused(); }
|
||||
if (as.issuer !== p.issuer) throw new Refused();
|
||||
if (!Array.isArray(as.id_token_signing_alg_values_supported) || !p.algs.some(a => as.id_token_signing_alg_values_supported.includes(a))) throw new Refused();
|
||||
if (!Array.isArray(as.code_challenge_methods_supported) || !as.code_challenge_methods_supported.includes('S256')) throw new Refused();
|
||||
if (![as.authorization_endpoint, as.token_endpoint, as.jwks_uri].every(https)) throw new Refused();
|
||||
if (as.end_session_endpoint !== undefined && !https(as.end_session_endpoint)) throw new Refused();
|
||||
let jwks;
|
||||
try {
|
||||
const fetcher = fetchOptions[oauth.customFetch] ?? fetch;
|
||||
const res = await fetcher(as.jwks_uri, { method: 'GET', headers: { accept: 'application/json, application/jwk-set+json' }, redirect: 'manual', signal: AbortSignal.timeout(FETCH_TIMEOUT) });
|
||||
if (res.status !== 200) throw new Error();
|
||||
jwks = JSON.parse(await res.text());
|
||||
} catch { throw new Unreachable(); }
|
||||
if (jwks === null || typeof jwks !== 'object' || !Array.isArray(jwks.keys)) throw new Refused();
|
||||
const usable = p.algs.filter(alg => jwks.keys.some(k => usableKey(k, alg)));
|
||||
if (!usable.length) throw new Refused();
|
||||
return as;
|
||||
}
|
||||
|
||||
// `config` is console.sso, checked again here; `secrets` maps provider id to its client secret
|
||||
// (readSlot). `fetch` is for tests: a fetch that trusts only the fake OP's certificate.
|
||||
// `retryFirst` is for tests too; production waits 5 s, doubling, capped at 5 minutes.
|
||||
export async function createSso(config, { secrets, fetch: customFetch = null, log = line => console.log(line), retryFirst = 5000 } = {}) {
|
||||
checkConsole({ sso: config });
|
||||
oauth ??= await import('oauth4webapi');
|
||||
const publicOrigin = config.publicOrigin;
|
||||
const publicHost = new URL(publicOrigin).host;
|
||||
const redirectUri = `${publicOrigin}/auth/callback`;
|
||||
const fetchOptions = customFetch ? { [oauth.customFetch]: customFetch } : {};
|
||||
const sessions = new Map(), logins = new Map();
|
||||
const timers = new Set();
|
||||
let closed = false;
|
||||
const refuse = (code, provider, extra = '') => {
|
||||
if (!CODES.has(code)) throw new Error(`unknown sso log code ${code}`);
|
||||
log(`${new Date().toISOString()} sso refused code=${code} provider=${provider}${extra}`);
|
||||
};
|
||||
|
||||
const providers = new Map(config.providers.map(p => {
|
||||
if (typeof secrets?.get(p.id) !== 'string') throw new Error(`console.sso provider ${p.id} has no client secret`);
|
||||
return [p.id, {
|
||||
config: p, state: 'unreachable', as: null, jwks: {},
|
||||
client: { client_id: p.clientId, id_token_signed_response_alg: [...p.algs] },
|
||||
auth: oauth.ClientSecretBasic(secrets.get(p.id)),
|
||||
subjects: new Map(p.subjects.map(s => [s.sub, s.name])),
|
||||
}];
|
||||
}));
|
||||
|
||||
async function attempt(entry, atStart) {
|
||||
try {
|
||||
entry.as = await discover(entry.config, fetchOptions);
|
||||
entry.jwks = {};
|
||||
entry.state = 'ready';
|
||||
} catch (err) {
|
||||
if (err instanceof Refused) {
|
||||
entry.state = 'refused';
|
||||
refuse('discovery-failed', entry.config.id);
|
||||
if (atStart) throw new Error(`console.sso provider ${entry.config.id} refused at discovery: the issuer, algorithms, S256 or JWKS check failed`);
|
||||
return;
|
||||
}
|
||||
entry.state = 'unreachable';
|
||||
refuse('discovery-failed', entry.config.id);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
function retry(entry, delay) {
|
||||
if (closed) return;
|
||||
const timer = setTimeout(async () => {
|
||||
timers.delete(timer);
|
||||
try { await attempt(entry, false); } catch { retry(entry, Math.min(delay * 2, RETRY_CAP)); }
|
||||
}, delay);
|
||||
timer.unref();
|
||||
timers.add(timer);
|
||||
}
|
||||
for (const entry of providers.values()) {
|
||||
try { await attempt(entry, true); } catch (err) {
|
||||
if (!(err instanceof Unreachable)) throw err;
|
||||
retry(entry, retryFirst);
|
||||
}
|
||||
}
|
||||
|
||||
const page = (res, status, title, html, headers = {}) => {
|
||||
res.writeHead(status, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', ...headers });
|
||||
res.end(`<!doctype html><meta charset="utf-8"><title>${escape(title)}</title><main style="font-family:sans-serif;max-width:32rem;margin:4rem auto"><h1>${escape(title)}</h1>${html}</main>\n`);
|
||||
};
|
||||
const redirect = (res, location, setCookie = []) => {
|
||||
res.writeHead(303, { location, 'cache-control': 'no-store', ...(setCookie.length ? { 'set-cookie': setCookie } : {}) });
|
||||
res.end();
|
||||
};
|
||||
const failed = (res, setCookie = []) => page(res, 403, 'Sign-in failed', '<p><a href="/auth/login">Sign in again</a></p>', setCookie.length ? { 'set-cookie': setCookie } : {});
|
||||
const expire = name => `${name}=; ${ATTRS}; Max-Age=0`;
|
||||
const remember = (map, key, value, cap) => {
|
||||
while (map.size >= cap) map.delete(map.keys().next().value);
|
||||
map.set(key, value);
|
||||
};
|
||||
|
||||
// B2: only a URL on publicOrigin itself; anything else, or nothing, is publicOrigin's `/`. The
|
||||
// Location is absolute and its origin checked again: dot segments can leave a pathname of `//host`,
|
||||
// which as a relative Location a browser reads as another origin (webby-0019 B1, sage-1403).
|
||||
function returnURL(ret) {
|
||||
const home = `${publicOrigin}/`;
|
||||
try {
|
||||
if (typeof ret !== 'string' || !ret) return home;
|
||||
const u = new URL(ret, publicOrigin);
|
||||
if (u.origin !== publicOrigin) return home;
|
||||
const out = new URL(`${publicOrigin}${u.pathname}${u.search}`);
|
||||
return out.origin === publicOrigin ? out.href : home;
|
||||
} catch { return home; }
|
||||
}
|
||||
|
||||
async function start(res, entry, ret) {
|
||||
const state = oauth.generateRandomState(), nonce = oauth.generateRandomNonce(), verifier = oauth.generateRandomCodeVerifier();
|
||||
const challenge = await oauth.calculatePKCECodeChallenge(verifier);
|
||||
const id = randomBytes(32).toString('base64url');
|
||||
remember(logins, sha256(id), { provider: entry.config.id, state, nonce, verifier, ret: typeof ret === 'string' ? ret.slice(0, 2048) : '/', expires: Date.now() + LOGIN_MAX_AGE * 1000 }, MAX_LOGINS);
|
||||
const url = new URL(entry.as.authorization_endpoint);
|
||||
for (const [k, v] of Object.entries({ client_id: entry.config.clientId, response_type: 'code', scope: 'openid profile email', redirect_uri: redirectUri, state, nonce, code_challenge: challenge, code_challenge_method: 'S256' })) url.searchParams.set(k, v);
|
||||
return redirect(res, url.href, [`${LOGIN_COOKIE}=${id}; ${ATTRS}; Max-Age=${LOGIN_MAX_AGE}`]);
|
||||
}
|
||||
|
||||
function login(res, query) {
|
||||
const asked = query.get('provider');
|
||||
const ret = query.get('return');
|
||||
if (asked === null && providers.size > 1) {
|
||||
const items = [...providers.values()].map(e => `<li><a href="/auth/login?${escape(new URLSearchParams({ provider: e.config.id, ...(ret ? { return: ret } : {}) }))}">${escape(e.config.id)}</a></li>`).join('');
|
||||
return page(res, 200, 'Sign in', `<ul>${items}</ul>`);
|
||||
}
|
||||
const entry = asked === null ? [...providers.values()][0] : providers.get(asked);
|
||||
if (!entry) return page(res, 404, 'Unknown sign-in provider', '<p><a href="/auth/login">Sign in</a></p>');
|
||||
if (entry.state !== 'ready') return page(res, 503, 'sign-in unavailable', '<p>The sign-in provider can\'t be reached. Try again shortly.</p>');
|
||||
return start(res, entry, ret);
|
||||
}
|
||||
|
||||
// Design section 1, steps 2-7. Each refusal ends here with one log line and the 403 page.
|
||||
async function callback(res, query, req) {
|
||||
const clear = [expire(LOGIN_COOKIE)];
|
||||
const ids = cookies(req, LOGIN_COOKIE);
|
||||
const key = ids.length === 1 ? sha256(ids[0]) : null;
|
||||
const record = key === null ? undefined : logins.get(key);
|
||||
if (key !== null) logins.delete(key);
|
||||
// The provider comes from the record, never from the query.
|
||||
const entry = record && providers.get(record.provider);
|
||||
if (!record || record.expires <= Date.now() || !entry) {
|
||||
refuse('login-record-missing', record?.provider ?? 'none');
|
||||
return failed(res, clear);
|
||||
}
|
||||
const pid = entry.config.id;
|
||||
if (entry.state !== 'ready') { refuse('discovery-failed', pid); return failed(res, clear); }
|
||||
let params;
|
||||
try { params = oauth.validateAuthResponse(entry.as, entry.client, query, record.state); } catch (err) {
|
||||
refuse(err instanceof oauth.AuthorizationResponseError ? 'auth-error-response' : 'state-mismatch', pid);
|
||||
return failed(res, clear);
|
||||
}
|
||||
const options = () => ({ ...fetchOptions, [oauth.jwksCache]: entry.jwks, signal: AbortSignal.timeout(FETCH_TIMEOUT) });
|
||||
let response;
|
||||
try { response = await oauth.authorizationCodeGrantRequest(entry.as, entry.client, entry.auth, params, redirectUri, record.verifier, options()); } catch {
|
||||
refuse('grant-failed', pid); return failed(res, clear);
|
||||
}
|
||||
let result, claims;
|
||||
try {
|
||||
result = await oauth.processAuthorizationCodeResponse(entry.as, entry.client, response, { expectedNonce: record.nonce, requireIdToken: true });
|
||||
claims = oauth.getValidatedIdTokenClaims(result);
|
||||
} catch (err) {
|
||||
refuse(err instanceof oauth.ResponseBodyError ? 'grant-failed' : 'id-token-invalid', pid);
|
||||
return failed(res, clear);
|
||||
}
|
||||
// Step 5: the library skips this for a token-endpoint ID token; point 2 needs it, on the same
|
||||
// Response object. Its throw ends the callback.
|
||||
try { await oauth.validateApplicationLevelSignature(entry.as, response, options()); } catch (err) {
|
||||
const fetching = !(err instanceof oauth.OperationProcessingError || err instanceof oauth.UnsupportedOperationError) || [oauth.RESPONSE_IS_NOT_CONFORM, oauth.RESPONSE_IS_NOT_JSON].includes(err.code);
|
||||
refuse(fetching ? 'jwks-failed' : 'signature-invalid', pid);
|
||||
return failed(res, clear);
|
||||
}
|
||||
// Step 6: our own checks on top of the library's.
|
||||
let header;
|
||||
try { header = JSON.parse(Buffer.from(result.id_token.split('.')[0], 'base64url').toString('utf8')); } catch { header = null; }
|
||||
if (!header || !entry.config.algs.includes(header.alg)) { refuse('alg-refused', pid); return failed(res, clear); }
|
||||
if (claims.iss !== entry.config.issuer) { refuse('issuer-mismatch', pid); return failed(res, clear); }
|
||||
const name = typeof claims.sub === 'string' ? entry.subjects.get(claims.sub) : undefined;
|
||||
if (name === undefined) {
|
||||
refuse('subject-unknown', pid, ` iss=${JSON.stringify(String(claims.iss).slice(0, 255))} sub=${JSON.stringify(String(claims.sub).slice(0, 255))}`);
|
||||
return failed(res, clear);
|
||||
}
|
||||
// Step 7: the session, then the return path.
|
||||
const id = randomBytes(32).toString('base64url'), now = Date.now();
|
||||
remember(sessions, sha256(id), { provider: pid, iss: claims.iss, sub: claims.sub, name, idToken: result.id_token, created: now, seen: now }, MAX_SESSIONS);
|
||||
return redirect(res, returnURL(record.ret), [...clear, `${SESSION_COOKIE}=${id}; ${ATTRS}`]);
|
||||
}
|
||||
|
||||
// The session for this request, or null. `touch` is true only for a page load or a write (Q-D).
|
||||
function session(req, { touch = false } = {}) {
|
||||
const ids = cookies(req, SESSION_COOKIE);
|
||||
if (ids.length !== 1) return null;
|
||||
const key = sha256(ids[0]);
|
||||
const s = sessions.get(key);
|
||||
if (!s) return null;
|
||||
const now = Date.now();
|
||||
if (now - s.created >= SESSION_ABSOLUTE || now - s.seen >= SESSION_IDLE) { sessions.delete(key); return null; }
|
||||
if (touch) s.seen = now;
|
||||
return s;
|
||||
}
|
||||
|
||||
function logout(req, res) {
|
||||
const one = name => (req.headersDistinct[name]?.length === 1 ? req.headersDistinct[name][0] : null);
|
||||
if (one('origin') !== publicOrigin || one('sec-fetch-site') !== 'same-origin') {
|
||||
return page(res, 403, 'Sign-out refused', '<p>Sign out from the Console page.</p>');
|
||||
}
|
||||
const ids = cookies(req, SESSION_COOKIE);
|
||||
const key = ids.length === 1 ? sha256(ids[0]) : null;
|
||||
const s = key === null ? undefined : sessions.get(key);
|
||||
if (key !== null) sessions.delete(key);
|
||||
const entry = s && providers.get(s.provider);
|
||||
let location = `${publicOrigin}/auth/signed-out`;
|
||||
if (entry?.as?.end_session_endpoint) {
|
||||
const url = new URL(entry.as.end_session_endpoint);
|
||||
url.searchParams.set('id_token_hint', s.idToken);
|
||||
url.searchParams.set('post_logout_redirect_uri', `${publicOrigin}/auth/signed-out`);
|
||||
url.searchParams.set('client_id', entry.config.clientId);
|
||||
location = url.href;
|
||||
}
|
||||
return redirect(res, location, [expire(SESSION_COOKIE)]);
|
||||
}
|
||||
|
||||
// `/auth/*` on the human listener; the caller has already checked the Host.
|
||||
async function handle(req, res, path, search) {
|
||||
const query = new URLSearchParams(search);
|
||||
if (path === '/auth/logout') {
|
||||
if (req.method !== 'POST') return page(res, 405, 'Method not allowed', '');
|
||||
return logout(req, res);
|
||||
}
|
||||
if (req.method !== 'GET') return page(res, 405, 'Method not allowed', '');
|
||||
if (path === '/auth/login') return login(res, query);
|
||||
if (path === '/auth/callback') return callback(res, query, req);
|
||||
if (path === '/auth/signed-out') return page(res, 200, 'Signed out', '<p><a href="/auth/login">Sign in</a></p>');
|
||||
return page(res, 404, 'Not found', '');
|
||||
}
|
||||
|
||||
return {
|
||||
publicOrigin, publicHost, sessions, logins, handle, session, returnURL,
|
||||
health: () => Object.fromEntries([...providers].map(([id, e]) => [id, e.state])),
|
||||
formTargets: () => [...new Set([...providers.values()].filter(e => e.as?.end_session_endpoint).map(e => new URL(e.as.end_session_endpoint).origin))],
|
||||
summary: () => config.providers.map(p => ({ id: p.id, issuer: p.issuer, subjects: p.subjects.length })),
|
||||
close() { closed = true; for (const t of timers) clearTimeout(t); timers.clear(); },
|
||||
};
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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(); }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
// Q175 phase 1 (#1690, design test 24, sage-1340 F1, sage-1347 F7): the page under --sso. A read
|
||||
// the Console answers 401 `sign-in required` signs the page out the way a proxy redirect does, and
|
||||
// the line links to /auth/login with the current path and query; any other 401 stays an error. Both
|
||||
// fetch paths in app.js and both in bus.js (fetchRead and fetchApi) do it. The Sign out form shows
|
||||
// only when /api/config says sso. The board-held.test.mjs stub, with two lists of paths: those in
|
||||
// window.__signIn answer 401 `sign-in required`, those in window.__other 401 `unauthenticated`. A
|
||||
// page URL holding `sso` gets `sso: true` added to /api/config. Poll timers fire after 150 ms.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { Store } from '../../bus/src/store.mjs';
|
||||
import { Broker } from '../../bus/src/broker.mjs';
|
||||
import { views } from '../../bus/src/views.mjs';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
import { browser } from './browser.mjs';
|
||||
import { fixture, close } from './fixture.mjs';
|
||||
|
||||
const businesses = { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'cto', delivery: 'pm' }, roles: { pm: { authority: { withinRole: ['message.send'], crossRole: [] } }, cto: { authority: { withinRole: ['message.send'], crossRole: [] } } } } };
|
||||
|
||||
const stub = `(() => {
|
||||
const real = window.fetch.bind(window);
|
||||
window.__reqs = []; window.__signIn = []; window.__other = [];
|
||||
const timeout = window.setTimeout.bind(window);
|
||||
window.setTimeout = (f, ms, ...rest) => timeout(f, ms === 10000 ? 150 : ms, ...rest);
|
||||
const answer = (status, body) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
|
||||
window.fetch = async (url, init = {}) => {
|
||||
const path = String(url).split('?')[0];
|
||||
window.__reqs.push(path);
|
||||
if (window.__signIn.includes(path)) return answer(401, { error: 'sign-in required' });
|
||||
if (window.__other.includes(path)) return answer(401, { error: 'unauthenticated' });
|
||||
if (path === '/api/config' && /sso/.test(location.search)) return answer(200, { ...(await (await real(url, init)).json()), sso: true });
|
||||
return real(url, init);
|
||||
};
|
||||
})();`;
|
||||
|
||||
async function setup() {
|
||||
const root = mkdtempSync(join(tmpdir(), 'webui-sso-browser-'));
|
||||
const store = new Store(root), b0 = new Broker({ store, businesses });
|
||||
b0.request(b0.bindLaunch({ business: 'demo', role: 'pm', run: 'pm-run', harness: 'pi', address: 'pm-run' }), { verb: 'role.claim' });
|
||||
const reader = b0.bindReader({ business: 'demo' });
|
||||
const bus = views({ call: async (verb, args = {}) => structuredClone(b0.request(reader, { verb, args })) });
|
||||
const f = await fixture(), b = await browser({ machine: true });
|
||||
const web = await startServer({ port: 0, board: f.boardURL, bus }), base = `http://127.0.0.1:${web.address().port}/`;
|
||||
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: stub });
|
||||
await b.viewport(1280);
|
||||
const wait = expr => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expr})return true;await new Promise(r=>setTimeout(r,50))}throw new Error('Condition timed out: '+${JSON.stringify(expr)})})()`);
|
||||
const text = sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.textContent ?? null`);
|
||||
const shown = sel => b.evaluate(`(()=>{const e=document.querySelector(${JSON.stringify(sel)});return !!e && !e.hidden && getComputedStyle(e).display !== 'none' && !!e.getClientRects().length})()`);
|
||||
const reqs = () => b.evaluate('window.__reqs.length');
|
||||
const settle = () => new Promise(r => setTimeout(r, 600));
|
||||
const done = async () => { await b.close(); await close(web); await f.close(); store.close(); rmSync(root, { recursive: true, force: true }); };
|
||||
return { b, base, wait, text, shown, reqs, settle, done };
|
||||
}
|
||||
|
||||
const LOADED = 'document.querySelector("table.sessions [data-open=\'proj/agent1\']")';
|
||||
const LINE = 'Signed out. Sign in again.';
|
||||
|
||||
test('a 401 sign-in required on each fetch path signs the page out with a sign-in link and stops the refresh; another 401 stays an error', { timeout: 120000 }, async () => {
|
||||
const { b, base, wait, text, shown, reqs, settle, done } = await setup();
|
||||
// The line, its link back to this page, the Sign out form gone, and no further fetch.
|
||||
const out = async ret => {
|
||||
await wait('!document.querySelector("#signed-out").hidden');
|
||||
assert.equal(await text('#signed-out'), LINE);
|
||||
assert.equal(await b.evaluate('document.querySelector("#signed-out a").getAttribute("href")'), `/auth/login?return=${encodeURIComponent(ret)}`);
|
||||
assert.equal(await shown('#sign-out'), false);
|
||||
assert.equal(await b.evaluate('document.querySelector("#refresh").disabled'), true);
|
||||
const n = await reqs();
|
||||
await b.evaluate('document.querySelector("#refresh").click(), true');
|
||||
await settle();
|
||||
assert.equal(await reqs(), n, 'the page fetched after signing out');
|
||||
};
|
||||
try {
|
||||
// app.js api(): the Board read, with sso on from /api/config.
|
||||
await b.navigate(`${base}?sso&x=1`);
|
||||
await wait(LOADED);
|
||||
await b.evaluate(`window.__signIn.push('/api/board'), document.querySelector('#refresh').click(), true`);
|
||||
await out('/?sso&x=1');
|
||||
|
||||
// app.js getJSON: the conversation catalogue behind History.
|
||||
await b.navigate(`${base}?sso&conv`);
|
||||
await wait(LOADED);
|
||||
await b.evaluate(`window.__signIn.push('/api/conversations'), document.querySelector('table.sessions [data-history="proj/agent1"]').click(), true`);
|
||||
await out('/?sso&conv');
|
||||
|
||||
// app.js api(): the board POST behind Seen.
|
||||
await b.navigate(`${base}?sso&seen`);
|
||||
await wait(LOADED);
|
||||
await b.evaluate(`document.querySelector('table.sessions [data-open="proj/agent1"]').click(), true`);
|
||||
await wait('document.querySelector("#inspection [data-seen]")');
|
||||
await b.evaluate(`window.__signIn.push('/api/seen'), document.querySelector('#inspection [data-seen]').click(), true`);
|
||||
await out('/?sso&seen');
|
||||
|
||||
// bus.js fetchRead and fetchApi, on a page whose /api/config has no sso: the link comes from
|
||||
// the 401 itself (F1).
|
||||
await b.navigate(`${base}?read#/inbox`);
|
||||
await wait(LOADED);
|
||||
await b.evaluate(`window.__signIn.push('/api/bus/inbox'), document.querySelector('#refresh').click(), true`);
|
||||
await out('/?read');
|
||||
await b.navigate(`${base}?module`);
|
||||
await wait(LOADED);
|
||||
await b.evaluate(`window.__signIn.push('/api/queue'), location.hash = '#/queue', true`);
|
||||
await out('/?module');
|
||||
|
||||
// Any other 401 is an error: no sign-out, and the polls go on.
|
||||
await b.navigate(`${base}?sso&other`);
|
||||
await wait(LOADED);
|
||||
await b.evaluate(`window.__other.push('/api/board', '/api/bus/inbox', '/api/queue'), document.querySelector('#refresh').click(), true`);
|
||||
await settle();
|
||||
assert.equal(await b.evaluate('document.querySelector("#signed-out").hidden'), true);
|
||||
assert.equal(await b.evaluate('document.body.classList.contains("signed-out")'), false);
|
||||
const n = await reqs();
|
||||
await settle();
|
||||
assert.ok(await reqs() > n, 'the refresh stopped on another 401');
|
||||
assert.equal(await shown('#sign-out'), true);
|
||||
} finally { await done(); }
|
||||
});
|
||||
|
||||
test('the Sign out form shows only when /api/config says sso, and posts to /auth/logout', { timeout: 120000 }, async () => {
|
||||
const { b, base, wait, shown, done } = await setup();
|
||||
try {
|
||||
await b.navigate(base);
|
||||
await wait(LOADED);
|
||||
await new Promise(r => setTimeout(r, 300));
|
||||
assert.equal(await shown('#sign-out'), false);
|
||||
await b.navigate(`${base}?sso`);
|
||||
await wait(`${LOADED} && !document.querySelector("#sign-out").hidden`);
|
||||
assert.equal(await shown('#sign-out'), true);
|
||||
assert.deepEqual(await b.evaluate('(() => { const f = document.querySelector("#sign-out"); return [f.method, f.getAttribute("action"), f.querySelector("button").textContent, f.querySelector("button").type]; })()'), ['post', '/auth/logout', 'Sign out', 'submit']);
|
||||
} finally { await done(); }
|
||||
});
|
||||
@@ -0,0 +1,589 @@
|
||||
// Q175 phase 1 (#1690, design agents/sage/work/q175-sso/DESIGN.md section 9): the Console as an
|
||||
// OIDC relying party (--sso). Every provider is the fake OP in helpers/fake-oidc.mjs on loopback;
|
||||
// no test reaches a real provider, the live Console or a real config or slot file. The public name
|
||||
// is console.example.test, sent only as a Host header to the test's own loopback listener. Each
|
||||
// refusal checks the status, that no session cookie was set and that the send stub never ran.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { request } from 'node:http';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { chmodSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
import { createSso, readSlot, SESSION_COOKIE, LOGIN_COOKIE } from '../src/sso.mjs';
|
||||
import { close } from './fixture.mjs';
|
||||
import { b64url, ecKey, fakeOidc, jwt, psKey, rsaKey } from './helpers/fake-oidc.mjs';
|
||||
|
||||
const HOST = 'console.example.test', ORIGIN = `https://${HOST}`;
|
||||
const CLI = fileURLToPath(new URL('../src/cli.mjs', import.meta.url));
|
||||
const MSG = JSON.stringify({ to: 'coder', body: 'Please look at row 192.' });
|
||||
// Test 19: every log line any test here captured, and every token, code, state, nonce, verifier,
|
||||
// client secret and session id any test produced. The last test checks the one against the other.
|
||||
const ALL_LINES = [], PRODUCED = new Set();
|
||||
|
||||
// node:http, not fetch: fetch can't set Host, Origin or Sec-Fetch-Site.
|
||||
function call(port, { method = 'GET', path = '/', headers = {}, body, host = HOST } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const length = body === undefined ? {} : { 'content-length': Buffer.byteLength(body) };
|
||||
const req = request({ host: '127.0.0.1', port, method, path, headers: { host, ...length, ...headers } }, res => {
|
||||
let text = '';
|
||||
res.on('data', c => (text += c));
|
||||
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: text && (res.headers['content-type'] ?? '').startsWith('application/json') ? JSON.parse(text) : text }));
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.end(body);
|
||||
});
|
||||
}
|
||||
const setCookies = r => [r.headers['set-cookie'] ?? []].flat();
|
||||
const sessionOf = r => setCookies(r).find(c => c.startsWith(`${SESSION_COOKIE}=`) && !c.includes('Max-Age=0'))?.split(';')[0] ?? null;
|
||||
const writeHeaders = cookie => ({ ...(cookie ? { cookie } : {}), origin: ORIGIN, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' });
|
||||
|
||||
function providerFor(iss, extra = {}) {
|
||||
return { id: iss.name, issuer: iss.issuer, clientId: iss.clientId, clientSecret: { slot: iss.name }, algs: [...new Set(iss.keys.map(k => k.alg))], subjects: [{ sub: `user-${iss.name}`, name: `person-${iss.name}` }], ...extra };
|
||||
}
|
||||
|
||||
// A fake OP with `issuers`, createSso over them, and the human listener with a counting send.
|
||||
async function setup(t, { issuers = [{ name: 'a' }], provider = {}, before = null, retryFirst = 5000, server = {} } = {}) {
|
||||
const op = await fakeOidc();
|
||||
t.after(() => op.close());
|
||||
const iss = issuers.map(o => op.issuer(o.name, o));
|
||||
for (const i of iss) PRODUCED.add(i.clientSecret);
|
||||
before?.(op, iss);
|
||||
const config = { publicOrigin: ORIGIN, providers: iss.map(i => providerFor(i, provider[i.name] ?? {})) };
|
||||
const lines = [];
|
||||
const log = line => { lines.push(line); ALL_LINES.push(line); };
|
||||
const sso = await createSso(config, { secrets: new Map(iss.map(i => [i.name, i.clientSecret])), fetch: op.fetch, log, retryFirst });
|
||||
t.after(() => sso.close());
|
||||
const sends = [];
|
||||
const bus = Object.fromEntries(['inbox', 'tasks', 'agents', 'trail', 'decisions'].map(v => [v, async () => [{ verb: v }]]));
|
||||
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', bus, sso, send: async args => { sends.push(args); return { id: 'msg-0001' }; }, log, ...server });
|
||||
t.after(() => close(web));
|
||||
return { op, iss, sso, port: web.address().port, lines, sends, config, bus };
|
||||
}
|
||||
|
||||
// GET /auth/login; returns the authorize URL and the login cookie (name=value).
|
||||
async function begin(env, { provider = null, ret = null, query = null, host = HOST } = {}) {
|
||||
const q = query ?? new URLSearchParams({ ...(provider ? { provider } : {}), ...(ret !== null ? { return: ret } : {}) }).toString();
|
||||
const r = await call(env.port, { path: `/auth/login${q ? `?${q}` : ''}`, host });
|
||||
assert.equal(r.status, 303, `login start: ${r.status} ${r.body}`);
|
||||
const cookie = setCookies(r)[0].split(';')[0];
|
||||
const location = r.headers.location;
|
||||
const params = new URL(location).searchParams;
|
||||
PRODUCED.add(cookie.split('=')[1]);
|
||||
for (const k of ['state', 'nonce', 'code_challenge']) PRODUCED.add(params.get(k));
|
||||
for (const record of env.sso.logins.values()) PRODUCED.add(record.verifier);
|
||||
return { r, cookie, location, params };
|
||||
}
|
||||
function callback(env, begun, { code, state = begun.params.get('state'), extra = {}, cookie = begun.cookie } = {}) {
|
||||
const q = new URLSearchParams({ ...(code !== undefined ? { code } : {}), state, ...extra });
|
||||
return call(env.port, { path: `/auth/callback?${q}`, headers: cookie ? { cookie } : {} });
|
||||
}
|
||||
function grant(env, begun, iss, opts = {}) {
|
||||
const g = env.op.grant(iss, begun.location, opts);
|
||||
PRODUCED.add(g.code);
|
||||
PRODUCED.add(g.idToken);
|
||||
return g;
|
||||
}
|
||||
// A whole sign-in: start, a code at the OP, the callback. Returns the callback answer and the
|
||||
// session cookie (name=value) when one was set.
|
||||
async function signIn(env, { iss = env.iss[0], ret = null, opts = {} } = {}) {
|
||||
const begun = await begin(env, { provider: env.iss.length > 1 ? iss.name : null, ret });
|
||||
const g = grant(env, begun, iss, opts);
|
||||
const r = await callback(env, begun, { code: g.code });
|
||||
const session = sessionOf(r);
|
||||
if (session) PRODUCED.add(session.split('=')[1]);
|
||||
return { begun, g, r, session };
|
||||
}
|
||||
function refused(env, r, code) {
|
||||
assert.equal(r.status, 403, `expected 403, got ${r.status}`);
|
||||
assert.equal(sessionOf(r), null, 'a session cookie was set');
|
||||
assert.equal(env.sends.length, 0, 'the send stub ran');
|
||||
assert.ok(setCookies(r).includes(`${LOGIN_COOKIE}=; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=0`), 'the login cookie was not expired');
|
||||
if (code) assert.match(env.lines.at(-1), new RegExp(` sso refused code=${code} provider=`), env.lines.at(-1));
|
||||
}
|
||||
|
||||
test('positive: an RS256 provider signs in and sends one message, and the session reads', async t => {
|
||||
const env = await setup(t);
|
||||
assert.notEqual(env.port, 7332);
|
||||
const { r, session } = await signIn(env, { ret: '/board?x=1' });
|
||||
assert.equal(r.status, 303);
|
||||
assert.equal(r.headers.location, `${ORIGIN}/board?x=1`);
|
||||
assert.ok(session);
|
||||
const send = await call(env.port, { method: 'POST', path: '/api/bus/message', headers: writeHeaders(session), body: MSG });
|
||||
assert.equal(send.status, 200, JSON.stringify(send.body));
|
||||
assert.deepEqual(env.sends, [{ to: 'coder', body: 'Please look at row 192.' }]);
|
||||
assert.match(env.lines.at(-1), / send user="person-a" to="coder" id=msg-0001$/);
|
||||
const read = await call(env.port, { path: '/api/bus/inbox', headers: { cookie: session } });
|
||||
assert.equal(read.status, 200);
|
||||
const config = await call(env.port, { path: '/api/config', headers: { cookie: session } });
|
||||
assert.equal(config.body.sso, true);
|
||||
assert.equal(config.body.send, true);
|
||||
});
|
||||
|
||||
test('positive: an ES256 provider signs in and sends', async t => {
|
||||
const env = await setup(t, { issuers: [{ name: 'e', keys: [ecKey()] }] });
|
||||
assert.deepEqual(env.config.providers[0].algs, ['ES256']);
|
||||
const { session } = await signIn(env);
|
||||
assert.ok(session);
|
||||
assert.equal((await call(env.port, { method: 'POST', path: '/api/bus/message', headers: writeHeaders(session), body: MSG })).status, 200);
|
||||
assert.equal(env.sends.length, 1);
|
||||
});
|
||||
|
||||
test('positive: two providers each sign in their own subjects, and one provider\'s subject is unknown to the other', async t => {
|
||||
const env = await setup(t, { issuers: [{ name: 'a' }, { name: 'b', keys: [ecKey()] }] });
|
||||
const list = await call(env.port, { path: '/auth/login?return=%2Fboard' });
|
||||
assert.equal(list.status, 200);
|
||||
assert.match(list.body, /provider=a&return=%2Fboard/);
|
||||
assert.match(list.body, /provider=b&return=%2Fboard/);
|
||||
const [a, b] = env.iss;
|
||||
assert.ok((await signIn(env, { iss: a })).session);
|
||||
assert.ok((await signIn(env, { iss: b })).session);
|
||||
const names = [...env.sso.sessions.values()].map(s => s.name);
|
||||
assert.deepEqual(names, ['person-a', 'person-b']);
|
||||
const cross = await signIn(env, { iss: b, opts: { sub: 'user-a' } });
|
||||
refused({ ...env, sends: [] }, cross.r, 'subject-unknown');
|
||||
});
|
||||
|
||||
test('1: a forged x-authentik header without a cookie is 401 on the API and a sign-in redirect on a page', async t => {
|
||||
const env = await setup(t);
|
||||
const forged = { 'x-authentik-username': 'jason.woltje', origin: ORIGIN, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' };
|
||||
for (const [method, path, body] of [['POST', '/api/bus/message', MSG], ['POST', '/api/settings', '{}'], ['GET', '/api/config', undefined]]) {
|
||||
const r = await call(env.port, { method, path, headers: forged, body });
|
||||
assert.equal(r.status, 401, `${method} ${path}`);
|
||||
assert.deepEqual(r.body, { error: 'sign-in required' });
|
||||
}
|
||||
const page = await call(env.port, { path: '/', headers: forged });
|
||||
assert.equal(page.status, 303);
|
||||
assert.equal(page.headers.location, '/auth/login?return=%2F');
|
||||
const deep = await call(env.port, { path: '/board?x=1' });
|
||||
assert.equal(deep.headers.location, '/auth/login?return=%2Fboard%3Fx%3D1');
|
||||
assert.equal(env.sends.length, 0);
|
||||
});
|
||||
|
||||
test('2: an HS256 ID token keyed with the client secret and a real RSA kid is refused', async t => {
|
||||
const env = await setup(t);
|
||||
const [iss] = env.iss;
|
||||
const { r } = await signIn(env, { opts: { header: { alg: 'HS256', kid: iss.keys[0].kid } } });
|
||||
refused(env, r, 'id-token-invalid');
|
||||
});
|
||||
|
||||
test('3: a wrong aud, and an aud list holding another client with no azp, are refused', async t => {
|
||||
const env = await setup(t);
|
||||
refused(env, (await signIn(env, { opts: { claims: { aud: 'another-client' } } })).r, 'id-token-invalid');
|
||||
refused(env, (await signIn(env, { opts: { claims: { aud: [env.iss[0].clientId, 'another-client'] } } })).r, 'id-token-invalid');
|
||||
});
|
||||
|
||||
test('4: a token that expired 10 minutes ago is refused', async t => {
|
||||
const env = await setup(t);
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
refused(env, (await signIn(env, { opts: { claims: { iat: now - 900, exp: now - 600 } } })).r, 'id-token-invalid');
|
||||
});
|
||||
|
||||
test('5: another issuer, and the issuer without its trailing slash, are refused', async t => {
|
||||
const env = await setup(t);
|
||||
const [iss] = env.iss;
|
||||
refused(env, (await signIn(env, { opts: { claims: { iss: `${env.op.base}/other/` } } })).r, 'id-token-invalid');
|
||||
refused(env, (await signIn(env, { opts: { claims: { iss: iss.issuer.slice(0, -1) } } })).r, 'id-token-invalid');
|
||||
});
|
||||
|
||||
test('6: a nonce mismatch and a missing nonce are refused', async t => {
|
||||
const env = await setup(t);
|
||||
refused(env, (await signIn(env, { opts: { claims: { nonce: 'not-the-nonce' } } })).r, 'id-token-invalid');
|
||||
refused(env, (await signIn(env, { opts: { claims: { nonce: undefined } } })).r, 'id-token-invalid');
|
||||
});
|
||||
|
||||
test('7: state mismatch, no login cookie, a replay, an expired record and two login cookies are refused', async t => {
|
||||
const env = await setup(t);
|
||||
const [iss] = env.iss;
|
||||
let begun = await begin(env);
|
||||
refused(env, await callback(env, begun, { code: grant(env, begun, iss).code, state: 'wrong-state' }), 'state-mismatch');
|
||||
begun = await begin(env);
|
||||
refused(env, await callback(env, begun, { code: grant(env, begun, iss).code, cookie: null }), 'login-record-missing');
|
||||
// Replay: the OP takes the code twice, so only the take-and-delete rule refuses the second callback.
|
||||
iss.acceptTwice = true;
|
||||
begun = await begin(env);
|
||||
const g = grant(env, begun, iss);
|
||||
const first = await callback(env, begun, { code: g.code });
|
||||
assert.equal(first.status, 303);
|
||||
PRODUCED.add(sessionOf(first).split('=')[1]);
|
||||
refused(env, await callback(env, begun, { code: g.code }), 'login-record-missing');
|
||||
iss.acceptTwice = false;
|
||||
begun = await begin(env);
|
||||
for (const record of env.sso.logins.values()) record.expires = Date.now() - 1;
|
||||
refused(env, await callback(env, begun, { code: grant(env, begun, iss).code }), 'login-record-missing');
|
||||
begun = await begin(env);
|
||||
const other = await begin(env);
|
||||
refused(env, await callback(env, begun, { code: grant(env, begun, iss).code, cookie: `${begun.cookie}; ${other.cookie}` }), 'login-record-missing');
|
||||
});
|
||||
|
||||
test('8: the token request carries the PKCE verifier, and the OP refuses a wrong one', async t => {
|
||||
const env = await setup(t);
|
||||
const [iss] = env.iss;
|
||||
const { r, begun } = await signIn(env);
|
||||
assert.equal(r.status, 303);
|
||||
const { form } = iss.tokenRequests.at(-1);
|
||||
assert.equal(begun.params.get('code_challenge_method'), 'S256');
|
||||
const { createHash } = await import('node:crypto');
|
||||
assert.equal(createHash('sha256').update(form.code_verifier).digest('base64url'), begun.params.get('code_challenge'));
|
||||
refused(env, (await signIn(env, { opts: { challenge: 'not-the-challenge' } })).r, 'grant-failed');
|
||||
});
|
||||
|
||||
test('9: alg none, a changed payload under a real kid, and an unknown kid even after the refetch are refused', async t => {
|
||||
const env = await setup(t);
|
||||
const [iss] = env.iss;
|
||||
// (a) alg none: the alg rule, before any signature check.
|
||||
refused(env, (await signIn(env, { opts: { header: { alg: 'none' } } })).r, 'id-token-invalid');
|
||||
// (b) a real kid's signature over another payload: only the signature check catches it.
|
||||
let begun = await begin(env);
|
||||
const real = env.op.grant(iss, begun.location);
|
||||
PRODUCED.add(real.idToken);
|
||||
const [h, , s] = real.idToken.split('.');
|
||||
const forged = `${h}.${b64url({ ...real.payload, name: 'changed' })}.${s}`;
|
||||
refused(env, await callback(env, begun, { code: grant(env, begun, iss, { idToken: forged }).code }), 'signature-invalid');
|
||||
// (c) a key the JWKS never held, again after the cache is old enough to refetch.
|
||||
t.mock.timers.enable({ apis: ['Date'], now: Date.now() });
|
||||
const stranger = rsaKey('not-in-jwks');
|
||||
refused(env, (await signIn(env, { opts: { key: stranger } })).r, 'signature-invalid');
|
||||
const fetches = iss.jwksFetches;
|
||||
t.mock.timers.tick(61000);
|
||||
refused(env, (await signIn(env, { opts: { key: stranger } })).r, 'signature-invalid');
|
||||
assert.equal(iss.jwksFetches, fetches + 1, 'no refetch');
|
||||
});
|
||||
|
||||
test('10: a valid token for a subject not configured is 403 with one subject-unknown line', async t => {
|
||||
const env = await setup(t);
|
||||
const before = env.lines.length;
|
||||
const { r } = await signIn(env, { opts: { sub: 'stranger' } });
|
||||
refused(env, r, 'subject-unknown');
|
||||
const added = env.lines.slice(before);
|
||||
assert.equal(added.length, 1);
|
||||
assert.match(added[0], new RegExp(`^\\S+ sso refused code=subject-unknown provider=a iss=${JSON.stringify(env.iss[0].issuer).replace(/[.*+?^${}()|[\]\\/]/g, '\\$&')} sub="stranger"$`));
|
||||
});
|
||||
|
||||
test('11: start refusals: HS or none algs, reserved names, HS-only discovery, an empty JWKS, no S256, an issuer mismatch, a short RSA key', async t => {
|
||||
const op = await fakeOidc();
|
||||
t.after(() => op.close());
|
||||
const start = (iss, extra = {}) => createSso({ publicOrigin: ORIGIN, providers: [providerFor(iss, extra)] }, { secrets: new Map([[iss.name, iss.clientSecret]]), fetch: op.fetch, log: l => ALL_LINES.push(l) });
|
||||
const a = op.issuer('a');
|
||||
PRODUCED.add(a.clientSecret);
|
||||
for (const [extra, why] of [
|
||||
[{ algs: ['HS256'] }, /algs\[0\] must be one of .*HS\* and none are refused/],
|
||||
[{ algs: ['none'] }, /algs\[0\] must be one of/],
|
||||
[{ subjects: [{ sub: 'u', name: 'tester' }] }, /subjects\[0\]\.name must not be tester or human/],
|
||||
[{ subjects: [{ sub: 'u', name: 'Human' }] }, /subjects\[0\]\.name must not be tester or human/],
|
||||
]) await assert.rejects(start(a, extra), why);
|
||||
const refusedAtDiscovery = /console\.sso provider \w+ refused at discovery/;
|
||||
// The mosaic-preview shape: HS256 only.
|
||||
const hs = op.issuer('hs', { discovery: { id_token_signing_alg_values_supported: ['HS256'] } });
|
||||
await assert.rejects(start(hs, { algs: ['RS256'] }), refusedAtDiscovery);
|
||||
const empty = op.issuer('empty');
|
||||
empty.jwksOverride = { keys: [] };
|
||||
await assert.rejects(start(empty), refusedAtDiscovery);
|
||||
const plain = op.issuer('plain', { discovery: { code_challenge_methods_supported: ['plain'] } });
|
||||
await assert.rejects(start(plain), refusedAtDiscovery);
|
||||
const moved = op.issuer('moved');
|
||||
moved.discovery = { issuer: `${op.base}/elsewhere/` };
|
||||
await assert.rejects(start(moved), refusedAtDiscovery);
|
||||
const small = op.issuer('small', { keys: [rsaKey('small', 1024)] });
|
||||
await assert.rejects(start(small), refusedAtDiscovery);
|
||||
// The control: the same OP and provider start.
|
||||
const sso = await start(a);
|
||||
assert.deepEqual(sso.health(), { a: 'ready' });
|
||||
sso.close();
|
||||
await assert.rejects(startServer({ port: 0, sso, preview: { host: HOST, header: 'remote-user', user: 'jason' } }), /--sso cannot be combined/);
|
||||
});
|
||||
|
||||
test('11: slot files: a symlink, mode 0644, mode 0640 and a file under the repository are refused, and no message holds the value', t => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'webui-sso-slot-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const dir = join(root, 'config'), slots = join(dir, 'secrets', 'mosaic-stack');
|
||||
mkdirSync(slots, { recursive: true });
|
||||
const provider = slot => ({ id: 'a', clientSecret: { slot } });
|
||||
const value = 'slot-value-do-not-print';
|
||||
const opts = { repo: join(root, 'repo'), copies: join(root, 'copies') };
|
||||
writeFileSync(join(slots, 'good'), `${value}\n`, { mode: 0o600 });
|
||||
assert.equal(readSlot(dir, provider('good'), opts), value);
|
||||
writeFileSync(join(root, 'target'), value, { mode: 0o600 });
|
||||
symlinkSync(join(root, 'target'), join(slots, 'link'));
|
||||
for (const [slot, mode] of [['wide', 0o644], ['group', 0o640]]) { writeFileSync(join(slots, slot), value); chmodSync(join(slots, slot), mode); }
|
||||
writeFileSync(join(slots, 'two'), `${value}\nmore\n`, { mode: 0o600 });
|
||||
writeFileSync(join(slots, 'empty'), '\n', { mode: 0o600 });
|
||||
const cases = [
|
||||
['missing', /slot is missing$/, opts],
|
||||
['link', /must not be a symbolic link$/, opts],
|
||||
['wide', /must have no group or other permission bits$/, opts],
|
||||
['group', /must have no group or other permission bits$/, opts],
|
||||
['two', /must hold one non-empty line$/, opts],
|
||||
['empty', /must hold one non-empty line$/, opts],
|
||||
// The config dir itself under the repository, and under a --checkout.
|
||||
['good', /must not be inside the repository or a stack copy$/, { ...opts, repo: root }],
|
||||
['good', /must not be inside the repository or a stack copy$/, { ...opts, checkout: dir }],
|
||||
['good', /must not be inside the repository or a stack copy$/, { ...opts, copies: dir }],
|
||||
];
|
||||
for (const [slot, why, o] of cases) {
|
||||
let err = null;
|
||||
try { readSlot(dir, provider(slot), o); } catch (e) { err = e; }
|
||||
assert.ok(err, `${slot} was read`);
|
||||
assert.match(err.message, /^console\.sso provider a client secret slot /);
|
||||
assert.match(err.message, why, slot);
|
||||
assert.equal(err.message.includes(value), false);
|
||||
assert.equal(err.message.includes(root), false);
|
||||
}
|
||||
});
|
||||
|
||||
test('11: the CLI refuses --sso with the old flags and without console.sso, and starts with a good one', async t => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'wsso-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const env = { ...process.env, MOSAIC_CONFIG: join(root, 'cfg', 'config.json'), XDG_DATA_HOME: join(root, 'xdg') };
|
||||
for (const flags of [['--auth-header', 'remote-user'], ['--public-host', HOST], ['--allow-user', 'jason'], ['--proof-header', 'x-proof']]) {
|
||||
const r = spawnSync(process.execPath, [CLI, 'serve', '--port', '0', '--sso', ...flags], { encoding: 'utf8', timeout: 10000, env });
|
||||
assert.equal(r.status, 2, flags.join(' '));
|
||||
assert.match(r.stderr, /^refused: --sso cannot be combined with --public-host, --auth-header, --allow-user or --proof-\*$/m);
|
||||
}
|
||||
const missing = spawnSync(process.execPath, [CLI, 'serve', '--port', '0', '--sso'], { encoding: 'utf8', timeout: 10000, env });
|
||||
assert.equal(missing.status, 2);
|
||||
assert.match(missing.stderr, /^refused: --sso needs the system config \(/);
|
||||
const op = await fakeOidc();
|
||||
t.after(() => op.close());
|
||||
const a = op.issuer('a');
|
||||
PRODUCED.add(a.clientSecret);
|
||||
mkdirSync(join(root, 'cfg', 'secrets', 'mosaic-stack'), { recursive: true });
|
||||
writeFileSync(join(root, 'ca.pem'), op.cert);
|
||||
const base = { configVersion: 1, environment: 'development', dataRoot: join(root, 'data'), execution: { backend: 'docker', provider: 'zai', model: 'm' } };
|
||||
writeFileSync(env.MOSAIC_CONFIG, JSON.stringify(base));
|
||||
const noKey = spawnSync(process.execPath, [CLI, 'serve', '--port', '0', '--sso'], { encoding: 'utf8', timeout: 10000, env });
|
||||
assert.equal(noKey.status, 2);
|
||||
assert.match(noKey.stderr, /^refused: --sso needs console\.sso in the system config$/m);
|
||||
writeFileSync(env.MOSAIC_CONFIG, JSON.stringify({ ...base, console: { sso: { publicOrigin: ORIGIN, providers: [providerFor(a)] } } }));
|
||||
writeFileSync(join(root, 'cfg', 'secrets', 'mosaic-stack', 'a'), `${a.clientSecret}\n`, { mode: 0o644 });
|
||||
const wide = spawnSync(process.execPath, [CLI, 'serve', '--port', '0', '--sso'], { encoding: 'utf8', timeout: 10000, env });
|
||||
assert.equal(wide.status, 2);
|
||||
assert.match(wide.stderr, /^refused: console\.sso provider a client secret slot must have no group or other permission bits$/m);
|
||||
chmodSync(join(root, 'cfg', 'secrets', 'mosaic-stack', 'a'), 0o600);
|
||||
// The child trusts the fake OP's certificate through NODE_EXTRA_CA_CERTS; the code under test has no flag for it.
|
||||
const child = spawn(process.execPath, [CLI, 'serve', '--port', '0', '--sso'], { env: { ...env, NODE_EXTRA_CA_CERTS: join(root, 'ca.pem') } });
|
||||
t.after(() => child.kill('SIGKILL'));
|
||||
let out = '', err = '';
|
||||
child.stderr.on('data', c => (err += c));
|
||||
await new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error(`no start line: ${out} ${err}`)), 15000);
|
||||
child.stdout.on('data', c => { out += c; if (out.includes('Ctrl-C')) { clearTimeout(timer); resolve(); } });
|
||||
child.on('exit', () => { clearTimeout(timer); reject(new Error(`exited: ${err}`)); });
|
||||
});
|
||||
assert.match(out, new RegExp(`^Sign-in: ${ORIGIN}/ \\(--sso\\), read-only$`, 'm'));
|
||||
assert.match(out, new RegExp(`^Sign-in provider: a issuer ${a.issuer.replace(/[.]/g, '\\.')} subjects 1 \\(ready\\)$`, 'm'));
|
||||
assert.equal(out.includes(a.clientSecret) || err.includes(a.clientSecret), false);
|
||||
assert.equal(out.includes(a.clientId), false);
|
||||
const port = Number(out.match(/^Mosaic Console: http:\/\/127\.0\.0\.1:(\d+)\//m)[1]);
|
||||
const health = await call(port, { path: '/healthz', host: `127.0.0.1:${port}` });
|
||||
assert.deepEqual(health.body, { ok: true, sso: { a: 'ready' } });
|
||||
child.kill('SIGTERM');
|
||||
});
|
||||
|
||||
test('12: under --sso the tester listener refuses each kind of sign-in, has no /auth, and startServer refuses tester with sso', async t => {
|
||||
const env = await setup(t);
|
||||
const tester = await startServer({ port: 0, board: 'http://127.0.0.1:9', bus: env.bus, tester: true, testerNoSignIn: true, log: () => {} });
|
||||
t.after(() => close(tester));
|
||||
const port = tester.address().port, host = `127.0.0.1:${port}`;
|
||||
const { session, g, begun } = await signIn(env);
|
||||
assert.ok(session);
|
||||
const plain = await call(port, { path: '/api/config', host });
|
||||
assert.equal(plain.status, 200, 'control: a request with no sign-in is served');
|
||||
for (const headers of [
|
||||
{ cookie: session },
|
||||
{ cookie: `theme=dark; ${begun.cookie}` },
|
||||
{ cookie: `${SESSION_COOKIE.toLowerCase()}=x` },
|
||||
{ authorization: `Bearer ${g.idToken}` },
|
||||
{ 'x-authentik-jwt': g.idToken },
|
||||
{ 'X-Authentik-Username': 'jason.woltje' },
|
||||
]) {
|
||||
const r = await call(port, { path: '/api/config', host, headers });
|
||||
assert.equal(r.status, 403, JSON.stringify(Object.keys(headers)));
|
||||
assert.deepEqual(r.body, { error: 'tester listener takes no sign-in' });
|
||||
}
|
||||
assert.equal((await call(port, { path: '/auth/login', host })).status, 404);
|
||||
await assert.rejects(startServer({ port: 0, tester: true, sso: env.sso }), /tester listener cannot use sso/);
|
||||
});
|
||||
|
||||
test('13: idle and absolute expiry, logout then reuse, a cross-origin logout, and background reads that don\'t keep a session', async t => {
|
||||
const env = await setup(t);
|
||||
const read = cookie => call(env.port, { path: '/api/bus/inbox', headers: { cookie } });
|
||||
const record = () => [...env.sso.sessions.values()].at(-1);
|
||||
// Background reads don't move `seen`; a page load does.
|
||||
let { session } = await signIn(env);
|
||||
const old = Date.now() - 59 * 60 * 1000;
|
||||
record().seen = old;
|
||||
assert.equal((await read(session)).status, 200);
|
||||
assert.equal(record().seen, old, 'a background read refreshed the session');
|
||||
assert.equal((await call(env.port, { path: '/', headers: { cookie: session } })).status, 200);
|
||||
assert.ok(record().seen > old, 'a page load did not refresh the session');
|
||||
// Idle: 59 minutes of background reads, then one more past the hour.
|
||||
record().seen = Date.now() - 61 * 60 * 1000;
|
||||
assert.equal((await read(session)).status, 401);
|
||||
// Absolute.
|
||||
({ session } = await signIn(env));
|
||||
record().created = Date.now() - 8 * 3600 * 1000;
|
||||
assert.equal((await call(env.port, { path: '/', headers: { cookie: session } })).headers.location, '/auth/login?return=%2F');
|
||||
// Logout needs same-origin; refused, the session stays.
|
||||
({ session } = await signIn(env));
|
||||
const cross = await call(env.port, { method: 'POST', path: '/auth/logout', headers: { cookie: session, origin: ORIGIN, 'sec-fetch-site': 'cross-site' } });
|
||||
assert.equal(cross.status, 403);
|
||||
assert.equal((await read(session)).status, 200);
|
||||
const noOrigin = await call(env.port, { method: 'POST', path: '/auth/logout', headers: { cookie: session, 'sec-fetch-site': 'same-origin' } });
|
||||
assert.equal(noOrigin.status, 403);
|
||||
assert.equal((await read(session)).status, 200);
|
||||
const out = await call(env.port, { method: 'POST', path: '/auth/logout', headers: { cookie: session, origin: ORIGIN, 'sec-fetch-site': 'same-origin' } });
|
||||
assert.equal(out.status, 303);
|
||||
const end = new URL(out.headers.location);
|
||||
assert.equal(`${end.origin}${end.pathname}`, `${env.iss[0].issuer}end-session`);
|
||||
assert.equal(end.searchParams.get('post_logout_redirect_uri'), `${ORIGIN}/auth/signed-out`);
|
||||
assert.equal(end.searchParams.get('client_id'), env.iss[0].clientId);
|
||||
assert.ok(end.searchParams.get('id_token_hint'));
|
||||
assert.deepEqual(setCookies(out), [`${SESSION_COOKIE}=; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=0`]);
|
||||
assert.equal((await read(session)).status, 401);
|
||||
assert.equal((await call(env.port, { method: 'GET', path: '/auth/logout', headers: { cookie: session } })).status, 405);
|
||||
});
|
||||
|
||||
test('13: the CSP lets the Sign out form reach the provider\'s end-session origin, and only under --sso', async t => {
|
||||
const env = await setup(t);
|
||||
const r = await call(env.port, { path: '/healthz', host: `127.0.0.1:${env.port}` });
|
||||
assert.match(r.headers['content-security-policy'], new RegExp(`; form-action 'self' ${env.op.base.replace(/[.]/g, '\\.')}$`));
|
||||
const plain = await startServer({ port: 0, board: 'http://127.0.0.1:9' });
|
||||
t.after(() => close(plain));
|
||||
const p = await call(plain.address().port, { path: '/healthz', host: `127.0.0.1:${plain.address().port}` });
|
||||
assert.match(p.headers['content-security-policy'], /; form-action 'self'$/);
|
||||
});
|
||||
|
||||
test('14: open redirects land on /, and a path on the Console lands there', async t => {
|
||||
const env = await setup(t);
|
||||
for (const [query, where] of [
|
||||
['return=%2F%2Fevil.example', `${ORIGIN}/`],
|
||||
['return=https%3A%2F%2Fevil.example', `${ORIGIN}/`],
|
||||
['return=%2F%5Cevil.example', `${ORIGIN}/`],
|
||||
['return=/%09/evil.example', `${ORIGIN}/`],
|
||||
// Dot segments that leave a pathname of //evil.example stay on the Console (webby-0019 B1).
|
||||
['return=%2F.%2F%2Fevil.example', `${ORIGIN}//evil.example`],
|
||||
['return=%2Fx%2F..%2F%2Fevil.example', `${ORIGIN}//evil.example`],
|
||||
[`return=${encodeURIComponent(`${ORIGIN}//evil.example`)}`, `${ORIGIN}//evil.example`],
|
||||
['return=%2Fboard%2F.%2Fx', `${ORIGIN}/board/x`],
|
||||
['return=%2Fboard%3Fx%3D1', `${ORIGIN}/board?x=1`],
|
||||
['', `${ORIGIN}/`],
|
||||
]) {
|
||||
const begun = await begin(env, { query });
|
||||
const r = await callback(env, begun, { code: grant(env, begun, env.iss[0]).code });
|
||||
assert.equal(r.status, 303);
|
||||
assert.equal(r.headers.location, where, query);
|
||||
assert.equal(new URL(r.headers.location).origin, ORIGIN, query);
|
||||
PRODUCED.add(sessionOf(r).split('=')[1]);
|
||||
}
|
||||
});
|
||||
|
||||
test('15: a callback for A carrying B\'s code and iss, and a provider= query, is refused', async t => {
|
||||
const env = await setup(t, { issuers: [{ name: 'a' }, { name: 'b' }] });
|
||||
const [, b] = env.iss;
|
||||
for (const extra of [{ iss: b.issuer, provider: 'b' }, { provider: 'b' }]) {
|
||||
const begun = await begin(env, { provider: 'a' });
|
||||
// B's code against A's PKCE challenge and nonce, for B's own subject: only the record rule
|
||||
// (this callback belongs to A) stands in the way.
|
||||
const g = grant(env, begun, b, { sub: 'user-b' });
|
||||
const r = await callback(env, begun, { code: g.code, extra });
|
||||
refused(env, r);
|
||||
}
|
||||
assert.equal(b.tokenRequests.length, 0, 'B\'s token endpoint was called');
|
||||
});
|
||||
|
||||
test('16: a provider with RS256 and ES256 takes either, and refuses PS256 with a usable PS256 key in the JWKS', async t => {
|
||||
const rsa = rsaKey(), ec = ecKey(), ps = psKey();
|
||||
const env = await setup(t, { issuers: [{ name: 'a', keys: [rsa, ec, ps] }], provider: { a: { algs: ['RS256', 'ES256'] } } });
|
||||
assert.ok((await signIn(env, { opts: { key: rsa } })).session);
|
||||
assert.ok((await signIn(env, { opts: { key: ec } })).session);
|
||||
refused(env, (await signIn(env, { opts: { key: ps } })).r, 'id-token-invalid');
|
||||
});
|
||||
|
||||
test('17: 33 started logins leave 32 records, and the first one\'s callback finds none', async t => {
|
||||
const env = await setup(t);
|
||||
const first = await begin(env);
|
||||
for (let i = 0; i < 32; i++) await begin(env);
|
||||
assert.equal(env.sso.logins.size, 32);
|
||||
refused(env, await callback(env, first, { code: grant(env, first, env.iss[0]).code }), 'login-record-missing');
|
||||
});
|
||||
|
||||
test('18: after a key rotation a sign-in works through one refetch, and the removed kid is refused', async t => {
|
||||
const env = await setup(t);
|
||||
const [iss] = env.iss;
|
||||
t.mock.timers.enable({ apis: ['Date'], now: Date.now() });
|
||||
const old = iss.keys[0];
|
||||
assert.ok((await signIn(env)).session);
|
||||
const fetches = iss.jwksFetches;
|
||||
env.op.rotate(iss);
|
||||
t.mock.timers.tick(61000);
|
||||
assert.ok((await signIn(env)).session);
|
||||
assert.equal(iss.jwksFetches, fetches + 1);
|
||||
refused(env, (await signIn(env, { opts: { key: old } })).r, 'signature-invalid');
|
||||
});
|
||||
|
||||
test('20 and 21: the store holds no raw session id, and the cookie strings are exact', async t => {
|
||||
const env = await setup(t);
|
||||
const begun = await begin(env);
|
||||
assert.match(setCookies(begun.r)[0], new RegExp(`^${LOGIN_COOKIE}=[A-Za-z0-9_-]{43}; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=600$`));
|
||||
for (const key of env.sso.logins.keys()) assert.match(key, /^[0-9a-f]{64}$/);
|
||||
const r = await callback(env, begun, { code: grant(env, begun, env.iss[0]).code });
|
||||
const set = setCookies(r);
|
||||
assert.equal(set.length, 2);
|
||||
assert.equal(set[0], `${LOGIN_COOKIE}=; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=0`);
|
||||
assert.match(set[1], new RegExp(`^${SESSION_COOKIE}=[A-Za-z0-9_-]{43}; Path=/; Secure; HttpOnly; SameSite=Lax$`));
|
||||
const id = set[1].split(';')[0].split('=')[1];
|
||||
PRODUCED.add(id);
|
||||
for (const [key, value] of env.sso.sessions) {
|
||||
assert.match(key, /^[0-9a-f]{64}$/);
|
||||
assert.notEqual(key, id);
|
||||
assert.equal(JSON.stringify(value).includes(id), false);
|
||||
}
|
||||
});
|
||||
|
||||
test('22: a loopback Host is 403 on a page, a read and a write; /healthz on loopback is 200; a redirect_uri never comes from the Host', async t => {
|
||||
const env = await setup(t);
|
||||
const { session } = await signIn(env);
|
||||
const loop = `127.0.0.1:${env.port}`;
|
||||
for (const [method, path, body] of [['GET', '/', undefined], ['GET', '/api/board', undefined], ['GET', '/api/bus/inbox', undefined], ['POST', '/api/bus/message', MSG]]) {
|
||||
// A loopback Host on this listener's port; any other Host is already the older non-local refusal.
|
||||
for (const host of [loop, `localhost:${env.port}`]) {
|
||||
const r = await call(env.port, { method, path, host, body, headers: { cookie: session, 'content-type': 'application/json' } });
|
||||
assert.equal(r.status, 403, `${method} ${path} ${host}`);
|
||||
assert.deepEqual(r.body, { error: 'public Host required' });
|
||||
}
|
||||
}
|
||||
const health = await call(env.port, { path: '/healthz', host: loop });
|
||||
assert.equal(health.status, 200);
|
||||
assert.deepEqual(health.body, { ok: true, sso: { a: 'ready' } });
|
||||
assert.equal(env.sends.length, 0);
|
||||
const begun = await begin(env, { host: HOST.toUpperCase() });
|
||||
assert.equal(begun.params.get('redirect_uri'), `${ORIGIN}/auth/callback`);
|
||||
});
|
||||
|
||||
test('23: a provider down at start leaves the Console up and 503, and the retry makes sign-in work', async t => {
|
||||
let env = await setup(t, { retryFirst: 50, before: (op, [a]) => { a.mode = 'down'; } });
|
||||
assert.equal((await call(env.port, { path: '/auth/login' })).status, 503);
|
||||
assert.deepEqual((await call(env.port, { path: '/healthz', host: `127.0.0.1:${env.port}` })).body.sso, { a: 'unreachable' });
|
||||
assert.match(env.lines.at(-1), / sso refused code=discovery-failed provider=a$/);
|
||||
env.iss[0].mode = 'ok';
|
||||
for (let i = 0; i < 100 && env.sso.health().a !== 'ready'; i++) await new Promise(r => setTimeout(r, 20));
|
||||
assert.deepEqual(env.sso.health(), { a: 'ready' });
|
||||
assert.ok((await signIn(env)).session);
|
||||
// A 200 HTML page at the discovery URL is unreachable too, and the Console starts.
|
||||
env = await setup(t, { before: (op, [a]) => { a.mode = 'html'; } });
|
||||
assert.deepEqual(env.sso.health(), { a: 'unreachable' });
|
||||
// HS-only discovery at start refuses the start.
|
||||
await assert.rejects(setup(t, { before: (op, [a]) => { a.discovery = { id_token_signing_alg_values_supported: ['HS256'] }; } }), /refused at discovery/);
|
||||
});
|
||||
|
||||
test('19: no captured log line holds a token, code, state, nonce, verifier, client secret or session id', () => {
|
||||
assert.ok(ALL_LINES.length > 20, `only ${ALL_LINES.length} lines`);
|
||||
assert.ok(PRODUCED.size > 50, `only ${PRODUCED.size} values`);
|
||||
for (const line of ALL_LINES) {
|
||||
for (const value of PRODUCED) if (value) assert.equal(line.includes(value), false, `a log line holds a produced value: ${line.slice(0, 80)}`);
|
||||
assert.match(line, /^\S+ (sso refused code=[a-z-]+ provider=[a-z0-9-]+( iss="[^"]*" sub="[^"]*")?|send user="[^"]+" to="[^"]*" (id=msg-0001|error=[a-z -]+))$/, line);
|
||||
}
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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=()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 "$@" ;;
|
||||
|
||||
Reference in New Issue
Block a user