Compare commits

...
Author SHA1 Message Date
jason.woltje 62556184fb gosalyn: row 226 C2-13b build, case2-install.sh writes the business file and personas through a plan step before any write, case2-precheck.sh adds the D6a mode, persona and Discord checks and --library-status with lastError first and the wider escape, case2-install.test.mjs, run sheet lines; candidate before the gate (webby-0087 N2 N3, sage-2195, sage-2205, #1727) 2026-10-21 17:13:06 -05:00
jason.woltje d572b61026 gosalyn: row 226 C2-13b design d2, release package and activate dropped, precheck split into pre-start checks and --library-status after the bus is up, Webby's N1-N6 taken in, design 10 text change listed (webby-0085, webby-0086, sage-2189, #1727) 2026-10-21 16:54:52 -05:00
jason.woltje f46e66883f gosalyn: row 226 C2-13b design d1, the business file as an input, install steps, persona staging before 2b, re-run rules, precheck library checks and tests, Q1-Q3 for review (#1727) 2026-10-21 16:46:12 -05:00
jason.woltje d372102882 row 251: T27 adds its key with -t 2 and sleeps 3.5 s, because ssh-agent counts lifetimes in whole seconds (#1756)
Candidate darkwing/row251 27f01ef63 on 3b0aef995, manifest f2bdd684
(BUILD.md and packages/bus/tests/host-run.test.mjs). Dewey APPROVE r2,
rev 2996 (#1756 c30168). Gate on Sage's slot: bus suite whole in a clean
worktree on f7a77721a plus the two manifest paths (b79ad5f2c adds docs only),
627/627 at 21:30:33Z.
Darkwing's slot runs (darkwing-0114) are on the old base and are not the
gate.
2026-10-21 16:30:50 -05:00
jason.woltje b79ad5f2cb brief: the human deletes a creative instance, chained after row 194 (REQ-COS-3, mos-1215); window plan W11 posts on 435 2026-10-21 16:20:12 -05:00
jason.woltje 3fb00018e0 window plan: draft 1 correction, 7333 acceptance was Mos's (mos-1015), Q1/Q3/Q4 answered (mos-1214) 2026-10-21 16:18:35 -05:00
jason.woltje f7a77721a9 window plan: 10-24 re-pin draft 1, NEW 7247d80c7 with --tester-socket (mos-1145, mos-1211) 2026-10-21 16:15:48 -05:00
jason.woltje 829e5f8340 sessions: Sage row 196 landing, rows 253 and 254 launched, 194 b4 ruling, queue revs to 2994, window draft 1 2026-10-21 16:14:06 -05:00
jason.woltje abb352d27e queue: revs 2977-2993; Morgana 244 start, in-review and outcome (2977-2979), Gander 196 r6 manifest (2980/2981), Sage 244 and 196 jarvis review requests, Duckworth 196 m2 approve record (2986), Sage 196 landing note and done, row 254 issue #1759, Darkwing 251 r2 in-progress and in-review (2990/2991), rows 253 and 254 briefed 2026-10-21 16:13:56 -05:00
13 changed files with 2230 additions and 36 deletions

No files matched your search

+191
View File
@@ -0,0 +1,191 @@
# Row 251 build: T27 lists no identities right after `ssh-add -t 1` (Vikunja #453)
Brief `docs/plans/2026-10-21_t27-ssh-add-lifetime.md` (rev 2931), Mos ruling mos-1185 item 2, assigned in
sage-2075. Owner Darkwing, reviewer Dewey, gate owner Sage. Branch `darkwing/row251` from 3b0aef995,
claimed in-progress at rev 2938.
## Step 1: the failure names its cause (e5e4b2e5a)
T27's two lists now carry a message that prints only when they fail. It gives the time from addKey's
start to its resolve and to each `ssh-add -l` call, the hrtime at each with how far into its second it
fell, `/proc/uptime` at each, and whether the agent process and its socket were still there. The checks
themselves did not change. The commit holds nothing else.
## Step 2: the cause
ssh-agent counts a key's lifetime in whole seconds of CLOCK_BOOTTIME, at the add and at the reap. I read
OpenSSH 10.5p1, the version on dragon-lin (`ssh -V`: OpenSSH_10.5p1), at tag V_10_5_P1 (ssh-agent.c
sha256 873691c4, misc.c sha256 ee467f43):
- `misc.c:1790-1798` and `:1828`: `monotime()` returns `ts.tv_sec` of `clock_gettime(CLOCK_BOOTTIME)`,
falling back to MONOTONIC. The fraction of the second is dropped.
- `ssh-agent.c:1315`: the `-t` constraint sets `*deathp = monotime() + seconds`. Lines 1410 and 1598
do the same for the agent's default lifetime.
- `ssh-agent.c:1064`: the reaper drops a key when `now >= id->death`, `now` being `monotime()` again.
- `ssh-agent.c:2650`: the main loop runs `reaper()` after every poll, before it reads requests. A list
that arrives after the boundary sees the key gone.
So a key added at second s plus a fraction f dies at s + N, and `-t N` keeps it N - f seconds, between
N - 1 and N. With `-t 1`, an add late in a second leaves the key milliseconds to live. That matches
Sage's probe in the brief: 8 of 80 `-t 1` adds missed, each one made at 0.9947-0.9973 of a monotonic
second and finished at 0.998-0.0008, and none earlier in the second missed. It also matches proof 5's
failure, where the first list ran 31.9 ms into the test and found no identities.
Node's hrtime is CLOCK_MONOTONIC. BOOTTIME differs from it by the time the host spent suspended. On
dragon-lin the difference read 0.000001 s at 19:09Z, so a monotonic second boundary is a BOOTTIME one
here.
`addKey` and the agent start are not the cause. `addKey` passes the lifetime through as `-t`
(`host-run.mjs:90`), and the agent starts with no default lifetime (`:175`). Production adds the key
with `timeout + 30` (`:180`), where losing up to a second makes no difference. The only caller that
can't afford the lost fraction is T27, which asks for 1 s. The fix belongs in the test's lifetime choice,
and `host-run.mjs` doesn't change.
The slot confirms this against T27 itself (below), not just from the source.
## Step 3: the fix (this commit)
T27 adds the key with `-t 2`. The agent then keeps it more than 1 s and at most 2 s. The first list
runs milliseconds after the add, so it has over a second of margin even under load. The sleep before
the second list grows from 2.5 s to 3.5 s, which keeps the old 1.5 s margin past the new 2 s ceiling.
Both checks stay: listed after the add, gone after the lifetime. A comment above the add says why the
lifetime is 2.
I considered `-t 3` with a 4.5 s sleep for a 2 s floor. One second of floor is far above anything the
first list has needed (31.9 ms in proof 5), and the concurrent-suite run below tests it under load, so I
took the smaller change.
## Base
The branch stays on 3b0aef995. By 19:15Z origin/refactor was at 6096b051b, after rows 66 and 243
landed. `git diff 3b0aef995 6096b051b` touches neither `packages/bus/src/host-run.mjs` nor
`packages/bus/tests/host-run.test.mjs`, so there's no rebase (sage-2084). Row 243 did change
`packages/bus/tests/process.test.mjs`. My slot's bus suites run that file as it was at 3b0aef995, and the
gate rerun on Sage's slot runs it as landed.
## Prediction for part A on e5e4b2e5a (written before the slot)
The hook starts `ssh-add -t 1` at the given fraction of a monotonic second. From the brief's probe, the
agent stores the key about 3 ms later. The first list reaches the agent a few ms after addKey resolves:
Node's exit event, the mark, and `ssh-add -l`'s own start. A red needs the add to land before the second
boundary and the list to land after it.
- 0.990: green. The add and the list both land before the boundary.
- 0.993, 0.995, 0.996, 0.997: red expected. The add lands before the boundary, the list after. At 0.993
and 0.997 I'm less sure, because the list (at 0.993) or the add (at 0.997) can land right at the
boundary. A green at either is in line with the cause, and so is a red.
- 0.998: green. The add lands after the boundary, so the key gets almost a full second.
Every red should show "first list got" an answer with no SHA256 line. It should also show addKey
resolving before a second boundary and the first list starting after it or within a few ms of it, with
the agent alive and its socket present.
Under sage-2084's rule, on e5e4b2e5a only a red at 0.990 or 0.998 counts toward the stop, and two such
reds stop the run. On the fix commit any red counts, and two stop it. In parts B to D, the first red stops
the run.
## Slot plan
All scripts are in `/mnt/storage/scratch/darkwing-251-out/`, launched from `/`, each behind the busy
checks and chained with `&&`, TMPDIR under `/mnt/storage/scratch/tmp`.
1. Confirmation. `align-hooks.mjs` is a load hook, outside the repo, that serves `host-run.mjs` with
`addKey` wrapped to start `ssh-add` at a chosen fraction of a monotonic second. T27 runs unchanged
under it, alone (`--test-name-pattern`). It runs at fractions 0.990, 0.993, 0.995, 0.996, 0.997 and
0.998, three times each:
- on e5e4b2e5a (step 1, `-t 1`). Expected: some first lists fail, and the step 1 message shows the
add's resolve and the list on either side of a second boundary, with the agent alive and the
socket present. These reds are the point of the run.
- on the fix commit (`-t 2`). Expected: all 18 pass.
2. `host-run.test.mjs` whole, once, on the fix commit.
3. The bus suite whole, three times in a row.
4. The bus suite whole in two clean worktrees at the same commit, started together.
## Slot results
Sage's CLEAR came in sage-2154 at 20:58:55Z. `slot-251.sh` ran parts A and B from 20:59:22Z to 21:01:33Z,
on node v26.8.1 and OpenSSH_10.5p1, with TMPDIR `/mnt/storage/scratch/tmp/r251`. At the start, BOOTTIME
minus MONOTONIC read 0.000001 s, then 0.000000 s twice (dewey-0769 N1). So the hrtime fractions below
put the agent's second boundary in the right place.
### Part A: T27 alone under `align-hooks.mjs`, 3 runs per fraction
The fraction is when the hook started `ssh-add`. The hook logged each start within 0.0001 of its target.
| fraction | e5e4b2e5a (`-t 1`) | 68f2dfd4b (`-t 2`) |
|---|---|---|
| 0.990 | 3 pass | 3 pass |
| 0.993 | 3 pass | 3 pass |
| 0.995 | 3 red | 3 pass |
| 0.996 | 3 red | 3 pass |
| 0.997 | 3 pass | 3 pass |
| 0.998 | 3 pass | 3 pass |
e5e4b2e5a went red 6 times out of 18, every time at a fraction the prediction named, so none of them
counted toward the stop. 68f2dfd4b passed all 18. The fix runs used 68f2dfd4b, whose test blob is the same
as c19b4167e's (280132c7).
Every red failed on the first list and got `"The agent has no identities.\n"`, with the agent alive and
its socket present. The step 1 message gave these hrtime fractions. "addKey start" is the mark before the
hook's wait, so its fraction is earlier than the ssh-add start.
| run | addKey resolved | first list mark |
|---|---|---|
| 0.995 rep 1 | 0.9992 (+317.6 ms) | 0.9993 |
| 0.995 rep 2 | 0.9991 (+846.5 ms) | 0.9991 |
| 0.995 rep 3 | 0.9988 (+845.0 ms) | 0.9988 |
| 0.996 rep 1 | 0.9999 (+839.8 ms) | 1.0000 |
| 0.996 rep 2 | 0.0004 (+845.0 ms) | 0.0004 |
| 0.996 rep 3 | 0.0002 (+835.9 ms) | 0.0002 |
At 0.995 the add finished about 4 ms in, before the boundary, and the list mark came just before it. The
`ssh-add -l` process then reached the agent after the boundary. At 0.996 the agent stored the key before
the boundary, and addKey resolved just after it. In both cases the key's whole second ended between the
add and the list, which is the cause step 2 gives. The two edge fractions came out green on the first
build. At 0.993 the list still reached the agent before the boundary, and at 0.997 the add landed after
it, so the key got almost a full second. The prediction allowed either result at those two.
### Part B: `host-run.test.mjs` whole on 68f2dfd4b
24 tests, 24 passed (21:01:18Z, 14.0 s).
### The stop before C1, and the resume
At 21:01:33Z the script stopped before C1, with exit 4 and no red. My own `busy.sh` matched two `sleep 60`
processes, mine, in `darkwing-r251/packages/bus`. Part B left them. They come from T27 "the start sweep
keeps a live bus dir and kills only a matching live process" (`host-run.test.mjs:528` and `:536`). Its
`t.after` SIGKILLs the `/bin/sh -c 'sleep 60; :'` parent, but the `sleep` child lives on for up to 60 s.
That code is outside this row's diff, so the row leaves it alone, and it's recorded below as a follow-up.
Sage confirmed (sage-2159) that the right move is to resume only the scope that hadn't run.
`slot-251-cd.sh` runs C and D as `slot-251.sh` has them, with the same two busy checks. Before each check,
it waits up to 75 s for those orphans to exit: my uid, my trees, a command line of exactly `sleep 60`.
It logs them as it waits. Any other process still trips the checks.
### Parts C and D: the bus suite whole on 68f2dfd4b
| run | start | tests | pass | fail | time |
|---|---|---|---|---|---|
| C1 | 21:02:48Z | 598 | 598 | 0 | 21.6 s |
| C2 | 21:04:06Z | 598 | 598 | 0 | 21.4 s |
| C3 | 21:05:26Z | 598 | 598 | 0 | 22.1 s |
| D1, darkwing-r251 | 21:06:44Z | 598 | 598 | 0 | 24.2 s |
| D2, darkwing-r251b, TMPDIR `r251-b` | 21:06:44Z | 598 | 598 | 0 | 24.2 s |
The script waited 33 s before C2, 34 s before C3, 33 s before D and 32 s before the sweep. Each time it
was waiting for that run's `sleep 60` orphans (two per suite, four after D), and nothing else. After the
wait, the sweep of my trees, the out dir and TMPDIR found 0 processes left (21:08:04Z). Sage confirmed
(sage-2159, sage-2160) that the short gitea-api and `node --check` runs they made during the slot weren't
stops. None of my busy checks refused on any of them.
These runs used the bus files as they were at 3b0aef995. Since then, origin/refactor (666173292, then
7247d80c7 and c3d88840c with row 196) has changed `broker.mjs`, `runtime.mjs` and `server.mjs`, which T27's
file imports, along with other bus files. None of those commits touch this row's two paths or
`host-run.mjs`, and `git merge-tree` of c3d88840c with 68f2dfd4b is clean. The gate rerun in
a clean worktree on Sage's slot runs T27 against them.
### Follow-up, not in this row
T27's start-sweep test leaves the `sleep 60` child of each planted shell running for up to 60 s after
the test. Killing the process group (`process.kill(-pid)`; both shells are spawned `detached`) or
`exec sleep 60` in the shell would end the child along with its parent.
+331
View File
@@ -0,0 +1,331 @@
# Row 226 build notes, C2-13b install script and library precheck (#1727)
Brief: docs/plans/2026-10-20_case2-rows.md "C2-13b install script and library precheck" (:1073-1134 at
d37210288). Design: agents/gosalyn/work/case2/DESIGN.md r11 sections 4 and 10, on gosalyn/case2-design
75fb3e10e. Row 195's needs: agents/darkwing/work/queue-195/DESIGN.md "What row 195 needs from row 226".
This is design round d2. Nothing is built yet. d1 was f46e66883, and Webby's review of it is webby-0085
(CHANGES, B1 and N1-N6) and webby-0086 (the ordering problem). Sage ruled on both in sage-2189. The
changes from d1 are listed at the end.
## Base
Branch gosalyn/c2-13b from refactor d37210288. Rows 215, 221, 222 and 230 are done.
## Facts the design rests on
- No CLI writes a business file. `mosaic business validate <id>` checks one and exits 0 ok, 2 invalid
(packages/business/src/cli.mjs). A business file needs `human`, `arbiters`, `projects`, `tracker`
with a sync bot and a Vikunja credential ref, and `roles`, each role with its tracker bot when its
definition uses Vikunja (business.mjs:23, :84-100, :131-149).
- The library is a project, `projects.<id>.kind === "library"`, checked by `checkMapping`
(packages/library/src/mapping.mjs). There is no top-level `library` key.
- `library.sync` and `library.status` are bus verbs, human only (broker.mjs:92, :2452-2476). The only
client is `packages/bus/src/human-cli.mjs` over the bus socket.
- The bus makes each library mirror at start, `ensureMirror({ business, project, dataRoot })`
(packages/bus/src/runtime.mjs:143-145). Before the bus first starts there is no mirror.
- `sync()` fetches outside `withLibraryLock`. Only a per-process `running` set keeps two syncs apart
(packages/library/src/sync.mjs:97-98), so a second process calling `sync()` would race the bus's
timer.
- `libraryStatus(context)` reads the recorded state under the flock and writes nothing
(mirror.mjs:232-239). A missing or unsafe mirror fails `library-git` (`ownedRoot`, mirror.mjs:43-49).
A mirror that has never synced returns the empty state, `lastFetch: null`, `lastError: null`,
`problems: []` (mirror.mjs:27, :205-210). `lastError` is `{ code, at }` with `code` matching
`^[a-z-]{1,64}$` (mirror.mjs:225).
- The bus builds the library context as `{ business, project, dataRoot }` and the slot as
`<slotDir>/secrets/<business.id>/library-gitea` (runtime.mjs:146-151, :162-164).
- `case2-persona-import.mjs install --staging DIR --config-dir DIR --business ID` writes
`<configDir>/personas/<business>/<instance>.md` at 0600 in 0700 dirs, never overwriting, and prints
`instance bean: persona.name Bean, persona.sha256 <hex>` per persona.
- A Discord binding is `<dataRoot>/discord/<name>.json` (packages/discord/src/binding.mjs:70-83).
- `case2-unit.sh --copy ID` needs `<home>/.local/share/mosaic-stack/copies/<ID>`, which
`stack-copy.sh copy COMMIT --copies DIR` makes. The copy runs `npm ci` from package-lock.json
(stack-copy.sh:70), which installs the pinned pi.
## Design
### D1. The business file is an input
Unchanged from d1. `case2-install.sh` takes `--business-file FILE`, a JSON business document written for
the host from Jason's go. The script doesn't build one from flags, because bots, botIds and credential
refs are host values and the brief says every host value is an input. It checks the input, adds what
only it knows, writes it, and then runs `mosaic business validate`, so business.mjs stays the only
schema.
What the script checks before it writes anything, beyond `validate`:
- `id` equals `--business`.
- `library`: at least one library project, `bus.tester` not true, roles `bean` and `chet` with
definition `companion`, and no `persona.*` var in the input, since D3 adds them.
- `factory`: no library project, and `bus.tester` true. These are the precheck's rules at
case2-precheck.sh:127-135, so the install refuses what the precheck would refuse.
### D2. Install steps, in order
1. Refuse to run as root.
2. `scripts/bootstrap.sh`. It's idempotent and never rewrites an existing config.
3. `scripts/stack-copy.sh copy <--commit> --copies <home>/.local/share/mosaic-stack/copies`. The copy
`case2-unit.sh --copy` names has to exist before root renders the units (row 195 need 3).
4. `library` only: the persona import's `install` step from `--personas DIR` (D3).
5. Write `<configDir>/businesses/<id>.json` at 0600 with O_EXCL (D4).
6. `library` only: make `<configDir>/secrets/<business>/` at 0700, empty. The token is Jason's, design 10
step 4.
7. `mosaic business validate <id>`. If it refuses and step 5 created the file in this run, remove that
file, then refuse. A file that already matched on a re-run (D4) is never removed (N4).
8. Print the copy id and the next steps: root's 2c, Jason's 2d, 3 and 4, then the `--library-status`
check (D6b).
d1's step 3, `release.sh package` and `activate`, is gone (Q4a, webby-0085 B1, sage-2189). Package is a
`docker compose build`, and activate runs a pi Compose health check that mounts `PI_AUTH_FILE`
(compose.yaml:43). Neither fits 2a, which has no Docker and no pi credential, and the hub doesn't run
containers. The pinned harness comes from the copy's `npm ci` (Q3). The `CASE2_RELEASE` seam goes with
it.
It starts no unit, writes no unit file, doesn't write `/etc/mosaic-stack/dev.args` and doesn't run
`adopt`. These are my answers to row 195's needs 4 and 5 (gosalyn-0087), unchanged. Row 195's W8 now
passes `--business-file` and has no release step.
### D3. Personas come from the staging dir
Unchanged from d1. `library` requires `--personas DIR`, the staging dir from `case2-persona-import.mjs
stage`. The install runs `case2-persona-import.mjs install --staging DIR --config-dir <configDir>
--business <id>` and reads its two `instance` lines into `roles.bean.vars` and `roles.chet.vars`. Only
those two lines go into the business file. They hold a name and a digest, never text.
This moves the persona import's stage part of design 10 step 6 ahead of 2b: the read of her Hermes
profiles and Jason's check of the printed pairing. Webby agreed in webby-0085 (Q1).
### D4. Re-runs
Unchanged from d1, plus N4. Each step is safe to run again. bootstrap and the copy are idempotent. An
existing business file that matches the one the script would write passes, and the script remembers it
didn't create it. One that differs refuses `business-file-differs`, and the script doesn't touch it.
The persona import already refuses to overwrite. On a re-run the install compares each existing
persona file's sha256 with the staging manifest, and refuses on a mismatch.
### D5. Inputs and seams
```
case2-install.sh --business ID --business-file FILE --commit SHA [--profile factory|library]
[--personas DIR] [--home DIR]
```
`--home` defaults to `$HOME`, which is `/srv/mosaic` under `sudo -u mosaic -H`. `MOSAIC_CONFIG` is
honoured as bootstrap.sh honours it. One test seam, in case2-unit.sh's `CASE2_*` pattern:
`CASE2_STACK_COPY` is the copy script, and the test uses a stub that makes the copy dir.
Exit codes: 0 ok, 1 refused, 4 usage, as case2-unit.sh uses.
### D6. Precheck library checks, split in two (Q4b, sage-2189)
d1 ran `sync()` from the precheck. Webby's N1 and webby-0086 showed two faults. The precheck runs before
the units start, so there is no mirror yet (runtime.mjs:143-145). And a precheck `sync()` after the bus
is up would race the bus's timer, since `sync()` fetches outside the lock (sync.mjs:97-98). Sage split
the check in sage-2189, and the brief's "runs library.sync" becomes "reads the bus's recorded sync
state".
#### D6a. Before the units start, `--profile library`
These run where the precheck runs today, after 2c's render and before the start. They sync nothing and
read no mirror. Each is a `refuse` line in the script's style.
- The business file is owned by the uid, with no group or other bit (`business-file-mode`).
`businesses/.history/<id>/` is 0700 and its `changes.jsonl` is 0600, when present (`history-mode`).
- The persona dirs are 0700. Each creative role's persona file is regular, owned and 0600
(`persona-mode`), and its sha256 equals the role's `persona.sha256` (`persona-mismatch`). The output
prints names and digests, never text.
- No `<dataRoot>/discord/*.json` exists (`discord-binding`).
#### D6b. `case2-precheck.sh --business ID --library-status`, after the bus is up and after step 4
A new mode, run as `<user>` once the bus is up and Jason has placed the token. It changes nothing. A
node block builds the context the bus builds, `{ business, project, dataRoot }` for each library
project, with `dataRoot` from the config (N3, runtime.mjs:146-151, :162-164). For each one it:
- refuses `library-mirror-missing` when the mirror path doesn't exist. It checks this itself, so the
message says the bus hasn't made it, not `library-git`;
- calls `libraryStatus(context)` only, and refuses with the thrown code when that fails, for example
`library-git` for an unsafe mirror or `library-sync-busy` when the flock wait runs out;
- refuses `library-never-synced` when `lastFetch` is null;
- refuses `library-last-error <code>` when `lastError` is not null. The code is the fixed
`^[a-z-]{1,64}$` string, so it's safe to print. This also covers Webby's N2;
- prints each recorded R-problem as `problem R<n> <path>`, with the path through `JSON.stringify`, then
one `problems <count>` line (N5). Her file names are the only part that prints, never text.
R-problems don't refuse (Q2, agreed by Webby).
The bus syncs each library with a `user` every 60 s, so the first run after step 4 may need a minute,
or Jason's own `library.sync`. The run sheet says so.
### D7. host-run-sheet.md fix
`docs/guides/host-run-sheet.md:240` says "I2's install writes it" about `dev.args`. It becomes: root
writes it at the unit render, from the values the go names, and the install never writes it, because
`<user>` can't write `/etc` and root reads nothing `<user>` wrote. Sage asked in sage-1832 for this fix
to go in this build commit, with its path in the manifest. Section 9's `library-gitea` line (:275) says
"C2-13b's `library.sync` (not landed)". It becomes the `--library-status` check (D6b). The install
section also gets a line that the copy's `npm ci` needs the npm registry from the host (Q3, webby-0085).
### D8. Design 10 text
Sage ruled that design 10 gets the same change when 226 lands, with the line in this row's manifest.
In agents/gosalyn/work/case2/DESIGN.md:
- step 2b drops `release.sh package` and `activate`;
- a new step after step 4 runs `case2-precheck.sh --business <business> --library-status`;
- the precheck's library text says it reads the bus's recorded sync state, not that it runs
`library.sync`.
### D9. Tests, `packages/cli/tests/case2-install.test.mjs`
These follow case2-unit.test.mjs: scratch HOME, MOSAIC_CONFIG, stub bin dir and seams, a local bare repo
cloned `--bare --no-local` as sync.test.mjs does, and the fixture's key never in output.
- Install `library`:
- writes the business file at 0600 with bean's and chet's persona vars;
- writes the persona files at 0600 in 0700 dirs and the empty slot dir at 0700;
- logs the copy call and no release call;
- starts nothing;
- passes on a re-run;
- refuses `business-file-differs` and leaves the file unchanged.
- Install `factory`:
- writes no library mapping, no persona file and no slot dir, which is the brief's case;
- refuses a library project and `bus.tester` off.
- Install refuses: run as root, a business id mismatch, missing `--personas` under library, and a
`validate` refusal, after which no business file is left.
- N4: a re-run where the matching business file is already there and `validate` refuses leaves that
file in place, byte for byte.
- D6a refusals, one case each: a persona file at 0644, a digest mismatch, a business file at 0644, a
`changes.jsonl` at 0644, and a Discord binding. A clean library host passes D6a with no mirror.
- D6b, with the mirror made by `ensureMirror` and the state written by a real `sync()` against the local
bare repo, as the bus would:
- no mirror refuses `library-mirror-missing`;
- a fresh mirror refuses `library-never-synced`;
- a sync against a remote that doesn't resolve refuses `library-last-error` with its code;
- a synced bare repo with one R2 path prints its `problem` line and `problems 1`, and passes.
Every new refusal gets a mutant that removes it. The count goes in the slot request.
### D10. Files
Owned by the brief: `scripts/case2-install.sh` (new), `scripts/case2-precheck.sh` (library checks and
`--library-status` only) and `packages/cli/tests/case2-install.test.mjs` (new). Also
`docs/guides/host-run-sheet.md` (D7, sage-1832), agents/gosalyn/work/case2/DESIGN.md (D8, sage-2189)
and this BUILD.md. Before the slot request I'll grep for outside readers of case2-precheck.sh, since
case2-unit.test.mjs runs it.
## Questions
All three d1 questions are settled:
- Q1, persona stage before 2b: Webby agreed (webby-0085).
- Q2, R-problems don't refuse: Webby agreed.
- Q3, the pinned harness is the copy's `npm ci`: Webby agreed, and asked for the registry note in D7.
- Q4a and Q4b: Sage, sage-2189.
One choice for Webby to check: D6b's `problem` line carries the path through `JSON.stringify`. N5 asked
for a prefix and a count. I kept the path because Jason needs it to tell her which file to fix, and the
names are hers, not text.
## Changes from d1
- B1: release package and activate dropped, with the `CASE2_RELEASE` seam and its test (D2, D5).
- N1 and webby-0086: the sync is gone from the precheck. D6 splits into D6a before the start and D6b,
`--library-status`, after the bus is up and step 4 (sage-2189).
- N2: covered by D6b's `lastError` refusal.
- N3: D6b builds the context and slot as the bus does.
- N4: step 7 removes only a file this run created, with a test.
- N5: `problem` prefix and a `problems` count.
- N6: the run sheet lines are :240 and :275.
- New D8, the design 10 text change, and its path in D10.
## Build
Webby approved d2 in webby-0087 (REVIEW-design-d2.md, webby/reviews 2f488b4ea). Nothing above this
section changed after approval. This section records the build.
### D8 lands on case2-design
Sage ruled in sage-2195, after Webby's N1, that D8 goes on gosalyn/case2-design, because
agents/gosalyn/work/case2/DESIGN.md isn't on refactor. The change is commit 624212842 there, pushed:
step 2b drops `release.sh package` and `activate`, new step 4a runs `--library-status`, and the C2-13b
row text says the precheck reads the bus's recorded sync state. DESIGN.md is not in this row's manifest.
The brief's lines docs/plans/2026-10-20_case2-rows.md:1093-1096 still name release package, activate and
a library.sync fetch. Sage owns the brief and will amend those lines to match D8 in a doc-only commit
after the re-pin window (sage-2205). They are not in this row's manifest.
### Webby's d2 notes
- N2: the `problem` line keeps the path. After `JSON.stringify` the precheck also escapes DEL, the C1
controls U+0080-009F, U+200E and U+200F, U+2028 and U+2029, and the bidi controls U+202A-202E and
U+2066-2069 as `\uXXXX`. Webby named C1 and bidi. I added the rest because `JSON.stringify` leaves
them raw too, and each one can change how a terminal shows the line. The test path holds U+202E and
U+0085, and the test checks that neither prints raw.
- N3: `lastError` is checked before `lastFetch`. A first sync with no token records
`library-credential-missing` and leaves `lastFetch` null, so it refuses `library-last-error`, not
`library-never-synced`. The fresh-mirror test has `lastError` null and refuses `library-never-synced`.
### Choices made in the build
- The config dir. The persona import refuses a config dir with group or other bits, and bootstrap makes
one with the default mode. So the install makes the config dir at 0700 when it's absent, before
bootstrap, and refuses `config-dir-mode` when it exists with a group or other bit. It checks
`secrets/` and `secrets/<id>/` the same way under library (`secrets-mode`, `slot-mode`).
- `--home` must equal `$HOME` (`home-mismatch`). Every path comes from `$HOME` and `MOSAIC_CONFIG`, as
bootstrap's does, so a different `--home` would only name a second home the script doesn't use.
- A plan step runs before anything is written. It reads the input and the staging manifest, computes
the persona vars from the manifest, and compares the result with what's on disk. It refuses every
input rule, `business-file-differs` and `persona-differs` there, so a refused run writes nothing.
After the import the script checks that the import's two `instance` lines carry the manifest's
digests.
- A re-run with both persona files present and matching skips the import, which would refuse to
overwrite them. One matching file and one missing refuses `persona-differs`.
- A diverged library prints one line under `--library-status`, naming the tip `library.sync --accept`
would take. It doesn't refuse. The design doesn't list it as a refusal, and the bus still serves the
old tip.
- The precheck's library checks print `ok` lines for each check they pass, as the rest of the script
does. A business file that's missing prints no extra line, since the existing business-file check
already refuses it.
- The business id must match the persona import's rule, `^[a-z0-9][a-z0-9-]{0,63}$`. That's narrower
than the precheck's, which also takes `.` and `_`, because the persona dir uses the id.
### Outside readers
`git grep` over packages/*/tests/*, packages/*/src/* and scripts/test-*.sh finds case2-precheck.sh read
by case2-unit.test.mjs, which runs it, and case1-accept.test.mjs, which names a copy's path but runs a
fake. No test reads host-run-sheet.md. No test pins a hash of either script. case2-unit.test.mjs's
precheck host has a 0600 business file and no persona vars, so the new D6a checks pass there.
### Mutants
24, in /mnt/storage/scratch/gosalyn-r226/mutate.py. Each replaces one exact string once. Line numbers
are the candidate's. run.sh stops with BASE RED before any mutant unless the base run is rc=0 and
fail 0.
| Mutant | Line | Check removed |
|---|---|---|
| I1 | install :73 | run-as-root |
| I2 | install :74 | home-mismatch |
| I3 | install :81 | config-dir-mode |
| I4 | install :101 | business-id-mismatch |
| I5 | install :107 | profile-library-business |
| I6 | install :108 | bus-tester-off |
| I7 | install :111 | bus-tester-on |
| I8 | install :115 | persona-var-input |
| I9 | install :158 | business-file-differs |
| I10 | install :152 | persona-differs |
| I11 | install :218 | N4, removes the file even when this run didn't write it |
| I12 | install :218 | validate's removal of the file this run wrote |
| P1 | precheck :229 | business-file-mode |
| P2 | precheck :235 | history-mode for changes.jsonl |
| P3 | precheck :246 | persona-mode for a persona file |
| P4 | precheck :251 | persona-mismatch |
| P5 | precheck :241 | persona-mode for a persona dir |
| P6 | precheck :258 | discord-binding |
| P7 | precheck :104 | library-mirror-missing |
| P8 | precheck :107 | library-last-error |
| P9 | precheck :107 | N3, the order swapped |
| P10 | precheck :108 | library-never-synced |
| P11 | precheck :92 | N2, the escape after stringify |
| P12 | precheck :65 | --library-status refuses --profile |
`history-mode` for the history dir itself has no mutant and no test. The brief names only the
`changes.jsonl` case.
### Tests
Not run yet. They wait for a slot.
@@ -0,0 +1,90 @@
# 10-24 window, notes for draft 1
Sage, 2026-10-21T19:40Z. These are working notes, not a draft. Draft 1 is cut from them once NEW is fixed.
Draft 0 is `repin-1024-draft0.md`.
## Changes since draft 0
- **Phase 2 order (mos-1197 item 3).** Phase 2 now runs after this window's re-pin is verified, not on
10-23 before it, and Jason picks the hour. So this window's OLD is the live pin, not a phase 2 copy, and
draft 0's line 14-17 and Q2 are out of date.
- **Console flags.** They stay as they are: no `--sso` and no `--send`. `--send` goes on with `--sso` in the
phase 2 window (mos-1197 2a). P0 checks that the window's activate line carries neither flag.
- **NEW (mos-1161).** NEW is the latest code landing when draft 1 is cut. At 19:40Z that is e54e05463 (row
215). 194 hasn't landed, so P3 has no lifecycle step unless it lands with a full gate first.
- **Row 66 live check (Vikunja #454, DEFERRED 3b0aef995).** This is the first re-pin whose tree includes
2c38325ec. Add a step after the window is verified. One Claude Code managed session with a prompt that
draws a known tell, then record the operator note on the RESULT, the next-turn notice and the
`unslop.jsonl` counts. Backup first (Q173). Pi isn't run (#383).
## OLD, read 19:38Z from `stack-release.sh status mosaic-stack` (read only)
- Copy 4429a104f6d65ccf165f6c83b5d1dce3147dfde0, schema 3i, pm true, activation 20261020T220206Z-4429a104f6d6.
- Console flags: `--port 7332 --public-host mosaic.woltje.com --auth-header X-authentik-username
--allow-user jason.woltje --tester-port 7333`. P1 re-reads these on the day.
- Check pass, unit flags match the pointer, 123 commits of drift.
## Schema
`git diff --name-only 4429a104f origin/refactor` shows only `packages/bus/schema.sql` and
`migrations/3i-3j.sql` (row 234). Nothing since c771a36f4 adds a step, so the migration is still 3i to 3j.
Recheck this at the cut. Row 206 (3k-3l) hasn't landed.
## Rows NEW carries after c771a36f4 (first-parent, code paths)
| Row | Commit | Live effect to check |
|---|---|---|
| 240 | c515273db | Installer I4: root host check, pins, boot unit. This window doesn't run it. |
| 228 | 86249ac55 | ZAI_API_KEY joins the session env allowlist. The live config sets no ZAI key, so the env is unchanged. Check in P3. |
| 238 | d49b1f2bd | sso-config takes `--instance` on named instances. Default instance, `--sso` off, so there's no effect. |
| 212 | ee7b2f137 | New package packages/library. The live business has no library entry. |
| 195 | b65014647 | scripts/case1-accept.sh, root only. Not run. |
| 213 | 361ba2387 | Creative roles (roles/companion.*), persona file and block, launch-settings, bundle.mjs. Live roles are chief, pm, coder and reviewer. P3: the chief's and pm's bundles are unchanged apart from the new code path, and one pm reply. |
| 231 | 29ce1e360 | Library reader fixes. No live library. |
| 235 | b1fece6f0 | Machine grant row b: raise binding, executor, reconcile, revoke and sweep (broker, host-grant, credentials, runtime). The live business has no hosts. P3: bus start runs the grant reconcile and sweep, and they change nothing. Show the log lines. |
| 221 | 300766450 | Persona import script, with a count-only dry run. Not run. yaml 2.9 joins package-lock, so P3 runs npm ci on the copy. |
| 247 | 856c1826b | Persona import follow-up. Not run. |
| 245 | 6d5545d44 | case1-accept F9 follow-up. Not run. |
| 248 | 8a04f4aa4 | Test stubs only. |
| 249 | 9de5a57ab | broker: hostLineLeft on a grant whose install never returned. No grants live. The A22b look-alike count in draft 0 already covers `hostLineLeft`. |
| 197 | a36523713 | Test lane fixture. No live effect. |
| 66 | 2c38325ec | Managed sessions run the unslop check, runner.mjs, and stack-copy.sh COPY_PATHS adds tools/unslop-hook. P3 checks that the copy has tools/unslop-hook. The live check is above. |
| 243 | 6096b051b | Tests only. |
| 215 | e54e05463 | Library entry kind, consumer skips, M9, bare mirror, library.file and library.status. The live business has no library entry. M9 refuses any organization write that changes `projects`, so P3 checks that the chief's or pm's usual organization writes don't touch projects. |
| 250 | 1b6158ce2 | Conversation shim and K12 test. No live effect on its own. |
| 222 | 0b33f5e9d | Fetch-only library sync: library.sync bus verb, a 60 s timer per library entry with a sync user, the askpass helper, and stack-copy.sh COPY_PATHS. The live business has no library entry, so the timer starts nothing. P3: the copy has packages/library/bin/askpass with its mode, and bus start logs no `library:` line. |
| 196 | 7247d80c7 | Human boundary: SO_PEERCRED on the bus socket, the interpreter receipt in bus/peercred-starts.jsonl, and the Console's private tester socket in place of `--tester-port`. The section "If 196 lands before the cut" applies. P3: bus start writes a receipt line, and the Console starts with `--tester-socket`. |
Added 2026-10-21T20:30Z. NEW at this hour is 0b33f5e9d (row 222, landed 20:13Z), the latest code
landing. PRD 1.9 (f48725cfc) and 1.10 are doc and test changes, but `packages/tasks/requirements.json`
is in the copy, so `task.create` takes REQ-WEB-2 and REQ-INST-2 as ids after the re-pin. P3: requirements
returns 70 ids at 1.10. 196 r6 (527b9a1e6, on f48725cfc) is in its gate, so the section below may apply.
Added 2026-10-21T21:02Z. 196 landed at 7247d80c7 (20:57Z), and c3d88840c after it is doc-only. NEW at
this hour is 7247d80c7, so the section below applies.
## If 196 lands before the cut
196 retires the Console's `--tester-port` (webui cli.mjs:30, "activate explicitly with --tester-socket").
The live Console runs `--tester-port 7333`, so a NEW that carries 196 changes the Console's command line.
Draft 1 would then need:
- the activate line with `--tester-socket` and its path;
- the P2 unit diff showing only that change;
- a P3 rehearsal that starts the Console with it.
bus.tester stays Jason's. The tester path changes how the test client reaches the Console, but not who can
reach it from outside, so it stays Q173 work. I confirm that with Mos in draft 1. If 196 hasn't landed, NEW
leaves it out and none of this applies.
mos-1198 item 4: until row 253 (#1758, case2-unit.sh and case1-accept.sh to `--tester-socket`) lands, no
Case 1 or Case 2 host (.53, .54) takes a copy that carries 196. This window is dragon-lin only, so it isn't
blocked, but any .53 or .54 step that names NEW waits on 253.
Row 250 landed 1b6158ce2 at 19:42Z (conversation shim and K12 test). It has no live effect on its own, but
it is now the latest code landing if the cut were made today.
## Still open from draft 0
- The P-step evidence for rows 199, 210 and now 235 at bus start, from a P3 rehearsal run.
- Recount the restore-guard table list from NEW (T46).
- Q3 operator: Darkwing if free.
@@ -0,0 +1,168 @@
# 10-24 window: re-pin 4429a104f to 7247d80c7 (rows 190, 199, 210, 234 and on, row 196 last), with 3i-3j (draft 1)
Sage, 2026-10-21, draft 1, for mos-1145 under DECISIONS Q173. Mos checks this file at its commit before P0.
Any change to this file or to NEW after that check means Mos checks it again (mos-0849/0850). The step layout
follows `repin-4429a104f.md`. Where a step is unchanged, I name it and say what differs. Draft 0 is
`repin-1024-draft0.md`, and the working notes are in `repin-1024-draft1-prep.md`.
## Who, and what nobody does
- Q3 below names the operator: Darkwing if they're free, otherwise me. Mos verifies each step's output before
the next one, and runs `rehearse` (P3).
- Jason has no step. Q173 covers a re-pin of this test system with a migration, a backup first and a STATE
report after. Nothing is pruned.
- Nobody acts as Jason. No `--by jason`, no human shell or tmux pane, and every bus read and write carries
`--tester`. `bus.tester` stays as it is. If it isn't `true`, stop at P1.
- No pi (P19 #383). No Save in the human Console, no `mosaic name`.
- Outside reach doesn't change. There's no `--sso` and no `--send`. Both go on together in the Q175 phase 2
window, which runs after this window is verified, when Jason starts it (mos-1195, mos-1197). The proof pair
stays unset. The tunnel still forwards 7332 only (P4 and W10).
- One Console flag does change. Row 196 retires the tester's TCP port. The live Console runs
`--tester-port 7333`, and NEW refuses that flag. So W5 converts it explicitly with `--tester-socket`, and
the tester listener becomes the 0600 socket `$DATA/console/tester.sock`. Only 127.0.0.1 could reach port
7333, so nobody outside gains or loses reach. Mos ruled this Q173 work in mos-1211 item 2. The socket is
narrower than loopback TCP, and Mos's acceptance in mos-1015 (on sage-1359) already accepted losing
browser access to 7333. DECISIONS has no Jason ruling on 7333 (mos-1214 item 2).
- Under mos-1198, .53 and .54 take no copy carrying 196 until row 253 lands. This window is dragon-lin only.
## Names
- OLD, live: `4429a104f6d65ccf165f6c83b5d1dce3147dfde0`, schema 3i, pointer `pm: true`, activation
`20261020T220206Z-4429a104f6d6`. The Console runs `--port 7332 --public-host mosaic.woltje.com --auth-header
X-authentik-username --allow-user jason.woltje --tester-port 7333`, read from `status` at 19:38Z. P1
re-reads all of these on the day. I don't copy them from this file.
- NEW: `7247d80c7af7ffa70fc61557406bf491cdf3bfc7`, the row 196 landing, the latest code landing when this
draft was cut (mos-1161). It is 135 commits past OLD (`git rev-list --count 4429a104f..7247d80c7`). The commit
after it, c3d88840c, is doc-only and doesn't move NEW. If 244 or 251 land before P0, NEW stays here unless
Mos rules otherwise (Q1).
- 194 hasn't landed, so P3 has no lifecycle step (mos-1161).
- `REPO`, `P`, `WIN=agents/sage/work/window-repin-7247d80c7/` and `SNAP` are as in `repin-4429a104f.md`, with
the sha checks at P0 and before S1, S2 and any D3.
## What NEW carries over OLD
This is draft 0's table, rows 201 through 234 plus PRD 1.7 and 1.8, unchanged, together with these rows from
`git log --first-parent c771a36f4..7247d80c7`, limited to packages, scripts, roles, contracts, adapters,
skills, tools, migrations and the package files:
| Row | Commit | Live effect to check |
|---|---|---|
| 240 | c515273db | Installer I4: root host check, pins and the boot unit. This window doesn't run them. |
| 228 | 86249ac55 | ZAI_API_KEY joins the session env allowlist. The live config sets no ZAI key, so the env doesn't change. P3 checks this. |
| 238 | d49b1f2bd | sso-config takes `--instance`. Default instance with `--sso` off, so no effect. |
| 212 | ee7b2f137 | New package, packages/library. The live business has no library entry. |
| 195 | b65014647 | scripts/case1-accept.sh, root only. Not run. |
| 213 | 361ba2387 | Creative roles, persona file and block, launch-settings, bundle.mjs. P3 checks that the chief's and the pm's bundles are unchanged apart from the new code path, and gets one pm reply. |
| 231 | 29ce1e360 | Library reader fixes. No live library. |
| 235 | b1fece6f0 | Machine grant row b. The live business has no hosts. P3: bus start runs the grant reconcile and sweep, they change nothing, and the log lines are shown. |
| 221, 247 | 300766450, 856c1826b | Persona import script and follow-up. Not run. yaml 2.9 joins package-lock, and W0's npm ci covers it. |
| 245, 248, 249 | 6d5545d44, 8a04f4aa4, 9de5a57ab | case1-accept F9, test stubs, and `hostLineLeft` on a grant whose install never returned. No grants are live. The look-alike count below covers `hostLineLeft`. |
| 197 | a36523713 | Test lane fixture. No live effect. |
| 66 | 2c38325ec | Managed sessions run the unslop check. COPY_PATHS adds tools/unslop-hook, and P3 checks the copy has it. W12 is the live check. |
| 243 | 6096b051b | Tests only. |
| 215 | e54e05463 | Library entry kind, consumer skips, M9 and the bare mirror. M9 refuses an organization write that changes `projects`, so P3 checks that the chief's and the pm's usual organization writes don't touch it. |
| 250 | 1b6158ce2 | Conversation shim and K12 test. No live effect on its own. |
| 222 | 0b33f5e9d | library.sync verb, a 60 s timer per library entry, and the askpass helper in COPY_PATHS. No library entry, so the timer starts nothing. P3: the copy has `packages/library/bin/askpass` with its mode, and bus start logs no `library:` line. |
| PRD 1.9, 1.10 | f48725cfc, add0f1aae | `packages/tasks/requirements.json` is in the copy. W9: the pm's requirements read answers version 1.10 with 70 ids. |
| 196 | 7247d80c7 | The human boundary. See the next section. |
## Row 196 in this window
What the code does (`scripts/stack-release.sh:791-845`, `docs/TOOLS.md` "Console tester listener"):
- An inherited `--tester-port` with no `--tester-socket` refuses `tester-transport-migration` before anything
stops. So `--tester-socket` goes on W3 and W5, and the plan's drop line must read
`--tester-port 7333 (replaced by private Unix socket)`.
- Before the stop, activate checks the socket path: `$DATA/console/tester.sock` must fit 107 bytes, and
`console/` must be absent or a directory with no symlink, owned by the instance's uid, with no group or
other bits. A failure refuses `tester-socket-path` before the stop. P1 reads `ls -ld $DATA/console`
(expected absent).
- Activate preflights the pinned root-owned system Python, records its identity, and checks the bus's fresh
start receipt. Each bus start appends one line to `$DATA/bus/peercred-starts.jsonl`. P3's rehearsal must show
the preflight passing on dragon-lin, and W9 reads the one new receipt line.
- `rehearse` starts NEW's Console with `--tester-socket` under its scratch data root when the copy carries
the socket marker (`stack-release.sh:2153-2155`). It doesn't run activate's path check from line 815, so
that check first runs live at W3. P1's `ls -ld $DATA/console` plus the path length are its only preview.
If the rehearsal Console fails to start on the socket, P3 stops and the log goes to Mos.
- Direct browser access to the tester is gone, with Mos's acceptance in mos-1015. W10's browser check on
127.0.0.1:7333 becomes `node packages/cli/src/tester-http.mjs GET /api/config` and `GET /api/bus/agents`, run from
the NEW copy against the live data root. Each must answer 200 with the same tester flags OLD's 7333 gave.
`mosaic ... --tester` still uses broker.sock.
Rollback is where 196 matters most. Without `--sso`, rollback takes the Console flags from the pointer, and
after W5 the pointer carries `--tester-socket`. OLD's Console doesn't support the socket. So rollback to OLD
drops the tester listener, the plan prints `--tester-socket (tester disabled; no TCP fallback)`, and port 7333
doesn't come back. The test client then has no tester listener until a new activate. Q4 asks Mos how to
handle that.
## Schema and backups
- `SCHEMA_VERSION` is 3i at OLD and 3j at NEW (`packages/bus/src/store.mjs:17`). The only step is
`packages/bus/src/migrations/3i-3j.sql` (row 234, tables `hosts` and `host_grants`). Nothing after c771a36f4
changes schema.sql or adds a step. `--plan` must say `migration: 3i -> 3j`.
- Restore guard, recounted at NEW (T46, `packages/cli/tests/stack-release.test.mjs:913`). COUNTED is the
same 11 tables (`stack-release.sh:404`). The untimed list has six: `host_grants`, `hosts`, `meta`,
`message_threads`, `sqlite_sequence` and `task_comment_cursors`. Each write to `hosts` or `host_grants` goes
with a `grant.*` event, which is counted. A D3 snapshot still covers them, as draft 0 says.
- W5 takes the stopped store backup every activate takes (SHA256SUMS, SCHEMA 3i, STAMP) plus the rollback
set. Before `--migrate` writes, I check that SCHEMA reads 3i and record the backup's SHA256SUMS hash. W0b
copies the business file, as last time.
- Grant look-alikes (row 234 A22b, gizmoduck-0107): P3, and again just before W5, on the backup copy. Count the
`action.allowed` events whose body.action or body.operation starts with `grant.`, or whose body has
`hostLineLeft`. Expect 0. Any nonzero count stops the window until I've read those rows.
- Rollback after the migration isn't the plain rollback. OLD is 3i and the store is 3j, so going back needs
`--restore <W5 activation>`, which loses the events written after W5. Mos decides any restore past W5.
## Before the window (nothing stopped)
| # | Who | Step |
|---|-----|------|
| P0 | Mos | The `$P` sha check, and this file's commit. |
| P1 | Sage | `status`: pointer, activation, schema 3i, Console flags, unit flags match. The business file sha256 and `business-check.mjs` LOAD_OK for OLD now and NEW after W0. `ls -ld $DATA/console` (expected absent). `bus.tester` is `true`. |
| W0 | Sage | `scripts/stack-copy.sh copy $NEW` in a gap between gate runs. The package-lock diff is empty. The copy has `tools/unslop-hook` and `packages/library/bin/askpass`, with the mode NEW's tree gives it. |
| P2 | Sage | `console-check.sh $NEW` on a stub Console on a scratch port, once without and once with `--tester-socket` under a scratch data root. CONSOLE_PASS both. Render the Console unit for NEW from REPO's `webui-service.sh` and diff it against the live unit. The only difference allowed is `--tester-port 7333` becoming `--tester-socket`. Diff the bus unit too. It must match apart from the copy path. |
| P3 | Mos | `stack-release.sh rehearse $NEW mosaic-stack --backup <latest live activation> --port <scratch>` with `--migrate` on the backup copy. It must show the 3j step, row counts before and after, the interpreter preflight passing, a receipt line, the grant reconcile and sweep changing nothing, no `library:` line, no publish or land decision open (rows 241, 242), the look-alike count 0, and one pm reply. As in mos-1107, Mos also starts NEW's Console by hand with the pointer's flags, with `--tester-socket` in place of `--tester-port`, and reads healthz, `/api/config` and the two tester-http reads. |
| P4 | Sage | The tunnel read. |
| P5 | Sage | The four service scripts are held at NEW (`git diff --quiet $NEW HEAD --` on them, and empty porcelain). If 244 or 251 lands and touches one of them, P5 fails and the window waits for Mos. |
| P6 | Sage | The 7332 matrix (a)-(g) on OLD, saved as `$WIN/matrix-old.txt`, plus the two tester reads on 7333 with curl, for W10 to compare against. |
## The window
| # | Who | Step |
|---|-----|------|
| W0b | Sage | The business file copy. |
| W1 | Sage | Idle check, the start stamp from `date -u`, then S1 with `$SNAP --held`. Open pm tasks from row 42: pm finishes or parks them before W5, or I record which piece crosses the window. |
| W2 | Sage | `status`: pointer on OLD, `pm: true`, 3i, unit flags match. |
| W3 | Sage | `stack-release.sh activate $NEW mosaic-stack --pm --migrate --tester-socket --plan`. It must show OLD to NEW, `migration: 3i -> 3j`, the backup line, the drop line for `--tester-port 7333`, and a Console line with OLD's other flags exactly: no `--send`, no `--sso`, no proof pair. Any other difference stops here. |
| W4 | Sage | The unit reads, as in the 177-185 plan. |
| W5 | Sage, on Mos's go | The same command without `--plan`. Exit 0, `result ok`, and every gate check passes, `pm_launched` included. Record the activation id, the backup path, STAMP and the backup SHA256SUMS sha256. Then `status`. |
| W9 | Sage, after Mos verifies W5 | The 177-185 W9 list, plus: the webui journal since the W5 stamp (with `UTC`) has no `Sign-in:` line; `/proc/<Console pid>/cwd` names the NEW copy; `$DATA/console/tester.sock` is 0600 in a 0700 `console/`; `peercred-starts.jsonl` gained one line for this start; the bus journal has the grant reconcile and sweep lines and no `library:` line; the pm's requirements read gives 1.10 with 70 ids. |
| W10 | Sage | The matrix again, as `$WIN/matrix-new.txt`, identical to `matrix-old.txt` apart from the lines the pin changes, each one named. The two tester reads through `tester-http.mjs` match P6's. Port 7333 has no listener (`ss -ltn`). Then P4 again. A write without a signed-in session must still be refused. That's Console POST through the main port, with no session. This is the check mos-1195 asks for before phase 2 is done, so I run it here too, as a baseline. |
| W11 | Sage | STATE report in `$WIN/STATE.md`, posted on P19 386, 412 and 435 (J2, mos-1215), and on the Gitea issues for 190, 199, 210, 234 and 196. |
| W12 | Sage, after W11 | Row 66's live check (Vikunja #454). One Claude Code managed session with a prompt that draws a known tell, then the operator note on the RESULT, the next-turn notice and the `unslop.jsonl` counts. Backup first (Q173). Pi isn't run (#383). |
## Stop conditions and rollback
The same as the 177-185 plan. Two failures in one window: stop and report (mos-0823).
Rollback before W5 writes is `rollback --plan`, then the real run. After the migration it needs `--restore
<W5 activation>`, with Mos deciding. Both drop the tester listener (see Row 196 above). Keep every copy.
Prune nothing.
## Questions for the plan check, answered (mos-1214 item 3)
- Q1: NEW stays 7247d80c7 unless a landing fixes something this window needs.
- Q2: mos-1211 item 2 rules the `--tester-port 7333` to `--tester-socket` change Q173 work.
- Q3: Darkwing operates if free, and I check. Otherwise I operate.
- Q4: after a rollback, the tester listener stays off for the window's length, and the tester goes through
broker.sock. A same-copy re-activation of OLD with `--tester-port 7333` goes to Mos only if a rollback
holds past the window day.
## What Sage ran for this plan
Read only, 2026-10-21 between 20:40Z and 21:20Z: the path-limited first-parent log c771a36f4..7247d80c7,
`git rev-list --count`, `SCHEMA_VERSION` at both ends, `git diff --name-only` on the schema and migrations,
`stack-release.sh` lines 40-47, 125-158, 404, 791-845, 886-919 and 2140-2157, T46 in
`stack-release.test.mjs:913`, `packages/cli/src/tester-http.mjs`, and TOOLS.md:1274-1310 on the tester
listener. Nothing ran against
live. OLD's values come from the 19:38Z `status` read, and P1 reads them again.
+1
View File
@@ -1432,3 +1432,4 @@ are never rewritten or removed; corrections are new entries.
2026-10-21T19:46:12Z | Dewey (Opus 5.5, 84fb346c) | row 222 r1 static review (megavolt-0095), c9aa9c2ee, manifest c2121b55 | APPROVE, comment 30138 on #1723, queue rev 2966; rulings: createObject=rename accepted, 5 s human timeout deferred with STATUS codes in the follow-up, credential.helper reset argv-only accepted; notes N1-N5; no tests run
2026-10-21T19:49:37Z | Sage (Opus 5.5, 1ef1e4f8) | row 250 landing, row 196 r5 grant (mos-1198) and resume (mos-1200), F27 row 253 | 250 LANDED 1b6158ce2 (patch-id 29786caa, Filbert rev 2957, Gizmoduck slot green), #1755 closed c30139, Vikunja 450; 196 r5 91e3696ef hunk checked by Duckworth, webui 304/0; my queue add tripped Gander's busy check, not a stop (mos-1200), resume on 05e940117; row 253 #1758 brief 0cdead66c
2026-10-21T20:46:03Z | Sage (Opus 5.5, 1ef1e4f8) | row 222 landing, PRD 1.9 and 1.10, Pocket ID design row 255, row 254 brief, 196 r6 grants | 222 LANDED 0b33f5e9d (megavolt-0098 gate, Dewey rev 2966, patch-id edb60b4f), #1723 closed c30147, done rev 2972; follow-up row 254 (#1759, after 196); PRD 1.9 f48725cfc (REQ-WEB-2, REQ-DEC-3 amended, mos-1202); 458 brief 7d08237e1 (#1760), row 255 rev 2976; PRD 1.10 add0f1aae REQ-INST-2 post-v1 (mos-1204, tasks 97/0, bus req 3/0); Vikunja 458 c478, 416 c479; queue-commit 21def2850 revs 2968-2976; 196 r6 gate gander-0066 2334/0, 38/38 killed; 244 granted sage-2147; 196 r6 resume granted sage-2145 after self-tee busy refusal (mos-1205). Miss: landed 222 before checking 196's manifest; 196 carried results voided. Class rule now (mos-1204, replaces the mos-1200 interface grep): before any landing push, compare every in-review and in-progress row's manifest against the landing's paths.
2026-10-21T21:14Z | Sage (claude-opus-5-5, 1ef1e4f8) | row 196 landed 7247d80c7 and doc commit c3d88840c (#1694 closed, comment 30159); rows 253 and 254 briefed and launched (Gander #1758, Megavolt #1759); Honker 194 b4 rebase ruling (creative delete stays refused, Agents page hides Delete on the server predicate, actor-threaded delete deferred); queue revs 2977-2994 committed abb352d27 incl. Morgana 244, Gander 196 r6, Duckworth 196 m2, Darkwing 251 r2; 10-24 window draft 1 written (NEW 7247d80c7, --tester-socket, Mos mos-1211 agrees Q173) | landed and launched; draft 1 to Mos next
+8 -2
View File
@@ -234,10 +234,16 @@ When a run doesn't exit 0, send Mos the exit and the last lines of `/var/log/mos
Precheck lines: none. Root's checkout at `<sha>` and the stack's user's clone: precheck line missing.
After install, `stack-copy.sh check --units external` reads the copy the units run.
The stack's user's `scripts/case2-install.sh` (C2-13b, design 10 step 2b) makes the first copy with
`stack-copy.sh copy <sha>`. The copy runs `npm ci` from `package-lock.json`, which installs the pinned
harness, so the host needs to reach the npm registry during the install. The install runs no
`release.sh package` or `activate`.
## 7. The args file
`/etc/mosaic-stack/dev.args` holds the `case2-unit.sh` arguments, one per line, with the serve flags after a
line holding `--`. I2's install writes it; ops-01 doesn't write it by hand. It must be root's, with no group
line holding `--`. Root writes it at the unit render, from the values the go names. The stack's user's
install never writes it: that user can't write `/etc`, and root reads nothing that user wrote. It must be root's, with no group
or other write (`chmod 0644` or tighter). Before `--` it takes only `--user`, `--home`, `--business`,
`--copy` (exactly once), `--node`, `--profile`, `--claude` and `--pm`. `stack-upgrade.sh` changes only the
`--copy` line, and only after confirm passes.
@@ -272,7 +278,7 @@ file with `stat -c '%U %a %n' PATH`, never `cat`. `<business>` is the host's bus
|---|---|---|---|---|---|
| `/srv/mosaic/.config/mosaic-dev/secrets/` | `claude-session.env`, one line `ZAI_API_KEY=...` | 0600 | mosaic | library | `<path> sets ZAI_API_KEY` |
| `/srv/mosaic/.config/mosaic-dev/secrets/` | the Console's proof file, the path the args file's `--proof-file` names | 0600 or 0400 | mosaic | library | none. `case2-unit.sh` refuses at render. Precheck line missing. |
| `/srv/mosaic/.config/mosaic-dev/secrets/<business>/` | `library-gitea` | 0600 | mosaic | library | C2-13b's `library.sync` (not landed) |
| `/srv/mosaic/.config/mosaic-dev/secrets/<business>/` | `library-gitea` | 0600 | mosaic | library | `case2-precheck.sh --business <business> --library-status`, after the bus is up: `library-last-error library-credential-missing` until it's placed, then `library <project> last fetched ...` |
| `/srv/mosaic/.config/mosaic-dev/secrets/agents/<business>/<agent>/` | one file per service | 0600, dirs 0700 | mosaic | both | none. Filled only by `mosaic credentials issue`, or Approve after #436. Never by hand. |
The installer key, `<configDir>/secrets/hosts/<business>/<hostId>/installer`, is not a slot anyone places.
@@ -0,0 +1,50 @@
# Brief: the human deletes a creative instance (REQ-COS-3, after row 194)
Sage, 2026-10-21, for mos-1214 item 5 and mos-1215 item 3. Owner Honker, reviewer Webby, gate owner Sage.
Chained after row 194: `deleteRefusal` and the delete path land in 194, so this row builds on 194's
landing. The row has to land before the v1 tag for 10-27. If it slips past that date, Sage tells Mos before
194's v1 tag (mos-1215).
## Why
REQ-COS-3 (`docs/prd/mosaic-stack.md:342`) says "Only the user deletes an instance." Creative entries are
instances: they sit in the business file's `instances`, and only the human's `instances.set` adds or changes
one (C2-08 M10, row 213). Row 213 made every creative definition END_PROTECTED (`vocabulary.mjs:195`,
`END_PROTECTED = THREAD_DEFINITIONS`). In row 194, `deleteRefusal` refuses END_PROTECTED for every actor
(`organization-definition-refused`), so nobody can delete a creative instance. The human can only delist one
with `launch: false`. This row lets the human delete one, which matches END_PROTECTED's intent: a protected
end goes to the human (sage-0525).
## What this row does
1. **Human-only delete of a creative instance.** Pass the actor into `deleteRefusal`, and through every
caller: the broker's check at the verb, the launcher pass's `set op delete`, the CLI and the Console.
- A creative definition (`CREATIVE_DEFINITIONS`) whose actor is the business file's `human` passes this
check and goes on to the remaining checks (references, kept, credentials).
- Any other actor still gets a refusal. Use the code `creativeHumanOnly` gives
(`organization-creative-human-only`), so the refusal says why.
- Every other END_PROTECTED definition (the thread definitions that aren't creative, such as the chief
of staff) still refuses `organization-definition-refused` for every actor, the human included.
2. **Fire stays as it is.** `role.fire` and `instance.fire` keep refusing END_PROTECTED for every actor.
REQ-COS-3's fire is the chief's action, and the chief can never hire a creative instance.
3. **Agents page.** Row 194 hides Delete on `endProtected`. Offer Delete on a creative instance only on the
human's page, from the same server predicate, and never on the tester listener. Add one browser test for
each side.
4. **Persona file.** Say in BUILD.md what happens to `<configDir>/personas/<business>/<instance>.md` on
delete. The smallest choice is to leave it in place and record that. If you remove it, open it with
`O_NOFOLLOW` as row 213 reads it, and add a test.
5. **DEFERRED.** Close row 194's DEFERRED entry for human delete of a creative entry, and name this row in
it.
## Boundaries
- No change to who may add or change a creative instance (M10), or to `role.hire`'s bounds.
- No change to fire, veto or `launch.keep`.
- No test touches `~/.mosaic-dev`, `~/.config/mosaic-dev`, the live units, a slot file, .53 or .54.
## Gate
Webby approves the manifest. On Sage's slot, every touched test file passes whole, along with the
business, bus, cli and webui suites whole and every `scripts/test-*.sh`. Two mutants must be killed: one
that lets a non-human actor delete a creative instance, and one that lets the human delete a non-creative
END_PROTECTED instance.
+6 -6
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 2976 by `queue render`. Do not edit between the markers; change the queue with `scripts/mosaic queue`.
Generated from `docs/plans/queue.json` revision 2994 by `queue render`. Do not edit between the markers; change the queue with `scripts/mosaic queue`.
| # | Piece | Owner | Issues | State | After | Gate | Brief | Note |
|---|---|---|---|---|---|---|---|---|
@@ -232,7 +232,7 @@ Generated from `docs/plans/queue.json` revision 2976 by `queue render`. Do not e
| 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 | sage-1610 (honker-0049): a human add takes tracker {bot, botId}, required for a vikunja definition, refused otherwise, checked by checkBot and bot uniqueness; CLI and API only. Delete accepts a tracker bot when all credentials are store refs, and its result names the bot for removal by hand in Vikunja. No Vikunja account is created (Q188 untouched). Mutants: drop the requirement, refuse the bot on delete, skip checkBot on add. |
| 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 | done | 190 done, 226 done, 233 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) | LANDED b65014647 (pushed 361ba2387 tip). Patch-id 656e43ff25de = 4ced5e63d..37171f65b; verify-commit ok, 5 paths. Combined gate with 213 on 38b83ea5c+paths 09:45:52-09:57:52Z: business 200/0 bus 542/0 harness 140/0 cli 663/0, all 10 scripts green, L1 killed. N1-N3 to DEFERRED next; follow-up darkwing/row195-n1 1e3c4767c. |
| 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) | — |
| 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 | done | — | 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) | Landed 7247d80c7 on 666173292: 39 manifest paths of 527b9a1e6 (CANDIDATE.sha256 537d30ae), patch-id 6f3777d593e6 matches f48725cfc..527b9a1e6 on the manifest, blob diff empty, tree matches merge-tree. Gate gander-0066 2334/0/1, all 10 shells, 38/38 mutants. Approved rev 2973 and rev 2986 (Duckworth, c30156). Overlap: 202 cli README and 194 Honker branches rebase onto it. BUILD-LOG and C1 DEFERRED follow doc-only. |
| 197 | a second gate lane: two full gates at once on dragon-lin, design first (mos-0977 item 6) | herb; reviewers webby | #1695 | done | — | 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 | Landed a36523713 on refactor (base 5afff5490, pushed 18:40Z): Herb's c45ddeb6b cherry-picked. Range-diff = (only the cherry-pick line), patch-id 79c46b98c685 equals c45ddeb6b and candidate A d2de56bd2; 37 paths equal, marker test absent (mos-1174 check). Proof 6 PROOF_PASSED 18:40:06Z, sage-r197p6: 8 waves 26/26 steps, cb A 124 B 125, probe ok, no event or cleanup errors, final CLEAR/CLEAR. Webby approved r8 (rev 2867). |
| 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 | done | — | 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 | Landed cb5fdb064 on refactor (base 76134dfde), pushed. Patch-id 828fa21a matches git diff 00d4cc1f6..86963d281 on the 37 manifest paths; docs/TOOLS.md 3-way with row 200, hunk unchanged. Manifest 35f900fd, Duckworth approve r1 29779. Gate on 9e1085816+patch: packages 2735/0, scripts 429/0; landed blobs equal the gate tree. |
@@ -280,17 +280,17 @@ Generated from `docs/plans/queue.json` revision 2976 by `queue render`. Do not e
| 241 | Row 237 follow-up: one decision lands once, a two-ticket broker land test and three mutants on the landing guards (megavolt-0069, sage-1651, P19 441) | megavolt; reviewers duckworth | #1747 | done | 237 done | Duckworth approves the manifest. The bus, seat and cli suites and every scripts/test-*.sh are green on Sage's gate, and the three mutants are killed. (sage) (sage) | `docs/plans/2026-10-20_agent-credentials.md` § Brief: row 237 follow-up, one decision lands once, with tests at the broker | Landed 5d4a61776 on refactor, pushed (base e65a3ab4d). Blobs from 3e3ed303e on 2 paths; patch-id a1649f80 equals git diff f5753c0e8..3e3ed303e on those paths; ls-tree modes equal; manifest e8266864 verifies on the landed files. Sage slot on bbf3b1ee8: M1-M3 killed; gate 15 packages 2797/0, 10 script suites 429/0. Duckworth approve 29870 rev 2628. |
| 242 | Row 237 follow-up N1: a retry after publish-timeout pushes once; hold the decision or kill and wait for the push, a slow-push retry test and a mutant (duckworth-0069, P19 440) | megavolt; reviewers duckworth | #1748 | done | 237 done | Duckworth approves the manifest. The bus and cli suites and every scripts/test-*.sh are green on Sage's gate, and the mutant is killed. (sage) (sage) | `docs/plans/2026-10-20_agent-credentials.md` § Brief: row 237 follow-up N1, a retry after publish-timeout pushes once | Landed 5fb736b92 on refactor, pushed (base 96bac588f). Blobs from 635c10924 on 3 paths; patch-id ceb741ee equals git diff 8fede5fc6..635c10924 on those paths; ls-tree modes equal; manifest 1fbfab6c verifies on the landed files. Megavolt M1/M2 killed on Sage's slot; Sage gate 15 packages 2826/0, 10 script suites 429/0. Duckworth approve 29889 rev 2639. |
| 243 | process.test.mjs startup token refusal sets its file mode (chmod), umask sweep of packages/*/tests | morgana; reviewers webby | #1754 | done | 197 done | Webby approves the manifest. The test file passes whole under umask 022 and under umask 077 on Sage's slot. (sage) | `docs/plans/2026-10-21_gate-env-rows.md` § The startup token refusal test sets its file mode | LANDED 6096b051b on 9a8c8860d, pushed. Patch-id f9d687dd1d41 equals a36523713..6581f750e; 5 paths, blobs and modes equal the candidate; no drift on those paths since a36523713. Gate: Webby r1 APPROVE rev 2939 (c30111); slot singles (morgana-0041): candidate 4 files whole green at umask 022 and 077, base 077 red only on the predicted tests. |
| 244 | Gate runs use a fixture PI_AUTH_FILE (DEFERRED 059577886, Webby N4) | morgana; reviewers webby | #1757 | briefed | 197 done | Webby approves the manifest. The new test and every touched file are green on Sage's slot, along with test-task.sh and test-release.sh. (sage) | `docs/plans/2026-10-21_gate-env-rows.md` § Gate runs use a fixture pi auth file | — |
| 244 | Gate runs use a fixture PI_AUTH_FILE (DEFERRED 059577886, Webby N4) | morgana; reviewers webby | #1757 | in-review, round 1, request posted | 197 done | Webby approves the manifest. The new test and every touched file are green on Sage's slot, along with test-task.sh and test-release.sh. (sage) | `docs/plans/2026-10-21_gate-env-rows.md` § Gate runs use a fixture pi auth file | — |
| 245 | row 195 follow-up: Duckworth's N1-N3 on scripts/case1-accept.sh (root never runs an unchecked --node; the header lists what root runs; F6 refuses .. after the claude pin dir) | darkwing; reviewers duckworth | #1693; closes none | done | — | Duckworth approves the manifest. The cli suite and every scripts/test-*.sh are green on Sage's gate, and a mutant for each of N1 and N3 is killed. (sage) | `docs/plans/2026-10-20_hub-own-uid.md` § Row 195 follow-up: Duckworth's N1-N3 on the Case 1 acceptance script | LANDED 6d5545d44 on 856c1826b, pushed. Patch-id 36a621ac3966 equals 62ddc96cc..ef8b4439e. Gate: Duckworth r1 APPROVE rev 2849; N1 mutants 13-15 and N3 mutant 16 killed (sage-1961, darkwing-0107); Sage gate 12:24-12:35Z: case1-accept alone 23/0, cli 681/0, scripts/test-*.sh 429/0. Lands before row 246 as the brief asks. #1693 stays open for 246. |
| 246 | row 195 window: the Case 1 acceptance run on 10.1.1.53 (carries row 195's done condition; needs a Jason or Mos go) | darkwing; reviewers duckworth | #1693 | queued | 226 done, 245 done | The window's acceptance run passes on .53, Duckworth reads its output, and Mos confirms. (sage) | `docs/plans/2026-10-20_hub-own-uid.md` § Row 195 window: the Case 1 acceptance run on 10.1.1.53 | — |
| 247 | Case 2 C2-20b persona import follow-up: printed keys, the dry run fence check and the mkdir race | gosalyn; reviewers webby | #1749 | done | 221 done | Webby approves the manifest. The test file runs green on its own, and the three mutants are killed on Sage's slot. The cli suite and every scripts/test-*.sh are green on Sage's gate. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-20b persona import follow-up: printed keys, the dry run fence check and the mkdir race | LANDED 856c1826b on f8276c5eb, pushed. Patch-id 373ab2cc4c50 equals 6d43618a3's. Gate: Webby r1 APPROVE rev 2852; test file alone 14/0 and M1-M4 killed (sage-1971); Sage gate 12:10-12:21Z: file 14/0, cli 680/0, scripts/test-*.sh 429/0. M1/M2 at :248 not :237 (gosalyn-0111). |
| 248 | row 235 follow-up: the grant-executor hold-add and hold-remove stubs stop when their test dir is gone (found by the 197 r8 proof) | sage; reviewers webby | #1750 | done | — | Webby approves the commit. On Sage's gate the whole grant-executor.test.mjs passes three times with no stub left, the bus suite is green, and a mutant that drops the dir check leaves a stub. (sage) | `docs/plans/2026-10-20_host-credentials.md` § Machine grant row b follow-up: the held stubs end with their test | LANDED 8a04f4aa4 on refactor, pushed. Temp-index landing of candidate 4d99c178b's one path onto bc579f7a8. No drift on the path since 540239b6b. Patch-id 84974d6b08d6 matches the candidate's. Gate per the brief: Webby APPROVE r1 rev 2883, Sage's gate 13:05-13:07Z (file 3x 37/0 no stub, bus 596/0, mutant left 2). |
| 249 | row 235 follow-up: the grant undo records hostLineLeft when install never returned, since the remote add can outlive an abort or timeout (mos-1166) | gizmoduck; reviewers duckworth | #1751 | done | — | Duckworth approves the manifest. On Sage's gate the bus suite and the whole touched test files are green, and a mutant that drops the new catch branch is killed. (sage) | `docs/plans/2026-10-20_host-credentials.md` § Machine grant row b follow-up: the undo can't prove the remote add has ended | LANDED 9de5a57ab on refactor, pushed with 7b9737953 (DEFERRED N1). Range-diff: 8a04f4aa4..05595794b vs 8ca064158..9de5a57ab, same 3 paths, patch-id 99551b122e66 on both. Gate sage-2026 14:53Z: grant-executor whole 39/0, bus 598/0, no stub left, C1 (drop :1025) killed by G8 and both G23. Duckworth approve c30070. |
| 250 | K12 (cohort.test.mjs) reads cgroup.events after cgroup.kill under load: print the shim's reason, then fix the cause at the source (Vikunja #450) | gizmoduck; reviewers filbert | #1755 | done | 197 done | reviewer approves the manifest; conversation and control-board suites whole on Sage's slot (sage) | `docs/plans/2026-10-21_k12-cohort-events.md` § Brief: K12 reads cgroup.events after cgroup.kill under load | LANDED 1b6158ce2 on refactor 19:42Z: candidate c1a858975 on 3b0aef995, manifest eca0b8e2, Filbert APPROVE rev 2957 (#1755 c30130). patch-id 29786caa = candidate; blob diff empty; tree 59f1e4781 = merge-tree. Gate on Sage slot sage-2109 (gizmoduck-0150): P1/P2/M1 as predicted, conversation whole 3x 183/183 plus concurrent pair, control-board 124/124. No drift on paths since base. |
| 251 | T27 (host-run.test.mjs:453) lists no identities right after ssh-add -t 1: print timing and agent state, then fix the cause at the source (Vikunja #453) | darkwing; reviewers dewey | #1756 | in-review, round 1, request posted | 197 done | reviewer approves the manifest; bus suite whole on Sage's slot (sage) | `docs/plans/2026-10-21_t27-ssh-add-lifetime.md` § Brief: T27 lists no identities right after ssh-add -t 1 | mos-1185 item 2. If proof 6 fails outside 197's paths in a pair wave, this and row 250 go ahead of 197 (item 4). |
| 251 | T27 (host-run.test.mjs:453) lists no identities right after ssh-add -t 1: print timing and agent state, then fix the cause at the source (Vikunja #453) | darkwing; reviewers dewey | #1756 | in-review, round 2, request posted | 197 done | reviewer approves the manifest; bus suite whole on Sage's slot (sage) | `docs/plans/2026-10-21_t27-ssh-add-lifetime.md` § Brief: T27 lists no identities right after ssh-add -t 1 | mos-1185 item 2. If proof 6 fails outside 197's paths in a pair wave, this and row 250 go ahead of 197 (item 4). |
| 252 | Console inbox Approve and Deny for grant requests (#435, J2) | gander; reviewers webby | — | briefed | — | Webby approves DESIGN.md then manifest; webui, bus, cli suites and every test-*.sh green on Sage's gate; DESIGN mutants killed; REQ-WEB-2 PRD amendment landed first (sage) | `docs/plans/2026-10-21_console-inbox-approve.md` § Gate | mos-1193. Chained on row 196's candidate (Q177): design starts now on Gander's row 196 build; code rebases onto 196's landing. Vikunja #435. |
| 253 | F27: case2-unit.sh and case1-accept.sh move to --tester-socket and refuse --tester-port with stack-release's migration wording; no .53/.54 copy with 196 until this lands (mos-1198 item 4) | gander; reviewers duckworth | #1758 | queued | 196 done | reviewer approves DESIGN.md then the manifest; cli and webui suites whole and every scripts/test-*.sh on Sage's slot; mutants per DESIGN.md; static check covers scripts and tests (sage) | `docs/plans/2026-10-21_tester-socket-scripts.md` § Brief: case2-unit.sh and case1-accept.sh move to --tester-socket (F27) | — |
| 254 | Library sync follow-up: client timeout, STATUS codes, README (222 deferrals, #1759) | megavolt; reviewers dewey | — | queued | 196 done | Dewey approves the manifest; bus, library and webui suites whole and every scripts/test-*.sh green on Sage's slot; the timeout and STATUS mutants killed (sage) | `docs/plans/2026-10-21_library-sync-followup.md` § Gate | Dewey's deferrals from 222 approval c30138. Chained after 196 (human-cli channel proof). Issue #1759. |
| 253 | F27: case2-unit.sh and case1-accept.sh move to --tester-socket and refuse --tester-port with stack-release's migration wording; no .53/.54 copy with 196 until this lands (mos-1198 item 4) | gander; reviewers duckworth | #1758 | briefed | 196 done | reviewer approves DESIGN.md then the manifest; cli and webui suites whole and every scripts/test-*.sh on Sage's slot; mutants per DESIGN.md; static check covers scripts and tests (sage) | `docs/plans/2026-10-21_tester-socket-scripts.md` § Brief: case2-unit.sh and case1-accept.sh move to --tester-socket (F27) | — |
| 254 | Library sync follow-up: client timeout, STATUS codes, README (222 deferrals, #1759) | megavolt; reviewers dewey | #1759 | briefed | 196 done | Dewey approves the manifest; bus, library and webui suites whole and every scripts/test-*.sh green on Sage's slot; the timeout and STATUS mutants killed (sage) | `docs/plans/2026-10-21_library-sync-followup.md` § Gate | Dewey's deferrals from 222 approval c30138. Chained after 196 (human-cli channel proof). Issue #1759. |
| 255 | Bundled Pocket ID for users with no OIDC provider, design (Q203, #1760) | launchpad; reviewers webby | — | queued | 252 done | Webby approves DESIGN.md; design only, build rows follow after Sage checks it against Q203, O6, REQ-WEB-2 and REQ-INST-1 (sage) | `docs/plans/2026-10-21_bundled-pocket-id.md` § Gate | Vikunja 458, Gitea #1760, mos-1202 item 2. REQ-INST-2 lands in PRD 1.10 as post-v1 (mos-1204). Design only: no install, no download, no .53/.54, dragon-lin keeps Authentik. |
<!-- mosaic-queue:end -->
+608 -20
View File
@@ -1,7 +1,7 @@
{
"version": 1,
"canonicalRoot": "/mnt/storage/src/mosaic-stack",
"revision": 2976,
"revision": 2994,
"rows": [
{
"id": 1,
@@ -20514,7 +20514,7 @@
"closes": [
1694
],
"state": "in-progress",
"state": "done",
"previousState": null,
"required": false,
"requiredSince": null,
@@ -20584,18 +20584,69 @@
"candidate": "d9fefc49ab1affb02d68b35b658772da4e82c724255611a7fe1d59b48b62006c"
}
]
},
{
"n": 2,
"op": "gander-196-manifest-r6",
"by": "gander",
"at": "2026-10-21T20:51:08.099Z",
"issue": 1694,
"candidate": {
"kind": "manifest",
"digest": "537d30aef70b6cd19ab7283ce2a702d9ac55bb179c60d6a55f50b45a0e5b149d",
"text": "0ad63c4f7a6cdd5b2587a0969fe7a95539cf1e4601f51fb33cc4df94a718b2b5 docs/TOOLS.md\n10c996d323ac46d45548f7748d40b246de93f8c5eacae9f951c6eeb5dcc62697 packages/bus/README.md\n60720461a7994011a9a30594715eb2c992be699cd61c656e77cbd609ad05328e packages/bus/fixtures/peer-client.mjs\n0c6d96abe4607cba0b02b0638a2fc217ff8135bac9a47761a0ef015fbe9d3079 packages/bus/src/human.mjs\nd5d98a9133a1b449acc48143fd559d890a470c6ea210b30b9b70e491e6cd855a packages/bus/src/interpreter.mjs\n29c88655e8b9aed8db604573c9213fd9f3c40ef35ff04f4eda8fbe8f1b8a32d7 packages/bus/src/peercred.mjs\nd1536a8f472fa5b3773adb10555529ed049ad63b647c0b44c00ce5f25ce09f85 packages/bus/src/peercred.py\n32ef39d80b523bdd586c29620d7a752c309c8e1aa70befc1414125468f3411dd packages/bus/src/principal.mjs\n190a4f56c44131f8a2325bc9c4a822556de432d1afd2e75ffe3168a171320bed packages/bus/src/runtime.mjs\n7be306c830aa98763b34b329995b40e9df443483fe20fcd69100a3acb6311bd6 packages/bus/src/server.mjs\ne99133c3c957a1e856e404343721add2430e4a0d8c75452517aef04942af56ab packages/bus/tests/channel-proof.test.mjs\nd19779b97e80ed3d62f58d0e798df46c23f45fa02b14fb4ac7a2ff4690a734e2 packages/bus/tests/interpreter.test.mjs\na39af877a2d9143cc8d0584af0d603ca7ab651db8787e5816579762f17295887 packages/bus/tests/peer-transport.test.mjs\n2c4c0bed41e546ce94535d8c4547b1cf5552a181183525dd1d52e2af1d64ccb6 packages/bus/tests/peercred.test.mjs\ncefbc0688d823c69196d94bb71467884acd639070721286d7e8e4e986c8240c1 packages/bus/tests/principal.test.mjs\n95c7acdb47f753d1e129adaa90b5937318d8d006ca47be9d92729fdae013776f packages/cli/README.md\n4bd7bdf0898844684ebf9444b110d2c5b6fa874b2681183e54f1f62f97823192 packages/cli/src/tester-http.mjs\na7cb20db2941b3b3abb09bcaa55eac92a1d420cf4ec79c6f83e4bb3235e04a2b packages/cli/tests/stack-release-fixture.mjs\n414d39e37d8e332761a6120ab728f67538e36082bf1f609eec7747bebdb434d4 packages/cli/tests/stack-release.test.mjs\nfbaa36e64129aee324341364bde831c379c098b97932cceadb7fcaa7909d4b20 packages/cli/tests/tester-http.test.mjs\n04b7c1165c4fa73137a72e8373d70fd7d96a5897b5511170e6d41908a7667e96 packages/webui/README.md\n934ab2f9b6bde72aafe7814758550c283eb25a4f3a23b284021b13718d67178f packages/webui/src/bus.mjs\n63dc2af0ff0c8f7c624268a78ffeede1593182a38d657a53e597338edf09226e packages/webui/src/cli.mjs\nda624f188c69e016ff82e1d1dcae1c579e68a57f0f25d773bb5d8bca56b80ee1 packages/webui/src/serve.mjs\n1c1fe0c795adac97f28502564a75f26d5666d9be5ae51169f7dd5bb7741fad16 packages/webui/src/tester-socket.mjs\n2f3ea92cbe1ca115301ee1e3b8fc55a206842606237446b700881ee57a1b18cb packages/webui/tests/browser.mjs\nf505b5fc635aa699556c237a92401b495943e45e25ebfadab945d64477e3e86e packages/webui/tests/library-cli.test.mjs\nedef361c4654ec010809c12c87c00197ec6e6b8337863068c4ffa144e291e300 packages/webui/tests/proof-cli.test.mjs\n5f34d699a1bce6f46f286def1a93fdd4d863d06bec6839677fae1041be7153dd packages/webui/tests/proof.test.mjs\n6457debc3d233a24643be00e4876e1129db664da2fbf5cc7168f3e371c46f313 packages/webui/tests/service.test.mjs\n3c1d2481eff2271c1c83f7ec2e9d616ac22600d032af23afc47f919c58f1b9ba packages/webui/tests/sso.test.mjs\n9215ff92df774ba1e83819b87f758b53c8f3d19c2824c916490165914f1cb9e8 packages/webui/tests/tester-browser-fixture.mjs\n89ffc5cbed1630a523080e8561d3678c1f781fb657199a5cd6cdb8189f082c73 packages/webui/tests/tester-browser.test.mjs\n23219434713fbd283a1e5cb1a373ed9d19d0963e171636dbf39bed3d60cd1316 packages/webui/tests/tester-cli.test.mjs\n7d4f3a59d21675807b1bfb92d40b7378134df450590bc6d42fa4103ab1343348 packages/webui/tests/tester-fixture.mjs\n7f0fc2daead11048cb86e0a74b6a223314785b1740a2927461d012b876a69f18 packages/webui/tests/tester-listener.test.mjs\nc01a942da1ad66a08a4e7c92d3c7e64f517530acfe5517f1e9895767d5ea3c0b packages/webui/tests/tester-socket.test.mjs\nb916a2793311d21e3ce7117e9b5e0768e6e33c841fa3aa39fb7ffc9c5974acd2 scripts/stack-release.sh\n6e7069cca9f7aef84c17f15611f2d7e0d09083a363c23da8dbd14d3ed29f0f40 scripts/webui-service.sh\n"
},
"request": "comment",
"attempts": [
{
"op": "gander-196-manifest-r6",
"by": "gander",
"at": "2026-10-21T20:51:08.099Z",
"state": "failed",
"comment": null,
"transport": {
"outcome": "failed",
"status": null,
"comment": null,
"detail": "pre-send: the credential or account check failed"
},
"resolutions": []
},
{
"op": "sage-196-review-r2b",
"by": "sage",
"at": "2026-10-21T20:53:25.208Z",
"state": "posted",
"comment": 30155,
"transport": {
"outcome": "posted",
"status": 201,
"comment": 30155,
"detail": "created"
},
"resolutions": []
}
],
"duplicateRisk": false,
"receipts": [
{
"reviewer": "duckworth",
"op": "duckworth-0098-record",
"at": "2026-10-21T20:54:59.089Z",
"verdict": "approve",
"comment": 30156,
"candidate": "537d30aef70b6cd19ab7283ce2a702d9ac55bb179c60d6a55f50b45a0e5b149d"
}
]
}
]
},
"claim": {
"seat": "gander",
"op": "gander-196-start"
},
"note": null,
"claim": null,
"note": "Landed 7247d80c7 on 666173292: 39 manifest paths of 527b9a1e6 (CANDIDATE.sha256 537d30ae), patch-id 6f3777d593e6 matches f48725cfc..527b9a1e6 on the manifest, blob diff empty, tree matches merge-tree. Gate gander-0066 2334/0/1, all 10 shells, 38/38 mutants. Approved rev 2973 and rev 2986 (Duckworth, c30156). Overlap: 202 cli README and 194 Honker branches rebase onto it. BUILD-LOG and C1 DEFERRED follow doc-only.",
"blockedReason": null,
"createdAt": "2026-10-20T19:01:13.621Z",
"updatedAt": "2026-10-21T20:17:46.980Z",
"updatedBy": "gander"
"updatedAt": "2026-10-21T20:58:33.437Z",
"updatedBy": "sage"
},
{
"id": 197,
@@ -25085,7 +25136,7 @@
"closes": [
1757
],
"state": "briefed",
"state": "in-review",
"previousState": null,
"required": false,
"requiredSince": null,
@@ -25105,12 +25156,63 @@
"reviewers": [
"webby"
],
"review": null,
"claim": null,
"review": {
"rounds": [
{
"n": 1,
"op": "morgana-244-inreview-r1",
"by": "morgana",
"at": "2026-10-21T20:49:44.772Z",
"issue": 1757,
"candidate": {
"kind": "manifest",
"digest": "6e7e3e3c290fe5034cc8766b6dc7eeb3725a99f330837a357c66381d18be1b60",
"text": "87e5deae3021116da0e78ce9c47bc2899dbcbc4d7207b8b293400d9b1549807c agents/morgana/work/queue-244/BUILD.md\n75d83e2cd7bc7173a0b8175600df819fb2bdf522d3930b265a78d4fcd31186d1 packages/cli/tests/gate-lane-auth.test.mjs\n5063d9458b2bf61ae85e8470f713f326a2ae1e19e6dd85f71ba531782b312fa8 packages/cli/tests/gate-lane-image.test.mjs\nbe706dff832257bd4781abeabd19faa4ac16edf9a4af0c96457e33cf3bb6deb3 scripts/test-release.sh\n10c4abb70bf9b83e617089b06a5436cc7fa6b866a176458b22128c3a15163f94 scripts/test-task.sh\nb488c7f275721e3e7b93d02a91345c0f5af99be0a5aed2e3f171d46ceca24521 scripts/testing/README.md\n8c5b08b136eda3964d8729ef646ff09f028c8348eded3bb6f79fb3fdd101bee7 scripts/testing/gate-lane.mjs\n10e87fcc9bce0cead8ff27af27cc6e0910820cd15da8e449cb5c07e379392e75 scripts/testing/lane-docker.mjs\n"
},
"request": "comment",
"attempts": [
{
"op": "morgana-244-inreview-r1",
"by": "morgana",
"at": "2026-10-21T20:49:44.772Z",
"state": "failed",
"comment": null,
"transport": {
"outcome": "failed",
"status": null,
"comment": null,
"detail": "pre-send: the credential or account check failed"
},
"resolutions": []
},
{
"op": "sage-244-review-r1b",
"by": "sage",
"at": "2026-10-21T20:52:11.784Z",
"state": "posted",
"comment": 30154,
"transport": {
"outcome": "posted",
"status": 201,
"comment": 30154,
"detail": "created"
},
"resolutions": []
}
],
"duplicateRisk": false,
"receipts": []
}
]
},
"claim": {
"seat": "morgana",
"op": "morgana-244-start"
},
"note": null,
"blockedReason": null,
"createdAt": "2026-10-21T09:15:58.616Z",
"updatedAt": "2026-10-21T19:33:20.763Z",
"updatedAt": "2026-10-21T20:52:44.248Z",
"updatedBy": "sage"
},
{
@@ -25709,6 +25811,41 @@
],
"duplicateRisk": false,
"receipts": []
},
{
"n": 2,
"op": "darkwing-251-inreview-r2",
"by": "darkwing",
"at": "2026-10-21T21:10:40.372Z",
"issue": 1756,
"candidate": {
"kind": "manifest",
"digest": "f2bdd68461976b682df8ae0d7cf0e01d64eebf90162eafb1e3a505956c8ce278",
"text": "e7d64f42fcf125d3a170cc00d749a5c6d0102f2130bd7c4bbe7f8c0ce8aef51c agents/darkwing/work/queue-251/BUILD.md\n280132c727bb3f9f392f6e95a6ff199efe947c4b40c1e8896640f75498cd6044 packages/bus/tests/host-run.test.mjs\n"
},
"request": "comment",
"attempts": [
{
"op": "darkwing-251-inreview-r2",
"by": "darkwing",
"at": "2026-10-21T21:10:40.372Z",
"state": "posted",
"comment": 30164,
"transport": null,
"resolutions": [
{
"verb": "resolve",
"op": "darkwing-251-resolve-r2",
"by": "darkwing",
"at": "2026-10-21T21:12:19.309Z",
"comment": 30164,
"reason": null
}
]
}
],
"duplicateRisk": false,
"receipts": []
}
]
},
@@ -25719,7 +25856,7 @@
"note": "mos-1185 item 2. If proof 6 fails outside 197's paths in a pair wave, this and row 250 go ahead of 197 (item 4).",
"blockedReason": null,
"createdAt": "2026-10-21T17:13:07.643Z",
"updatedAt": "2026-10-21T19:18:59.931Z",
"updatedAt": "2026-10-21T21:12:19.309Z",
"updatedBy": "darkwing"
},
{
@@ -25761,7 +25898,7 @@
"closes": [
1758
],
"state": "queued",
"state": "briefed",
"previousState": null,
"required": false,
"requiredSince": null,
@@ -25786,16 +25923,20 @@
"note": null,
"blockedReason": null,
"createdAt": "2026-10-21T19:47:54.542Z",
"updatedAt": "2026-10-21T19:47:54.542Z",
"updatedAt": "2026-10-21T21:11:12.638Z",
"updatedBy": "sage"
},
{
"id": 254,
"piece": "Library sync follow-up: client timeout, STATUS codes, README (222 deferrals, #1759)",
"owner": "megavolt",
"issues": [],
"closes": [],
"state": "queued",
"issues": [
1759
],
"closes": [
1759
],
"state": "briefed",
"previousState": null,
"required": false,
"requiredSince": null,
@@ -25820,7 +25961,7 @@
"note": "Dewey's deferrals from 222 approval c30138. Chained after 196 (human-cli channel proof). Issue #1759.",
"blockedReason": null,
"createdAt": "2026-10-21T20:17:15.497Z",
"updatedAt": "2026-10-21T20:17:15.497Z",
"updatedAt": "2026-10-21T21:11:45.908Z",
"updatedBy": "sage"
},
{
@@ -104262,6 +104403,453 @@
"receipt": "ok sage-r458-add rev 2976 row 255 none→queued"
},
"viewSha": "9b2b48147dff353bc92e8f47eb3d9bc0f248b2b382d7148d1591f1c8437cce04"
},
{
"rev": 2977,
"op": "morgana-244-start",
"verb": "move",
"args": {
"id": 244,
"to": "in-progress",
"reason": null,
"candidate": null,
"evidence": null,
"issue": null
},
"by": "morgana",
"at": "2026-10-21T20:49:10.163Z",
"semantics": 2,
"result": {
"row": 244,
"from": "briefed",
"to": "in-progress",
"round": null,
"issue": null,
"candidate": null,
"receipt": "ok morgana-244-start rev 2977 row 244 briefed→in-progress"
},
"viewSha": "1923cb487ec169ba5eeb58cdaba354b3a0f061136fddd3b99fc67706af18a64a"
},
{
"rev": 2978,
"op": "morgana-244-inreview-r1",
"verb": "move",
"args": {
"id": 244,
"to": "in-review",
"reason": null,
"candidate": "/mnt/storage/scratch/morgana-r244/candidate-manifest.sha256",
"evidence": null,
"issue": null
},
"by": "morgana",
"at": "2026-10-21T20:49:44.772Z",
"semantics": 2,
"result": {
"row": 244,
"from": "in-progress",
"to": "in-review",
"round": 1,
"issue": 1757,
"candidate": {
"kind": "manifest",
"digest": "6e7e3e3c290fe5034cc8766b6dc7eeb3725a99f330837a357c66381d18be1b60",
"text": "87e5deae3021116da0e78ce9c47bc2899dbcbc4d7207b8b293400d9b1549807c agents/morgana/work/queue-244/BUILD.md\n75d83e2cd7bc7173a0b8175600df819fb2bdf522d3930b265a78d4fcd31186d1 packages/cli/tests/gate-lane-auth.test.mjs\n5063d9458b2bf61ae85e8470f713f326a2ae1e19e6dd85f71ba531782b312fa8 packages/cli/tests/gate-lane-image.test.mjs\nbe706dff832257bd4781abeabd19faa4ac16edf9a4af0c96457e33cf3bb6deb3 scripts/test-release.sh\n10c4abb70bf9b83e617089b06a5436cc7fa6b866a176458b22128c3a15163f94 scripts/test-task.sh\nb488c7f275721e3e7b93d02a91345c0f5af99be0a5aed2e3f171d46ceca24521 scripts/testing/README.md\n8c5b08b136eda3964d8729ef646ff09f028c8348eded3bb6f79fb3fdd101bee7 scripts/testing/gate-lane.mjs\n10e87fcc9bce0cead8ff27af27cc6e0910820cd15da8e449cb5c07e379392e75 scripts/testing/lane-docker.mjs\n"
},
"receipt": "ok morgana-244-inreview-r1 rev 2978 row 244 in-progress→in-review round 1 on #1757; request morgana-244-inreview-r1 requesting"
},
"viewSha": "ffc26c20562fd80286b5d36f796d235f13c89fbc1b828b88187c1350de4abb0f"
},
{
"rev": 2979,
"op": "morgana-244-inreview-r1.outcome",
"verb": "review-outcome",
"args": {
"id": 244,
"attempt": "morgana-244-inreview-r1",
"outcome": "failed",
"status": null,
"comment": null,
"detail": "pre-send: the credential or account check failed"
},
"by": "morgana",
"at": "2026-10-21T20:50:16.649Z",
"semantics": 2,
"result": {
"row": 244,
"round": 1,
"attempt": "morgana-244-inreview-r1",
"state": "failed",
"receipt": "ok morgana-244-inreview-r1.outcome rev 2979 row 244 round 1 request morgana-244-inreview-r1 failed"
},
"viewSha": "39f8cf4a5854d75ba63b0706d8bffd312fe51821ce712a3f7d1c6aa787fecff2"
},
{
"rev": 2980,
"op": "gander-196-manifest-r6",
"verb": "move",
"args": {
"id": 196,
"to": "in-review",
"reason": null,
"candidate": "/home/jwoltje/.t3/worktrees/mosaic-stack/gander-row196/agents/gander/work/queue-196/CANDIDATE.sha256",
"evidence": null,
"issue": null
},
"by": "gander",
"at": "2026-10-21T20:51:08.099Z",
"semantics": 2,
"result": {
"row": 196,
"from": "in-progress",
"to": "in-review",
"round": 2,
"issue": 1694,
"candidate": {
"kind": "manifest",
"digest": "537d30aef70b6cd19ab7283ce2a702d9ac55bb179c60d6a55f50b45a0e5b149d",
"text": "0ad63c4f7a6cdd5b2587a0969fe7a95539cf1e4601f51fb33cc4df94a718b2b5 docs/TOOLS.md\n10c996d323ac46d45548f7748d40b246de93f8c5eacae9f951c6eeb5dcc62697 packages/bus/README.md\n60720461a7994011a9a30594715eb2c992be699cd61c656e77cbd609ad05328e packages/bus/fixtures/peer-client.mjs\n0c6d96abe4607cba0b02b0638a2fc217ff8135bac9a47761a0ef015fbe9d3079 packages/bus/src/human.mjs\nd5d98a9133a1b449acc48143fd559d890a470c6ea210b30b9b70e491e6cd855a packages/bus/src/interpreter.mjs\n29c88655e8b9aed8db604573c9213fd9f3c40ef35ff04f4eda8fbe8f1b8a32d7 packages/bus/src/peercred.mjs\nd1536a8f472fa5b3773adb10555529ed049ad63b647c0b44c00ce5f25ce09f85 packages/bus/src/peercred.py\n32ef39d80b523bdd586c29620d7a752c309c8e1aa70befc1414125468f3411dd packages/bus/src/principal.mjs\n190a4f56c44131f8a2325bc9c4a822556de432d1afd2e75ffe3168a171320bed packages/bus/src/runtime.mjs\n7be306c830aa98763b34b329995b40e9df443483fe20fcd69100a3acb6311bd6 packages/bus/src/server.mjs\ne99133c3c957a1e856e404343721add2430e4a0d8c75452517aef04942af56ab packages/bus/tests/channel-proof.test.mjs\nd19779b97e80ed3d62f58d0e798df46c23f45fa02b14fb4ac7a2ff4690a734e2 packages/bus/tests/interpreter.test.mjs\na39af877a2d9143cc8d0584af0d603ca7ab651db8787e5816579762f17295887 packages/bus/tests/peer-transport.test.mjs\n2c4c0bed41e546ce94535d8c4547b1cf5552a181183525dd1d52e2af1d64ccb6 packages/bus/tests/peercred.test.mjs\ncefbc0688d823c69196d94bb71467884acd639070721286d7e8e4e986c8240c1 packages/bus/tests/principal.test.mjs\n95c7acdb47f753d1e129adaa90b5937318d8d006ca47be9d92729fdae013776f packages/cli/README.md\n4bd7bdf0898844684ebf9444b110d2c5b6fa874b2681183e54f1f62f97823192 packages/cli/src/tester-http.mjs\na7cb20db2941b3b3abb09bcaa55eac92a1d420cf4ec79c6f83e4bb3235e04a2b packages/cli/tests/stack-release-fixture.mjs\n414d39e37d8e332761a6120ab728f67538e36082bf1f609eec7747bebdb434d4 packages/cli/tests/stack-release.test.mjs\nfbaa36e64129aee324341364bde831c379c098b97932cceadb7fcaa7909d4b20 packages/cli/tests/tester-http.test.mjs\n04b7c1165c4fa73137a72e8373d70fd7d96a5897b5511170e6d41908a7667e96 packages/webui/README.md\n934ab2f9b6bde72aafe7814758550c283eb25a4f3a23b284021b13718d67178f packages/webui/src/bus.mjs\n63dc2af0ff0c8f7c624268a78ffeede1593182a38d657a53e597338edf09226e packages/webui/src/cli.mjs\nda624f188c69e016ff82e1d1dcae1c579e68a57f0f25d773bb5d8bca56b80ee1 packages/webui/src/serve.mjs\n1c1fe0c795adac97f28502564a75f26d5666d9be5ae51169f7dd5bb7741fad16 packages/webui/src/tester-socket.mjs\n2f3ea92cbe1ca115301ee1e3b8fc55a206842606237446b700881ee57a1b18cb packages/webui/tests/browser.mjs\nf505b5fc635aa699556c237a92401b495943e45e25ebfadab945d64477e3e86e packages/webui/tests/library-cli.test.mjs\nedef361c4654ec010809c12c87c00197ec6e6b8337863068c4ffa144e291e300 packages/webui/tests/proof-cli.test.mjs\n5f34d699a1bce6f46f286def1a93fdd4d863d06bec6839677fae1041be7153dd packages/webui/tests/proof.test.mjs\n6457debc3d233a24643be00e4876e1129db664da2fbf5cc7168f3e371c46f313 packages/webui/tests/service.test.mjs\n3c1d2481eff2271c1c83f7ec2e9d616ac22600d032af23afc47f919c58f1b9ba packages/webui/tests/sso.test.mjs\n9215ff92df774ba1e83819b87f758b53c8f3d19c2824c916490165914f1cb9e8 packages/webui/tests/tester-browser-fixture.mjs\n89ffc5cbed1630a523080e8561d3678c1f781fb657199a5cd6cdb8189f082c73 packages/webui/tests/tester-browser.test.mjs\n23219434713fbd283a1e5cb1a373ed9d19d0963e171636dbf39bed3d60cd1316 packages/webui/tests/tester-cli.test.mjs\n7d4f3a59d21675807b1bfb92d40b7378134df450590bc6d42fa4103ab1343348 packages/webui/tests/tester-fixture.mjs\n7f0fc2daead11048cb86e0a74b6a223314785b1740a2927461d012b876a69f18 packages/webui/tests/tester-listener.test.mjs\nc01a942da1ad66a08a4e7c92d3c7e64f517530acfe5517f1e9895767d5ea3c0b packages/webui/tests/tester-socket.test.mjs\nb916a2793311d21e3ce7117e9b5e0768e6e33c841fa3aa39fb7ffc9c5974acd2 scripts/stack-release.sh\n6e7069cca9f7aef84c17f15611f2d7e0d09083a363c23da8dbd14d3ed29f0f40 scripts/webui-service.sh\n"
},
"receipt": "ok gander-196-manifest-r6 rev 2980 row 196 in-progress→in-review round 2 on #1694; request gander-196-manifest-r6 requesting"
},
"viewSha": "9f6ae73cc3c59e22f71ac340a7d09c5c9c4de226e3bf98f3bf45f33851a020a5"
},
{
"rev": 2981,
"op": "gander-196-manifest-r6.outcome",
"verb": "review-outcome",
"args": {
"id": 196,
"attempt": "gander-196-manifest-r6",
"outcome": "failed",
"status": null,
"comment": null,
"detail": "pre-send: the credential or account check failed"
},
"by": "gander",
"at": "2026-10-21T20:51:39.942Z",
"semantics": 2,
"result": {
"row": 196,
"round": 2,
"attempt": "gander-196-manifest-r6",
"state": "failed",
"receipt": "ok gander-196-manifest-r6.outcome rev 2981 row 196 round 2 request gander-196-manifest-r6 failed"
},
"viewSha": "f79bfeade3f582818a8ff4e8cd34ee5f9a8f54828d1c95d8bd62d227b34338e6"
},
{
"rev": 2982,
"op": "sage-244-review-r1b",
"verb": "review-request",
"args": {
"id": 244
},
"by": "sage",
"at": "2026-10-21T20:52:11.784Z",
"semantics": 2,
"result": {
"row": 244,
"round": 1,
"attempt": "sage-244-review-r1b",
"state": "requesting",
"receipt": "ok sage-244-review-r1b rev 2982 row 244 round 1 request sage-244-review-r1b requesting"
},
"viewSha": "88925213d32cb6b355fe6c54a293ff16f473a174aadb46b4aa1f196fabe36e1f"
},
{
"rev": 2983,
"op": "sage-244-review-r1b.outcome",
"verb": "review-outcome",
"args": {
"id": 244,
"attempt": "sage-244-review-r1b",
"outcome": "posted",
"status": 201,
"comment": 30154,
"detail": "created"
},
"by": "sage",
"at": "2026-10-21T20:52:44.248Z",
"semantics": 2,
"result": {
"row": 244,
"round": 1,
"attempt": "sage-244-review-r1b",
"state": "posted",
"receipt": "ok sage-244-review-r1b.outcome rev 2983 row 244 round 1 request sage-244-review-r1b posted comment 30154"
},
"viewSha": "4b3da809621219ffbff95bff6adf31f71440a496f04d5a3e506cf4bfb75eb444"
},
{
"rev": 2984,
"op": "sage-196-review-r2b",
"verb": "review-request",
"args": {
"id": 196
},
"by": "sage",
"at": "2026-10-21T20:53:25.208Z",
"semantics": 2,
"result": {
"row": 196,
"round": 2,
"attempt": "sage-196-review-r2b",
"state": "requesting",
"receipt": "ok sage-196-review-r2b rev 2984 row 196 round 2 request sage-196-review-r2b requesting"
},
"viewSha": "c1d882f65b6ed95b007c78b8a532a80a731e34dcf70ce5b3c85eafb3832a6efd"
},
{
"rev": 2985,
"op": "sage-196-review-r2b.outcome",
"verb": "review-outcome",
"args": {
"id": 196,
"attempt": "sage-196-review-r2b",
"outcome": "posted",
"status": 201,
"comment": 30155,
"detail": "created"
},
"by": "sage",
"at": "2026-10-21T20:53:57.304Z",
"semantics": 2,
"result": {
"row": 196,
"round": 2,
"attempt": "sage-196-review-r2b",
"state": "posted",
"receipt": "ok sage-196-review-r2b.outcome rev 2985 row 196 round 2 request sage-196-review-r2b posted comment 30155"
},
"viewSha": "cd92dcbc6f2c8a57380369e9c57dd09cf6db128545ff11e77fa246d80bfe614a"
},
{
"rev": 2986,
"op": "duckworth-0098-record",
"verb": "review-record",
"args": {
"id": 196,
"verdict": "approve",
"comment": 30156,
"candidate": "537d30aef70b6cd19ab7283ce2a702d9ac55bb179c60d6a55f50b45a0e5b149d"
},
"by": "duckworth",
"at": "2026-10-21T20:54:59.089Z",
"semantics": 2,
"result": {
"row": 196,
"round": 2,
"verdict": "approve",
"receipt": "ok duckworth-0098-record rev 2986 row 196 round 2 approve by duckworth (comment 30156)"
},
"viewSha": "204d0c2d64d27c6e3480f866cea0c2aa3657851fc56dd2ee2b3ecdd3cdb0ed1b"
},
{
"rev": 2987,
"op": "sage-196-landing-note",
"verb": "note",
"args": {
"id": 196,
"text": "Landed 7247d80c7 on 666173292: 39 manifest paths of 527b9a1e6 (CANDIDATE.sha256 537d30ae), patch-id 6f3777d593e6 matches f48725cfc..527b9a1e6 on the manifest, blob diff empty, tree matches merge-tree. Gate gander-0066 2334/0/1, all 10 shells, 38/38 mutants. Approved rev 2973 and rev 2986 (Duckworth, c30156). Overlap: 202 cli README and 194 Honker branches rebase onto it. BUILD-LOG and C1 DEFERRED follow doc-only."
},
"by": "sage",
"at": "2026-10-21T20:58:02.224Z",
"semantics": 2,
"result": {
"row": 196,
"field": "note",
"receipt": "ok sage-196-landing-note rev 2987 row 196 note"
},
"viewSha": "f6ee00477e8cb4bfea13eea5590c16b713eec9c485cc3c82da79cbbd53740473"
},
{
"rev": 2988,
"op": "sage-196-done",
"verb": "move",
"args": {
"id": 196,
"to": "done",
"reason": null,
"candidate": null,
"evidence": null,
"issue": null
},
"by": "sage",
"at": "2026-10-21T20:58:33.437Z",
"semantics": 2,
"result": {
"row": 196,
"from": "in-review",
"to": "done",
"round": 2,
"issue": null,
"candidate": null,
"receipt": "ok sage-196-done rev 2988 row 196 in-review→done round 2"
},
"viewSha": "785cd1b3d64c9fe685cef50753c9a04b5718ecc097d7199dfb9bc2cf27ae4361"
},
{
"rev": 2989,
"op": "sage-254-issue",
"verb": "set",
"args": {
"id": 254,
"field": "issues",
"value": [
1759
],
"reason": null
},
"by": "sage",
"at": "2026-10-21T21:09:30.787Z",
"semantics": 2,
"result": {
"row": 254,
"field": "issues",
"from": [],
"to": [
1759
],
"closes": {
"from": [],
"to": [
1759
],
"narrowed": false
},
"receipt": "ok sage-254-issue rev 2989 row 254 issues: none→#1759; closes: none→#1759"
},
"viewSha": "209ae6f110d6eeed579b3f795719a05351dc6c7f37c151227d73f8bf89d2a53e"
},
{
"rev": 2990,
"op": "darkwing-251-inprogress-r2",
"verb": "move",
"args": {
"id": 251,
"to": "in-progress",
"reason": null,
"candidate": null,
"evidence": null,
"issue": null
},
"by": "darkwing",
"at": "2026-10-21T21:10:03.811Z",
"semantics": 2,
"result": {
"row": 251,
"from": "in-review",
"to": "in-progress",
"round": null,
"issue": null,
"candidate": null,
"receipt": "ok darkwing-251-inprogress-r2 rev 2990 row 251 in-review→in-progress"
},
"viewSha": "ee26d40e917bd52bf2770819fe5b5c81a2d3ea9546cb8c2113af215bd40e9519"
},
{
"rev": 2991,
"op": "darkwing-251-inreview-r2",
"verb": "move",
"args": {
"id": 251,
"to": "in-review",
"reason": null,
"candidate": "/home/jwoltje/darkwing-scratch/r251/manifest-27f01ef63a6e9d2fcb7198ffc11ae9e389ab4da5.sha256",
"evidence": null,
"issue": null
},
"by": "darkwing",
"at": "2026-10-21T21:10:40.372Z",
"semantics": 2,
"result": {
"row": 251,
"from": "in-progress",
"to": "in-review",
"round": 2,
"issue": 1756,
"candidate": {
"kind": "manifest",
"digest": "f2bdd68461976b682df8ae0d7cf0e01d64eebf90162eafb1e3a505956c8ce278",
"text": "e7d64f42fcf125d3a170cc00d749a5c6d0102f2130bd7c4bbe7f8c0ce8aef51c agents/darkwing/work/queue-251/BUILD.md\n280132c727bb3f9f392f6e95a6ff199efe947c4b40c1e8896640f75498cd6044 packages/bus/tests/host-run.test.mjs\n"
},
"receipt": "ok darkwing-251-inreview-r2 rev 2991 row 251 in-progress→in-review round 2 on #1756; request darkwing-251-inreview-r2 requesting"
},
"viewSha": "f05f76b194c6b73232db4892921f4b56c310d5b493981287dd8a4217b23edec3"
},
{
"rev": 2992,
"op": "sage-253-briefed",
"verb": "move",
"args": {
"id": 253,
"to": "briefed",
"reason": null,
"candidate": null,
"evidence": null,
"issue": null
},
"by": "sage",
"at": "2026-10-21T21:11:12.638Z",
"semantics": 2,
"result": {
"row": 253,
"from": "queued",
"to": "briefed",
"round": null,
"issue": null,
"candidate": null,
"receipt": "ok sage-253-briefed rev 2992 row 253 queued→briefed"
},
"viewSha": "48947ebf2f45373f6443fbe62f43616b8c2dba7e3ed3d5134ae8e80383063658"
},
{
"rev": 2993,
"op": "sage-254-briefed",
"verb": "move",
"args": {
"id": 254,
"to": "briefed",
"reason": null,
"candidate": null,
"evidence": null,
"issue": null
},
"by": "sage",
"at": "2026-10-21T21:11:45.908Z",
"semantics": 2,
"result": {
"row": 254,
"from": "queued",
"to": "briefed",
"round": null,
"issue": null,
"candidate": null,
"receipt": "ok sage-254-briefed rev 2993 row 254 queued→briefed"
},
"viewSha": "ec736c1e91f42fc9e2141454630e1664000d2be2562c03052d04e08015e32af7"
},
{
"rev": 2994,
"op": "darkwing-251-resolve-r2",
"verb": "review-resolve",
"args": {
"id": 251,
"attempt": "darkwing-251-inreview-r2",
"comment": 30164
},
"by": "darkwing",
"at": "2026-10-21T21:12:19.309Z",
"semantics": 2,
"result": {
"row": 251,
"round": 2,
"attempt": "darkwing-251-inreview-r2",
"state": "posted",
"receipt": "ok darkwing-251-resolve-r2 rev 2994 row 251 round 2 request darkwing-251-inreview-r2 requesting→posted comment 30164"
},
"viewSha": "ad44355179caf42d60dc72ac782bfb1abe147df011c93639df2a727f20c9832b"
}
]
}
+24 -5
View File
@@ -458,17 +458,36 @@ test('T27: ssh-add -t drops the key from an agent the test keeps alive', async (
const agent = spawn('ssh-agent', ['-D', '-a', sock], { stdio: 'ignore' });
t.after(() => agent.kill('SIGKILL'));
assert.ok(await until(() => existsSync(sock)));
await addKey(sock, readFileSync(join(dir, 'k'), 'utf8'), 1);
const list = () => {
// A failed list names its cause: when each step ran on the monotonic clock (hrtime) and on
// /proc/uptime (CLOCK_BOOTTIME, which ssh-agent's lifetime counts in whole seconds), and
// whether the agent and its socket were still there.
const marks = [];
const mark = (name) => marks.push({ name, ns: process.hrtime.bigint(), uptime: readFileSync('/proc/uptime', 'utf8').split(' ')[0] });
const timing = () => {
const at = (m) => `${m.name} +${(Number(m.ns - marks[0].ns) / 1e6).toFixed(1)} ms (hrtime ${(Number(m.ns % 1_000_000_000n) / 1e9).toFixed(4)} s into its second, uptime ${m.uptime})`;
const alive = agent.exitCode === null && agent.signalCode === null;
return `${marks.map(at).join('; ')}; agent ${alive ? 'alive' : `gone (${agent.exitCode ?? agent.signalCode})`}, socket ${existsSync(sock) ? 'present' : 'missing'}`;
};
// ssh-agent sets a key's death to the whole second of CLOCK_BOOTTIME at the add plus the
// lifetime, and drops it once that whole second is reached. So `-t N` keeps a key between N - 1
// and N seconds: `-t 1` added just before a second boundary was gone within milliseconds (#453).
// `-t 2` keeps it at least 1 s, and the sleep waits past the 2 s ceiling.
mark('addKey start');
await addKey(sock, readFileSync(join(dir, 'k'), 'utf8'), 2);
mark('addKey resolved');
const list = (name) => {
mark(name);
try {
return execFileSync('ssh-add', ['-l'], { env: { PATH: '/usr/bin:/bin', SSH_AUTH_SOCK: sock }, encoding: 'utf8' });
} catch (e) {
return e.stdout;
}
};
assert.match(list(), /SHA256:/);
await sleep(2500);
assert.match(list(), /no identities/);
const first = list('first list');
assert.match(first, /SHA256:/, `first list got ${JSON.stringify(first)}: ${timing()}`);
await sleep(3500);
const second = list('second list');
assert.match(second, /no identities/, `second list got ${JSON.stringify(second)}: ${timing()}`);
});
// The test's own limit makes a hung addKey (M37a, M37b) fail in seconds, not at the batch timeout.
+401
View File
@@ -0,0 +1,401 @@
// Case 2 row C2-13b (#1727): scripts/case2-install.sh installs a hub as <user>, and
// scripts/case2-precheck.sh's library checks and --library-status mode. Brief:
// docs/plans/2026-10-20_case2-rows.md "C2-13b". Every case runs in a scratch home with its own
// MOSAIC_CONFIG, a stub stack-copy and fake getent, sudo, id, node, bwrap and systemctl. The library
// sync runs in-process against a local bare repo, as the bus would. Nothing here reads or writes
// ~/.mosaic-dev, ~/.config/mosaic-dev, /etc or a network host, and no fixture value is a credential.
import { test } from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { businessDoc, writeJson } from "../../business/tests/helpers.mjs";
import { BASE, MARKER, git, mappingEntry, repo } from "../../library/tests/helpers.mjs";
import { ensureMirror, mirrorPath, sync } from "../../library/src/index.mjs";
import { gitEnv } from "../../seat/src/checkout.mjs";
import { SAFE } from "../../project/src/git.mjs";
import { tmp } from "./helpers.mjs";
const REPO = fileURLToPath(new URL("../../../", import.meta.url));
const INSTALL = join(REPO, "scripts", "case2-install.sh");
const PRECHECK = join(REPO, "scripts", "case2-precheck.sh");
const COMMIT = "c".repeat(40);
const UID = process.getuid();
const GID = process.getgid();
// Made-up values; no output may hold them.
const FIXTURE_KEY = "fixture-zai-key-8d41a7";
const PERSONA_TEXT = { bean: "Bean is warm and brief. PERSONA-TEXT-bean-61f0\n", chet: "Chet is dry and exact. PERSONA-TEXT-chet-2b9e\n" };
const sha256 = (data) => createHash("sha256").update(data).digest("hex");
const mode = (p) => statSync(p).mode & 0o777;
function script(file, text) {
mkdirSync(dirname(file), { recursive: true });
writeFileSync(file, `#!/usr/bin/env bash\n${text}\n`);
chmodSync(file, 0o755);
return file;
}
// A scratch <user>: a home, an input business file, a persona staging dir and fakes.
function world(t, { profile = "library" } = {}) {
const root = tmp(t, "case2-install-");
const home = join(root, "home");
const configDir = join(home, ".config", "mosaic-dev");
// claude-session.env is in place before the install, as 2d would leave it on a re-run.
const secrets = join(configDir, "secrets");
mkdirSync(secrets, { recursive: true, mode: 0o700 });
chmodSync(configDir, 0o700);
writeFileSync(join(secrets, "claude-session.env"), `ZAI_API_KEY=${FIXTURE_KEY}\n`, { mode: 0o600 });
const doc = businessDoc(root);
for (const r of Object.values(doc.roles)) r.vars = { ...r.vars, harness: "pi" };
if (profile === "library") {
doc.projects.books = { ...mappingEntry(), user: "writer" };
doc.roles.bean = { definition: "companion", vars: { harness: "pi" } };
doc.roles.chet = { definition: "companion", vars: { harness: "pi" } };
} else doc.vars["bus.tester"] = true;
const input = writeJson(join(root, "input", "acme.json"), doc);
const staging = join(root, "staging");
mkdirSync(staging, { mode: 0o700 });
const personas = [];
for (const [instance, name] of [["bean", "Bean"], ["chet", "Chet"]]) {
writeFileSync(join(staging, `${instance}.md`), PERSONA_TEXT[instance], { mode: 0o600 });
personas.push({ instance, name, bytes: Buffer.byteLength(PERSONA_TEXT[instance]), sha256: sha256(PERSONA_TEXT[instance]) });
}
writeFileSync(join(staging, "manifest.json"), `${JSON.stringify({ version: 1, personas }, null, 2)}\n`, { mode: 0o600 });
const bin = join(root, "bin");
const log = join(root, "calls.log");
script(join(bin, "stack-copy"), `echo "stack-copy $*" >>'${log}'
[ "$1" = copy ] && [ "$3" = --copies ] || exit 4
mkdir -p "$4/$2/scripts" && : >"$4/$2/scripts/mosaic" && echo "copied: $4/$2"`);
script(join(bin, "systemctl"), `echo "systemctl $*" >>'${log}'`);
const me = spawnSync("id", ["-un"], { encoding: "utf8" }).stdout.trim();
script(join(bin, "getent"), `[ "$1" = passwd ] && [ "$2" = '${me}' ] && echo '${me}:x:${UID}:${GID}::${home}:/bin/bash'`);
script(join(bin, "sudo"), "exit 1");
script(join(bin, "id"), `if [ "$1" = -G ]; then echo '${GID}'; elif [ "$1" = -u ] && [ -n "\${CASE2_TEST_UID:-}" ]; then echo "$CASE2_TEST_UID"; else exec /usr/bin/id "$@"; fi`);
script(join(bin, "node"), `if [ "$1" = --version ]; then echo v24.3.0; exit 0; fi\nexec '${process.execPath}' "$@"`);
script(join(bin, "bwrap"), "exit 0");
const units = join(root, "units");
mkdirSync(units);
writeFileSync(join(units, "mosaic-webui.service"), "[Service]\nExecStart=/usr/bin/node /x/cli.mjs serve --port 443 --business acme\n");
writeFileSync(join(units, "[email protected]"), "[Service]\nEnvironment=PATH=/usr/bin\n");
const sys = join(root, "sys");
mkdirSync(join(sys, "net", "ipv4"), { recursive: true });
mkdirSync(join(sys, "kernel"), { recursive: true });
writeFileSync(join(sys, "net", "ipv4", "ip_unprivileged_port_start"), "1024\n");
writeFileSync(join(sys, "kernel", "apparmor_restrict_unprivileged_userns"), "1\n");
return {
root, home, configDir, secrets, input, staging, bin, log, units, sys, doc,
config: join(configDir, "config.json"),
business: join(configDir, "businesses", "acme.json"),
personaDir: join(configDir, "personas", "acme"),
slot: join(secrets, "acme"),
dataRoot: join(home, ".mosaic-dev"),
};
}
function env(x, extra = {}) {
return { PATH: `${x.bin}:/usr/bin:/bin`, HOME: x.home, MOSAIC_CONFIG: x.config, CASE2_STACK_COPY: join(x.bin, "stack-copy"), ...extra };
}
function clean(out) {
assert.doesNotMatch(out, new RegExp(FIXTURE_KEY), "the output holds the fixture key");
assert.doesNotMatch(out, /PERSONA-TEXT/, "the output holds persona text");
assert.doesNotMatch(out, new RegExp(MARKER), "the output holds her text");
}
function install(x, { profile = "library", args = null, extra = {} } = {}) {
const argv = args ?? ["--business", "acme", "--business-file", x.input, "--commit", COMMIT, "--profile", profile,
...(profile === "library" ? ["--personas", x.staging] : [])];
const r = spawnSync(INSTALL, argv, { encoding: "utf8", timeout: 120000, env: env(x, extra) });
const out = r.stdout + r.stderr;
clean(out);
return { code: r.status, out };
}
function precheck(x, { args = ["--business", "acme", "--profile", "library"] } = {}) {
const r = spawnSync(PRECHECK, args, {
encoding: "utf8",
timeout: 60000,
env: env(x, { CASE2_UNIT_DIR: x.units, CASE2_PROC_SYS: x.sys, CASE2_GETENT: join(x.bin, "getent") }),
});
const out = r.stdout + r.stderr;
clean(out);
return { code: r.status, out };
}
const status = (x) => precheck(x, { args: ["--business", "acme", "--library-status"] });
function refused(r, pattern) {
assert.equal(r.code, 1, r.out);
assert.match(r.out, pattern);
}
const calls = (x) => (existsSync(x.log) ? readFileSync(x.log, "utf8") : "");
// ---- case2-install.sh ----
test("the library install writes the business file, personas and slot dir, makes the copy and starts nothing", (t) => {
const x = world(t);
const r = install(x);
assert.equal(r.code, 0, r.out);
assert.equal(mode(x.business), 0o600);
const doc = JSON.parse(readFileSync(x.business, "utf8"));
for (const [instance, name] of [["bean", "Bean"], ["chet", "Chet"]]) {
assert.equal(doc.roles[instance].vars["persona.name"], name);
assert.equal(doc.roles[instance].vars["persona.sha256"], sha256(PERSONA_TEXT[instance]));
assert.equal(doc.roles[instance].vars.harness, "pi");
const file = join(x.personaDir, `${instance}.md`);
assert.equal(readFileSync(file, "utf8"), PERSONA_TEXT[instance]);
assert.equal(mode(file), 0o600);
}
assert.equal(mode(join(x.configDir, "personas")), 0o700);
assert.equal(mode(x.personaDir), 0o700);
assert.equal(mode(x.slot), 0o700);
assert.deepEqual(readdirSync(x.slot), []);
assert.equal(calls(x), `stack-copy copy ${COMMIT} --copies ${join(x.home, ".local", "share", "mosaic-stack", "copies")}\n`);
assert.doesNotMatch(r.out, /release/);
assert.match(r.out, /business acme validates/);
assert.match(r.out, /--library-status/);
assert.ok(existsSync(x.config));
});
test("a library re-run passes and leaves every file as it was", (t) => {
const x = world(t);
assert.equal(install(x).code, 0);
const before = readFileSync(x.business);
const r = install(x);
assert.equal(r.code, 0, r.out);
assert.match(r.out, /already matches/);
assert.match(r.out, /personas already installed/);
assert.deepEqual(readFileSync(x.business), before);
});
test("a re-run refuses business-file-differs and leaves the file unchanged", (t) => {
const x = world(t);
assert.equal(install(x).code, 0);
const doc = JSON.parse(readFileSync(x.business, "utf8"));
doc.vars["tracker.pollSeconds"] = 120;
writeJson(x.business, doc);
const before = readFileSync(x.business);
refused(install(x), /business-file-differs/);
assert.deepEqual(readFileSync(x.business), before);
});
test("a re-run refuses persona-differs when an installed persona isn't the staged one, and writes nothing", (t) => {
const x = world(t);
assert.equal(install(x).code, 0);
rmSync(x.business);
writeFileSync(join(x.personaDir, "bean.md"), "Someone else. PERSONA-TEXT-other\n");
refused(install(x), /persona-differs/);
assert.equal(existsSync(x.business), false);
});
test("the factory install writes no library mapping, persona or slot dir", (t) => {
const x = world(t, { profile: "factory" });
const r = install(x, { profile: "factory" });
assert.equal(r.code, 0, r.out);
const doc = JSON.parse(readFileSync(x.business, "utf8"));
assert.equal(Object.values(doc.projects).some((p) => p.kind === "library"), false);
assert.equal(existsSync(join(x.configDir, "personas")), false);
assert.equal(existsSync(x.slot), false);
assert.doesNotMatch(r.out, /--library-status/);
});
test("the factory install refuses a library project and bus.tester off, before anything is written", (t) => {
const x = world(t, { profile: "factory" });
const doc = JSON.parse(readFileSync(x.input, "utf8"));
doc.projects.books = { ...mappingEntry(), user: "writer" };
writeJson(x.input, doc);
refused(install(x, { profile: "factory" }), /profile-library-business/);
delete doc.projects.books;
doc.vars["bus.tester"] = false;
writeJson(x.input, doc);
refused(install(x, { profile: "factory" }), /bus-tester-off/);
assert.equal(calls(x), "");
assert.equal(existsSync(x.business), false);
});
test("the library install refuses bus.tester on and a persona var in the input", (t) => {
const x = world(t);
const doc = JSON.parse(readFileSync(x.input, "utf8"));
doc.vars["bus.tester"] = true;
writeJson(x.input, doc);
refused(install(x), /bus-tester-on/);
doc.vars["bus.tester"] = false;
doc.roles.bean.vars["persona.name"] = "Bean";
writeJson(x.input, doc);
refused(install(x), /persona-var-input/);
assert.equal(calls(x), "");
});
test("the install refuses root, a business id mismatch, another --home and a config dir with group bits", (t) => {
const x = world(t);
refused(install(x, { extra: { CASE2_TEST_UID: "0" } }), /run-as-root/);
refused(install(x, { args: ["--business", "other", "--business-file", x.input, "--commit", COMMIT, "--personas", x.staging] }), /business-id-mismatch/);
refused(install(x, { args: ["--business", "acme", "--business-file", x.input, "--commit", COMMIT, "--personas", x.staging, "--home", x.root] }), /home-mismatch/);
chmodSync(x.configDir, 0o750);
refused(install(x), /config-dir-mode/);
assert.equal(calls(x), "");
assert.equal(existsSync(x.business), false);
});
test("the library install needs --personas", (t) => {
const x = world(t);
const r = install(x, { args: ["--business", "acme", "--business-file", x.input, "--commit", COMMIT] });
assert.equal(r.code, 4, r.out);
assert.match(r.out, /needs --personas/);
});
test("a validate refusal removes the business file this run wrote", (t) => {
const x = world(t);
unlinkSync(x.doc.roles.pm.credentials.gitea.file);
const r = install(x);
refused(r, /validate: .*the business file this run wrote is removed/);
assert.equal(existsSync(x.business), false);
});
test("N4: a validate refusal on a re-run leaves the matching business file in place, byte for byte", (t) => {
const x = world(t);
assert.equal(install(x).code, 0);
const before = readFileSync(x.business);
unlinkSync(x.doc.roles.pm.credentials.gitea.file);
const r = install(x);
refused(r, /validate: /);
assert.doesNotMatch(r.out, /is removed/);
assert.deepEqual(readFileSync(x.business), before);
});
// ---- case2-precheck.sh, the library profile (D6a) ----
test("a clean library host passes the precheck with no mirror yet", (t) => {
const x = world(t);
assert.equal(install(x).code, 0);
const r = precheck(x);
assert.equal(r.code, 0, r.out);
assert.match(r.out, /ok {6}business file .* with no group or other bit/);
assert.match(r.out, /ok {6}no history for acme yet/);
assert.match(r.out, new RegExp(`ok {6}persona bean sha256 ${sha256(PERSONA_TEXT.bean)}`));
assert.match(r.out, new RegExp(`ok {6}persona chet sha256 ${sha256(PERSONA_TEXT.chet)}`));
assert.match(r.out, /ok {6}no Discord binding/);
assert.equal(existsSync(mirrorPath({ business: { id: "acme", projects: { books: x.doc.projects.books } }, project: "books", dataRoot: x.dataRoot })), false);
});
test("the library precheck refuses a persona file at 0644", (t) => {
const x = world(t);
assert.equal(install(x).code, 0);
chmodSync(join(x.personaDir, "bean.md"), 0o644);
refused(precheck(x), /REFUSED persona-mode: .*bean\.md must be .* 0600 file \(mode 644\)/);
});
test("the library precheck refuses a persona whose digest isn't persona.sha256", (t) => {
const x = world(t);
assert.equal(install(x).code, 0);
writeFileSync(join(x.personaDir, "chet.md"), "Not Chet. PERSONA-TEXT-other\n");
refused(precheck(x), /REFUSED persona-mismatch: persona chet has sha256 [0-9a-f]{64}, and persona.sha256 is /);
});
test("the library precheck refuses a persona dir with group bits", (t) => {
const x = world(t);
assert.equal(install(x).code, 0);
chmodSync(x.personaDir, 0o750);
refused(precheck(x), /REFUSED persona-mode: .*personas\/acme must be .* 0700 directory/);
});
test("the library precheck refuses a business file at 0644", (t) => {
const x = world(t);
assert.equal(install(x).code, 0);
chmodSync(x.business, 0o644);
refused(precheck(x), /REFUSED business-file-mode: .*acme\.json .*\(mode 644\)/);
});
test("the library precheck refuses a changes.jsonl at 0644", (t) => {
const x = world(t);
assert.equal(install(x).code, 0);
const history = join(x.configDir, "businesses", ".history", "acme");
mkdirSync(history, { recursive: true, mode: 0o700 });
writeFileSync(join(history, "changes.jsonl"), "", { mode: 0o644 });
chmodSync(join(history, "changes.jsonl"), 0o644);
refused(precheck(x), /REFUSED history-mode: .*changes\.jsonl must be .* 0600 file \(mode 644\)/);
chmodSync(join(history, "changes.jsonl"), 0o600);
assert.equal(precheck(x).code, 0);
});
test("the library precheck refuses a Discord binding", (t) => {
const x = world(t);
assert.equal(install(x).code, 0);
mkdirSync(join(x.dataRoot, "discord"), { recursive: true });
writeFileSync(join(x.dataRoot, "discord", "her.json"), "{}\n", { mode: 0o600 });
refused(precheck(x), /REFUSED discord-binding: .*her\.json exists/);
});
// ---- case2-precheck.sh --library-status (D6b) ----
// The context the bus builds for books (runtime.mjs:161-164), and a sync the way the bus's timer
// runs it, against a local bare clone through the command parameter.
function library(x) {
const context = { business: { id: "acme", projects: { books: x.doc.projects.books } }, project: "books", dataRoot: x.dataRoot };
mkdirSync(x.dataRoot, { recursive: true, mode: 0o700 });
chmodSync(x.dataRoot, 0o700);
const run = (bare, slot) => sync(context, {
slot,
command: (kind, url, { mirror, branch }) => ({
argv: [...SAFE, "-c", "protocol.file.allow=always", "-c", "core.createObject=rename", "-C", mirror, "fetch", "--no-tags",
"--no-write-fetch-head", "--end-of-options", bare, `+refs/heads/${branch}:refs/remotes/library/${branch}`],
env: { ...gitEnv(), LC_ALL: "C", GIT_CEILING_DIRECTORIES: dirname(mirror) },
}),
});
return { context, run };
}
test("--library-status refuses library-mirror-missing before the bus has made the mirror", (t) => {
const x = world(t);
assert.equal(install(x).code, 0);
refused(status(x), /REFUSED library-mirror-missing: library books has no mirror at .*the bus makes it when it starts/);
});
test("--library-status refuses library-never-synced on a fresh mirror", async (t) => {
const x = world(t);
assert.equal(install(x).code, 0);
await ensureMirror(library(x).context);
const r = status(x);
refused(r, /REFUSED library-never-synced: library books has no fetch yet/);
assert.doesNotMatch(r.out, /library-last-error/);
});
test("--library-status names a recorded lastError before the null lastFetch it leaves (N3)", async (t) => {
const x = world(t);
assert.equal(install(x).code, 0);
const lib = library(x);
await lib.run("/nonexistent", join(x.slot, "library-gitea"));
const r = status(x);
refused(r, /REFUSED library-last-error: library books: library-credential-missing at \d{4}-\d\d-\d\dT/);
assert.doesNotMatch(r.out, /library-never-synced/);
});
test("--library-status prints each R-problem's path as an escaped JSON string, then the count, and passes", async (t) => {
const x = world(t);
assert.equal(install(x).code, 0);
const odd = "Odd‮Name\u0085.md";
const f = repo(t, { ...BASE, [odd]: `${MARKER}\n` });
const bare = join(f.root, "remote.git");
git(f.root, "clone", "-q", "--bare", "--no-local", f.repo, bare);
writeFileSync(join(x.slot, "library-gitea"), "placeholder-not-a-token\n", { mode: 0o600 });
const { state } = await library(x).run(bare, join(x.slot, "library-gitea"));
assert.equal(state.lastError, null);
const r = status(x);
assert.equal(r.code, 0, r.out);
assert.match(r.out, new RegExp(`ok {6}library books last fetched \\S+, serving ${f.commit}`));
assert.match(r.out, /^problem {3}R2 "Inbox Note\.md"$/m);
assert.match(r.out, /^problem {3}R\d+ "Odd\\u202eName\\u0085\.md"$/m);
assert.doesNotMatch(r.out, /[\u0085‮]/);
const lines = r.out.split("\n").filter((l) => l.startsWith("problem "));
assert.equal(lines.length, state.problems.length);
assert.match(r.out, new RegExp(`^problems ${state.problems.length}$`, "m"));
assert.match(r.out, /case2-precheck: library status passed/);
});
test("--library-status takes no --profile", (t) => {
const x = world(t);
const r = precheck(x, { args: ["--business", "acme", "--library-status", "--profile", "library"] });
assert.equal(r.code, 4, r.out);
});
+228
View File
@@ -0,0 +1,228 @@
#!/usr/bin/env bash
# Case 2 row C2-13b (design 10 step 2b): a hub's install, run as <user>
# after root's 2a and before root's 2c renders the units.
#
# case2-install.sh --business ID --business-file FILE --commit SHA
# [--profile factory|library] [--personas DIR] [--home DIR]
#
# Steps, in order: refuse root; scripts/bootstrap.sh; stack-copy.sh copy
# SHA, whose npm ci installs the pinned harness; for library, the persona
# import's install from the staging DIR; the business file at 0600; for
# library, the empty 0700 slot dir <configDir>/secrets/<id>/; `mosaic
# business validate`; then the next steps. It runs no release package or
# activate, starts no unit, writes no unit file and nothing under /etc, and
# never writes the library token, which is Jason's step 4.
#
# FILE is the business document written for the host from the go. The
# script adds only roles.bean.vars and roles.chet.vars persona.name and
# persona.sha256, from the staging manifest, and checks the input against
# the precheck's profile rules first: library needs a library project,
# bus.tester off and bean and chet as companion roles with no persona var;
# factory needs no library project and bus.tester on.
#
# Every refusal it can see before writing comes first, so a refused run
# writes nothing. A re-run passes when the business file and personas on
# disk match what it would write, and refuses business-file-differs or
# persona-differs when they don't, touching neither. If validate refuses,
# the business file goes only when this run created it.
#
# --home must be $HOME. The config dir is dirname of $MOSAIC_CONFIG, or
# <home>/.config/mosaic-dev; the install makes it 0700 when absent and
# refuses config-dir-mode when it has a group or other bit.
#
# Test seam, for packages/cli/tests/case2-install.test.mjs only:
# CASE2_STACK_COPY the copy script, default scripts/stack-copy.sh
#
# Exit 0 ok, 1 refused, 4 usage.
set -uo pipefail
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
STACK_COPY="${CASE2_STACK_COPY:-$REPO/scripts/stack-copy.sh}"
usage() { echo 'usage: case2-install.sh --business ID --business-file FILE --commit SHA [--profile factory|library] [--personas DIR] [--home DIR]' >&2; exit 4; }
PROFILE=library BUSINESS='' BFILE='' COMMIT='' PERSONAS='' HOME_ARG="${HOME:-}"
while [ $# -gt 0 ]; do
case "$1" in
--business) [ $# -ge 2 ] || usage; BUSINESS="$2"; shift 2 ;;
--business-file) [ $# -ge 2 ] || usage; BFILE="$2"; shift 2 ;;
--commit) [ $# -ge 2 ] || usage; COMMIT="$2"; shift 2 ;;
--profile) [ $# -ge 2 ] || usage; PROFILE="$2"; shift 2 ;;
--personas) [ $# -ge 2 ] || usage; PERSONAS="$2"; shift 2 ;;
--home) [ $# -ge 2 ] || usage; HOME_ARG="$2"; shift 2 ;;
*) usage ;;
esac
done
[ -n "$BUSINESS" ] && [ -n "$BFILE" ] && [ -n "$COMMIT" ] || usage
case "$PROFILE" in factory|library) ;; *) echo "case2-install: --profile must be factory or library: $PROFILE" >&2; exit 4 ;; esac
[[ "$BUSINESS" =~ ^[a-z0-9][a-z0-9-]{0,63}$ ]] || { echo "case2-install: --business must be a business id: $BUSINESS" >&2; exit 4; }
[[ "$COMMIT" =~ ^[0-9a-f]{40}$ ]] || { echo "case2-install: --commit must be a full 40-character commit id: $COMMIT" >&2; exit 4; }
if [ "$PROFILE" = library ] && [ -z "$PERSONAS" ]; then echo 'case2-install: the library profile needs --personas DIR' >&2; exit 4; fi
if [ "$PROFILE" = factory ] && [ -n "$PERSONAS" ]; then echo 'case2-install: the factory profile takes no --personas' >&2; exit 4; fi
say() { echo "case2-install: $1"; }
refuse() { echo "case2-install: refused: $1" >&2; exit 1; }
mode_of() { stat -c '%a' -- "$1"; }
# An existing directory, not a link, owned by this uid, with no group or other bit.
private_dir() { [ ! -L "$1" ] && [ -d "$1" ] && [ "$(stat -c '%u' -- "$1")" = "$(id -u)" ] && [ $((8#$(mode_of "$1") & 8#077)) -eq 0 ]; }
# An absent path, or a private directory; anything else refuses CODE.
absent_or_private() {
if [ ! -e "$1" ] && [ ! -L "$1" ]; then return 0; fi
private_dir "$1" || refuse "$2: $1 must be $(id -un)'s directory with no group or other bit"
}
# 1. Never as root: root owns nothing under <home> here.
[ "$(id -u)" != 0 ] || refuse 'run-as-root: run the install as <user>, not root'
[ -n "${HOME:-}" ] && [ "$HOME_ARG" = "$HOME" ] || refuse "home-mismatch: --home $HOME_ARG is not \$HOME ${HOME:-unset}"
CONFIG="${MOSAIC_CONFIG:-$HOME/.config/mosaic-dev/config.json}"
case "$CONFIG" in /*) ;; *) refuse "config-path: MOSAIC_CONFIG must be an absolute path: $CONFIG" ;; esac
CONFIG_DIR="$(dirname -- "$CONFIG")"
SECRETS="$CONFIG_DIR/secrets"
SLOT="$SECRETS/$BUSINESS"
COPIES="$HOME/.local/share/mosaic-stack/copies"
absent_or_private "$CONFIG_DIR" config-dir-mode
if [ "$PROFILE" = library ]; then
absent_or_private "$SECRETS" secrets-mode
absent_or_private "$SLOT" slot-mode
fi
# The plan and the write share one helper. plan checks the input, the
# staging manifest and what is on disk, and prints one decision per line;
# write makes the business file with O_EXCL. Neither prints persona text.
HELPER='
import { closeSync, constants, fchmodSync, lstatSync, openSync, readFileSync, writeSync } from "node:fs";
import { createHash } from "node:crypto";
import { join } from "node:path";
const [verb, id, input, profile, staging, configDir] = process.argv.slice(1);
const say = (...f) => console.log(f.join("\t"));
const stop = (code, message) => { say("REFUSE", `${code}: ${message}`); process.exit(0); };
const sha256 = (data) => createHash("sha256").update(data).digest("hex");
let doc;
try { doc = JSON.parse(readFileSync(input, "utf8")); } catch (e) { stop("business-file-input", `cannot read ${input} as JSON (${e.code ?? "parse"})`); }
if (doc === null || typeof doc !== "object" || Array.isArray(doc)) stop("business-file-input", `${input} is not a JSON object`);
if (doc.id !== id) stop("business-id-mismatch", `${input} has id ${JSON.stringify(doc.id)}, not ${id}`);
const projects = doc.projects && typeof doc.projects === "object" ? doc.projects : {};
const library = Object.keys(projects).filter((k) => projects[k]?.kind === "library");
const tester = doc.vars?.["bus.tester"] === true;
let personas = [];
if (profile === "factory") {
if (library.length) stop("profile-library-business", `the factory profile refuses library projects ${library.join(",")}`);
if (!tester) stop("bus-tester-off", "the factory profile needs vars bus.tester true");
} else {
if (!library.length) stop("no-library-project", `${input} has no library project`);
if (tester) stop("bus-tester-on", "the library profile needs bus.tester off");
for (const instance of ["bean", "chet"]) {
const role = doc.roles?.[instance];
if (role?.definition !== "companion") stop("companion-roles", `roles.${instance} must have definition companion`);
if (Object.keys(role.vars ?? {}).some((k) => k.startsWith("persona."))) stop("persona-var-input", `roles.${instance}.vars sets a persona var; the install adds them`);
}
let manifest;
try { manifest = JSON.parse(readFileSync(join(staging, "manifest.json"), "utf8")); } catch (e) { stop("persona-manifest", `cannot read ${join(staging, "manifest.json")} (${e.code ?? "parse"})`); }
const entries = manifest?.version === 1 && Array.isArray(manifest.personas) ? manifest.personas : [];
const want = (instance) => entries.filter((m) => m?.instance === instance);
for (const instance of ["bean", "chet"]) {
const m = want(instance);
if (entries.length !== 2 || m.length !== 1 || m[0].name !== instance[0].toUpperCase() + instance.slice(1) || !/^[0-9a-f]{64}$/.test(m[0].sha256 ?? ""))
stop("persona-manifest", "the staging manifest does not list Bean and Chet once each with a sha256");
personas.push(m[0]);
}
for (const m of personas) doc.roles[m.instance].vars = { ...(doc.roles[m.instance].vars ?? {}), "persona.name": m.name, "persona.sha256": m.sha256 };
}
const text = `${JSON.stringify(doc, null, 2)}\n`;
const file = join(configDir, "businesses", `${id}.json`);
if (verb === "write") {
let fd;
try { fd = openSync(file, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600); }
catch (e) { stop("business-file-raced", `${file} could not be made new (${e.code})`); }
fchmodSync(fd, 0o600);
writeSync(fd, text);
closeSync(fd);
say("WROTE", file);
process.exit(0);
}
// plan: the personas on disk, then the business file on disk.
if (profile === "library") {
const dir = join(configDir, "personas", id);
const seen = personas.map((m) => {
const p = join(dir, `${m.instance}.md`);
const s = lstatSync(p, { throwIfNoEntry: false });
if (!s) return "absent";
return s.isFile() && sha256(readFileSync(p)) === m.sha256 ? "same" : "differs";
});
if (seen.every((s) => s === "absent")) say("PERSONAS", "install");
else if (seen.every((s) => s === "same")) say("PERSONAS", "present");
else stop("persona-differs", `the persona files under ${dir} are not the staged pair; nothing written`);
for (const m of personas) say("PERSONA", m.instance, m.name, m.sha256);
}
const s = lstatSync(file, { throwIfNoEntry: false });
if (!s) say("BUSINESS", "new", file);
else if (s.isFile() && !s.isSymbolicLink() && readFileSync(file, "utf8") === text) say("BUSINESS", "same", file);
else stop("business-file-differs", `${file} exists and differs from the one this install writes; nothing written`);
'
helper() { (cd "$REPO" && node --input-type=module -e "$HELPER" "$1" "$BUSINESS" "$BFILE" "$PROFILE" "$PERSONAS" "$CONFIG_DIR" 2>&1); }
field() { printf '%s\n' "$plan" | awk -F'\t' -v k="$1" '$1 == k { print $2 }'; }
plan="$(helper plan)" || refuse "plan: the check failed: $(printf '%s' "$plan" | head -n 1)"
r="$(printf '%s\n' "$plan" | awk -F'\t' '$1 == "REFUSE" { print $2; exit }')"
[ -z "$r" ] || refuse "$r"
BUSINESS_STATE="$(field BUSINESS)"
[ -n "$BUSINESS_STATE" ] || refuse "plan: no decision: $(printf '%s' "$plan" | head -n 1)"
PERSONA_STATE="$(field PERSONAS)"
# 2. bootstrap: idempotent, and it never rewrites an existing config.
if [ ! -e "$CONFIG_DIR" ]; then
mkdir -p -- "$(dirname -- "$CONFIG_DIR")" && mkdir -m 700 -- "$CONFIG_DIR" || refuse "config-dir: cannot make $CONFIG_DIR"
fi
out="$("$REPO/scripts/bootstrap.sh" 2>&1)" || refuse "bootstrap: $(printf '%s' "$out" | tail -n 1)"
say "config $CONFIG"
# 3. The stack copy row 195's units run from.
out="$("$STACK_COPY" copy "$COMMIT" --copies "$COPIES" 2>&1)" || refuse "stack-copy: $(printf '%s' "$out" | tail -n 1)"
[ -f "$COPIES/$COMMIT/scripts/mosaic" ] || refuse "stack-copy: no copy $COMMIT in $COPIES after the copy step"
say "copy $COPIES/$COMMIT"
# 4. library: the personas, from the staging dir.
if [ "$PROFILE" = library ]; then
if [ "$PERSONA_STATE" = install ]; then
out="$(node "$REPO/scripts/case2-persona-import.mjs" install --staging "$PERSONAS" --config-dir "$CONFIG_DIR" --business "$BUSINESS" 2>&1)" \
|| refuse "persona-import: $(printf '%s' "$out" | tail -n 1)"
while IFS=$'\t' read -r _ inst name sum; do
printf '%s\n' "$out" | grep -qxF "instance $inst: persona.name $name, persona.sha256 $sum" \
|| refuse "persona-import: the import did not report $inst with the manifest's sha256"
done < <(printf '%s\n' "$plan" | awk -F'\t' '$1 == "PERSONA"')
say "personas installed under $CONFIG_DIR/personas/$BUSINESS"
else
say "personas already installed under $CONFIG_DIR/personas/$BUSINESS"
fi
fi
# 5. The business file, at 0600 and new, unless an equal one is there.
CREATED=0
if [ "$BUSINESS_STATE" = new ]; then
mkdir -p -m 700 -- "$CONFIG_DIR/businesses" || refuse "business-file: cannot make $CONFIG_DIR/businesses"
w="$(helper write)" || refuse "business-file: $(printf '%s' "$w" | head -n 1)"
case "$w" in WROTE*) CREATED=1 ;; REFUSE*) refuse "${w#REFUSE }" ;; *) refuse "business-file: $(printf '%s' "$w" | head -n 1)" ;; esac
say "business file $CONFIG_DIR/businesses/$BUSINESS.json written"
else
say "business file $CONFIG_DIR/businesses/$BUSINESS.json already matches"
fi
# 6. library: the empty slot dir. The token is Jason's, design 10 step 4.
if [ "$PROFILE" = library ]; then
[ -e "$SECRETS" ] || mkdir -m 700 -- "$SECRETS" || refuse "slot: cannot make $SECRETS"
[ -e "$SLOT" ] || mkdir -m 700 -- "$SLOT" || refuse "slot: cannot make $SLOT"
private_dir "$SLOT" || refuse "slot-mode: $SLOT must be a 0700 directory"
say "slot dir $SLOT"
fi
# 7. validate, the business package's own schema.
if ! out="$("$REPO/scripts/mosaic" business validate "$BUSINESS" 2>&1)"; then
if [ "$CREATED" = 1 ]; then rm -f -- "$CONFIG_DIR/businesses/$BUSINESS.json"; fi
refuse "validate: $(printf '%s' "$out" | tail -n 1)$( [ "$CREATED" = 1 ] && echo '; the business file this run wrote is removed')"
fi
say "business $BUSINESS validates"
# 8. What comes next. Root's steps run from root's own checkout.
say "done. Next: root runs case2-unit.sh --copy $COMMIT --business $BUSINESS (design 10 step 2c), then 2d and 3."
if [ "$PROFILE" = library ]; then
say "Then Jason places the library token in $SLOT (step 4), and <user> runs case2-precheck.sh --business $BUSINESS --library-status (step 4a)."
fi
exit 0
+124 -3
View File
@@ -5,6 +5,7 @@
# any one failed.
#
# case2-precheck.sh --business ID [--profile factory|library]
# case2-precheck.sh --business ID --library-status
#
# Checks: the config and business file validate; `sudo -n true` fails and
# `id -G` prints one group; bus.tester is off for library and on for factory,
@@ -17,6 +18,22 @@
# instance's provider is zai. It prints free memory and checks that HOME is
# <home>. It never prints a value from claude-session.env.
#
# C2-13b adds to the library profile: the business file is <user>'s with no
# group or other bit; businesses/.history/<id>/ is 0700 and its changes.jsonl
# 0600, when present; the persona dirs are 0700 and each persona file is
# <user>'s at 0600 with the sha256 its persona.sha256 names; and no Discord
# binding exists under <dataRoot>/discord. These read no mirror and sync
# nothing, since the bus makes the mirror when it starts.
#
# --library-status (design 10 step 4a) runs instead, once the bus is up and
# the token is placed. It reads the bus's recorded sync state for each library
# project through libraryStatus(), the call the bus's library.status makes,
# and syncs nothing: sync() fetches outside the library lock, so a second
# process would race the bus's timer. It refuses a missing mirror, a recorded
# lastError and a null lastFetch, in that order, and prints each R-problem as a
# `problem` line with its path as a JSON string, then a count. It prints no
# text from her repo.
#
# Test seams, for packages/cli/tests/case2-unit.test.mjs only:
# CASE2_UNIT_DIR the installed units, default /etc/systemd/system
# CASE2_PROC_SYS default /proc/sys
@@ -34,22 +51,86 @@ WEBUI_UNIT="$UNIT_DIR/mosaic-webui.service"
# Claude Code's own prompt is many times this.
MARKER_TOKEN_CEILING=2000
usage() { echo 'usage: case2-precheck.sh --business ID [--profile factory|library]' >&2; exit 4; }
PROFILE=library BUSINESS=''
usage() { echo 'usage: case2-precheck.sh --business ID [--profile factory|library] | --business ID --library-status' >&2; exit 4; }
PROFILE=library PROFILE_SET=0 BUSINESS='' STATUS=0
while [ $# -gt 0 ]; do
case "$1" in
--profile) [ $# -ge 2 ] || usage; PROFILE="$2"; shift 2 ;;
--profile) [ $# -ge 2 ] || usage; PROFILE="$2"; PROFILE_SET=1; shift 2 ;;
--business) [ $# -ge 2 ] || usage; BUSINESS="$2"; shift 2 ;;
--library-status) STATUS=1; shift ;;
*) usage ;;
esac
done
[ -n "$BUSINESS" ] || usage
[ "$STATUS" = 0 ] || [ "$PROFILE_SET" = 0 ] || usage
case "$PROFILE" in factory|library) ;; *) echo "case2-precheck: --profile must be factory or library: $PROFILE" >&2; exit 4 ;; esac
[[ "$BUSINESS" =~ ^[a-z0-9][a-z0-9._-]{0,63}$ ]] || { echo "case2-precheck: --business must be a business id: $BUSINESS" >&2; exit 4; }
FAILED=0
ok() { echo "ok $1"; }
refuse() { echo "REFUSED $1"; FAILED=1; }
mode_of() { stat -c '%a' -- "$1"; }
# Regular, not a link, owned by this uid, with no group or other bit.
private_file() { [ ! -L "$1" ] && [ -f "$1" ] && [ "$(stat -c '%u' -- "$1")" = "$(id -u)" ] && [ $((8#$(mode_of "$1") & 8#077)) -eq 0 ]; }
# A directory, not a link, owned by this uid, at 0700.
private_dir() { [ ! -L "$1" ] && [ -d "$1" ] && [ "$(stat -c '%u' -- "$1")" = "$(id -u)" ] && [ "$(mode_of "$1")" = 700 ]; }
# --library-status: the bus's recorded sync state, and nothing else.
if [ "$STATUS" = 1 ]; then
status="$(cd "$REPO" && node --input-type=module -e '
import { lstatSync } from "node:fs";
const [repo, id] = process.argv.slice(1);
const { loadSystem, rolesDir } = await import(`${repo}/packages/cli/src/config.mjs`);
const { loadBusiness } = await import(`${repo}/packages/business/src/index.mjs`);
const { libraryStatus, mirrorPath } = await import(`${repo}/packages/library/src/index.mjs`);
const say = (...f) => console.log(f.join("\t"));
// A library error code is a fixed [a-z-] string; anything else prints as one name.
const code = (e) => typeof e?.code === "string" && /^[a-z-]{1,64}$/.test(e.code) ? e.code : "library-status-failed";
// JSON.stringify leaves DEL, C1 controls, the line and paragraph separators and the bidi controls
// raw. They print as \uXXXX too, so a path from her repo cannot move the cursor or reorder a line.
const RAW = /[\u007f-\u009f\u200e\u200f\u2028\u2029\u202a-\u202e\u2066-\u2069]/g;
const quote = (s) => JSON.stringify(s).replace(RAW, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`);
let system, b;
try { system = loadSystem(); } catch (e) { say("BAD", `config: ${e.message.split("\n")[0]}`); process.exit(0); }
try { b = loadBusiness(id, { rolesDir: rolesDir() }); } catch (e) { say("BAD", `business file: ${e.message.split("\n")[0]}`); process.exit(0); }
const projects = Object.keys(b.projects).filter((p) => b.projects[p].kind === "library");
if (!projects.length) say("BAD", `business ${id} has no library project`);
for (const project of projects) {
const context = { business: b, project, dataRoot: system.dataRoot };
let mirror, s;
try { mirror = mirrorPath(context); } catch (e) { say("ERROR", project, code(e)); continue; }
let there;
try { there = lstatSync(mirror, { throwIfNoEntry: false }); } catch (e) { say("ERROR", project, "library-git"); continue; }
if (!there) { say("MISSING", project, mirror); continue; }
try { s = await libraryStatus(context); } catch (e) { say("ERROR", project, code(e)); continue; }
// lastError first: a first sync with no token records an error and leaves lastFetch null.
if (s.lastError !== null) { say("LASTERROR", project, s.lastError.code, s.lastError.at); continue; }
if (s.lastFetch === null) { say("NEVER", project); continue; }
say("OK", project, s.lastFetch, s.served ?? "none");
if (s.diverged !== null) say("DIVERGED", project, s.diverged.commit);
for (const p of s.problems) say("PROBLEM", p.code, quote(p.path));
say("PROBLEMS", project, s.problems.length);
}
' "$REPO" "$BUSINESS" 2>&1)" || true
[ -n "$status" ] || refuse "library status: the check printed nothing"
while IFS=$'\t' read -r kind a b c; do
case "$kind" in
BAD) refuse "$a" ;;
ERROR) refuse "library-status: library $a: $b" ;;
MISSING) refuse "library-mirror-missing: library $a has no mirror at $b; the bus makes it when it starts" ;;
LASTERROR) refuse "library-last-error: library $a: $b at $c" ;;
NEVER) refuse "library-never-synced: library $a has no fetch yet; wait for the bus's next sync, 60 s, or run library.sync" ;;
OK) ok "library $a last fetched $b, serving $c" ;;
DIVERGED) echo " library $a: a diverged tip $b waits for library.sync --accept" ;;
PROBLEM) echo "problem $a $b" ;;
PROBLEMS) echo "problems $b" ;;
*) refuse "library status: $kind${a:+ $a}" ;;
esac
done <<<"$status"
if [ "$FAILED" = 0 ]; then echo "case2-precheck: library status passed"; exit 0; fi
echo "case2-precheck: refused"
exit 1
fi
# One setting's value from an installed unit: the last KEY= line in the file.
unit_value() { [ -f "$1" ] && sed -n "s/^[[:space:]]*$2[[:space:]]*=[[:space:]]*//p" -- "$1" | tail -n 1; }
@@ -100,6 +181,8 @@ let b;
try { b = loadBusiness(id, { rolesDir: rolesDir() }); } catch (e) { say("BUSINESS", "bad", e.message.split("\n")[0]); process.exit(0); }
say("BUSINESS", "ok", file);
say("TESTER", b.vars["bus.tester"] === true ? "on" : "off");
for (const [instance, role] of Object.entries(raw.roles ?? {}))
if (typeof role?.vars?.["persona.sha256"] === "string") say("PERSONA", instance, role.vars["persona.sha256"]);
const vars = systemVars(system);
const models = b.launch?.models ?? null;
for (const instance of Object.keys(b.roles)) {
@@ -138,6 +221,44 @@ elif [ -n "$cfg" ] && [ "${cfg#CONFIG ok}" != "$cfg" ]; then
esac
fi
# C2-13b: the library files' modes, the persona digests and no Discord
# binding (design 4). Names and digests print, never persona text.
if [ "$PROFILE" = library ] && [ -n "$CONFIG_DIR" ]; then
bfile="$CONFIG_DIR/businesses/$BUSINESS.json"
if [ ! -e "$bfile" ] && [ ! -L "$bfile" ]; then :
elif private_file "$bfile"; then ok "business file $bfile is $ME's with no group or other bit"
else refuse "business-file-mode: $bfile must be $ME's regular file with no group or other bit (mode $(mode_of "$bfile"))"; fi
hdir="$CONFIG_DIR/businesses/.history/$BUSINESS"
if [ ! -e "$hdir" ] && [ ! -L "$hdir" ]; then ok "no history for $BUSINESS yet"
elif ! private_dir "$hdir"; then refuse "history-mode: $hdir must be $ME's 0700 directory (mode $(mode_of "$hdir"))"
elif [ ! -e "$hdir/changes.jsonl" ] && [ ! -L "$hdir/changes.jsonl" ]; then ok "history $hdir is 0700"
elif private_file "$hdir/changes.jsonl" && [ "$(mode_of "$hdir/changes.jsonl")" = 600 ]; then ok "history $hdir is 0700 and changes.jsonl 0600"
else refuse "history-mode: $hdir/changes.jsonl must be $ME's 0600 file (mode $(mode_of "$hdir/changes.jsonl"))"; fi
personas="$(line PERSONA)"
if [ -z "$personas" ]; then ok "no instance has persona vars"
else
for d in "$CONFIG_DIR/personas" "$CONFIG_DIR/personas/$BUSINESS"; do
if private_dir "$d"; then ok "persona dir $d is 0700"
else refuse "persona-mode: $d must be $ME's 0700 directory"; fi
done
while IFS=$'\t' read -r _ inst want; do
pfile="$CONFIG_DIR/personas/$BUSINESS/$inst.md"
if ! private_file "$pfile" || [ "$(mode_of "$pfile")" != 600 ]; then
refuse "persona-mode: $pfile must be $ME's 0600 file$( [ -e "$pfile" ] && echo " (mode $(mode_of "$pfile"))")"
continue
fi
have="$(sha256sum -- "$pfile" | cut -d' ' -f1)"
if [ "$have" = "$want" ]; then ok "persona $inst sha256 $have"
else refuse "persona-mismatch: persona $inst has sha256 $have, and persona.sha256 is $want"; fi
done <<<"$personas"
fi
fi
if [ "$PROFILE" = library ] && [ -n "$DATA_ROOT" ]; then
binding="$(find "$DATA_ROOT/discord" -maxdepth 1 -name '*.json' -print -quit 2>/dev/null)"
if [ -n "$binding" ]; then refuse "discord-binding: $binding exists; a library hub has no Discord binding"
else ok "no Discord binding under $DATA_ROOT/discord"; fi
fi
# No sudo, and one group.
if sudo -n true >/dev/null 2>&1; then refuse "sudo -n true succeeds as $ME"; else ok "sudo -n true fails as $ME"; fi
groups="$(id -G)"