plans: Case 2 row briefs from Gosalyn's eac92d956 (design r11, approved webby-0038: C2-13 split into C2-13a unit render and host precheck and C2-13b install script and library precheck, C2-04 owner Herb, Console unit without [Install] per mos-1097)

This commit is contained in:
2026-10-20 16:22:47 -05:00
parent 4429a104f6
commit 5b8def2f14
+239 -91
View File
@@ -18,9 +18,9 @@ packages adapters roles scripts` touches only `scripts/stack-release.sh` and its
is in either.
Order (design 8, "Parallel waves"):
1. Start now: C2-01, C2-08, C2-11a.
1. Start now: C2-01, C2-08, C2-11a, C2-13a.
2. On C2-01's candidate: C2-04, C2-05a, C2-06, C2-12. On C2-08's: C2-09a, C2-10a, C2-20.
3. Then C2-03a and C2-07. Then C2-05b, C2-10b and C2-13.
3. Then C2-03a and C2-07. Then C2-05b, C2-10b and C2-13b.
4. Last: C2-14.
A row whose parent hasn't landed builds on the parent's candidate and rebases onto its landing (Q177).
@@ -34,7 +34,7 @@ Rules every row keeps:
text wherever a test checks that output holds none.
- No credential value in any output, test, log or message (CONSTITUTION gate 13). Tests use a fixture
token file with a made-up value.
- No live stack change. Nothing here re-pins, restarts or activates anything. C2-13's run on the host
- No live stack change. Nothing here re-pins, restarts or activates anything. C2-13a and C2-13b's runs on the host
needs Jason's go (design 10).
- Before in-review, grep `packages/*/tests` and `scripts/test-*.sh` for each changed path and for any
64-hex pin of it, and run every changed or touched test file on its own. Suites need Sage's slot.
@@ -172,7 +172,7 @@ Tests, all named in the design 8 C2-08 row:
### Out of scope
Thread creation per instance and the launcher (C2-09a), the lookup tools (C2-05b), the cited-reply guard
(C2-06). Checking the host's claude for `--system-prompt-file` is C2-13's precheck. If the C2-20 dry run
(C2-06). Checking the host's claude for `--system-prompt-file` is C2-13a's precheck. If the C2-20 dry run
finds heading lines in a real persona, the fence rule changes in a follow-up to this row, reviewed,
before the import (design 9.1).
@@ -230,7 +230,7 @@ from changing `projects`, and nothing creates a mirror.
### Owner and reviewer
- Owner: Rocko, gpt-6.1-sol.
- Owner: Herb, gpt-6.1-sol. Herb replaces Rocko, who holds five other rows (sage-1482).
- Reviewer: Filbert.
- After: C2-01.
@@ -706,27 +706,236 @@ Dewey approves the manifest. The webui suite is green on Sage's gate, along with
---
## C2-13 install script and precheck for the host
## C2-13a unit render and host precheck
### Problem
The hub runs on 10.1.1.54 as `mosaic` under `/srv/mosaic`, as `mosaic-stack.service` with
`ProtectHome` (design 9, Q181). Nothing installs a library business there, or checks the host before it
starts.
The hub runs on 10.1.1.54 as `mosaic` under `/srv/mosaic`, as two system units,
`mosaic-bus@<business>.service` and `mosaic-webui.service` (design 9 and 10 step 2c, r11). Nothing
renders those units for a system install, and nothing checks the host before the start. Row 195 needs
the same render and precheck for Case 1 on 10.1.1.53, so this part comes first and has no library
dependency (sage-1480, Q177).
### Owner and reviewer
- Owner: Gosalyn, Opus 5.5. The scripts run on a host only under Jason's go, by him or his delegate
(design 10).
- Reviewer: Webby.
- After: nothing. Row 195 chains on this row's candidate.
### Files owned
`scripts/case2-unit.sh` and `scripts/case2-precheck.sh` (new), `packages/cli/tests/case2-unit.test.mjs`
(new), and one line of `packages/cli/README.md`: at `:713`, "nothing in the repository creates or reads
it" changes to say that nothing creates it, that only the Case 2 precheck reads it, and that the
precheck prints only variable names, never values (webby-0034 N2, webby-0035).
### What ships: `case2-unit.sh`
`case2-unit.sh` is the 2c step. Root runs it from a root-owned checkout of the reviewed commit outside
`/srv/mosaic`, never from the release, because `mosaic` can write the release.
Inputs. `--profile factory|library` (default `library`), `--user`, `--home`, `--business`, `--copy ID`,
`--node PATH`, and the Console's serve flags after `--`. Row 195 calls it with those names.
- `--node` must be an absolute path to a root-owned regular file outside `<home>`.
- The copy id must be exactly 40 lowercase hex characters naming a directory that holds
`scripts/mosaic`, directly under `<home>/.local/share/mosaic-stack/copies`. That is the rule
`copy_root` uses (`scripts/stack-copy.sh:6`, `:20`). Anything else refuses before the render.
- The serve flags. The script copies the checks from `serve_flags` (`scripts/webui-service.sh:146-207`)
into its own function, with its own tests. It doesn't source or change that script, because its
proof-file check compares the owner to `id -u` (`:201`), which is root here, and its render finds
node with `command -v` (`:237`), not `--node` (webby-0036 N3). The library profile takes exactly
these flags, each once, and refuses any other:
- `--port N`, with N in 1..1023 (design 9.3).
- `--business ID`, equal to `--business`.
- `--public-host NAME`, `--auth-header HEADER` and `--allow-user ID`, checked as
`webui-service.sh` checks them. Their values come from design 10 step 5.
- `--send`, because C2-10b's chat needs it.
- `--proof-header NAME` and `--proof-file PATH`. The proof is required, not optional (design 9.3).
The file must be a regular file, not a symlink, owned by `<user>`'s uid from `getent passwd`, mode
0600 or 0400, under `<home>/.config/mosaic-dev/secrets/`. That keeps it out of the data root, where
a session's run directory is. The script stats it and never opens it.
- It refuses `--tester-port`. The tester listener takes instance, route, name and chat writes from
loopback with no sign-in (`packages/webui/src/serve.mjs:472`, `:532-542`).
- It refuses `--checkout`. Her host has no canonical checkout, and the queue store refuses a read
from the copy, so the queue panel isn't part of Case 2 (webby-0036 N4).
- It refuses `--board`. Case 2 runs no control board, and design 9.3 says why the default stays.
The factory profile takes the same set plus an optional `--tester-port N` in 1024..65535, never equal
to `--port`, because Case 1's acceptance uses the tester. Row 195 names any other difference in its
own brief.
The render. It renders two system units from the checkout's existing templates and changes neither:
`[email protected]` from `packages/cli/systemd/mosaic-bus.service.in`, enabled as
`mosaic-bus@<business>.service`, and `mosaic-webui.service` from
`packages/webui/systemd/mosaic-webui.service.in`. Every token has one value (webby-0036 B2):
- `@REPO@` is the copy directory.
- `@NODE@` is `--node`.
- `@PATH@` is the directory of `--node` first, then whichever of `/usr/local/bin`, `/usr/bin` and `/bin`
it isn't, in that order. The bus's `ExecStart=` is `scripts/mosaic`, which runs `exec node` from
PATH (`scripts/mosaic:22-40`), so this line picks the bus's node. It differs from
`scripts/webui-service.sh:246-248` in one case on purpose. That script leaves the list alone when the
node's directory is already in it, so with `--node /usr/bin/node` a node in `/usr/local/bin` would
come first and the bus would run it.
- `@FLAGS@` is the checked serve flags, in the order given.
- `@PM_FLAGS@` and `@CLAUDE_ENV@` render empty.
- `%h` becomes the literal `--home`, because in a system unit `%h` is root's home.
- After the render it refuses any `@[A-Z_]+@` or `%h` left in either unit.
Lines it adds or replaces. At the end of `[Service]` in both units it adds `User=<user>`,
`Group=<user's primary group>` and these four lines, in this order (webby-0036 B3):
```
ProtectSystem=strict
ReadWritePaths=<home>
ProtectHome=yes
PrivateTmp=yes
```
The templates' own `NoNewPrivileges=yes` stays. The Console unit also gets
`AmbientCapabilities=CAP_NET_BIND_SERVICE` and `CapabilityBoundingSet=CAP_NET_BIND_SERVICE`, so it can
bind its port below 1024 (design 9.3). The bus unit gets no capability line. It adds nothing else. No
line restricts namespaces or system calls. A session's bwrap needs a user, PID and IPC namespace
(`packages/seat/src/sandbox.mjs:67`) and a mount namespace, and `RestrictNamespaces=`,
`PrivateUsers=` or `SystemCallFilter=` can each take one away, so it refuses a template or render that
holds any of them.
- `[Install]`. In the bus unit, `WantedBy=default.target` is replaced by `WantedBy=multi-user.target`,
not followed by a second line (webby-0036 N1). The Console unit renders with no `[Install]` section
(webby-0036 B1, mos-1097). The script enables only the bus unit. Jason starts the Console at design
10 step 9 and after each boot.
- No `After=` or `Wants=`. The Console never starts at boot, so there is no boot order to set, and
serve calls the bus per request, so it starts without the bus (webby-0036 N2). The step 9 order is
manual: the bus, then the Console. Stop in reverse.
Checks. It refuses unless `User=`, `Group=`, `ExecStart=`, the bus unit's `EnvironmentFile=`, the
hardening lines and the Console's two capability lines match fixed values. `EnvironmentFile=` is the
line in `packages/cli/systemd/mosaic-bus.service.in:24`, with the literal home. The Console unit has
none, and it refuses one. It also refuses:
- any `PermissionsStartOnly=`, and any `Exec*=` value whose leading run of prefix characters (`@`, `-`,
`:`, `+`, `!`) holds a `+` or `!`. systemd takes those prefixes in any order, so `-+/srv/mosaic/x`
runs as root even with `User=mosaic`. The named-line check doesn't read these lines (webby-0026 N1,
webby-0027, defence in depth).
- on a re-install, an installed pair whose two units name different copy ids (webby-0036 N5). The bus
accepts only its own `human-cli.mjs` path, so a split pair breaks the human proof
(`scripts/webui-service.sh:266-273`).
- on a re-install, an installed unit that differs from the render in anything but the copy id. So the
node path and serve flags must equal the installed ones. The copy id is the one value that may
change, so a re-pin or a rollback to another copy re-renders both units together (row 195).
It writes both units under `/etc/systemd/system/` as `root:root` 0644. It never uses `systemctl link`,
and it refuses a symlink at either unit path. It opens no file under `<home>`. It only stats the copy
directory, its `scripts/mosaic` and the proof file.
### What ships: `case2-precheck.sh`
It runs as `<user>` and takes `--profile factory|library`, default `library`. C2-13a ships the host
checks below. C2-13b adds the library checks to the same file. It refuses unless all of these hold:
- The config validates.
- `sudo -n true` fails as `<user>`, and `id -G` prints exactly one entry, its primary group. A system
user always has one, so the check is for supplementary groups.
- `bus.tester`: `library` refuses it on. `factory` requires it on, because Case 1's acceptance uses the
tester. `factory` refuses a business file that has a `kind: "library"` project, with
`profile-library-business`, so Case 2 can't reach a tester-on check through a wrong flag.
- The run root is 0700.
- `XDG_DATA_HOME` is unset or is `<home>/.local/share`. `copy_root` reads the copies dir from it
(`scripts/stack-copy.sh:6`), and the unit fixes `<home>/.local/share`, so any other value would put
the copy where the unit refuses it (webby-0032 N2).
- `node --version` is 24 or later, because every package's `engines` field says `>=24` (row 195: .53
has v22.22.2).
- `bwrap --unshare-all --ro-bind / / true` exits 0 as `<user>`. Without it no session starts. Row 195
found no bwrap on .53, and `kernel.apparmor_restrict_unprivileged_userns=1`, and .54 may be the same.
On a refusal, the precheck prints the sysctl's value and whether `bwrap` is on PATH. This runs in
`<user>`'s shell, outside the unit's hardening, so it can't show that the unit lets bwrap run. Design
10 step 9 checks that.
- `net.ipv4.ip_unprivileged_port_start` is above the Console's `--port`, read from the installed
Console unit. It refuses when the floor is at or below the port, because a lowered floor lets any
`mosaic` process bind the port, a session included, and makes the Console's capability pointless
(design 9.3, mos-1100).
- When some instance's harness is claude-code, one marker prompt run through the host's claude with
`--system-prompt-file` shows that it replaces the default prompt. The claude version is printed. With
the design's model path, Bean and Chet run pi, so this check is skipped unless she adds a claude-code
specialist.
- When an instance's provider is `zai`, `<configDir>/secrets/claude-session.env` exists, is a regular
file owned by `<user>` at 0600, and sets a nonempty `ZAI_API_KEY` (design 10 step 2d). It parses the
file the way systemd does, so the last assignment of a name wins (webby-0032 N3). A pi launch has no
credential check, so without this the first turn is where a missing key shows. The precheck prints
the name and never the value.
It also prints free memory and verifies that HOME is `<home>`.
### Tests
`case2-unit.sh` runs against a scratch unit directory. A clean render writes 0644 files, and these hold:
- the bus unit's `Environment=PATH=` starts with the `--node` directory, both for a `--node` outside the
default list and for `/usr/bin/node`, which gives `/usr/bin:/usr/local/bin:/bin`. Its `ExecStart=` is
the copy's `scripts/mosaic`
- the Console unit's `ExecStart=` starts with `--node` and holds the flags in order
- the bus unit has exactly one `WantedBy=`, `multi-user.target`, and the Console unit has no `[Install]`
- both units hold the four hardening lines once, in order, and only the Console holds the two
capability lines
- a check of the script for `systemctl link` finds nothing
These cases refuse:
- an installed unit edited to `User=root`
- an edited `ExecStart=`
- an installed unit with a hardening line removed, and a template with `RestrictNamespaces=`,
`PrivateUsers=` or `SystemCallFilter=` added
- a bus template with the `EnvironmentFile=` line removed or pointing elsewhere, and a Console template
with one added
- a symlink at either unit path
- a `--node` that is relative, not root-owned, or under `<home>`
- a re-install with a different `--node` or different serve flags
- an installed pair whose units name different copy ids
- a template holding an unknown `@TOKEN@`, and an unrendered `%h` left in either unit
- a template with `ExecStartPre=+/srv/mosaic/x`, and one with `ExecStartPre=-+/srv/mosaic/x`
- a copy id that is short, uppercase, holds `/` or `..`, or names no directory
- with `library`: `--port 7332`, a missing proof pair, a proof file owned by root, at 0644, a symlink, or
under the data root, `--tester-port`, `--checkout`, `--board`, a `--business` different from the
unit's, and an unknown flag. With `factory`: `--tester-port` equal to `--port`.
The precheck runs against a scratch data root. Each refusal has a case. A fake `node` printing v22
refuses, a fake `bwrap` that exits 1 refuses, and an unprivileged port start at or below the Console's
port refuses. The claude check runs against a fake claude that appends rather than replaces, and that
case refuses. With no claude-code instance the claude check doesn't run, and a test shows a missing
claude passes. The key file refuses when missing, at 0644, a symlink, with `ZAI_API_KEY=` empty, and
with `ZAI_API_KEY=x` followed by `ZAI_API_KEY=`. A search of the test output for the fixture key value
finds nothing. `factory` passes with the tester on and refuses a business file with a library project.
`library` refuses with the tester on.
The bind test (mos-1100). A session can't take the Console's port. The test builds the argv a real
session gets, with `binds()` and `sandboxArgv()` from `packages/seat/src/sandbox.mjs`, around a node
one-liner that binds `127.0.0.1` on a port below 1024 and prints the error code. Inside bwrap the bind
fails with `EACCES`. The control runs the same one-liner in the same sandbox on a free port of 1024 or
above, and that bind succeeds, so the failure comes from the port floor and not from a broken sandbox.
The test reads `net.ipv4.ip_unprivileged_port_start` first. If the test host's floor is at or below the
chosen port, the test fails and says so, rather than skipping.
### Out of scope
Running either script on a host. Design 10 steps 2 to 9 are Jason's, and the root step 2c is ops-01's
over ssh as `localadmin`. The library checks and the install are C2-13b.
### Gate
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.
---
## C2-13b install script and library precheck
### Problem
Nothing installs a library business on her host, and C2-13a's precheck doesn't yet check the library
parts.
### Owner and reviewer
- Owner: Gosalyn, Opus 5.5. The script runs only under Jason's go, by him or his delegate (design 10).
- Reviewer: Webby.
- After: C2-04, C2-03a, C2-20.
- After: C2-13a, C2-04, C2-03a, C2-20.
### Files owned
`scripts/case2-install.sh`, `scripts/case2-precheck.sh` and `scripts/case2-unit.sh` (new), the template
`packages/cli/systemd/mosaic-stack.service.in` (new), `packages/cli/tests/case2-install.test.mjs`
(new), and one line of `packages/cli/README.md`: at `:713`, "nothing in the repository creates or reads
it" changes to say that nothing creates it, that only the Case 2 precheck reads it, and that the
precheck prints only variable names, never values (webby-0034 N2, webby-0035).
`scripts/case2-install.sh` (new), `scripts/case2-precheck.sh` (the library checks only) and
`packages/cli/tests/case2-install.test.mjs` (new).
### What ships
@@ -736,92 +945,31 @@ path. It runs as `<user>` through `sudo -u mosaic -H` from `localadmin`, and `mo
doesn't create `<user>` and writes no unit file. Root does those in design 10 steps 2a and 2c. Every
host value is an input, so a later host move changes only inputs.
Row 195 runs these scripts by input for Case 1 on 10.1.1.53 (darkwing to gosalyn, design at
`agents/darkwing/work/queue-195/DESIGN.md` section 2). They don't copy them. So the install and the
precheck take `--profile factory|library`, default `library`:
Row 195 runs the install by input for Case 1 on 10.1.1.53 (darkwing to gosalyn, design at
`agents/darkwing/work/queue-195/DESIGN.md` section 2). It doesn't copy it. So the install takes
`--profile factory|library`, default `library`, as the precheck does:
- `library` is everything this brief lists.
- `factory` skips the library mapping, the persona files and digests, the token slot, and
`library.sync` and `library.status`. It requires `bus.tester` on, because Case 1's acceptance uses the
tester.
- `factory` refuses a business file that has a `kind: "library"` project, with
`profile-library-business`. So Case 2 can't reach a tester-on check through a wrong flag.
- `library` refuses with `bus.tester` on, as before.
`library.sync` and `library.status`.
`case2-unit.sh` is the 2c step. Root runs it from a root-owned checkout of the reviewed commit outside
`/srv/mosaic`, never from the release, because `mosaic` can write the release. It does the following:
- It renders the unit from the checkout's template, with the user, the home and the copy id as inputs.
- It refuses unless `User=`, `Group=`, `ExecStart=`, `EnvironmentFile=` and the hardening lines match
fixed values. `EnvironmentFile=` is the line in `packages/cli/systemd/mosaic-bus.service.in:24`. The
copy id is the one part of `ExecStart=` that varies, so a re-pin to a new copy can re-render the
unit (row 195). It must be exactly 40 lowercase hex characters naming a copy that holds `scripts/mosaic`,
directly under `<home>/.local/share/mosaic-stack/copies`. That is the rule `copy_root` uses
(`scripts/stack-copy.sh:6`, `:20`). Anything else refuses before the render.
- It refuses any `PermissionsStartOnly=`, and any `Exec*=` value whose leading run of prefix
characters (`@`, `-`, `:`, `+`, `!`) holds a `+` or `!`. systemd takes those prefixes in any order,
so `-+/srv/mosaic/x` runs as root even with `User=mosaic`. The named-line check doesn't read these
lines (webby-0026 N1, webby-0027, defence in depth).
- It writes `/etc/systemd/system/mosaic-stack.service` as `root:root` 0644. It never uses
`systemctl link`, and it refuses a symlink at the unit path.
- On a re-install, it refuses an installed unit that differs from the render.
- It reads nothing from under `<user>`'s home.
The precheck refuses unless all of these hold:
- The config validates.
- `sudo -n true` fails as `<user>`, and `id -G` prints exactly one entry, its primary group. A system
user always has one, so the check is for supplementary groups. `bus.tester` is not on.
- The business file, `.history`, `changes.jsonl` and persona files have the modes in design 4, and
each persona digest matches.
- The run root is 0700.
- `XDG_DATA_HOME` is unset or is `<home>/.local/share`. `copy_root` reads the copies dir from it
(`scripts/stack-copy.sh:6`), and the unit fixes `<home>/.local/share`, so any other value would
put the copy where the unit refuses it (webby-0032 N2). A test sets it elsewhere and the precheck
refuses.
- `node --version` is 24 or later, because every package's `engines` field says `>=24` (row 195: .53
has v22.22.2).
- `bwrap --unshare-all --ro-bind / / true` exits 0 as `<user>`. Without it no session starts. Row 195
found no bwrap on .53, and `kernel.apparmor_restrict_unprivileged_userns=1`, and .54 may be the same.
On a refusal, the precheck prints the sysctl's value and whether `bwrap` is on PATH.
- When some creative instance's harness is claude-code, one marker prompt run through the host's claude
with `--system-prompt-file` shows that it replaces the default prompt. The claude version is printed.
With design r9's model path, Bean and Chet run pi, so this check is skipped unless she adds a
claude-code specialist.
- When an instance's provider is `zai`, `<configDir>/secrets/claude-session.env` exists, is a regular
file owned by `<user>` at 0600, and sets a nonempty `ZAI_API_KEY` (design 10 step 2d). It parses the
file the way systemd does, so the last assignment of a name wins (webby-0032 N3). A pi launch
has no credential check, so without this the first turn is where a missing key shows. The precheck
prints the name and never the value.
The precheck's library profile adds these checks to C2-13a's:
- The business file, `.history`, `changes.jsonl` and persona files have the modes in design 4, and each
persona digest matches.
- `library.sync` fetches her repo read-only, and `library.status` lists R-problems.
- No Discord binding exists.
It also prints free memory and verifies HOME is `/srv/mosaic`.
### Tests
Tests run against a scratch data root and a local bare repo. Each precheck refusal has a case. The
claude check runs against a fake claude that appends rather than replaces, and that case refuses. With
no claude-code instance the claude check doesn't run, and a test shows a missing claude passes. The key
file refuses when missing, at 0644, a symlink, with `ZAI_API_KEY=` empty, and with `ZAI_API_KEY=x`
followed by `ZAI_API_KEY=`. A search of the test
output for the fixture key value finds nothing.
`case2-unit.sh` runs against a scratch unit directory. These cases refuse:
- an installed unit edited to `User=root`
- an edited `ExecStart=`
- a template with a hardening line removed
- a template with the `EnvironmentFile=` line removed or pointing elsewhere
- a symlink at the unit path
- a template with `ExecStartPre=+/srv/mosaic/x`
- a template with `ExecStartPre=-+/srv/mosaic/x`
- a copy id that is short, uppercase, holds `/` or `..`, or names no directory
Precheck cases for the new lines: a fake `node` printing v22 refuses, and a fake `bwrap` that exits 1
refuses. `--profile factory` passes with the tester on and no library items, and refuses a business
file with a library project. `--profile library` refuses with the tester on.
A clean render writes a 0644 file. A test checks the script for `systemctl link`.
They run against a scratch data root and a local bare repo. Each new precheck refusal has a case: a
persona file at 0644, a digest mismatch, a business file at the wrong mode, a sync failure and a
Discord binding. The install's `factory` profile writes no library mapping, persona file or token slot.
### Out of scope
Running it on the host. Design 10 steps 2 to 7 are Jason's. Step 1 no longer waits on #421 (Q183), and
Mos lifted its hold (mos-1074). The root steps 2a and 2c are ops-01's, over ssh as `localadmin`,
so the install waits on #423 step 1 and on nothing else in #423. The Gitea bot account and the token slot are steps 3 and 4. Stopping Hermes
is step 9 and her choice.
Mos lifted its hold (mos-1074). The root steps 2a and 2c are ops-01's, over ssh as `localadmin`, so the
install waits on #423 step 1 and on nothing else in #423. The Gitea bot account and the token slot are
steps 3 and 4. Stopping Hermes is step 9 and her choice.
### Gate
@@ -896,7 +1044,7 @@ its first turn, as `packages/cli/README.md:439-444` says.
shell inside any of them can read it. Where the README names `claude-session.env` ("Unit", `:711`),
"The file holds one line" becomes "The file holds one line per variable", followed by a sentence
saying it holds every session provider key, not only Claude's (webby-0032, webby-0034 N1). Leave
`:713`, "nothing in the repository creates or reads it", alone. C2-13 owns that line.
`:713`, "nothing in the repository creates or reads it", alone. C2-13a owns that line.
- One test in `session.test.mjs`: `ENV_ALLOW` includes `ZAI_API_KEY`, and
`sessionEnv({ ZAI_API_KEY: "fixture-zai", ZAI_TOKEN: "t" }, run)` returns `ZAI_API_KEY` with the fixture
value and no `ZAI_TOKEN`. The existing allowlist test (`:51-59`) stays as it is.