gosalyn: Case 2 design r11, Console unit without [Install], render rules, hardening list, 9.3 forgery facts with a privileged Console port, C2-13 split into 13a and 13b, C2-04 to Herb (webby-0036, mos-1097, sage-1480, sage-1482)

This commit is contained in:
jason.woltje committed 2026-10-20 16:11:06 -05:00
1 parent 4281b0064c
commit fb20a61b4e
2 files changed
+366 -116

No files matched your search

+138 -11
View File
@@ -1,4 +1,4 @@
# Case 2 design: the creative writing hub (r10)
# Case 2 design: the creative writing hub (r11)
Gosalyn, 2026-10-20 (from `date -u`). Track T2 in jason-master-restart `projects/mosaic-mvp/PLAN.md`,
Vikunja #416, DECISIONS Q176-Q178. The requirement is Jason's text in TARGET.md (addendum 2026-10-20).
@@ -8,6 +8,31 @@ Base: `refactor` at 9ae8c73ac. r1 cited 5d019d876. `git diff --stat 5d019d876 9a
adapters roles scripts` is empty (Mos in mos-0980, Webby in webby-0001), so every `file:line` holds at
9ae8c73ac. Paths are relative to the repository root.
## Changes from r10
Webby returned r10 with B1-B3 and N1-N5 (webby-0036, `agents/webby/work/case2-r10/REVIEW.md` at
2beb49dae). Mos ruled on B1 in mos-1097.
- B1. The Console unit renders with no `[Install]` section. Jason starts it at step 9 and after each
boot. Decisions 94 and 102 keep the start with Jason, and Mos reads his grant to Mos in 102 as covering
dragon-lin only. The bus unit stays boot-enabled, since it holds no human capability. Step 9 states
the tradeoff. The r10 paragraph below, which let a boot start stand in for the human's start, no longer
holds.
- Mos's forgery question (mos-1097 item 2). New section 9.3 answers it from the code: which processes
run as `mosaic`, what a session can reach from inside bwrap, the narrowest form by 10-27, and what
closes the rest. It also adds one finding of mine, a port squat while the Console is down, and the
fix the C2-13a brief now carries, a privileged port.
- B2. The render has a rule for every token, `@PATH@` included, and refuses any token left over
(C2-13a brief).
- B3. The brief lists the exact hardening lines, none of which restricts namespaces or system calls.
Step 9 checks that one session starts under the bus unit.
- Split (sage-1480). C2-13 is now C2-13a, the unit render and the host precheck with no library
dependency, which row 195 chains on, and C2-13b, the install and the precheck's library checks. The
precheck file is shared: C2-13a creates it and C2-13b adds to it. C2-04's owner is Herb, not Rocko
(sage-1482).
- N1-N5 are in the C2-13a brief: the bus unit's `WantedBy=` line is replaced, not added. There is no
`After=`, with the reason. The serve checks are copied into `case2-unit.sh`, and the serve flags are
named. There is no `--checkout`, so the queue panel is out. Two units naming different copy ids refuse.
## Changes from r9
Darkwing's row 195 question (darkwing to gosalyn, after Duckworth's B1) found a gap. r9 named one
@@ -984,7 +1009,7 @@ Chains follow Q177 merge-chaining. S marks a slice row.
| Row | S | Scope | After | Owner | Test gate |
|---|---|---|---|---|---|
| C2-01 | S | `packages/library`: mapping schema, scope rule, front matter reader, link resolver (`vaultPrefix`, relative, shortest path), R1-R7 reporting, `renderItems`, and `library survey` | none | Opus (Gosalyn) | one case per R-rule and per resolver step, a link into another repo unresolved, `appears_in` wikilinks indexed. `renderItems` snapshot per kind and notice. The survey prints names, counts, key sets and link-form counts, and a marker string in a fixture body never appears in its output |
| C2-04 | S | Business file `kind: "library"` entry, skips at each consumer in 2.1, M9, mirror creation at the `projectRepo` path, human verbs `library.file` (read) and `library.status` | C2-01 | Sol (Rocko) | business file tests. One test per consumer skip. A launcher test that the workspace has no clone. M9 test. Each verb from an agent session refuses (M4) |
| C2-04 | S | Business file `kind: "library"` entry, skips at each consumer in 2.1, M9, mirror creation at the `projectRepo` path, human verbs `library.file` (read) and `library.status` | C2-01 | Sol (Herb, sage-1482) | business file tests. One test per consumer skip. A launcher test that the workspace has no clone. M9 test. Each verb from an agent session refuses (M4) |
| C2-03a | S | Fetch-only sync (7.2): `syncCommand`, askpass helper, served ref, `library-diverged`, `--accept`, sync state file, timer, `library.sync` | C2-04 | Opus | the C2-03a tests in 7.2 |
| C2-05a | S | Lookup index and queries: find, get, search, manuscript with scope, universe, isolation | C2-01 | Haiku high | isolation, scope labels on every tool, unmapped files never returned, paging under `REPLY_CAP`, rebuild time on a 3,000-file fixture |
| C2-05b | S | Broker lookup verbs and typed tools. Sync before a read older than 60 s | C2-05a, C2-03a | Sol (Rocko) | broker verb tests. Typed tool name snapshot per creative role (M3) |
@@ -996,7 +1021,8 @@ Chains follow Q177 merge-chaining. S marks a slice row.
| C2-10b | S | Console chat: cited-item renderer through `renderItems`, the status line | C2-06, C2-09a, C2-03a | Haiku high | webui tests for items, scope badges, notices, "changed since" and the status line |
| C2-12 | S | The setup sheet for Melanie (7.1, including N2 and N4, and what changes for Bean and Chet in 4) and the survey run sheet for `<host>` | C2-01 | Luna | doc only, reviewed against the survey's real flags |
| C2-20 | S | Persona import (9.1): a script that reads only the persona definitions of the two Hermes profiles, confirms profile to name, and writes each persona file (4) with its `persona.name` and `persona.sha256` for the business file. Prints names, byte counts and sha256 only. A `--dry-run` mode, run before Jason's go, prints only counts of files, lines and lines starting `# ` or `## ` | C2-08 | Opus (Gosalyn), run under Jason's go | fixture Hermes profile trees: the right fields are taken, memory and session files are never opened (a read trace on the fixture), a profile with no persona field refuses, a persona that would fail C2-08's fence check refuses, output holds no persona text, each written file is 0600 and its digest matches, `--dry-run` writes nothing and prints the right counts for a fixture with two heading lines and no marker string from it |
| C2-13 | S | Install script and precheck for `<host>` as `<user>`: bootstrap, release activate, business file with the mapping, `bean` and `chet` with their persona vars and files, any specialists she picks, her human id, token slot path, and `scripts/case2-unit.sh`, which renders the bus and Console system units from their existing templates and which root runs from a root-owned checkout in 10 step 2c. The install writes no unit. Jason or his delegate runs it (10) | C2-04, C2-03a, C2-20 | Opus (Gosalyn), Jason's go | precheck: config validates, `<user>` has no sudo and `id -G` prints exactly one entry, `bus.tester` is not on, the business file, `.history`, `changes.jsonl` and persona files have the modes in 4 and each persona digest matches, the run root is 0700, when some creative instance's harness is claude-code, one marker prompt run through the host's claude with `--system-prompt-file` shows it replaces the default prompt (its version printed), the key file in 10 step 2d exists, is owned by `<user>` at 0600 and sets a nonempty `ZAI_API_KEY` when an instance's provider is `zai` (name only, never the value), free memory printed, `library.sync` fetches her repo read-only, `library.status` lists R-problems, no Discord binding exists. `case2-unit.sh` refuses an installed unit that differs from its render (one edited to `User=root` is the test), a symlink at either unit path, and a render whose `User=`, `Group=`, `ExecStart=`, the bus unit's `EnvironmentFile=` or hardening lines are off their fixed values. Tested against a scratch data root and a local bare repo |
| C2-13a | S | `scripts/case2-unit.sh`, which renders the bus and Console system units from their existing templates and which root runs from a root-owned checkout in 10 step 2c, and `scripts/case2-precheck.sh` with the host checks. No library dependency, so row 195 chains on it (sage-1480) | none | Opus (Gosalyn) | the C2-13a brief's render and precheck cases: token rules, `@PATH@` from `--node`, the four hardening lines, the Console's capability lines and privileged port, no `[Install]` on the Console, one `WantedBy=` on the bus, copy-id pairing, the serve flag set, the key file and the unprivileged port start. Scratch unit directory and scratch data root |
| C2-13b | S | Install script for `<host>` as `<user>`: bootstrap, release activate, business file with the mapping, `bean` and `chet` with their persona vars and files, any specialists she picks, her human id, token slot path. The install writes no unit. Adds the library checks to the precheck. Jason or his delegate runs it (10) | C2-13a, C2-04, C2-03a, C2-20 | Opus (Gosalyn), Jason's go | precheck library checks: the business file, `.history`, `changes.jsonl` and persona files have the modes in 4 and each persona digest matches, `library.sync` fetches her repo read-only, `library.status` lists R-problems, no Discord binding exists. Tested against a scratch data root and a local bare repo |
| C2-14 | S | End-to-end acceptance on a scratch data root and a bare repo in her layout: sync, ask a creative agent in its thread, a reply whose cites verify, a playground cite as canon refused, a force-push held as diverged, the Console shows the reply as items | slice rows | Opus | one script, which the reviewer also runs |
| C2-02 | | Bus writes: `writeLibraryFile`, `commitFile` refactor, r1's S0 rule on the working tree | C2-04 | Sol (Rocko) | writer tests, `packages/project/tests` unchanged and green |
| C2-03b | | Push path, race retry, `unpushed`, `library-remote-invalid` on writes | C2-02, C2-03a | Opus | push tests against a bare repo |
@@ -1012,9 +1038,9 @@ Chains follow Q177 merge-chaining. S marks a slice row.
Parallel waves:
- Start now: C2-01, C2-08, C2-11a.
- Start now: C2-01, C2-08, C2-11a, C2-13a.
- On C2-01's candidate: C2-04, C2-05a, C2-06, C2-12. On C2-08's: C2-09a, C2-10a and C2-20.
- Next: C2-03a, C2-07. Then C2-05b, C2-10b and C2-13.
- Next: C2-03a, C2-07. Then C2-05b, C2-10b and C2-13b.
- Last: C2-14. The write rows and rooms follow when a seat is free.
Dependencies on other tracks: row 190 doesn't apply, since nothing else runs as `<user>`. SSO (Q175,
@@ -1110,7 +1136,84 @@ Not ready by 10-27: bus writes and the Console editor (C2-02, C2-03b, C2-11a, C2
section 10 steps 1-7 are done by about 10-24. The Console from her machine needs SSO (#412) or an interim
sign-in ruling, and that is the step most likely to be late.
## 10. Jason-gated steps for C2-13
### 9.3 The Console's forgery limit on .54
Mos asked this in mos-1097 item 2, for Jason. Decision 95 accepted the local-forgery limit only on
dragon-lin under Jason's user, so on .54 it is Jason's call. Everything below comes from the code and
this design. Nobody has looked at the live host.
Who runs as `mosaic`, the Console's uid.
- The bus host, `mosaic-bus@<business>.service`, and everything it starts.
- The Console, `mosaic-webui.service`, and the `human-cli.mjs` children it starts for each bus call
(`packages/webui/src/bus.mjs:2`).
- Bean's and Chet's sessions, and any specialist's. The bus starts each one inside bwrap, and the
runner, the harness and every shell the harness opens run inside it (`packages/seat/src/sandbox.mjs:1-4`).
Inside, the session is still `mosaic` (`packages/harness/README.md`, "Limits", "Same UID").
- The admin steps Jason or his delegate runs as `sudo -u mosaic -H` from `localadmin`: the install,
the precheck, `library survey` and the C2-20 import (10 steps 2b, 6 and 7).
Nothing else. `mosaic` has no login shell, no key and no sudo (10 step 2a).
What a session can reach from inside bwrap.
- The Console's port, yes. bwrap doesn't confine the network. A session shares the host's network
namespace and reaches every service on `127.0.0.1` and every abstract Unix socket
(`packages/harness/README.md`, "Limits", "Network and abstract sockets aren't confined").
- The proof file, no. The sandbox holds only the binds `sandbox.mjs:6-22` lists, and C2-13a requires the
proof file under `<home>/.config/mosaic-dev/secrets/`, which is not one of them. The session's `/proc`
is private and in its own PID namespace (`sandbox.mjs:16`, `:67`), so it can't read the Console's
memory or environment either.
- The bus's human socket, no. The sandbox binds the broker socket, the launch socket and the git socket
only (`sandbox.mjs:97-108`). The bus and Console code opens no abstract socket (a search of
`packages/bus/src`, `packages/webui/src` and `packages/cli/src` for one finds none).
What reaching the port gives a session. Every write in preview mode needs the public Host, and the proof
check comes before every auth-header read (`packages/webui/src/serve.mjs:495-503`). A request with the
public Host and a missing or wrong proof gets 401. A request with a loopback Host is read-only, since
every write route requires `named` (`serve.mjs:532-546`), and the proof header with a loopback Host gets
403. So with the proof on and no tester listener, a session that reaches the port can read what the
Console shows on its loopback path and can't send as Melanie. Without the proof it could, because it can
set the public Host and the auth header itself. That is why C2-13a makes the proof pair required and
refuses `--tester-port`.
A gap I found while checking this: a port squat. When the Console isn't running, a session can bind
its port, if the port is 1024 or above. The proxy then forwards Melanie's browser to the session's
listener, with her sign-in header and the proof header on every request. The session gets the proof
value, and from then on it can send as her to the real Console. It can also show her a fake Console.
The Console is down after every reboot until Jason starts it (step 9), after a crash for
`RestartSec=15`, and during each re-pin. So B1 makes this window longer than it would be with a boot
start. Bean and Chet run model-driven shells, so I treat it as reachable. The same reach exists wherever
sessions share a host and a network namespace with a Console on a port above 1023. I haven't looked at
the live install and make no claim about it. The fix in C2-13a is a Console port below 1024. Only a
process with `CAP_NET_BIND_SERVICE` in the host's network namespace can bind it. The Console unit gets
that capability through `AmbientCapabilities=` and `CapabilityBoundingSet=`. A session holds
capabilities only in its own user namespace, which doesn't own the host's network namespace, so it can't
bind the port. The precheck checks that `net.ipv4.ip_unprivileged_port_start` is above the port. The
Console's own `--port` check takes 0..65535 (`packages/webui/src/cli.mjs:27`), so serve needs no change.
Step 5's forward must point at that port. The default board origin, `127.0.0.1:7331`, has a smaller
version of the same gap. Case 2 runs no control board, so a session could listen there and the Console
would show its answers in the board panel. That is content on her screen, not a send as her. I left it
as a named residual and didn't add a flag for it.
The narrowest form by 10-27, with C2-13a's flags (proof on, no tester listener, port below 1024): a
send as Melanie needs the proof value. These can read the proof file:
- `mosaic` outside bwrap: the bus host, the Console and the admin steps above. All run reviewed code
from the copy, or a command Jason's go names. No model-driven process runs there.
- root on .54. That is `localadmin`, the factory's paths through pve (Jason accepted those in Q182 and
Q183), and `melanie` through her passwordless sudo. Her Hermes processes could use that sudo, as
section 9 says for the key file.
Bean's and Chet's sessions reach the port and get 401 or 403. Melanie's own uid without sudo can't
read `/srv/mosaic` (mode 0700).
What closes the rest.
- #423 step 5 removes `melanie`'s sudo once Bean and Chet are live. That ends the Hermes part.
- A separate Console uid (#385) splits the Console from the bus, so the bus host no longer runs as the
uid that owns the proof.
- The containerized install puts the Console in its own container (I4) and the sessions in theirs (I5).
Then no session reaches the Console at all (`docs/plans/2026-10-18_containerized-install-design.md:584-586`),
and the squat goes with it. That design says it doesn't close the limit for a deliberate process on
the host (`:587-588`), so root and `mosaic` outside a container stay.
- Root on .54 stays after all of these. That part is Jason's acceptance, as for the key file.
## 10. Jason-gated steps for C2-13a and C2-13b
The Gitea host. Her books repo is already on git.mosaicstack.dev (mos-1000). On this host that name
resolves to 10.1.1.222, and STATE.md lists its certificate among the edge certificates, so I treat it as
@@ -1144,14 +1247,16 @@ What it adds is one bot account with read access to that one repo.
`packages/webui/systemd/mosaic-webui.service.in`. Inputs: `--user`, `--home`, `--business`,
`--copy`, `--node` and the Console's serve flags. In both, `@REPO@` is the copy
`<home>/.local/share/mosaic-stack/copies/<id>`. `%h` becomes the literal home, because a system
unit's `%h` is root's home. The render adds `User=`, `Group=`, the hardening lines and
`WantedBy=multi-user.target`. It checks `User=`, `Group=`, `ExecStart=`, the bus unit's
unit's `%h` is root's home. `@PATH@` starts with the directory of `--node`. The render adds `User=`,
`Group=` and four hardening lines, and the Console unit also gets `CAP_NET_BIND_SERVICE` for its
port below 1024 (9.3). The bus unit's `WantedBy=` becomes `multi-user.target`, and the Console
unit has no `[Install]`. The C2-13a brief lists every line and rule. It checks `User=`, `Group=`, `ExecStart=`, the bus unit's
`EnvironmentFile=` and the hardening lines against fixed values, and the copy id is the one part
that may change between renders. It writes both under `/etc/systemd/system/` as `root:root` 0644.
It never uses `systemctl link`, and it refuses a symlink at either unit path. On a re-install it
refuses an installed unit that differs from the render. Root runs nothing from `/srv/mosaic` and
reads no unit that `mosaic` wrote. Then it enables both. They start only at step 9, the bus first
and then the Console, and they stop in the reverse order.
reads no unit that `mosaic` wrote. Then it enables the bus unit only. Both start only at step 9,
the bus first and then the Console, and they stop in the reverse order.
- 2d, Jason: the hub's own key on the existing Z.ai account, with a spend cap if the account offers
one. Model use is spend, so the key and the cap are his. Whose Z.ai account it is hasn't been said,
and that is his input. The key goes in `/srv/mosaic/.config/mosaic-dev/secrets/claude-session.env`
@@ -1167,7 +1272,10 @@ What it adds is one bot account with read access to that one repo.
`/srv/mosaic/.config/mosaic-dev/secrets/<business>/` for `mosaic`, mode 0600. He puts it there, or
says who may, per the Q173 secrets rule. Agents never print it.
5. Her Console sign-in from her machine: her Authentik user and `sub` once SSO lands (#412 step 3 is the
model), or his ruling on an interim.
model), or his ruling on an interim. The proxy forwards to the Console's port below 1024 and sets the
proof header. The proof file goes under `<configDir>/secrets/`, mode 0600, owned by `mosaic`, and
agents never print it (9.3). He places it, or says who may, per the Q173 secrets rule. Whether he
accepts the forgery limit on .54 in the form 9.3 names is his decision, and Mos takes it to him.
6. The C2-20 persona import: who runs it on `<host>`, his check of the printed profile-to-name pairing,
and whether the per-channel prompts count as personality.
7. Running `library survey` on `<host>` after step 4, which prints names and counts, never text. Then
@@ -1178,3 +1286,22 @@ What it adds is one bot account with read access to that one repo.
8. The residual in 5.1: up to 5 idea items of 200 characters per reply, 1,000 characters of free text
within 1,200 total. He or Melanie accepts it or orders lower caps. Mos takes it to him.
9. The instance start. Stopping Hermes comes later, and only when she chooses it.
- Jason starts the bus unit, then the Console unit with `systemctl start mosaic-webui.service`.
The Console unit has no `[Install]`, so this is his start, and he makes it again after each boot
(decisions 94 and 102, mos-1097). Q185 already has him in the homelab for any VM 1012 reboot, so a
planned reboot costs him one command while he is there. The tradeoff: after an unplanned reboot the
Console stays down until he starts it. The bus comes back on its own and Bean and Chet keep
running, but Melanie has no Console until then. Her repo and Obsidian don't depend on it. With a
port below 1024, no session can take the port while it is down (9.3).
- One session starts under the bus unit before she is told the hub is up. The first turn of `bean`
reaches a running session, and its run record shows the runner started. The bwrap precheck runs in
`mosaic`'s shell, outside the unit's hardening, so it can't show this. A `sandbox-unavailable`
refusal here means a unit line or the host's AppArmor rule for unprivileged user namespaces stops
bwrap. The fix is a reviewed re-render, never a hand edit of the installed unit. Running a pi role
waits on #383, so this check does too.
- .54 runs Ubuntu 24.04 (MELANIE-HOST-RECON.md:7), the release where row 195 found
`kernel.apparmor_restrict_unprivileged_userns=1` and no bwrap profile on .53. If the precheck's
bwrap check refuses on .54, the fix is row 195's root step 2a': bubblewrap and Ubuntu's
`bwrap-userns-restrict` profile from the pinned `apparmor-profiles` deb (darkwing/row195 at
0075be358, `agents/darkwing/work/queue-195/DESIGN.md` section 1, mos-1091 Q2 4b). On .54 it would
run under step 2's go, as a 2a item for ops-01.
+228 -105
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,226 @@ 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 two system units,
`mosaic-bus@<business>.service` and `mosaic-webui.service`, with `ProtectHome` (design 9 and 10 step 2c, r10). Nothing installs a library business there, or checks the host before it
starts.
`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. Otherwise a session could bind that port while the Console is down (design 9.3).
- 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.
### 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),
`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,107 +935,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 two system units from the checkout's existing templates and changes neither template:
`[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`. Flags: `--user`, `--home`, `--business`, `--copy ID`,
`--node PATH` and the Console's serve flags after `--`. Row 195 calls it with those names. `@REPO@` is
the copy dir. `%h` becomes the literal `--home`, because in a system unit `%h` is root's home.
`@PM_FLAGS@` and `@CLAUDE_ENV@` render empty. The render adds `User=`, `Group=`, the hardening lines
and `WantedBy=multi-user.target`.
- It refuses unless `User=`, `Group=`, `ExecStart=`, the bus unit's `EnvironmentFile=` and the
hardening 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. `--node` must be an absolute path to a root-owned file outside `<home>`. The
serve flags pass `scripts/webui-service.sh`'s own flag checks. On a re-install, the node path and
serve flags must equal the installed unit's. The copy id is the one value that may change, so a
re-pin or a rollback to another copy re-renders both units (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 both units under `/etc/systemd/system/` as `root:root` 0644. It never uses
`systemctl link`, and it refuses a symlink at either unit path.
- On a re-install, it refuses an installed unit that differs from the render, except for the copy id.
- Order: start the bus, then the Console. Stop in reverse.
- 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 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 unrendered `%h` left in either unit
- 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
@@ -911,7 +1034,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.