Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9df40878fc | ||
|
|
d395b6c95b | ||
|
|
46d5db0cbf | ||
|
|
7a2cd9e66b | ||
|
|
87ffc55745 | ||
|
|
30e4bb1a34 | ||
|
|
00d4cc1f69 | ||
|
|
99a406ed3c | ||
|
|
d0bbc5c1cb | ||
|
|
c9f374284b | ||
|
|
4385b9ec18 | ||
|
|
afad4993f6 | ||
|
|
f41729178e | ||
|
|
305857c8f9 | ||
|
|
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`.
|
||||
@@ -0,0 +1,202 @@
|
||||
# Row 200: `stack-release.sh activate --sso` (Q175 phase 2 prerequisite)
|
||||
|
||||
Launchpad, 2026-10-20. Author Launchpad, reviewer Webby, gate owner Sage. Issue #1698. Brief
|
||||
`docs/plans/2026-10-20_stack-release-sso.md` (blob 426336b177f6). Rulings sage-1389 (R1 uses the activation onto
|
||||
the current copy, plus a same-copy test and a latest-set mutant), sage-1403 and sage-1407 (N4), mos-1056 item 5
|
||||
(N4 is a window acceptance item), sage-1432 (round 2 for webby-0028 N1 and N2), sage-1556 (round 3: rebase onto row 190's
|
||||
landing 00d4cc1f6, and `--sso` for a named instance).
|
||||
|
||||
Base 00d4cc1f6 since round 3 (`base.txt`), row 190's landing, which has row 192 (4429a104f) below it. Rounds 1
|
||||
and 2 were built on 4d487dfdc: aa1bd209e plus row 192's two commits picked. 48359dbfa is 83b32d8d8 (patch-id
|
||||
8d11f48a9e03) and 4d487dfdc is r2 37582e820 (patch-id 0e32af8aa2a4 on both). The webui tree at the base matches
|
||||
`launchpad/row192` at 37582e820 path for path. Rounds 1 and 2 are on `launchpad/row200`;
|
||||
round 3 is on `launchpad/row200-r3` (see Round 3 below).
|
||||
`candidate-manifest.sha256` lists its 3 paths; the packet files are not in it. Drift check at commit time:
|
||||
round 3, origin/refactor is 00d4cc1f6 itself, so `git log 00d4cc1f6..origin/refactor` is empty.
|
||||
|
||||
No test touches `~/.mosaic-dev`, `~/.config/mosaic-dev`, `~/.mosaic`, a live unit or a live slot. T57 runs on
|
||||
the scratch roots and seeded pointers rows 187 and 189 use. Its config and slot sit in the test's temp dir,
|
||||
and the slot carries the `proofFile` read watch, so a run that opened it would fail the test.
|
||||
|
||||
## Files
|
||||
|
||||
| Path | Change |
|
||||
| --- | --- |
|
||||
| `scripts/stack-release.sh` | `--sso` on activate (usage, parse, `sso_for_target`), `sso_checks` and `slot_problem` (item 2), `sso_base` (rollback flags, item 3), `config_checks` (item 3 and N4), the `sso:` plan line and step lines. |
|
||||
| `packages/cli/tests/stack-release.test.mjs` | Fixture copies gain row 192's `--sso` marker, an old loader without `console`, and a C copy whose `ALGS` lack RS256. T57 (T56 before round 3), 11 subtests (2 in round 2, 1 in round 3). |
|
||||
| `docs/TOOLS.md` | Usage line and two paragraphs after row 189's `--no-send`. |
|
||||
|
||||
## What it does
|
||||
|
||||
1. **Item 1.** `activate ID BUSINESS --sso` drops `--public-host`, `--auth-header`, `--allow-user` and any proof
|
||||
pair, and puts `--sso` after the other flags, before `--send` and `--tester-port`, as `webui-service.sh`
|
||||
renders it (`sso_for_target`, `:775`). It may name the live copy once. After that, both a plain activate and
|
||||
an `--sso` activate refuse `already-active`. Later activates carry `--sso` (`sso: carried`; T57 covers
|
||||
activate, not `pm-on`, which carries the pointer's flags as it does for every flag).
|
||||
`--plan` prints `sso: on; drops ...; carries ...`, plus the proof line's `dropped; --sso drops header ...`.
|
||||
`--sso` with a pair, with `--no-proof` or on another verb is a usage error.
|
||||
2. **Item 2** (`sso_checks`, `:817`). These checks run whenever the run's Console flags hold `--sso`, before
|
||||
anything stops:
|
||||
- `sso-target`: the target's `cli.mjs` has no `--sso`.
|
||||
- `sso-config`: the target copy's own `checkConsole` refuses `console`, or there is no `console.sso`. The
|
||||
config is parsed in node, and only the message's first line is kept. A JSON parse error never quotes
|
||||
the text.
|
||||
- `sso-slot`: one per provider, from `lstat`, `stat` and `realpath` in `readSlot`'s order (`slot_problem`,
|
||||
`:847`). The slot is never opened.
|
||||
3. **Item 3.**
|
||||
- **Rollback** (`sso_base`, `:728`): from an `--sso` pointer to a copy without `--sso`, the Console flags
|
||||
come from the rollback set of the activation that moved the stack onto the pointer's copy from the
|
||||
target (`from_log arrival`). That set's `show-mosaic-webui.service.txt` is checked against its
|
||||
`SHA256SUMS`. `--send` and `--tester-port` follow the pointer.
|
||||
- **`sso-rollback-flags`** refuses when that set is missing, fails its sums, holds no readable argv, or
|
||||
already ran `--sso`.
|
||||
- **`config-console-key`** (`config_checks`, `:866`): any activate or rollback refuses before anything
|
||||
stops when the config has a `console` key that the target's `scripts/mosaic-config.mjs validate` refuses.
|
||||
The message says to remove the key first (restore step 1's backup), then run the verb again.
|
||||
4. **N4, the first half** (sage-1403, sage-1407). With no `console.sso` and the stack on another copy, the
|
||||
`sso-config` refusal names the first step: "activate ID without --sso first, then write console.sso, then
|
||||
run activate ID --sso". A copy before row 192 refuses the key, so the re-pin comes first. The `--sso`
|
||||
activate is then the allowed same-copy re-pin.
|
||||
5. **TOOLS.md.** The flag, its refusals, the rollback rule and the N4 order.
|
||||
|
||||
## Window plan acceptance item (N4, mos-1056 item 5, sage-1407)
|
||||
|
||||
For the row 200 window plan, word for word:
|
||||
|
||||
- **Before the `console` key is written, the window re-pins to row 192 or later.** Check line, run before the
|
||||
key is written: `git -C /mnt/storage/src/mosaic-stack merge-base --is-ancestor <row 192 landing sha>
|
||||
<pointer copy sha from scripts/stack-release.sh status acme> && echo REPIN_OK`. It must print `REPIN_OK`.
|
||||
If the window instead runs `activate <ID> acme --sso --plan` before the key is written, the
|
||||
`refuses sso-config:` line must end with "activate <ID> without --sso first, then write console.sso, then run
|
||||
activate <ID> --sso". It names that step only when the pointer is on another copy.
|
||||
- **A rollback past row 192 removes the key first.** Check line, run before the rollback:
|
||||
`scripts/stack-release.sh rollback acme --plan` must print no `refuses config-console-key:` line. If it
|
||||
prints one, restore `config.json` from the window's step 1 backup and run the plan again. The rollback itself
|
||||
refuses on the same check, so a skipped step stops it before anything stops.
|
||||
- **Back-out line (webby-0028 N4, sage-1432).** Once a copy with `--sso` support is live, no verb turns `--sso`
|
||||
off. Only a rollback to a copy before row 192 gets the preview set back, after the `console` key is removed
|
||||
(the check line above).
|
||||
|
||||
## Tests run (this worktree, final tree)
|
||||
|
||||
- `node --test --test-name-pattern='^T56' tests/stack-release.test.mjs`: 10/10 on the round 2 tree
|
||||
(`/mnt/storage/scratch/launchpad-r200/t56-r2c.log`). Round 1 was 8/8 (`t56-6.log`).
|
||||
- Round 3, this tree: `node --test tests/stack-release.test.mjs`, the whole file, 192/192
|
||||
(`/mnt/storage/scratch/launchpad-r200/sr-whole-r3b.log`); T57 alone 11/11 (`t57-r3a.log`);
|
||||
`tests/stack-instances.test.mjs` 23/23 (`si-r3.log`). No other test reads the 3 paths or pins their hashes.
|
||||
- The cli suite and every `scripts/test-*.sh` go to Sage's gate.
|
||||
|
||||
## Mutants (`mutants.py`, run as `python3 mutants.py <tree> [N ...]`, each against T57; T56 in rounds 1 and 2)
|
||||
|
||||
| # | Mutant | Result |
|
||||
| --- | --- | --- |
|
||||
| 1 | no `sso-target` refusal (item 2) | KILLED |
|
||||
| 2 | no `sso-config` refusal (item 2) | KILLED |
|
||||
| 3 | no `sso-slot` refusal (item 2) | KILLED |
|
||||
| 4 | no `sso-rollback-flags` refusal (item 3) | KILLED |
|
||||
| 5 | no `config-console-key` refusal (item 3) | KILLED |
|
||||
| 6 | keep `--auth-header` under `--sso` (item 1) | KILLED |
|
||||
| 7 | keep `--public-host` under `--sso` (item 1) | KILLED |
|
||||
| 8 | rollback takes the latest activation onto the copy, not the arrival (sage-1389) | KILLED |
|
||||
| 9 | `sso-config` names no first step (N4) | KILLED |
|
||||
| 10 | `slot_problem` compares the unresolved `REPO` (round 2, N1) | not run, Sage's gate |
|
||||
| 11 | `proof_file_problem` compares the unresolved `REPO` (round 2, N1) | not run, Sage's gate |
|
||||
| 12 | `config_checks` passes a config that doesn't parse (round 2, N2) | not run; equivalent if it survives, guard kept (sage-1446) |
|
||||
| 13 | `sso_checks` reads the default config, not `MOSAIC_CONFIG` (round 3) | not run, Sage's gate |
|
||||
| 14 | `sso_base` reads `show-mosaic-webui.service.txt`, not the instance unit's (round 3) | not run, Sage's gate |
|
||||
|
||||
9/9 killed (`/mnt/storage/scratch/launchpad-r200/mutants.log`). The script restored `stack-release.sh`, and it
|
||||
matches its pre-run sha256. No mutant hit the 300 s timeout. This batch ran without Sage's slot (sage-1414),
|
||||
while Darkwing's gate was running, so Sage reruns all 9 on the row 200 gate and doesn't take these results
|
||||
(sage-1423). Mutants 10 to 12 are new in round 2 and have not run. Sage runs 1 to 12 on the gate (sage-1446). Mutant 5's
|
||||
pattern now names the `holds` message, because the round 2 message also starts with `config-console-key "$cfg`.
|
||||
Every pattern matches once on the round 2 tree, and all 14 match once on the round 3 tree.
|
||||
|
||||
## Choices
|
||||
|
||||
- **R1, `--send` and `--tester-port` follow the pointer.** These two flags come from the pointer, not from the
|
||||
rollback set, so a `--no-send` or a tester port set after the arrival stays. This is row 189's rule that
|
||||
rollback carries `--send` off. Tradeoff: the rolled-back Console differs from the set's unit in those two
|
||||
flags, and the plan line says so.
|
||||
- **`config_checks` runs for activate too**, not only rollback. An activate onto a pre-192 copy with the key
|
||||
in place breaks that copy's loader the same way.
|
||||
- **The inside-repository slot test** symlinks the secrets directory to a temp dir under the fixture repo. The
|
||||
slot file is then not a link, but its real path is inside the repository.
|
||||
|
||||
## Not tested
|
||||
|
||||
- The slot owner-uid refusal: it needs a file owned by another uid, which needs privilege.
|
||||
|
||||
## Review r1 notes (webby-0028, non-blocking)
|
||||
|
||||
- **N1.** `slot_problem` (`:859`) and row 187's `proof_file_problem` (`:988`) compare a resolved path to
|
||||
`REPO="$(pwd)"` (`:26`), which is not resolved. Run through the `~/src/mosaic-stack-dev-test` symlink, a slot
|
||||
inside the repository passes here, and the Console refuses it after the stop. Confirmed by reading; Sage
|
||||
rules on a round 2 or a follow-up.
|
||||
- **N2.** `config_checks` (`:868`) returns 0 when config.json doesn't parse. Same ruling.
|
||||
- **N3.** A check this script doesn't make: `slot_problem` refuses only a size of 0. A slot holding only a
|
||||
newline, or two lines, passes here, and the Console's one-non-empty-line rule (`readSlot`) refuses it after
|
||||
the stop. Reading the content would break the rule that this script never opens the slot.
|
||||
- **N4, for the window's back-out step.** Once the live copy supports `--sso`, no verb turns `--sso` off on it.
|
||||
The only way back to the preview set is a rollback to a copy before row 192, which removes the `console` key
|
||||
first (the second check line above).
|
||||
|
||||
## Round 2 (sage-1432: N1 and N2; N3 and N4 stay notes)
|
||||
|
||||
- **N1.** `slot_problem` (`:847`) and `proof_file_problem` (`:989`) now compare the slot's real path with
|
||||
`realpath -m` of both `REPO` and `COPIES`, and refuse "cannot be resolved" if either fails. T57's
|
||||
"round 2 (webby-0028 N1)" subtest runs the script through a symlink to the fixture repo
|
||||
(`repo-link.200/scripts/stack-release.sh`). A proof file inside the repository is refused `proof-file-invalid`,
|
||||
and a slot whose secrets directory links into the repository is refused `sso-slot` "is inside the repository
|
||||
or the copies". Mutants 10 and 11 undo each check.
|
||||
- **N2, with a deviation.** `config_checks` (`:867`) now tells "no key" (node exits 1) from "doesn't parse"
|
||||
(exits 2) and refuses `config-console-key` "cannot be read as JSON" on the second. But webby-0028's case
|
||||
doesn't reach it: `load_config` (common.sh, through `scripts/mosaic-config.mjs env`) exits 1 on a config that
|
||||
doesn't parse, before anything is read or stopped. So the refusal is a guard for a config that changes after
|
||||
`load_config` ran, and no test reaches it without a race. The T57 subtest "round 2 (webby-0028 N2)" checks
|
||||
what does happen: activate, rollback and `activate --plan` each exit 1 with "common: configuration load
|
||||
failed", the pointer, the start/stop log and the activation log are unchanged, and activate works after the
|
||||
config is restored. Mutant 12 drops the guard and is expected to survive as equivalent for that reason. sage-1446:
|
||||
the guard stays, and mutant 12 is equivalent if it survives, the same call as row 192's mutant 23.
|
||||
- **N4.** The back-out line is in the window plan section above.
|
||||
|
||||
## Round 3 (sage-1556: rebase onto 00d4cc1f6, `--sso` for a named instance)
|
||||
|
||||
- **Rebase.** Row 190 landed as 00d4cc1f6 and changed all 3 paths. The 5 round 1 and 2 commits are picked onto
|
||||
it on a new branch, `launchpad/row200-r3` (no force-push, no branch delete, #426(a)). `launchpad/row200` stays
|
||||
as it was. `docs/TOOLS.md` and the test file applied cleanly. `scripts/stack-release.sh` conflicted in `plan()`
|
||||
and in the `--plan` lines of `do_activate` and `do_rollback`. The merge keeps both sides: the `sso:` plan line
|
||||
comes before the Console line, the Console and CLI lines carry row 190's `$flags` (`--instance NAME`), and
|
||||
`--plan` runs `ports_unclaimed` after `console_for_target` and before `plugin_checks` and `config_checks`.
|
||||
Functions on this tree: `sso_base` `:738`, `sso_for_target` `:785`, `sso_checks` `:827`, `slot_problem`
|
||||
`:857`, `config_checks` `:877`, `proof_file_problem` `:999`.
|
||||
- **Named instance: no code change was needed.** Row 190's `instance_select` exports `MOSAIC_CONFIG` as NAME's
|
||||
config, and the named Console unit sets `Environment=MOSAIC_CONFIG` to the same file. So `sso_checks` and
|
||||
`config_checks` read `~/.config/mosaic-NAME/config.json`, and the slot path is
|
||||
`~/.config/mosaic-NAME/secrets/mosaic-stack/<slot>`, the directory the Console's `readSlot` reads under that
|
||||
unit. `sso_base` reads `show-$CONSOLE_UNIT.txt`, so a named rollback reads `show-mosaic-webui-NAME.service.txt`
|
||||
from NAME's own set. The pointer, the log and the sets are in NAME's data root. One comment (`:734`) named the
|
||||
default unit's file and now says `show-$CONSOLE_UNIT.txt`.
|
||||
- **Test.** New T57 subtest "round 3 (sage-1556)": instance `alpha`. A missing slot refuses `sso-slot` and names
|
||||
alpha's slot path. `--plan` prints `--instance alpha` on the Console line. `activate C1 acme --sso` writes
|
||||
`--sso` to `mosaic-webui-alpha.service` (with `MOSAIC_CONFIG` set to alpha's config), leaves the default
|
||||
Console unit unwritten, and status matches. After the key is removed, `rollback` takes its flags from alpha's
|
||||
arrival set, `show-mosaic-webui-alpha.service.txt`. The slot carries the read watch. Mutants 13 and 14 undo
|
||||
the two instance-specific reads.
|
||||
- **Rename.** Row 190 also named its test T56, so this row's test is now T57; `mutants.py` runs `^T57`, and the
|
||||
in-repository temp dirs are `t57-slot-` and `t57-proof-`.
|
||||
- **Defect from round 1, fixed.** Round 1 added `| --sso` to the activate usage line, but T53 test 6 (row 187)
|
||||
and T55 test 5 (row 189) still matched the old line, so 4 tests failed (2 subtests and their parents). Rounds
|
||||
1 and 2 ran only this row's test, so the failure went unseen. The round 2 tree d4ec998cc fails the same 4
|
||||
(`r2-t53t55.log`), and the first whole-file run on this tree was 188/192 (`sr-whole-r3.log`). Both regexes now
|
||||
include `| --sso` (`:1672`, `:1831`; T57's own check is `:1940`). Webby: please check these two lines; the
|
||||
round 2 approve did not cover them (sage-1569). A grep of `packages/*/tests` and `scripts/test-*.sh` for the
|
||||
usage text (`--no-proof]`, `proof-file PATH`, `usage: stack-release`) finds no third copy, and finds no
|
||||
64-hex pin on any of the 3 paths. The only test files that name `stack-release` are `stack-release.test.mjs`
|
||||
and `stack-instances.test.mjs` (a comment); both ran as whole files (above).
|
||||
- **Not changed (note).** The N4 first step reads "activate ID without --sso first"; for a named instance the
|
||||
operator also passes `--instance NAME`, as with every verb. The message doesn't repeat it.
|
||||
|
||||
## Not done here
|
||||
|
||||
- No change to the live pointer, units or config. The phase 2 window is Sage's and Mos's.
|
||||
@@ -0,0 +1 @@
|
||||
00d4cc1f6987d5eec340361c17efc2b3e58d1505
|
||||
@@ -0,0 +1,3 @@
|
||||
4bf241979d5cf2e83a2f209adbf3acb1a8ea091c5c384b27b5e3462fa348d9d0 docs/TOOLS.md
|
||||
959567d9bf6c2435640060fcb33890a344ead7f7cbc1f8019e45ae2498fcdd4e packages/cli/tests/stack-release.test.mjs
|
||||
63073fbea264549694b54cc1ae7d46ba9dbe8c5d72670da87985a5223c06ac3f scripts/stack-release.sh
|
||||
@@ -0,0 +1,50 @@
|
||||
# Row 200 (#1698) mutants: `python3 mutants.py <tree> [N ...]`. Each applies to the tree's scripts/stack-release.sh,
|
||||
# runs T57 in packages/cli/tests/stack-release.test.mjs and restores the file from a saved copy, checked by sha256.
|
||||
# Killed = test exit != 0.
|
||||
import hashlib, os, subprocess, sys, signal
|
||||
R = os.path.abspath(sys.argv[1])
|
||||
S = os.path.join(R, 'scripts', 'stack-release.sh')
|
||||
if not os.path.isfile(S): sys.exit(f'ABORT no scripts/stack-release.sh under {sys.argv[1]}')
|
||||
T, PAT = 'tests/stack-release.test.mjs', '^T57'
|
||||
M = [
|
||||
('no sso-target refusal (item 2)', [(" sso_supported || { check_refuse sso-target", " true || { check_refuse sso-target")]),
|
||||
('no sso-config refusal (item 2)', [(' check_refuse sso-config "$TO', ' : "$TO')]),
|
||||
('no sso-slot refusal (item 2)', [('[ -z "$why" ] || check_refuse sso-slot', '[ -z "$why" ] || : sso-slot')]),
|
||||
('no sso-rollback-flags refusal (item 3)', [(' check_refuse sso-rollback-flags "$TO', ' : "$TO')]),
|
||||
('no config-console-key refusal (item 3)', [(' check_refuse config-console-key "$cfg holds', ' : "$cfg holds')]),
|
||||
('keep --auth-header under --sso (item 1)', [(" --public-host | --auth-header | --allow-user)", " --public-host | --allow-user)")]),
|
||||
('keep --public-host under --sso (item 1)', [(" --public-host | --auth-header | --allow-user)", " --auth-header | --allow-user)")]),
|
||||
('rollback takes the latest activation onto the copy, not the arrival (sage-1389)', [('o.to===current&&o.from!==current){process.stdout.write(`${o.activation', 'o.to===current){process.stdout.write(`${o.activation')]),
|
||||
('sso-config names no first step (N4)', [(' first="; activate $TO without --sso first, then write console.sso, then run activate $TO --sso"', ' first=""')]),
|
||||
('slot_problem compares the unresolved REPO (round 2, webby-0028 N1)', [(' case "$real" in "$repo" | "$repo"/* | "$copies" | "$copies"/*) echo "is inside the repository or the copies"; return ;; esac\n [ "$size" != 0 ]', ' case "$real" in "$REPO" | "$REPO"/* | "$copies" | "$copies"/*) echo "is inside the repository or the copies"; return ;; esac\n [ "$size" != 0 ]')]),
|
||||
('proof_file_problem compares the unresolved REPO (round 2, webby-0028 N1)', [(' case "$real" in "$repo" | "$repo"/* | "$copies" | "$copies"/*) echo "is inside the repository or the copies"; return ;; esac\n [ "$mode" = 600 ]', ' case "$real" in "$REPO" | "$REPO"/* | "$copies" | "$copies"/*) echo "is inside the repository or the copies"; return ;; esac\n [ "$mode" = 600 ]')]),
|
||||
('config_checks passes a config that does not parse (round 2, webby-0028 N2; expected equivalent)', [(' [ "$key" != 1 ] || return 0', ' [ "$key" = 0 ] || return 0')]),
|
||||
('sso_checks reads the default config, not the instance config (round 3, sage-1556)', [(' local cfg="${MOSAIC_CONFIG:-$HOME/.config/mosaic-dev/config.json}" out id slot path why', ' local cfg="$HOME/.config/mosaic-dev/config.json" out id slot path why')]),
|
||||
('sso_base reads the default Console\'s show file, not the instance unit\'s (round 3, sage-1556)', [(' local f="show-$CONSOLE_UNIT.txt" argv=""', ' local f="show-mosaic-webui.service.txt" argv=""')]),
|
||||
]
|
||||
sha = lambda b: hashlib.sha256(b).hexdigest()
|
||||
only = sys.argv[2:]
|
||||
os.chdir(os.path.join(R, 'packages', 'cli'))
|
||||
survived = []
|
||||
for i, (name, pairs) in enumerate(M, 1):
|
||||
if only and str(i) not in only: continue
|
||||
orig = open(S, 'rb').read(); h = sha(orig)
|
||||
src = orig.decode()
|
||||
for a, b in pairs:
|
||||
if src.count(a) != 1: sys.exit(f'ABORT {i} {name}: pattern found {src.count(a)} times')
|
||||
src = src.replace(a, b)
|
||||
try:
|
||||
open(S, 'w').write(src); os.chmod(S, 0o755)
|
||||
p = subprocess.Popen(['node', '--test', f'--test-name-pattern={PAT}', T], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, start_new_session=True)
|
||||
try: out, _ = p.communicate(timeout=300)
|
||||
except subprocess.TimeoutExpired:
|
||||
os.killpg(p.pid, signal.SIGKILL); out, _ = p.communicate(); out += b'\nTIMEOUT'
|
||||
text = out.decode(errors='replace')
|
||||
ran = [l for l in text.splitlines() if l.startswith('ℹ fail ')]
|
||||
verdict = 'KILLED' if p.returncode != 0 else 'SURVIVED'
|
||||
if verdict == 'SURVIVED': survived.append(name)
|
||||
print(f'{i:2} {verdict} {name} [{ran[0] if ran else "no count"}]', flush=True)
|
||||
finally:
|
||||
open(S, 'wb').write(orig); os.chmod(S, 0o755)
|
||||
if sha(open(S, 'rb').read()) != h: sys.exit(f'ABORT restore failed for {S}')
|
||||
print('survived:', survived or 'none')
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"seq": 22,
|
||||
"id": "3a6c8fb9-a5ab-42f8-9755-902a88375ca2",
|
||||
"at": "2026-10-20T22:41:14.444Z",
|
||||
"business": "mosaic-stack",
|
||||
"project": null,
|
||||
"raised_by_role": "pm",
|
||||
"raised_by_run": "rc9e993da24cf",
|
||||
"class": "gated",
|
||||
"action": "prd.approve",
|
||||
"route_to": "human",
|
||||
"question": "Approve PRD 1.8 exactly as drafted?\n\nFile: agents/honker/work/queue-194/PRD-1.8-DRAFT.md on mosaicstack/stack at commit 762a6711ff7ed049aa73dfde98b75ebf326bdd99 (revision 5), 319 lines, sha256 8eee52470b1d7df139164e3ba5cc128dc469457b2b52f298c12685c3483011a8. Duckworth reviewed it (duckworth-0061, confirmed duckworth-0062) and Mos checked it (mos-1140). The tip of branch honker/row194-b2 holds a later r6 (aba3cbdeb), which is not used here. This approval covers only the r5 commit and sha256 above. The PM copied these values from the request and did not compute or check the sha256.\n\nNotes the approval carries:\n1. Mos's reading: \"the main agent or chief of staff\" in Q10 is one role under two names, the chief of staff. Only the chief raises a grant request; the assistant passes the user's request to the chief. To change it, say so at approval and REQ-GRANT-1 changes first.\n2. Mos's reading: revoke by \"you, Mos or the tester\" means the user, the assistant (\"Mos\", Jason's assistant) for grants in its own organization, and the tester only on a dev instance.\n3. N5: host.sudo keeps the arbiter route version 1.7 set for git.push.remote. A raise for it that cites the spec and isn't within-role goes to the arbiter for its domain, not to the user. In manual mode it comes to the user.\n4. B7 residual: only an Approve creates a grant, but an agent running as the stack's OS user can still write the stack's own store (Q182 on dragon-lin, #423 on .53 and .54). This version doesn't close it.\n5. N6: the broker enforces only that the chief of staff raised a grant request. The rest of the chain of command is the messaging lists and role contracts, not a broker check.\n\nSource: a tester request relayed by the chief (message 6990efb4, tester message d54a190e). It is a test request, not Jason's instruction. It takes the same route as PRD 1.7 (decision d52febb5): non-blocking, no task, uncited, so it goes to the human's Inbox. I recommend yes because two reviewers have passed r5 and the open points are written down above, not hidden.",
|
||||
"options": [
|
||||
{
|
||||
"key": "yes",
|
||||
"text": "Approve PRD 1.8 exactly as drafted: the r5 file at commit 762a6711ff7ed049aa73dfde98b75ebf326bdd99 with the sha256 below, and only that."
|
||||
},
|
||||
{
|
||||
"key": "no",
|
||||
"text": "Don't approve. Say what changes, and a new revision comes back for review."
|
||||
}
|
||||
],
|
||||
"recommendation": "yes",
|
||||
"task_ref": null,
|
||||
"requirement_ref": null,
|
||||
"blocking": false,
|
||||
"supersedes": null,
|
||||
"spec_path": null,
|
||||
"spec_commit": null,
|
||||
"authorization": {
|
||||
"action": "prd.approve",
|
||||
"target": null,
|
||||
"approvalChoice": "yes"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
[
|
||||
{
|
||||
"seq": 22,
|
||||
"id": "3a6c8fb9-a5ab-42f8-9755-902a88375ca2",
|
||||
"at": "2026-10-20T22:41:14.444Z",
|
||||
"business": "mosaic-stack",
|
||||
"project": null,
|
||||
"raised_by_role": "pm",
|
||||
"raised_by_run": "rc9e993da24cf",
|
||||
"class": "gated",
|
||||
"action": "prd.approve",
|
||||
"route_to": "human",
|
||||
"question": "Approve PRD 1.8 exactly as drafted?\n\nFile: agents/honker/work/queue-194/PRD-1.8-DRAFT.md on mosaicstack/stack at commit 762a6711ff7ed049aa73dfde98b75ebf326bdd99 (revision 5), 319 lines, sha256 8eee52470b1d7df139164e3ba5cc128dc469457b2b52f298c12685c3483011a8. Duckworth reviewed it (duckworth-0061, confirmed duckworth-0062) and Mos checked it (mos-1140). The tip of branch honker/row194-b2 holds a later r6 (aba3cbdeb), which is not used here. This approval covers only the r5 commit and sha256 above. The PM copied these values from the request and did not compute or check the sha256.\n\nNotes the approval carries:\n1. Mos's reading: \"the main agent or chief of staff\" in Q10 is one role under two names, the chief of staff. Only the chief raises a grant request; the assistant passes the user's request to the chief. To change it, say so at approval and REQ-GRANT-1 changes first.\n2. Mos's reading: revoke by \"you, Mos or the tester\" means the user, the assistant (\"Mos\", Jason's assistant) for grants in its own organization, and the tester only on a dev instance.\n3. N5: host.sudo keeps the arbiter route version 1.7 set for git.push.remote. A raise for it that cites the spec and isn't within-role goes to the arbiter for its domain, not to the user. In manual mode it comes to the user.\n4. B7 residual: only an Approve creates a grant, but an agent running as the stack's OS user can still write the stack's own store (Q182 on dragon-lin, #423 on .53 and .54). This version doesn't close it.\n5. N6: the broker enforces only that the chief of staff raised a grant request. The rest of the chain of command is the messaging lists and role contracts, not a broker check.\n\nSource: a tester request relayed by the chief (message 6990efb4, tester message d54a190e). It is a test request, not Jason's instruction. It takes the same route as PRD 1.7 (decision d52febb5): non-blocking, no task, uncited, so it goes to the human's Inbox. I recommend yes because two reviewers have passed r5 and the open points are written down above, not hidden.",
|
||||
"options": [
|
||||
{
|
||||
"key": "yes",
|
||||
"text": "Approve PRD 1.8 exactly as drafted: the r5 file at commit 762a6711ff7ed049aa73dfde98b75ebf326bdd99 with the sha256 below, and only that."
|
||||
},
|
||||
{
|
||||
"key": "no",
|
||||
"text": "Don't approve. Say what changes, and a new revision comes back for review."
|
||||
}
|
||||
],
|
||||
"recommendation": "yes",
|
||||
"task_ref": null,
|
||||
"requirement_ref": null,
|
||||
"blocking": false,
|
||||
"supersedes": null,
|
||||
"spec_path": null,
|
||||
"spec_commit": null,
|
||||
"authorization": {
|
||||
"action": "prd.approve",
|
||||
"target": null,
|
||||
"approvalChoice": "yes"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,13 @@
|
||||
[mos test] Please have the PM raise a prd.approve decision for PRD 1.8, the same route as PRD 1.7 (decision d52febb5): blocking false, no task_ref, recommendation yes, gated to the human. The PM must not compute or change the sha256; it copies the values below.
|
||||
|
||||
Question: "Approve PRD 1.8 exactly as drafted?"
|
||||
File: agents/honker/work/queue-194/PRD-1.8-DRAFT.md on mosaicstack/stack at commit 762a6711ff7ed049aa73dfde98b75ebf326bdd99 (revision 5), 319 lines, sha256 8eee52470b1d7df139164e3ba5cc128dc469457b2b52f298c12685c3483011a8. Reviewed by Duckworth (duckworth-0061, confirmed duckworth-0062) and checked by Mos (mos-1140). The branch tip of honker/row194-b2 holds a later r6 (aba3cbdeb) that is not used; the approval covers only the r5 commit and sha256 above.
|
||||
|
||||
The decision notes must carry these five items:
|
||||
1. Mos's reading: "the main agent or chief of staff" in Q10 is one role under two names, the chief of staff. Only the chief raises a grant request; the assistant passes the user's request to the chief. To change it, say so at approval and REQ-GRANT-1 changes first.
|
||||
2. Mos's reading: revoke by "you, Mos or the tester" means the user, the assistant ("Mos", Jason's assistant) for grants in its own organization, and the tester only on a dev instance.
|
||||
3. N5: host.sudo keeps the arbiter route version 1.7 set for git.push.remote. A raise for it that cites the spec and isn't within-role goes to the arbiter for its domain, not to the user. In manual mode it comes to the user.
|
||||
4. B7 residual: only an Approve creates a grant, but an agent running as the stack's OS user can still write the stack's own store (Q182 on dragon-lin, #423 on .53 and .54). This version doesn't close it.
|
||||
5. N6: the broker enforces only that the chief of staff raised a grant request. The rest of the chain of command is the messaging lists and role contracts, not a broker check.
|
||||
|
||||
Reply with the decision id once raised. Do not resolve it.
|
||||
@@ -0,0 +1,8 @@
|
||||
sent d54a190e to chief
|
||||
chief (RESULT, re d54a190e) cc46a7b4:
|
||||
I passed your request to the pm without changing any of it, and launched the pm so it can act on it. No decision id yet, because the pm hasn't raised the decision.
|
||||
|
||||
- **Message to pm:** 6990efb4-8321-4d0e-9b8a-771f65625b83. It says the request comes from a tester and authorizes nothing, and it carries your PRD 1.8 values and the five notes as you wrote them.
|
||||
- **pm launch:** run rc9e993da24cf.
|
||||
|
||||
When the pm answers with the decision id, I'll send it to you.
|
||||
@@ -0,0 +1,5 @@
|
||||
trail 3a6c8fb9-a5ab-42f8-9755-902a88375ca2: 4 row(s)
|
||||
2026-10-20T22:40:47.949Z events#1550 session.launched pm
|
||||
2026-10-20T22:40:47.969Z role_claims#102 claim pm by rc9e993da24cf
|
||||
2026-10-20T22:41:14.444Z decisions#22 gated prd.approve → human: Approve PRD 1.8 exactly as drafted? File: agents/honker/work/queue-194/PRD-1.8-DRAFT.md on mosaicstack/stack at commit …
|
||||
2026-10-20T22:41:14.445Z events#1553 action.allowed pm decision.raise decision 3a6c8fb9
|
||||
@@ -0,0 +1,125 @@
|
||||
# Re-pin 490e2771d to 4429a104f (row 192, Q175 phase 1), with npm ci in the new copy
|
||||
|
||||
Sage, 2026-10-20, draft 1, for mos-1099 item 3 under DECISIONS Q173. Mos checks this file at its commit before
|
||||
P0. Any change to this file or to NEW after the check means Mos checks again (mos-0849/0850).
|
||||
|
||||
This is a smaller window than `repin-rows-177-185.md` (draft 2, which Mos checked in mos-0929). Where a step is
|
||||
the same, this file names that file's step and says what differs. The 7332 matrix, the snapshot, the W9 list and
|
||||
the rollback compare are reused unchanged.
|
||||
|
||||
## Who, and what nobody does
|
||||
|
||||
- Sage operates, as the tester. Darkwing holds the gate lane for row 190 round 3 tonight, so I take the
|
||||
operator seat. Mos verifies each step's output before the next one and runs `rehearse` (P3), which reads the
|
||||
live config and a live backup (`docs/TOOLS.md` `rehearse`: Jason or Mos runs it).
|
||||
- Jason has no step. Q173 covers a re-pin of this test system with a backup first and a STATE report after.
|
||||
Nothing is pruned.
|
||||
- Nobody acts as Jason: no `--by jason`, no human shell or tmux pane, every bus read and write carries
|
||||
`--tester`, and no request carries `jason.woltje`. `bus.tester` stays as it is; if it isn't `true`, stop at P1.
|
||||
- No pi (mos-0865 D2, P19 #383). No Save in the human Console, no `mosaic name`.
|
||||
- Outside reach doesn't change. The Console keeps its current flags. `--sso` stays off: row 192's sign-in code is
|
||||
dormant without the flag, and `cli.mjs:57` refuses `--sso` with the preview flags the pointer carries anyway.
|
||||
The proof pair stays unset. The tunnel still forwards 7332 only (P4 and W10 check it).
|
||||
|
||||
## Names
|
||||
|
||||
- OLD, live: `490e2771d3d057ab76991e2c7859cd251857e64c`, activation `20261020T192956Z-490e2771d3d0`, pointer
|
||||
`pm: true`, schema 3i, Console `--port 7332 --public-host mosaic.woltje.com --auth-header X-authentik-username
|
||||
--allow-user jason.woltje --tester-port 7333` (no `--send`, read from `stack-release.sh status` between 21:36Z and 21:39Z).
|
||||
- NEW: `4429a104f6d65ccf165f6c83b5d1dce3147dfde0`, row 192's landing, 103 commits past OLD (`git rev-list --count 490e2771d..4429a104f`).
|
||||
The 106 that `status` showed is OLD to HEAD 305857c8f, so it also counts the three record commits below.
|
||||
The commits after it (5b8def2f1 plans, 176a6bf4b sessions, 305857c8f queue) are records and don't move NEW. If
|
||||
another code row lands before W0 (row 190 is the likely one), NEW stays here unless Mos rules otherwise.
|
||||
- REPO: the canonical checkout. `activate` runs REPO's `webui-service.sh`, so the four service scripts are held
|
||||
at NEW for the window (P5).
|
||||
- `P=/mnt/storage/scratch/sage-pin-de352dfaf` (`business-check.mjs`, `console-check.sh`, checked against
|
||||
`agents/sage/work/window-plan/repin-de352dfaf/SCRIPTS.sha256`).
|
||||
- `WIN=agents/sage/work/window-repin-4429a104f/`, where every transcript goes.
|
||||
- `SNAP=$WIN/snapshot.mjs`, a copy of `agents/darkwing/work/window-rows-177-185/snapshot.mjs`, sha256
|
||||
`639fd28a2674aaa03ce824e3394b4052f21cdef7d6fa49bae61a9927ed341e67` (mos-0899). I check the sha before S1, S2
|
||||
and any D3.
|
||||
|
||||
## What NEW carries over OLD
|
||||
|
||||
The path-limited log over `packages`, `scripts`, `roles`, `contracts`, `adapters`, `skills` and the package files:
|
||||
|
||||
| Row | Commit | Live effect |
|
||||
|---|---|---|
|
||||
| 186 | 269134d8d | Tool only: the activate plan's backup line says what the run will do. |
|
||||
| 187 | 9a71e4e80 | Tool: activate sets, carries and drops the proof pair. The pointer has no pair, so nothing changes. |
|
||||
| 188 | 8fc0ee216 | Tool: proof file checks on a carried pair. No pair, no effect. |
|
||||
| 163 | 8ecc7a239 | Tool: `rehearse`, which P3 uses, and `bus start --no-launch`. CLI `cli.mjs` and `launcher.mjs` gain the `--no-launch` path; the default start is unchanged. |
|
||||
| 189 | ace27dc5e | Tool: `activate --no-send`. Live already runs without `--send`; the pointer carries that. |
|
||||
| 192 | 4429a104f | Console `--sso` (off here), `scripts/mosaic-config.mjs` load becomes async, `webui-service.sh` renders `--sso` only when given, and a new dependency, oauth4webapi 3.8.8. `cli.mjs` imports `sso.mjs` at the top, so the Console won't start from a copy without that module. |
|
||||
|
||||
No schema step: `SCHEMA_VERSION` (`packages/bus/src/store.mjs:17`) is 3i at OLD and NEW, and no migration was
|
||||
added. `--plan` must say `migration: none`. No roles, skills, contracts, adapters or plug-in kinds changed. So: no
|
||||
harness set, no skill carry, no `pm-on`. One `activate --pm`.
|
||||
|
||||
## npm ci in the new copy
|
||||
|
||||
mos-1099 asks for npm ci in the new copy. `stack-copy.sh copy` already does it: it extracts the archive to a temp
|
||||
dir, refuses without `package.json` and `package-lock.json` (:55), runs `npm ci --no-audit --no-fund` there and
|
||||
writes no copy if it fails (:56). W0 checks the result: `node_modules/oauth4webapi/package.json` in the copy reads
|
||||
3.8.8, and `node --input-type=module -e "await import('oauth4webapi')"` from the copy exits 0. The live copy is
|
||||
163M; `/home` has 30G free (94% used), enough for one more.
|
||||
|
||||
## Backups
|
||||
|
||||
| Before | Backup | Taken by |
|
||||
|---|---|---|
|
||||
| W5 | the stopped store, `$DATA/backups/stack/<W5 activation id>/` with SHA256SUMS, DATA_ROOT, SCHEMA 3i, STAMP | `stack-release.sh` (row 185: every activate) |
|
||||
| W5 | the rollback set (units, wrapper, pointer) | `stack-release.sh` |
|
||||
| W9 | the business file, `cp -p` to `~/.config/mosaic-dev/businesses/mosaic-stack.json.pre-window-<stamp>` with its sha256, as evidence nothing wrote it | Sage, in W0b |
|
||||
|
||||
## Before the window (nothing stopped)
|
||||
|
||||
| # | Who | Step |
|
||||
|---|-----|------|
|
||||
| P0 | Mos | The `$P` sha check, as in the 177-185 plan P0. |
|
||||
| P1 | Sage | The business file, read only, the same values as the 177-185 plan P1. I report its sha256 now rather than assume the 06:15Z value. `business-check.mjs` for OLD now and for NEW after W0: LOAD_OK both. |
|
||||
| W0 | Sage | `cd $REPO && scripts/stack-copy.sh copy $NEW`, in a gap between gate-lane runs, since npm ci is heavy. Then the two npm checks above, and `diff <(git -C $REPO show $NEW:package-lock.json) $COPIES/$NEW/package-lock.json` empty. W0 writes only under `copies/` and changes nothing live, so it moves before P2 and P3, which need the copy. |
|
||||
| P2 | Sage | `console-check.sh $NEW` against a stub Console of NEW on a scratch port, once without and once with `--tester-port` on a second scratch port. CONSOLE_PASS both. |
|
||||
| P3 | Mos | `scripts/stack-release.sh rehearse $NEW mosaic-stack --backup 20261020T192956Z-490e2771d3d0 --port <scratch>`. It must pass every gate check but the PM's. It stops no unit and writes no pointer. Transcript in `$WIN/p3/`. Correction (mos-1107): `rehearse` starts NEW's Console with only `--port` and `--business`, not the pointer's flags. So Mos also starts NEW's Console by hand with the pointer's flags on two scratch ports and reads healthz and `/api/config` on both, then stops it (`$WIN/p3/flags-check.txt`, `flags-console.log`). |
|
||||
| P4 | Sage | The tunnel read, as in the 177-185 plan P4. |
|
||||
| P5 | Sage | `git -C $REPO diff --quiet $NEW HEAD -- scripts/stack-release.sh scripts/webui-service.sh scripts/bus-service.sh scripts/stack-copy.sh` and an empty `git status --porcelain` on the same four. Row 190 round 3 touches `webui-service.sh`; if it lands before W5, P5 fails and the window waits for Mos's ruling. |
|
||||
| P6 | Sage | The 7332 matrix (a)-(g) on OLD, as in the 177-185 plan, saved as `$WIN/matrix-old.txt`. |
|
||||
|
||||
## The window
|
||||
|
||||
| # | Who | Step |
|
||||
|---|-----|------|
|
||||
| W0b | Sage | The business file copy. |
|
||||
| W1 | Sage | Idle check (`mosaic agents --business mosaic-stack --tester`, `mosaic launches list`), the start stamp from `date -u`, then S1 `node $SNAP --store "$DATA/bus/bus.sqlite" --held`. |
|
||||
| W2 | Sage | `stack-release.sh status mosaic-stack`: pointer on OLD, `pm: true`, 3i, last line ok, unit flags match. |
|
||||
| W3 | Sage | `stack-release.sh activate $NEW mosaic-stack --pm --plan`. Must show from OLD to NEW, `migration: none`, the backup line, and the Console line with OLD's flags exactly: no `--send`, no `--sso`, no proof pair, `--tester-port 7333`. Any other difference stops here. |
|
||||
| W4 | Sage | The unit reads, as in the 177-185 plan W4. |
|
||||
| W5 | Sage, on Mos's go | `stack-release.sh activate $NEW mosaic-stack --pm`. Exit 0, `result ok`, every gate check including `pm_launched`. Record the activation id, the backup path and STAMP, and the backup SHA256SUMS sha256. Then `status`. |
|
||||
| W9 | Sage, after Mos verifies W5 | The 177-185 plan's W9 list unchanged, the chief turn and the one `assistant` thread turn included (mos-0929 Q3), plus two lines: the webui journal since the W5 stamp (with `UTC`) has one `Proxy: proxy proof off` and no `Sign-in:` line, and `ls -l /proc/<Console pid>/cwd` names the NEW copy. |
|
||||
| W10 | Sage | The browser check on 127.0.0.1:7333 and the matrix again as `$WIN/matrix-new.txt`, identical to `matrix-old.txt`. Then P4 again. Corrections after the window (mos-1135, mos-1137): the matrix line for a file the pin changes isn't "identical". Line (g) serves `index.html`, which row 192 grows by 121 bytes (6470 to 6591), so (g) is 200 non-json at 6575 bytes, not 6454. The two browser checks come from the 177-185 window, before row 192 and before a second thread project. "Only tester forms" now allows `#sign-out` while it is `hidden` and `/api/config` has no `sso: true`. "Chat lists the W9 thread" now reads every `.chat-projects section.panel` list (chat.js:93 renders one per project), not the first. |
|
||||
| W11 | Sage | STATE report in `$WIN/STATE.md`, posted on P19 386 and P19 412 and on Gitea #1690. |
|
||||
|
||||
## Stop conditions and rollback
|
||||
|
||||
The same as the 177-185 plan. Two failures in one window: stop and report (mos-0823).
|
||||
|
||||
Rollback: `stack-release.sh rollback mosaic-stack --plan`, then the real run. The previous distinct copy in the
|
||||
log is 490e2771d, both are 3i, so no `--restore` and no data loss. OLD takes the same Console flags. The R1 and
|
||||
R1post compares are as in the 177-185 plan. A restore past W5 follows that plan's D3 steps, with Mos deciding.
|
||||
|
||||
Keep every copy. Prune nothing.
|
||||
|
||||
## Questions for the plan check
|
||||
|
||||
- Q1: NEW is row 192 alone, not bundled with row 190. Row 190 (several instances) changes `stack-release.sh`
|
||||
and `webui-service.sh` and deserves its own window. I recommend running this one first.
|
||||
- Q2: Sage operates instead of Darkwing, who is on the row 190 gate. OK?
|
||||
- Q3: W0 before P2 and P3. It writes a copy only. OK?
|
||||
|
||||
## What Sage ran for this plan
|
||||
|
||||
Read only, 2026-10-20 between 21:36Z and 21:39Z (clock read 21:39:50Z at the commit): `stack-release.sh status mosaic-stack` (pointer, last five log
|
||||
lines, unit flags match, 106 commits of drift), `git rev-list --count`, `SCHEMA_VERSION` at both ends, the
|
||||
path-limited log, `git diff --stat` on the service scripts, CLI and config loader, `stack-copy.sh:23-56`,
|
||||
`webui/src/cli.mjs:15-120`, `docs/TOOLS.md` on `rehearse`, `du -sh` of the live copy and `df -h /home`. Nothing
|
||||
live was changed.
|
||||
@@ -0,0 +1,3 @@
|
||||
chief rbb708846b605 claude-code since 2026-10-20T19:29:59.053Z
|
||||
pm rc36b17ef3788 claude-code since 2026-10-20T20:20:56.278Z
|
||||
# exit 0
|
||||
@@ -0,0 +1,256 @@
|
||||
{
|
||||
"settings loaded": {
|
||||
"pass": true
|
||||
},
|
||||
"settings": {
|
||||
"banner": "Tester Console. Changes are recorded as tester, never as the human.",
|
||||
"h1": "Settings",
|
||||
"forms": [
|
||||
"sign-out",
|
||||
"conv-form",
|
||||
"routes-form",
|
||||
"instance-add-form",
|
||||
"instance-change-form",
|
||||
"tester-name-form",
|
||||
"tester-name-form",
|
||||
"tester-name-form",
|
||||
"tester-name-form",
|
||||
"tester-name-form",
|
||||
"tester-name-form"
|
||||
],
|
||||
"convFormHidden": true,
|
||||
"panels": [
|
||||
"Routing",
|
||||
"Role instances",
|
||||
"Seat names"
|
||||
],
|
||||
"instancesTable": true,
|
||||
"nameForms": 6,
|
||||
"routesForm": true,
|
||||
"routesSubmitEnabled": true,
|
||||
"forbidden": [],
|
||||
"tokenLimitsPanel": false
|
||||
},
|
||||
"tester banner": {
|
||||
"pass": true,
|
||||
"detail": "Tester Console. Changes are recorded as tester, never as the human."
|
||||
},
|
||||
"instance, routes and name editors present": {
|
||||
"pass": true
|
||||
},
|
||||
"only tester forms (conv-form is index.html:21, global and hidden)": {
|
||||
"pass": false,
|
||||
"detail": [
|
||||
"sign-out",
|
||||
"conv-form",
|
||||
"routes-form",
|
||||
"instance-add-form",
|
||||
"instance-change-form",
|
||||
"tester-name-form",
|
||||
"tester-name-form",
|
||||
"tester-name-form",
|
||||
"tester-name-form",
|
||||
"tester-name-form",
|
||||
"tester-name-form"
|
||||
]
|
||||
},
|
||||
"no launch settings, assistant, limit reset or send": {
|
||||
"pass": true,
|
||||
"detail": []
|
||||
},
|
||||
"no Token limits panel (plan W10 item 2, amended)": {
|
||||
"pass": true
|
||||
},
|
||||
"board": {
|
||||
"busViewHidden": true,
|
||||
"banner": true,
|
||||
"headings": [
|
||||
"Waiting on you –",
|
||||
"All sessions –"
|
||||
],
|
||||
"unavailable": false,
|
||||
"length": 179,
|
||||
"forms": [
|
||||
"sign-out",
|
||||
"conv-form"
|
||||
]
|
||||
},
|
||||
"board reads": {
|
||||
"pass": true,
|
||||
"detail": {
|
||||
"busViewHidden": true,
|
||||
"banner": true,
|
||||
"headings": [
|
||||
"Waiting on you –",
|
||||
"All sessions –"
|
||||
],
|
||||
"unavailable": false,
|
||||
"length": 179,
|
||||
"forms": [
|
||||
"sign-out",
|
||||
"conv-form"
|
||||
]
|
||||
}
|
||||
},
|
||||
"chat": {
|
||||
"hash": "#/chat",
|
||||
"banner": true,
|
||||
"threads": [
|
||||
{
|
||||
"href": "#/chat/threads/d0ba7a66-aa3f-4918-9518-94132bce6b28",
|
||||
"name": "w9",
|
||||
"instance": "assistant"
|
||||
}
|
||||
],
|
||||
"conversationOpen": false,
|
||||
"forms": [
|
||||
"sign-out",
|
||||
"conv-form",
|
||||
"chat-project-form"
|
||||
]
|
||||
},
|
||||
"chat lists the W9 thread": {
|
||||
"pass": false,
|
||||
"detail": [
|
||||
{
|
||||
"href": "#/chat/threads/d0ba7a66-aa3f-4918-9518-94132bce6b28",
|
||||
"name": "w9",
|
||||
"instance": "assistant"
|
||||
}
|
||||
]
|
||||
},
|
||||
"W9 thread page not opened": {
|
||||
"pass": true,
|
||||
"detail": "#/chat"
|
||||
},
|
||||
"tester banner on chat": {
|
||||
"pass": true
|
||||
},
|
||||
"requests": [
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/config",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/config",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/bus/messages",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/bus-status",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/bus/messages",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/settings",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/bus/usage",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/settings",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/bus/usage",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/bus/names",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/bus/names",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/bus/instances",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/bus/instances",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/bus/routes",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/bus/routes",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/bus/limits",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/bus/limits",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/bus/inbox",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/bus/agents",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/bus/messages",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/chat/projects",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/chat/threads",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/business",
|
||||
"refused": false
|
||||
}
|
||||
],
|
||||
"no write through the listener": {
|
||||
"pass": true,
|
||||
"detail": []
|
||||
},
|
||||
"no request to the W9 thread messages or open": {
|
||||
"pass": true,
|
||||
"detail": []
|
||||
}
|
||||
}
|
||||
W10 browser: FAIL
|
||||
# exit 1
|
||||
@@ -0,0 +1,200 @@
|
||||
{
|
||||
"config": {
|
||||
"sso": null
|
||||
},
|
||||
"forms": [
|
||||
{
|
||||
"id": "sign-out",
|
||||
"hidden": true,
|
||||
"action": "/auth/logout",
|
||||
"method": "post"
|
||||
},
|
||||
{
|
||||
"id": "conv-form",
|
||||
"hidden": true,
|
||||
"action": null,
|
||||
"method": null
|
||||
},
|
||||
{
|
||||
"id": "routes-form",
|
||||
"hidden": false,
|
||||
"action": null,
|
||||
"method": null
|
||||
},
|
||||
{
|
||||
"id": "instance-add-form",
|
||||
"hidden": false,
|
||||
"action": null,
|
||||
"method": null
|
||||
},
|
||||
{
|
||||
"id": "instance-change-form",
|
||||
"hidden": false,
|
||||
"action": null,
|
||||
"method": null
|
||||
},
|
||||
{
|
||||
"id": "tester-name-form",
|
||||
"hidden": false,
|
||||
"action": null,
|
||||
"method": null
|
||||
},
|
||||
{
|
||||
"id": "tester-name-form",
|
||||
"hidden": false,
|
||||
"action": null,
|
||||
"method": null
|
||||
},
|
||||
{
|
||||
"id": "tester-name-form",
|
||||
"hidden": false,
|
||||
"action": null,
|
||||
"method": null
|
||||
},
|
||||
{
|
||||
"id": "tester-name-form",
|
||||
"hidden": false,
|
||||
"action": null,
|
||||
"method": null
|
||||
},
|
||||
{
|
||||
"id": "tester-name-form",
|
||||
"hidden": false,
|
||||
"action": null,
|
||||
"method": null
|
||||
},
|
||||
{
|
||||
"id": "tester-name-form",
|
||||
"hidden": false,
|
||||
"action": null,
|
||||
"method": null
|
||||
}
|
||||
],
|
||||
"chat": {
|
||||
"hash": "#/chat",
|
||||
"open": false,
|
||||
"projects": [
|
||||
{
|
||||
"project": "window-20261020-0801",
|
||||
"threads": [
|
||||
"#/chat/threads/d0ba7a66-aa3f-4918-9518-94132bce6b28"
|
||||
]
|
||||
},
|
||||
{
|
||||
"project": "window-20261020-2205",
|
||||
"threads": [
|
||||
"#/chat/threads/446201de-e8b5-4df3-9356-d0d5422cc899"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"requests": [
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/config",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/config",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/bus/messages",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/bus-status",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/bus/messages",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/settings",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/bus/usage",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/settings",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/bus/usage",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/bus/names",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/bus/names",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/bus/instances",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/bus/instances",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/bus/routes",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/bus/routes",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/bus/limits",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/bus/limits",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/config",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/chat/projects",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/chat/threads",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/business",
|
||||
"refused": false
|
||||
}
|
||||
]
|
||||
}
|
||||
sign-out hidden true, sso null; W9 thread listed true; thread page open false; writes 0
|
||||
# exit 0
|
||||
@@ -0,0 +1,8 @@
|
||||
a 200 ok=true
|
||||
b 401 error="sign-in required"
|
||||
c 401 error="sign-in required"
|
||||
d 403 error="proxy Host refused"
|
||||
e 403 error="tester listener required"
|
||||
f 403 error="proxy header refused"
|
||||
g 200 non-json 6575 bytes
|
||||
# exit 0
|
||||
@@ -0,0 +1,7 @@
|
||||
# P4 2026-10-20T22:31:29Z
|
||||
DropInPaths=
|
||||
ExecStart={ path=/home/jwoltje/.local/bin/chisel ; argv[]=/home/jwoltje/.local/bin/chisel client --fingerprint ce+ISFvMAhuoCRDuc7riIf+hYz87TLHk/C9C/H8QsUg= --keepalive 25s --max-retry-interval 10s http://10.1.1.227:8080 R:0.0.0.0:7332:127.0.0.1:7332 ; ignore_errors=no ; start_time=[n/a] ; stop_time=[n/a] ; pid=0 ; code=(null) ; status=0/0 }
|
||||
# exit 0
|
||||
# R: specs (1): R:0.0.0.0:7332:127.0.0.1:7332
|
||||
P4 PASS: one R: spec R:0.0.0.0:7332:127.0.0.1:7332, DropInPaths empty
|
||||
# exit 0
|
||||
@@ -0,0 +1,6 @@
|
||||
ActiveState=active
|
||||
DropInPaths=
|
||||
MainPID=2950923
|
||||
ExecStart={ path=/home/jwoltje/.local/share/mosaic-stack/copies/4429a104f6d65ccf165f6c83b5d1dce3147dfde0/scripts/mosaic ; argv[]=/home/jwoltje/.local/share/mosaic-stack/copies/4429a104f6d65ccf165f6c83b5d1dce3147dfde0/scripts/mosaic bus start mosaic-stack --pm ; ignore_errors=no ; start_time=[Tue 2026-10-20 17:02:08 CDT] ; stop_time=[n/a] ; pid=2950923 ; code=(null) ; status=0/0 }
|
||||
WorkingDirectory=/home/jwoltje/.local/share/mosaic-stack/copies/4429a104f6d65ccf165f6c83b5d1dce3147dfde0
|
||||
# exit 0
|
||||
@@ -0,0 +1,12 @@
|
||||
● jarvis-curation-gate.service loaded failed failed Jarvis curation-gate liveness check (wake the agent if staged pulls sit uncurated)
|
||||
● jarvis-retro-check.service loaded failed failed Jarvis weekly retrospective ARTIFACT check (did Sunday's tick produce a retro?)
|
||||
● jarvis-retro.service loaded failed failed Jarvis weekly retrospective tick
|
||||
● mosaic-install-drift.service loaded failed failed Assert the installed Mosaic binaries still match the committed source (drift, not failure)
|
||||
● mosaic-standup-pm.service loaded failed failed Coordinator standup trigger (evening)
|
||||
● [email protected] loaded failed failed Escalate failed Mosaic unit jarvis-curation-gate.service to the local agent seat
|
||||
● [email protected] loaded failed failed Escalate failed Mosaic unit jarvis-retro-check.service to the local agent seat
|
||||
● [email protected] loaded failed failed Escalate failed Mosaic unit jarvis-retro.service to the local agent seat
|
||||
● [email protected] loaded failed failed Escalate failed Mosaic unit mosaic-install-drift.service to the local agent seat
|
||||
● [email protected] loaded failed failed Escalate failed Mosaic unit mosaic-reconcile.service to the local agent seat
|
||||
● setspark-tmp-guard.service loaded failed failed SetSpark: /tmp and scratch guard
|
||||
# exit 0
|
||||
@@ -0,0 +1,5 @@
|
||||
ActiveState=active
|
||||
SubState=running
|
||||
ActiveEnterTimestamp=Tue 2026-10-13 08:40:19 CDT
|
||||
MainPID=1317
|
||||
# exit 0
|
||||
@@ -0,0 +1,6 @@
|
||||
ActiveState=active
|
||||
DropInPaths=
|
||||
MainPID=2950925
|
||||
ExecStart={ path=/usr/bin/node ; argv[]=/usr/bin/node /home/jwoltje/.local/share/mosaic-stack/copies/4429a104f6d65ccf165f6c83b5d1dce3147dfde0/packages/webui/src/cli.mjs serve --checkout /mnt/storage/src/mosaic-stack --port 7332 --public-host mosaic.woltje.com --auth-header X-authentik-username --allow-user jason.woltje --tester-port 7333 ; ignore_errors=no ; start_time=[Tue 2026-10-20 17:02:08 CDT] ; stop_time=[n/a] ; pid=2950925 ; code=(null) ; status=0/0 }
|
||||
WorkingDirectory=/home/jwoltje/.local/share/mosaic-stack/copies/4429a104f6d65ccf165f6c83b5d1dce3147dfde0
|
||||
# exit 0
|
||||
@@ -0,0 +1,2 @@
|
||||
chief r097436c91816 claude-code since 2026-10-20T22:02:08.951Z
|
||||
# exit 0
|
||||
@@ -0,0 +1,7 @@
|
||||
PASS markdown-preview.js is served and equals the copy's
|
||||
PASS /api/config on loopback: send=false edit=false
|
||||
PASS project.files through the Console: head=ae0b17c9586e51da7bc1b7e548c6acf913a55665 files=2 spec=true error=none
|
||||
PASS project.file spec/north-star.md through the Console: commit=ae0b17c9586e51da7bc1b7e548c6acf913a55665 length=99 error=none
|
||||
PASS loopback save refused: HTTP 403 error=read-only sent=false
|
||||
CONSOLE_PASS
|
||||
# exit 0
|
||||
@@ -0,0 +1,19 @@
|
||||
[
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"instance": "chief",
|
||||
"run": "r097436c91816",
|
||||
"launchedBy": "jason",
|
||||
"harness": "claude-code",
|
||||
"model": "claude-opus-5-5",
|
||||
"provider": "anthropic",
|
||||
"modelKey": "opus",
|
||||
"pid": 2951160,
|
||||
"startTime": "63491912",
|
||||
"runnerPid": 2951161,
|
||||
"runnerStartTime": "63491912",
|
||||
"runDir": "/home/jwoltje/.mosaic-dev/launches/mosaic-stack/r097436c91816",
|
||||
"startedAt": "2026-10-20T22:02:08.941Z",
|
||||
"live": true
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,2 @@
|
||||
r097436c91816 mosaic-stack/chief claude-code claude-opus-5-5 by jason since 2026-10-20T22:02:08.941Z running
|
||||
# exit 0
|
||||
@@ -0,0 +1,2 @@
|
||||
[]
|
||||
# exit 0
|
||||
@@ -0,0 +1,2 @@
|
||||
created project window-20261020-2205 id 77e7b45f-8c34-49a3-b2fa-9cf565177c30
|
||||
# exit 0
|
||||
@@ -0,0 +1 @@
|
||||
1792533965
|
||||
@@ -0,0 +1,21 @@
|
||||
pointer: /home/jwoltje/.mosaic-dev/state/stack-active.json
|
||||
{"copy":"4429a104f6d65ccf165f6c83b5d1dce3147dfde0","business":"mosaic-stack","pm":true,"claude":"/home/jwoltje/.local/bin/claude","console":["--port","7332","--public-host","mosaic.woltje.com","--auth-header","X-authentik-username","--allow-user","jason.woltje","--tester-port","7333"],"schema":"3i","activation":"20261020T220206Z-4429a104f6d6","at":"2026-10-20T22:02:09Z"}
|
||||
log (last five lines):
|
||||
{"at":"2026-10-20T07:58:02Z","op":"activate","from":"89af38a83afa22a8a4f0d368a0a4030ad15a0efa","to":"490e2771d3d057ab76991e2c7859cd251857e64c","backup":"20261020T075800Z-490e2771d3d0","activation":"20261020T075800Z-490e2771d3d0","result":"ok"}
|
||||
{"at":"2026-10-20T19:29:56Z","op":"activate","from":"490e2771d3d057ab76991e2c7859cd251857e64c","to":"490e2771d3d057ab76991e2c7859cd251857e64c","backup":null,"activation":"20261020T192956Z-490e2771d3d0","result":"started"}
|
||||
{"at":"2026-10-20T19:30:00Z","op":"activate","from":"490e2771d3d057ab76991e2c7859cd251857e64c","to":"490e2771d3d057ab76991e2c7859cd251857e64c","backup":"20261020T192956Z-490e2771d3d0","activation":"20261020T192956Z-490e2771d3d0","result":"ok"}
|
||||
{"at":"2026-10-20T22:02:06Z","op":"activate","from":"490e2771d3d057ab76991e2c7859cd251857e64c","to":"4429a104f6d65ccf165f6c83b5d1dce3147dfde0","backup":null,"activation":"20261020T220206Z-4429a104f6d6","result":"started"}
|
||||
{"at":"2026-10-20T22:02:09Z","op":"activate","from":"490e2771d3d057ab76991e2c7859cd251857e64c","to":"4429a104f6d65ccf165f6c83b5d1dce3147dfde0","backup":"20261020T220206Z-4429a104f6d6","activation":"20261020T220206Z-4429a104f6d6","result":"ok"}
|
||||
check:
|
||||
bus: /home/jwoltje/.local/share/mosaic-stack/copies/4429a104f6d65ccf165f6c83b5d1dce3147dfde0
|
||||
Console: /home/jwoltje/.local/share/mosaic-stack/copies/4429a104f6d65ccf165f6c83b5d1dce3147dfde0
|
||||
CLI: /home/jwoltje/.local/share/mosaic-stack/copies/4429a104f6d65ccf165f6c83b5d1dce3147dfde0
|
||||
result: pass
|
||||
bus status:
|
||||
host: mosaic-stack, pid 2950923, running, since 2026-10-20T22:02:08.133Z, notifier off
|
||||
socket: present
|
||||
writer.lock: pid 2950981
|
||||
boot: organization file loaded at 2026-10-20T22:02:08.125Z
|
||||
unit flags: match the pointer
|
||||
drift: 6 commit(s) on refactor since the pointer's copy
|
||||
# exit 0
|
||||
@@ -0,0 +1,4 @@
|
||||
sent c5b43f04 to chief
|
||||
chief (RESULT, re c5b43f04) c3e236d7:
|
||||
Got the re-pin of 4429a104f6d65ccf165f6c83b5d1dce3147dfde0 from the tester; I took no action because a tester message authorizes nothing, and I launched and changed nothing.
|
||||
# exit 0
|
||||
@@ -0,0 +1,4 @@
|
||||
sent b4b66ced to window-20261020-2205/w9
|
||||
assistant (RESULT, re b4b66ced) cfa7ea99:
|
||||
Assistant (run r497f1c817d4d) got the [mos test] message and is replying in one line as asked, without treating it as Jason's instruction or acting on it.
|
||||
# exit 0
|
||||
@@ -0,0 +1,32 @@
|
||||
[
|
||||
{
|
||||
"seq": 570,
|
||||
"id": "b4b66ced-d088-4b9c-a944-306f164028f9",
|
||||
"at": "2026-10-20T22:05:58.283Z",
|
||||
"business": "mosaic-stack",
|
||||
"from_role": "tester",
|
||||
"from_run": "tester-cli",
|
||||
"to_role": "assistant",
|
||||
"class": "REQUEST",
|
||||
"in_reply_to": null,
|
||||
"decision": null,
|
||||
"corrects": null,
|
||||
"body": "[mos test] reply with one line, then stop",
|
||||
"to_name": null
|
||||
},
|
||||
{
|
||||
"seq": 571,
|
||||
"id": "cfa7ea99-7ccb-4225-80a6-9a903540e337",
|
||||
"at": "2026-10-20T22:06:04.247Z",
|
||||
"business": "mosaic-stack",
|
||||
"from_role": "assistant",
|
||||
"from_run": "r497f1c817d4d",
|
||||
"to_role": "tester",
|
||||
"class": "RESULT",
|
||||
"in_reply_to": "b4b66ced-d088-4b9c-a944-306f164028f9",
|
||||
"decision": null,
|
||||
"corrects": null,
|
||||
"body": "Assistant (run r497f1c817d4d) got the [mos test] message and is replying in one line as asked, without treating it as Jason's instruction or acting on it.",
|
||||
"to_name": null
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,2 @@
|
||||
created window-20261020-2205/w9 id 446201de-e8b5-4df3-9356-d0d5422cc899 instance assistant
|
||||
# exit 0
|
||||
@@ -0,0 +1,3 @@
|
||||
window-20261020-0801/w9 assistant d0ba7a66
|
||||
window-20261020-2205/w9 assistant 446201de
|
||||
# exit 0
|
||||
@@ -0,0 +1 @@
|
||||
446201de-e8b5-4df3-9356-d0d5422cc899
|
||||
@@ -0,0 +1,165 @@
|
||||
{
|
||||
"total": {
|
||||
"input": 62,
|
||||
"output": 9623,
|
||||
"cacheWrite": 186650,
|
||||
"cacheRead": 404132,
|
||||
"unknownTurns": 1
|
||||
},
|
||||
"byRun": [
|
||||
{
|
||||
"run": "r8fba7e598156",
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cacheWrite": 0,
|
||||
"cacheRead": 0,
|
||||
"unknownTurns": 1
|
||||
},
|
||||
{
|
||||
"run": "r4a96e2142e4d",
|
||||
"input": 2,
|
||||
"output": 52,
|
||||
"cacheWrite": 11750,
|
||||
"cacheRead": 0,
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"run": "rc78c9700c8e9",
|
||||
"input": 14,
|
||||
"output": 1519,
|
||||
"cacheWrite": 18187,
|
||||
"cacheRead": 89559,
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"run": "r293f33bcbc63",
|
||||
"input": 2,
|
||||
"output": 44,
|
||||
"cacheWrite": 18595,
|
||||
"cacheRead": 0,
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"run": "r34520a581318",
|
||||
"input": 6,
|
||||
"output": 323,
|
||||
"cacheWrite": 26349,
|
||||
"cacheRead": 14307,
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"run": "r7e99838f9f93",
|
||||
"input": 2,
|
||||
"output": 44,
|
||||
"cacheWrite": 9858,
|
||||
"cacheRead": 0,
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"run": "rc964611b2092",
|
||||
"input": 4,
|
||||
"output": 120,
|
||||
"cacheWrite": 26138,
|
||||
"cacheRead": 23870,
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"run": "rbb708846b605",
|
||||
"input": 12,
|
||||
"output": 2089,
|
||||
"cacheWrite": 17285,
|
||||
"cacheRead": 73823,
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"run": "rc36b17ef3788",
|
||||
"input": 16,
|
||||
"output": 5221,
|
||||
"cacheWrite": 36717,
|
||||
"cacheRead": 202573,
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"run": "r097436c91816",
|
||||
"input": 2,
|
||||
"output": 131,
|
||||
"cacheWrite": 11915,
|
||||
"cacheRead": 0,
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"run": "r497f1c817d4d",
|
||||
"input": 2,
|
||||
"output": 80,
|
||||
"cacheWrite": 9856,
|
||||
"cacheRead": 0,
|
||||
"unknownTurns": 0
|
||||
}
|
||||
],
|
||||
"byInstance": [
|
||||
{
|
||||
"instance": "chief",
|
||||
"input": 36,
|
||||
"output": 4114,
|
||||
"cacheWrite": 85486,
|
||||
"cacheRead": 177689,
|
||||
"unknownTurns": 1
|
||||
},
|
||||
{
|
||||
"instance": "coder",
|
||||
"input": 2,
|
||||
"output": 44,
|
||||
"cacheWrite": 18595,
|
||||
"cacheRead": 0,
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"instance": "assistant",
|
||||
"input": 4,
|
||||
"output": 124,
|
||||
"cacheWrite": 19714,
|
||||
"cacheRead": 0,
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"instance": "pm",
|
||||
"input": 20,
|
||||
"output": 5341,
|
||||
"cacheWrite": 62855,
|
||||
"cacheRead": 226443,
|
||||
"unknownTurns": 0
|
||||
}
|
||||
],
|
||||
"byProvider": [
|
||||
{
|
||||
"provider": "anthropic",
|
||||
"input": 62,
|
||||
"output": 9623,
|
||||
"cacheWrite": 186650,
|
||||
"cacheRead": 404132,
|
||||
"unknownTurns": 1
|
||||
}
|
||||
],
|
||||
"byModel": [
|
||||
{
|
||||
"provider": "anthropic",
|
||||
"model": "claude-opus-5-5",
|
||||
"input": 62,
|
||||
"output": 9623,
|
||||
"cacheWrite": 186650,
|
||||
"cacheRead": 404132,
|
||||
"unknownTurns": 1
|
||||
}
|
||||
],
|
||||
"byAccount": [
|
||||
{
|
||||
"account": null,
|
||||
"input": 62,
|
||||
"output": 9623,
|
||||
"cacheWrite": 186650,
|
||||
"cacheRead": 404132,
|
||||
"unknownTurns": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
# exit 0
|
||||
@@ -0,0 +1,2 @@
|
||||
r097436c91816 mosaic-stack/chief claude-code claude-opus-5-5 by jason since 2026-10-20T22:02:08.941Z running
|
||||
# exit 0
|
||||
@@ -0,0 +1,19 @@
|
||||
[
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"instance": "chief",
|
||||
"run": "r097436c91816",
|
||||
"launchedBy": "jason",
|
||||
"harness": "claude-code",
|
||||
"model": "claude-opus-5-5",
|
||||
"provider": "anthropic",
|
||||
"modelKey": "opus",
|
||||
"pid": 2951160,
|
||||
"startTime": "63491912",
|
||||
"runnerPid": 2951161,
|
||||
"runnerStartTime": "63491912",
|
||||
"runDir": "/home/jwoltje/.mosaic-dev/launches/mosaic-stack/r097436c91816",
|
||||
"startedAt": "2026-10-20T22:02:08.941Z",
|
||||
"live": true
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,153 @@
|
||||
{
|
||||
"schema": "3i",
|
||||
"tables": {
|
||||
"decision_events": {
|
||||
"rows": 21,
|
||||
"maxSeq": 21,
|
||||
"sha256": "28dd95056663dd5eb7442a3d815b73a4a3cd311a0e854fd82dc54f7dc3e6ef26"
|
||||
},
|
||||
"decisions": {
|
||||
"rows": 21,
|
||||
"maxSeq": 21,
|
||||
"sha256": "fef1058660b0d128ef21089dda5e0fba7f71763d8325148fe521de0d3f57e4ad"
|
||||
},
|
||||
"deliveries": {
|
||||
"rows": 1726,
|
||||
"maxSeq": 1726,
|
||||
"sha256": "8667571f4a05ce3621b04cf2dc5eb0311bac3d3aff1858584c78445c5c4c2fb4"
|
||||
},
|
||||
"events": {
|
||||
"rows": 1525,
|
||||
"maxSeq": 1525,
|
||||
"sha256": "e51827e31f2fa5a9a50993e5a10f37f1dee383b7d673f793006bd2ba1e9a5573"
|
||||
},
|
||||
"instance_names": {
|
||||
"rows": 0,
|
||||
"maxSeq": null,
|
||||
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
"message_threads": {
|
||||
"rows": 2,
|
||||
"maxSeq": null
|
||||
},
|
||||
"messages": {
|
||||
"rows": 567,
|
||||
"maxSeq": 567
|
||||
},
|
||||
"meta": {
|
||||
"rows": 3,
|
||||
"maxSeq": null,
|
||||
"sha256": "89fec56e7293d19e0b2f6cd8c0ef745ebf99e4115abeaea7fbdbc98a261332d9"
|
||||
},
|
||||
"projects": {
|
||||
"rows": 1,
|
||||
"maxSeq": 1,
|
||||
"sha256": "a3945f7462895e0e302029c542d384a6d33e559da900e6461d78bbcd7cc28b16"
|
||||
},
|
||||
"role_claims": {
|
||||
"rows": 98,
|
||||
"maxSeq": 98,
|
||||
"sha256": "ac6694fd0b365469f7486ae1f31486cd52b31be7bc0399938b7361f22454362e"
|
||||
},
|
||||
"task_comment_cursors": {
|
||||
"rows": 215,
|
||||
"maxSeq": 215,
|
||||
"sha256": "7f80df8efb90b502a9530a7ed5068d59be59b2eb318aa4e64ce6bf481762a5e9"
|
||||
},
|
||||
"task_snapshots": {
|
||||
"rows": 401,
|
||||
"maxSeq": 401,
|
||||
"sha256": "cced8c7f90ecaa60eee3010d987013d8c0301563cb888860fd91b8c5a02f0a00"
|
||||
},
|
||||
"thread_claims": {
|
||||
"rows": 2,
|
||||
"maxSeq": 2,
|
||||
"sha256": "6046c7088779aa2f6e9b3fd2c7520404b1678a4fcae71c2b9157a008bba22a5f"
|
||||
},
|
||||
"thread_opens": {
|
||||
"rows": 0,
|
||||
"maxSeq": null,
|
||||
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
"threads": {
|
||||
"rows": 1,
|
||||
"maxSeq": 1,
|
||||
"sha256": "3043721efd386f23dfdaad4acf41e0d553ff6182f0068c2133a615141bcf772a"
|
||||
},
|
||||
"usage_records": {
|
||||
"rows": 30,
|
||||
"maxSeq": 30,
|
||||
"sha256": "7ecbc0606273043b017d778bb23880377bfe367f788d234289b7c132caa588f2",
|
||||
"byRun": [
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"run": "r8fba7e598156",
|
||||
"input": "0",
|
||||
"output": "0",
|
||||
"unknownTurns": 1
|
||||
},
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"run": "r4a96e2142e4d",
|
||||
"input": "2",
|
||||
"output": "52",
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"run": "rc78c9700c8e9",
|
||||
"input": "14",
|
||||
"output": "1519",
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"run": "r293f33bcbc63",
|
||||
"input": "2",
|
||||
"output": "44",
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"run": "r34520a581318",
|
||||
"input": "6",
|
||||
"output": "323",
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"run": "r7e99838f9f93",
|
||||
"input": "2",
|
||||
"output": "44",
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"run": "rc964611b2092",
|
||||
"input": "4",
|
||||
"output": "120",
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"run": "rbb708846b605",
|
||||
"input": "12",
|
||||
"output": "2089",
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"run": "rc36b17ef3788",
|
||||
"input": "16",
|
||||
"output": "5221",
|
||||
"unknownTurns": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"untimed": [
|
||||
"message_threads",
|
||||
"meta",
|
||||
"task_comment_cursors"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
{
|
||||
"schema": "3i",
|
||||
"tables": {
|
||||
"decision_events": {
|
||||
"rows": 21,
|
||||
"maxSeq": 21,
|
||||
"sha256": "28dd95056663dd5eb7442a3d815b73a4a3cd311a0e854fd82dc54f7dc3e6ef26"
|
||||
},
|
||||
"decisions": {
|
||||
"rows": 21,
|
||||
"maxSeq": 21,
|
||||
"sha256": "fef1058660b0d128ef21089dda5e0fba7f71763d8325148fe521de0d3f57e4ad"
|
||||
},
|
||||
"deliveries": {
|
||||
"rows": 1740,
|
||||
"maxSeq": 1740,
|
||||
"sha256": "554ce5b377ffacc72bfde11c99c920cd5d30337c8bfa217275d0073b587e2169"
|
||||
},
|
||||
"events": {
|
||||
"rows": 1543,
|
||||
"maxSeq": 1543,
|
||||
"sha256": "0151673193d0286ce8f3b234d7d21fa2b0f43f2ceec55644cccaa5cbd77603dd"
|
||||
},
|
||||
"instance_names": {
|
||||
"rows": 0,
|
||||
"maxSeq": null,
|
||||
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
"message_threads": {
|
||||
"rows": 4,
|
||||
"maxSeq": null
|
||||
},
|
||||
"messages": {
|
||||
"rows": 571,
|
||||
"maxSeq": 571
|
||||
},
|
||||
"meta": {
|
||||
"rows": 3,
|
||||
"maxSeq": null,
|
||||
"sha256": "89fec56e7293d19e0b2f6cd8c0ef745ebf99e4115abeaea7fbdbc98a261332d9"
|
||||
},
|
||||
"projects": {
|
||||
"rows": 2,
|
||||
"maxSeq": 2,
|
||||
"sha256": "3435f574ab94bc2fcc65fb8605251f30d4e9e63baf38e6fdf3c39c126531aa63"
|
||||
},
|
||||
"role_claims": {
|
||||
"rows": 101,
|
||||
"maxSeq": 101,
|
||||
"sha256": "fb47114defbb8a57dd148a06fbaf61b30f791a81f14b1510b8c32690506b4308"
|
||||
},
|
||||
"task_comment_cursors": {
|
||||
"rows": 220,
|
||||
"maxSeq": 220,
|
||||
"sha256": "df03c84094999299499de01107ddd08636df6156f5c9ccd2ff6e8c43e459f7e4"
|
||||
},
|
||||
"task_snapshots": {
|
||||
"rows": 408,
|
||||
"maxSeq": 408,
|
||||
"sha256": "e2243a89388afba07e8d096b4961deeb2d17c656cdcace54b46eac7001393814"
|
||||
},
|
||||
"thread_claims": {
|
||||
"rows": 4,
|
||||
"maxSeq": 4,
|
||||
"sha256": "088b8236570d8afe03364b85803fdc5034cce2cef6ed7b16f8b99bf6366524ad"
|
||||
},
|
||||
"thread_opens": {
|
||||
"rows": 0,
|
||||
"maxSeq": null,
|
||||
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
"threads": {
|
||||
"rows": 2,
|
||||
"maxSeq": 2,
|
||||
"sha256": "819fd24c64e279d0debad5ab5dbbe533d8e6f26de58277c69510b9cbbaadbc3a"
|
||||
},
|
||||
"usage_records": {
|
||||
"rows": 34,
|
||||
"maxSeq": 34,
|
||||
"sha256": "de614f3d62c9b3819757c5659bee40085cb86cc873b7bfe381b13e254ddb7cad",
|
||||
"byRun": [
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"run": "r8fba7e598156",
|
||||
"input": "0",
|
||||
"output": "0",
|
||||
"unknownTurns": 1
|
||||
},
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"run": "r4a96e2142e4d",
|
||||
"input": "2",
|
||||
"output": "52",
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"run": "rc78c9700c8e9",
|
||||
"input": "14",
|
||||
"output": "1519",
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"run": "r293f33bcbc63",
|
||||
"input": "2",
|
||||
"output": "44",
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"run": "r34520a581318",
|
||||
"input": "6",
|
||||
"output": "323",
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"run": "r7e99838f9f93",
|
||||
"input": "2",
|
||||
"output": "44",
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"run": "rc964611b2092",
|
||||
"input": "4",
|
||||
"output": "120",
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"run": "rbb708846b605",
|
||||
"input": "12",
|
||||
"output": "2089",
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"run": "rc36b17ef3788",
|
||||
"input": "16",
|
||||
"output": "5221",
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"run": "r097436c91816",
|
||||
"input": "2",
|
||||
"output": "131",
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"run": "r497f1c817d4d",
|
||||
"input": "2",
|
||||
"output": "80",
|
||||
"unknownTurns": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"untimed": [
|
||||
"message_threads",
|
||||
"meta",
|
||||
"task_comment_cursors"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
# Re-pin window 490e2771d to 4429a104f (row 192, Q175 phase 1), STATE report (W11)
|
||||
|
||||
Operator and tester: Sage. Plan: `agents/sage/work/window-plan/repin-4429a104f.md` at f41729178, sha256
|
||||
e1272d1a391e00fb1291d47da9973c0ba342d65fcd42f583ea007e883c1f32b9 (checked before W0b, W9). The plan file in
|
||||
this commit carries three corrections made after the window. They are listed at the end, so its sha256 differs
|
||||
now. Tool blob 3b068b1546d4 (`scripts/stack-release.sh`). OLD 490e2771d3d057ab76991e2c7859cd251857e64c, NEW
|
||||
4429a104f6d65ccf165f6c83b5d1dce3147dfde0. Every stamp below comes from `date -u` or from the transcript `W.txt`
|
||||
in this directory. Mos's gos: mos-1113 (W5), mos-1114 (W9 to W11), mos-1128 (W9b accepted), mos-1135 (W10
|
||||
browser part), mos-1137 (line g, post W11).
|
||||
|
||||
## Outcome
|
||||
|
||||
NEW is live for mosaic-stack with its PM, and the store stays on 3i. Row 192's sign-in code is in the copy but
|
||||
dormant, because the Console runs without `--sso`. W0 to W10 passed, and nothing was rolled back. W10 stopped
|
||||
once on two failed browser checks. I diagnosed both read-only as checks written before row 192, not faults in
|
||||
NEW, and Mos passed them (mos-1135). Matrix line (g) isn't byte-identical, for a known cause, and Mos passed it
|
||||
(mos-1137). Nobody acted as Jason. There was no `--by jason`, no pi, no Save and no `mosaic name`, and
|
||||
`bus.tester` was not touched.
|
||||
|
||||
## Stamps and activation
|
||||
|
||||
| Step | Start (UTC) | Result |
|
||||
| --- | --- | --- |
|
||||
| P1 values, business-check OLD | file written 21:43:13Z | values as planned, LOAD_OK (`p1.txt`) |
|
||||
| W0 copy NEW with npm ci, business-check NEW | 21:45:57Z | copied, lockfile diff empty, LOAD_OK (`w0.txt`) |
|
||||
| P2 console-check on a stub of NEW | 21:46:09Z | CONSOLE_PASS without and with `--tester-port` (`p2.txt`) |
|
||||
| P3 rehearsal (Mos) | 21:52:31Z to 21:52:34Z | REHEARSAL_PASS, rc 0 (`p3/`) |
|
||||
| P3 flags check (Mos) | 21:53:52Z to 21:53:56Z | NEW Console with the pointer flags on 7341/7342: healthz 200 both, then gone, 0 listeners |
|
||||
| P4, P5, P6 | 22:00:17Z | P4 PASS, P5 PASS (HEAD 4385b9ec1), `matrix-old.txt` |
|
||||
| W0b business file copy | 22:00:49Z | sha256 1e49bdf0 |
|
||||
| W1 idle check, S1 | 22:00:49.758Z | chief idle; S1 a7649760 |
|
||||
| W2 status | 22:00:49.819Z | pointer OLD, pm true, 3i, unit flags match |
|
||||
| W3 plan | 22:00:50.068Z | exit 0 (`W3-plan.txt` 45de68d4) |
|
||||
| W4 unit read | 22:00:50.238Z | OLD units, no drop-ins, bus `--pm`, no quote or backslash |
|
||||
| W5 activate (mos-1113) | 22:02:06.776Z, end 22:02:09.556Z | exit 0, gate passed, activate ok |
|
||||
| W5 status | 22:02:13.885Z | pointer NEW, pm true, 3i, unit flags match |
|
||||
| W9 checks and turns (mos-1114) | 22:05:51Z to 22:06:06Z | pass |
|
||||
| W9b run end and S2 | 22:21:05.171Z, S2 22:21:07Z | ended by the launcher (thread-idle); S2 1a444f5a |
|
||||
| W10 browser (stop), diagnosis | 22:29:14Z, 22:30:02Z | 10 of 12, then both failures traced to the checks |
|
||||
| W10 matrix, P4 (mos-1135) | 22:31:29Z | a-f identical, g +121 bytes; P4 PASS |
|
||||
| W11 reads | 22:44:33Z to 22:44:34Z | units, tunnel, business file, failed units |
|
||||
|
||||
Activation id 20261020T220206Z-4429a104f6d6. Pointer `at` 2026-10-20T22:02:09Z.
|
||||
|
||||
## Backups and hashes
|
||||
|
||||
- Store backup: `~/.mosaic-dev/backups/stack/20261020T220206Z-4429a104f6d6`, SHA256SUMS sha256
|
||||
b61d64843c9e2a153842606353450838da9cb778c8f3b9c27cc9366dd72ec0c9.
|
||||
- Rollback set: `~/.mosaic-dev/state/stack-rollback/20261020T220206Z-4429a104f6d6`. Rollback goes to 490e2771d.
|
||||
- Business file: 1e49bdf05e62607972958f5b7298cfd600c7825812370e26d7c2a842f2af9b93 at P1, at W0b and again at
|
||||
W11. The W0b copy is `~/.config/mosaic-dev/businesses/mosaic-stack.json.pre-window-20261020T220049Z` with the
|
||||
same hash. Nothing in the window wrote it. `backups.txt` holds the W0b and W5 lines.
|
||||
|
||||
## Pointer before and after
|
||||
|
||||
Before (W2): copy 490e2771d3d0, pm true, schema 3i, activation 20261020T192956Z-490e2771d3d0. Console flags
|
||||
`--port 7332 --public-host mosaic.woltje.com --auth-header X-authentik-username --allow-user jason.woltje
|
||||
--tester-port 7333`.
|
||||
|
||||
After (W5, W9 and W11): copy 4429a104f6d6, pm true, schema 3i, activation 20261020T220206Z-4429a104f6d6. The
|
||||
Console flags are the same, with no `--send`, no `--sso` and no proof pair. Bus pid 2950923 and Console pid
|
||||
2950925, both since 22:02:08Z. Status shows drift of 6 commits on refactor since the pointer's copy (records).
|
||||
|
||||
## Unit ExecStart lines (no Environment values)
|
||||
|
||||
W11, after. Both units active, DropInPaths empty, WorkingDirectory the NEW copy:
|
||||
|
||||
```
|
||||
bus: ~/.local/share/mosaic-stack/copies/4429a104f6d65ccf165f6c83b5d1dce3147dfde0/scripts/mosaic bus start mosaic-stack --pm (MainPID 2950923)
|
||||
webui: /usr/bin/node ~/.local/share/mosaic-stack/copies/4429a104f6d65ccf165f6c83b5d1dce3147dfde0/packages/webui/src/cli.mjs serve --checkout /mnt/storage/src/mosaic-stack --port 7332 --public-host mosaic.woltje.com --auth-header X-authentik-username --allow-user jason.woltje --tester-port 7333 (MainPID 2950925)
|
||||
```
|
||||
|
||||
W4 read the same two lines on OLD's copy (W.txt 329-344). The bus Environment has 2 assignments with no quote or
|
||||
backslash. The values are not recorded. The Console's cwd is the NEW copy (W9). The webui journal since W5 has one
|
||||
"Proxy: proxy proof off" line and no "Sign-in:" line.
|
||||
|
||||
Tunnel: `mosaic-preview-tunnel.service` active, running, MainPID 1317, since 2026-10-13 08:40:19 CDT. The window
|
||||
didn't touch it. P4 before and after shows one R: spec, `R:0.0.0.0:7332:127.0.0.1:7332`.
|
||||
|
||||
## S1, S2 and the store
|
||||
|
||||
S1 (`S1.json`, sha256 a7649760c3439705c8b5f3af26d0c21aea1081c1eb295fd02aef3a642064194b) is W1's held read at
|
||||
22:00:49Z. S2 (`agents/sage/work/window-repin-4429a104f/S2.json`, sha256
|
||||
1a444f5ac0049bc78e397b6928e1008cfe8cbc30989bbf2042b4ed27f56a4173) is the held read after the assistant run
|
||||
ended, at 22:21:07Z. Both are 3i. SNAP (`snapshot.mjs`, 639fd28a) was checked before each.
|
||||
|
||||
| Table | S1 rows | S2 rows | S2 vs S1 |
|
||||
| --- | --- | --- | --- |
|
||||
| decisions, decision_events | 21, 21 | 21, 21 | same sha256 |
|
||||
| meta, instance_names, thread_opens | 3, 0, 0 | 3, 0, 0 | same sha256 |
|
||||
| deliveries | 1726 | 1740 | +14 |
|
||||
| events | 1525 | 1543 | +18 |
|
||||
| role_claims | 98 | 101 | +3 |
|
||||
| messages | 567 | 571 | +4 (counts only) |
|
||||
| message_threads | 2 | 4 | +2 (counts only) |
|
||||
| usage_records | 30 | 34 | +4 |
|
||||
| projects, threads, thread_claims | 1, 1, 2 | 2, 2, 4 | +1, +1, +2 |
|
||||
| task_snapshots | 401 | 408 | +7 |
|
||||
| task_comment_cursors | 215 | 220 | +5 |
|
||||
|
||||
For every grown table with a seq, maxSeq grew by exactly the row delta.
|
||||
|
||||
The task_snapshots and task_comment_cursors rows (mos-1128 item 2). The writer is the bus's own Vikunja poll, not
|
||||
an agent. `packages/bus/src/process.mjs:104` loads the tasks adapter. Its timer runs `tick` every `pollSeconds`
|
||||
(`packages/tasks/src/adapter.mjs:192-197`), and `sync.mjs:169` calls `broker.recordTask` with no cap
|
||||
(`packages/bus/src/broker.mjs:1123`). That writes `source 'poll'` with role and run null (`broker.mjs:1159-1174`)
|
||||
and the comment cursors (`:1131-1138`). The adapter logs only on an error (`adapter.mjs:115`), so the bus journal
|
||||
has no line for a good tick. That's why there's no sync line between 22:05Z and 22:22Z. The rows, read
|
||||
read-only after the window:
|
||||
|
||||
- task_snapshots 402-404 at 22:05:08Z: P19 436, 435 and 434, all updated 22:04:55-56Z. 405-406 at 22:12:38Z: 438
|
||||
and 437. 407 at 22:17:08Z: 438 again. 408 at 22:19:08Z: 431. All have `source poll`, `via cursor`, role null
|
||||
and run null. These are tracker edits made by seats during the window, which the poll picked up.
|
||||
- task_comment_cursors 216-220: tasks 424, 432, 438, 431 and 436, last comments 397 to 401. These are the
|
||||
comments seats posted on P19 in the same minutes (398 is Mos's comment on #432).
|
||||
|
||||
Rows after S2 (task_snapshots 409 at 22:33Z, cursors 221-223) are the same poll and are outside the window.
|
||||
|
||||
## W9 output
|
||||
|
||||
- Status, console-check (CONSOLE_PASS), the journal counts, the Console cwd and one running chief from this
|
||||
start (r097436c91816) all passed.
|
||||
- `talk chief --tester` (c5b43f04): the chief replied that a tester message authorizes nothing and it took no
|
||||
action.
|
||||
- Project `window-20261020-2205`, thread 446201de-e8b5-4df3-9356-d0d5422cc899. `talk --thread` (b4b66ced) got one
|
||||
assistant reply from run r497f1c817d4d. `thread read` shows the tester REQUEST and the assistant RESULT.
|
||||
- `threads`, `names` (empty) and `usage` read fine. The human-thread probe was skipped: S1's one thread is the
|
||||
177-185 window's tester thread d0ba7a66, so no human thread exists.
|
||||
- W9b: the launcher logged launch at 22:06:00.766Z, thread-idle at 22:21:05.149Z and end at 22:21:05.171Z
|
||||
(reason stopped, exit 0). Nobody stopped it by hand.
|
||||
|
||||
## W10
|
||||
|
||||
The browser check (`w10-browser.mjs`, dda57ccf) ran against 127.0.0.1:7333 with fetch and XHR limited to GET and
|
||||
HEAD. It made no writes and sent no W9 thread request. 10 of 12 checks passed. The two that failed:
|
||||
|
||||
1. "Only tester forms": `#sign-out` was in the list. It is row 192's form at `packages/webui/src/public/index.html:12`,
|
||||
`hidden`. `app.js:479` shows it only when `/api/config` has `sso: true`. The live config has no `sso`
|
||||
key, and the form stayed hidden.
|
||||
2. "Chat lists the W9 thread": the check read only the first `ul.chat-thread-list`. `chat.js:93` renders one list
|
||||
per project, and W9 made the second project. The diagnosis (`w10-diag.mjs`, 42d0844b) read every panel and
|
||||
found 446201de listed.
|
||||
|
||||
Both checks come from the 177-185 window (Darkwing's `window-rows-177-185/w10-browser.mjs`), written before row
|
||||
192 and before a second thread project existed. Corrected, they now read:
|
||||
|
||||
- Only tester forms, allowing `#sign-out` when it is `hidden` and `/api/config` has no `sso: true`.
|
||||
- The W9 thread is in some `.chat-projects section.panel` thread list, not only the first.
|
||||
|
||||
Both corrections are in the plan's W10 line, for the next window that copies this one.
|
||||
|
||||
The matrix (`matrix-new.txt` against `matrix-old.txt`). Lines a to f are byte-identical. Line g is 200 non-json
|
||||
both times, but 6454 bytes on OLD and 6575 on NEW. `index.html` is 6470 bytes at 490e2771d and 6591 at 4429a104f,
|
||||
+121, and the only hunk is the line 12 form. The served page grew by the same 121. The plan's W10 line now says a
|
||||
line for a file the pin changes isn't "identical", with this case as its example. P4 again at 22:31:29Z: PASS.
|
||||
|
||||
## Failed user units (mos-1128 item 3)
|
||||
|
||||
All three predate the re-pin at 22:02Z and come from the legacy `~/.mosaic` and jarvis-brain setup, not the
|
||||
stack. P19 #393, "dragon-lin: seven legacy user units in failed state", covers all three (filed by Mos on
|
||||
10-20 at about 01:42Z). It has no comments yet. The current failure times are later than the ones #393 lists,
|
||||
so I added a comment with them. Times are from `systemctl --user show` (CDT) with UTC alongside.
|
||||
|
||||
| Unit | Runs | Failed | #393 |
|
||||
| --- | --- | --- | --- |
|
||||
| mosaic-standup-pm.service | `~/.mosaic/tools/coordinator/standup_trigger.py pm` | 2026-10-19 19:00:28 CDT (10-20 00:00:28Z), exit 1 | listed |
|
||||
| mosaic-install-drift.service | `~/src/jarvis-brain/scripts/alerting/mosaic-install-drift` | 2026-10-20 00:12:54 CDT (05:12:54Z), exit 9 | listed, with an older time |
|
||||
| mosaic-unit-failed@mosaic-reconcile.service | `~/.local/bin/mosaic-unit-failed mosaic-reconcile.service` | 2026-10-20 13:16:12 CDT (18:16:12Z), exit 4 | listed, with an older time |
|
||||
|
||||
The marker's journal at 18:16:12Z says the escalation's tmux send to jarvis failed ("tmux target not found",
|
||||
socket mosaic-fleet), so the page wasn't delivered. The record is on disk in
|
||||
`~/.local/state/mosaic-unit-failures/20261020.jsonl`. `mosaic-reconcile.service` failed at that time (exit 7)
|
||||
but last ran OK at 17:16:27 CDT (22:16:27Z). The marker stays failed until something resets it. The journal no
|
||||
longer has lines for the other two units. The window changed none of these units, and touching them is
|
||||
outside it (`~/.mosaic` is off limits, and #393 needs Jason or ops-01's charter to disable any). W11's list also
|
||||
shows `setspark-tmp-guard.service` failed. It belongs to another project, and I've left it.
|
||||
|
||||
## Corrections to the plan, made after the window
|
||||
|
||||
- NEW is 103 commits past OLD (`git rev-list --count 490e2771d..4429a104f`), not 106. The 106 in `status` was OLD
|
||||
to HEAD 305857c8f, so it also counted the three record commits.
|
||||
- P3: `rehearse` starts NEW's Console with only `--port` and `--business`, not the pointer's flags. Mos's
|
||||
separate flags check covers that (`p3/flags-check.txt`, `p3/flags-console.log`). The line was wrong as
|
||||
written (mos-1107).
|
||||
- W10: the matrix line g size change and the two corrected browser checks, as above.
|
||||
|
||||
## Files
|
||||
|
||||
`W.txt` (transcript), `S1.json`, `S2.json`, `matrix-old.txt`, `matrix-new.txt`, `backups.txt`, `W3-plan.txt`,
|
||||
`W5.txt`, `W5-status.txt`, `w0.txt`, `p1.txt`, `p2.txt`, `p3/` (Mos's rehearsal and flags check), `p456.sh`,
|
||||
`w9.sh`, `w9b.sh`, `w9.out`, `w9b.out`, `w10-browser.mjs`, `w10-diag.mjs` with their outputs (the three
|
||||
screenshots stay on disk in this directory, uncommitted, since the push check refuses binaries),
|
||||
and the `.w9*`, `.w10*` and `.w11*` reads. The store snapshot tool is `snapshot.mjs` (639fd28a).
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,116 @@
|
||||
plan: activate for mosaic-stack
|
||||
from: 490e2771d3d057ab76991e2c7859cd251857e64c
|
||||
to: 4429a104f6d65ccf165f6c83b5d1dce3147dfde0
|
||||
store schema: 3i (read from the store the running bus holds)
|
||||
target schema: 3i
|
||||
migration: none; the store is at the target's version
|
||||
backup: the run backs up the stopped store to /home/jwoltje/.mosaic-dev/backups/stack/<activation>/ before any migration
|
||||
commits: the run adds 103 (newest first, at most 200):
|
||||
4429a104f webui: Q175 phase 1, the Console verifies SSO itself under --sso, OIDC RP with oauth4webapi 3.8.8 (row 192, #1690)
|
||||
32b54ac3b plans: Case 2 row briefs from Gosalyn's 51006710a (webby-0034 N1/N2 and webby-0035: row A README :711 one line per variable, C2-13 owns README :713 and says the precheck prints only variable names)
|
||||
6025ddd9c plans: Case 2 row briefs from Gosalyn's a2d94b2c2 (Webby r9 notes, webby-0032: row A README line on claude-session.env and the dist/providers/data/zai.json path, C2-13 XDG_DATA_HOME precheck and last-assignment-wins key file parse)
|
||||
ecbfd6deb plans: Case 2 row briefs from Gosalyn's 29e85196b (design r9 for mos-1090: row A out of scope points at design 10 step 2d, C2-13 precheck checks the hub key file by name and mode, claude marker check only for claude-code harness, EnvironmentFile= fixed in case2-unit.sh)
|
||||
0498c0613 agents: Gosalyn seat dir, README from gosalyn/case2-design, so Case 2 rows can name the seat as owner (#416)
|
||||
49d1f3a23 plans: Case 2 slice row briefs plus rows A and B, from Gosalyn's f325657c7 on gosalyn/case2-design (#416, design r8, C2-13 copy id and profile asks for row 195, C2-20 count-only note, row A ZAI_API_KEY per mos-1086/mos-1089, row B #428 after 10-27)
|
||||
628e2ca5c sessions: Filbert row 190 manifest round 1 CHANGES (base drift on row 189)
|
||||
088087ac9 queue: revs 2248-2361+. Sage: rows 210 (#424 host access, Gizmoduck) and 211 (#426 force-push guard, Rocko) added and briefed; row 195 re-briefed to the Case 1 install on .53; review requests for 200 r1/r2 and 202; plus earlier Sage revs. Other seats' revs: Launchpad 2254, 2283-2284, 2286, 2311-2313, 2328-2329, 2344, 2347-2348 (rows 192/200); Honker 2255; Morgana 2257, 2320, 2326-2327 (row 204); Gander 2262; Herb 2265; Dewey 2268; Bushroot 2273; Megavolt 2276; Webby 2303, 2316, 2339, 2355 (row 192/200 verdicts); Gizmoduck 2304, 2318-2319 and the row 210 claim (rows 201/210); Quackerjack 2317, 2341-2342 (row 202); Duckworth 2323, 2335, 2353 (rows 201/204/202 verdicts); Darkwing 2354, 2357 (row 190 in-review)
|
||||
a0fbefd48 plans: pre-push force-push hook and failing busy check brief (Vikunja #426, mos-1078, mos-1083; owner Rocko, reviewer Dewey)
|
||||
f24f2cd40 plans: row 195 brief becomes the Case 1 install on 10.1.1.53 (Q181 hosts, Q182/Q183 recorded acceptance, Q185 existing account, chained on rows 190 and C2-13; ops-01 root steps under #423 step 1; sage-1439)
|
||||
9ba31b5b3 agents: Honker's PRD 1.7 draft r2 on refactor for the prd.approve raise (11cb0423b, sha256 e896746df595d1b5155130ca163fbf63b0dc38a87a8dd154af77f1a6d1328647, Duckworth approved duckworth-0039, Vikunja #420)
|
||||
9db578ff5 plans: agent host access with sudo through the credential store brief (#1707, Vikunja #424, Q183/Q184, chained on row 199)
|
||||
8eb9ddd12 plans: FED R8 brief to r4.11 (b93f098f0, recorded acceptance, Duckworth N1/N2, #416 date), R5 context source (sage-1419), 193a makes covers level required (duckworth-0033 N1)
|
||||
ae3c36f24 agents: Gizmoduck seat dir and roster row (FED R1 owner, #414)
|
||||
a2af7ab49 plans: FED R1, R5, R2 and R8 briefs (#414, r4.9 a1daa3c07) and row 193a-d build briefs (#1691); Quackerjack seat dir and roster row
|
||||
aa1bd209e gate-lane: row 189 gate timing table for row 197 (Herb)
|
||||
ace27dc5e row 189 (#1687): stack-release.sh activate --no-send drops the Console's --send (Sage; Dewey approve 29485; Sage gate passed)
|
||||
a4a42dbac plans: stack-release --sso brief (#1698, Launchpad, reviewer Webby, row 192 F3; chained on 192)
|
||||
a31f1a0f8 plans: row 198 brief records Mos's call, in-process board reads behind the Console's auth (mos-1019)
|
||||
992650232 plans: agent credentials and git identity brief (MVP T1 item 6, #1697, Megavolt, reviewer Duckworth); Megavolt seat dir
|
||||
a7661ada0 plans: row 195 acceptance script takes Gizmoduck's gate item list; B8 issuer and TLS front end (mos-1011)
|
||||
f871cb309 plans: retire the control board writer on 7331 brief (#1696, Bushroot, reviewer Webby, mos-1008 item 2); Bushroot seat dir
|
||||
0bbd23c61 plans: row 195 brief adds the identity provider check and flags any new model account for Jason (mos-1008)
|
||||
0217b71c8 plans: row 195 brief rewritten as the hub on its own host, host left to Jason (#1693, mos-0998, mos-1005)
|
||||
c6f528a32 plans: second gate lane brief (#1695, Herb, reviewer Webby, mos-0977 item 6); Herb seat dir; gate template and busy check copies for the row
|
||||
96f243faf plans: human boundary row brief (P19 333, #1694, Gander, reviewer Duckworth, mos-0989); Gander seat dir; seat READMEs name the credentials row, not row 195 (now 190b)
|
||||
c86269935 sessions: Filbert row 190 design review r3, approved
|
||||
48ebee0ff plans: row 190 brief scopes isolation to addressing on one uid, points to row 190b (Filbert N7, mos-0986)
|
||||
66bdd84b8 sessions: Filbert row 190 design review r2, changes requested
|
||||
6966164f0 plans: row 190b brief, Melanie's hub under its own Linux user (#1693, Darkwing, reviewer Duckworth), mos-0986
|
||||
53bb59462 sage: common start for MVP T1 builder seats (mos-0974, mos-0977, mos-0981)
|
||||
5e01c3683 agents: seat dirs and roster rows for Launchpad, Morgana, Honker (Sage's T1 builders) and Webby, Duckworth (Mos's reviewers); row 193 brief takes Mos's B1 starting rule (mos-0985)
|
||||
9f56d2098 plans: rows 192 and 194 review by Webby, not Filbert or Dewey (mos-0981)
|
||||
0599b119f plans: MVP T1 briefs, SSO phase 1 (#1690, Launchpad), human users (#1691, Morgana), agent lifecycle (#1692, Honker), mos-0974/0977
|
||||
b416d2ede sage: Q175 SSO design, Mos C1 condition (mos-0977): straggler check before Jason step 4 in the phase 2 window
|
||||
5fc73a729 revs 2237-2247: row 189 added, briefed, started, in-review round 1 (Dewey, comment 29457); rows 190 (Darkwing) and 191 (Rocko) added and briefed for MVP T1 (mos-0974); darkwing-190-start (rev 2245, Darkwing's) and rocko-191-start (rev 2244, made for Rocko)
|
||||
99772421a sage: row 189 candidate manifest for Dewey's review (d6116771f)
|
||||
da230ca48 plans: MVP T1 briefs, several instances per host (#1688, Darkwing) and the kill switch (#1689, Rocko), mos-0974
|
||||
4f2d1be68 sessions: Filbert Q175 SSO design D1/D2 ack
|
||||
d55d86bb0 sage: Q175 SSO design r3 fix, Filbert r3 D1 (loader checks slot shape only) and D2 (slot from config dir, scratch Console dataRoot and unreachable issuer)
|
||||
c56ccf916 sessions: Filbert Q175 SSO design review r3, approve with D1
|
||||
817e8f51b sage: Q175 SSO design r3 for #412, Filbert r2 C1 (shared loader, config.json inside the phase 2 window, rollback order, test 25) and C2 (401 signed-out page, test 24), notes and Q-E
|
||||
5d019d876 sessions: Filbert Q175 SSO design review r2, changes
|
||||
59475fc5a sage: row 163 landing record, P19 comments 372/373 on 337/412, and P19 #413 filed for the --no-send row
|
||||
1a268249e sage: Q175 SSO design r2 for #412, Filbert r1 B1-B6, rulings Q-B/Q-C/Q-D, config in config.json (mos-0973)
|
||||
def2e7932 plans: brief for stack-release activate --no-send (Q175 interim, mos-0972/0973, #1687, P19 413)
|
||||
c30b36fff sessions: Filbert Q175 SSO design review r1, changes
|
||||
9ae8c73ac queue: revs 2235-2236: row 163 Sage landing note (8ecc7a239, patch-id b8d5f8a37c24, gate passed on eaa0f2666) and done
|
||||
8ecc7a239 row 163 (#1660): stack-release.sh rehearse, the D2 rehearsal on a scratch root from a backup, and bus start --no-launch (Sage; Filbert approve 29447; Sage gate passed)
|
||||
7f57419b3 sage: Q175 SSO design for #412, OIDC relying party on oauth4webapi 3.8.8, for Filbert review (mos-0970, mos-0971)
|
||||
4ca8d6e94 sage: row 42 run log, S7 reachability pass (51cebff7 answered by cff1e9ad after a chief launch, mos-0969)
|
||||
22e5e825b sage: row 42 run log, plain mosaic on a pinned host supersedes the 10-12 full-path line (mos-0968)
|
||||
e6dea74b2 sessions: Sage row 163 r2 approval intake; P19 337 comment 368 script and record
|
||||
cd2b377bb queue: rev 2234: row 163 r2 approve from Filbert (op filbert-163-r2, comment 29447, candidate c6f4f4c12596)
|
||||
ceccb0ef2 sessions: Filbert row 163 r2 review, approve
|
||||
10ab8ea62 sessions: Sage row 163 r2 in-review; P19 337 comment 367 script and record
|
||||
c539995cf queue: revs 2231-2233: row 163 r2 in-review from Sage (ops sage-163-r2-start, sage-163-inreview-2, .outcome; request comment 29446, reviewer Filbert, manifest c6f4f4c12596)
|
||||
92cc6011c queue: rev 2230: row 163 r1 changes from Filbert (op filbert-163-r1, comment 29445, candidate 430212299beb)
|
||||
7af166574 sage: row 163 r2 packet (candidate 2c090eefd on code 2a5f3a3a0, base 4f02486d5: diff, manifest c6f4f4c12596, gate passed, mutants K34-K41 killed)
|
||||
2244e8baf sessions: Filbert row 163 r1 review, changes
|
||||
bde0fdec9 sessions: Sage row 163 r1 in-review; P19 337 comment 366 script and record
|
||||
def677bc7 queue: revs 2228-2229: row 163 r1 in-review from Sage (ops sage-163-inreview-1, .outcome; request comment 29444, reviewer Filbert, manifest 430212299beb)
|
||||
51f67f1f8 sage: row 163 packet (candidate 4be3f6aec on 580500699: diff, manifest 430212299beb, gate passed, mutants K1-K33 killed after four survivors got tests)
|
||||
5b298bd00 sessions: Dewey row 66 r1 (changes) and r2 (approve) reviews
|
||||
565bc32c2 queue: revs 2223-2227: row 66 r2 round from Sage (2223-2225, ops sage-r66-r2-start, sage-r66-inreview-2); r2 verdicts from Filbert (2226, op filbert-66-r2, approve) and Dewey (2227, op dewey-66-r2, approve)
|
||||
5140aa9e8 sessions: Filbert row 66 r2 review, approve
|
||||
973cb5a87 queue: revs 2220-2222: row 163 Sage start (2220); row 66 r1 verdicts from Filbert (2221, op filbert-66-r1) and Dewey (2222, op dewey-66-r1)
|
||||
b82ec9353 sage: row 66 r2 packet (candidate 0f4a57956: diff, manifest cee63e06fdea, gate, mutants K1-K4 killed, singles rerun)
|
||||
3560c450a sessions: Filbert row 66 r1 review, changes
|
||||
4f02486d5 sage: row 66 r1 packet (candidate 8ec03b45a: diff, manifest e68c6bce5ec4, gate r2), row 42 run log (trigger (a), mos-0960), SESSIONS line
|
||||
263ea8a9a queue: revs 2215-2219: row 66 Sage start (2215), in-review round 1 on candidate manifest e68c6bce5ec4 (8ec03b45a) and request comment 29437 (2216-2217); row 163 brief re-pinned to b1500d96ad27 (6020bb43a) and briefed (2218-2219)
|
||||
6020bb43a plans: D2 brief owner is sage (row 163, #1660), and D2 lands after the Part B window (mos-0958, mos-0959)
|
||||
5487ed39d sage: row 188 landing record: P19 411 (N1) and comments 361-363 script and record, 409/410 description restore after a partial done update, SESSIONS line
|
||||
31e328958 queue: revs 2209-2214: row 188 Darkwing start, in-review and request 29432 (2209-2211); Filbert approve 29433 (2212); Sage landing note (8fc0ee216, patch-id c9a56b167c07, stack-release.sh blob 2586a2a37cd2, gate passed) and done (2213-2214)
|
||||
8fc0ee216 row 188 (#1686, P19 409, 410): stack-release T54, the proof file checks on a carried pair and a proof file inside the repository (Darkwing; Filbert approve 29433; Sage gate passed)
|
||||
23a609c5c sessions: Filbert row 188 r1 review, approve
|
||||
b9c1c9fe2 sage: Part B draft 2 A2 passed (mos-0958), row 188 launched, Vikunja comments 358-360 script and record, SESSIONS line
|
||||
525990ecb queue: revs 2207-2208: row 188 add and briefed (Sage), #1686, P19 409/410, gates Part B (mos-0956)
|
||||
148a9503d sage: Part B window file draft 2 for mos-0957 (F1: B6 and B-R1 name the stop's endLaunch rows, PB5 counts per step; F2: PB6, PB7 and B-R1 matrices run (a)-(h)); Q2 answered, Q1 to Jason's list
|
||||
240f21650 brief: stack-release tests for a carried proof pair and a proof file inside the repository (P19 409, 410, #1686, mos-0956)
|
||||
742e98976 sage: Part B window file, the read window's end stamp from the clock (10:29Z, not 10:40Z)
|
||||
288ab1b5a sage: Part B window file for Mos's A2 check (live-copy B3 and B-R1, Q1 on J2 order), SESSIONS lines for Darkwing (row 187, 10:25:22Z) and Sage
|
||||
a843ae284 sage: row 187 landing, P19 407-410 filed and P19 385 comment 357, #1685 closed (comment 29425), SESSIONS line
|
||||
652d6f3a8 queue: revs 2201-2206: row 187 Darkwing start, in-review and request comment 29423 (2201-2203); Filbert approve comment 29424 (2204); landing note (9a71e4e80, patch-id 6b57c47bc661, gate passed) and done (Sage 2205-2206)
|
||||
9a71e4e80 row 187 (#1685): activate sets, carries and drops the Console's proof pair
|
||||
d17328a55 sessions: Filbert row 187 r1 review, approve
|
||||
f81ebfbae sage: row 186 follow-ups filed as P19 405 and 406, P19 404 closed (mos-0955)
|
||||
9760d4967 sage: row 186 landing, P19 404 comment 354 and SESSIONS line, with Darkwing's row 186 SESSIONS line (09:13:21Z)
|
||||
16a936c79 queue: revs 2198-2200: row 186 Filbert approve comment 29419 (2198); landing note (269134d8d, patch-id c97b9f6e25cd, gate passed) and done (Sage 2199-2200)
|
||||
269134d8d row 186 (#1684): the activate plan's backup line says what the run will do
|
||||
3c5da3a91 sessions: Filbert row 186 r1 review, approve
|
||||
b367f5730 sage: rows 186-187 Vikunja comments (P19 404 351, P19 385 352-353) and SESSIONS line (mos-0954)
|
||||
5a768d82c queue: row 187 added and briefed (Part B A0, #1685), with Darkwing's row 186 start (rev 2193)
|
||||
6de3c86be plans: brief for row 187, activate carries the row 178 proof pair (mos-0928 Part B A0, #1685)
|
||||
1d7fd75e2 queue: row 186 added and briefed (P19 404, #1684)
|
||||
f5c72a5c3 plans: brief for row 186, the activate plan's backup line (P19 404, #1684)
|
||||
b724b7b0b sessions: Sage Part A window line, with Darkwing's 06:53:53Z and 08:28:01Z lines
|
||||
e24a13ad8 sage: Vikunja scripts and read-back records 2026-10-13 to 10-20 (decision 110, mos-0882)
|
||||
21aa1cab0 darkwing: re-pin window rows 177-185 packet, STATE.md (sha256 b586f11c9b6f1329), W.txt (edd4622c5c45ef3f), S1/S2/S2b/B snapshots, matrices, W10 browser check and P3 rehearsals (#1683; P19 386)
|
||||
9ddd8a92c plans: repin-rows-177-185 draft 4, R1 snapshot after the plain rollback (mos-0943)
|
||||
e7637b99c plans: repin-rows-177-185 draft 3, R1post expects one tester action.refused per refused read (mos-0935, mos-0942)
|
||||
2ea05700b plans: mos-0928 plan draft 2, NEW is the row 185 landing 490e2771d
|
||||
010666074 queue: revs 2185-2190: row 185 start and in-review on bf87dbba with request comment 29403 (Darkwing 2185-2187); Filbert approve comment 29404 (2188); landing note (490e2771d, patch-id 774cd23186d5, gate passed) and done (Sage 2189-2190)
|
||||
bus: scripts/bus-service.sh install --copy 4429a104f6d65ccf165f6c83b5d1dce3147dfde0 --copies /home/jwoltje/.local/share/mosaic-stack/copies --pm --claude /home/jwoltje/.local/bin/claude
|
||||
proof: off
|
||||
send: off
|
||||
Console: scripts/webui-service.sh install --copy 4429a104f6d65ccf165f6c83b5d1dce3147dfde0 --copies /home/jwoltje/.local/share/mosaic-stack/copies --port 7332 --public-host mosaic.woltje.com --auth-header X-authentik-username --allow-user jason.woltje --tester-port 7333
|
||||
CLI: scripts/stack-copy.sh wrapper 4429a104f6d65ccf165f6c83b5d1dce3147dfde0 --copies /home/jwoltje/.local/share/mosaic-stack/copies --bin /home/jwoltje/.npm-global/bin
|
||||
@@ -0,0 +1,21 @@
|
||||
pointer: /home/jwoltje/.mosaic-dev/state/stack-active.json
|
||||
{"copy":"4429a104f6d65ccf165f6c83b5d1dce3147dfde0","business":"mosaic-stack","pm":true,"claude":"/home/jwoltje/.local/bin/claude","console":["--port","7332","--public-host","mosaic.woltje.com","--auth-header","X-authentik-username","--allow-user","jason.woltje","--tester-port","7333"],"schema":"3i","activation":"20261020T220206Z-4429a104f6d6","at":"2026-10-20T22:02:09Z"}
|
||||
log (last five lines):
|
||||
{"at":"2026-10-20T07:58:02Z","op":"activate","from":"89af38a83afa22a8a4f0d368a0a4030ad15a0efa","to":"490e2771d3d057ab76991e2c7859cd251857e64c","backup":"20261020T075800Z-490e2771d3d0","activation":"20261020T075800Z-490e2771d3d0","result":"ok"}
|
||||
{"at":"2026-10-20T19:29:56Z","op":"activate","from":"490e2771d3d057ab76991e2c7859cd251857e64c","to":"490e2771d3d057ab76991e2c7859cd251857e64c","backup":null,"activation":"20261020T192956Z-490e2771d3d0","result":"started"}
|
||||
{"at":"2026-10-20T19:30:00Z","op":"activate","from":"490e2771d3d057ab76991e2c7859cd251857e64c","to":"490e2771d3d057ab76991e2c7859cd251857e64c","backup":"20261020T192956Z-490e2771d3d0","activation":"20261020T192956Z-490e2771d3d0","result":"ok"}
|
||||
{"at":"2026-10-20T22:02:06Z","op":"activate","from":"490e2771d3d057ab76991e2c7859cd251857e64c","to":"4429a104f6d65ccf165f6c83b5d1dce3147dfde0","backup":null,"activation":"20261020T220206Z-4429a104f6d6","result":"started"}
|
||||
{"at":"2026-10-20T22:02:09Z","op":"activate","from":"490e2771d3d057ab76991e2c7859cd251857e64c","to":"4429a104f6d65ccf165f6c83b5d1dce3147dfde0","backup":"20261020T220206Z-4429a104f6d6","activation":"20261020T220206Z-4429a104f6d6","result":"ok"}
|
||||
check:
|
||||
bus: /home/jwoltje/.local/share/mosaic-stack/copies/4429a104f6d65ccf165f6c83b5d1dce3147dfde0
|
||||
Console: /home/jwoltje/.local/share/mosaic-stack/copies/4429a104f6d65ccf165f6c83b5d1dce3147dfde0
|
||||
CLI: /home/jwoltje/.local/share/mosaic-stack/copies/4429a104f6d65ccf165f6c83b5d1dce3147dfde0
|
||||
result: pass
|
||||
bus status:
|
||||
host: mosaic-stack, pid 2950923, running, since 2026-10-20T22:02:08.133Z, notifier off
|
||||
socket: present
|
||||
writer.lock: pid 2950981
|
||||
boot: organization file loaded at 2026-10-20T22:02:08.125Z
|
||||
unit flags: match the pointer
|
||||
drift: 6 commit(s) on refactor since the pointer's copy
|
||||
# exit 0
|
||||
@@ -0,0 +1,8 @@
|
||||
stack-release: captured the rollback set: /home/jwoltje/.mosaic-dev/state/stack-rollback/20261020T220206Z-4429a104f6d6
|
||||
stack-release: stopped both units
|
||||
stack-release: stopped store schema: 3i
|
||||
stack-release: backup: /home/jwoltje/.mosaic-dev/backups/stack/20261020T220206Z-4429a104f6d6
|
||||
stack-release: installed 4429a104f6d65ccf165f6c83b5d1dce3147dfde0
|
||||
stack-release: gate passed on 4429a104f6d65ccf165f6c83b5d1dce3147dfde0
|
||||
stack-release: activate ok: 4429a104f6d65ccf165f6c83b5d1dce3147dfde0 is live for mosaic-stack
|
||||
# exit 0
|
||||
@@ -0,0 +1,2 @@
|
||||
W0b 2026-10-20T22:00:49Z business file copy /home/jwoltje/.config/mosaic-dev/businesses/mosaic-stack.json.pre-window-20261020T220049Z sha256 1e49bdf05e62607972958f5b7298cfd600c7825812370e26d7c2a842f2af9b93 (source /home/jwoltje/.config/mosaic-dev/businesses/mosaic-stack.json sha256 1e49bdf05e62607972958f5b7298cfd600c7825812370e26d7c2a842f2af9b93)
|
||||
W5 2026-10-20T22:02:13Z activation 20261020T220206Z-4429a104f6d6 backup /home/jwoltje/.mosaic-dev/backups/stack/20261020T220206Z-4429a104f6d6 SHA256SUMS sha256 b61d64843c9e2a153842606353450838da9cb778c8f3b9c27cc9366dd72ec0c9
|
||||
@@ -0,0 +1,7 @@
|
||||
a 200 ok=true
|
||||
b 401 error="sign-in required"
|
||||
c 401 error="sign-in required"
|
||||
d 403 error="proxy Host refused"
|
||||
e 403 error="tester listener required"
|
||||
f 403 error="proxy header refused"
|
||||
g 200 non-json 6575 bytes
|
||||
@@ -0,0 +1,8 @@
|
||||
# P6 2026-10-20T22:00:17Z
|
||||
a 200 ok=true
|
||||
b 401 error="sign-in required"
|
||||
c 401 error="sign-in required"
|
||||
d 403 error="proxy Host refused"
|
||||
e 403 error="tester listener required"
|
||||
f 403 error="proxy header refused"
|
||||
g 200 non-json 6454 bytes
|
||||
@@ -0,0 +1,4 @@
|
||||
copy 490e2771d: roles 6, projects 1, arbiters 2
|
||||
HOLDS chief instance.name yes
|
||||
HOLDS pm instance.name no
|
||||
LOAD_OK
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"bus.tester": true,
|
||||
"plugins": {
|
||||
"usage-source": [
|
||||
"transcript"
|
||||
],
|
||||
"channel": [
|
||||
"cli",
|
||||
"webui"
|
||||
],
|
||||
"harness": [
|
||||
"claude-code"
|
||||
]
|
||||
},
|
||||
"launch.by": "chief",
|
||||
"launch.instances has assistant": true,
|
||||
"roles with pi harness": [],
|
||||
"sha256": "1e49bdf05e62607972958f5b7298cfd600c7825812370e26d7c2a842f2af9b93"
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
start 2026-10-20T21:46:09Z
|
||||
business-check.mjs: OK
|
||||
console-check.sh: OK
|
||||
1ba3c7efbc84a1d7eb8ee0068d32b7917f4e75cdc4066703944cea4c20db5b93 /mnt/storage/scratch/sage-pin-de352dfaf/fixture/console-stub.mjs
|
||||
a4500094e7c7a6dc21797eaf2d696f9814f272ece945c2735b79e4593f9c9503 /mnt/storage/scratch/sage-p2-4429/console-stub-tester.mjs
|
||||
== run 1: pinned stub, no tester listener
|
||||
stub pid 2495939 port 39561
|
||||
PASS markdown-preview.js is served and equals the copy's
|
||||
PASS /api/config on loopback: send=false edit=false
|
||||
PASS project.files through the Console: head=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa files=2 spec=true error=none
|
||||
PASS project.file spec/north-star.md through the Console: commit=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa length=4 error=none
|
||||
PASS loopback save refused: HTTP 403 error=read-only sent=false
|
||||
CONSOLE_PASS
|
||||
PORT 39561
|
||||
CALLS []
|
||||
run 1 console-check rc=0
|
||||
== run 2: main listener plus a tester listener on a second port
|
||||
stub pid 2496085 port 35213 tester port 35945
|
||||
PASS markdown-preview.js is served and equals the copy's
|
||||
PASS /api/config on loopback: send=false edit=false
|
||||
PASS project.files through the Console: head=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa files=2 spec=true error=none
|
||||
PASS project.file spec/north-star.md through the Console: commit=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa length=4 error=none
|
||||
PASS loopback save refused: HTTP 403 error=read-only sent=false
|
||||
CONSOLE_PASS
|
||||
PASS main refuses x-mosaic-tester: HTTP 403 "tester listener required"
|
||||
PASS tester /api/config: HTTP 200 "tester":true
|
||||
PASS tester /api/config send and edit off: HTTP 200 "send":false,"edit":false
|
||||
PASS tester refuses the main auth header: HTTP 403 "proxy header refused"
|
||||
PASS tester refuses project-file save: HTTP 403
|
||||
PORT 35213
|
||||
TPORT 35945
|
||||
CALLS []
|
||||
TESTER_PASS
|
||||
run 2 console-check rc=0 tester rc=0
|
||||
end 2026-10-20T21:46:10Z
|
||||
@@ -0,0 +1 @@
|
||||
2026-10-20T21:52:34Z
|
||||
@@ -0,0 +1,10 @@
|
||||
flags-check start 2026-10-20T21:53:52Z
|
||||
pid 2729699 alive: yes
|
||||
127.0.0.1:7341
|
||||
127.0.0.1:7342
|
||||
main /healthz: 200
|
||||
tester /healthz: 200
|
||||
main /api/config: {"board":"http://127.0.0.1:7331","send":false,"edit":false}
|
||||
pid 2729699 after TERM: gone
|
||||
listeners after: 0
|
||||
flags-check end 2026-10-20T21:53:56Z
|
||||
@@ -0,0 +1,7 @@
|
||||
Mosaic Console: http://127.0.0.1:7341/
|
||||
Board: http://127.0.0.1:7331
|
||||
Bus: mosaic-stack
|
||||
Preview: https://mosaic.woltje.com/ through the proxy, signed-in user from x-authentik-username, read-only
|
||||
Proxy: proxy proof off
|
||||
Tester Console: http://127.0.0.1:7342/ (binds the tester)
|
||||
Ctrl-C stops this server.
|
||||
@@ -0,0 +1 @@
|
||||
rc=0
|
||||
@@ -0,0 +1,20 @@
|
||||
PASS copy: 4429a104f6d65ccf165f6c83b5d1dce3147dfde0, schema 3i
|
||||
PASS port: 127.0.0.1:7341 is free
|
||||
PASS backup: /home/jwoltje/.mosaic-dev/backups/stack/20261020T192956Z-490e2771d3d0 matches its SHA256SUMS
|
||||
PASS scratch-root: /home/jwoltje/.mosaic-dev/rehearsals/20261020T215231Z-4429a104f6d6/data from the backup, config and business file under /home/jwoltje/.mosaic-dev/rehearsals/20261020T215231Z-4429a104f6d6/config, business.diff beside them
|
||||
PASS data-root-live: /home/jwoltje/.mosaic-dev/rehearsals/20261020T215231Z-4429a104f6d6/data is not the live data root
|
||||
PASS sessions-live: no running session named in the data root
|
||||
PASS data-root-scratch
|
||||
PASS notify-off: binding null, so no Discord bridge
|
||||
PASS tracker-configured: bootConfig sets no trackers
|
||||
PASS credential-ref-live: 9 reference(s), each a placeholder
|
||||
PASS migration: not needed: the backup is already 3i
|
||||
PASS gate: host-up, bus-status, console-up, healthz and copy-match on 4429a104f6d65ccf165f6c83b5d1dce3147dfde0
|
||||
PASS no-session: no sessions.json and no launches under /home/jwoltje/.mosaic-dev/rehearsals/20261020T215231Z-4429a104f6d6/data
|
||||
PASS console-stopped: pid 2675437
|
||||
PASS host-stopped: pid 2675436
|
||||
PASS host-released-store
|
||||
PASS backup-unchanged
|
||||
PASS live-config-unchanged
|
||||
REHEARSAL_PASS
|
||||
rehearsal directory: /home/jwoltje/.mosaic-dev/rehearsals/20261020T215231Z-4429a104f6d6
|
||||
@@ -0,0 +1 @@
|
||||
2026-10-20T21:52:31Z
|
||||
@@ -0,0 +1,7 @@
|
||||
# P4 2026-10-20T22:00:17Z
|
||||
DropInPaths=
|
||||
ExecStart={ path=/home/jwoltje/.local/bin/chisel ; argv[]=/home/jwoltje/.local/bin/chisel client --fingerprint ce+ISFvMAhuoCRDuc7riIf+hYz87TLHk/C9C/H8QsUg= --keepalive 25s --max-retry-interval 10s http://10.1.1.227:8080 R:0.0.0.0:7332:127.0.0.1:7332 ; ignore_errors=no ; start_time=[n/a] ; stop_time=[n/a] ; pid=0 ; code=(null) ; status=0/0 }
|
||||
# exit 0
|
||||
# R: specs (1): R:0.0.0.0:7332:127.0.0.1:7332
|
||||
P4 PASS: one R: spec R:0.0.0.0:7332:127.0.0.1:7332, DropInPaths empty
|
||||
rc 0
|
||||
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env bash
|
||||
# P4-P6 of agents/sage/work/window-plan/repin-4429a104f.md (f41729178), adapted from Darkwing's window-rows-177-185/p456.sh, read only.
|
||||
# P4 the tunnel unit, P5 the four REPO scripts against NEW, P6 the 7332 matrix on OLD (matrix-old.txt).
|
||||
# usage: p456.sh p4|p5|p6
|
||||
set -uo pipefail
|
||||
REPO=/mnt/storage/src/mosaic-stack
|
||||
WIN=$REPO/agents/sage/work/window-repin-4429a104f
|
||||
NEW=4429a104f6d65ccf165f6c83b5d1dce3147dfde0
|
||||
FILES=(scripts/stack-release.sh scripts/webui-service.sh scripts/bus-service.sh scripts/stack-copy.sh)
|
||||
# The plan's <32 letters>: a fixed dummy, not a secret. matrix-new.txt uses the same value.
|
||||
PROOF=abcdefghijklmnopqrstuvwxyzabcdef
|
||||
|
||||
p4() {
|
||||
echo "# P4 $(date -u +%FT%TZ)"
|
||||
local out; out="$(systemctl --user show mosaic-preview-tunnel.service -p ExecStart -p DropInPaths)"; local rc=$?
|
||||
printf '%s\n' "$out"; echo "# exit $rc"
|
||||
local specs; specs="$(grep -oE 'R:[^ ;]+' <<<"$out")"
|
||||
local n; n="$(grep -c . <<<"$specs")"
|
||||
echo "# R: specs ($n): $(tr '\n' ' ' <<<"$specs")"
|
||||
local drop; drop="$(sed -n 's/^DropInPaths=//p' <<<"$out")"
|
||||
if [[ $rc -eq 0 && $n -eq 1 && "$specs" == "R:0.0.0.0:7332:127.0.0.1:7332" && -z "$drop" ]]; then
|
||||
echo "P4 PASS: one R: spec R:0.0.0.0:7332:127.0.0.1:7332, DropInPaths empty"
|
||||
else
|
||||
echo "P4 FAIL"; return 3
|
||||
fi
|
||||
}
|
||||
|
||||
p5() {
|
||||
echo "# P5 $(date -u +%FT%TZ); HEAD $(git -C $REPO rev-parse HEAD)"
|
||||
git -C $REPO diff --quiet $NEW HEAD -- "${FILES[@]}"; local d=$?
|
||||
echo "# git diff --quiet $NEW HEAD -- ${FILES[*]}: exit $d"
|
||||
local st; st="$(git -C $REPO status --porcelain -- "${FILES[@]}")"; local s=$?
|
||||
echo "# git status --porcelain (exit $s): [${st}]"
|
||||
for f in "${FILES[@]}"; do echo "# $f HEAD blob $(git -C $REPO rev-parse HEAD:$f) NEW blob $(git -C $REPO rev-parse $NEW:$f)"; done
|
||||
if [[ $d -eq 0 && $s -eq 0 && -z "$st" ]]; then echo "P5 PASS"; else echo "P5 FAIL"; return 3; fi
|
||||
}
|
||||
|
||||
# one matrix line: label, then curl arguments; prints "<label> <code> <error or ok field>"
|
||||
req() {
|
||||
local label="$1"; shift
|
||||
local body; body="$(mktemp -p "${TMPDIR:-/tmp}")"
|
||||
local code; code="$(curl -s -m 10 -o "$body" -w '%{http_code}' "$@")"
|
||||
local field; field="$(node -e '
|
||||
let s = require("fs").readFileSync(process.argv[1], "utf8"), j;
|
||||
try { j = JSON.parse(s); } catch { console.log("non-json " + s.length + " bytes"); process.exit(0); }
|
||||
console.log(j.error !== undefined ? "error=" + JSON.stringify(j.error) : "ok=" + JSON.stringify(j.ok));
|
||||
' "$body")"
|
||||
rm -f "$body"
|
||||
echo "$label $code $field"
|
||||
}
|
||||
|
||||
p6() {
|
||||
req a http://127.0.0.1:7332/healthz
|
||||
req b -H 'Host: mosaic.woltje.com' http://127.0.0.1:7332/
|
||||
req c -H 'Host: mosaic.woltje.com' -H "X-Mosaic-Proof: $PROOF" http://127.0.0.1:7332/
|
||||
req d -H 'X-authentik-username: tester-probe' http://127.0.0.1:7332/
|
||||
req e -H 'x-mosaic-tester: 1' http://127.0.0.1:7332/
|
||||
req f -H 'X-authentik-username: tester-probe' http://127.0.0.1:7333/
|
||||
req g -H "X-Mosaic-Proof: $PROOF" http://127.0.0.1:7333/
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
p4) p4 ;;
|
||||
p5) p5 ;;
|
||||
p6) p6 ;;
|
||||
*) echo "usage: p456.sh p4|p5|p6" >&2; exit 2 ;;
|
||||
esac
|
||||
@@ -0,0 +1,9 @@
|
||||
# P5 2026-10-20T22:00:17Z; HEAD 4385b9ec183a2da4136ccd639d2a6b041eb9c68f
|
||||
# git diff --quiet 4429a104f6d65ccf165f6c83b5d1dce3147dfde0 HEAD -- scripts/stack-release.sh scripts/webui-service.sh scripts/bus-service.sh scripts/stack-copy.sh: exit 0
|
||||
# git status --porcelain (exit 0): []
|
||||
# scripts/stack-release.sh HEAD blob 3b068b1546d4020cffde5d73929d9130afe91911 NEW blob 3b068b1546d4020cffde5d73929d9130afe91911
|
||||
# scripts/webui-service.sh HEAD blob 0a32547c54ac233e75a21f4817ae230855dbc088 NEW blob 0a32547c54ac233e75a21f4817ae230855dbc088
|
||||
# scripts/bus-service.sh HEAD blob f71250204dfeceb51beeea5941ebbf3a33ce4b92 NEW blob f71250204dfeceb51beeea5941ebbf3a33ce4b92
|
||||
# scripts/stack-copy.sh HEAD blob 24bbb02a6aa8d7857da7be3b6815105a9fef629f NEW blob 24bbb02a6aa8d7857da7be3b6815105a9fef629f
|
||||
P5 PASS
|
||||
rc 0
|
||||
@@ -0,0 +1,235 @@
|
||||
// Merged window B+C, S1, S2 and D3 (repin-merged-bc.md "S1 and S2"): a read-only snapshot of every
|
||||
// table in a bus store, so the 3h to 3i migration and a restore can be checked without the bus.
|
||||
// OLD's broker doesn't take --tester on usage, names or threads.
|
||||
//
|
||||
// usage: node snapshot.mjs --store /abs/bus.sqlite (--held | --stopped) [--after STAMP] [--backup /abs/bus.sqlite]
|
||||
// --held the running bus holds the store: a plain readOnly open, which adds no file to a WAL
|
||||
// store that already has -wal and -shm (as stack-release.sh does for a held store).
|
||||
// Refuses store-not-held when either is absent
|
||||
// --stopped the bus is stopped: refuses store-unclean when -wal or -shm is present, else opens
|
||||
// with immutable=1 and readOnly, which creates no file and leaves the store's mtime
|
||||
// and sha256 alone
|
||||
// --after also counts each timed table's rows with `at` after STAMP (mos-0838 D3, before a
|
||||
// --restore). STAMP is a backup's STAMP file, in now_ms form (stack-release.sh:43,
|
||||
// 2026-10-20T00:11:14.123Z). The count is a text compare, as the tool's own heldcount
|
||||
// does, so every `at` in every timed table must have that form: a table with any
|
||||
// other value refuses at-format and names the table (sage-1125)
|
||||
// --backup also compares every table with a stack-release backup's bus.sqlite, which opens as
|
||||
// --stopped opens a store: immutable=1 and readOnly, refusing backup-unclean when -wal
|
||||
// or -shm is present, so nothing writes into the backup set
|
||||
//
|
||||
// It prints one JSON object: the schema version, then for every table in sqlite_master (type
|
||||
// table, less sqlite_*) the row count, the max seq and a sha256 of the rows (in seq order, else in
|
||||
// column order). `untimed` names the tables with no `at` column. For usage_records it adds per-run
|
||||
// input and output sums, counted as `mosaic usage` counts them (usage.mjs turnRecords). messages
|
||||
// and message_threads hold bodies and names, so they print counts only, no sha256 (sage-1125). It
|
||||
// prints counts, hashes and table and meta key names only: no message body, no instance, project
|
||||
// or thread name, no credential and no file content. It reads no config: the store path is always
|
||||
// given.
|
||||
//
|
||||
// With --backup, each table gets `vsBackup` and `stopTables` names every table other than events
|
||||
// whose rows differ from the backup's (sage-1123): a row with `at` after STAMP, a row past the
|
||||
// backup's max seq, a changed row at or below it, a changed untimed table, or a table only one
|
||||
// side has. meta counts only when a key other than schema_version and schema_digest changed (the
|
||||
// 3h to 3i migration sets those two). Events are left to the tool's own --events N.
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { existsSync, lstatSync } from 'node:fs';
|
||||
import { isAbsolute } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
// usage_records is hashed on these columns in this order, never SELECT * (mos-0888 F1), as
|
||||
// 3h-3i.sql:9 copies it. Every other table is hashed on its own columns in name order, so a
|
||||
// rebuild that reorders columns keeps the hash. usage_records with any other column set refuses.
|
||||
const USAGE_COLUMNS = ['seq', 'at', 'business', 'run', 'instance', 'provider', 'model', 'account', 'turn', 'source',
|
||||
'phase', 'status', 'code', 'cursor', 'input', 'output', 'cache_write', 'cache_read'];
|
||||
const COUNTS_ONLY = new Set(['messages', 'message_threads']);
|
||||
const SCHEMA_KEYS = ['schema_digest', 'schema_version'];
|
||||
const STAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
|
||||
|
||||
function refuse(code, detail) {
|
||||
process.stderr.write(`snapshot: ${code}: ${detail}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function args(argv) {
|
||||
const o = {};
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a === '--store' || a === '--after' || a === '--backup') {
|
||||
if (i + 1 >= argv.length) refuse('usage', `${a} needs a value`);
|
||||
o[a.slice(2)] = argv[++i];
|
||||
} else if (a === '--held' || a === '--stopped') {
|
||||
if (o.mode) refuse('usage', 'give one of --held and --stopped');
|
||||
o.mode = a.slice(2);
|
||||
} else refuse('usage', `unknown argument ${a}`);
|
||||
}
|
||||
if (!o.store || !o.mode) refuse('usage', 'node snapshot.mjs --store /abs/bus.sqlite (--held | --stopped) [--after STAMP] [--backup /abs/bus.sqlite]');
|
||||
if (!isAbsolute(o.store)) refuse('usage', '--store must be an absolute path');
|
||||
if (o.backup !== undefined && !isAbsolute(o.backup)) refuse('usage', '--backup must be an absolute path');
|
||||
if (o.after !== undefined && !(STAMP.test(o.after) && Number.isFinite(Date.parse(o.after))))
|
||||
refuse('usage', '--after must be a STAMP like 2026-10-20T00:11:14.123Z');
|
||||
return o;
|
||||
}
|
||||
|
||||
function file(path, code) {
|
||||
if (!existsSync(path) || !lstatSync(path).isFile()) refuse(code, `${path} is not a file`);
|
||||
}
|
||||
|
||||
function immutable(path, code) {
|
||||
for (const f of [path + '-wal', path + '-shm'])
|
||||
if (existsSync(f)) refuse(code, `${f} is present; an immutable read would skip the WAL`);
|
||||
return new DatabaseSync(pathToFileURL(path).href + '?immutable=1', { readOnly: true });
|
||||
}
|
||||
|
||||
function open({ store, mode }) {
|
||||
file(store, 'store-missing');
|
||||
if (mode === 'stopped') return immutable(store, 'store-unclean');
|
||||
// A plain readOnly open of a WAL store with no -wal and -shm creates them, so a store that
|
||||
// nothing holds refuses here instead of gaining two files.
|
||||
for (const f of [store + '-wal', store + '-shm'])
|
||||
if (!existsSync(f)) refuse('store-not-held', `${f} is absent; use --stopped for a store the bus doesn't hold`);
|
||||
return new DatabaseSync(store, { readOnly: true });
|
||||
}
|
||||
|
||||
const q = (name) => `"${name.replaceAll('"', '""')}"`;
|
||||
const value = (v) => (typeof v === 'bigint' ? String(v) : v instanceof Uint8Array ? { hex: Buffer.from(v).toString('hex') } : v);
|
||||
|
||||
// The same records `mosaic usage` counts: per (run, turn, source) the latest row, an end row
|
||||
// winning; a turn is known only when that row is an end row with status known.
|
||||
function byRun(rows) {
|
||||
const turns = new Map();
|
||||
for (const row of rows) {
|
||||
const key = JSON.stringify([row.business, row.run, row.turn, row.source]);
|
||||
if (row.phase === 'end' || !turns.has(key)) turns.set(key, row);
|
||||
}
|
||||
const runs = new Map();
|
||||
for (const row of turns.values()) {
|
||||
const key = JSON.stringify([row.business, row.run]);
|
||||
if (!runs.has(key)) runs.set(key, { business: row.business, run: row.run, input: 0n, output: 0n, unknown: new Set() });
|
||||
const r = runs.get(key);
|
||||
if (row.phase === 'end' && row.status === 'known') {
|
||||
r.input += BigInt(row.input);
|
||||
r.output += BigInt(row.output);
|
||||
} else r.unknown.add(row.turn);
|
||||
}
|
||||
return [...runs.values()].map(({ unknown, input, output, ...r }) => ({
|
||||
...r, input: String(input), output: String(output), unknownTurns: unknown.size,
|
||||
}));
|
||||
}
|
||||
|
||||
// meta is compared by key: the names of the keys whose value differs, never the values.
|
||||
function metaKeys(db) {
|
||||
return new Map(db.prepare('SELECT key, value FROM meta').all().map((r) => [String(r.key), String(r.value)]));
|
||||
}
|
||||
|
||||
// One table: rows, max seq and sha256; rows after `after`; with `priorMax` (the backup's max
|
||||
// seq) a second hash of the rows at or below it and the count of rows above it. The hashes stay
|
||||
// inside: a COUNTS_ONLY table's `t` gets none.
|
||||
function read(db, table, { after, priorMax } = {}) {
|
||||
const cols = db.prepare('SELECT name FROM pragma_table_info(?)').all(table).map((c) => c.name);
|
||||
if (!cols.length) refuse('table-missing', table);
|
||||
let hashed = [...cols].sort();
|
||||
if (table === 'usage_records') {
|
||||
if (JSON.stringify(hashed) !== JSON.stringify([...USAGE_COLUMNS].sort()))
|
||||
refuse('columns-changed', `usage_records has columns ${hashed.join(',')}`);
|
||||
hashed = USAGE_COLUMNS;
|
||||
}
|
||||
const seq = cols.includes('seq');
|
||||
const at = cols.includes('at');
|
||||
const stmt = db.prepare(`SELECT ${hashed.map(q).join(',')} FROM ${q(table)} ORDER BY ${seq ? 'seq' : hashed.map(q).join(',')}`);
|
||||
stmt.setReadBigInts(true);
|
||||
const full = createHash('sha256');
|
||||
const prior = createHash('sha256');
|
||||
const limit = priorMax === undefined ? undefined : BigInt(priorMax ?? 0);
|
||||
let rows = 0;
|
||||
let rowsAfter = 0;
|
||||
let badAt = 0;
|
||||
let newRows = 0;
|
||||
const kept = [];
|
||||
for (const row of stmt.iterate()) {
|
||||
rows++;
|
||||
const line = JSON.stringify(hashed.map((c) => value(row[c]))) + '\n';
|
||||
full.update(line);
|
||||
if (seq && limit !== undefined) {
|
||||
if (row.seq <= limit) prior.update(line);
|
||||
else newRows++;
|
||||
}
|
||||
if (after !== undefined && at) {
|
||||
if (typeof row.at !== 'string' || !STAMP.test(row.at)) badAt++;
|
||||
else if (row.at > after) rowsAfter++;
|
||||
}
|
||||
if (table === 'usage_records') kept.push(row);
|
||||
}
|
||||
if (badAt) refuse('at-format', `${table} has ${badAt} row(s) whose at is not in the STAMP form, so a text compare with --after would miscount`);
|
||||
const sha256 = full.digest('hex');
|
||||
const t = { rows, maxSeq: seq ? (db.prepare(`SELECT max(seq) AS m FROM ${q(table)}`).get().m ?? null) : null };
|
||||
if (!COUNTS_ONLY.has(table)) t.sha256 = sha256;
|
||||
if (after !== undefined && at) t.rowsAfter = rowsAfter;
|
||||
if (table === 'usage_records') t.byRun = byRun(kept);
|
||||
return { t, sha256, cols: hashed, seq, at, newRows, priorSha: seq && limit !== undefined ? prior.digest('hex') : null };
|
||||
}
|
||||
|
||||
// Tables only: no index, view or trigger, and none of SQLite's own sqlite_* tables.
|
||||
const tablesOf = (db) => db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite\\_%' ESCAPE '\\' ORDER BY name")
|
||||
.all().map((r) => r.name);
|
||||
|
||||
const o = args(process.argv.slice(2));
|
||||
if (o.backup !== undefined) file(o.backup, 'backup-missing');
|
||||
const db = open(o);
|
||||
const bdb = o.backup === undefined ? null : immutable(o.backup, 'backup-unclean');
|
||||
const out = {};
|
||||
try {
|
||||
db.exec('BEGIN');
|
||||
out.schema = db.prepare("SELECT value FROM meta WHERE key='schema_version'").get()?.value ?? null;
|
||||
const names = tablesOf(db);
|
||||
const base = new Map();
|
||||
if (bdb) {
|
||||
out.backupSchema = bdb.prepare("SELECT value FROM meta WHERE key='schema_version'").get()?.value ?? null;
|
||||
for (const name of tablesOf(bdb)) base.set(name, read(bdb, name));
|
||||
}
|
||||
out.tables = {};
|
||||
out.untimed = [];
|
||||
const stop = [];
|
||||
for (const name of names) {
|
||||
const b = base.get(name);
|
||||
const r = read(db, name, { after: o.after, priorMax: b ? b.t.maxSeq : undefined });
|
||||
if (!r.at) out.untimed.push(name);
|
||||
if (bdb) {
|
||||
const v = { backupRows: b ? b.t.rows : null };
|
||||
let changed;
|
||||
if (!b) changed = true;
|
||||
else if (name === 'meta') {
|
||||
const now = metaKeys(db);
|
||||
const then = metaKeys(bdb);
|
||||
v.keysChanged = [...new Set([...now.keys(), ...then.keys()])].filter((k) => now.get(k) !== then.get(k)).sort();
|
||||
changed = v.keysChanged.some((k) => !SCHEMA_KEYS.includes(k));
|
||||
} else if (r.seq) {
|
||||
v.backupMaxSeq = b.t.maxSeq;
|
||||
v.newRows = r.newRows;
|
||||
v.priorSame = JSON.stringify(r.cols) === JSON.stringify(b.cols) && r.priorSha === b.sha256;
|
||||
changed = r.newRows > 0 || !v.priorSame;
|
||||
} else {
|
||||
v.same = JSON.stringify(r.cols) === JSON.stringify(b.cols) && r.sha256 === b.sha256;
|
||||
changed = !v.same;
|
||||
}
|
||||
if ((r.t.rowsAfter ?? 0) > 0) changed = true;
|
||||
r.t.vsBackup = v;
|
||||
if (changed && name !== 'events') stop.push(name);
|
||||
}
|
||||
out.tables[name] = r.t;
|
||||
}
|
||||
if (bdb) {
|
||||
out.onlyInBackup = [...base.keys()].filter((n) => !names.includes(n));
|
||||
out.stopTables = [...stop, ...out.onlyInBackup].sort();
|
||||
}
|
||||
if (o.after !== undefined) {
|
||||
out.after = o.after;
|
||||
out.eventsAfter = out.tables.events?.rowsAfter ?? null;
|
||||
}
|
||||
} finally {
|
||||
try { db.exec('ROLLBACK'); } catch {}
|
||||
db.close();
|
||||
bdb?.close();
|
||||
}
|
||||
process.stdout.write(JSON.stringify(out, null, 2) + '\n');
|
||||
Executable
+16
@@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env bash
|
||||
# Re-pin 4429a104f window step wrapper (copied from Darkwing's window-rows-177-185/w.sh): w.sh FILE TITLE CMD... runs CMD in REPO under a clean env (no ANTHROPIC_*, CLAUDE_*,
|
||||
# MOSAIC_* or pi variables), appends the title, command, output, exit code and date -u stamps to window/FILE,
|
||||
# prints the output, and exits with CMD's exit code.
|
||||
set -uo pipefail
|
||||
W=/mnt/storage/src/mosaic-stack/agents/sage/work/window-repin-4429a104f
|
||||
f="$W/$1"; title="$2"; shift 2
|
||||
E=(env -i HOME="$HOME" PATH="$HOME/.local/bin:/usr/local/bin:/usr/bin:/bin" LANG=C.UTF-8
|
||||
XDG_RUNTIME_DIR="$XDG_RUNTIME_DIR" DBUS_SESSION_BUS_ADDRESS="${DBUS_SESSION_BUS_ADDRESS:-unix:path=$XDG_RUNTIME_DIR/bus}")
|
||||
t0=$(date -u +%FT%T.%3NZ)
|
||||
{ echo; echo "## $title"; echo "# start $t0"; echo "# command: (cd \$REPO && <clean env> $*)"; } >> "$f"
|
||||
out=$(cd /mnt/storage/src/mosaic-stack && "${E[@]}" "$@" 2>&1); rc=$?
|
||||
printf '%s\n' "$out" >> "$f"
|
||||
echo "# exit $rc, end $(date -u +%FT%T.%3NZ)" >> "$f"
|
||||
printf '%s\n# exit %s\n' "$out" "$rc"
|
||||
exit $rc
|
||||
@@ -0,0 +1,9 @@
|
||||
npm warn deprecated [email protected]: Use your platform's native DOMException instead
|
||||
|
||||
added 128 packages in 2s
|
||||
npm warn install-scripts 3 packages had install scripts blocked because they are not covered by allowScripts:
|
||||
npm warn install-scripts @google/[email protected] (preinstall: echo 'preinstall: no-op')
|
||||
npm warn install-scripts [email protected] (postinstall: node install.js)
|
||||
npm warn install-scripts [email protected] (postinstall: node scripts/postinstall)
|
||||
npm warn install-scripts
|
||||
npm warn install-scripts Run `npm install-scripts ls` to review, or `npm install-scripts approve <pkg>` to allow.
|
||||
@@ -0,0 +1 @@
|
||||
copied: /home/jwoltje/.local/share/mosaic-stack/copies/4429a104f6d65ccf165f6c83b5d1dce3147dfde0
|
||||
@@ -0,0 +1,16 @@
|
||||
start 2026-10-20T21:45:57Z
|
||||
stack-copy rc=0
|
||||
copied: /home/jwoltje/.local/share/mosaic-stack/copies/4429a104f6d65ccf165f6c83b5d1dce3147dfde0
|
||||
npm warn install-scripts [email protected] (postinstall: node scripts/postinstall)
|
||||
npm warn install-scripts
|
||||
npm warn install-scripts Run `npm install-scripts ls` to review, or `npm install-scripts approve <pkg>` to allow.
|
||||
oauth4webapi 3.8.8
|
||||
IMPORT_OK
|
||||
LOCK_MATCH
|
||||
163M /home/jwoltje/.local/share/mosaic-stack/copies/4429a104f6d65ccf165f6c83b5d1dce3147dfde0
|
||||
/dev/nvme1n1p5 458G 405G 30G 94% /home
|
||||
copy 4429a104f: roles 6, projects 1, arbiters 2
|
||||
HOLDS chief instance.name yes
|
||||
HOLDS pm instance.name no
|
||||
LOAD_OK
|
||||
end 2026-10-20T21:45:59Z
|
||||
@@ -0,0 +1,109 @@
|
||||
// W10 of repin-4429a104f.md (e1272d1a), adapted from Darkwing's window-rows-177-185/w10-browser.mjs: the tester listener
|
||||
// on 127.0.0.1:7333 through the webui browser harness (packages/webui/tests/browser.mjs, same blob at HEAD and NEW). Reads only: before any page
|
||||
// script runs, fetch and XMLHttpRequest are wrapped so every call is logged and any method other than
|
||||
// GET or HEAD is refused in the page, and the script submits no form and clicks no button. The chat
|
||||
// check reads the #/chat list only and never opens the W9 thread page (the launcher reads lastOpen).
|
||||
import { browser } from '/mnt/storage/src/mosaic-stack/packages/webui/tests/browser.mjs';
|
||||
|
||||
const OUT = '/mnt/storage/src/mosaic-stack/agents/sage/work/window-repin-4429a104f';
|
||||
const BASE = 'http://127.0.0.1:7333/';
|
||||
const W9_THREAD = '446201de-e8b5-4df3-9356-d0d5422cc899';
|
||||
const GUARD = `(() => {
|
||||
const log = window.__w10 = [];
|
||||
const ok = m => m === 'GET' || m === 'HEAD';
|
||||
const f = window.fetch;
|
||||
window.fetch = (input, init = {}) => {
|
||||
const method = String(init.method || (input instanceof Request ? input.method : 'GET')).toUpperCase();
|
||||
const url = String(input instanceof Request ? input.url : input);
|
||||
log.push({ method, url, refused: !ok(method) });
|
||||
return ok(method) ? f(input, init) : Promise.reject(new TypeError('w10 refused ' + method));
|
||||
};
|
||||
const open = XMLHttpRequest.prototype.open, send = XMLHttpRequest.prototype.send;
|
||||
XMLHttpRequest.prototype.open = function (method, url, ...rest) { this.__m = String(method).toUpperCase(); this.__u = String(url); return open.call(this, method, url, ...rest); };
|
||||
XMLHttpRequest.prototype.send = function (...a) { log.push({ method: this.__m, url: this.__u, xhr: true, refused: !ok(this.__m) }); if (!ok(this.__m)) throw new TypeError('w10 refused ' + this.__m); return send.apply(this, a); };
|
||||
})();`;
|
||||
|
||||
const b = await browser({ machine: false });
|
||||
const result = {};
|
||||
let failed = false;
|
||||
const check = (name, pass, detail) => { result[name] = { pass, ...(detail === undefined ? {} : { detail }) }; if (!pass) failed = true; };
|
||||
try {
|
||||
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: GUARD });
|
||||
await b.viewport(1280, 1600);
|
||||
const wait = expr => b.evaluate(`(async()=>{for(let n=0;n<300;n++){if(${expr})return true;await new Promise(r=>setTimeout(r,50));}return false;})()`);
|
||||
|
||||
await b.navigate(BASE + '#/settings');
|
||||
const ready = await wait('!!document.querySelector("#tester-banner") && !!document.querySelector("#routes-form") && !!document.querySelector(".tester-name-form")');
|
||||
check('settings loaded', ready);
|
||||
const s = await b.evaluate(`(() => {
|
||||
const q = x => document.querySelector(x);
|
||||
return {
|
||||
banner: q('#tester-banner')?.textContent.trim() ?? null,
|
||||
h1: q('#bus-view h1')?.textContent.trim() ?? null,
|
||||
forms: [...document.querySelectorAll('form')].map(f => f.id || f.className),
|
||||
convFormHidden: q('#conv-form')?.hidden ?? null,
|
||||
panels: [...document.querySelectorAll('#bus-view h2')].map(h => h.textContent.trim()),
|
||||
instancesTable: !!q('#instances-table'),
|
||||
nameForms: document.querySelectorAll('.tester-name-form').length,
|
||||
routesForm: !!q('#routes-form'),
|
||||
routesSubmitEnabled: q('#routes-form button[type=submit]')?.disabled === false,
|
||||
forbidden: [...document.querySelectorAll('#launch-settings-form, .assistant-form, #token-limits-table [data-limit-reset], #send-section')].map(e => e.id || e.className || e.tagName),
|
||||
tokenLimitsPanel: !!q('#token-limits-title') || !!q('#token-limits-table'),
|
||||
};
|
||||
})()`);
|
||||
result.settings = s;
|
||||
check('tester banner', /tester/i.test(s.banner ?? ''), s.banner);
|
||||
check('instance, routes and name editors present', s.instancesTable && s.routesForm && s.nameForms > 0);
|
||||
check('only tester forms (conv-form is index.html:21, global and hidden)', s.convFormHidden === true && s.forms.every(f => f === 'conv-form' || f === 'routes-form' || /tester-name-form/.test(f) || /^instance-(add|change)-form$/.test(f)), s.forms);
|
||||
check('no launch settings, assistant, limit reset or send', s.forbidden.length === 0, s.forbidden);
|
||||
check('no Token limits panel (plan W10 item 2, amended)', s.tokenLimitsPanel === false);
|
||||
await b.screenshot(`${OUT}/w10-settings.png`);
|
||||
|
||||
await b.navigate(BASE + '#/');
|
||||
// The Board is index.html:22 #board-view; bus.js hides #bus-view on it.
|
||||
const board = await wait('document.querySelector("#bus-view")?.hidden === true && !!document.querySelector("#board-view #waiting-title")');
|
||||
await new Promise(r => setTimeout(r, 1500));
|
||||
const bd = await b.evaluate(`(() => { const v = document.querySelector('#board-view'); return {
|
||||
busViewHidden: document.querySelector('#bus-view')?.hidden ?? null,
|
||||
banner: !!document.querySelector('#tester-banner'),
|
||||
headings: [...v.querySelectorAll('h2')].map(h => h.textContent.replace(/\\s+/g, ' ').trim()),
|
||||
unavailable: /unavailable|refused|not authorized/i.test(v.textContent),
|
||||
length: v.textContent.length,
|
||||
forms: [...document.querySelectorAll('form')].map(f => f.id || f.className),
|
||||
}; })()`);
|
||||
result.board = bd;
|
||||
check('board reads', board && bd.busViewHidden === true && !bd.unavailable && bd.headings.length > 0, bd);
|
||||
await b.screenshot(`${OUT}/w10-board.png`);
|
||||
|
||||
await b.navigate(BASE + '#/chat');
|
||||
// chat.js threads(): ul.chat-thread-list with <a href="#/chat/threads/<id>">. Read only; no link is followed.
|
||||
const chatReady = await wait('!!document.querySelector("ul.chat-thread-list") || /No threads yet/.test(document.body.textContent)');
|
||||
const c = await b.evaluate(`(() => {
|
||||
const list = document.querySelector('ul.chat-thread-list');
|
||||
return {
|
||||
hash: location.hash,
|
||||
banner: !!document.querySelector('#tester-banner'),
|
||||
threads: list ? [...list.querySelectorAll('li')].map(li => ({ href: li.querySelector('a')?.getAttribute('href') ?? null, name: li.querySelector('a')?.textContent.trim() ?? null, instance: li.querySelector('.muted')?.textContent.trim() ?? null })) : [],
|
||||
conversationOpen: !!document.querySelector('.chat-conversation'),
|
||||
forms: [...document.querySelectorAll('form')].map(f => f.id || f.className),
|
||||
};
|
||||
})()`);
|
||||
result.chat = c;
|
||||
check('chat lists the W9 thread', chatReady && c.threads.some(t => t.href === '#/chat/threads/' + W9_THREAD), c.threads);
|
||||
check('W9 thread page not opened', c.hash === '#/chat' && c.conversationOpen === false, c.hash);
|
||||
check('tester banner on chat', c.banner);
|
||||
await b.screenshot(`${OUT}/w10-chat.png`);
|
||||
|
||||
const log = await b.evaluate('window.__w10');
|
||||
result.requests = log;
|
||||
check('no write through the listener', log.every(r => !r.refused && (r.method === 'GET' || r.method === 'HEAD')), log.filter(r => r.refused));
|
||||
check('no request to the W9 thread messages or open', !log.some(r => r.url.includes(W9_THREAD)), log.filter(r => r.url.includes(W9_THREAD)));
|
||||
} catch (e) {
|
||||
failed = true;
|
||||
result.error = String(e?.message ?? e);
|
||||
} finally {
|
||||
await b.close();
|
||||
}
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
console.log(failed ? 'W10 browser: FAIL' : 'W10 browser: pass');
|
||||
process.exit(failed ? 1 : 0);
|
||||
@@ -0,0 +1,30 @@
|
||||
// W10 diagnosis after the two failed checks of w10-browser.mjs (sign-out form listed; chat list missing the W9 thread).
|
||||
// Read only, with the same GET/HEAD guard as w10-browser.mjs. Records each form's hidden flag on Settings, the /api/config
|
||||
// sso value the page sees, and every ul.chat-thread-list on #/chat (chat.js:93 renders one per project). No screenshots.
|
||||
import { browser } from '/mnt/storage/src/mosaic-stack/packages/webui/tests/browser.mjs';
|
||||
import { readFileSync } from 'node:fs';
|
||||
const src = readFileSync(new URL('./w10-browser.mjs', import.meta.url), 'utf8');
|
||||
const GUARD = eval(src.slice(src.indexOf('const GUARD = ') + 14, src.indexOf('`;', src.indexOf('const GUARD = ')) + 1));
|
||||
const BASE = 'http://127.0.0.1:7333/';
|
||||
const W9_THREAD = '446201de-e8b5-4df3-9356-d0d5422cc899';
|
||||
const b = await browser({ machine: false });
|
||||
const out = {};
|
||||
try {
|
||||
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: GUARD });
|
||||
const wait = expr => b.evaluate(`(async()=>{for(let n=0;n<300;n++){if(${expr})return true;await new Promise(r=>setTimeout(r,50));}return false;})()`);
|
||||
await b.navigate(BASE + '#/settings');
|
||||
await wait('!!document.querySelector("#routes-form")');
|
||||
out.config = await b.evaluate(`fetch('/api/config').then(r=>r.json()).then(j=>({sso:j.sso??null}))`);
|
||||
out.forms = await b.evaluate(`[...document.querySelectorAll('form')].map(f=>({id:f.id||f.className,hidden:f.hidden,action:f.getAttribute('action'),method:f.getAttribute('method')}))`);
|
||||
await b.navigate(BASE + '#/chat');
|
||||
await wait('document.querySelectorAll("ul.chat-thread-list").length >= 1');
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
out.chat = await b.evaluate(`({hash:location.hash,open:!!document.querySelector('.chat-conversation'),projects:[...document.querySelectorAll('.chat-projects section.panel')].map(s=>({project:s.querySelector('h2')?.textContent.trim(),threads:[...s.querySelectorAll('ul.chat-thread-list li a')].map(a=>a.getAttribute('href'))}))})`);
|
||||
out.requests = await b.evaluate('window.__w10');
|
||||
} finally { await b.close(); }
|
||||
const signOut = out.forms.find(f => f.id === 'sign-out');
|
||||
const listed = out.chat.projects.some(p => p.threads.includes('#/chat/threads/' + W9_THREAD));
|
||||
const writes = out.requests.filter(r => r.refused || !['GET', 'HEAD'].includes(r.method));
|
||||
console.log(JSON.stringify(out, null, 2));
|
||||
console.log(`sign-out hidden ${signOut?.hidden}, sso ${out.config.sso}; W9 thread listed ${listed}; thread page open ${out.chat.open}; writes ${writes.length}`);
|
||||
process.exit(signOut?.hidden === true && listed && !out.chat.open && writes.length === 0 ? 0 : 1);
|
||||
@@ -0,0 +1,277 @@
|
||||
# W9 go mos-1114, start 2026-10-20T22:05:51Z
|
||||
# plan e1272d1a, tool blob 3b068b1546d4, console-check.sh d068fe02, wrapper on NEW: checked
|
||||
pointer: /home/jwoltje/.mosaic-dev/state/stack-active.json
|
||||
{"copy":"4429a104f6d65ccf165f6c83b5d1dce3147dfde0","business":"mosaic-stack","pm":true,"claude":"/home/jwoltje/.local/bin/claude","console":["--port","7332","--public-host","mosaic.woltje.com","--auth-header","X-authentik-username","--allow-user","jason.woltje","--tester-port","7333"],"schema":"3i","activation":"20261020T220206Z-4429a104f6d6","at":"2026-10-20T22:02:09Z"}
|
||||
log (last five lines):
|
||||
{"at":"2026-10-20T07:58:02Z","op":"activate","from":"89af38a83afa22a8a4f0d368a0a4030ad15a0efa","to":"490e2771d3d057ab76991e2c7859cd251857e64c","backup":"20261020T075800Z-490e2771d3d0","activation":"20261020T075800Z-490e2771d3d0","result":"ok"}
|
||||
{"at":"2026-10-20T19:29:56Z","op":"activate","from":"490e2771d3d057ab76991e2c7859cd251857e64c","to":"490e2771d3d057ab76991e2c7859cd251857e64c","backup":null,"activation":"20261020T192956Z-490e2771d3d0","result":"started"}
|
||||
{"at":"2026-10-20T19:30:00Z","op":"activate","from":"490e2771d3d057ab76991e2c7859cd251857e64c","to":"490e2771d3d057ab76991e2c7859cd251857e64c","backup":"20261020T192956Z-490e2771d3d0","activation":"20261020T192956Z-490e2771d3d0","result":"ok"}
|
||||
{"at":"2026-10-20T22:02:06Z","op":"activate","from":"490e2771d3d057ab76991e2c7859cd251857e64c","to":"4429a104f6d65ccf165f6c83b5d1dce3147dfde0","backup":null,"activation":"20261020T220206Z-4429a104f6d6","result":"started"}
|
||||
{"at":"2026-10-20T22:02:09Z","op":"activate","from":"490e2771d3d057ab76991e2c7859cd251857e64c","to":"4429a104f6d65ccf165f6c83b5d1dce3147dfde0","backup":"20261020T220206Z-4429a104f6d6","activation":"20261020T220206Z-4429a104f6d6","result":"ok"}
|
||||
check:
|
||||
bus: /home/jwoltje/.local/share/mosaic-stack/copies/4429a104f6d65ccf165f6c83b5d1dce3147dfde0
|
||||
Console: /home/jwoltje/.local/share/mosaic-stack/copies/4429a104f6d65ccf165f6c83b5d1dce3147dfde0
|
||||
CLI: /home/jwoltje/.local/share/mosaic-stack/copies/4429a104f6d65ccf165f6c83b5d1dce3147dfde0
|
||||
result: pass
|
||||
bus status:
|
||||
host: mosaic-stack, pid 2950923, running, since 2026-10-20T22:02:08.133Z, notifier off
|
||||
socket: present
|
||||
writer.lock: pid 2950981
|
||||
boot: organization file loaded at 2026-10-20T22:02:08.125Z
|
||||
unit flags: match the pointer
|
||||
drift: 6 commit(s) on refactor since the pointer's copy
|
||||
# exit 0
|
||||
PASS markdown-preview.js is served and equals the copy's
|
||||
PASS /api/config on loopback: send=false edit=false
|
||||
PASS project.files through the Console: head=ae0b17c9586e51da7bc1b7e548c6acf913a55665 files=2 spec=true error=none
|
||||
PASS project.file spec/north-star.md through the Console: commit=ae0b17c9586e51da7bc1b7e548c6acf913a55665 length=99 error=none
|
||||
PASS loopback save refused: HTTP 403 error=read-only sent=false
|
||||
CONSOLE_PASS
|
||||
# exit 0
|
||||
# journal since W5: proxy proof off 1, Sign-in 0
|
||||
# Console pid 2950925 cwd /home/jwoltje/.local/share/mosaic-stack/copies/4429a104f6d65ccf165f6c83b5d1dce3147dfde0
|
||||
r097436c91816 mosaic-stack/chief claude-code claude-opus-5-5 by jason since 2026-10-20T22:02:08.941Z running
|
||||
# exit 0
|
||||
[
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"instance": "chief",
|
||||
"run": "r097436c91816",
|
||||
"launchedBy": "jason",
|
||||
"harness": "claude-code",
|
||||
"model": "claude-opus-5-5",
|
||||
"provider": "anthropic",
|
||||
"modelKey": "opus",
|
||||
"pid": 2951160,
|
||||
"startTime": "63491912",
|
||||
"runnerPid": 2951161,
|
||||
"runnerStartTime": "63491912",
|
||||
"runDir": "/home/jwoltje/.mosaic-dev/launches/mosaic-stack/r097436c91816",
|
||||
"startedAt": "2026-10-20T22:02:08.941Z",
|
||||
"live": true
|
||||
}
|
||||
]
|
||||
# exit 0
|
||||
# one running chief from this start: run r097436c91816
|
||||
chief r097436c91816 claude-code since 2026-10-20T22:02:08.951Z
|
||||
# exit 0
|
||||
sent c5b43f04 to chief
|
||||
chief (RESULT, re c5b43f04) c3e236d7:
|
||||
Got the re-pin of 4429a104f6d65ccf165f6c83b5d1dce3147dfde0 from the tester; I took no action because a tester message authorizes nothing, and I launched and changed nothing.
|
||||
# exit 0
|
||||
created project window-20261020-2205 id 77e7b45f-8c34-49a3-b2fa-9cf565177c30
|
||||
# exit 0
|
||||
created window-20261020-2205/w9 id 446201de-e8b5-4df3-9356-d0d5422cc899 instance assistant
|
||||
# exit 0
|
||||
# project window-20261020-2205, thread 446201de-e8b5-4df3-9356-d0d5422cc899
|
||||
sent b4b66ced to window-20261020-2205/w9
|
||||
assistant (RESULT, re b4b66ced) cfa7ea99:
|
||||
Assistant (run r497f1c817d4d) got the [mos test] message and is replying in one line as asked, without treating it as Jason's instruction or acting on it.
|
||||
# exit 0
|
||||
# reply received 2026-10-20T22:06:05Z
|
||||
[
|
||||
{
|
||||
"seq": 570,
|
||||
"id": "b4b66ced-d088-4b9c-a944-306f164028f9",
|
||||
"at": "2026-10-20T22:05:58.283Z",
|
||||
"business": "mosaic-stack",
|
||||
"from_role": "tester",
|
||||
"from_run": "tester-cli",
|
||||
"to_role": "assistant",
|
||||
"class": "REQUEST",
|
||||
"in_reply_to": null,
|
||||
"decision": null,
|
||||
"corrects": null,
|
||||
"body": "[mos test] reply with one line, then stop",
|
||||
"to_name": null
|
||||
},
|
||||
{
|
||||
"seq": 571,
|
||||
"id": "cfa7ea99-7ccb-4225-80a6-9a903540e337",
|
||||
"at": "2026-10-20T22:06:04.247Z",
|
||||
"business": "mosaic-stack",
|
||||
"from_role": "assistant",
|
||||
"from_run": "r497f1c817d4d",
|
||||
"to_role": "tester",
|
||||
"class": "RESULT",
|
||||
"in_reply_to": "b4b66ced-d088-4b9c-a944-306f164028f9",
|
||||
"decision": null,
|
||||
"corrects": null,
|
||||
"body": "Assistant (run r497f1c817d4d) got the [mos test] message and is replying in one line as asked, without treating it as Jason's instruction or acting on it.",
|
||||
"to_name": null
|
||||
}
|
||||
]
|
||||
# exit 0
|
||||
# thread read: 2 messages: tester->assistant REQUEST, assistant->tester RESULT
|
||||
window-20261020-0801/w9 assistant d0ba7a66
|
||||
window-20261020-2205/w9 assistant 446201de
|
||||
# exit 0
|
||||
[]
|
||||
# exit 0
|
||||
{
|
||||
"total": {
|
||||
"input": 62,
|
||||
"output": 9623,
|
||||
"cacheWrite": 186650,
|
||||
"cacheRead": 404132,
|
||||
"unknownTurns": 1
|
||||
},
|
||||
"byRun": [
|
||||
{
|
||||
"run": "r8fba7e598156",
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cacheWrite": 0,
|
||||
"cacheRead": 0,
|
||||
"unknownTurns": 1
|
||||
},
|
||||
{
|
||||
"run": "r4a96e2142e4d",
|
||||
"input": 2,
|
||||
"output": 52,
|
||||
"cacheWrite": 11750,
|
||||
"cacheRead": 0,
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"run": "rc78c9700c8e9",
|
||||
"input": 14,
|
||||
"output": 1519,
|
||||
"cacheWrite": 18187,
|
||||
"cacheRead": 89559,
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"run": "r293f33bcbc63",
|
||||
"input": 2,
|
||||
"output": 44,
|
||||
"cacheWrite": 18595,
|
||||
"cacheRead": 0,
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"run": "r34520a581318",
|
||||
"input": 6,
|
||||
"output": 323,
|
||||
"cacheWrite": 26349,
|
||||
"cacheRead": 14307,
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"run": "r7e99838f9f93",
|
||||
"input": 2,
|
||||
"output": 44,
|
||||
"cacheWrite": 9858,
|
||||
"cacheRead": 0,
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"run": "rc964611b2092",
|
||||
"input": 4,
|
||||
"output": 120,
|
||||
"cacheWrite": 26138,
|
||||
"cacheRead": 23870,
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"run": "rbb708846b605",
|
||||
"input": 12,
|
||||
"output": 2089,
|
||||
"cacheWrite": 17285,
|
||||
"cacheRead": 73823,
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"run": "rc36b17ef3788",
|
||||
"input": 16,
|
||||
"output": 5221,
|
||||
"cacheWrite": 36717,
|
||||
"cacheRead": 202573,
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"run": "r097436c91816",
|
||||
"input": 2,
|
||||
"output": 131,
|
||||
"cacheWrite": 11915,
|
||||
"cacheRead": 0,
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"run": "r497f1c817d4d",
|
||||
"input": 2,
|
||||
"output": 80,
|
||||
"cacheWrite": 9856,
|
||||
"cacheRead": 0,
|
||||
"unknownTurns": 0
|
||||
}
|
||||
],
|
||||
"byInstance": [
|
||||
{
|
||||
"instance": "chief",
|
||||
"input": 36,
|
||||
"output": 4114,
|
||||
"cacheWrite": 85486,
|
||||
"cacheRead": 177689,
|
||||
"unknownTurns": 1
|
||||
},
|
||||
{
|
||||
"instance": "coder",
|
||||
"input": 2,
|
||||
"output": 44,
|
||||
"cacheWrite": 18595,
|
||||
"cacheRead": 0,
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"instance": "assistant",
|
||||
"input": 4,
|
||||
"output": 124,
|
||||
"cacheWrite": 19714,
|
||||
"cacheRead": 0,
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"instance": "pm",
|
||||
"input": 20,
|
||||
"output": 5341,
|
||||
"cacheWrite": 62855,
|
||||
"cacheRead": 226443,
|
||||
"unknownTurns": 0
|
||||
}
|
||||
],
|
||||
"byProvider": [
|
||||
{
|
||||
"provider": "anthropic",
|
||||
"input": 62,
|
||||
"output": 9623,
|
||||
"cacheWrite": 186650,
|
||||
"cacheRead": 404132,
|
||||
"unknownTurns": 1
|
||||
}
|
||||
],
|
||||
"byModel": [
|
||||
{
|
||||
"provider": "anthropic",
|
||||
"model": "claude-opus-5-5",
|
||||
"input": 62,
|
||||
"output": 9623,
|
||||
"cacheWrite": 186650,
|
||||
"cacheRead": 404132,
|
||||
"unknownTurns": 1
|
||||
}
|
||||
],
|
||||
"byAccount": [
|
||||
{
|
||||
"account": null,
|
||||
"input": 62,
|
||||
"output": 9623,
|
||||
"cacheWrite": 186650,
|
||||
"cacheRead": 404132,
|
||||
"unknownTurns": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
# exit 0
|
||||
# human-thread probe: skipped. S1 has 1 threads row, and threads --tester lists the 177-185 window's tester thread d0ba7a66, so no human thread exists to read
|
||||
# W9 checks and turns end 2026-10-20T22:06:06Z; the assistant run wait and S2 are w9b.sh
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env bash
|
||||
# Re-pin 4429a104f, W9 checks and the turns (repin-4429a104f.md at f41729178, sha256 e1272d1a; the W9 list of
|
||||
# repin-rows-177-185.md plus the journal Sign-in line and the Console cwd), on Mos's go mos-1114.
|
||||
# Adapted from Darkwing's window-rows-177-185/w9.sh. Every call goes through w.sh into W.txt; every bus call carries
|
||||
# --tester. A failed check stops the script. The wait for the assistant run and S2 are in w9b.sh, so the interim
|
||||
# report to Mos goes out between the two (report rule, waits over 5 minutes).
|
||||
# usage: w9.sh
|
||||
set -uo pipefail
|
||||
REPO=/mnt/storage/src/mosaic-stack
|
||||
WIN=$REPO/agents/sage/work/window-repin-4429a104f
|
||||
NEW=4429a104f6d65ccf165f6c83b5d1dce3147dfde0
|
||||
W5_STAMP="2026-10-20 22:02:06 UTC"
|
||||
W5_MS=$(date -d "2026-10-20T22:02:06Z" +%s)000
|
||||
PLAN=$REPO/agents/sage/work/window-plan/repin-4429a104f.md
|
||||
PLAN_SHA=e1272d1a391e00fb1291d47da9973c0ba342d65fcd42f583ea007e883c1f32b9
|
||||
TOOL_BLOB=3b068b1546d4020cffde5d73929d9130afe91911
|
||||
CC=/mnt/storage/scratch/sage-pin-de352dfaf/console-check.sh
|
||||
CC_SHA=d068fe02da897b700110a162d13d9671fe5994f053c45aff99113fade737094c
|
||||
CONSOLE_PID=2950925
|
||||
PRIOR_TID=d0ba7a66-aa3f-4918-9518-94132bce6b28
|
||||
MOSAIC=$HOME/.npm-global/bin/mosaic
|
||||
C_NEW=(node "$HOME/.local/share/mosaic-stack/copies/$NEW/packages/cli/src/cli.mjs")
|
||||
BZ=(--business mosaic-stack)
|
||||
W=$WIN/W.txt
|
||||
note() { echo "# $*" >> "$W"; echo "# $*"; }
|
||||
stop() { note "STOP: $*"; exit 1; }
|
||||
sha() { sha256sum < "$1" | cut -c1-64; }
|
||||
step() { local f="$1"; shift; $WIN/w.sh W.txt "$@" > "$WIN/$f"; local rc=$?; cat "$WIN/$f"; return $rc; }
|
||||
|
||||
note "W9 go mos-1114, start $(date -u +%FT%TZ)"
|
||||
[ "$(sha $PLAN)" = "$PLAN_SHA" ] || stop "the plan file is not e1272d1a"
|
||||
[ "$(git -C $REPO rev-parse HEAD:scripts/stack-release.sh)" = "$TOOL_BLOB" ] || stop "tool blob moved"
|
||||
[ "$(sha $CC)" = "$CC_SHA" ] || stop "console-check.sh is not d068fe02 (SCRIPTS.sha256)"
|
||||
grep -q "/copies/$NEW/scripts/mosaic" "$MOSAIC" || stop "the wrapper is not pinned to NEW"
|
||||
note "plan e1272d1a, tool blob 3b068b1546d4, console-check.sh d068fe02, wrapper on NEW: checked"
|
||||
|
||||
# checks
|
||||
step .w9-status.txt "W9 status" scripts/stack-release.sh status mosaic-stack || stop "W9 status exit"
|
||||
grep -q "\"copy\":\"$NEW\"" $WIN/.w9-status.txt && grep -q '"pm":true' $WIN/.w9-status.txt && grep -q '"schema":"3i"' $WIN/.w9-status.txt || stop "W9 status: pointer not on NEW, pm true, 3i"
|
||||
grep -q 'unit flags: match the pointer' $WIN/.w9-status.txt || stop "W9 status: unit flags"
|
||||
step .w9-cc.txt "W9 console-check NEW ($CC, d068fe02)" bash $CC $NEW || stop "console-check exit"
|
||||
grep -qx CONSOLE_PASS $WIN/.w9-cc.txt || stop "console-check not CONSOLE_PASS"
|
||||
J=$(journalctl --user -u mosaic-webui.service --since "$W5_STAMP" --no-pager)
|
||||
N=$(printf '%s\n' "$J" | grep -c 'Proxy: proxy proof off'); S=$(printf '%s\n' "$J" | grep -c 'Sign-in:')
|
||||
{ echo; echo "## W9 webui journal since $W5_STAMP: 'Proxy: proxy proof off' and 'Sign-in:' counts"; echo "proxy proof off $N, Sign-in $S"; } >> "$W"
|
||||
note "journal since W5: proxy proof off $N, Sign-in $S"
|
||||
[ "$N" = 1 ] || stop "proxy proof off count is $N, not 1"
|
||||
[ "$S" = 0 ] || stop "Sign-in count is $S, not 0"
|
||||
systemctl --user show -p MainPID --value mosaic-webui.service | grep -qx "$CONSOLE_PID" || stop "Console MainPID is not $CONSOLE_PID"
|
||||
CWD=$(readlink /proc/$CONSOLE_PID/cwd)
|
||||
{ echo; echo "## W9 Console pid $CONSOLE_PID cwd"; ls -l /proc/$CONSOLE_PID/cwd | sed 's/^.* -> /-> /'; } >> "$W"
|
||||
note "Console pid $CONSOLE_PID cwd $CWD"
|
||||
[ "$CWD" = "$HOME/.local/share/mosaic-stack/copies/$NEW" ] || stop "Console cwd is not the NEW copy"
|
||||
step .w9-launches.txt "W9 launches list" $MOSAIC launches list || stop "launches list"
|
||||
step .w9-launches.json "W9 launches list --json" $MOSAIC launches list --json || stop "launches list --json"
|
||||
sed -i '$d' $WIN/.w9-launches.json
|
||||
CHIEF=$(node -e '
|
||||
const l=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")),since=Number(process.argv[2]);
|
||||
const c=l.filter(x=>x.business==="mosaic-stack"&&x.instance==="chief"&&x.live===true);
|
||||
if(c.length!==1||!(Date.parse(c[0].startedAt)>=since)){console.error(`running chiefs ${c.length}`);process.exit(1)}
|
||||
process.stdout.write(c[0].run)' $WIN/.w9-launches.json "$W5_MS") || stop "launches: not one running chief from this start"
|
||||
note "one running chief from this start: run $CHIEF"
|
||||
step .w9-agents.txt "W9 agents --tester" $MOSAIC agents "${BZ[@]}" --tester || stop "agents"
|
||||
grep -E '(^|[[:space:]])chief[[:space:]].*working' $WIN/.w9-agents.txt && stop "the chief is working"
|
||||
|
||||
# turns
|
||||
step .w9-talk-chief.txt "W9 talk chief --tester" $MOSAIC talk chief "[mos test] re-pin $NEW: reply with one line, no staffing request" "${BZ[@]}" --tester || stop "talk chief"
|
||||
PROJ=window-$(date -u +%Y%m%d-%H%M)
|
||||
step .w9-project.txt "W9 project create $PROJ --tester" $MOSAIC project create $PROJ "${BZ[@]}" --tester || stop "project create"
|
||||
step .w9-thread.txt "W9 thread create $PROJ w9 --tester" $MOSAIC thread create $PROJ w9 "${BZ[@]}" --tester || stop "thread create"
|
||||
TID=$(sed -n "s/^created $PROJ\/w9 id \([0-9a-f-]*\) instance .*$/\1/p" $WIN/.w9-thread.txt)
|
||||
[ -n "$TID" ] || stop "no thread id"
|
||||
note "project $PROJ, thread $TID"
|
||||
echo "$TID" > $WIN/.w9-tid.txt
|
||||
step .w9-talk-thread.txt "W9 talk --thread --wait 180 --tester" $MOSAIC talk --thread "$TID" "[mos test] reply with one line, then stop" --wait 180 "${BZ[@]}" --tester || stop "talk --thread"
|
||||
note "reply received $(date -u +%FT%TZ)"
|
||||
date -u +%s > $WIN/.w9-reply-at.txt
|
||||
step .w9-thread-read.json "W9 thread read --json --tester" $MOSAIC thread read "$TID" --json "${BZ[@]}" --tester || stop "thread read"
|
||||
sed -i '$d' $WIN/.w9-thread-read.json
|
||||
node -e '
|
||||
const t=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"));
|
||||
console.log(`# thread read: ${t.length} messages: ${t.map(m=>`${m.from_role}->${m.to_role} ${m.class}`).join(", ")}`);
|
||||
process.exit(t.length===2&&t[0].from_role==="tester"&&t[1].from_role==="assistant"?0:1)' $WIN/.w9-thread-read.json | tee -a "$W" || stop "thread read is not the tester message plus one assistant reply"
|
||||
step .w9-threads.txt "W9 threads --tester" $MOSAIC threads "${BZ[@]}" --tester || stop "threads"
|
||||
grep -q "$TID\|$PROJ" $WIN/.w9-threads.txt || stop "threads does not list the W9 thread"
|
||||
step .w9-names.json "W9 C_NEW names --tester --json" "${C_NEW[@]}" names "${BZ[@]}" --tester --json || stop "names"
|
||||
step .w9-usage.json "W9 usage --tester --json" $MOSAIC usage "${BZ[@]}" --tester --json || stop "usage"
|
||||
if grep -q "${PRIOR_TID:0:8}" $WIN/.w9-threads.txt; then
|
||||
note "human-thread probe: skipped. S1 has 1 threads row, and threads --tester lists the 177-185 window's tester thread ${PRIOR_TID:0:8}, so no human thread exists to read"
|
||||
else
|
||||
note "human-thread probe: S1's one thread is not in threads --tester; probe it by hand before S2"
|
||||
fi
|
||||
note "W9 checks and turns end $(date -u +%FT%TZ); the assistant run wait and S2 are w9b.sh"
|
||||
@@ -0,0 +1,185 @@
|
||||
# W9b start 2026-10-20T22:06:27Z: waiting for the launcher to end r497f1c817d4d (thread 446201de-e8b5-4df3-9356-d0d5422cc899), no stop by hand
|
||||
# r497f1c817d4d not live in launches list --json by 2026-10-20T22:21:07Z (5 s poll)
|
||||
r097436c91816 mosaic-stack/chief claude-code claude-opus-5-5 by jason since 2026-10-20T22:02:08.941Z running
|
||||
# exit 0
|
||||
|
||||
## W9b launches log lines for r497f1c817d4d (at, event, instance, thread, run, reason, exitCode, signal only)
|
||||
at=2026-10-20T22:06:00.766Z event=launch instance=assistant thread=446201de-e8b5-4df3-9356-d0d5422cc899 run=r497f1c817d4d
|
||||
at=2026-10-20T22:21:05.149Z event=thread-idle instance=assistant thread=446201de-e8b5-4df3-9356-d0d5422cc899 run=r497f1c817d4d
|
||||
at=2026-10-20T22:21:05.171Z event=end instance=assistant thread=446201de-e8b5-4df3-9356-d0d5422cc899 run=r497f1c817d4d reason=stopped exitCode=0 signal=null
|
||||
|
||||
## W9b bus and webui journal since 2026-10-20 22:05:00 UTC, lines naming r497f1c817d4d or thread-idle
|
||||
2026-10-20T17:21:05.171444-05:00 dragon-lin mosaic[2950923]: mosaic-bus: run r497f1c817d4d (assistant) ended: stopped
|
||||
(grep exit 0)
|
||||
# SNAP sha256 639fd28a checked before S2 2026-10-20T22:21:07Z
|
||||
{
|
||||
"schema": "3i",
|
||||
"tables": {
|
||||
"decision_events": {
|
||||
"rows": 21,
|
||||
"maxSeq": 21,
|
||||
"sha256": "28dd95056663dd5eb7442a3d815b73a4a3cd311a0e854fd82dc54f7dc3e6ef26"
|
||||
},
|
||||
"decisions": {
|
||||
"rows": 21,
|
||||
"maxSeq": 21,
|
||||
"sha256": "fef1058660b0d128ef21089dda5e0fba7f71763d8325148fe521de0d3f57e4ad"
|
||||
},
|
||||
"deliveries": {
|
||||
"rows": 1740,
|
||||
"maxSeq": 1740,
|
||||
"sha256": "554ce5b377ffacc72bfde11c99c920cd5d30337c8bfa217275d0073b587e2169"
|
||||
},
|
||||
"events": {
|
||||
"rows": 1543,
|
||||
"maxSeq": 1543,
|
||||
"sha256": "0151673193d0286ce8f3b234d7d21fa2b0f43f2ceec55644cccaa5cbd77603dd"
|
||||
},
|
||||
"instance_names": {
|
||||
"rows": 0,
|
||||
"maxSeq": null,
|
||||
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
"message_threads": {
|
||||
"rows": 4,
|
||||
"maxSeq": null
|
||||
},
|
||||
"messages": {
|
||||
"rows": 571,
|
||||
"maxSeq": 571
|
||||
},
|
||||
"meta": {
|
||||
"rows": 3,
|
||||
"maxSeq": null,
|
||||
"sha256": "89fec56e7293d19e0b2f6cd8c0ef745ebf99e4115abeaea7fbdbc98a261332d9"
|
||||
},
|
||||
"projects": {
|
||||
"rows": 2,
|
||||
"maxSeq": 2,
|
||||
"sha256": "3435f574ab94bc2fcc65fb8605251f30d4e9e63baf38e6fdf3c39c126531aa63"
|
||||
},
|
||||
"role_claims": {
|
||||
"rows": 101,
|
||||
"maxSeq": 101,
|
||||
"sha256": "fb47114defbb8a57dd148a06fbaf61b30f791a81f14b1510b8c32690506b4308"
|
||||
},
|
||||
"task_comment_cursors": {
|
||||
"rows": 220,
|
||||
"maxSeq": 220,
|
||||
"sha256": "df03c84094999299499de01107ddd08636df6156f5c9ccd2ff6e8c43e459f7e4"
|
||||
},
|
||||
"task_snapshots": {
|
||||
"rows": 408,
|
||||
"maxSeq": 408,
|
||||
"sha256": "e2243a89388afba07e8d096b4961deeb2d17c656cdcace54b46eac7001393814"
|
||||
},
|
||||
"thread_claims": {
|
||||
"rows": 4,
|
||||
"maxSeq": 4,
|
||||
"sha256": "088b8236570d8afe03364b85803fdc5034cce2cef6ed7b16f8b99bf6366524ad"
|
||||
},
|
||||
"thread_opens": {
|
||||
"rows": 0,
|
||||
"maxSeq": null,
|
||||
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
"threads": {
|
||||
"rows": 2,
|
||||
"maxSeq": 2,
|
||||
"sha256": "819fd24c64e279d0debad5ab5dbbe533d8e6f26de58277c69510b9cbbaadbc3a"
|
||||
},
|
||||
"usage_records": {
|
||||
"rows": 34,
|
||||
"maxSeq": 34,
|
||||
"sha256": "de614f3d62c9b3819757c5659bee40085cb86cc873b7bfe381b13e254ddb7cad",
|
||||
"byRun": [
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"run": "r8fba7e598156",
|
||||
"input": "0",
|
||||
"output": "0",
|
||||
"unknownTurns": 1
|
||||
},
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"run": "r4a96e2142e4d",
|
||||
"input": "2",
|
||||
"output": "52",
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"run": "rc78c9700c8e9",
|
||||
"input": "14",
|
||||
"output": "1519",
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"run": "r293f33bcbc63",
|
||||
"input": "2",
|
||||
"output": "44",
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"run": "r34520a581318",
|
||||
"input": "6",
|
||||
"output": "323",
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"run": "r7e99838f9f93",
|
||||
"input": "2",
|
||||
"output": "44",
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"run": "rc964611b2092",
|
||||
"input": "4",
|
||||
"output": "120",
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"run": "rbb708846b605",
|
||||
"input": "12",
|
||||
"output": "2089",
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"run": "rc36b17ef3788",
|
||||
"input": "16",
|
||||
"output": "5221",
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"run": "r097436c91816",
|
||||
"input": "2",
|
||||
"output": "131",
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"run": "r497f1c817d4d",
|
||||
"input": "2",
|
||||
"output": "80",
|
||||
"unknownTurns": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"untimed": [
|
||||
"message_threads",
|
||||
"meta",
|
||||
"task_comment_cursors"
|
||||
]
|
||||
}
|
||||
# exit 0
|
||||
# S2.json sha256 1a444f5ac0049bc78e397b6928e1008cfe8cbc30989bbf2042b4ed27f56a4173
|
||||
# W9b end 2026-10-20T22:21:07Z
|
||||
rc=0
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env bash
|
||||
# Re-pin 4429a104f, W9 tail (adapted from Darkwing's window-rows-177-185/w9b.sh, under mos-0948's ruling for that window: the launcher
|
||||
# ends the assistant run at threadIdleSeconds 900 after the 22:06:05Z reply, about 22:21:05Z). Nothing is stopped by hand. Report if live at 22:27Z.
|
||||
# usage: w9b.sh
|
||||
set -uo pipefail
|
||||
REPO=/mnt/storage/src/mosaic-stack
|
||||
WIN=/mnt/storage/src/mosaic-stack/agents/sage/work/window-repin-4429a104f
|
||||
RUN=r497f1c817d4d
|
||||
TID=446201de-e8b5-4df3-9356-d0d5422cc899
|
||||
DATA=/home/jwoltje/.mosaic-dev
|
||||
SNAP=$WIN/snapshot.mjs
|
||||
SNAP_SHA=639fd28a2674aaa03ce824e3394b4052f21cdef7d6fa49bae61a9927ed341e67
|
||||
PLAN=$REPO/agents/sage/work/window-plan/repin-4429a104f.md
|
||||
PLAN_SHA=e1272d1a391e00fb1291d47da9973c0ba342d65fcd42f583ea007e883c1f32b9
|
||||
TOOL_BLOB=3b068b1546d4020cffde5d73929d9130afe91911
|
||||
MOSAIC=$HOME/.npm-global/bin/mosaic
|
||||
DEADLINE=$(date -d "2026-10-20T22:27:00Z" +%s)
|
||||
MARK18=$(date -d "2026-10-20T22:22:00Z" +%s)
|
||||
W=$WIN/W.txt
|
||||
note() { echo "# $*" >> "$W"; echo "# $*"; }
|
||||
stop() { note "STOP: $*"; exit 1; }
|
||||
sha() { sha256sum < "$1" | cut -c1-64; }
|
||||
step() { local f="$1"; shift; $WIN/w.sh W.txt "$@" > "$WIN/$f"; local rc=$?; cat "$WIN/$f"; return $rc; }
|
||||
# launches log lines for the run: at, event, instance, thread, run, reason, exitCode, signal only
|
||||
runlog() { node -e '
|
||||
const keep=["at","event","instance","thread","run","reason","exitCode","signal"];
|
||||
for(const s of require("fs").readFileSync(process.argv[1],"utf8").trim().split("\n")){const o=JSON.parse(s);if(o.run!==process.argv[2])continue;
|
||||
console.log(keep.filter(k=>k in o).map(k=>`${k}=${o[k]}`).join(" "))}' "$DATA/launches/mosaic-stack.jsonl" "$RUN"; }
|
||||
|
||||
note "W9b start $(date -u +%FT%TZ): waiting for the launcher to end $RUN (thread $TID), no stop by hand"
|
||||
[ "$(sha $PLAN)" = "$PLAN_SHA" ] || stop "the plan file is not e1272d1a"
|
||||
[ "$(git -C $REPO rev-parse HEAD:scripts/stack-release.sh)" = "$TOOL_BLOB" ] || stop "tool blob moved"
|
||||
END=; M18=
|
||||
while :; do
|
||||
$MOSAIC launches list --json > $WIN/.w9b-poll.json 2>&1 || stop "launches poll"
|
||||
LIVE=$(node -e 'const l=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"));process.stdout.write(l.filter(x=>x.run===process.argv[2]&&x.live===true).map(x=>x.run).join(" "))' $WIN/.w9b-poll.json $RUN)
|
||||
[ -z "$LIVE" ] && { END=$(date -u +%FT%TZ); break; }
|
||||
NOW=$(date +%s)
|
||||
if [ -z "$M18" ] && [ $NOW -ge $MARK18 ]; then M18=1; note "22:22Z mark $(date -u +%FT%TZ): $RUN still live; launches log lines for it:"; runlog | tee -a "$W"; fi
|
||||
[ $NOW -ge $DEADLINE ] && break
|
||||
sleep 5
|
||||
done
|
||||
if [ -z "$END" ]; then
|
||||
note "STOP: $RUN still live at $(date -u +%FT%TZ) (22:27Z mark); a stop is Mos's call. S2 waits."
|
||||
runlog | tee -a "$W"
|
||||
exit 4
|
||||
fi
|
||||
note "$RUN not live in launches list --json by $END (5 s poll)"
|
||||
step .w9b-launches-after.txt "W9b launches list after the assistant run" $MOSAIC launches list
|
||||
{ echo; echo "## W9b launches log lines for $RUN (at, event, instance, thread, run, reason, exitCode, signal only)"; runlog; } | tee -a "$W"
|
||||
{ echo; echo "## W9b bus and webui journal since 2026-10-20 22:05:00 UTC, lines naming $RUN or thread-idle"; journalctl --user -u [email protected] -u mosaic-webui.service --since "2026-10-20 22:05:00 UTC" --no-pager -o short-iso-precise | grep -E "$RUN|thread-idle|threadIdle"; echo "(grep exit ${PIPESTATUS[1]})"; } | tee -a "$W"
|
||||
[ "$(sha $SNAP)" = "$SNAP_SHA" ] || stop "SNAP sha before S2"
|
||||
note "SNAP sha256 639fd28a checked before S2 $(date -u +%FT%TZ)"
|
||||
step S2.json "S2 snapshot --held" node $SNAP --store "$DATA/bus/bus.sqlite" --held || stop "S2"
|
||||
sed -i '$d' $WIN/S2.json
|
||||
note "S2.json sha256 $(sha $WIN/S2.json)"
|
||||
note "W9b end $(date -u +%FT%TZ)"
|
||||
@@ -0,0 +1,43 @@
|
||||
# W0b-W4 start 2026-10-20T22:00:49Z
|
||||
# plan e1272d1a, tool blob 3b068b1546d4, P5 four scripts at NEW and clean, business-check.mjs eece4d46: checked 2026-10-20T22:00:49Z
|
||||
# W0 copy present, w0.txt LOAD_OK
|
||||
# W0b: /home/jwoltje/.config/mosaic-dev/businesses/mosaic-stack.json.pre-window-20261020T220049Z, sha256 1e49bdf05e62607972958f5b7298cfd600c7825812370e26d7c2a842f2af9b93, recorded in backups.txt
|
||||
rbb708846b605 mosaic-stack/chief claude-code claude-opus-5-5 by jason since 2026-10-20T19:29:59.045Z running
|
||||
rc36b17ef3788 mosaic-stack/pm claude-code claude-opus-5-5 by chief since 2026-10-20T20:20:56.252Z running
|
||||
# exit 0
|
||||
# W1 start stamp 2026-10-20T22:00:49Z
|
||||
# SNAP sha256 639fd28a checked before S1
|
||||
# S1.json sha256 a7649760c3439705c8b5f3af26d0c21aea1081c1eb295fd02aef3a642064194b
|
||||
pointer: /home/jwoltje/.mosaic-dev/state/stack-active.json
|
||||
{"copy":"490e2771d3d057ab76991e2c7859cd251857e64c","business":"mosaic-stack","pm":true,"claude":"/home/jwoltje/.local/bin/claude","console":["--port","7332","--public-host","mosaic.woltje.com","--auth-header","X-authentik-username","--allow-user","jason.woltje","--tester-port","7333"],"schema":"3i","activation":"20261020T192956Z-490e2771d3d0","at":"2026-10-20T19:30:00Z"}
|
||||
log (last five lines):
|
||||
{"at":"2026-10-20T01:41:05Z","op":"pm-on","from":"89af38a83afa22a8a4f0d368a0a4030ad15a0efa","to":"89af38a83afa22a8a4f0d368a0a4030ad15a0efa","backup":null,"activation":"20261020T014103Z-89af38a83afa","result":"ok","pm":"on"}
|
||||
{"at":"2026-10-20T07:58:00Z","op":"activate","from":"89af38a83afa22a8a4f0d368a0a4030ad15a0efa","to":"490e2771d3d057ab76991e2c7859cd251857e64c","backup":null,"activation":"20261020T075800Z-490e2771d3d0","result":"started"}
|
||||
{"at":"2026-10-20T07:58:02Z","op":"activate","from":"89af38a83afa22a8a4f0d368a0a4030ad15a0efa","to":"490e2771d3d057ab76991e2c7859cd251857e64c","backup":"20261020T075800Z-490e2771d3d0","activation":"20261020T075800Z-490e2771d3d0","result":"ok"}
|
||||
{"at":"2026-10-20T19:29:56Z","op":"activate","from":"490e2771d3d057ab76991e2c7859cd251857e64c","to":"490e2771d3d057ab76991e2c7859cd251857e64c","backup":null,"activation":"20261020T192956Z-490e2771d3d0","result":"started"}
|
||||
{"at":"2026-10-20T19:30:00Z","op":"activate","from":"490e2771d3d057ab76991e2c7859cd251857e64c","to":"490e2771d3d057ab76991e2c7859cd251857e64c","backup":"20261020T192956Z-490e2771d3d0","activation":"20261020T192956Z-490e2771d3d0","result":"ok"}
|
||||
check:
|
||||
bus: /home/jwoltje/.local/share/mosaic-stack/copies/490e2771d3d057ab76991e2c7859cd251857e64c
|
||||
Console: /home/jwoltje/.local/share/mosaic-stack/copies/490e2771d3d057ab76991e2c7859cd251857e64c
|
||||
CLI: /home/jwoltje/.local/share/mosaic-stack/copies/490e2771d3d057ab76991e2c7859cd251857e64c
|
||||
result: pass
|
||||
bus status:
|
||||
host: mosaic-stack, pid 3229540, running, since 2026-10-20T19:29:57.662Z, notifier off
|
||||
socket: present
|
||||
writer.lock: pid 3229657
|
||||
boot: organization file loaded at 2026-10-20T19:29:57.661Z
|
||||
unit flags: match the pointer
|
||||
drift: 109 commit(s) on refactor since the pointer's copy
|
||||
# exit 0
|
||||
# W3-plan.txt sha256 45de68d42b4cf88640fe0986d071dba4fa076b7db86ff4ed34de4bb6fa2915c6
|
||||
DropInPaths=
|
||||
ExecStart={ path=/home/jwoltje/.local/share/mosaic-stack/copies/490e2771d3d057ab76991e2c7859cd251857e64c/scripts/mosaic ; argv[]=/home/jwoltje/.local/share/mosaic-stack/copies/490e2771d3d057ab76991e2c7859cd251857e64c/scripts/mosaic bus start mosaic-stack --pm ; ignore_errors=no ; start_time=[n/a] ; stop_time=[n/a] ; pid=0 ; code=(null) ; status=0/0 }
|
||||
WorkingDirectory=/home/jwoltje/.local/share/mosaic-stack/copies/490e2771d3d057ab76991e2c7859cd251857e64c
|
||||
# exit 0
|
||||
DropInPaths=
|
||||
ExecStart={ path=/usr/bin/node ; argv[]=/usr/bin/node /home/jwoltje/.local/share/mosaic-stack/copies/490e2771d3d057ab76991e2c7859cd251857e64c/packages/webui/src/cli.mjs serve --checkout /mnt/storage/src/mosaic-stack --port 7332 --public-host mosaic.woltje.com --auth-header X-authentik-username --allow-user jason.woltje --tester-port 7333 ; ignore_errors=no ; start_time=[n/a] ; stop_time=[n/a] ; pid=0 ; code=(null) ; status=0/0 }
|
||||
WorkingDirectory=/home/jwoltje/.local/share/mosaic-stack/copies/490e2771d3d057ab76991e2c7859cd251857e64c
|
||||
# exit 0
|
||||
# W4 checks: DropInPaths empty on both, both ExecStart name the OLD copy, bus has --pm, no quote or backslash in either ExecStart
|
||||
# W4 bus Environment: 2 assignments; quote or backslash: none (values not recorded)
|
||||
# W0b-W4 end 2026-10-20T22:00:50Z
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env bash
|
||||
# Re-pin 4429a104f, W0b-W4 (repin-4429a104f.md at f41729178, sha256 e1272d1a...32b9; mos-1105, mos-1107 go). W0 ran before P2.
|
||||
# Adapted from Darkwing's window-rows-177-185/wsteps.sh.
|
||||
# Nothing is stopped. Each step goes through w.sh into W.txt; a failed check stops the script.
|
||||
# usage: wsteps.sh
|
||||
set -uo pipefail
|
||||
REPO=/mnt/storage/src/mosaic-stack
|
||||
WIN=$REPO/agents/sage/work/window-repin-4429a104f
|
||||
OLD=490e2771d3d057ab76991e2c7859cd251857e64c
|
||||
NEW=4429a104f6d65ccf165f6c83b5d1dce3147dfde0
|
||||
PLAN=$REPO/agents/sage/work/window-plan/repin-4429a104f.md
|
||||
PLAN_SHA=e1272d1a391e00fb1291d47da9973c0ba342d65fcd42f583ea007e883c1f32b9
|
||||
TOOL_BLOB=3b068b1546d4020cffde5d73929d9130afe91911
|
||||
BC=$REPO/agents/sage/work/window-plan/repin-de352dfaf/business-check.mjs
|
||||
BC_SHA=eece4d4671e1ce15ba81e1654c641925ac4988b343687b062897392285a8a1a7
|
||||
BIZ=$HOME/.config/mosaic-dev/businesses/mosaic-stack.json
|
||||
BIZ_SHA=1e49bdf05e62607972958f5b7298cfd600c7825812370e26d7c2a842f2af9b93
|
||||
DATA=/home/jwoltje/.mosaic-dev
|
||||
SNAP=$WIN/snapshot.mjs
|
||||
SNAP_SHA=639fd28a2674aaa03ce824e3394b4052f21cdef7d6fa49bae61a9927ed341e67
|
||||
MOSAIC=$HOME/.npm-global/bin/mosaic
|
||||
W=$WIN/W.txt
|
||||
note() { echo "# $*" >> "$W"; echo "# $*"; }
|
||||
stop() { note "STOP: $*"; exit 1; }
|
||||
sha() { sha256sum < "$1" | cut -c1-64; }
|
||||
|
||||
note "W0b-W4 start $(date -u +%FT%TZ)"
|
||||
[ "$(git -C $REPO show f41729178:agents/sage/work/window-plan/repin-4429a104f.md | sha256sum | cut -c1-64)" = "$PLAN_SHA" ] || stop "plan at f41729178 is not e1272d1a"
|
||||
[ "$(sha $PLAN)" = "$PLAN_SHA" ] || stop "the plan file in REPO is not e1272d1a"
|
||||
[ "$(git -C $REPO rev-parse HEAD:scripts/stack-release.sh)" = "$TOOL_BLOB" ] || stop "tool blob moved"
|
||||
git -C $REPO diff --quiet $NEW HEAD -- scripts/stack-release.sh scripts/webui-service.sh scripts/bus-service.sh scripts/stack-copy.sh || stop "P5: the four scripts differ from NEW"
|
||||
[ -z "$(git -C $REPO status --porcelain -- scripts/stack-release.sh scripts/webui-service.sh scripts/bus-service.sh scripts/stack-copy.sh)" ] || stop "P5: the four scripts are dirty"
|
||||
[ "$(sha $BC)" = "$BC_SHA" ] || stop "business-check.mjs sha"
|
||||
note "plan e1272d1a, tool blob 3b068b1546d4, P5 four scripts at NEW and clean, business-check.mjs eece4d46: checked $(date -u +%FT%TZ)"
|
||||
|
||||
# W0 ran at 21:45:57Z (w0.txt): the copy must still be there with LOAD_OK recorded.
|
||||
[ -f "$HOME/.local/share/mosaic-stack/copies/$NEW/scripts/mosaic" ] || stop "W0 copy missing"
|
||||
grep -qx LOAD_OK $WIN/w0.txt || stop "w0.txt has no LOAD_OK"
|
||||
note "W0 copy present, w0.txt LOAD_OK"
|
||||
|
||||
# W0b
|
||||
[ "$(sha $BIZ)" = "$BIZ_SHA" ] || stop "business file sha256 is not P1's 1e49bdf0"
|
||||
STAMP=$(date -u +%Y%m%dT%H%M%SZ)
|
||||
COPY=$BIZ.pre-window-$STAMP
|
||||
[ ! -e "$COPY" ] || stop "$COPY exists"
|
||||
cp -p "$BIZ" "$COPY" || stop "W0b cp"
|
||||
[ "$(sha "$COPY")" = "$BIZ_SHA" ] || stop "W0b copy sha256"
|
||||
echo "W0b $(date -u +%FT%TZ) business file copy $COPY sha256 $(sha "$COPY") (source $BIZ sha256 $(sha "$BIZ"))" >> $WIN/backups.txt
|
||||
note "W0b: $COPY, sha256 $(sha "$COPY"), recorded in backups.txt"
|
||||
|
||||
# W1
|
||||
$WIN/w.sh W.txt "W1 agents --tester" $MOSAIC agents --business mosaic-stack --tester > $WIN/.w1-agents.txt || stop "W1 agents"
|
||||
grep -E '(^|[[:space:]])chief[[:space:]].*working' $WIN/.w1-agents.txt && stop "W1: the chief is working"
|
||||
$WIN/w.sh W.txt "W1 launches list" $MOSAIC launches list || stop "W1 launches list"
|
||||
note "W1 start stamp $(date -u +%FT%TZ)"
|
||||
[ "$(sha $SNAP)" = "$SNAP_SHA" ] || stop "SNAP sha before S1"
|
||||
note "SNAP sha256 639fd28a checked before S1"
|
||||
$WIN/w.sh W.txt "S1 snapshot --held" node $SNAP --store "$DATA/bus/bus.sqlite" --held > $WIN/S1.json || stop "S1"
|
||||
sed -i '$d' $WIN/S1.json # w.sh prints "# exit N" last; S1.json keeps the snapshot only
|
||||
note "S1.json sha256 $(sha $WIN/S1.json)"
|
||||
|
||||
# W2
|
||||
$WIN/w.sh W.txt "W2 status" scripts/stack-release.sh status mosaic-stack || stop "W2 status"
|
||||
|
||||
# W3
|
||||
$WIN/w.sh W.txt "W3 activate NEW --pm --plan" scripts/stack-release.sh activate $NEW mosaic-stack --pm --plan > $WIN/W3-plan.txt || stop "W3 plan exit"
|
||||
sed -i '$d' $WIN/W3-plan.txt
|
||||
note "W3-plan.txt sha256 $(sha $WIN/W3-plan.txt)"
|
||||
|
||||
# W4
|
||||
$WIN/w.sh W.txt "W4 bus unit (Environment omitted from the transcript)" systemctl --user show [email protected] -p ExecStart -p WorkingDirectory -p DropInPaths || stop "W4 bus"
|
||||
$WIN/w.sh W.txt "W4 webui unit" systemctl --user show mosaic-webui.service -p ExecStart -p WorkingDirectory -p DropInPaths || stop "W4 webui"
|
||||
BUSL=$(systemctl --user show [email protected] -p ExecStart -p DropInPaths -p WorkingDirectory)
|
||||
WEBL=$(systemctl --user show mosaic-webui.service -p ExecStart -p DropInPaths -p WorkingDirectory)
|
||||
for u in BUSL WEBL; do v=${!u}; grep -qx 'DropInPaths=' <<< "$v" || stop "W4 $u DropInPaths not empty"; grep '^ExecStart=' <<< "$v" | grep -q "copies/$OLD/" || stop "W4 $u ExecStart does not name the OLD copy"; grep '^ExecStart=' <<< "$v" | grep -q "copies/$NEW" && stop "W4 $u ExecStart names NEW"; grep '^ExecStart=' <<< "$v" | grep -q "[\"'\\\\]" && stop "W4 $u ExecStart has a quote or backslash"; done
|
||||
grep '^ExecStart=' <<< "$BUSL" | grep -q -- ' --pm' || stop "W4 bus ExecStart has no --pm"
|
||||
note "W4 checks: DropInPaths empty on both, both ExecStart name the OLD copy, bus has --pm, no quote or backslash in either ExecStart"
|
||||
ENVL=$(systemctl --user show [email protected] -p Environment)
|
||||
note "W4 bus Environment: $(tr ' ' '\n' <<< "${ENVL#Environment=}" | grep -c =) assignments; quote or backslash: $(grep -q "[\"'\\\\]" <<< "$ENVL" && echo FOUND || echo none) (values not recorded)"
|
||||
note "W0b-W4 end $(date -u +%FT%TZ)"
|
||||
@@ -1363,3 +1363,6 @@ 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)
|
||||
2026-10-20T22:16:20Z | Filbert (Opus 5.5, 048f3c63) | row 190 (#1688) manifest round 3 review, candidate df65a3d4c on darkwing/row190-r3 | APPROVE, Gitea 29713, queue rev 2504 (left for Sage's queue-commit); static review, no slot used
|
||||
|
||||
+120
-11
@@ -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,
|
||||
@@ -368,11 +445,11 @@ reports checkout or copy identity and refactor drift, and writes nothing.
|
||||
```
|
||||
scripts/stack-release.sh adopt BUSINESS
|
||||
scripts/stack-release.sh activate ID BUSINESS [--pm | --pm-after-harness] [--migrate] [--tester-port N]
|
||||
[--proof-header NAME --proof-file PATH | --no-proof] [--no-send] [--timeout S] [--plan]
|
||||
[--proof-header NAME --proof-file PATH | --no-proof | --sso] [--no-send] [--timeout S] [--plan]
|
||||
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.
|
||||
```
|
||||
@@ -488,6 +565,36 @@ rollbacks carry the pointer, so send stays off. No flag puts `--send` back.
|
||||
`--plan` prints one `send:` line: `dropped`, `off` with or without the
|
||||
`--no-send` note, or `carried`.
|
||||
|
||||
`activate ... --sso` (row 200, #1698) starts the Console with Q175 sign-in
|
||||
(row 192). It drops `--public-host`, `--auth-header`, `--allow-user` and any
|
||||
proof pair from the pointer's Console flags and the unit, and puts `--sso` after
|
||||
the other flags, before `--send` and `--tester-port`. It may name the live copy
|
||||
once, so a copy already at row 192 or later turns sign-in on without a copy
|
||||
change. Later activates, `pm-on` and rollbacks to a copy with `--sso` carry it.
|
||||
`--sso` with a proof pair or `--no-proof`, or on any other verb, is a usage
|
||||
error. Before anything stops, a run whose Console flags hold `--sso` refuses
|
||||
`sso-target` for a target Console without it, `sso-config` when the target's own
|
||||
`checkConsole` refuses the config's `console` key (or there is none, in which
|
||||
case, from another copy, it names the first step: activate the target without
|
||||
`--sso`, then write `console.sso`), and `sso-slot` for each provider's slot file
|
||||
that is missing, a symlink, not a regular file, not owned by this uid, has group
|
||||
or other bits, resolves inside the repository or the copies, or is empty. It
|
||||
uses `lstat`, `stat` and `realpath` on the slot and never reads it. `--plan`
|
||||
prints one `sso:` line: `on` with what it drops and carries, `carried`, or
|
||||
`dropped` on a rollback.
|
||||
|
||||
A rollback from an `--sso` pointer to a copy whose Console lacks `--sso` takes
|
||||
the Console flags from the rollback set of the activation that moved the stack
|
||||
onto the pointer's copy from the target, past any same-copy re-pin, checked
|
||||
against the set's `SHA256SUMS`. `--send` and `--tester-port` follow the pointer.
|
||||
When that set is missing, fails its sums, holds no readable argv or already ran
|
||||
`--sso`, the run refuses `sso-rollback-flags`. Any activate or rollback also
|
||||
refuses `config-console-key` before anything stops when the config holds a
|
||||
`console` key the target's `scripts/mosaic-config.mjs validate` refuses (a copy
|
||||
before row 192 refuses the key). The message says to remove the key first. So a
|
||||
window re-pins to row 192 or later before it writes the key, and a rollback past
|
||||
row 192 removes the key first.
|
||||
|
||||
#### A target past c216f47db: the PM after the harness kind
|
||||
|
||||
From c216f47db on, the bus launches the PM only with a `harness` kind in the
|
||||
@@ -602,6 +709,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 +787,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 +814,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
+23
-4
@@ -33,7 +33,7 @@ Gaps found while working go to `docs/plans/DEFERRED.md`, not here.
|
||||
|
||||
<!-- mosaic-queue:begin -->
|
||||
|
||||
Generated from `docs/plans/queue.json` revision 2361 by `queue render`. Do not edit between the markers; change the queue with `scripts/mosaic queue`.
|
||||
Generated from `docs/plans/queue.json` revision 2455 by `queue render`. Do not edit between the markers; change the queue with `scripts/mosaic queue`.
|
||||
|
||||
| # | Piece | Owner | Issues | State | After | Gate | Brief | Note |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
@@ -226,14 +226,14 @@ Generated from `docs/plans/queue.json` revision 2361 by `queue render`. Do not e
|
||||
| 187 | stack-release activate carries the row 178 proof pair (mos-0928 Part B A0) | darkwing; reviewers filbert | #1685 | done | 186 done | Filbert approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the brief mutants killed (sage) | `docs/plans/2026-10-20_activate-proof-pair.md` § Brief: activate carries the row 178 proof pair | Landed 9a71e4e80 (author Darkwing) on d17328a55; patch-id 6b57c47bc661 matches candidate 52a5ecc1d143 and Filbert's r1 reading. Sage gate passed 10:21:55Z: cli 446/446 and every package and scripts/test-*.sh green. Filbert approve 29424, rev 2204; follow-ups N1-N4 filed on P19. |
|
||||
| 188 | stack-release tests: a carried proof pair and a proof file inside the repository (P19 409, 410; gates Part B) | darkwing; reviewers filbert | #1686 | done | 187 settled | Filbert approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the brief mutants killed (sage) | `docs/plans/2026-10-20_proof-test-gaps.md` § Brief: tests for the two proof-file paths row 187 left open | Landed 8fc0ee216 (author Darkwing) on 23a609c5c; patch-id c9a56b167c07 matches candidate 86aa549bad75 and Filbert's r1 reading; stack-release.sh blob 2586a2a37cd2 unchanged. Sage gate passed 10:58Z: cli 453/453 and every package and scripts/test-*.sh green. Filbert approve 29433, rev 2212; N1 (REPO logical path) to P19 after Part B (mos-0959). |
|
||||
| 189 | stack-release activate --no-send drops the Console's --send and keeps the pointer in step (Q175 interim, mos-0972, P19 413) | sage; reviewers dewey | #1687 | done | 163 done | Dewey approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the brief mutants killed (sage) | `docs/plans/2026-10-20_activate-no-send.md` § Brief: activate --no-send drops the Console's --send | Landed ace27dc5e on refactor (cherry-pick of candidate d6116771f onto a4a42dbac). Gate GATE_PASSED on 0c177e9ef (base 0217b71c8), 2026-10-20T19:09:37Z-19:20:36Z, 25 suites rc=0. patch-id ce89378718f6 for both 0c177e9ef and ace27dc5e; range-diff differs only in the message. Landed manifest hashes to 67af69bd (Dewey approve 29485). Base drift 0217b71c8..a4a42dbac: 7 doc/README files, none on manifest paths, no test reads them. Brief re-pinned to blob 29a9e6d2d. |
|
||||
| 190 | several Mosaic Stack instances on one host: per-instance config, data root, units and ports (MVP T1.1, mos-0974, Vikunja #414) | darkwing; reviewers filbert | #1688 | in-review, round 1, request posted | — | Filbert approves DESIGN.md then the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the design mutants killed (sage) | `docs/plans/2026-10-20_multi-instance.md` § Brief: several Mosaic Stack instances on one host | — |
|
||||
| 190 | several Mosaic Stack instances on one host: per-instance config, data root, units and ports (MVP T1.1, mos-0974, Vikunja #414) | darkwing; reviewers filbert | #1688 | in-review, round 2, request posted | — | Filbert approves DESIGN.md then the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the design mutants killed (sage) | `docs/plans/2026-10-20_multi-instance.md` § Brief: several Mosaic Stack instances on one host | — |
|
||||
| 191 | one kill switch for the bus, every run, Discord and new launches, from the CLI and the Console (MVP T1.4, mos-0974, Vikunja #414) | rocko; reviewers dewey | #1689 | in-progress | — | Dewey approves DESIGN.md then the manifest; cli, bus, discord and webui suites and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_kill-switch.md` § Brief: one kill switch for the bus, every run, Discord and new launches | — |
|
||||
| 192 | Q175 phase 1: the Console verifies SSO itself (--sso), OIDC RP with oauth4webapi 3.8.8, no live change (MVP T1.2, #412) | launchpad; reviewers webby | #1690 | in-review, round 2, request posted | — | Webby approves the manifest; webui and cli suites, scripts/test-config.sh and every scripts/test-*.sh green on Sage's gate; design section 9 mutants killed (sage) | `docs/plans/2026-10-20_sso-phase1.md` § Brief: Q175 phase 1, the Console verifies SSO itself (--sso) | — |
|
||||
| 192 | Q175 phase 1: the Console verifies SSO itself (--sso), OIDC RP with oauth4webapi 3.8.8, no live change (MVP T1.2, #412) | launchpad; reviewers webby | #1690 | done | — | Webby approves the manifest; webui and cli suites, scripts/test-config.sh and every scripts/test-*.sh green on Sage's gate; design section 9 mutants killed (sage) | `docs/plans/2026-10-20_sso-phase1.md` § Brief: Q175 phase 1, the Console verifies SSO itself (--sso) | Landed 4429a104f on refactor (squash of launchpad/row192 53bb59462..37582e820, round 2 manifest 6f8cee14...7787). patch-id 4a4869026 matches the reviewed diff on all 18 paths. Gate on 088087ac9: package suites 2758/0, scripts 429/0, mutants 22 of 23 killed (#23 equivalent, sage-1407). npm ci in canonical, oauth4webapi 3.8.8. Live re-pin is a window with Mos (mos-1099 item 3). |
|
||||
| 193 | many humans per business, human roles, private and shared threads, one access.json store (MVP T1.3, Vikunja #414/#415) | morgana; reviewers duckworth | #1691 | in-progress | — | Duckworth approves DESIGN.md then the manifest; bus, business, cli and webui suites and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-users.md` § Brief: many humans per business, human roles, private and shared threads | — |
|
||||
| 194 | agent lifecycle from the Console and the CLI (create, launch, terminate, regenerate, delete) with a per-agent hire/fire veto (MVP T1.5, Vikunja #414/#415) | honker; reviewers webby | #1692 | in-progress | — | Webby approves DESIGN.md then the manifest; bus, cli, business and webui suites and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_agent-lifecycle.md` § Brief: agent lifecycle from the Console and the CLI, with a per-agent hire/fire veto | — |
|
||||
| 195 | row 195: the Case 1 install on 10.1.1.53 as mosaic under /srv/mosaic, reusing C2-13's scripts by input under row 190's canon (Q181, Q182/Q183, Q185; ops-01 root steps under #423 step 1) | darkwing; reviewers duckworth | #1693 | briefed | 190 done | Duckworth approves DESIGN.md then the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_hub-own-uid.md` § Brief: the Case 1 install on 10.1.1.53 (row 195) | sage-1439: rewritten to Case 1 on .53 (f24f2cd40). Chains on C2-13's candidate too once C2-13 is queued. Darkwing starts DESIGN.md after row 190's mutants. |
|
||||
| 196 | the human boundary across uids and humans: Console, tester listener, broker.sock and the path-bound human proof (P19 333, before federation R3, mos-0989) | gander; reviewers duckworth | #1694 | in-progress | — | Duckworth approves DESIGN.md then the manifest; bus, webui and cli suites and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-boundary.md` § Brief: the human boundary across uids and humans (P19 333) | — |
|
||||
| 197 | a second gate lane: two full gates at once on dragon-lin, design first (mos-0977 item 6) | herb; reviewers webby | #1695 | in-progress | — | Webby approves DESIGN.md then the manifest; cli suite and every scripts/test-*.sh green on Sage's gate, singly and in the two-lane proof (sage) | `docs/plans/2026-10-20_second-gate-lane.md` § Brief: a second gate lane, two full gates at once on dragon-lin | Order with row 198 (Sage, mos-1040 N1): 197 lands first. After B1 the shared tests are packages/webui/tests/serve.test.mjs:100 and preview.test.mjs:208. Row 198 rebases onto 197's landing before its manifest review. |
|
||||
| 197 | a second gate lane: two full gates at once on dragon-lin, design first (mos-0977 item 6) | herb; reviewers webby | #1695 | in-review, round 1, request posted | — | Webby approves DESIGN.md then the manifest; cli suite and every scripts/test-*.sh green on Sage's gate, singly and in the two-lane proof (sage) | `docs/plans/2026-10-20_second-gate-lane.md` § Brief: a second gate lane, two full gates at once on dragon-lin | Order with row 198 (Sage, mos-1040 N1): 197 lands first. After B1 the shared tests are packages/webui/tests/serve.test.mjs:100 and preview.test.mjs:208. Row 198 rebases onto 197's landing before its manifest review. |
|
||||
| 198 | retire the control board writer on 7331 (unauthenticated loopback writer, Q175; mos-1008) | bushroot; reviewers webby | #1696 | in-progress | 192 done, 197 done | Webby approves INVENTORY.md then the manifest; control-board, webui and cli suites and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_retire-control-board.md` § Brief: retire the control board writer on 7331 | Order with row 197 (Sage, mos-1040 N1): 197 lands first. Row 198 rebases onto 197's landing before its manifest review; shared tests are packages/webui/tests/serve.test.mjs:100 and preview.test.mjs:208. |
|
||||
| 199 | MVP T1 item 6: a credential store per agent, own git identity, pushes to a remote (G11, G13; mos-0974) | megavolt; reviewers duckworth | #1697 | in-progress | — | Duckworth approves DESIGN.md; Mos rules on the authority model; Duckworth approves the manifest; bus, business, cli and tasks suites and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_agent-credentials.md` § Brief: a credential store per agent, own git identity, pushes to a remote | — |
|
||||
| 200 | stack-release.sh activate --sso for the Q175 phase 2 window (row 192 F3; chained on 192) | launchpad; reviewers webby | #1698 | in-review, round 2, request posted | — | Webby approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_stack-release-sso.md` § Brief: stack-release.sh activates a Console with --sso (Q175 phase 2 prerequisite) | — |
|
||||
@@ -248,6 +248,25 @@ Generated from `docs/plans/queue.json` revision 2361 by `queue render`. Do not e
|
||||
| 209 | FED R8: the hub window on 10.1.1.54, a recorded acceptance (#414, r4.11 b93f098f0; live window run by Jason and Mos, after the Case 2 install #416, Bean and Chet live and both Hermes gateways stopped) | sage; reviewers duckworth | — | queued | — | Every entry check's output is in the R8 record with who ran it; Jason signs in to the hub and sends one message, Melanie revokes, his next request is refused; Duckworth checks the record (sage) | `docs/plans/2026-10-20_federation-r1-r2-r5.md` § Brief: FED R8, the hub window on 10.1.1.54 | Stays queued until its entry gates near: Case 2 install on .54 (#416, by 2026-10-27), Bean and Chet live, both Hermes gateways on .54 stopped, R7, #412 phase 2, #419 answer. Filed per mos-1073 after duckworth-0034 (Sage) |
|
||||
| 210 | Agent host access with sudo through the credential store: grant per agent and host, the stack does ssh and sudo, the agent never sees the key (#1707, Vikunja #424, Q183/Q184, due 10-27; chained on row 199's candidate) | gizmoduck; reviewers duckworth | #1707 | in-progress | — | Duckworth approves DESIGN.md, then the manifest; bus, business, cli suites and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_host-credentials.md` § Brief: agent host access with sudo through the credential store (#424) | Design runs before 199 lands (Q177 chain on 199's candidate). after cleared so Gizmoduck can claim; lands only after 199 and rebases onto it. |
|
||||
| 211 | Force-push guard: scripts/git-hooks/pre-push refuses non-fast-forward and delete pushes from every worktree, and scripts/slot-busy.sh exits 1 on BUSY (#1709, Vikunja #426, mos-1078/1083) | rocko; reviewers dewey | #1709 | briefed | — | Dewey approves the manifest; the new or changed scripts/test-*.sh and every other scripts/test-*.sh green on Sage's gate; the mutants killed; hook installed in the canonical checkout with a scripted refused force-push (sage) | `docs/plans/2026-10-20_force-push-guard.md` § Brief: a pre-push hook that refuses force-pushes, and a busy check that fails on BUSY (#426) | — |
|
||||
| 212 | Case 2 C2-01 packages/library: mapping, scope, reader, resolver, R-rules, renderItems, survey | gosalyn; reviewers webby | #1713 | in-progress | — | Webby approves the manifest. The library tests and the cli suite are green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-01 packages/library: mapping, scope, reader, resolver, R-rules, renderItems, survey | — |
|
||||
| 213 | Case 2 C2-08 creative roles, persona file and block, M1, M2, M6, M10, prompt passed by file | morgana; reviewers webby | #1714 | in-progress | — | Webby approves the manifest. The business, bus and harness suites, `scripts/test-config.sh` and every `scripts/test-*.sh` are green on Sage's gate, and the `models` mutant is killed. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-08 creative roles, persona file and block, M1, M2, M6, M10, prompt passed by file | — |
|
||||
| 214 | Case 2 C2-11a editor wikilink node, after measuring whether [[ round-trips | rocko; reviewers filbert | #1715 | briefed | — | Filbert approves the manifest. The webui suite is green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-11a editor wikilink node, after measuring whether [[ round-trips | — |
|
||||
| 215 | Case 2 C2-04 the library entry in the business file, consumer skips, M9, mirror, library.file and library.status | herb; reviewers filbert | #1716 | briefed | — | Filbert approves the manifest. The business, seat, cli, project, bus and webui suites are green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-04 the library entry in the business file, consumer skips, M9, mirror, library.file and library.status | — |
|
||||
| 216 | Case 2 C2-05a lookup index and queries | researcher; reviewers filbert | #1717 | briefed | — | Filbert approves the manifest. The library suite is green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-05a lookup index and queries | — |
|
||||
| 217 | Case 2 C2-06 the cited-reply guard, the page store and the three notices | launchpad; reviewers webby | #1718 | briefed | — | Webby approves the manifest. The library, bus and harness suites are green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-06 the cited-reply guard, the page store and the three notices | — |
|
||||
| 218 | Case 2 C2-12 the setup sheet for Melanie and the survey run sheet | sage; reviewers webby | #1719 | briefed | — | Doc only. Webby approves it against C2-01's real flags. Before landing, grep `packages/*/tests` and `scripts/test-*.sh` to confirm no test reads either file. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-12 the setup sheet for Melanie and the survey run sheet | — |
|
||||
| 219 | Case 2 C2-09a one thread per creative instance | rocko; reviewers dewey | #1720 | briefed | — | Dewey approves the manifest. The bus, cli and webui suites are green on Sage's gate, along with every `scripts/test-*.sh`. Watch for hash pins on `bus.js` and `broker.mjs` (`packages/bus/tests/channel-proof.test.mjs` pins a webui file). (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-09a one thread per creative instance | — |
|
||||
| 220 | Case 2 C2-10a no raw session view (M7) and no raw Discord engine (M8) | researcher; reviewers filbert | #1721 | briefed | — | Filbert approves the manifest. The conversation and discord suites and `scripts/test-discord.sh` are green on Sage's gate, along with every `scripts/test-*.sh`. No test reaches a Discord server. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-10a no raw session view (M7) and no raw Discord engine (M8) | — |
|
||||
| 221 | Case 2 C2-20 Bean and Chet persona import, with a count-only dry run | gosalyn; reviewers webby | #1722 | briefed | — | Webby approves the manifest. The test file runs green on its own and the cli suite is green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-20 Bean and Chet persona import, with a count-only dry run | — |
|
||||
| 222 | Case 2 C2-03a fetch-only sync | megavolt; reviewers dewey | #1723 | briefed | — | Dewey approves the manifest. The library and bus suites are green on Sage's gate, along with every `scripts/test-*.sh`, and the mutant is killed. A search of the test run's output for the fixture token finds nothing. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-03a fetch-only sync | — |
|
||||
| 223 | Case 2 C2-07 runner retry on a guard refusal, and the runner's notices | researcher; reviewers filbert | #1724 | briefed | — | Filbert approves the manifest. The harness suite is green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-07 runner retry on a guard refusal, and the runner's notices | — |
|
||||
| 224 | Case 2 C2-05b broker lookup verbs and typed tools | rocko; reviewers dewey | #1725 | briefed | — | Dewey approves the manifest. The bus and harness suites are green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-05b broker lookup verbs and typed tools | — |
|
||||
| 225 | Case 2 C2-10b Console chat: cited items and the status line | researcher; reviewers dewey | #1726 | briefed | — | Dewey approves the manifest. The webui suite is green on Sage's gate, along with every `scripts/test-*.sh`. Grep for hash pins on `bus.js` and `serve.mjs` before in-review. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-10b Console chat: cited items and the status line | — |
|
||||
| 226 | Case 2 C2-13 install script and precheck for the host | gosalyn; reviewers webby | #1727 | briefed | — | Webby approves the manifest. The test file and the cli suite are green on Sage's gate, along with every `scripts/test-*.sh`. The run on the host is a separate window with Jason's go. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-13b install script and library precheck | — |
|
||||
| 227 | Case 2 C2-14 end-to-end acceptance on a scratch root | launchpad; reviewers dewey | #1728 | briefed | — | Dewey runs the script and approves. It is green on Sage's gate with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-14 end-to-end acceptance on a scratch root | — |
|
||||
| 228 | Row A: ZAI_API_KEY passes through ENV_ALLOW to sessions, README line, pass-through test (XS, Haiku high) | researcher; reviewers webby | #1729 | in-review, round 1, request posted | — | Webby approves the manifest. The mutant is killed. The seat suite and the cli suite are green on Sage's gate with every scripts/test-*.sh. (sage) | `docs/plans/2026-10-20_case2-rows.md` § Brief: row A, ZAI_API_KEY in the session env allowlist | — |
|
||||
| 229 | Row B: per-instance pi agent dir for Chet's Codex login, design only, starts after 10-27 (#428) | gosalyn; reviewers webby | #1730 | queued | — | A live check, with Jason's or Mos's go, that two clients on one ChatGPT login don't revoke each other's refresh tokens. No build row starts before it passes. Held until after 10-27. (sage) | `docs/plans/2026-10-20_case2-rows.md` § Brief: row B, a per-instance pi agent dir for Chet's Codex login (#428) | — |
|
||||
| 230 | Case 2 C2-13a unit render and host precheck (case2-unit.sh, case2-precheck.sh) | gosalyn; reviewers webby | #1732 | briefed | — | Webby approves the manifest. The test file and the cli suite are green on Sage's gate, along with every scripts/test-*.sh. A run on a host is a separate window with Jason's go. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-13a unit render and host precheck | — |
|
||||
|
||||
<!-- mosaic-queue:end -->
|
||||
|
||||
|
||||
+3546
-16
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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
// The access file, <configDir>/access.json (federation design section 3.5,
|
||||
// FED R1, #414). It names the extra users who may sign in to this instance
|
||||
// and the grants that let them reach a business's threads. The validator is
|
||||
// pure: providers, peers, businesses and the owner's sign-in subjects come in
|
||||
// as arguments. Any failed rule refuses the whole file, which leaves every
|
||||
// user with no grant (fail closed).
|
||||
|
||||
import { join } from "node:path";
|
||||
import { refuse } from "./errors.mjs";
|
||||
import { configDir } from "./business.mjs";
|
||||
import { isPlainObject, requireObject, rejectUnknownKeys, requireId, requirePositiveInt, readJsonFile, deepFreeze } from "./util.mjs";
|
||||
|
||||
// The grant levels, most to least (row 193a, #1691): a viewer reads threads shared with it and drives
|
||||
// no instance. covers() relies on this order.
|
||||
export const LEVELS = Object.freeze(["collaborator", "viewer"]);
|
||||
// Ids a user may never take: the bus's own principals.
|
||||
const RESERVED_USERS = Object.freeze(["human", "tester"]);
|
||||
const LOCAL_HOME = "local";
|
||||
const ALL = "*";
|
||||
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||
const ISO_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,3})?Z$/;
|
||||
const SUB_MAX = 255;
|
||||
|
||||
export function accessFilePath(dir = configDir()) {
|
||||
return join(dir, "access.json");
|
||||
}
|
||||
|
||||
function requireArray(value, where) {
|
||||
if (!Array.isArray(value)) refuse(`${where} must be an array`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireText(value, where) {
|
||||
if (typeof value !== "string" || value.length === 0 || value.length > SUB_MAX || value.includes("\0")) {
|
||||
refuse(`${where} must be a non-empty string of at most ${SUB_MAX} characters`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function subjectKey(provider, sub) {
|
||||
return JSON.stringify([provider, sub]);
|
||||
}
|
||||
|
||||
function checkContext(context) {
|
||||
const where = "access context";
|
||||
requireObject(context, where);
|
||||
rejectUnknownKeys(context, ["providers", "peers", "businesses", "ssoSubjects"], where);
|
||||
const providers = new Set(requireArray(context.providers, `${where}.providers`).map((p, i) => requireText(p, `${where}.providers[${i}]`)));
|
||||
const peers = new Set(requireArray(context.peers, `${where}.peers`).map((p, i) => requireId(p, `${where}.peers[${i}]`)));
|
||||
if (peers.has(LOCAL_HOME)) refuse(`${where}.peers must not name ${JSON.stringify(LOCAL_HOME)}`);
|
||||
requireObject(context.businesses, `${where}.businesses`);
|
||||
const businesses = new Map();
|
||||
for (const [id, business] of Object.entries(context.businesses)) {
|
||||
const at = `${where}.businesses.${id}`;
|
||||
requireId(id, `${where}.businesses key`);
|
||||
requireObject(business, at);
|
||||
requireId(business.human, `${at}.human`);
|
||||
const instances = requireArray(business.instances, `${at}.instances`).map((x, i) => requireId(x, `${at}.instances[${i}]`));
|
||||
businesses.set(id, { human: business.human, instances: new Set(instances) });
|
||||
}
|
||||
const owners = new Set();
|
||||
requireArray(context.ssoSubjects, `${where}.ssoSubjects`).forEach((s, i) => {
|
||||
const at = `${where}.ssoSubjects[${i}]`;
|
||||
requireObject(s, at);
|
||||
owners.add(subjectKey(requireText(s.provider, `${at}.provider`), requireText(s.sub, `${at}.sub`)));
|
||||
});
|
||||
return { providers, peers, businesses, owners };
|
||||
}
|
||||
|
||||
function checkUser(user, at, ctx, humans, seenSubjects) {
|
||||
requireObject(user, at);
|
||||
rejectUnknownKeys(user, ["id", "home", "subjects"], at);
|
||||
requireId(user.id, `${at}.id`);
|
||||
if (RESERVED_USERS.includes(user.id)) refuse(`${at}.id must not be ${JSON.stringify(user.id)}, a bus principal`);
|
||||
if (humans.has(user.id)) refuse(`${at}.id must not be a business's human (${JSON.stringify(user.id)})`);
|
||||
if (user.home !== LOCAL_HOME && !ctx.peers.has(user.home)) {
|
||||
refuse(`${at}.home must be a configured peer or ${JSON.stringify(LOCAL_HOME)} (got ${JSON.stringify(user.home)})`);
|
||||
}
|
||||
const subjects = requireArray(user.subjects, `${at}.subjects`);
|
||||
if (subjects.length === 0) refuse(`${at}.subjects must not be empty`);
|
||||
subjects.forEach((s, i) => {
|
||||
const sat = `${at}.subjects[${i}]`;
|
||||
requireObject(s, sat);
|
||||
rejectUnknownKeys(s, ["provider", "sub"], sat);
|
||||
if (typeof s.provider !== "string" || !ctx.providers.has(s.provider)) {
|
||||
refuse(`${sat}.provider must be a configured provider (got ${JSON.stringify(s.provider)})`);
|
||||
}
|
||||
requireText(s.sub, `${sat}.sub`);
|
||||
const key = subjectKey(s.provider, s.sub);
|
||||
if (ctx.owners.has(key)) refuse(`${sat} is an owner sign-in subject (console.sso.subjects)`);
|
||||
if (seenSubjects.has(key)) refuse(`${sat} appears more than once in the file`);
|
||||
seenSubjects.add(key);
|
||||
});
|
||||
}
|
||||
|
||||
// Either ["*"] alone or a non-empty list of distinct entries, each checked.
|
||||
function checkScope(value, where, check) {
|
||||
const list = requireArray(value, where);
|
||||
if (list.length === 0) refuse(`${where} must not be empty`);
|
||||
if (list.includes(ALL)) {
|
||||
if (list.length !== 1) refuse(`${where} must be ["*"] alone or list entries without "*"`);
|
||||
return;
|
||||
}
|
||||
const seen = new Set();
|
||||
list.forEach((entry, i) => {
|
||||
check(entry, `${where}[${i}]`);
|
||||
if (seen.has(entry)) refuse(`duplicate entry in ${where}: ${JSON.stringify(entry)}`);
|
||||
seen.add(entry);
|
||||
});
|
||||
}
|
||||
|
||||
function checkExpires(value, where) {
|
||||
if (typeof value !== "string" || !ISO_TIME.test(value) || Number.isNaN(Date.parse(value))) {
|
||||
refuse(`${where} must be an ISO time such as 2026-12-31T00:00:00Z (got ${JSON.stringify(value)})`);
|
||||
}
|
||||
const iso = new Date(value).toISOString();
|
||||
if (iso.slice(0, 19) !== value.slice(0, 19)) refuse(`${where} is not a real time: ${value}`);
|
||||
}
|
||||
|
||||
function checkGrant(grant, at, ctx, users, seenGrants) {
|
||||
requireObject(grant, at);
|
||||
rejectUnknownKeys(grant, ["id", "user", "business", "projects", "instances", "level", "expires"], at);
|
||||
requireId(grant.id, `${at}.id`);
|
||||
if (seenGrants.has(grant.id)) refuse(`${at}.id is a duplicate grant id: ${grant.id}`);
|
||||
seenGrants.add(grant.id);
|
||||
if (typeof grant.user !== "string" || !users.has(grant.user)) refuse(`${at}.user must name a user in this file (got ${JSON.stringify(grant.user)})`);
|
||||
if (typeof grant.business !== "string" || !ctx.businesses.has(grant.business)) {
|
||||
refuse(`${at}.business must name a declared business (got ${JSON.stringify(grant.business)})`);
|
||||
}
|
||||
const business = ctx.businesses.get(grant.business);
|
||||
checkScope(grant.projects, `${at}.projects`, (p, where) => {
|
||||
if (typeof p !== "string" || !UUID.test(p)) refuse(`${where} must be "*" or a bus project id, a lowercase UUID (got ${JSON.stringify(p)})`);
|
||||
});
|
||||
checkScope(grant.instances, `${at}.instances`, (x, where) => {
|
||||
if (typeof x !== "string" || !business.instances.has(x)) refuse(`${where} must be "*" or a declared instance of ${grant.business} (got ${JSON.stringify(x)})`);
|
||||
});
|
||||
if (!LEVELS.includes(grant.level)) refuse(`${at}.level must be one of ${LEVELS.join(", ")} (got ${JSON.stringify(grant.level)})`);
|
||||
if (grant.expires !== undefined) checkExpires(grant.expires, `${at}.expires`);
|
||||
}
|
||||
|
||||
// Check a parsed access document against the instance's context. Returns the
|
||||
// document frozen. Every refusal is a BusinessError naming the key path. An
|
||||
// expired grant is not refused: covers() treats it as absent.
|
||||
export function checkAccess(document, context) {
|
||||
const ctx = checkContext(context);
|
||||
const humans = new Set([...ctx.businesses.values()].map((b) => b.human));
|
||||
if (!isPlainObject(document)) refuse("access file must be a JSON object");
|
||||
rejectUnknownKeys(document, ["accessVersion", "revision", "users", "grants"], "access file");
|
||||
if (document.accessVersion !== 1) refuse(`accessVersion must be 1 (got ${JSON.stringify(document.accessVersion)})`);
|
||||
requirePositiveInt(document.revision, "revision");
|
||||
const users = new Set();
|
||||
const seenSubjects = new Set();
|
||||
requireArray(document.users, "users").forEach((user, i) => {
|
||||
checkUser(user, `users[${i}]`, ctx, humans, seenSubjects);
|
||||
if (users.has(user.id)) refuse(`users[${i}].id is a duplicate user id: ${user.id}`);
|
||||
users.add(user.id);
|
||||
});
|
||||
const seenGrants = new Set();
|
||||
requireArray(document.grants, "grants").forEach((grant, i) => checkGrant(grant, `grants[${i}]`, ctx, users, seenGrants));
|
||||
return deepFreeze(structuredClone(document));
|
||||
}
|
||||
|
||||
// Read and check the access file. One descriptor, opened without following a
|
||||
// link, serves the stat checks and the read (readJsonFile). The file holds
|
||||
// authority, so it must belong to this uid and not be writable by group or
|
||||
// other, as the business file is.
|
||||
export function loadAccess(file, context, { uid = process.getuid() } = {}) {
|
||||
const document = readJsonFile(file, "access file", (stat) => {
|
||||
if (stat.uid !== uid) refuse(`access file must belong to uid ${uid}: ${file}`);
|
||||
if ((stat.mode & 0o022) !== 0) refuse(`access file must not be writable by group or other (mode ${(stat.mode & 0o777).toString(8)}): ${file}`);
|
||||
});
|
||||
return checkAccess(document, context);
|
||||
}
|
||||
|
||||
// Does a checked grant cover a thread? `target` is the thread's business, bus
|
||||
// project id and instance. `level` is required (row 193a, Duckworth's N1 on R1):
|
||||
// it is the least level the action needs (LEVELS runs from most to least), so `viewer` asks for
|
||||
// read and any grant covers it, while `collaborator` asks to drive the instance
|
||||
// and a viewer grant never covers it. An expired grant covers nothing.
|
||||
export function covers(grant, { business, project, instance }, now, { level } = {}) {
|
||||
if (!(now instanceof Date) || Number.isNaN(now.getTime())) throw new TypeError("covers needs a valid Date for now");
|
||||
if (!LEVELS.includes(level)) throw new TypeError(`covers needs a level, one of ${LEVELS.join(", ")} (got ${JSON.stringify(level)})`);
|
||||
if (grant.expires !== undefined && now.getTime() >= Date.parse(grant.expires)) return false;
|
||||
if (grant.business !== business) return false;
|
||||
if (!(grant.projects[0] === ALL || grant.projects.includes(project))) return false;
|
||||
if (!(grant.instances[0] === ALL || grant.instances.includes(instance))) return false;
|
||||
// A grant whose own level is unknown covers nothing, even if it skipped checkAccess.
|
||||
if (!LEVELS.includes(grant.level) || LEVELS.indexOf(grant.level) > LEVELS.indexOf(level)) return false;
|
||||
return true;
|
||||
}
|
||||
@@ -10,5 +10,6 @@ export { validateRoleDocument, loadRoleFile, loadRole } from "./role.mjs";
|
||||
export { LAYERS, REGISTRY, THINKING, FALLBACK_VARS, checkVars, mergeVars } from "./vars.mjs";
|
||||
export { parseCredentialRef, checkCredentialRef } from "./credentials.mjs";
|
||||
export { configDir, businessFilePath, validateBusinessDocument, loadBusiness } from "./business.mjs";
|
||||
export { LEVELS, accessFilePath, checkAccess, loadAccess, covers } from "./access.mjs";
|
||||
export { projectFilePath, validateProjectDocument, loadProject } from "./project.mjs";
|
||||
export { systemVars, resolveInstance, classify } from "./resolve.mjs";
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { BusinessError } from "../src/errors.mjs";
|
||||
import * as access from "../src/access.mjs";
|
||||
|
||||
// Row 193a (#1691): the viewer level. R1 (federation design r4.5, section 7) owns access.mjs and its
|
||||
// tests; this file pins only what row 193 adds: a grant's level is collaborator or viewer, and covers()
|
||||
// tells the two apart with a required least level. Every call into R1 goes through check() below, so a change to R1's loader
|
||||
// interface edits one place.
|
||||
const CONTEXT = {
|
||||
providers: ["authentik"],
|
||||
peers: ["factory"],
|
||||
businesses: { "creative-hub": { human: "melanie", instances: ["researcher", "writer"] } },
|
||||
ssoSubjects: [],
|
||||
};
|
||||
const check = (doc) => access.checkAccess(doc, CONTEXT);
|
||||
|
||||
const PROJECT = "0b6f4a52-6f1e-4c7a-9a0e-3f1d2c4b5a69";
|
||||
const NOW = new Date("2026-10-20T19:30:00Z");
|
||||
|
||||
function file(level) {
|
||||
const grant = { id: "g-1", user: "jason", business: "creative-hub", projects: ["*"], instances: ["researcher"] };
|
||||
if (level !== undefined) grant.level = level;
|
||||
return {
|
||||
accessVersion: 1,
|
||||
revision: 1,
|
||||
users: [{ id: "jason", home: "local", subjects: [{ provider: "authentik", sub: "s-1" }] }],
|
||||
grants: [grant],
|
||||
};
|
||||
}
|
||||
|
||||
function refuses(fn, pattern) {
|
||||
assert.throws(fn, (error) => {
|
||||
assert.ok(error instanceof BusinessError, `expected BusinessError, got ${error}`);
|
||||
assert.equal(error.exitCode, 2);
|
||||
assert.match(error.message, pattern);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
test("a grant's level is collaborator or viewer", () => {
|
||||
for (const level of ["collaborator", "viewer"]) {
|
||||
assert.doesNotThrow(() => check(file(level)), level);
|
||||
}
|
||||
});
|
||||
|
||||
test("any other level refuses with the key path named", () => {
|
||||
for (const level of ["owner", "admin", "member", "Viewer", "viewer ", "", null, 1, ["viewer"], undefined]) {
|
||||
refuses(() => check(file(level)), /grants\[0\]\.level/);
|
||||
}
|
||||
});
|
||||
|
||||
test("covers tells a viewer grant from a collaborator grant", () => {
|
||||
const target = { business: "creative-hub", project: PROJECT, instance: "researcher" };
|
||||
const viewer = file("viewer").grants[0];
|
||||
const collaborator = file("collaborator").grants[0];
|
||||
// The level is the least one the action needs: share reads ask viewer; create, open, send and share ask
|
||||
// collaborator (design 9).
|
||||
assert.equal(access.covers(viewer, target, NOW, { level: "viewer" }), true);
|
||||
assert.equal(access.covers(viewer, target, NOW, { level: "collaborator" }), false);
|
||||
assert.equal(access.covers(collaborator, target, NOW, { level: "viewer" }), true);
|
||||
assert.equal(access.covers(collaborator, target, NOW, { level: "collaborator" }), true);
|
||||
});
|
||||
|
||||
test("covers refuses a call with no level, so a viewer grant can't slip past a collaborator check", () => {
|
||||
const target = { business: "creative-hub", project: PROJECT, instance: "researcher" };
|
||||
const viewer = file("viewer").grants[0];
|
||||
assert.throws(() => access.covers(viewer, target, NOW), TypeError);
|
||||
assert.throws(() => access.covers(viewer, target, NOW, {}), TypeError);
|
||||
});
|
||||
|
||||
test("a viewer grant still needs its business, project, instance and expiry to cover", () => {
|
||||
const viewer = { ...file("viewer").grants[0], projects: [PROJECT], expires: "2026-10-21T00:00:00Z" };
|
||||
const target = { business: "creative-hub", project: PROJECT, instance: "researcher" };
|
||||
const read = { level: "viewer" };
|
||||
assert.equal(access.covers(viewer, target, NOW, read), true);
|
||||
assert.equal(access.covers(viewer, { ...target, business: "other" }, NOW, read), false);
|
||||
assert.equal(access.covers(viewer, { ...target, project: "7c1e2d3f-0a9b-4c8d-8e7f-6a5b4c3d2e1f" }, NOW, read), false);
|
||||
assert.equal(access.covers(viewer, { ...target, instance: "writer" }, NOW, read), false);
|
||||
assert.equal(access.covers(viewer, target, new Date("2026-10-21T00:00:01Z"), read), false);
|
||||
});
|
||||
@@ -0,0 +1,300 @@
|
||||
// FED R1 (#414, row 201): the access file loader and validator, DESIGN.md
|
||||
// section 3.5, and covers(). Every test works in a scratch directory.
|
||||
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { chmodSync, mkdirSync, symlinkSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { BusinessError } from "../src/errors.mjs";
|
||||
import { LEVELS, accessFilePath, checkAccess, loadAccess, covers } from "../src/access.mjs";
|
||||
import { tmp, writeJson } from "./helpers.mjs";
|
||||
|
||||
const P1 = "0f8b6c1e-2a3d-4e5f-8a9b-0c1d2e3f4a5b";
|
||||
const P2 = "11111111-2222-4333-8444-555555555555";
|
||||
|
||||
function context(over = {}) {
|
||||
return {
|
||||
providers: ["authentik"],
|
||||
peers: ["factory"],
|
||||
businesses: {
|
||||
"creative-hub": { human: "melanie", instances: ["researcher", "writer"] },
|
||||
acme: { human: "jason", instances: ["coder"] },
|
||||
},
|
||||
ssoSubjects: [{ provider: "authentik", sub: "owner-sub" }],
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function doc() {
|
||||
return {
|
||||
accessVersion: 1,
|
||||
revision: 3,
|
||||
users: [
|
||||
{ id: "bean", home: "factory", subjects: [{ provider: "authentik", sub: "s-1" }] },
|
||||
{ id: "chet", home: "local", subjects: [{ provider: "authentik", sub: "s-2" }] },
|
||||
],
|
||||
grants: [
|
||||
{ id: "g-1", user: "bean", business: "creative-hub", projects: ["*"], instances: ["researcher"], level: "collaborator", expires: "2026-12-31T00:00:00Z" },
|
||||
{ id: "g-2", user: "chet", business: "creative-hub", projects: [P1], instances: ["*"], level: "collaborator" },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// Refuses with a BusinessError, exit 2 unless given, and a message matching `pattern`.
|
||||
function refuses(fn, pattern, exitCode = 2) {
|
||||
assert.throws(fn, (error) => {
|
||||
assert.ok(error instanceof BusinessError, `not a BusinessError: ${error}`);
|
||||
assert.equal(error.exitCode, exitCode);
|
||||
assert.match(error.message, pattern);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function mutated(change) {
|
||||
const d = doc();
|
||||
change(d);
|
||||
return d;
|
||||
}
|
||||
|
||||
test("a valid document passes and comes back frozen", () => {
|
||||
const checked = checkAccess(doc(), context());
|
||||
assert.deepEqual(checked, doc());
|
||||
assert.ok(Object.isFrozen(checked));
|
||||
assert.ok(Object.isFrozen(checked.grants[0].projects));
|
||||
});
|
||||
|
||||
test("checkAccess does not freeze or alias the caller's document", () => {
|
||||
const d = doc();
|
||||
const checked = checkAccess(d, context());
|
||||
assert.ok(!Object.isFrozen(d));
|
||||
assert.notEqual(checked.grants, d.grants);
|
||||
});
|
||||
|
||||
test("LEVELS is the single allowed level set, collaborator and viewer (row 193a)", () => {
|
||||
assert.deepEqual([...LEVELS], ["collaborator", "viewer"]);
|
||||
assert.ok(Object.isFrozen(LEVELS));
|
||||
});
|
||||
|
||||
test("accessFilePath is access.json in the config directory", () => {
|
||||
assert.equal(accessFilePath("/x/y"), join("/x/y", "access.json"));
|
||||
const dir = tmp();
|
||||
const saved = process.env.MOSAIC_CONFIG;
|
||||
process.env.MOSAIC_CONFIG = join(dir, "config.json");
|
||||
try {
|
||||
assert.equal(accessFilePath(), join(dir, "access.json"));
|
||||
} finally {
|
||||
if (saved === undefined) delete process.env.MOSAIC_CONFIG;
|
||||
else process.env.MOSAIC_CONFIG = saved;
|
||||
}
|
||||
});
|
||||
|
||||
test("top level: object, unknown keys, accessVersion, revision, arrays", () => {
|
||||
refuses(() => checkAccess([], context()), /access file must be a JSON object/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.extra = 1; }), context()), /unsupported access file key: "extra"/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.accessVersion = 2; }), context()), /accessVersion must be 1/);
|
||||
refuses(() => checkAccess(mutated((d) => { delete d.accessVersion; }), context()), /accessVersion must be 1/);
|
||||
for (const bad of [0, -1, 1.5, "3", undefined]) {
|
||||
refuses(() => checkAccess(mutated((d) => { d.revision = bad; }), context()), /revision must be a positive integer/);
|
||||
}
|
||||
refuses(() => checkAccess(mutated((d) => { d.users = {}; }), context()), /^users must be an array/);
|
||||
refuses(() => checkAccess(mutated((d) => { delete d.grants; }), context()), /^grants must be an array/);
|
||||
});
|
||||
|
||||
test("users: unknown keys and the id rule", () => {
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[1].role = "x"; }), context()), /unsupported users\[1\] key: "role"/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[0] = "bean"; }), context()), /users\[0\] must be a JSON object/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[0].id = "Bean"; }), context()), /users\[0\]\.id must match/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[1].id = "bean"; }), context()), /users\[1\]\.id is a duplicate user id: bean/);
|
||||
});
|
||||
|
||||
test("users: human, tester and any business's human are refused", () => {
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[0].id = "human"; d.grants = []; }), context()), /users\[0\]\.id must not be "human"/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[1].id = "tester"; d.grants = []; }), context()), /users\[1\]\.id must not be "tester"/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[0].id = "melanie"; d.grants = []; }), context()), /users\[0\]\.id must not be a business's human \("melanie"\)/);
|
||||
// The human of a business the grant doesn't name is refused too.
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[1].id = "jason"; d.grants = []; }), context()), /users\[1\]\.id must not be a business's human \("jason"\)/);
|
||||
});
|
||||
|
||||
test("users: home is a given peer or local", () => {
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[0].home = "elsewhere"; }), context()), /users\[0\]\.home must be a configured peer or "local" \(got "elsewhere"\)/);
|
||||
refuses(() => checkAccess(mutated((d) => { delete d.users[1].home; }), context()), /users\[1\]\.home must be a configured peer/);
|
||||
refuses(() => checkAccess(doc(), context({ peers: [] })), /users\[0\]\.home must be a configured peer/);
|
||||
});
|
||||
|
||||
test("users: subjects, provider, sub, uniqueness and owner subjects", () => {
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[0].subjects = []; }), context()), /users\[0\]\.subjects must not be empty/);
|
||||
refuses(() => checkAccess(mutated((d) => { delete d.users[0].subjects; }), context()), /users\[0\]\.subjects must be an array/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[0].subjects[0].extra = 1; }), context()), /unsupported users\[0\]\.subjects\[0\] key: "extra"/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[1].subjects[0].provider = "google"; }), context()), /users\[1\]\.subjects\[0\]\.provider must be a configured provider \(got "google"\)/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[0].subjects[0].sub = ""; }), context()), /users\[0\]\.subjects\[0\]\.sub must be a non-empty string/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[0].subjects[0].sub = 7; }), context()), /users\[0\]\.subjects\[0\]\.sub must be a non-empty string/);
|
||||
// A pair twice in one user, and across two users.
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[0].subjects.push({ provider: "authentik", sub: "s-1" }); }), context()), /users\[0\]\.subjects\[1\] appears more than once/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[1].subjects[0].sub = "s-1"; }), context()), /users\[1\]\.subjects\[0\] appears more than once/);
|
||||
// The owner's own sign-in subject can't be an extra user.
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[1].subjects[0].sub = "owner-sub"; }), context()), /users\[1\]\.subjects\[0\] is an owner sign-in subject/);
|
||||
// The same sub under another provider is a different pair.
|
||||
const twoProviders = context({ providers: ["authentik", "google"] });
|
||||
assert.ok(checkAccess(mutated((d) => { d.users[1].subjects[0] = { provider: "google", sub: "owner-sub" }; }), twoProviders));
|
||||
});
|
||||
|
||||
test("grants: unknown keys, id, user and business", () => {
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[0].role = "x"; }), context()), /unsupported grants\[0\] key: "role"/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[1].id = "g-1"; }), context()), /grants\[1\]\.id is a duplicate grant id: g-1/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[0].id = "G 1"; }), context()), /grants\[0\]\.id must match/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[0].user = "nobody"; }), context()), /grants\[0\]\.user must name a user in this file \(got "nobody"\)/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[1].business = "other"; }), context()), /grants\[1\]\.business must name a declared business \(got "other"\)/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[1].business = "constructor"; }), context()), /grants\[1\]\.business must name a declared business/);
|
||||
});
|
||||
|
||||
test("grants: projects is [\"*\"] or distinct lowercase UUIDs", () => {
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = ["stack"]; }), context()), /grants\[1\]\.projects\[0\] must be "\*" or a bus project id, a lowercase UUID \(got "stack"\)/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = [P1.toUpperCase()]; }), context()), /grants\[1\]\.projects\[0\] must be "\*" or a bus project id/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = [P1, 7]; }), context()), /grants\[1\]\.projects\[1\] must be "\*" or a bus project id/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = ["*", P1]; }), context()), /grants\[1\]\.projects must be \["\*"\] alone/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = [P1, "*"]; }), context()), /grants\[1\]\.projects must be \["\*"\] alone/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = ["*", "*"]; }), context()), /grants\[1\]\.projects must be \["\*"\] alone/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = [P1, P1]; }), context()), /duplicate entry in grants\[1\]\.projects/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = []; }), context()), /grants\[1\]\.projects must not be empty/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = "*"; }), context()), /grants\[1\]\.projects must be an array/);
|
||||
assert.ok(checkAccess(mutated((d) => { d.grants[1].projects = [P1, P2]; }), context()));
|
||||
});
|
||||
|
||||
test("grants: instances is [\"*\"] or distinct declared instances of that business", () => {
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[0].instances = ["coder"]; }), context()), /grants\[0\]\.instances\[0\] must be "\*" or a declared instance of creative-hub \(got "coder"\)/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[0].instances = ["*", "writer"]; }), context()), /grants\[0\]\.instances must be \["\*"\] alone/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[0].instances = ["writer", "writer"]; }), context()), /duplicate entry in grants\[0\]\.instances/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[0].instances = []; }), context()), /grants\[0\]\.instances must not be empty/);
|
||||
assert.ok(checkAccess(mutated((d) => { d.grants[0].instances = ["researcher", "writer"]; }), context()));
|
||||
});
|
||||
|
||||
test("grants: any level other than collaborator or viewer refuses, naming grants[i].level", () => {
|
||||
for (const bad of ["owner", "Collaborator", "COLLABORATOR", "Viewer", "", null, 1]) {
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[1].level = bad; }), context()), /grants\[1\]\.level must be one of collaborator, viewer/);
|
||||
}
|
||||
refuses(() => checkAccess(mutated((d) => { delete d.grants[0].level; }), context()), /grants\[0\]\.level must be one of collaborator, viewer \(got undefined\)/);
|
||||
});
|
||||
|
||||
test("grants: expires is optional ISO time, and a past expiry is not refused", () => {
|
||||
for (const bad of ["2026-12-31", "2026-12-31 00:00:00Z", "2026-12-31T00:00:00+01:00", "tomorrow", 1798675200000, null]) {
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[0].expires = bad; }), context()), /grants\[0\]\.expires must be an ISO time/);
|
||||
}
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[0].expires = "2026-02-30T00:00:00Z"; }), context()), /grants\[0\]\.expires is not a real time/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[0].expires = "2026-13-01T00:00:00Z"; }), context()), /grants\[0\]\.expires must be an ISO time/);
|
||||
assert.ok(checkAccess(mutated((d) => { d.grants[0].expires = "2026-12-31T00:00:00.500Z"; }), context()));
|
||||
// DESIGN 3.5: a grant past expires is treated as absent, not refused (sage-1399).
|
||||
assert.ok(checkAccess(mutated((d) => { d.grants[0].expires = "2001-01-01T00:00:00Z"; }), context()));
|
||||
});
|
||||
|
||||
test("the context is required and checked", () => {
|
||||
refuses(() => checkAccess(doc()), /access context must be a JSON object/);
|
||||
for (const key of ["providers", "peers", "ssoSubjects"]) {
|
||||
const c = context();
|
||||
delete c[key];
|
||||
refuses(() => checkAccess(doc(), c), new RegExp(`access context\\.${key} must be an array`));
|
||||
}
|
||||
refuses(() => checkAccess(doc(), context({ businesses: [] })), /access context\.businesses must be a JSON object/);
|
||||
refuses(() => checkAccess(doc(), context({ extra: 1 })), /unsupported access context key: "extra"/);
|
||||
refuses(() => checkAccess(doc(), context({ peers: ["local"] })), /access context\.peers must not name "local"/);
|
||||
refuses(() => checkAccess(doc(), context({ ssoSubjects: [{ provider: "authentik" }] })), /access context\.ssoSubjects\[0\]\.sub must be a non-empty string/);
|
||||
refuses(() => checkAccess(doc(), context({ businesses: { acme: { human: "jason" } } })), /access context\.businesses\.acme\.instances must be an array/);
|
||||
});
|
||||
|
||||
// loadAccess: the file checks, then checkAccess.
|
||||
|
||||
test("loadAccess reads a valid file and checks it", () => {
|
||||
const file = writeJson(join(tmp(), "access.json"), doc());
|
||||
assert.deepEqual(loadAccess(file, context()), doc());
|
||||
});
|
||||
|
||||
test("loadAccess refuses a missing file, a symlink and a directory with exit 4", () => {
|
||||
const dir = tmp();
|
||||
refuses(() => loadAccess(join(dir, "access.json"), context()), /access file not found/, 4);
|
||||
const real = writeJson(join(dir, "real.json"), doc());
|
||||
const link = join(dir, "access.json");
|
||||
symlinkSync(real, link);
|
||||
refuses(() => loadAccess(link, context()), /access file must be a regular, non-symbolic-link file/, 4);
|
||||
const sub = join(dir, "d");
|
||||
mkdirSync(sub);
|
||||
refuses(() => loadAccess(sub, context()), /access file must be a regular, non-symbolic-link file/, 4);
|
||||
});
|
||||
|
||||
test("loadAccess refuses another owner and group or other write", () => {
|
||||
const file = writeJson(join(tmp(), "access.json"), doc());
|
||||
refuses(() => loadAccess(file, context(), { uid: process.getuid() + 1 }), /access file must belong to uid/);
|
||||
for (const mode of [0o620, 0o602, 0o666]) {
|
||||
chmodSync(file, mode);
|
||||
refuses(() => loadAccess(file, context()), /access file must not be writable by group or other/);
|
||||
}
|
||||
chmodSync(file, 0o644);
|
||||
assert.ok(loadAccess(file, context()));
|
||||
});
|
||||
|
||||
test("loadAccess refuses bad JSON and a bad document with exit 2", () => {
|
||||
const dir = tmp();
|
||||
refuses(() => loadAccess(writeJson(join(dir, "a.json"), "{"), context()), /access file is not valid JSON/);
|
||||
refuses(() => loadAccess(writeJson(join(dir, "b.json"), mutated((d) => { d.grants[0].level = "owner"; })), context()), /grants\[0\]\.level/);
|
||||
});
|
||||
|
||||
// covers.
|
||||
|
||||
const NOW = new Date("2026-10-20T12:00:00Z");
|
||||
const target = (over = {}) => ({ business: "creative-hub", project: P1, instance: "researcher", ...over });
|
||||
// Any level reads: the least level is viewer (row 193a).
|
||||
const READ = { level: "viewer" };
|
||||
|
||||
test("covers: * and listed ids match", () => {
|
||||
const [g1, g2] = checkAccess(doc(), context()).grants;
|
||||
assert.equal(covers(g1, target({ project: P2 }), NOW, READ), true);
|
||||
assert.equal(covers(g2, target({ instance: "writer" }), NOW, READ), true);
|
||||
assert.equal(covers(g2, target(), NOW, READ), true);
|
||||
});
|
||||
|
||||
test("covers: an unlisted project, instance or business doesn't match", () => {
|
||||
const [g1, g2] = checkAccess(doc(), context()).grants;
|
||||
assert.equal(covers(g2, target({ project: P2 }), NOW, READ), false);
|
||||
assert.equal(covers(g1, target({ instance: "writer" }), NOW, READ), false);
|
||||
assert.equal(covers(g2, target({ business: "acme" }), NOW, READ), false);
|
||||
assert.equal(covers(g1, target({ business: "acme" }), NOW, READ), false);
|
||||
});
|
||||
|
||||
test("covers: an expired grant doesn't match, at and after the instant", () => {
|
||||
const [g1, g2] = checkAccess(doc(), context()).grants;
|
||||
assert.equal(covers(g1, target(), new Date("2026-12-30T23:59:59.999Z"), READ), true);
|
||||
assert.equal(covers(g1, target(), new Date("2026-12-31T00:00:00Z"), READ), false);
|
||||
assert.equal(covers(g1, target(), new Date("2027-01-01T00:00:00Z"), READ), false);
|
||||
// No expires never expires.
|
||||
assert.equal(covers(g2, target(), new Date("2099-01-01T00:00:00Z"), READ), true);
|
||||
});
|
||||
|
||||
test("covers: level is the least level asked for (row 193a)", () => {
|
||||
const g = checkAccess(doc(), context()).grants[0];
|
||||
const v = { ...g, level: "viewer" };
|
||||
assert.equal(covers(g, target(), NOW, { level: "collaborator" }), true);
|
||||
assert.equal(covers(g, target(), NOW, READ), true);
|
||||
assert.equal(covers(v, target(), NOW, READ), true);
|
||||
assert.equal(covers(v, target(), NOW, { level: "collaborator" }), false);
|
||||
});
|
||||
|
||||
test("covers: a missing or unknown level throws (row 193a, N1)", () => {
|
||||
const v = { ...checkAccess(doc(), context()).grants[0], level: "viewer" };
|
||||
assert.throws(() => covers(v, target(), NOW), TypeError);
|
||||
assert.throws(() => covers(v, target(), NOW, {}), TypeError);
|
||||
assert.throws(() => covers(v, target(), NOW, { level: undefined }), TypeError);
|
||||
for (const bad of ["owner", "Viewer", "", null, 1]) {
|
||||
assert.throws(() => covers(v, target(), NOW, { level: bad }), /covers needs a level, one of collaborator, viewer/);
|
||||
}
|
||||
});
|
||||
|
||||
test("covers: a grant with an unknown level covers nothing (row 193a)", () => {
|
||||
const g = checkAccess(doc(), context()).grants[0];
|
||||
for (const bad of ["owner", "Collaborator", undefined]) {
|
||||
assert.equal(covers({ ...g, level: bad }, target(), NOW, READ), false);
|
||||
}
|
||||
});
|
||||
|
||||
test("covers: now must be a valid Date", () => {
|
||||
const g = checkAccess(doc(), context()).grants[0];
|
||||
assert.throws(() => covers(g, target(), Date.now(), READ), TypeError);
|
||||
assert.throws(() => covers(g, target(), new Date("nope"), READ), TypeError);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -12,6 +12,7 @@ import {tmpdir} from 'node:os';
|
||||
import {join,dirname,basename} from 'node:path';
|
||||
import {fileURLToPath,pathToFileURL} from 'node:url';
|
||||
import {once} from 'node:events';
|
||||
import {ALGS} from '../../webui/src/sso-config.mjs';
|
||||
|
||||
const ROOT=fileURLToPath(new URL('../../..',import.meta.url)).replace(/\/$/,'');
|
||||
const NODE=process.execPath;
|
||||
@@ -25,7 +26,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 +39,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 +57,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 +117,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 +148,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 +189,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 +203,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 +211,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)}
|
||||
}
|
||||
@@ -260,9 +275,17 @@ const storeText=(v,froms,to=true)=>`export const SCHEMA_VERSION = '${v}';\nexpor
|
||||
const KINDS_NEW=readFileSync(join(repo,'packages','plugins','src','kinds.mjs'),'utf8');
|
||||
const KINDS_OLD=KINDS_NEW.replace('\n harness: Object.freeze({','\n "harness-before-c216f47db": Object.freeze({');
|
||||
assert.notEqual(KINDS_OLD,KINDS_NEW);
|
||||
// Row 200 (#1698): C1 and C take --sso and have the row 192 loader and checkConsole; C's checker
|
||||
// refuses RS256, a stand-in for a target whose rules differ from the repository's. B has the loader
|
||||
// from before row 192, which refuses a `console` key. The other copies have no loader.
|
||||
const LOADER=readFileSync(join(repo,'scripts','mosaic-config.mjs'),'utf8'),SSO_CONFIG=readFileSync(join(repo,'packages','webui','src','sso-config.mjs'),'utf8');
|
||||
const LOADER_OLD=LOADER.replace('["configVersion", "environment", "dataRoot", "execution", "console"]','["configVersion", "environment", "dataRoot", "execution"]');
|
||||
const SSO_CONFIG_C=SSO_CONFIG.replace("'ES256', 'RS256',","'ES256',");
|
||||
assert.notEqual(LOADER_OLD,LOADER);assert.notEqual(SSO_CONFIG_C,SSO_CONFIG);
|
||||
const ssoFiles=(id)=>id===B?[['scripts/mosaic-config.mjs',LOADER_OLD]]:[C1,C].includes(id)?[['scripts/mosaic-config.mjs',LOADER],['packages/webui/src/sso-config.mjs',id===C?SSO_CONFIG_C:SSO_CONFIG]]:[];
|
||||
for(const [id,store] of [[F,'// a copy from before SCHEMA_VERSION\n'],[A,storeText('3g',['3f'],'3x')],[B,storeText('3g',['3f'])],[C1,storeText('3g',['3f'],false)],[C,storeText('3h',['3f','3g'])],[S,storeText('3g',['3f'])],[U,storeText('3g',['3f'])]]){
|
||||
const d=join(COPIES,id);
|
||||
for(const [f,text,mode] of [['scripts/mosaic',script(mosaicStub),0o755],['packages/webui/src/cli.mjs',[B,C1,C].includes(id)?"// fixture Console, never run\n// serve accepts '--tester-port'\n// serve accepts '--proof-header' (row 178)\n":'// fixture Console before tester listener, never run\n',0o644],['packages/bus/src/store.mjs',store,0o644],['packages/bus/src/migrate-schema.mjs',`(${migrateStub})();\n`,0o644],['packages/plugins/src/kinds.mjs',[C1,C].includes(id)?KINDS_NEW:KINDS_OLD,0o644]]){
|
||||
for(const [f,text,mode] of [['scripts/mosaic',script(mosaicStub),0o755],['packages/webui/src/cli.mjs',[B,C1,C].includes(id)?`// fixture Console, never run\n// serve accepts '--tester-port'\n// serve accepts '--proof-header' (row 178)\n${[C1,C].includes(id)?"// serve accepts '--sso' (row 192)\n":''}`:'// fixture Console before tester listener, never run\n',0o644],['packages/bus/src/store.mjs',store,0o644],['packages/bus/src/migrate-schema.mjs',`(${migrateStub})();\n`,0o644],['packages/plugins/src/kinds.mjs',[C1,C].includes(id)?KINDS_NEW:KINDS_OLD,0o644],...ssoFiles(id).map(([f,text])=>[f,text,0o644])]){
|
||||
mkdirSync(dirname(join(d,f)),{recursive:true});writeFileSync(join(d,f),text,{mode});
|
||||
}
|
||||
for(const p of ['packages/business/src','roles'])cpSync(join(repo,p),join(d,p),{recursive:true});
|
||||
@@ -289,7 +312,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 +340,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 +351,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 +411,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});
|
||||
@@ -1622,7 +1669,7 @@ test('T53 row 187: activate sets, carries and drops the proof pair',async(t)=>{
|
||||
['activate',C1,'acme','--plan','--proof-file',f]]){
|
||||
const r=await prun(e,...args);assert.equal(r.code,4,args.join(' '));assert.equal(r.out,'');assert.equal(r.err,usageText);
|
||||
}
|
||||
assert.match(usageText,/\[--proof-header NAME --proof-file PATH \| --no-proof\]/);
|
||||
assert.match(usageText,/\[--proof-header NAME --proof-file PATH \| --no-proof \| --sso\]/);
|
||||
assert.deepEqual(fp(e),before);assert.deepEqual(startsStops(e,n),[]);assert.equal(logLines(e).length,logs);
|
||||
await noLeak(e);
|
||||
});
|
||||
@@ -1781,7 +1828,7 @@ test('T55 row 189: activate --no-send drops --send, and rollback and pm-on carry
|
||||
['rehearse','acme','--backup','/tmp/x','--port','7400','--no-send']]){
|
||||
const r=await prun(e,...args);assert.equal(r.code,4,args.join(' '));assert.equal(r.out,'');assert.equal(r.err,usageText);
|
||||
}
|
||||
assert.match(usageText,/\| --no-proof\] \[--no-send\]/);
|
||||
assert.match(usageText,/\| --no-proof \| --sso\] \[--no-send\]/);
|
||||
assert.deepEqual(fp(e),before);assert.deepEqual(startsStops(e,n),[]);assert.equal(logLines(e).length,logs);
|
||||
await noLeak(e);
|
||||
});
|
||||
@@ -1813,6 +1860,244 @@ test('T55 row 189: activate --no-send drops --send, and rollback and pm-on carry
|
||||
});
|
||||
});
|
||||
|
||||
// Row 200 (#1698): activate --sso. The config gets console.sso with one provider `a`, and its slot
|
||||
// file sits where cli.mjs's readSlot looks, beside the config, with a read watch: the run may stat
|
||||
// it but never open it. ES256 passes every copy's checker; RS256 passes C1's and fails C's.
|
||||
const SLOT='console-sso-a';
|
||||
const slotPath=(e)=>join(dirname(e.cfg),'secrets','mosaic-stack',SLOT);
|
||||
const SSO_KEY={sso:{publicOrigin:'https://console.example.test',providers:[{id:'a',issuer:'https://op.example.test/',clientId:'mosaic-console',clientSecret:{slot:SLOT},algs:['ES256'],subjects:[{sub:'subject-1',name:'jason'}]}]}};
|
||||
function setKey(e,key){const c=JSON.parse(readFileSync(e.cfg,'utf8'));if(key===undefined)delete c.console;else c.console=key;writeFileSync(e.cfg,JSON.stringify(c))}
|
||||
async function ssoConfig(e,{key=SSO_KEY,slot=true}={}){
|
||||
setKey(e,key);mkdirSync(dirname(slotPath(e)),{recursive:true,mode:0o700});
|
||||
return slot?proofFile(e,SLOT,0o600,dirname(slotPath(e))):slotPath(e);
|
||||
}
|
||||
const ssoLines=(out)=>out.filter((l)=>l.startsWith('sso:'));
|
||||
const DROPS='--public-host console.example --auth-header x-user --allow-user jason';
|
||||
const SHOW=`show-${CON}.txt`;
|
||||
const setOf=(e,act)=>join(e.state,'stack-rollback',act);
|
||||
const arrivalSet=(e,to)=>setOf(e,logLines(e).findLast((l)=>l.result==='ok'&&l.to===to&&l.from!==to).activation);
|
||||
const ROLLBACK_PLAN=(set)=>`sso: dropped; ${B}'s Console does not support --sso, so the rollback takes the Console flags from ${set}/${SHOW}, with --send and --tester-port as the pointer has them`;
|
||||
const KEY_MSG=(e,to,op,why)=>`${e.cfg} holds a console key, and ${to}'s loader refuses it (${why}); remove the key first (restore the config.json backup from the window's step 1), then run the ${op} again`;
|
||||
const RB_MSG=(why)=>`${B}'s Console does not support --sso, and the Console flags it ran with cannot be read: ${why}`;
|
||||
// Rewrites a file in a rollback set (0700, files kept by cp -p) and, unless sums is false, its SHA256SUMS line.
|
||||
function setEdit(set,name,from,to,sums=true){
|
||||
const f=join(set,name);chmodSync(set,0o700);chmodSync(f,0o600);edit(f,from,to);
|
||||
if(sums){const s=join(set,'SHA256SUMS');chmodSync(s,0o600);writeFileSync(s,readFileSync(s,'utf8').replace(new RegExp(`^[0-9a-f]{64}( \\./${name.replace(/[.@]/g,'\\$&')})$`,'m'),`${sha(f)}$1`))}
|
||||
}
|
||||
|
||||
test('T57 row 200: activate --sso, its refusals before any stop, and the rollback to a Console without --sso',async(t)=>{
|
||||
await t.test('item 1: --sso drops the preview set and the pair, puts --sso before --send and the tester port, and the plan names what it drops and carries',async(t)=>{
|
||||
const e=await mk(t),base=await seedPreview(e,undefined,['--send']),f=await proofFile(e),pre=noSend(base);
|
||||
let r=await prun(e,'activate',B,'acme','--tester-port','7333',...pair(f));assert.equal(r.code,0,r.out+r.err);
|
||||
assert.deepEqual(pointer(e).console,[...pre,...pair(f),'--send','--tester-port','7333']);
|
||||
await ssoConfig(e);
|
||||
const want=['--port',e.port,'--business','acme','--sso','--send','--tester-port','7333'];
|
||||
const out=await planOnly(e,0,'activate',C1,'acme','--sso');
|
||||
assert.deepEqual([...proofLines(out),...sendLines(out),...ssoLines(out)],['proof: dropped; --sso drops header x-console-proof','send: carried',
|
||||
`sso: on; drops ${DROPS}; carries --port ${e.port} --business acme --send --tester-port 7333`]);
|
||||
assert.equal(out[out.findIndex((l)=>l.startsWith('sso:'))+1],`Console: scripts/webui-service.sh install --copy ${C1} --copies ${COPIES} ${want.join(' ')}`);
|
||||
r=await prun(e,'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
|
||||
assert.equal(pointer(e).copy,C1);assert.deepEqual(pointer(e).console,want);
|
||||
assert.ok(conExec(e).endsWith(` --checkout ${repo} ${want.join(' ')}`),conExec(e));
|
||||
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
|
||||
const steps=stepText(e);
|
||||
assert.ok(steps.includes(`Z drop --proof-header x-console-proof --proof-file ${f}; --sso was given\n`),steps);
|
||||
assert.ok(steps.includes(`Z turn on --sso; drop ${DROPS}\n`),steps);
|
||||
await noLeak(e);
|
||||
});
|
||||
await t.test('item 1: --sso may re-pin the live copy once; later activates carry it; --no-send still drops --send',async(t)=>{
|
||||
const e=await mk(t),base=await seedPreview(e,undefined,['--send']);await ssoConfig(e);
|
||||
let r=await prun(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);
|
||||
let out=await planOnly(e,0,'activate',C1,'acme','--sso');
|
||||
assert.deepEqual([...proofLines(out),...ssoLines(out)],['proof: off',`sso: on; drops ${DROPS}; carries --port ${e.port} --business acme --send`]);
|
||||
r=await prun(e,'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
|
||||
const l=logLines(e).at(-1);assert.deepEqual([l.op,l.from,l.to,l.result],['activate',C1,C1,'ok']);
|
||||
assert.deepEqual(pointer(e).console,['--port',e.port,'--business','acme','--sso','--send']);
|
||||
assert.doesNotMatch(stepText(e),/proof/);
|
||||
await refused(e,['activate',C1,'acme','--sso'],'already-active',`${C1} is the pointer's copy`);
|
||||
await refused(e,['activate',C1,'acme'],'already-active',`${C1} is the pointer's copy`);
|
||||
out=await planOnly(e,0,'activate',C,'acme');assert.deepEqual(ssoLines(out),['sso: carried']);
|
||||
r=await prun(e,'activate',C,'acme','--migrate');assert.equal(r.code,0,r.out+r.err);
|
||||
// Carried, not turned on: no stamped step names sso (the install output's serve line still shows --sso).
|
||||
assert.deepEqual(pointer(e).console,['--port',e.port,'--business','acme','--sso','--send']);assert.doesNotMatch(stepText(e),/^\S+Z .*sso/m);
|
||||
assert.match(stepText(e),/^serve: .* --sso --send$/m);
|
||||
r=await prun(e,'activate',C,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
|
||||
assert.deepEqual(pointer(e).console,['--port',e.port,'--business','acme','--sso']);
|
||||
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
|
||||
const g=await mk(t);await seedPreview(g,undefined,['--send']);await ssoConfig(g);
|
||||
r=await prun(g,'activate',C1,'acme','--sso','--no-send');assert.equal(r.code,0,r.out+r.err);
|
||||
assert.deepEqual(pointer(g).console,['--port',g.port,'--business','acme','--sso']);
|
||||
assert.ok(stepText(g).includes('Z drop --send; --no-send was given\n'));
|
||||
for(const x of [e,g])await noLeak(x);
|
||||
});
|
||||
await t.test('item 1: --sso with a pair or --no-proof, or on any verb but activate, is a usage error',async(t)=>{
|
||||
const e=await mk(t);await seedPreview(e);const f=await proofFile(e);
|
||||
const usageText=(await prun(e,'--help')).err,n=calls(e).length,logs=logLines(e).length,before=fp(e);
|
||||
for(const args of [['activate',C1,'acme','--sso',...pair(f)],['activate',C1,'acme','--sso','--no-proof'],['activate',C1,'acme','--no-proof','--sso','--plan'],
|
||||
['rollback','acme','--sso'],['pm-on','acme','--sso'],['status','acme','--sso'],['adopt','acme','--sso']]){
|
||||
const r=await prun(e,...args);assert.equal(r.code,4,args.join(' '));assert.equal(r.out,'');assert.equal(r.err,usageText);
|
||||
}
|
||||
assert.match(usageText,/\[--proof-header NAME --proof-file PATH \| --no-proof \| --sso\] \[--no-send\]/);
|
||||
assert.deepEqual(fp(e),before);assert.deepEqual(startsStops(e,n),[]);assert.equal(logLines(e).length,logs);
|
||||
await noLeak(e);
|
||||
});
|
||||
await t.test('item 2: a target without --sso, a missing or bad console.sso, and each slot problem refuse before any stop',async(t)=>{
|
||||
const e=await mk(t);await seedPreview(e);await ssoConfig(e,{slot:false});
|
||||
const slot=slotPath(e),put=(mode=0o600,text='x\n')=>{rmSync(slot,{force:true,recursive:true});writeFileSync(slot,text,{mode});chmodSync(slot,mode)};
|
||||
put();
|
||||
await refused(e,['activate',B,'acme','--sso'],'sso-target',`${B}'s Console does not support --sso`);
|
||||
setKey(e,undefined);
|
||||
// N4: from another copy, the refusal names the re-pin without --sso as the first step.
|
||||
await refused(e,['activate',C1,'acme','--sso'],'sso-config',`${C1}'s Console would refuse its --sso start: there is no console.sso in the system config; activate ${C1} without --sso first, then write console.sso, then run activate ${C1} --sso`);
|
||||
let r=await prun(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);
|
||||
await refused(e,['activate',C1,'acme','--sso'],'sso-config',`${C1}'s Console would refuse its --sso start: there is no console.sso in the system config`);
|
||||
setKey(e,{sso:{...SSO_KEY.sso,providers:[{...SSO_KEY.sso.providers[0],algs:['RS256']}]}});
|
||||
r=await prun(e,'activate',C1,'acme','--sso','--plan');assert.equal(r.code,0,r.out+r.err);
|
||||
await refused(e,['activate',C,'acme','--sso'],'sso-config',`${C}'s Console would refuse its --sso start: console.sso.providers[0].algs[0] must be one of ${ALGS.filter((a)=>a!=='RS256').join(', ')}; HS* and none are refused`);
|
||||
setKey(e,SSO_KEY);
|
||||
const bad=(why)=>`console.sso provider a client secret slot ${slot} ${why}`;
|
||||
rmSync(slot);
|
||||
await refused(e,['activate',C,'acme','--sso'],'sso-slot',bad('is missing'));
|
||||
const elsewhere=join(e.root,'elsewhere');writeFileSync(elsewhere,'x\n',{mode:0o600});symlinkSync(elsewhere,slot);
|
||||
await refused(e,['activate',C,'acme','--sso'],'sso-slot',bad('is a symbolic link'));
|
||||
rmSync(slot);mkdirSync(slot,{mode:0o700});
|
||||
await refused(e,['activate',C,'acme','--sso'],'sso-slot',bad('is not a regular file'));
|
||||
for(const mode of [0o644,0o640,0o604]){put(mode);await refused(e,['activate',C,'acme','--sso'],'sso-slot',bad(`has mode ${mode.toString(8)}, which gives group or other permission bits`))}
|
||||
put(0o600,'');
|
||||
await refused(e,['activate',C,'acme','--sso'],'sso-slot',bad('is empty'));
|
||||
// The slot directory resolving into the repository: the file is no link, but its real path is.
|
||||
const inRepo=mkdtempSync(join(repo,'t57-slot-'));t.after(()=>rmSync(inRepo,{recursive:true,force:true}));
|
||||
writeFileSync(join(inRepo,SLOT),'x\n',{mode:0o600});chmodSync(join(inRepo,SLOT),0o600);
|
||||
const secrets=dirname(slot);rmSync(secrets,{recursive:true});symlinkSync(inRepo,secrets);
|
||||
await refused(e,['activate',C,'acme','--sso'],'sso-slot',bad('is inside the repository or the copies'));
|
||||
// --plan lists the sso refusal with the rest and writes nothing.
|
||||
const out=await planOnly(e,3,'activate',C,'acme','--sso','--tester-port','80');
|
||||
assert.deepEqual(out.filter((l)=>l.startsWith('refuses ')),[`refuses tester-port-invalid: --tester-port 80 is not a whole port from 1024 to 65535 other than 7332 and the Console's --port ${e.port}`,`refuses sso-slot: ${bad('is inside the repository or the copies')}`]);
|
||||
rmSync(secrets);mkdirSync(secrets,{mode:0o700});put();
|
||||
r=await prun(e,'activate',C,'acme','--sso','--migrate');assert.equal(r.code,0,r.out+r.err);
|
||||
await noLeak(e);
|
||||
});
|
||||
await t.test('item 3: a config whose console key the target loader refuses stops an activate or a rollback before any stop, naming the first step',async(t)=>{
|
||||
const e=await mk(t);await seedPreview(e);await ssoConfig(e);
|
||||
let r=await prun(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);
|
||||
const why='mosaic-config: unsupported configuration key: "console"';
|
||||
await refused(e,['activate',B,'acme'],'config-console-key',KEY_MSG(e,B,'activate',why));
|
||||
await refused(e,['rollback','acme'],'config-console-key',KEY_MSG(e,B,'rollback',why));
|
||||
const out=await planOnly(e,3,'rollback','acme');
|
||||
assert.deepEqual(out.filter((l)=>l.startsWith('refuses ')),[`refuses config-console-key: ${KEY_MSG(e,B,'rollback',why)}`]);
|
||||
setKey(e,{sso:{...SSO_KEY.sso,providers:[{...SSO_KEY.sso.providers[0],algs:['RS256']}]}});
|
||||
await refused(e,['activate',C,'acme'],'config-console-key',KEY_MSG(e,C,'activate',`mosaic-config: console.sso.providers[0].algs[0] must be one of ${ALGS.filter((a)=>a!=='RS256').join(', ')}; HS* and none are refused`));
|
||||
setKey(e,undefined);
|
||||
r=await prun(e,'rollback','acme');assert.equal(r.code,0,r.out+r.err);assert.equal(pointer(e).copy,B);
|
||||
await noLeak(e);
|
||||
});
|
||||
await t.test('item 3: a rollback from --sso to a Console without it takes the flags from the set of the activation onto the copy, past a same-copy re-pin (sage-1389)',async(t)=>{
|
||||
const e=await mk(t),base=await seedPreview(e,undefined,['--send','--tester-port','7333']);await ssoConfig(e);
|
||||
let r=await prun(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);
|
||||
const set=arrivalSet(e,C1);
|
||||
r=await prun(e,'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
|
||||
r=await prun(e,'activate',C1,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
|
||||
// The latest set holds the --sso Console; the arrival set holds B's.
|
||||
assert.match(readFileSync(join(setOf(e,pointer(e).activation),SHOW),'utf8'),/ --sso /);
|
||||
assert.doesNotMatch(readFileSync(join(set,SHOW),'utf8'),/ --sso /);
|
||||
await refused(e,['rollback','acme'],'config-console-key',KEY_MSG(e,B,'rollback','mosaic-config: unsupported configuration key: "console"'));
|
||||
setKey(e,undefined);
|
||||
const want=[...noSend(base.slice(0,-2)),'--tester-port','7333'];
|
||||
const out=await planOnly(e,0,'rollback','acme');
|
||||
assert.deepEqual(ssoLines(out),[ROLLBACK_PLAN(set)]);
|
||||
assert.equal(out.find((l)=>l.startsWith('Console: ')),`Console: scripts/webui-service.sh install --copy ${B} --copies ${COPIES} ${want.join(' ')}`);
|
||||
r=await prun(e,'rollback','acme');assert.equal(r.code,0,r.out+r.err);
|
||||
assert.equal(pointer(e).copy,B);assert.deepEqual(pointer(e).console,want);
|
||||
assert.ok(conExec(e).endsWith(` --checkout ${repo} ${want.join(' ')}`),conExec(e));
|
||||
assert.ok(stepText(e).includes(`Z drop --sso; ${B}'s Console does not support it, so the Console flags come from ${set}/${SHOW}\n`));
|
||||
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
|
||||
// The plain case: --send and the preview set and pair come back as they were.
|
||||
const g=await mk(t),gbase=await seedPreview(g,undefined,['--send']),f=await proofFile(g);
|
||||
r=await prun(g,'activate',B,'acme',...pair(f));assert.equal(r.code,0,r.out+r.err);
|
||||
await ssoConfig(g);
|
||||
r=await prun(g,'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
|
||||
setKey(g,undefined);
|
||||
r=await prun(g,'rollback','acme');assert.equal(r.code,0,r.out+r.err);
|
||||
assert.deepEqual(pointer(g).console,[...noSend(gbase),...pair(f),'--send']);
|
||||
for(const x of [e,g])await noLeak(x);
|
||||
});
|
||||
await t.test('item 3: a rollback set that is missing, fails its sums, holds no argv or already ran --sso refuses sso-rollback-flags',async(t)=>{
|
||||
const e=await mk(t);await seedPreview(e);await ssoConfig(e);
|
||||
let r=await prun(e,'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
|
||||
setKey(e,undefined);
|
||||
const set=arrivalSet(e,C1),show=join(set,SHOW),text=readFileSync(show,'utf8');
|
||||
setEdit(set,SHOW,' --public-host ',' --sso --public-host ');
|
||||
await refused(e,['rollback','acme'],'sso-rollback-flags',RB_MSG(`the Console in ${show} already ran with --sso`));
|
||||
let out=await planOnly(e,3,'rollback','acme');
|
||||
assert.deepEqual(out.filter((l)=>l.startsWith('refuses ')),[`refuses sso-rollback-flags: ${RB_MSG(`the Console in ${show} already ran with --sso`)}`]);
|
||||
setEdit(set,SHOW,' --sso --public-host ',' --public-host ',false);
|
||||
await refused(e,['rollback','acme'],'sso-rollback-flags',RB_MSG(`${show} does not match the set's SHA256SUMS`));
|
||||
setEdit(set,SHOW,' serve ',' ',true);
|
||||
await refused(e,['rollback','acme'],'sso-rollback-flags',RB_MSG(`${show} holds no Console argv this script can read`));
|
||||
rmSync(show);
|
||||
await refused(e,['rollback','acme'],'sso-rollback-flags',RB_MSG(`${set} has no ${SHOW} and SHA256SUMS`));
|
||||
writeFileSync(show,text);setEdit(set,SHOW,'\n','\n',true);
|
||||
// A log whose arrival line came from another copy names no set for B.
|
||||
const log=readFileSync(e.log,'utf8');chmodSync(e.log,0o600);
|
||||
writeFileSync(e.log,log.replaceAll(`"from":"${B}","to":"${C1}"`,`"from":"${A}","to":"${C1}"`));
|
||||
await refused(e,['rollback','acme'],'sso-rollback-flags',RB_MSG(`the log names no activation that moved the stack from ${B} onto ${C1}`));
|
||||
writeFileSync(e.log,readFileSync(e.log,'utf8').replaceAll(`"from":"${A}","to":"${C1}"`,`"from":"${B}","to":"${C1}"`));
|
||||
r=await prun(e,'rollback','acme');assert.equal(r.code,0,r.out+r.err);assert.equal(pointer(e).copy,B);
|
||||
await noLeak(e);
|
||||
});
|
||||
await t.test('round 2 (webby-0028 N1): run through a symlinked repository path, a slot or a proof file inside the repository still refuses',async(t)=>{
|
||||
const link=join(base,'repo-link.200');if(!existsSync(link))symlinkSync(repo,link);
|
||||
const e=await mk(t);e.sr=join(link,'scripts','stack-release.sh');await seedPreview(e);
|
||||
const f=await proofFile(e,'proof.200',0o600,mkdtempSync(join(repo,'t57-proof-')));t.after(()=>rmSync(dirname(f),{recursive:true,force:true}));
|
||||
await refused(e,['activate',C1,'acme',...pair(f)],'proof-file-invalid',`--proof-file ${f} is inside the repository or the copies`);
|
||||
await ssoConfig(e,{slot:false});
|
||||
let r=await prun(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);
|
||||
const inRepo=mkdtempSync(join(repo,'t57-slot-'));t.after(()=>rmSync(inRepo,{recursive:true,force:true}));
|
||||
writeFileSync(join(inRepo,SLOT),'x\n',{mode:0o600});chmodSync(join(inRepo,SLOT),0o600);
|
||||
const secrets=dirname(slotPath(e));rmSync(secrets,{recursive:true});symlinkSync(inRepo,secrets);
|
||||
await refused(e,['activate',C1,'acme','--sso'],'sso-slot',`console.sso provider a client secret slot ${slotPath(e)} is inside the repository or the copies`);
|
||||
await noLeak(e);
|
||||
});
|
||||
await t.test('round 2 (webby-0028 N2): a config.json that does not parse stops the run before anything is read or stopped',async(t)=>{
|
||||
const e=await mk(t);await seedPreview(e);
|
||||
// load_config exits first, before the pointer, the log or a unit is read; config_checks' own
|
||||
// refusal is the guard for a file that changes after that (BUILD.md, round 2).
|
||||
const good=readFileSync(e.cfg,'utf8');writeFileSync(e.cfg,good.slice(0,-1));
|
||||
const before=fp(e),n=calls(e).length,logs=logLines(e).length;
|
||||
for(const args of [['activate',C1,'acme'],['rollback','acme'],['activate',C1,'acme','--plan']]){
|
||||
const r=await prun(e,...args);assert.equal(r.code,1,args.join(' '));assert.match(r.err,/common: configuration load failed\n$/);
|
||||
}
|
||||
assert.deepEqual(fp(e),before);assert.deepEqual(startsStops(e,n),[]);assert.equal(logLines(e).length,logs);
|
||||
writeFileSync(e.cfg,good);
|
||||
const r=await prun(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);
|
||||
await noLeak(e);
|
||||
});
|
||||
await t.test('round 3 (sage-1556): a named instance turns --sso on with its own config, slot and Console unit, and rolls back from its own set',async(t)=>{
|
||||
const e=await mk(t),a=await named(e,'alpha');await seed(a);
|
||||
const show=`show-${a.con}.txt`,unit=()=>readFileSync(join(a.units,a.con),'utf8').split('\n').find((l)=>l.startsWith('ExecStart='));
|
||||
assert.ok(slotPath(a).startsWith(join(a.env.HOME,'.config','mosaic-alpha','secrets','mosaic-stack')+'/'),slotPath(a));
|
||||
await ssoConfig(a,{slot:false});
|
||||
await refused(a,['activate',C1,'acme','--sso'],'sso-slot',`console.sso provider a client secret slot ${slotPath(a)} is missing`);
|
||||
await proofFile(a,SLOT,0o600,dirname(slotPath(a)));
|
||||
const want=['--port',a.port,'--business','acme','--sso'];
|
||||
const out=await planOnly(a,0,'activate',C1,'acme','--sso');
|
||||
assert.equal(out.find((l)=>l.startsWith('Console: ')),`Console: scripts/webui-service.sh install --instance alpha --copy ${C1} --copies ${COPIES} ${want.join(' ')}`);
|
||||
let r=await prun(a,'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
|
||||
assert.equal(pointer(a).copy,C1);assert.deepEqual(pointer(a).console,want);
|
||||
assert.ok(unit().endsWith(` ${want.join(' ')}`),unit());
|
||||
assert.ok(readFileSync(join(a.units,a.con),'utf8').includes(`\nEnvironment=MOSAIC_CONFIG=${a.cfg}\n`));
|
||||
assert.ok(!existsSync(join(e.units,CON)),'the default Console unit was written');
|
||||
r=await prun(a,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
|
||||
setKey(a,undefined);
|
||||
const set=arrivalSet(a,C1);assert.ok(existsSync(join(set,show)),show);
|
||||
assert.deepEqual(ssoLines(await planOnly(a,0,'rollback','acme')),[`sso: dropped; ${B}'s Console does not support --sso, so the rollback takes the Console flags from ${set}/${show}, with --send and --tester-port as the pointer has them`]);
|
||||
r=await prun(a,'rollback','acme');assert.equal(r.code,0,r.out+r.err);
|
||||
assert.equal(pointer(a).copy,B);assert.deepEqual(pointer(a).console,want.slice(0,-1));
|
||||
assert.ok(stepText(a).includes(`Z drop --sso; ${B}'s Console does not support it, so the Console flags come from ${set}/${show}\n`));
|
||||
await noLeak(a);
|
||||
});
|
||||
});
|
||||
|
||||
// --- rehearse (D2, row 163; the brief's "Stack rehearsal on a scratch root") ---
|
||||
// Copies of their own in RCOPIES: the real scripts/mosaic, config loader, business loader, roles and
|
||||
// mosaic-config.mjs, with stand-ins for the bus host (cli.mjs), the Console, the Store and
|
||||
@@ -2191,3 +2476,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);
|
||||
})();
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user