Compare commits

..
Author SHA1 Message Date
jason.woltje b7239bb66e agents: row 195 Case 1 install design r6 (#1693): Duckworth r5 B1 (confirm's result lines carry the activation's op, from, to and the rollback set's BACKUP, so from_log reads .53's log; sequence 7), B2 (MIGRATING recorded after backup_store and before migrate-schema.mjs, as TAKEN today; sequence 8), N1 marker step makes $RS, N2 gate --since, N3 apparmor.d word grep baseline, N4 item 4 under systemd-run with NoNewPrivileges, N5 plan --expect-from OLD 2026-10-20 16:53:53 -05:00
jason.woltje 0b6d29e909 agents: row 195 Case 1 install design r5 (#1693): Duckworth r4 B1 (one result line per activation, FAILED file and --cause, marker written first, refusals log no activation, real-sequence tests), B2 per mos-1098/mos-1101 (case1-pins.sh and case1-hostcheck.sh, seven-item drift stop with apt hook and boot unit, ops-01 owner, bwrap closed on drift), N1-N4; Console on 127.0.0.1:732 with CAP_NET_BIND_SERVICE, tester 7333 as P10; chains on row 190 r3 and row 230 (C2-13a), node under /opt, four factory profile items for Gosalyn 2026-10-20 16:46:21 -05:00
jason.woltje 0075be3581 agents: row 195 Case 1 install design r4 (#1693): Duckworth B1 (root holds only the go's values and the unit files; confirm and revert find the one open activation, AWAITING-UNITS; rollback --to checked against from_log previous; A2 failure path; root step log), B2 (confirm runs gate with --timeout; .53 runs the PM; external PM check reads launches list with ActiveEnterTimestamp, no journal; pm-on refuses), N1-N5; mos-1096 bwrap-userns-restrict from the pinned deb, dpkg-statoverride root:mosaic 0750, P9 accepted by Mos; gosalyn-0046 two units and args file; managed settings for DISABLE_AUTOUPDATER; sage-1481 C2-13a 2026-10-20 16:08:53 -05:00
jason.woltje 9a16d7795d agents: row 195 Case 1 install design r3 (#1693): Duckworth B1, one ordered .53 activate with a runner per step (case1-repin.sh, stack-release --units external with confirm and revert), stopped-store backup under $DATA/backups/stack; N1-N6; mos-1091 4a-4c with the bwrap AppArmor profile; gosalyn-0034 C2-13 items 2026-10-20 15:56:54 -05:00
jason.woltje 818b79696d agents: row 195 Case 1 install design r2 (#1693, brief blob 45be7f09af, Sage rulings sage-1451) 2026-10-20 15:38:55 -05:00
65 changed files with 1122 additions and 11739 deletions
+872
View File
@@ -0,0 +1,872 @@
# Row 195 design: the Case 1 install on 10.1.1.53 (r6)
Darkwing, 2026-10-20. Brief: `docs/plans/2026-10-20_hub-own-uid.md`, blob 45be7f09afc1 (f24f2cd40,
sage-1439). Gitea #1693. Reviewer Duckworth, gate owner Sage. Chained on row 190's candidate (round 3,
df65a3d4c on `darkwing/row190-r3`, rebased on row 192; the config canon) and on row 230's candidate
(C2-13a, #1732, Gosalyn: `scripts/case2-unit.sh` and `case2-precheck.sh`), Q177, sage-1499. Row 230's
brief is `docs/plans/2026-10-20_case2-rows.md` "C2-13a" at 5b8def2f1, from Gosalyn's design r11
(eac92d956 on `gosalyn/case2-design`, approved webby-0038). It has no candidate yet, so I read that
brief as the interface and expect a rebase. Section 2 lists what Case 1 needs from it that the brief
doesn't give.
Changes from r5 (Duckworth's REVIEW-r4.md at 62200362b on `duckworth/reviews`, duckworth-0054). This
round changes only B1's and B2's lines, their section 7 cases and the notes:
- B1. The result line `confirm` writes said op `confirm`, because `log_line` (:58) writes `$OP` and
`OP="$1"` (:1493). `from_log` (:169) reads only `adopt`, `activate` and `rollback`, so it would have
skipped every .53 activation: after adopt C0 and re-pins to C1 and C2, `from_log previous` returns
C0. Now A5's `ok` line and R4's line carry the op, from and to of the activation's `started` line,
and the backup that A3 records in the rollback set. Real sequence 7 and a mutant that writes op
`confirm`. Section 4.
- B2. r5 recorded the migration after the migrate, while today's run sets TAKEN before
`migrate-schema.mjs` (:1004-1005). A migrate that changed the store and then exited 1 would have
left no record, so R2 would skip the restore and R4 would gate OLD on a half-migrated store. A3 now
writes `MIGRATING` after `backup_store` returns and before the migrate. Real sequence 8 and a mutant
that records after the migrate. Section 4.
- N1. The marker step makes `$RS` with capture's exclusive `mkdir` (:416), and capture under
`--units external` makes none. N2. `gate` takes `--since T`, root's clock before its A2f start, as
its start floor. N3. Section 0 now has a read-only grep of `/etc/apparmor.d` for the word `bwrap`,
and item 3 matches the word against a list of exactly one file. N4. Item 4 runs under
`systemd-run` with the units' `NoNewPrivileges=yes`, as 2a' does. N5. A1's plan takes
`--expect-from OLD` and refuses `pointer-differs`.
- Not in this round: claude's path. gosalyn-0058 proposes that `--claude`, like `--node`, takes only a
root-owned path outside the home, and `/srv/mosaic/.local/bin/claude` fails that. I asked Sage to
rule and recommended moving claude under `/opt/mosaic-stack`. That change gets its own round.
Changes from r4 (Duckworth's REVIEW-r3.md at 9f4d5556b on `duckworth/reviews`; mos-1098; mos-1101;
gosalyn-0049):
- B1. An activation now has exactly one result line, written at the end. A5's gate failure and A3's
failure after its capture write no log line. Each writes a `FAILED` file next to `AWAITING-UNITS` and
exits 1, so the activation stays open for R2 and R4, and R4 writes the result. A3 writes
`AWAITING-UNITS` right after `started`. Refusals by `confirm`, `revert` and `gate` log no activation
id, so a refused `confirm` can't close one either. There are real-sequence tests on one log, and
mutants that put back r4's interim line, r4's marker order and a refusal that names the activation.
Section 4 and section 7.
- B2. The bwrap profile has an owner and a stop. `scripts/case1-hostcheck.sh` checks the profile file
against the pin in `scripts/case1-pins.sh`, the two loaded profiles, the live label of a process
bwrap runs, the statoverride and mode, that no other profile names bwrap, two sysctls and the port
floor. It has three callers:
1. A1 of every re-pin refuses `profile-drift`.
2. A root apt hook runs it after every package run, unattended ones included.
3. A boot unit runs it before the bus starts.
On drift, callers 2 and 3 unload the profile and close `/usr/bin/bwrap` to `mosaic`, so every role
session refuses to start. They write `DRIFT` and log it, and only root clears it. ops-01 owns the host
side under a Mos go: it reads the log in each STATE report and, before each re-pin window, compares
the upstream profile to the pin. A changed upstream profile becomes a new pin, which is its own review
round. Section 1 has the details. The facts behind it are in section 0: unattended-upgrades runs daily
on .53 with the security pocket, and the pinned bubblewrap is in that pocket.
- N1 and mos-1098 point 2. P9 has a seventh item: no `mosaic-bwrap` profile is loaded, and no profile
other than Ubuntu's attaches to `/usr/bin/bwrap`. r4 never installed `mosaic-bwrap`. F5 now compares
exact strings. P9 reads `accepted mos-1096` only while all seven items hold.
- N2: a role session gets no capabilities, so a nested bwrap fails. Section 1. N3: A3 refuses "before
it logs `started`". N4: the stale-start rule and its fixtures handle `ActiveEnterTimestamp`'s whole
seconds.
- The port squat (mos-1101 item 5, gosalyn-0049). It applies: role sessions share the host's network
namespace (`packages/seat/src/sandbox.mjs:67` unshares no net), so while the Console is down a
session can bind its loopback port. The Console moves to 127.0.0.1:732 with
`CAP_NET_BIND_SERVICE` on its unit (row 230's line), so no session can take it. The tester port stays
7333, because serve refuses a tester port below 1024. That residue is P10, accepted under Mos's
reading. Case 1 can't meet the preview trio or the proof pair, because .53 has no proxy. Section 2
names the factory serve set for row 230.
- Jason's Q9 (Vikunja #429) is still open. If install and upgrade become product commands, these
steps become that installer's configuration and row 195 rebases on its row (sage-1491). Section 6.
## 0. What I checked on the host
Read only, as `localadmin` over ssh, 2026-10-20 between 20:40Z and 21:00Z, and again at 21:27Z for the
last seven rows (the brief allows this):
| Item | 10.1.1.53 today | Needed |
|---|---|---|
| OS | Ubuntu 24.04.5, kernel 6.8.0-124, x86_64, 12 cores, 94 GiB, 217 GiB free on `/` | fits |
| `mosaic` user, group, `/srv/mosaic` | absent | step 2a creates them |
| node | `/usr/local/bin/node` v22.22.2, root-owned | 24 or later (every `packages/*/package.json` engines line); dragon-lin runs 26.8.1 |
| bwrap | absent, no statoverride; apt candidate `bubblewrap 0.9.0-1ubuntu0.3` | every role session runs under `/usr/bin/bwrap` (`packages/seat/src/sandbox.mjs:43`) |
| user namespaces | `kernel.apparmor_restrict_unprivileged_userns = 1`, apparmor 4.0.1, no bwrap profile loaded | bwrap needs a user namespace, which this setting refuses unless a profile allows it |
| `apparmor-profiles` | not installed; candidate `4.0.1really4.0.1-0ubuntu0.24.04.9`, whose `/usr/share/apparmor/extra-profiles/bwrap-userns-restrict` has sha256 `11d39094f044f0cda0febb3ad517b830301da6b2ce929664af09ee9e4dd264f9` (read from the deb on dragon-lin) | section 1 |
| `fs.protected_hardlinks`, `fs.protected_symlinks` | 1, 1 | mos-1096 item 2 |
| journald | `SplitMode=uid` (the default) | section 4, B2 |
| `useradd` | `USERGROUPS_ENAB yes` | 2a passes `--user-group` anyway |
| claude | not on localadmin's PATH | the factory's harness; dragon-lin runs 2.1.304 |
| ports 7330-7333 | free (no listener) | tester |
| `localadmin` | NOPASSWD sudo (#423 step 1, applied 20:05:47Z, mos-1066, mos-1077) | the root steps run through it |
| `jarvis` | groups sudo, docker, lxd, adm | #423 step 4 removes all four (section 5, P5) |
| unattended-upgrades | on: `APT::Periodic::Unattended-Upgrade "1"`, `apt-daily-upgrade.timer` enabled. Allowed origins `${distro_id}:${distro_codename}`, `-security`, ESM apps-security and ESM infra-security. No `Package-Blacklist` | B2: packages change daily without a window |
| bubblewrap's pocket | `0.9.0-1ubuntu0.3` is in both `noble-updates` and `noble-security`, so its later security updates install unattended | B2 |
| apparmor | installed `4.0.1really4.0.1-0ubuntu0.24.04.8`. The `.9` candidate is in `noble-updates` only, which unattended-upgrades doesn't take. `aa-status --json` works | B2 |
| profiles that name bwrap | at 21:51:46Z `grep -rlw bwrap /etc/apparmor.d` and `grep -rlF /usr/bin/bwrap /etc/apparmor.d` both find nothing (exit 1). After 2a' the word grep lists only `bwrap-userns-restrict` | N1, P9 item 7, hostcheck item 3 |
| `net.ipv4.ip_unprivileged_port_start` | 1024 | the port squat |
| listeners below 1024 | 22 (sshd) and 53 (the resolver) only; 732 is free | the Console's port (section 1) |
| other listeners | 4003, 6052, 6055, 8123, 8765, 14242, 18555, and on loopback 1933, 5905, 6085, 6379, 6380, 9119, 9225, 18554, 18789, 38147 | none in 7330-7333 |
## 1. The install as inputs to the Case 2 scripts
Instance. Row 190's canon with the default name, `dev`, as Case 2 uses it (Gosalyn design 9). `~` is
`mosaic`'s home, `/srv/mosaic`, so:
| Input | Value |
|---|---|
| user | `mosaic`, primary group `mosaic` with no other member |
| home | `/srv/mosaic`, 0700, owner `mosaic` |
| config dir | `/srv/mosaic/.config/mosaic-dev` |
| data root | `/srv/mosaic/.mosaic-dev`, marker `instance.json` `{"instanceVersion":1,"name":"dev"}` |
| business | `mosaic-stack`, file `<configDir>/businesses/mosaic-stack.json` (section 2) |
| profile | `--profile factory` (row 230, C2-13b) |
| roles | the factory set in section 2 |
| human id | Jason's id, given in the window's go. Not `tester` (`broker.mjs:245`) |
| model credential | `<configDir>/secrets/claude-session.env`, 0600 (row 190's `SESSION_ENV` for the default instance) |
| token slot path | none for this row (section 2) |
| Console port | 127.0.0.1:732, below the port floor, bound through the Console unit's `CAP_NET_BIND_SERVICE` (row 230). The port squat below says why |
| tester port | 127.0.0.1:7333 (P10) |
| board | none. UNITS has no board unit, and the Console gets no `--board` |
| harness | claude at `/srv/mosaic/.local/bin/claude`, pinned through `MOSAIC_CLAUDE_BIN` (decision 91), which the bus unit's `@CLAUDE_ENV@` line sets (section 2) |
| node | `/opt/mosaic-stack/node-<version>/bin/node`, a pinned 26.x release, `root:root`. Row 230 refuses a `--node` under the home or not owned by root |
| PM | on: the bus unit's `@PM_FLAGS@` renders ` --pm` (section 2, section 4) |
| source | `/srv/mosaic/src/mosaic-stack` (`mosaic`'s checkout, section 4) and `/root/mosaic-stack` (root's) |
Why `dev` and not a named instance: one instance per OS user, and Case 2's rows already assume
`/srv/mosaic/.config/mosaic-dev`. A second instance under `mosaic` on .53 would need a name, and row 190
makes that a flag, not a move.
Why node lives under `/opt/mosaic-stack`: changing `/usr/local/bin/node` would change what Hermes and
anything else on the host runs, and Q181 keeps Hermes running. r4 put node under `/srv/mosaic`, but row
230 requires a root-owned `--node` outside the home. Nothing else on the host puts `/opt/mosaic-stack`
on its PATH.
PATH for `mosaic`'s steps. `sudo -u mosaic -H` resets PATH to sudo's `secure_path`, which finds
`/usr/local/bin/node`, v22. The precheck would then refuse, and `scripts/mosaic` would run v22 (it runs
`exec node` from PATH, `scripts/mosaic:22-40`). So every `mosaic` step in the run sheet and in
`case1-repin.sh` runs as `sudo -u mosaic -H env PATH=<node dir>:/usr/local/bin:/usr/bin:/bin ...`, the
list row 230 renders as the units' `@PATH@`. `case1-repin.sh` builds it from the `--node` in
`case1-unit.args`.
The units (row 230, design r11). Two system units, from the existing templates, in start order:
`[email protected]` from `packages/cli/systemd/mosaic-bus.service.in`, then
`mosaic-webui.service` from `packages/webui/systemd/mosaic-webui.service.in`. Root stops them in the
reverse order. `@REPO@` is the copy dir, and `%h` becomes the literal home. Row 230 adds
`ProtectSystem=strict`, `ReadWritePaths=/srv/mosaic`, `ProtectHome=yes` and `PrivateTmp=yes` to both, and
`AmbientCapabilities=CAP_NET_BIND_SERVICE` with `CapabilityBoundingSet=CAP_NET_BIND_SERVICE` to the
Console only. The Console unit has no `[Install]`, so root starts it by hand after a reboot until Jason
rules (mos-1097). The install call is:
```
case2-unit.sh --profile factory --user mosaic --home /srv/mosaic --business mosaic-stack --copy ID \
--node /opt/mosaic-stack/node-<version>/bin/node --pm --claude /srv/mosaic/.local/bin/claude \
-- --port 732 --business mosaic-stack --tester-port 7333
```
`--pm` and `--claude` are the two inputs row 230's brief doesn't have yet (section 2). A re-install
must pass the same `--node`, flags and serve flags, and only the copy id may change (row 230's
re-install check). So 2c writes the whole argument list, without `--copy`, to
`/etc/mosaic-stack/case1-unit.args`, `root:root` 0644, one argument per line. Every later
`case2-unit.sh` call in section 4 reads that file and adds `--copy`. `mosaic` can't write it. Changing
a serve flag is a re-install with its own go, not a re-pin.
The port squat (mos-1101 item 5). Role sessions share the host's network namespace:
`packages/seat/src/sandbox.mjs:67` unshares the user, PID and IPC namespaces and not the network. So
while the Console is down, which A2 to A4 of every re-pin and any crash before `Restart=on-failure`
brings it back both cause, a session can bind 127.0.0.1:7332. It would then receive Jason's `ssh -L`
traffic, and it would answer the gate's `/healthz` (`stack-release.sh:485-487`) in the Console's place.
At 732 that can't happen. The floor is 1024 (section 0), a session has no capabilities (below), and
`mosaic`'s own processes outside the sandbox have none either. Only the Console unit holds
`CAP_NET_BIND_SERVICE`. Its children, the human-cli and read scripts it spawns
(`packages/webui/src/bus.mjs:31`, `reads.mjs:79`), inherit the ambient capability. No role session
descends from the Console: sessions are children of the bus unit, which has no capability line. The
gate needs no change, because `console_port` (`stack-release.sh:213-215`) reads the Console's `--port`
from the pointer. Row 230's precheck refuses when the floor is at or below the Console's port, and
`case1-hostcheck.sh` checks it after every package run (B2). The tester port can't go below 1024,
because serve refuses that (`packages/webui/src/cli.mjs:33`), so P10 records it.
Node download (N4). The run sheet pins one release, its tarball name and its sha256. As root, in a
root-owned temp dir: fetch the tarball, `SHASUMS256.txt` and `SHASUMS256.txt.asc`; `gpg --verify` the
`.asc` against the Node release keys listed in the nodejs/release-keys repository, imported into a
keyring under `/etc/mosaic-stack/node-keys`; check that the tarball's line in the verified file is the
pinned sha256; then `sha256sum -c` the tarball and unpack it to `/opt/mosaic-stack/node-<version>`,
`root:root`, no file writable by group or other. Any mismatch stops the step. Rollback: `rm -r` that
dir.
Claude (N5). The run sheet pins the version, 2.1.304 unless the window's plan names a newer one. As
`mosaic`: `npm install --prefix /srv/mosaic/.local @anthropic-ai/claude-code@<pin>`, with `@<pin>`
exact. `/srv/mosaic/.local/bin/claude` is the path `MOSAIC_CLAUDE_BIN` names. The autoupdater stays off
through `/etc/claude-code/managed-settings.json`, `root:root` 0644, holding
`{"env":{"DISABLE_AUTOUPDATER":"1"}}`. Root writes it in 2a'. Why there:
- `ENV_ALLOW` (`packages/seat/src/session.mjs:83-88`) doesn't pass `DISABLE_AUTOUPDATER`, so a unit
`Environment=` line never reaches a role session (gosalyn-0046 item 3).
- A role session's HOME is the run's own `home/`, and the sandbox doesn't show `~/.claude`
(`session.mjs:92-93`, `sandbox.mjs:16`). So `mosaic`'s claude settings file doesn't reach one either.
- The sandbox mounts `/etc` read-only (`sandbox.mjs:85`), and Claude Code reads managed settings from
`/etc/claude-code/` whatever HOME is. `mosaic`'s own CLI runs, like the precheck's marker prompt
and F6, read it too.
The pinned binary is a read-only bind inside the sandbox (`sandbox.mjs:9-10`), so a session can't
rewrite it anyway. The managed file covers `mosaic`'s runs outside the sandbox. If the installed
version doesn't honour `env` there, F6 catches the drift. Rollback: `rm` the file.
bwrap and the user namespace (mos-1091 4a, 4b; mos-1096 item 2; N1). Three root steps in 2a', in this
order, each with its rollback. The `mosaic` group must exist first (2a).
1. `apt-get install bubblewrap=0.9.0-1ubuntu0.3`, the version the run sheet pins. Then record
`stat -c '%U:%G %a' /usr/bin/bwrap` to `/etc/mosaic-stack/bwrap.orig` (I expect `root:root 755`).
Rollback: `apt-get remove bubblewrap`.
2. Ubuntu's own `bwrap-userns-restrict`, one file, not the package. `apt-get download
apparmor-profiles=4.0.1really4.0.1-0ubuntu0.24.04.9` into a root-owned temp dir, extract
`./usr/share/apparmor/extra-profiles/bwrap-userns-restrict` with `dpkg-deb --fsys-tarfile`, check
its sha256 is `11d39094f044f0cda0febb3ad517b830301da6b2ce929664af09ee9e4dd264f9`, and install it as
`/etc/apparmor.d/bwrap-userns-restrict`, `root:root` 0644. Load it with `apparmor_parser -r
/etc/apparmor.d/bwrap-userns-restrict`. `aa-status` then lists `bwrap` and `unpriv_bwrap` in
enforce mode. Why one file: the package also installs 20 profiles under `/etc/apparmor.d`
(`usr.sbin.dnsmasq`, `bin.ping`, `usr.sbin.smbd` and more), which would load at the next boot and
could change services on the host that have nothing to do with Mosaic.
What the profile does: `bwrap` gets `userns`, `capability` and `mount` for its own setup, and
every program it runs is stacked under `unpriv_bwrap`, which has the same file and network rules
with `audit deny capability`. So a role session gets a user namespace and no capabilities in it.
The sysctl stays at 1, so nothing else on the host gains user namespaces.
One consequence (N2): a bwrap that a session starts inside its own sandbox fails, because it needs
capabilities in the namespace. Claude Code's own sandbox is such a bwrap, and it's off by default,
so a role session doesn't use it. F3 shows a session still answers under the profile.
Rollback: `apparmor_parser -R /etc/apparmor.d/bwrap-userns-restrict`, then `rm` it.
3. `dpkg-statoverride --update --add root mosaic 0750 /usr/bin/bwrap` (Mos). Only root and the
`mosaic` group can read or run bwrap, and package upgrades keep the mode. Rollback:
`dpkg-statoverride --remove /usr/bin/bwrap`, then `chown` and `chmod` to the values in
`bwrap.orig`.
4. The drift stop (B2, below): `case1-hostcheck.sh` and `case1-pins.sh` from `/root/mosaic-stack` to
`/usr/local/lib/mosaic-stack/`, `root:root` 0755 and 0644; the apt hook
`/etc/apt/apt.conf.d/80mosaic-hostcheck`; the boot unit `mosaic-hostcheck.service`, enabled. Then
`case1-hostcheck.sh --check` exits 0. Rollback: disable and remove the unit, `rm` the hook file and
the dir.
Full rollback runs 4, 3, 2, 1. Tests after 2a' and again in section 5: as `mosaic`, `bwrap --unshare-all
--ro-bind / / true` exits 0 (row 230's precheck line, F4); as `jarvis`, `/usr/bin/bwrap --version`
fails with "Permission denied" (P9); `getent group mosaic` lists no member and no other account has
gid `mosaic` (P9); `fs.protected_hardlinks` is 1 (P9). A role session running under the profile is F5.
One more test runs in 2a', because I can't settle it from here. Row 230's units keep the templates'
`NoNewPrivileges=yes`, and AppArmor limits profile changes for a task with no-new-privs set. The
`bwrap` attach happens on exec from an unconfined task, and the child's change is a stack
(`bwrap//&unpriv_bwrap`), which only narrows. I expect both to pass under no-new-privs, since bwrap
sets no-new-privs on itself before it runs the child, and Ubuntu's profile works with that. But I
haven't seen it run, and .53 is read-only to me. So as root, after step 4: `systemd-run --wait --pipe
--uid=mosaic --gid=mosaic -p NoNewPrivileges=yes -p ProtectSystem=strict -p ProtectHome=yes -p
PrivateTmp=yes /usr/bin/bwrap --unshare-user --unshare-pid --ro-bind / / cat /proc/self/attr/current`
prints `bwrap//&unpriv_bwrap (enforce)`. If it fails, the window stops at 2a' and rolls back 4 to 1,
before any `mosaic` step. That beats finding it at F5.
The drift stop (B2, Duckworth's REVIEW-r3 B2, mos-1098 point 1). r4 installed a profile file that no
package owns, from a pinned deb, onto a host where unattended-upgrades changes packages every day
(section 0). Three things can move under it: bubblewrap (its security updates install unattended), the
`apparmor` package and its parser, and any new profile a package ships that attaches to
`/usr/bin/bwrap`. Two more host values matter to this design: the user-namespace sysctl and the port
floor. So:
- `scripts/case1-pins.sh` holds the pins, sourced by the scripts below: the `apparmor-profiles` version
the file came from, the file's sha256, and the bubblewrap version 2a' installs. Changing a pin is a
reviewed commit and its own round. The bubblewrap version is an install pin, not a drift item:
a security update to bubblewrap keeps the statoverride, and the profile attaches by path.
- `scripts/case1-hostcheck.sh` checks seven items and prints one line each:
1. `/etc/apparmor.d/bwrap-userns-restrict` has the pinned sha256, and `dpkg -S` finds no package
that owns it.
2. `aa-status --json` lists `bwrap` and `unpriv_bwrap` in enforce mode, and no loaded profile named
`mosaic-bwrap`.
3. `grep -rlw bwrap /etc/apparmor.d` lists exactly `/etc/apparmor.d/bwrap-userns-restrict`. The word
match also catches a path written as `/{usr/,}bin/bwrap`. Section 0's read found no file today.
4. The live label: 2a' line `systemd-run --wait --pipe --uid=mosaic --gid=mosaic -p NoNewPrivileges=yes
-p ProtectSystem=strict -p ProtectHome=yes -p PrivateTmp=yes /usr/bin/bwrap --unshare-user
--unshare-pid --ro-bind / / cat /proc/self/attr/current` prints exactly `bwrap//&unpriv_bwrap
(enforce)`. It runs under the units' no-new-privs, so it reads the label a role session gets.
This catches a profile that attaches by a glob that item 3's search misses.
5. `dpkg-statoverride --list /usr/bin/bwrap` reads `root mosaic 0750`, and `stat` agrees.
6. `kernel.apparmor_restrict_unprivileged_userns` is 1 and `fs.protected_hardlinks` is 1.
7. `net.ipv4.ip_unprivileged_port_start` is above the Console's `--port` in the installed unit.
`--check` exits 0 when all hold and 1 otherwise. `--enforce` runs the same check and always exits 0.
On a failure, or while `/etc/mosaic-stack/DRIFT` exists, it applies the stop:
`apparmor_parser -R` on the profile if loaded, then `chown root:root` and `chmod 0700` on
`/usr/bin/bwrap`. It writes the failed items to `DRIFT` and appends a line to
`/var/log/mosaic-stack/hostcheck.log`. With bwrap closed to `mosaic`, every role session refuses
to start. That's the fail-closed state, and the stack needs no change for it. The chmod is undone by
the next bubblewrap upgrade, which reapplies the statoverride. But the apt hook runs again right
after that upgrade and finds `DRIFT`, so it closes bwrap again. Only root removes `DRIFT`, in a
reviewed step with a new pin or a Mos go. I don't call `dpkg-statoverride` from inside the hook,
because I haven't confirmed it can take dpkg's lock while apt holds the frontend lock.
- Three callers. A1 of every re-pin runs `--check` and refuses `profile-drift` on exit 1. The apt hook
is `DPkg::Post-Invoke { "/usr/local/lib/mosaic-stack/case1-hostcheck.sh --enforce || true"; };`, so it
runs after every package run, unattended ones included, and can't fail one. The boot unit,
`mosaic-hostcheck.service` (`Type=oneshot`, `After=apparmor.service`,
`Before=mosaic-bus@mosaic-stack.service`, `WantedBy=multi-user.target`), runs `--enforce` before the
bus starts, which covers a kernel or parser change that takes effect at boot.
- Owner. ops-01 owns the host side under a Mos go. It reads `hostcheck.log` in each STATE report and
sends any `DRIFT` to Mos. Before each re-pin window it also runs `--upstream`, which downloads the
current `apparmor-profiles` candidate into a temp dir and compares its `bwrap-userns-restrict` to the
pin. A difference goes to Mos as a new-pin request and refuses nothing. The pins and the scripts are
this row's, and later changes go through review like any other.
Who runs what:
| Step | Runs as | Through |
|---|---|---|
| 2a: `useradd --system --user-group --home-dir /srv/mosaic --shell /usr/sbin/nologin mosaic`, `/srv/mosaic` 0700, `/etc/mosaic-stack` `root:root` 0755, `/var/log/mosaic-stack` `root:root` 0750 | root | ops-01, `ssh localadmin@10.1.1.53`, `sudo` (#423 step 1) |
| 2a': node under `/opt/mosaic-stack`; bubblewrap, the AppArmor profile, the statoverride, the drift stop and the managed settings file, each with the test and rollback above; the no-new-privs test | root | ops-01, same path, R27 (mos-1091) |
| 2a'': a `git bundle` of the reviewed commit copied to .53; root clones it to `/root/mosaic-stack` and checks HEAD is the commit the go names; it installs the bundle as `/srv/mosaic/incoming/<sha>.bundle`, owner `mosaic`, 0600 | root | ops-01, same path |
| 2b: claude under `~/.local` (above); `git clone` of the bundle to `/srv/mosaic/src/mosaic-stack`; `scripts/bootstrap.sh`; `stack-copy.sh copy`; the install with the inputs above (C2-13b's `case2-install.sh --profile factory` if it has landed, else the run sheet's steps) | `mosaic` | `sudo -u mosaic -H env PATH=...` from `localadmin` (PATH above). `mosaic` gets no key and no login shell |
| 2b': place `claude-session.env` | Jason, or who he names (Q173 secrets rule) | section 3 |
| 2c: write `case1-unit.args`; `case2-unit.sh` with those arguments and `--copy <id>`, from `/root/mosaic-stack`; then `case2-precheck.sh --profile factory` as `mosaic`, which reads the Console's `--port` from the installed unit for its port-floor line, so it runs after the render; `systemctl enable` the bus unit; the first start of UNITS. The Console unit has no `[Install]` until Jason rules (C2-13a), so it isn't enabled, and after a host reboot root starts it by hand until then. The run sheet says so | root | ops-01, same path |
| 2c': `stack-release.sh adopt mosaic-stack --units external`, which records the running copy as the pointer (`do_adopt`, `scripts/stack-release.sh:856`) | `mosaic` | `sudo -u mosaic -H` |
| every later re-pin or rollback | root, with its `mosaic` steps through `sudo -u mosaic -H` | ops-01 runs `case1-repin.sh` (section 4) |
| acceptance (section 5) | `localadmin`, with `sudo -u mosaic -H` for the in-instance checks | the agent the window names |
No git credential lands on .53. Both checkouts come from bundles that ops-01 copies in, and a re-pin
brings a new bundle that `mosaic` fetches into its checkout.
## 2. Where Case 1 differs from Case 2
| Case 2 item (row 230 brief) | Case 1 | Reason |
|---|---|---|
| business file with the library mapping | business `mosaic-stack` with no `library` key; `--profile factory` refuses a `kind: "library"` project (`profile-library-business`) | the factory has no books repo |
| `bean`, `chet`, persona vars and files, specialists | factory roles from `roles/`, with a chief that has a harness var | Jason names the set in the go. I haven't read his live business file and won't. The acceptance needs only the chief (F3), since a chief with no harness var answers `no-route`. Others are added later by the human with `instances.set`, no new row |
| token slot `library-gitea` | none | the factory's per-agent git identity is row 199's credential store (Megavolt, #1697). Until it lands, git work stays on dragon-lin (Q182) |
| precheck: `bus.tester` is not on | `bus.tester` is on; `factory` requires it | the acceptance and the Q173 test system need the tester (agents act as tester, never Jason) |
| precheck: persona file modes and digests | skipped under `factory` | no persona files |
| precheck: `library.sync`, `library.status` | skipped under `factory` | no library |
| precheck: `ZAI_API_KEY` in the key file | doesn't run | no instance has provider `zai` |
| precheck: no Discord binding | kept | the factory's Discord connector stays on dragon-lin. Two connectors on one bot token would split its events |
| precheck: claude marker prompt with `--system-prompt-file` | runs, since the chief's harness is claude-code | |
| precheck: `sudo -n true` fails, `id -G` one entry, run root 0700, config validates, node 24 or later, `bwrap` exits 0, free memory, HOME | kept | node and bwrap are in row 230 (gosalyn-0034) |
| Console serve flags: `--port` 1..1023, `--business`, the preview trio, `--send`, the proof pair, no `--tester-port` | `--port 732`, `--business mosaic-stack`, `--tester-port 7333`. No preview trio, no `--send`, no proof pair | below |
| bus: `@PM_FLAGS@` and `@CLAUDE_ENV@` render empty | ` --pm`, and `Environment=MOSAIC_CLAUDE_BIN=/srv/mosaic/.local/bin/claude` | below |
All of these are inputs. Nothing in the Case 2 scripts hardcodes a Case 1 value (gosalyn-0034 item 4).
Row 230's brief says the factory profile takes the library serve set plus an optional `--tester-port`,
and that row 195 names any other difference (gosalyn-0049). .53 can't meet the library set, and the bus
needs two lines the brief renders empty. The factory profile I need from row 230:
1. Serve flags. `--port N` with N in 1..1023 and `--business ID` are required, and `--tester-port N` in
1024..65535, not equal to `--port`, is optional. The preview trio (`--public-host`, `--auth-header`,
`--allow-user`) and the proof pair (`--proof-header`, `--proof-file`) are refused. Both exist for a
proxy in front of the Console: the proxy signs the user in and adds the header, and the proof shows
the request came through it. .53 has no proxy. Jason reaches the Console with `ssh -L` (sage-1451
Q3), so there is no header to trust and no proxy to hold the proof. `--send` is refused too, since
serve takes it only with the trio or `--sso` (`packages/webui/src/cli.mjs:15`). When Q175 SSO reaches
.53, `--sso` with an optional `--send` (row 192) would be the factory's sign-in, and adding it is a
re-install with its own go. Row 230 can accept `--sso [--send]` under factory now or later. Either
way it needs a `console.sso` block in `mosaic`'s config, which this row doesn't write. `--checkout`
and `--board` stay refused, as in the library set.
2. `--pm` (factory only) renders the bus unit's `@PM_FLAGS@` as ` --pm`. The factory's PM dispatches its
work on .53 as on dragon-lin (section 4). With the token empty the bus never launches the PM, and
A5's PM check fails every re-pin.
3. `--claude PATH` (factory only) renders `@CLAUDE_ENV@` as `Environment=MOSAIC_CLAUDE_BIN=PATH`, the
line `scripts/bus-service.sh:79` renders today. PATH must be absolute. Without it the bus runs
`claude` from the unit's PATH, which is the node dir, `/usr/local/bin`, `/usr/bin` and `/bin`, and
holds no claude on .53. The chief's first launch would then fail. The precheck's claude marker
prompt should run the same binary, so it reads the path from the installed bus unit.
4. Row 190's two template tokens, if row 190 lands before row 230. Row 190 adds `@CONFIG_ENV@` to both
templates and `@SESSION_ENV@` to the bus template's `EnvironmentFile=` line. For the default
instance, `@CONFIG_ENV@` is deleted (`scripts/instance.sh`) and `@SESSION_ENV@` is
`<home>/.config/mosaic-dev/secrets/claude-session.env`. Row 230's "every token has one value" list
and its `EnvironmentFile=` check need both. This applies to the library profile too.
I've sent these to Gosalyn. If row 230's candidate lacks any of them, row 195's manifest names the
change to `case2-unit.sh` or `case2-precheck.sh`, reviewed by Duckworth with Webby told.
## 3. Model access
Q185: Case 1 uses the account the factory uses today, the Claude subscription behind dragon-lin's
`claude-session.env`. The install never copies that file from dragon-lin. In the window, Jason (or the
person he names under the Q173 secrets rule) makes a credential for .53 from that account and writes it
to `/srv/mosaic/.config/mosaic-dev/secrets/claude-session.env` as `mosaic`, mode 0600. Nobody prints it,
and no script reads its value. The precheck checks only its mode, owner and that the variable name is
present (a `grep -c` on the name, output a count).
Quota: the factory on dragon-lin and on .53 share one subscription. Q185's "use existing" accepts that.
Case 2 shares nothing with it (Mos's reading of Q185).
The pi harness stays off on .53 until P19 #383 settles (mos-0865). The business file names claude only.
## 4. Re-pin, rollback and backup on .53
Unit form (ruled, sage-1451 Q1): system units that root writes, with `User=mosaic`. `mosaic` can't
start or stop them, and root never runs code that `mosaic` can write. `stack-release.sh activate` today
does everything in one run under `systemctl --user` and puts the old stack back inside that run
(`rollback_in_run`, `scripts/stack-release.sh:561`). On .53 the same run needs two users. So one
re-pin is one ordered list, and one root command drives all of it: `scripts/case1-repin.sh`, run by
ops-01 from `/root/mosaic-stack`. Each `mosaic` step is `sudo -u mosaic -H` running
`/srv/mosaic/src/mosaic-stack/scripts/stack-release.sh` with `--units external`. Copies carry no
`stack-release.sh` (`COPY_PATHS`, `scripts/stack-copy.sh:23-30`), so the `mosaic` steps run from that
checkout, as dragon-lin's run from the canonical one.
What root holds, and where each value comes from (r4's B1):
| Value | Source |
|---|---|
| ID, the target copy | the go |
| TO, a rollback's target | the go, as `--to ID` |
| `--restore ACT --events N` | the go, when the rollback restores |
| OLD, the running copy | the `ExecStart=` of the two unit files root installed. If the bus and Console units name different copies, A1 refuses `units-disagree` |
| the `case2-unit.sh` arguments | `/etc/mosaic-stack/case1-unit.args` (section 1) |
| each `mosaic` step's result | its exit code |
Root parses nothing `mosaic` wrote and runs nothing under `/srv/mosaic` except through `sudo -u mosaic
-H`. It never needs the activation id: the `mosaic` steps find it themselves.
One result line per activation (B1). The stack log closes an activation on the first line after
`started` that names it, whatever the result (`interrupted`, :146-156). r4 wrote an interim
`... rollback pending` line at A3's and A5's failures. That line closed the activation, so R2 and R4
then refused `open-activation` against an activation the log no longer held open. The rollback could
never run as designed, and nothing tested the steps in sequence. r5 keeps an activation open from A3's
`started` line until its single result line, and A5 or R4 writes that line:
| Activation ends at | Result line | Written by |
|---|---|---|
| A3 fails before its rollback set exists (marker or capture) | `refused code marker-failed` or `refused code capture-failed`, as `capture || refuse` does today (:987). Nothing has changed, so there is nothing to roll back | A3 itself, exit 3, after it removes the marker. Root runs A2f |
| A5 passes | `ok` | A5 |
| A3 (after capture), A4 or A5 fails, and the rollback passes | `failed code <c> rollback ok` for A3 and A4, `gate-failed code <c> rollback ok` for A5 | R4 |
| the rollback fails at R4's gate | `rollback-failed code <c> rollbackCode <r>` | R4 |
| R1, R2 or R3 fails, or the run dies | none. The activation stays open, and the next activate refuses `interrupted-run` (:603-606), which Jason or Mos settles as on dragon-lin | |
What the result line carries (r5's B1). `log_line` (:58) writes `op "$OP"`, and `from_log` (:169)
reads only lines whose op is `adopt`, `activate` or `rollback`. So `confirm` doesn't log under its own
op. Before A5's `ok` line or any line R4 writes, it sets OP, FROM and TO from the open activation's
`started` line, ACT to that activation, and BACKUP from the rollback set's `BACKUP` file, which A3
writes when `backup_store` returns. A re-pin's lines then read `op activate`, a rollback's `op
rollback`, each with the from, to and backup a dragon-lin run would log. `from_log previous` and
`from_log backup` read .53's log as they read dragon-lin's. `confirm` reads only the line A3 wrote for
that activation, which `interrupted` already names.
A failing `mosaic` step after `started` writes no log line. It writes its code to `FAILED` beside
`AWAITING-UNITS` and exits 1. R4 reads that code for the result line. Root passes R4 `--cause
A3|A4|A5`, the step it saw fail, from its own exit codes. A4 is root's own step and has no `FAILED`
file, so R4 writes `failed code units-start` for it. A cause with no `FAILED` file where one should be
writes code `unrecorded`.
The open activation. A3 writes `$STATE/stack-rollback/<ACT>/AWAITING-UNITS` holding the target copy
(renamed from r3's `PENDING`, which read like the pointer's `pmPending`, `P_PENDING` at :128; N3).
`confirm` and `revert` take the copy root passed, not an activation id. Each lists the
`AWAITING-UNITS` files and the `interrupted` set (:146), and goes on only when there is exactly one
file, its activation is the one `interrupted` names, and the copy in it is the copy root passed.
Anything else refuses `open-activation` with exit 3 and the list it found, and changes nothing.
`confirm` without `--rollback` also refuses `activation-failed` when `FAILED` exists, so a second
confirm can't pass an activation that already failed. Whichever `confirm` writes the result line
removes `AWAITING-UNITS` and `FAILED` after it.
Refusals of the three external verbs. `confirm`, `revert` and `gate` act on an open activation by
design, so they skip the `interrupted-run` refusal (:605). Their refusals log `result refused` with no
activation id: they set ACT only after every check has passed. So a refused `confirm`, a wrong copy
say, leaves the open activation open. Today's `refuse` (:62-67) adds the activation id whenever ACT is
set, and the external verbs keep that order. A test and a mutant hold it (section 7).
Root's step log. Every step `case1-repin.sh` runs appends one line to `/var/log/mosaic-stack/case1-repin.log`,
`root:root` 0640: the time from `date -u`, the step, its argv, its exit code. `mosaic` can't write it,
so it stays readable after a run that dies before `mosaic` logs anything.
### Activate: `case1-repin.sh activate ID [--migrate] [--timeout S]`
| # | Step | Runner | On failure |
|---|---|---|---|
| A1 | `/root/mosaic-stack` HEAD is the commit the go names. ID is 40 lowercase hex and names a copy under `/srv/mosaic/.local/share/mosaic-stack/copies` that holds `scripts/mosaic` (row 230's copy-id rule). `case1-hostcheck.sh --check` exits 0, else `profile-drift` (B2). Root reads OLD from the unit files. `stack-release.sh activate ID mosaic-stack --units external --plan --expect-from OLD` exits 0. The plan refuses `pointer-differs` when the pointer's copy isn't OLD (N5), so a pointer and units that disagree stop before any stop | root; the plan as `mosaic` | refuse, exit 3. Nothing has changed |
| A2 | `systemctl stop` UNITS in reverse order, then wait until each is inactive, within `--timeout` | root | A2f below |
| A3 | `stack-release.sh activate ID mosaic-stack --units external [--migrate]`. Before it logs `started` (N3), it refuses `units-running` unless each of UNITS is inactive (system-manager reads, which need no privilege) and the store is stopped (`stopped`, :359), and `interrupted-run` while any activation is open. Then, in this order: logs `result started`; makes `$RS` with capture's `mkdir -p` of `stack-rollback` and exclusive `mkdir -m 0700` (:415-416), and writes `AWAITING-UNITS` in it at once (N1); captures the rollback set into that directory, making none (pointer, wrapper, and the sha256 and `ExecStart=` of each installed unit, read only); takes `backup_store` (:372) into `$DATA/backups/stack/<ACT>` and writes its name to `BACKUP` in the rollback set; under `--migrate`, writes `MIGRATING` in the rollback set and sets TAKEN, both before `migrate-schema.mjs` runs, where today's run sets TAKEN (:1004-1005, r5's B2), then migrates; installs the wrapper for ID. It writes no pointer | `mosaic` | a refusal before `started` exits 3 with nothing changed. A marker or capture failure, an existing `$RS` included, removes the marker if it wrote one, logs its `refused` line and exits 3. Root runs A2f for both. Any later failure writes `FAILED` with its code, logs nothing, cleans up nothing and exits 1. Root runs R1-R4 with `--cause A3` |
| A4 | `case2-unit.sh` with `case1-unit.args` and `--copy ID`, `systemctl daemon-reload`, `systemctl start` UNITS in order | root | R1-R4 with `--cause A4` |
| A5 | `stack-release.sh confirm ID mosaic-stack --units external --timeout S`. This is `gate` (:522), which polls `gate_once` (:471) every second until `--timeout`, with the external checks below. On a pass it writes the pointer, logs `result ok`, removes `AWAITING-UNITS`, exit 0. When the timeout runs out it writes `FAILED` with the last gate code, logs nothing and exits 1 | `mosaic` | R1-R4 with `--cause A5` |
Why A3 writes the marker first and does no cleanup of its own. With the marker written last, as in r4,
an A3 failure after `started` left an open activation with no marker, and R2 and R4 refused it. With
the marker first, every A3 failure after the marker leaves exactly what R2 needs: the open activation,
its marker and whatever part of the rollback set exists. R2 is then the one cleanup path, for A3, A4
and A5 alike. Two cleanup paths, A3's own and R2, would each need their own tests for each failure point.
A2f, a stop that fails or times out, or an A3 that exits 3. This run has no open activation: A2 logs nothing, and
A3's exit 3 comes either before `started` or after its own `refused` line. Nothing of `mosaic`'s has
changed. Root reads `date -u +%s` as T, runs `systemctl start` UNITS in order, then `stack-release.sh gate
OLD mosaic-stack --units external --since T --timeout S`, a new verb that runs `gate` on OLD and writes no
log line, no pointer and no file. A2f has no `started` line to take a start floor from (the first
re-pin after adopt has none at all), so `--since` is the floor, and `gate` refuses `usage` without it
(r5's N2). T is root's own clock, so root still parses nothing `mosaic` wrote. A unit that never
stopped keeps its old `ActiveEnterTimestamp`, fails `stale-start` and takes the exit 2 path below.
If that passes, root logs `A2 failed, OLD running` (or `A3 refused`) in its step log and exits
1. If it fails, root stops UNITS, logs `OLD gate failed <code>` and exits 2. The next run's
`interrupted` check finds nothing open either way.
The external gate. `gate_once` keeps its order and codes, with these changes under `--units external`:
- `systemctl` without `--user` for `is-active` and `show`. Both work unprivileged against the system
manager.
- This start's time is the bus unit's `ActiveEnterTimestamp`, read with `systemctl show --timestamp=unix
-p ActiveEnterTimestamp`, in place of the `START_MS` that `start_both` (:451) sets on dragon-lin.
It is whole seconds (`@1792540800`), and the `started` line has milliseconds (N4). So the rule
compares seconds: the start passes when its second is at or after the floor of `started`'s second.
A unit that was active across A3 can't reach this check, because A3 refuses `units-running`, so the
equal second can only be a start after A3. An earlier second fails `gate_once` with the gate code
`stale-start`. It's a gate failure like any other, not a refusal (r4 had it as a refusal, which
would have logged against the open activation): `gate` keeps polling, since `Restart=on-failure`
can bring a fresh start, and at the timeout `FAILED` holds `stale-start`. The PM check's
`startedAt` bound uses the same second.
- `copy-check` runs `stack-copy.sh check --units external`, which reads the system units.
- The PM check is below.
The PM (r4's B2). .53 runs the PM: the factory's PM dispatches its work, as it does on dragon-lin, and the
serve flags carry `--pm`. The pointer's `pm` comes from the installed bus unit's `ExecStart=`, as the
unit parse at :195 already reads it. Today's `pm_launched` (:505) takes the run id from the
`launched ...` line in `journalctl --user -u BUS_UNIT`. On .53 that read fails twice over: the unit is
a system unit, and under `SplitMode=uid` the logs of a system-range uid land in the system journal,
which `mosaic` (no `adm` or `systemd-journal` group) can't read. Under `--units external` the check
reads `launches list --json` alone. It passes when exactly one entry has business `mosaic-stack`,
instance equal to the business file's `launch.by` (the PM's instance, the one `bus start --pm` launches
at `packages/cli/src/launcher.mjs:675`), `live` true, and `startedAt` at or after this start's time.
Zero entries, two, a stale one or one for another business fail with `pm-launch-unconfirmed` and what
it saw, as today. A run the PM starts for another role has another instance, so it doesn't count.
Why not the other reads. A regular uid for `mosaic` would put its logs in a per-user journal it can
read, but it changes the account Case 2's install creates. Root reading the journal and passing the run id in
would make root parse output from `mosaic`'s process, which section 4's rule forbids.
Under `--units external`, `pm-on` (:1137) and `activate --pm-after-harness` refuse `external-pm`. Both
flip the PM on dragon-lin by restarting the user bus with new arguments. On .53 the PM's state is a
serve flag in a root-owned unit, so changing it is a re-install with its own go.
### Rollback inside the run (R1-R4)
| # | Step | Runner | On failure |
|---|---|---|---|
| R1 | `systemctl stop` UNITS, then wait as in A2 | root | rollback-failed |
| R2 | `stack-release.sh revert ID mosaic-stack --units external`. It finds the open activation as above, puts the wrapper back from the rollback set and checks its sha256, and restores TAKEN (`restore_store`, :396) only if the rollback set holds `MIGRATING`. Each part is idempotent, so it works for an A3 that stopped partway: no `MIGRATING` means no restore, a migrate that failed partway is restored from the backup taken before it, and copying the saved wrapper over an unchanged one changes nothing. It writes no log line | `mosaic` | rollback-failed, activation left open |
| R3 | `case2-unit.sh` with `case1-unit.args` and `--copy OLD`, `systemctl daemon-reload`, `systemctl start` UNITS | root | rollback-failed, activation left open |
| R4 | `stack-release.sh confirm ID mosaic-stack --units external --rollback OLD --cause A3\|A4\|A5 --timeout S`. It finds the open activation, checks OLD is its `from`, gates OLD with `gate`, and writes the activation's one result line from the cause and the `FAILED` code: `failed code <c> rollback ok` (A3, A4), `gate-failed code <c> rollback ok` (A5), or `rollback-failed code <c> rollbackCode <gate>` when OLD's gate fails | `mosaic` | rollback-failed, logged |
rollback-failed: root stops UNITS and exits 2, as `rollback_failed` (:587) does. When R4's gate fails,
the log has the `rollback-failed` line and the activation is closed. When R1, R2 or R3 fails, no
`mosaic` step writes a result, so the activation stays open with its marker. Root's step log has the
codes, the next activate refuses `interrupted-run` until Jason or Mos settles it (:605), and status
shows it. I chose that over a log-only verb for root to call after an R2 failure: nothing could gate
the old stack at that point, and a closed activation would let the next activate start against a
wrapper and store in an unknown state. Exit codes match `stack-release.sh`: 0 ok, 1 failed with the old
stack running (rolled back, or A2f), 2 rollback-failed with UNITS stopped, 3 refused with nothing
changed.
### Rollback to the previous copy: `case1-repin.sh rollback --to ID [--restore ACT --events N]`
The same list with TO in place of ID. The go names TO, from Mos's or Sage's read of `stack-release.sh
status` as `mosaic`. Root passes it. A1's plan, `stack-release.sh rollback mosaic-stack --units external
--to ID --plan --expect-from OLD`, refuses `pointer-differs` as in activate. It also exits 3 unless ID
is `from_log previous` (:162), the previous distinct copy, so a
stale or mistyped go refuses before anything stops. With `--restore`, the plan also refuses unless
`$DATA/backups/stack/ACT` exists. `--to` is a new flag, accepted only with `--units external`;
dragon-lin's rollback keeps reading TO from the log.
The post-backup event count runs in A1's plan as `mosaic` while the bus still holds the store (the
`heldcount` read, :1074), and again in A3 on the stopped store. A difference refuses
`post-backup-events`, as `run_steps` does (:1013).
### Migrations (N4)
`--migrate` under `--units external` runs only inside A3, and A3 refuses `units-running` before it logs
or migrates anything while any of UNITS is active. So a migration never runs against a store a live bus
holds. The test is in section 7.
### Backup
A3's `backup_store` is today's. It copies the stopped store into `$DATA/backups/stack/<ACT>`, so
`rollback --restore <ACT>` and row 181's guard find it. As on dragon-lin, the config dir isn't in the
backup, and the credential isn't either. PBS backups of VM 1008 cover the disk (P4). A restore swaps
only `bus/` and leaves `secrets/claude-session.env` in place (N6, in the run sheet).
The install's adopt (2c') takes no backup. The store holds only what the acceptance wrote, and the first
re-pin's A3 takes the first backup.
### The log
Every `mosaic` line goes to `$DATA/state/stack-activation-log.jsonl`, as today, with `units external`
added. So `from_log previous` reads .53's history the same way it reads dragon-lin's. A3 writes
`started`. A5 or R4 writes the one result line, with the op, from, to and backup of that activation,
or A3 its `refused` line before any change. Refusals of
`confirm`, `revert` and `gate` log no activation id. If a run dies between `started` and the result,
the log keeps `started` with no result, the next run's `interrupted` check reports it, and root's step
log shows the last step that ran.
### Who
Mos or Sage plans each re-pin. ops-01 runs `case1-repin.sh` on Jason's or Mos's go, and a STATE report
follows. The gate and the in-run rollback have one owner, the root command, as they do on dragon-lin.
### dragon-lin's path
`--units external` is a new value. Without it, `stack-release.sh` and `stack-copy.sh check` run as they
do today, `pm_launched` still reads the user journal, and rollback still takes TO from the log.
`confirm`, `revert`, `gate` and `--to` refuse without it. Section 7 names the tests that show this.
### Not taken
- Row 190's user units under `mosaic`, with root running `loginctl enable-linger mosaic` once. Every
existing re-pin tool would work unchanged and root would be needed only once. But the unit form
would differ from Case 2, and a user manager would run as `mosaic`.
- A sudoers rule that lets `mosaic` start and stop the units. P1 and row 230's precheck require that
`mosaic` has no sudo.
- `confirm` and `revert` taking ACT from root. Root would have to read it from `mosaic`'s output or
log, which the rule above forbids (r4's B1).
## 5. Acceptance checks
A new script, `scripts/case1-accept.sh`, in this row's manifest. It runs on .53 as `localadmin` and prints
one line per check: `<id> <path> <mark> <evidence>`. The marks are `closed`, `accepted Q182`,
`accepted Q183`, `accepted mos-1096`, and `unnamed`, which means "not named in Q182, Q183 or a Mos
ruling". The script ends with a count of each mark. It exits non-zero only when a functional check (F)
fails. A P check never changes the exit status. Each `unnamed` line goes to Mos, who takes it to Jason
(R8 N1).
Functional checks. Any failure fails the row:
| Id | Check | How |
|---|---|---|
| F1 | the units run as `mosaic` | `systemctl show <unit> -p User -p MainPID` for each of UNITS; each MainPID's uid in `/proc/<pid>/status` is `mosaic`'s, and each unit is active |
| F2 | the Console listens on 127.0.0.1:732 on .53 and answers there | `curl -fsS http://127.0.0.1:732/healthz` on .53 succeeds, and the listener table shows 732 bound to 127.0.0.1 only, by the Console unit's MainPID. Jason reaches it with `ssh -L 7332:127.0.0.1:732 localadmin@10.1.1.53` and his browser at 127.0.0.1:7332 as today. A LAN listener waits for Q175 SSO (sage-1451 Q3) |
| F3 | one tester message gets a reply | as `mosaic`: `mosaic talk chief <marker> --tester`, then `mosaic follow --tester`, with a 300 s limit. A stored reply to the marked message passes. The inbox alone shows decisions only, so it isn't used (N3) |
| F4 | the precheck passes | `case2-precheck.sh --profile factory` exits 0 as `mosaic` |
| F5 | a role session runs under bwrap with the profile | while F3's reply is pending: a `/usr/bin/bwrap` process owned by `mosaic` has a claude process under it. `/proc/<bwrap pid>/attr/current` equals `bwrap (enforce)` and the claude process's equals `bwrap//&unpriv_bwrap (enforce)`, each compared as an exact string after the trailing newline is cut (N1). If `launches list --json` records the sandbox, the build reads that for the first part, since a short session can end before `ps` sees it |
| F6 | claude is the pinned version | `/srv/mosaic/.local/bin/claude --version` as `mosaic`, after F3, equals the run sheet's pin |
| F7 | the PM run is live | `launches list --json` as `mosaic` has exactly one live entry for the PM's instance, the check `confirm` runs (section 4) |
Privilege and host paths. These report and never fail the row:
| Id | Path | Mark | Evidence the script prints |
|---|---|---|---|
| P1 | `mosaic` has sudo | closed only when `sudo -l -U mosaic` prints "is not allowed to run sudo" (N2); any other output is unnamed | the `sudo -l` result |
| P2 | `mosaic` has supplementary groups or a key | closed when `id -G mosaic` is one entry and `~mosaic/.ssh/authorized_keys` is absent or empty | `id`, file state |
| P3 | `localadmin` NOPASSWD sudo, reached with the dragon-lin key | accepted Q183 (agents keep ssh to .53; localadmin from #423 step 1) | `sudo -l -U localadmin` |
| P4 | root on pve over VM 1008, and PBS backups of VM 1008, which hold `/srv/mosaic` including the credential | accepted Q182 and Q183 (agents keep root on pve and PBS) | a fixed line; the script can't see pve from inside |
| P5 | `jarvis`, which runs Hermes, can reach `/srv/mosaic` through sudo, or through the docker or lxd groups, which are root-equivalent | closed when `jarvis` is in none of sudo, docker and lxd; unnamed otherwise. #423 step 4 lands before the install (mos-1091 4c), and its `step4.sh` removes `jarvis` from sudo, docker, lxd and adm, so after it this reads closed (Duckworth N2) | `id jarvis` |
| P6 | other members of sudo, docker, lxd | closed when sudo holds only `localadmin` and docker and lxd are empty. Every other member is its own unnamed line, `jarvis` included (N1) | `getent group` lines |
| P7 | a file under `/etc/sudoers.d` naming `mosaic` | closed when none | the file names |
| P8 | the Console or tester reachable from the LAN | closed when 732 and 7333 are bound to 127.0.0.1 only, each by the Console's MainPID, and nothing listens on 7331 | listener table |
| P9 | a user namespace through `/usr/bin/bwrap` | accepted mos-1096 only while all seven hold, unnamed otherwise: (1) `dpkg-statoverride --list /usr/bin/bwrap` reads `root mosaic 0750`; (2) `aa-status --json` lists `bwrap` and `unpriv_bwrap` in enforce mode; (3) `/etc/apparmor.d/bwrap-userns-restrict` has the pinned sha256 and no package owns it; (4) `sudo -u jarvis /usr/bin/bwrap --version` fails with "Permission denied"; (5) `getent group mosaic` lists no member and no other account in `/etc/passwd` has its gid; (6) `fs.protected_hardlinks` is 1; (7) no `mosaic-bwrap` profile is loaded, no other file under `/etc/apparmor.d` names `bwrap`, and the live label of a process bwrap runs is exactly `bwrap//&unpriv_bwrap (enforce)` (N1, mos-1098 point 2). Items 1-3, 6 and 7 are `case1-hostcheck.sh --check`'s, so P9 reuses its lines | each item's line, and Mos's reason: "Case 1's agents are the factory's agents. In Q182 and Q183 Jason accepted that the factory reaches root on pve and, through it, every VM. So a user-namespace path to root on .53 gives that class no new reach." With the profile, what's left is bwrap's own setup, run by `mosaic` |
| P10 | a session binds the tester port, 7333, while the Console is down | accepted mos-1101 while `ip_unprivileged_port_start` is above 732 and 7333 is held by the Console's MainPID at the check. Serve refuses a tester port below 1024 (`cli.mjs:33`), so the Console-port fix can't cover it. A squatter would get the tester's writes and could answer them. The tester's users are the factory's agents, the class Mos's reading covers (mos-1101 item 5). Mos can object, and then this line reads unnamed | the floor, and the owner pid of 7333 |
Tests, in `packages/cli/tests/case1-accept.test.mjs`. The script takes a fake root (`--root DIR`) for
`/proc`, `/etc` and `getent` output, and fake `systemctl`, `curl`, `ss`, `sudo`, `bwrap`, `aa-status`,
`dpkg-statoverride`, `sysctl` and `claude` on PATH. Cases:
- each F check failing gives a non-zero exit with its line;
- each P check in each of its marks, including P1 with output that grants nothing but lacks the refusal
text (unnamed), P9 unnamed once for each of its seven items missing, and P10 unnamed with a floor of
700 and with 7333 held by another pid;
- F5 fails on a label of `unpriv_bwrap (enforce)`, `bwrap//&unpriv_bwrap (complain)` and
`bwrap (enforce)` with a trailing space;
- a run with every P check `unnamed` still exits 0;
- the summary counts match the lines.
Mutants: one per F check (the check made to pass on failure), one that maps P5 to `closed`, one that
closes P1 on any output without a rule line, one that drops P9's jarvis item, one that drops P9's item
7, and one that makes F5 a substring match.
Nothing in the tests reaches .53 or dragon-lin's live state.
## 6. Rulings
sage-1451:
- Q1, unit form: the brief's system unit. C2-13's `ExecStart=` check accepts a varying copy id, and
C2-13 took that (gosalyn-0034 item 3).
- Q3, the Console: it listens on 127.0.0.1 on .53 (`packages/webui/src/serve.mjs:466`; `cli.mjs` passes no
host), and Jason reaches it with `ssh -L`. F2 says so. A LAN listener waits for Q175 SSO.
- Q4, the date: if C2-13's candidate is late, I build on C2-13's latest commit and rebase (Q177). Under
the #426(a) push rules (mos-1086) a rebase goes to a new branch name.
mos-1091 (none goes to Jason):
- Q2 4a: installing bubblewrap is an install step, run by ops-01 as root under R27. Rollback is
`apt-get remove`.
- Q2 4b: a per-binary AppArmor profile for `/usr/bin/bwrap`, the sysctl left at 1. r4 takes Ubuntu's
`bwrap-userns-restrict` for it (mos-1096). Section 1 has the steps.
- 4c: P5 ends at #423 step 4, which Mos sent to Duckworth so it lands in Jason's VM 1008 boot session,
before the install. The P5 check stays.
mos-1096:
- B1 and B2 are mine, and so is the PM answer: .53 runs the PM (section 4).
- The `dpkg-statoverride` on `/usr/bin/bwrap`, the hardlink and group checks, the jarvis refusal and F5
under the profile (sections 1 and 5).
- P9 is Mos's acceptance, with the reason quoted in section 5.
- Jason's Q9 on install and upgrade is open. r4 goes ahead, since B1's re-pin and rollback are needed
whatever he answers. If his answer changes how upgrades reach .53, the change lands in section 4 and
the run sheet as a new round.
mos-1098 and mos-1101 (on Duckworth's REVIEW-r3):
- Duckworth's CHANGES stand. B1 and B2 are fixed in this round, with B1's real-sequence test, and all
three of B2's asks: an owner across upgrades, what fails closed, and the pins as reviewed values.
- r4's PM, statoverride and P9 stand. mos-1098's two questions are answered in section 1: the profile's
owner across upgrades is ops-01 with `case1-hostcheck.sh`, and the stop is bwrap closed to `mosaic`.
Both profiles attach to `/usr/bin/bwrap`: `bwrap` to the binary and `unpriv_bwrap` stacked on what it
runs. P9 item 7 checks that no third one does.
- P9 has seven items and F5 compares exact strings (N1).
- The port squat applies (section 1). The Console takes Case 2's fix, a port below 1024 with
`CAP_NET_BIND_SERVICE` and the floor checked. Only the tester port can't take it, and P10 records it
under Mos's reading that Case 1's sessions are the factory class. I've told Mos, who can object.
- Work on r5 goes ahead while Jason's Q9 (Vikunja #429) is open. If install and upgrade become
product commands, these steps become that installer's configuration and row 195 rebases on its row
(sage-1481, sage-1491).
sage-1499: C2-13a is row 230 (#1732, Gosalyn), and row 195 chains on row 230's candidate.
gosalyn-0049: the factory profile is the library set plus an optional `--tester-port`, and row 195 names
any other difference. Section 2 names four, with the reason .53 can't meet the preview trio, `--send` or
the proof pair. The managed-settings route needs no Case 2 change.
## 7. Manifest (planned)
- `scripts/case1-repin.sh` (new) and `packages/cli/tests/case1-repin.test.mjs` (new). Fake `systemctl`,
`sudo` and `case2-unit.sh` on PATH record their argv, with a scratch home standing in for `/srv/mosaic`,
a scratch dir for `/etc/mosaic-stack` and one for `/var/log/mosaic-stack`. Cases:
- a pass runs A1-A5 in order;
- a failure at A3, A4 or A5 runs R1-R4 with `--cause` set to that step, and exits 1;
- an A3 exit 3 runs A2f, not R1-R4;
- a failure at R2 or R4 exits 2 with UNITS stopped;
- A1 refuses `profile-drift` when a fake `case1-hostcheck.sh --check` exits 1, before any stop;
- every `mosaic` step's argv carries `env PATH=<node dir>:/usr/local/bin:/usr/bin:/bin`, with the node
dir from `case1-unit.args`;
- a HEAD mismatch, an ID that fails the copy-id rule, or unit files that name two copies refuses with
exit 3 before any stop;
- A1's plan argv carries `--expect-from` with OLD from the unit files;
- A2f passes `gate OLD --since T`, with T from a fake `date` that root calls before its start;
- A2f: a fake `systemctl` that never reports inactive. The script starts UNITS, runs `gate OLD`, exits
1 with OLD's gate passing and 2 with it failing, and the stack log is byte-identical before and
after;
- `rollback --to` with an ID that isn't `from_log previous` exits 3 before any stop;
- every exec of a path under the home goes through `sudo -u mosaic -H` (from the recorded argv);
- no argv root passes holds an activation id, and OLD comes from the unit files, not from anything
the `mosaic` steps print;
- every `case2-unit.sh` call carries the arguments in `case1-unit.args` and differs only in `--copy`;
- the step log has one line per step with its exit code.
- `scripts/stack-release.sh`: `--units external` for `adopt`, `activate`, `rollback` and `status`, the
`--to` flag for an external rollback, and the new `confirm`, `revert` and `gate` verbs, which refuse
without it. `scripts/stack-copy.sh`: `check --units external`, which reads the system units. Tests in
`packages/cli/tests/stack-release-external.test.mjs` (new), against a fake system-scope `systemctl`
and a fake `mosaic` for `bus status` and `launches list`:
- `units-running` refuses while any unit is active, and `activate --migrate` with a unit active
leaves the store, the log and TAKEN as they were (N4);
- activate leaves the pointer as it was, writes `AWAITING-UNITS`, and writes a backup of the stopped
store under `$DATA/backups/stack/<ACT>`;
- `confirm` and `revert` refuse `open-activation` with zero `AWAITING-UNITS` files, with two, with
one whose copy isn't the one passed, and with one whose activation isn't in the `interrupted` set;
- `confirm` polls: a fake bus whose status turns good after 3 s passes with `--timeout 10` and fails
with `--timeout 1`;
- `stale-start` (N4): with `started` at 1792540800.900, an `ActiveEnterTimestamp` of `@1792540800`
passes and `@1792540799` fails `gate_once` with `stale-start`. A fake that turns to `@1792540805`
after 2 s passes with `--timeout 10`, which shows it's a gate code that polling can clear, not a
refusal;
- `confirm` on a pass writes the pointer, logs `ok` and removes `AWAITING-UNITS`. On a timeout it
writes `FAILED` with the gate code, leaves the pointer, the log and the marker as they were, and
exits 1;
- `confirm` without `--rollback` refuses `activation-failed` when `FAILED` exists;
- every line `confirm` writes has op `activate` or `rollback`, never `confirm`, and the from, to and
activation of its `started` line, and backup equal to the rollback set's `BACKUP`;
- `gate` (N2): with `--since 1792540800`, `@1792540800` passes and `@1792540799` fails `stale-start`;
without `--since` it refuses `usage`;
- `--plan --expect-from` (N5) refuses `pointer-differs` when the pointer names another copy, for
activate and for `rollback --to`;
- A3's order: a fake that fails the capture leaves the log with `started` and `refused code
capture-failed` for that activation, no marker and no `FAILED`, and exit 3. An existing `$RS`
gives `refused code marker-failed` and exit 3, and a capture under `--units external` makes no
directory of its own. A fake that fails the
wrapper install after a migration leaves `started` only, the marker, `FAILED`, TAKEN as migrated,
and exit 1;
- refusals by `confirm`, `revert` and `gate` log with no activation id, and the `interrupted` set
is the same before and after each one;
- the PM check passes with one live entry for the PM's instance since the start, and fails
`pm-launch-unconfirmed` with none, with two, with one whose `startedAt` is before the start, with one
not live, and with one for another business. A second live entry for another instance doesn't
count. A fake `journalctl` on PATH records any call, and no external run makes one;
- `pm-on` and `activate --pm-after-harness` refuse `external-pm`;
- `revert` puts the wrapper back, and restores TAKEN only after a migration;
- `confirm --rollback OLD` writes the line for each `--cause` (A3 and A4 `failed`, A5 `gate-failed`,
with the `FAILED` code, `units-start` for A4, `unrecorded` when the file is missing), or
`rollback-failed` when OLD's gate fails. It removes the marker and `FAILED` after the line, and
refuses when OLD isn't the open activation's `from`;
- real sequences on one log (B1). The fake `sudo` execs the real `stack-release.sh` as the test's
user, so each step reads the log and state the last one wrote. Fakes stand in only for
`systemctl`, `mosaic` and `case2-unit.sh`. Each sequence ends with the log holding exactly one
`started` and one result line for its activation, an empty `interrupted` set, no marker or
`FAILED`, and a following `activate --plan` that doesn't refuse `interrupted-run`:
1. activate, confirm times out, revert, `confirm --rollback --cause A5` passes:
`gate-failed code <c> rollback ok`;
2. activate with `--migrate`, the wrapper install fails, revert restores TAKEN, `confirm --rollback
--cause A3`: `failed code <c> rollback ok`;
3. activate, root's A4 fails, revert, `confirm --rollback --cause A4`: `failed code units-start
rollback ok`;
4. activate whose capture fails: `refused code capture-failed` and nothing open, then A2f's
`gate OLD` writes nothing;
5. activate, then `confirm` with the wrong copy is refused, then revert and `confirm --rollback
--cause A5` still close the activation, because the refusal didn't;
6. activate, revert fails (a wrapper whose saved copy is missing): the activation stays open, and
the next `activate --plan` refuses `interrupted-run`;
7. adopt C0, activate C1 and confirm, activate C2 and confirm (r5's B1): both `ok` lines read
`op activate` with their `started` line's from and to and the backup each took. `rollback --to C1
--plan` exits 0 and `--to C0 --plan` refuses. A rollback to C1, confirmed, logs `op rollback`, and
`from_log previous` then names C2;
8. activate with `--migrate`, where the copy's `migrate-schema.mjs` is a fake that writes to the
store and exits 1 (r5's B2): `FAILED` holds `migrate-failed` and `MIGRATING` exists. revert
restores the store byte-identical to the backup, and `confirm --rollback --cause A3` gates OLD on
it: `failed code migrate-failed rollback ok`;
- `rollback --to` refuses when ID isn't `from_log previous`, and `rollback --restore` finds a backup
that an external activate took;
- `gate` writes nothing: the log, the pointer and the state dir are byte-identical before and after;
- `confirm`, `revert`, `gate` and `--to` refuse without `--units external`.
- dragon-lin unchanged. `stack-release.test.mjs` and `stack-copy.test.mjs` run unchanged and green. A
new case in `stack-release-external.test.mjs` runs a default activate, rollback and check against a
fake `systemctl` that records argv, and every call carries `--user`. A default `activate --plan`
prints the same bytes on the candidate as on its base, with the base output as a fixture.
- Mutants for the re-pin:
- `confirm` writes the pointer on a gate failure;
- `confirm` calls `gate_once` once in place of `gate`;
- the external PM check counts entries of any instance;
- the external PM check drops the `startedAt` bound;
- `confirm` takes the newest `AWAITING-UNITS` instead of refusing two;
- `rollback --to` skips the `from_log previous` comparison;
- `case1-repin.sh` skips R1-R4;
- `case1-repin.sh` exits 0 after A2f;
- `case1-repin.sh` runs `mosaic`'s `stack-release.sh` without `sudo -u mosaic`;
- the default path drops `--user` from one `systemctl` call;
- `confirm`'s timeout writes r4's interim `gate-failed ... rollback pending` line (B1; killed by
sequences 1 and 5);
- A3 writes `AWAITING-UNITS` last, after the wrapper (killed by sequence 2);
- an external verb's refusal logs with the open activation id (ACT set before the checks; killed by
sequence 5 and the refusal test);
- `stale-start` is a refusal again (killed by the polling case);
- `revert` restores TAKEN without `MIGRATING` (killed by the revert case);
- `confirm` logs with its own op, `confirm` (killed by sequence 7 and the op case);
- A3 writes `MIGRATING` after `migrate-schema.mjs` (killed by sequence 8);
- `gate` takes its floor from the newest `started` line when `--since` is missing (killed by the
`gate` case).
- `scripts/case1-pins.sh` and `scripts/case1-hostcheck.sh` (new),
`packages/cli/systemd/mosaic-hostcheck.service` (new) and `packages/cli/tests/case1-hostcheck.test.mjs`
(new). The script takes `--root DIR` for `/etc`, `/proc/sys` and `/usr/bin/bwrap`'s stat, and fake
`aa-status`, `dpkg`, `dpkg-statoverride`, `apparmor_parser`, `systemd-run`, `chown` and `chmod`
on PATH record their argv. Cases:
- `--check` exits 0 with all seven items and 1 with each one failing alone, its line naming it;
item 3 fails on a second file naming `bwrap` as a word, one that writes `/{usr/,}bin/bwrap` included,
and passes with `bwrap-userns-restrict` alone. Item 4 fails on a label of `unpriv_bwrap (enforce)`
or one with a trailing space, and its `systemd-run` argv carries `-p NoNewPrivileges=yes` and the
other 2a' properties. Item 7 fails with the floor at 732;
- `--enforce` exits 0 on a pass and on every failure. On a failure it unloads the profile, closes
bwrap to `root:root` 0700, writes `DRIFT` and one log line. With `DRIFT` present and every item
passing, it still closes bwrap;
- `--enforce` never calls `dpkg-statoverride` with `--update`, `--add` or `--remove`;
- a bubblewrap version other than the install pin is a printed line, not a failure;
- `--upstream` against a fake deb whose file differs prints the difference and exits 0;
- the service unit has `Type=oneshot`, `After=apparmor.service`,
`Before=mosaic-bus@mosaic-stack.service` and runs `--enforce`.
Mutants: `--enforce` exits 1 on a drift; `--enforce` ignores `DRIFT` when the items pass; item 1
drops the `dpkg -S` owner check; item 3 matches only the `/usr/bin/bwrap` path; item 4 matches a
substring; item 4 drops `-p NoNewPrivileges=yes`; item 7 compares with `>=`.
- `scripts/case1-accept.sh` (new) and `packages/cli/tests/case1-accept.test.mjs` (new), section 5.
- Row 230 (C2-13a). `--profile`, the node, bwrap and port-floor precheck lines, the copy-id rule, the
two units and the Console's capability lines are in its brief at 5b8def2f1. Section 2's four items
are not. Any of them that row 230's candidate lacks becomes a named change here, reviewed by
Duckworth with Webby told. I told Gosalyn earlier that the settings-file route for
`DISABLE_AUTOUPDATER` doesn't reach a role session, and that Case 1 uses the managed settings file.
- Docs: the Case 1 run sheet `docs/guides/case1-install.md` (new). It lists the window's steps in order,
each with who runs it and its go, the pinned node sha256 and claude version, a pointer to
`case1-pins.sh` for the bubblewrap and profile pins, the statoverride, the drift stop and the managed
settings file, each with its rollback, how ops-01 clears a `DRIFT`, the `ssh -L` line for port 732,
and a note that a restore leaves the credential in place.
- No change to `~/.mosaic-dev`, `~/.config/mosaic-dev`, dragon-lin's units or `~/.mosaic`.
-11
View File
@@ -1,11 +0,0 @@
# 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`.
-476
View File
@@ -1,476 +0,0 @@
# Row 194 build notes
Design r4 approved: DESIGN.md sha256 824ef884 at 7d0788236 (Webby webby-0014, review at e1e64ff01 on
webby/reviews; Mos mos-1042; Sage sage-1387 "build now"). Build base: refactor aa1bd209e merged into
honker/row194 at fa9eaa4c3.
## Build notes from the approval (each gets a test and a mutant)
- W-N1 (webby-0014 N1): launch refuses `instance-firing` while I has a `fire.stopped` with no `fire.done`.
Test case in T9c. Mutant M59: the check is dropped.
- W-N2 (webby-0014 N2): the broker's hire-time check requires roleVersion 2, so a definition with no
action lists refuses. Test case in T8b. Mutant M60: the version check is dropped.
- Grace (sage-1387): 5 s, one constant shared with row 191.
## Chain state (2026-10-20T19:45Z)
Rows 190, 191 and 193 have no product commits yet (rocko/row191 and darkwing/row190 sit at da230ca48, the
brief commit). Slices that don't need row 191 build first. S6 waits for row 191's candidate.
## Slices
| Slice | Scope | Tests | State |
|---|---|---|---|
| S1 | business: vocabulary (role.hire, role.fire hard waits, HIRE_ALLOWED), role.mjs `hireable`, business.mjs keep/noHire/retired, instance-settings delete and veto ops, chief-of-staff.json `hireable: []` | T6 (business), T7, T8c, T17, D2.2 writer | done, see below |
| S2 | bus: reloadBusiness and runtime callback, instance.launch/end/regenerate requests, instances.set delete, veto.set, instance.hire/fire, keep checks, D4 hook, tester checks | T8-T10, T9c, T12-T14, T18 | S2a (D3), S2b (veto, hook, keep, T13), S2c (requests, terminate, delete) and S2d (hire, fire, W-N1) and S2e (PRD 1.7 routing, sage-1471) done |
| S3 | cli: launcher lifecyclePass, host ops, `business instance` verbs | T1-T6, T11 | S3a (launcher pass) and S3b (verbs, veto, end to end, T2a, T4, T6, T11) done |
| S4 | harness: launch tool schema, hire and fire tools | T8 (D3 item 5) | done, see S4 notes |
| S5 | webui: Agents section and routes | T16 | done, see S5 notes. The "still running after fire" flag waits for S6 |
| S6 | row 191 chain: halted checks, fire SIGKILL through row 191's stop | T9a, T9b, T15 | waits on row 191 |
## Changed assertions (D3 item 4, N4)
Listed here as the build changes them.
- `packages/business/tests/instance-settings.test.mjs:53, :67, :72`: the row 165 read and add views
gain `kept: false` (D1, the read gains `kept` per instance). No other field changed.
- `packages/business/tests/instance-settings.test.mjs:70` (`added.restart` true to false) and `:91`
(`redefined.restart` true to false): the writer no longer asks for a restart. The bus decides,
after its reload (D3 items 2-3). The two test names say so.
- `packages/cli/tests/instances.test.mjs`: `setup` boots the bus from the file through `bootConfig`, as
the unit does, instead of a one-role stub. The add test (T1) now expects the "no restart" line and
binds `sec-1` on the running bus. The `RESTART_NEEDED` text pins moved, unchanged, to a new test that
boots the old stub (`setup(t, { stub: true })`). Its coder has no credential references and the
file's has, so the bus doesn't reload, prints `RESTART_NEEDED` and doesn't bind `sec-1` (D3 item 3).
- `packages/webui/tests/instances.test.mjs:25`: the fake writer's default answer is `restart: false`,
the usual answer now. The real-broker test boots through `bootConfig` and expects `restart: false`
(`:71`) and the log line `done restart=false` (`:74`). The answer-shape rows at `:140-145` keep
`restart: true`. Each row tests another field, or `restart: 'yes'`, so the value is not what they
check.
- `packages/bus/tests/instances-verb.test.mjs` T12 (S2c): the human delete now answers `{request}`
where S2b refused `invalid-request`. The stub writer answers `instances.check` with the revision,
and the calls list gains that one check, actor `jason`. Nothing else in the test changed.
- `packages/bus/tests/tester-writes.test.mjs:19` (row 175 pin): TESTER_VERBS gains `instance.launch`,
`instance.end` and `instance.request` (D4.1). TESTER_ORGANIZATION_VERBS is unchanged.
- `packages/bus/tests/instances-verb.test.mjs:78` keeps `restart: true`, now for a D3 item 3 reason:
its boot copy is a stub without the file's credential references. A comment there says so.
`reload.test.mjs` covers the reload from a file boot.
- `packages/bus/tests/hard-waits.test.mjs:38` (S2d): the every-hard-wait loop gives `role.hire` and
`role.fire` a target and `arguments`, which their raise now requires (D2.1). The class and route it
checks are unchanged.
- S2e (PRD 1.7 REQ-COS-3) takes back the S2d entry above. `role.hire` and `role.fire` left the hard
waits, so `packages/bus/tests/hard-waits.test.mjs` and `packages/business/tests/hard-waits.test.mjs`
drop them from WAITS, and the bus file's every-hard-wait loop is back to its S2c form. The business
file gains a test that both are routed and not in GATED_ONLY.
- `packages/bus/tests/reload.test.mjs` (S2e): its hard-wait example used `role.hire`, now routed. It
uses `spend`. The check is unchanged.
- `packages/business/tests/chief-of-staff.test.mjs` (S2e): the chief's pinned authority and the test
name gain `role.hire` and `role.fire`. `packages/business/tests/role.test.mjs` gains the check that
no other shipped role holds either.
- `packages/harness/tests/tools.test.mjs` and `packages/harness/tests/bundle.test.mjs` (S2e): the
chief's pinned tool lists gain `hire` and `fire`. Every held action needs a typed tool
(`untypedActions`), so the tools come forward from S4.
- `packages/bus/tests/chief-of-staff.test.mjs` (S2e): the S2d decision-form tests are rewritten to the
reason form (below). The S2d raise tests with `arguments` are gone with the key.
- `packages/bus/tests/lifecycle-verbs.test.mjs` V5 retry (S3a): `lifecycleDelete` on a retry now
answers `{transaction, retry: true}` with the failed delete's transaction, where S2c answered
`{transaction: null, retry: true}`. The launcher needs it to name `.retired/I-<transaction>`. The
test checks the value and that the retry still neither checks nor writes the file.
- `packages/harness/tests/tools.test.mjs:48` (S4): the launch tool's `instance` was an enum of the
start-time listing. It is now `{type: "string", description, pattern: ID_PATTERN.source}`, and the
listing moves to the tool description, which the test matches. `:107` (the pm test's chief check)
asserts there is no enum and the description names `chief.launch.instances`.
- `packages/business/tests/chief-of-staff.test.mjs` (S4): the contract test gains a match on the new
sentence that a hire is listed for `launch` at once but runs only after the human sets its route.
- `packages/webui/tests/browser.test.mjs:21-22` (S5): the page's script list pins the scripts in
order. It now has `/agents.js` between `/chat.js` and `/bus.js`, and the comment names row 194.
## S1 notes
- Tests: `tests/lifecycle-settings.test.mjs` (new) holds T6's business part (V5 order, last listed,
clean delete, retired refusals for add and hire), T7 (V6) and the writer's hire and fire (D2.2).
T8c is in `role.test.mjs`, T17 in `business.test.mjs`.
- `deleteRefusal`'s entry half (`entry.tracker`, `entry.credentials`) follows V5's wording "holds
credentials or a tracker bot". In a file that validates it is the same as the definition check: a
definition with no credential refuses an entry tracker or credentials at load. So no test reaches it
alone, and no mutant is listed for it.
- END_PROTECTED is `assistant` only. The chief's own instance refuses delete and fire as
`organization-instance-referenced` (launch.by), not as protected.
- The writer refuses an `assistant` hire `organization-definition-refused` through `definitionFor`, as
row 165's add does.
- PRD draft: 69544f342 (sha256 c1f52d98…), sage-1395: adding to the fixed list is a PRD change.
## S2a notes (D3 live reload)
- `Broker.reloadBusiness(id, doc)` runs the boot check (`checkBusiness`, moved out of the
constructor unchanged) on a clone, then refuses a changed `human` and swaps the document in.
- The runtime reloads after every committed write, through the chain `bootConfig` uses
(`loadBusiness`, `resolveInstance` with `organization.system`, `busBusiness`). Then `limits.refresh`
runs. `instances.set` answers `restart: !live`. Any other verb name now throws `invalid-request`
where it used to fall through to `plugins.set`.
- D3 item 3 check: a role in the boot copy must keep its credential references and tracker bot. A role
not in the boot copy must have neither. The tracker sync block must match. Otherwise no reload and
`restart: true`. The first version compared one fingerprint over all roles, so every create failed
it. T14's create test caught that.
- Consumers checked against item 3 (`runtime.mjs` startBroker):
- The Credentials store takes the role and sync references and the project roots it protects at
start. The tasks adapter takes the bots at start. All of these are compared.
- Limits read `settings.current` at each ask. The human proof reads `human` from the boot copy,
and `reloadBusiness` refuses a changed human, so the two agree.
- Web grants: the broker reads `roles.<I>.web` from its own document (`broker.mjs:1545`), so a
reload carries the grant. A hireable definition has no `web` tool.
- Usage loads its `usage-source` plug-ins from `b.plugins` at start. That was already so before
this row (`plugins.set` never restarted them). A reload doesn't change it, and it isn't compared.
- Instance-running: `instances.set` op change with a `definition` on a declared instance with a
claim holder (or thread holders for a thread instance) refuses `instance-running` before the
writer. A vars or launch change still passes.
- Tests: `packages/bus/tests/reload.test.mjs` (T14, 6 tests).
## S2b notes (veto.set, the D4 hook, keep checks)
- `LIFECYCLE_HUMAN_VERBS` (exported from `broker.mjs`) has one entry per verb and per `instances.set`
op: `instance.launch`, `instance.end`, `instance.regenerate`, `veto.set`, `instances.set add`,
`instances.set change`, `instances.set delete`. Choice: `#mayHuman(s, verb, target)` refuses
`human-required` for an entry missing from the list. So the list and the calls can't drift apart,
and T12 shows the hook got the exact entry: each listed human call passes. The tradeoff is that a
new human verb has to join the list before it works, which is what row 193 needs anyway.
- `bindHuman` already refuses any human but the owner, so the hook's owner check can't fail on any
path today. It is the body row 193 replaces.
- M46 (`veto.set` skips `#human`) is equivalent while the generic check runs first: no session
without `s.human` gets past `human-required`, and only `bindHuman` sets `s.human`, always with
`via: 'cli'` and `outsideAgent: true`. T18 shows the refusal. `#human` stays as the design asks.
- `instances.set` now checks `op` in the broker (`add`, `change`, `delete`, else `invalid-request`).
The tester's delete refuses `tester-refused` (B6). Until S2c records the delete request, a human
delete refuses `invalid-request` after the hook. Without that, the writer's `set` op `delete` would
commit with no running or starting check and no workspace rename.
- Keep: the `role.revoke` reason branch refuses `instance-kept` for any caller, not only the chief.
Only the chief holds `role.end` today, so this is the same set. A caller with `role.end` added
later is held to the forbid too. The `organization.set` check refuses when any key of
`args.instances` is kept, before the writer and before the grant is spent. The human's `limits.set`
isn't checked: the human owns the forbid.
- T13 needed no code. `organization.set` and `limits.set` take only `ORGANIZATION_KEYS`, and
`limits` and `models` write only `launch.limits` and `launch.models`.
- Tests: `chief-of-staff.test.mjs` T10 (reason end, decision end, model write), T13 and T18's
veto.set and instances.set paths on a stub writer. T18's file-bytes check and its hire and fire
cases come with S2d. `instances-verb.test.mjs` T12 (sessions, tester, hook). `reload.test.mjs`
gains veto.set.
## S2c notes (lifecycle requests, terminate, delete)
- A request is an `action.allowed` event, action `lifecycle.launch`, `.regenerate`, `.delete` or
`.end`, body `{action, request, via, by, ...}`. The human's also gets `human.input {kind: 'admin'}`.
The tester's gets none. Steps are `lifecycle.taken`, `lifecycle.done {request, run?, transaction?,
refusal?}` and `lifecycle.cancelled {request, by}`, with no role or run in the session. No new
table or event kind, so no migration.
- Host ops for the launcher pass (S3), each in LAUNCH_OPS: `lifecycleRequests`, `lifecycleTake`,
`lifecycleDelete`, `lifecycleDone`. Choice: `lifecycleDelete` commits through the bus's own
organization callback, so the D3 reload runs after it as after any other write. The tradeoff is
one more host op instead of the launcher calling the writer.
- The take re-checks: request-expired (60 s, `LIFECYCLE_REQUEST_MS`), then launch-revoked and
instance-retired for launch and regenerate only, then tester-off for a tester request. A refused
take writes `lifecycle.done` with the refusal, so the request closes. `lifecycle.end` never
expires: the runs it names still need the signal.
- instance-starting counts any taken request of I, and any open one still under 60 s, delete
included. An open request past 60 s can only close request-expired, so it doesn't count. The end
request doesn't count.
- Delete order at the verb (V5): keys and identifier, the 64-hex revision, the retired-name retry
gate, then `instances.check` (the writer's plan and validation, no write). A named organization
refusal fails at once. A BusinessError (the last-listed refusal) is held until instance-running
and instance-starting have passed, then answers invalid-request. Then revision-conflict, then the
request with `{revision}`. `lifecycleDelete` checks running and starting again after the take
(other requests only), then calls `instances.set` op delete with actor = the request's `by`.
- Retry: a retired name that isn't in roles deletes again only when its last delete closed
`workspace-rename-failed`. The request carries `retry: true`, and `lifecycleDelete` answers
`{transaction: null, retry: true}` with no check and no write. The launcher (S3) retries the rename.
- `lifecycleDone` carries `transaction`, so the delete's done names the commit the rename used.
- Terminate (V3): every claim holder of I, or every thread holder for a thread instance, is revoked
with the reason (default "ended by the human" or "ended by the tester"). The answer names the held
runs plus the bound runs of I not yet ended, cancels open requests of I and lists taken ones. With
none of these it refuses not-held. The tester ends only its own threads and cancels only its own
requests.
- The tester's business scope needs no check: its cap is bound to one business (M19), so another
business's instance answers unknown-role. `instance.request` needs no tester-file check: a tester
reads only requests it made.
- The tester switch is read through a `testerOn` option. The runtime reads `bus.tester` from the
live file through `settings.current`, so a change applies with no restart. Anything but `true`,
or a read error, is off.
- Refactor: the organization error mapping moved into `#organizationFail(e, verb)`, shared by the
organization verbs and `lifecycleDelete`. Same mapping as before.
- Deferred: the halted checks (S6, row 191) and W-N1 (S2d with fire).
- Tests: `packages/bus/tests/lifecycle-verbs.test.mjs` (12 tests: T12 lifecycle part, T12a, V2-V5,
the take and the runtime delete end to end). Probe mutants on the expiry, starting (verb and
take), the take's expiry and tester checks, the retry gate, the tester's own-request read and
not-held all died. They are not the M-list run.
## S2d notes (the chief's hire and fire, W-N1)
- Still hard waits (PRD 1.6). Under PRD 1.7 they move to ROUTED in S2e, with the reason form.
- The raise: `decision.raise` takes `arguments`, only for `role.hire` (`{name, definition, vars?}`)
or `role.fire` (`{name, reason}`), and requires it for those. `target` must equal
`arguments.name`. The raise event records it, and the human's decision view shows it under
`authorization.arguments`. The shape checks are the call's (`#hireFireArgs`): identifiers, not
`human` or `tester`, vars only `model` and `limits.*`, reason 1-500 characters.
- The call: `instance.hire {name, definition, vars?, decision}` and `instance.fire {name, reason,
decision}`, from a chief session only. Order: tester-refused, read-only and agent-required
(`#agent`), keys, `role-not-chief` (launch.by), not-configured, decision-mismatch for a decision of
another business, `#checkAuthority`, the call's arguments against the raise's (canonical JSON,
decision-mismatch), decision-consumed, instance-kept. The writer gets the chief as actor
(`chief-of-staff.chief`) and the context `{instance, run, decision}`, which changes.jsonl records.
- Hire: `#hireable` loads the definition and the chief's own role file through the new `roleFile`
option (the runtime passes `loadRole(rolesDir, name)`). Version 1, any action off HIRE_ALLOWED
(`unhireableAction`), not on the chief's `hireable` list, on `noHire` or no file all refuse
definition-not-hireable before the writer. Then the writer's hire, then the consume with
`{transaction}`. Without `roleFile` a hire refuses not-configured.
- Fire, before step A: instance-kept, unknown-role, organization-definition-refused (END_PROTECTED),
organization-instance-referenced (launch.by), and invalid-request for the last listed name.
Step A, one transaction: revoke the claims with the decision and reason, cancel open requests,
list taken ones, write `lifecycle.end {via: 'chief', by, runs, reason, kill: true}` when runs exist,
and `fire.stopped {cites, request}`. Step B: the writer's fire (already delisted is done). Step C,
one transaction: the consume with `{reason, transaction}` and `fire.done {cites, transaction}`.
- The fire's own events cite the decision under `cites`, not `decision`, so `#spent` counts only the
consume. A failure at B or C leaves the decision unspent, so the repeat runs.
- Choice: a repeat after a failed B or C asks for the kill again for any run of I still bound. A
revoked run stays bound until the launcher reports it ended, and a second signal to a dead
process does nothing. The tradeoff is a duplicate `lifecycle.end` against a run that may outlive
the first one.
- W-N1 `#firing`: a `fire.stopped` with no `fire.done` for the same `cites`. It refuses
instance-firing at the human and tester verbs (`#launchable`), at the take of a launch or
regenerate (`#takeRefusal`), and at the chief's `role.launch` (`#authorityFor`).
- `#organizationFail` passes the writer's own definition-not-hireable and instance-kept through by
name for hire and fire. They come from the file as it stands, after the broker's checks passed.
- `lifecycleView` shows `kill: true` when the request carries it.
- Deferred: the kill escalation in the launcher (S3, S6).
- Tests: `packages/bus/tests/chief-of-staff.test.mjs`, 16 new (T8 x5, T8a, T8b x2, T9 x2, T9c x2,
T10, T18, the runtime end to end, W-N1 at the take). 39 in the file pass. Probe mutants on 21
lines (the arguments check, each W-N1 site, M60's version check, keep, the other-business check,
last-listed, kill, the chief's list, noHire, role-not-chief, the pre-check consume, both consumes'
events, the raise's argument rules, the revoke, the cancel, the pass-through, the view's kill) all
died. They are not the M-list run.
## S2e notes (PRD 1.7 REQ-COS-3 routing, sage-1471, mos-1089)
- Role-policy change, for Webby to review on its own: `roles/chief-of-staff.json` withinRole gains
`role.hire` and `role.fire`. `packages/business/src/vocabulary.mjs` and `packages/bus/src/broker.mjs`
move both from the hard waits to ROUTED. `roles/chief-of-staff.md` and `roles/pm.md` drop them from
the hard-wait list, which `workflow-roles.test.mjs:113` pins to GATED_ONLY. No other shipped role
gains either action, and `role.test.mjs` pins that.
- The call takes the reason form, as `role.end` does: `instance.hire {name, definition, vars?, reason}`
and `instance.fire {name, reason}`, with no decision. The reason is 1-500 characters for both.
- Choice: the decision form is gone, not kept beside the reason form. A within-role raise closes at
once, so a decision there adds an event and no check. The `decision.raise` `arguments` key from S2d
goes with it. Tradeoff: if a later PRD gates hire again, the raise path comes back as new code.
- Choice: hire takes a reason too. The PRD logs every hire and fire with its reason.
- Order: tester-refused, read-only and agent-required, keys, `role-not-chief` (launch.by),
not-configured, `#checkAuthority` with `{target: name}`, instance-kept. `role-not-chief` comes before
the authority check, so a non-chief role refuses `role-not-chief` even when its file grants the
actions. A chief whose file lacks the grant refuses `decision-required`.
- The writer gets only its own keys. The reason goes in the change context `{instance, run, reason}`,
which changes.jsonl records. The hire's consume detail is `{reason, transaction}`.
- W-N1 `#firing` now goes by order, since the reason form has no decision id: a `fire.stopped` of the
subject with no later `fire.done` (`seq` after it) for the same subject. `fire.stopped` carries
`{action, request}` and `fire.done` `{action, transaction}`.
- Harness: the typed `hire` and `fire` tools (verbs `instance.hire` and `instance.fire`) come forward
from S4, because a held action with no typed tool fails `untypedActions`. Each tool follows its own
grant. `roles/chief-of-staff.md` already names them. The rest of the S4 contract text and the runner
end to end for hire and fire stay in S4.
- Going live is a later re-pin step with Mos (sage-1471). Landing the change doesn't put it on the live
stack.
- Correction to S2d (found in S2e): S2d's broker imported `business/src/role.mjs`, which is not in
`seat/src/sandbox.mjs` RUNNER_FILES. The runner imports broker.mjs, so a session died with
ERR_MODULE_NOT_FOUND: cli `launcher.test.mjs` failed 15 of 29 and seat `sandbox.test.mjs` 4 of 10 at
089b92ca3. A bisect showed S2c passes 29 of 29. I had not run those two files for S2d. Fix:
`unhireableAction` moves into vocabulary.mjs, which is mounted, and role.mjs re-exports it.
RUNNER_FILES is unchanged, so the sandbox boundary does not widen. Rule from here (sage-1497): a
change to broker.mjs imports, or those of any RUNNER_FILES file, runs these four singly: seat
`sandbox.test.mjs`, cli `launcher.test.mjs`, cli `thread-launch.test.mjs` and cli
`git-socket.test.mjs`.
- Mutant (sage-1497): broker.mjs imports `unhireableAction` from role.mjs again. Killed: sandbox.test
fails 4 of 10 (role.mjs outside RUNNER_FILES), launcher.test 15 of 29, thread-launch 7 of 11. git-socket
passes 24 of 24 under it, so it does not catch this one and stays on the list for its own sandbox runs.
- Tests: `packages/bus/tests/chief-of-staff.test.mjs` S2d block rewritten to the reason form (39 pass),
with new tests for the reason bounds, `role-not-chief` with the grant, `decision-required` for a chief
without each grant, W-N1 by order, and the runtime end to end with the reason in the receipt context.
`packages/harness/tests/tools.test.mjs` gains the hire and fire tool shape test, and that no coder,
reviewer or pm gets either tool.
- Probe (not the M-list run): 16 mutants, all killed. ROUTED drops of each action in the broker and in
vocabulary, the role file drops of each, `#firing` without the order and without the subject, the
reason unchecked, the writer given the reason, the context without it, no `#checkAuthority`, no
`role-not-chief`, the consume without the reason, the hire tool's reason optional, and the fire tool
gated on the hire grant. The last one survived at first and a one-grant check in tools.test killed it.
## S3a notes (the launcher's lifecycle pass)
- `packages/cli/src/launcher.mjs` `lifecyclePass` runs on the tick after the thread and land passes.
It lists the open requests (host op `lifecycleRequests`) and carries each under `serialize`: the
take, the act, then `lifecycleDone`. A take that answers no closes nothing more here. A refusal
goes in the launch log as `refused` with the request id. A done that throws leaves the request
taken with no done, which S3b's CLI reports as `outcome-unknown`. The log line names a run that
did start.
- Launch: `check()` and `start()` gain `human`. The human's request may name any declared instance,
as V2 asks. The listing limits the chief. launch.by still takes the `pm` branch (N8). A tester
request launches with `launchedBy: "tester"`.
- End: SIGTERM to each run the verb named and to any other run of the instance in the registry,
each after the start-time check, with a `lifecycle-end` entry. The exit handler writes the one
`end` record. A thread instance's end signals only the runs the verb named, so the tester's end
leaves the human's threads alone. No SIGKILL escalation here: that is row 191's stop (S6).
- Regenerate: the end's signals, then a wait until the old run leaves the registry, then a launch.
The wait runs under `serialize`, so no other start runs meanwhile. Past the request's mark
(`at` + 60 s, `lifecycleRequestMs` for tests) it closes `regenerate-timeout` and starts nothing. A
closing host closes it `host-closing`. Tradeoff: one stuck regenerate holds other starts for up to
60 s. The other choice, a second pass that comes back for it, needed state across ticks for the
same result.
- Delete (V5 steps 1-3): `instance-running` against the registry first, then `lifecycleDelete` (the
writer's commit), then the rename of `workspaces/<b>/I/` to `workspaces/<b>/.retired/I-<tx>/`,
both 0700. The file goes first, so a failed rename can't leave a launchable name on the old
workspace. A failed rename closes `workspace-rename-failed` with the transaction. A later delete of
the name is the retry, and the broker now answers the failed transaction, so the rename target is
the same (changed assertion above). No workspace means nothing to rename.
- `--no-launch` (row 163): the ticker now runs on a no-launch host, but only the lifecycle pass, and
`act` closes each request `launches-disabled` before any signal, start or rename. The thread and
land passes stay off, so `launcher.test.mjs`'s no-launch tests pass unchanged.
- Tests: new `packages/cli/tests/lifecycle.test.mjs`, 9 tests on the in-process host of
thread-launch.test.mjs, with ticks called by the test: T2 (unlisted and launch.by launches, a
request past 60 s, a check refusal with its code in the log), T3 (`--no-launch`, `capacity-full`
with the limit in the log, `instance-running`), T4 (end at the verb, the signal, one end record,
the cancelled request never runs), T5 (a new run on the same workspace, `regenerate-timeout`
with a SIGSTOPped runner, `regenerate-thread` at the verb), V5 (a failed rename then the retry,
modes 0700, no workspace).
- Singles at S3a: seat sandbox 10/10, cli launcher 29/29, thread-launch 11/11, git-socket 24/24,
bus lifecycle-verbs 12/12, cli instances 6/6, cli lifecycle 9/9, bus process 8/8. broker.mjs
imports did not change, and the four must-run files ran anyway.
## S3b notes (the CLI verbs)
- `packages/cli/src/instances.mjs`: `business instance launch|regenerate BUSINESS I [--wait S]
[--tester]`, `terminate BUSINESS I [--reason TEXT] [--tester]`, `delete BUSINESS I --revision REV
[--wait S]` and `business veto BUSINESS keep|unkeep INSTANCE|nohire|hire DEFINITION --revision
REV`. `--wait` takes 0 to 600 s and defaults to 30. Every usage error exits 4 before any bus call.
add and set refuse `--wait` and `--reason`.
- Launch, regenerate and delete print the request id on stderr, then poll `instance.request` every
200 ms. Closed with a run or a transaction prints it and exits 0. A refusal prints its reason, the
request id and any transaction. A cancel prints who cancelled it. Still open at `--wait` says so and
names the 60 s expiry. Taken with no done is `outcome-unknown` and points at the launch log.
Terminate doesn't wait: it prints the revoked runs, the cancelled requests and any taken request
past cancelling.
- Exit codes: every lifecycle outcome that isn't done (refusal, cancel, still open, outcome unknown)
exits 3, as the design's `--wait` timeout does. Tradeoff: errors.mjs has 1 for an unknown outcome
elsewhere. One code lets a script treat "the request did not do it" as one case, and the message
says which. A refusal at the verb keeps the transport's code (2), as add and set do.
- The bus `instances` read gains `running` per instance (`#running`, from the claims), so the CLI
and S5 can show it without a second verb. An answer with no instances map passes through as
before. `bus instances-verb.test.mjs`'s stub writer answers `{ok: true}` and caught the first
version, which threw. The CLI view marks `kept` and `running r1, r2` and adds `retired:` and
`the chief may not hire:` lines.
- `scripts/mosaic` sends `business veto` to packages/cli with instances and instance.
- Tests: cli `instances.test.mjs` +8: the V6 veto ops and kept-delete refusal, the `running` read,
launch and regenerate printing the run, a closed refusal with its id (exit 3), `--wait 0` still
open and taken `outcome-unknown`, request-cancelled, terminate output and `not-held`, delete to
its transaction and `workspace-rename-failed` naming it, usage errors (exit 4, no call), and the
scripts/mosaic veto route. The existing route test gains the veto assertions. cli
`lifecycle.test.mjs` +5: the CLI end to end through the pass (launch, regenerate, `capacity-full`
with the request id, terminate, delete), T2a (`launch-revoked` at the take with no process, a
done that fails once leaves the request taken and the next pass starts nothing), T4 (a spawned
run the bus never bound is signalled, a mismatched start time is not), T6 (run evidence bytes
unchanged by a delete, the launch log only grows, a create and a launch of the retired name
refuse), T11 (two roots with business `acme`: a launch, a terminate and a delete in one leave the
other's files byte-identical). bus `chief-of-staff.test.mjs` +1: T11's bus half, a chief on a bus
with two businesses hires and fires only in its own.
- Not covered here: T6's `instance-starting` and the V5 refusal order are bus tests from S2c.
`check()` refusing a retired name is shown through the verb and the take, since `check()` reads a
file that no longer lists the name.
- Singles at S3b: cli instances 14/14, cli lifecycle 14/14, bus lifecycle-verbs 12/12, bus
instances-verb 3/3 (after the fix), bus chief-of-staff 40/40, webui instances 5/5, seat sandbox
10/10, cli launcher 29/29, thread-launch 11/11, git-socket 24/24, usage 2/2, project 4/4, app
30/30, host 42/42, tester-threads 5/5, tester-reads 6/6, stack-copy 12/12, queue dispatch 4/4,
runs task-cli 9/9, webui pinned-copy 10/10, service 31/31, views 3/3. The broker change adds no
import. The four must-run files ran while the `instances` guard was being written, and the guard
touches only the read. No test pins a hash of scripts/mosaic.
## S4 notes (the launch tool takes a hire, D3 item 5)
- `packages/harness/src/tools.mjs`: the launch tool's `instance` is a string with the ID_PATTERN
pattern, not an enum fixed when the bundle was built. The start-time listing stays in the
description. The host checks the listing at each launch (`instance-not-listed`), so the chief
launches its own hire in the same session. The hire and fire tools came forward in S2e.
- The runner does not enforce a tool's schema pattern. A bad name reaches the host and refuses
`instance-not-listed`, so the end-to-end test asserts that. M15 (the enum kept) is killed by the
tools.test shape assertion, not by the end-to-end test.
- `packages/cli/tests/lifecycle.test.mjs` gains the T8 end to end: the chief's session hires
`sec-2` (definition chief-information-security-officer, the shipped definition `hireable` accepts:
message.send and record.write, no credential) with `TOOL hire`, a launch refuses `no-route`, the
human sets the route with `instances.set` op change, the same session launches it (launchedBy
chief), `NOT A NAME` and `reviewer` refuse `instance-not-listed`, then `TOOL fire` ends its run,
leaves it `launched: false` and a later launch refuses `instance-not-listed`.
- Route gap, raised with Sage (honker-0027): harness, model and thinking are agent-layer vars
(`business/src/vars.mjs:142-147`) and hire vars allow only model and limits.* (N3). So a hire runs
only after the human sets harness and thinking. D3 item 5 says the same session launches it, and
it does, after that human step. Letting hire vars carry the route would widen the chief's
authority, a policy change that is not mine to make. As built the human step stays, and
`roles/chief-of-staff.md` says so. Sage ruled (b) in sage-1521: the human route step stays, and
the manifest notes say that launch refuses `no-route` until the human sets the route.
- sage-1521 also keeps exit 3 for every lifecycle outcome but done (outcome-unknown included, the
S3b tradeoff line above stands for Webby) and the S3a choices (a `--no-launch` host runs only the
lifecycle pass, `lifecycleDelete` answers the transaction on a retry). `roles/chief-of-staff.md`
is role policy: the manifest names it and the review request flags it as a policy change.
- `roles/chief-of-staff.md` (role policy, named in the manifest): one paragraph on the hire's route
step and on a fired instance staying in the file off the list.
- Singles, each run alone: business chief-of-staff 10/10, spec-review-contracts 3/3, role 19/19,
resolve 10/10, harness bundle 22/22, tools 23/23, runner 34/34, pi-session 8/8, seat sandbox
10/10, cli launcher 29/29, thread-launch 11/11, git-socket 24/24, lifecycle 15/15, webui reads
11/11. No test pins a hash of tools.mjs or roles/chief-of-staff.md. tools.mjs gains an import from
vocabulary.mjs, already in RUNNER_FILES, and the four must-run files passed after it.
## Rebase onto row 192 (2026-10-20T22:10Z)
Row 192 (4429a104f, Q175 phase 1 SSO) changed webui `serve.mjs`, `cli.mjs`, `app.js` and
`index.html`, the files S5 changes. Before S5 the branch was rebased onto origin/refactor
f41729178 as `honker/row194-b2`. `honker/row194` stays at f70b4da50 (no force-push). The only file
both sides changed was `PRD-1.7-DRAFT.md`, whose blob was the same on both (9bfc6f76), so its five
draft commits dropped as already upstream. The tree check: the rebased tip differs from f70b4da50
by exactly the base's changes. Every test file the branch changes, the four must-run files and
webui sso, service and reads were run again singly on the new base: 23 files, all pass.
## S5 notes (the Console's Agents section, D7)
- `packages/webui/src/public/agents.js` (new, `window.MosaicAgents.section`): Settings draws it after
the role instances, from the same instances read. The table has instance, definition, launched, kept
and running runs, and each row has Launch, a reason box and Terminate. A human also gets Regenerate,
Keep or Unkeep, and Delete. The protected assistant has no Delete. Each definition has a No-hire or
Allow-hiring button, and a Create form adds an instance of an accepted definition. Retired names show
as one line. D7's "retired" column is that line, since a retired name has no instance row.
- Regenerate and Delete take a second click. The confirm says what happens: for Delete, "Its evidence
and its workspace under .retired stay." Cancel asks nothing.
- Choice: every answer that names a request is followed at `/api/agent/request` every 2 s, up to 90
reads, until the request closes. D7 names launch and regenerate. Delete and terminate also answer a
request (S2c), so the page follows them too, and the receipt shows the close or its refusal reason.
A failed read is shown and the poll goes on. The request is never sent again. Past 90 reads the page
says the request is still open and asks for a fresh read. A sign-out stops the poll.
- With `send` false the section shows "Agent actions are unavailable here. Use mosaic business instance
from the instance CLI." and every control is disabled. The tester sees Launch and Terminate only.
- `serve.mjs`: `/api/instances` takes op `delete` with the revision and the instance alone and answers
the request. `/api/agent/launch`, `/regenerate`, `/terminate` and `/api/veto` each have
`instanceSend`'s boundary and one call to `agentWrite`, with only the route's keys. A reason is never
logged. `GET /api/agent/request?id=` takes one request id and adds the reason for a closed refusal or
a cancel. Every refusal code the S2/S3 verbs answer has a reason line (`AGENT_REASONS`), and a code
missing there shows as itself. No route has a `--send` exception.
- `cli.mjs`: `agentWrite` reaches only `instance.launch`, `instance.regenerate`, `instance.end`,
`veto.set` and `instance.request` (`agentCaller` throws for any other). The tester listener gets
launch, end and request only (D4.1).
- D4.1 at the webui: the tester listener lets launch and terminate through and refuses regenerate and
veto with no call. A tester delete on `/api/instances` is refused in `instanceSend` with no call
(`tester-refused`). The bus refuses it too (broker tester-refused), and this keeps the webui from
asking. The agents test covers it, and with the guard removed that test fails (control run).
- Deferred to S6: the "still running after fire" flag needs the `lifecycle.end.alive` event, which
comes with row 191's stop.
- Tests: webui `agents.test.mjs` (new, 8: actions and logs, boundary refusals, read-only, refusal and
unknown outcomes, the request read, delete, tester routes, agents.js served) and
`agents-browser.test.mjs` (new, 1: read-only, launch followed to close, confirms, terminate reason,
refusal, keep and unknown outcome).
- Singles, each run alone, after the last code change: webui agents 8/8, agents-browser 1/1, browser
1/1, instances 5/5, instances-browser 1/1, serve 4/4, service 31/31, sso 28/28, sso-browser 2/2,
routes 5/5, routes-browser 1/1, tester-listener 10/10, tester-browser 2/2, tester-cli 2/2,
settings-write 4/4, send 13/13, send-browser 2/2, shell 5/5, cli launcher 29/29, thread-launch
11/11, git-socket 24/24, stack-copy 12/12, cli stack-release 174/174, seat sandbox 10/10. No test
pins a hash of a file S5 changes. shell.test reads bus.js strings and its POST count, which S5 leaves
alone because agents.js holds the section's writes.
-707
View File
@@ -1,707 +0,0 @@
# Row 194 design: agent lifecycle from the Console and the CLI, with a per-agent veto
Brief: `docs/plans/2026-10-20_agent-lifecycle.md` (blob a907109409ac). Gitea #1692. MVP T1 item 5 (mos-0974,
mos-0977, DECISIONS Q176). Owner Honker, reviewer Webby, gate owner Sage. Base: refactor at 53bb59462.
Branch `honker/row194`.
Related designs read: row 190 `agents/darkwing/work/queue-190/DESIGN.md` (r2, after mos-0986), row 191
`agents/rocko/work/queue-191/DESIGN-r2.md`. Row 193 is still in design. All line numbers below are at
53bb59462.
## Summary
- One verb family on the CLI, `mosaic business instance <verb> BUSINESS INSTANCE`, and one Console section.
Create and delete change the business file. Launch, terminate and regenerate act on runs.
- Every human verb goes through the bus with the human proof. Launch, regenerate and delete are requests the
launcher takes once and carries out under `serialize`. Launches go through the one `start()`, so
`--no-launch`, the limits, `launch.revoke` and row 191's switch all apply.
- The chief hires (adds and lists an instance) and fires (ends its runs and delists it). It never deletes.
`role.hire` and `role.fire` are new hard waits, so Jason resolves each one (Mos, mos-1017). The routed
form waits on a PRD 1.7 draft in this packet.
- A hire allowlist in code: the chief can hire only a definition whose every action is on `HIRE_ALLOWED`
(message, record, review request, assigned-task updates, and two actions that matter after row 199).
Launch, end, organization, naming, hire, verdict, merge and every later action stay out.
- The per-agent forbid is two lists in the business file that only the human writes: `launch.keep`
(instances the chief can't fire, replace or end) and `launch.noHire` (definitions the chief can't hire).
- The broker reloads a business from its file after each committed write. Create, delete, hire, fire and
veto changes then count without a bus restart. Hand edits still need one, except `bus.tester`.
- No bus schema change and no migration. Named for review: two new vocabulary actions (hard waits), the
role-file `hireable` key, `"hireable": []` in `roles/chief-of-staff.json`, and three business-file keys.
## What exists today (checked at 53bb59462)
| # | Fact | Where |
|---|---|---|
| E1 | Instances are keys of the business file's `roles`. `launch.instances` lists those the chief may launch. The list must be non-empty | `business.mjs:121-132` |
| E2 | The human adds an instance and changes its definition, vars and listing. No delete. Credentialed definitions are refused | `instance-settings.mjs:1-5, 22-31, 75-156` |
| E3 | The broker copies the businesses once at start and refuses a role it doesn't know | `broker.mjs:208, 446` |
| E4 | The chief launches over `launch.sock`. `fromSession` checks identity, then `check()` (which reloads the file), then `authorizeLaunch`, then `start()` | `launcher.mjs:232-327, 624-668` |
| E5 | The chief's `launch` tool takes an enum of the instances listed when its bundle was built | `harness/src/tools.mjs:116-119` |
| E6 | The chief ends a run with `role.revoke` plus a reason (`role.end`). Ending then launching again is already a replace | `broker.mjs:1601-1628`, `tools.mjs:167` |
| E7 | `mosaic stop RUN` is a same-uid SIGTERM. It has no bus call and no human proof | `cli.mjs:447-454`, `seat/src/session.mjs:249-270` |
| E8 | The Console stops only the assistant (`assistant.stop`) and writes instances through `instances.set` | `serve.mjs:282-289, 315-363, 535` |
| E9 | The launcher already starts runs for the human on its 2 s tick: threads with pending messages, and merge holders | `launcher.mjs:133, 487-597` |
| E10 | `launch.revoke` and `launch.restore` are human-only. A revoked business refuses `role.launch` | `broker.mjs:770-774, 2087-2095` |
| E11 | Only the assistant is END_PROTECTED. Coder, reviewer, pm and cto all declare gitea and vikunja credentials | `vocabulary.mjs:135`, `roles/*.json` |
The chief's veto today comes from the human-only membership in E2. The chief can only launch what the human
listed. It has no way to create an instance, and the human has no way to stop the chief ending a given
instance's runs.
## D1. The verbs
All of them are human verbs. Each is a broker verb behind `#human(s)` and then the D4 hook. Each logs its
actor. The CLI usage joins `INSTANCES_USAGE` (`cli/src/instances.mjs:9`).
| Verb | CLI | Bus verb | Effect |
|---|---|---|---|
| V1 create | `business instance add B I --definition DEF [--vars FILE] [--launch] --revision REV` | `instances.set` op `add` | as today, with no restart (D3) |
| V2 launch | `business instance launch B I [--wait SECONDS]` | `instance.launch` | one run, through the launcher |
| V3 terminate | `business instance terminate B I [--reason TEXT]` | `instance.end` | ends every run of I and cancels I's open requests |
| V4 regenerate | `business instance regenerate B I [--wait SECONDS]` | `instance.regenerate` | V3, then V2 once the run has exited |
| V5 delete | `business instance delete B I --revision REV [--wait SECONDS]` | `instances.set` op `delete` | a request. The launcher removes I from the business file and renames its workspace |
| V6 veto | `business veto B (keep\|unkeep) I` and `business veto B (nohire\|hire) DEF`, each `--revision REV` | `veto.set` | edits `launch.keep` and `launch.noHire` |
`mosaic business instances B` (read) gains, per instance: `running` (run ids from the bus claims), `kept`
and `retired`. The top level gains `noHire`. `mosaic stop RUN` stays as it is. It is the same-uid operator
tool, and V3 is the human verb.
### D1.1 Requests (V2, V4, V5)
The broker never starts a process or touches a workspace. Launch, regenerate and delete are requests the
launcher carries out, as E9 already does for the human's threads. A request has three states, open, taken
and closed, all recorded as `action.allowed` events with reserved action names:
| Event | Written by | Body |
|---|---|---|
| `lifecycle.launch`, `lifecycle.regenerate`, `lifecycle.delete` | the broker, at the verb | `{ request, via }`, subject I, actor the human or `tester`. A `human.input` event goes with it, as `launch.revoke` does |
| `lifecycle.taken` | host op `lifecycleTake` | `{ request }` |
| `lifecycle.done` | host op `lifecycleDone` | `{ request, run? , refusal? }` |
| `lifecycle.cancelled` | V3, for each open request of I | `{ request, by }` |
1. The launcher's new `lifecyclePass` runs on the same tick as `threadPass` and `landPass`. It lists open
requests with host op `lifecycleRequests`.
2. For each one, under `serialize`, it calls `lifecycleTake(request)`. That op runs in one store
transaction. It re-checks, in order: the request is still open (not taken, cancelled or closed), it is
less than 60 s old (else it closes as `request-expired`), the business isn't revoked (`launch_state`,
`launch-revoked`), I isn't retired, row 191's switch is off (`processing-halted`) for launch and
regenerate, and for a tester request `bus.tester` is true in the file (D4.1). Any failure closes the
request with that code. Otherwise it writes `lifecycle.taken` and answers go.
3. Only after go does the pass act: `check(I, { human: true })` then `start()` for a launch, the V5 steps
for a delete.
4. The pass then calls `lifecycleDone`. A taken request never runs again, whatever happens after the take.
If `lifecycleDone` throws, the request stays taken with no done event. The CLI reports that as
`outcome-unknown` with the request id, and the launcher log names the run.
5. The CLI polls a read verb, `instance.request`, until the request closes or `--wait` runs out (default
30 s). A timeout exits 3 with the request id. An untaken request can still be taken within its 60 s.
So the human's `launch.revoke` stops every untaken request, not only new ones (B4), and a bus restart
can't start a request older than 60 s.
### V2 launch
1. At the verb the broker checks that I exists, isn't a thread instance (`not-thread-launch`: threads start
on a message), isn't retired, and that the business isn't revoked and, with row 191, isn't halted. These
repeat at the take (D1.1 item 2).
2. After the take, `check(I, { human: true })` and `start({ instance: I, launchedBy })`, where
`launchedBy` is the business human or `tester`. `human: true` lets the human launch an instance that
isn't in `launch.instances`. The listing limits the chief, not the human. `launch.by` still goes through
the `pm: true` branch (`launcher.mjs:235`), as `--pm` does today. Every other step of `check()` and
`start()` is unchanged.
### V3 terminate
1. The broker revokes every claim I holds (`#claimEnd(s, h, 'revoke', null, reason, actor)`). A thread
instance has all its thread claims revoked, which is exactly `assistant.stop`.
2. In the same transaction it writes `lifecycle.cancelled` for every open (untaken) request of I, so no
launch the human asked for before the terminate starts after it (B5).
3. It writes `lifecycle.end` with the runs. The launcher pass sends SIGTERM to each run's runner after
checking its start time, as the idle stop does (`launcher.mjs:526`). It also signals any run of I in the
launcher's registry that has spawned but not yet bound, which holds no claim yet. The existing exit
handler records the end, so the launch journal and `session.ended` are unchanged.
4. Nothing held, nothing open and nothing in the registry answers `not-held`.
5. Terminate is allowed while row 191's switch is on. It starts nothing.
A taken launch request is past cancelling. Its run reaches the registry under `serialize`, and the next
terminate ends it. The CLI's terminate output says so when a taken request exists.
There is no SIGKILL escalation in this row. A run that ignores SIGTERM keeps running. `mosaic stop` and the
limits are the operator's tools for it (N6).
### V4 regenerate
A fresh session with the same identity and role. It is V3 then V2 under one request. The pass takes the
request, waits for the old run's exit handler, then runs `check()` and `start()`. The workspace stays, as
it does across every launch today. The session is new because every non-thread launch gets a new run dir
(`seat/src/session.mjs:107`, newRun). If the old run hasn't exited by the request's 60 s mark, the request
closes as `regenerate-timeout`, nothing starts, and the old run is still running (N6).
A thread instance (the assistant) refuses `regenerate-thread`. Its context lives in the thread's session
directory, and clearing that is T1 item 7 (clear context). This row doesn't take it.
### V5 delete
`instances.set` gains op `delete`. At the verb the broker and the writer refuse, in this order:
| Refusal | When |
|---|---|
| `tester-refused` | the caller is the tester (B6) |
| `organization-definition-refused` | I is END_PROTECTED |
| `organization-instance-referenced` | I is `launch.by` or an arbiter. The message names the reference |
| `organization-instance-kept` | I is in `launch.keep`. The human runs `veto unkeep` first. Delete never clears a veto silently |
| `organization-credentials-out-of-scope` | I holds credentials or a tracker bot. That stays a hand edit until the credentials row (row 199) |
| `instance-running` | the broker sees a claim for I. The human terminates first |
| `instance-starting` | an open or taken request for I has no done event |
| BusinessError | I is the last name in `launch.instances` (E1). The message says so |
The checks pass with no write, and the broker records a `lifecycle.delete` request. After the take, the
launcher pass, under `serialize` so no `start()` of I runs alongside it:
1. Re-checks the registry for I (`instance-running`) and re-runs the writer's checks against the file as it
stands. The `--revision` the human gave must still match, else `revision-conflict`.
2. Commits the file through the organization writer. It removes `roles.I` and the references that exist
only for I: its `launch.instances` entry, its `launch.limits.tokens.instances` entry and its
per-instance model entry. It adds I to a new top-level list, `retired`. The build lists every place
`validateBusinessDocument` reads a role instance name and puts each one in one of the two groups (refuse
or remove). A reference the table doesn't name stops the build and goes back to Webby.
3. Renames `workspaces/<b>/I/` to `workspaces/<b>/.retired/I-<transaction>/`, mode 0700. No workspace is
no rename.
4. Calls `lifecycleDone` with the transaction.
The file goes first. Once it is committed, `check()` refuses I as unknown, so no launch can open the old
workspace even if step 3 fails. A failed rename closes the request with `workspace-rename-failed`, the
transaction and the path left behind. A second `delete` of a retired name skips steps 1 and 2 and retries
step 3 only. The file and evidence are already right, so this is the whole recovery.
What stays:
- Run evidence (`launches/<b>/<run>/`, `launches/<b>.jsonl`) and bus rows are untouched. They are
write-once.
- The workspace, under `.retired`. This row deletes no file. A prune with a receipt is a later act.
- `retired` stops a later create or hire from reusing the name. Decisions bind to the role instance and
outlive restarts (`broker.mjs:783-788`). A new `coder` would otherwise inherit an approval raised by the
old one. The tradeoff is a name that can't come back without a hand edit. I chose that over voiding
decisions, which would touch a second store.
### V6 veto
`veto.set` args: `{ revision, op: "keep" | "unkeep" | "nohire" | "hire", name }`. It goes through the
organization writer, so it is logged with actor and diff in `changes.jsonl`. `keep` needs a declared,
non-END_PROTECTED instance. `nohire` needs a name that matches the id pattern. A no-op answers
`organization-no-change`. It is human-only and isn't in `TESTER_ORGANIZATION_VERBS`.
## D2. Chief hire and fire
### D2.1 Authority (Mos, mos-1015 and mos-1017, through Sage, sage-1362 and sage-1365)
This row adds `role.hire` and `role.fire` to `ACTIONS` (`vocabulary.mjs:11`). Under PRD REQ-WAIT-3 a new
action is a hard wait until a PRD version says it isn't (`vocabulary.mjs:52-57`), so both ship as hard
waits. Jason resolves each one. There is no PRD change and no grant in a role file: `role.mjs:42` refuses
a withinRole that lists a hard wait, and REQ-WAIT-2 forbids it.
The flow in this row:
1. The chief raises a `role.hire` or `role.fire` decision whose body carries the arguments (business,
name, definition, vars, or name and reason). A hard wait goes to the human only. No arbiter resolves it.
2. Jason resolves it in the Console or the CLI.
3. The chief calls `instance.hire` or `instance.fire` citing the decision. The broker checks it with
`#consumeAuthority(s, 'role.hire', { decision, target })`, as the `role.revoke` decision branch does,
and the arguments must equal the decision body field by field (N1, webby-0010). For hire that is
`name`, `definition` and `vars` (vars compared with `canonicalJson`, absent equal to absent only). For
fire it is `name` and `reason`. A key in the call that the body lacks, or a body key the call lacks,
refuses `decision-mismatch`. Every check below runs again at this call, so a veto set after the resolve
still refuses.
The routed form, where the chief hires and fires without a decision each time, needs a PRD version that
moves both actions to `ROUTED`. My draft is `agents/honker/work/queue-194/PRD-1.7-DRAFT.md`. It quotes
TARGET.md line 66 and carries Mos's bounds. Jason approves it as a `prd.approve` decision in the Console,
and Sage opens that decision. When it lands, a later commit adds the two actions to both `ROUTED` lists
(`vocabulary.mjs`, `broker.mjs:43-69`) and to `roles/chief-of-staff.json` withinRole. The broker then
takes the `reason` form instead of a decision. The verbs and every check below stay the same. This row
builds and tests the hard-wait form only. It writes the `hireable` key and its allowlist now (D2.3), because
both forms use them.
### D2.2 What the chief may do
| Act | Bus verb | Rule |
|---|---|---|
| launch | `launch` (host) | as today: listed instances only |
| end a run | `role.revoke` + reason | as today, and refused `instance-kept` when the target is kept (D2.4) |
| hire | `instance.hire { name, definition, vars?, decision }` | adds and lists an instance. Refusals below |
| fire | `instance.fire { name, reason, decision }` | ends I's runs, cancels I's open requests, and removes I from `launch.instances`. Refusals below |
Only the business's chief, the instance named by `launch.by`, may call either verb. Any other caller
refuses `role-not-chief`, even with a resolved decision, as `#classification` already limits
`role.launch`. The broker reads `hireable` from that chief's own role file. Both act only in the chief's
own business. Neither verb takes a business argument. The broker uses the
cap's business, and a decision raised in another business refuses `decision-mismatch`. Row 191's switch
refuses both while halted (D6). Every hire and fire writes `changes.jsonl` through the organization writer
with actor the chief's instance and run, and the decision id.
Hire refuses, in order:
1. `definition-not-hireable` when the definition holds an action outside `HIRE_ALLOWED` (D2.3), whatever
any list says.
2. `definition-not-hireable` when the definition isn't in the chief role file's `hireable` list, or is in
the business's `launch.noHire`.
3. `organization-definition-refused` for END_PROTECTED, `organization-credentials-out-of-scope` for a
credentialed definition (until the credentials row, row 199), `invalid-request` for version 1.
4. `organization-instance-retired` when the name is retired.
5. When the name exists: a fired instance (in `roles`, not listed) is listed again only if `definition`
equals its current one and `vars` is absent. Any other definition or vars refuses
`organization-instance-exists`, and so does a listed name. A re-hire never moves an instance's
decisions to a different role (B3).
Hire's `vars` may hold only the keys the chief can already set on an instance through `organization.set`
(the model key, `broker.mjs:1266-1269`) and narrowing `limits.*` keys. Anything else, such as `harness`
or a route, refuses `invalid-request`. The human sets those (N3).
Fire refuses `instance-kept` for a kept instance, `organization-definition-refused` for END_PROTECTED,
`organization-instance-referenced` for `launch.by`, and a BusinessError for the last listed instance. A
fired instance stays in `roles`. The chief never deletes. Delete stays a human act because it retires the
name.
Fire ends the fired instance's processes, not only its claims (F4, Megavolt through sage-1374). Every run
gets the shared model token at start: `ENV_ALLOW` (`packages/seat/src/session.mjs:83-94`) passes
`ANTHROPIC_API_KEY`, `CLAUDE_CODE_OAUTH_TOKEN` and the other provider variables into the sandbox. A revoke
can't take that token back, so a fired agent whose process lives on still holds it.
The delist is a business file commit through the organization writer, and the revoke and events are store
rows. No transaction covers both (webby-0011 B2), so fire runs in a fixed order, store side first:
A. One store transaction: steps 1 to 3 below, plus `fire.stopped` with the decision id. The decision isn't
consumed yet.
B. The delist through the organization writer (`launch.instances` loses I, logged in `changes.jsonl`).
C. One store write consuming the decision and recording `fire.done`.
If B or C fails, fire answers the step's code and the decision stays unconsumed. A second fire citing the
same decision runs again. A finds nothing to revoke, cancel or stop, and that isn't a refusal for fire
(unlike V3's `not-held`). B finds I already delisted and counts as done. C consumes. So a fire is safe to
repeat until it's consumed. The other order would leave a delisted agent with a live process and its model
token when the store step fails, which is the case F4 exists to stop. A halt between A and B doesn't stop
B or C, because neither starts anything. A second fire while halted refuses (D6), and the halt's own stop
covers the runs.
Steps 1 to 3 are V3's, with one change:
1. It revokes every claim the instance holds.
2. It writes `lifecycle.cancelled` for each of the instance's open requests.
3. It writes `lifecycle.end` with the runs and `kill: true`. The launcher pass stops each runner, spawned
but unbound registry runs included, with escalation (sage-1380 item 1):
- SIGTERM after the identity check.
- After the grace, SIGKILL to any runner still alive, after the same identity check again, and
`lifecycle.end.killed` with the run and pid.
- A runner still alive after SIGKILL and the observe wait gets `lifecycle.end.alive`. The Console's
Agents section shows it to the human.
A runner whose identity no longer matches is never signalled. Every check, the recheck after the
grace included, uses the identity (start ticks plus boot id), never a bare pid, so a reused pid never
counts as the fired run (webby-0011 N2).
Fire's answer lists the runs it is stopping. The delist stops any new chief launch (`launch` takes listed
instances only). A human launch request already taken is past cancelling, as in V3. The human made it, the
fire answer names it, and the human's terminate ends its run.
The stop is row 191's per-run stop, not a second one, so the two can't fight (sage-1380 item 1). Row 191's
design r2 (`agents/rocko/work/queue-191/DESIGN-r2.md`, "Stop policy and completion") sends SIGTERM, waits
5 s, sends SIGKILL to verified groups and namespaces, and observes for 2 s. Its identity check is start
ticks plus boot id, which replaces the start-time check at `launcher.mjs:526`. Fire calls that routine for
one instance's runs and uses its constants, so the grace is row 191's 5 s, not the 10 s in r3. The order:
- Fire is refused while halted (D6), so a halt sweep never meets a fire that started after it.
- A halt that comes during a fire's stop finds those runs already stopping. Each run has one stop receipt
(row 191's), and the first stop owns it. The halt sweep joins it instead of sending a second SIGTERM or
restarting the deadline, and counts the run in its remaining or quiescent totals.
- The record says which stop owned it: reason `fire` with the decision, or row 191's `kill-switch` with
its generation.
V3 terminate is unchanged. It still sends SIGTERM only (N6). Only fire escalates, because only a fired
agent must lose the token at once. If row 191 hasn't landed when this row builds, the build chains on its
candidate (Q177), as D6 already says.
### D2.3 The hire allowlist (B2, sage-1365 item 4, webby-0010 B1)
r2 and r3 had a deny list, `HIRE_FLOOR`. Webby's B1 showed it missed routed actions (`git.merge.protected`,
which `roles/merge-gate.json` holds with no credentials, `decision.resolve.technical`, `policy.change`,
`prd.approve`), and that any action added later would be hireable by default. That isn't fail-closed. r4
replaces it with an allowlist.
A definition is hireable only if every action in its withinRole and crossRole is on `HIRE_ALLOWED`:
| Allowed action | Held today by |
|---|---|
| `message.send`, `record.write` | every v2 role file |
| `review.request`, `task.update.assigned` | coder, cto, pm, reviewer |
| `task.scope.change`, `git.push.working` | coder, cto, pm (credentialed, so they matter only after row 199) |
Everything else in `ACTIONS` fails, including the actions of the old floor (`role.launch`, `role.end`,
`role.revoke`, `organization.change`, `instance.name`, `review.verdict`, `role.hire`, `role.fire`) and those
B1 named. An action added to `ACTIONS` later fails until a reviewed commit adds it here.
`git.push.remote` (row 199, routed to coder by PRD 1.7) isn't on the list. A coder holding it stays
unhireable until a reviewed commit adds it, after the credentials bound is lifted.
Against today's role files: chief-of-staff, cto, merge-gate, pm, reviewer and security-reviewer fail.
Assistant, researcher, coder and the five C-suite files pass the allowlist. Assistant is END_PROTECTED and
coder is credentialed, so D2.2 item 3 refuses them. The C-suite files hold only `message.send` and
`record.write`, so hiring one gives no authority beyond its name. `hireable` still has to list it.
The list is a constant in `business/src/vocabulary.mjs`, `HIRE_ALLOWED`. It is checked twice:
- At load. `role.mjs` validates the chief role file's `hireable` list: distinct definition names, each
present in `rolesDir`, version 2, and every action on `HIRE_ALLOWED`. A list naming one that isn't refuses
the role file (`hireable names <def>, which holds <action>`). The check reads only the named file's authority lists,
never its `hireable`, so a list can't recurse. `hireable` is an optional key on any role file. This is a
role schema change, named for review.
- At hire, in the broker, against the definition as loaded then. A role file edited after load is caught
here.
`hireable` in `roles/chief-of-staff.json` stays empty in this row (`[]`, Q4, sage-1374). Naming the first
role kind is a separate policy commit in `roles/`, made after Jason resolves the `prd.approve` decision, as
AGENTS.md invariant 7 requires. With an empty list the chief can hire nothing, even with Jason's resolve,
until that commit. The row's tests use scratch role files whose `hireable` names a kind.
### D2.4 The per-agent forbid and the veto (sage-1365 item 3)
TARGET.md line 66: "Chief of staff agent has hire/fire authority for agents as needed; unless user forbids
per agent". The forbid is two lists in the business file that only the human writes:
- `launch.keep`: instance names. The chief can't fire, replace or end a kept instance. The broker refuses
`instance-kept` for every chief act aimed at it except launch:
- `instance.fire`, even with a resolved `role.fire` decision. Mos's limit is that Jason's veto applies
to every hire and fire, so the human unkeeps before a fire.
- a hire of that name.
- `role.revoke` in the reason branch (`broker.mjs:1607-1613`). End then launch is a replace (E6).
- an `organization.set` write of that instance's model key (B7). A model change is a replace at the next
launch.
The `role.revoke` decision branch (`broker.mjs:1614-1615`) still ends a kept run. It ends a run, it
doesn't fire or replace, and it cites a decision the human resolved (Webby's Q2 ruling). The human's V3
and the limits also still end a kept run.
- `launch.noHire`: definition names the chief can't hire into this business (D2.2 item 2).
Why the chief can't change the forbid:
- Only `veto.set` writes the lists. It is human-only (`#human(s)` then the D4 hook) and refuses the tester.
- `organization.set`, the chief's one write verb, reaches only `ORGANIZATION_KEYS` (`broker.mjs:145`),
which exclude `launch` and `retired`.
- `instances.set` is human or tester only (`broker.mjs:1248-1298`).
- Hire and fire carry no field that writes either list.
T18 tests each of those paths from a chief session and checks the business file bytes are unchanged.
END_PROTECTED instances can't be kept (N7). That is intended: Q169 lets the chief end the assistant's
runs, and the assistant's own stop is `assistant.stop`. Default, per "unless user forbids": nothing is
kept and nothing is on `noHire`.
### D2.5 Where it lives and how the broker enforces it
The forbid lives in the business file, under `launch`, beside the membership it limits:
`launch.keep: [instance]`, `launch.noHire: [definition]`, and top-level `retired: [instance]`. `hireable`
lives in the chief's role file, because it bounds the role, not one business.
`checkLaunch` (`business.mjs:118`) gains the two keys. Each list is distinct. `keep` names declared,
non-protected instances. `noHire` entries need only the id pattern (N2). A stale deny entry is harmless,
and a reviewed commit that removes a role file must not stop every business that lists it. `retired`
names none of `roles`. This is the business-file schema change for review. Old files without the keys load
unchanged. An old bus loader rejects a file that has them (`rejectUnknownKeys`), so a rollback past this
row needs the keys removed first. The activation plan has to say so (R4).
The broker enforces from its live copy of the business, which D3 keeps current. The checks sit in the
`role.revoke` reason branch, in `organization.set`'s model write and in the two chief verbs, before any
write.
## D3. Live changes
Today the broker's `#businesses` is fixed at start (E3), and `instances.set` answers `restart: true` for
add and redefine.
1. `Broker.reloadBusiness(id, document)` swaps in one business's validated document. It is synchronous, so
no request sees half of it. It refuses an unknown id and a document whose `human` changed (that is row
193's ground).
2. The runtime's organization callback (`runtime.mjs:121-133`) calls it in one place, after every verb
that commits a write, whatever the verb (N1). Today that is `limits.set`, `instances.set`, `routes.set`
and `plugins.set`, and after this row `veto.set`, `instance.hire`, `instance.fire` and the launcher's
delete through the same writer. It loads from the file through the loader `bootConfig` uses
(`cli/src/config.mjs:88-105`), so the broker holds exactly what a restart would give it. Then
`limits.refresh(id)` runs, as `plugins.set` does now.
3. Other startup consumers of `businesses` in `startBroker` (`runtime.mjs:75-81`): credential references,
usage, the web services and the tasks adapter's bots. A create or hire adds no credentials, no tracker
bot and no web grant, because credentialed definitions are refused. The build checks each consumer
against that claim. Any change that would need one of them still answers `restart: true` and doesn't
reload.
4. A definition change on a running instance refuses `instance-running`. A live cap must not change
authority under its run. This is new: today the change waits for the restart. Existing tests that pin
`restart: true` change with it (N4): `business/tests/instance-settings.test.mjs`,
`bus/tests/instances-verb.test.mjs`, `cli/tests/instances.test.mjs:76-94` (the `RESTART_NEEDED` text and
the restart line) and `webui/tests/instances.test.mjs:25, 71, 74, 140-145`. The build names each
changed assertion in its manifest.
5. The chief's `launch` tool (E5) takes `instance` as a string with the id pattern instead of the start-time
enum. Its description says the host refuses an unlisted instance. `check()` already refuses one
(`launcher.mjs:235`). Without this, the chief can't launch its own hire until its session restarts.
`tools.mjs` may carry hash pins in tests. The build greps for them (R5).
6. Hand edits of the business file still need a restart. Nothing watches the file. The one exception is
`bus.tester` (D4.1), read from the file at each tester call.
After this, `restart` is false for create, delete, hire, fire and veto changes, and true only under item 3.
## D4. RBAC until row 193
Every human verb above calls `#human(s)` and then one hook, `#mayHuman(s, verb, target)`. Until row 193
lands, the hook passes for the business's one `human`, which is the owner. The verbs are listed once, in
an exported `LIFECYCLE_HUMAN_VERBS` (`instance.launch`, `instance.end`, `instance.regenerate`, `veto.set`,
and `instances.set` ops `add`, `change`, `delete`), so row 193 maps each verb to the human roles that may
use it. Row 193 replaces the hook body and nothing else.
### D4.1 The tester (Sage, sage-1355)
What the tester may do after this row:
| Verb | Tester |
|---|---|
| `instance.launch`, `instance.end` | yes, new |
| `instances.set` ops `add`, `change` | yes, as today (row 175, `broker.mjs:103`, `serve.mjs:537`) |
| `instances.set` op `delete` | no, `tester-refused` in the broker (B6) |
| `instance.regenerate`, `veto.set` | no, `tester-refused` |
The tester's cap is bound to one business, and only when that business sets `bus.tester` to true
(`broker.mjs:542-546`). That stays Jason's switch, and no bus verb writes it. Two checks run on each tester
lifecycle call, before any effect, and again at the take (D1.1):
1. The request's business is the cap's business. Any other refuses `tester-refused`.
2. `bus.tester` is exactly true in the business file as it stands, read through `settings.current(id)` as
`harnessOn` does (`runtime.mjs:136-142`), not from the broker's copy (B8). An unreadable file refuses
`tester-off`. So a hand edit turning testing off counts at the next tester call, without a restart.
The request and done events record actor `tester`. The tester's Q173 tests of the Agents section then cover
launch and terminate on a scratch business, and the rest through fixtures.
## D5. One stack instance
Row 190 carries the stack instance as the config `MOSAIC_CONFIG` names. These verbs take no instance name.
The CLI reaches the broker socket under that config's `dataRoot`. The broker acts on its own businesses,
launcher and data root. A business id that exists in two stack instances is two separate businesses. A test
runs two scratch roots with the same business id and shows a launch, a terminate and a delete in one leave
the other byte-identical (T11).
## D6. The kill switch
- Launch and regenerate end in `start()`, which row 191 gates (its D3 inventory, `launcher.mjs:329-457`).
The new pass adds no second start path.
- The broker refuses `instance.launch` and `instance.regenerate` with `processing-halted` while the switch
is on (V2 step 1), and the take checks again (D1.1), so no request is waiting when a human resumes.
- `instance.hire` and `instance.fire` are agent work verbs, so row 191's default refuses them while halted.
The build adds them to row 191's work table, not its read list. A hire Jason resolved before a halt
still refuses while halted, and the chief calls it again after the resume.
- Terminate, create, delete and veto start nothing and stay allowed.
- If row 191 hasn't landed when this row builds, the build chains on its candidate (Q177). The two halted
checks are written against its reader and tested with its fixture.
## D7. The Console
A new Settings section, "Agents", in its own `agents.js` file. Settings itself isn't rewritten. It lists
each instance with definition, listed, kept, retired and running runs, and flags a run with a
`lifecycle.end.alive` event as "still running after fire" (D2.2). It has these actions:
- Create (definition select, from `definitions`), Delete (with a confirm naming what stays: the evidence
and the workspace under `.retired`).
- Launch, Terminate (with an optional reason), Regenerate (with a confirm).
- Keep and Unkeep per instance, and a No-hire toggle per definition.
The routes are `POST /api/instances` (op `delete` joins `add` and `change`), `POST /api/agent/launch`,
`/api/agent/terminate`, `/api/agent/regenerate` and `POST /api/veto`. Each uses `instanceSend`'s boundary:
signed-in user, exact Origin, same-origin Fetch Metadata, JSON within 16 KiB, strict keys, one bus call
through the human transport, and `outcome-unknown` on an unexpected answer. Launch and regenerate show the
request, then poll `GET /api/agent/request?id=` until it closes.
`agents.js` joins the static file list (`serve.mjs:28`) and `index.html` (N5).
When `/api/config` says `send` is false, every action button shows as unavailable with the text "Agent
actions are unavailable here. Use mosaic business instance from the instance CLI." This is the live state
until Q175 phase 2. Every write test uses a fixture server and fixture writers, as rows 165 and 191 do. No
route gets a `--send` exception.
## D8. Tests
Every test uses scratch roots under `TMPDIR` and the existing fixtures (`business/tests/helpers.mjs`,
`cli/tests/helpers.mjs`, `webui/tests/tester-fixture.mjs`). None touches `~/.mosaic-dev`,
`~/.config/mosaic-dev`, a live unit or a slot file.
| Test | Shows | File |
|---|---|---|
| T1 | V1 create on a running bus: a launch of the new instance binds with no restart, and the answer says `restart: false` | cli `instances.test.mjs` |
| T2 | V2: a human launch of an unlisted instance runs. `launch.by` goes through the `pm` branch (N8). A thread instance, a retired one and a revoked business refuse at the verb. A request older than 60 s never launches. A refused `check()` closes the request with its code | cli `lifecycle.test.mjs` (new) |
| T2a | Take: a `launch.revoke` between the request and the pass closes it `launch-revoked` with no process. A `lifecycleDone` that throws once leaves the request taken, and the next tick starts nothing. A taken request is never taken twice | same |
| T3 | V2 with `--no-launch` and with each limit refusal: no process, request closed with the code | same |
| T4 | V3: claims revoked, runner SIGTERMed after the start-time check, one end record. A mismatched start time sends no signal. An open request is cancelled and never starts. A spawned, unbound run in the registry is signalled | same |
| T5 | V4: the new run starts only after the old exit, with a new run dir and the same workspace. A run that ignores SIGTERM closes the request `regenerate-timeout` with nothing started. The assistant refuses `regenerate-thread` | same |
| T6 | V5: each refusal in the V5 table, in order, including `instance-starting` with an open request. A clean delete leaves the evidence bytes unchanged, commits the file before the rename, adds `retired`, and a create or hire of the same name refuses. A failed rename closes `workspace-rename-failed`, `check()` refuses the name, and a second delete renames only | business `instance-settings.test.mjs` and cli `lifecycle.test.mjs` |
| T7 | V6: each op, the no-op refusal, an unknown `noHire` name accepted, and a `changes.jsonl` entry with actor `human` | business |
| T8 | Chief hire (hard-wait form): no decision refuses. A decision raised by the chief goes to the human only, no arbiter. After the resolve, the hire adds and lists the instance, logs actor and decision, and the same chief session launches it (D3 item 5). Arguments that differ from the decision body refuse. A non-chief caller with a resolved decision refuses `role-not-chief`. Outside `hireable`, in `noHire`, credentialed, END_PROTECTED, retired, and extra vars each refuse | bus `chief-of-staff.test.mjs` |
| T8a | Re-hire (B3): a fired name with the same definition and no vars is listed again. Another definition or vars refuses `organization-instance-exists` | same |
| T8b | Hire allowlist (D2.3): one case per action in `ACTIONS` that is neither on `HIRE_ALLOWED` nor a hard wait (`role.mjs` already refuses those), looped from the lists, not typed by hand. A fixture definition, clean when the chief's role file loads and listed in `hireable`, is rewritten to hold that action before the hire. The hire refuses `definition-not-hireable`. A definition holding one allowed and one refused action refuses. One holding only allowed actions hires. A pin test fixes `HIRE_ALLOWED` to the six actions | same |
| T8c | Allowlist at load: a role file whose `hireable` names a definition holding each refused action refuses at `loadRole`, one case per action, looped the same way. The mixed case refuses. A clean list loads. A missing, version 1 or duplicate name refuses | business `role.test.mjs` |
| T9 | Chief fire: runs end, open requests cancelled, delisted, still in `roles`. Kept (even with a resolved decision), END_PROTECTED, `launch.by` and last-listed refuse | bus `chief-of-staff.test.mjs` |
| T9a | Fire ends processes (F4, sage-1380 item 1): with a bound run and a spawned but unbound registry run of I, both fixture runners holding a stand-in model variable, fire answers both runs. After the launcher pass, neither runner pid is alive and I holds no claim. A fixture runner that ignores SIGTERM gets SIGKILL after the grace, is gone, and has `lifecycle.end.killed` with its run and pid. A stand-in whose kill is stubbed to fail gets `lifecycle.end.alive`. A runner whose identity changed before the SIGTERM, or between SIGTERM and SIGKILL, gets no signal. V3 terminate of a SIGTERM-ignoring runner sends no SIGKILL | cli `launcher.test.mjs` |
| T9b | Fire and row 191's halt: a halt during a fire's stop sends no second SIGTERM, keeps the fire's deadline, writes one stop receipt with reason `fire` and the decision, and counts the run in its totals. A fire while halted refuses (D6) | cli `launcher.test.mjs`, on row 191's fixture |
| T9c | Fire order (webby-0011 B2): the organization writer stubbed to fail after A commits. Fire answers the writer's code, I's claims are revoked and its runs stopped, I is still listed, and the decision is unconsumed. A second fire citing the same decision delists I and consumes it. A third fire refuses `decision-consumed`. With C stubbed to fail, the second fire finds I delisted and only consumes | bus `chief-of-staff.test.mjs` |
| T10 | Keep: chief `role.revoke` with a reason, fire, hire of that name and an `organization.set` model write aimed at a kept instance each refuse `instance-kept`. A `role.revoke` citing a human-resolved decision ends the kept run (Q2). The human's V3 works | same |
| T11 | Two scratch stack instances with the same business id: V2, V3 and V5 in A leave B's root and config byte-identical. A chief of business A can't hire into business B on the same bus | cli `lifecycle.test.mjs`, bus |
| T12 | Agent and reader sessions refuse every human verb before any effect. The tester gets exactly the D4.1 table, including `tester-refused` for `instances.set` op `delete` (B6). The D4 hook is called with the verb | bus `instances-verb.test.mjs` |
| T12a | Tester launch and terminate work on the cap's business with `bus.tester` true, record actor `tester`, and refuse `tester-refused` for a second business on the same bus. A file edit setting `bus.tester` to false, with no restart, refuses the next call and the take `tester-off`. An unreadable file refuses `tester-off` | bus `instances-verb.test.mjs` |
| T13 | `organization.set` with `launch`, `retired`, `keep` or `noHire` keys refuses | bus |
| T14 | Live reload: after each committed verb, `plugins.set` and `routes.set` included, the broker answers from the new document. A definition change on a running instance refuses `instance-running` | bus |
| T15 | Kill switch (with row 191's fixture): launch and regenerate refuse `processing-halted` at the verb and at the take, hire and fire refuse, terminate works | bus and cli |
| T16 | Console: each route's boundary refusals, a fixture write per action, `outcome-unknown` on a bad shape, unavailable buttons when `send` is false, request polling, and `agents.js` served from the static list | webui `agents.test.mjs` (new) |
| T17 | Business file: the new keys validate, each bad entry refuses, and a file without them loads byte for byte as before | business `business.test.mjs` |
| T18 | The forbid can't be changed by the chief (D2.4): from a chief session, `veto.set`, `organization.set` with each list key, `instances.set`, and hire and fire with extra fields all refuse, and the business file bytes are unchanged | bus `chief-of-staff.test.mjs` |
### Mutants
Each one must fail at least one test. They run against the candidate, and the file is restored and its
sha256 checked after each one.
| # | Change | Killer |
|---|---|---|
| M1 | `instance.launch` skips `#human` | T12 |
| M2 | the pass calls `check()` without `human: true` | T2 |
| M3 | the take accepts a request older than 60 s | T2 |
| M4 | the take skips the `launch_state` check | T2a |
| M5 | terminate signals without the start-time check | T4 |
| M6 | regenerate starts before the old exit | T5 |
| M7 | delete skips the running check | T6 |
| M8 | delete skips `retired` | T6 |
| M9 | delete clears `keep` instead of refusing | T6 |
| M10 | delete removes the workspace instead of renaming it | T6 |
| M11 | the `keep` check is removed from the `role.revoke` reason branch | T10 |
| M12 | `noHire` is not read by hire | T8 |
| M13 | fire deletes the instance from `roles` | T9 |
| M14 | `reloadBusiness` isn't called after `instances.set` | T1, T14 |
| M15 | the tool enum is kept | T8 |
| M16 | the halted check is dropped from `instance.launch` | T15 |
| M17 | the Console route skips the Origin check | T16 |
| M18 | `veto.set` joins `TESTER_ORGANIZATION_VERBS` | T12 |
| M19 | the tester's business scope check is dropped | T12a |
| M20 | the tester's `bus.tester` file read is dropped | T12a |
| M21 | hire skips the role file's `hireable` list | T8 |
| M22 | the take doesn't write `lifecycle.taken`, so a request can be taken twice | T2a |
| M23 | terminate doesn't cancel open requests | T4 |
| M24 | delete skips the `instance-starting` check | T6 |
| M25 | delete renames before it commits the file | T6 |
| M26 | the tester's `instances.set` op `delete` refusal is dropped | T12 |
| M27 | the `keep` check is dropped from `organization.set`'s model write | T10 |
| M28 | the `keep` check is added to the `role.revoke` decision branch | T10 |
| M29 | a re-hire accepts a different definition | T8a |
| M30 to M36 | the broker's hire check accepts one action outside `HIRE_ALLOWED`, one mutant per action: `role.launch`, `role.end`, `organization.change`, `instance.name`, `review.verdict`, `git.merge.protected`, `decision.resolve.technical` | T8b |
| M37 to M43 | the same seven, accepted by the load check in `role.mjs` only | T8c |
| M44 | fire skips `instance-kept` when a decision is cited | T9 |
| M45 | `instance.hire` skips the `launch.by` caller check | T8 |
| M46 | `veto.set` skips `#human` | T18 |
| M47 | the broker's hire check needs some action on `HIRE_ALLOWED` instead of every action | T8b |
| M48 | the same in `role.mjs` only | T8c |
| M49 | fire revokes the claims but writes no `lifecycle.end`, so no runner is signalled | T9a |
| M50 | fire's signal skips spawned but unbound registry runs | T9a |
| M51 | fire's stop drops the SIGKILL escalation | T9a |
| M52 | SIGKILL is sent without the second identity check | T9a |
| M53 | a killed runner gets no `lifecycle.end.killed` | T9a |
| M54 | V3 terminate escalates to SIGKILL as fire does | T9a |
| M55 | the halt sweep sends its own SIGTERM and restarts the deadline on a run fire is stopping | T9b |
| M56 | fire delists before the store step | T9c |
| M57 | fire consumes the decision in step A | T9c |
| M58 | a second fire with nothing to revoke refuses `not-held` | T9c |
## Scope and boundaries
- In the row: the verbs and passes above in `business` (vocabulary, role validation, instance-settings,
business validation), `bus` (broker verbs, reload, runtime callback), `cli` (instances command, launcher
pass, host ops), `harness` (the launch tool's schema, and the hire and fire tools), `webui` (routes and
the Agents section), the tests, the package READMEs and the PRD draft in this packet. `docs/TOOLS.md`
text goes to Sage.
- Named for review:
- Vocabulary: `role.hire` and `role.fire` join `ACTIONS` as hard waits, under REQ-WAIT-3. The commit
message names them as hard waits, as REQ-WAIT-3 requires.
- Role schema: the optional `hireable` key, validated in `role.mjs` (D2.3).
- Role file: `roles/chief-of-staff.json` gains `"hireable": []`. No authority change.
- Business file schema: `launch.keep`, `launch.noHire`, `retired` (D2.5).
- Not in the row: the routed form (after PRD 1.7), filling `hireable`, credentialed hires and deletes (row
199), clear context for the assistant (T1 item 7), human roles (row 193), SIGKILL escalation, and any
live activation or Console start.
## Risks
- R1. Case 1's working roles (coder, reviewer, pm, cto) all declare credentials (E11), and pm, cto and
reviewer fail the hire allowlist anyway. Neither the human verb nor the chief can create or delete credentialed
roles until the credentials row (row 199). That is the largest gap against Case 1, and it isn't this
row's to close.
- R2. The human proof is path-bound and same-uid (P19 333). Any same-uid process that runs the tree's
`human-cli.mjs` can launch and delete. Row 190b's separate uid and row 193 narrow it. This row adds no
check of its own.
- R3. The 60 s request window means a slow `check()` (checkouts) can still start a run up to that point
after the CLI gave up. The CLI says so on timeout.
- R4. Rollback past this row needs `keep`, `noHire` and `retired` removed from each business file, and
`hireable` from the role file, or the old loaders refuse them. The activation plan states this.
- R5. `tools.mjs`, `serve.mjs`, `broker.mjs`, `vocabulary.mjs` and `role.mjs` may carry sha256 pins in
other packages' tests. The build greps the tests for every changed path plus a 64-hex value before
in-review, without a `head`.
- R6. With `hireable` empty, the chief can hire nothing until the policy commit in `roles/` that follows
the `prd.approve` resolve names a kind (Q4). Case 1's "as needed" waits on that commit and on PRD 1.7.
- R7. Fire's SIGKILL closes the SIGTERM-ignoring case (sage-1380). A runner still alive after SIGKILL
(an unkillable process, or a stop whose identity check fails) keeps the shared model token until the
human acts on `lifecycle.end.alive`. Only a per-agent model token (row 199 Q-M4, a later proxy row)
would let a fire revoke the token itself.
## Questions and rulings
- Q1, ruled by Mos (mos-1015, mos-1017, through sage-1365): `role.hire` and `role.fire` ship as hard
waits Jason resolves. The routed form needs PRD 1.7, drafted in `PRD-1.7-DRAFT.md`. D2.1.
- Q2, ruled by Webby (webby-0003): keep blocks the chief's `role.revoke` reason branch, not its decision
branch. D2.4.
- Q3, ruled by Sage (sage-1355): launch and terminate are tester verbs scoped to the tester's own
business, behind `bus.tester`. D4.1.
- Q4, ruled by Sage (sage-1374): `hireable` stays empty in this row. Naming the first role kind is a
separate policy commit in `roles/` after Jason resolves the `prd.approve` decision. Tests use scratch role
files that name a kind. D2.3.
## r2 changes against webby-0003
| Finding | Where |
|---|---|
| B1 | D2.1: hard waits under REQ-WAIT-3, no role grant. The routed form is PRD 1.7 (packet draft) |
| B2 | D2.3: the floor from sage-1365 item 4, checked at load and at hire, T8b, T8c, M30 to M43 (an allowlist since r4) |
| B3 | D2.2 hire item 5: a re-hire keeps the definition, T8a, M29 |
| B4 | D1.1: one `lifecycleTake` transaction re-checks and writes `lifecycle.taken`, T2a, M4, M22 |
| B5 | V3 cancels open requests and signals unbound runs. V5 refuses `instance-starting`, runs under `serialize`, commits the file before the rename, and states the recovery. T4, T6, M23 to M25 |
| B6 | V5 table and D4.1: the tester's delete refuses, T12, M26 |
| B7 | D2.4: keep covers `organization.set`'s model write, T10, M27 |
| B8 | D4.1: `bus.tester` is read from the file at each call and at the take, T12a, M20 |
| Q2 | D2.4, T10, M28 |
| N1 | D3 item 2: one reload after every committed write |
| N2 | D2.5: `noHire` takes any id-pattern name |
| N3 | D2.2: hire's vars limited to the model key and narrowing `limits.*` |
| N4 | D3 item 4 names the tests that change |
| N5 | D7 and T16: `agents.js` joins `serve.mjs:28` and `index.html` |
| N6 | V3 and V4: no SIGKILL, `regenerate-timeout` |
| N7 | D2.4: intended, Q169 |
| N8 | V2 item 2 and T2 |
## r3 changes against sage-1374, sage-1377 and Megavolt's F4
| Item | Where |
|---|---|
| Q4 ruled: `hireable` stays empty, the first kind is a later policy commit in `roles/` | D2.3, R6, Questions |
| `instance.name` joins the floor (Webby B2, mos-1023 item 6) | D2.3 table, T8b, T8c, M47, M48 |
| F4: fire ends the fired agent's processes, since `ENV_ALLOW` gives every run the shared model token | D2.2, D7, T9a, M49 to M51, R7 |
| `git.push.remote` routed to coder only (mos-1028 item 3, row 199 r3) | `PRD-1.7-DRAFT.md`, REQ-LAND-2 and the REQ-WAIT-3 append |
## r4 changes against webby-0010, sage-1380 and megavolt-0008
| Item | Where |
|---|---|
| webby-0010 B1: the deny-list floor becomes the allowlist `HIRE_ALLOWED`, so merge-gate, `decision.resolve.technical`, `policy.change`, `prd.approve` and any later action are refused | D2.3, Summary, R1, T8b, T8c, M30 to M43, M47, M48, PRD REQ-COS-3 bound |
| webby-0010 N1: field-by-field compare of the call against the decision body, extra or missing keys refuse | D2.1 item 3 |
| webby-0010 N2: the model write is in REQ-COS-3's text | `PRD-1.7-DRAFT.md` |
| sage-1380 item 1: fire escalates to SIGKILL through row 191's per-run stop, and the order against a halt is fixed | D2.2, T9a, T9b, M51 to M55, R7 |
| sage-1380 item 2 and megavolt-0008: REQ-LAND-2 binds one commit oid, and Megavolt's three text changes against row 199 r4 (e1aa6216c) | `PRD-1.7-DRAFT.md` |
| webby-0011 B1: same as webby-0010 B1, M47 and M48 now allowlist mutants | D2.3 |
| webby-0011 B2: fire runs store side first, then the delist, then the consume, safe to repeat | D2.2 order A to C, T9c, M56 to M58 |
| webby-0011 N1: the remote-owner host check at each push | sent to Megavolt for row 199 |
| webby-0011 N2: identity, not bare pid, at every check | D2.2 step 3 |
| Q4: Webby's view matches Sage's ruling (empty) | no change |
@@ -1,342 +0,0 @@
# PRD 1.8 draft: trusted actions within role guardrails, the manual mode, and grants the chief of staff
# requests and the user approves
Draft by Honker for Vikunja #434, under Mos's assignment mos-1117 (through Sage, sage-1525). Not approved.
It changes nothing until Jason approves it as a `prd.approve` decision. Sage opens that decision and sends
Mos its id. Base: `docs/prd/mosaic-stack.md` version 1.6 at 008746b6e, plus the version 1.7 text Jason
approved (PRD-1.7-DRAFT.md r2, 9ba31b5b3, decision d52febb5, mos-1089). Where 1.7 is not yet written into
the PRD file, this draft's anchors name 1.7's sections.
Revision 2 makes Mos's edits a to f (mos-1124, through Sage, sage-1534) and fixes Duckworth's B1 and B2
with notes N1 to N6 (duckworth-0057) on r1 at 3c30f1036. Revision 3 fixes Duckworth's C1 and takes
notes N1 and N2 (duckworth-0058) on r2 at 60f5ca8fa, with Gizmoduck's enrollment wording
(gizmoduck-0067). Revision 4 makes Mos's rulings on duckworth-0057 (mos-1129, through Sage, sage-1538)
on r3 at d16ccdb91: only an Approve creates a grant, one decision for a grant that makes an account, and
the arbiter route for `host.sudo`. Revision 5 fixes two stale notes from Duckworth's r4 check
(duckworth-0061 N1), with no change to the requirement text. Revision 7 is on r5 at 762a6711f, not r6
(Jason, Q191, through Mos mos-1149 and Sage sage-1623): the assistant and the chief of staff are separate
roles, and only the chief raises a grant request. It also takes Mos's rulings on r6 items 1, 3 and 4
(mos-1152): the heading below reads "For Jason at approval:" again with Mos's reading of the Q188 revokers,
the machine-grant citation names r1 to r4, and the revoke note no longer says their Q-S5 is answered.
Only the chief of staff raises a grant request. The assistant, the user's main agent, never raises one.
It passes the user's request to the chief and can revoke a grant in its own organization.
For Jason at approval:
- Q188's 'you, Mos or the tester' is read as the user, the assistant for grants in its own organization, and the
tester only on a dev instance (Mos's reading, mos-1124 d).
- Only an Approve creates a grant. A grant written into the business file by hand makes nothing. An agent
that runs as the same OS user as the stack can still write the stack's own store. That is the B7
residual (Q182 on dragon-lin, #423 on .53 and .54), and this version doesn't close it.
- Who decides a root command on another host changes. Once `host.sudo` is routed, a raise for it that
cites the spec and isn't within-role goes to the arbiter for its domain, as version 1.7 set for
`git.push.remote`, not to you. The grant and its allowlist still limit the command. In manual mode the
decision comes to you (REQ-TRUST-2).
## What it changes
Today an agent acts without a raise only on actions its role file grants within-role, and the role
files grant few. Every other use of a tool or command waits on a decision. Row 210 builds `host.sudo` as a
hard wait, so Jason would decide each command run on another machine. Grants themselves are hand work:
Jason makes every token and key from a runbook (REQ-CRED-1).
Jason's ruling Q188 turns this around. Roles set what each agent may do, and by default agents do it at
will within their sandbox and guardrails. A per-use raise happens only in a manual mode. No agent creates
a grant. The chief of staff brings a grant request to the user's inbox, the user clicks Approve or Deny, and
protected stack code does every step of making the grant. The user's own tokens are entered once in the
WebUI and never reach an agent.
This version adds:
1. Trusted actions within role guardrails as the default mode (REQ-TRUST-1).
2. Manual mode, a list of actions that each need a decision per use: who sets it, where it lives and what
it changes (REQ-TRUST-2).
3. Grant requests, raised only by the chief of staff through the chain of command (REQ-GRANT-1).
4. Approve and the protected grant code, with three grant methods: machine key, Gitea and GitHub
(REQ-GRANT-2).
5. The user's tokens, entered in the WebUI and used only by the grant code (REQ-GRANT-3).
6. Row 210's REQ-CRED-4, generalized from `host.sudo` to every role-listed action that uses a grant
(REQ-CRED-4).
7. Instant revoke (REQ-GRANT-4).
The hard waits stay with the user (Mos, mos-1117): spending money, deleting data, new external accounts,
messages outside the fleet and the rest of REQ-WAIT-1. Approving a grant is the user resolving one of
those hard waits, `credential.mint`. A grant that makes an account, such as a seat's own Gitea user
(Q190), is the same one decision: its method says it makes an account, and the inbox shows the account
name before Approve. So no hard wait moves to an agent.
When this version lands, reviewed commits:
1. add the manual-mode list to the organization file schema, written only on the human's path
(REQ-TRUST-2), and make the broker classify a listed action as needing a decision for each use, before
any other routing.
2. add the grant request: a decision the chief of staff raises with action `credential.mint`, naming the
agent, the method and target, the exact rights and, when the method makes an account, the account
name (REQ-GRANT-1). The broker refuses a `credential.mint` raise from any other role. An
`account.create` raise isn't a grant request and routes as it does in version 1.7, such as a PM's
sign-up for a service.
3. add the grant code that runs on a resolved Approve, one method at a time, and the grant record it
writes (REQ-GRANT-2, Vikunja #435 and #436).
4. add the user's token store, written from the WebUI (or the CLI until the Console knows the signed-in
human) and read only by the grant code (REQ-GRANT-3).
5. move `host.sudo` into both `ROUTED` lists, as row 199 does for `git.push.remote`, and route it under
REQ-CRED-4, with the arbiter route version 1.7 set for `git.push.remote`. Which definitions list it
within-role is a separate reviewed role commit in `roles/`.
6. update the role contracts that list the hard waits: `roles/chief-of-staff.md:75` raises grant
requests with the REQ-GRANT-1 fields, and `roles/pm.md:55` asks the chief of staff for a grant instead
of raising `credential.mint` itself (line numbers on origin/refactor). Other roles that list the hard
waits get the same line as the PM. A role's messaging list carries the chain of command.
## Text for the PRD
Status line, appended to the current paragraph:
> Version 1.8 records Jason's ruling Q188 of 2026-10-20: trusted actions within role guardrails by
> default, a manual mode for per-use raises, and grants the chief of staff requests and the user
> approves, approved by Jason in decision <prd.approve decision id>.
Source, two new bullets above "Version 1.7 source":
> - Version 1.8 source, Jason's ruling Q188 (DECISIONS, 2026-10-20), his answers to Q8 and Q10, in part.
> Q8: "I would rather grant the grant right to an agent that runs with strict guardrails. That agent
> presents the final authorization for the user. Upon approval, the grant is created programmatically.
> I see this as: User configures their PATs, API tokens, authorizations within the WebUI. The agent
> presents the request for grant with X rights for another agent. The user reviews, then clicks
> Approve/Deny. Click of Approve fires off protected mechanical methods to handle the grants on Github,
> Gitea, .... services as needed. The tooling would need to exist and must be guarded." And "Disallowing
> the agents to create grants autonomously is a necessary evil for better security. Requiring the user
> to manually perform the steps is a no-fly." Q10: "Agents should have tools and command access based on
> roles. Those roles dictate the allowed tools and commands. The use of a tool or command should not be
> raised to the user unless the system is configured with a manual mode that requires user
> interaction. By default I want to have the agents capable of running commands and tools at will
> within their sandbox/guardrails. This allows autonomous actions by agents within their lanes. Perhaps
> a "Trusted actions within approved guardrails" methodology." And "The main agent or chief of staff
> would be responsible for surfacing the request to the user's inbox for approval. Not all agents are
> capable or authorized to request directly. It must follow the chain of command properly." He agreed
> with Mos's Q10 points 3 to 6: protected stack code does every step on Approve, his tokens are entered
> in the WebUI and agents never see them, a command on the approved list runs without a click and any
> other is refused, and he, Mos or the tester can revoke a grant at once.
> - Version 1.8 source, Jason's ruling Q190 (DECISIONS, 2026-10-20), his answer "Q12: O1": each agent seat
> gets its own account on his Gitea, created through the Q188 approval path once the Gitea grants exist.
Under "Decisions", after REQ-DEC-6, a new section:
> ### Trusted actions
>
> Added in version 1.8 (Q188).
>
> - **REQ-TRUST-1.** Agents act within their role guardrails without asking. The default mode is
> "trusted actions within approved guardrails". An action a role file grants within-role is done at
> will, inside the agent's sandbox, its tool limit and its grants, and is logged. It raises nothing to
> the user. An action the file doesn't grant within-role still routes by REQ-DEC-2, and a hard wait
> still goes to the user (REQ-WAIT-1). Only a reviewed role change adds an action to a role file, and
> REQ-WAIT-2 still keeps every hard wait out of every role file.
> - **REQ-TRUST-2.** The user can put chosen actions in manual mode. The organization file holds the list
> of actions in manual mode, empty by default. While an action is on the list, every use of it by any
> role becomes a decision for that use, raised to the user, and the action waits until the user
> resolves it. A decision denied, or not resolved, refuses the action. Each decision is used once. For a
> command on another host, the decision names the host, the command and its input. Actions not on the
> list are unchanged. Only the user changes the list, through the WebUI or the CLI. The chief of
> staff's organization writes (REQ-ROLE-3) can't change it, and no agent can. Every change is logged
> with who made it. A change applies to the next use, and a run already in progress keeps no
> exemption.
Under "Credentials", after REQ-CRED-3 (version 1.7):
> Added in version 1.8 (Q188):
>
> - **REQ-CRED-4.** An action a role definition lists within-role, and that uses a grant, runs without a
> decision for each use only when all of these hold:
> - the definition declares the credential kind the action uses;
> - a grant names that agent, and that host or service;
> - for a command, the command matches the grant's allowlist. A grant lists exact argv prefixes. A
> wildcard grant is refused;
> - the agent's key or token for that grant is issued and not revoked.
>
> A command off the allowlist is refused, and adding one takes a new grant request (REQ-GRANT-1). An
> action whose definition doesn't list it within-role still needs a decision for each use. Only a
> reviewed role change adds an action to a definition. `host.sudo` routes under this requirement.
> - **REQ-GRANT-1.** No agent creates or writes a grant. A grant request follows the chain of command,
> and only the chief of staff raises it to the user's inbox. Any other role that needs a grant for
> itself or for an agent it directs asks the chief of staff, through its chain: a worker role through its
> PM, the PM and the CTO directly. The assistant passes the user's own request to the chief of staff
> (REQ-ASSIST-1). The request names the agent, the grant method and its target, the exact rights, and a
> rotate-by or expiry date. The chief raises one only for an agent whose reviewed role definition
> allows that kind of credential, and the broker refuses any other. A grant can't name a wildcard, a
> shell or an empty command. The request is a decision with the action `credential.mint`, so it goes
> to the user as a hard wait (REQ-WAIT-1). A grant whose method makes an account, such as a seat's own
> Gitea user (Q190), is the same one decision: the method in the grant says it makes an account, the
> request names the account, and the inbox shows the account name before Approve. The user's one
> resolution covers the account and its credential, so the hard-waits list doesn't change. An
> `account.create` raise isn't a grant request and routes as version 1.7 has it. The broker checks
> only that the chief of staff raised the request. The chain of command before that is in the
> messaging lists and the role contracts, not a broker check. The assistant and the chief of staff are
> separate roles. No agent holds both, and the broker refuses a claim that would.
> - **REQ-GRANT-2.** The user approves or denies each grant request in the inbox. Approve is the only thing
> that creates a grant, and the code uses a grant only when it has a matching Approve record. No agent
> creates a grant. A grant written into the business file by hand makes nothing. Row 210's hand-written
> grants stay a test path until Vikunja #436a moves `host.run` to grants made by Approve. On Approve,
> protected stack code does every step, not an agent and not the user: it records the grant, makes the
> key or token, installs it on the machine or creates it on the service, and logs each step. On Deny
> nothing is made, and the denial is logged. The grant methods are:
> - machine key: a key for one agent on one enrolled host, with the command allowlist of REQ-CRED-4. A
> host's identity comes from the host side when the user approves its enrollment, never from a first
> connection. A host user with sudo receives a grant only after the user approved that user by name;
> - Gitea: a token for one agent, and for an agent seat its own Gitea user (Q190), with an expiry. The
> all scope and the admin scope are never granted;
> - GitHub: a grant for one agent.
>
> The Gitea and GitHub rights units come in a later design that Jason approves.
>
> The approval binds the exact rights the user saw and the target or its enrolled identity. A change to
> the agent, its definition, the target, the target's identity or the rights after the raise voids it. An
> approval is used once. A grant the code couldn't finish is undone at once on the stack's side, so it
> is never used. What it left on the host or service stays visible as pending until it is removed, as
> for a revoke (REQ-GRANT-4). A new grant needs a new approval, never an automatic retry. The grant
> code runs only on a request the user resolved. A request resolved by an arbiter, the tester or any
> agent makes nothing. A `credential.mint` decision raised before version 1.8, or without the fields of
> REQ-GRANT-1, makes nothing when it is approved. Until the Console
> knows the signed-in human (Vikunja #412), Approve is `mosaic decide` in the CLI (REQ-DEC-3).
> - **REQ-GRANT-3.** The user enters their own personal access tokens and API tokens in the WebUI, or the
> CLI. Only the grant code reads them, to make grants. No agent ever sees a token value or a key's
> private half. Values appear in no event, log, message, prompt or agent file, and no agent can read
> them back (REQ-VAR-2). Until the Console knows the signed-in human, they are entered through the
> CLI.
> - **REQ-GRANT-4.** A grant can be revoked at once by the user, by the assistant for a grant in its own
> organization, or by the tester on a dev instance (Q173). A revoke needs no decision and no new trust.
> It takes effect at once on the stack's side, without waiting on the network, so the next use is
> refused. Removing the key or token at the host or service follows. Until that removal is done it stays
> visible as pending. A revoke while the grant is being made stops it, and nothing it made stays usable.
> Every revoke is logged with who made it.
REQ-CRED-1, appended:
> Since version 1.8, a token or key for a grant is made by the grant code on the user's Approve
> (REQ-GRANT-2), not by hand. Tokens that exist stay as they are.
REQ-WAIT-3, appended:
> `host.sudo` was added as a hard wait and routes under REQ-CRED-4 since version 1.8.
Under "Risks and open questions", a new bullet:
> - Trusted mode lets an agent run its allowed commands at will. On another host, an allowed command that
> writes a file as root or starts a shell can plant a key, and the seat sandbox doesn't confine the
> network, so root can outlive the grant and a revoke. The allowlist narrows this. A seat sandbox with
> its own network namespace closes it. Until then the host's own audit log is the check after the fact
> (row 210 design section 6).
## Notes for the reviewer and for Mos
- REQ-TRUST-1 restates what REQ-DEC-2 already does for within-role actions ("routine and within-role
decisions are logged") and names it as the default mode. It grants nothing new by itself. What widens
is the role files, one reviewed commit at a time.
- The hard waits stay with the user (mos-1117). REQ-GRANT-1 makes the grant request a `credential.mint`
decision, already a hard wait under REQ-WAIT-1, so Approve is the user resolving it. REQ-WAIT-2 stands:
no role file grants `credential.mint`. The grant code acts on the user's resolution, not on a role
grant. A grant that makes an account, such as a seat's Gitea user under Q190, is the same one
`credential.mint` decision. So 1.8 doesn't change the hard-waits list (mos-1124 a, mos-1129 N4).
- Manual mode: Sage asked who sets it, where it lives and what it changes. Mos accepted these choices
(mos-1124 b), and Jason can change them: a list of actions in the organization file, empty by
default, changed only by the user, outside the chief's organization writes. A list rather than one
switch follows Gizmoduck's #436 draft section 7 (gizmoduck/machine-grant-design f6b6a04b6), so Jason
can make host commands manual without making every action manual. Listing every within-role action
gives the whole-organization manual mode. Row 210's per-call `host.run` decision, bound to host, argv
and stdin and spent once, is this mode's decision for a host command (Q188, Mos's reading).
- Gizmoduck's draft proposes REQ-CRED-5 (section 8) for the machine grant. Its content is in REQ-GRANT-1
to REQ-GRANT-4 here, generalized to all three methods, so 1.8 has no REQ-CRED-5. Their bounds in
gizmoduck-0062 items 4a to 4g are in the text: the chief raises only for an allowed definition, the
approval binds the rights shown, it is used once with no automatic retry, no value is ever seen, revoke
is at once on the stack's side with the host or service removal visible until done, every grant has a
rotate-by or expiry date, and the host identity comes from the host side. Duckworth has reviewed their
design r1 to r4 (duckworth-0056 and duckworth-0060), so a later change there may need a change here.
- B1 (duckworth-0057, mos-1129): a grant the code couldn't finish is undone at once on the stack's side.
What it left on the host or service stays visible as pending until it is removed, as for a revoke.
- B2 (duckworth-0057, mos-1129): Mos chose Duckworth's second option. Only an Approve creates a grant,
and the code uses a grant only when it has a matching Approve record. A grant written by hand makes
nothing. Jason's Q188 gives the reason: "Disallowing the agents to create grants autonomously is a
necessary evil for better security" and "Upon approval, the grant is created programmatically." Row
210's hand-written grants stay a test path until #436a moves `host.run` to grants made by Approve. r2
and r3 had a hand path in the business file, and r4 takes it out, with r3's load check on hand grants
(duckworth-0058 N1) and the note that a revoke never rewrites the user's file (duckworth-0058 N2).
The same-uid write to the stack's own store stays the B7 residual, and the top of this draft names it
for Jason.
- C1 (duckworth-0058, sage-1538): `account.create` is the hard wait for any new external account, not
only a grant (REQ-WAIT-1, `vocabulary.mjs:43-46`, `roles/pm.md:55`). It stays as version 1.7 has it.
Only `credential.mint` is chief-only, and a grant that makes an account is a `credential.mint`
decision (N4), so a PM's sign-up for a service routes as before.
- Gizmoduck's machine-grant r2 (eb4c756a1, gizmoduck-0067): enrollment is a request the user approves
and the stack runs (mos-1120), and a host user with sudo is enrolled by name, the user's Approve
covering that user (mos-1125). REQ-GRANT-2's machine key says both. Their design r3 and r4 (1c5705d6d
section 6) give revoke to the user, the assistant in its own organization and the tester on a dev
instance, as REQ-GRANT-4 does (mos-1124 d).
- N1 (duckworth-0057, mos-1129): the approval binds the target or its enrolled identity.
- N2 (duckworth-0057, mos-1129): a revoke during creation stops it, and nothing it made stays usable.
- N3 (duckworth-0057, mos-1129): landing step 6 lists the role contract updates (`roles/pm.md:55`,
`roles/chief-of-staff.md:75`). A `credential.mint` decision raised before 1.8, or one without the
grant fields, makes nothing when it is approved.
- N4 (duckworth-0057, mos-1129): a decision carries one action (`a.action`, broker.mjs:1678 on
origin/refactor). A grant that makes an account is one `credential.mint` decision: the method in the
grant says it makes an account, and the inbox shows the account name before Approve. The user clicks
once, and there is no separate `account.create` action for it. Gizmoduck's machine-grant r2 reads it
the same way.
- N5 (duckworth-0057, mos-1129): `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 (broker.mjs:1677-1678 on origin/refactor), and the grant and its allowlist still limit the
command. In manual mode the decision goes to the user. The top of this draft names this for Jason as a
change in who decides a root command on another host.
- N6 (duckworth-0057, mos-1129): 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 the role contracts (step 6 of "When this
version lands"). REQ-GRANT-1 says so.
- Approving a grant doesn't by itself let a command run without a click. That needs REQ-CRED-4 and
`host.sudo` in the routed lists, step 5 of "When this version lands" (gizmoduck-0062 item 4i).
- Which roles may request: Q188 says "Not all agents are capable or authorized to request directly. It
must follow the chain of command properly." The text makes the chief of staff the only role that raises,
the PM and the CTO ask it directly, and worker roles ask through their PM. Jason's words also name "the
main agent". That is the assistant, a separate role from the chief of staff. Reading the two as one role
would give grant rights to both and overlap their authority (Jason, Q191). So the assistant never raises
a grant request: it passes the user's request to the chief (REQ-ASSIST-1) and can revoke a grant in its
own organization (REQ-GRANT-4). Jason accepted this at Q191 ("Q13: that looks better").
- What stops one holder claiming both roles today, at refactor 011d41ba6. Nothing names the pair. The
separation holds by construction, and no check or test asserts it:
- An instance has exactly one definition (`packages/business/src/business.mjs:118-120` in checkRoles).
An assistant is an instance of the definition `assistant` (`packages/business/src/vocabulary.mjs:134`)
and the chief of staff is an instance of `chief-of-staff` (`packages/bus/src/broker.mjs:164`, `:2137`).
- A run binds one instance. `bindLaunch` takes one `role` (`broker.mjs:451-456`), refuses a second bind of
the same run in the business (`duplicate-run`, `:479`), and refuses a rebind under another role
(`launch-record-mismatch`, `:499`). The launcher spawns one process per launch and binds it to one
instance (`packages/cli/src/launcher.mjs:438-449`).
- `role.claim` claims only the run's own bound role (`broker.mjs:1689-1734`). An assistant run claims its
thread in `thread_claims` and never writes `role_claims` (`:1693-1709`). The `role_one_holder` trigger
(`packages/bus/src/migrations/3b.sql:117-119`) allows one claim per role, and nothing limits the roles
one run holds, because a run has only one.
Not enforced: the run key is per business (`broker.mjs:478`), so nothing stops one run id being bound
in two businesses. Nothing ties two runs to two different processes or OS users beyond the launcher's
own record (the B7 residual). `credential.mint` is an action (`broker.mjs:34`) and a hard wait
(`:71`), but no check limits which role raises it. The landing step adds a broker refusal for a claim
that would put both roles on one holder, with a test for each path above.
- REQ-CRED-4 keeps row 210's conditions (origin/gizmoduck/row210-on-199-r2,
agents/gizmoduck/work/queue-210/DESIGN.md:376-392): the definition declares the kind, the grant names the
agent, an exact argv-prefix allowlist with wildcards refused, the key issued, and only a reviewed role
change adds the action. Row 210 put the grant in a hand-edited business file, which stays a test path until
#436a. Under 1.8 the grant is what Approve records, so "the business file grants that agent that host"
becomes "a grant names that agent and that host".
- Revoke: Q188 says "you, Mos or the tester". Mos is Jason's assistant (REQ-ROLE-4), so the text says the
assistant, limited to grants in its own organization, and the tester only on a dev instance (Q173).
Mos accepted this with those limits (mos-1124 d).
- Approve in the Console needs the Console to know the signed-in human (Q175 phase 2, #412). Until then
Approve is `mosaic decide`, which REQ-DEC-3 already allows (mos-1124 e). This draft doesn't take
REQ-WEB-2, which stays with the Console decide buttons amendment (Vikunja 19/266). That amendment, or a
later version, moves Approve into the Console.
- Roles already use a scoped Gitea token (`<read|write>:<category>`, all and admin never granted,
`packages/business/src/vocabulary.mjs:105-106` on origin/refactor). It is a likely Gitea unit, not a settled
one. Only the machine key is designed so far. Gitea and GitHub are a later #436 draft, and minting a Gitea
token needs an admin credential the user enters, which is a design of its own (Gizmoduck's draft section
11). GitHub's rights unit isn't settled. So the text leaves both units to a later design Jason approves
(mos-1124 f), and keeps only the bounds: an expiry, and never the all or admin scope. The PRD names outcomes
and checks, not the grant code's steps, as REQ-CRED-3 does.
- Jason expects the inbox Approve and the Gitea and GitHub grants by 10-27 (Q188 point 6). That is a
delivery date, so it is in the source and not in the requirement text.
- This draft follows the PRD's own style: plain words, no semicolons in new sentences, and the status
paragraph never edited in place except to append.
@@ -1,124 +0,0 @@
# 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, 106 commits past OLD counting records.
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, with the pointer's Console flags. It stops no unit and writes no pointer. Transcript in `$WIN/p3/`. |
| 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. |
| 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.
-2
View File
@@ -1363,5 +1363,3 @@ 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)
File diff suppressed because it is too large Load Diff
+4 -23
View File
@@ -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 2455 by `queue render`. Do not edit between the markers; change the queue with `scripts/mosaic queue`.
Generated from `docs/plans/queue.json` revision 2361 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 2455 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 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 | — |
| 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 | — |
| 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 | 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). |
| 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) | — |
| 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-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. |
| 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. |
| 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,25 +248,6 @@ Generated from `docs/plans/queue.json` revision 2455 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 -->
+16 -3546
View File
File diff suppressed because it is too large Load Diff
+1 -11
View File
@@ -9,8 +9,7 @@
"version": "0.0.3",
"license": "UNLICENSED",
"dependencies": {
"@earendil-works/pi-coding-agent": "0.85.1",
"oauth4webapi": "3.8.8"
"@earendil-works/pi-coding-agent": "0.85.1"
}
},
"node_modules/@anthropic-ai/sdk": {
@@ -1934,15 +1933,6 @@
"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",
+1 -2
View File
@@ -5,7 +5,6 @@
"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",
"oauth4webapi": "3.8.8"
"@earendil-works/pi-coding-agent": "0.85.1"
}
}
+45 -494
View File
@@ -3,7 +3,7 @@ import { isAbsolute } from 'node:path';
import { NAME as HARNESS_NAME } from '../../plugins/src/kinds.mjs';
import { loadRequirements } from '../../tasks/src/requirements.mjs';
import { MERGE, LandingRefusal, readLanding } from './landing.mjs';
import { ID_PATTERN, THREAD_DEFINITION, END_PROTECTED, unhireableAction } from '../../business/src/vocabulary.mjs';
import { ID_PATTERN, THREAD_DEFINITION, END_PROTECTED } from '../../business/src/vocabulary.mjs';
// Kept closed while S1 lands; its vocabulary is the integration authority.
export const ACTIONS = Object.freeze([
@@ -29,9 +29,6 @@ export const ACTIONS = Object.freeze([
'organization.change',
// Row 141 (#1635): routed under PRD 1.5 REQ-MSG-2.
'instance.name',
// Row 194 (#1692): routed under PRD 1.7 REQ-COS-3, below.
'role.hire',
'role.fire',
'credential.mint',
'spend',
'deploy',
@@ -67,9 +64,6 @@ const ROUTED = new Set([
'policy.change',
'prd.approve',
'decision.resolve.technical',
// PRD 1.7 REQ-COS-3 (row 194, #1692): the chief's hire and fire, within the bounds the broker checks.
'role.hire',
'role.fire',
]);
// REQ-WAIT-1's hard waits: always gated, and a raise for one goes to the human, cited or not.
export const HARD_WAITS = Object.freeze(ACTIONS.filter((action) => !ROUTED.has(action)));
@@ -103,42 +97,10 @@ export const TASK_VERBS = Object.freeze(ACTIONS.filter((a) => a.startsWith('task
// Row 177 (#1675, Q173): the tester's own threads, owner `tester`, and a stop of their runs.
export const TESTER_VERBS = Object.freeze(['message.send', 'message.receive', 'message.read', 'inbox', 'agents', 'tasks', 'trail', 'decisions', 'messages', 'replies',
'usage', 'names', 'limits', 'plugins', 'instances', 'project.list', 'thread.list', 'name.set', 'project.create', 'credentials',
'thread.create', 'thread.open', 'thread.send', 'thread.messages', 'assistant.stop',
// Row 194 (#1692, D4.1): launch and terminate, and the read of the requests they make.
'instance.launch', 'instance.end', 'instance.request']);
'thread.create', 'thread.open', 'thread.send', 'thread.messages', 'assistant.stop']);
// Row 175: the organization verbs a tester may call. Its writes run every check the human's do
// and log actor `tester`; row 176 adds routes reads; limits.set, organization.set and organization.revision refuse it.
export const TESTER_ORGANIZATION_VERBS = Object.freeze(['limits', 'plugins', 'instances', 'plugins.set', 'instances.set', 'routes.set', 'routes']);
// Row 194 (#1692, D4): the human lifecycle verbs, one entry per verb and instances.set op. Each calls
// #mayHuman after #human, and the hook refuses a verb missing here. Row 193 maps each entry to the
// human roles that may use it.
export const LIFECYCLE_HUMAN_VERBS = Object.freeze(['instance.launch', 'instance.end', 'instance.regenerate', 'veto.set',
'instances.set add', 'instances.set change', 'instances.set delete']);
// Row 194 (D1.1): the requests the launcher carries out, and the events that move each one. All are
// action.allowed events, which event.emit refuses for any action but routine.
const LIFECYCLE_REQUESTS = ['lifecycle.launch', 'lifecycle.regenerate', 'lifecycle.delete', 'lifecycle.end'];
const LIFECYCLE_STEPS = ['lifecycle.taken', 'lifecycle.done', 'lifecycle.cancelled'];
// A launch, regenerate or delete request older than this never runs. A terminate's signal has no
// limit: the launcher checks each run's start time before it signals.
export const LIFECYCLE_REQUEST_MS = 60_000;
const LIFECYCLE_CODE = /^[a-z-]{1,64}$/;
// Row 194 (#1692, D2.1, D2.2, PRD 1.7 REQ-COS-3): the chief's hire and fire arguments, allowed then
// required. Each carries the reason the log records.
const HIRE_FIRE_ARGS = Object.freeze({
'role.hire': [['name', 'definition', 'vars', 'reason'], ['name', 'definition', 'reason']],
'role.fire': [['name', 'reason'], ['name', 'reason']],
});
// N3: hire's vars hold only the model key and limits.* keys. The writer checks the values.
const HIRE_VAR = (key) => key === 'model' || key.startsWith('limits.');
// What instance.request and the host ops answer about a request.
const lifecycleView = (r) => ({
request: r.request, action: r.action, instance: r.instance, by: r.by, at: r.at, state: r.state,
...('revision' in r ? { revision: r.revision, retry: r.retry === true } : {}),
...('runs' in r ? { runs: r.runs } : {}),
...(r.kill === true ? { kill: true } : {}),
done: r.done ? { run: r.done.run ?? null, transaction: r.done.transaction ?? null, refusal: r.done.refusal ?? null } : null,
cancelled: r.cancelled ? { by: r.cancelled.by } : null,
});
const SELF_KINDS = new Set(['task.created', 'task.assigned', 'task.state', 'task.closed', 'task.conflict']);
const POLL_KINDS = new Set([
'task.changed.external',
@@ -183,7 +145,6 @@ const oid = (x) => typeof x === 'string' && /^[0-9a-f]{40}([0-9a-f]{24})?$/.test
export const ORGANIZATION_KEYS = ['revision', 'limits', 'models', 'instances', 'reset', 'assistants'];
// Row 165 (#1662): the keys of the human-only instances.set (business instance-settings.mjs).
const INSTANCE_KEYS = ['revision', 'instance', 'op', 'definition', 'vars', 'launch'];
const VETO_KEYS = ['revision', 'op', 'name'];
// Row 123 (#1617): role.end never reaches the caller's own run. END_PROTECTED (vocabulary.mjs, row
// 155): only the chief of staff, or a decision the human resolved through the CLI, ends or revokes
// an instance of one (#protectedEnd, Q169). A raise to revoke one is a hard wait.
@@ -219,36 +180,6 @@ export const THREAD_PAGE_MAX = 200;
// A project or thread name: what the user calls it, one line.
const name = (x) => text(x, 200) && !/[\r\n]/.test(x);
// The checks a business document passes at boot and at every reload.
function checkBusiness(name, b) {
// Row 177: a human named `tester` would share the tester's threads.
if (!id(name) || b.id !== name || !id(b.human) || b.human === 'tester' || !object(b.roles) || !object(b.arbiters))
fail('invalid-business');
for (const role of Object.keys(b.roles)) {
if (!id(role) || role === 'human' || role === 'tester') fail('invalid-business');
const a = b.roles[role].authority;
if (!object(a) || !Array.isArray(a.withinRole) || !Array.isArray(a.crossRole))
fail('invalid-authority');
const all = [...a.withinRole, ...a.crossRole];
if (new Set(all).size !== all.length || all.some((v) => !ACTIONS.includes(v) || GATED.has(v)))
fail('invalid-authority');
}
if (!Object.hasOwn(b.roles, b.arbiters.technical) || !Object.hasOwn(b.roles, b.arbiters.delivery))
fail('invalid-arbiter');
// Row 120 (#1614): a role definition's optional messaging lists. A malformed one refuses at boot.
for (const d of Object.values(b.definitions ?? {})) {
const m = d?.messaging;
if (m === undefined || m === null) continue;
if (
!object(m) ||
!['sendTo', 'receiveFrom'].every(
(k) => Array.isArray(m[k]) && m[k].every((r) => id(r) && r !== 'tester'),
)
)
fail('invalid-messaging');
}
}
export class Broker {
#store;
#businesses;
@@ -268,13 +199,11 @@ export class Broker {
#usage;
#limits;
#harnessOn;
#testerOn;
#roleFile;
// Row 125 (#1619, REQ-LIMIT-4): a run a limit stops, by business:run, until its next receive or state.
#stops = new Map();
#clock = 0;
// credentialStatus is the runtime's metadata-only Credentials.status(); the broker never gets use().
constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, usage = null, limits = null, harnessOn = null, testerOn = null, roleFile = null }) {
constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, usage = null, limits = null, harnessOn = null }) {
if (!object(businesses) || !Object.keys(businesses).length) fail('invalid-business');
this.#businesses = structuredClone(businesses);
this.#store = store;
@@ -289,15 +218,6 @@ export class Broker {
// module-shaped name passes and the launcher's own harness-off check is the only one.
if (harnessOn !== null && typeof harnessOn !== 'function') throw new TypeError('harnessOn must be a function');
this.#harnessOn = harnessOn ?? (() => true);
// Row 194 (#1692, D4.1, B8): testerOn(business) is true when the organization file as it stands
// sets bus.tester to exactly true; the bus runtime reads it live, so a hand edit counts at the next
// tester lifecycle call and at the take. Without it the broker's own copy decides.
if (testerOn !== null && typeof testerOn !== 'function') throw new TypeError('testerOn must be a function');
this.#testerOn = testerOn ?? ((business) => this.#business(business).vars?.['bus.tester'] === true);
// Row 194 (#1692, D2.3): roleFile(name) loads role definition `name` from the roles directory as it
// stands, for the hire checks. Without it a hire refuses not-configured.
if (roleFile !== null && typeof roleFile !== 'function') throw new TypeError('roleFile must be a function');
this.#roleFile = roleFile;
// Row 90 (#1584): the approved PRD's requirement ids. A bad requirements.json refuses here.
this.#requirements = loadRequirements();
for (const table of [
@@ -320,17 +240,34 @@ export class Broker {
this.#clock = Math.max(this.#clock, ms);
}
}
for (const [name, b] of Object.entries(this.#businesses)) checkBusiness(name, b);
}
// Row 194 (#1692, D3): swap in one business's document, loaded from the file as a restart would
// load it. It is synchronous, so no request sees half of it. A new human is row 193's ground.
reloadBusiness(name, document) {
if (!Object.hasOwn(this.#businesses, name)) fail('unknown-business');
if (!object(document)) fail('invalid-business');
const next = structuredClone(document);
checkBusiness(name, next);
if (next.human !== this.#businesses[name].human) fail('invalid-business');
this.#businesses[name] = next;
for (const [name, b] of Object.entries(this.#businesses)) {
// Row 177: a human named `tester` would share the tester's threads.
if (!id(name) || b.id !== name || !id(b.human) || b.human === 'tester' || !object(b.roles) || !object(b.arbiters))
fail('invalid-business');
for (const role of Object.keys(b.roles)) {
if (!id(role) || role === 'human' || role === 'tester') fail('invalid-business');
const a = b.roles[role].authority;
if (!object(a) || !Array.isArray(a.withinRole) || !Array.isArray(a.crossRole))
fail('invalid-authority');
const all = [...a.withinRole, ...a.crossRole];
if (new Set(all).size !== all.length || all.some((v) => !ACTIONS.includes(v) || GATED.has(v)))
fail('invalid-authority');
}
if (!Object.hasOwn(b.roles, b.arbiters.technical) || !Object.hasOwn(b.roles, b.arbiters.delivery))
fail('invalid-arbiter');
// Row 120 (#1614): a role definition's optional messaging lists. A malformed one refuses at boot.
for (const d of Object.values(b.definitions ?? {})) {
const m = d?.messaging;
if (m === undefined || m === null) continue;
if (
!object(m) ||
!['sendTo', 'receiveFrom'].every(
(k) => Array.isArray(m[k]) && m[k].every((r) => id(r) && r !== 'tester'),
)
)
fail('invalid-messaging');
}
}
}
#now() {
this.#clock = Math.max(Date.now(), this.#clock + 1);
@@ -367,92 +304,6 @@ export class Broker {
owner,
);
}
// Row 194 (#1692): the runs that hold a claim for an instance, one per thread for a thread instance.
#running(business, instance) {
if (this.#threaded(business, instance)) return this.#threadHolders(business, instance).map((h) => h.holder_run);
const h = this.#holder(business, instance);
return h?.op === 'claim' ? [h.holder_run] : [];
}
// Row 194 (#1692, D1.1): a lifecycle request and how far it got, or null. The request is the
// action.allowed event the verb wrote. Its taken, done and cancelled events name it by id.
#lifecycle(business, request) {
const row = this.#store.get(
"SELECT * FROM events WHERE business=? AND kind='action.allowed' AND json_extract(body,'$.request')=? AND json_extract(body,'$.action') IN (?,?,?,?)",
business, request, ...LIFECYCLE_REQUESTS);
if (!row) return null;
const steps = this.#store.all(
"SELECT body FROM events WHERE business=? AND kind='action.allowed' AND json_extract(body,'$.request')=? AND json_extract(body,'$.action') IN (?,?,?) ORDER BY seq",
business, request, ...LIFECYCLE_STEPS).map((e) => JSON.parse(e.body));
const step = (action) => steps.find((x) => x.action === action) ?? null;
const done = step('lifecycle.done'), cancelled = step('lifecycle.cancelled'), taken = step('lifecycle.taken') !== null;
return { ...JSON.parse(row.body), at: row.at, instance: row.subject, taken, done, cancelled,
state: done || cancelled ? 'closed' : taken ? 'taken' : 'open' };
}
// The requests not yet closed, oldest first, of one instance or of the whole business.
#openRequests(business, instance = null) {
return this.#store.all(
`SELECT json_extract(r.body,'$.request') request FROM events r WHERE r.business=? AND r.kind='action.allowed'
AND json_extract(r.body,'$.action') IN (?,?,?,?) AND (? IS NULL OR r.subject=?)
AND NOT EXISTS (SELECT 1 FROM events c WHERE c.business=r.business AND c.kind='action.allowed'
AND json_extract(c.body,'$.request')=json_extract(r.body,'$.request') AND json_extract(c.body,'$.action') IN ('lifecycle.done','lifecycle.cancelled'))
ORDER BY r.seq`,
business, ...LIFECYCLE_REQUESTS, instance, instance).map((r) => this.#lifecycle(business, r.request));
}
#expired(r) {
return Math.max(Date.now(), this.#clock) - Date.parse(r.at) >= LIFECYCLE_REQUEST_MS;
}
// V5 instance-starting: a launch, regenerate or delete of I that is taken, or open and still young
// enough to be taken. An open request past its 60 s can only close as request-expired.
#starting(business, instance) {
return this.#openRequests(business, instance).some((r) => r.action !== 'lifecycle.end' && (r.state === 'taken' || !this.#expired(r)));
}
// The verb's request event, with a human.input for the human's own, as launch.revoke writes. A
// tester request records `tester` and has no human input.
#lifecycleRecord(s, action, instance, extra = {}) {
const request = randomUUID();
const eid = this.#event(s, 'action.allowed', { action, request, via: s.tester ? 'tester' : s.via, by: this.#owner(s), ...extra }, instance);
if (!s.tester) this.#event(s, 'human.input', { kind: 'admin', event: eid });
return request;
}
// A step the launcher's host ops write, or a terminate's cancel. No session acts in it.
#lifecycleStep(business, action, body) {
this.#event({ business, role: null, run: null }, 'action.allowed', { action, ...body });
}
// D4.1: a tester lifecycle call, and the take of a tester request, need bus.tester exactly true in
// the file as it stands.
#testerFile(business) {
if (this.#testerOn(business) !== true) fail('tester-off');
}
// V2 and V4 at the verb. The take repeats the revoked and retired checks.
#launchable(s, instance, thread) {
const b = this.#business(s.business);
if ((b.retired ?? []).includes(instance)) fail('instance-retired');
if (!Object.hasOwn(b.roles, instance)) fail('unknown-role');
if (this.#threaded(s.business, instance)) fail(thread);
if (this.#store.get('SELECT state FROM launch_state WHERE business=?', s.business)?.state === 'revoked') fail('launch-revoked');
if (this.#firing(s.business, instance)) fail('instance-firing');
}
// W-N1 (webby-0014): a fire of I stopped its runs, and no fire of I has finished since.
#firing(business, instance) {
return !!this.#store.get(
`SELECT 1 FROM events f WHERE f.business=? AND f.kind='action.allowed' AND f.subject=? AND json_extract(f.body,'$.action')='fire.stopped'
AND NOT EXISTS (SELECT 1 FROM events d WHERE d.business=f.business AND d.kind='action.allowed' AND d.subject=f.subject
AND json_extract(d.body,'$.action')='fire.done' AND d.seq>f.seq) LIMIT 1`,
business, instance);
}
// D1.1 item 2: why the take closes a request instead of going, or null. A terminate's signal
// always goes. Its claims were revoked at the verb.
#takeRefusal(business, r) {
if (r.action === 'lifecycle.end') return null;
if (this.#expired(r)) return 'request-expired';
if (r.action !== 'lifecycle.delete') {
if (this.#store.get('SELECT state FROM launch_state WHERE business=?', business)?.state === 'revoked') return 'launch-revoked';
if ((this.#business(business).retired ?? []).includes(r.instance)) return 'instance-retired';
if (this.#firing(business, r.instance)) return 'instance-firing';
}
if (r.by === 'tester' && this.#testerOn(business) !== true) return 'tester-off';
return null;
}
// The claim a run acts under: its thread's for a thread run, its role's otherwise.
#own(s) {
return s.thread ? this.#threadHolder(s.thread) : this.#holder(s.business, s.role);
@@ -525,12 +376,6 @@ export class Broker {
if (s.reader) fail('read-only');
if (!s.human || s.via !== 'cli' || s.outsideAgent !== true) fail('human-required');
}
// Row 194 (#1692, D4): until row 193, a lifecycle verb passes for the business's one human, the
// owner. Row 193 replaces this body and nothing else.
#mayHuman(s, verb, target) {
if (!LIFECYCLE_HUMAN_VERBS.includes(verb) || typeof target !== 'string') fail('human-required');
if (s.human !== this.#business(s.business).human) fail('human-required');
}
#cap(s) {
const cap = randomBytes(32).toString('hex');
this.#sessions.set(cap, Object.freeze(s));
@@ -927,7 +772,6 @@ export class Broker {
this.#store.get('SELECT state FROM launch_state WHERE business=?', s.business)?.state === 'revoked'
)
fail('launch-revoked');
if (action === 'role.launch' && this.#firing(s.business, target)) fail('instance-firing');
const cls = this.#classification(s, action, target);
if (cls === 'within-role' && !decision) return { class: cls };
if (!decision) fail('decision-required');
@@ -1225,70 +1069,6 @@ export class Broker {
this.#store.transaction(() => this.#claimEnd(session, h, 'release', null, null, 'land-relaunch'));
return { released: true };
}
// Row 194 (#1692, D1.1): the launcher's lifecycle pass. Trusted launcher API, not socket verbs.
// lifecycleRequests lists the requests not yet closed, oldest first.
lifecycleRequests(business) {
this.#business(identifier(business));
return this.#openRequests(business).map(lifecycleView);
}
// The take, in one transaction: an open request that passes #takeRefusal is taken and answers go,
// one that fails it closes with the code. A taken request is never taken again.
lifecycleTake(record) {
keys(record, ['business', 'request'], ['business', 'request']);
this.#business(identifier(record.business));
identifier(record.request);
return this.#store.transaction(() => {
const r = this.#lifecycle(record.business, record.request) ?? fail('unknown-request');
if (r.state !== 'open') return { go: false, refusal: 'request-closed', ...lifecycleView(r) };
const refusal = this.#takeRefusal(record.business, r);
this.#lifecycleStep(record.business, refusal ? 'lifecycle.done' : 'lifecycle.taken', { request: r.request, ...(refusal ? { refusal } : {}) });
return { go: !refusal, refusal, ...lifecycleView(this.#lifecycle(record.business, r.request)) };
});
}
// V5 step 2 for a taken delete: the running and starting checks again, then the writer's delete
// against the file as it stands, with the human's revision. The bus reloads after the commit, as
// after every write. A retry of a retired name commits nothing and goes to the rename, with the
// transaction of the delete whose rename failed (row 194 S3), which names the rename's target.
lifecycleDelete(record) {
keys(record, ['business', 'request'], ['business', 'request']);
this.#business(identifier(record.business));
const r = this.#lifecycle(record.business, identifier(record.request)) ?? fail('unknown-request');
if (r.action !== 'lifecycle.delete' || r.state !== 'taken') fail('request-not-taken');
if (r.retry === true) {
const failed = this.#store.all(
"SELECT json_extract(body,'$.request') request FROM events WHERE business=? AND kind='action.allowed' AND subject=? AND json_extract(body,'$.action')='lifecycle.delete' AND json_extract(body,'$.request')<>? ORDER BY seq DESC",
record.business, r.instance, r.request).map((x) => this.#lifecycle(record.business, x.request))
.find((x) => x.done?.refusal === 'workspace-rename-failed');
return { transaction: failed?.done?.transaction ?? null, retry: true };
}
if (this.#running(record.business, r.instance).length > 0) fail('instance-running');
if (this.#openRequests(record.business, r.instance).some((o) => o.request !== r.request && o.action !== 'lifecycle.end' &&
(o.state === 'taken' || !this.#expired(o)))) fail('instance-starting');
if (!this.#organization) fail('not-configured');
let out;
try {
out = this.#organization(record.business, 'instances.set', { revision: r.revision, instance: r.instance, op: 'delete' }, r.by);
} catch (e) {
this.#organizationFail(e, 'instances.set');
}
return { transaction: out.transaction, retry: false };
}
// The close of a taken request, with the run it started, the delete's transaction or the code
// that stopped it.
lifecycleDone(record) {
keys(record, ['business', 'request', 'run', 'transaction', 'refusal'], ['business', 'request']);
this.#business(identifier(record.business));
identifier(record.request);
for (const k of ['run', 'transaction']) if (k in record) identifier(record[k]);
if ('refusal' in record && !LIFECYCLE_CODE.test(record.refusal)) fail('invalid-request');
return this.#store.transaction(() => {
const r = this.#lifecycle(record.business, record.request) ?? fail('unknown-request');
if (r.state !== 'taken') fail(r.state === 'open' ? 'request-not-taken' : 'request-closed');
const { business, ...body } = record;
this.#lifecycleStep(business, 'lifecycle.done', body);
return lifecycleView(this.#lifecycle(business, r.request));
});
}
// The merge decisions landHeld would count, each with its raised context and closing event. A
// null role is every non-thread instance's (row 156): raised outside a thread by a role that
// isn't a thread instance.
@@ -1465,9 +1245,7 @@ export class Broker {
return this.#requestProject(s, { verb: request.verb, args: structuredClone(args) });
if (PROJECT_FILE_VERBS.includes(request.verb))
return this.#requestProjectFile(s, { verb: request.verb, args: structuredClone(args) });
if (request.verb === 'instance.hire' || request.verb === 'instance.fire')
return this.#chiefLifecycle(s, request.verb, structuredClone(args));
if (["limits", "limits.set", "organization.set", "organization.revision", "plugins", "plugins.set", "instances", "instances.set", "routes", "routes.set", "veto.set"].includes(request.verb)) {
if (["limits", "limits.set", "organization.set", "organization.revision", "plugins", "plugins.set", "instances", "instances.set", "routes", "routes.set"].includes(request.verb)) {
// Row 123 (#1617, REQ-COS-1, REQ-LIMIT-1): organization.set is limits.set for a role whose
// file lists organization.change. It takes the same keys, runs the same write path and
// logs the same receipt; only the actor differs: the role definition and instance.
@@ -1484,42 +1262,13 @@ export class Broker {
else if (request.verb === "plugins.set") keys(args, ["revision", "plugins"], ["revision", "plugins"]);
// Row 165 (#1662): instances.set is human-only and outside ORGANIZATION_KEYS, so
// organization.set and the chief of staff's tool never reach it (sage-0918).
else if (request.verb === "instances.set") {
keys(args, INSTANCE_KEYS, ["revision", "instance", "op"]);
if (!["add", "change", "delete"].includes(args.op)) fail("invalid-request");
// Row 194 (D4.1, B6): the tester never deletes.
if (s.tester && args.op === "delete") fail("tester-refused");
if (!s.tester) {
this.#human(s);
this.#mayHuman(s, `instances.set ${args.op}`, args.instance);
}
// A delete is a launcher request (V5): the checks here write nothing, and the launcher's
// pass commits the file and renames the workspace after the take.
if (args.op === "delete") return this.#deleteRequest(s, args);
// Row 194 (D3 item 4): the write now goes live at once, so a live cap's authority must not
// change under its run.
if (args.op === "change" && "definition" in args && typeof args.instance === "string" &&
Object.hasOwn(this.#business(s.business).roles, args.instance) && this.#running(s.business, args.instance).length > 0)
fail("instance-running");
}
else if (request.verb === "instances.set") keys(args, INSTANCE_KEYS, ["revision", "instance", "op"]);
// Row 65 (mos-0840): routes.set, the Console's routing table, is human-only and outside
// ORGANIZATION_KEYS too. Through organization.set the chief of staff changes only the model
// key of an instance, except the assistant, whose whole vars it may set (Q169); that write
// runs the same route checks.
else if (request.verb === "routes.set") keys(args, ["revision", "routes"], ["revision", "routes"]);
// Row 194 (#1692, D2.4, V6): only the human writes launch.keep and launch.noHire. The tester
// is refused by the gate above, as veto.set isn't in TESTER_ORGANIZATION_VERBS.
else if (request.verb === "veto.set") {
keys(args, VETO_KEYS, VETO_KEYS);
this.#human(s);
this.#mayHuman(s, "veto.set", args.name);
} else {
keys(args, ORGANIZATION_KEYS, ["revision"]);
// Row 194 (D2.4, B7): the chief can't change a kept instance's model, as that replaces it at
// the next launch. The human's limits.set still can.
const keep = this.#business(s.business).launch?.keep ?? [];
if (role && object(args.instances) && Object.keys(args.instances).some(i => keep.includes(i))) fail("instance-kept");
}
else keys(args, ORGANIZATION_KEYS, ["revision"]);
let actor = s.tester ? "tester" : s.human;
// This branch runs outside the store transaction below, so the grant is checked before the writer
// and consumed only once the writer has committed: a refused change logs action.refused alone,
@@ -1534,14 +1283,20 @@ export class Broker {
? { revision: this.#organization(s.business, "limits", {}, actor).revision }
: this.#organization(s.business, role ? "limits.set" : request.verb, args, actor);
} catch (e) {
this.#organizationFail(e, request.verb);
if (e.code === "revision-conflict") fail("revision-conflict");
if (e.code?.startsWith("organization-")) fail(e.code);
if (e.code === "invalid-plugins" || e.code === "unknown-plugin") fail(e.code);
// Row 65: the route checks every write of an instance's route vars runs.
if (["harness-off", "harness-refused", "thinking-unsupported"].includes(e.code)) fail(e.code);
// The routing table names a model the table lacks; the older verbs keep invalid-request.
if (request.verb === "routes.set" && e.code === "unknown-model") fail(e.code);
if (e.name === "BusinessError" || e.code === "invalid-request" || e.code === "unknown-model") {
fail("invalid-request");
}
throw new BusError("outcome-unknown");
}
if (role && !revisionRead) this.#store.transaction(() => this.#consumeAuthority(s, "organization.change"));
this.#secretCheck(result);
// Row 194 (#1692, D1): the read gains each instance's running runs, from the bus claims. An
// answer with no instances map passes through as it did before.
if (request.verb === "instances" && result?.instances && typeof result.instances === "object")
for (const [name, entry] of Object.entries(result.instances)) entry.running = this.#running(s.business, name);
return result;
}
return this.#store.transaction(() => {
@@ -1561,153 +1316,6 @@ export class Broker {
throw this.#refused(s, e, request);
}
}
// Row 194 (#1692, D2.1, D2.2): the chief's hire and fire. PRD 1.7 REQ-COS-3 routes both within the
// chief's role with a reason, as role.end ends a run (S2e). The writer commits outside the store
// transaction, so the grant is checked before it and the use logged only after it, as
// organization.set's grant is.
#chiefLifecycle(s, verb, a) {
const hire = verb === 'instance.hire', action = hire ? 'role.hire' : 'role.fire';
this.#agent(s);
const [allowed, required] = HIRE_FIRE_ARGS[action];
keys(a, allowed, required);
this.#hireFireArgs(action, a);
const args = a;
const b = this.#business(s.business);
if (b.launch?.by !== s.role) fail('role-not-chief');
if (!this.#organization) fail('not-configured');
// The grant: the action is routed and on the chief's withinRole. Without both it needs a decision
// (decision-required), and these verbs take none.
const authority = { target: args.name };
this.#checkAuthority(s, action, authority);
// D2.4: a kept name is neither hired nor fired.
if ((b.launch?.keep ?? []).includes(args.name)) fail('instance-kept');
const actor = b.roles[s.role].definition + '.' + s.role;
const context = { instance: s.role, run: s.run, reason: args.reason };
// The writer gets its own keys. The reason goes in the context, which its receipt logs.
const { reason: _, ...written } = args;
const write = (op) => {
try {
return this.#organization(s.business, op, written, actor, context);
} catch (e) {
this.#organizationFail(e, verb);
}
};
if (hire) {
this.#hireable(s, args.definition);
const result = write('instance.hire');
this.#store.transaction(() => this.#consumeAuthority(s, action, authority, { reason: args.reason, transaction: result.transaction }));
return result;
}
if (!Object.hasOwn(b.roles, args.name)) fail('unknown-role');
if (END_PROTECTED.includes(b.roles[args.name].definition)) fail('organization-definition-refused');
if (b.launch?.by === args.name) fail('organization-instance-referenced');
// The last listed name refuses before step A stops anything, as the writer would at step B.
const listed = b.launch?.instances ?? [];
if (listed.length === 1 && listed[0] === args.name) fail('invalid-request');
// Step A (webby-0011 B2): the store side first, so a delisted agent never keeps a live process.
// V3's revoke, cancel and end, with kill: true for the launcher's escalation.
const stopped = this.#store.transaction(() => {
const held = this.#threaded(s.business, args.name)
? this.#threadHolders(s.business, args.name)
: [this.#holder(s.business, args.name)].filter((h) => h?.op === 'claim');
for (const h of held) this.#claimEnd(s, h, 'revoke', null, args.reason);
const runs = held.map((h) => h.holder_run);
for (const [key, r] of this.#runs)
if (r.business === s.business && r.role === args.name && !this.#ended.has(key) && !runs.includes(r.run)) runs.push(r.run);
const open = this.#openRequests(s.business, args.name).filter((r) => r.action !== 'lifecycle.end');
const cancelled = open.filter((r) => r.state === 'open').map((r) => r.request);
const taken = open.filter((r) => r.state === 'taken').map((r) => r.request);
for (const request of cancelled) this.#lifecycleStep(s.business, 'lifecycle.cancelled', { request, by: s.role });
// The request the launcher's pass takes to signal the runs.
const request = runs.length === 0 ? null : randomUUID();
if (request)
this.#event(s, 'action.allowed', { action: 'lifecycle.end', request, via: 'chief', by: s.role, runs, reason: args.reason, kill: true }, args.name);
this.#event(s, 'action.allowed', { action: 'fire.stopped', request }, args.name);
return { request, runs, cancelled, taken };
});
// Step B: the delist. An instance already delisted counts as done.
const result = write('instance.fire');
// Step C: log the fire and record it done. A failure at B or C leaves I firing until a repeat.
this.#store.transaction(() => {
this.#consumeAuthority(s, action, authority, { reason: args.reason, transaction: result.transaction ?? null });
this.#event(s, 'action.allowed', { action: 'fire.done', transaction: result.transaction ?? null }, args.name);
});
return { ...result, ...stopped };
}
// The shape of hire and fire arguments.
#hireFireArgs(action, args) {
identifier(args.name);
if (args.name === 'human' || args.name === 'tester') fail('invalid-request');
string(args.reason, 500);
if (action === 'role.fire') return;
identifier(args.definition);
if ('vars' in args && (!object(args.vars) || !Object.keys(args.vars).every(HIRE_VAR))) fail('invalid-request');
}
// D2.3 at hire, against the role files as they stand: a version 2 definition whose every action is
// on HIRE_ALLOWED (W-N2), named in the chief role file's hireable list and not in launch.noHire.
#hireable(s, definition) {
if (!this.#roleFile) fail('not-configured');
const b = this.#business(s.business);
const load = (name) => {
try {
return this.#roleFile(name);
} catch (e) {
if (e.name === 'BusinessError') fail('definition-not-hireable');
throw e;
}
};
const def = load(definition);
if (def.roleVersion !== 2 || unhireableAction(def) !== null) fail('definition-not-hireable');
if (!(load(b.roles[s.role].definition).hireable ?? []).includes(definition)) fail('definition-not-hireable');
if ((b.launch?.noHire ?? []).includes(definition)) fail('definition-not-hireable');
}
// The bus code for an organization writer's refusal. Anything the writer didn't refuse by name is
// outcome-unknown: the write may have committed.
#organizationFail(e, verb) {
if (e.code === "revision-conflict") fail("revision-conflict");
if (e.code?.startsWith("organization-")) fail(e.code);
if (e.code === "invalid-plugins" || e.code === "unknown-plugin") fail(e.code);
// Row 194: the writer's own copy of the hire and fire checks, against the file as it stands.
if ((verb === "instance.hire" || verb === "instance.fire") && ["definition-not-hireable", "instance-kept"].includes(e.code)) fail(e.code);
// Row 65: the route checks every write of an instance's route vars runs.
if (["harness-off", "harness-refused", "thinking-unsupported"].includes(e.code)) fail(e.code);
// The routing table names a model the table lacks; the older verbs keep invalid-request.
if (verb === "routes.set" && e.code === "unknown-model") fail(e.code);
if (e.name === "BusinessError" || e.code === "invalid-request" || e.code === "unknown-model") {
fail("invalid-request");
}
throw new BusError("outcome-unknown");
}
// Row 194 (#1692, V5): a delete's checks at the verb, in the design's order, then its request.
// The writer's instances.check runs deleteRefusal then the last-listed check, with no write. Its
// named refusals come first. The last-listed one (a BusinessError) waits until the broker's
// running and starting checks have passed. A retired name is a retry of the workspace rename, and
// only after a rename that failed.
#deleteRequest(s, args) {
keys(args, ["revision", "instance", "op"], ["revision", "instance", "op"]);
identifier(args.instance);
if (typeof args.revision !== "string" || !/^[a-f0-9]{64}$/.test(args.revision)) fail("invalid-request");
const b = this.#business(s.business);
if ((b.retired ?? []).includes(args.instance) && !Object.hasOwn(b.roles, args.instance)) {
const last = this.#store.get(
"SELECT json_extract(body,'$.request') request FROM events WHERE business=? AND kind='action.allowed' AND subject=? AND json_extract(body,'$.action')='lifecycle.delete' ORDER BY seq DESC LIMIT 1",
s.business, args.instance);
if (this.#lifecycle(s.business, last?.request ?? "")?.done?.refusal !== "workspace-rename-failed") fail("organization-instance-retired");
return { request: this.#store.transaction(() => this.#lifecycleRecord(s, "lifecycle.delete", args.instance, { revision: args.revision, retry: true })) };
}
let lastListed = null, revision = null;
try {
({ revision } = this.#organization(s.business, "instances.check", { instance: args.instance }, s.human));
} catch (e) {
if (e.name !== "BusinessError") this.#organizationFail(e, "instances.set");
lastListed = e;
}
if (this.#running(s.business, args.instance).length > 0) fail("instance-running");
if (this.#starting(s.business, args.instance)) fail("instance-starting");
if (lastListed) fail("invalid-request");
if (revision !== args.revision) fail("revision-conflict");
return { request: this.#store.transaction(() => this.#lifecycleRecord(s, "lifecycle.delete", args.instance, { revision: args.revision })) };
}
// Only the runner's bound session and delivered message identify a turn; no host path crosses the wire.
// Row 156 (#1651, sage-0808): a role run's landing turn is a merge decision id instead. Its begin
// needs the decision held for the run's role; its end needs that begin, since the landing may
@@ -2002,9 +1610,6 @@ export class Broker {
this.#consumeAuthority(s, 'role.end', { target: a.role }, { reason: a.reason });
if (a.role === s.role) fail('role-end-refused');
this.#protectedEnd(s, a.role);
// Row 194 (#1692, D2.4): a kept instance's run ends only by a decision the human resolved,
// the human's own end or the limits. End then launch would be a replace.
if ((this.#business(s.business).launch?.keep ?? []).includes(a.role)) fail('instance-kept');
} else {
identifier(a.decision);
this.#consumeAuthority(s, 'role.revoke', { decision: a.decision, target: a.role });
@@ -2680,60 +2285,6 @@ export class Broker {
this.#claimEnd(s, h, 'revoke', null, s.tester ? 'stopped by the tester' : 'stopped by the human', s.tester ? 'tester' : s.human);
return { stopped: held.map((h) => ({ thread: h.thread, run: h.holder_run })) };
}
// Row 194 (#1692, D1.1, V2, V4): launch and regenerate are requests the launcher's pass takes.
// The tester launches (D4.1) and never regenerates: instance.regenerate isn't in TESTER_VERBS.
case 'instance.launch':
case 'instance.regenerate': {
if (!s.tester) this.#human(s);
keys(a, ['instance'], ['instance']);
identifier(a.instance);
if (s.tester) this.#testerFile(s.business);
else this.#mayHuman(s, verb, a.instance);
this.#launchable(s, a.instance, verb === 'instance.launch' ? 'not-thread-launch' : 'regenerate-thread');
const action = verb === 'instance.launch' ? 'lifecycle.launch' : 'lifecycle.regenerate';
return { request: this.#lifecycleRecord(s, action, a.instance) };
}
// V3: every claim of I is revoked, and every open request of I cancelled, in this transaction.
// lifecycle.end names the runs for the launcher's pass to signal, the bound runs that hold no
// claim included. A tester ends only its own threads of a thread instance, as assistant.stop
// does, and cancels only its own requests.
case 'instance.end': {
if (!s.tester) this.#human(s);
keys(a, ['instance', 'reason'], ['instance']);
identifier(a.instance);
if ('reason' in a) string(a.reason, 500);
if (s.tester) this.#testerFile(s.business);
else this.#mayHuman(s, verb, a.instance);
if (!Object.hasOwn(b.roles, a.instance)) fail('unknown-role');
const owner = s.tester ? 'tester' : null;
const threaded = this.#threaded(s.business, a.instance);
const held = threaded
? this.#threadHolders(s.business, a.instance, owner)
: [this.#holder(s.business, a.instance)].filter((h) => h?.op === 'claim');
const reason = a.reason ?? (s.tester ? 'ended by the tester' : 'ended by the human');
for (const h of held) this.#claimEnd(s, h, 'revoke', null, reason, this.#owner(s));
const runs = held.map((h) => h.holder_run);
for (const [key, r] of this.#runs)
if (r.business === s.business && r.role === a.instance && !this.#ended.has(key) && !runs.includes(r.run) &&
(!owner || !r.thread || this.#store.get('SELECT owner FROM threads WHERE id=?', r.thread)?.owner === owner))
runs.push(r.run);
const open = this.#openRequests(s.business, a.instance).filter((r) => r.action !== 'lifecycle.end' && (!owner || r.by === owner));
const cancelled = open.filter((r) => r.state === 'open').map((r) => r.request);
// A taken request is past cancelling. Its run reaches the registry, and the lifecycle.end
// lets the pass signal it there.
const taken = open.filter((r) => r.state === 'taken').map((r) => r.request);
if (runs.length === 0 && cancelled.length === 0 && taken.length === 0) fail('not-held');
for (const request of cancelled) this.#lifecycleStep(s.business, 'lifecycle.cancelled', { request, by: this.#owner(s) });
return { request: this.#lifecycleRecord(s, 'lifecycle.end', a.instance, { runs, reason }), runs, cancelled, taken };
}
// D1.1 item 5: the read the CLI and the Console poll. A tester reads only its own requests.
case 'instance.request': {
if (!s.tester) this.#human(s);
keys(a, ['request'], ['request']);
const r = this.#lifecycle(s.business, identifier(a.request));
if (!r || (s.tester && r.by !== 'tester')) fail('unknown-request');
return lifecycleView(r);
}
case 'names': {
keys(a, []);
if (!s.human && !s.tester) this.#agent(s);
-9
View File
@@ -20,10 +20,6 @@ const LAUNCH_OPS = new Set([
'mayLaunch',
'landHolders',
'landRelease',
'lifecycleRequests',
'lifecycleTake',
'lifecycleDelete',
'lifecycleDone',
]);
function launchOp(m) {
if (m.op === 'identity') return runtime.identity(m.cap);
@@ -48,11 +44,6 @@ function launchOp(m) {
// role run started before its decision was resolved.
if (m.op === 'landHolders') return runtime.landHolders(m.business);
if (m.op === 'landRelease') return runtime.landRelease(m.record);
// Row 194 (#1692, D1.1): the launcher's lifecycle pass.
if (m.op === 'lifecycleRequests') return runtime.lifecycleRequests(m.business);
if (m.op === 'lifecycleTake') return runtime.lifecycleTake(m.record);
if (m.op === 'lifecycleDelete') return runtime.lifecycleDelete(m.record);
if (m.op === 'lifecycleDone') return runtime.lifecycleDone(m.record);
if (m.op === 'credentialStatus') return runtime.credentialStatus(m.business, m.role);
// Every LAUNCH_OPS name has its line above. A missing one used to answer as credentialStatus,
// whose [] for an unknown role read as "no holders" for landHolders (row 156 mutant P02).
+6 -72
View File
@@ -11,11 +11,6 @@ import { readSessions } from '../../seat/src/session.mjs';
import { createLoader, hasModule } from '../../plugins/src/loader.mjs';
import { pluginSettings } from '../../business/src/plugin-settings.mjs';
import { instanceSettings } from '../../business/src/instance-settings.mjs';
import { loadBusiness } from '../../business/src/business.mjs';
import { loadRole } from '../../business/src/role.mjs';
import { resolveInstance } from '../../business/src/resolve.mjs';
import { canonicalJson } from '../../business/src/util.mjs';
import { busBusiness } from './business.mjs';
import { webService } from './web.mjs';
import { verifyHuman } from './human.mjs';
import { createUsage } from './usage.mjs';
@@ -108,40 +103,6 @@ export async function startBroker({
const limits = createLimits({ store, read: id => settings ? settings.current(id) : businesses[id], log: limitLog });
for (const id of Object.keys(businesses)) await limits.refresh(id);
const withTokens = (id, result) => ({ ...result, tokens: limits.report(id) });
// Row 194 (#1692, D3): after every write that commits, the broker takes the business as a restart
// would load it. The credential references, the project roots the Credentials store protects
// and the tracker bots are fixed at start (the Credentials store, the tasks adapter), so a file
// whose references, roots or bots differ from the boot copy isn't reloaded, and the write
// answers restart: true. Create and hire add neither,
// because credentialed definitions are refused.
// A role not in the boot copy matches only with no references and no bot.
const fixedRole = r => canonicalJson({ credentials: r?.credentials ?? {}, tracker: r?.tracker ?? null });
const fixedAtStart = b => ({
roles: Object.fromEntries(Object.entries(b.roles).map(([role, r]) => [role, fixedRole(r)])),
sync: canonicalJson(b.tracker?.sync ?? null),
roots: canonicalJson(Object.values(b.projects ?? {}).map(p => p.root).sort()),
});
const sameFixed = (boot, b) => fixedAtStart(b).sync === boot.sync && fixedAtStart(b).roots === boot.roots
&& Object.entries(fixedAtStart(b).roles).every(([role, f]) => f === (boot.roles[role] ?? fixedRole(null)));
const booted = new Map(Object.entries(businesses).map(([id, b]) => [id, fixedAtStart(b)]));
const reloadLog = entry => process.stderr.write(`organization: ${JSON.stringify(entry)}\n`);
function reload(id) {
try {
const b = loadBusiness(id, { dir: organization.dir, rolesDir: organization.rolesDir });
const resolved = {};
for (const instance of Object.keys(b.roles)) resolved[instance] = resolveInstance({ system: organization.system, business: b, instance });
const next = busBusiness(b, resolved);
if (!sameFixed(booted.get(id), next)) {
reloadLog({ event: 'reload-needs-restart', business: id });
return false;
}
broker.reloadBusiness(id, next);
return true;
} catch (e) {
reloadLog({ event: 'reload-failed', business: id, message: e.message });
return false;
}
}
const broker = new Broker({
usage,
limits,
@@ -157,32 +118,19 @@ export async function startBroker({
},
// limits and limits.set (organization.set is limits.set) carry each token limit's count; a
// plugins.set reloads the business's limit checks in the background.
organization:settings?(id,verb,args,actor,context)=>{
organization:settings?(id,verb,args,actor)=>{
if(verb==='limits')return withTokens(id,settings.read(id));
if(verb==='limits.set')return withTokens(id,settings.set(id,args,actor));
if(verb==='plugins')return plugins.read(id);
if(verb==='instances')return instances.read(id);
if(verb==='instances.set')return instances.set(id,args,actor);
// Row 65 (mos-0840): the Console's routing table.
if(verb==='routes')return settings.routes(id);
// Row 194 (#1692, V5): a delete's checks at the verb, with no write and no reload.
if(verb==='instances.check')return instances.checkDelete(id,args.instance);
let result;
if(verb==='limits.set')result=withTokens(id,settings.set(id,args,actor));
else if(verb==='instances.set')result=instances.set(id,args,actor);
else if(verb==='routes.set')result=settings.setRoutes(id,args,actor);
else if(verb==='plugins.set')result=plugins.set(id,args,actor);
// Row 194 (#1692, V6): the human's keep and noHire lists, through the instance writer.
else if(verb==='veto.set')result=instances.veto(id,args,actor);
// Row 194 (D2.1, D2.2): the chief's hire and fire, with the reason in the change context.
else if(verb==='instance.hire')result=instances.hire(id,args,{actor,context});
else if(verb==='instance.fire')result=instances.fire(id,{name:args.name},{actor,context});
else throw new BusError('invalid-request');
// Row 194 (D3 item 2): one reload, after every verb that committed, then the limit checks.
const live=reload(id);
if(verb==='routes.set')return settings.setRoutes(id,args,actor);
const result=plugins.set(id,args,actor);
limits.refresh(id).catch(e=>limitLog({event:'limit-refresh-failed',business:id,message:e.message}));
return verb==='instances.set'?{...result,restart:!live}:result;
return result;
}:null,
// Row 194 (D2.3): the role files as they stand, for the hire checks.
roleFile: organization ? (name) => loadRole(organization.rolesDir, name) : null,
secretCheck: (value) => credentials.assertClean(value),
credentialStatus: () => credentials.status(),
// Row 65: a new launch binds only on a harness its business switches on, read from the file
@@ -195,15 +143,6 @@ export async function startBroker({
return false;
}
},
// Row 194 (#1692, D4.1, B8): bus.tester as the file sets it now; an unreadable file is off.
testerOn: (id) => {
try {
const b = settings ? settings.current(id) : businesses[id];
return b?.vars?.['bus.tester'] === true;
} catch {
return false;
}
},
});
// Process identities must be supplied for every managed run when using the human transport.
const launchRecords = [];
@@ -302,11 +241,6 @@ export async function startBroker({
// Row 156 (#1651): the launcher's landing pass.
landHolders: (business) => broker.landHolders(business),
landRelease: (r) => broker.landRelease(r),
// Row 194 (#1692, D1.1): the launcher's lifecycle pass.
lifecycleRequests: (business) => broker.lifecycleRequests(business),
lifecycleTake: (r) => broker.lifecycleTake(r),
lifecycleDelete: (r) => broker.lifecycleDelete(r),
lifecycleDone: (r) => broker.lifecycleDone(r),
refuse: (cap, code, subject) => broker.refuse(cap, code, subject),
credentialStatus,
launches: bound,
+5 -413
View File
@@ -7,13 +7,9 @@ import { createHash } from 'node:crypto';
import { DatabaseSync } from 'node:sqlite';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { Store, schemaDigest } from '../src/store.mjs';
import { Broker, ACTIONS } from '../src/broker.mjs';
import { HIRE_ALLOWED } from '../../business/src/vocabulary.mjs';
import { Broker } from '../src/broker.mjs';
import { startBroker } from '../src/runtime.mjs';
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../business/tests/helpers.mjs';
import { busBusiness } from '../src/business.mjs';
import { loadBusiness } from '../../business/src/business.mjs';
import { resolveInstance } from '../../business/src/resolve.mjs';
// Row 123 (#1617): the chief of staff ends runs without a decision and changes the organization
// file through the shared write path. REQ-ROLE-2 (one holder; revoke), REQ-ROLE-3 (the
@@ -31,7 +27,7 @@ const roles = {
pm: { definition: 'pm', ...none },
coder: { definition: 'coder', ...none },
};
const demo = (extra = {}) => ({
const demo = () => ({
demo: {
id: 'demo',
human: 'jason',
@@ -42,13 +38,12 @@ const demo = (extra = {}) => ({
// Row 65: runtime launches bind only on a harness the business switches on.
plugins: { harness: ['pi'] },
vars: { 'bus.tester': true },
...extra,
},
});
const PATCH = { revision: 'a'.repeat(64), limits: { total: 3 } };
function setup(t, extra = {}, { roleFile = null } = {}) {
function setup(t) {
const root = mkdtempSync(join(tmpdir(), 'bus-chief-'));
const store = new Store(root);
t.after(() => {
@@ -56,16 +51,13 @@ function setup(t, extra = {}, { roleFile = null } = {}) {
rmSync(root, { recursive: true, force: true });
});
const organization = [];
const writer = { error: null, during: null };
const writer = { error: null };
const b = new Broker({
store,
businesses: demo(extra),
roleFile,
businesses: demo(),
organization: (...args) => {
organization.push(args);
if (writer.during) writer.during(args);
if (writer.error) throw writer.error;
if (args[1] === 'instance.hire' || args[1] === 'instance.fire') return { revision: 'b'.repeat(64), transaction: 't-' + organization.length };
// The human's limits read answers more than the revision; organization.revision must not.
return args[1] === 'limits'
? { revision: 'b'.repeat(64), limits: { total: 7 }, models: { m: {} }, tokens: { used: 1 } }
@@ -653,403 +645,3 @@ test('REQ-ROLE-2, REQ-COS-1: the 3c→3d migration lets the chief of staff end a
);
assert.throws(() => store.run(REVOKE_NEITHER), /revoke needs a resolved decision or a reason/);
});
// Row 194 (#1692, D2.4): the per-agent forbid. The chief can't end a kept instance's run with a
// reason or change its model. A decision the human resolved, and the human's own write, still can.
const KEPT = { launch: { by: 'chief', instances: ['pm', 'coder'], keep: ['coder'] } };
test('row 194 T10: the chief\'s reason end of a kept instance refuses instance-kept, holds the claim and spends nothing', (t) => {
const { call, lastClaim, allowed } = setup(t, KEPT);
assert.throws(() => call('chief', 'role.revoke', { role: 'coder', reason: 'replace it' }), /instance-kept/);
assert.deepEqual(lastClaim('coder'), { op: 'claim', decision: null, reason: null, by: 'coder-run' });
assert.equal(allowed('role.end').length, 0);
// An instance not on the list still ends with a reason.
assert.deepEqual(call('chief', 'role.revoke', { role: 'pm', reason: 'stuck' }), { revoked: true });
});
test('row 194 T10 (Q2): a role.revoke citing a decision the human resolved still ends a kept run', (t) => {
const { call, lastClaim } = setup(t, KEPT);
const d = call('chief', 'decision.raise', {
action: 'role.revoke', target: 'coder', question: 'Stop the coder?',
options: [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }], recommendation: 'yes', blocking: false,
});
call('human', 'decision.resolve', { id: d.id, choice: 'yes' });
assert.deepEqual(call('chief', 'role.revoke', { role: 'coder', decision: d.id }), { revoked: true });
assert.deepEqual(lastClaim('coder'), { op: 'revoke', decision: d.id, reason: null, by: 'chief-run' });
});
test('row 194 T10 (B7): the chief\'s organization.set of a kept instance\'s model refuses before the writer; the human\'s limits.set does not', (t) => {
const { call, organization, allowed } = setup(t, KEPT);
assert.throws(() => call('chief', 'organization.set', { revision: PATCH.revision, instances: { coder: 'm' } }), /instance-kept/);
assert.throws(() => call('chief', 'organization.set', { revision: PATCH.revision, instances: { pm: 'm', coder: 'm' } }), /instance-kept/);
assert.equal(organization.length, 0);
assert.equal(allowed('organization.change').length, 0);
call('chief', 'organization.set', { revision: PATCH.revision, instances: { pm: 'm' } });
call('human', 'limits.set', { revision: PATCH.revision, instances: { coder: 'm' } });
assert.deepEqual(organization.map(([, verb, args]) => [verb, Object.keys(args.instances)]), [['limits.set', ['pm']], ['limits.set', ['coder']]]);
});
test('row 194 T13: organization.set and limits.set refuse the launch, retired, keep and noHire keys before the writer', (t) => {
const { call, organization } = setup(t, KEPT);
for (const extra of [{ launch: { keep: [] } }, { retired: [] }, { keep: ['pm'] }, { noHire: ['coder'] }])
for (const [who, verb] of [['chief', 'organization.set'], ['human', 'limits.set']])
assert.throws(() => call(who, verb, { ...PATCH, ...extra }), /invalid-request/, `${who} ${Object.keys(extra)[0]}`);
assert.equal(organization.length, 0);
});
test('row 194 T18 (D2.4): from a chief session veto.set and instances.set refuse, and nothing reaches the writer', (t) => {
const { call, organization } = setup(t, KEPT);
for (const op of ['keep', 'unkeep', 'nohire', 'hire'])
assert.throws(() => call('chief', 'veto.set', { revision: PATCH.revision, op, name: 'coder' }), /human-required/, op);
for (const op of ['add', 'change', 'delete'])
assert.throws(() => call('chief', 'instances.set', { revision: PATCH.revision, instance: 'coder', op, definition: 'coder' }), /human-required/, op);
assert.equal(organization.length, 0);
// The human's veto reaches the writer with the human as actor.
call('human', 'veto.set', { revision: PATCH.revision, op: 'unkeep', name: 'coder' });
assert.deepEqual(organization.at(-1), ['demo', 'veto.set', { revision: PATCH.revision, op: 'unkeep', name: 'coder' }, 'jason']);
});
// Row 194 (#1692, D2.1-D2.3): the chief's hire and fire. PRD 1.7 REQ-COS-3 routes role.hire and
// role.fire within-role for the chief (sage-1471, mos-1089), so they take the reason form, as
// role.end does: no decision, a reason of 1 to 500 characters that goes into the log. The role
// files are stubs, so a definition's authority is the test's to set.
const LISTED = { launch: { by: 'chief', instances: ['pm', 'coder'], keep: ['pm'], noHire: ['banned'] } };
const roleDoc = (withinRole, extra = {}) => ({ roleVersion: 2, authority: { withinRole, crossRole: [] }, hireable: [], ...extra });
const ROLE_FILES = {
'chief-of-staff': roleDoc(chiefAuthority, { hireable: ['coder', 'researcher', 'wide', 'banned'] }),
coder: roleDoc(['message.send', 'record.write']),
// A version 1 file has empty authority lists, so only the version check refuses it (M60).
researcher: { roleVersion: 1, authority: { withinRole: [], crossRole: [] }, hireable: [] },
wide: roleDoc(['message.send', 'task.create']),
banned: roleDoc(['message.send']),
pm: roleDoc(['message.send']),
};
const roleFile = (files) => (name) => {
if (!Object.hasOwn(files, name)) throw Object.assign(new Error(`no role ${name}`), { name: 'BusinessError' });
return files[name];
};
function hireSetup(t, extra = LISTED, files = ROLE_FILES) {
return setup(t, extra, { roleFile: roleFile(files) });
}
const HIRE = { name: 'helper', definition: 'coder', reason: 'more hands for the queue' };
const FIRE = { name: 'coder', reason: 'the work is done' };
const CONTEXT = (reason) => ({ instance: 'chief', run: 'chief-run', reason });
const withoutReason = ({ reason, ...rest }) => rest;
test('row 194 T8: the chief hires with a reason and no decision, the writer gets the reason as context, and the log names it', (t) => {
const { call, organization, allowed, store } = hireSetup(t);
const result = call('chief', 'instance.hire', HIRE);
assert.equal(result.transaction, 't-1');
assert.deepEqual(organization, [['demo', 'instance.hire', withoutReason(HIRE), 'chief-of-staff.chief', CONTEXT(HIRE.reason)]]);
const [use, ...rest] = allowed('role.hire');
assert.equal(rest.length, 0);
assert.equal(use.subject, 'helper');
assert.deepEqual({ reason: use.body.reason, transaction: use.body.transaction, decision: use.body.decision ?? null }, { reason: HIRE.reason, transaction: 't-1', decision: null });
// Within-role: no decision is raised or needed.
assert.equal(store.get('SELECT count(*) AS n FROM decisions').n, 0);
// vars reach the writer with the name and definition.
call('chief', 'instance.hire', { ...HIRE, name: 'aide', vars: { model: 'm', 'limits.x': 1 } });
assert.deepEqual(organization.at(-1)[2], { name: 'aide', definition: 'coder', vars: { model: 'm', 'limits.x': 1 } });
});
// T11 (D5): a business id names one business on one bus. The chief's hire and fire go to its own
// business's writer, and no argument names another.
test('row 194 T11: on a bus with two businesses, the chief of one hires and fires only in its own', (t) => {
const root = mkdtempSync(join(tmpdir(), 'bus-chief-two-'));
const store = new Store(root);
t.after(() => {
store.close();
rmSync(root, { recursive: true, force: true });
});
const organization = [];
const one = demo(LISTED).demo;
const b = new Broker({
store,
businesses: { demo: one, other: { ...one, id: 'other' } },
roleFile: roleFile(ROLE_FILES),
organization: (...args) => (organization.push(args), { revision: 'b'.repeat(64), transaction: 't-' + organization.length }),
});
const chief = b.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi' });
b.request(chief, { verb: 'role.claim' });
for (const verb of ['instance.hire', 'instance.fire'])
for (const extra of [{ business: 'other' }, { target: 'other' }])
assert.throws(() => b.request(chief, { verb, args: { ...(verb === 'instance.hire' ? HIRE : FIRE), ...extra } }), /invalid-request/, `${verb} ${Object.keys(extra)}`);
assert.equal(organization.length, 0);
b.request(chief, { verb: 'instance.hire', args: HIRE });
b.request(chief, { verb: 'instance.fire', args: FIRE });
assert.deepEqual(organization.map((c) => [c[0], c[1]]), [['demo', 'instance.hire'], ['demo', 'instance.fire']]);
assert.equal(store.get("SELECT count(*) AS n FROM events WHERE business='other'").n, 0);
});
test('row 194 T8: hire and fire need a reason of 1 to 500 characters and refuse a decision key', (t) => {
const { call, organization } = hireSetup(t);
for (const reason of [undefined, '', 'x'.repeat(501), 7]) {
const r = reason === undefined ? {} : { reason };
assert.throws(() => call('chief', 'instance.hire', { name: 'helper', definition: 'coder', ...r }), /invalid-request/, String(reason));
assert.throws(() => call('chief', 'instance.fire', { name: 'coder', ...r }), /invalid-request/, String(reason));
}
assert.throws(() => call('chief', 'instance.hire', { ...HIRE, decision: 'd-1' }), /invalid-request/);
assert.throws(() => call('chief', 'instance.fire', { ...FIRE, decision: 'd-1' }), /invalid-request/);
for (const name of ['human', 'tester']) assert.throws(() => call('chief', 'instance.hire', { ...HIRE, name }), /invalid-request/, name);
assert.equal(organization.length, 0);
call('chief', 'instance.hire', { ...HIRE, reason: 'x'.repeat(500) });
assert.equal(organization.length, 1);
});
test('row 194 T8 (sage-1471): only the chief hires or fires; every other role refuses role-not-chief, even one granted the actions', (t) => {
const granted = { authority: { withinRole: ['message.send', 'role.hire', 'role.fire'], crossRole: [] } };
const { call, organization, allowed } = hireSetup(t, { ...LISTED, roles: { ...roles, pm: { definition: 'pm', ...granted }, coder: { definition: 'coder', ...granted } } });
for (const who of ['pm', 'coder', 'assistant']) {
assert.throws(() => call(who, 'instance.hire', HIRE), /role-not-chief/, who);
assert.throws(() => call(who, 'instance.fire', FIRE), /role-not-chief/, who);
}
assert.throws(() => call('tester', 'instance.hire', HIRE), /tester-refused/);
assert.throws(() => call('reader', 'instance.hire', HIRE), /read-only/);
assert.throws(() => call('human', 'instance.fire', FIRE), /agent-required/);
assert.equal(organization.length, 0);
assert.equal(allowed('role.hire').length + allowed('role.fire').length, 0);
});
test('row 194 T8 (sage-1471): a chief whose authority lacks role.hire or role.fire refuses decision-required before the writer', (t) => {
for (const missing of ['role.hire', 'role.fire']) {
const authority = { withinRole: chiefAuthority.withinRole.filter((a) => a !== missing), crossRole: [] };
const { call, organization, lastClaim } = hireSetup(t, { ...LISTED, roles: { ...roles, chief: { definition: 'chief-of-staff', authority } } });
const [verb, args] = missing === 'role.hire' ? ['instance.hire', HIRE] : ['instance.fire', FIRE];
assert.throws(() => call('chief', verb, args), /decision-required/, missing);
assert.equal(organization.length, 0, missing);
assert.equal(lastClaim('coder').op, 'claim', missing);
// The other action still goes through.
const [other, otherArgs] = missing === 'role.hire' ? ['instance.fire', FIRE] : ['instance.hire', HIRE];
call('chief', other, otherArgs);
assert.equal(organization.length, 1, missing);
}
});
test('row 194 T8a: a re-hire of a fired name goes to the writer like a hire, and the writer\'s refusals pass through by name', (t) => {
const { call, organization, writer } = hireSetup(t);
const again = { name: 'coder', definition: 'coder', reason: 'needed again' };
for (const code of ['organization-instance-exists', 'organization-instance-retired', 'definition-not-hireable', 'instance-kept']) {
writer.error = Object.assign(new Error(code), { code });
assert.throws(() => call('chief', 'instance.hire', again), new RegExp(code));
}
writer.error = null;
call('chief', 'instance.hire', again);
assert.deepEqual(organization.at(-1).slice(1, 3), ['instance.hire', withoutReason(again)]);
});
test('row 194 T8b: a definition is hireable only when every action is on HIRE_ALLOWED, it is version 2, the chief lists it and noHire doesn\'t', (t) => {
assert.deepEqual([...HIRE_ALLOWED], ['message.send', 'record.write', 'review.request', 'task.update.assigned', 'task.scope.change', 'git.push.working']);
for (const action of ACTIONS) {
const files = { ...ROLE_FILES, wide: roleDoc(['message.send', action]) };
const { call, organization } = hireSetup(t, LISTED, files);
const run = () => call('chief', 'instance.hire', { ...HIRE, definition: 'wide' });
if (HIRE_ALLOWED.includes(action)) {
run();
assert.equal(organization.length, 1, action);
} else {
assert.throws(run, /definition-not-hireable/, action);
assert.equal(organization.length, 0, action);
}
}
const { call, organization } = hireSetup(t);
const hire = (definition) => call('chief', 'instance.hire', { ...HIRE, definition });
// Version 1 (M60), noHire, not on the chief's list, and no role file.
for (const definition of ['researcher', 'banned', 'pm', 'nothing'])
assert.throws(() => hire(definition), /definition-not-hireable/, definition);
assert.equal(organization.length, 0);
hire('coder');
assert.equal(organization.length, 1);
});
test('row 194 T8b: a hire without the role files configured refuses not-configured', (t) => {
const { call, organization } = setup(t, LISTED);
assert.throws(() => call('chief', 'instance.hire', HIRE), /not-configured/);
assert.equal(organization.length, 0);
assert.throws(() => new Broker({ store: null, businesses: demo(), roleFile: 'roles' }), /roleFile must be a function/);
});
test('row 194 T9: a fire revokes the claim, cancels open requests, asks the launcher to kill the runs, then delists and logs', (t) => {
const { call, organization, allowed, lastClaim, writer } = hireSetup(t);
// An open launch request of the coder, made by the human.
const open = call('human', 'instance.launch', { instance: 'coder' }).request;
let seen = null;
writer.during = ([, verb]) => { if (verb === 'instance.fire') seen = { stopped: allowed('fire.stopped').length, claim: lastClaim('coder').op, used: allowed('role.fire').length }; };
const result = call('chief', 'instance.fire', FIRE);
// Step A ran before the writer; step C after it.
assert.deepEqual(seen, { stopped: 1, claim: 'revoke', used: 0 });
assert.deepEqual(lastClaim('coder'), { op: 'revoke', decision: null, reason: 'the work is done', by: 'chief-run' });
assert.deepEqual(result.runs, ['coder-run']);
assert.deepEqual(result.cancelled, [open]);
assert.deepEqual(organization.at(-1), ['demo', 'instance.fire', { name: 'coder' }, 'chief-of-staff.chief', CONTEXT(FIRE.reason)]);
const [end] = allowed('lifecycle.end');
assert.equal(end.subject, 'coder');
assert.deepEqual({ ...end.body, request: null }, { action: 'lifecycle.end', request: null, via: 'chief', by: 'chief', runs: ['coder-run'], reason: 'the work is done', kill: true });
assert.equal(allowed('lifecycle.cancelled').filter((e) => e.body.request === open).length, 1);
const [use] = allowed('role.fire');
assert.equal(use.subject, 'coder');
assert.equal(use.body.reason, 'the work is done');
assert.equal(use.body.transaction, result.transaction);
assert.deepEqual(allowed('fire.done').map((e) => [e.subject, e.body.transaction]), [['coder', result.transaction]]);
// The human's read of the request shows the kill.
assert.equal(call('human', 'instance.request', { request: end.body.request }).kill, true);
});
test('row 194 T9: fire refusals come before step A and leave the claim, the requests and the file alone', (t) => {
const { call, organization, allowed, lastClaim } = hireSetup(t, { launch: { by: 'chief', instances: ['coder'], keep: ['pm'] } });
const fire = (name) => call('chief', 'instance.fire', { name, reason: 'r' });
assert.throws(() => fire('pm'), /instance-kept/);
assert.throws(() => fire('nobody'), /unknown-role/);
assert.throws(() => fire('assistant'), /organization-definition-refused/);
assert.throws(() => fire('chief'), /organization-instance-referenced/);
assert.throws(() => fire('coder'), /invalid-request/);
assert.equal(organization.length, 0);
assert.equal(allowed('fire.stopped').length + allowed('lifecycle.end').length + allowed('role.fire').length, 0);
assert.equal(lastClaim('coder').op, 'claim');
});
test('row 194 T9c: a writer failure at B leaves the instance firing, and the repeat delists and logs once', (t) => {
const { call, b, caps, organization, allowed, writer } = hireSetup(t);
writer.error = new Error('disk');
assert.throws(() => call('chief', 'instance.fire', FIRE), /outcome-unknown/);
writer.error = null;
assert.equal(allowed('fire.stopped').length, 1);
assert.equal(allowed('role.fire').length, 0);
// W-N1: while it fires, no launch of the instance starts by any path.
assert.throws(() => call('human', 'instance.launch', { instance: 'coder' }), /instance-firing/);
assert.throws(() => b.authorize(caps.chief, 'role.launch', { target: 'coder' }), /instance-firing/);
// The repeat has no claim to revoke. The revoked run is still bound until the launcher reports it
// ended, so the repeat asks for its kill again, then delists and logs.
const result = call('chief', 'instance.fire', FIRE);
assert.deepEqual(result.runs, ['coder-run']);
assert.equal(organization.filter(([, verb]) => verb === 'instance.fire').length, 2);
assert.equal(allowed('role.fire').length, 1);
assert.deepEqual(allowed('lifecycle.end').map((e) => e.body.kill), [true, true]);
// Once done, the instance launches again.
assert.ok(call('human', 'instance.launch', { instance: 'coder' }).request);
});
test('row 194 T9c: a failure at C leaves the fire unlogged and the instance firing, and the repeat logs it', (t) => {
const { call, organization, allowed, store, writer } = hireSetup(t);
// The delist commits, then the step C transaction aborts.
writer.during = () => store.run("CREATE TRIGGER fail_c BEFORE INSERT ON events WHEN json_extract(new.body,'$.action')='fire.done' BEGIN SELECT RAISE(ABORT, 'c failed'); END");
assert.throws(() => call('chief', 'instance.fire', FIRE));
writer.during = null;
store.run('DROP TRIGGER fail_c');
assert.equal(allowed('role.fire').length, 0);
assert.equal(allowed('fire.done').length, 0);
assert.throws(() => call('human', 'instance.launch', { instance: 'coder' }), /instance-firing/);
call('chief', 'instance.fire', FIRE);
assert.equal(organization.filter(([, verb]) => verb === 'instance.fire').length, 2);
assert.equal(allowed('role.fire').length, 1);
assert.equal(allowed('fire.done').length, 1);
assert.ok(call('human', 'instance.launch', { instance: 'coder' }).request);
});
test('row 194 W-N1: a fire.done before a later fire.stopped of the same instance leaves it firing; another instance\'s done does not end it', (t) => {
const { call, store } = hireSetup(t);
const step = (instance, action) =>
store.run("INSERT INTO events(id,at,business,kind,actor_role,actor_run,subject,body) VALUES(?,?,'demo','action.allowed','chief','chief-run',?,?)",
`${action}-${instance}-${Math.random()}`, new Date().toISOString(), instance, JSON.stringify({ action, request: null, transaction: null }));
step('coder', 'fire.done');
step('coder', 'fire.stopped');
step('pm', 'fire.done');
assert.throws(() => call('human', 'instance.launch', { instance: 'coder' }), /instance-firing/);
step('coder', 'fire.done');
assert.ok(call('human', 'instance.launch', { instance: 'coder' }).request);
});
test('row 194 T10: a hire or fire of a kept name refuses instance-kept before the writer', (t) => {
const { call, organization, lastClaim, allowed } = hireSetup(t);
assert.throws(() => call('chief', 'instance.hire', { ...HIRE, name: 'pm' }), /instance-kept/);
assert.throws(() => call('chief', 'instance.fire', { ...FIRE, name: 'pm' }), /instance-kept/);
assert.equal(organization.length, 0);
assert.equal(lastClaim('pm').op, 'claim');
assert.equal(allowed('role.hire').length + allowed('role.fire').length, 0);
});
test('row 194 T18: hire and fire take only their own keys, and a refused call writes nothing', (t) => {
const { call, organization, allowed } = hireSetup(t);
for (const extra of [{ keep: ['x'] }, { launch: {} }, { revision: 'a'.repeat(64) }, { force: true }]) {
assert.throws(() => call('chief', 'instance.hire', { ...HIRE, ...extra }), /invalid-request/, JSON.stringify(extra));
assert.throws(() => call('chief', 'instance.fire', { ...FIRE, ...extra }), /invalid-request/, JSON.stringify(extra));
}
assert.throws(() => call('chief', 'instance.hire', { ...HIRE, vars: { harness: 'pi' } }), /invalid-request/);
assert.throws(() => call('chief', 'instance.fire', { ...FIRE, definition: 'coder' }), /invalid-request/);
assert.equal(organization.length, 0);
assert.equal(allowed('fire.stopped').length, 0);
});
test('row 194 T8, T9, T18: through the runtime the chief hires and fires with the real writer, the receipt carries the reason, and a refusal leaves the file alone', async (t) => {
const root = mkdtempSync(join(tmpdir(), 'bus-chief-hire-'));
const rolesDir = rolesCopy(root);
// A hireable scratch definition: version 2, no credentials, every action on HIRE_ALLOWED.
writeFileSync(join(rolesDir, 'aide.md'), '# Aide\n');
writeJson(join(rolesDir, 'aide.json'), {
roleVersion: 2, name: 'aide', title: 'Aide', contract: 'aide.md', tools: ['read'], network: 'api-only',
authority: { withinRole: ['message.send'], crossRole: [] }, credentials: [],
});
const chiefFile = join(rolesDir, 'chief-of-staff.json');
writeJson(chiefFile, { ...JSON.parse(readFileSync(chiefFile, 'utf8')), hireable: ['aide'] });
const file = join(root, 'businesses/demo.json');
writeJson(file, { ...businessDoc(root, 'demo'), plugins: { harness: ['pi'] } });
mkdirSync(join(root, 'data'), { mode: 0o700 });
// The boot copy is the file's own bus form, as cli bootConfig builds it, so a write reloads.
const busForm = (dir, roles) => {
const b = loadBusiness('demo', { dir, rolesDir: roles });
const resolved = {};
for (const instance of Object.keys(b.roles)) resolved[instance] = resolveInstance({ system: systemFor(dir), business: b, instance });
return busBusiness(b, resolved);
};
const bus = await startBroker({
dataRoot: join(root, 'data'),
businesses: { demo: busForm(root, rolesDir) },
organization: { dir: root, rolesDir, system: systemFor(root) },
});
t.after(async () => {
await bus.close();
rmSync(root, { recursive: true, force: true });
});
const { broker } = bus;
const chief = broker.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi' });
broker.request(chief, { verb: 'role.claim' });
const call = (cap, verb, args) => broker.request(cap, { verb, args });
const receipts = () =>
readFileSync(join(root, 'businesses/.history/demo/changes.jsonl'), 'utf8').trim().split('\n').map(JSON.parse).filter((r) => r.state === 'committed');
const document = () => JSON.parse(readFileSync(file, 'utf8'));
// The shipped chief file grants both actions, so the runtime takes the reason form.
// A refused hire: coder declares credentials and isn't on the chief's list. The file is unchanged.
const bytes = readFileSync(file, 'utf8');
assert.throws(() => call(chief, 'instance.hire', { name: 'helper', definition: 'coder', reason: 'r' }), /definition-not-hireable/);
assert.equal(readFileSync(file, 'utf8'), bytes);
call(chief, 'instance.hire', { name: 'helper', definition: 'aide', reason: 'more hands' });
assert.deepEqual(document().roles.helper, { definition: 'aide' });
assert.ok(document().launch.instances.includes('helper'));
const [h] = receipts().slice(-1);
assert.equal(h.actor, 'chief-of-staff.chief');
assert.deepEqual(h.context, CONTEXT('more hands'));
const result = call(chief, 'instance.fire', { name: 'helper', reason: 'the work is done' });
assert.equal(result.delisted, true);
assert.ok(!document().launch.instances.includes('helper'));
assert.deepEqual(receipts().slice(-1)[0].context, CONTEXT('the work is done'));
// The reloaded business sees the delist: a refused call leaves the file alone.
const after = readFileSync(file, 'utf8');
assert.throws(() => call(chief, 'instance.fire', { name: 'helper', reason: 'again', extra: 1 }), /invalid-request/);
assert.equal(readFileSync(file, 'utf8'), after);
});
test('row 194 W-N1: the launcher\'s take of a launch or regenerate of a firing instance closes it as instance-firing', (t) => {
const { b, call, store } = hireSetup(t);
const launch = call('human', 'instance.launch', { instance: 'coder' }).request;
const regenerate = call('human', 'instance.regenerate', { instance: 'pm' }).request;
// A fire's step A as the store holds it with no step C, for requests step A didn't see.
for (const instance of ['coder', 'pm'])
store.run("INSERT INTO events(id,at,business,kind,actor_role,actor_run,subject,body) VALUES(?,?,'demo','action.allowed','chief','chief-run',?,?)",
'stopped-' + instance, new Date().toISOString(), instance, JSON.stringify({ action: 'fire.stopped', request: null }));
for (const request of [launch, regenerate]) {
const { go, refusal } = b.lifecycleTake({ business: 'demo', request });
assert.deepEqual({ go, refusal }, { go: false, refusal: 'instance-firing' });
}
});
+1 -42
View File
@@ -8,7 +8,7 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { Store } from '../src/store.mjs';
import { Broker, LIFECYCLE_HUMAN_VERBS, ORGANIZATION_KEYS, TESTER_ORGANIZATION_VERBS } from '../src/broker.mjs';
import { Broker, ORGANIZATION_KEYS } from '../src/broker.mjs';
const chiefAuthority = JSON.parse(readFileSync(fileURLToPath(new URL('../../../roles/chief-of-staff.json', import.meta.url)), 'utf8')).authority;
const none = { authority: { withinRole: ['message.send'], crossRole: [] } };
@@ -75,8 +75,6 @@ test('row 165 runtime: an add and a change reach the organization file with the
assert.equal(view.definitions['merge-gate'].accepted, true);
const added = bus.broker.request(human, { verb: 'instances.set', args: { ...ADD, revision: view.revision } });
assert.equal(added.instances['sec-1'].launched, true);
// Row 194 (D3 item 3): this boot copy's coder has no credential references and the file's has, so
// the bus doesn't reload and asks for a restart. reload.test.mjs boots from the file.
assert.equal(added.restart, true);
assert.deepEqual(JSON.parse(readFileSync(file, 'utf8')).roles['sec-1'], { definition: 'security-reviewer' });
const first = log().at(-1);
@@ -96,42 +94,3 @@ test('row 165 runtime: an add and a change reach the organization file with the
assert.equal(readFileSync(file, 'utf8'), before);
assert.equal(log().length, entries, 'a refused write logs nothing');
});
// Row 194 (#1692, D4, D4.1): the human lifecycle verbs so far, veto.set and instances.set's three ops.
test('row 194 T12: agent and reader sessions refuse veto.set; the tester gets add and change but not delete or veto.set; the human passes the hook for each listed verb', (t) => {
const root = mkdtempSync(join(tmpdir(), 'instances-verb-'));
const store = new Store(root);
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
const calls = [];
const b = new Broker({ store, businesses: biz(), organization: (id, verb, args, actor) => (calls.push([verb, args.op, actor]), verb === 'instances.check' ? { revision: ADD.revision } : { ok: true }) });
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const tester = b.bindTester({ business: 'demo' });
const caps = { reader: b.bindReader({ business: 'demo' }) };
for (const role of ['chief', 'pm']) caps[role] = b.bindLaunch({ business: 'demo', role, run: `${role}-run`, harness: 'pi' });
const VETO = { revision: ADD.revision, op: 'keep', name: 'pm' };
for (const [who, cap] of Object.entries(caps)) {
assert.throws(() => b.request(cap, { verb: 'veto.set', args: VETO }), /human-required/, who);
assert.throws(() => b.request(cap, { verb: 'instances.set', args: { ...ADD, op: 'delete' } }), /human-required/, who);
}
assert.throws(() => b.request(tester, { verb: 'veto.set', args: VETO }), /tester-refused/);
assert.throws(() => b.request(tester, { verb: 'instances.set', args: { revision: ADD.revision, instance: 'pm', op: 'delete' } }), /tester-refused/);
assert.equal(TESTER_ORGANIZATION_VERBS.includes('veto.set'), false);
assert.equal(calls.length, 0);
// veto.set takes exactly its three keys, and instances.set only its three ops.
for (const args of [{ ...VETO, actor: 'forged' }, { revision: VETO.revision, op: 'keep' }, { ...VETO, holder: 'mos' }])
assert.throws(() => b.request(human, { verb: 'veto.set', args }), /invalid-request/);
assert.throws(() => b.request(human, { verb: 'instances.set', args: { ...ADD, op: 'remove' } }), /invalid-request/);
assert.equal(calls.length, 0);
// The hook refuses a verb missing from LIFECYCLE_HUMAN_VERBS, so each call below passing shows the
// hook got that exact entry.
assert.deepEqual([...LIFECYCLE_HUMAN_VERBS].sort(), ['instance.end', 'instance.launch', 'instance.regenerate', 'instances.set add', 'instances.set change', 'instances.set delete', 'veto.set']);
b.request(human, { verb: 'veto.set', args: VETO });
b.request(human, { verb: 'instances.set', args: ADD });
b.request(human, { verb: 'instances.set', args: { revision: ADD.revision, instance: 'pm', op: 'change', vars: {} } });
// S2c: the delete is a request after the writer's check, and writes nothing yet.
assert.ok(b.request(human, { verb: 'instances.set', args: { revision: ADD.revision, instance: 'pm', op: 'delete' } }).request);
b.request(tester, { verb: 'instances.set', args: ADD });
b.request(tester, { verb: 'instances.set', args: { revision: ADD.revision, instance: 'pm', op: 'change', vars: {} } });
assert.deepEqual(calls, [['veto.set', 'keep', 'jason'], ['instances.set', 'add', 'jason'], ['instances.set', 'change', 'jason'],
['instances.check', undefined, 'jason'], ['instances.set', 'add', 'tester'], ['instances.set', 'change', 'tester']]);
});
-373
View File
@@ -1,373 +0,0 @@
// Row 194 (#1692, D1.1, V2-V5, D4.1): the broker half of the lifecycle verbs. Launch, regenerate
// and delete are requests the launcher's pass takes through the host ops. Terminate revokes and
// cancels at the verb and names the runs the pass signals. The launcher half is in cli lifecycle.test.mjs.
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { Store } from '../src/store.mjs';
import { Broker, LIFECYCLE_REQUEST_MS, TESTER_VERBS } from '../src/broker.mjs';
const shipped = (name) => JSON.parse(readFileSync(fileURLToPath(new URL(`../../../roles/${name}.json`, import.meta.url)), 'utf8'));
const none = { authority: { withinRole: ['message.send'], crossRole: [] } };
const R = 'a'.repeat(64);
const doc = (extra = {}) => ({
id: 'demo', human: 'jason', arbiters: { technical: 'pm', delivery: 'chief' },
vars: { 'bus.tester': true },
definitions: { 'chief-of-staff': {}, assistant: { messaging: shipped('assistant').messaging } },
roles: {
chief: { definition: 'chief-of-staff', authority: shipped('chief-of-staff').authority },
pm: { definition: 'pm', ...none },
coder: { definition: 'coder', ...none },
assistant: { definition: 'assistant', ...none },
},
retired: ['old'],
...extra,
});
const OTHER = { id: 'other', human: 'jason', arbiters: { technical: 'only-there', delivery: 'only-there' }, roles: { 'only-there': none } };
function setup(t, { organization = null, testerOn = undefined } = {}) {
const root = mkdtempSync(join(tmpdir(), 'lifecycle-verbs-'));
const store = new Store(root);
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
const tester = { on: true };
const b = new Broker({ store, businesses: { demo: doc(), other: OTHER }, organization,
testerOn: testerOn === undefined ? () => tester.on : testerOn });
const caps = {
human: b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }),
tester: b.bindTester({ business: 'demo' }),
reader: b.bindReader({ business: 'demo' }),
};
const call = (who, verb, args = {}) => b.request(caps[who], { verb, args });
const code = (fn) => { try { fn(); } catch (e) { return e.code; } return null; };
const ask = (who, verb, args = {}) => code(() => call(who, verb, args));
let runs = 0;
const bind = (role, { claim = true, thread, name = `${role}-${++runs}` } = {}) => {
caps[name] = b.bindLaunch({ business: 'demo', role, run: name, harness: 'pi', ...(thread ? { thread } : {}) });
if (claim) call(name, 'role.claim');
return name;
};
const lifecycle = () => store.get("SELECT count(*) n FROM events WHERE kind='action.allowed' AND json_extract(body,'$.action') LIKE 'lifecycle.%'").n;
const inputs = () => store.get("SELECT count(*) n FROM events WHERE kind='human.input'").n;
const take = (request) => b.lifecycleTake({ business: 'demo', request });
const done = (request, extra = {}) => b.lifecycleDone({ business: 'demo', request, ...extra });
const state = (request) => call('human', 'instance.request', { request });
return { b, store, caps, call, code, ask, bind, lifecycle, inputs, take, done, state, tester };
}
// Moves the clock past a request's 60 s, for the rest of the test.
const later = (t) => { const now = Date.now(); t.mock.method(Date, 'now', () => now + LIFECYCLE_REQUEST_MS + 1000); };
test('row 194 T12: agent and reader sessions refuse the lifecycle verbs before any effect; the tester gets launch, end and the request read, never regenerate', (t) => {
const f = setup(t);
f.bind('chief');
f.bind('pm');
const verbs = [['instance.launch', { instance: 'coder' }], ['instance.regenerate', { instance: 'coder' }],
['instance.end', { instance: 'coder' }], ['instance.request', { request: 'r1' }]];
for (const [verb, args] of verbs) {
assert.equal(f.ask('reader', verb, args), 'read-only', `reader ${verb}`);
for (const who of ['chief-1', 'pm-2']) assert.equal(f.ask(who, verb, args), 'human-required', `${who} ${verb}`);
}
assert.equal(f.ask('tester', 'instance.regenerate', { instance: 'coder' }), 'tester-refused');
assert.equal(f.lifecycle(), 0);
for (const verb of ['instance.launch', 'instance.end', 'instance.request']) assert.ok(TESTER_VERBS.includes(verb), verb);
assert.equal(TESTER_VERBS.includes('instance.regenerate'), false);
// Each human verb passes the D4 hook with its LIFECYCLE_HUMAN_VERBS entry.
const { request } = f.call('human', 'instance.launch', { instance: 'coder' });
f.call('human', 'instance.regenerate', { instance: 'pm' });
assert.deepEqual(f.call('human', 'instance.end', { instance: 'coder' }).cancelled, [request]);
});
test('row 194 V2 and V4 at the verb: the request is open, by the human, with a human.input; thread, retired, unknown and revoked refuse with no event', (t) => {
const f = setup(t);
const before = f.inputs();
const { request } = f.call('human', 'instance.launch', { instance: 'coder' });
const r = f.state(request);
assert.deepEqual([r.action, r.instance, r.by, r.state, r.done, r.cancelled], ['lifecycle.launch', 'coder', 'jason', 'open', null, null]);
assert.equal(f.inputs(), before + 1);
const raw = JSON.parse(f.store.get("SELECT body FROM events WHERE json_extract(body,'$.request')=? AND json_extract(body,'$.action')='lifecycle.launch'", request).body);
assert.deepEqual(raw, { action: 'lifecycle.launch', request, via: 'cli', by: 'jason' });
const count = f.lifecycle();
assert.equal(f.ask('human', 'instance.launch', { instance: 'assistant' }), 'not-thread-launch');
assert.equal(f.ask('human', 'instance.regenerate', { instance: 'assistant' }), 'regenerate-thread');
assert.equal(f.ask('human', 'instance.launch', { instance: 'old' }), 'instance-retired');
assert.equal(f.ask('human', 'instance.regenerate', { instance: 'old' }), 'instance-retired');
assert.equal(f.ask('human', 'instance.launch', { instance: 'ghost' }), 'unknown-role');
assert.equal(f.ask('human', 'instance.launch', { instance: 'coder', extra: 1 }), 'invalid-request');
assert.equal(f.ask('human', 'instance.launch', { instance: '../x' }), 'invalid-request');
f.call('human', 'launch.revoke');
assert.equal(f.ask('human', 'instance.launch', { instance: 'coder' }), 'launch-revoked');
assert.equal(f.ask('human', 'instance.regenerate', { instance: 'coder' }), 'launch-revoked');
assert.equal(f.lifecycle(), count);
assert.equal(f.ask('human', 'instance.request', { request: 'no-such' }), 'unknown-request');
});
test('row 194 D1.1 take: a request is taken once, closes once, and lifecycleRequests lists only what is not closed', (t) => {
const f = setup(t);
const { request } = f.call('human', 'instance.launch', { instance: 'coder' });
const second = f.call('human', 'instance.regenerate', { instance: 'pm' }).request;
assert.deepEqual(f.b.lifecycleRequests('demo').map((r) => [r.request, r.state]), [[request, 'open'], [second, 'open']]);
assert.equal(f.code(() => f.done(request, { run: 'run-x' })), 'request-not-taken');
const go = f.take(request);
assert.deepEqual([go.go, go.refusal, go.state, go.action, go.instance, go.by], [true, null, 'taken', 'lifecycle.launch', 'coder', 'jason']);
const again = f.take(request);
assert.deepEqual([again.go, again.refusal, again.state], [false, 'request-closed', 'taken']);
assert.equal(f.store.get("SELECT count(*) n FROM events WHERE json_extract(body,'$.action')='lifecycle.taken'").n, 1);
assert.equal(f.code(() => f.done(request, { refusal: 'Not A Code' })), 'invalid-request');
assert.equal(f.code(() => f.done(request, { run: '../x' })), 'invalid-request');
assert.equal(f.code(() => f.done(request, { extra: 1 })), 'invalid-request');
const closed = f.done(request, { run: 'run-x' });
assert.deepEqual([closed.state, closed.done], ['closed', { run: 'run-x', transaction: null, refusal: null }]);
assert.equal(f.code(() => f.done(request, { run: 'run-x' })), 'request-closed');
assert.deepEqual(f.take(request).refusal, 'request-closed');
assert.deepEqual(f.b.lifecycleRequests('demo').map((r) => r.request), [second]);
assert.equal(f.code(() => f.take('no-such')), 'unknown-request');
assert.equal(f.code(() => f.b.lifecycleRequests('ghost')), 'unknown-business');
// A request from one business isn't another's.
assert.equal(f.code(() => f.b.lifecycleTake({ business: 'other', request: second })), 'unknown-request');
});
test('row 194 D1.1 take: a launch.revoke after the request closes it launch-revoked, and a request past 60 s closes request-expired', (t) => {
const f = setup(t);
const revoked = f.call('human', 'instance.launch', { instance: 'coder' }).request;
const regen = f.call('human', 'instance.regenerate', { instance: 'coder' }).request;
f.call('human', 'launch.revoke');
for (const request of [revoked, regen]) {
const r = f.take(request);
assert.deepEqual([r.go, r.refusal, r.state, r.done.refusal], [false, 'launch-revoked', 'closed', 'launch-revoked']);
}
assert.equal(f.store.get("SELECT count(*) n FROM events WHERE json_extract(body,'$.action')='lifecycle.taken'").n, 0);
f.call('human', 'launch.restore');
const old = f.call('human', 'instance.launch', { instance: 'coder' }).request;
later(t);
const r = f.take(old);
assert.deepEqual([r.go, r.refusal, r.state], [false, 'request-expired', 'closed']);
assert.deepEqual(f.b.lifecycleRequests('demo'), []);
});
test('row 194 T12a: the tester launches and ends on its own business with actor tester; bus.tester off in the file refuses the call and the take tester-off', (t) => {
const f = setup(t);
const inputs = f.inputs();
const { request } = f.call('tester', 'instance.launch', { instance: 'coder' });
const r = f.call('tester', 'instance.request', { request });
assert.deepEqual([r.by, r.state], ['tester', 'open']);
assert.equal(JSON.parse(f.store.get("SELECT body FROM events WHERE json_extract(body,'$.request')=? AND json_extract(body,'$.action')='lifecycle.launch'", request).body).via, 'tester');
assert.equal(f.inputs(), inputs, 'a tester request is not human input');
const human = f.call('human', 'instance.launch', { instance: 'pm' }).request;
// A tester reads only its own requests.
assert.equal(f.ask('tester', 'instance.request', { request: human }), 'unknown-request');
// The cap is bound to demo: an instance only `other` declares is unknown here.
assert.equal(f.ask('tester', 'instance.launch', { instance: 'only-there' }), 'unknown-role');
// bus.tester switched off in the file, with no restart.
f.tester.on = false;
assert.equal(f.ask('tester', 'instance.launch', { instance: 'coder' }), 'tester-off');
assert.equal(f.ask('tester', 'instance.end', { instance: 'coder' }), 'tester-off');
const off = f.take(request);
assert.deepEqual([off.go, off.refusal], [false, 'tester-off']);
assert.equal(f.take(human).go, true, 'the human request is not the tester switch');
// Anything but exactly true is off.
f.tester.on = 'true';
assert.equal(f.ask('tester', 'instance.launch', { instance: 'coder' }), 'tester-off');
f.tester.on = true;
const later2 = f.call('tester', 'instance.launch', { instance: 'coder' }).request;
const ended = f.call('tester', 'instance.end', { instance: 'coder' });
assert.deepEqual(ended.cancelled, [later2]);
assert.equal(f.state(ended.request).by, 'tester');
assert.equal(f.state(later2).cancelled.by, 'tester');
});
test('row 194 V3 terminate: claims revoked, open requests cancelled, bound runs named, taken requests listed; nothing at all answers not-held', (t) => {
const f = setup(t);
const held = f.bind('coder');
const unclaimed = f.bind('coder', { claim: false });
const open = f.call('human', 'instance.launch', { instance: 'coder' }).request;
const taken = f.call('human', 'instance.regenerate', { instance: 'coder' }).request;
f.take(taken);
const other = f.call('human', 'instance.launch', { instance: 'pm' }).request;
const out = f.call('human', 'instance.end', { instance: 'coder', reason: 'stuck on a loop' });
assert.deepEqual([out.runs, out.cancelled, out.taken], [[held, unclaimed], [open], [taken]]);
const claim = f.store.get("SELECT * FROM role_claims WHERE role='coder' ORDER BY seq DESC LIMIT 1");
assert.deepEqual([claim.op, claim.holder_run, claim.by, claim.reason], ['revoke', held, 'jason', 'stuck on a loop']);
assert.deepEqual([f.state(open).state, f.state(open).cancelled], ['closed', { by: 'jason' }]);
assert.equal(f.state(taken).state, 'taken', 'a taken request is past cancelling');
assert.equal(f.state(other).state, 'open', 'another instance keeps its request');
// The end is a request the pass takes, with the runs to signal. It never expires.
const end = f.state(out.request);
assert.deepEqual([end.action, end.runs, end.state], ['lifecycle.end', [held, unclaimed], 'open']);
later(t);
assert.equal(f.take(out.request).go, true);
assert.equal(f.take(open).refusal, 'request-closed');
// The runs stay named while bound and not ended.
f.done(taken, { run: 'coder-new' });
for (const run of [held, unclaimed]) f.b.endLaunch({ business: 'demo', run, reason: 'signalled' });
assert.equal(f.ask('human', 'instance.end', { instance: 'coder' }), 'not-held');
assert.equal(f.ask('human', 'instance.end', { instance: 'old' }), 'unknown-role');
assert.equal(f.ask('human', 'instance.end', { instance: 'coder', reason: '' }), 'invalid-request');
});
test('row 194 V3 terminate of a thread instance: the human ends every thread, the tester only its own', (t) => {
const f = setup(t);
const hp = f.call('human', 'project.create', { name: 'human work' }).id;
const tp = f.call('tester', 'project.create', { name: 'test work' }).id;
const H = f.call('human', 'thread.create', { project: hp, name: 'mine' }).id;
const T = f.call('tester', 'thread.create', { project: tp, name: 'probe' }).id;
const h = f.bind('assistant', { thread: H });
const tr = f.bind('assistant', { thread: T });
const tester = f.call('tester', 'instance.end', { instance: 'assistant' });
assert.deepEqual(tester.runs, [tr]);
assert.equal(f.store.get('SELECT op FROM thread_claims WHERE thread=? ORDER BY seq DESC LIMIT 1', H).op, 'claim');
assert.equal(f.store.get('SELECT op FROM thread_claims WHERE thread=? ORDER BY seq DESC LIMIT 1', T).op, 'revoke');
const human = f.call('human', 'instance.end', { instance: 'assistant' });
assert.deepEqual(human.runs, [h, tr]);
assert.equal(f.store.get('SELECT op FROM thread_claims WHERE thread=? ORDER BY seq DESC LIMIT 1', H).op, 'revoke');
});
// The writer stand-in: instances.check answers per instance, instances.set commits a transaction.
function writer(answers) {
const calls = [];
const organization = (id, verb, args, actor) => {
calls.push([verb, args, actor]);
if (verb === 'instances.check') {
const a = answers[args.instance] ?? { revision: R };
if (a instanceof Error) throw a;
return a;
}
if (verb === 'instances.set') return { transaction: `tx-${calls.length}`, restart: false };
return { ok: true };
};
return { calls, organization };
}
const refused = (code) => Object.assign(new Error(code), { code });
const lastListed = () => Object.assign(new Error('coder is the last name in launch.instances'), { name: 'BusinessError' });
test('row 194 T6 V5 order at the verb: the writer\'s named refusals, then instance-running, instance-starting, the last-listed refusal and the revision', (t) => {
const w = writer({ chief: refused('organization-instance-referenced'), pm: lastListed(), coder: lastListed() });
const f = setup(t, { organization: w.organization });
const del = (instance, revision = R) => f.ask('human', 'instances.set', { revision, instance, op: 'delete' });
// A named refusal comes before the broker's checks, even with a claim held.
f.bind('chief');
assert.equal(del('chief'), 'organization-instance-referenced');
// The last-listed refusal waits until running and starting have passed.
const run = f.bind('coder');
assert.equal(del('coder'), 'instance-running');
f.call('human', 'instance.end', { instance: 'coder' });
f.b.endLaunch({ business: 'demo', run, reason: 'signalled' });
const starting = f.call('human', 'instance.launch', { instance: 'coder' }).request;
assert.equal(del('coder'), 'instance-starting');
f.take(starting);
assert.equal(del('coder'), 'instance-starting', 'a taken request with no done is still starting');
f.done(starting, { refusal: 'launch-refused' });
assert.equal(del('coder'), 'invalid-request');
assert.equal(del('pm'), 'invalid-request');
assert.equal(del('assistant', 'b'.repeat(64)), 'revision-conflict');
assert.equal(del('assistant', 'not-a-revision'), 'invalid-request');
assert.equal(f.ask('human', 'instances.set', { revision: R, instance: 'assistant', op: 'delete', definition: 'x' }), 'invalid-request');
assert.ok(w.calls.every(([verb]) => verb === 'instances.check'), 'the checks write nothing');
assert.equal(f.store.get("SELECT count(*) n FROM events WHERE json_extract(body,'$.action')='lifecycle.delete'").n, 0);
const { request } = f.call('human', 'instances.set', { revision: R, instance: 'assistant', op: 'delete' });
const r = f.state(request);
assert.deepEqual([r.action, r.instance, r.by, r.revision, r.retry, r.state], ['lifecycle.delete', 'assistant', 'jason', R, false, 'open']);
assert.deepEqual(w.calls.at(-1), ['instances.check', { instance: 'assistant' }, 'jason']);
// An open delete is a starting request too: a second delete waits for the first.
assert.equal(del('assistant'), 'instance-starting');
});
test('row 194 V5: an open launch past its 60 s is not starting, as it can only close request-expired', (t) => {
const w = writer({});
const f = setup(t, { organization: w.organization });
f.call('human', 'instance.launch', { instance: 'coder' });
later(t);
assert.ok(f.call('human', 'instances.set', { revision: R, instance: 'coder', op: 'delete' }).request);
});
test('row 194 V5 after the take: lifecycleDelete re-checks running and starting, then commits through the writer with the human as actor', (t) => {
const w = writer({});
const f = setup(t, { organization: w.organization });
const { request } = f.call('human', 'instances.set', { revision: R, instance: 'coder', op: 'delete' });
const del = () => f.b.lifecycleDelete({ business: 'demo', request });
assert.equal(f.code(del), 'request-not-taken');
assert.equal(f.take(request).go, true);
// A launch recorded after the delete's verb and a claim taken since both stop it.
const launch = f.call('human', 'instance.launch', { instance: 'coder' }).request;
assert.equal(f.code(del), 'instance-starting');
f.call('human', 'instance.end', { instance: 'coder' });
const run = f.bind('coder');
assert.equal(f.code(del), 'instance-running');
f.call('human', 'instance.end', { instance: 'coder' });
f.b.endLaunch({ business: 'demo', run, reason: 'signalled' });
assert.equal(f.state(launch).cancelled.by, 'jason');
const out = del();
assert.deepEqual(w.calls.at(-1), ['instances.set', { revision: R, instance: 'coder', op: 'delete' }, 'jason']);
assert.deepEqual(out, { transaction: out.transaction, retry: false });
assert.match(out.transaction, /^tx-/);
assert.equal(f.done(request, { transaction: out.transaction }).done.transaction, out.transaction);
const launchReq = f.call('human', 'instance.launch', { instance: 'pm' }).request;
assert.equal(f.code(() => f.b.lifecycleDelete({ business: 'demo', request: launchReq })), 'request-not-taken');
});
test('row 194 V5 retry: a retired name deletes again only after workspace-rename-failed, and that request commits nothing', (t) => {
const w = writer({});
const f = setup(t, { organization: w.organization });
assert.equal(f.ask('human', 'instances.set', { revision: R, instance: 'old', op: 'delete' }), 'organization-instance-retired');
const { request } = f.call('human', 'instances.set', { revision: R, instance: 'pm', op: 'delete' });
f.take(request);
const { transaction } = f.b.lifecycleDelete({ business: 'demo', request });
f.done(request, { transaction, refusal: 'workspace-rename-failed' });
// The bus reloads after the commit, as the runtime does.
const next = doc({ retired: ['old', 'pm'], arbiters: { technical: 'coder', delivery: 'chief' } });
delete next.roles.pm;
f.b.reloadBusiness('demo', next);
const writes = w.calls.length;
const retry = f.call('human', 'instances.set', { revision: R, instance: 'pm', op: 'delete' }).request;
assert.deepEqual([f.state(retry).retry, f.state(retry).revision], [true, R]);
f.take(retry);
// S3: the retry answers the failed delete's transaction, which names the rename's target.
assert.ok(transaction);
assert.deepEqual(f.b.lifecycleDelete({ business: 'demo', request: retry }), { transaction, retry: true });
assert.equal(w.calls.length, writes, 'a retry neither checks nor writes the file');
f.done(retry);
assert.equal(f.ask('human', 'instances.set', { revision: R, instance: 'pm', op: 'delete' }), 'organization-instance-retired');
});
test('row 194 V5 runtime: a delete request, the take and lifecycleDelete commit the file with retired, log the human and reload so the name no longer binds', async (t) => {
const { startBroker } = await import('../src/runtime.mjs');
const { busBusiness } = await import('../src/business.mjs');
const { loadBusiness } = await import('../../business/src/business.mjs');
const { resolveInstance } = await import('../../business/src/resolve.mjs');
const { businessDoc, rolesCopy, systemFor, writeJson } = await import('../../business/tests/helpers.mjs');
const root = mkdtempSync(join(tmpdir(), 'lifecycle-runtime-'));
const rolesDir = rolesCopy(root);
const file = writeJson(join(root, 'businesses/demo.json'), { ...businessDoc(root, 'demo'), plugins: { harness: ['pi'] } });
mkdirSync(join(root, 'data'), { mode: 0o700 });
const b = loadBusiness('demo', { dir: root, rolesDir });
const resolved = {};
for (const i of Object.keys(b.roles)) resolved[i] = resolveInstance({ system: systemFor(root), business: b, instance: i });
const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: busBusiness(b, resolved) }, organization: { dir: root, rolesDir, system: systemFor(root) } });
t.after(async () => { await bus.close(); rmSync(root, { recursive: true, force: true }); });
const human = bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const ask = (verb, args) => bus.broker.request(human, { verb, args });
const code = (fn) => { try { fn(); } catch (e) { return e.code; } return null; };
const revision = () => ask('instances').revision;
ask('instances.set', { revision: revision(), instance: 'sec-1', op: 'add', definition: 'security-reviewer', launch: true });
const before = readFileSync(file, 'utf8');
// The real writer's refusals reach the verb, and none of them writes.
assert.equal(code(() => ask('instances.set', { revision: revision(), instance: 'coder', op: 'delete' })), 'organization-credentials-out-of-scope');
assert.equal(code(() => ask('instances.set', { revision: revision(), instance: 'pm', op: 'delete' })), 'organization-instance-referenced');
assert.equal(code(() => ask('instances.set', { revision: 'b'.repeat(64), instance: 'sec-1', op: 'delete' })), 'revision-conflict');
assert.equal(readFileSync(file, 'utf8'), before);
const { request } = ask('instances.set', { revision: revision(), instance: 'sec-1', op: 'delete' });
assert.equal(readFileSync(file, 'utf8'), before, 'the verb only records the request');
assert.deepEqual(bus.lifecycleRequests('demo').map((r) => [r.request, r.action, r.instance]), [[request, 'lifecycle.delete', 'sec-1']]);
assert.equal(bus.lifecycleTake({ business: 'demo', request }).go, true);
const { transaction } = bus.lifecycleDelete({ business: 'demo', request });
const after = JSON.parse(readFileSync(file, 'utf8'));
assert.equal(Object.hasOwn(after.roles, 'sec-1'), false);
assert.deepEqual(after.retired, ['sec-1']);
assert.equal(after.launch.instances.includes('sec-1'), false);
const log = readFileSync(join(root, 'businesses/.history/demo/changes.jsonl'), 'utf8').trim().split('\n').map(JSON.parse).at(-1);
assert.deepEqual([log.actor, log.state, log.transaction], ['jason', 'committed', transaction]);
assert.equal(bus.lifecycleDone({ business: 'demo', request, transaction }).state, 'closed');
assert.equal(code(() => bus.broker.bindLaunch({ business: 'demo', role: 'sec-1', run: 'run-1', harness: 'pi' })), 'unknown-role');
assert.equal(code(() => ask('instance.launch', { instance: 'sec-1' })), 'instance-retired');
});
-126
View File
@@ -1,126 +0,0 @@
// Row 194 (#1692, D3, T14): after every write that commits, the broker takes the business as a
// restart would load it, so a created instance binds with no restart. A definition change on a
// running instance refuses, and a file whose credential references changed isn't reloaded.
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Broker } from '../src/broker.mjs';
import { busBusiness } from '../src/business.mjs';
import { startBroker } from '../src/runtime.mjs';
import { Store } from '../src/store.mjs';
import { loadBusiness } from '../../business/src/business.mjs';
import { resolveInstance } from '../../business/src/resolve.mjs';
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../business/tests/helpers.mjs';
// The bus form of the business file, built as cli bootConfig builds it.
function busForm(root, rolesDir, id = 'demo') {
const b = loadBusiness(id, { dir: root, rolesDir });
const resolved = {};
for (const instance of Object.keys(b.roles)) resolved[instance] = resolveInstance({ system: systemFor(root), business: b, instance });
return busBusiness(b, resolved);
}
async function setup(t) {
const root = mkdtempSync(join(tmpdir(), 'reload-'));
const rolesDir = rolesCopy(root);
const doc = businessDoc(root, 'demo');
doc.plugins = { harness: ['pi', 'claude-code'] };
const file = writeJson(join(root, 'businesses/demo.json'), doc);
mkdirSync(join(root, 'data'), { mode: 0o700 });
const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: busForm(root, rolesDir) }, organization: { dir: root, rolesDir, system: systemFor(root) } });
t.after(async () => { await bus.close(); rmSync(root, { recursive: true, force: true }); });
const reloads = [];
const reloadBusiness = bus.broker.reloadBusiness.bind(bus.broker);
bus.broker.reloadBusiness = (id, next) => { reloads.push(structuredClone(next)); reloadBusiness(id, next); };
const human = bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const ask = (verb, args) => bus.broker.request(human, { verb, args });
const revision = () => ask('instances').revision;
return { root, rolesDir, file, bus, reloads, ask, revision };
}
const code = (fn) => { try { fn(); } catch (e) { return e.code; } return null; };
let runs = 0;
const bind = (bus, role) => bus.broker.bindLaunch({ business: 'demo', role, run: `run-${++runs}`, harness: 'pi' });
test('T14 a create goes live with no restart: the new instance binds at once and the answer says restart false', async (t) => {
const f = await setup(t);
assert.equal(code(() => bind(f.bus, 'sec-1')), 'unknown-role');
const added = f.ask('instances.set', { revision: f.revision(), instance: 'sec-1', op: 'add', definition: 'security-reviewer', launch: true });
assert.equal(added.restart, false);
assert.equal(f.reloads.length, 1);
assert.equal(f.reloads[0].roles['sec-1'].definition, 'security-reviewer');
assert.ok(bind(f.bus, 'sec-1'));
});
test('T14 every committed verb reloads once, limits.set, routes.set, plugins.set and veto.set included, and a refused one does not', async (t) => {
const f = await setup(t);
f.ask('limits.set', { revision: f.revision(), limits: { tokens: { instances: { coder: { limit: 100 } } } } });
assert.equal(f.reloads.length, 1);
assert.equal(f.reloads[0].launch.limits.tokens.instances.coder.limit, 100);
f.ask('routes.set', { revision: f.revision(), routes: { coder: { harness: 'claude-code', model: 'claude-sonnet-5-5', thinking: 'low', fallback: null } } });
assert.equal(f.reloads.length, 2);
assert.equal(f.reloads[1].roles.coder.vars.harness, 'claude-code');
f.ask('plugins.set', { revision: f.revision(), plugins: { harness: ['claude-code', 'pi'] } });
assert.equal(f.reloads.length, 3);
assert.deepEqual(f.reloads[2].plugins.harness, ['claude-code', 'pi']);
f.ask('veto.set', { revision: f.revision(), op: 'keep', name: 'coder' });
assert.equal(f.reloads.length, 4);
assert.deepEqual(f.reloads[3].launch.keep, ['coder']);
assert.equal(code(() => f.ask('instances.set', { revision: f.revision(), instance: 'ghost', op: 'change', vars: {} })), 'organization-unknown-instance');
assert.equal(code(() => f.ask('veto.set', { revision: f.revision(), op: 'keep', name: 'coder' })), 'organization-no-change');
assert.equal(f.reloads.length, 4);
});
test('T14 a definition change on a running instance refuses instance-running; a vars change still goes live', async (t) => {
const f = await setup(t);
f.ask('instances.set', { revision: f.revision(), instance: 'sec-1', op: 'add', definition: 'security-reviewer', launch: true });
const cap = bind(f.bus, 'sec-1');
f.bus.broker.request(cap, { verb: 'role.claim' });
const before = readFileSync(f.file, 'utf8');
assert.equal(code(() => f.ask('instances.set', { revision: f.revision(), instance: 'sec-1', op: 'change', definition: 'merge-gate' })), 'instance-running');
assert.equal(readFileSync(f.file, 'utf8'), before);
const changed = f.ask('instances.set', { revision: f.revision(), instance: 'sec-1', op: 'change', vars: { model: 'claude-sonnet-5-5' } });
assert.equal(changed.restart, false);
assert.equal(f.reloads.at(-1).roles['sec-1'].vars.model, 'claude-sonnet-5-5');
});
test('T14 a file whose credential references differ from the boot copy is not reloaded and the write answers restart true', async (t) => {
const f = await setup(t);
const doc = JSON.parse(readFileSync(f.file, 'utf8'));
const moved = join(f.root, 'secrets', 'moved-gitea.token');
writeFileSync(moved, 'placeholder-not-a-token', { mode: 0o600 });
doc.roles.coder.credentials.gitea.file = moved;
writeFileSync(f.file, JSON.stringify(doc, null, 2) + '\n', { mode: 0o600 });
const added = f.ask('instances.set', { revision: f.revision(), instance: 'sec-1', op: 'add', definition: 'security-reviewer' });
assert.equal(added.restart, true);
assert.equal(f.reloads.length, 0);
assert.equal(code(() => bind(f.bus, 'sec-1')), 'unknown-role');
});
test('T14 a file whose project root differs from the boot copy is not reloaded, because the Credentials store protects the boot roots', async (t) => {
const f = await setup(t);
const doc = JSON.parse(readFileSync(f.file, 'utf8'));
const moved = join(f.root, 'project-moved');
mkdirSync(moved);
doc.projects.stack.root = moved;
writeFileSync(f.file, JSON.stringify(doc, null, 2) + '\n', { mode: 0o600 });
const added = f.ask('instances.set', { revision: f.revision(), instance: 'sec-1', op: 'add', definition: 'security-reviewer' });
assert.equal(added.restart, true);
assert.equal(f.reloads.length, 0);
});
test('T14 reloadBusiness refuses an unknown business, a changed human and a document the boot check refuses', (t) => {
const root = mkdtempSync(join(tmpdir(), 'reload-unit-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const role = { authority: { withinRole: [], crossRole: [] } };
const demo = { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: role } };
const b = new Broker({ store: new Store(root), businesses: { demo } });
assert.equal(code(() => b.reloadBusiness('other', demo)), 'unknown-business');
assert.equal(code(() => b.reloadBusiness('demo', { ...demo, human: 'mos' })), 'invalid-business');
assert.equal(code(() => b.reloadBusiness('demo', { ...demo, roles: { coder: role, tester: role } })), 'invalid-business');
assert.equal(code(() => b.reloadBusiness('demo', { ...demo, roles: { coder: { authority: { withinRole: ['spend'], crossRole: [] } } } })), 'invalid-authority');
assert.equal(code(() => b.reloadBusiness('demo', null)), 'invalid-business');
b.reloadBusiness('demo', { ...demo, roles: { coder: role, 'sec-1': role } });
assert.ok(b.bindLaunch({ business: 'demo', role: 'sec-1', run: 'run-1', harness: 'pi' }));
});
@@ -21,8 +21,6 @@ test('row 175: TESTER_VERBS and the organization branch tester set are pinned',
'usage', 'names', 'limits', 'plugins', 'instances', 'project.list', 'thread.list', 'name.set', 'project.create', 'credentials',
// Row 177 (#1675): the tester's own threads.
'thread.create', 'thread.open', 'thread.send', 'thread.messages', 'assistant.stop',
// Row 194 (#1692, D4.1): launch and terminate, and the read of its own requests.
'instance.launch', 'instance.end', 'instance.request',
]);
assert.deepEqual([...TESTER_ORGANIZATION_VERBS], ['limits', 'plugins', 'instances', 'plugins.set', 'instances.set', 'routes.set', 'routes']);
assert.ok(Object.isFrozen(TESTER_VERBS) && Object.isFrozen(TESTER_ORGANIZATION_VERBS));
+1 -4
View File
@@ -156,10 +156,7 @@ file through the broker's `organization.set`, limited to the keys the human's
call them (row 132). Under Jason's ruling Q169 (row 155, #1649) the chief of
staff is the one agent that ends the assistant, which ends every assistant
thread run, and it configures the assistant through `assistants` (Assistant
settings, below) and names instances through `set_name`. PRD 1.7 REQ-COS-3
(row 194) grants it `role.hire` and `role.fire` within role: it hires and
fires with a reason and no decision, through the broker's `instance.hire` and
`instance.fire`, which the harness's `hire` and `fire` tools call. It has no credentials and the same read-only tools. It is the only shipped role that holds `role.launch`, so
settings, below) and names instances through `set_name`. It has no credentials and the same read-only tools. It is the only shipped role that holds `role.launch`, so
it is the only role `launch.by` can name. The PM holds no `role.launch`
(REQ-LAUNCH-1); it asks the chief of staff for a session.
+3 -28
View File
@@ -9,7 +9,6 @@ import { checkModels, checkLimits, checkAccount } from "./launch.mjs";
import { checkVars } from "./vars.mjs";
import { parseCredentialRef } from "./credentials.mjs";
import { loadRole } from "./role.mjs";
import { END_PROTECTED } from "./vocabulary.mjs";
import { checkPlugins } from "../../plugins/src/kinds.mjs";
import {
requireObject, rejectUnknownKeys, requireId, requirePositiveInt, requireString, requireDistinctList, readJsonFile, deepFreeze,
@@ -19,7 +18,7 @@ const BOT_NAME = /^bot-[a-z0-9][a-z0-9._-]{0,60}$/;
// How long a thread's session stays up with no turn before the launcher stops
// it, when launch.threadIdleSeconds is absent (REQ-THREAD-2, row 121).
export const THREAD_IDLE_SECONDS = 900;
const TOP_KEYS = ["businessVersion", "id", "human", "arbiters", "projects", "vars", "tracker", "roles", "launch", "plugins", "retired"];
const TOP_KEYS = ["businessVersion", "id", "human", "arbiters", "projects", "vars", "tracker", "roles", "launch", "plugins"];
// The config directory: next to the system config, $MOSAIC_CONFIG or
// ~/.config/mosaic-dev/config.json, the same rule mosaic-config.mjs uses.
@@ -119,7 +118,7 @@ function checkLaunch(launch, roles, definitions, file) {
const where = `${file} launch`;
requireObject(launch, where);
if (Object.hasOwn(launch,"max")) refuse(`${where}.max needs migration: mosaic limits migrate --file FILE --models FILE`);
rejectUnknownKeys(launch, ["by", "instances", "models", "limits", "threadIdleSeconds", "account", "keep", "noHire"], where);
rejectUnknownKeys(launch, ["by", "instances", "models", "limits", "threadIdleSeconds", "account"], where);
requireId(launch.by, `${where}.by`);
if (!Object.hasOwn(roles, launch.by)) refuse(`${where}.by names ${launch.by}, which the business file doesn't declare`);
const launcher = definitions[roles[launch.by].definition];
@@ -143,29 +142,7 @@ function checkLaunch(launch, roles, definitions, file) {
for(const [instance,role] of Object.entries(roles))if(role.vars.model!==undefined&&!Object.hasOwn(models,role.vars.model))refuse(`${where}: instance ${instance} selects unknown model ${role.vars.model}`);
// No upper bound: limits have no ceiling in code (Q150).
const threadIdleSeconds = launch.threadIdleSeconds === undefined ? THREAD_IDLE_SECONDS : requirePositiveInt(launch.threadIdleSeconds, `${where}.threadIdleSeconds`);
// Row 194 (#1692, D2.4, D2.5): the human's per-agent forbid. keep names declared instances the
// chief can't fire, replace or end. noHire names definitions the chief can't hire, by the id
// pattern only, so a stale entry is harmless. Both are optional and only the human writes them.
const keep = launch.keep === undefined ? undefined : requireDistinctList(launch.keep, `${where}.keep`, (name) => {
requireId(name, `${where}.keep entry`);
if (!Object.hasOwn(roles, name)) refuse(`${where}.keep names ${name}, which the business file doesn't declare`);
if (END_PROTECTED.includes(roles[name].definition)) refuse(`${where}.keep names ${name}, whose definition ${roles[name].definition} can't be kept`);
});
const noHire = launch.noHire === undefined ? undefined
: requireDistinctList(launch.noHire, `${where}.noHire`, (name) => requireId(name, `${where}.noHire entry`));
return {
by: launch.by, instances, models, limits, threadIdleSeconds, ...(account ? { account } : {}),
...(keep ? { keep } : {}), ...(noHire ? { noHire } : {}),
};
}
// Row 194 (#1692, V5): instance names a delete retired. None may be declared again, so a later
// create or hire can't reuse a name whose decisions outlive it.
function checkRetired(retired, roles, file) {
return requireDistinctList(retired, `${file} retired`, (name) => {
requireId(name, `${file} retired entry`);
if (Object.hasOwn(roles, name)) refuse(`${file} retired names ${name}, which roles still declares`);
});
return { by: launch.by, instances, models, limits, threadIdleSeconds, ...(account ? { account } : {}) };
}
// Validate a parsed business document read from `file`. `rolesDir` is where
@@ -228,11 +205,9 @@ export function validateBusinessDocument(document, file, { rolesDir }) {
// here; whether each module loads is the loader's check, at load time.
const plugins = document.plugins === undefined ? {}
: structuredClone(checkPlugins(document.plugins, `${file} plugins`, { fail: (_code, message) => refuse(message) }));
const retired = document.retired === undefined ? undefined : checkRetired(document.retired, roles, file);
return deepFreeze({
businessVersion: 1, id: document.id, file, human, arbiters, projects, vars, tracker, roles, launch, plugins, definitions,
...(retired ? { retired } : {}),
});
}
+2 -2
View File
@@ -4,9 +4,9 @@
export { BusinessError } from "./errors.mjs";
export {
ACTIONS, GATED_ONLY, HIRE_ALLOWED, TOOLS, NETWORKS, SERVICES, GITEA_SCOPE_CATEGORIES, VIKUNJA_GRANTABLE,
ACTIONS, GATED_ONLY, TOOLS, NETWORKS, SERVICES, GITEA_SCOPE_CATEGORIES, VIKUNJA_GRANTABLE,
} from "./vocabulary.mjs";
export { validateRoleDocument, loadRoleFile, loadRole, unhireableAction } from "./role.mjs";
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";
+14 -159
View File
@@ -1,9 +1,8 @@
// Row 165 (#1662, Q171): the human adds a role instance and changes an existing one's definition,
// vars and launch membership. It is human-only: the broker's instances and instances.set verbs
// reach it, and neither is in ORGANIZATION_KEYS, so the chief of staff's tool can't (sage-0918).
// holder, credentials and tracker stay hand edits. Row 194 (#1692) adds delete, the human's veto
// lists and the chief's hire and fire. Every write goes through the organization writer, which
// logs the actor and the old and new values.
// holder, credentials and tracker stay hand edits, and there is no delete. Every write goes
// through the organization writer, which logs the actor and the old and new values.
import { existsSync, readdirSync } from "node:fs";
import { organizationWriter, OrganizationError } from "./organization.mjs";
import { validateBusinessDocument, businessFilePath } from "./business.mjs";
@@ -11,59 +10,11 @@ import { checkRoutes } from "./launch-settings.mjs";
import { loadProject, projectFilePath } from "./project.mjs";
import { resolveInstance } from "./resolve.mjs";
import { loadRole } from "./role.mjs";
import { refuse } from "./errors.mjs";
import { isPlainObject, canonicalJson } from "./util.mjs";
import { END_PROTECTED, ID_PATTERN } from "./vocabulary.mjs";
const SET_KEYS = ["revision", "instance", "op", "definition", "vars", "launch"];
const VETO_KEYS = ["revision", "op", "name"];
const VETO_OPS = ["keep", "unkeep", "nohire", "hire"];
const id = value => typeof value === "string" && ID_PATTERN.test(value);
const revisionOk = value => typeof value === "string" && /^[a-f0-9]{64}$/.test(value);
// Row 194 (D2.2, N3): the vars a hire may set, the ones the chief can already set on an instance.
// Every limits.* key merges by intersection, so it can only narrow.
const HIRE_VAR = key => key === "model" || key.startsWith("limits.");
// Row 194 (#1692, V5): why an instance can't be deleted, checked in the design's order, or null.
// The last-listed check runs when the proposed file is validated (deletedDocument).
export function deleteRefusal(document, rolesDir, instance) {
if (!Object.hasOwn(document.roles, instance)) return { code: "organization-unknown-instance" };
const entry = document.roles[instance];
if (END_PROTECTED.includes(entry.definition)) return { code: "organization-definition-refused" };
const references = [];
if (document.launch?.by === instance) references.push("launch.by");
for (const kind of ["delivery", "technical"]) if (document.arbiters?.[kind] === instance) references.push(`arbiters.${kind}`);
if (references.length > 0) {
return { code: "organization-instance-referenced", message: `${instance} is ${references.join(" and ")}; change that first` };
}
if (document.launch?.keep?.includes(instance)) {
return { code: "organization-instance-kept", message: `${instance} is kept; run veto unkeep first` };
}
const definition = loadRole(rolesDir, entry.definition);
if (definition.credentials.length > 0 || entry.tracker !== undefined || entry.credentials !== undefined) {
return { code: "organization-credentials-out-of-scope", message: `${instance} holds credentials or a tracker bot; deleting it is a hand edit` };
}
return null;
}
// The document with `instance` deleted (V5 step 2). It removes roles.<instance> and the references
// that exist only for it, launch.instances and launch.limits.tokens.instances, and adds the name to
// retired. Every other reference (launch.by, the arbiters, launch.keep) refuses in deleteRefusal.
// A business whose only listed instance is this one refuses with a BusinessError.
export function deletedDocument(document, instance) {
const next = structuredClone(document);
delete next.roles[instance];
if (next.launch) {
if (next.launch.instances.length === 1 && next.launch.instances[0] === instance) {
refuse(`${instance} is the last name in launch.instances; a business needs one instance the chief may launch`);
}
next.launch.instances = next.launch.instances.filter(name => name !== instance);
const tokens = next.launch.limits?.tokens?.instances;
if (tokens && Object.hasOwn(tokens, instance)) delete tokens[instance];
}
next.retired = [...(next.retired ?? []), instance];
return next;
}
// A definition this writer can give an instance: version 2, not END_PROTECTED, and declaring no
// credential. An instance of one that declares credentials needs credentials and a tracker bot,
@@ -113,19 +64,14 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null })
launched: launch ? launch.instances.includes(name) : false,
launcher: launch ? launch.by === name : false,
protected: END_PROTECTED.includes(entry.definition),
kept: (launch?.keep ?? []).includes(name),
}]));
// Row 194 (D1): the human's forbid lists and the retired names. The bus adds running runs.
return {
business, revision, launch: launch !== null, instances, definitions: definitions(),
noHire: [...(launch?.noHire ?? [])], retired: [...(document.retired ?? [])],
};
return { business, revision, launch: launch !== null, instances, definitions: definitions() };
}
// args: { revision, instance, op: "add" | "change" | "delete", definition?, vars?, launch?: boolean }.
// args: { revision, instance, op: "add" | "change", definition?, vars?, launch?: boolean }.
// add needs definition; change needs at least one of definition, vars and launch. vars replaces
// the instance's vars whole. Row 194 (D3): the writer's restart is false. The bus reloads the
// business after the write and sets restart from that reload (runtime.mjs).
// the instance's vars whole. The answer's restart is true when the running bus must restart
// before the change counts: the broker holds the roles it loaded at start (broker.mjs bindLaunch).
function set(business, args, actor) {
if (!isPlainObject(args) || Object.keys(args).some(key => !SET_KEYS.includes(key))) {
throw new OrganizationError("invalid-request");
@@ -134,7 +80,7 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null })
throw new OrganizationError("invalid-request");
}
const { instance, op } = args;
if (!id(instance) || !["add", "change", "delete"].includes(op)) throw new OrganizationError("invalid-request");
if (!id(instance) || (op !== "add" && op !== "change")) throw new OrganizationError("invalid-request");
// The broker refuses a business whose roles name human or tester (broker.mjs, the load check),
// so an instance under either name would stop the bus at its next start (row 165 r2, Dewey F1).
if (instance === "human" || instance === "tester") {
@@ -145,18 +91,15 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null })
if (Object.hasOwn(args, "launch") && typeof args.launch !== "boolean") throw new OrganizationError("invalid-request");
const old = writer.read(business).document;
if (op === "delete") {
if (Object.keys(args).some(key => !["revision", "instance", "op"].includes(key))) throw new OrganizationError("invalid-request");
return commit(business, old, planDelete(old, instance), { revision: args.revision, actor });
}
const next = structuredClone(old);
const exists = Object.hasOwn(next.roles, instance);
let redefined = false;
if (op === "add") {
if (!Object.hasOwn(args, "definition")) throw new OrganizationError("invalid-request");
if (exists) throw new OrganizationError("organization-instance-exists");
if ((old.retired ?? []).includes(instance)) throw new OrganizationError("organization-instance-retired");
definitionFor(rolesDir, args.definition);
next.roles[instance] = { definition: args.definition, ...(Object.hasOwn(args, "vars") ? { vars: structuredClone(args.vars) } : {}) };
redefined = true;
} else {
if (!["definition", "vars", "launch"].some(key => Object.hasOwn(args, key))) throw new OrganizationError("invalid-request");
if (!exists) throw new OrganizationError("organization-unknown-instance");
@@ -172,7 +115,8 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null })
`${instance} holds ${entry.definition} credentials; moving them to another definition is a hand edit`);
}
entry.definition = args.definition;
}
redefined = true;
}
if (Object.hasOwn(args, "vars")) entry.vars = structuredClone(args.vars);
}
if (Object.hasOwn(args, "launch")) {
@@ -181,95 +125,6 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null })
if (!args.launch) next.launch.instances = listed.filter(name => name !== instance);
else if (!listed.includes(instance)) listed.push(instance);
}
return commit(business, old, next, { revision: args.revision, actor });
}
// Row 194 (V5): the delete's checks against the file as it stands, and its document. The broker
// runs this at the verb with no write, and the launcher's pass runs set op delete after the take.
function planDelete(document, instance) {
const refusal = deleteRefusal(document, rolesDir, instance);
if (refusal) throw new OrganizationError(refusal.code, refusal.message);
return deletedDocument(document, instance);
}
function checkDelete(business, instance) {
const { document, revision } = writer.read(business);
const next = planDelete(document, instance);
validateBusinessDocument(next, businessFilePath(business, dir), { rolesDir });
return { revision };
}
// Row 194 (V6): args { revision, op: keep | unkeep | nohire | hire, name }. keep needs a declared
// instance that isn't END_PROTECTED. nohire needs only the id pattern. Human-only at the broker.
function veto(business, args, actor) {
if (!isPlainObject(args) || Object.keys(args).some(key => !VETO_KEYS.includes(key))) throw new OrganizationError("invalid-request");
if (!revisionOk(args.revision) || !VETO_OPS.includes(args.op) || !id(args.name)) throw new OrganizationError("invalid-request");
const old = writer.read(business).document;
if (!old.launch) throw new OrganizationError("organization-launch-not-configured");
const next = structuredClone(old);
const { op, name } = args;
const key = op === "keep" || op === "unkeep" ? "keep" : "noHire";
const list = next.launch[key] ?? [];
if (op === "keep") {
if (!Object.hasOwn(next.roles, name)) throw new OrganizationError("organization-unknown-instance");
if (END_PROTECTED.includes(next.roles[name].definition)) throw new OrganizationError("organization-definition-refused");
}
const add = op === "keep" || op === "nohire";
if (add === list.includes(name)) throw new OrganizationError("organization-no-change");
next.launch[key] = add ? [...list, name] : list.filter(entry => entry !== name);
return commit(business, old, next, { revision: args.revision, actor });
}
// Row 194 (#1692, D2.2): the chief's hire, after the broker's identity, grant, allowlist and
// hireable checks. args { name, definition, vars? }. A new name is added and listed. A fired name
// (declared, not listed) is listed again only with its own definition and no vars. The writer
// reads the file as it stands, so a veto or retire written since the broker's copy still counts.
function hire(business, args, { actor, context }) {
const { document: old, revision } = writer.read(business);
if (!old.launch) throw new OrganizationError("organization-launch-not-configured");
const { name, definition } = args;
if (!id(name) || !id(definition) || name === "human" || name === "tester") throw new OrganizationError("invalid-request");
if (Object.hasOwn(args, "vars") && (!isPlainObject(args.vars) || !Object.keys(args.vars).every(HIRE_VAR))) {
throw new OrganizationError("invalid-request", "hire vars may set only model and limits keys");
}
if ((old.launch.noHire ?? []).includes(definition)) throw new OrganizationError("definition-not-hireable");
definitionFor(rolesDir, definition);
if ((old.retired ?? []).includes(name)) throw new OrganizationError("organization-instance-retired");
if ((old.launch.keep ?? []).includes(name)) throw new OrganizationError("instance-kept");
const next = structuredClone(old);
if (Object.hasOwn(old.roles, name)) {
const fired = !old.launch.instances.includes(name) && old.launch.by !== name;
if (!fired || old.roles[name].definition !== definition || Object.hasOwn(args, "vars")) {
throw new OrganizationError("organization-instance-exists");
}
} else {
next.roles[name] = { definition, ...(Object.hasOwn(args, "vars") ? { vars: structuredClone(args.vars) } : {}) };
}
next.launch.instances.push(name);
return commit(business, old, next, { revision, actor, context });
}
// Row 194 (D2.2 step B): the fire's delist. An instance already delisted is done, so a repeated
// fire after a failed step C goes on to consume (webby-0011 B2). The checks repeat against the file.
function fire(business, args, { actor, context }) {
const { document: old, revision } = writer.read(business);
if (!old.launch) throw new OrganizationError("organization-launch-not-configured");
const { name } = args;
if (!id(name) || !Object.hasOwn(old.roles, name)) throw new OrganizationError("organization-unknown-instance");
if ((old.launch.keep ?? []).includes(name)) throw new OrganizationError("instance-kept");
if (END_PROTECTED.includes(old.roles[name].definition)) throw new OrganizationError("organization-definition-refused");
if (old.launch.by === name) throw new OrganizationError("organization-instance-referenced", `${name} is launch.by`);
if (!old.launch.instances.includes(name)) return { ...read(business), transaction: null, delisted: false };
if (old.launch.instances.length === 1) {
refuse(`${name} is the last name in launch.instances; a business needs one instance the chief may launch`);
}
const next = structuredClone(old);
next.launch.instances = old.launch.instances.filter(entry => entry !== name);
return { ...commit(business, old, next, { revision, actor, context }), delisted: true };
}
// The checks every write runs, then the write.
function commit(business, old, next, { revision, actor, context }) {
if (canonicalJson(next) === canonicalJson(old)) throw new OrganizationError("organization-no-change");
// The checks mosaic business validate runs, before the writer publishes anything, then the
@@ -296,9 +151,9 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null })
}
}
}
const receipt = writer.write(business, next, { revision, actor, context });
return { ...read(business), transaction: receipt.transaction, restart: false };
const receipt = writer.write(business, next, { revision: args.revision, actor });
return { ...read(business), transaction: receipt.transaction, restart: redefined };
}
return { writer, read, set, checkDelete, veto, hire, fire };
return { writer, read, set };
}
+2 -4
View File
@@ -174,9 +174,7 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
}
}
// context, when given, is logged with the change: row 194's hire and fire carry the chief's run
// and the decision there (D2.2).
function write(id, document, { revision, actor, context }) {
function write(id, document, { revision, actor }) {
requireId(actor, "authenticated actor");
requireId(id, "organization id");
return withLock(id, ({ home, parent }) => {
@@ -202,7 +200,7 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
durable(candidate, bytes);
syncDir(tx);
const event = {
transaction, actor, ...(context === undefined ? {} : { context }), business: id, before, after,
transaction, actor, business: id, before, after,
changes: changes(old, written), at: new Date().toISOString(),
};
log(id, { ...event, state: "prepared" });
+6 -29
View File
@@ -4,11 +4,11 @@
// ceilings. Version 1 files still load: they carry no authority, so every
// vocabulary action is gated for them.
import { existsSync, lstatSync } from "node:fs";
import { lstatSync } from "node:fs";
import { basename, dirname, join } from "node:path";
import { refuse } from "./errors.mjs";
import {
ACTIONS, GATED_ONLY, TOOLS, NETWORKS, SERVICES, GITEA_SCOPE_CATEGORIES, VIKUNJA_GRANTABLE, unhireableAction,
ACTIONS, GATED_ONLY, TOOLS, NETWORKS, SERVICES, GITEA_SCOPE_CATEGORIES, VIKUNJA_GRANTABLE,
} from "./vocabulary.mjs";
import {
requireObject, rejectUnknownKeys, requireId, requireString, requireDistinctList, readJsonFile, deepFreeze,
@@ -17,7 +17,7 @@ import {
const CONTRACT_NAME = /^[a-z0-9][a-z0-9._-]{0,60}\.md$/;
const V1_KEYS = ["roleVersion", "name", "tools", "network"];
const V2_REQUIRED = ["roleVersion", "name", "title", "contract", "tools", "network", "authority", "credentials"];
const V2_KEYS = [...V2_REQUIRED, "messaging", "skills", "hireable"];
const V2_KEYS = [...V2_REQUIRED, "messaging", "skills"];
const SKILL_NAME = /^[A-Za-z0-9._-]+$/;
function checkTools(tools) {
@@ -75,25 +75,6 @@ function checkSkills(skills) {
});
}
// Row 194 (#1692, D2.3): unhireableAction is vocabulary.mjs's, exported here as before.
export { unhireableAction };
// Row 194 (#1692, D2.3): optional. The role definitions this role may hire, by name. Each one must
// be a version 2 file in the same directory whose every action is on HIRE_ALLOWED. The named file
// is validated without its own "hireable" check, so a list can't recurse.
function checkHireable(hireable, file) {
const dir = dirname(file);
return requireDistinctList(hireable, "role hireable", (name) => {
requireId(name, "role hireable entry");
const path = join(dir, `${name}.json`);
if (!existsSync(path)) refuse(`hireable names ${name}, which has no role file`);
const named = validateRoleDocument(readJsonFile(path, "role file"), path, { hireable: false });
if (named.roleVersion !== 2) refuse(`hireable names ${name}, which is role version 1`);
const outside = unhireableAction(named);
if (outside !== null) refuse(`hireable names ${name}, which holds ${outside}`);
});
}
function checkGiteaScopes(scopes) {
const list = requireDistinctList(scopes, "gitea scopes", (scope) => {
const m = typeof scope === "string" ? /^(read|write):([a-z]+)$/.exec(scope) : null;
@@ -153,9 +134,8 @@ function checkContract(contract, file) {
// Validate a parsed role document read from `file`. Returns a frozen role:
// { roleVersion, name, title, contract, contractPath, tools, network,
// authority: { withinRole, crossRole }, credentials: [{ service, scopes }],
// messaging: { sendTo, receiveFrom } | null, skills: [name], hireable: [name] }.
// options.hireable false skips the hireable list's checks of other files (D2.3) and keeps the list.
export function validateRoleDocument(document, file, options = {}) {
// messaging: { sendTo, receiveFrom } | null, skills: [name] }.
export function validateRoleDocument(document, file) {
requireObject(document, "role");
if (document.roleVersion !== 1 && document.roleVersion !== 2) refuse('role "roleVersion" must be 1 or 2');
rejectUnknownKeys(document, document.roleVersion === 1 ? V1_KEYS : V2_KEYS, "role");
@@ -167,7 +147,7 @@ export function validateRoleDocument(document, file, options = {}) {
const network = document.network === undefined ? "none" : checkNetwork(document.network);
return deepFreeze({
roleVersion: 1, name: document.name, title: null, contract: null, contractPath: null, tools, network,
authority: { withinRole: [], crossRole: [] }, credentials: [], messaging: null, skills: [], hireable: [],
authority: { withinRole: [], crossRole: [] }, credentials: [], messaging: null, skills: [],
});
}
for (const key of V2_REQUIRED) {
@@ -185,9 +165,6 @@ export function validateRoleDocument(document, file, options = {}) {
credentials: checkCredentials(document.credentials),
messaging: document.messaging === undefined ? null : checkMessaging(document.messaging),
skills: document.skills === undefined ? [] : checkSkills(document.skills),
hireable: document.hireable === undefined ? []
: options.hireable === false ? requireDistinctList(document.hireable, "role hireable", (name) => requireId(name, "role hireable entry"))
: checkHireable(document.hireable, file),
});
}
+2 -33
View File
@@ -35,10 +35,6 @@ export const ACTIONS = Object.freeze([
// Row 141 (#1635): sets a role instance's name. Routed under PRD 1.5 REQ-MSG-2, which has the chief of
// staff set names once its role file grants that within-role.
"instance.name",
// Row 194 (#1692): the chief of staff hires (adds and lists a role instance) and fires (ends its
// runs and delists it). Hard waits under PRD 1.6 (mos-1017), routed under PRD 1.7 REQ-COS-3.
"role.hire",
"role.fire",
"credential.mint",
"spend",
"deploy",
@@ -58,7 +54,7 @@ export const ACTIONS = Object.freeze([
// action joins this list only by the reviewed commit that names it routed.
// git.merge.protected, policy.change, prd.approve and role.revoke left the
// hard waits in 1.5 and route by REQ-DEC-2. instance.name (row 141) is routed
// under REQ-MSG-2 (1.5). role.hire and role.fire left the hard waits in 1.7 (REQ-COS-3).
// under REQ-MSG-2 (1.5).
export const ROUTED = Object.freeze([
"task.create",
"task.assign",
@@ -82,42 +78,15 @@ export const ROUTED = Object.freeze([
"policy.change",
"prd.approve",
"decision.resolve.technical",
// PRD 1.7 REQ-COS-3 (row 194, #1692): the chief's hire and fire, within the bounds the broker checks.
"role.hire",
"role.fire",
]);
// The hard waits (PRD 1.5 REQ-WAIT-1): always gated, always the human's,
// cited spec or not. A role file may not list these under withinRole or
// crossRole (REQ-WAIT-2). Today this is spend, data.delete, network.expose,
// account.create, credential.mint, message.external, git.push.protected,
// account.create, credential.mint, message.external, git.push.protected and
// deploy; a test pins the list.
export const GATED_ONLY = Object.freeze(ACTIONS.filter((action) => !ROUTED.includes(action)));
// Row 194 (#1692, D2.3): the chief of staff hires only a definition whose every withinRole and
// crossRole action is on this list. An allowlist, so an action added to ACTIONS later is never
// hireable until a reviewed commit adds it here. git.push.remote (row 199) is not on it.
export const HIRE_ALLOWED = Object.freeze([
"message.send",
"record.write",
"review.request",
"task.update.assigned",
"task.scope.change",
"git.push.working",
]);
// Row 194 (#1692, D2.3): the first action of a definition's authority that isn't on
// HIRE_ALLOWED, or null when every one is. role.mjs's hireable check and the broker's hire check
// use it. It lives here because the session sandbox mounts this file and not role.mjs
// (seat/src/sandbox.mjs RUNNER_FILES), and the runner imports broker.mjs.
export function unhireableAction(role) {
for (const key of ["withinRole", "crossRole"]) {
const outside = role.authority[key].find((action) => !HIRE_ALLOWED.includes(action));
if (outside !== undefined) return outside;
}
return null;
}
// pi's documented built-in tools; the same list scripts/mosaic-task.mjs
// accepts for tasks.
export const BUILTIN_TOOLS = Object.freeze(["read", "write", "edit", "bash", "grep", "find", "ls"]);
-33
View File
@@ -234,36 +234,3 @@ test("the shipped example refuses as written and validates once filled in", () =
assert.equal(business.tracker.sync.credentials.vikunja.file, join(root, ".config/mosaic-dev/secrets/mosaic-stack/sync-vikunja.token"));
assert.equal(id, 15);
});
// Row 194 (#1692, D2.5, T17): the per-agent forbid and the retired list.
test("row 194 keep, noHire and retired validate, and a file without them loads as before", () => {
const plain = check();
assert.equal(Object.hasOwn(plain.launch, "keep"), false);
assert.equal(Object.hasOwn(plain.launch, "noHire"), false);
assert.equal(Object.hasOwn(plain, "retired"), false);
const business = check((d) => {
d.launch.keep = ["coder", "chief"];
d.launch.noHire = ["reviewer", "no-such-role"];
d.retired = ["old-coder"];
});
assert.deepEqual(business.launch.keep, ["coder", "chief"]);
// A stale noHire entry is harmless: only the id pattern is checked.
assert.deepEqual(business.launch.noHire, ["reviewer", "no-such-role"]);
assert.deepEqual(business.retired, ["old-coder"]);
assert.ok(Object.isFrozen(business.launch.keep) && Object.isFrozen(business.retired));
assert.deepEqual(check((d) => { d.launch.keep = []; d.launch.noHire = []; d.retired = []; }).launch.keep, []);
});
test("row 194 each bad keep, noHire or retired entry refuses", () => {
refuses(() => check((d) => { d.launch.keep = ["ghost"]; }), /launch\.keep names ghost, which the business file doesn't declare/);
refuses(() => check((d) => { d.launch.keep = ["coder", "coder"]; }), /duplicate entry in .*launch\.keep/);
refuses(() => check((d) => { d.launch.keep = "coder"; }), /launch\.keep must be an array/);
refuses(() => check((d) => { d.roles.assistant = { definition: "assistant" }; d.launch.keep = ["assistant"]; }),
/launch\.keep names assistant, whose definition assistant can't be kept/);
refuses(() => check((d) => { d.launch.noHire = ["Bad Name"]; }), /launch\.noHire entry/);
refuses(() => check((d) => { d.launch.noHire = ["coder", "coder"]; }), /duplicate entry in .*launch\.noHire/);
refuses(() => check((d) => { d.retired = ["coder"]; }), /retired names coder, which roles still declares/);
refuses(() => check((d) => { d.retired = ["x", "x"]; }), /duplicate entry in .*retired/);
refuses(() => check((d) => { d.retired = ["../x"]; }), /retired entry/);
refuses(() => check((d) => { d.retired = {}; }), /retired must be an array/);
});
@@ -14,15 +14,12 @@ import { businessDoc, launchDoc, REPO_ROLES, systemFor, tmp, writeJson } from ".
// Decision 104's list plus role.launch (decision 139) plus role.end and
// organization.change (row 123, REQ-COS-1) and record.write (row 138), plus instance.name
// (row 141, REQ-MSG-2, sage-0589), plus role.hire and role.fire (row 194, PRD 1.7 REQ-COS-3,
// sage-1471, mos-1089). A role file that differs from it fails.
// (row 141, REQ-MSG-2, sage-0589). A role file that differs from it fails.
const APPROVED = Object.freeze({
tools: ["read", "grep", "find", "ls"],
network: "api-only",
authority: Object.freeze({ withinRole: Object.freeze(["message.send", "role.launch", "role.end", "organization.change", "record.write", "instance.name", "role.hire", "role.fire"]), crossRole: Object.freeze([]) }),
authority: Object.freeze({ withinRole: Object.freeze(["message.send", "role.launch", "role.end", "organization.change", "record.write", "instance.name"]), crossRole: Object.freeze([]) }),
credentials: Object.freeze([]),
// Row 194 (#1692, Q4, sage-1374): empty until a policy commit after the PRD 1.7 resolve.
hireable: Object.freeze([]),
});
// The fixture business's chief-of-staff instance, which is also its launcher.
@@ -88,9 +85,9 @@ test("the resolved record matches the approved list, so a widened file fails", (
assert.deepEqual(record.credentials, {});
});
test("the chief of staff holds role.launch, message.send, role.end, organization.change, record.write, instance.name, role.hire and role.fire; every other action is gated", () => {
test("the chief of staff holds role.launch, message.send, role.end, organization.change, record.write and instance.name; every other action is gated", () => {
const record = resolved(loadFixture());
const held = ["message.send", "role.launch", "role.end", "organization.change", "record.write", "instance.name", "role.hire", "role.fire"];
const held = ["message.send", "role.launch", "role.end", "organization.change", "record.write", "instance.name"];
for (const action of ACTIONS) {
assert.equal(classify(record, action), held.includes(action) ? "within" : "gated", action);
}
@@ -142,8 +139,6 @@ test("the contract names no duty to talk to the human and the pm contract no lau
assert.doesNotMatch(chief, /main (agent|point of interaction)/i);
assert.doesNotMatch(chief, /\bJason\b/);
assert.match(chief, /never talk to the user/);
// Row 194 (#1692, D3 item 5, N3): a hire launches with no restart once the human sets its route.
assert.match(chief, /A hire is listed for `launch` at once, with no restart, but it\s+runs only after the human sets its route \(harness and thinking\)/);
const pm = readFileSync(join(REPO_ROLES, "pm.md"), "utf8");
assert.doesNotMatch(pm, /^- Launch /m);
assert.match(pm, /You don't launch sessions/);
@@ -33,11 +33,6 @@ test("REQ-ROLE-2: END_PROTECTED includes the assistant's thread definition", ()
assert.ok(Object.isFrozen(END_PROTECTED));
});
// PRD 1.7 REQ-COS-3 (row 194, #1692): the chief's hire and fire left the hard waits and are routed.
test("REQ-COS-3: role.hire and role.fire are routed, not hard waits", () => {
for (const action of ["role.hire", "role.fire"]) assert.ok(ROUTED.includes(action) && !GATED_ONLY.includes(action), action);
});
test("REQ-WAIT-3: every action is routed or a hard wait, never both, and the routed list names only actions", () => {
for (const action of ROUTED) assert.ok(ACTIONS.includes(action), action);
for (const action of ACTIONS) assert.notEqual(ROUTED.includes(action), GATED_ONLY.includes(action), action);
@@ -50,7 +50,7 @@ test("row 165 read lists each instance and the definitions add accepts", () => {
const view = f.settings.read("acme");
assert.match(view.revision, /^[a-f0-9]{64}$/);
assert.equal(view.launch, true);
assert.deepEqual(view.instances.coder, { definition: "coder", vars: { harness: "pi", "limits.network": "none" }, launched: true, launcher: false, protected: false, kept: false });
assert.deepEqual(view.instances.coder, { definition: "coder", vars: { harness: "pi", "limits.network": "none" }, launched: true, launcher: false, protected: false });
assert.equal(view.instances.chief.launcher, true);
assert.equal(view.instances.assistant.protected, true);
assert.deepEqual(view.definitions["security-reviewer"], { services: [], accepted: true });
@@ -60,16 +60,16 @@ test("row 165 read lists each instance and the definitions add accepts", () => {
assert.equal(Object.hasOwn(view.definitions, "conductor-policy"), false);
});
test("row 165 add: two instances join, validate exits 0, the log names the human, and the writer asks for no restart (row 194, D3)", () => {
test("row 165 add: two instances join, validate exits 0, the log names the human, and the bus restart is reported", () => {
const f = fixture();
const view = f.settings.read("acme");
const added = f.settings.set("acme", { revision: view.revision, instance: "sec-1", op: "add", definition: "security-reviewer", vars: { model: "claude-sonnet-5-5" }, launch: true }, "jason");
assert.deepEqual(added.instances["sec-1"], { definition: "security-reviewer", vars: { model: "claude-sonnet-5-5" }, launched: true, launcher: false, protected: false, kept: false });
assert.deepEqual(added.instances["sec-1"], { definition: "security-reviewer", vars: { model: "claude-sonnet-5-5" }, launched: true, launcher: false, protected: false });
assert.notEqual(added.revision, view.revision);
assert.match(added.transaction, /\S/);
assert.equal(added.restart, false);
assert.equal(added.restart, true);
const gate = f.settings.set("acme", { revision: added.revision, instance: "gate-1", op: "add", definition: "merge-gate" }, "jason");
assert.deepEqual(gate.instances["gate-1"], { definition: "merge-gate", vars: {}, launched: false, launcher: false, protected: false, kept: false });
assert.deepEqual(gate.instances["gate-1"], { definition: "merge-gate", vars: {}, launched: false, launcher: false, protected: false });
const file = JSON.parse(readFileSync(f.file, "utf8"));
assert.deepEqual(file.roles["gate-1"], { definition: "merge-gate" });
assert.deepEqual(file.launch.instances, ["coder", "reviewer", "sec-1"]);
@@ -84,11 +84,11 @@ test("row 165 add: two instances join, validate exits 0, the log names the human
assert.equal(second.actor, "jason");
});
test("row 165 change: definition, vars and launch membership log before and after; none asks for a restart, the bus reloads (row 194, D3)", () => {
test("row 165 change: definition, vars and launch membership log before and after; only a definition change asks for a restart", () => {
const f = fixture(doc => { harnessOn(doc); doc.roles["sec-1"] = { definition: "security-reviewer", vars: { harness: "pi" } }; });
const redefined = f.settings.set("acme", { revision: f.settings.read("acme").revision, instance: "sec-1", op: "change", definition: "merge-gate" }, "jason");
assert.equal(redefined.instances["sec-1"].definition, "merge-gate");
assert.equal(redefined.restart, false);
assert.equal(redefined.restart, true);
let entry = f.log().at(-1);
assert.ok(entry.changes.some(c => c.path === "roles.sec-1.definition" && c.old === "security-reviewer" && c.new === "merge-gate"));
const revarred = f.settings.set("acme", { revision: redefined.revision, instance: "sec-1", op: "change", vars: { harness: "claude-code", model: "claude-opus-5-5" } }, "jason");
@@ -1,228 +0,0 @@
// Row 194 (#1692): the organization writer's delete (V5), the human's veto lists (V6) and the
// chief's hire and fire (D2.2). The broker's identity, decision and allowlist checks are bus tests.
// A refused write leaves the file's bytes alone.
import test, { after } from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { readFileSync, rmSync } from "node:fs";
import { join } from "node:path";
import { instanceSettings } from "../src/instance-settings.mjs";
import { businessDoc, REPO, rolesCopy, systemConfig, systemFor, tmp, writeJson } from "./helpers.mjs";
const CLI = join(REPO, "packages", "business", "src", "cli.mjs");
const roots = [];
after(() => roots.forEach(root => rmSync(root, { recursive: true, force: true })));
const T0 = "2026-10-18T07:00:00.000Z";
const CONTEXT = Object.freeze({ instance: "chief", run: "run-1", reason: "a security review is due" });
// sec-1 is a listed security-reviewer: no credential, no tracker bot, so the writer may delete it.
function fixture(mutate) {
const root = tmp();
roots.push(root);
const rolesDir = rolesCopy(root);
const doc = businessDoc(root, "acme");
doc.roles["sec-1"] = { definition: "security-reviewer" };
doc.launch.instances.push("sec-1");
mutate?.(doc, root);
const dir = join(root, "config");
const file = writeJson(join(dir, "businesses", "acme.json"), doc);
const config = systemConfig(root);
const settings = instanceSettings({ dir, rolesDir, system: systemFor(root) });
const validate = () => spawnSync(process.execPath, [CLI, "validate", "acme"], {
env: { PATH: process.env.PATH, HOME: join(root, "home"), MOSAIC_CONFIG: config, MOSAIC_ROLES_DIR: rolesDir }, encoding: "utf8",
});
const log = () => readFileSync(settings.writer.logFile("acme"), "utf8").trim().split("\n").map(line => JSON.parse(line));
const onDisk = () => JSON.parse(readFileSync(file, "utf8"));
const revision = () => settings.read("acme").revision;
return { root, file, settings, validate, log, onDisk, revision };
}
// Each refusal: its code (or the validator's BusinessError), and the file's bytes unchanged.
function refuses(f, act, code) {
const bytes = readFileSync(f.file, "utf8");
assert.throws(act, e => (code === "BusinessError" ? e.name === code : e.code === code), code);
assert.equal(readFileSync(f.file, "utf8"), bytes, code);
}
const del = (f, instance, extra = {}) => () => f.settings.set("acme", { revision: f.revision(), instance, op: "delete", ...extra }, "jason");
const veto = (f, op, name, actor = "human") => f.settings.veto("acme", { revision: f.revision(), op, name }, actor);
const hire = (f, args) => f.settings.hire("acme", args, { actor: "chief", context: CONTEXT });
const fire = (f, name) => f.settings.fire("acme", { name }, { actor: "chief", context: CONTEXT });
test("T6 delete refuses in the design's order and leaves the file's bytes alone", () => {
const f = fixture(doc => {
doc.roles.assistant = { definition: "assistant", holder: "mos" };
doc.roles["sec-3"] = { definition: "security-reviewer" };
doc.launch.keep = ["sec-3", "coder"];
});
refuses(f, del(f, "ghost"), "organization-unknown-instance");
refuses(f, del(f, "assistant"), "organization-definition-refused");
refuses(f, del(f, "chief"), "organization-instance-referenced");
refuses(f, del(f, "pm"), "organization-instance-referenced");
refuses(f, del(f, "cto"), "organization-instance-referenced");
assert.throws(del(f, "cto"), /cto is arbiters\.technical; change that first/);
refuses(f, del(f, "sec-3"), "organization-instance-kept");
// coder declares credentials and is kept: kept is checked first, so the veto is named.
refuses(f, del(f, "coder"), "organization-instance-kept");
refuses(f, del(f, "reviewer"), "organization-credentials-out-of-scope");
refuses(f, del(f, "sec-1", { definition: "security-reviewer" }), "invalid-request");
// checkDelete runs the same checks with no write.
refuses(f, () => f.settings.checkDelete("acme", "sec-3"), "organization-instance-kept");
const bytes = readFileSync(f.file, "utf8");
assert.deepEqual(f.settings.checkDelete("acme", "sec-1"), { revision: f.revision() });
assert.equal(readFileSync(f.file, "utf8"), bytes);
});
test("T6 delete refuses the last listed instance", () => {
const f = fixture(doc => { doc.launch.instances = ["sec-1"]; });
refuses(f, del(f, "sec-1"), "BusinessError");
refuses(f, () => f.settings.checkDelete("acme", "sec-1"), "BusinessError");
});
test("T6 a clean delete removes the entry and its own references, adds retired, and the name can't come back", () => {
const f = fixture(doc => { doc.launch.limits.tokens = { instances: { "sec-1": { limit: 100, since: T0 }, coder: { limit: 50, since: T0 } } }; });
const done = del(f, "sec-1")();
assert.equal(Object.hasOwn(done.instances, "sec-1"), false);
assert.deepEqual(done.retired, ["sec-1"]);
assert.equal(done.restart, false);
const file = f.onDisk();
assert.equal(Object.hasOwn(file.roles, "sec-1"), false);
assert.deepEqual(file.launch.instances, ["coder", "reviewer"]);
assert.deepEqual(file.launch.limits.tokens.instances, { coder: { limit: 50, since: T0 } });
assert.deepEqual(file.retired, ["sec-1"]);
const run = f.validate();
assert.equal(run.status, 0, run.stderr);
const entry = f.log().at(-1);
assert.equal(entry.actor, "jason");
assert.equal(entry.transaction, done.transaction);
assert.ok(entry.changes.some(c => c.path === "roles.sec-1" && c.newPresent === false));
assert.ok(entry.changes.some(c => c.path === "retired" && c.oldPresent === false));
refuses(f, () => f.settings.set("acme", { revision: f.revision(), instance: "sec-1", op: "add", definition: "security-reviewer" }, "jason"),
"organization-instance-retired");
refuses(f, () => hire(f, { name: "sec-1", definition: "security-reviewer" }), "organization-instance-retired");
refuses(f, del(f, "sec-1"), "organization-unknown-instance");
});
test("T7 veto: keep, unkeep, nohire and hire each write once, a repeat is no change, and the log names the human", () => {
const f = fixture();
let view = veto(f, "keep", "sec-1");
assert.equal(view.instances["sec-1"].kept, true);
assert.equal(view.restart, false);
assert.deepEqual(f.onDisk().launch.keep, ["sec-1"]);
let entry = f.log().at(-1);
assert.equal(entry.actor, "human");
assert.ok(entry.changes.some(c => c.path === "launch.keep" && c.oldPresent === false && c.new.includes("sec-1")));
refuses(f, () => veto(f, "keep", "sec-1"), "organization-no-change");
view = veto(f, "unkeep", "sec-1");
assert.equal(view.instances["sec-1"].kept, false);
assert.deepEqual(f.onDisk().launch.keep, []);
refuses(f, () => veto(f, "unkeep", "sec-1"), "organization-no-change");
view = veto(f, "nohire", "merge-gate");
assert.deepEqual(view.noHire, ["merge-gate"]);
// A name with no role file is accepted: the list forbids, it doesn't declare.
view = veto(f, "nohire", "no-such-role");
assert.deepEqual(view.noHire, ["merge-gate", "no-such-role"]);
refuses(f, () => veto(f, "nohire", "merge-gate"), "organization-no-change");
view = veto(f, "hire", "merge-gate");
assert.deepEqual(view.noHire, ["no-such-role"]);
refuses(f, () => veto(f, "hire", "merge-gate"), "organization-no-change");
entry = f.log().at(-1);
assert.equal(entry.actor, "human");
assert.ok(entry.changes.some(c => c.path === "launch.noHire"));
assert.equal(f.validate().status, 0);
});
test("T7 veto refuses an unknown or END_PROTECTED keep, a bad request, a stale revision and a business without launch", () => {
const f = fixture(doc => { doc.roles.assistant = { definition: "assistant", holder: "mos" }; });
refuses(f, () => veto(f, "keep", "ghost"), "organization-unknown-instance");
refuses(f, () => veto(f, "keep", "assistant"), "organization-definition-refused");
const revision = f.revision();
for (const bad of [
{ revision, op: "keep" }, { revision, op: "forbid", name: "sec-1" }, { revision, op: "keep", name: "Bad Name" },
{ revision: "stale", op: "keep", name: "sec-1" }, { revision, op: "keep", name: "sec-1", by: "chief" },
]) refuses(f, () => f.settings.veto("acme", bad, "human"), "invalid-request");
refuses(f, () => f.settings.veto("acme", null, "human"), "invalid-request");
veto(f, "keep", "sec-1");
refuses(f, () => f.settings.veto("acme", { revision, op: "nohire", name: "coder" }, "human"), "revision-conflict");
const none = fixture(doc => { delete doc.launch; doc.roles["sec-1"] = { definition: "security-reviewer" }; });
refuses(none, () => veto(none, "nohire", "coder"), "organization-launch-not-configured");
});
test("D2.2 hire adds and lists a new instance, logs the chief's context, and sets only model and limits", () => {
const f = fixture();
const done = hire(f, { name: "sec-2", definition: "security-reviewer", vars: { model: "claude-sonnet-5-5" } });
assert.deepEqual(done.instances["sec-2"], {
definition: "security-reviewer", vars: { model: "claude-sonnet-5-5" }, launched: true, launcher: false, protected: false, kept: false,
});
assert.equal(done.restart, false);
assert.deepEqual(f.onDisk().launch.instances, ["coder", "reviewer", "sec-1", "sec-2"]);
const entry = f.log().at(-1);
assert.equal(entry.actor, "chief");
assert.deepEqual(entry.context, CONTEXT);
assert.equal(entry.transaction, done.transaction);
assert.equal(f.validate().status, 0);
for (const vars of [{ harness: "pi" }, { model: "claude-sonnet-5-5", thinking: "high" }, []]) {
refuses(f, () => hire(f, { name: "sec-3", definition: "security-reviewer", vars }), "invalid-request");
}
for (const args of [{ name: "Bad Name", definition: "security-reviewer" }, { name: "human", definition: "security-reviewer" },
{ name: "tester", definition: "security-reviewer" }, { name: "sec-3", definition: "Bad Name" }]) {
refuses(f, () => hire(f, args), "invalid-request");
}
refuses(f, () => hire(f, { name: "sec-3", definition: "security-reviewer", vars: { model: "not-in-the-table" } }), "BusinessError");
refuses(f, () => hire(f, { name: "coder-2", definition: "coder" }), "organization-credentials-out-of-scope");
refuses(f, () => hire(f, { name: "asst-2", definition: "assistant" }), "organization-definition-refused");
refuses(f, () => hire(f, { name: "sec-3", definition: "no-such-role" }), "BusinessError");
});
test("D2.2 hire refuses noHire, a kept name and a listed name, and re-lists a fired one only as it was", () => {
const f = fixture(doc => {
doc.launch.noHire = ["merge-gate"];
doc.roles["sec-2"] = { definition: "security-reviewer", vars: { model: "claude-sonnet-5-5" } };
doc.roles["sec-3"] = { definition: "security-reviewer" };
doc.launch.keep = ["sec-3"];
});
refuses(f, () => hire(f, { name: "gate-1", definition: "merge-gate" }), "definition-not-hireable");
refuses(f, () => hire(f, { name: "sec-3", definition: "security-reviewer" }), "instance-kept");
refuses(f, () => hire(f, { name: "sec-1", definition: "security-reviewer" }), "organization-instance-exists");
refuses(f, () => hire(f, { name: "chief", definition: "chief-of-staff" }), "organization-instance-exists");
// sec-2 is declared and not listed: fired. Only its own definition with no vars lists it again.
refuses(f, () => hire(f, { name: "sec-2", definition: "merge-gate" }), "definition-not-hireable");
veto(f, "hire", "merge-gate");
refuses(f, () => hire(f, { name: "sec-2", definition: "merge-gate" }), "organization-instance-exists");
refuses(f, () => hire(f, { name: "sec-2", definition: "security-reviewer", vars: { model: "claude-opus-5-5" } }), "organization-instance-exists");
const done = hire(f, { name: "sec-2", definition: "security-reviewer" });
assert.equal(done.instances["sec-2"].launched, true);
assert.deepEqual(f.onDisk().roles["sec-2"], { definition: "security-reviewer", vars: { model: "claude-sonnet-5-5" } });
const none = fixture(doc => { delete doc.launch; });
refuses(none, () => hire(none, { name: "sec-9", definition: "security-reviewer" }), "organization-launch-not-configured");
});
test("D2.2 fire delists and keeps the entry, a repeat is done with no write, and each refusal holds", () => {
const f = fixture(doc => {
doc.roles.assistant = { definition: "assistant", holder: "mos" };
doc.roles["sec-3"] = { definition: "security-reviewer" };
doc.launch.instances.push("sec-3");
doc.launch.keep = ["sec-3"];
});
const done = fire(f, "sec-1");
assert.equal(done.delisted, true);
assert.equal(done.instances["sec-1"].launched, false);
assert.deepEqual(f.onDisk().roles["sec-1"], { definition: "security-reviewer" });
assert.deepEqual(f.onDisk().launch.instances, ["coder", "reviewer", "sec-3"]);
const entry = f.log().at(-1);
assert.equal(entry.actor, "chief");
assert.deepEqual(entry.context, CONTEXT);
const bytes = readFileSync(f.file, "utf8");
const again = fire(f, "sec-1");
assert.equal(again.delisted, false);
assert.equal(again.transaction, null);
assert.equal(readFileSync(f.file, "utf8"), bytes);
refuses(f, () => fire(f, "ghost"), "organization-unknown-instance");
refuses(f, () => fire(f, "Bad Name"), "organization-unknown-instance");
refuses(f, () => fire(f, "sec-3"), "instance-kept");
refuses(f, () => fire(f, "assistant"), "organization-definition-refused");
refuses(f, () => fire(f, "chief"), "organization-instance-referenced");
assert.equal(f.validate().status, 0);
const one = fixture(doc => { doc.launch.instances = ["sec-1"]; });
refuses(one, () => fire(one, "sec-1"), "BusinessError");
});
+1 -68
View File
@@ -2,7 +2,7 @@ import { test } from "node:test";
import assert from "node:assert/strict";
import { existsSync, readdirSync, symlinkSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { loadRole, loadRoleFile, validateRoleDocument, ACTIONS, GATED_ONLY, HIRE_ALLOWED, BusinessError } from "../src/index.mjs";
import { loadRole, loadRoleFile, validateRoleDocument, GATED_ONLY, BusinessError } from "../src/index.mjs";
import { REPO_ROLES, tmp, writeJson } from "./helpers.mjs";
function refuses(fn, pattern, exitCode = 2) {
@@ -74,13 +74,7 @@ test("shipped authority follows the note's table", () => {
for (const name of others.map((f) => f.slice(0, -5))) {
assert.ok(!auth(name).withinRole.includes("instance.name"), name);
assert.ok(!auth(name).crossRole.includes("instance.name"), name);
// Row 194 (PRD 1.7 REQ-COS-3, sage-1471): hire and fire are the chief's alone too.
for (const action of ["role.hire", "role.fire"]) {
assert.ok(!auth(name).withinRole.includes(action), `${name} ${action}`);
assert.ok(!auth(name).crossRole.includes(action), `${name} ${action}`);
}
}
assert.ok(auth("chief-of-staff").withinRole.includes("role.hire") && auth("chief-of-staff").withinRole.includes("role.fire"));
assert.ok(auth("cto").withinRole.includes("decision.resolve.technical"));
assert.ok(!auth("pm").withinRole.includes("decision.resolve.technical"));
});
@@ -221,64 +215,3 @@ test("contract: a non-empty regular Markdown file beside the role file", () => {
symlinkSync("r.md", join(dir, "link.md"));
refuses(() => validateRoleDocument(doc, file), /non-empty regular file/);
});
// Row 194 (#1692, D2.3, T8c): the chief's hireable list, checked at load against HIRE_ALLOWED.
// Each refused action is looped from the lists, not typed by hand.
const REFUSED_FOR_HIRE = ACTIONS.filter((action) => !HIRE_ALLOWED.includes(action) && !GATED_ONLY.includes(action));
function hireDir(definitions) {
const dir = tmp();
for (const [name, doc] of Object.entries(definitions)) {
writeFileSync(join(dir, `${name}.md`), "# contract\n");
writeJson(join(dir, `${name}.json`), doc === 1 ? { roleVersion: 1, name, tools: ["read"] } : {
roleVersion: 2, name, title: name, contract: `${name}.md`, tools: ["read"], network: "none",
authority: { withinRole: doc.withinRole ?? ["message.send"], crossRole: doc.crossRole ?? [] }, credentials: [],
...(doc.hireable === undefined ? {} : { hireable: doc.hireable }),
});
}
return dir;
}
test("hireable: HIRE_ALLOWED is exactly the six plain work actions, and every one is routed", () => {
assert.deepEqual([...HIRE_ALLOWED], ["message.send", "record.write", "review.request", "task.update.assigned",
"task.scope.change", "git.push.working"]);
assert.ok(Object.isFrozen(HIRE_ALLOWED));
for (const action of HIRE_ALLOWED) assert.ok(ACTIONS.includes(action) && !GATED_ONLY.includes(action), action);
assert.ok(!HIRE_ALLOWED.includes("git.push.remote"));
assert.ok(REFUSED_FOR_HIRE.length >= 15, "the loop covers the routed actions outside the list");
});
test("hireable: a list naming a definition that holds any refused action refuses at load, one case per action", () => {
for (const action of REFUSED_FOR_HIRE) {
for (const key of ["withinRole", "crossRole"]) {
const dir = hireDir({ chief: { hireable: ["worker"] }, worker: { [key]: [action], ...(key === "crossRole" ? { withinRole: [] } : {}) } });
refuses(() => loadRole(dir, "chief"), new RegExp(`hireable names worker, which holds ${action.replace(/\./g, "\\.")}$`));
}
}
});
test("hireable: a mixed definition refuses, a clean list loads, and the named file's own hireable isn't read", () => {
const mixed = hireDir({ chief: { hireable: ["worker"] }, worker: { withinRole: ["message.send", "role.launch"] } });
refuses(() => loadRole(mixed, "chief"), /hireable names worker, which holds role\.launch/);
const clean = hireDir({
chief: { hireable: ["worker", "helper"] },
worker: { withinRole: [...HIRE_ALLOWED] },
// helper's own list names a definition that doesn't exist. Only the chief's list is checked.
helper: { withinRole: ["record.write"], hireable: ["nobody"] },
});
assert.deepEqual(loadRole(clean, "chief").hireable, ["worker", "helper"]);
assert.ok(Object.isFrozen(loadRole(clean, "chief").hireable));
refuses(() => loadRole(clean, "helper"), /hireable names nobody, which has no role file/);
});
test("hireable: a missing, version 1, duplicate or badly named entry refuses, and the key is optional", () => {
refuses(() => loadRole(hireDir({ chief: { hireable: ["ghost"] } }), "chief"), /hireable names ghost, which has no role file/);
refuses(() => loadRole(hireDir({ chief: { hireable: ["old"] }, old: 1 }), "chief"), /hireable names old, which is role version 1/);
refuses(() => loadRole(hireDir({ chief: { hireable: ["w", "w"] }, w: {} }), "chief"), /duplicate entry in role hireable/);
refuses(() => loadRole(hireDir({ chief: { hireable: ["../w"] } }), "chief"), /role hireable entry/);
refuses(() => loadRole(hireDir({ chief: { hireable: "w" } }), "chief"), /role hireable must be an array/);
assert.deepEqual(loadRole(hireDir({ chief: {} }), "chief").hireable, []);
assert.deepEqual(loadRole(REPO_ROLES, "chief-of-staff").hireable, []);
// A version 1 file can't carry the key.
refuses(() => validateRoleDocument({ roleVersion: 1, name: "r", tools: ["read"], hireable: [] }, join(tmp(), "r.json")), /unsupported role key/);
});
+1 -1
View File
@@ -576,7 +576,7 @@ export async function main(argv, io = { env: process.env, stdin: process.stdin,
if (verb === "limits") return limitsCommand(rest,io,p=>callFor(p,io,deps));
if (verb === "usage") return usageCommand(rest, io, p => callFor(p, io, deps));
if (verb === "plugins") return pluginsCommand(rest, io, (p) => callFor(p, io, deps));
// Row 165 (#1662): scripts/mosaic sends only `business instances`, `business instance` and (row 194) `business veto` here.
// Row 165 (#1662): scripts/mosaic sends only `business instances` and `business instance` here.
if (verb === "business") return instancesCommand(rest, io, (p) => callFor(p, io, deps));
if (verb === "talk") return talk(rest, io, deps);
if (verb === "follow") return talk(rest, io, deps, true);
+6 -133
View File
@@ -3,19 +3,12 @@
// and instances.set verbs, and with --tester the tester does (row 175, #1673, Q173). The broker
// refuses an agent and a reader; the business package's instance writer refuses a stale revision
// and every document `mosaic business validate` would refuse, and logs the change with its actor.
// Row 194 (#1692, D1): `business instance launch|terminate|regenerate|delete` and `business veto`.
// Launch, regenerate and delete are requests the launcher's pass carries out. The command polls
// instance.request until the request closes or --wait runs out (D1.1 item 5).
import { readFileSync } from "node:fs";
import { CliError } from "./errors.mjs";
export const INSTANCES_USAGE = "mosaic business instances BUSINESS [--json] [--tester] | " +
"business instance add BUSINESS INSTANCE --definition DEF [--vars FILE] [--launch] --revision REV [--tester] | " +
"business instance set BUSINESS INSTANCE [--definition DEF] [--vars FILE] [--launch | --no-launch] --revision REV [--tester] | " +
"business instance launch|regenerate BUSINESS INSTANCE [--wait SECONDS] [--tester] | " +
"business instance terminate BUSINESS INSTANCE [--reason TEXT] [--tester] | " +
"business instance delete BUSINESS INSTANCE --revision REV [--wait SECONDS] | " +
"business veto BUSINESS keep|unkeep INSTANCE --revision REV | business veto BUSINESS nohire|hire DEFINITION --revision REV";
"business instance set BUSINESS INSTANCE [--definition DEF] [--vars FILE] [--launch | --no-launch] --revision REV [--tester]";
// The bus answers a refusal with its code alone, so the reason a human reads is here.
export const REASONS = {
@@ -30,32 +23,8 @@ export const REASONS = {
"organization-project-mismatch": "a project file's id differs from its key in the business file; `mosaic business validate` shows it",
"organization-unresolvable-instance": "an instance wouldn't resolve against a project file; `mosaic business validate` shows why",
"revision-conflict": "the business file changed since that revision; read it again with `mosaic business instances`",
// Row 194 (#1692): the lifecycle and veto refusals.
"organization-instance-referenced": "the instance is launch.by or an arbiter; change that reference first",
"organization-instance-kept": "the instance is kept; run `mosaic business veto BUSINESS unkeep INSTANCE` first",
"organization-instance-retired": "that name was deleted and stays retired; pick another name",
"instance-running": "the instance has a run; end it with `mosaic business instance terminate` first",
"instance-starting": "a request for the instance has not closed yet; wait for it and try again",
"instance-retired": "that name was deleted and launches no more",
"not-thread-launch": "a thread instance starts on a thread message, not by launch",
"regenerate-thread": "a thread instance isn't regenerated; its context is the thread's",
"not-held": "the instance has no run and no open request",
"launch-revoked": "launches are revoked for the business; `mosaic launches on --business BUSINESS` lifts it",
"launches-disabled": "the bus host was started with --no-launch",
"request-expired": "the launcher didn't take the request within its 60 s",
"regenerate-timeout": "the old run didn't exit within the request's 60 s, so no new run started; it still runs",
"workspace-rename-failed": "the business file is changed and the name retired, but the workspace stayed where it was; " +
"run the same delete again to retry the rename",
"capacity-full": "a launch limit is reached; `mosaic limits --business BUSINESS` shows them",
"limit-reached": "a launch limit is reached; `mosaic limits --business BUSINESS` shows them",
"unknown-role": "the business has no such instance",
};
const LIFECYCLE_OPS = ["launch", "terminate", "regenerate", "delete"];
const VETO_OPS = ["keep", "unkeep", "nohire", "hire"];
const WAIT_SECONDS = 30;
const POLL_MS = 200;
export const RESTART_NEEDED = "restart needed: the running bus keeps the roles it loaded at start; this command does not restart it";
const NO_RESTART = "no restart: the launcher reads the change at its next launch check";
@@ -66,7 +35,7 @@ function parse(args) {
if (["--json", "--launch", "--no-launch", "--tester"].includes(arg)) {
if (out.flags.has(arg)) throw new CliError(INSTANCES_USAGE, 4);
out.flags.add(arg);
} else if (["--definition", "--vars", "--revision", "--wait", "--reason"].includes(arg)) {
} else if (["--definition", "--vars", "--revision"].includes(arg)) {
if (!args[i + 1] || Object.hasOwn(out.values, arg)) throw new CliError(INSTANCES_USAGE, 4);
out.values[arg] = args[++i];
} else if (arg.startsWith("--")) throw new CliError(INSTANCES_USAGE, 4);
@@ -79,7 +48,6 @@ function parse(args) {
function setArgs(parsed) {
const [, op, , instance] = parsed.positional;
const { values, flags } = parsed;
if (values["--wait"] !== undefined || values["--reason"] !== undefined) throw new CliError(INSTANCES_USAGE, 4);
const launch = flags.has("--launch") ? true : flags.has("--no-launch") ? false : undefined;
if (parsed.positional.length !== 4 || !["add", "set"].includes(op) || !values["--revision"] || flags.has("--json") ||
(flags.has("--launch") && flags.has("--no-launch")) || (op === "add" && (!values["--definition"] || launch === false)) ||
@@ -99,89 +67,10 @@ function setArgs(parsed) {
return args;
}
// Row 194: the lifecycle and veto args, or null when `instance OP` is add or set. Usage errors exit 4.
function lifecycleArgs(parsed) {
const { positional, values, flags } = parsed;
const [sub, op, , name] = positional;
const only = (allowed) => Object.keys(values).every((k) => allowed.includes(k)) && [...flags].every((f) => allowed.includes(f));
let wait = WAIT_SECONDS;
if (values["--wait"] !== undefined) {
if (!/^[0-9]{1,3}$/.test(values["--wait"]) || Number(values["--wait"]) > 600) throw new CliError(INSTANCES_USAGE, 4);
wait = Number(values["--wait"]);
}
if (sub === "veto") {
const [, , vop, target] = positional;
if (positional.length !== 4 || !VETO_OPS.includes(vop) || !values["--revision"] || !only(["--revision"])) throw new CliError(INSTANCES_USAGE, 4);
return { verb: "veto.set", args: { revision: values["--revision"], op: vop, name: target } };
}
if (!LIFECYCLE_OPS.includes(op)) return null;
if (positional.length !== 4) throw new CliError(INSTANCES_USAGE, 4);
if (op === "terminate") {
if (!only(["--reason", "--tester"])) throw new CliError(INSTANCES_USAGE, 4);
return { verb: "instance.end", args: { instance: name, ...(values["--reason"] !== undefined ? { reason: values["--reason"] } : {}) } };
}
if (op === "delete") {
if (!values["--revision"] || !only(["--revision", "--wait"])) throw new CliError(INSTANCES_USAGE, 4);
return { verb: "instances.set", args: { revision: values["--revision"], instance: name, op: "delete" }, wait };
}
if (!only(["--wait", "--tester"])) throw new CliError(INSTANCES_USAGE, 4);
return { verb: `instance.${op}`, args: { instance: name }, wait };
}
// D1.1 item 5: polls instance.request until the request closes or `wait` seconds pass. A closed
// request with a refusal or a cancel is exit 3 (refused) with its code. Still open or taken at the
// end is exit 3 with the request id, as the design's timeout is: a taken request with no done is
// outcome-unknown.
async function follow(call, request, wait) {
const end = Date.now() + wait * 1000;
for (;;) {
const r = await call("instance.request", { request });
if (r.state === "closed") {
if (r.cancelled) throw refusal("request-cancelled", `cancelled by ${r.cancelled.by} (request ${request})`);
if (r.done?.refusal) {
const kept = r.done.transaction ? `, transaction ${r.done.transaction}` : "";
throw refusal(r.done.refusal, `${REASONS[r.done.refusal] ?? "the launcher refused the request"} (request ${request}${kept})`);
}
return r;
}
if (Date.now() >= end) {
const error = r.state === "taken"
? new CliError(`outcome-unknown: request ${request} was taken and has not closed after ${wait} s; the launch log names any run it started`, 3)
: new CliError(`request ${request} is still open after ${wait} s; the launcher takes it within its 60 s or closes it request-expired`, 3);
error.code = r.state === "taken" ? "outcome-unknown" : "request-open";
error.final = true;
throw error;
}
await new Promise((resolve) => setTimeout(resolve, Math.min(POLL_MS, Math.max(0, end - Date.now()))));
}
}
// `final`: the catch below passes it on as it is, with its request id.
function refusal(code, why) {
const error = new CliError(`${code}: ${why}`, 3);
error.code = code;
error.final = true;
return error;
}
function lifecycleLine(business, op, instance, r) {
if (op === "delete") return `business ${business}: deleted ${instance}, now retired\ntransaction ${r.done.transaction}\n`;
return `business ${business}: ${op === "launch" ? "launched" : "regenerated"} ${instance}, run ${r.done.run} (request ${r.request})\n`;
}
function terminateLines(business, instance, out) {
const lines = [`business ${business}: ended ${instance} (request ${out.request})`];
lines.push(out.runs.length ? `runs ${out.runs.join(", ")}: claims revoked, the launcher signals each` : "no run held a claim");
if (out.cancelled.length) lines.push(`cancelled ${out.cancelled.join(", ")}`);
if (out.taken.length) lines.push(`taken ${out.taken.join(", ")}: past cancelling; a run it starts is ended by the launcher's pass, or by a second terminate`);
return `${lines.join("\n")}\n`;
}
export function formatInstances(view) {
const lines = [`business ${view.business}, revision ${view.revision}`];
for (const [name, entry] of Object.entries(view.instances)) {
const marks = [entry.launcher && "launcher", entry.launched && "launched", entry.protected && "protected", entry.kept && "kept",
entry.running?.length && `running ${entry.running.join(", ")}`].filter(Boolean);
const marks = [entry.launcher && "launcher", entry.launched && "launched", entry.protected && "protected"].filter(Boolean);
const vars = Object.keys(entry.vars).length ? ` vars ${JSON.stringify(entry.vars)}` : "";
lines.push(` ${name}: ${entry.definition}${marks.length ? ` (${marks.join(", ")})` : ""}${vars}`);
}
@@ -190,8 +79,6 @@ export function formatInstances(view) {
const bound = definitions.filter(([, d]) => d.services.length).map(([name, d]) => `${name} (${d.services.join(", ")})`);
lines.push(`definitions add and set accept: ${accepted.join(", ") || "none"}`);
if (bound.length) lines.push(`definitions that declare credentials, a hand edit: ${bound.join(", ")}`);
if (view.retired?.length) lines.push(`retired: ${view.retired.join(", ")}`);
if (view.noHire?.length) lines.push(`the chief may not hire: ${view.noHire.join(", ")}`);
return `${lines.join("\n")}\n`;
}
@@ -199,29 +86,16 @@ export function formatInstances(view) {
export async function instancesCommand(args, io, callFor) {
const parsed = parse(args);
const sub = parsed.positional[0];
const business = parsed.positional[sub === "instance" ? 2 : 1];
if (!["instances", "instance", "veto"].includes(sub) || !business) throw new CliError(INSTANCES_USAGE, 4);
const business = parsed.positional[sub === "instances" ? 1 : 2];
if (!["instances", "instance"].includes(sub) || !business) throw new CliError(INSTANCES_USAGE, 4);
let request;
const lifecycle = sub === "instances" ? null : lifecycleArgs(parsed);
if (lifecycle) {
// veto and the lifecycle verbs, below.
} else if (sub === "instances") {
if (sub === "instances") {
if (parsed.positional.length !== 2 || Object.keys(parsed.values).length || parsed.flags.has("--launch") || parsed.flags.has("--no-launch")) {
throw new CliError(INSTANCES_USAGE, 4);
}
} else request = setArgs(parsed);
try {
const call = callFor({ positional: parsed.positional, flags: parsed.flags, values: { "--business": business } });
if (lifecycle) {
const out = await call(lifecycle.verb, lifecycle.args);
if (sub === "veto") {
return io.stdout.write(`business ${out.business}: veto ${lifecycle.args.op} ${lifecycle.args.name}\nrevision ${out.revision}\ntransaction ${out.transaction}\n`);
}
const op = parsed.positional[1];
if (op === "terminate") return io.stdout.write(terminateLines(business, lifecycle.args.instance, out));
io.stderr.write(`mosaic: request ${out.request}, waiting up to ${lifecycle.wait} s\n`);
return io.stdout.write(lifecycleLine(business, op, lifecycle.args.instance, await follow(call, out.request, lifecycle.wait)));
}
if (!request) {
const view = await call("instances", {});
return io.stdout.write(parsed.flags.has("--json") ? `${JSON.stringify(view, null, 2)}\n` : formatInstances(view));
@@ -235,7 +109,6 @@ export async function instancesCommand(args, io, callFor) {
result.restart ? RESTART_NEEDED : NO_RESTART,
].join("\n") + "\n");
} catch (error) {
if (error.final) throw error;
const code = error.code;
if (Object.hasOwn(REASONS, code)) {
const refused = new CliError(`${code}: ${REASONS[code]}`, error.exitCode ?? 2);
+13 -167
View File
@@ -95,30 +95,10 @@
//
// Nothing here is persisted. After a restart recover() ends the earlier
// host's runs, so the first tick starts each holder.
//
// Row 194 (#1692, D1.1): the lifecycle pass, on the same tick, carries out the
// human's and the tester's requests the broker recorded (launch, end,
// regenerate, delete). The broker never starts a process or touches a
// workspace. Under serialize, each open request is taken (host op
// lifecycleTake, which re-checks it and closes it with a code when it may not
// run), then acted on, then closed with lifecycleDone. A taken request never
// runs again, whatever happens after the take. A done that throws leaves it
// taken with no done, which the CLI reports as outcome-unknown.
// - launch: check() and start() with `human`, so an unlisted instance runs.
// - end: SIGTERM to each named run's runner after the start-time check, and
// to any other run of the instance in the registry. The exit handler
// records the end. No SIGKILL here (row 191, S6).
// - regenerate: the end's signals, then a new run once the old one has
// left the registry. Not gone by the request's 60 s mark closes it
// regenerate-timeout and starts nothing.
// - delete: instance-running against the registry, then lifecycleDelete
// (the writer's commit), then the rename of workspaces/<b>/I/ to
// workspaces/<b>/.retired/I-<transaction>/. A failed rename closes it
// workspace-rename-failed with the transaction; a later delete retries it.
import { createServer } from "node:net";
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync } from "node:fs";
import { dirname, join } from "node:path";
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, rmSync } from "node:fs";
import { join } from "node:path";
import { configDir, loadBusiness, resolveInstance, systemVars } from "../../business/src/index.mjs";
import { limitHit } from "../../business/src/launch.mjs";
import { BundleError, buildBundle, hasFallback, readSkills, sessionModel } from "../../harness/src/bundle.mjs";
@@ -153,8 +133,6 @@ const LINE_MAX = 4096;
const THREAD_TICK_MS = 2000;
// The broker's thread instance definition (packages/bus/src/broker.mjs).
const THREAD_DEFINITION = "assistant";
// Row 194: the broker's LIFECYCLE_REQUEST_MS. A regenerate's old run must be gone by then.
const LIFECYCLE_REQUEST_MS = 60000;
// A refusal the host decided; `code` goes back to the caller and to the broker.
class Refusal extends Error {
@@ -176,14 +154,13 @@ function call(name, what, fn) {
// host: startHost()'s handle. tracker: true when the broker has task verbs for
// the business. launches: false for `bus start --no-launch` (row 163, the D2
// rehearsal): start() refuses launches-disabled, the ticker runs only the
// lifecycle pass (row 194), which closes each request launches-disabled, a
// rehearsal): start() refuses launches-disabled, listen() starts no ticker, a
// session's launch request is refused, recover() kills and ends nothing, and
// sessions.json stays as it was.
// makeLoader (the plug-in loader's factory, which tests point at a
// scratch tree), bwrap, readOnly, sessionDefaults, harnessVersion,
// stopTimeoutMs, threadTickMs, lifecycleRequestMs and git (createGitSocket
// options) exist for the tests; the command line never sets them.
// stopTimeoutMs, threadTickMs and git (createGitSocket options) exist for
// the tests; the command line never sets them.
export function createLauncher({
host,
system,
@@ -197,7 +174,6 @@ export function createLauncher({
harnessVersion = undefined,
stopTimeoutMs = STOP_TIMEOUT_MS,
threadTickMs = THREAD_TICK_MS,
lifecycleRequestMs = LIFECYCLE_REQUEST_MS,
git = {},
log = (l) => process.stderr.write(`mosaic-bus: ${l}\n`),
}) {
@@ -233,7 +209,6 @@ export function createLauncher({
const refusedLand = new Map();
const refusedRelease = new Map();
let refusedHolders = null;
let refusedRequests = null;
const sockets = new Set();
const gitSocket = createGitSocket({ host, business: b, dataRoot, env, log, ...git });
@@ -253,13 +228,11 @@ export function createLauncher({
// Steps 2 to 9. Returns what the launch needs. Only `--pm` may name
// launch.by itself. A thread instance launches only for a thread, and
// nothing else launches for one. Row 194 (#1692, V2): `human` is the
// human's launch request, which may name any declared instance. The listing
// limits the chief, not the human. launch.by still takes the `pm` branch.
async function check(instance, { pm = false, thread = null, human = false } = {}) {
// nothing else launches for one.
async function check(instance, { pm = false, thread = null } = {}) {
try { b=loadBusiness(host.business,{dir:configDir(env),rolesDir:rolesDir(env)}); } catch(e){throw new Refusal("organization-refused",e.message);}
if (!b.launch) throw new Refusal("launch-not-configured", `business ${b.id} has no launch block`);
if (!(pm ? instance === b.launch.by : b.launch.instances.includes(instance) || (human && Object.hasOwn(b.roles, instance)))) throw new Refusal("instance-not-listed");
if (!(pm ? instance === b.launch.by : b.launch.instances.includes(instance))) throw new Refusal("instance-not-listed");
const threaded = b.roles[instance]?.definition === THREAD_DEFINITION;
if (threaded && !thread) throw new Refusal("thread-required", `${instance} runs only for a thread`);
if (!threaded && thread) throw new Refusal("thread-not-allowed", `${instance} isn't a thread instance`);
@@ -354,7 +327,7 @@ export function createLauncher({
}
// Bundle, spawn, bind, register, hand over the capability.
async function start({ instance, launchedBy, resolved, model, account = null, thread = null, human = false }) {
async function start({ instance, launchedBy, resolved, model, account = null, thread = null }) {
// Every launch path ends here, so a --no-launch host spawns nothing whichever path asked.
if (!launches) throw new Refusal("launches-disabled", "this host was started with --no-launch");
// A thread's store is checked first, so a bad id or an unsafe directory
@@ -387,7 +360,7 @@ export function createLauncher({
else if (c.outcome === "fetched") record({ event: "checkout-fetched", instance, project: c.project, reason: c.reason });
}
let harness, route, routeReason;
({resolved,model,account,harness,route,routeReason}=await check(instance,{pm:instance===b.launch.by,thread,human}));
({resolved,model,account,harness,route,routeReason}=await check(instance,{pm:instance===b.launch.by,thread}));
const { run, runDir } = newRun(dataRoot, b.id);
const sessionDir = store ? store.session : join(runDir, "session");
if (!store) mkdirSync(sessionDir, { mode: 0o700 });
@@ -516,12 +489,8 @@ export function createLauncher({
if (ticking || closed) return;
ticking = true;
try {
// A --no-launch host runs only the lifecycle pass, which closes each request launches-disabled.
if (launches) {
await threadPass();
await landPass();
}
await lifecyclePass();
await threadPass();
await landPass();
} finally {
ticking = false;
}
@@ -641,129 +610,6 @@ export function createLauncher({
}
}
// Row 194 (#1692, D1.1): the human's and the tester's lifecycle requests, oldest first.
async function lifecyclePass() {
let requests;
try {
requests = await host.op({ op: "lifecycleRequests", business: b.id });
if (!Array.isArray(requests)) throw new Refusal("malformed-answer");
refusedRequests = null;
} catch (e) {
const code = e.code ?? e.message;
if (refusedRequests !== code) log(`lifecycleRequests refused: ${code}`);
refusedRequests = code;
return;
}
for (const r of requests) {
if (closed) return;
// A taken request is past this pass: it ran, or its done failed.
if (r.state !== "open") continue;
await serialize(() => carry(r));
}
}
// One request: the take, the act, the done. Runs under serialize, so no start() runs alongside it.
async function carry(r) {
if (closed) return;
let take;
try {
take = await host.op({ op: "lifecycleTake", record: { business: b.id, request: r.request } });
} catch (e) {
log(`request ${r.request} (${r.instance}): lifecycleTake refused: ${e.code ?? e.message}`);
return;
}
if (take?.go !== true) {
if (take?.refusal && take.refusal !== "request-closed") record({ event: "refused", by: r.by, instance: r.instance, request: r.request, code: take.refusal });
return;
}
let done;
try {
done = await act(take);
} catch (e) {
done = { refusal: e instanceof Refusal ? e.code : "launch-failed", why: e.message, limit: e.limit };
}
const { why, limit, ...closing } = done;
if (closing.refusal) record({ event: "refused", by: r.by, instance: r.instance, request: r.request, code: closing.refusal, ...(why ? { why } : {}), ...(limit ? { limit } : {}) });
try {
await host.op({ op: "lifecycleDone", record: { business: b.id, request: r.request, ...closing } });
} catch (e) {
log(`request ${r.request} (${r.instance}): lifecycleDone refused: ${e.code ?? e.message}${closing.run ? `; run ${closing.run} started` : ""}`);
}
}
// What the request did: { run } for a start, { transaction } for a delete, {} for an end, or { refusal }.
async function act(r) {
// Row 163: a --no-launch host starts, signals and renames nothing.
if (!launches) return { refusal: "launches-disabled" };
const launchedBy = r.by === "tester" ? "tester" : b.human;
if (r.action === "lifecycle.launch") return launch(r.instance, launchedBy);
if (r.action === "lifecycle.end") {
terminate(r.instance, r.runs ?? []);
return {};
}
if (r.action === "lifecycle.regenerate") {
terminate(r.instance, []);
const deadline = Date.parse(r.at) + lifecycleRequestMs;
while (registry.sessions.some((s) => s.business === b.id && s.instance === r.instance)) {
if (closed) return { refusal: "host-closing" };
if (Date.now() >= deadline) return { refusal: "regenerate-timeout" };
await new Promise((resolve) => setTimeout(resolve, 50));
}
return launch(r.instance, launchedBy);
}
if (r.action === "lifecycle.delete") return remove(r);
return { refusal: "invalid-request" };
}
async function launch(instance, launchedBy) {
const pm = instance === b.launch?.by;
const { run } = await start({ instance, launchedBy, human: true, ...(await check(instance, { pm, human: true })) });
return { run };
}
// SIGTERM, after the start-time check, to the named runs and to every other run of the instance
// the registry holds. A thread instance's end names only the runs it revoked (the tester's own).
function terminate(instance, runs) {
const threaded = b.roles[instance]?.definition === THREAD_DEFINITION;
for (const s of registry.sessions) {
if (s.business !== b.id || !(runs.includes(s.run) || (!threaded && s.instance === instance))) continue;
stopping.add(s.run);
record({ event: "lifecycle-end", instance: s.instance, run: s.run });
try {
if (startTimeOf(s.runnerPid) === s.runnerStartTime) process.kill(s.runnerPid, "SIGTERM");
} catch {}
}
}
// V5 steps 1 to 3. The file goes first: once committed, check() refuses the name, so no launch
// opens the old workspace even if the rename fails.
async function remove(r) {
if (registry.sessions.some((s) => s.business === b.id && s.instance === r.instance)) return { refusal: "instance-running" };
let out;
try {
out = await host.op({ op: "lifecycleDelete", record: { business: b.id, request: r.request } });
} catch (e) {
return { refusal: e.code ?? "outcome-unknown" };
}
const transaction = out?.transaction ?? null;
if (transaction === null) return { refusal: "workspace-rename-failed" };
const from = workspaceDir(dataRoot, b.id, r.instance);
if (!existsSync(from)) return { transaction };
const to = join(dirname(from), ".retired", `${r.instance}-${transaction}`);
try {
mkdirSync(dirname(to), { recursive: true, mode: 0o700 });
chmodSync(dirname(to), 0o700);
if (existsSync(to)) throw new Error(`${to} exists`);
renameSync(from, to);
chmodSync(to, 0o700);
} catch (e) {
log(`request ${r.request} (${r.instance}): workspace rename failed: ${e.message}; left at ${from}`);
return { transaction, refusal: "workspace-rename-failed", why: `${e.message}; left at ${from}` };
}
record({ event: "workspace-retired", instance: r.instance, request: r.request, transaction, path: to });
return { transaction };
}
// endLaunch's answer ({ released, limit? }), or null when it refused.
async function endRun(run, reason, exitCode) {
try {
@@ -917,7 +763,7 @@ export function createLauncher({
});
chmodSync(path, 0o600);
await gitSocket.listen();
if (b.launch) ticker = setInterval(() => tick().catch((e) => log(`thread tick: ${e.message}`)), threadTickMs);
if (b.launch && launches) ticker = setInterval(() => tick().catch((e) => log(`thread tick: ${e.message}`)), threadTickMs);
}
// SIGTERM every runner, wait, then end what's left. Runs before the host
+10 -187
View File
@@ -1,9 +1,6 @@
// Row 165 (#1662, Q171, REQ-ROLE-3): `mosaic business instances` and `mosaic business instance
// add|set` reach the broker's human-only verbs, which write through the business package's
// instance writer. The bus here is a real startBroker over a temporary organization directory.
// Row 194 (#1692, D3): it boots from the file through bootConfig, as the unit does, so a create goes
// live with no restart. A stub boot whose credential references differ from the file still answers
// restart needed.
import test from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
@@ -14,26 +11,20 @@ import { BusError } from "../../bus/src/broker.mjs";
import { startBroker } from "../../bus/src/runtime.mjs";
import { systemFor, writeJson } from "../../business/tests/helpers.mjs";
import { main, run } from "../src/cli.mjs";
import { bootConfig, loadSystem } from "../src/config.mjs";
import { RESTART_NEEDED } from "../src/instances.mjs";
import { fixture, io, tmp } from "./helpers.mjs";
const BUSINESS_CLI = fileURLToPath(new URL("../../business/src/cli.mjs", import.meta.url));
const MOSAIC = fileURLToPath(new URL("../../../scripts/mosaic", import.meta.url));
// hook(verb, args, bus), when given, runs before each call reaches the broker (row 194: a test moves a
// request on as the launcher's pass would).
async function setup(t, { stub = false, hook = null } = {}) {
async function setup(t) {
const root = tmp(t, "mosaic-cli-instances-");
const f = fixture(root, "acme");
const dir = join(root, "config");
const boot = bootConfig({ system: loadSystem({ env: f.env }), businessId: "acme", env: f.env });
const bus = await startBroker({
dataRoot: f.dataRoot,
businesses: stub
? { acme: { id: "acme", human: "jason", arbiters: { technical: "coder", delivery: "coder" }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } } } }
: boot.businesses,
organization: stub ? { dir, rolesDir: f.roles, system: systemFor(root) } : boot.organization,
businesses: { acme: { id: "acme", human: "jason", arbiters: { technical: "coder", delivery: "coder" }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } } } },
organization: { dir, rolesDir: f.roles, system: systemFor(root) },
});
t.after(() => bus.close());
const human = bus.broker.bindHuman({ business: "acme", human: "jason", via: "cli", outsideAgent: true });
@@ -42,7 +33,6 @@ async function setup(t, { stub = false, hook = null } = {}) {
system: { dataRoot: f.dataRoot },
transport: (business) => async (verb, args = {}) => {
calls.push({ business, verb, args });
await hook?.(verb, args, bus);
try {
return structuredClone(bus.broker.request(human, { verb, args }));
} catch (e) {
@@ -64,10 +54,10 @@ async function setup(t, { stub = false, hook = null } = {}) {
return { code, out: x.out.text, err: x.err.text };
};
const revision = async () => JSON.parse((await mosaic(["instances", "acme", "--json"])).out).revision;
return { root, f, file, log, validate, mosaic, revision, calls, bus };
return { root, f, file, log, validate, mosaic, revision, calls };
}
test("row 165 CLI: instance add puts an instance in the file, validate exits 0, the log names the human, and the instance binds with no restart (row 194, T1)", async (t) => {
test("row 165 CLI: instance add puts an instance in the file, validate exits 0, and the log names the human", async (t) => {
const s = await setup(t);
const view = await s.mosaic(["instances", "acme"]);
assert.equal(view.code, 0, view.err);
@@ -81,10 +71,11 @@ test("row 165 CLI: instance add puts an instance in the file, validate exits 0,
const entry = s.log().at(-1);
const now = await s.revision();
assert.deepEqual(added.out.split("\n"), [
"business acme: added sec-1 (security-reviewer, launched)", `revision ${now}`, `transaction ${entry.transaction}`,
"no restart: the launcher reads the change at its next launch check", "",
"business acme: added sec-1 (security-reviewer, launched)", `revision ${now}`, `transaction ${entry.transaction}`, RESTART_NEEDED, "",
]);
assert.ok(s.bus.broker.bindLaunch({ business: "acme", role: "sec-1", run: "run-1", harness: "pi" }), "the running bus binds the new instance");
assert.equal(RESTART_NEEDED, "restart needed: the running bus keeps the roles it loaded at start; this command does not restart it");
assert.doesNotMatch(RESTART_NEEDED, /Mos|Jason/, "product text names no seat (sage-0931)");
assert.doesNotMatch(added.out, /--restart|run `mosaic bus/, "the CLI names the restart and doesn't offer to run it");
const doc = JSON.parse(readFileSync(s.file, "utf8"));
assert.deepEqual(doc.roles["sec-1"], { definition: "security-reviewer" });
assert.ok(doc.launch.instances.includes("sec-1"));
@@ -93,17 +84,6 @@ test("row 165 CLI: instance add puts an instance in the file, validate exits 0,
assert.equal(checked.status, 0, checked.stderr);
});
test("row 194 CLI: a bus whose boot credential references differ from the file answers restart needed, and doesn't bind the instance (D3 item 3)", async (t) => {
const s = await setup(t, { stub: true });
const added = await s.mosaic(["instance", "add", "acme", "sec-1", "--definition", "security-reviewer", "--launch", "--revision", await s.revision()]);
assert.equal(added.code, 0, added.err);
assert.equal(added.out.split("\n").at(-2), RESTART_NEEDED);
assert.equal(RESTART_NEEDED, "restart needed: the running bus keeps the roles it loaded at start; this command does not restart it");
assert.doesNotMatch(RESTART_NEEDED, /Mos|Jason/, "product text names no seat (sage-0931)");
assert.doesNotMatch(added.out, /--restart|run `mosaic bus/, "the CLI names the restart and doesn't offer to run it");
assert.throws(() => s.bus.broker.bindLaunch({ business: "acme", role: "sec-1", run: "run-1", harness: "pi" }), { code: "unknown-role" });
});
test("row 165 CLI: instance set passes the vars file and --no-launch, and a vars-only change needs no restart", async (t) => {
const s = await setup(t);
const vars = writeJson(join(s.root, "vars.json"), { harness: "pi", model: "claude-sonnet-5-5" });
@@ -163,169 +143,12 @@ test("row 165 CLI: usage errors exit 4 before any bus call, and an agent session
await assert.rejects(main(["business", "validate", "acme"], io(s.f.env), {}), /mosaic business instances/);
});
test("row 165: scripts/mosaic sends business instances, instance and (row 194) veto to packages/cli, and validate still to packages/business", (t) => {
test("row 165: scripts/mosaic sends business instances and instance to packages/cli, and validate still to packages/business", (t) => {
const root = tmp(t, "mosaic-cli-route-");
const env = { PATH: process.env.PATH, HOME: join(root, "home"), MOSAIC_CONFIG: join(root, "none.json") };
const routed = spawnSync(MOSAIC, ["business", "instance"], { env, encoding: "utf8" });
assert.equal(routed.status, 4);
assert.match(routed.stderr, /mosaic business instances BUSINESS/);
// Row 194: veto goes to packages/cli too.
const veto = spawnSync(MOSAIC, ["business", "veto"], { env, encoding: "utf8" });
assert.equal(veto.status, 4);
assert.match(veto.stderr, /\| business veto BUSINESS keep\|unkeep INSTANCE/);
const business = spawnSync(MOSAIC, ["business", "validate"], { env, encoding: "utf8" });
assert.doesNotMatch(business.stderr, /mosaic business instances BUSINESS/);
});
test("row 194 CLI V6: veto keep, unkeep, nohire and hire write through veto.set, the read shows kept and noHire, and a no-op refuses", async (t) => {
const s = await setup(t);
const kept = await s.mosaic(["veto", "acme", "keep", "coder", "--revision", await s.revision()]);
assert.equal(kept.code, 0, kept.err);
assert.deepEqual(s.calls.at(-1), { business: "acme", verb: "veto.set", args: { revision: s.calls.at(-1).args.revision, op: "keep", name: "coder" } });
const entry = s.log().at(-1);
assert.deepEqual(kept.out.split("\n"), ["business acme: veto keep coder", `revision ${await s.revision()}`, `transaction ${entry.transaction}`, ""]);
assert.equal(entry.actor, "jason");
const nohire = await s.mosaic(["veto", "acme", "nohire", "merge-gate", "--revision", await s.revision()]);
assert.equal(nohire.code, 0, nohire.err);
const view = await s.mosaic(["instances", "acme"]);
assert.match(view.out, /^ {2}coder: coder \(launched, kept\)/m);
assert.match(view.out, /^the chief may not hire: merge-gate$/m);
const json = JSON.parse((await s.mosaic(["instances", "acme", "--json"])).out);
assert.deepEqual([json.instances.coder.kept, json.noHire, json.retired], [true, ["merge-gate"], []]);
// A kept instance refuses delete at the verb.
const del = await s.mosaic(["instance", "delete", "acme", "coder", "--revision", await s.revision()]);
assert.equal(del.code, 2);
assert.match(del.err, /^mosaic: organization-instance-kept: the instance is kept; run `mosaic business veto BUSINESS unkeep INSTANCE` first$/m);
const again = await s.mosaic(["veto", "acme", "keep", "coder", "--revision", await s.revision()]);
assert.equal(again.code, 2);
assert.match(again.err, /^mosaic: organization-no-change: /);
for (const [op, name] of [["unkeep", "coder"], ["hire", "merge-gate"]]) {
const r = await s.mosaic(["veto", "acme", op, name, "--revision", await s.revision()]);
assert.equal(r.code, 0, r.err);
}
const after = JSON.parse(readFileSync(s.file, "utf8"));
assert.deepEqual([after.launch.keep, after.launch.noHire], [[], []]);
});
test("row 194 CLI: the read lists each instance's running run from the bus claims", async (t) => {
const s = await setup(t);
const cap = s.bus.broker.bindLaunch({ business: "acme", role: "coder", run: "run-c1", harness: "pi" });
s.bus.broker.request(cap, { verb: "role.claim", args: {} });
const json = JSON.parse((await s.mosaic(["instances", "acme", "--json"])).out);
assert.deepEqual([json.instances.coder.running, json.instances.reviewer.running], [["run-c1"], []]);
assert.match((await s.mosaic(["instances", "acme"])).out, /^ {2}coder: coder \(launched, running run-c1\)/m);
});
// The launcher's pass, played by the test: on the first poll the request is taken, then closed with `done`.
const closing = (done) => (verb, args, bus) => {
if (verb !== "instance.request") return;
if (bus.lifecycleTake({ business: "acme", request: args.request }).go && done) bus.lifecycleDone({ business: "acme", request: args.request, ...done(bus, args.request) });
};
test("row 194 CLI D1.1 item 5: launch and regenerate poll instance.request and print the run; a done with a refusal exits 3 with its code and the request id", async (t) => {
let done = () => ({ run: "run-new" });
const s = await setup(t, { hook: closing((bus, request) => done(bus, request)) });
const launched = await s.mosaic(["instance", "launch", "acme", "reviewer"]);
assert.equal(launched.code, 0, launched.err);
const request = s.calls.find((c) => c.verb === "instance.request").args.request;
assert.deepEqual(s.calls.find((c) => c.verb === "instance.launch").args, { instance: "reviewer" });
assert.equal(launched.out, `business acme: launched reviewer, run run-new (request ${request})\n`);
assert.match(launched.err, new RegExp(`^mosaic: request ${request}, waiting up to 30 s$`, "m"));
const regen = await s.mosaic(["instance", "regenerate", "acme", "reviewer"]);
assert.equal(regen.code, 0, regen.err);
assert.match(regen.out, /^business acme: regenerated reviewer, run run-new \(request [^)]+\)$/m);
done = () => ({ refusal: "capacity-full" });
const full = await s.mosaic(["instance", "launch", "acme", "reviewer"]);
assert.equal(full.code, 3);
const id = s.calls.filter((c) => c.verb === "instance.request").at(-1).args.request;
assert.match(full.err, new RegExp(`^mosaic: capacity-full: a launch limit is reached; .* \\(request ${id}\\)$`, "m"));
done = () => ({ refusal: "some-new-code" });
const other = await s.mosaic(["instance", "launch", "acme", "reviewer"]);
assert.equal(other.code, 3);
assert.match(other.err, /^mosaic: some-new-code: the launcher refused the request \(request [^)]+\)$/m);
});
test("row 194 CLI D1.1 item 5: an open request at --wait exits 3 with its id, and a taken one with no done is outcome-unknown", async (t) => {
const s = await setup(t);
const open = await s.mosaic(["instance", "launch", "acme", "reviewer", "--wait", "0"]);
assert.equal(open.code, 3);
const request = s.calls.find((c) => c.verb === "instance.request").args.request;
assert.match(open.err, new RegExp(`^mosaic: request ${request} is still open after 0 s; the launcher takes it within its 60 s or closes it request-expired$`, "m"));
const t2 = await setup(t, { hook: closing(null) });
const taken = await t2.mosaic(["instance", "launch", "acme", "reviewer", "--wait", "1"]);
assert.equal(taken.code, 3);
const id = t2.calls.find((c) => c.verb === "instance.request").args.request;
assert.match(taken.err, new RegExp(`^mosaic: outcome-unknown: request ${id} was taken and has not closed after 1 s; the launch log names any run it started$`, "m"));
assert.ok(t2.calls.filter((c) => c.verb === "instance.request").length > 1, "it polled until --wait ran out");
});
test("row 194 CLI: a request cancelled by a terminate exits 3 request-cancelled", async (t) => {
const s = await setup(t, { hook: (verb, args, bus) => {
if (verb === "instance.request") bus.broker.request(bus.broker.bindHuman({ business: "acme", human: "jason", via: "cli", outsideAgent: true }), { verb: "instance.end", args: { instance: "reviewer" } });
} });
const r = await s.mosaic(["instance", "launch", "acme", "reviewer"]);
assert.equal(r.code, 3);
assert.match(r.err, /^mosaic: request-cancelled: cancelled by jason \(request [^)]+\)$/m);
});
test("row 194 CLI V3: terminate prints the runs, the cancelled and the taken requests, and nothing held refuses not-held", async (t) => {
const s = await setup(t);
const cap = s.bus.broker.bindLaunch({ business: "acme", role: "coder", run: "run-c1", harness: "pi" });
s.bus.broker.request(cap, { verb: "role.claim", args: {} });
const human = s.bus.broker.bindHuman({ business: "acme", human: "jason", via: "cli", outsideAgent: true });
const open = s.bus.broker.request(human, { verb: "instance.launch", args: { instance: "coder" } }).request;
const taken = s.bus.broker.request(human, { verb: "instance.regenerate", args: { instance: "coder" } }).request;
s.bus.lifecycleTake({ business: "acme", request: taken });
const r = await s.mosaic(["instance", "terminate", "acme", "coder", "--reason", "stuck"]);
assert.equal(r.code, 0, r.err);
assert.deepEqual(s.calls.at(-1).args, { instance: "coder", reason: "stuck" });
const lines = r.out.split("\n");
assert.match(lines[0], /^business acme: ended coder \(request [^)]+\)$/);
assert.deepEqual(lines.slice(1), [
"runs run-c1: claims revoked, the launcher signals each", `cancelled ${open}`,
`taken ${taken}: past cancelling; a run it starts is ended by the launcher's pass, or by a second terminate`, "",
]);
const none = await s.mosaic(["instance", "terminate", "acme", "reviewer"]);
assert.equal(none.code, 2);
assert.match(none.err, /^mosaic: not-held: the instance has no run and no open request$/m);
});
test("row 194 CLI V5: delete polls to the transaction and the read lists the name retired; workspace-rename-failed names the transaction", async (t) => {
let rename = null;
const s = await setup(t, { hook: closing((bus, request) => ({ transaction: bus.lifecycleDelete({ business: "acme", request }).transaction, ...(rename ? { refusal: rename } : {}) })) });
for (const name of ["sec-1", "sec-2"]) {
const added = await s.mosaic(["instance", "add", "acme", name, "--definition", "security-reviewer", "--revision", await s.revision()]);
assert.equal(added.code, 0, added.err);
}
const del = await s.mosaic(["instance", "delete", "acme", "sec-1", "--revision", await s.revision()]);
assert.equal(del.code, 0, del.err);
const entry = s.log().at(-1);
assert.equal(del.out, `business acme: deleted sec-1, now retired\ntransaction ${entry.transaction}\n`);
assert.deepEqual(s.calls.find((c) => c.verb === "instances.set" && c.args.op === "delete").args, { revision: s.calls.find((c) => c.args.op === "delete").args.revision, instance: "sec-1", op: "delete" });
assert.match((await s.mosaic(["instances", "acme"])).out, /^retired: sec-1$/m);
rename = "workspace-rename-failed";
const failed = await s.mosaic(["instance", "delete", "acme", "sec-2", "--revision", await s.revision()]);
assert.equal(failed.code, 3);
assert.match(failed.err, new RegExp(`^mosaic: workspace-rename-failed: .*run the same delete again to retry the rename \\(request [^,]+, transaction ${s.log().at(-1).transaction}\\)$`, "m"));
});
test("row 194 CLI: lifecycle and veto usage errors exit 4 before any bus call", async (t) => {
const s = await setup(t);
const rev = "a".repeat(64);
for (const args of [
["instance", "launch", "acme"], ["instance", "launch", "acme", "coder", "extra"],
["instance", "launch", "acme", "coder", "--wait", "x"], ["instance", "launch", "acme", "coder", "--wait", "601"],
["instance", "launch", "acme", "coder", "--revision", rev], ["instance", "launch", "acme", "coder", "--reason", "x"],
["instance", "terminate", "acme", "coder", "--wait", "5"], ["instance", "terminate", "acme", "coder", "--revision", rev],
["instance", "delete", "acme", "coder"], ["instance", "delete", "acme", "coder", "--revision", rev, "--tester"],
["instance", "delete", "acme", "coder", "--revision", rev, "--reason", "x"],
["instance", "add", "acme", "sec-1", "--definition", "security-reviewer", "--revision", rev, "--wait", "5"],
["veto", "acme", "keep", "coder"], ["veto", "acme", "drop", "coder", "--revision", rev],
["veto", "acme", "keep", "--revision", rev], ["veto", "acme", "keep", "coder", "--revision", rev, "--tester"],
["veto", "acme", "keep", "coder", "--revision", rev, "--json"], ["veto"],
]) {
const r = await s.mosaic(args);
assert.equal(r.code, 4, args.join(" "));
}
assert.equal(s.calls.length, 0);
});
-549
View File
@@ -1,549 +0,0 @@
// Row 194 (#1692, D1.1, T2-T5, V5): the launcher half of the lifecycle verbs. The broker records
// the human's launch, end, regenerate and delete as requests (bus lifecycle-verbs.test.mjs), and
// the launcher's lifecycle pass takes each one, acts on it and closes it. Real runners in the bwrap
// sandbox on the fake adapter.
//
// The host is in process, as in thread-launch.test.mjs: the real host proves a human CLI process
// for every human verb, which a test run under an agent can't. The tick interval is long and each
// test calls tick() itself, so a request waits until the test says.
import { test } from "node:test";
import assert from "node:assert/strict";
import { spawn, spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { launchDoc } from "../../business/tests/helpers.mjs";
import { BusError } from "../../bus/src/broker.mjs";
import { startBroker } from "../../bus/src/runtime.mjs";
import { gitEnv } from "../../seat/src/checkout.mjs";
import { sandboxProblem } from "../../seat/src/sandbox.mjs";
import { launchLogFile, readSessions, SESSION_CREDENTIALS, workspaceDir } from "../../seat/src/session.mjs";
import { run } from "../src/cli.mjs";
import { bootConfig, loadSystem } from "../src/config.mjs";
import { startTimeOf } from "../src/host.mjs";
import { createLauncher } from "../src/launcher.mjs";
import { fakeHarnesses, fixture, io, tmp } from "./helpers.mjs";
const FAKE = fileURLToPath(new URL("../../harness/tests/fixtures/fake-adapter.mjs", import.meta.url));
const skip = sandboxProblem() ?? false;
async function until(fn, ms = 15000) {
const end = Date.now() + ms;
while (Date.now() < end) {
const v = await fn();
if (v) return v;
await new Promise((r) => setTimeout(r, 50));
}
throw new Error("timed out waiting");
}
function gitIn(cwd, ...args) {
const r = spawnSync("git", ["-c", "user.name=test", "-c", "[email protected]", "-C", cwd, ...args], { env: gitEnv(), encoding: "utf8" });
assert.equal(r.status, 0, r.stderr);
}
// chief opus, coder and reviewer sonnet, at most one sonnet run. Only coder is listed, so reviewer
// is an instance the chief may not launch and the human may. sec-1 holds no credential, so the
// organization writer lets the human delete it. assistant is the thread instance.
function prepare(t, edit = (doc) => doc) {
const root = tmp(t, "mosaic-life-");
const f = fixture(root, "acme", (doc) => {
doc.roles.chief.vars = { harness: "pi", model: "claude-opus-5-5", thinking: "high" };
doc.roles.coder.vars.model = "claude-sonnet-5-5";
doc.roles.reviewer.vars = { harness: "pi", model: "claude-sonnet-5-5", thinking: "high" };
doc.roles["sec-1"] = { definition: "security-reviewer", vars: { harness: "pi", model: "claude-sonnet-5-5", thinking: "high" } };
doc.roles.assistant = { definition: "assistant", vars: { harness: "pi", model: "claude-sonnet-5-5", thinking: "high" } };
doc.launch = launchDoc("chief", ["coder"], { opus: 1, sonnet: 1 });
return edit(doc);
});
for (const k of SESSION_CREDENTIALS.flat()) delete f.env[k];
const project = f.doc.projects.stack.root;
gitIn(project, "init", "-q", "-b", "main");
writeFileSync(join(project, "file.txt"), "one\n");
gitIn(project, "add", "file.txt");
gitIn(project, "commit", "-q", "-m", "one");
const adapter = join(root, "adapter.sh");
writeFileSync(adapter, `exec '${process.execPath}' '${FAKE}'\n`, { mode: 0o644 });
return { root, f, adapter };
}
async function start(t, { f, adapter }, options = {}) {
const system = loadSystem({ env: f.env });
const { trackers, ...boot } = bootConfig({ system, businessId: "acme", env: f.env });
assert.equal(trackers, undefined);
const runtime = await startBroker(boot);
// The real host makes bus-host/ (0700). A --no-launch launcher's read-only registry doesn't.
mkdirSync(join(f.dataRoot, "bus-host"), { recursive: true, mode: 0o700 });
const ops = {
identity: (m) => runtime.identity(m.cap),
authorizeLaunch: (m) => runtime.authorizeLaunch(m.cap, m.instance),
refuse: (m) => runtime.refuse(m.cap, m.code, m.subject),
endLaunch: (m) => runtime.endLaunch(m.record),
credentialStatus: (m) => runtime.credentialStatus(m.business, m.role),
threadsState: (m) => runtime.threadsState(m.business),
threadIdle: (m) => runtime.threadIdle(m.record),
mayLaunch: (m) => runtime.mayLaunch(m),
landHeld: (m) => runtime.landHeld(m.record),
landHolders: (m) => runtime.landHolders(m.business),
landRelease: (m) => runtime.landRelease(m.record),
lifecycleRequests: (m) => runtime.lifecycleRequests(m.business),
lifecycleTake: (m) => runtime.lifecycleTake(m.record),
lifecycleDelete: (m) => runtime.lifecycleDelete(m.record),
lifecycleDone: (m) => runtime.lifecycleDone(m.record),
};
const refusing = (what, fn) => async (m) => {
try {
return fn(m);
} catch (e) {
const code = e instanceof BusError ? e.code : `${what}-refused`;
throw Object.assign(new Error(`${what} refused: ${code}`), { code });
}
};
const hooks = [];
let closing = null;
// A host op named here throws once, as a bus that refused it would.
const faults = new Set();
const host = {
business: "acme",
path: runtime.path,
dataRoot: f.dataRoot,
op: refusing("launch-op", (m) => {
if (!Object.hasOwn(ops, m.op)) throw new BusError("invalid-host-request");
if (faults.delete(m.op)) throw new BusError("storage-refused");
return ops[m.op](m);
}),
bindLaunch: refusing("launch bind", (r) => runtime.bindLaunch(r)),
beforeClose: (fn) => void hooks.push(fn),
};
const logs = [];
const launcher = createLauncher({
host,
system,
env: f.env,
makeLoader: await fakeHarnesses(t, { pi: adapter, "claude-code": adapter }),
sessionDefaults: { pollInterval: 100 },
harnessVersion: "0.85.1",
readOnly: [FAKE],
threadTickMs: 3600000,
stopTimeoutMs: 1000,
log: (l) => logs.push(l),
...options,
});
const close = () =>
(closing ??= (async () => {
for (const fn of hooks) await fn(0);
await runtime.close();
})());
t.after(close);
await launcher.listen();
const human = runtime.broker.bindHuman({ business: "acme", human: f.doc.human, via: "cli", outsideAgent: true });
const call = (verb, args = {}) => runtime.broker.request(human, { verb, args });
const state = (request) => call("instance.request", { request });
// One pass, then the request's state.
const carry = async (request) => {
await launcher.tick();
return state(request);
};
const running = (instance) => launcher.registry.sessions.filter((s) => s.instance === instance);
const claimed = (run) => until(() => {
const file = join(f.dataRoot, "launches", "acme", run, "runner.log");
return existsSync(file) && readFileSync(file, "utf8").includes("claimed by run");
});
// The CLI through the in-process human. Each poll of instance.request runs one pass first, so a
// --wait sees the request closed as the launcher's ticker would close it.
const cli = async (args) => {
const x = io(f.env);
const deps = {
system: { dataRoot: f.dataRoot },
transport: () => async (verb, a = {}) => {
if (verb === "instance.request") await launcher.tick();
try {
return structuredClone(call(verb, a));
} catch (e) {
if (!(e instanceof BusError)) throw e;
throw Object.assign(new Error(e.code), { code: e.code });
}
},
};
const code = await run(["business", ...args], x, deps);
return { code, out: x.out.text, err: x.err.text };
};
return { runtime, launcher, call, state, carry, running, claimed, logs, close, faults, cli };
}
const launchLog = (f) =>
existsSync(launchLogFile(f.dataRoot, "acme"))
? readFileSync(launchLogFile(f.dataRoot, "acme"), "utf8").trim().split("\n").map((l) => JSON.parse(l))
: [];
// Stops a runner from outside its namespace, so it can't act on SIGTERM, and kills it at the end.
function stop(t, s) {
process.kill(s.runnerPid, "SIGSTOP");
t.after(() => {
if (startTimeOf(s.runnerPid) === s.runnerStartTime) process.kill(s.runnerPid, "SIGKILL");
});
}
test("row 194 T2: the human's launch runs an unlisted instance and launch.by through the pm branch, and closes with the run", { skip, timeout: 60000 }, async (t) => {
const p = prepare(t);
const s = await start(t, p);
const { request } = s.call("instance.launch", { instance: "reviewer" });
assert.equal(s.state(request).state, "open");
const done = await s.carry(request);
assert.equal(done.state, "closed");
assert.equal(done.done.refusal, null);
assert.match(done.done.run, /^r[0-9a-f]{12}$/);
const [r] = s.running("reviewer");
assert.equal(r.run, done.done.run);
const session = readSessions(p.f.dataRoot).find((x) => x.run === r.run);
assert.equal(session.launchedBy, "jason");
await s.claimed(r.run);
// launch.by is not on launch.instances. The human's request takes the pm branch, as --pm does.
const chief = s.call("instance.launch", { instance: "chief" }).request;
const c = await s.carry(chief);
assert.equal(c.done.refusal, null);
assert.equal(s.running("chief")[0].run, c.done.run);
// A request is taken once: the next pass starts nothing more.
await s.launcher.tick();
assert.equal(s.running("reviewer").length, 1);
assert.equal(launchLog(p.f).filter((e) => e.event === "launch").length, 2);
});
test("row 194 T2: a request past its 60 s never launches, and a refused check closes the request with its code", { skip, timeout: 60000 }, async (t) => {
const p = prepare(t);
const s = await start(t, p);
const old = s.call("instance.launch", { instance: "coder" }).request;
const now = Date.now();
t.mock.method(Date, "now", () => now + 61000);
const expired = await s.carry(old);
assert.deepEqual([expired.state, expired.done.refusal, expired.done.run], ["closed", "request-expired", null]);
t.mock.restoreAll();
assert.equal(s.running("coder").length, 0);
// A check refusal: the organization file now names a harness that isn't switched on.
const file = join(p.root, "config", "businesses", "acme.json");
const doc = JSON.parse(readFileSync(file, "utf8"));
doc.roles.coder.vars.harness = "claude-code";
doc.plugins.harness = ["pi"];
writeFileSync(file, JSON.stringify(doc, null, 2));
const off = s.call("instance.launch", { instance: "coder" }).request;
const refused = await s.carry(off);
assert.deepEqual([refused.state, refused.done.refusal, refused.done.run], ["closed", "harness-off", null]);
assert.equal(s.running("coder").length, 0);
const entry = launchLog(p.f).find((e) => e.event === "refused" && e.request === off);
assert.deepEqual([entry.instance, entry.code, entry.by], ["coder", "harness-off", "jason"]);
});
test("row 194 T3: a --no-launch host closes each request launches-disabled and starts nothing", { skip, timeout: 60000 }, async (t) => {
const p = prepare(t);
const s = await start(t, p, { launches: false });
const launch = s.call("instance.launch", { instance: "coder" }).request;
const regen = s.call("instance.regenerate", { instance: "reviewer" }).request;
await s.launcher.tick();
for (const request of [launch, regen]) assert.deepEqual([s.state(request).state, s.state(request).done.refusal], ["closed", "launches-disabled"]);
assert.equal(s.launcher.registry.sessions.length, 0);
assert.equal(existsSync(join(p.f.dataRoot, "launches", "acme")), false);
assert.deepEqual(launchLog(p.f).filter((e) => e.event === "launch"), []);
});
test("row 194 T3: a limit refusal closes the request with its code and the limit in the launch log", { skip, timeout: 60000 }, async (t) => {
const p = prepare(t);
const s = await start(t, p);
const first = await s.carry(s.call("instance.launch", { instance: "coder" }).request);
assert.equal(first.done.refusal, null);
// One sonnet run at a time: reviewer runs sonnet too.
const full = s.call("instance.launch", { instance: "reviewer" }).request;
const r = await s.carry(full);
assert.deepEqual([r.state, r.done.refusal, r.done.run], ["closed", "capacity-full", null]);
assert.equal(s.running("reviewer").length, 0);
const entry = launchLog(p.f).find((e) => e.event === "refused" && e.request === full);
assert.equal(entry.code, "capacity-full");
assert.ok(entry.limit, JSON.stringify(entry));
// The instance already running refuses a second launch of itself.
const again = await s.carry(s.call("instance.launch", { instance: "coder" }).request);
assert.equal(again.done.refusal, "instance-running");
});
test("row 194 T4: terminate revokes and cancels at the verb, then the pass signals the run, which ends once", { skip, timeout: 60000 }, async (t) => {
const p = prepare(t);
const s = await start(t, p);
const launched = await s.carry(s.call("instance.launch", { instance: "coder" }).request);
const run = launched.done.run;
await s.claimed(run);
const open = s.call("instance.launch", { instance: "coder" }).request;
const out = s.call("instance.end", { instance: "coder", reason: "stuck" });
assert.deepEqual([out.runs, out.cancelled], [[run], [open]]);
assert.equal(s.state(open).state, "closed");
const end = await s.carry(out.request);
assert.deepEqual([end.state, end.done], ["closed", { run: null, transaction: null, refusal: null }]);
await until(() => s.running("coder").length === 0);
const log = launchLog(p.f);
assert.equal(log.filter((e) => e.event === "lifecycle-end" && e.run === run).length, 1);
await until(() => launchLog(p.f).some((e) => e.event === "end" && e.run === run));
assert.equal(launchLog(p.f).filter((e) => e.event === "end" && e.run === run).length, 1);
// The cancelled request never ran.
await s.launcher.tick();
assert.equal(s.running("coder").length, 0);
});
test("row 194 T5: regenerate ends the run in the registry and starts a new run on the same workspace", { skip, timeout: 60000 }, async (t) => {
const p = prepare(t);
const s = await start(t, p);
const first = (await s.carry(s.call("instance.launch", { instance: "coder" }).request)).done.run;
await s.claimed(first);
const marker = join(workspaceDir(p.f.dataRoot, "acme", "coder"), "kept.txt");
writeFileSync(marker, "kept\n");
const regen = await s.carry(s.call("instance.regenerate", { instance: "coder" }).request);
assert.equal(regen.done.refusal, null);
const second = regen.done.run;
assert.notEqual(second, first);
assert.deepEqual(s.running("coder").map((x) => x.run), [second]);
assert.ok(existsSync(join(p.f.dataRoot, "launches", "acme", second)));
assert.equal(readFileSync(marker, "utf8"), "kept\n");
const log = launchLog(p.f);
assert.equal(log.filter((e) => e.event === "lifecycle-end" && e.run === first).length, 1);
assert.equal(log.filter((e) => e.event === "end" && e.run === first).length, 1);
// A thread instance is never regenerated: the verb refuses before any request.
assert.throws(() => s.call("instance.regenerate", { instance: "assistant" }), (e) => e.code === "regenerate-thread");
});
test("row 194 T5: a run that outlasts the request's mark closes the regenerate regenerate-timeout and starts nothing", { skip, timeout: 60000 }, async (t) => {
const p = prepare(t);
const s = await start(t, p, { lifecycleRequestMs: 1500 });
const first = (await s.carry(s.call("instance.launch", { instance: "coder" }).request)).done.run;
await s.claimed(first);
stop(t, s.running("coder")[0]);
const regen = await s.carry(s.call("instance.regenerate", { instance: "coder" }).request);
assert.deepEqual([regen.state, regen.done.refusal, regen.done.run], ["closed", "regenerate-timeout", null]);
assert.deepEqual(s.running("coder").map((x) => x.run), [first]);
assert.equal(launchLog(p.f).filter((e) => e.event === "launch").length, 1);
});
test("row 194 V5: delete retires the name, renames the workspace to .retired/I-<transaction> at 0700, and a retry renames after workspace-rename-failed", { skip, timeout: 60000 }, async (t) => {
const p = prepare(t);
const s = await start(t, p);
const ws = workspaceDir(p.f.dataRoot, "acme", "sec-1");
mkdirSync(ws, { recursive: true, mode: 0o700 });
writeFileSync(join(ws, "notes.txt"), "mine\n");
// A file where .retired goes makes the rename fail after the commit.
const retired = join(workspaceDir(p.f.dataRoot, "acme", ".retired"));
writeFileSync(retired, "in the way\n");
const revision = () => s.call("instances").revision;
const del = s.call("instances.set", { revision: revision(), instance: "sec-1", op: "delete" }).request;
const failed = await s.carry(del);
assert.equal(failed.done.refusal, "workspace-rename-failed");
const transaction = failed.done.transaction;
assert.ok(transaction);
const file = JSON.parse(readFileSync(join(p.root, "config", "businesses", "acme.json"), "utf8"));
assert.equal(Object.hasOwn(file.roles, "sec-1"), false, "the commit stands");
assert.ok(file.retired.includes("sec-1"));
assert.equal(readFileSync(join(ws, "notes.txt"), "utf8"), "mine\n", "the workspace stays where it was");
// A retired name launches no more, so nothing opens the old workspace before the retry.
assert.throws(() => s.call("instance.launch", { instance: "sec-1" }), (e) => e.code === "instance-retired");
rmSync(retired);
const retry = s.call("instances.set", { revision: revision(), instance: "sec-1", op: "delete" }).request;
assert.equal(s.state(retry).retry, true);
const ok = await s.carry(retry);
assert.deepEqual(ok.done, { run: null, transaction, refusal: null });
const to = join(retired, `sec-1-${transaction}`);
assert.equal(existsSync(ws), false);
assert.equal(readFileSync(join(to, "notes.txt"), "utf8"), "mine\n");
assert.equal(statSync(retired).mode & 0o777, 0o700);
assert.equal(statSync(to).mode & 0o777, 0o700);
const entry = launchLog(p.f).find((e) => e.event === "workspace-retired");
assert.deepEqual([entry.instance, entry.request, entry.transaction, entry.path], ["sec-1", retry, transaction, to]);
});
test("row 194 V5: a delete with no workspace commits and closes with the transaction", { skip, timeout: 60000 }, async (t) => {
const p = prepare(t);
const s = await start(t, p);
const del = s.call("instances.set", { revision: s.call("instances").revision, instance: "sec-1", op: "delete" }).request;
const r = await s.carry(del);
assert.equal(r.done.refusal, null);
assert.ok(r.done.transaction);
assert.equal(existsSync(join(workspaceDir(p.f.dataRoot, "acme", ".retired"))), false);
});
// Every regular file under root, by path, as a sha256. Sockets and other special files are left out.
function snapshot(root) {
const out = {};
const walk = (dir) => {
for (const name of readdirSync(dir).sort()) {
const path = join(dir, name);
const st = lstatSync(path);
if (st.isDirectory()) walk(path);
else if (st.isFile()) out[path.slice(root.length)] = createHash("sha256").update(readFileSync(path)).digest("hex");
}
};
walk(root);
return out;
}
test("row 194 D1.1 item 5: the CLI's launch, regenerate, terminate and delete end to end through the pass, and a closed refusal exits 3 with the request id", { skip, timeout: 90000 }, async (t) => {
const p = prepare(t);
const s = await start(t, p);
const launched = await s.cli(["instance", "launch", "acme", "reviewer"]);
assert.equal(launched.code, 0, launched.err);
const [r] = s.running("reviewer");
assert.match(launched.out, new RegExp(`^business acme: launched reviewer, run ${r.run} \\(request [^)]+\\)\n$`));
await s.claimed(r.run);
const regen = await s.cli(["instance", "regenerate", "acme", "reviewer"]);
assert.equal(regen.code, 0, regen.err);
const [r2] = s.running("reviewer");
assert.notEqual(r2.run, r.run);
assert.match(regen.out, new RegExp(`^business acme: regenerated reviewer, run ${r2.run} \\(request [^)]+\\)\n$`));
await s.claimed(r2.run);
// One sonnet run at a time: the launcher closes coder's request capacity-full.
const full = await s.cli(["instance", "launch", "acme", "coder"]);
assert.equal(full.code, 3);
const refused = launchLog(p.f).find((e) => e.event === "refused" && e.instance === "coder");
assert.match(full.err, new RegExp(`^mosaic: capacity-full: .* \\(request ${refused.request}\\)$`, "m"));
assert.equal(s.running("coder").length, 0);
const ended = await s.cli(["instance", "terminate", "acme", "reviewer", "--reason", "done for today"]);
assert.equal(ended.code, 0, ended.err);
assert.match(ended.out, new RegExp(`^runs ${r2.run}: claims revoked, the launcher signals each$`, "m"));
await s.launcher.tick();
await until(() => s.running("reviewer").length === 0);
const revision = JSON.parse((await s.cli(["instances", "acme", "--json"])).out).revision;
const del = await s.cli(["instance", "delete", "acme", "sec-1", "--revision", revision]);
assert.equal(del.code, 0, del.err);
assert.match(del.out, /^business acme: deleted sec-1, now retired\ntransaction [^\n]+\n$/);
assert.match((await s.cli(["instances", "acme"])).out, /^retired: sec-1$/m);
});
test("row 194 T2a: a launch.revoke between the request and the pass closes it launch-revoked with no process; a done that fails once leaves the request taken and the next pass starts nothing", { skip, timeout: 60000 }, async (t) => {
const p = prepare(t);
const s = await start(t, p);
const request = s.call("instance.launch", { instance: "coder" }).request;
s.call("launch.revoke");
const revoked = await s.carry(request);
assert.deepEqual([revoked.state, revoked.done.refusal, revoked.done.run], ["closed", "launch-revoked", null]);
assert.equal(s.launcher.registry.sessions.length, 0);
assert.equal(existsSync(join(p.f.dataRoot, "launches", "acme")), false);
s.call("launch.restore");
const held = s.call("instance.launch", { instance: "coder" }).request;
s.faults.add("lifecycleDone");
const taken = await s.carry(held);
assert.deepEqual([taken.state, taken.done], ["taken", null]);
const [r] = s.running("coder");
assert.ok(s.logs.some((l) => l === `request ${held} (coder): lifecycleDone refused: storage-refused; run ${r.run} started`), s.logs.join("\n"));
await s.launcher.tick();
assert.equal(s.state(held).state, "taken");
assert.equal(s.running("coder").length, 1);
assert.equal(launchLog(p.f).filter((e) => e.event === "launch").length, 1);
});
test("row 194 T4: terminate signals a spawned run in the registry that the bus never bound, and not a run whose start time differs", { timeout: 30000 }, async (t) => {
const p = prepare(t);
const s = await start(t, p);
const spawned = (instance) => {
const child = spawn("sleep", ["60"], { stdio: "ignore" });
t.after(() => child.kill("SIGKILL"));
const exited = new Promise((resolve) => child.on("exit", (code, signal) => resolve(signal)));
return { child, exited, instance };
};
const a = spawned("coder");
const b = spawned("reviewer");
await until(() => startTimeOf(a.child.pid) !== null && startTimeOf(b.child.pid) !== null);
// Neither holds a claim. An open request each lets the end through, and the end cancels it.
for (const [x, start] of [[a, startTimeOf(a.child.pid)], [b, startTimeOf(b.child.pid) + 1]])
s.launcher.registry.add({ business: "acme", instance: x.instance, run: `run-${x.instance}`, pid: x.child.pid, startTime: start, runnerPid: x.child.pid, runnerStartTime: start });
t.after(() => ["coder", "reviewer"].forEach((i) => s.launcher.registry.remove("acme", `run-${i}`)));
for (const x of [a, b]) {
s.call("instance.launch", { instance: x.instance });
const out = s.call("instance.end", { instance: x.instance });
assert.deepEqual([out.runs, out.cancelled.length], [[], 1]);
await s.carry(out.request);
}
assert.equal(await a.exited, "SIGTERM");
await new Promise((r) => setTimeout(r, 300));
assert.equal(b.child.exitCode, null);
assert.equal(b.child.signalCode, null);
const ends = launchLog(p.f).filter((e) => e.event === "lifecycle-end").map((e) => e.run);
assert.deepEqual(ends, ["run-coder", "run-reviewer"]);
});
test("row 194 T6: a delete leaves the run evidence bytes unchanged, and the retired name refuses a create and a launch", { skip, timeout: 60000 }, async (t) => {
const p = prepare(t);
const s = await start(t, p);
const run = (await s.carry(s.call("instance.launch", { instance: "sec-1" }).request)).done.run;
await s.claimed(run);
await s.carry(s.call("instance.end", { instance: "sec-1" }).request);
await until(() => s.running("sec-1").length === 0 && launchLog(p.f).some((e) => e.event === "end" && e.run === run));
const runDir = join(p.f.dataRoot, "launches", "acme", run);
const before = snapshot(runDir);
const log = readFileSync(launchLogFile(p.f.dataRoot, "acme"), "utf8");
assert.ok(Object.keys(before).length > 0);
const del = await s.carry(s.call("instances.set", { revision: s.call("instances").revision, instance: "sec-1", op: "delete" }).request);
assert.equal(del.done.refusal, null);
assert.deepEqual(snapshot(runDir), before);
assert.ok(readFileSync(launchLogFile(p.f.dataRoot, "acme"), "utf8").startsWith(log), "the launch log only grows");
assert.throws(
() => s.call("instances.set", { revision: s.call("instances").revision, instance: "sec-1", op: "add", definition: "security-reviewer" }),
(e) => e.code === "organization-instance-retired",
);
assert.throws(() => s.call("instance.launch", { instance: "sec-1" }), (e) => e.code === "instance-retired");
});
test("row 194 T11: two stack roots with the same business id; a launch, a terminate and a delete in one leave the other byte-identical", { skip, timeout: 90000 }, async (t) => {
const pa = prepare(t);
const pb = prepare(t);
const a = await start(t, pa);
const b = await start(t, pb);
b.call("instances");
const before = snapshot(pb.root);
const run = (await a.carry(a.call("instance.launch", { instance: "coder" }).request)).done.run;
await a.claimed(run);
await a.carry(a.call("instance.end", { instance: "coder" }).request);
await until(() => a.running("coder").length === 0);
const del = await a.carry(a.call("instances.set", { revision: a.call("instances").revision, instance: "sec-1", op: "delete" }).request);
assert.equal(del.done.refusal, null);
await b.launcher.tick();
assert.deepEqual(snapshot(pb.root), before);
assert.equal(b.launcher.registry.sessions.length, 0);
assert.equal(Object.hasOwn(b.call("instances").instances, "sec-1"), true);
});
// D3 item 5 and T8: the chief's session hires through its hire tool, and the same session launches
// the new name with no restart. Its launch tool takes a name, so a name absent from the start-time
// listing gets through to the host, which checks the file.
test("row 194 D3 item 5: the chief's session hires with its tool and launches its hire in the same session, then fires it", { skip, timeout: 90000 }, async (t) => {
const p = prepare(t);
// The shipped role file lists nothing hireable. The test's copy lists the CISO, which holds
// only message.send and record.write and no credential.
const roleFile = join(p.f.env.MOSAIC_ROLES_DIR, "chief-of-staff.json");
const role = JSON.parse(readFileSync(roleFile, "utf8"));
role.hireable = ["chief-information-security-officer"];
writeFileSync(roleFile, JSON.stringify(role, null, 2));
const s = await start(t, p);
const chief = (await s.carry(s.call("instance.launch", { instance: "chief" }).request)).done.run;
await s.claimed(chief);
const ask = async (name, args) => {
const sent = s.call("message.send", { to: "chief", class: "REQUEST", body: `TOOL ${name} ${JSON.stringify(args)}` }).id;
return (await until(() => s.call("replies", { id: sent })[0], 30000)).body;
};
const hired = JSON.parse(await ask("hire", { name: "sec-2", definition: "chief-information-security-officer", vars: { model: "claude-sonnet-5-5" }, reason: "a security lead for the review queue" }));
assert.ok(hired.transaction, JSON.stringify(hired));
const listed = s.call("instances").instances["sec-2"];
assert.equal(listed.launched, true);
// Hire vars set only model and limits.* (N3). harness and thinking are agent-layer route vars the
// human sets, so until then the launch refuses no-route.
assert.equal(await ask("launch", { instance: "sec-2" }), "refused: no-route");
const route = { harness: "pi", model: "claude-sonnet-5-5", thinking: "high" };
s.call("instances.set", { revision: s.call("instances").revision, instance: "sec-2", op: "change", vars: route });
const launched = JSON.parse(await ask("launch", { instance: "sec-2" }));
assert.match(launched.run, /^r[0-9a-f]{12}$/);
assert.deepEqual(s.running("sec-2").map((x) => x.run), [launched.run]);
assert.equal(readSessions(p.f.dataRoot).find((x) => x.run === launched.run).launchedBy, "chief");
// The schema's pattern guides the model. The host refuses any name its file doesn't list.
assert.equal(await ask("launch", { instance: "NOT A NAME" }), "refused: instance-not-listed");
assert.equal(await ask("launch", { instance: "reviewer" }), "refused: instance-not-listed");
await s.claimed(launched.run);
const fired = JSON.parse(await ask("fire", { name: "sec-2", reason: "the review is done" }));
assert.ok(fired.transaction, JSON.stringify(fired));
await until(() => s.running("sec-2").length === 0, 30000);
// A fired instance stays in roles, off the listing, so the chief can't launch it again.
assert.equal(s.call("instances").instances["sec-2"].launched, false);
assert.equal(await ask("launch", { instance: "sec-2" }), "refused: instance-not-listed");
});
+3 -21
View File
@@ -11,7 +11,7 @@
// name against this list and forwards the call with its own capability.
import { connect } from "node:net";
import { ACTIONS, ID_PATTERN, THREAD_DEFINITION } from "../../business/src/vocabulary.mjs";
import { ACTIONS, THREAD_DEFINITION } from "../../business/src/vocabulary.mjs";
const str = (description, extra = {}) => ({ type: "string", description, ...extra });
const obj = (properties, required = []) => ({ type: "object", properties, required, additionalProperties: false });
@@ -113,11 +113,9 @@ export function typedTools({ resolved, arbiter = false, tracker = false, project
decision: COMMON.decision,
}, ["task_ref", "roles"]));
}
// Row 194 (#1692, D3 item 5): the instance is a name, not an enum fixed when the bundle was built,
// so the chief launches its own hire without a restart. The host checks the listing at each launch.
if (resolved.launch) {
add("launch", "launch", "role.launch", `Launch a managed session for a role instance the organization lists for you to launch. When this session started they were: ${resolved.launch.instances.join(", ")}. An instance you hired since is listed too. The host refuses an instance that isn't listed, one that is already running, a full model family, or a launch while launches are switched off.`, obj({
instance: str("Role instance to launch", { pattern: ID_PATTERN.source }),
add("launch", "launch", "role.launch", `Launch a managed session for one of these role instances: ${resolved.launch.instances.join(", ")}. The host refuses an instance that is already running, a full model family, or a launch while launches are switched off.`, obj({
instance: str("Role instance to launch", { enum: [...resolved.launch.instances] }),
}, ["instance"]));
}
if (tracker) {
@@ -172,22 +170,6 @@ export function typedTools({ resolved, arbiter = false, tracker = false, project
reason: str("Why the run ends, 1 to 500 characters", { minLength: 1, maxLength: 500 }),
}, ["role", "reason"]));
}
// Row 194 (#1692, PRD 1.7 REQ-COS-3): the chief of staff's hire and fire, routed within-role. The
// broker checks the definition against the role files and the veto on every call.
if (held.has("role.hire")) {
add("hire", "instance.hire", "role.hire", "Hire a new role instance: add it to the organization file under a new name, with a definition your role file lists as hireable. A reason is required, 1 to 500 characters, and goes into the log. vars may set the model key and limits.* keys only. The broker refuses a definition that isn't hireable with definition-not-hireable. Launch the instance afterwards with launch.", obj({
name: str("The new instance's name"),
definition: str("The role definition it runs"),
vars: { type: "object", description: "The instance's vars: model and limits.* keys only" },
reason: str("Why you hire it, 1 to 500 characters", { minLength: 1, maxLength: 500 }),
}, ["name", "definition", "reason"]));
}
if (held.has("role.fire")) {
add("fire", "instance.fire", "role.fire", "Fire a role instance: stop its run, then remove it from the organization file. A reason is required, 1 to 500 characters, and goes into the log. The broker refuses an instance the human has kept with instance-kept. It also refuses your own instance.", obj({
name: str("Role instance to fire"),
reason: str("Why you fire it, 1 to 500 characters", { minLength: 1, maxLength: 500 }),
}, ["name", "reason"]));
}
if (held.has("organization.change")) {
// Row 134 (#1669): organization_revision is the read change_organization's revision comes from.
// Row 155 (#1649, Q169): assistants configures the assistant.
+4 -5
View File
@@ -92,23 +92,22 @@ test("a chief-of-staff bundle lists launch among the bus tools and the manifest'
const b = build(t, r);
const prompt = readFileSync(b.files.prompt, "utf8");
assert.match(prompt, /Role instance: chief \(definition chief-of-staff\)\. Run: run-1\./);
assert.match(prompt, /Bus tools: send_message, launch, end_run, hire, fire, organization_revision, change_organization, set_name, read_spec, write_record, raise_decision, /);
assert.match(prompt, /Bus tools: send_message, launch, end_run, organization_revision, change_organization, set_name, read_spec, write_record, raise_decision, /);
assert.ok(b.manifest.typed.includes("launch"));
assert.deepEqual(JSON.parse(readFileSync(b.files.policy, "utf8")).needs, []);
});
// Row 132 (#1628, REQ-ROLE-2, REQ-COS-1): the 3d re-pin window checks this manifest: both tools typed.
// Row 141 (#1635, REQ-MSG-2) granted instance.name, and row 155 (#1649) gives it set_name, so nothing
// the chief holds is untyped and the prompt has no untyped line. Row 194 (#1692, PRD 1.7 REQ-COS-3)
// granted role.hire and role.fire, with hire and fire.
// the chief holds is untyped and the prompt has no untyped line.
test("a chief-of-staff bundle lists end_run, change_organization and set_name as typed and nothing untyped, for either harness", (t) => {
for (const harness of ["pi", "claude-code"]) {
const r = resolvedFor(t, "chief", (d) => (d.roles.chief.vars = { harness, model: "claude-opus-5-5" }));
const b = build(t, r);
const prefix = harness === "pi" ? "" : "mcp__mosaic__";
assert.deepEqual(b.manifest.typed, ["send_message", "launch", "end_run", "hire", "fire", "organization_revision", "change_organization", "set_name", "read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task"].map((n) => prefix + n), harness);
assert.deepEqual(b.manifest.typed, ["send_message", "launch", "end_run", "organization_revision", "change_organization", "set_name", "read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task"].map((n) => prefix + n), harness);
assert.deepEqual(b.manifest.untyped, [], harness);
assert.match(readFileSync(b.files.prompt, "utf8"), /Bus tools: send_message, launch, end_run, hire, fire, organization_revision, change_organization, set_name, read_spec, write_record, raise_decision, /);
assert.match(readFileSync(b.files.prompt, "utf8"), /Bus tools: send_message, launch, end_run, organization_revision, change_organization, set_name, read_spec, write_record, raise_decision, /);
assert.doesNotMatch(readFileSync(b.files.prompt, "utf8"), /Actions you hold that have no bus tool yet/);
}
});
+5 -35
View File
@@ -3,7 +3,6 @@ import assert from "node:assert/strict";
import { callTool, ORGANIZATION_KEYS, REPLY_CAP, replyText, typedTools, untypedActions } from "../src/tools.mjs";
import { ORGANIZATION_KEYS as BROKER_ORGANIZATION_KEYS, TASK_PAGE, TASK_PAGE_MAX } from "../../bus/src/broker.mjs";
import { SETTINGS_KEYS } from "../../business/src/launch-settings.mjs";
import { ID_PATTERN } from "../../business/src/vocabulary.mjs";
import { fakeToolSocket, resolvedFor, scratch } from "./helpers.mjs";
const names = (tools) => tools.map((t) => t.name);
@@ -38,47 +37,19 @@ test("send_message offers the BLOCK class, beside the classes it already had", (
});
// Row 119 (#1613): the chief of staff holds launch and message.send; row 123 adds role.end and organization.change, which row 132 gives tools.
test("the chief of staff gets launch, send_message, end_run, hire, fire, change_organization and set_name, and no task verbs", (t) => {
test("the chief of staff gets launch, send_message, end_run, change_organization and set_name, and no task verbs", (t) => {
const chief = resolvedFor(t, "chief");
const tools = typedTools({ resolved: chief, tracker: true });
assert.deepEqual(names(tools), ["send_message", "launch", "end_run", "hire", "fire", "organization_revision", "change_organization", "set_name", "read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task"]);
assert.deepEqual(names(tools), ["send_message", "launch", "end_run", "organization_revision", "change_organization", "set_name", "read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task"]);
const launch = tools.find((x) => x.name === "launch");
assert.equal(launch.verb, "launch");
assert.equal(launch.action, "role.launch");
// Row 194 (#1692, D3 item 5): a name with the id pattern, not an enum fixed at bundle time, so a hire
// launches with no restart. The start-time listing stays in the description, and the host checks it.
assert.deepEqual(launch.parameters.properties.instance, { type: "string", description: "Role instance to launch", pattern: ID_PATTERN.source });
assert.match(launch.description, /When this session started they were: coder, reviewer\. An instance you hired since is listed too\. The host refuses an instance that isn't listed,/);
assert.deepEqual(launch.parameters.properties.instance.enum, ["coder", "reviewer"]);
// Row 132 (#1628): role.end and organization.change have their tools. Row 141 (#1635) granted
// instance.name, and row 155 (#1649) gives it set_name, so no held action is untyped. Row 194 (#1692,
// PRD 1.7 REQ-COS-3) granted role.hire and role.fire, with hire and fire.
// instance.name, and row 155 (#1649) gives it set_name, so no held action is untyped.
assert.deepEqual(untypedActions(chief, tools), []);
});
// Row 194 (#1692, PRD 1.7 REQ-COS-3): hire and fire send the broker's argument keys, reason required,
// and only a holder of the grant gets them.
test("hire and fire send instance.hire and instance.fire under role.hire and role.fire, only for a holder of the grant", (t) => {
const tools = typedTools({ resolved: resolvedFor(t, "chief"), tracker: true });
const hire = tools.find((x) => x.name === "hire");
const fire = tools.find((x) => x.name === "fire");
assert.deepEqual([hire.verb, hire.action, fire.verb, fire.action], ["instance.hire", "role.hire", "instance.fire", "role.fire"]);
assert.deepEqual(Object.keys(hire.parameters.properties), ["name", "definition", "vars", "reason"]);
assert.deepEqual(hire.parameters.required, ["name", "definition", "reason"]);
assert.deepEqual(Object.keys(fire.parameters.properties), ["name", "reason"]);
assert.deepEqual(fire.parameters.required, ["name", "reason"]);
for (const tool of [hire, fire]) {
assert.equal(tool.parameters.additionalProperties, false);
assert.deepEqual([tool.parameters.properties.reason.minLength, tool.parameters.properties.reason.maxLength], [1, 500]);
}
for (const instance of ["coder", "reviewer", "pm"])
for (const name of ["hire", "fire"]) assert.ok(!names(typedTools({ resolved: resolvedFor(t, instance), tracker: true })).includes(name), `${instance} ${name}`);
// Each tool follows its own grant.
const only = (grant) => resolvedFor(t, "chief", (d) => (d.roles.chief.vars = { ...d.roles.chief.vars, "limits.authority": ["message.send", grant] }));
const HIRE_FIRE = (n) => n === "hire" || n === "fire";
assert.deepEqual(names(typedTools({ resolved: only("role.hire") })).filter(HIRE_FIRE), ["hire"]);
assert.deepEqual(names(typedTools({ resolved: only("role.fire") })).filter(HIRE_FIRE), ["fire"]);
});
// Row 140 (#1634, REQ-COS-1): the pm asks for staff with request_staff, which spends message.send; the
// chief of staff and every other role has no such tool, and the chief's launch offer is unchanged.
test("request_staff is the pm's alone: staff.request over message.send, roles and counts bounded", (t) => {
@@ -104,8 +75,7 @@ test("request_staff is the pm's alone: staff.request over message.send, roles an
assert.ok(!names(tools).includes("launch"));
const chief = resolvedFor(t, "chief");
const launch = typedTools({ resolved: chief, tracker: true }).find((x) => x.name === "launch");
assert.equal(launch.parameters.properties.instance.enum, undefined);
assert.ok(launch.description.includes(`they were: ${chief.launch.instances.join(", ")}.`));
assert.deepEqual(launch.parameters.properties.instance.enum, chief.launch.instances);
});
// Row 132 (#1628, REQ-ROLE-2, REQ-COS-1, REQ-LIMIT-1): each tool maps to the broker verb and action row 123 added.
-39
View File
@@ -129,45 +129,6 @@ 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
+2 -3
View File
@@ -58,9 +58,8 @@ 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');
// 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, preview.header) !== preview.user) return refuse(401, 'unauthenticated');
if (one(req, 'origin') !== `https://${preview.host}`) return refuse(403, 'cross-origin');
}
if (one(req, 'sec-fetch-site') !== 'same-origin') return refuse(403, 'cross-site');
}
+10 -39
View File
@@ -7,31 +7,20 @@ 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';
// Row 194 (#1692, D7): the Agents section's verbs. The caller refuses any other, so the server's
// agentWrite reaches only these.
const AGENT_VERBS = ['instance.launch', 'instance.regenerate', 'instance.end', 'veto.set', 'instance.request'];
const TESTER_AGENT_VERBS = ['instance.launch', 'instance.end', 'instance.request'];
const agentCaller = (call, verbs) => async (verb, args) => {
if (!verbs.includes(verb)) throw new Error(`the Console does not call ${verb}`);
return call(verb, args);
};
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] [--sso [--send]] [--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] [--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, ssoOn = false, testerPort = null;
let sendOn = 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') {
@@ -62,17 +51,16 @@ 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 && !ssoOn) throw new Error('--send needs the preview mode (--public-host, --auth-header and --allow-user) or --sso');
if (sendOn && !options.preview) throw new Error('--send needs the preview mode (--public-host, --auth-header and --allow-user)');
// 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
// the system config, Business can't resolve authority and Settings has
// no notifier. A refusal printed here names no path (redactor).
// --send without a bus refuses to start rather than serve a composer that can't send.
let bus = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, busLine, system = null;
let bus = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, busLine, system = null;
try {
system = loadSystem();
const { dataRoot } = system;
@@ -85,52 +73,35 @@ try {
// Row 148: the spec and docs editor reads like the other reads and saves only under --send.
projectFiles = args => call('project.files', args);
projectFile = args => call('project.file', args);
if (sendOn) {send = args => call('message.send', args);settingsWrite=args=>call('limits.set',args);projectSave=args=>call('project.file.save',args);nameWrite=args=>call('name.set',args);assistantStop=args=>call('assistant.stop',args);instancesWrite=args=>call('instances.set',args);routesWrite=args=>call('routes.set',args);agentWrite=agentCaller(call,AGENT_VERBS);}
if (sendOn) {send = args => call('message.send', args);settingsWrite=args=>call('limits.set',args);projectSave=args=>call('project.file.save',args);nameWrite=args=>call('name.set',args);assistantStop=args=>call('assistant.stop',args);instancesWrite=args=>call('instances.set',args);routesWrite=args=>call('routes.set',args);}
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 });
// 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, agentWrite });
} catch (err) { sso?.close(); throw err; }
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 });
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, testerNoSignIn: ssoOn, testerProofHeader: proof?.header ?? null, testerAuthHeader: options.preview?.header ?? null, bus: testerBus, reads,
tester: true, 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 194 (#1692, D4.1): the tester launches, terminates and reads its own requests.
agentWrite: agentCaller(call, TESTER_AGENT_VERBS),
// Row 177 (#1675): the tester's own chat, through the tester CLI; 7332's stays the human's.
chat: createChat({ call }),
});
} catch (err) { sso?.close(); await new Promise(resolve => server.close(resolve)); throw err; }
} catch (err) { 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}${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(); });
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(); });
} catch (err) {
console.error(`refused: ${err.message}`);
process.exitCode = 2;
-149
View File
@@ -1,149 +0,0 @@
/* Row 194 (#1692, D7): the Agents section of Settings. bus.js draws it with section(view, ctx) after the
role instances, from the same instances read. Create and Delete go through /api/instances; Launch,
Terminate and Regenerate through /api/agent/*; Keep, Unkeep, No-hire and Hire through /api/veto.
Every answer that names a request is followed at /api/agent/request until the request closes.
Regenerate and Delete take a second click to confirm. Nothing retries: an unknown outcome says so
and asks for a fresh read. The tester listener offers Launch and Terminate only (D4.1). */
(() => {
'use strict';
const UNAVAILABLE = 'Agent actions are unavailable here. Use mosaic business instance from the instance CLI.';
const REQUEST_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
const POLL_MS = 2000, POLL_LIMIT = 90;
const UNKNOWN = 'Outcome unknown. Read the agents again before trying; nothing was retried.';
const DONE = { launch: 'Launched', regenerate: 'Regenerated', end: 'Terminated', delete: 'Deleted' };
let ctx = null, view = null, busy = false, receipt = '', confirming = null, pending = null, generation = 0;
let create = { instance: '', definition: '' };
const reasons = {};
const usable = () => !!ctx && (ctx.canSend || ctx.isTester) && !ctx.out();
const human = () => !!ctx && ctx.canSend && !ctx.isTester;
const redraw = () => { if (ctx && !ctx.out()) ctx.render(); };
const reread = () => { if (ctx && !ctx.out()) ctx.refresh(); };
function section(v, c) {
ctx = c; view = v;
const { txt, esc } = c, title = '<h2 id="agents-title">Agents</h2>';
if (!v) return `<section class="panel" id="agents-section" aria-labelledby="agents-title">${title}<p class="small warn">The agents could not be read, so there is nothing to act on. Read again to retry.</p></section>`;
const open = usable(), off = !open || busy || pending ? ' disabled' : '';
const button = (action, name, label, extra = '') =>
`<button type="button" data-agent-action="${esc(action)}" data-agent-name="${esc(name)}"${off}${extra}>${txt(label)}</button>`;
const confirmPair = (action, name, label, ask) => confirming?.action === action && confirming?.name === name
? `<span class="agent-confirm"><span>${txt(ask)}</span> ${button(action, name, `Confirm ${label.toLowerCase()}`, ' data-agent-confirmed=""')} <button type="button" data-agent-cancel=""${off}>Cancel</button></span>`
: button(action, name, label);
const rows = Object.entries(v.instances).map(([name, e]) => {
const running = Array.isArray(e.running) ? e.running : [];
const acts = [button('launch', name, 'Launch'),
`<label>Reason <input data-agent-reason="${esc(name)}" value="${esc(reasons[name] ?? '')}" maxlength="500"${off}></label>`,
button('end', name, 'Terminate')];
if (!c.isTester) {
acts.push(confirmPair('regenerate', name, 'Regenerate', `Regenerate ${name}? Its workspace is renamed under .retired and it starts fresh.`));
acts.push(e.kept ? button('unkeep', name, 'Unkeep') : button('keep', name, 'Keep'));
if (!e.protected) acts.push(confirmPair('delete', name, 'Delete', `Delete ${name}? Its evidence and its workspace under .retired stay.`));
}
return `<tr><th scope="row">${txt(name)}</th><td>${txt(e.definition)}</td><td>${e.launched ? 'yes' : 'no'}</td><td>${e.kept ? 'yes' : 'no'}</td><td>${running.length ? running.map(r => `<code>${txt(r)}</code>`).join(' ') : '<span class="muted">none</span>'}</td><td class="agent-actions">${acts.join(' ')}</td></tr>`;
}).join('');
const noHire = Array.isArray(v.noHire) ? v.noHire : [], retired = Array.isArray(v.retired) ? v.retired : [];
const defs = Object.keys(v.definitions || {});
const hiring = c.isTester ? '' : `<h3>Hiring per definition</h3><ul class="agent-defs">${defs.map(d => `<li>${txt(d)}: ${noHire.includes(d) ? `no hire ${button('hire', d, 'Allow hiring')}` : `hire allowed ${button('nohire', d, 'No hire')}`}</li>`).join('')}</ul>`;
const accepted = defs.filter(d => v.definitions[d]?.accepted);
const createForm = c.isTester ? '' : `<form id="agent-create-form"><fieldset><legend>Create an agent</legend><div class="filters"><label>Instance <input data-agent-create="instance" value="${esc(create.instance)}" maxlength="64"${off}></label><label>Definition <select data-agent-create="definition"${off}><option value="">Choose</option>${accepted.map(d => `<option value="${esc(d)}"${d === create.definition ? ' selected' : ''}>${txt(d)}</option>`).join('')}</select></label></div><button type="submit"${off}>Create agent</button></fieldset></form>`;
const state = pending ? `<p class="small" id="agents-pending">Request <code>${txt(pending.request)}</code> to ${txt(pending.action)} ${txt(pending.instance)}: ${txt(pending.state)}.</p>` : '';
const note = open ? `<p class="small muted">Actions are checked and logged with your identity.</p>` : `<p class="small warn" id="agents-unavailable">${txt(UNAVAILABLE)}</p>`;
return `<section class="panel" id="agents-section" aria-labelledby="agents-title">${title}<p>Each agent with its definition, launch membership, keep and running runs. A launch refuses no-route until the agent has a route in Routing.</p>${note}<div class="table-wrap" tabindex="0" role="region" aria-label="Agents, scroll for all columns"><table id="agents-table"><thead><tr><th scope="col">Instance</th><th scope="col">Definition</th><th scope="col">Launched</th><th scope="col">Kept</th><th scope="col">Running</th><th scope="col">Actions</th></tr></thead><tbody>${rows}</tbody></table></div>${hiring}${createForm}${retired.length ? `<p class="small muted">Retired names, never reused: ${retired.map(txt).join(', ')}.</p>` : ''}${state}<p role="status" id="agents-receipt">${txt(receipt)}</p></section>`;
}
// One POST. `null` means the page signed out; otherwise the status and the parsed answer.
async function post(url, payload) {
const res = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload), cache: 'no-store', redirect: 'manual' });
if (res.type === 'opaqueredirect') { ctx.signOut(); return null; }
let result = null;
try { result = await res.json(); } catch { /* an unreadable answer is an unknown outcome */ }
return { ok: res.ok, result };
}
const refused = r => r?.sent === false && typeof r.message === 'string' ? `${r.message}` : UNKNOWN;
function plan(action, name) {
switch (action) {
case 'launch': return ['/api/agent/launch', { instance: name }];
case 'regenerate': return ['/api/agent/regenerate', { instance: name }];
case 'end': { const reason = String(reasons[name] ?? '').trim(); return ['/api/agent/terminate', reason ? { instance: name, reason } : { instance: name }]; }
case 'delete': return ['/api/instances', { revision: view.revision, instance: name, op: 'delete' }];
default: return ['/api/veto', { revision: view.revision, op: action, name }];
}
}
async function act(action, name) {
if (!usable() || busy || pending || !view) return;
const [url, payload] = plan(action, name);
busy = true; confirming = null; receipt = ''; redraw();
let answer;
try { answer = await post(url, payload); } catch { answer = { ok: false, result: { sent: 'unknown' } }; }
busy = false;
if (answer === null) return;
const { ok, result } = answer;
if (!ok) { receipt = refused(result); redraw(); return; }
if (url === '/api/veto') { receipt = `${{ keep: 'Kept', unkeep: 'Unkept', nohire: 'No hire for', hire: 'Hiring allowed for' }[action]} ${name}.`; reread(); return; }
if (!REQUEST_ID.test(result?.request ?? '')) { receipt = UNKNOWN; redraw(); return; }
if (action === 'end') delete reasons[name];
pending = { request: result.request, action, instance: name, state: 'open', tries: 0 };
receipt = action === 'end' && Array.isArray(result.runs)
? `Terminate asked for ${result.runs.length} run${result.runs.length === 1 ? '' : 's'}.` : '';
follow(generation);
}
// Polls the request until it closes, the page signs out or the limit passes. A read that fails
// is shown and the poll goes on; the request itself is never sent again.
async function follow(gen) {
while (pending && gen === generation) {
redraw();
await new Promise(r => setTimeout(r, POLL_MS));
if (!pending || gen !== generation || ctx.out()) return;
if (++pending.tries > POLL_LIMIT) { receipt = `Request ${pending.request} is still ${pending.state}. Read the agents again later.`; pending = null; reread(); return; }
let res, r = null;
try { res = await fetch(`/api/agent/request?id=${encodeURIComponent(pending.request)}`, { cache: 'no-store', redirect: 'manual' }); } catch { continue; }
if (res.type === 'opaqueredirect') { pending = null; ctx.signOut(); return; }
try { r = await res.json(); } catch { continue; }
if (!res.ok || r?.request !== pending.request) { receipt = `Could not read request ${pending.request}: ${r?.message ?? 'no answer'}. Still following it.`; continue; }
pending.state = r.state;
if (r.state !== 'closed') continue;
const { action, instance } = pending;
pending = null;
receipt = r.reason ? `${instance}: ${r.reason}.` : `${DONE[action] ?? 'Done'} ${instance}${r.done?.run ? ` (run ${r.done.run})` : ''}.`;
reread();
return;
}
}
async function createAgent() {
if (!usable() || busy || pending || !view || ctx.isTester) return;
const instance = create.instance.trim(), definition = create.definition;
if (!instance || !definition) { receipt = 'Name the instance and choose a definition.'; redraw(); return; }
busy = true; receipt = ''; redraw();
let answer;
try { answer = await post('/api/instances', { revision: view.revision, instance, op: 'add', definition }); } catch { answer = { ok: false, result: { sent: 'unknown' } }; }
busy = false;
if (answer === null) return;
if (answer.ok && /^[a-f0-9]{64}$/.test(answer.result?.revision ?? '')) { create = { instance: '', definition: '' }; receipt = `Created ${instance}.`; reread(); }
else { receipt = answer.ok ? UNKNOWN : `${refused(answer.result)} Draft kept.`; redraw(); }
}
document.addEventListener('click', e => {
const t = e.target;
if (!t?.closest?.('#agents-section')) return;
if (t.dataset.agentCancel !== undefined) { confirming = null; redraw(); return; }
const action = t.dataset.agentAction, name = t.dataset.agentName;
if (!action || name === undefined) return;
if ((action === 'regenerate' || action === 'delete') && t.dataset.agentConfirmed === undefined) { confirming = { action, name }; redraw(); return; }
act(action, name);
});
document.addEventListener('input', e => {
const t = e.target;
if (t?.dataset?.agentReason !== undefined) reasons[t.dataset.agentReason] = t.value;
else if (t?.dataset?.agentCreate !== undefined) create[t.dataset.agentCreate] = t.value;
});
document.addEventListener('change', e => { const t = e.target; if (t?.dataset?.agentCreate !== undefined) create[t.dataset.agentCreate] = t.value; });
document.addEventListener('submit', e => { if (e.target?.id !== 'agent-create-form') return; e.preventDefault(); createAgent(); });
// A sign-out stops any poll; nothing that was asked is retried.
document.addEventListener('mosaic-signed-out', () => { generation++; pending = null; confirming = null; });
window.MosaicAgents = Object.freeze({ section });
})();
+3 -14
View File
@@ -16,11 +16,6 @@
// 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>` : '';
@@ -115,7 +110,6 @@
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) {
@@ -310,7 +304,6 @@
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;
}
@@ -459,7 +452,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', e => {
document.addEventListener('mosaic-signed-out', () => {
if (out()) return;
document.body.classList.add('signed-out');
clearTimeout(timer); convGen++;
@@ -468,13 +461,9 @@
for (const id of ['projects', 'waiting', 'seen', 'sessions']) $(id).replaceChildren();
$('refresh').disabled = $('pause').disabled = true;
const line = $('signed-out');
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;
line.innerHTML = '<p><b>Signed out.</b> Reload the page to sign in.</p>'; line.hidden = false;
// 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(); });
});
// 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);
api('/api/config').then(config => { board = config.board; $('board-url').textContent = `Board: ${board}`; }).catch(err => { if (machine) error(err); }).finally(refresh);
})();
+2 -11
View File
@@ -30,10 +30,6 @@
const out = () => document.body.classList.contains('signed-out');
const signOut = () => { if (!out()) document.dispatchEvent(new Event('mosaic-signed-out')); };
const signedOut = () => new ReadError('signed-out', 'The sign-in proxy redirected the read.', 0);
// Q175 phase 1 (#1690, sage-1340 F1): under --sso a read without a session gets 401 `sign-in
// required` from the Console itself. That signs out the same way; any other 401 stays an error.
const signInRequired = (res, body) => res.status === 401 && body?.error === 'sign-in required';
const signOutSso = () => { if (!out()) document.dispatchEvent(new CustomEvent('mosaic-signed-out', { detail: { signIn: true } })); };
// `replies` takes a message id as `id`; trail takes `subject`.
async function fetchRead(verb, subject) {
if (out()) throw signedOut();
@@ -42,7 +38,6 @@
catch { throw new ReadError('unreachable', 'The Console server did not answer.', 0); }
if (res.type === 'opaqueredirect') { signOut(); throw signedOut(); }
try { body = await res.json(); } catch { throw new ReadError('unreachable', 'The Console server did not answer.', 0); }
if (signInRequired(res, body)) { signOutSso(); throw signedOut(); }
if (!res.ok || !body || !Array.isArray(body.rows)) throw new ReadError(typeof body?.error === 'string' ? body.error : 'invalid-response', typeof body?.message === 'string' ? body.message : `HTTP ${res.status}`, res.status);
return { ...body, rows: body.rows, at: body.at };
}
@@ -55,7 +50,6 @@
try { res = await fetch(`/api/${path}`, { cache: 'no-store', redirect: 'manual' }); } catch { throw unreachable(); }
if (res.type === 'opaqueredirect') { signOut(); throw signedOut(); }
try { body = await res.json(); } catch { throw unreachable(); }
if (signInRequired(res, body)) { signOutSso(); throw signedOut(); }
if (!res.ok || !body || typeof body !== 'object' || Array.isArray(body)) throw Object.assign(new ReadError(typeof body?.error === 'string' ? body.error : 'invalid-response', typeof body?.message === 'string' ? body.message : `HTTP ${res.status}`, res.status), { module: true });
return body;
}
@@ -787,9 +781,6 @@
else routesReceipt=response.ok||result?.sent==='unknown'?unknownRoutes:`${result?.message||'Refused.'} Draft kept.`;}}
catch{routesReceipt=unknownRoutes;}finally{routesBusy=false;if(!out())render(false);}
});
// Row 194 (#1692, D7): the Agents section lives in agents.js (window.MosaicAgents) and draws from
// the same instances read. A refresh drops that read so the next render asks the bus again.
const agentsSection=view=>window.MosaicAgents?window.MosaicAgents.section(view,{txt,esc,canSend,isTester,out,signOut,render:()=>render(false),refresh:()=>{last.delete('instances');render(false);}}):'';
// Tester management is deliberately separate from the human's assistant/settings forms.
function testerNames(names, instances) {
if (!instances || !names) return '';
@@ -823,11 +814,11 @@
try { [routes] = await get('routes'); } catch (err) { if (err.status === 403 || err.code === 'signed-out') throw err; }
let limitRows = [], limitsRefused = false;
try { limitRows = await get('limits'); } catch (err) { if (err.status === 403 || err.code === 'signed-out') throw err; limitsRefused = true; }
if (isTester) return `${head('Settings', `${readLine()} Tester management.`)}${routesSection(routes)}${instancesSection(instances)}${agentsSection(instances)}${testerNames(names, instances)}`;
if (isTester) return `${head('Settings', `${readLine()} Tester management.`)}${routesSection(routes)}${instancesSection(instances)}${testerNames(names, instances)}`;
const notify = typeof n.binding === 'string'
? `<dl class="kv"><dt>Binding</dt><dd><code>${txt(n.binding)}</code></dd></dl><p class="small muted">The binding name only. Its channels, server and user ids stay in the binding file.</p>`
: empty('No notifier binding to show.', `${n.refused ? `<code>${txt(n.refused)}</code>: ` : ''}${txt(n.message || 'the notifier settings could not be read')}`);
return `${head('Settings', `${readLine()} Agent limits and appearance.`)}${appearance()}${launchForm(s.launch)}${routesSection(routes)}${assistantSection(s.launch, names)}${instancesSection(instances)}${agentsSection(instances)}${tokenLimits(limitRows, limitsRefused)}${tokenUsage(usage)}
return `${head('Settings', `${readLine()} Agent limits and appearance.`)}${appearance()}${launchForm(s.launch)}${routesSection(routes)}${assistantSection(s.launch, names)}${instancesSection(instances)}${tokenLimits(limitRows, limitsRefused)}${tokenUsage(usage)}
<section aria-labelledby="st-notify"><h2 id="st-notify">Notifications</h2>${notify}</section>
<section aria-labelledby="st-about" class="panel"><h2 id="st-about">About</h2><dl class="kv"><dt>Address</dt><dd><code>${txt(s.address) || 'unknown'}</code></dd><dt>Release</dt><dd>${s.release ? `<code>${txt(s.release)}</code>` : '<span class="muted">unknown: no RELEASE file was read</span>'}</dd><dt>Business</dt><dd>${txt(s.business) || none}</dd><dt>Board</dt><dd><code>${txt(s.board) || 'unknown'}</code></dd></dl></section>`;
}
+2 -2
View File
@@ -3,13 +3,13 @@
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Mosaic Console</title>
<link rel="stylesheet" href="/shared/app.css"><link rel="stylesheet" href="/tokens.css"><link rel="stylesheet" href="/console.css"><link rel="stylesheet" href="/live.css"><link rel="stylesheet" href="/bus.css"><link rel="stylesheet" href="/chat.css"><link rel="stylesheet" href="/shell.css">
<link rel="stylesheet" href="/editor.css"><script src="/editor.js" defer></script>
<script src="/brand.js" defer></script><script src="/app.js" defer></script><script src="/markdown-preview.js" defer></script><script src="/chat.js" defer></script><script src="/agents.js" defer></script><script src="/bus.js" defer></script></head>
<script src="/brand.js" defer></script><script src="/app.js" defer></script><script src="/markdown-preview.js" defer></script><script src="/chat.js" defer></script><script src="/bus.js" defer></script></head>
<body>
<a class="skip" href="#main">Skip to content</a>
<header class="cmdbar"><a href="/" class="wordmark"><svg class="mark" viewBox="0 0 16 17" aria-hidden="true"><use href="/icons.svg#mark"/></svg>Mosaic<small>Console</small></a>
<button type="button" class="cmdk" id="cmdk" aria-haspopup="dialog" aria-controls="cmdk-dialog"><svg class="i" aria-hidden="true"><use href="/icons.svg#i-search"/></svg><span>Go to a view, decision or task</span><kbd>Ctrl K</kbd></button>
<p class="fresh" id="fresh"><span id="fresh-board"></span><span id="fresh-bus"></span></p>
<div class="cmd-right"><label>Palette<select id="palette"></select></label><label>Appearance<select id="mode"><option value="system">system</option><option value="light">light</option><option value="dim">dim</option><option value="dark">dark</option></select></label><button class="btn" id="refresh" type="button">Refresh</button><button class="btn" id="pause" type="button" aria-pressed="false">Pause</button><form id="sign-out" method="post" action="/auth/logout" hidden><button class="btn" type="submit">Sign out</button></form></div></header>
<div class="cmd-right"><label>Palette<select id="palette"></select></label><label>Appearance<select id="mode"><option value="system">system</option><option value="light">light</option><option value="dim">dim</option><option value="dark">dark</option></select></label><button class="btn" id="refresh" type="button">Refresh</button><button class="btn" id="pause" type="button" aria-pressed="false">Pause</button></div></header>
<div class="frame">
<nav class="tree" aria-label="Console"><h2 class="tree-title">Console</h2><ul class="s1-sections" id="sections"></ul><div id="board-tree"><h2 class="tree-title">Projects</h2><div id="projects"><p class="muted">Loading projects…</p></div><p class="small muted">Select a project to filter sessions. Waiting on you always shows all projects.</p></div></nav>
<main id="main" class="content" tabindex="-1"><div id="signed-out" class="banner ref" role="alert" tabindex="-1" hidden></div><div id="bus-down" class="banner err" role="alert" hidden></div><div class="page-head" id="board-head"><div><h1>Control board</h1><p id="status" class="small muted" role="status">Loading board…</p></div><div class="filters"><label><input id="show-machine" type="checkbox">Show machine sessions</label></div></div>
+32 -174
View File
@@ -25,7 +25,7 @@ export function boardURL(value) {
const root = resolve(import.meta.dirname, 'public');
const files = new Map([
['/', ['index.html', 'text/html; charset=utf-8']],
...['app.js', 'brand.js', 'bus.js', 'markdown-preview.js', 'chat.js', 'agents.js'].map(f => ['/' + f, [f, 'text/javascript; charset=utf-8']]),
...['app.js', 'brand.js', 'bus.js', 'markdown-preview.js', 'chat.js'].map(f => ['/' + f, [f, 'text/javascript; charset=utf-8']]),
...['shared/app.css', 'tokens.css', 'console.css', 'live.css', 'bus.css', 'shell.css', 'chat.css'].map(f => ['/' + f, [f, 'text/css; charset=utf-8']]),
['/editor.js', ['editor.js', 'text/javascript; charset=utf-8']],
['/editor.css', ['editor.css', 'text/css; charset=utf-8']],
@@ -61,8 +61,6 @@ async function body(req, max = 4096) {
// Row 65 (mos-0840) adds `routes`: each instance's harness, model, thinking and fallback.
const BUS_VERBS = ['inbox', 'tasks', 'agents', 'trail', 'decisions', 'credentials', 'replies', 'messages', 'usage', 'limits', 'names', 'instances', 'routes'];
const DECISION_LISTS = ['roles', 'recent'];
// Q175 phase 1 (#1690, F8): either sign-in cookie, which the tester listener refuses under --sso.
const SIGN_IN_COOKIE = /(?:^|;)\s*__host-mosaic_(?:session|login)\s*=/i;
// Row 177 (#1675): the chat writes the tester listener lets through, exactly the four chatHTTP routes.
const TESTER_CHAT_WRITE = /^\/api\/chat\/(?:projects|threads|threads\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\/(?:messages|open))$/i;
async function busRead(res, bus, verb, search) {
@@ -162,18 +160,14 @@ async function busRead(res, bus, verb, search) {
// broker's to refuse. Nothing retries; an outcome the bus can't confirm is reported as unknown.
const SEND_KEYS = ['to', 'body', 'in_reply_to'];
const one = (req, name) => (req.headersDistinct[name]?.length === 1 ? req.headersDistinct[name][0] : null);
// Q175 phase 1 (#1690): under --sso the writer comes from a session the handler already checked,
// so it carries `sso: true` and the public origin; the preview writer still checks its header.
const signedIn = (req, p) => p.sso === true || one(req, p.header) === p.user;
const ownOrigin = p => p.origin ?? `https://${p.host}`;
async function busSend(req, res, { send, preview, log }) {
if (!signedIn(req, preview)) return json(res, 401, { error: 'sign-in required' });
if (one(req, preview.header) !== preview.user) return json(res, 401, { error: 'sign-in required' });
let to = null;
// `to` is quoted and cut short so a crafted value can't forge a log line.
const line = outcome => log(`${new Date().toISOString()} send user=${JSON.stringify(preview.user)} to=${typeof to === 'string' ? JSON.stringify(to.slice(0, 64)) : 'none'} ${outcome}`);
// `sent` tells the page which it was: false, nothing was written; 'unknown', it may have been.
const refuse = (status, error, message, sent = false) => { line(`error=${error}`); return json(res, status, { error, message, sent }); };
if (one(req, 'origin') !== ownOrigin(preview)) return refuse(403, 'cross-origin request refused', 'a send needs the Console\'s own Origin');
if (one(req, 'origin') !== `https://${preview.host}`) return refuse(403, 'cross-origin request refused', 'a send needs the Console\'s own Origin');
if (one(req, 'sec-fetch-site') !== 'same-origin') return refuse(403, 'cross-site request refused', 'a send needs Sec-Fetch-Site: same-origin');
let value;
try { value = JSON.parse((await body(req, 16384)).toString('utf8')); } catch (err) { return refuse(400, 'invalid-request', err.message); }
@@ -247,10 +241,10 @@ export function proofOptions({ proofHeader, proofFile } = {}, preview = null) {
// REQ-WEB-1: same signed-in send boundary, a distinct strict settings payload.
async function settingsSend(req, res, { settingsWrite, preview, log }) {
const refuse = (status, error, message) => json(res, status, { error, message, sent: false });
if (!signedIn(req, preview)) {
if (one(req, preview.header) !== preview.user) {
return refuse(401, 'sign-in required', 'sign in again');
}
if (one(req, 'origin') !== ownOrigin(preview)) {
if (one(req, 'origin') !== `https://${preview.host}`) {
return refuse(403, 'cross-origin request refused', 'settings need the Console origin');
}
if (one(req, 'sec-fetch-site') !== 'same-origin') {
@@ -298,8 +292,8 @@ async function seatSend(req, res, { route, write, preview, log }) {
// The instance is quoted and cut short so a crafted value can't forge a log line.
const line = outcome => log(`${new Date().toISOString()} ${route.kind} user=${JSON.stringify(preview.user)} instance=${quoted(instance)} ${outcome}`);
const refuse = (status, error, message, sent = false) => { line(`error=${error}`); return json(res, status, { error, message, sent }); };
if (!preview.tester && !signedIn(req, preview)) return json(res, 401, { error: 'sign-in required', message: 'sign in again', sent: false });
if (one(req, 'origin') !== ownOrigin(preview)) return refuse(403, 'cross-origin request refused', 'this needs the Console\'s own Origin');
if (!preview.tester && one(req, preview.header) !== preview.user) return json(res, 401, { error: 'sign-in required', message: 'sign in again', sent: false });
if (one(req, 'origin') !== (preview.origin ?? `https://${preview.host}`)) return refuse(403, 'cross-origin request refused', 'this needs the Console\'s own Origin');
if (one(req, 'sec-fetch-site') !== 'same-origin') return refuse(403, 'cross-site request refused', 'this needs same-origin Fetch Metadata');
let value;
try { value = JSON.parse((await body(req)).toString('utf8')); } catch (err) { return refuse(400, 'invalid-request', err.message); }
@@ -336,67 +330,30 @@ const INSTANCE_REASONS = {
'organization-unresolvable-instance': 'an instance wouldn\'t resolve against a project file; mosaic business validate shows why',
'revision-conflict': 'the business file changed since you read it. Read the role instances again',
};
// Row 194 (#1692, D7): the lifecycle and veto refusals, for the delete op, the Agents routes and a
// closed request's refusal. A code missing here shows as itself.
const AGENT_REASONS = {
'organization-instance-referenced': 'the instance is launch.by or an arbiter; change that reference first',
'organization-instance-kept': 'the instance is kept; unkeep it first',
'organization-instance-retired': 'that name was deleted and stays retired; pick another name',
'instance-kept': 'the instance is kept; unkeep it first',
'instance-running': 'the instance has a run; terminate it first',
'instance-starting': 'a request for the instance has not closed yet; wait for it and try again',
'instance-retired': 'that name was deleted and launches no more',
'not-thread-launch': 'a thread instance starts on a thread message, not by launch',
'regenerate-thread': 'a thread instance isn\'t regenerated; its context is the thread\'s',
'not-held': 'the instance has no run and no open request',
'launch-revoked': 'launches are revoked for the business',
'launches-disabled': 'the bus host was started with --no-launch',
'request-expired': 'the launcher didn\'t take the request within its 60 s',
'request-cancelled': 'a terminate cancelled the request',
'regenerate-timeout': 'the old run didn\'t exit within the request\'s 60 s, so no new run started; it still runs',
'workspace-rename-failed': 'the business file is changed and the name retired, but the workspace stayed where it was; delete it again to retry the rename',
'capacity-full': 'a launch limit is reached',
'limit-reached': 'a launch limit is reached',
'no-route': 'the instance has no harness, model and thinking yet; set its route first',
'unknown-role': 'the business has no such instance',
'unknown-request': 'the bus has no such request',
'instance-not-listed': 'the instance isn\'t on the launch list',
};
const reasonFor = code => Object.hasOwn(INSTANCE_REASONS, code) ? INSTANCE_REASONS[code] : Object.hasOwn(AGENT_REASONS, code) ? AGENT_REASONS[code] : 'refused by the bus';
async function instanceSend(req, res, { instancesWrite, preview, log }) {
let instance = null;
// The instance is quoted and cut short so a crafted value can't forge a log line. Vars are never logged.
const line = outcome => log(`${new Date().toISOString()} instance user=${JSON.stringify(preview.user)} instance=${quoted(instance)} ${outcome}`);
const refuse = (status, error, message, sent = false) => { line(`error=${error}`); return json(res, status, { error, message, sent }); };
if (!preview.tester && !signedIn(req, preview)) return json(res, 401, { error: 'sign-in required', message: 'sign in again', sent: false });
if (one(req, 'origin') !== ownOrigin(preview)) return refuse(403, 'cross-origin request refused', 'this needs the Console\'s own Origin');
if (!preview.tester && one(req, preview.header) !== preview.user) return json(res, 401, { error: 'sign-in required', message: 'sign in again', sent: false });
if (one(req, 'origin') !== (preview.origin ?? `https://${preview.host}`)) return refuse(403, 'cross-origin request refused', 'this needs the Console\'s own Origin');
if (one(req, 'sec-fetch-site') !== 'same-origin') return refuse(403, 'cross-site request refused', 'this needs same-origin Fetch Metadata');
let value;
try { value = JSON.parse((await body(req, 16384)).toString('utf8')); } catch (err) { return refuse(400, 'invalid-request', err.message); }
const has = key => Object.hasOwn(value, key);
// Row 194 (#1692, D7, V5): op delete takes the revision and the instance alone. The bus answers a
// request the launcher's pass carries out, which the page follows at /api/agent/request.
const deleting = value?.op === 'delete';
if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).some(key => !INSTANCE_KEYS.includes(key)) || typeof value.revision !== 'string' || !/^[a-f0-9]{64}$/.test(value.revision) ||
typeof value.instance !== 'string' || !['add', 'change', 'delete'].includes(value.op) || (deleting && ['definition', 'vars', 'launch'].some(has)) || (has('definition') && typeof value.definition !== 'string') ||
if (Object.keys(value).some(key => !INSTANCE_KEYS.includes(key)) || typeof value.revision !== 'string' || !/^[a-f0-9]{64}$/.test(value.revision) ||
typeof value.instance !== 'string' || !['add', 'change'].includes(value.op) || (has('definition') && typeof value.definition !== 'string') ||
(has('vars') && (!value.vars || typeof value.vars !== 'object' || Array.isArray(value.vars))) || (has('launch') && typeof value.launch !== 'boolean')) {
return refuse(400, 'invalid-request', 'this takes a revision, an instance, op add or change and a definition, vars or launch, or op delete alone, nothing else');
return refuse(400, 'invalid-request', 'this takes a revision, an instance, op add or change, and a definition, vars or launch, nothing else');
}
({ instance } = value);
// D4.1: the tester never deletes. The bus refuses it too (broker tester-refused); this makes no call.
if (deleting && preview.tester) return refuse(403, 'tester-refused', 'the tester listener doesn\'t delete. Nothing changed.');
let result;
try { result = await instancesWrite(Object.fromEntries(INSTANCE_KEYS.filter(has).map(k => [k, value[k]]))); } catch (err) {
const { code, sent } = sendOutcome(err);
return sent === false
? refuse(busStatus(code), code, `${code}: ${reasonFor(code)}. Nothing changed.`)
? refuse(busStatus(code), code, `${code}: ${Object.hasOwn(INSTANCE_REASONS, code) ? INSTANCE_REASONS[code] : 'refused by the bus'}. Nothing changed.`)
: refuse(503, 'outcome-unknown', 'the change may have been made; nothing was retried. Read the role instances again before trying', 'unknown');
}
if (deleting) {
if (!REQUEST_ID.test(result?.request ?? '')) return refuse(503, 'outcome-unknown', 'the bus answered in an unexpected shape; the delete may have been asked for. Read the role instances again before trying', 'unknown');
line(`done request=${result.request}`);
return json(res, 200, { request: result.request, at: new Date().toISOString() });
}
const object = o => o && typeof o === 'object' && !Array.isArray(o);
if (!/^[a-f0-9]{64}$/.test(result?.revision ?? '') || typeof result.restart !== 'boolean' || !object(result.definitions) || !object(result.instances) || !result.instances[instance]) {
return refuse(503, 'outcome-unknown', 'the bus answered in an unexpected shape; the change may have been made. Read the role instances again before trying', 'unknown');
@@ -404,68 +361,6 @@ async function instanceSend(req, res, { instancesWrite, preview, log }) {
line(`done restart=${result.restart}`);
return json(res, 200, { ...result, at: new Date().toISOString() });
}
// Row 194 (#1692, D7): the Agents section's launch, regenerate, terminate and veto. Each has
// instanceSend's boundary and one human-transport call. Launch and regenerate answer a request the
// page follows at /api/agent/request until it closes. `tester` marks the routes the tester listener
// lets through (D4.1): launch, terminate and the request read.
const REQUEST_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
const VETO_OPS = ['keep', 'unkeep', 'nohire', 'hire'];
const ids = list => Array.isArray(list) && list.every(x => typeof x === 'string');
const AGENT_ROUTES = {
'/api/agent/launch': { kind: 'launch', verb: 'instance.launch', keys: ['instance'], tester: true, done: r => REQUEST_ID.test(r?.request ?? '') },
'/api/agent/regenerate': { kind: 'regenerate', verb: 'instance.regenerate', keys: ['instance'], tester: false, done: r => REQUEST_ID.test(r?.request ?? '') },
'/api/agent/terminate': { kind: 'terminate', verb: 'instance.end', keys: ['instance', 'reason'], tester: true,
done: r => REQUEST_ID.test(r?.request ?? '') && ids(r.runs) && ids(r.cancelled) && ids(r.taken) },
'/api/veto': { kind: 'veto', verb: 'veto.set', keys: ['revision', 'op', 'name'], tester: false,
done: r => /^[a-f0-9]{64}$/.test(r?.revision ?? '') && r.instances && typeof r.instances === 'object' && !Array.isArray(r.instances) },
};
async function agentSend(req, res, { route, agentWrite, preview, log }) {
let target = null;
// The instance or name is quoted and cut short so a crafted value can't forge a log line. A reason is never logged.
const line = outcome => log(`${new Date().toISOString()} ${route.kind} user=${JSON.stringify(preview.user)} instance=${quoted(target)} ${outcome}`);
const refuse = (status, error, message, sent = false) => { line(`error=${error}`); return json(res, status, { error, message, sent }); };
if (!preview.tester && !signedIn(req, preview)) return json(res, 401, { error: 'sign-in required', message: 'sign in again', sent: false });
if (one(req, 'origin') !== ownOrigin(preview)) return refuse(403, 'cross-origin request refused', 'this needs the Console\'s own Origin');
if (one(req, 'sec-fetch-site') !== 'same-origin') return refuse(403, 'cross-site request refused', 'this needs same-origin Fetch Metadata');
let value;
try { value = JSON.parse((await body(req, 16384)).toString('utf8')); } catch (err) { return refuse(400, 'invalid-request', err.message); }
const required = route.keys.filter(k => k !== 'reason');
if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).some(k => !route.keys.includes(k)) || !required.every(k => Object.hasOwn(value, k)) ||
!Object.values(value).every(v => typeof v === 'string') || (Object.hasOwn(value, 'revision') && !/^[a-f0-9]{64}$/.test(value.revision)) ||
(Object.hasOwn(value, 'op') && !VETO_OPS.includes(value.op))) {
return refuse(400, 'invalid-request', `this takes ${route.keys.join(', ')}${route.kind === 'terminate' ? ' (the reason may be left out)' : ''}, all text, nothing else`);
}
target = value.instance ?? value.name;
let result;
try { result = await agentWrite(route.verb, Object.fromEntries(route.keys.filter(k => Object.hasOwn(value, k)).map(k => [k, value[k]]))); } catch (err) {
const { code, sent } = sendOutcome(err);
return sent === false
? refuse(busStatus(code), code, `${code}: ${reasonFor(code)}. Nothing changed.`)
: refuse(503, 'outcome-unknown', 'the request may have been made; nothing was retried. Read the agents again before trying', 'unknown');
}
if (!route.done(result)) return refuse(503, 'outcome-unknown', 'the bus answered in an unexpected shape; the request may have been made. Read the agents again before trying', 'unknown');
line(result.request ? `done request=${result.request}` : 'done');
return json(res, 200, { ...result, at: new Date().toISOString() });
}
// D1.1 item 5: the read the page polls. A closed request's refusal gains its reason.
const REQUEST_STATES = ['open', 'taken', 'closed'];
async function agentRequest(res, { agentWrite, search }) {
const params = new URLSearchParams(search);
const id = params.get('id');
if ([...params.keys()].some(k => k !== 'id') || params.getAll('id').length !== 1 || !REQUEST_ID.test(id)) {
return json(res, 400, { error: 'invalid-request', message: 'this takes one request id' });
}
let r;
try { r = await agentWrite('instance.request', { request: id }); } catch (err) {
const code = err instanceof BusReadError ? err.code : 'invalid-response';
return json(res, busStatus(code), { error: code, message: `${code}: ${reasonFor(code)}` });
}
if (r?.request !== id || !REQUEST_STATES.includes(r.state) || typeof r.instance !== 'string' || typeof r.action !== 'string') {
return json(res, 502, { error: 'invalid-response', message: 'the bus answered the request read in an unexpected shape' });
}
const refusal = r.done?.refusal ?? (r.cancelled ? 'request-cancelled' : null);
return json(res, 200, { ...r, ...(refusal ? { reason: reasonFor(refusal) } : {}) });
}
// Row 65 (mos-0840): the routing table. The Settings boundary and one bus call, `routes.set`,
// through the human transport. It is human-only and outside ORGANIZATION_KEYS, so the chief of
// staff's tool never reaches it. The bus checks the route rules; a refusal comes back with its code.
@@ -487,8 +382,8 @@ async function routesSend(req, res, { routesWrite, preview, log }) {
let names = [];
const line = outcome => log(`${new Date().toISOString()} routes user=${JSON.stringify(preview.user)} instances=${quoted(names.join(','))} ${outcome}`);
const refuse = (status, error, message, sent = false) => { line(`error=${error}`); return json(res, status, { error, message, sent }); };
if (!preview.tester && !signedIn(req, preview)) return json(res, 401, { error: 'sign-in required', message: 'sign in again', sent: false });
if (one(req, 'origin') !== ownOrigin(preview)) return refuse(403, 'cross-origin request refused', 'this needs the Console\'s own Origin');
if (!preview.tester && one(req, preview.header) !== preview.user) return json(res, 401, { error: 'sign-in required', message: 'sign in again', sent: false });
if (one(req, 'origin') !== (preview.origin ?? `https://${preview.host}`)) return refuse(403, 'cross-origin request refused', 'this needs the Console\'s own Origin');
if (one(req, 'sec-fetch-site') !== 'same-origin') return refuse(403, 'cross-site request refused', 'this needs same-origin Fetch Metadata');
let value;
try { value = JSON.parse((await body(req, 16384)).toString('utf8')); } catch (err) { return refuse(400, 'invalid-request', err.message); }
@@ -525,8 +420,8 @@ async function projectFileSend(req, res, { projectSave, preview, log }) {
const line = outcome => log(`${new Date().toISOString()} project-file user=${JSON.stringify(preview.user)} project=${quoted(target?.project)} path=${quoted(target?.path)} ${outcome}`);
// `sent` tells the page which it was: false, nothing was written; 'unknown', it may have been.
const refuse = (status, error, message, sent = false) => { line(`error=${error}`); return json(res, status, { error, message, sent }); };
if (!signedIn(req, preview)) return json(res, 401, { error: 'sign-in required', message: 'sign in again', sent: false });
if (one(req, 'origin') !== ownOrigin(preview)) return refuse(403, 'cross-origin request refused', 'a save needs the Console\'s own Origin');
if (one(req, preview.header) !== preview.user) return json(res, 401, { error: 'sign-in required', message: 'sign in again', sent: false });
if (one(req, 'origin') !== `https://${preview.host}`) return refuse(403, 'cross-origin request refused', 'a save needs the Console\'s own Origin');
if (one(req, 'sec-fetch-site') !== 'same-origin') return refuse(403, 'cross-site request refused', 'a save needs same-origin Fetch Metadata');
let value;
try { value = JSON.parse((await body(req, FILE_SAVE_MAX)).toString('utf8')); } catch (err) { return refuse(400, 'invalid-request', err.message); }
@@ -565,18 +460,13 @@ async function projectFileRead(res, read, path, search) {
return json(res, busStatus(code), { error: code, message: err instanceof BusReadError ? err.message : code });
}
}
// `send` is the bus write (row 76): `args => Promise<{id}>`, only with the preview mode or `sso`.
// `sso` is createSso() from sso.mjs (Q175 phase 1): the human listener signs people in itself.
// `testerNoSignIn` makes the tester listener refuse anything that looks like a sign-in (--sso). `log`
// `send` is the bus write (row 76): `args => Promise<{id}>`, only with the preview mode. `log`
// takes one line per send attempt that got past sign-in; never the body. `fieldsFile` is for
// tests; the field table is otherwise the committed packages/tasks/fields.json.
// `agentWrite` (row 194, D7) is `(verb, args) => Promise` for the Agents section's verbs and instance.request.
export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null, preview = null, sso = null, tester = false, testerNoSignIn = false, testerAuthHeader = null, proof = null, testerProofHeader = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, log = line => console.log(line), fieldsFile = undefined } = {}) {
export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null, preview = null, tester = false, testerAuthHeader = null, proof = null, testerProofHeader = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, log = line => console.log(line), fieldsFile = undefined } = {}) {
if (!isLoopback(host)) throw new Error('refusing to bind to non-loopback host');
if (host === 'localhost') host = '127.0.0.1';
if (tester && preview !== null) throw new Error('tester listener cannot use preview mode');
if (tester && sso !== null) throw new Error('tester listener cannot use sso');
if (sso !== null && (preview !== null || proof !== null)) throw new Error('--sso cannot be combined with --auth-header, --allow-user or --proof-*');
if (tester && host !== '127.0.0.1') throw new Error('tester listener must bind 127.0.0.1');
// Row 177 (#1675): chat is allowed. The tester listener's chat binds the tester, so the bus keeps it on tester threads.
if (tester && [send, settingsWrite, projectSave, assistantStop].some(value => value !== null)) throw new Error('tester listener permits only instance, route, name and chat writes');
@@ -586,26 +476,21 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
throw new Error('preview mode needs --public-host, --auth-header and --allow-user together');
}
if (proof !== null && (preview === null || tester)) throw new Error('proxy proof needs preview mode');
if (send !== null && ((preview === null && sso === null) || typeof send !== 'function')) throw new Error('--send needs the preview mode (--public-host, --auth-header and --allow-user) or --sso');
if (send !== null && (preview === null || typeof send !== 'function')) throw new Error('--send needs the preview mode (--public-host, --auth-header and --allow-user)');
const server = createServer(async (req, res) => {
res.setHeader('x-content-type-options', 'nosniff');
res.setHeader('referrer-policy', 'no-referrer');
// Under --sso the Sign out form's POST is redirected to the provider's end-session endpoint, and
// browsers apply form-action to that redirect, so those origins (from discovery) are allowed too.
res.setHeader('content-security-policy', `default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; font-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'${sso ? sso.formTargets().map(o => ` ${o}`).join('') : ''}`);
res.setHeader('content-security-policy', "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; font-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'");
// Presence, even with an empty value: never turn the public listener into a tester ingress.
if (!tester && Object.hasOwn(req.headers, 'x-mosaic-tester')) return json(res, 403, { error: 'tester listener required' });
if (tester && testerAuthHeader && Object.hasOwn(req.headers, testerAuthHeader.toLowerCase())) return json(res, 403, { error: 'proxy header refused' });
if (tester && testerProofHeader && Object.hasOwn(req.headers, testerProofHeader.toLowerCase())) return json(res, 403, { error: 'proxy proof header refused' });
// Under --sso the tester listener reads no cookie and takes no sign-in of any kind (design section 6).
if (tester && testerNoSignIn && (Object.hasOwn(req.headers, 'authorization') || SIGN_IN_COOKIE.test(req.headers.cookie ?? '') || Object.keys(req.headers).some(h => h.startsWith('x-authentik-'))))
return json(res, 403, { error: 'tester listener takes no sign-in' });
let path, search;
// Node keeps the first of repeated Host headers; a second one (a loopback Host, then NAME) is
// never legitimate, so it is refused before any check reads the Host.
if ((req.headersDistinct.host?.length ?? 0) > 1) return json(res, 400, { error: 'repeated Host refused' });
// Preview mode: a request for the public name, exactly, as the proxy forwards it.
const named = preview !== null ? (req.headers.host ?? '').toLowerCase() === preview.host : sso !== null && (req.headers.host ?? '').toLowerCase() === sso.publicHost;
const named = preview !== null && (req.headers.host ?? '').toLowerCase() === preview.host;
// The proof precedes every auth-header read, including the wrong-Host fence.
if (proof !== null) {
const values = req.headersDistinct[proof.header];
@@ -627,35 +512,19 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
}
({ pathname: path, search } = new URL(req.url, 'http://localhost'));
} catch { return json(res, 400, { error: 'invalid URL' }); }
// Under --sso the human listener serves only the public Host, whatever its port; GET /healthz
// alone stays open on a loopback Host for the status and release gates (Q-C).
if (sso !== null && !named && !(path === '/healthz' && req.method === 'GET')) return json(res, 403, { error: 'public Host required' });
// No CORS. JSON content type and same-origin checks keep browser forms out.
if (req.headers.origin && req.headers.origin !== (named ? (sso ? sso.publicOrigin : `https://${preview.host}`) : `http://${req.headers.host}`)) return json(res, 403, { error: 'cross-origin request refused' });
let signer = preview;
if (sso !== null && named) {
if (path.startsWith('/auth/')) return sso.handle(req, res, path, search);
// Only a page load or a write keeps a session alive; the page's background reads don't (Q-D).
const session = path === '/healthz' ? null : sso.session(req, { touch: req.method === 'POST' || (req.method === 'GET' && !path.startsWith('/api/')) });
if (!session && path !== '/healthz') {
if (path.startsWith('/api/') || req.method !== 'GET') return json(res, 401, { error: 'sign-in required' });
res.writeHead(303, { location: `/auth/login?return=${encodeURIComponent(path + search)}`, 'cache-control': 'no-store' });
return res.end();
}
if (session) signer = { sso: true, user: session.name, host: sso.publicHost, origin: sso.publicOrigin };
}
if (named && preview !== null) {
if (req.headers.origin && req.headers.origin !== (named ? `https://${preview.host}` : `http://${req.headers.host}`)) return json(res, 403, { error: 'cross-origin request refused' });
if (named) {
// One value, equal to the user id; the proxy strips client copies and sets it after sign-in.
const values = req.headersDistinct[preview.header];
if (!values || values.length !== 1 || values[0] !== preview.user) return json(res, 401, { error: 'sign-in required' });
}
const writer = tester ? { tester: true, user: 'tester', origin: `http://127.0.0.1:${server.address().port}` } : signer;
// Row 194 (#1692, D4.1): the tester also launches and terminates.
if (tester && !['GET', 'HEAD'].includes(req.method) && !['/api/instances', '/api/routes', '/api/name', '/api/agent/launch', '/api/agent/terminate'].includes(path) && !TESTER_CHAT_WRITE.test(path))
const writer = tester ? { tester: true, user: 'tester', origin: `http://127.0.0.1:${server.address().port}` } : preview;
if (tester && !['GET', 'HEAD'].includes(req.method) && !['/api/instances', '/api/routes', '/api/name'].includes(path) && !TESTER_CHAT_WRITE.test(path))
return json(res, 403, { error: 'read-only', sent: false });
if(path==='/api/settings'&&req.method==='POST'){
if(!send||!settingsWrite||!named)return json(res,403,{error:'read-only',sent:false});
return settingsSend(req,res,{settingsWrite,preview:signer,log});
return settingsSend(req,res,{settingsWrite,preview,log});
}
if (Object.hasOwn(SEAT_ROUTES, path)) {
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' });
@@ -668,17 +537,6 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
if ((!tester && (!send || !named)) || !instancesWrite) return json(res, 403, { error: 'read-only', sent: false });
return instanceSend(req, res, { instancesWrite, preview: writer, log });
}
// Row 194 (#1692, D7): the Agents section. No route gets a --send exception.
if (Object.hasOwn(AGENT_ROUTES, path)) {
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' });
if ((!tester && (!send || !named)) || (tester && !AGENT_ROUTES[path].tester) || !agentWrite) return json(res, 403, { error: 'read-only', sent: false });
return agentSend(req, res, { route: AGENT_ROUTES[path], agentWrite, preview: writer, log });
}
if (path === '/api/agent/request') {
if (req.method !== 'GET') return json(res, 405, { error: 'method not allowed' });
if ((!tester && (!send || !named)) || !agentWrite) return json(res, 403, { error: 'read-only', sent: false });
return agentRequest(res, { agentWrite, search });
}
if (path === '/api/routes') {
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' });
if ((!tester && (!send || !named)) || !routesWrite) return json(res, 403, { error: 'read-only', sent: false });
@@ -686,18 +544,18 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
}
if (path === '/api/project-file' && req.method === 'POST') {
if (!send || !projectSave || !named) return json(res, 403, { error: 'read-only', sent: false });
return projectFileSend(req, res, { projectSave, preview: signer, log });
return projectFileSend(req, res, { projectSave, preview, log });
}
// Message sending has its own bounded payload and repeats the identity check.
if (send && path === '/api/bus/message') {
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' });
return busSend(req, res, { send, preview: signer, log });
return busSend(req, res, { send, preview, log });
}
if (path === '/api/chat' || path.startsWith('/api/chat/')) {
return chatHTTP(req, res, { path, search, chat, named, send, preview: writer, log });
}
// Nothing else but GET and HEAD gets past here in preview mode, whatever the Host.
if ((preview !== null || sso !== null) && req.method !== 'GET' && req.method !== 'HEAD') return json(res, 403, { error: 'read-only', message: 'the Console preview is read-only' });
if (preview !== null && req.method !== 'GET' && req.method !== 'HEAD') return json(res, 403, { error: 'read-only', message: 'the Console preview is read-only' });
// The conversation routes (#1507, CHAT-02) keep their query; the board validates it.
const conversation = path === '/api/conversations' || path === '/api/conversation';
const allowed = path === '/api/board' || conversation ? 'GET' : ['/api/seen', '/api/reply'].includes(path) ? 'POST' : null;
@@ -749,8 +607,8 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
if (req.method !== 'GET' && req.method !== 'HEAD') return json(res, 405, { error: 'method not allowed' });
// `send`: the page shows the composer only where a send can pass (the public name, --send on).
// `edit`: the editor shows Save only where a save can pass, the same test as the route's.
if (path === '/api/config') return json(res, 200, { board: upstream, ...(tester ? { tester: true } : {}), ...(sso ? { sso: true } : {}), send: !tester && Boolean(send) && named, edit: !tester && Boolean(send) && Boolean(projectSave) && named });
if (path === '/healthz') return json(res, 200, { ok: true, ...(sso ? { sso: sso.health() } : {}) });
if (path === '/api/config') return json(res, 200, { board: upstream, ...(tester ? { tester: true } : {}), send: !tester && Boolean(send) && named, edit: !tester && Boolean(send) && Boolean(projectSave) && named });
if (path === '/healthz') return json(res, 200, { ok: true });
if (path === '/favicon.ico') { res.writeHead(204); return res.end(); }
const file = files.get(path);
if (!file) return json(res, 404, { error: 'not found' });
-75
View File
@@ -1,75 +0,0 @@
// Q175 phase 1 (#1690): the shape of config.json's optional `console` key. The one loader,
// scripts/mosaic-config.mjs, runs this on every read, so a bad key refuses the config for every
// tool (design section 4, C1). The Console's --sso start runs it again before it uses the value.
// Shape only: nothing here touches the network or a slot file. A refusal names the key path and
// never quotes a value, so a refusal can't print a client id or a subject.
// The asymmetric algorithms oauth4webapi 3.8.8 verifies (its supported(), build/index.js). No HS*
// and no none, so a client secret can never be a signing key.
export const ALGS = Object.freeze(['PS256', 'ES256', 'RS256', 'PS384', 'ES384', 'RS384', 'PS512', 'ES512', 'RS512', 'Ed25519', 'EdDSA', 'ML-DSA-44', 'ML-DSA-65', 'ML-DSA-87']);
const PROVIDER_ID = /^[a-z0-9-]{1,32}$/;
const SLOT = /^[a-z0-9-]{1,64}$/;
const SUB = /^[\x21-\x7e]{1,255}$/;
const CLIENT_ID = /^[\x21-\x7e]{1,255}$/;
// The name goes where the preview's --allow-user went (serve.mjs USER): visible ASCII, no comma.
const NAME = /^[\x21-\x2b\x2d-\x7e]{1,64}$/;
// The bus keeps these for the tester and the human role (section 6), so no subject can sign in as one.
const RESERVED_NAMES = ['tester', 'human'];
const object = v => typeof v === 'object' && v !== null && !Array.isArray(v);
function refuse(where, why) {
throw new Error(`${where} ${why}`);
}
function keys(value, where, allowed, required = allowed) {
if (!object(value)) refuse(where, 'must be a JSON object');
for (const key of Object.keys(value)) if (!allowed.includes(key)) refuse(`${where}.${key}`, 'is not a supported key');
for (const key of required) if (!Object.hasOwn(value, key)) refuse(`${where}.${key}`, 'is required');
}
function httpsURL(value, where) {
let url;
if (typeof value !== 'string' || value.length > 512) refuse(where, 'must be an https URL of at most 512 characters');
try { url = new URL(value); } catch { refuse(where, 'must be an https URL'); }
if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash || value.includes('?') || value.includes('#')) {
refuse(where, 'must be https with no credentials, query or fragment');
}
return url;
}
// Returns the value unchanged when it passes; throws an Error naming the bad key path otherwise.
export function checkConsole(value, where = 'console') {
keys(value, where, ['sso']);
const sso = value.sso, at = `${where}.sso`;
keys(sso, at, ['publicOrigin', 'providers']);
const origin = httpsURL(sso.publicOrigin, `${at}.publicOrigin`);
if (origin.origin !== sso.publicOrigin) refuse(`${at}.publicOrigin`, 'must be an https origin with no path, query, fragment or trailing slash');
if (!Array.isArray(sso.providers) || sso.providers.length === 0 || sso.providers.length > 8) refuse(`${at}.providers`, 'must be a list of 1 to 8 providers');
const ids = new Set();
sso.providers.forEach((p, i) => {
const pat = `${at}.providers[${i}]`;
keys(p, pat, ['id', 'issuer', 'clientId', 'clientSecret', 'algs', 'subjects']);
if (typeof p.id !== 'string' || !PROVIDER_ID.test(p.id)) refuse(`${pat}.id`, 'must match [a-z0-9-]{1,32}');
if (ids.has(p.id)) refuse(`${pat}.id`, 'repeats an earlier provider id');
ids.add(p.id);
httpsURL(p.issuer, `${pat}.issuer`);
if (typeof p.clientId !== 'string' || !CLIENT_ID.test(p.clientId)) refuse(`${pat}.clientId`, 'must be 1 to 255 visible characters');
keys(p.clientSecret, `${pat}.clientSecret`, ['slot']);
if (typeof p.clientSecret.slot !== 'string' || !SLOT.test(p.clientSecret.slot)) refuse(`${pat}.clientSecret.slot`, 'must match [a-z0-9-]{1,64}');
if (!Array.isArray(p.algs) || p.algs.length === 0) refuse(`${pat}.algs`, 'must be a non-empty list');
p.algs.forEach((alg, j) => {
if (!ALGS.includes(alg)) refuse(`${pat}.algs[${j}]`, `must be one of ${ALGS.join(', ')}; HS* and none are refused`);
});
if (new Set(p.algs).size !== p.algs.length) refuse(`${pat}.algs`, 'repeats an algorithm');
if (!Array.isArray(p.subjects) || p.subjects.length === 0 || p.subjects.length > 64) refuse(`${pat}.subjects`, 'must be a list of 1 to 64 subjects');
const subs = new Set();
p.subjects.forEach((s, j) => {
const sat = `${pat}.subjects[${j}]`;
keys(s, sat, ['sub', 'name']);
if (typeof s.sub !== 'string' || !SUB.test(s.sub)) refuse(`${sat}.sub`, 'must be 1 to 255 visible characters');
if (subs.has(s.sub)) refuse(`${sat}.sub`, 'repeats an earlier subject');
subs.add(s.sub);
if (typeof s.name !== 'string' || !NAME.test(s.name)) refuse(`${sat}.name`, 'must be 1 to 64 visible characters');
if (RESERVED_NAMES.includes(s.name.toLowerCase())) refuse(`${sat}.name`, `must not be ${RESERVED_NAMES.join(' or ')}`);
});
});
return value;
}
-347
View File
@@ -1,347 +0,0 @@
// Q175 phase 1 (#1690, design agents/sage/work/q175-sso/DESIGN.md): the Console as an OIDC relying
// party. Used only by the human listener under --sso; startServer refuses it on the tester listener.
// Every URL handed to a provider is built from the configured publicOrigin, never from a request's
// Host. A refusal logs one fixed code and the provider id, never an exception, its message or its
// cause: oauth4webapi's errors carry token bodies and claims there.
import { createHash, randomBytes } from 'node:crypto';
import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, realpathSync } from 'node:fs';
import { homedir } from 'node:os';
import { join, relative, isAbsolute, resolve } from 'node:path';
import { checkConsole } from './sso-config.mjs';
// oauth4webapi loads in createSso, not here: cli.mjs imports this module on every start, and a
// Console without --sso must not need the package (pinned-copy.test.mjs builds copies whose lock
// installs nothing). A real copy installs it from the root lock.
let oauth;
export const SESSION_COOKIE = '__Host-mosaic_session';
export const LOGIN_COOKIE = '__Host-mosaic_login';
const ATTRS = 'Path=/; Secure; HttpOnly; SameSite=Lax';
const LOGIN_MAX_AGE = 600;
const SESSION_ABSOLUTE = 8 * 3600 * 1000, SESSION_IDLE = 3600 * 1000;
const MAX_SESSIONS = 64, MAX_LOGINS = 32;
const FETCH_TIMEOUT = 10000;
const RETRY_CAP = 300000;
const CODES = new Set(['login-record-missing', 'state-mismatch', 'auth-error-response', 'grant-failed', 'id-token-invalid', 'signature-invalid', 'alg-refused', 'issuer-mismatch', 'subject-unknown', 'discovery-failed', 'jwks-failed']);
const sha256 = value => createHash('sha256').update(value).digest('hex');
const escape = text => String(text).replace(/[&<>"']/g, c => `&#${c.charCodeAt(0)};`);
const under = (parent, child) => { const rel = relative(parent, child); return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel)); };
// The slot file holding a provider's client secret (design section 4, D1). Only the Console's --sso
// start calls this. A refusal names the provider and the check, never the path's contents.
export function readSlot(configDir, provider, { repo, checkout = null, copies = join(process.env.XDG_DATA_HOME || join(homedir(), '.local', 'share'), 'mosaic-stack', 'copies') } = {}) {
const where = `console.sso provider ${provider.id} client secret slot`;
const file = join(resolve(configDir), 'secrets', 'mosaic-stack', provider.clientSecret.slot);
let st;
try { st = lstatSync(file); } catch { throw new Error(`${where} is missing`); }
if (st.isSymbolicLink()) throw new Error(`${where} must not be a symbolic link`);
let fd, real, text;
try {
fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
st = fstatSync(fd);
if (!st.isFile()) throw new Error(`${where} must be a regular file`);
if (st.uid !== process.getuid()) throw new Error(`${where} must be owned by the Console's user`);
if (st.mode & 0o077) throw new Error(`${where} must have no group or other permission bits`);
real = realpathSync(file);
for (const root of [repo, checkout, copies].filter(Boolean)) {
let base;
try { base = realpathSync(root); } catch { base = resolve(root); }
if (under(base, real)) throw new Error(`${where} must not be inside the repository or a stack copy`);
}
text = readFileSync(fd, 'utf8');
} catch (err) {
throw err.message?.startsWith(where) ? err : new Error(`${where} is not readable`);
} finally {
if (fd !== undefined) closeSync(fd);
}
const secret = text.endsWith('\n') ? text.slice(0, -1) : text;
if (!secret || /[\r\n]/.test(secret)) throw new Error(`${where} must hold one non-empty line`);
return secret;
}
export function cookies(req, name) {
const found = [];
for (const part of (req.headers.cookie ?? '').split(';')) {
const at = part.indexOf('=');
if (at > 0 && part.slice(0, at).trim() === name) found.push(part.slice(at + 1).trim());
}
return found;
}
// The JWKS check at discovery, the same filter the library's key selection uses (kty, alg, use,
// key_ops, crv; RSA at least 2048 bits), without a kid since no token is at hand yet.
function usableKey(jwk, alg) {
const kty = { RS: 'RSA', PS: 'RSA', ES: 'EC', Ed: 'OKP', ML: 'AKP' }[alg.slice(0, 2)];
if (jwk === null || typeof jwk !== 'object' || jwk.kty !== kty) return false;
if (jwk.alg !== undefined && jwk.alg !== alg) return false;
if (jwk.use !== undefined && jwk.use !== 'sig') return false;
if (Array.isArray(jwk.key_ops) && !jwk.key_ops.includes('verify')) return false;
const crv = { ES256: 'P-256', ES384: 'P-384', ES512: 'P-521', Ed25519: 'Ed25519', EdDSA: 'Ed25519' }[alg];
if (crv && jwk.crv !== crv) return false;
if (kty === 'RSA' && (typeof jwk.n !== 'string' || Buffer.from(jwk.n, 'base64url').length * 8 < 2048)) return false;
return true;
}
class Unreachable extends Error {}
class Refused extends Error {}
const https = value => { try { return new URL(value).protocol === 'https:'; } catch { return false; } };
// One provider's discovery and JWKS. Throws Unreachable (network, timeout, a status other than 200,
// a body that isn't JSON) or Refused (a JSON document that fails a check). Q-B, design section 4.
async function discover(p, fetchOptions) {
let response, text;
const options = () => ({ ...fetchOptions, signal: AbortSignal.timeout(FETCH_TIMEOUT) });
try {
response = await oauth.discoveryRequest(new URL(p.issuer), options());
if (response.status !== 200 || !/^application\/json\b/i.test(response.headers.get('content-type') ?? '')) throw new Error();
text = await response.text();
JSON.parse(text);
} catch { throw new Unreachable(); }
let as;
try {
as = await oauth.processDiscoveryResponse(new URL(p.issuer), new Response(text, { status: 200, headers: { 'content-type': 'application/json' } }));
} catch { throw new Refused(); }
if (as.issuer !== p.issuer) throw new Refused();
if (!Array.isArray(as.id_token_signing_alg_values_supported) || !p.algs.some(a => as.id_token_signing_alg_values_supported.includes(a))) throw new Refused();
if (!Array.isArray(as.code_challenge_methods_supported) || !as.code_challenge_methods_supported.includes('S256')) throw new Refused();
if (![as.authorization_endpoint, as.token_endpoint, as.jwks_uri].every(https)) throw new Refused();
if (as.end_session_endpoint !== undefined && !https(as.end_session_endpoint)) throw new Refused();
let jwks;
try {
const fetcher = fetchOptions[oauth.customFetch] ?? fetch;
const res = await fetcher(as.jwks_uri, { method: 'GET', headers: { accept: 'application/json, application/jwk-set+json' }, redirect: 'manual', signal: AbortSignal.timeout(FETCH_TIMEOUT) });
if (res.status !== 200) throw new Error();
jwks = JSON.parse(await res.text());
} catch { throw new Unreachable(); }
if (jwks === null || typeof jwks !== 'object' || !Array.isArray(jwks.keys)) throw new Refused();
const usable = p.algs.filter(alg => jwks.keys.some(k => usableKey(k, alg)));
if (!usable.length) throw new Refused();
return as;
}
// `config` is console.sso, checked again here; `secrets` maps provider id to its client secret
// (readSlot). `fetch` is for tests: a fetch that trusts only the fake OP's certificate.
// `retryFirst` is for tests too; production waits 5 s, doubling, capped at 5 minutes.
export async function createSso(config, { secrets, fetch: customFetch = null, log = line => console.log(line), retryFirst = 5000 } = {}) {
checkConsole({ sso: config });
oauth ??= await import('oauth4webapi');
const publicOrigin = config.publicOrigin;
const publicHost = new URL(publicOrigin).host;
const redirectUri = `${publicOrigin}/auth/callback`;
const fetchOptions = customFetch ? { [oauth.customFetch]: customFetch } : {};
const sessions = new Map(), logins = new Map();
const timers = new Set();
let closed = false;
const refuse = (code, provider, extra = '') => {
if (!CODES.has(code)) throw new Error(`unknown sso log code ${code}`);
log(`${new Date().toISOString()} sso refused code=${code} provider=${provider}${extra}`);
};
const providers = new Map(config.providers.map(p => {
if (typeof secrets?.get(p.id) !== 'string') throw new Error(`console.sso provider ${p.id} has no client secret`);
return [p.id, {
config: p, state: 'unreachable', as: null, jwks: {},
client: { client_id: p.clientId, id_token_signed_response_alg: [...p.algs] },
auth: oauth.ClientSecretBasic(secrets.get(p.id)),
subjects: new Map(p.subjects.map(s => [s.sub, s.name])),
}];
}));
async function attempt(entry, atStart) {
try {
entry.as = await discover(entry.config, fetchOptions);
entry.jwks = {};
entry.state = 'ready';
} catch (err) {
if (err instanceof Refused) {
entry.state = 'refused';
refuse('discovery-failed', entry.config.id);
if (atStart) throw new Error(`console.sso provider ${entry.config.id} refused at discovery: the issuer, algorithms, S256 or JWKS check failed`);
return;
}
entry.state = 'unreachable';
refuse('discovery-failed', entry.config.id);
throw err;
}
}
function retry(entry, delay) {
if (closed) return;
const timer = setTimeout(async () => {
timers.delete(timer);
try { await attempt(entry, false); } catch { retry(entry, Math.min(delay * 2, RETRY_CAP)); }
}, delay);
timer.unref();
timers.add(timer);
}
for (const entry of providers.values()) {
try { await attempt(entry, true); } catch (err) {
if (!(err instanceof Unreachable)) throw err;
retry(entry, retryFirst);
}
}
const page = (res, status, title, html, headers = {}) => {
res.writeHead(status, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', ...headers });
res.end(`<!doctype html><meta charset="utf-8"><title>${escape(title)}</title><main style="font-family:sans-serif;max-width:32rem;margin:4rem auto"><h1>${escape(title)}</h1>${html}</main>\n`);
};
const redirect = (res, location, setCookie = []) => {
res.writeHead(303, { location, 'cache-control': 'no-store', ...(setCookie.length ? { 'set-cookie': setCookie } : {}) });
res.end();
};
const failed = (res, setCookie = []) => page(res, 403, 'Sign-in failed', '<p><a href="/auth/login">Sign in again</a></p>', setCookie.length ? { 'set-cookie': setCookie } : {});
const expire = name => `${name}=; ${ATTRS}; Max-Age=0`;
const remember = (map, key, value, cap) => {
while (map.size >= cap) map.delete(map.keys().next().value);
map.set(key, value);
};
// B2: only a URL on publicOrigin itself; anything else, or nothing, is publicOrigin's `/`. The
// Location is absolute and its origin checked again: dot segments can leave a pathname of `//host`,
// which as a relative Location a browser reads as another origin (webby-0019 B1, sage-1403).
function returnURL(ret) {
const home = `${publicOrigin}/`;
try {
if (typeof ret !== 'string' || !ret) return home;
const u = new URL(ret, publicOrigin);
if (u.origin !== publicOrigin) return home;
const out = new URL(`${publicOrigin}${u.pathname}${u.search}`);
return out.origin === publicOrigin ? out.href : home;
} catch { return home; }
}
async function start(res, entry, ret) {
const state = oauth.generateRandomState(), nonce = oauth.generateRandomNonce(), verifier = oauth.generateRandomCodeVerifier();
const challenge = await oauth.calculatePKCECodeChallenge(verifier);
const id = randomBytes(32).toString('base64url');
remember(logins, sha256(id), { provider: entry.config.id, state, nonce, verifier, ret: typeof ret === 'string' ? ret.slice(0, 2048) : '/', expires: Date.now() + LOGIN_MAX_AGE * 1000 }, MAX_LOGINS);
const url = new URL(entry.as.authorization_endpoint);
for (const [k, v] of Object.entries({ client_id: entry.config.clientId, response_type: 'code', scope: 'openid profile email', redirect_uri: redirectUri, state, nonce, code_challenge: challenge, code_challenge_method: 'S256' })) url.searchParams.set(k, v);
return redirect(res, url.href, [`${LOGIN_COOKIE}=${id}; ${ATTRS}; Max-Age=${LOGIN_MAX_AGE}`]);
}
function login(res, query) {
const asked = query.get('provider');
const ret = query.get('return');
if (asked === null && providers.size > 1) {
const items = [...providers.values()].map(e => `<li><a href="/auth/login?${escape(new URLSearchParams({ provider: e.config.id, ...(ret ? { return: ret } : {}) }))}">${escape(e.config.id)}</a></li>`).join('');
return page(res, 200, 'Sign in', `<ul>${items}</ul>`);
}
const entry = asked === null ? [...providers.values()][0] : providers.get(asked);
if (!entry) return page(res, 404, 'Unknown sign-in provider', '<p><a href="/auth/login">Sign in</a></p>');
if (entry.state !== 'ready') return page(res, 503, 'sign-in unavailable', '<p>The sign-in provider can\'t be reached. Try again shortly.</p>');
return start(res, entry, ret);
}
// Design section 1, steps 2-7. Each refusal ends here with one log line and the 403 page.
async function callback(res, query, req) {
const clear = [expire(LOGIN_COOKIE)];
const ids = cookies(req, LOGIN_COOKIE);
const key = ids.length === 1 ? sha256(ids[0]) : null;
const record = key === null ? undefined : logins.get(key);
if (key !== null) logins.delete(key);
// The provider comes from the record, never from the query.
const entry = record && providers.get(record.provider);
if (!record || record.expires <= Date.now() || !entry) {
refuse('login-record-missing', record?.provider ?? 'none');
return failed(res, clear);
}
const pid = entry.config.id;
if (entry.state !== 'ready') { refuse('discovery-failed', pid); return failed(res, clear); }
let params;
try { params = oauth.validateAuthResponse(entry.as, entry.client, query, record.state); } catch (err) {
refuse(err instanceof oauth.AuthorizationResponseError ? 'auth-error-response' : 'state-mismatch', pid);
return failed(res, clear);
}
const options = () => ({ ...fetchOptions, [oauth.jwksCache]: entry.jwks, signal: AbortSignal.timeout(FETCH_TIMEOUT) });
let response;
try { response = await oauth.authorizationCodeGrantRequest(entry.as, entry.client, entry.auth, params, redirectUri, record.verifier, options()); } catch {
refuse('grant-failed', pid); return failed(res, clear);
}
let result, claims;
try {
result = await oauth.processAuthorizationCodeResponse(entry.as, entry.client, response, { expectedNonce: record.nonce, requireIdToken: true });
claims = oauth.getValidatedIdTokenClaims(result);
} catch (err) {
refuse(err instanceof oauth.ResponseBodyError ? 'grant-failed' : 'id-token-invalid', pid);
return failed(res, clear);
}
// Step 5: the library skips this for a token-endpoint ID token; point 2 needs it, on the same
// Response object. Its throw ends the callback.
try { await oauth.validateApplicationLevelSignature(entry.as, response, options()); } catch (err) {
const fetching = !(err instanceof oauth.OperationProcessingError || err instanceof oauth.UnsupportedOperationError) || [oauth.RESPONSE_IS_NOT_CONFORM, oauth.RESPONSE_IS_NOT_JSON].includes(err.code);
refuse(fetching ? 'jwks-failed' : 'signature-invalid', pid);
return failed(res, clear);
}
// Step 6: our own checks on top of the library's.
let header;
try { header = JSON.parse(Buffer.from(result.id_token.split('.')[0], 'base64url').toString('utf8')); } catch { header = null; }
if (!header || !entry.config.algs.includes(header.alg)) { refuse('alg-refused', pid); return failed(res, clear); }
if (claims.iss !== entry.config.issuer) { refuse('issuer-mismatch', pid); return failed(res, clear); }
const name = typeof claims.sub === 'string' ? entry.subjects.get(claims.sub) : undefined;
if (name === undefined) {
refuse('subject-unknown', pid, ` iss=${JSON.stringify(String(claims.iss).slice(0, 255))} sub=${JSON.stringify(String(claims.sub).slice(0, 255))}`);
return failed(res, clear);
}
// Step 7: the session, then the return path.
const id = randomBytes(32).toString('base64url'), now = Date.now();
remember(sessions, sha256(id), { provider: pid, iss: claims.iss, sub: claims.sub, name, idToken: result.id_token, created: now, seen: now }, MAX_SESSIONS);
return redirect(res, returnURL(record.ret), [...clear, `${SESSION_COOKIE}=${id}; ${ATTRS}`]);
}
// The session for this request, or null. `touch` is true only for a page load or a write (Q-D).
function session(req, { touch = false } = {}) {
const ids = cookies(req, SESSION_COOKIE);
if (ids.length !== 1) return null;
const key = sha256(ids[0]);
const s = sessions.get(key);
if (!s) return null;
const now = Date.now();
if (now - s.created >= SESSION_ABSOLUTE || now - s.seen >= SESSION_IDLE) { sessions.delete(key); return null; }
if (touch) s.seen = now;
return s;
}
function logout(req, res) {
const one = name => (req.headersDistinct[name]?.length === 1 ? req.headersDistinct[name][0] : null);
if (one('origin') !== publicOrigin || one('sec-fetch-site') !== 'same-origin') {
return page(res, 403, 'Sign-out refused', '<p>Sign out from the Console page.</p>');
}
const ids = cookies(req, SESSION_COOKIE);
const key = ids.length === 1 ? sha256(ids[0]) : null;
const s = key === null ? undefined : sessions.get(key);
if (key !== null) sessions.delete(key);
const entry = s && providers.get(s.provider);
let location = `${publicOrigin}/auth/signed-out`;
if (entry?.as?.end_session_endpoint) {
const url = new URL(entry.as.end_session_endpoint);
url.searchParams.set('id_token_hint', s.idToken);
url.searchParams.set('post_logout_redirect_uri', `${publicOrigin}/auth/signed-out`);
url.searchParams.set('client_id', entry.config.clientId);
location = url.href;
}
return redirect(res, location, [expire(SESSION_COOKIE)]);
}
// `/auth/*` on the human listener; the caller has already checked the Host.
async function handle(req, res, path, search) {
const query = new URLSearchParams(search);
if (path === '/auth/logout') {
if (req.method !== 'POST') return page(res, 405, 'Method not allowed', '');
return logout(req, res);
}
if (req.method !== 'GET') return page(res, 405, 'Method not allowed', '');
if (path === '/auth/login') return login(res, query);
if (path === '/auth/callback') return callback(res, query, req);
if (path === '/auth/signed-out') return page(res, 200, 'Signed out', '<p><a href="/auth/login">Sign in</a></p>');
return page(res, 404, 'Not found', '');
}
return {
publicOrigin, publicHost, sessions, logins, handle, session, returnURL,
health: () => Object.fromEntries([...providers].map(([id, e]) => [id, e.state])),
formTargets: () => [...new Set([...providers.values()].filter(e => e.as?.end_session_endpoint).map(e => new URL(e.as.end_session_endpoint).origin))],
summary: () => config.providers.map(p => ({ id: p.id, issuer: p.issuer, subjects: p.subjects.length })),
close() { closed = true; for (const t of timers) clearTimeout(t); timers.clear(); },
};
}
@@ -1,97 +0,0 @@
// Row 194 (#1692, D7, T16): Settings' Agents section. Read-only mode shows every action unavailable
// with the instance CLI named. In send mode a launch posts once and follows its request until it
// closes; regenerate and delete take a second click and name what stays; a terminate carries its
// reason; a refusal shows the bus's message and nothing is retried.
import test from 'node:test';
import assert from 'node:assert/strict';
import {browser} from './browser.mjs';
import {startServer} from '../src/serve.mjs';
import {close} from './fixture.mjs';
const hostile='<img src=x onerror="window.injected=true">';
const REV='a'.repeat(64), ID='0b5e8f1c-3a2d-4c6e-9f10-1234567890ab';
const view={business:'acme',revision:REV,launch:true,noHire:['reviewer'],retired:['old-coder'],
instances:{coder:{definition:'coder',vars:{},launched:true,launcher:false,protected:false,kept:false,running:['run-1']},[hostile]:{definition:'coder',vars:{},launched:false,launcher:false,protected:false,kept:true,running:[]},assistant:{definition:'assistant',vars:{},launched:false,launcher:false,protected:true,kept:false,running:[]}},
definitions:{assistant:{services:[],accepted:false},coder:{services:[],accepted:true},reviewer:{services:[],accepted:true}}};
test('row 194 T16 Settings Agents: unavailable read-only, launch follows its request, confirms, reason and refusal',{timeout:90000},async()=>{
const bus={messages:async()=>({messages:[],unread:0}),usage:async()=>{throw Error('no usage');},instances:async()=>view};
const web=await startServer({port:0,board:'http://127.0.0.1:9',bus,reads:{settings:()=>({business:'acme',release:'fixture',notifier:{}})}}),b=await browser();
try{
// POSTs answer from `replies`, request reads from `polls`; both record what was asked.
await b.call('Page.addScriptToEvaluateOnNewDocument',{source:`window.posts=[];window.replies=[];window.polls=[];window.reads=[];window.enabled=new URL(location.href).searchParams.has('send');const original=window.fetch;window.fetch=async(url,options={})=>{if(url==='/api/config')return new Response(JSON.stringify({send:window.enabled}));if(options.method==='POST'&&/^\\/api\\/(agent|veto|instances)/.test(url)){posts.push([url,JSON.parse(options.body)]);const [status,body]=replies.shift();return new Response(JSON.stringify(body),{status});}if(String(url).startsWith('/api/agent/request')){reads.push(url);const [status,body]=polls.shift()??[200,{request:'${ID}',action:'launch',instance:'coder',state:'open',done:null,cancelled:null}];return new Response(JSON.stringify(body),{status});}return original(url,options);};`});
const port=web.address().port;
const wait=expr=>b.evaluate(`(async()=>{for(let n=0;n<400;n++){if(${expr})return true;await new Promise(r=>setTimeout(r,30));}throw Error('wait failed: '+${JSON.stringify(expr)});})()`);
const receipt=()=>b.evaluate('document.querySelector("#agents-receipt").textContent');
const click=sel=>b.evaluate(`(()=>{document.querySelector(${JSON.stringify(sel)}).click();return true;})()`);
// Read-only: the table, the unavailable line, every action disabled, nothing injected.
await b.navigate(`http://127.0.0.1:${port}/#/settings`);
await wait('!!document.querySelector("#agents-table")');
assert.deepEqual(await b.evaluate("[...document.querySelectorAll('#agents-table tbody tr')].map(r=>[...r.querySelectorAll('th,td')].slice(0,5).map(c=>c.textContent))"),[
['coder','coder','yes','no','run-1'],[hostile,'coder','no','yes','none'],['assistant','assistant','no','no','none'],
]);
assert.equal(await b.evaluate('window.injected===true'),false);
assert.equal(await b.evaluate('document.querySelector("#agents-unavailable").textContent'),'Agent actions are unavailable here. Use mosaic business instance from the instance CLI.');
assert.equal(await b.evaluate('[...document.querySelectorAll("#agents-section button,#agents-section input,#agents-section select")].every(e=>e.disabled)'),true);
assert.equal(await b.evaluate('document.querySelectorAll("#agents-section [data-agent-action]").length>0'),true);
// The protected assistant has no Delete; the kept instance offers Unkeep; reviewer is no-hire.
assert.equal(await b.evaluate('!!document.querySelector(\'[data-agent-action="delete"][data-agent-name="assistant"]\')'),false);
assert.equal(await b.evaluate(`document.querySelector('[data-agent-action="unkeep"]').dataset.agentName`),hostile);
assert.equal(await b.evaluate(`document.querySelector('[data-agent-action="hire"]').dataset.agentName`),'reviewer');
assert.match(await b.evaluate('document.querySelector("#agents-section").textContent'),/Retired names, never reused: old-coder\./);
// Send mode: a launch posts once, shows the request and follows it to its close.
await b.navigate(`http://127.0.0.1:${port}/?send=1#/settings`);
await wait(`document.querySelector('[data-agent-action="launch"][data-agent-name="coder"]')?.disabled===false`);
assert.equal(await b.evaluate('!!document.querySelector("#agents-unavailable")'),false);
await b.evaluate(`replies.push([200,{request:'${ID}',at:'2026-10-20T00:00:00Z'}]);polls.push([200,{request:'${ID}',action:'launch',instance:'coder',state:'taken',done:null,cancelled:null}],[200,{request:'${ID}',action:'launch',instance:'coder',state:'closed',done:{run:'run-2',transaction:null,refusal:null},cancelled:null}]);true`);
await click('[data-agent-action="launch"][data-agent-name="coder"]');
await wait('!!document.querySelector("#agents-pending")');
assert.match(await b.evaluate('document.querySelector("#agents-pending").textContent'),new RegExp(`Request ${ID} to launch coder`));
assert.equal(await b.evaluate('document.querySelector(\'[data-agent-action="launch"][data-agent-name="coder"]\').disabled'),true,'one request at a time');
await wait('document.querySelector("#agents-receipt")?.textContent==="Launched coder (run run-2)."');
assert.deepEqual(await b.evaluate('posts'),[['/api/agent/launch',{instance:'coder'}]]);
assert.deepEqual(await b.evaluate('reads'),[`/api/agent/request?id=${ID}`,`/api/agent/request?id=${ID}`]);
assert.equal(await b.evaluate('!!document.querySelector("#agents-pending")'),false);
// Regenerate and delete: the first click only asks; Cancel asks nothing; the confirm posts.
await click('[data-agent-action="regenerate"][data-agent-name="coder"]');
await wait('!!document.querySelector("[data-agent-confirmed]")');
assert.match(await b.evaluate('document.querySelector(".agent-confirm").textContent'),/Regenerate coder\?/);
await click('[data-agent-cancel]');
await wait('!document.querySelector("[data-agent-confirmed]")');
await click('[data-agent-action="delete"][data-agent-name="coder"]');
await wait('!!document.querySelector("[data-agent-confirmed]")');
assert.match(await b.evaluate('document.querySelector(".agent-confirm").textContent'),/Its evidence and its workspace under \.retired stay\./);
assert.equal(await b.evaluate('posts.length'),1,'asking posts nothing');
await b.evaluate(`replies.push([200,{request:'${ID}',at:'x'}]);polls.push([200,{request:'${ID}',action:'delete',instance:'coder',state:'closed',done:{run:null,transaction:'t-9',refusal:null},cancelled:null}]);true`);
await click('[data-agent-confirmed]');
await wait('document.querySelector("#agents-receipt")?.textContent==="Deleted coder."');
assert.deepEqual(await b.evaluate('posts.at(-1)'),['/api/instances',{revision:REV,instance:'coder',op:'delete'}]);
// Terminate carries the typed reason; a closed refusal shows its reason.
await b.evaluate(`(()=>{const e=document.querySelector('[data-agent-reason="coder"]');e.value='done for today';e.dispatchEvent(new Event('input',{bubbles:true}));return true;})()`);
await b.evaluate(`replies.push([200,{request:'${ID}',runs:['run-1'],cancelled:[],taken:[]}]);polls.push([200,{request:'${ID}',action:'end',instance:'coder',state:'closed',done:{run:null,transaction:null,refusal:'not-held'},cancelled:null,reason:'the run no longer holds the instance'}]);true`);
await click('[data-agent-action="end"][data-agent-name="coder"]');
await wait('document.querySelector("#agents-receipt")?.textContent==="coder: the run no longer holds the instance."');
assert.deepEqual(await b.evaluate('posts.at(-1)'),['/api/agent/terminate',{instance:'coder',reason:'done for today'}]);
// A refusal shows the bus's message and follows nothing; Keep posts the read's revision.
await b.evaluate(`replies.push([400,{error:'no-route',message:'no-route: set a route in Routing first. Nothing changed.',sent:false}]);true`);
const before=await b.evaluate('reads.length');
await click('[data-agent-action="launch"][data-agent-name="coder"]');
await wait('document.querySelector("#agents-receipt")?.textContent.startsWith("no-route:")');
assert.equal(await b.evaluate('reads.length'),before);
await b.evaluate(`replies.push([200,{...${JSON.stringify(view)},transaction:'t-2',restart:false}]);true`);
await click('[data-agent-action="keep"][data-agent-name="coder"]');
await wait('document.querySelector("#agents-receipt")?.textContent==="Kept coder."');
assert.deepEqual(await b.evaluate('posts.at(-1)'),['/api/veto',{revision:REV,op:'keep',name:'coder'}]);
// An unknown outcome says so and is not retried.
await b.evaluate(`replies.push([503,{error:'outcome-unknown',sent:'unknown'}]);true`);
const sent=await b.evaluate('posts.length');
await click('[data-agent-action="nohire"][data-agent-name="coder"]');
await wait('document.querySelector("#agents-receipt")?.textContent.startsWith("Outcome unknown")');
assert.equal(await b.evaluate('posts.length'),sent+1);
}finally{await b.close();await close(web);}
});
-205
View File
@@ -1,205 +0,0 @@
// Row 194 (#1692, D7, T16): the Console's Agents routes. Launch, terminate, regenerate and veto each
// have instanceSend's boundary and make one call through agentWrite; delete joins add and change on
// /api/instances; the page follows a request at /api/agent/request. The tester listener lets launch,
// terminate and the request read through (D4.1) and refuses regenerate and veto with no bus call.
// Every write here uses a fixture writer; the bus verbs' own checks are in the bus tests.
import test from 'node:test';
import assert from 'node:assert/strict';
import { request } from 'node:http';
import { startServer } from '../src/serve.mjs';
import { BusReadError } from '../src/bus.mjs';
const preview = { host: 'console.example', header: 'x-user', user: 'jason' };
const headers = { host: 'console.example', 'x-user': 'jason', origin: 'https://console.example', 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' };
const REV = 'a'.repeat(64);
const ID = '0b5e8f1c-3a2d-4c6e-9f10-1234567890ab';
const read = (extra = {}) => ({ business: 'acme', revision: 'b'.repeat(64), launch: true, instances: { coder: { definition: 'coder', vars: {}, launched: true, launcher: false, protected: false, kept: false } }, definitions: {}, noHire: [], retired: [], ...extra });
const ANSWERS = {
'instance.launch': () => ({ request: ID }),
'instance.regenerate': () => ({ request: ID }),
'instance.end': () => ({ request: ID, runs: ['r-1'], cancelled: [], taken: [] }),
'veto.set': () => ({ ...read(), transaction: 't-1', restart: false }),
'instance.request': args => ({ request: args.request, action: 'launch', instance: 'coder', by: 'jason', at: '2026-10-20T00:00:00.000Z', state: 'closed', done: { run: 'r-2', transaction: null, refusal: null }, cancelled: null }),
};
async function setup(t, { on = true, agent = async (verb, args) => ANSWERS[verb](args), instances = async args => ({ request: ID, args }), tester = false } = {}) {
const calls = [], lines = [];
const server = await startServer({
port: 0, log: line => lines.push(line),
...(tester ? { tester: true } : { preview, send: on ? async () => ({}) : null }),
agentWrite: agent ? async (verb, args) => { calls.push([verb, args]); return agent(verb, args); } : null,
instancesWrite: instances ? async args => { calls.push(['instances.set', args]); return instances(args); } : null,
});
t.after(() => new Promise(r => server.close(r)));
const port = server.address().port;
const hs = tester ? { host: `127.0.0.1:${port}`, origin: `http://127.0.0.1:${port}`, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' } : headers;
return { calls, lines, port, headers: hs };
}
const send = (s, path, data, hs = s.headers, method = 'POST') => new Promise((resolve, reject) => {
const req = request({ hostname: '127.0.0.1', port: s.port, path, method, headers: hs }, r => {
let text = ''; r.on('data', c => text += c); r.on('end', () => resolve({ status: r.statusCode, type: r.headers['content-type'], text, body: (() => { try { return JSON.parse(text); } catch { return null; } })() }));
});
req.on('error', reject);
req.end(data === undefined ? undefined : typeof data === 'string' ? data : JSON.stringify(data));
});
const get = (s, path, hs = s.headers) => send(s, path, undefined, hs, 'GET');
test('row 194 T16: each agent action makes one call with only its keys, logs the user and answers the request', async t => {
const s = await setup(t);
const launched = await send(s, '/api/agent/launch', { instance: 'coder' });
assert.equal(launched.status, 200, launched.text);
assert.equal(launched.body.request, ID); assert.match(launched.body.at, /^\d{4}-/);
const regenerated = await send(s, '/api/agent/regenerate', { instance: 'coder' });
assert.equal(regenerated.status, 200, regenerated.text);
const ended = await send(s, '/api/agent/terminate', { instance: 'coder', reason: 'secret reason text' });
assert.equal(ended.status, 200, ended.text); assert.deepEqual(ended.body.runs, ['r-1']);
const bare = await send(s, '/api/agent/terminate', { instance: 'coder' });
assert.equal(bare.status, 200, bare.text);
const kept = await send(s, '/api/veto', { revision: REV, op: 'keep', name: 'coder' });
assert.equal(kept.status, 200, kept.text); assert.equal(kept.body.transaction, 't-1');
assert.deepEqual(s.calls, [
['instance.launch', { instance: 'coder' }], ['instance.regenerate', { instance: 'coder' }],
['instance.end', { instance: 'coder', reason: 'secret reason text' }], ['instance.end', { instance: 'coder' }],
['veto.set', { revision: REV, op: 'keep', name: 'coder' }],
]);
assert.deepEqual(s.lines.map(l => l.replace(/^\S+ /, '')), [
`launch user="jason" instance="coder" done request=${ID}`, `regenerate user="jason" instance="coder" done request=${ID}`,
`terminate user="jason" instance="coder" done request=${ID}`, `terminate user="jason" instance="coder" done request=${ID}`,
'veto user="jason" instance="coder" done',
]);
assert.ok(s.lines.every(l => !l.includes('secret reason text')), 'a reason is never logged');
});
test('row 194 T16: the boundary refuses before any bus call', async t => {
const s = await setup(t);
const cases = [
['/api/agent/launch', { instance: 'coder' }, { ...headers, 'x-user': 'mallory' }, 401],
['/api/agent/launch', { instance: 'coder' }, { ...headers, origin: 'https://evil.example' }, 403],
['/api/agent/launch', { instance: 'coder' }, { ...headers, 'sec-fetch-site': 'cross-site' }, 403],
['/api/agent/launch', { instance: 'coder', extra: 'x' }, headers, 400],
['/api/agent/launch', {}, headers, 400],
['/api/agent/launch', { instance: 7 }, headers, 400],
['/api/agent/launch', ['coder'], headers, 400],
['/api/agent/launch', 'not json', headers, 400],
['/api/agent/launch', { instance: 'x'.repeat(17000) }, headers, 400],
['/api/agent/terminate', { instance: 'coder', reason: 3 }, headers, 400],
['/api/agent/regenerate', { instance: 'coder', reason: 'no' }, headers, 400],
['/api/veto', { revision: REV, op: 'fire', name: 'coder' }, headers, 400],
['/api/veto', { revision: 'nope', op: 'keep', name: 'coder' }, headers, 400],
['/api/veto', { op: 'keep', name: 'coder' }, headers, 400],
];
for (const [path, body, hs, status] of cases) {
const r = await send(s, path, body, hs);
assert.equal(r.status, status, `${path} ${JSON.stringify(body).slice(0, 60)}: ${r.text}`);
}
for (const path of ['/api/agent/launch', '/api/agent/terminate', '/api/agent/regenerate', '/api/veto']) {
assert.equal((await get(s, path)).status, 405, path);
}
assert.equal((await send(s, '/api/agent/request', { request: ID })).status, 405);
assert.deepEqual(s.calls, []);
});
test('row 194 T16: read-only when send is off or no writer is wired; no route has a --send exception', async t => {
for (const options of [{ on: false }, { agent: null }]) {
const s = await setup(t, options);
for (const path of ['/api/agent/launch', '/api/agent/terminate', '/api/agent/regenerate', '/api/veto']) {
const r = await send(s, path, { instance: 'coder' });
assert.equal(r.status, 403, path); assert.equal(r.body.error, 'read-only');
}
const r = await get(s, `/api/agent/request?id=${ID}`);
assert.equal(r.status, 403); assert.equal(r.body.error, 'read-only');
assert.deepEqual(s.calls, []);
}
});
test('row 194 T16: a bus refusal names its reason and nothing changed; an unknown outcome or a bad shape is never retried', async t => {
let answer;
const s = await setup(t, { agent: async () => answer() });
answer = () => { throw new BusReadError('no-route'); };
const refused = await send(s, '/api/agent/launch', { instance: 'coder' });
assert.equal(refused.body.sent, false); assert.equal(refused.body.error, 'no-route');
assert.match(refused.body.message, /^no-route: .+\. Nothing changed\.$/);
assert.doesNotMatch(refused.body.message, /refused by the bus/, 'no-route has its own reason');
answer = () => { throw new BusReadError('instance-running'); };
const running = await send(s, '/api/agent/regenerate', { instance: 'coder' });
assert.equal(running.body.error, 'instance-running'); assert.equal(running.body.sent, false);
answer = () => { throw new BusReadError('outcome-unknown'); };
const unknown = await send(s, '/api/agent/launch', { instance: 'coder' });
assert.deepEqual([unknown.status, unknown.body.error, unknown.body.sent], [503, 'outcome-unknown', 'unknown']);
for (const [path, body, bad] of [
['/api/agent/launch', { instance: 'coder' }, { request: 'not-an-id' }],
['/api/agent/terminate', { instance: 'coder' }, { request: ID, runs: 'r-1', cancelled: [], taken: [] }],
['/api/veto', { revision: REV, op: 'nohire', name: 'coder' }, { revision: REV }],
]) {
answer = () => bad;
const r = await send(s, path, body);
assert.deepEqual([r.status, r.body.error, r.body.sent], [503, 'outcome-unknown', 'unknown'], path);
}
assert.equal(s.calls.length, 6, 'one call per POST, nothing retried');
});
test('row 194 T16: the request read takes one id, adds the refusal reason and refuses an unexpected shape', async t => {
let answer = ANSWERS['instance.request'];
const s = await setup(t, { agent: async (verb, args) => answer(args) });
const closed = await get(s, `/api/agent/request?id=${ID}`);
assert.equal(closed.status, 200, closed.text);
assert.deepEqual([closed.body.state, closed.body.done.run, closed.body.reason], ['closed', 'r-2', undefined]);
answer = args => ({ ...ANSWERS['instance.request'](args), done: { run: null, transaction: null, refusal: 'no-route' } });
assert.match((await get(s, `/api/agent/request?id=${ID}`)).body.reason, /route/);
answer = args => ({ ...ANSWERS['instance.request'](args), done: null, cancelled: { by: 'jason' } });
assert.ok((await get(s, `/api/agent/request?id=${ID}`)).body.reason);
answer = args => ({ ...ANSWERS['instance.request'](args), state: 'taken', done: null });
assert.equal((await get(s, `/api/agent/request?id=${ID}`)).body.state, 'taken');
assert.deepEqual(s.calls.map(c => c[0]), Array(4).fill('instance.request'));
for (const query of ['', '?id=nope', `?id=${ID}&id=${ID}`, `?id=${ID}&x=1`, `?id=${ID.toUpperCase()}`]) {
const r = await get(s, `/api/agent/request${query}`);
assert.equal(r.status, 400, query); assert.equal(r.body.error, 'invalid-request');
}
assert.equal(s.calls.length, 4, 'a bad id makes no call');
answer = () => { throw new BusReadError('unknown-request'); };
const unknown = await get(s, `/api/agent/request?id=${ID}`);
assert.equal(unknown.body.error, 'unknown-request');
for (const bad of [args => ({ ...ANSWERS['instance.request'](args), request: '0'.repeat(36) }), args => ({ ...ANSWERS['instance.request'](args), state: 'done' }), () => null]) {
answer = bad;
const r = await get(s, `/api/agent/request?id=${ID}`);
assert.deepEqual([r.status, r.body.error], [502, 'invalid-response']);
}
});
test('row 194 T16: delete joins add and change on /api/instances with only revision, instance and op', async t => {
const s = await setup(t);
const deleted = await send(s, '/api/instances', { revision: REV, instance: 'coder', op: 'delete' });
assert.equal(deleted.status, 200, deleted.text); assert.equal(deleted.body.request, ID);
assert.deepEqual(s.calls, [['instances.set', { revision: REV, instance: 'coder', op: 'delete' }]]);
assert.match(s.lines.at(-1), / instance user="jason" instance="coder" done request=/);
for (const extra of [{ definition: 'coder' }, { vars: {} }, { launch: false }]) {
const r = await send(s, '/api/instances', { revision: REV, instance: 'coder', op: 'delete', ...extra });
assert.equal(r.status, 400, JSON.stringify(extra));
}
assert.equal(s.calls.length, 1);
});
test('row 194 T16 D4.1: the tester listener launches, terminates and reads requests; regenerate, veto and delete are refused with no call', async t => {
const s = await setup(t, { tester: true });
assert.equal((await send(s, '/api/agent/launch', { instance: 'coder' })).status, 200);
assert.equal((await send(s, '/api/agent/terminate', { instance: 'coder' })).status, 200);
assert.equal((await get(s, `/api/agent/request?id=${ID}`)).status, 200);
for (const [path, body] of [['/api/agent/regenerate', { instance: 'coder' }], ['/api/veto', { revision: REV, op: 'keep', name: 'coder' }],
['/api/instances', { revision: REV, instance: 'coder', op: 'delete' }]]) {
const r = await send(s, path, body);
assert.equal(r.status, 403, path);
assert.equal(r.body.sent, false, path);
}
assert.deepEqual(s.calls.map(c => c[0]), ['instance.launch', 'instance.end', 'instance.request']);
assert.ok(s.lines.every(l => l.includes('user="tester"')));
});
test('row 194 N5: agents.js is served as JavaScript and index.html loads it once', async t => {
const s = await setup(t);
const js = await get(s, '/agents.js', { host: 'console.example', 'x-user': 'jason' });
assert.equal(js.status, 200); assert.match(js.type, /^text\/javascript/);
assert.match(js.text, /window\.MosaicAgents = Object\.freeze\(\{ section \}\)/);
assert.match(js.text, /Agent actions are unavailable here\. Use mosaic business instance from the instance CLI\./);
const html = await get(s, '/', { host: 'console.example', 'x-user': 'jason' });
assert.equal(html.text.match(/<script src="\/agents\.js" defer><\/script>/g)?.length, 1);
});
+2 -2
View File
@@ -18,8 +18,8 @@ test('served Console browser: real board fixtures, keyboard, drafts, receipts, t
assert.equal(await b.evaluate('document.querySelector("#session-count").textContent'), '3 of 4');
await b.evaluate('document.querySelector("#hide-offline").click()');
assert.equal(await b.evaluate('document.querySelectorAll("table.sessions tbody tr").length'), 4);
// Rows 129, 154 and 194: chat.js, editor.js and agents.js are the scripts added, same origin; the list is the pin, so another script is a visible change.
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("script")].map(s => s.getAttribute("src"))'), ['/editor.js', '/brand.js', '/app.js', '/markdown-preview.js', '/chat.js', '/agents.js', '/bus.js']);
// Rows 129 and 154: chat.js and editor.js are the scripts added, same origin; the list is the pin, so another script is a visible change.
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("script")].map(s => s.getAttribute("src"))'), ['/editor.js', '/brand.js', '/app.js', '/markdown-preview.js', '/chat.js', '/bus.js']);
assert.equal(await b.evaluate(`document.querySelectorAll('script[src="/chat.js"]').length`), 1);
assert.equal(await b.evaluate('!!window.injected'), false);
// Project filtering does not hide another project's waiting requests.
-155
View File
@@ -1,155 +0,0 @@
// Q175 phase 1 (#1690, design section 9): a fake OpenID provider for the --sso tests. A loopback
// https server with a self-signed certificate made in a temp dir; `op.fetch` is a fetch that trusts
// only that certificate, so the code under test needs no insecure flag. It serves any number of
// issuers (`/<name>/`) at once, each with discovery, a JWKS, authorization, token and end-session
// endpoints. Keys come from node:crypto. Nothing here reaches any other host.
import { constants, createHash, createHmac, generateKeyPairSync, randomBytes, sign } from 'node:crypto';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { createServer, request } from 'node:https';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
export const b64url = value => Buffer.from(typeof value === 'string' ? value : JSON.stringify(value)).toString('base64url');
export function rsaKey(kid = `rsa-${randomBytes(4).toString('hex')}`, bits = 2048) {
const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: bits });
return { kid, alg: 'RS256', privateKey, jwk: { ...publicKey.export({ format: 'jwk' }), kid, use: 'sig', alg: 'RS256' } };
}
export function ecKey(kid = `ec-${randomBytes(4).toString('hex')}`) {
const { privateKey, publicKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' });
return { kid, alg: 'ES256', privateKey, jwk: { ...publicKey.export({ format: 'jwk' }), kid, use: 'sig', alg: 'ES256' } };
}
export function psKey(kid = `ps-${randomBytes(4).toString('hex')}`) {
const k = rsaKey(kid);
return { ...k, alg: 'PS256', jwk: { ...k.jwk, alg: 'PS256' } };
}
// A compact JWS. `alg` HS256 uses `hmac` as the key, `none` has an empty signature.
export function jwt(header, payload, { key = null, hmac = null } = {}) {
const input = `${b64url(header)}.${b64url(payload)}`;
let sig = '';
if (header.alg === 'HS256') sig = createHmac('sha256', hmac).update(input).digest('base64url');
else if (header.alg === 'RS256') sig = sign('sha256', Buffer.from(input), key).toString('base64url');
else if (header.alg === 'PS256') sig = sign('sha256', Buffer.from(input), { key, padding: constants.RSA_PKCS1_PSS_PADDING, saltLength: 32 }).toString('base64url');
else if (header.alg === 'ES256') sig = sign('sha256', Buffer.from(input), { key, dsaEncoding: 'ieee-p1363' }).toString('base64url');
return `${input}.${sig}`;
}
function certificate() {
const dir = mkdtempSync(join(tmpdir(), 'fake-oidc-'));
try {
const r = spawnSync('openssl', ['req', '-x509', '-newkey', 'ec', '-pkeyopt', 'ec_paramgen_curve:P-256', '-nodes', '-days', '1',
'-subj', '/CN=127.0.0.1', '-addext', 'subjectAltName=IP:127.0.0.1', '-keyout', join(dir, 'key.pem'), '-out', join(dir, 'cert.pem')], { encoding: 'utf8' });
if (r.status !== 0) throw new Error(`openssl failed: ${r.stderr}`);
return { key: readFileSync(join(dir, 'key.pem')), cert: readFileSync(join(dir, 'cert.pem')) };
} finally { rmSync(dir, { recursive: true, force: true }); }
}
// One issuer's state. `discovery` is merged over the defaults; `mode` is 'ok', 'down' (the socket is
// destroyed) or 'html' (200 with an HTML page) for discovery.
function issuerState(name, base, opts) {
const keys = opts.keys ?? [rsaKey()];
return {
name, issuer: `${base}/${name}/`, keys, retired: [], clientId: opts.clientId ?? `client-${name}`, clientSecret: opts.clientSecret ?? `secret-${name}-${randomBytes(6).toString('hex')}`,
discovery: opts.discovery ?? {}, mode: 'ok', jwksOverride: null, codes: new Map(), acceptTwice: false, requirePkce: true,
tokenRequests: [], authorizeRequests: [], jwksFetches: 0, discoveryFetches: 0,
};
}
export async function fakeOidc() {
const { key, cert } = certificate();
const issuers = new Map();
const server = createServer({ key, cert }, (req, res) => {
const url = new URL(req.url, 'https://127.0.0.1');
const [, name, ...rest] = url.pathname.split('/');
const iss = issuers.get(name);
const route = rest.join('/');
const json = (status, body, type = 'application/json') => { res.writeHead(status, { 'content-type': type }); res.end(typeof body === 'string' ? body : JSON.stringify(body)); };
if (!iss) return json(404, { error: 'no issuer' });
if (route === '.well-known/openid-configuration') {
iss.discoveryFetches++;
if (iss.mode === 'down') return req.socket.destroy();
if (iss.mode === 'html') return json(200, '<!doctype html><title>Sign in</title>', 'text/html');
return json(200, discovery(iss));
}
if (route === 'jwks') {
iss.jwksFetches++;
return json(200, iss.jwksOverride ?? { keys: iss.keys.map(k => k.jwk) });
}
if (route === 'authorize') { iss.authorizeRequests.push(Object.fromEntries(url.searchParams)); return json(200, { ok: true }); }
if (route === 'end-session') return json(200, { ok: true });
if (route === 'token' && req.method === 'POST') {
let text = '';
req.on('data', c => (text += c));
req.on('end', () => {
const form = new URLSearchParams(text);
const basic = Buffer.from((req.headers.authorization ?? '').replace(/^Basic /, ''), 'base64').toString();
const [id, secret] = basic.split(':').map(decodeURIComponent);
iss.tokenRequests.push({ form: Object.fromEntries(form), clientId: id });
if (id !== iss.clientId || secret !== iss.clientSecret) return json(401, { error: 'invalid_client' });
const grant = iss.codes.get(form.get('code'));
if (!grant) return json(400, { error: 'invalid_grant' });
if (!iss.acceptTwice) iss.codes.delete(form.get('code'));
if (form.get('redirect_uri') !== grant.redirectUri) return json(400, { error: 'invalid_grant', error_description: 'redirect_uri' });
if (iss.requirePkce && createHash('sha256').update(form.get('code_verifier') ?? '').digest('base64url') !== grant.challenge) return json(400, { error: 'invalid_grant', error_description: 'pkce' });
return json(200, { access_token: randomBytes(16).toString('hex'), token_type: 'Bearer', expires_in: 300, id_token: grant.idToken });
});
return;
}
return json(404, { error: 'not found' });
});
await new Promise(r => server.listen(0, '127.0.0.1', r));
const base = `https://127.0.0.1:${server.address().port}`;
const discovery = iss => ({
issuer: iss.issuer, authorization_endpoint: `${iss.issuer}authorize`, token_endpoint: `${iss.issuer}token`, jwks_uri: `${iss.issuer}jwks`,
end_session_endpoint: `${iss.issuer}end-session`, response_types_supported: ['code'], subject_types_supported: ['public'],
id_token_signing_alg_values_supported: [...new Set(iss.keys.map(k => k.alg))], code_challenge_methods_supported: ['S256'],
...iss.discovery,
});
// A fetch over node:https that trusts only this certificate. oauth4webapi passes a Headers object
// and a string or URLSearchParams body.
const opFetch = (input, init = {}) => new Promise((resolve, reject) => {
const url = new URL(typeof input === 'string' ? input : input.href ?? input.url);
if (url.protocol !== 'https:' || url.hostname !== '127.0.0.1' || url.port !== String(server.address().port)) return reject(new TypeError(`fake OP fetch refuses ${url.origin}`));
const headers = Object.fromEntries(new Headers(init.headers ?? {}));
const body = init.body === undefined || init.body === null ? undefined : String(init.body);
if (body !== undefined) headers['content-length'] = Buffer.byteLength(body);
const req = request(url, { method: init.method ?? 'GET', headers, ca: cert, signal: init.signal }, res => {
const chunks = [];
res.on('data', c => chunks.push(c));
res.on('end', () => {
const h = new Headers();
for (const [k, v] of Object.entries(res.headers)) h.set(k, Array.isArray(v) ? v.join(', ') : v);
const response = new Response(res.statusCode === 204 ? null : Buffer.concat(chunks), { status: res.statusCode, headers: h });
Object.defineProperty(response, 'url', { value: url.href });
resolve(response);
});
res.on('error', reject);
});
req.on('error', reject);
req.end(body);
});
return {
base, fetch: opFetch, cert,
issuer(name, opts = {}) { const iss = issuerState(name, base, opts); issuers.set(name, iss); return iss; },
// A code for `iss` whose ID token is built from the start redirect's nonce, the claims and header
// overrides. `challenge` defaults to the start redirect's; `idToken` replaces the token outright.
grant(iss, authorizeUrl, { sub = `user-${iss.name}`, claims = {}, header = {}, key = iss.keys[0], idToken = null, challenge = null, redirectUri = null } = {}) {
const q = new URL(authorizeUrl).searchParams;
const now = Math.floor(Date.now() / 1000);
const payload = { iss: iss.issuer, sub, aud: iss.clientId, iat: now, exp: now + 300, nonce: q.get('nonce'), ...claims };
for (const [k, v] of Object.entries(payload)) if (v === undefined) delete payload[k];
const token = idToken ?? jwt({ alg: key.alg, kid: key.kid, typ: 'JWT', ...header }, payload, { key: key.privateKey, hmac: iss.clientSecret });
const code = randomBytes(12).toString('base64url');
iss.codes.set(code, { idToken: token, challenge: challenge ?? q.get('code_challenge'), redirectUri: redirectUri ?? q.get('redirect_uri') });
return { code, idToken: token, payload };
},
rotate(iss, next = rsaKey()) { iss.retired.push(...iss.keys); iss.keys = [next]; return next; },
close: () => new Promise(r => { server.close(r); server.closeAllConnections(); }),
};
}
+11 -9
View File
@@ -1,8 +1,7 @@
// Row 165 (#1662, Q171, REQ-ROLE-3): the Console's role instance read and write. The write has the
// Settings boundary and makes one human-transport call, `instances.set`; the broker's and the
// writer's own checks are in the bus and business tests. The first test runs a real broker over a
// temporary organization directory, so the add reaches the file, the log and `validate`. Row 194
// (#1692, D3): that broker boots from the file through bootConfig, so the add goes live with no restart.
// temporary organization directory, so the add reaches the file, the log and `validate`.
import test from 'node:test';
import assert from 'node:assert/strict';
import { request } from 'node:http';
@@ -14,8 +13,8 @@ import { startServer } from '../src/serve.mjs';
import { BusReadError } from '../src/bus.mjs';
import { BusError } from '../../bus/src/broker.mjs';
import { startBroker } from '../../bus/src/runtime.mjs';
import { systemFor } from '../../business/tests/helpers.mjs';
import { fixture, tmp } from '../../cli/tests/helpers.mjs';
import { bootConfig, loadSystem } from '../../cli/src/config.mjs';
const BUSINESS_CLI = fileURLToPath(new URL('../../business/src/cli.mjs', import.meta.url));
const preview = { host: 'console.example', header: 'x-user', user: 'jason' };
@@ -23,7 +22,7 @@ const headers = { host: 'console.example', 'x-user': 'jason', origin: 'https://c
const REV = 'a'.repeat(64);
const view = (extra = {}) => ({ business: 'acme', revision: 'b'.repeat(64), launch: true, instances: { coder: { definition: 'coder', vars: {}, launched: true, launcher: false, protected: false } }, definitions: {}, ...extra });
async function setup(t, { on = true, writer = on, write = async args => ({ ...view(), instances: { [args.instance]: { definition: 'merge-gate', vars: {}, launched: false, launcher: false, protected: false } }, transaction: 't-1', restart: false }), bus = null } = {}) {
async function setup(t, { on = true, writer = on, write = async args => ({ ...view(), instances: { [args.instance]: { definition: 'merge-gate', vars: {}, launched: false, launcher: false, protected: false } }, transaction: 't-1', restart: true }), bus = null } = {}) {
const calls = [], lines = [];
const server = await startServer({
port: 0, preview, bus, log: line => lines.push(line), send: on ? async () => ({}) : null,
@@ -40,12 +39,15 @@ const send = (s, path, data, hs = headers, method = 'POST') => new Promise((reso
req.end(data === undefined ? undefined : typeof data === 'string' ? data : JSON.stringify(data));
});
test('row 165 Console: the signed-in human adds an instance through a real broker; validate exits 0, the log names the human and no restart is needed (row 194)', async t => {
test('row 165 Console: the signed-in human adds an instance through a real broker; validate exits 0 and the log names the human', async t => {
const root = tmp(t, 'webui-instances-');
const f = fixture(root, 'acme');
const dir = join(root, 'config');
const boot = bootConfig({ system: loadSystem({ env: f.env }), businessId: 'acme', env: f.env });
const host = await startBroker({ dataRoot: f.dataRoot, businesses: boot.businesses, organization: boot.organization });
const host = await startBroker({
dataRoot: f.dataRoot,
businesses: { acme: { id: 'acme', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } } } },
organization: { dir, rolesDir: f.roles, system: systemFor(root) },
});
t.after(() => host.close());
const human = host.broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true });
// The human transport's shape: an answer, or a BusReadError with the broker's code.
@@ -66,10 +68,10 @@ test('row 165 Console: the signed-in human adds an instance through a real broke
const added = await send(s, '/api/instances', { revision: current.revision, instance: 'sec-1', op: 'add', definition: 'security-reviewer', launch: true });
assert.equal(added.status, 200, JSON.stringify(added.body));
assert.equal(added.body.restart, false);
assert.equal(added.body.restart, true);
assert.equal(added.body.instances['sec-1'].launched, true);
assert.deepEqual(s.calls, [{ revision: current.revision, instance: 'sec-1', op: 'add', definition: 'security-reviewer', launch: true }]);
assert.match(s.lines.at(-1), / instance user="jason" instance="sec-1" done restart=false$/);
assert.match(s.lines.at(-1), / instance user="jason" instance="sec-1" done restart=true$/);
const entry = log().at(-1);
assert.deepEqual([entry.actor, entry.state, entry.transaction], ['jason', 'committed', added.body.transaction]);
assert.deepEqual(JSON.parse(readFileSync(file, 'utf8')).roles['sec-1'], { definition: 'security-reviewer' });
+1 -1
View File
@@ -30,7 +30,7 @@ function fakeCopy(dir) {
writeFileSync(join(copyRoot(dir), 'packages', 'webui', 'src', 'cli.mjs'), '');
return copyRoot(dir);
}
const SEND_REFUSAL = /^webui-service: --send needs the preview mode \(--public-host, --auth-header and --allow-user\) or --sso$/m;
const SEND_REFUSAL = /^webui-service: --send needs the preview mode \(--public-host, --auth-header and --allow-user\)$/m;
const TEMPLATE = readFileSync(join(REPO, 'packages', 'webui', 'systemd', 'mosaic-webui.service.in'), 'utf8');
// The unit as the base script renders it (row 75): the template with its four
-132
View File
@@ -1,132 +0,0 @@
// Q175 phase 1 (#1690, design test 24, sage-1340 F1, sage-1347 F7): the page under --sso. A read
// the Console answers 401 `sign-in required` signs the page out the way a proxy redirect does, and
// the line links to /auth/login with the current path and query; any other 401 stays an error. Both
// fetch paths in app.js and both in bus.js (fetchRead and fetchApi) do it. The Sign out form shows
// only when /api/config says sso. The board-held.test.mjs stub, with two lists of paths: those in
// window.__signIn answer 401 `sign-in required`, those in window.__other 401 `unauthenticated`. A
// page URL holding `sso` gets `sso: true` added to /api/config. Poll timers fire after 150 ms.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Store } from '../../bus/src/store.mjs';
import { Broker } from '../../bus/src/broker.mjs';
import { views } from '../../bus/src/views.mjs';
import { startServer } from '../src/serve.mjs';
import { browser } from './browser.mjs';
import { fixture, close } from './fixture.mjs';
const businesses = { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'cto', delivery: 'pm' }, roles: { pm: { authority: { withinRole: ['message.send'], crossRole: [] } }, cto: { authority: { withinRole: ['message.send'], crossRole: [] } } } } };
const stub = `(() => {
const real = window.fetch.bind(window);
window.__reqs = []; window.__signIn = []; window.__other = [];
const timeout = window.setTimeout.bind(window);
window.setTimeout = (f, ms, ...rest) => timeout(f, ms === 10000 ? 150 : ms, ...rest);
const answer = (status, body) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
window.fetch = async (url, init = {}) => {
const path = String(url).split('?')[0];
window.__reqs.push(path);
if (window.__signIn.includes(path)) return answer(401, { error: 'sign-in required' });
if (window.__other.includes(path)) return answer(401, { error: 'unauthenticated' });
if (path === '/api/config' && /sso/.test(location.search)) return answer(200, { ...(await (await real(url, init)).json()), sso: true });
return real(url, init);
};
})();`;
async function setup() {
const root = mkdtempSync(join(tmpdir(), 'webui-sso-browser-'));
const store = new Store(root), b0 = new Broker({ store, businesses });
b0.request(b0.bindLaunch({ business: 'demo', role: 'pm', run: 'pm-run', harness: 'pi', address: 'pm-run' }), { verb: 'role.claim' });
const reader = b0.bindReader({ business: 'demo' });
const bus = views({ call: async (verb, args = {}) => structuredClone(b0.request(reader, { verb, args })) });
const f = await fixture(), b = await browser({ machine: true });
const web = await startServer({ port: 0, board: f.boardURL, bus }), base = `http://127.0.0.1:${web.address().port}/`;
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: stub });
await b.viewport(1280);
const wait = expr => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expr})return true;await new Promise(r=>setTimeout(r,50))}throw new Error('Condition timed out: '+${JSON.stringify(expr)})})()`);
const text = sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.textContent ?? null`);
const shown = sel => b.evaluate(`(()=>{const e=document.querySelector(${JSON.stringify(sel)});return !!e && !e.hidden && getComputedStyle(e).display !== 'none' && !!e.getClientRects().length})()`);
const reqs = () => b.evaluate('window.__reqs.length');
const settle = () => new Promise(r => setTimeout(r, 600));
const done = async () => { await b.close(); await close(web); await f.close(); store.close(); rmSync(root, { recursive: true, force: true }); };
return { b, base, wait, text, shown, reqs, settle, done };
}
const LOADED = 'document.querySelector("table.sessions [data-open=\'proj/agent1\']")';
const LINE = 'Signed out. Sign in again.';
test('a 401 sign-in required on each fetch path signs the page out with a sign-in link and stops the refresh; another 401 stays an error', { timeout: 120000 }, async () => {
const { b, base, wait, text, shown, reqs, settle, done } = await setup();
// The line, its link back to this page, the Sign out form gone, and no further fetch.
const out = async ret => {
await wait('!document.querySelector("#signed-out").hidden');
assert.equal(await text('#signed-out'), LINE);
assert.equal(await b.evaluate('document.querySelector("#signed-out a").getAttribute("href")'), `/auth/login?return=${encodeURIComponent(ret)}`);
assert.equal(await shown('#sign-out'), false);
assert.equal(await b.evaluate('document.querySelector("#refresh").disabled'), true);
const n = await reqs();
await b.evaluate('document.querySelector("#refresh").click(), true');
await settle();
assert.equal(await reqs(), n, 'the page fetched after signing out');
};
try {
// app.js api(): the Board read, with sso on from /api/config.
await b.navigate(`${base}?sso&x=1`);
await wait(LOADED);
await b.evaluate(`window.__signIn.push('/api/board'), document.querySelector('#refresh').click(), true`);
await out('/?sso&x=1');
// app.js getJSON: the conversation catalogue behind History.
await b.navigate(`${base}?sso&conv`);
await wait(LOADED);
await b.evaluate(`window.__signIn.push('/api/conversations'), document.querySelector('table.sessions [data-history="proj/agent1"]').click(), true`);
await out('/?sso&conv');
// app.js api(): the board POST behind Seen.
await b.navigate(`${base}?sso&seen`);
await wait(LOADED);
await b.evaluate(`document.querySelector('table.sessions [data-open="proj/agent1"]').click(), true`);
await wait('document.querySelector("#inspection [data-seen]")');
await b.evaluate(`window.__signIn.push('/api/seen'), document.querySelector('#inspection [data-seen]').click(), true`);
await out('/?sso&seen');
// bus.js fetchRead and fetchApi, on a page whose /api/config has no sso: the link comes from
// the 401 itself (F1).
await b.navigate(`${base}?read#/inbox`);
await wait(LOADED);
await b.evaluate(`window.__signIn.push('/api/bus/inbox'), document.querySelector('#refresh').click(), true`);
await out('/?read');
await b.navigate(`${base}?module`);
await wait(LOADED);
await b.evaluate(`window.__signIn.push('/api/queue'), location.hash = '#/queue', true`);
await out('/?module');
// Any other 401 is an error: no sign-out, and the polls go on.
await b.navigate(`${base}?sso&other`);
await wait(LOADED);
await b.evaluate(`window.__other.push('/api/board', '/api/bus/inbox', '/api/queue'), document.querySelector('#refresh').click(), true`);
await settle();
assert.equal(await b.evaluate('document.querySelector("#signed-out").hidden'), true);
assert.equal(await b.evaluate('document.body.classList.contains("signed-out")'), false);
const n = await reqs();
await settle();
assert.ok(await reqs() > n, 'the refresh stopped on another 401');
assert.equal(await shown('#sign-out'), true);
} finally { await done(); }
});
test('the Sign out form shows only when /api/config says sso, and posts to /auth/logout', { timeout: 120000 }, async () => {
const { b, base, wait, shown, done } = await setup();
try {
await b.navigate(base);
await wait(LOADED);
await new Promise(r => setTimeout(r, 300));
assert.equal(await shown('#sign-out'), false);
await b.navigate(`${base}?sso`);
await wait(`${LOADED} && !document.querySelector("#sign-out").hidden`);
assert.equal(await shown('#sign-out'), true);
assert.deepEqual(await b.evaluate('(() => { const f = document.querySelector("#sign-out"); return [f.method, f.getAttribute("action"), f.querySelector("button").textContent, f.querySelector("button").type]; })()'), ['post', '/auth/logout', 'Sign out', 'submit']);
} finally { await done(); }
});
-589
View File
@@ -1,589 +0,0 @@
// Q175 phase 1 (#1690, design agents/sage/work/q175-sso/DESIGN.md section 9): the Console as an
// OIDC relying party (--sso). Every provider is the fake OP in helpers/fake-oidc.mjs on loopback;
// no test reaches a real provider, the live Console or a real config or slot file. The public name
// is console.example.test, sent only as a Host header to the test's own loopback listener. Each
// refusal checks the status, that no session cookie was set and that the send stub never ran.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { request } from 'node:http';
import { spawn, spawnSync } from 'node:child_process';
import { chmodSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { startServer } from '../src/serve.mjs';
import { createSso, readSlot, SESSION_COOKIE, LOGIN_COOKIE } from '../src/sso.mjs';
import { close } from './fixture.mjs';
import { b64url, ecKey, fakeOidc, jwt, psKey, rsaKey } from './helpers/fake-oidc.mjs';
const HOST = 'console.example.test', ORIGIN = `https://${HOST}`;
const CLI = fileURLToPath(new URL('../src/cli.mjs', import.meta.url));
const MSG = JSON.stringify({ to: 'coder', body: 'Please look at row 192.' });
// Test 19: every log line any test here captured, and every token, code, state, nonce, verifier,
// client secret and session id any test produced. The last test checks the one against the other.
const ALL_LINES = [], PRODUCED = new Set();
// node:http, not fetch: fetch can't set Host, Origin or Sec-Fetch-Site.
function call(port, { method = 'GET', path = '/', headers = {}, body, host = HOST } = {}) {
return new Promise((resolve, reject) => {
const length = body === undefined ? {} : { 'content-length': Buffer.byteLength(body) };
const req = request({ host: '127.0.0.1', port, method, path, headers: { host, ...length, ...headers } }, res => {
let text = '';
res.on('data', c => (text += c));
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: text && (res.headers['content-type'] ?? '').startsWith('application/json') ? JSON.parse(text) : text }));
});
req.on('error', reject);
req.end(body);
});
}
const setCookies = r => [r.headers['set-cookie'] ?? []].flat();
const sessionOf = r => setCookies(r).find(c => c.startsWith(`${SESSION_COOKIE}=`) && !c.includes('Max-Age=0'))?.split(';')[0] ?? null;
const writeHeaders = cookie => ({ ...(cookie ? { cookie } : {}), origin: ORIGIN, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' });
function providerFor(iss, extra = {}) {
return { id: iss.name, issuer: iss.issuer, clientId: iss.clientId, clientSecret: { slot: iss.name }, algs: [...new Set(iss.keys.map(k => k.alg))], subjects: [{ sub: `user-${iss.name}`, name: `person-${iss.name}` }], ...extra };
}
// A fake OP with `issuers`, createSso over them, and the human listener with a counting send.
async function setup(t, { issuers = [{ name: 'a' }], provider = {}, before = null, retryFirst = 5000, server = {} } = {}) {
const op = await fakeOidc();
t.after(() => op.close());
const iss = issuers.map(o => op.issuer(o.name, o));
for (const i of iss) PRODUCED.add(i.clientSecret);
before?.(op, iss);
const config = { publicOrigin: ORIGIN, providers: iss.map(i => providerFor(i, provider[i.name] ?? {})) };
const lines = [];
const log = line => { lines.push(line); ALL_LINES.push(line); };
const sso = await createSso(config, { secrets: new Map(iss.map(i => [i.name, i.clientSecret])), fetch: op.fetch, log, retryFirst });
t.after(() => sso.close());
const sends = [];
const bus = Object.fromEntries(['inbox', 'tasks', 'agents', 'trail', 'decisions'].map(v => [v, async () => [{ verb: v }]]));
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', bus, sso, send: async args => { sends.push(args); return { id: 'msg-0001' }; }, log, ...server });
t.after(() => close(web));
return { op, iss, sso, port: web.address().port, lines, sends, config, bus };
}
// GET /auth/login; returns the authorize URL and the login cookie (name=value).
async function begin(env, { provider = null, ret = null, query = null, host = HOST } = {}) {
const q = query ?? new URLSearchParams({ ...(provider ? { provider } : {}), ...(ret !== null ? { return: ret } : {}) }).toString();
const r = await call(env.port, { path: `/auth/login${q ? `?${q}` : ''}`, host });
assert.equal(r.status, 303, `login start: ${r.status} ${r.body}`);
const cookie = setCookies(r)[0].split(';')[0];
const location = r.headers.location;
const params = new URL(location).searchParams;
PRODUCED.add(cookie.split('=')[1]);
for (const k of ['state', 'nonce', 'code_challenge']) PRODUCED.add(params.get(k));
for (const record of env.sso.logins.values()) PRODUCED.add(record.verifier);
return { r, cookie, location, params };
}
function callback(env, begun, { code, state = begun.params.get('state'), extra = {}, cookie = begun.cookie } = {}) {
const q = new URLSearchParams({ ...(code !== undefined ? { code } : {}), state, ...extra });
return call(env.port, { path: `/auth/callback?${q}`, headers: cookie ? { cookie } : {} });
}
function grant(env, begun, iss, opts = {}) {
const g = env.op.grant(iss, begun.location, opts);
PRODUCED.add(g.code);
PRODUCED.add(g.idToken);
return g;
}
// A whole sign-in: start, a code at the OP, the callback. Returns the callback answer and the
// session cookie (name=value) when one was set.
async function signIn(env, { iss = env.iss[0], ret = null, opts = {} } = {}) {
const begun = await begin(env, { provider: env.iss.length > 1 ? iss.name : null, ret });
const g = grant(env, begun, iss, opts);
const r = await callback(env, begun, { code: g.code });
const session = sessionOf(r);
if (session) PRODUCED.add(session.split('=')[1]);
return { begun, g, r, session };
}
function refused(env, r, code) {
assert.equal(r.status, 403, `expected 403, got ${r.status}`);
assert.equal(sessionOf(r), null, 'a session cookie was set');
assert.equal(env.sends.length, 0, 'the send stub ran');
assert.ok(setCookies(r).includes(`${LOGIN_COOKIE}=; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=0`), 'the login cookie was not expired');
if (code) assert.match(env.lines.at(-1), new RegExp(` sso refused code=${code} provider=`), env.lines.at(-1));
}
test('positive: an RS256 provider signs in and sends one message, and the session reads', async t => {
const env = await setup(t);
assert.notEqual(env.port, 7332);
const { r, session } = await signIn(env, { ret: '/board?x=1' });
assert.equal(r.status, 303);
assert.equal(r.headers.location, `${ORIGIN}/board?x=1`);
assert.ok(session);
const send = await call(env.port, { method: 'POST', path: '/api/bus/message', headers: writeHeaders(session), body: MSG });
assert.equal(send.status, 200, JSON.stringify(send.body));
assert.deepEqual(env.sends, [{ to: 'coder', body: 'Please look at row 192.' }]);
assert.match(env.lines.at(-1), / send user="person-a" to="coder" id=msg-0001$/);
const read = await call(env.port, { path: '/api/bus/inbox', headers: { cookie: session } });
assert.equal(read.status, 200);
const config = await call(env.port, { path: '/api/config', headers: { cookie: session } });
assert.equal(config.body.sso, true);
assert.equal(config.body.send, true);
});
test('positive: an ES256 provider signs in and sends', async t => {
const env = await setup(t, { issuers: [{ name: 'e', keys: [ecKey()] }] });
assert.deepEqual(env.config.providers[0].algs, ['ES256']);
const { session } = await signIn(env);
assert.ok(session);
assert.equal((await call(env.port, { method: 'POST', path: '/api/bus/message', headers: writeHeaders(session), body: MSG })).status, 200);
assert.equal(env.sends.length, 1);
});
test('positive: two providers each sign in their own subjects, and one provider\'s subject is unknown to the other', async t => {
const env = await setup(t, { issuers: [{ name: 'a' }, { name: 'b', keys: [ecKey()] }] });
const list = await call(env.port, { path: '/auth/login?return=%2Fboard' });
assert.equal(list.status, 200);
assert.match(list.body, /provider=a&#38;return=%2Fboard/);
assert.match(list.body, /provider=b&#38;return=%2Fboard/);
const [a, b] = env.iss;
assert.ok((await signIn(env, { iss: a })).session);
assert.ok((await signIn(env, { iss: b })).session);
const names = [...env.sso.sessions.values()].map(s => s.name);
assert.deepEqual(names, ['person-a', 'person-b']);
const cross = await signIn(env, { iss: b, opts: { sub: 'user-a' } });
refused({ ...env, sends: [] }, cross.r, 'subject-unknown');
});
test('1: a forged x-authentik header without a cookie is 401 on the API and a sign-in redirect on a page', async t => {
const env = await setup(t);
const forged = { 'x-authentik-username': 'jason.woltje', origin: ORIGIN, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' };
for (const [method, path, body] of [['POST', '/api/bus/message', MSG], ['POST', '/api/settings', '{}'], ['GET', '/api/config', undefined]]) {
const r = await call(env.port, { method, path, headers: forged, body });
assert.equal(r.status, 401, `${method} ${path}`);
assert.deepEqual(r.body, { error: 'sign-in required' });
}
const page = await call(env.port, { path: '/', headers: forged });
assert.equal(page.status, 303);
assert.equal(page.headers.location, '/auth/login?return=%2F');
const deep = await call(env.port, { path: '/board?x=1' });
assert.equal(deep.headers.location, '/auth/login?return=%2Fboard%3Fx%3D1');
assert.equal(env.sends.length, 0);
});
test('2: an HS256 ID token keyed with the client secret and a real RSA kid is refused', async t => {
const env = await setup(t);
const [iss] = env.iss;
const { r } = await signIn(env, { opts: { header: { alg: 'HS256', kid: iss.keys[0].kid } } });
refused(env, r, 'id-token-invalid');
});
test('3: a wrong aud, and an aud list holding another client with no azp, are refused', async t => {
const env = await setup(t);
refused(env, (await signIn(env, { opts: { claims: { aud: 'another-client' } } })).r, 'id-token-invalid');
refused(env, (await signIn(env, { opts: { claims: { aud: [env.iss[0].clientId, 'another-client'] } } })).r, 'id-token-invalid');
});
test('4: a token that expired 10 minutes ago is refused', async t => {
const env = await setup(t);
const now = Math.floor(Date.now() / 1000);
refused(env, (await signIn(env, { opts: { claims: { iat: now - 900, exp: now - 600 } } })).r, 'id-token-invalid');
});
test('5: another issuer, and the issuer without its trailing slash, are refused', async t => {
const env = await setup(t);
const [iss] = env.iss;
refused(env, (await signIn(env, { opts: { claims: { iss: `${env.op.base}/other/` } } })).r, 'id-token-invalid');
refused(env, (await signIn(env, { opts: { claims: { iss: iss.issuer.slice(0, -1) } } })).r, 'id-token-invalid');
});
test('6: a nonce mismatch and a missing nonce are refused', async t => {
const env = await setup(t);
refused(env, (await signIn(env, { opts: { claims: { nonce: 'not-the-nonce' } } })).r, 'id-token-invalid');
refused(env, (await signIn(env, { opts: { claims: { nonce: undefined } } })).r, 'id-token-invalid');
});
test('7: state mismatch, no login cookie, a replay, an expired record and two login cookies are refused', async t => {
const env = await setup(t);
const [iss] = env.iss;
let begun = await begin(env);
refused(env, await callback(env, begun, { code: grant(env, begun, iss).code, state: 'wrong-state' }), 'state-mismatch');
begun = await begin(env);
refused(env, await callback(env, begun, { code: grant(env, begun, iss).code, cookie: null }), 'login-record-missing');
// Replay: the OP takes the code twice, so only the take-and-delete rule refuses the second callback.
iss.acceptTwice = true;
begun = await begin(env);
const g = grant(env, begun, iss);
const first = await callback(env, begun, { code: g.code });
assert.equal(first.status, 303);
PRODUCED.add(sessionOf(first).split('=')[1]);
refused(env, await callback(env, begun, { code: g.code }), 'login-record-missing');
iss.acceptTwice = false;
begun = await begin(env);
for (const record of env.sso.logins.values()) record.expires = Date.now() - 1;
refused(env, await callback(env, begun, { code: grant(env, begun, iss).code }), 'login-record-missing');
begun = await begin(env);
const other = await begin(env);
refused(env, await callback(env, begun, { code: grant(env, begun, iss).code, cookie: `${begun.cookie}; ${other.cookie}` }), 'login-record-missing');
});
test('8: the token request carries the PKCE verifier, and the OP refuses a wrong one', async t => {
const env = await setup(t);
const [iss] = env.iss;
const { r, begun } = await signIn(env);
assert.equal(r.status, 303);
const { form } = iss.tokenRequests.at(-1);
assert.equal(begun.params.get('code_challenge_method'), 'S256');
const { createHash } = await import('node:crypto');
assert.equal(createHash('sha256').update(form.code_verifier).digest('base64url'), begun.params.get('code_challenge'));
refused(env, (await signIn(env, { opts: { challenge: 'not-the-challenge' } })).r, 'grant-failed');
});
test('9: alg none, a changed payload under a real kid, and an unknown kid even after the refetch are refused', async t => {
const env = await setup(t);
const [iss] = env.iss;
// (a) alg none: the alg rule, before any signature check.
refused(env, (await signIn(env, { opts: { header: { alg: 'none' } } })).r, 'id-token-invalid');
// (b) a real kid's signature over another payload: only the signature check catches it.
let begun = await begin(env);
const real = env.op.grant(iss, begun.location);
PRODUCED.add(real.idToken);
const [h, , s] = real.idToken.split('.');
const forged = `${h}.${b64url({ ...real.payload, name: 'changed' })}.${s}`;
refused(env, await callback(env, begun, { code: grant(env, begun, iss, { idToken: forged }).code }), 'signature-invalid');
// (c) a key the JWKS never held, again after the cache is old enough to refetch.
t.mock.timers.enable({ apis: ['Date'], now: Date.now() });
const stranger = rsaKey('not-in-jwks');
refused(env, (await signIn(env, { opts: { key: stranger } })).r, 'signature-invalid');
const fetches = iss.jwksFetches;
t.mock.timers.tick(61000);
refused(env, (await signIn(env, { opts: { key: stranger } })).r, 'signature-invalid');
assert.equal(iss.jwksFetches, fetches + 1, 'no refetch');
});
test('10: a valid token for a subject not configured is 403 with one subject-unknown line', async t => {
const env = await setup(t);
const before = env.lines.length;
const { r } = await signIn(env, { opts: { sub: 'stranger' } });
refused(env, r, 'subject-unknown');
const added = env.lines.slice(before);
assert.equal(added.length, 1);
assert.match(added[0], new RegExp(`^\\S+ sso refused code=subject-unknown provider=a iss=${JSON.stringify(env.iss[0].issuer).replace(/[.*+?^${}()|[\]\\/]/g, '\\$&')} sub="stranger"$`));
});
test('11: start refusals: HS or none algs, reserved names, HS-only discovery, an empty JWKS, no S256, an issuer mismatch, a short RSA key', async t => {
const op = await fakeOidc();
t.after(() => op.close());
const start = (iss, extra = {}) => createSso({ publicOrigin: ORIGIN, providers: [providerFor(iss, extra)] }, { secrets: new Map([[iss.name, iss.clientSecret]]), fetch: op.fetch, log: l => ALL_LINES.push(l) });
const a = op.issuer('a');
PRODUCED.add(a.clientSecret);
for (const [extra, why] of [
[{ algs: ['HS256'] }, /algs\[0\] must be one of .*HS\* and none are refused/],
[{ algs: ['none'] }, /algs\[0\] must be one of/],
[{ subjects: [{ sub: 'u', name: 'tester' }] }, /subjects\[0\]\.name must not be tester or human/],
[{ subjects: [{ sub: 'u', name: 'Human' }] }, /subjects\[0\]\.name must not be tester or human/],
]) await assert.rejects(start(a, extra), why);
const refusedAtDiscovery = /console\.sso provider \w+ refused at discovery/;
// The mosaic-preview shape: HS256 only.
const hs = op.issuer('hs', { discovery: { id_token_signing_alg_values_supported: ['HS256'] } });
await assert.rejects(start(hs, { algs: ['RS256'] }), refusedAtDiscovery);
const empty = op.issuer('empty');
empty.jwksOverride = { keys: [] };
await assert.rejects(start(empty), refusedAtDiscovery);
const plain = op.issuer('plain', { discovery: { code_challenge_methods_supported: ['plain'] } });
await assert.rejects(start(plain), refusedAtDiscovery);
const moved = op.issuer('moved');
moved.discovery = { issuer: `${op.base}/elsewhere/` };
await assert.rejects(start(moved), refusedAtDiscovery);
const small = op.issuer('small', { keys: [rsaKey('small', 1024)] });
await assert.rejects(start(small), refusedAtDiscovery);
// The control: the same OP and provider start.
const sso = await start(a);
assert.deepEqual(sso.health(), { a: 'ready' });
sso.close();
await assert.rejects(startServer({ port: 0, sso, preview: { host: HOST, header: 'remote-user', user: 'jason' } }), /--sso cannot be combined/);
});
test('11: slot files: a symlink, mode 0644, mode 0640 and a file under the repository are refused, and no message holds the value', t => {
const root = mkdtempSync(join(tmpdir(), 'webui-sso-slot-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const dir = join(root, 'config'), slots = join(dir, 'secrets', 'mosaic-stack');
mkdirSync(slots, { recursive: true });
const provider = slot => ({ id: 'a', clientSecret: { slot } });
const value = 'slot-value-do-not-print';
const opts = { repo: join(root, 'repo'), copies: join(root, 'copies') };
writeFileSync(join(slots, 'good'), `${value}\n`, { mode: 0o600 });
assert.equal(readSlot(dir, provider('good'), opts), value);
writeFileSync(join(root, 'target'), value, { mode: 0o600 });
symlinkSync(join(root, 'target'), join(slots, 'link'));
for (const [slot, mode] of [['wide', 0o644], ['group', 0o640]]) { writeFileSync(join(slots, slot), value); chmodSync(join(slots, slot), mode); }
writeFileSync(join(slots, 'two'), `${value}\nmore\n`, { mode: 0o600 });
writeFileSync(join(slots, 'empty'), '\n', { mode: 0o600 });
const cases = [
['missing', /slot is missing$/, opts],
['link', /must not be a symbolic link$/, opts],
['wide', /must have no group or other permission bits$/, opts],
['group', /must have no group or other permission bits$/, opts],
['two', /must hold one non-empty line$/, opts],
['empty', /must hold one non-empty line$/, opts],
// The config dir itself under the repository, and under a --checkout.
['good', /must not be inside the repository or a stack copy$/, { ...opts, repo: root }],
['good', /must not be inside the repository or a stack copy$/, { ...opts, checkout: dir }],
['good', /must not be inside the repository or a stack copy$/, { ...opts, copies: dir }],
];
for (const [slot, why, o] of cases) {
let err = null;
try { readSlot(dir, provider(slot), o); } catch (e) { err = e; }
assert.ok(err, `${slot} was read`);
assert.match(err.message, /^console\.sso provider a client secret slot /);
assert.match(err.message, why, slot);
assert.equal(err.message.includes(value), false);
assert.equal(err.message.includes(root), false);
}
});
test('11: the CLI refuses --sso with the old flags and without console.sso, and starts with a good one', async t => {
const root = mkdtempSync(join(tmpdir(), 'wsso-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const env = { ...process.env, MOSAIC_CONFIG: join(root, 'cfg', 'config.json'), XDG_DATA_HOME: join(root, 'xdg') };
for (const flags of [['--auth-header', 'remote-user'], ['--public-host', HOST], ['--allow-user', 'jason'], ['--proof-header', 'x-proof']]) {
const r = spawnSync(process.execPath, [CLI, 'serve', '--port', '0', '--sso', ...flags], { encoding: 'utf8', timeout: 10000, env });
assert.equal(r.status, 2, flags.join(' '));
assert.match(r.stderr, /^refused: --sso cannot be combined with --public-host, --auth-header, --allow-user or --proof-\*$/m);
}
const missing = spawnSync(process.execPath, [CLI, 'serve', '--port', '0', '--sso'], { encoding: 'utf8', timeout: 10000, env });
assert.equal(missing.status, 2);
assert.match(missing.stderr, /^refused: --sso needs the system config \(/);
const op = await fakeOidc();
t.after(() => op.close());
const a = op.issuer('a');
PRODUCED.add(a.clientSecret);
mkdirSync(join(root, 'cfg', 'secrets', 'mosaic-stack'), { recursive: true });
writeFileSync(join(root, 'ca.pem'), op.cert);
const base = { configVersion: 1, environment: 'development', dataRoot: join(root, 'data'), execution: { backend: 'docker', provider: 'zai', model: 'm' } };
writeFileSync(env.MOSAIC_CONFIG, JSON.stringify(base));
const noKey = spawnSync(process.execPath, [CLI, 'serve', '--port', '0', '--sso'], { encoding: 'utf8', timeout: 10000, env });
assert.equal(noKey.status, 2);
assert.match(noKey.stderr, /^refused: --sso needs console\.sso in the system config$/m);
writeFileSync(env.MOSAIC_CONFIG, JSON.stringify({ ...base, console: { sso: { publicOrigin: ORIGIN, providers: [providerFor(a)] } } }));
writeFileSync(join(root, 'cfg', 'secrets', 'mosaic-stack', 'a'), `${a.clientSecret}\n`, { mode: 0o644 });
const wide = spawnSync(process.execPath, [CLI, 'serve', '--port', '0', '--sso'], { encoding: 'utf8', timeout: 10000, env });
assert.equal(wide.status, 2);
assert.match(wide.stderr, /^refused: console\.sso provider a client secret slot must have no group or other permission bits$/m);
chmodSync(join(root, 'cfg', 'secrets', 'mosaic-stack', 'a'), 0o600);
// The child trusts the fake OP's certificate through NODE_EXTRA_CA_CERTS; the code under test has no flag for it.
const child = spawn(process.execPath, [CLI, 'serve', '--port', '0', '--sso'], { env: { ...env, NODE_EXTRA_CA_CERTS: join(root, 'ca.pem') } });
t.after(() => child.kill('SIGKILL'));
let out = '', err = '';
child.stderr.on('data', c => (err += c));
await new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(`no start line: ${out} ${err}`)), 15000);
child.stdout.on('data', c => { out += c; if (out.includes('Ctrl-C')) { clearTimeout(timer); resolve(); } });
child.on('exit', () => { clearTimeout(timer); reject(new Error(`exited: ${err}`)); });
});
assert.match(out, new RegExp(`^Sign-in: ${ORIGIN}/ \\(--sso\\), read-only$`, 'm'));
assert.match(out, new RegExp(`^Sign-in provider: a issuer ${a.issuer.replace(/[.]/g, '\\.')} subjects 1 \\(ready\\)$`, 'm'));
assert.equal(out.includes(a.clientSecret) || err.includes(a.clientSecret), false);
assert.equal(out.includes(a.clientId), false);
const port = Number(out.match(/^Mosaic Console: http:\/\/127\.0\.0\.1:(\d+)\//m)[1]);
const health = await call(port, { path: '/healthz', host: `127.0.0.1:${port}` });
assert.deepEqual(health.body, { ok: true, sso: { a: 'ready' } });
child.kill('SIGTERM');
});
test('12: under --sso the tester listener refuses each kind of sign-in, has no /auth, and startServer refuses tester with sso', async t => {
const env = await setup(t);
const tester = await startServer({ port: 0, board: 'http://127.0.0.1:9', bus: env.bus, tester: true, testerNoSignIn: true, log: () => {} });
t.after(() => close(tester));
const port = tester.address().port, host = `127.0.0.1:${port}`;
const { session, g, begun } = await signIn(env);
assert.ok(session);
const plain = await call(port, { path: '/api/config', host });
assert.equal(plain.status, 200, 'control: a request with no sign-in is served');
for (const headers of [
{ cookie: session },
{ cookie: `theme=dark; ${begun.cookie}` },
{ cookie: `${SESSION_COOKIE.toLowerCase()}=x` },
{ authorization: `Bearer ${g.idToken}` },
{ 'x-authentik-jwt': g.idToken },
{ 'X-Authentik-Username': 'jason.woltje' },
]) {
const r = await call(port, { path: '/api/config', host, headers });
assert.equal(r.status, 403, JSON.stringify(Object.keys(headers)));
assert.deepEqual(r.body, { error: 'tester listener takes no sign-in' });
}
assert.equal((await call(port, { path: '/auth/login', host })).status, 404);
await assert.rejects(startServer({ port: 0, tester: true, sso: env.sso }), /tester listener cannot use sso/);
});
test('13: idle and absolute expiry, logout then reuse, a cross-origin logout, and background reads that don\'t keep a session', async t => {
const env = await setup(t);
const read = cookie => call(env.port, { path: '/api/bus/inbox', headers: { cookie } });
const record = () => [...env.sso.sessions.values()].at(-1);
// Background reads don't move `seen`; a page load does.
let { session } = await signIn(env);
const old = Date.now() - 59 * 60 * 1000;
record().seen = old;
assert.equal((await read(session)).status, 200);
assert.equal(record().seen, old, 'a background read refreshed the session');
assert.equal((await call(env.port, { path: '/', headers: { cookie: session } })).status, 200);
assert.ok(record().seen > old, 'a page load did not refresh the session');
// Idle: 59 minutes of background reads, then one more past the hour.
record().seen = Date.now() - 61 * 60 * 1000;
assert.equal((await read(session)).status, 401);
// Absolute.
({ session } = await signIn(env));
record().created = Date.now() - 8 * 3600 * 1000;
assert.equal((await call(env.port, { path: '/', headers: { cookie: session } })).headers.location, '/auth/login?return=%2F');
// Logout needs same-origin; refused, the session stays.
({ session } = await signIn(env));
const cross = await call(env.port, { method: 'POST', path: '/auth/logout', headers: { cookie: session, origin: ORIGIN, 'sec-fetch-site': 'cross-site' } });
assert.equal(cross.status, 403);
assert.equal((await read(session)).status, 200);
const noOrigin = await call(env.port, { method: 'POST', path: '/auth/logout', headers: { cookie: session, 'sec-fetch-site': 'same-origin' } });
assert.equal(noOrigin.status, 403);
assert.equal((await read(session)).status, 200);
const out = await call(env.port, { method: 'POST', path: '/auth/logout', headers: { cookie: session, origin: ORIGIN, 'sec-fetch-site': 'same-origin' } });
assert.equal(out.status, 303);
const end = new URL(out.headers.location);
assert.equal(`${end.origin}${end.pathname}`, `${env.iss[0].issuer}end-session`);
assert.equal(end.searchParams.get('post_logout_redirect_uri'), `${ORIGIN}/auth/signed-out`);
assert.equal(end.searchParams.get('client_id'), env.iss[0].clientId);
assert.ok(end.searchParams.get('id_token_hint'));
assert.deepEqual(setCookies(out), [`${SESSION_COOKIE}=; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=0`]);
assert.equal((await read(session)).status, 401);
assert.equal((await call(env.port, { method: 'GET', path: '/auth/logout', headers: { cookie: session } })).status, 405);
});
test('13: the CSP lets the Sign out form reach the provider\'s end-session origin, and only under --sso', async t => {
const env = await setup(t);
const r = await call(env.port, { path: '/healthz', host: `127.0.0.1:${env.port}` });
assert.match(r.headers['content-security-policy'], new RegExp(`; form-action 'self' ${env.op.base.replace(/[.]/g, '\\.')}$`));
const plain = await startServer({ port: 0, board: 'http://127.0.0.1:9' });
t.after(() => close(plain));
const p = await call(plain.address().port, { path: '/healthz', host: `127.0.0.1:${plain.address().port}` });
assert.match(p.headers['content-security-policy'], /; form-action 'self'$/);
});
test('14: open redirects land on /, and a path on the Console lands there', async t => {
const env = await setup(t);
for (const [query, where] of [
['return=%2F%2Fevil.example', `${ORIGIN}/`],
['return=https%3A%2F%2Fevil.example', `${ORIGIN}/`],
['return=%2F%5Cevil.example', `${ORIGIN}/`],
['return=/%09/evil.example', `${ORIGIN}/`],
// Dot segments that leave a pathname of //evil.example stay on the Console (webby-0019 B1).
['return=%2F.%2F%2Fevil.example', `${ORIGIN}//evil.example`],
['return=%2Fx%2F..%2F%2Fevil.example', `${ORIGIN}//evil.example`],
[`return=${encodeURIComponent(`${ORIGIN}//evil.example`)}`, `${ORIGIN}//evil.example`],
['return=%2Fboard%2F.%2Fx', `${ORIGIN}/board/x`],
['return=%2Fboard%3Fx%3D1', `${ORIGIN}/board?x=1`],
['', `${ORIGIN}/`],
]) {
const begun = await begin(env, { query });
const r = await callback(env, begun, { code: grant(env, begun, env.iss[0]).code });
assert.equal(r.status, 303);
assert.equal(r.headers.location, where, query);
assert.equal(new URL(r.headers.location).origin, ORIGIN, query);
PRODUCED.add(sessionOf(r).split('=')[1]);
}
});
test('15: a callback for A carrying B\'s code and iss, and a provider= query, is refused', async t => {
const env = await setup(t, { issuers: [{ name: 'a' }, { name: 'b' }] });
const [, b] = env.iss;
for (const extra of [{ iss: b.issuer, provider: 'b' }, { provider: 'b' }]) {
const begun = await begin(env, { provider: 'a' });
// B's code against A's PKCE challenge and nonce, for B's own subject: only the record rule
// (this callback belongs to A) stands in the way.
const g = grant(env, begun, b, { sub: 'user-b' });
const r = await callback(env, begun, { code: g.code, extra });
refused(env, r);
}
assert.equal(b.tokenRequests.length, 0, 'B\'s token endpoint was called');
});
test('16: a provider with RS256 and ES256 takes either, and refuses PS256 with a usable PS256 key in the JWKS', async t => {
const rsa = rsaKey(), ec = ecKey(), ps = psKey();
const env = await setup(t, { issuers: [{ name: 'a', keys: [rsa, ec, ps] }], provider: { a: { algs: ['RS256', 'ES256'] } } });
assert.ok((await signIn(env, { opts: { key: rsa } })).session);
assert.ok((await signIn(env, { opts: { key: ec } })).session);
refused(env, (await signIn(env, { opts: { key: ps } })).r, 'id-token-invalid');
});
test('17: 33 started logins leave 32 records, and the first one\'s callback finds none', async t => {
const env = await setup(t);
const first = await begin(env);
for (let i = 0; i < 32; i++) await begin(env);
assert.equal(env.sso.logins.size, 32);
refused(env, await callback(env, first, { code: grant(env, first, env.iss[0]).code }), 'login-record-missing');
});
test('18: after a key rotation a sign-in works through one refetch, and the removed kid is refused', async t => {
const env = await setup(t);
const [iss] = env.iss;
t.mock.timers.enable({ apis: ['Date'], now: Date.now() });
const old = iss.keys[0];
assert.ok((await signIn(env)).session);
const fetches = iss.jwksFetches;
env.op.rotate(iss);
t.mock.timers.tick(61000);
assert.ok((await signIn(env)).session);
assert.equal(iss.jwksFetches, fetches + 1);
refused(env, (await signIn(env, { opts: { key: old } })).r, 'signature-invalid');
});
test('20 and 21: the store holds no raw session id, and the cookie strings are exact', async t => {
const env = await setup(t);
const begun = await begin(env);
assert.match(setCookies(begun.r)[0], new RegExp(`^${LOGIN_COOKIE}=[A-Za-z0-9_-]{43}; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=600$`));
for (const key of env.sso.logins.keys()) assert.match(key, /^[0-9a-f]{64}$/);
const r = await callback(env, begun, { code: grant(env, begun, env.iss[0]).code });
const set = setCookies(r);
assert.equal(set.length, 2);
assert.equal(set[0], `${LOGIN_COOKIE}=; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=0`);
assert.match(set[1], new RegExp(`^${SESSION_COOKIE}=[A-Za-z0-9_-]{43}; Path=/; Secure; HttpOnly; SameSite=Lax$`));
const id = set[1].split(';')[0].split('=')[1];
PRODUCED.add(id);
for (const [key, value] of env.sso.sessions) {
assert.match(key, /^[0-9a-f]{64}$/);
assert.notEqual(key, id);
assert.equal(JSON.stringify(value).includes(id), false);
}
});
test('22: a loopback Host is 403 on a page, a read and a write; /healthz on loopback is 200; a redirect_uri never comes from the Host', async t => {
const env = await setup(t);
const { session } = await signIn(env);
const loop = `127.0.0.1:${env.port}`;
for (const [method, path, body] of [['GET', '/', undefined], ['GET', '/api/board', undefined], ['GET', '/api/bus/inbox', undefined], ['POST', '/api/bus/message', MSG]]) {
// A loopback Host on this listener's port; any other Host is already the older non-local refusal.
for (const host of [loop, `localhost:${env.port}`]) {
const r = await call(env.port, { method, path, host, body, headers: { cookie: session, 'content-type': 'application/json' } });
assert.equal(r.status, 403, `${method} ${path} ${host}`);
assert.deepEqual(r.body, { error: 'public Host required' });
}
}
const health = await call(env.port, { path: '/healthz', host: loop });
assert.equal(health.status, 200);
assert.deepEqual(health.body, { ok: true, sso: { a: 'ready' } });
assert.equal(env.sends.length, 0);
const begun = await begin(env, { host: HOST.toUpperCase() });
assert.equal(begun.params.get('redirect_uri'), `${ORIGIN}/auth/callback`);
});
test('23: a provider down at start leaves the Console up and 503, and the retry makes sign-in work', async t => {
let env = await setup(t, { retryFirst: 50, before: (op, [a]) => { a.mode = 'down'; } });
assert.equal((await call(env.port, { path: '/auth/login' })).status, 503);
assert.deepEqual((await call(env.port, { path: '/healthz', host: `127.0.0.1:${env.port}` })).body.sso, { a: 'unreachable' });
assert.match(env.lines.at(-1), / sso refused code=discovery-failed provider=a$/);
env.iss[0].mode = 'ok';
for (let i = 0; i < 100 && env.sso.health().a !== 'ready'; i++) await new Promise(r => setTimeout(r, 20));
assert.deepEqual(env.sso.health(), { a: 'ready' });
assert.ok((await signIn(env)).session);
// A 200 HTML page at the discovery URL is unreachable too, and the Console starts.
env = await setup(t, { before: (op, [a]) => { a.mode = 'html'; } });
assert.deepEqual(env.sso.health(), { a: 'unreachable' });
// HS-only discovery at start refuses the start.
await assert.rejects(setup(t, { before: (op, [a]) => { a.discovery = { id_token_signing_alg_values_supported: ['HS256'] }; } }), /refused at discovery/);
});
test('19: no captured log line holds a token, code, state, nonce, verifier, client secret or session id', () => {
assert.ok(ALL_LINES.length > 20, `only ${ALL_LINES.length} lines`);
assert.ok(PRODUCED.size > 50, `only ${PRODUCED.size} values`);
for (const line of ALL_LINES) {
for (const value of PRODUCED) if (value) assert.equal(line.includes(value), false, `a log line holds a produced value: ${line.slice(0, 80)}`);
assert.match(line, /^\S+ (sso refused code=[a-z-]+ provider=[a-z0-9-]+( iss="[^"]*" sub="[^"]*")?|send user="[^"]+" to="[^"]*" (id=msg-0001|error=[a-z -]+))$/, line);
}
});
+2 -3
View File
@@ -6,9 +6,8 @@
"tools": ["read", "grep", "find", "ls"],
"network": "api-only",
"authority": {
"withinRole": ["message.send", "role.launch", "role.end", "organization.change", "record.write", "instance.name", "role.hire", "role.fire"],
"withinRole": ["message.send", "role.launch", "role.end", "organization.change", "record.write", "instance.name"],
"crossRole": []
},
"credentials": [],
"hireable": []
"credentials": []
}
+2 -8
View File
@@ -31,14 +31,8 @@ product's code, you don't review it and you don't create tasks yourself.
holds them and what they may do. To terminate a role instance, raise a
`role.revoke` decision, naming the instance and saying why. Change the
launch limits, the model table and the model each instance runs with
the `change_organization` tool. Hire an instance with `hire` and fire
one with `fire`, each with a reason; you can hire only a definition
your role file lists as hireable, and never an instance the human
kept. A hire is listed for `launch` at once, with no restart, but it
runs only after the human sets its route (harness and thinking); until
then `launch` refuses it `no-route`. A fired instance stays in the file
off the list, so `launch` refuses it. To change what an instance may
do, raise a `policy.change` decision.
the `change_organization` tool. To hire an instance or change what one
may do, raise a `policy.change` decision.
- You and the human are the ones who configure and stop the assistant.
Change its vars, its tracker or its definition with
`change_organization` and `assistants`; its holder and credentials
+2 -2
View File
@@ -27,9 +27,9 @@ if [ "${1:-}" = queue ]; then
fi
if [ "${1:-}" = business ]; then
# Row 165 (#1662): the human's role instance commands need the human proof,
# so they run in packages/cli. validate and resolve stay in packages/business. Row 194 adds veto.
# so they run in packages/cli. validate and resolve stay in packages/business.
case "${2:-}" in
instances|instance|veto) exec node "$REPO/packages/cli/src/cli.mjs" "$@" ;;
instances|instance) exec node "$REPO/packages/cli/src/cli.mjs" "$@" ;;
esac
shift
exec node "$REPO/packages/business/src/cli.mjs" "$@"
+9 -32
View File
@@ -26,7 +26,6 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import process from "node:process";
import { pathToFileURL } from "node:url";
const SUPPORTED_CONFIG_VERSION = 1;
const SUPPORTED_ENVIRONMENTS = new Set(["development", "production"]);
@@ -107,30 +106,13 @@ function isAncestorOf(ancestor, candidate) {
return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
}
// Q175 phase 1 (#1690): the optional `console` key, checked for shape only by the Console's own
// checker. It is imported only when the key is there, so a config without it never needs the
// Console's code, and a copy of this file alone still reads one.
async function validateConsole(value) {
let checkConsole;
try {
({ checkConsole } = await import(pathToFileURL(path.join(import.meta.dirname, "..", "packages", "webui", "src", "sso-config.mjs")).href));
} catch {
fail(2, 'the "console" key needs packages/webui/src/sso-config.mjs beside this loader');
}
try {
return checkConsole(value);
} catch (error) {
fail(2, error.message);
}
}
async function validate(document, file) {
function validate(document, file) {
if (!isPlainObject(document)) {
fail(2, "configuration must be a JSON object");
}
rejectUnknownKeys(
document,
["configVersion", "environment", "dataRoot", "execution", "console"],
["configVersion", "environment", "dataRoot", "execution"],
"configuration",
);
@@ -164,10 +146,6 @@ async function validate(document, file) {
fail(2, `unsupported execution.adapter: ${JSON.stringify(adapter)} (supported: ${[...SUPPORTED_ADAPTERS].join(", ")})`);
}
// Last, so the older checks keep their order. The key is in the output only when the file has
// it, so a config without it prints what it always printed.
const consoleKey = Object.hasOwn(document, "console") ? await validateConsole(document.console) : undefined;
return {
configVersion: document.configVersion,
environment: document.environment,
@@ -178,11 +156,10 @@ async function validate(document, file) {
model: document.execution.model,
adapter,
},
...(consoleKey === undefined ? {} : { console: consoleKey }),
};
}
async function load(file) {
function load(file) {
const raw = readRaw(file);
if (raw === null) {
fail(3, `configuration not found: ${file} (run scripts/bootstrap.sh to create it)`);
@@ -193,7 +170,7 @@ async function load(file) {
} catch (error) {
fail(2, `configuration is not valid JSON (${file}): ${error.message}`);
}
return await validate(document, file);
return validate(document, file);
}
function shellQuote(value) {
@@ -217,7 +194,7 @@ const file = configPath();
switch (operation) {
case "bootstrap": {
if (fs.existsSync(file)) {
await load(file); // validate only; never rewrite
load(file); // validate only; never rewrite
process.stderr.write(`mosaic-config: configuration already present, validated without changes: ${file}\n`);
process.exit(0);
}
@@ -229,26 +206,26 @@ switch (operation) {
fs.writeFileSync(fd, `${JSON.stringify(DEFAULT_CONFIG, null, 2)}\n`);
} catch (error) {
if (error.code === "EEXIST") {
await load(file);
load(file);
process.exit(0);
}
fail(1, `unable to create configuration: ${error.message}`);
} finally {
if (fd !== undefined) fs.closeSync(fd);
}
await load(file);
load(file);
process.stderr.write(`mosaic-config: created default configuration: ${file}\n`);
process.exit(0);
}
case "validate": {
const resolved = await load(file);
const resolved = load(file);
process.stdout.write(`${JSON.stringify(resolved, null, 2)}\n`);
process.exit(0);
}
case "env": {
const resolved = await load(file);
const resolved = load(file);
process.stdout.write(
[
`MOSAIC_DATA_ROOT=${shellQuote(resolved.dataRoot)}`,
-46
View File
@@ -151,52 +151,6 @@ else
FAIL=$((FAIL+1)); echo "${C_FAIL}FAIL${C_RESET} env exports resolve correctly"
fi
# --- Q175 phase 1 (#1690), design test 25: the optional console key ---
# Shape only. The slot path is a FIFO, so a loader that opened the slot would block and the
# timeout would fail the case; no case reads a slot file.
mkdir -p "$SANDBOX/secrets/mosaic-stack"
mkfifo "$SANDBOX/secrets/mosaic-stack/console-sso-test"
sso_body() {
printf '{"configVersion":1,"environment":"development","dataRoot":"%s","execution":{"backend":"docker","provider":"zai","model":"m"},"console":%s}' "$DATA_ROOT" "$1"
}
# provider [ALGS] [CLIENT_SECRET]: one provider entry; an empty argument takes the valid value.
provider() {
local algs='["RS256"]' secret='{"slot":"console-sso-test"}'
[ -n "${1:-}" ] && algs="$1"
[ -n "${2:-}" ] && secret="$2"
printf '{"id":"fake","issuer":"https://op.test/app/","clientId":"client-1","clientSecret":%s,"algs":%s,"subjects":[{"sub":"user-1","name":"jason.woltje"}]}' "$secret" "$algs"
}
cfg sso-valid.json "$(sso_body '{"sso":{"publicOrigin":"https://console.test","providers":['"$(provider)"']}}')"
OUT="$(MOSAIC_CONFIG="$SANDBOX/sso-valid.json" timeout 20 $CONFIG_OP validate 2>&1)"
[ $? -eq 0 ] && printf '%s' "$OUT" | grep -q '"publicOrigin": "https://console.test"'
check "valid console.sso loads and is in the output" $?
# expect_refusal NAME PATH-IN-MESSAGE BODY: exit 2, and the message names the bad key path.
expect_refusal() {
cfg sso-bad.json "$2"
local err rc
err="$(MOSAIC_CONFIG="$SANDBOX/sso-bad.json" timeout 20 $CONFIG_OP validate 2>&1 >/dev/null)"
rc=$?
[ "$rc" -eq 2 ] && printf '%s' "$err" | grep -qF -- "$3"
check "$1 (exit $rc)" $?
}
expect_refusal "console.sso HS256 alg exits 2 naming the path" \
"$(sso_body '{"sso":{"publicOrigin":"https://console.test","providers":['"$(provider '["HS256"]')"']}}')" 'console.sso.providers[0].algs[0] must be one of'
expect_refusal "console.sso alg none exits 2 naming the path" \
"$(sso_body '{"sso":{"publicOrigin":"https://console.test","providers":['"$(provider '["RS256","none"]')"']}}')" 'console.sso.providers[0].algs[1] must be one of'
expect_refusal "console.sso missing slot field exits 2 naming the path" \
"$(sso_body '{"sso":{"publicOrigin":"https://console.test","providers":['"$(provider '' '{}')"']}}')" 'console.sso.providers[0].clientSecret.slot is required'
expect_refusal "console.sso slot name outside the pattern exits 2 naming the path" \
"$(sso_body '{"sso":{"publicOrigin":"https://console.test","providers":['"$(provider '' '{"slot":"../x"}')"']}}')" 'console.sso.providers[0].clientSecret.slot must match'
expect_refusal "unknown key under console exits 2 naming the path" \
"$(sso_body '{"sso":{"publicOrigin":"https://console.test","providers":['"$(provider)"']},"extra":1}')" 'console.extra is not a supported key'
# Without the key: the output is exactly what the loader printed before phase 1 (sage-1340, F2).
cfg no-console.json '{"configVersion":1,"environment":"development","dataRoot":"'$DATA_ROOT'","execution":{"backend":"docker","provider":"zai","model":"m"}}'
EXPECTED="$(printf '{\n "configVersion": 1,\n "environment": "development",\n "dataRoot": "%s",\n "execution": {\n "backend": "docker",\n "provider": "zai",\n "model": "m",\n "adapter": "pi"\n }\n}' "$DATA_ROOT")"
[ "$(MOSAIC_CONFIG="$SANDBOX/no-console.json" timeout 20 $CONFIG_OP validate)" = "$EXPECTED" ]
check "config without console loads with unchanged output" $?
# --- validation must not modify the file ---
SUM_INVALID_BEFORE=$(sha256sum "$SANDBOX/invalid.json" | cut -d' ' -f1)
MOSAIC_CONFIG="$SANDBOX/invalid.json" $CONFIG_OP validate >/dev/null 2>&1
+7 -20
View File
@@ -49,9 +49,6 @@
# character set and refused otherwise. --proof-header NAME --proof-file PATH
# go together under preview; only the path is carried here, never the secret.
# render checks ownership, mode and location without opening the proof file.
# --sso (Q175 phase 1, #1690): the Console signs people in itself from
# console.sso in config.json. It refuses the preview set and the proof pair,
# and --send takes it in place of the preview set. Nothing secret is carried.
#
# There is no start, stop, enable or restart verb. The Console reads the
# main listener's bus through the human transport; the optional loopback
@@ -71,7 +68,7 @@ REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TEMPLATE="$REPO/packages/webui/systemd/mosaic-webui.service.in"
UNIT_NAME="mosaic-webui.service"
USAGE="usage: scripts/webui-service.sh copy COMMIT [--copies DIR] [--allow-worktree] | render [--copy ID [--copies DIR]] [FLAGS] | install --copy ID [--copies DIR] [--dir DIR] [--no-reload] [--allow-worktree] [FLAGS] | uninstall [--dir DIR] [--no-reload] | status [--dir DIR]
FLAGS: [--port N] [--business ID] [--board URL] [--sso [--send] | --public-host NAME --auth-header HEADER --allow-user ID [--send]] [--tester-port N] [--proof-header NAME --proof-file PATH]
FLAGS: [--port N] [--business ID] [--board URL] [--public-host NAME --auth-header HEADER --allow-user ID [--send]] [--tester-port N] [--proof-header NAME --proof-file PATH]
--tester-port: 1024..65535, different from --port and never 7332; loopback tester listener.
--allow-worktree is for the tests only: copy and install otherwise refuse outside the canonical checkout."
@@ -94,7 +91,6 @@ PUBLIC_HOST=""
AUTH_HEADER=""
ALLOW_USER=""
SEND=0
SSO=0
PROOF_HEADER=""
PROOF_FILE=""
PROOF_SET=0
@@ -118,7 +114,6 @@ parse_flags() {
--proof-header) [ $# -ge 2 ] || usage; PROOF_HEADER="$2"; PROOF_SET=1; SERVE_SET=1; shift 2 ;;
--proof-file) [ $# -ge 2 ] || usage; PROOF_FILE="$2"; PROOF_SET=1; SERVE_SET=1; shift 2 ;;
--send) SEND=1; SERVE_SET=1; shift ;;
--sso) SSO=1; SERVE_SET=1; shift ;;
*) usage ;;
esac
done
@@ -171,13 +166,9 @@ serve_flags() {
[ -z "$PUBLIC_HOST" ] || preview=$((preview + 1))
[ -z "$AUTH_HEADER" ] || preview=$((preview + 1))
[ -z "$ALLOW_USER" ] || preview=$((preview + 1))
# serve refuses --sso with the preview set or the proof pair, and --send
# without --sso or the preview set; refuse them here first, so no unit is
# written that would only exit 2 at start.
if [ "$SSO" = 1 ] && { [ "$preview" -ne 0 ] || [ "$PROOF_SET" = 1 ]; }; then
die "--sso cannot be combined with --public-host, --auth-header, --allow-user or --proof-*"
fi
[ "$SEND" = 0 ] || [ "$preview" -eq 3 ] || [ "$SSO" = 1 ] || die "--send needs the preview mode (--public-host, --auth-header and --allow-user) or --sso"
# serve refuses --send without the preview set; refuse it here first,
# so no unit is written that would only exit 2 at start.
[ "$SEND" = 0 ] || [ "$preview" -eq 3 ] || die "--send needs the preview mode (--public-host, --auth-header and --allow-user)"
if [ "$preview" -ne 0 ]; then
[ "$preview" -eq 3 ] || die "--public-host, --auth-header and --allow-user go together: all three or none"
case "$PUBLIC_HOST" in [A-Za-z0-9]*) ;; *) die "public host must be a hostname, no port or scheme: $PUBLIC_HOST" ;; esac
@@ -210,7 +201,6 @@ serve_flags() {
{ [ "$proof_mode" = 600 ] || [ "$proof_mode" = 400 ]; } && [ "$proof_uid" = "$(id -u)" ] || die "invalid proxy proof file metadata"
flags="$flags --proof-header $PROOF_HEADER --proof-file $PROOF_FILE"
fi
[ "$SSO" = 0 ] || flags="$flags --sso"
[ "$SEND" = 0 ] || flags="$flags --send"
[ "$TESTER_SET" = 0 ] || flags="$flags --tester-port $TESTER_PORT"
echo "$flags"
@@ -395,17 +385,14 @@ status_unit() {
else
echo "copy: none; the unit runs from $wd"
fi
# render puts --send after the allowed user and optional proof pair, or
# after --sso, before the tester port. The allowed user
# render puts --send after the allowed user and optional proof pair,
# before the tester port. The allowed user
# starts with a letter or digit (N5), so it is never the value --send.
if grep -Eq '^ExecStart=.*( --allow-user [^ ][^ ]*( --proof-header [^ ]+ --proof-file /[^ ]+)?| --sso) --send( --tester-port [0-9]+)?$' "$target"; then
if grep -Eq '^ExecStart=.* --allow-user [^ ][^ ]*( --proof-header [^ ]+ --proof-file /[^ ]+)? --send( --tester-port [0-9]+)?$' "$target"; then
echo "send: on (--send; human messages to roles)"
else
echo "send: off (read-only)"
fi
if grep -Eq '^ExecStart=.* --sso( --send)?( --tester-port [0-9]+)?$' "$target"; then
echo "sign-in: the Console's own (--sso)"
fi
local tester_port
tester_port="$(sed -n 's/^ExecStart=.* --tester-port \([0-9][0-9]*\)$/\1/p' "$target")"
if [ -n "$tester_port" ]; then