Compare commits

...
Author SHA1 Message Date
jason.woltje 4d99c178b3 row 248: row 235 follow-up, the grant-executor hold-add and hold-remove stubs stop when their test dir is gone, so no stub outlives its test (#1750) 2026-10-21 08:05:28 -05:00
jason.woltje 540239b6b7 plans: row 235 follow-up brief, the held grant-executor stubs stop when their test dir is gone 2026-10-21 08:03:55 -05:00
jason.woltje 6d5545d442 row 245: row 195 follow-up, F9 runs node as root only at the pinned root-only path, the header and TOOLS.md list what root runs, F6 refuses .. after claude-<pin>/ (Duckworth N1-N3, #1693)
Darkwing's ef8b4439e (darkwing/row245), Duckworth round 1 APPROVE (rev 2849, comment 30043),
manifest 69bc05f4f706, 5 paths. Slot run sage-1961 (darkwing-0107): singles 23/0, mutants 16/16
killed, N1 and N3 among them. DESIGN.md:336-338 stays unedited, superseded by gosalyn-0087.
Sage gate on 856c1826b plus these 5 paths (12:24-12:35Z): case1-accept.test.mjs alone 23/0,
cli 681/0, every scripts/test-*.sh 429/0.
2026-10-21 07:35:39 -05:00
jason.woltje 856c1826bc row 247: C2-20b persona import follow-up, keys from config.yaml print as JSON strings, the dry run applies C2-08's fence and refuses, privateDir refuses an EEXIST race by name (#1749)
Gosalyn's 6d43618a3 (gosalyn/c2-20b), Webby round 1 APPROVE webby-0076 (rev 2852), manifest 6d3f279f692b,
4 paths. Webby's row 221 notes N1, N2 and N4. Slot runs sage-1969 (red base, the builder's comma
regression) and sage-1971 (14/0, M1-M4 killed). M1/M2 sit at :248 in this commit, not :237 as
BUILD.md says (gosalyn-0111). JSON.stringify leaves DEL, C1, U+2028/9 and bidi raw, a known limit
by sage-1964.
Sage gate on 5c6f73ed7 plus these 4 paths (12:10-12:21Z): the test file alone 14/0, cli 680/0,
every scripts/test-*.sh 429/0. f8276c5eb adds only docs/SESSIONS and docs/case2 sheets no test reads.
2026-10-21 07:22:07 -05:00
jason.woltje f8276c5eb3 row 218: C2-12 setup sheet for Melanie and the library survey run sheet (#1719)
Doc only, Sage's sage/row218 89ba79d94 (three commits squashed). Webby APPROVE round 1 (webby-0075,
rev 2848), round 2 (webby-0077, rev 2856) and round 3 (webby-0078, rev 2860). docs/case2/SETUP-SHEET.md
tells Melanie what the agents read, the playground, link and property forms, and what changes for
Bean and Chet, with M5's 1,200 character reply total (mos-1165). It goes to her only after C2-06
lands and on Jason's go (#419). docs/case2/SURVEY-RUN.md is design 10 step 7's run sheet: the
bare mirror as the source, a node check, output lines and refusals from survey.mjs. Claim sources
and rulings are in agents/sage/work/queue-218/BUILD.md. No test reads either doc path.
2026-10-21 07:05:39 -05:00
jason.woltje b65f3a0e84 sessions: Darkwing row 245 slot green, round 1 in review 2026-10-21 06:50:41 -05:00
15 changed files with 785 additions and 32 deletions

No files matched your search

+8
View File
@@ -151,3 +151,11 @@ every run behind my busy check and Sage's, chained with `&&`.
Summed: 818 node tests passed with 0 failed, and 429 selftests passed with 0 failed. The compose network
was removed afterwards, and the sweep found no process left in the worktree, the output directory or the
TMPDIR. This file's results are a doc-only commit on top of the build, so nothing that ran has changed.
## Correction to deviation 6 (follow-up, 2026-10-21)
Deviation 6 says the node F9 runs is root's own. At 37171f65b the script didn't check that. F9 ran
`--node` as root before F10's path check, so the header's "root itself only reads" was false
(Duckworth's N1, `agents/duckworth/work/row195/REVIEW-m1.md` at 6afcee2a5). A host installed as designed
was safe, because `case2-unit.sh` refused a non-root-owned `--node`. Row 245 carries the fix and its
tests: `agents/darkwing/work/queue-245/BUILD.md`.
+91
View File
@@ -0,0 +1,91 @@
# Row 245 build: Duckworth's N1-N3 on `scripts/case1-accept.sh` (row 195 follow-up)
Duckworth approved row 195's manifest round 1 (duckworth-0088, `agents/duckworth/work/row195/REVIEW-m1.md`
at 6afcee2a5) with three notes that don't block. Row 195's code landed as b65014647, and the notes are in
`docs/plans/DEFERRED.md` (d6cff0cdd). Row 245 carries them: brief `docs/plans/2026-10-20_hub-own-uid.md` §
"Row 195 follow-up" at 62ddc96cc, owner Darkwing, reviewer Duckworth, gate owner Sage (sage-1923).
The branch is `darkwing/row245`, on 62ddc96cc. Its first commit is a cherry-pick of `darkwing/row195-n1`
1e3c4767c, which was written on 37171f65b and never run. The script, test and TOOLS.md blobs are the same
as in 1e3c4767c, and none of those paths changed between 37171f65b and 62ddc96cc.
## What changes
N1. F9 ran `$ROOT$NODE` as root (`case1-accept.sh:298` at 37171f65b) before F10 checked that `--node` is
the pinned path. Duckworth offered two fixes: F9's parse through `AS_USER`, or F10's path check first. I
took the path check, plus one more:
- F9 now runs node only when `--node` equals `/opt/mosaic-stack/node-<--node-pin>/bin/node`.
- Then `not_root_only` walks from that file up to `/`. Each entry must be the running user's (root's on the
host) with no group or other write, the rule `case2-unit.sh` `root_only` applied when it rendered the
units. A symlink fails it, because its mode is 777. The first entry that breaks the rule goes in the
evidence.
I didn't run the parse through `AS_USER`. That would hand a check about a root file to the stack's user,
and a process running as `mosaic` shares that node's uid. Whether it could change what node prints then
turns on the host's ptrace setting, and the root-only check doesn't depend on that. Sage agreed in
sage-1877. Rechecking ownership costs one loop and doesn't depend on
how the host was installed.
N2. The header (:9-15) and the `docs/TOOLS.md` entry now list what root runs: `systemctl show` and
`is-enabled` (F1, P11), `curl` on the loopback healthz (F2), `sudo -l` (P1, P3), `sudo -u jarvis bwrap
--version` and `hostcheck.sh --check` (P9), and node for F9 under the rule above.
N3. F6 matched `--claude` by prefix, so `/opt/mosaic-stack/claude-<pin>/../claude-2.0.9/...` passed the
path check. F6 now fails on any `..` segment, before it runs `--version`. F10 compares for equality, so
it needed no change.
Row 195's BUILD.md gets a correction entry for deviation 6. Row 195's DESIGN.md is unchanged. Its "either
works" wording at `DESIGN.md:336-338` is superseded by gosalyn-0087 (root writes `dev.args` at W10), and
it stays in place: an approved design isn't edited (sage-1877).
## Tests
`packages/cli/tests/case1-accept.test.mjs`:
- The node stub touches `node-as-root` in the state directory when something runs it outside the fake
`sudo` (which sets HOME to the stack's home). `host()` sets the node file's directories and the scratch
root to 0755, so the umask can't fail F9.
- "every check passing" also asserts that root ran the pinned node.
- New: "F9 won't run --node as root off the pin path or with a writable directory above it". Another node
path fails F9 and F10, with no root run. A group-writable `bin/` and a symlinked node each fail F9
alone, with the bad path in the evidence and no root run.
- The F6 test gains a `..` case: a path under `claude-<pin>/` by prefix that names 2.0.9's binary, which
prints the pin. F6 alone fails, with "has a .. segment".
That makes 23 tests. All 23 pass on Sage's slot (results below).
## Mutants
The twelve from row 195 still apply, unchanged. These four are new, each one exact replacement:
| # | Mutant | Expected killer |
|---|---|---|
| 13 | F9 runs any `--node` (the pin check becomes `true`) | F9 won't run (another node) |
| 14 | F9 skips the ownership walk (`bad=""`) | F9 won't run (writable `bin/`, symlink) |
| 15 | `not_root_only` checks only the file, not the directories above it | F9 won't run (writable `bin/`) |
| 16 | F6 accepts a `..` segment | F6 stub, path and `..` |
The brief's gate asks for a mutant for each of N1 and N3. Mutants 13-15 are N1's, and 16 is N3's.
## Slot plan
Same shape as row 195's steps, on this row's tip: the single test file, then the sixteen mutants, then
the gate (the cli suite and every `scripts/test-*.sh`), each behind the busy checks and chained with
`&&`. The scripts are in `/mnt/storage/scratch/darkwing-245-out/`.
## Slot results (grant sage-1961, 2026-10-21)
All three steps ran on 0590a29fc with node v26.8.1, launched from `/`. Both busy checks were CLEAR
before each step.
- Singles, 11:27:46-11:27:59Z: `case1-accept.test.mjs` passed 23, failed 0.
- Mutants, 11:28:10-11:31:20Z: all 16 killed, each with exit 1. The runner restored the script, and
its sha256 875bb5b7 matches the committed blob. Rows 1-12 died as in row 195. 13, 14 and 15 died on
"F9 won't run --node as root off the pin path or with a writable directory above it". 16 died on
"F6 fails on a stub that prints nothing, on a path outside claude-<pin> and on a .. segment".
- Gate, 11:31:20-11:42:51Z: the cli suite passed 664, failed 0, skipped 0. Every `scripts/test-*.sh`
exited 0: auth 15, conductor 17, config 31, discord 78, extension-package 18, foundation 44,
land-work 86, queue 28 (its node part 156/0), release 14, task 98. That's 429 selftests, none failed.
- Afterwards no process had its cwd in the worktree, the out dir or TMPDIR, and the
`darkwing-r245_default` network was gone.
Logs: `/mnt/storage/scratch/darkwing-245-out/` (`step1.log`, `mut.out`, `mut/m1-16.log`, `gate.out`).
+87
View File
@@ -0,0 +1,87 @@
# Row 247 build notes, C2-20b persona import follow-up (#1749)
Brief: 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 in 335bb3e30. These are Webby's row 221 notes N1, N2 and N4
(webby-0073, REVIEW-r1 a0a00630e on webby/reviews). N3 is in DEFERRED (sage-1941).
## Base
Branch gosalyn/c2-20b from 335bb3e30, which is row 221's landing 300766450 plus the brief.
## What changed
- N1, `scripts/case2-persona-import.mjs`: the per-channel prompt line prints the platform key, with its
fixed `platforms.` or `gateway.platforms.` prefix, and the channel key through `JSON.stringify`. A key
holding a newline prints as `\n` inside one quoted line. The profile name on that line is not config
text: `pairing` already holds it to `PROFILE_ID`.
- N2: the dry run calls `personaTextFault` on each persona after its count line. A fault prints as
`profile P: persona <reason>, so C2-08's fence would refuse it`, where the reason is the fixed string
`personaTextFault` returns, never persona text or a line number. With any fault the run refuses, exit
1, and writes nothing, as stage would. The header comment no longer says a fault is counted, not
refused, and now says a SOUL.md over four times the fence in bytes refuses before any count, which is
what `soul()` already did (Webby's second point under N2).
- N4: `privateDir` catches an EEXIST from its mkdir and refuses with `<path> was made by something else
between the check and mkdir`. Any other mkdir error is thrown as before, since the brief names only
EEXIST.
- Tests, `packages/cli/tests/case2-persona-import.test.mjs`:
- The per-channel prompt test expects the quoted keys.
- New: a chet config.yaml with a newline in the platform key and in the channel key prints one quoted
line, and no output line starts with the forged text.
- The dry run heading test now expects the fence fault line, exit 1 and the refusal, still with
nothing written. Its persona has two heading lines, which the fence refuses.
- New: a persona of 8001 two-byte characters, 16002 bytes and under the 32000 byte cap, makes the dry
run print the over 8000 characters fault, write nothing and exit 1. At exactly 8000 it exits 0.
- New: the mkdir race refuses by name at stage, for the staging directory, and at install, for
`personas/<business>`. Each refused directory is left empty.
- New fixture `packages/cli/tests/fixtures/case2-persona-import/mkdir-race.mjs`, an `--import` preload
like `trace.mjs`. It makes the directory named by `$PERSONA_MKDIR_RACE` just before the script's own
`mkdirSync` of that path, so the script meets a real EEXIST from the kernel, not a thrown stub.
## Known limit of N1
`JSON.stringify` escapes C0 controls, `"` and `\`, but leaves DEL, C1, U+2028, U+2029 and the bidi
controls raw. None of those ends a line on a terminal, so the forged line N1 names can't happen. A bidi
control could still reorder how the rest of that one line displays. The brief asks for `JSON.stringify`,
and Sage kept it in sage-1964 because the keys come from Jason's own Hermes config. Note for Webby: row
231's `quote()` in packages/library covers the wider set, and this row doesn't import it. If you want it,
ask for it in review.
## Mutants
One per fix, plus a second for N1 so each of its two keys has its own. Each replaces one exact string
in the script, runs the test file, then restores the script from a copy and checks its sha256.
| Mutant | Fix | Change |
|---|---|---|
| M1 | N1 | the channel key prints raw |
| M2 | N1 | the platform key prints raw |
| M3 | N2 | the dry run's refusal after a fault is skipped |
| M4 | N4 | privateDir's EEXIST branch never matches, so EEXIST is thrown raw |
## Outside readers
`git grep` over packages/*/tests/* and scripts/test-*.sh finds `case2-persona-import` only in its own
test file and in two files of its fixture tree, README.md and trace.mjs, which name the test file in a
comment. No test pins a hash of the script.
## Tests
Two slot runs, `busy.sh && run.sh` in /mnt/storage/scratch/gosalyn-r247, on the test file alone.
Run 1, grant sage-1969, 11:47:45Z to 11:48:00Z, was red: 14 tests, 12 pass, 2 fail. The fault was in the
script. Adding `JSON.stringify` to the per-channel line dropped the space after the comma, so it printed
`"-1001",29 bytes`. The two per-channel tests caught it. run.sh also went on to the mutants with a red
base, so their counts meant nothing. Sage approved both fixes in sage-1971: the space goes back, and run.sh
now exits 3 before the mutants unless the base is rc=0 with fail 0.
Run 2, grant sage-1971, 11:49:04Z to 11:49:22Z, was green: 14 tests, 14 pass, 0 fail, rc=0.
| Mutant | Line | rc | Failed | Killed by |
|---|---|---|---|---|
| M1 | 237 | 1 | 2 | the per-channel prompt test and the newline key test |
| M2 | 237 | 1 | 2 | the same two |
| M3 | 238 | 1 | 2 | the dry run fence test and the dry run heading test |
| M4 | 192 | 1 | 1 | the mkdir race test |
After the run the script's sha256 was 7031d2c0dc9bd22bc748b364a35b461fc01cb98b795032a2e0ec436f3188d4e4, the
same as the copy taken before it. The /proc scan found no process in the worktree or the scratch dir.
+149
View File
@@ -0,0 +1,149 @@
# Row 218 draft notes: claims and sources
Drafts for `docs/case2/SETUP-SHEET.md` and `docs/case2/SURVEY-RUN.md`. Repo base: `refactor` at
947101d8a, which holds 29ce1e360 (row 231). "Design" means
`git show 75fb3e10e:agents/gosalyn/work/case2/DESIGN.md`, cited as `D:<line>`. "Brief" means
`docs/plans/2026-10-20_case2-rows.md`. Code paths are relative to the repo root. Nothing was run
except reads, greps and `git merge-base --is-ancestor 29ce1e360 HEAD`.
## SETUP-SHEET.md
| Claim | Source |
|---|---|
| She keeps writing in Obsidian, obsidian-git pushes to git.mosaicstack.dev, nothing changes on her machine, nothing commits to her repo | D:939-940 (7.1) |
| Agents read what she pushed; a push reaches them in about a minute | D:1006-1007 (7.5); D:972-973 (60-second sync timer) |
| Agents read `.md` notes inside book folders only | D:457-461 (1.2); `packages/library/src/scope.mjs:9-19` |
| Not read: top-level notes, `.canvas`, `.base`, attachments, anything outside a book folder | D:461 |
| Other repos under `~/src` are not read | D:489 (1.3), D:494-497 (N4) |
| Agents can't write to her repo: canon, playground, anything | D:389 (D3); D:1008 |
| Folder roles live in the `projects` entry of the business file, hand-edited, agents can't change it | D:409-429, D:443-451 (1.1, M9) |
| Jason asks her to confirm the mapping first | D:431; D:1324-1328 (10 step 7) |
| A folder named in `playground` is her playground | Brief:22; D:434 |
| Each playground prefix sits inside one book folder | D:434-436; `packages/library/src/mapping.mjs:77-80` |
| One per book, e.g. `<book>/playground/`; until then everything mapped is canon | D:438-439 |
| Agents search canon unless they ask for the playground | D:463 |
| Playground may back an idea or question, not a fact, finding or correction; refused before she sees it | D:858 (`playground-cited-as-canon`); D:378-382, D:835-839 (guard runs before store) |
| Only the folder decides; a tag or property can't change scope | D:388 (D2); D:440-441; `scope.mjs:1` |
| Graph colour group via Groups and a `path:` query, her own vault setting, changes nothing for agents | Brief:23; D:941-943. Obsidian UI steps from the task prompt, not the design |
| Path starts with the books repo folder because the vault is `~/src` | D:939 |
| N2: moving a note into a book folder makes it canon, agents may cite it as fact | D:944-945; D:259 |
| Rough drafts outside the playground are canon | Inference from D:460 (scope rule) |
| N4: Obsidian resolves short links across `~/src`, the reader sees one repo, the two can differ, the reader can't tell | D:494-497; D:262-263 |
| A name only in another repo leads nowhere for agents | D:489 (R4); `packages/library/src/links.mjs:61-87` |
| A full-path link resolves the same in both | D:497-498. Hub side: `links.mjs:77-78` (`vault-prefix` step) |
| "New link format: Absolute path in vault" setting | Obsidian UI knowledge. Not in design or code. Reviewer may strike it |
| `appears_in` and `relationships` links are read as links | D:489-490; `packages/library/src/reader.mjs:15`, `:153-162` |
| Quoted flow list `["[[A]]", "[[B]]"]` parses | `reader.mjs:81-96` (flow), `:66-73` (quoted scalar), `:159` |
| Block list with quoted items parses | `reader.mjs:117-122`, `:66-73` |
| Unquoted `[[[A]], [[B]]]` is R3 | Brief:28-29; `reader.mjs:87` (bracket inside a flow list stops parsing), `:137` |
| R3 effect: whole front matter unread, note still indexed by path, name and body, listed as a problem | D:480; `reader.mjs:103-105`, `:193`; `packages/library/README.md:69` |
| Problems show in Mosaic's status | D:473-474; D:999-1000 (7.4 status line) |
| A value without `[[ ]]` is a name | D:489-490; `reader.mjs:159` |
| Bean and Chet keep names and personalities | D:660-661 (Q180) |
| Hermes: free text and story text. Mosaic: short cited items, a prose request gets a question or idea | D:778-781 (design 4) |
| Bean and Chet's item kinds: idea, question, finding, fact; web cites allowed | D:649 (`companion`); D:860 (`citation-unfetched`) |
| Limits 10 items, 5 ideas of 200, others 300 | D:819 (M5) |
| She or Jason can ask for lower limits | D:1329-1330 (10 step 8); D:831 |
| "No cited answer" notice | D:870-871, D:996-997 |
| One item can't mix two worlds | D:859 (`citation-cross-universe`) |
| She talks to them in the Console; Hermes keeps running until she moves over; stopping it is her choice | D:381-382; D:1331 |
| Five more helpers; she chooses the start set and can add one later | D:647-654; D:783-784; D:1327-1328 |
| What Jason will ask | D:1325-1327 (10 step 7) |
## SURVEY-RUN.md
| Claim | Source |
|---|---|
| Runs on `<host>` 10.1.1.54 under design 10 step 7, after step 4 | D:1324; D:1266 (host) |
| Prints names and counts, never note text, property values, file names or link targets | `packages/library/src/survey.mjs:1-6`; README:118-125 |
| Sending to Melanie is Jason's call | Brief:36; D:1324-1328 |
| Token slot `library-gitea` is step 4 | D:1314-1316 |
| Run as `sudo -u mosaic -H` from `localadmin`; `mosaic` has no login shell | D:1195-1197; D:1281-1282 |
| Copy at `/srv/mosaic/.local/share/mosaic-stack/copies/<id>`, `<id>` = commit in Jason's go | D:1286-1292 (2c); `docs/TOOLS.md:417`; home `/srv/mosaic` per D:1314-1315 |
| The copy carries `packages/library/src` | `scripts/stack-copy.sh:36` |
| Row 231 adds the control and bidi escapes | `git log` 29ce1e360; `survey.mjs:78-82`; README:121-122 |
| `<node dir>` is the `--node` directory of step 2c; `scripts/mosaic` execs `node` from PATH | D:1290-1291; `scripts/mosaic:41` |
| Dispatch: `mosaic library survey` goes to `survey.mjs` with `survey <args>` | `scripts/mosaic:37-41` (one `shift` leaves `survey` as argv[0]); `survey.mjs:102-103` |
| Command and flags: `<repo>` plus optional `--mapping <file>` once; anything starting with `-` refuses | `survey.mjs:16`, `:104-110` |
| No fetch; reads `HEAD` | `survey.mjs:5-6`, `:27`; `reader.mjs:22-24` |
| Top folder only: ceiling is the path's parent | `packages/project/src/git.mjs:12`, `:62` |
| Owner must be `mosaic`: git gets no global config | `packages/seat/src/checkout.mjs:79-89` (`GIT_CONFIG_GLOBAL=/dev/null`, `GIT_CONFIG_NOSYSTEM=1`). The dubious-ownership refusal is git's own behaviour |
| Bus mirror path `<dataRoot>/projects/<business>/<project>` | Brief:300; `packages/project/src/paths.mjs:34-40` |
| C2-04 and C2-03a not landed | `grep` of `packages/*/src` finds no library entry handling or `refs/heads/served`; `docs/guides/host-run-sheet.md:269` says "not landed" |
| Served ref `refs/heads/served` | D:963 (7.2); Brief:676 |
| Token kept out of URL, config, argv, logs | D:955-956 (7.2) |
| Mapping file is the entry alone; `remote`, `branch` required | `survey.mjs:114-116`; `mapping.mjs:38-47` |
| Output lines and fields | `survey.mjs:83-97`; data from `:26-76` |
| Folder lines list folders that directly hold an entry, full path | `survey.mjs:30-35` |
| Key names: short identifiers print, others as `(other key)` | `survey.mjs:17-20`, `:47` |
| `unresolved` equals the R4 count | `survey.mjs:72` |
| Scope counts cover all tree entries | `survey.mjs:66` |
| Refusal exit codes 0, 2, 3, 4 | `survey.mjs:99-124`; README:127-129 |
| Mapping checked before repo | `survey.mjs:112-121` |
| stderr format `library survey: <message>` | `survey.mjs:101`; `problems.mjs:3-5` |
| `(project-git)` suffix on git failures | `reader.mjs:18-20`; `paths.mjs:4-6` |
| `library-playground-outside-book` message | `mapping.mjs:79-80` |
| 100-second git timeout | `checkout.mjs:49`, `:52`; `git.mjs:68` |
| Exit 127 when `node` is missing | `scripts/mosaic:18`, `:41` (bash `exec` failure). Not run |
| Other errors rethrow as a stack trace | `survey.mjs:117`, `:123`, `:128-131` |
| Don't work around a refusal | AGENTS.md invariant 6 |
## Code and design disagreements
1. Design 1.1 shows the mapping nested in `"projects": {"books": {...}}` (D:412-429). The survey's
`--mapping` file must be the inner entry alone (`survey.mjs:114-116`). The run sheet follows the code.
2. Design 1.3 calls `vaultPrefix` part of the mapping (D:418). The code makes it optional
(`mapping.mjs:48`, README:30). Without it, a full vault path link, the N4 advice, resolves to
nothing in the hub (`links.mjs:73-87`: `root` fails, `vault-prefix` is skipped, `shortest` refuses a
`/`). The setup sheet's full-path advice holds only when Jason sets `vaultPrefix` to her books repo
folder name.
3. The task prompt calls the unquoted form "refused". R3 is not a refusal. It is a reported problem,
and the note stays indexed (D:473, D:480). The sheet says what happens instead.
## Open questions
1. Where `<repo>` comes from on .54. The survey reads a local repo's `HEAD` and never fetches. The bus
mirror needs C2-04 and C2-03a, neither landed. C2-03a moves `refs/heads/served`, not `HEAD`, so the
mirror's `HEAD` may name no commit and the survey would refuse with exit 2. The sync also needs a
checked mapping with at least one book (`mapping.mjs:59`, `:70`), and the mapping's values are
meant to come from this survey (D:431). Sage needs to rule: a placeholder mapping for the first
sync, a `HEAD` the sync sets, a `--commit` flag on the survey, or another source.
2. The brief says Obsidian's property editor writes `appears_in: ["[[A]]", "[[B]]"]` (Brief:28-29,
webby-0041 N4). I didn't read webby-0041 or check Obsidian's output. Both the flow and the block
quoted forms parse, so the sheet shows both and says only that the editor adds the quotes.
3. Whether `/srv/mosaic/.npm-global/bin/mosaic` exists on .54. The run sheet uses the copy's
`scripts/mosaic` by full path to avoid depending on a wrapper.
## Sage's rulings on the open questions (2026-10-21)
1. Survey source: the bus's bare mirror after the first `library.sync`, the only copy on the host
(design 2.1). The C2-03a brief now requires the mirror's `HEAD` to be a symbolic ref to
`refs/heads/served`, with a test (5c6f73ed7), so the survey reads the served commit with no ref flag.
2. Property list forms: the sheet shows both quoted forms, since both parse. Webby checks the claim
that Obsidian's property editor writes the quoted form.
3. The "New link format: Absolute path in vault" tip stays. It is an Obsidian setting, not a design
claim, and Webby may strike it.
4. The run sheet calls the copy's `scripts/mosaic` by full path. That holds whether or not a wrapper
is on `PATH`.
5. `vaultPrefix`: the run sheet's step 4 tells Jason to set it, because the setup sheet's full-path
link advice depends on it.
## Round 2 (2026-10-21), from Webby's round 1 notes (webby-0075, rev 2848)
- N1, by Mos's ruling mos-1165 (fix before landing): SETUP-SHEET.md now gives the 1,200 character
total of item text per reply (design M5, step 8). The word "Today" is gone. No code enforces these
caps until C2-06 lands, so my ruling is that the sheet goes to Melanie only after C2-06 lands, and
then only on Jason's go (#419). The wording stays as is, because by then it holds.
- N2: SURVEY-RUN.md says tab, newline, carriage return, backspace and form feed print as `\t`, `\n`,
`\r`, `\b` and `\f`, and other control and bidi characters as `\uXXXX` (survey.mjs quote()).
- N3: SURVEY-RUN.md has a refusal row for `library-commit:` (reader.mjs listTree). survey.mjs turns
every LibraryError into exit 2 with its message.
- N4: SURVEY-RUN.md's new step 2 checks `command -v node` under the same sudo and PATH before the
survey runs.
## Round 3 (2026-10-21), from Webby's round 2 note N5 (webby-0077, rev 2856)
- N5: the library-commit row no longer says a moved `HEAD` causes it. The survey reads `HEAD` once,
and listTree raises library-commit only for an id that isn't 40 hex or an object that isn't a
commit (reader.mjs:29, :31). In practice that means a SHA-256 repository, so a rerun doesn't help.
The row now says "for example in a SHA-256 repository" and "Report to Sage".
+1
View File
@@ -1410,3 +1410,4 @@ are never rewritten or removed; corrections are new entries.
2026-10-21T09:20:46Z | Sage (Opus 5.5, 1ef1e4f8) | 212+197 combined landing gate (node 981/0, selftests 429/0, 08:51-09:03Z); 197 lane proof stopped after two failures (try 1 lane-image base inspect, N7; try 3 solo-a process.test.mjs:82 under umask 077), recovered cf48efccf083; mos-1162: row 212 landed alone as ee7b2f137 (paths match a2c72ac95, patch-id ea84d3e35470), #1713 closed 29996, revs 2759-2760; DEFERRED N7 plus brief docs/plans/2026-10-21_gate-env-rows.md at fefd54e13, rows 243 (chmod) and 244 (fixture PI_AUTH_FILE) revs 2761-2762; row 66 rebuilt as sage/row66-r4b 49ad61963 on fefd54e13 (manifest 99cab8275af4, not yet requested); slots: Herb 215 focused 26/0, Gosalyn 231 singles 60/0, Herb 197 r6 focused granted; kept root ga-8I3iZc removed (sage-1827..1893) | in progress
2026-10-21T10:12:45Z | Darkwing (Opus 5.5, 7525c8e4) | row 245 (#1693) built, slot asked (darkwing-0105) | claim rev 2795; darkwing/row245 0590a29fc pushed, on 62ddc96cc: 5273de0b7 cherry-pick of 1e3c4767c (case1-accept.sh, test, TOOLS.md blobs identical) and 0590a29fc notes to queue-245/BUILD.md (DESIGN.md:336-338 superseded by gosalyn-0087, unedited, sage-1877); slot scripts in /mnt/storage/scratch/darkwing-245-out pinned to 0590a29fc, nothing run; told Sage the untracked queue-195 DESIGN.md was r2 (fcbb4aa9b = 818b79696), nothing lost; flagged 245 and 246 both closing #1693
2026-10-21T11:28:49Z | Sage (Opus 5.5, 1ef1e4f8) | 235 landing gate (bus 596/0, business 200/0, cli 666/0, scripts green) and landing b1fece6f0, #1741 closed 30026; 197 r6 D5 proof stopped at solo-a script-release (Compose run has no --no-build, reproduced in lane env), #1695 30031; 221 landed before 197 r7 (sage-1954): gate 11:07-11:19Z (test 11/0, harness 140/0, cli 677/0, scripts green), 300766450, #1722 closed 30033, revs 2812-2813; C2-20b brief and DEFERRED N3 in 335bb3e30, row 247 #1749 briefed; 197 r7 9c3166915 focused run on my slot (red 9/2, green 11/0, mutant killed), request rev 2819 (30036), proof candidates A 2be6d07fa and B cceb258ff ready; slots Herb sage-1956, Darkwing 245 sage-1961 (sage-1894..1962) | in progress
2026-10-21T11:50:41Z | Darkwing (Opus 5.5, 7525c8e4) | row 245 (#1693) slot green, round 1 in review (rev 2842, request comment 30040) | grant sage-1961 on 0590a29fc: singles 23/0, mutants 16/16 killed (13-15 N1, 16 N3), gate cli 664/0 and every scripts/test-*.sh green (429/0), /proc scan empty; results commit ef8b4439e pushed on darkwing/row245; manifest ~/darkwing-scratch/r245/manifest-ef8b4439e*.sha256, 5 paths; slot handed back (darkwing-0107); Duckworth woken (darkwing-0108)
+7 -3
View File
@@ -905,9 +905,13 @@ args file (`/etc/mosaic-stack/dev.args` on .53), which must pass
`stack-upgrade.sh`'s rules, say `--profile factory` and name `--business`.
Every command that runs the stack's code runs as
`sudo -u USER -H env PATH=<node dir>:/usr/local/bin:/usr/bin:/bin` from `/`.
Root only reads systemd, `ss`, `/proc`, the account database and file
metadata. The one write is F3's message and its reply, and it never opens
`claude-session.env`.
Root itself reads `ss`, `/proc`, the account database and file metadata. It
runs `systemctl show` and `is-enabled`, `curl` on the Console's loopback
`/healthz`, `sudo -l`, `sudo -u jarvis bwrap --version`, `hostcheck.sh
--check`, and node to parse `managed-settings.json` for F9. F9 runs node only
when `--node` is the `--node-pin` path, and the file and every directory above
it are root's with no group or other write. The one write is F3's message and
its reply, and it never opens `claude-session.env`.
It prints one line per check, `<id> <path> <mark> <evidence>`, then
`count <mark> N` for each mark and a result line. The functional checks
+125
View File
@@ -0,0 +1,125 @@
# Your books in Mosaic: a setup sheet
This sheet is for you, Melanie. You keep writing in Obsidian the way you do now. Mosaic reads your
books repo after obsidian-git pushes it to git.mosaicstack.dev. It changes nothing on your machine and
commits nothing to your repo.
## What the agents read
The agents read your books repo as you last pushed it. A push reaches them in about a minute.
They read notes, the `.md` files, inside your book folders. They don't read:
- notes at the top of the repo, outside any book folder
- `.canvas` and `.base` files, even inside a book folder
- images and other attachments
- anything in your other repos under `~/src`
They can't write to your repo at all. That covers canon, the playground and everything else.
Which folders are book folders, and which hold characters, places, research, your tracker or chapter
summaries, is set in one place. It is the `projects` entry of your business file in Mosaic. Jason
edits it by hand, and the agents can't change it. He will ask you to confirm it before the agents start.
## Your playground
A folder named in the `playground` list of that entry is your playground. Each playground folder sits
inside one book folder, so one per book works well, for example `<book>/playground/`. You choose the
names and tell Jason. Until you have one, every note in a book folder is canon.
What the playground means for the agents:
- They search canon unless they ask for the playground on purpose.
- An agent may quote a playground note in an idea or a question. It may not use one to back a fact,
a finding or a correction. Mosaic refuses that reply before you see it.
- Only the folder decides. A tag or a property can't make a note playground or canon.
### Colour the playground in your graph
This is a setting of your own vault. It changes nothing for the agents.
1. Open the graph view and open its settings panel.
2. Under Groups, choose New group.
3. In the query box, type the playground's path with `path:`. Your vault is `~/src`, so the path
starts with your books repo folder: `path:"<books repo folder>/<book>/<playground folder>"`.
4. Pick a colour.
Add one group per playground folder.
## Two things you might not expect
### Moving a note into a book folder makes it canon
The folder decides, so a move changes what the agents may do with a note. Once you move a note out of
the playground into a book folder and push, it is canon. The agents may then cite it as fact. A note
that isn't settled yet belongs in the playground. Any note in a book folder outside the playground is
canon, rough drafts included.
### A short link can point somewhere else for the agents
Obsidian looks up a short link like `[[Mara]]` across your whole vault, `~/src`. The agents see only
your books repo. Say a note called `Mara` exists in your books repo and also in another of your repos.
Obsidian and the agents can then open different notes for the same link, and the agents can't tell.
If the name exists only in the other repo, the link leads nowhere for the agents.
A link with the full path from the top of your vault reaches the same note in both:
```
[[<books repo folder>/<book>/<characters folder>/Mara]]
```
Obsidian can write new links this way. In Settings, open Files and links and set New link format to
Absolute path in vault.
## Lists of links in properties
The agents read the links in two properties, `appears_in` and `relationships`. Put quotes around each
link in the list:
```yaml
appears_in: ["[[A]]", "[[B]]"]
```
One link per line works too:
```yaml
appears_in:
- "[[A]]"
- "[[B]]"
```
Obsidian's property editor adds the quotes for you. If you type the list by hand, don't leave them out:
```yaml
appears_in: [[[A]], [[B]]]
```
The agents' reader can't read that form. It then ignores every property of that note and lists the
note as a problem in Mosaic's status. It still finds the note by its path, its name and its text. A
value written without `[[ ]]` counts as a plain name, not a link.
## What changes for Bean and Chet
They keep their names and personalities. How they answer changes.
In Hermes they answer in free text and may write story text when you ask. In Mosaic every reply is a
short list of items. Each item is an idea, a question, a finding or a fact. Each one quotes a line
from your repo or from a web page the agent read for you.
They don't write passages. If you ask for prose, you get a question or an idea instead.
A reply holds at most 10 items and at most 1,200 characters of item text in all. It has at most 5
ideas of up to 200 characters each. A question, finding or fact runs up to 300 characters. You or
Jason can ask for lower limits. When an agent can't back an answer with a quote, you see "No cited
answer" instead.
Each book world stays separate. One item can't mix quotes from two worlds.
You talk to them in the Console. Hermes keeps running until you have moved over, and stopping it is
your choice.
Five more helpers can join them: ideas, research, proofreader, fact-checker and continuity. You
choose which ones to start with, and you can add one later.
## What Jason will ask you
He will ask you to confirm your book folders and type folders, which books share a world, the name of
each playground folder, and which helpers to start with and when.
+122
View File
@@ -0,0 +1,122 @@
# Survey run sheet for the library host
This sheet is for whoever runs `mosaic library survey` on `<host>`, 10.1.1.54, under design 10 step
7. The survey reads one commit of a git repo and prints names and counts. It never prints note text,
property values, file names or link targets. Jason fills in the mapping for Melanie's books repo
from its output and her answers.
Out of scope: sending anything to Melanie. Design 10 step 7 makes that Jason's call.
## Before you start
- Jason's go for step 7. Step 4, the bot token in the `library-gitea` slot, comes first.
- You run as `mosaic` with `sudo -u mosaic -H` from `localadmin`. `mosaic` has no login shell.
- The stack copy from step 2 is in place at `/srv/mosaic/.local/share/mosaic-stack/copies/<id>`.
`<id>` is the full commit id in Jason's go. It must hold row 231, which escapes control and bidi
characters in folder names. In a canonical checkout, `git merge-base --is-ancestor 29ce1e360 <id>`
exits 0 when it does.
- `<node dir>`, the directory that holds `node`. It is the directory of the `--node` path given to
`case2-unit.sh` in step 2c. `scripts/mosaic` runs `node` from `PATH`.
- `<repo>`, an absolute path. See the next section.
## The repo to survey
The survey reads a local repo. It doesn't fetch. `<repo>` must:
- be the repo's top folder, or a bare repo's own folder. A folder inside a repo refuses, because the
survey stops git from looking above the path's parent.
- belong to `mosaic`. The survey gives git no global config, so a `safe.directory` entry can't
admit a repo another user owns.
- have a `HEAD` that names a commit. The survey reads `HEAD` and has no flag for another ref.
`<repo>` is the bus's bare mirror at `<dataRoot>/projects/<business>/<project>`, which C2-04 (row 215)
creates and C2-03a (row 222) fetches into. It is the only copy of her repo on the host (design 2.1), and
`mosaic` owns it. Survey it after the first `library.sync` succeeds. The mirror's `HEAD` points at
`refs/heads/served` (C2-03a brief), so the survey reads the commit the agents are served.
Don't survey anything else. A hand clone with the bot token would put the token where design 7.2 keeps
it out, and the mirror's refs belong to the bus. If `HEAD` names no commit, stop and report to Sage.
## Steps
1. `ssh [email protected]`.
2. Check which `node` runs. `PATH` ends in `/usr/bin:/bin`, so a wrong `<node dir>` would quietly run
another `node`:
```sh
cd /
sudo -u mosaic -H env PATH="<node dir>:/usr/bin:/bin" sh -c 'command -v node; node --version'
```
The first line must be `<node dir>/node`. If it isn't, fix `<node dir>` before going on.
3. Run the survey without a mapping:
```sh
cd /
sudo -u mosaic -H env PATH="<node dir>:/usr/bin:/bin" \
/srv/mosaic/.local/share/mosaic-stack/copies/<id>/scripts/mosaic library survey <repo>
echo "exit $?"
```
4. On exit 0, give the output to Jason. On any other exit, see "Refusals" below.
5. When Jason has a draft mapping, run it again with `--mapping`. The file holds the library entry
alone, `{"kind": "library", "remote": ..., "branch": ..., ...}`, without the business file or the
`"projects"` wrapper around it. `remote` and `branch` are required even though the survey doesn't
fetch. `mosaic` must be able to read the file. Set `vaultPrefix` to her books repo's folder name
in her vault. Without it, a link with the full path from the top of her vault resolves to nothing,
and the setup sheet tells her to write links that way.
```sh
sudo -u mosaic -H env PATH="<node dir>:/usr/bin:/bin" \
/srv/mosaic/.local/share/mosaic-stack/copies/<id>/scripts/mosaic library survey <repo> --mapping <file>
echo "exit $?"
```
The usage line is `mosaic library survey <repo> [--mapping <file>]`. `--mapping <file>` is the only
flag, and it may appear once. `--mapping=<file>` and every other flag refuse.
## What the output holds
Plain text on stdout, one line per item:
| Line | What it holds |
|---|---|
| `commit <id>` | the 40-hex commit `HEAD` names |
| `entries <n>` | every entry in that commit's tree |
| `folders (direct children: md canvas base other)` | then one line per folder that holds a file directly: its path as a JSON string and four counts. `(root)` is the repo's top |
| `markdown <n> files, <n> read, ...` | `.md` files, how many were read, how many have no front matter, how many have front matter the reader can't fully parse |
| `front matter key sets` | then `<count> [<keys>]` per set of property names. A name that isn't a short identifier prints as `(other key)` |
| `body links plain .., label .., heading .., embed .., self .., markdown ..; attachment ..` | link forms in note bodies, and embeds of a target with an extension other than `.md` |
| `field appears_in wikilink .., name ..` | values written as links and as plain names. A `relationships` line follows |
With `--mapping` three lines follow:
| Line | What it holds |
|---|---|
| `scopes canon .., playground .., unmapped ..; indexed ..` | tree entries per scope, and notes indexed |
| `resolved root .., vault-prefix .., relative .., shortest .., unresolved ..` | links per resolver step. `unresolved` is the R4 count |
| `problems R1 .., ..., R7 ..` | problems per rule |
Folder paths and short property names are the only words from her repo in the output. In a folder
name, tab, newline, carriage return, backspace and form feed print as `\t`, `\n`, `\r`, `\b` and `\f`.
Any other control or bidi character prints as `\uXXXX`.
## Refusals
A refusal prints nothing on stdout and one line on stderr. The survey checks the mapping before the
repo, so a bad mapping gives exit 3 even when the repo path is wrong too.
| Exit | stderr | Cause | What to do |
|---|---|---|---|
| 4 | `library survey: usage: mosaic library survey <repo> [--mapping <file>]` | no repo, two repos, an unknown flag, `--mapping` without a file or twice | Fix the command |
| 3 | `library survey: library-mapping-invalid: <file> is not a readable JSON file` | missing, unreadable by `mosaic`, or not JSON | Fix the file or its mode |
| 3 | `library survey: library-mapping-invalid: <file> <reason>` | the entry breaks the schema. The reason names the key | Give it to Jason for the draft |
| 3 | `library survey: library-playground-outside-book: <file> universes.<u>.playground "<prefix>" is not inside one of its books` | a playground prefix outside its universe's books | Give it to Jason for the draft |
| 2 | `library survey: library-git: <repo> is not a directory` | wrong path | Check `<repo>` |
| 2 | `library survey: library-git: repository has no readable HEAD commit (project-git)` | not a repo, a folder inside one, another owner, no commits, a `HEAD` with no commit, or no `git` on `PATH` | Check each. If `<repo>` is the bus mirror, stop and report to Sage |
| 2 | `library survey: library-git: <other reason>`, such as `tree listing refused (project-git)` | git failed or ran past 100 seconds | Report to Sage |
| 2 | `library survey: library-commit: <reason>` | `HEAD` names something the reader can't take as a commit, for example in a SHA-256 repository | Report to Sage |
| 127 | a line ending `exec: node: not found` | `node` isn't in `<node dir>` | Fix `<node dir>` |
| other | a Node stack trace | a fault in the survey | Stop and report the exit code to Sage |
A refusal is the answer, not an obstacle. Don't run as another user, change modes on her repo or edit
its refs to get past one.
+15
View File
@@ -121,3 +121,18 @@ tells Mos before row b lands.
Gate: as row a, with section 10's row b tests and mutants, G27, G28 and the upsert mutant included, and
the creative refusal tests and mutants (writer and spend).
## Machine grant row b follow-up: the held stubs end with their test
The row 197 r8 D5 proof stopped on a leftover process on 2026-10-21. In
`packages/bus/tests/grant-executor.test.mjs`, the `add` stub waits for `release` when `hold-add`
is set. G23 ("a revoke during install") and G13 write `release`, finish, and `t.after` removes the
test dir. The stub polls every 50 ms, so it usually misses `release` and then loops forever on a
dir that is gone. Four runs of the file on Sage's slot left 7 stubs, every run 37/0.
Build: each hold loop in that file, `hold-add` and `hold-remove`, also stops when `$D` no longer
exists. The test file is the only change. Nothing under `src` changes.
Gate: Webby approves the commit. On Sage's gate the whole file passes three times in a row with no
stub left, checked by pgrep on the run's TMPDIR, and the bus suite is green. A mutant that drops the
dir check from `hold-add` leaves a stub behind.
+3 -2
View File
@@ -51,6 +51,7 @@ async function until(fn, ms = 10000) {
// The stubs read flag files in the test dir: fail-<verb> exits 1, noop-add exits 0 and writes nothing,
// hold-add waits for `release` (then exits 1 if `skip` is there), and hold-remove waits for `release`.
// Both also stop when the test dir is gone, since a held stub on the sshd side outlives its test (#1750).
// wrap.sh logs every request. The sudo stub logs its arguments to sudo.log and runs the command as the
// test's user.
async function sshd(t, dir) {
@@ -63,12 +64,12 @@ async function sshd(t, dir) {
stub('list', '[ -e "$D/fail-list" ] && exit 1\n[ -e "$D/granted" ] && grep " mosaic-[0-9a-f]*$" "$D/granted"\nexit 0');
stub(
'add',
'if [ -e "$D/hold-add" ]; then : > "$D/holding"; while [ ! -e "$D/release" ]; do sleep 0.05; done; [ -e "$D/skip" ] && exit 1; fi\n' +
'if [ -e "$D/hold-add" ]; then : > "$D/holding"; while [ ! -e "$D/release" ] && [ -d "$D" ]; do sleep 0.05; done; [ -e "$D/skip" ] && exit 1; fi\n' +
'[ -e "$D/fail-add" ] && exit 1\n[ -e "$D/noop-add" ] && exit 0\n' +
'grep -q " $4\\$" "$D/granted" 2>/dev/null && exit 0\necho "restrict $2 $3 $4" >> "$D/granted"',
);
stub('sudo', 'printf "%s\\n" "$*" >> "$D/sudo.log"\nshift 2\nexec "$@"');
stub('remove', 'if [ -e "$D/hold-remove" ]; then : > "$D/holding-remove"; while [ ! -e "$D/release" ]; do sleep 0.05; done; fi\n' +
stub('remove', 'if [ -e "$D/hold-remove" ]; then : > "$D/holding-remove"; while [ ! -e "$D/release" ] && [ -d "$D" ]; do sleep 0.05; done; fi\n' +
'[ -e "$D/fail-remove" ] && exit 1\n[ -e "$D/granted" ] || exit 0\ngrep -v " $2\\$" "$D/granted" > "$D/granted.tmp"\nmv "$D/granted.tmp" "$D/granted"');
writeFileSync(
join(dir, 'wrap.sh'),
+45 -5
View File
@@ -7,7 +7,7 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { tmp } from "./helpers.mjs";
@@ -77,10 +77,13 @@ args=(); for a in "$@"; do if [[ "$a" == /* ]] && [ -e "$ROOT_DIR$a" ]; then arg
HOME="$ROOT_DIR/srv/mosaic" PATH="$np$PATH" exec "$ROOT_DIR$cmd" "\${args[@]}"`,
};
// Executables inside the tree, run by the fake sudo or by the script itself.
// Executables inside the tree, run by the fake sudo or by the script itself. Node marks a run outside
// the fake sudo, which sets HOME to the stack's home, so a test can tell whether root ran it.
const NODE_STUB = `[ "$HOME" = "$ROOT_DIR/srv/mosaic" ] || touch "$HC/node-as-root"
[ "\${1:-}" = --version ] && { cat "$HC/node-version"; exit 0; }
exec "$REAL_NODE" "$@"`;
const TREE = {
[NODE]: `[ "\${1:-}" = --version ] && { cat "$HC/node-version"; exit 0; }
exec "$REAL_NODE" "$@"`,
[NODE]: NODE_STUB,
[CLAUDE]: `[ "\${1:-}" = --version ] && cat "$HC/claude-version"; exit 0`,
[CLI]: `${log("mosaic")}
case "$1" in
@@ -151,6 +154,9 @@ function host(t) {
for (const [name, text] of Object.entries(state)) put(join(hc, name), text);
for (const [name, body] of Object.entries(FAKES)) put(join(bin, name), `#!/usr/bin/env bash\n${name === "stat" ? "" : log(name)}\n${body}\n`, 0o755);
for (const [path, body] of Object.entries(TREE)) put(join(root, path), `#!/usr/bin/env bash\n${body}\n`, 0o755);
// F9 runs node only when it and each directory above it have no group or other write, whatever the umask.
for (let d = dirname(NODE); d !== "/"; d = dirname(d)) chmodSync(join(root, d), 0o755);
chmodSync(root, 0o755);
return { dir, root, hc, bin, args };
}
@@ -204,6 +210,7 @@ test("row 195 accept: every check passing exits 0, with each P mark and counts t
assert.match(r.out, /^F5 bwrap-label pass bwrap 300 reads 'bwrap \(enforce\)' and its claude descendant 302 reads/m);
assert.match(r.out, /^F7 pm-live pass one live launch: pm run r-0002$/m);
assert.ok(!(r.out + r.err).includes(CRED_VALUE));
assert.ok(existsSync(join(h.hc, "node-as-root")), "F9 runs the pinned, root-only node as root");
});
test("row 195 accept: exits 1 with each F check failing alone, and its line names it", (t) => {
@@ -274,7 +281,7 @@ test("row 195 accept: F5 fails with no bwrap of mosaic's, and passes on comm alo
assert.deepEqual(failed(mustRun(h)), []);
});
test("row 195 accept: F6 fails on a stub that prints nothing and on a path outside claude-<pin>", (t) => {
test("row 195 accept: F6 fails on a stub that prints nothing, on a path outside claude-<pin> and on a .. segment", (t) => {
let h = host(t);
put(join(h.hc, "claude-version"), "");
let r = mustRun(h);
@@ -286,6 +293,14 @@ test("row 195 accept: F6 fails on a stub that prints nothing and on a path outsi
r = mustRun(h);
assert.deepEqual(failed(r), ["F6"]);
assert.match(r.out, /^F6 claude-pin FAIL --claude \S+claude-2\.0\.9\S+ is not under \/opt\/mosaic-stack\/claude-2\.1\.0\/$/m);
// Under claude-<pin>/ by prefix, but the binary it names is 2.0.9's, which answers with the pin.
h = host(t);
const dotdot = CLAUDE.replace(`claude-${CLAUDE_PIN}/`, `claude-${CLAUDE_PIN}/../claude-2.0.9/`);
put(h.args, ARGS.map((a) => (a === CLAUDE ? dotdot : a)).join("\n") + "\n", 0o644);
put(join(h.root, other), `#!/usr/bin/env bash\necho "${CLAUDE_PIN} (Claude Code)"\n`, 0o755);
r = mustRun(h);
assert.deepEqual(failed(r), ["F6"], r.out);
assert.match(r.out, /^F6 claude-pin FAIL --claude \S+\/claude-2\.1\.0\/\.\.\/claude-2\.0\.9\/\S+ has a \.\. segment$/m);
});
test("row 195 accept: F7 fails on two live PM launches, one for another business, and no launch.by", (t) => {
@@ -328,6 +343,31 @@ test("row 195 accept: F9 fails on another owner, a group-writable mode and a num
}
});
test("row 195 accept: F9 won't run --node as root off the pin path or with a writable directory above it", (t) => {
// Another node that works for the stack's user: F9 refuses to run it, and F10 fails on the path.
let h = host(t);
const other = NODE.replace(NODE_PIN, "v24.0.9");
put(h.args, ARGS.map((a) => (a === NODE ? other : a)).join("\n") + "\n", 0o644);
put(join(h.root, other), `#!/usr/bin/env bash\n${NODE_STUB}\n`, 0o755);
let r = mustRun(h);
assert.deepEqual(failed(r), ["F9", "F10"], r.out);
assert.match(r.out, /^F9 autoupdater-off FAIL root won't run --node \/opt\/mosaic-stack\/node-v24\.0\.9\/bin\/node: it is not the --node-pin path$/m);
assert.ok(!existsSync(join(h.hc, "node-as-root")));
// The pinned path, under a group-writable directory and then a symlinked node: F9 alone fails, and
// root doesn't run it.
for (const [fault, bad] of [
[(x) => chmodSync(join(x.root, dirname(NODE)), 0o775), "/opt/mosaic-stack/node-v24\\.1\\.0/bin \\(uid \\d+, mode 775\\)"],
[(x) => { const f = join(x.root, NODE); renameSync(f, `${f}.real`); symlinkSync(`${f}.real`, f); }, "/opt/mosaic-stack/node-v24\\.1\\.0/bin/node \\(uid \\d+, mode 777\\)"],
]) {
h = host(t);
fault(h);
r = mustRun(h);
assert.deepEqual(failed(r), ["F9"], r.out);
assert.match(r.out, new RegExp(`^F9 autoupdater-off FAIL root won't run --node \\S+: ${bad} is not root's alone$`, "m"));
assert.ok(!existsSync(join(h.hc, "node-as-root")));
}
});
test("row 195 accept: F11 fails on one *.json under discord/ and passes on an empty directory", (t) => {
let h = host(t);
put(join(h.root, HOME, ".mosaic-dev", "discord", "chief.json"), "{}");
@@ -18,6 +18,7 @@ const REPO = fileURLToPath(new URL("../../../", import.meta.url));
const SCRIPT = join(REPO, "scripts", "case2-persona-import.mjs");
const FIXTURE = join(REPO, "packages", "cli", "tests", "fixtures", "case2-persona-import");
const TRACE = join(FIXTURE, "trace.mjs");
const MKDIR_RACE = join(FIXTURE, "mkdir-race.mjs");
const PAIRS = ["--pair", "default=Bean", "--pair", "chet=Chet"];
const sha256 = (data) => createHash("sha256").update(data).digest("hex");
const mode = (path) => statSync(path).mode & 0o777;
@@ -36,15 +37,15 @@ function scratch(t) {
return { root, hermes, configDir, staging: join(root, "staging"), trace: join(root, "trace.log") };
}
function run(argv, { trace } = {}) {
const pre = trace ? ["--import", TRACE] : [];
const env = { ...process.env, ...(trace ? { PERSONA_TRACE: trace } : {}) };
function run(argv, { trace, race } = {}) {
const pre = [...(trace ? ["--import", TRACE] : []), ...(race ? ["--import", MKDIR_RACE] : [])];
const env = { ...process.env, ...(trace ? { PERSONA_TRACE: trace } : {}), ...(race ? { PERSONA_MKDIR_RACE: race } : {}) };
const r = spawnSync(process.execPath, [...pre, SCRIPT, ...argv], { encoding: "utf8", env, timeout: 30_000 });
return { status: r.status, out: r.stdout, err: r.stderr, all: r.stdout + r.stderr };
}
const stage = (s, extra = [], opts) => run(["stage", "--hermes-root", s.hermes, ...PAIRS, "--staging", s.staging, ...extra], opts);
const install = (s, business = "case2") => run(["install", "--staging", s.staging, "--config-dir", s.configDir, "--business", business]);
const install = (s, business = "case2", opts) => run(["install", "--staging", s.staging, "--config-dir", s.configDir, "--business", business], opts);
// Every fixture marker and every line of fixture text the script reads.
function assertNoText(s, output) {
@@ -102,18 +103,38 @@ test("stage lists the per-channel prompts in all three places by name and byte c
assert.equal(r.status, 0, r.all);
const lines = r.out.split("\n").filter((l) => l.includes(": per-channel prompt "));
assert.deepEqual(lines, [
`profile default: per-channel prompt discord 111111111111111111, ${Buffer.byteLength("marker-prompt-top-5d02 Help with the novel.")} bytes`,
`profile default: per-channel prompt discord 222222222222222222, ${Buffer.byteLength("Ünïcode prompt")} bytes`,
`profile default: per-channel prompt "discord" "111111111111111111", ${Buffer.byteLength("marker-prompt-top-5d02 Help with the novel.")} bytes`,
`profile default: per-channel prompt "discord" "222222222222222222", ${Buffer.byteLength("Ünïcode prompt")} bytes`,
// Unquoted in the fixture, and past 2 ** 53: the id prints exactly.
"profile default: per-channel prompt discord 1333333333333333333, 5 bytes",
`profile default: per-channel prompt platforms.slack C0FIXTURE, ${Buffer.byteLength("marker-prompt-platforms-8e3a")} bytes`,
"profile default: per-channel prompt gateway.platforms.telegram -1001, 29 bytes",
"profile default: per-channel prompt gateway.platforms.telegram -1002, not text",
'profile default: per-channel prompt "discord" "1333333333333333333", 5 bytes',
`profile default: per-channel prompt "platforms.slack" "C0FIXTURE", ${Buffer.byteLength("marker-prompt-platforms-8e3a")} bytes`,
'profile default: per-channel prompt "gateway.platforms.telegram" "-1001", 29 bytes',
'profile default: per-channel prompt "gateway.platforms.telegram" "-1002", not text',
]);
assert.ok(r.out.includes("profile chet: no per-channel prompts"), r.out);
assertNoText(s, r.all);
});
// C2-20b N1 (webby-0073): a platform or channel key holding a newline prints as one quoted line, so
// it can't forge a line of the script's own output.
test("a config key with a newline prints as one quoted line", (t) => {
const s = scratch(t);
writeFileSync(join(s.hermes, "profiles", "chet", "config.yaml"), [
'"discord\\nstaged: /forged/bean.md":',
" channel_prompts:",
' "1\\npairing: profile chet is Bean": marker-prompt-forge-4c1e',
"",
].join("\n"));
const r = stage(s);
assert.equal(r.status, 0, r.all);
const lines = r.out.split("\n");
assert.deepEqual(lines.filter((l) => l.startsWith("profile chet: per-channel prompt ")), [
`profile chet: per-channel prompt "discord\\nstaged: /forged/bean.md" "1\\npairing: profile chet is Bean", ${Buffer.byteLength("marker-prompt-forge-4c1e")} bytes`,
]);
assert.ok(!lines.some((l) => l.startsWith("staged: /forged") || l.startsWith("pairing: profile chet is Bean")), r.out);
assertNoText(s, r.all);
});
test("a read trace shows stage and the dry run open SOUL.md and config.yaml and never memory, sessions or secrets", (t) => {
const s = scratch(t);
for (const argv of [["--staging", s.staging], ["--dry-run"]]) {
@@ -288,23 +309,66 @@ test("install refuses an open or foreign staging file, a digest off the manifest
assert.match(r.err, /the manifest doesn't list two personas/);
});
test("--dry-run writes nothing and prints only counts, two heading lines in a fixture counted", (t) => {
test("--dry-run writes nothing and prints only counts and the fence fault, two heading lines in a fixture counted", (t) => {
const s = scratch(t);
personaTree(s, { chet: "You are Chet. marker-chet-dry-7d21\n# Voice\nShort.\r\n## Habits\nNot # a heading, nor #nospace.\n" });
const before = readdirSync(s.root).sort();
const r = run(["stage", "--hermes-root", s.hermes, ...PAIRS, "--dry-run"]);
assert.equal(r.status, 0, r.all);
assert.equal(r.status, 1, r.all);
assert.deepEqual(readdirSync(s.root).sort(), before);
assert.deepEqual(r.out.split("\n").filter(Boolean), [
"files 2",
'profile default: lines 3, lines starting "# " or "## " 0, lines naming a Hermes tool or channel 0',
'profile chet: lines 5, lines starting "# " or "## " 2, lines naming a Hermes tool or channel 0',
'profile chet: persona holds </persona-file> or a line starting "# " or "## ", so C2-08\'s fence would refuse it',
]);
assert.equal(r.err, "");
assert.equal(r.err, "case2-persona-import: refused: C2-08's fence would refuse the persona of profile chet, so stage would write nothing\n");
assertNoText(s, r.all);
assert.ok(!r.all.includes("Voice") && !r.all.includes("Habits"));
});
// C2-20b N2 (webby-0073): a persona over the fence and under the byte cap used to pass the dry run
// and then refuse at stage. The dry run names the fault and refuses too, and passes at the limit.
test("--dry-run applies C2-08's fence: over it the fault prints, nothing is written and it exits 1", (t) => {
const atLimit = "é".repeat(PERSONA_CHARS);
const s = scratch(t);
personaTree(s, { bean: atLimit + "é" });
const before = readdirSync(s.root).sort();
const r = run(["stage", "--hermes-root", s.hermes, ...PAIRS, "--dry-run"]);
assert.equal(r.status, 1, r.all);
assert.deepEqual(readdirSync(s.root).sort(), before);
assert.deepEqual(r.out.split("\n").filter(Boolean), [
"files 2",
'profile default: lines 1, lines starting "# " or "## " 0, lines naming a Hermes tool or channel 0',
"profile default: persona is over 8000 characters, so C2-08's fence would refuse it",
'profile chet: lines 2, lines starting "# " or "## " 0, lines naming a Hermes tool or channel 0',
]);
assert.match(r.err, /refused: C2-08's fence would refuse the persona of profile default, so stage would write nothing/);
assert.ok(!r.all.includes("éé"));
personaTree(s, { bean: atLimit });
const ok = run(["stage", "--hermes-root", s.hermes, ...PAIRS, "--dry-run"]);
assert.equal(ok.status, 0, ok.all);
assert.equal(ok.err, "");
});
// C2-20b N4 (webby-0073): a directory made between privateDir's lstat and its mkdir refuses with a
// named reason, not a raw EEXIST. mkdir-race.mjs makes the directory just before the script's mkdir.
test("a directory made between the check and mkdir refuses by name, at stage and at install", (t) => {
const s = scratch(t);
let r = stage(s, [], { race: s.staging });
assert.equal(r.status, 1, r.all);
assert.equal(r.err, `case2-persona-import: refused: ${s.staging} was made by something else between the check and mkdir\n`);
assert.deepEqual(readdirSync(s.staging), []);
const s2 = scratch(t);
assert.equal(stage(s2).status, 0);
const dir = join(s2.configDir, "personas", "case2");
r = install(s2, "case2", { race: dir });
assert.equal(r.status, 1, r.all);
assert.equal(r.err, `case2-persona-import: refused: ${dir} was made by something else between the check and mkdir\n`);
assert.deepEqual(readdirSync(dir), []);
});
test("--dry-run counts three lines naming a Hermes tool or channel and prints none of the words", (t) => {
const s = scratch(t);
personaTree(s, {
@@ -0,0 +1,13 @@
// mkdir race for case2-persona-import.test.mjs: loaded with --import, it makes the directory named by
// $PERSONA_MKDIR_RACE just before the script's own mkdirSync of that path, as if another process won
// the race after the script's lstat. The script's mkdir then meets a real EEXIST.
import fs from "node:fs";
import { syncBuiltinESMExports } from "node:module";
const { mkdirSync } = fs;
const target = process.env.PERSONA_MKDIR_RACE;
fs.mkdirSync = function raced(p, ...rest) {
if (String(p) === target) mkdirSync(p, { mode: 0o700 });
return mkdirSync.call(this, p, ...rest);
};
syncBuiltinESMExports();
+26 -4
View File
@@ -7,9 +7,13 @@
# Root runs it on the Case 1 host after the install window, from a root-owned checkout. It reads the
# user, home, copy, node, claude and both ports from the args file, which must pass stack-upgrade.sh's
# rules and be a factory file for --business. Every command that runs the stack's code runs as
# `sudo -u USER -H env PATH=<node dir>:/usr/local/bin:/usr/bin:/bin` from /. Root itself only reads
# systemd, ss, /proc, the account database and file metadata. It changes nothing on the host but the
# message F3 sends and its reply, and it never opens claude-session.env.
# `sudo -u USER -H env PATH=<node dir>:/usr/local/bin:/usr/bin:/bin` from /. Root itself reads ss,
# /proc, the account database and file metadata, and runs these: systemctl show and is-enabled (F1,
# P11), curl on the Console's loopback healthz (F2), sudo -l (P1, P3), sudo -u jarvis bwrap --version
# and hostcheck.sh --check (P9), and node to parse managed-settings.json (F9). F9 runs node only when
# --node is the pinned path and it and every directory above it are root's with no group or other
# write. The script changes nothing on the host but the message F3 sends and its reply, and it never
# opens claude-session.env.
#
# One line per check, `<id> <path> <mark> <evidence>`, then `count <mark> N` for each mark and a
# result line. F checks are `pass` or `FAIL`. P checks are `closed`, `accepted <ruling>` or
@@ -259,6 +263,7 @@ check_f4() {
check_f6() {
local want="/opt/mosaic-stack/claude-$CLAUDE_PIN/" v
[[ "$CLAUDE" == "$want"* ]] || { ffail F6 claude-pin "--claude $CLAUDE is not under $want"; return; }
[[ "$CLAUDE/" != */../* ]] || { ffail F6 claude-pin "--claude $CLAUDE has a .. segment"; return; }
v="$("${AS_USER[@]}" "$CLAUDE" --version 2>/dev/null | sed -n 1p)"
if [ "$v" = "$CLAUDE_PIN (Claude Code)" ]; then fpass F6 claude-pin "$CLAUDE prints '$v'"
else ffail F6 claude-pin "$CLAUDE --version prints '$(clean "$v")', not '$CLAUDE_PIN (Claude Code)'"; fi
@@ -291,10 +296,27 @@ check_f8() {
else ffail F8 credential-file "$CRED is '$s', not '$S_USER 600 regular file'"; fi
}
# Empty when $1 and every directory above it, up to /, are the running user's (root's on the host) with
# no group or other write: case2-unit.sh's root_only rule for --node, checked again before root runs it.
# Otherwise the first path that breaks the rule. A symlink breaks it, because its mode is 777.
not_root_only() {
local p="$1" st
while :; do
st="$(stat -c '%u %a' -- "$ROOT$p" 2>/dev/null)" || { echo "$p (cannot stat)"; return; }
[ "${st% *}" = "$(id -u)" ] && (( (8#${st#* } & 8#022) == 0 )) || { echo "$p (uid ${st% *}, mode ${st#* })"; return; }
[ "$p" = / ] && return
p="$(dirname -- "$p")"
done
}
check_f9() {
local s v
local s v bad
s="$(stat -c '%U:%G %a %F' -- "$ROOT$SETTINGS" 2>/dev/null)" || s="missing"
[ ! -L "$ROOT$SETTINGS" ] && [ "$s" = "root:root 644 regular file" ] || { ffail F9 autoupdater-off "$SETTINGS is '$s', not 'root:root 644 regular file'"; return; }
# Root runs node here, so it runs only the pinned path, and only when nothing but root can replace it.
[ "$NODE" = "/opt/mosaic-stack/node-$NODE_PIN/bin/node" ] || { ffail F9 autoupdater-off "root won't run --node $NODE: it is not the --node-pin path"; return; }
bad="$(not_root_only "$NODE")"
[ -z "$bad" ] || { ffail F9 autoupdater-off "root won't run --node $NODE: $bad is not root's alone"; return; }
v="$("$ROOT$NODE" -e 'const s=JSON.parse(require("node:fs").readFileSync(process.argv[1],"utf8"));process.stdout.write(JSON.stringify(s?.env?.DISABLE_AUTOUPDATER??null))' -- "$ROOT$SETTINGS" 2>/dev/null)" || v="unreadable"
if [ "$v" = '"1"' ]; then fpass F9 autoupdater-off "$SETTINGS is root:root 0644 with env.DISABLE_AUTOUPDATER \"1\""
else ffail F9 autoupdater-off "$SETTINGS env.DISABLE_AUTOUPDATER is $v, not \"1\""; fi
+16 -5
View File
@@ -18,9 +18,11 @@
// staged file against the manifest and C2-08's fence, and writes
// <configDir>/personas/<business>/<instance>.md at 0600. It never overwrites.
//
// Output is names, byte counts and sha256, never persona or prompt text. --dry-run writes nothing
// and prints counts only: files, lines, lines starting "# " or "## ", and lines naming a Hermes tool
// or channel from HERMES_WORDS. A fence fault there is counted, not refused.
// Output is names, byte counts and sha256, never persona or prompt text. A key from config.yaml
// prints as a JSON string, so it can't start a line of its own. --dry-run writes nothing and prints
// counts only: files, lines, lines starting "# " or "## ", and lines naming a Hermes tool or channel
// from HERMES_WORDS. It applies C2-08's fence as stage does, prints each persona's fault by its
// reason and then refuses. A SOUL.md over four times the fence in bytes refuses before any count.
//
// Exit codes: 0 ok, 1 refused, 2 usage.
@@ -184,7 +186,12 @@ function privateDir(path, { create }) {
const s = lstatSync(path, { throwIfNoEntry: false });
if (!s) {
if (!create) throw new Refusal(`${path} doesn't exist`);
mkdirSync(path, { mode: 0o700 });
try {
mkdirSync(path, { mode: 0o700 });
} catch (e) {
if (e.code === "EEXIST") throw new Refusal(`${path} was made by something else between the check and mkdir`);
throw e;
}
chmodSync(path, 0o700);
return;
}
@@ -221,10 +228,14 @@ function stage(o, say) {
if (o.dryRun) {
say(`files ${personas.length}`);
const faulted = [];
for (const p of personas) {
const c = counts(p.text);
say(`profile ${p.profile}: lines ${c.lines}, lines starting "# " or "## " ${c.headings}, lines naming a Hermes tool or channel ${c.words}`);
const fault = personaTextFault(p.text);
if (fault) { say(`profile ${p.profile}: persona ${fault}, so C2-08's fence would refuse it`); faulted.push(p.profile); }
}
if (faulted.length) throw new Refusal(`C2-08's fence would refuse the persona of profile ${faulted.join(" and ")}, so stage would write nothing`);
return;
}
@@ -234,7 +245,7 @@ function stage(o, say) {
for (const p of pairs) {
const prompts = config(p);
if (!prompts.length) say(`profile ${p.profile}: no per-channel prompts`);
for (const c of prompts) say(`profile ${p.profile}: per-channel prompt ${c.where} ${c.channel}, ${c.bytes === null ? "not text" : `${c.bytes} bytes`}`);
for (const c of prompts) say(`profile ${p.profile}: per-channel prompt ${JSON.stringify(c.where)} ${JSON.stringify(c.channel)}, ${c.bytes === null ? "not text" : `${c.bytes} bytes`}`);
}
for (const p of personas) {
const fault = personaTextFault(p.text);