Compare commits

...
Author SHA1 Message Date
jason.woltje faf55618ee test(row320): reproduce an unsearchable inherited cwd and restore cleanup (#1816). Untested 2026-10-23 16:26:32 -05:00
jason.woltje 6fa47e07d9 docs(row 320, #1816): move Gladstone build receipt to queue path 2026-10-23 16:18:11 -05:00
jason.woltje af201b4a98 docs(build): correct the citation receipt line count (#1816) 2026-10-23 16:04:38 -05:00
jason.woltje 0f3d874d48 docs(installer): cite the wrapper live-step gate (#1816, sage-3747) 2026-10-23 16:04:23 -05:00
jason.woltje 075a2b3667 docs(installer): add gated wrapper creation and private notify parent (#1816, Vikunja 614). Untested 2026-10-23 16:02:31 -05:00
jason.woltje 5fd2328b62 test(installer): add approved explicit notifier fixtures (#1816, mos-1537). Untested 2026-10-23 16:01:59 -05:00
jason.woltje 33950cf9ba fix(precheck): require the explicit notifier decision before host start (#1816, Vikunja 614). Untested 2026-10-23 15:59:56 -05:00
jason.woltje a75532db18 fix(installer): allow scoped Claude paths and leave unreadable cwd (#1816, Vikunja 614). Untested 2026-10-23 15:56:19 -05:00
jason.woltje f30c74b128 fix(hostcheck): accept dpkg octal mode and verify bundles in scratch repos (#1816, Vikunja 614). Untested 2026-10-23 15:53:11 -05:00
jason.woltje 93694c5185 deferred: row 283 Webby N1, SURVEY-RUN.md:96 shows the scopes line without supporting; bound to the next docs commit on that file (webby-0267, #1788); docs only 2026-10-23 15:38:05 -05:00
jason.woltje 8d2856ee3c row 283 (#1788): BUILD.md: G1 per sage-3713: mos-1528 OKs the mapping.test.mjs:52 draft deepEqual gaining supporting: [] (old :48 at 45dca69f6, new :52), recorded as an append that completes BUILD.md:42; doc only 2026-10-23 15:17:26 -05:00
jason.woltje 252b29bd8c row 283 (#1788): BUILD.md: T2 receipts on c18ad3e12 (sage-3695): base 16+6+6+7 library and 21 business green, 14/14 mutants killed on their named tests, library 98/98 and business 231/231; the mos-1272 first-run red and its test-helper fix; doc only 2026-10-23 15:17:26 -05:00
jason.woltje 54d9e07bb7 row 283 (#1788): mos-1272 first-run red: supporting.test's refuses() labelled each assertion with JSON.stringify(path), which throws on the 1n case added for E3; it now uses util.inspect. Test helper only 2026-10-23 15:17:25 -05:00
jason.woltje de6419f30f row 283 (#1788): sage-2925/sage-3649: optional editor.user (her Gitea login for library-human, read by row 288), checked with USER as the entry user is, kept in the output only when given; T3 accepted and refused cases; README; mutants M6e/M6f; BUILD.md records the departure from DESIGN.md section 1. Untested 2026-10-23 15:17:25 -05:00
jason.woltje 9ed0f3374a row 283 (#1788): webby-0236 E5: the git round-trip test drops GIT_DIR, GIT_WORK_TREE, GIT_COMMON_DIR and GIT_INDEX_FILE and reads no global or system config (repo convention); BUILD.md records E5. Untested 2026-10-23 15:17:25 -05:00
jason.woltje efcd3f0fc4 row 283 (#1788): E4 per sage-3606: editor values starting or ending with git's crud() set (bytes <= 0x20 and , : ; < > " \ ') refuse library-mapping-invalid; T3 cases per character and key, inner chars pinned, git round-trip test; mutants M6c/M6d; BUILD.md cites crud() and records the claim (rev 3642). Untested 2026-10-23 15:17:25 -05:00
jason.woltje b358427677 row 283 (#1788): E2 per sage-3601: editor values with surrounding whitespace refuse library-mapping-invalid (git trims idents at commit); T3 padded cases per key, mutant M6c; BUILD.md records the departure from DESIGN.md section 1 and the fixture grep (none). Untested 2026-10-23 15:17:25 -05:00
jason.woltje 675813594f row 283 (#1788): webby-0227 E3: supportingPath checks the type before stringifying, so a non-string path refuses library-write-story-path; string case covers 7, null, undefined, 1n and an array; BUILD.md records E1-E3, E2 to Sage. Untested 2026-10-23 15:17:25 -05:00
jason.woltje 3f1aa76b03 row 283 (#1788): BUILD.md: drift check, N3 grep, mos-1330 :73 old and new text, Q2 tradeoff, Q4 handoff to row 288 via #1793 c30738, mutants M1-M8 exact strings, gate plan; doc only 2026-10-23 15:17:25 -05:00
jason.woltje bcd1f34c2d row 283 (#1788): survey.test:73 re-pin under mos-1330 item 4. The scopes line gains only supporting 0; old 'scopes canon 5, playground 1, unmapped 3; indexed 6', new 'scopes canon 5, playground 1, supporting 0, unmapped 3; indexed 6' 2026-10-23 15:17:25 -05:00
jason.woltje c87ddc6041 row 283 (#1788): C2-01d supporting folders. supporting lists on every universe kind with the playground's prefix rules, library-supporting-other-universe and the cross-list rules; optional editor {name, email} refusing controls, < and >; classify checks supporting before playground; supportingPath with the eight checks over the listTree array, exported; survey counts supporting (sage-2923); README; tests T1-T8. Untested: runs wait for Sage's CLEAR 2026-10-23 15:17:25 -05:00
jason.woltje ce4040542f row 283 (#1788): design draft 2 folds webby-0169 R1 (editor refuses < and >, T3, M6b) and N1 (check 8 compares NFC and case, NFD case in T6, M4b); Q2-Q4 accepted, Q4 handoff to C2-02 in BUILD.md; doc only 2026-10-23 15:17:25 -05:00
jason.woltje f2f76fd2c3 row 283 (#1788): design draft 1. supporting lists with the playground's prefix rules and library-supporting-other-universe, cross-list rules, the optional editor, supporting first in classify, supportingPath over the listTree array; survey.mjs departure Q1 for Sage, Q2-Q5 for Webby; doc only (sage-2906) 2026-10-23 15:17:25 -05:00
jason.woltje 59226f878f queue: revs 3637-3677: row 226 in-review and failed request (gosalyn 3637-3638), request (sage 3639-3640), approve record (webby 3641), landing note and done (sage 3665-3666); row 283 in-progress (honker 3642), E1 note, in-review and failed request (honker 3671-3673), request (sage 3674-3675, comment 31337), approve record (webby 3677); owner assigns 217 225 298 296 297 288 227 (sage 3643-3649); 225 and 217 after sets, 288 note (sage 3650-3652); row 227 in-progress (gizmoduck 3653); rows 220 and 216 in-progress, in-review and failed requests (researcher 3654-3659), requests (sage 3661-3664, comments 31321 31322); row 298 in-progress (boyd 3660), in-review and failed request (boyd 3667-3668), request (sage 3669-3670, comment 31336); row 319 GitHub consumer added (sage 3676, mos-1525) 2026-10-23 15:16:03 -05:00
jason.woltje f67e5c15c5 docs: brief for the GitHub consumer of row 297's protected githubWithToken callback, honoring expiry and cancellation, post-10-27 (mos-1525, #1803); docs only 2026-10-23 15:10:34 -05:00
jason.woltje e2b8733904 deferred: row 226 Webby N1-N4 (install id regex, first-run refusal leftovers, clone sha on the run sheet, library-status lock wait), webby-0225, #1727 2026-10-23 14:42:22 -05:00
jason.woltje fd3d2a99fe docs: host run sheet section 2 verifies Node's SHASUMS256.txt with the detached SHASUMS256.txt.sig, not the clearsigned .asc (Vikunja 611, ops-0243, mos-1514, mos-1515); docs only 2026-10-23 14:41:39 -05:00
jason.woltje 661890e909 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-23 14:41:39 -05:00
jason.woltje 3046fd60a3 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-23 14:41:39 -05:00
jason.woltje b97f9df1ee 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-23 14:41:39 -05:00
jason.woltje 8352da88c5 agents: register Beakley, Fethry, Boyd, Lena, Violet and Bubba as seats so the queue can name them as row owners (Q245, mos-1506) 2026-10-23 14:18:27 -05:00
37 changed files with 4014 additions and 105 deletions

No files matched your search

+12
View File
@@ -5411,3 +5411,15 @@ Row 299 (573, #1806, Gander, Filbert): before, the P10 band test timed a fault r
2026-10-23T17:29:46Z | Gyro Vikunja571 sage-3542 Connections amendment | Branch gyro/editor-connections-design cut exactly from landed PRD1.17 30e6bb680; read REQ-LIB-5 in that PRD. Replaced stale J-b waits in parent and W4. Proposed W8 owns current authorized Markdown/DOCX links/backlinks list, labels, versioned refresh, leakage refusals and fixture tests. Build order under mos-1470/comment961 is W7 closed routing candidate, W8 landing, then W7 human first-real-import opening. No REQ-LIB-8 condition or acceptance change. Q234/mos-1363/S0 M1-M14 and post-10-27/MVP priority fixed. Static diff, twelve brief sections and append-only checks pass. No repo Node, tests, build, browser, queue mutation or live action. Fenton reviews after authorized docs branch push; Sage lands on approval.
2026-10-23T17:32:37Z | Gyro sage-3544 fenton-0016 N1/N2 delta | Before: W7 Gate/final real-conversion list omit the W8 build-order cross-reference and W8 human-view filtering cites agent settings. After: both locations explicitly put W8 landing before human opening as mos-1470 build order, not acceptance; comment/tracked-deletion links are not current story links and never appear in the human Connections view regardless of agent settings. New commit atop approved 5af060fb5, no amend. Static diff and append-only checks pass. Docs only, no repo Node/tests/live action. Fenton checks only this delta.
2026-10-23 | Gladstone | Gitea #1816 / Vikunja 614 hostcheck item 5 and bundle verification | Before: dpkg's correct 750 form falsely fails item 5, and two run-sheet bundle verifies require a missing repository. Draft after: exact root/mosaic/path with octal 0750 equivalence; stat/output unchanged; both verifies use cleaned-up bare scratch repos. Three additive-only test blocks, two planned mutants. Untested under Sage-3738; Sage-3740 authorizes untested branch push for static review. Queue row pending; Sage owns gate and landing.
2026-10-23 | Gladstone | #1816 / Vikunja 614 scope amendment mos-1534, sage-3741 | F1 narrowed to exact 750/0750 and single spaces; F3 allows @ in absolute unit paths with unsafe-character refusals retained; F4 moves copy_tree to / after path resolution. AS_USER audit found git -C and stack-release's existing checkout cd cover the other steps. Added new unit and unreadable-cwd copy tests only. Untested under Sage's no-Node hold; no live action.
2026-10-23 | Gladstone | #1816 / Vikunja 614 F5 draft (mos-1536) | Before: the run sheet omits the explicit notify decision and precheck misses a bus-start refusal. After: section 9a creates a private null-binding decision as mosaic; precheck directly reuses readNotifyConfig and private_dir, and new tests cover accepted and refused decisions. Existing fixture setup additions await Mos approval through Sage; installer unchanged. Untested under no-Node hold.
2026-10-23 | Gladstone | #1816 / Vikunja 614 fixture ruling mos-1537 | Added only three setup lines each to host(t)/world(t) for the explicit null notifier decision. Existing assertions/fakes/lines intact. New install test checks decision bytes/mode preserved and missing decision remains absent. F5 missing-file fault removes the setup file. Untested under Sage's hold; separate follow-up atop 33950cf9b.
2026-10-23 | Gladstone | #1816 / Vikunja 614 F6 and notify parent (mos-1537) | Before: the run sheet's copy check lacks its required CLI wrapper; mkdir -p -m does not guarantee the notify parent mode. After: a separately Mos-gated wrapper --create step follows install and precedes check, as mosaic from cwd /; section 9a explicitly creates both notify directories at 0700. Docs only, no live action; Node/tests still held.
2026-10-23 | Gladstone | Row320 / #1816 first review CHANGES (morgana-0217) | Reviewer run: hostcheck19/19, unit47/47, install24/24, copy17/18; seven of eight mutants killed, M4 survived because both base/mutant failed only cleanup. Fixed this row's F4 test to enter cwd then chmod000, assert no read/search, pass an absolute outside bundle, and restore0700 in finally. Existing tests/setup and production scripts unchanged. Correction untested locally pending Morgana's focused rerun; no acceptance claim.
+8
View File
@@ -0,0 +1,8 @@
# Beakley
Builder seat, Codex gpt-6.1-sol, started by Mos under Q245 (mos-1506). Owns row 217 (C2-06, handed over from Launchpad).
No launcher in this directory. The seat runs as a T3 thread (thread 88b97f65) and works in its own worktree. It has no
credentials of its own; it pushes only its own `beakley/*` branches through the repository credential helper (mos-0981),
never `refactor`, `main` or another seat's branch. Claude seats orchestrate and review (Q245). Registered by Sage on
2026-10-23 so the queue can name the seat as row owner (store.mjs isSeatDir).
+8
View File
@@ -0,0 +1,8 @@
# Boyd
Builder seat, Codex gpt-6.1-sol, started by Mos under Q245 (mos-1506). Owns row 298 (the chief-of-staff grant scope, handed over from Megavolt).
No launcher in this directory. The seat runs as a T3 thread (thread 48a3ae38) and works in its own worktree. It has no
credentials of its own; it pushes only its own `boyd/*` branches through the repository credential helper (mos-0981),
never `refactor`, `main` or another seat's branch. Claude seats orchestrate and review (Q245). Registered by Sage on
2026-10-23 so the queue can name the seat as row owner (store.mjs isSeatDir).
+8
View File
@@ -0,0 +1,8 @@
# Bubba
Builder seat, Codex gpt-6.1-sol, started by Mos under Q245 (mos-1506). Owns row 288 (agent write notices and library revert, handed over from Honker).
No launcher in this directory. The seat runs as a T3 thread (thread 3d6672b5) and works in its own worktree. It has no
credentials of its own; it pushes only its own `bubba/*` branches through the repository credential helper (mos-0981),
never `refactor`, `main` or another seat's branch. Claude seats orchestrate and review (Q245). Registered by Sage on
2026-10-23 so the queue can name the seat as row owner (store.mjs isSeatDir).
+8
View File
@@ -0,0 +1,8 @@
# Fethry
Builder seat, Codex gpt-6.1-sol, started by Mos under Q245 (mos-1506). Owns row 225 (C2-10b, handed over from Morgana).
No launcher in this directory. The seat runs as a T3 thread (thread 14f80e3c) and works in its own worktree. It has no
credentials of its own; it pushes only its own `fethry/*` branches through the repository credential helper (mos-0981),
never `refactor`, `main` or another seat's branch. Claude seats orchestrate and review (Q245). Registered by Sage on
2026-10-23 so the queue can name the seat as row owner (store.mjs isSeatDir).
+304
View File
@@ -0,0 +1,304 @@
# Hostcheck item 5 and bundle verification (Gitea #1816, Vikunja 614)
Queue row pending from Sage. Owner Gladstone, reviewer Morgana, gate/landing Sage.
Base: fetched origin/refactor 93694c5185d0a7f989d2fe78fb620e44599b744e;
branch gladstone/hostcheck-item5. Scope: mos-1531 / ops-0256, sage-3738 and sage-3740.
## Before and after
Ubuntu 24.04 dpkg 1.22.6ubuntu6.6 prints `root mosaic 750 /usr/bin/bwrap`.
Item 5 compared the literal `0750`, falsely refusing a correctly configured host;
--enforce would unload the profile, close bwrap and record DRIFT.
The candidate accepts exactly four fields, root, mosaic, octal 0750, and $BWRAP.
`0*750` represents all octal spellings numerically equal to 0750 without arithmetic
conversion/overflow. Horizontal whitespace separates fields; extra fields and
embedded newlines refuse. Owner, group and path remain exact. The stat comparison
and existing output strings remain unchanged. Only the item 5 comment also changes.
The two release-transport bundle checks previously ran outside any repository.
Each now creates an empty bare scratch repository, verifies with git -C, and removes
the scratch directory using an EXIT trap. A failure exits the shell before cloning
or fetching. No other run-sheet text changes.
## Tests and mutants
Only packages/cli/tests/case1-hostcheck.test.mjs changes among tests, by appending
three new test() blocks; the existing passing 0750 fake and failing fake are intact.
The pass test exercises 750, 0750 and 000750. The enforce test checks no unload,
chown, chmod, DRIFT or hostcheck log for 750. Refusals independently cover 755,
0755, 4750, 75, 7500, 075, non-octal strings, other owner/group/path, missing path,
extra fields, multiple lines, blank/empty output and a missing fixture file.
| Mutant | Change | Killer | Result |
|---|---|---|---|
| M1 | Restore the old literal root mosaic 0750 comparison (drop 750) | new check pass test | Unrun |
| M2 | Accept any mode while preserving owner/group/path | new refusal test | Unrun |
Mutant procedure: preserve script bytes, assert one replacement, run the named
killer alone, require failure for its intended assertion, restore in finally and
check the original bytes/hash. Run the original targeted file again after restore.
## Verification status
Untested. Sage-3738 prohibits all Node until CLEAR because the joint full gate
runs alone. Sage-3740 explicitly directs pushing this untested build for Morgana's
static review before CLEAR. This supersedes test-first execution for this draft;
no Node, tests, node --check, full suites or live-host operations have run.
Queue next refused linked-worktree execution; Sage owns queue creation/movement.
Push only gladstone/*, with MOSAIC_GITEA_CREDENTIAL_FILE unset; no amend/force.
Static draft receipt: `git diff --check` passed. Test-file numstat against the
base is `46 0 packages/cli/tests/case1-hostcheck.test.mjs` (added/deleted).
## Scope and implementation amendment (mos-1534, sage-3741)
F1 correction: Sage requires precisely `750` or `0750` and exactly single spaces,
so the candidate now uses `0?750` and literal spaces. It refuses 00750/000750,
a doubled space and a tab. This supersedes the initial numeric-equivalence design
above. Only this row's own appended tests change; no base test line changes.
F3: case2-unit.sh plain() adds @. abs_plain is its only caller and requires an
absolute normalized path, so @ cannot become an ExecStart prefix. In sed @ is
ordinary replacement text (the delimiter is |); &, backslash and | still refuse.
Environment=MOSAIC_CLAUDE_BIN carries @ literally; the parser compares the complete
value, with no shell evaluation. @anthropic-ai also cannot match the residual
@[A-Z_]+@ token check. Tests render/reinstall with a regular executable under
@anthropic-ai, checking Environment and the new copy's WorkingDirectory. Since a
nonroot test cannot create root ownership, a stat fake models root:0755 only for
that file and its ancestor chain. The real file type/executable checks, render,
installed-unit parser, and all other stat calls remain active. A separate block
refuses space, %, $, both quote characters, backslash, & and |.
F4: copy_tree changes to / before archive/npm/find, after all its path arguments
are absolute. copy_bundle retains its existing relative-bundle resolution before
calling copy_tree. Checkout copies have absolute REPO and COPIES too. Source-only
use from stack-release.sh changes no cwd because copy_tree is not called there.
Audit of every AS_USER command in stack-upgrade.sh: fetch and merge use git -C;
plan, gate, revert, confirm-rollback, stage (activate/rollback), confirm and rehearse
invoke stack-release.sh, which changes to its checkout at line 34. No other
AS_USER command needs a fix. The new test enters a directory mode 0111, verifies
that ls cannot read it, then runs both a relative-bundle copy and checkout copy,
asserting success, no find restore error, and an immutable completed copy.
Additional mutants (unrun under the same no-Node hold):
| Mutant | Change | Killer | Result |
|---|---|---|---|
| M3 | Drop @ from plain() | new factory scoped Claude-path test | Unrun |
| M4 | Remove copy_tree's cd / | new unreadable-cwd copy test | Unrun |
All four fixes remain untested. Follow-up is an additive commit on the pushed
branch, per sage-3741, with no amend or force. Morgana's static F1/F2 read of
f30c74b12 was CLEAN (morgana-0205); F3/F4 and execution remain open.
Morgana N1: the outer bundle-verification failure now prints
`STOP: bundle verify failed; do not continue` instead of exiting the interactive
root shell. The subshell still returns failure and cleans up. This follows the
sheet's human read-and-stop convention and avoids sending remaining pasted lines
to a parent shell after exiting sudo -i. Inner mktemp failure still exits only the
subshell. This supersedes the initial outer-shell failure behavior noted above.
## File numstat against 93694c518 (added / deleted)
| File | Added | Deleted |
|---|---:|---:|
| `BUILD-LOG.md` | 4 | 0 |
| `agents/gladstone/work/hostcheck-item5/BUILD.md` | 114 | 0 |
| `docs/SESSIONS.md` | 1 | 0 |
| `docs/guides/host-run-sheet.md` | 12 | 2 |
| `packages/cli/tests/case1-hostcheck.test.mjs` | 48 | 0 |
| `packages/cli/tests/case2-unit.test.mjs` | 29 | 0 |
| `packages/cli/tests/stack-copy.test.mjs` | 16 | 0 |
| `scripts/case1-hostcheck.sh` | 2 | 2 |
| `scripts/case2-unit.sh` | 1 | 1 |
| `scripts/stack-copy.sh` | 3 | 0 |
## F5 (mos-1536 / ops-0260): explicit notifier decision
Before: no run-sheet step creates the required notifier decision, and precheck
accepts a host whose bus will refuse missing notify.json. After: section 9a
requires the mosaic user to create its business notify directory at 0700 and an
explicit {"notifyVersion": 1, "binding": null} file at 0600 before section 10.
The shell runs with cwd / and umask 077; noclobber refuses an existing decision.
Named binding is explicitly a later per-host choice requiring Mos's go.
case2-install.sh is unchanged and makes no silent default.
case2-precheck.sh's existing facts helper dynamically imports readNotifyConfig
from cli.mjs and calls it directly. File type, uid, exact 0600 mode, JSON keys,
version and binding-name validation therefore stay identical to the bus. The
shell checks the business notify directory with existing private_dir (same uid,
not a symlink, exactly 0700) and prints the new notifier config line. No notifier
is started and no configuration is written. Tests append pass cases for null and
a valid named binding, and refusal cases for missing file, 0644, 0755 directory,
extra key, malformed JSON, invalid binding/version and a symlinked file.
M5 (unrun): remove the notifier shell check, including its refusal branch; the
new notifier refusal test must fail. Also include Morgana's F3 negative mutant
M3b (allow $ in plain), killed by the new unsafe-character test.
Existing positive precheck tests need an explicit notify fixture. Approval
requested via Sage in gladstone-0006/-0007 for minimal setup-only additions to
case2-unit.test.mjs host(t) and case2-install.test.mjs world(t); assertions remain
unchanged. Those additions are not yet applied. Correction to gladstone-0007:
case1-accept.test.mjs also names case2-precheck.sh, but uses a stand-in; it does
not execute the changed script and needs no fixture amendment.
Execution remains held by Sage: no Node/tests run. Morgana-0207 static F1-F4
verdict on a75532db1 is CLEAN; execution and F5 review are still open.
F5 acceptance from mos-1536 (for review):
1. Before section 10, as mosaic, explicitly create <dataRoot>/notify/<business>
at 0700 and notify.json at 0600 with default binding null; a later named
binding needs Mos's go. Do not make case2-install.sh write it.
2. Precheck must refuse missing file, non-0600 file, directory looser than 0700
and a document outside the CLI's exact shape. Reuse/match cli.mjs validation,
and name the new line in this run-sheet step's Precheck entry.
3. Append-only test blocks cover pass, missing, 0644, 0755 dir, extra key; record
a skip-check mutant, the touched test file and its numstat.
4. F3/F4 precede F5, each follow-up commit stays on gladstone/hostcheck-item5;
send pushes to Sage/Morgana with Mos cc. No Node until Sage CLEAR.
Static F5 draft receipt: git diff --check passed. Existing fixture approvals
remain pending; F5's new pass test creates its own explicit decisions. No claim
that the old positive precheck tests pass without the pending setup changes.
## F5 candidate file numstat against 93694c518 (added / deleted)
| File | Added | Deleted |
|---|---:|---:|
| `BUILD-LOG.md` | 6 | 0 |
| `agents/gladstone/work/hostcheck-item5/BUILD.md` | 179 | 0 |
| `docs/SESSIONS.md` | 1 | 0 |
| `docs/guides/host-run-sheet.md` | 34 | 2 |
| `packages/cli/tests/case1-hostcheck.test.mjs` | 48 | 0 |
| `packages/cli/tests/case2-unit.test.mjs` | 65 | 0 |
| `packages/cli/tests/stack-copy.test.mjs` | 16 | 0 |
| `scripts/case1-hostcheck.sh` | 2 | 2 |
| `scripts/case2-precheck.sh` | 19 | 0 |
| `scripts/case2-unit.sh` | 1 | 1 |
| `scripts/stack-copy.sh` | 3 | 0 |
## Fixture approval and separate follow-up (mos-1537)
Mos approved exactly setup-only additions to case2-unit.test.mjs host(t) and
case2-install.test.mjs world(t): each gets three added lines for a 0700
notify/acme directory and 0600 notify.json with {"notifyVersion":1,"binding":null}.
No existing line, fake or assertion changes or is deleted. F5's missing-file
negative case removes the file after setup, so the new default cannot mask it.
The newly appended install test checks preserved bytes and mode after success,
and separately removes the decision before install and checks it stays absent.
case1-accept.test.mjs is unchanged. This follow-up is committed separately on
33950cf9b as directed, with no amend. No Node/tests run; suite claims remain open.
Morgana-0211 found F5 source/run sheet CLEAN statically; the setup prerequisite B1
is addressed by this approved fixture patch, pending execution.
Additional planned mutants from Morgana-0211 (unrun): M5b drops private_dir,
killed by the directory fault; M5c prints NOTIFY ok unconditionally, killed by
the document faults. Existing test preservation is checked by git diff showing
only additions (numstat below).
Fixture follow-up numstat: case2-unit.test.mjs +3/-0,
case2-install.test.mjs +18/-0. Against the original base: case1-hostcheck.test.mjs
+48/-0, case2-unit.test.mjs +68/-0, stack-copy.test.mjs +16/-0,
case2-install.test.mjs +18/-0. git diff --check passed.
## F6 and notify parent correction (mos-1537 / ops-0261)
Before: install creates a copy but no human CLI wrapper; the sheet's
stack-copy.sh check --units external therefore fails even with correct units.
After: the sheet explicitly places a wrapper step after successful install and
before that check. On Mos's separate live-step go, mosaic changes cwd to /,
creates $HOME/.npm-global/bin if absent, then invokes its clone's stack-copy.sh
wrapper <sha> --create. Existing wrappers refuse and require review; install is
unchanged. This is documentation of a gated host action, not execution of it.
There are no tests pinning these sheet steps to adjust.
Section 9a now explicitly creates/chmods <dataRoot>/notify and then its business
leaf, each 0700. This addresses ops-0261's observation that mkdir -p -m only
controls the leaf. The existing umask 077 remains. All exits are inside the
unprivileged shell, so no failure exits the operator's interactive root shell.
Static receipt: git diff --check passed. No Node, tests or live action. F6 and
this parent-directory documentation correction are a separate commit after the
approved fixture-only 5fd2328b6 follow-up. Reviewer should retain the earlier
F1-F5 source verdicts and inspect this delta. Queue row still pending from Sage.
## F6 candidate file numstat against 93694c518 (added / deleted)
| File | Added | Deleted |
|---|---:|---:|
| `BUILD-LOG.md` | 10 | 0 |
| `agents/gladstone/work/hostcheck-item5/BUILD.md` | 241 | 0 |
| `docs/SESSIONS.md` | 1 | 0 |
| `docs/guides/host-run-sheet.md` | 51 | 2 |
| `packages/cli/tests/case1-hostcheck.test.mjs` | 48 | 0 |
| `packages/cli/tests/case2-install.test.mjs` | 18 | 0 |
| `packages/cli/tests/case2-unit.test.mjs` | 68 | 0 |
| `packages/cli/tests/stack-copy.test.mjs` | 16 | 0 |
| `scripts/case1-hostcheck.sh` | 2 | 2 |
| `scripts/case2-precheck.sh` | 19 | 0 |
| `scripts/case2-unit.sh` | 1 | 1 |
| `scripts/stack-copy.sh` | 3 | 0 |
## F6 gate citation (sage-3747)
Added scripts/stack-copy.sh:3 to the F6 wrapper step's explicit Mos-go requirement.
This is doc-only; commands and tests are unchanged. Sage ruled the delete-after-
setup missing-file negative meets mos-1537, so no fixture change is needed.
Morgana-0213 found the fixture/F6 deltas CLEAN statically. No Node/tests run;
Morgana's eventual run waits for Sage's row and CLEAR after the 220+216 landing.
Numstat for this follow-up: host-run-sheet.md +1/-1; BUILD.md +9/-0.
Static receipt: git diff --check passed.
Correction: 0f3d874d4 added 10 BUILD.md lines, not 9; the git numstat is authoritative.
## First review run and F4 test correction (morgana-0217)
Morgana ran af201b4a9 (6fa47e07d only relocates this file) under her authorized
slot with timeout -k 10 and TMPDIR under /mnt/storage/scratch/tmp. Receipts:
/mnt/storage/scratch/morgana-g320-out/base-summary.txt and mutants-summary.txt.
| Targeted file | Passed | Failed |
|---|---:|---:|
| case1-hostcheck.test.mjs | 19 | 0 |
| case2-unit.test.mjs | 47 | 0 |
| case2-install.test.mjs | 24 | 0 |
| stack-copy.test.mjs | 17 | 1 |
Seven of eight mutants were killed: M1 literal0750, M2 anymode, M3a drop@,
M3b allow$, M5 drop shell block, M5b drop private_dir, M5c always NOTIFY ok.
M4 drop cd / SURVIVED: base and mutant both failed only setup's cleanup hook.
This is a test defect, not a green acceptance result. The 0111 cwd retained
search permission and did not reproduce the inaccessible inherited cwd. It
also blocked setup's chmod -R cleanup because the directory remained unreadable.
B1 correction touches only this row's appended F4 test: the child shell enters
an initially searchable directory, chmods it 000, checks both ! -r and ! -x, then
execs stack-copy. The parent asserts all read/search bits remain cleared. The
bundle now lives outside cwd and is passed absolutely, matching the real flow;
existing row232 coverage still tests relative bundles from an accessible cwd.
Both bundle and checkout paths remain covered.
B2 correction: each child run/assertion sequence has a finally that restores the
cwd to0700 before setup's existing cleanup. No old setup/test lines change.
Production scripts are unchanged; the cd / fix remains correct by static review.
Correction is untested locally: no builder Node/test CLEAR was given. Morgana
will rerun stack-copy whole, M4 and residue cleanup on the pushed correction;
other files need no rerun unless changed. git diff --check passed.
## F4 corrected candidate numstat against 93694c518 (added / deleted)
| File | Added | Deleted |
|---|---:|---:|
| `BUILD-LOG.md` | 12 | 0 |
| `agents/gladstone/work/queue-320/BUILD.md` | 304 | 0 |
| `docs/SESSIONS.md` | 1 | 0 |
| `docs/guides/host-run-sheet.md` | 51 | 2 |
| `packages/cli/tests/case1-hostcheck.test.mjs` | 48 | 0 |
| `packages/cli/tests/case2-install.test.mjs` | 18 | 0 |
| `packages/cli/tests/case2-unit.test.mjs` | 68 | 0 |
| `packages/cli/tests/stack-copy.test.mjs` | 22 | 0 |
| `scripts/case1-hostcheck.sh` | 2 | 2 |
| `scripts/case2-precheck.sh` | 19 | 0 |
| `scripts/case2-unit.sh` | 1 | 1 |
| `scripts/stack-copy.sh` | 3 | 0 |
+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.
+276
View File
@@ -0,0 +1,276 @@
# Row 283 (#1788) C2-01d build notes (Honker)
Append-only. Design: DESIGN.md d2 16e809457, APPROVE webby-0172. Gate owner Sage, reviewer Webby.
## 2026-10-23 build on 45dca69f6 (sage-3577)
Branch honker/row283-build from origin/refactor 45dca69f6e4bce52834587f66f2ff33ba839ab8a. The old branch
honker/row283 (16e809457) held only the design, so its two commits were cherry-picked as 398314cf4
(d1) and 4f66dccde (d2). The DESIGN.md blob equals 16e809457's.
Commits:
- 331653b38: code, README and tests T1-T8.
- 28918824c: the survey.test:73 re-pin, alone (mos-1330 item 4).
- this file.
Nothing has run. No node, test or mutant ran during the build: Darkwing held the slot. Every count
below is a plan until Sage's CLEAR.
### Drift check (sage-3577)
`git diff --stat 94c33a43c 45dca69f6` (design base to build base) over the paths the tests read or
hash-pin:
- No change in packages/library, packages/business/src/business.mjs or
packages/business/tests/business.test.mjs.
- No test hash-pins a library file. bus library.test and library-sync.test import index.mjs, and
stack-copy.test only checks that index.mjs exists.
- webui library-cli.test changed one line and doesn't read scope. The `classify` in webui reads.mjs is
the business one, not the library's.
I redo the drift check on BASE..refactor before the in-review move.
### N3 (webby-0169): deepEquals of the mapping output outside packages/library
`git grep -n playground -- 'packages/*/tests/*' ':!packages/library/*'` and the files naming
`universes` give two files:
- business.test.mjs:20 compares `validateBusinessDocument(...).projects.books` with
`checkMapping(mappingEntry())`. That is output against output, so `supporting: []` shows up on both
sides. :27 compares only `.playground`.
- library-projects-protected.test.mjs compares file bytes (:31, :32) and change paths (:58), never the
mapping output.
No re-pin is needed outside library. Inside library, only the draft deepEqual at mapping.test:48 gains
`supporting: []`, as the design says.
### Q1 departure (sage-2923) and the :73 re-pin (mos-1330 item 4)
survey.mjs:65 counts `supporting: 0` and formatSurvey prints it. T8 is the only added test
(survey.test "C2-01d: with --mapping the survey counts supporting files").
mos-1330 item 4, packages/library/tests/survey.test.mjs:73, commit 28918824c:
- old: `'scopes canon 5, playground 1, unmapped 3; indexed 6',`
- new: `'scopes canon 5, playground 1, supporting 0, unmapped 3; indexed 6',`
N2: the pin gains only `supporting 0`. No other text on the line or in that test moved.
### Q2 tradeoff (webby-0169)
A supporting prefix that holds a playground prefix refuses `library-mapping-invalid`. That goes past
the brief, but it is the fail-closed reading, and it uses an existing code: classify checks supporting
first, so that playground prefix could never match. The cost is that she can't nest a playground under
a supporting folder, though she can nest the other way (a playground holding a supporting folder).
### Handoffs
- Q4 (webby-0169, sage-2930): the M12 delete rule ("A delete also needs the path to exist at the tip as
a 100644 blob", case2 DESIGN.md :308) is not in supportingPath. It binds row 288 (C2-02) via Gitea
#1793 comment 30738, with tests for a missing path and a non-100644 entry.
- A save with no `editor` refusing is C2-02's (DESIGN.md section 1).
### Choices the design left open
- The book loop is now `againstBooks(owners, list, code)` in mapping.mjs, called once per list. The
playground's messages and codes are byte-equal to before.
- A non-string path refuses `library-write-story-path` ("the path must be a string") as part of
check 1. A non-array `tree` is a TypeError (DESIGN.md section 3).
- Check 8 builds the tree's names as every path and every folder above it, keyed by
`normalize('NFC').toLowerCase()`. A name on the way refuses if its key holds any name other than
itself, so a tree that already holds case twins refuses a write to either (R7).
### Mutants (exact strings)
Each applies one replacement to the build tip. Run one at a time, and restore with `git checkout --`
on the one file before the next.
| Mutant | File | Replace | With | Killed by |
|---|---|---|---|---|
| M1 | src/scope.mjs | `const prefix = entry.supporting.find(p => inside(p, path));` | `const prefix = entry.supporting.find(p => inside(p, path) && !entry.playground.some(q => inside(q, path)));` | scope.test "supporting comes before playground", supporting.test "passes at book level" (both-lists path) |
| M2 | src/mapping.mjs | `if ((inside \|\| holding) && owner !== id)` | `if ((inside \|\| holding) && owner !== id && list !== 'supporting')` | mapping.test "each supporting prefix rule", business.test C2-04 |
| M3 | src/supporting.mjs | `for (const p of own) if (LINKS.has(modes.get(p)))` | `for (const p of own) if (false && LINKS.has(modes.get(p)))` | supporting.test "symlink or gitlink" |
| M4 | src/supporting.mjs | `const fold = name => name.normalize('NFC').toLowerCase();` | `const fold = name => name.normalize('NFC');` | supporting.test "case or Unicode form" |
| M4b | src/supporting.mjs | `const fold = name => name.normalize('NFC').toLowerCase();` | `const fold = name => name.toLowerCase();` | supporting.test "case or Unicode form" (NFD case) |
| M5 | src/supporting.mjs | `Buffer.byteLength(path, 'utf8') > MAX_BYTES` | `path.length > MAX_BYTES` | supporting.test "1025 bytes" |
| M6 | src/mapping.mjs | `const IDENT_BAD = /[\u0000-\u001f\u007f-\u009f<>]/;` | `const IDENT_BAD = /[<>]/;` | mapping.test "editor is optional" |
| M6b | src/mapping.mjs | `const IDENT_BAD = /[\u0000-\u001f\u007f-\u009f<>]/;` | `const IDENT_BAD = /[\u0000-\u001f\u007f-\u009f]/;` | mapping.test "editor is optional" |
| M7 | src/supporting.mjs | `if (place.scope !== 'supporting' \|\| place.universe !== universe)` | `if (place.scope !== 'supporting')` | supporting.test "outside the universe's supporting folders" |
| M8 | src/mapping.mjs | `if (sid === pid && s !== p && holds(s, p))` | `if (false && sid === pid && s !== p && holds(s, p))` | mapping.test "each supporting prefix rule" (Workshop case) |
(The `\|` in the table is an escaped `|` in the source text.) Each mutant runs the one test file named,
plus business.test for M2, under `timeout -k 10 600`. A mutant passes the gate only when its run is red
on the named test and the restored file's sha256 equals the build tip's.
### Gate plan (on Sage's CLEAR)
1. The touched files whole, each under `timeout -k 10 600`: library mapping, scope, supporting and
survey tests, and business.test.mjs. Counts go below.
2. M1-M8 as above.
3. The full gate, under mos-1275 timeouts: `node --test packages/library/tests/` and
`node --test packages/business/tests/` each under `timeout -k 10 1800`, and every scripts/test-*.sh
under `timeout -k 10 300`. pipefail and tee, with skips named (test-task Docker groups).
4. Before in-review: grep packages/*/tests for a 64-hex pin on any changed file and for the changed
message text, then the drift check on BASE..refactor.
## 2026-10-23 Webby early look (webby-0227, static, not a verdict)
- E1: business.test.mjs is an existing test file with a 4-line addition after the playground refusal.
The in-review request lists it next to survey.test:73.
- E3 fixed: supportingPath now runs the string check before `JSON.stringify`, so a BigInt (or any
non-string) path refuses `library-write-story-path` instead of throwing a TypeError.
supporting.test's string case now covers 7, null, undefined, 1n and an array.
- E2 open: `editor` takes surrounding whitespace, and git trims it at commit, so the saved ident and
the commit ident can differ. DESIGN.md section 1 says "non-empty after trim". Refusing
`value.trim() !== value` would change that approved rule, so it went to Sage instead of into the
build.
## 2026-10-23 E2 departure (sage-3601)
A departure from DESIGN.md section 1 (which stays unedited). The design says each `editor` value is
"non-empty after trim". Sage ruled that surrounding whitespace refuses too, because git trims an
ident at commit, so a padded value would save one name and commit another. mapping.mjs now adds
`if (value.trim() !== value) bad(`editor.${key} must not start or end with whitespace`)` after the
IDENT_BAD check. `trim()` covers Unicode whitespace, so U+00A0 and U+3000 refuse as well. Inner
whitespace is kept ("Her Middle Name" passes).
T3 gains a case per key for leading, trailing and both-ends spaces, U+00A0 and U+3000, plus one inner
whitespace pass.
M6 grows a third part:
| Mutant | File | Replace | With | Killed by |
|---|---|---|---|---|
| M6c | src/mapping.mjs | `if (value.trim() !== value) bad(` | `if (false && value.trim() !== value) bad(` | mapping.test "editor is optional" (padded cases) |
Fixture grep: `git grep -nE "\beditor\b|\bauthor\b" -- 'packages/library/tests/*' 'packages/business/tests/*'`,
leaving out mapping.test.mjs, finds nothing. packages/business/tests has no fixtures directory. The
library fixture mapping.json has no `editor`. The positive control (`vaultPrefix` in mapping.json)
matches once. No fixture maps an editor or author with surrounding whitespace.
## 2026-10-23 E4 (webby-0230, sage-3606), same departure as E2
Claimed: `queue move 283 in-progress`, op honker-283-start, rev 3642, 19:10:49Z (briefed→in-progress).
Git's ident.c `crud()` strips any byte <= 0x20 and `, : ; < > " \ '` from both ends of an ident. I
checked that with git 2.55.0: `Jane,` `"Jane"` `'Jane'` `Jane;` `Jane:` `Jane\` and `,Jane` all
commit as `Jane`, while `Jane.` and `Ja,ne` are kept. mapping.mjs now takes the set from crud()
rather than from that list: `IDENT_CRUD_END = /^[\u0000-\u0020,:;<>"\\']|[\u0000-\u0020,:;<>"\\']$/`.
The E2 check became `if (value.trim() !== value || IDENT_CRUD_END.test(value))`, which refuses
library-mapping-invalid with "editor.<key> must not start or end with whitespace or any of , : ; " ' \".
`trim()` still catches Unicode whitespace (U+00A0, U+3000), which crud() leaves alone.
Inner characters: IDENT_BAD (`/[\u0000-\u001f\u007f-\u009f<>]/`) already refuses `<`, `>`, newline,
tab and every other control anywhere in the value. T3 now pins that at the start, the end and the
middle. Inner `, : ; " ' \` are kept, since git keeps them, and T3 pins that too.
Tests:
- T3 has one case per character in [space, U+00A0, U+3000, `,`, `:`, `;`, `"`, `'`, `\`], at the start
and at the end, for each key. `<`, `>`, newline and tab are pinned at the start, end and middle.
Four accepted names have inner crud characters.
- New round-trip test, "an accepted editor round-trips through a real git commit unchanged". It
commits in a scratch repo with editor `Doe, Jane Q.` / `[email protected].` as author and
committer, with GIT_AUTHOR_* and GIT_COMMITTER_* stripped from the env, and asserts `git log`
shows exactly the saved values. I preflighted that commit with git 2.55.0 by hand: both values came
back unchanged.
M6c now replaces the whole end check (this supersedes the earlier M6c row):
| Mutant | File | Replace | With | Killed by |
|---|---|---|---|---|
| M6c | src/mapping.mjs | `if (value.trim() !== value \|\| IDENT_CRUD_END.test(value))` | `if (false)` | mapping.test "editor is optional" (end cases) |
| M6d | src/mapping.mjs | `if (value.trim() !== value \|\| IDENT_CRUD_END.test(value))` | `if (value.trim() !== value)` | mapping.test "editor is optional" (`,` `:` `;` `"` `'` `\` cases) |
## 2026-10-23 E5 (webby-0236, not blocking), fixed
The round-trip test stripped only GIT_AUTHOR_* and GIT_COMMITTER_*. With GIT_DIR, GIT_WORK_TREE or
GIT_INDEX_FILE set (inside a hook, say), GIT_DIR beats `-C root` and the commit would land in the
outer repo, and the test read global and system config. It now deletes GIT_DIR, GIT_WORK_TREE,
GIT_COMMON_DIR and GIT_INDEX_FILE (as queue/tests/helpers.mjs gitEnv does) and sets
GIT_CONFIG_GLOBAL=/dev/null and GIT_CONFIG_NOSYSTEM=1 (as ledger.test.mjs does). The `-c user.*`
ident still applies. Test only; no mutant changes.
## 2026-10-23 sage-2925: optional editor.user (sage-3649)
sage-2925 (recorded in ludwig/row286-r2 agents/ludwig/work/queue-286/BUILD.md, Q4) ruled that row 283
adds an optional `editor.user`, her Gitea login for the `library-human` slot, which row 288 (C2-02)
passes to `humanCredential(context, {user})`. It reached this row after the DESIGN.md d2 approval
(ludwig-0068, bubba-0009, sage-3649), so it departs from DESIGN.md section 1 ("exactly the keys
`name` and `email`"). DESIGN.md stays unedited.
- mapping.mjs: `only(entry.editor, ['name', 'email', 'user'], 'editor')`. When present, `user` must be
a string matching USER (`/^[A-Za-z0-9_.-]{1,100}$/`, the entry `user` check), else
library-mapping-invalid "editor.user must be a plain account name". The output carries `user` only
when given. An absent `editor.user` refusing library-human-missing is 288's (bubba-0009).
- README: the `editor` line names `user` and the end-character rule from E2/E4.
- Tests: T3 (mapping.test "editor is optional") gains one accepted case (`her.login-1`, kept in the
output) and refused cases for '', an inner space, '@', 101 characters, 7 and null. They go in
mapping.test with the other editor cases, not in the new supporting.test, which tests write paths.
- No test outside mapping.test reads `editor` or pins mapping.mjs or README (git grep).
| Mutant | File | Replace | With | Killed by |
|---|---|---|---|---|
| M6e | src/mapping.mjs | `if (Object.hasOwn(entry.editor, 'user') && (typeof entry.editor.user !== 'string' \|\| !USER.test(entry.editor.user)))` | `if (false)` | mapping.test "editor is optional" (editor.user refusals) |
| M6f | src/mapping.mjs | `...(Object.hasOwn(entry.editor, 'user') ? { user: entry.editor.user } : {})` | `...({})` | mapping.test "editor is optional" (`her.login-1` kept) |
## 2026-10-23 T2 run (sage-3695 CLEAR, lane A, library and business only)
Node v26.8.1. refactor was e2b873390 (226 landed); `git diff --stat 45dca69f6 e2b873390 -- packages/library
packages/business` is empty. TMPDIR was a mktemp -d under /mnt/storage/scratch/tmp, with pipefail.
Singles and mutants ran under `timeout -k 10 600`, suites under `timeout -k 10 1800`.
First-run red (mos-1272, placed): the first base run on 46bad7ea3 at 20:01:09Z was red in
supporting.test "malformed paths refuse by class, in order". 5 of 6 passed, with `TypeError: Do not know
how to serialize a BigInt`. The cause was the test helper, not the product: `refuses()` labelled each
assertion with `JSON.stringify(path)`, and the 1n case added for E3 can't be stringified. In c18ad3e12
the label uses `util.inspect(path)`, a 2-line test-only change. The product's 1n refusal was never in
question: supportingPath type-checks before it stringifies (E3). The whole run below is on c18ad3e12.
Base, the touched files whole (20:01:43Z), all rc=0:
- library mapping.test 16/16, scope.test 6/6, supporting.test 6/6, survey.test 7/7;
- business business.test 21/21;
- 0 skipped, 0 cancelled, 0 todo.
Mutants (20:01:44Z). Each was applied by exact string with count 1, then restored with `git checkout --`.
Every restored sha256 equals HEAD's blob, and the tree was clean after.
| Mutant | Run | Result | Failing test |
|---|---|---|---|
| M1 | scope.test | rc=1, 5/6 | supporting comes before playground; a folder in both lists is supporting… |
| M1 | supporting.test | rc=1, 5/6 | a path passes at book level, universe level, in a draft and in a folder in both lists |
| M2 | mapping.test | rc=1, 15/16 | each supporting prefix rule refuses with its code |
| M2 | business.test | rc=1, 20/21 | C2-04: library mappings load without roots, remain frozen, and retain mapping refusals |
| M3 | supporting.test | rc=1, 5/6 | a symlink or gitlink on the way, or at the path itself, refuses |
| M4 | supporting.test | rc=1, 5/6 | a name that differs from a tree name only in case or Unicode form refuses… |
| M4b | supporting.test | rc=1, 5/6 | the same case/Unicode test |
| M5 | supporting.test | rc=1, 5/6 | a path of exactly 1024 bytes passes, and 1025 bytes… refuses |
| M6 | mapping.test | rc=1, 15/16 | editor is optional, kept when given, and refuses anything but a plain name and email |
| M6b | mapping.test | rc=1, 15/16 | the same editor test |
| M6c | mapping.test | rc=1, 15/16 | the same editor test |
| M6d | mapping.test | rc=1, 15/16 | the same editor test |
| M6e | mapping.test | rc=1, 15/16 | the same editor test |
| M6f | mapping.test | rc=1, 15/16 | the same editor test |
| M7 | supporting.test | rc=1, 5/6 | a path outside the universe's supporting folders refuses |
| M8 | mapping.test | rc=1, 15/16 | each supporting prefix rule refuses with its code |
14 of 14 mutants killed, each on its named test only.
Suites (20:01:47Z):
- `packages/library`: `node --test tests/*.test.mjs` rc=0, 98/98, 0 skipped.
- `packages/business`: `node --test tests/` rc=0, 231/231, 0 skipped.
Not run here: every scripts/test-*.sh and the other packages. Those are Sage's 283 gate, after Webby's
APPROVE.
## 2026-10-23 G1: mos-1528 OK for the mapping.test draft deepEqual (sage-3713)
This entry completes the N3 line above (BUILD.md:42, "only the draft deepEqual at mapping.test:48 gains
`supporting: []`"). BUILD.md is append-only, so :42 stays as written. Mos OK'd the change of that old
assertion in mos-1528. It is now at packages/library/tests/mapping.test.mjs:52, and commit 331653b38
changed it. Everything else in that commit's hunk is new test lines, not changed old ones.
- Old, mapping.test.mjs:48 at 45dca69f6:
`assert.deepEqual(m.universes.idea, { kind: 'draft', name: 'New World', books: [], playground: ['Drafts/New World/'] });`
- New, mapping.test.mjs:52 at b2a1f4092:
`assert.deepEqual(m.universes.idea, { kind: 'draft', name: 'New World', books: [], playground: ['Drafts/New World/'], supporting: [] });`
The line gains only `, supporting: []`; nothing else in it moved. The business.test and scope.test hunks
add tests only (sage-3713: no OK needed). The other old-test change in this row is survey.test:73
(mos-1330 item 4, 28918824c). Doc only.
+191
View File
@@ -0,0 +1,191 @@
# Row 283 (#1788) C2-01d design, draft 2 (Honker)
Brief: docs/plans/2026-10-20_case2-rows.md "C2-01d" at refactor 94c33a43c. Sources: the design at
agents/gosalyn/work/case2/DESIGN.md b68d79663, the ruling after r11 (:207), the M12 paragraph (:300),
1.1 (:755) and 1.2 (:817). Base 94c33a43c. Reviewer Webby, gate owner Sage. Nothing is built yet. The
build waits for 282 to land (sage-2906).
## 1. Mapping (packages/library/src/mapping.mjs)
`supporting`:
- Every universe kind takes an optional `supporting` list: plain `['name','books','playground','supporting']`,
hybrid adds it to its seven keys, and a draft gets `['kind','name','playground','supporting']`.
- Each value is checked as a playground prefix is: a string ending in `/` whose body passes `folder()`.
- The output carries `supporting` on every universe, `[]` when absent, the way a draft carries
`books: []`. classify then needs no `?? []`. The business writer writes the raw document
(organization.mjs:195), not this output, so no business file changes shape.
- The prefix rules, across every entry (ruling after r11):
- A supporting prefix holding another supporting prefix, or one listed twice, refuses
`library-mapping-invalid`.
- A prefix equal to one of its own entry's book folders, or holding one, refuses `library-mapping-invalid`.
- A prefix inside or holding another entry's book folder refuses `library-supporting-other-universe`.
- Level comes from where the prefix sits, as for the playground: inside one of its own entry's books
is book level, and outside every book is universe level. A draft's prefixes are all universe level.
- Between the two lists (my choice, Q2):
- A supporting prefix may equal a playground prefix of the same entry. That folder is writable.
- A playground prefix may hold a supporting prefix of the same entry. The inner folder is supporting
and the rest stays playground.
- A supporting prefix that holds a playground prefix refuses `library-mapping-invalid`. classify
checks supporting first, so that playground prefix could never match.
- A supporting prefix equal to, holding or inside a playground prefix of another entry refuses
`library-mapping-invalid`. Otherwise one path would have two universes.
- The book loop at mapping.mjs:128-140 becomes a helper that both lists call with their own code and
label. The playground's messages and codes stay byte-equal, so no existing test text moves.
`editor`:
- The entry takes an optional `editor`. When present, it is an object with exactly the keys `name` and
`email` (via `only()`). Each value is a string that is non-empty after trim and has no control
character (`[\u0000-\u001f\u007f-\u009f]`), `<` or `>` (webby-0169 R1: git drops them from an ident
and commits under a different name, so C2-02 would never see a failure). Anything else refuses
`library-mapping-invalid`.
- The output carries `editor: { name, email }` only when present, as `user` and `vaultPrefix` do. A save
with no `editor` refusing is C2-02's job.
## 2. Scope (scope.mjs)
- `classify` gets a supporting loop before the playground loop (design 1.2). It returns `place(...)`
with `{ scope: 'supporting', universe, book }`. The book is the book folder holding the prefix, or
null at universe level and in a draft. The type and folder come from the first folder under the
prefix, as for the playground.
- The `.md` check stays first, so a `.canvas` or `.base` under a supporting prefix stays unmapped.
- The comment above `classify` names the three prefix scopes and the order.
## 3. supportingPath (new supporting.mjs)
`supportingPath(mapping, universe, path, tree)` returns `path` or throws
`LibraryError('library-write-story-path', ...)`. It is pure and reads no git.
- `tree` is the array `listTree` returns (reader.mjs:26, `{ mode, type, oid, size, path }`), so the
C2-02 broker passes its listing as it is (Q3). A non-array `tree` throws a TypeError, because that is
a caller bug, not a path class.
- The checks run in this order, each with its own message naming the class:
1. The path must be a string of at most 1,024 UTF-8 bytes (`Buffer.byteLength`).
2. It must have no control character, using the same control class as `editor` (not `<` or `>`).
3. It must have no backslash.
4. It must not be absolute (a leading `/`).
5. It must have no empty, `.` or `..` segment. That also catches `//` and a trailing `/`.
6. `classify(mapping, path)` must give scope `supporting` and that same `universe`. This one check
covers an unknown universe, an unmapped path, a `.canvas`, `.base` or attachment, a canon or
story path, a playground path that isn't also supporting, and another universe's prefix. The scope
rule then has one source, and M1 below hits both.
7. Every proper prefix of the path (`a`, `a/b`, ...) and the path itself must not be in `tree` with
mode `120000` (symlink) or `160000` (gitlink).
8. No prefix of the path, and not the path itself, may equal a tree path or a tree directory
(derived from the entries' paths) after `normalize('NFC').toLowerCase()` on both sides, unless
it equals that tree path exactly. NFC as well as case, because APFS treats NFC and NFD forms of
a name as one file (webby-0169 N1).
Directories are checked too, because the folders under a supporting prefix are hers to name. A
path in a tree that holds both `a.md` and `A.md` refuses, which matches R7.
- The refusal messages quote the path with JSON, except for the control-character class, whose message
leaves the path out.
- The delete rule ("A delete also needs the path to exist at the tip as a 100644 blob", M12) isn't in
the brief's supportingPath list. It stays with C2-02's delete (Q4).
The C2-02 brief doesn't name it (only design :308), so BUILD.md carries it to C2-02 under Handoffs
(webby-0169 Q4).
- index.mjs exports `supportingPath`.
## 4. README (packages/library/README.md)
- Mapping: `supporting` and its rules, the cross-list rules, and `editor`.
- Refusals: a `library-supporting-other-universe` row, and the new cases in the
`library-mapping-invalid` row.
- Scope: supporting first, then playground, then canon.
- A new section, "Agent write paths", for supportingPath and its classes.
## 5. Callers of classify outside files owned (Q1 for Sage)
- survey.mjs:65 builds `{ canon: 0, playground: 0, unmapped: 0 }` and increments
`scopes[classify(...).scope]`. A supporting file adds `supporting: NaN`, and formatSurvey (:92) never
prints it. I propose a departure: add `supporting: 0` and print it, so the line becomes
`scopes canon 5, playground 1, supporting 0, unmapped 3; indexed 6`. That re-pins survey.test:73 and
adds a count test. Without it, the survey reports garbage once she marks a folder. The departure
covers two files, packages/library/src/survey.mjs and packages/library/tests/survey.test.mjs.
- No change in the rest, each for a reason:
- reader.mjs:183 skips only unmapped, so supporting files are indexed like playground files.
- R5 (:207) flags only canon to playground, as design 1.3 R5 says. A canon link to a supporting file
raises nothing.
- render.mjs:23 gives supporting a null badge. The supporting badge belongs to C2-10b (design :1415).
- mirror.mjs:246 refuses only unmapped. Reading supporting files by scope is C2-05c's.
- No bus or webui source filters on scope (git grep at 94c33a43c).
- M9 already protects the whole `projects` key from agent edits (library-projects-protected.test). No
test is added there.
## 6. Tests
- mapping.test:
- T1: `supporting` at book level, at universe level, in a hybrid and in a draft. The output carries
it, with `[]` when absent. The draft `deepEqual` at :48 gains `supporting: []`.
- T2: one test per prefix rule.
- These refuse `library-mapping-invalid`: no trailing `/`, a bad folder, one holding another, one
listed twice, one equal to its own book, one holding its own book, one holding a playground
prefix, and one overlapping another entry's playground.
- These refuse `library-supporting-other-universe`: one inside another universe's book, and one
holding another universe's book.
- An equal same-entry playground passes.
- T3: `editor` passes, and absent passes. It refuses `library-mapping-invalid` for a non-object, an
array, an extra key, a missing key, an empty or blank value, a non-string, and a control character,
`<` or `>` in either value.
- scope.test, T4:
- Supporting at book level, universe level and in a draft each give their universe and book.
- A folder in both lists is `supporting`, and a playground-only folder is `playground`.
- A supporting `.canvas` or `.base` is unmapped.
- supporting.test (new):
- T5 passes at book level, universe level, in a draft, in a both-lists folder, and at exactly 1,024 bytes.
- T6 has one refusal per class against a fixture tree with a symlink, a gitlink and a case variant.
The classes are:
- unmapped, `.canvas`, `.base`, an attachment, canon, playground-only, another universe's
supporting prefix, and an unknown universe;
- absolute, `..`, `.`, `//`, a trailing `/`, a backslash, a C0 control and DEL;
- a symlink folder, a gitlink folder, and a symlink at the path itself;
- a case-variant file, a case-variant folder, and an NFD-form variant of an NFC tree name;
- 1,025 bytes made with a multi-byte character, so bytes and characters differ.
- Each asserts the code `library-write-story-path`.
- business.test: T7 adds one assertion that `validateBusinessDocument` refuses
`library-supporting-other-universe`.
- survey.test (only if Q1 is approved): T8 counts one supporting file, and :73 is re-pinned.
## 7. Mutants (exact strings go in BUILD.md)
| Mutant | Change | Killed by |
|---|---|---|
| M1 | classify checks playground before supporting (the brief's mutant) | T4 both-lists, T5 both-lists |
| M2 | the other-universe fail for supporting is dropped | T2 |
| M3 | the symlink and gitlink check is dropped | T6 |
| M4 | the case check is dropped | T6 |
| M4b | check 8 drops `normalize('NFC')` | T6 NFD variant |
| M5 | the byte length uses `path.length` | T6 1,025 bytes |
| M6 | the `editor` control-character check is dropped | T3 |
| M6b | the `editor` `<` and `>` check is dropped | T3 |
| M7 | supportingPath skips the universe match | T6 other universe |
| M8 | the supporting-holds-playground check is dropped | T2 |
## 8. Gate
- Webby approves the manifest.
- The library and business suites and every scripts/test-*.sh are green on Sage's gate.
- Timeouts (mos-1275): each package `timeout -k 10 1800`, each script `timeout -k 10 300`, each single
file and mutant `timeout -k 10 600`. These come from my row 256 gate at 10:34Z.
- Before in-review, grep packages/*/tests for 64-hex pins on the changed files and for the changed
message text, then drift-check BASE..refactor.
## 9. Questions
- Q1 (Sage): approve the survey.mjs and survey.test.mjs departure in section 5?
- Q2 (Webby): the cross-list rules in section 1?
- Q3 (Webby): `tree` as the `listTree` array?
- Q4 (Webby): the delete's 100644 check stays with C2-02?
- Q5 (Webby): `editor` refuses only blank values and control characters, as the brief says. `<` and `>`
would break a git ident. I'd leave them to C2-02's commit step unless you want them refused here.
## 10. Draft 2 (webby-0169)
- R1 folded: `editor` refuses `<` and `>` too, with T3 cases and mutant M6b.
- Q2 accepted. BUILD.md records the tradeoff: refusing a supporting prefix that holds a playground
prefix goes past the brief, but it is the fail-closed reading and uses an existing code. The cost
is that she can't nest a playground under a supporting folder, though she can nest the other way.
- Q3 accepted. Q4: carried to C2-02 under Handoffs in BUILD.md, and Sage was asked to add it to C2-02's brief.
- N1 folded into check 8 (NFC and case), with an NFD case in T6 and mutant M4b.
- N2: the survey.test:73 pin gains only `supporting`, and the count test is the only addition
(mos-1330, its own commit).
- N3: before the build, re-grep tests outside packages/library for a `deepEqual` of the mapping output
on the build base.
+8
View File
@@ -0,0 +1,8 @@
# Lena
Builder seat, Codex gpt-6.1-sol, started by Mos under Q245 (mos-1506). Owns row 296 (FED R7, handed over from Quackerjack).
No launcher in this directory. The seat runs as a T3 thread (thread db27faca) and works in its own worktree. It has no
credentials of its own; it pushes only its own `lena/*` branches through the repository credential helper (mos-0981),
never `refactor`, `main` or another seat's branch. Claude seats orchestrate and review (Q245). Registered by Sage on
2026-10-23 so the queue can name the seat as row owner (store.mjs isSeatDir).
+8
View File
@@ -0,0 +1,8 @@
# Violet
Builder seat, Codex gpt-6.1-sol, started by Mos under Q245 (mos-1506). Owns row 297 (the GitHub grant method, handed over from Bushroot).
No launcher in this directory. The seat runs as a T3 thread (thread 6229eb89) and works in its own worktree. It has no
credentials of its own; it pushes only its own `violet/*` branches through the repository credential helper (mos-0981),
never `refactor`, `main` or another seat's branch. Claude seats orchestrate and review (Q245). Registered by Sage on
2026-10-23 so the queue can name the seat as row owner (store.mjs isSeatDir).
+1
View File
@@ -1593,3 +1593,4 @@ are never rewritten or removed; corrections are new entries.
2026-10-23T17:48:43Z | Scrooge (629b6cd3) | Row 252 Console inbox Approve and Deny (#1753) | Rebased onto 306 landing as 580037105 (range-diff all =), slot run bus 907/0 webui 410/0 cli 943/0, in-review revs 3608/3609, Webby r1 APPROVE, J1-J19 specs to Sage; Sage landed 45ea3fa0d (done rev 3616). Part 1 mutant results were never in BUILD.md; they went to Sage by T3 (scrooge-0252-mutant-specs).
2026-10-23 | Sage (1ef1e4f8) | Rows 308 and 252 landings, 571 Connections design amendment, Vikunja 597 | 308 (PRD 1.17, #1815) landed 30e6bb680 and 252 (#1753) landed 45ea3fa0d (replay of 580037105 onto 30e6bb680, range-diff all =), each after a full gate on the landed tree (252 also J1-J19, J11c and J18 equivalent); Gyro's 571 amendment (Fenton APPROVE) landed e8bb13df8; Vikunja 570, 435 and 597 done, canonical webui esbuild 0.28.2; queue revs 3589-3616 committed 59f013247
2026-10-23 | Sage (1ef1e4f8) | Vikunja 571 W build rows; rows 13, 42, 257 and 289 check | Filed the design's ten W rows as queue rows 309-318 (revs 3617-3626, owners set to unassigned 3627-3636, queue commit 293b42fee pushed); Vikunja 571 done (comment 994). Row 13's candidate fd72d2689 is on refactor, gate is Monday's row 7 ledger run; 42 paused under mos-1184; 257 post-v1; 289 waits on 214 and 288. Slot released 17:56Z.
2026-10-23 | Gladstone (433058fc-9cea-46e3-8554-6ddefb312762, Codex gpt-6.1-sol) | #1816 / Vikunja 614, mos-1531 | Built hostcheck octal-mode fix and two bare-scratch bundle verifies on gladstone/hostcheck-item5 from 93694c518. Three append-only test blocks; Node/tests held by Sage-3738, untested push authorized sage-3740. Queue number pending; Morgana static review, Sage gate/landing.
+61 -6
View File
@@ -40,8 +40,8 @@ install -d -o root -g root -m 0755 /etc/mosaic-stack/node-keys /opt/mosaic-stack
cd "$(mktemp -d)"
curl -fsSLO https://nodejs.org/dist/<v>/node-<v>-linux-x64.tar.xz
curl -fsSLO https://nodejs.org/dist/<v>/SHASUMS256.txt
curl -fsSLO https://nodejs.org/dist/<v>/SHASUMS256.txt.asc
gpg --no-default-keyring --keyring /etc/mosaic-stack/node-keys/pubring.kbx --verify SHASUMS256.txt.asc SHASUMS256.txt
curl -fsSLO https://nodejs.org/dist/<v>/SHASUMS256.txt.sig
gpg --no-default-keyring --keyring /etc/mosaic-stack/node-keys/pubring.kbx --verify SHASUMS256.txt.sig SHASUMS256.txt
grep " node-<v>-linux-x64.tar.xz\$" SHASUMS256.txt | sha256sum -c -
echo "<pinned sha256> node-<v>-linux-x64.tar.xz" | sha256sum -c -
tar -xJf node-<v>-linux-x64.tar.xz --no-same-owner
@@ -189,7 +189,12 @@ First install:
```sh
# Copy the bundle in, then check it before anything reads it.
echo "<bundle sha256> /root/<sha>.bundle" | sha256sum -c -
git bundle verify /root/<sha>.bundle
(
bundle_check="$(mktemp -d)" || exit 1
trap 'rm -rf "$bundle_check"' EXIT
git init -q --bare "$bundle_check" &&
git -C "$bundle_check" bundle verify /root/<sha>.bundle
) || echo "STOP: bundle verify failed; do not continue"
# Root's checkout. stack-upgrade.sh and case2-unit.sh run from here, never from a copy.
git clone -q -b refactor /root/<sha>.bundle /root/mosaic-stack
@@ -206,7 +211,12 @@ Each upgrade, before `stack-upgrade.sh upgrade`:
```sh
echo "<bundle sha256> /root/<sha>.bundle" | sha256sum -c -
git bundle verify /root/<sha>.bundle
(
bundle_check="$(mktemp -d)" || exit 1
trap 'rm -rf "$bundle_check"' EXIT
git init -q --bare "$bundle_check" &&
git -C "$bundle_check" bundle verify /root/<sha>.bundle
) || echo "STOP: bundle verify failed; do not continue"
git -C /root/mosaic-stack fetch -q /root/<sha>.bundle refs/heads/refactor:refs/remotes/origin/refactor
git -C /root/mosaic-stack checkout -q --detach <sha>
install -o mosaic -g mosaic -m 0600 /root/<sha>.bundle /srv/mosaic/incoming/<sha>.bundle
@@ -231,13 +241,34 @@ When a run doesn't exit 0, send Mos the exit and the last lines of `/var/log/mos
the old copy. The next run refuses `args-differ`, and a render from that file would put the old copy back.
Move the file the result line names into place with `mv -f`. The two differ only in the `--copy` value.
After `case2-install.sh` succeeds, create the human CLI wrapper before running
`stack-copy.sh check --units external`. Creating this wrapper is a separate live
step requiring Mos's go (`scripts/stack-copy.sh:3`); the install does not create it. Run only on that go:
```sh
sudo -u mosaic -H /bin/sh <<'MOSAIC_WRAPPER'
cd / || exit 1
mkdir -p -- "$HOME/.npm-global/bin" || exit 1
"$HOME/src/mosaic-stack/scripts/stack-copy.sh" wrapper <sha> --create
MOSAIC_WRAPPER
```
`--create` refuses an existing wrapper; stop and review it rather than replacing
it. The wrapper must name the same `<sha>` copy as the units.
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,13 +303,37 @@ 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.
Enrollment (section 8) writes it under the enrolling stack's config directory and deletes it when the host
leaves.
## 9a. Notifier decision, before starting units
Make the explicit per-host decision before section 10: the default below is
`binding: null`, so the bus runs without DMs. A named binding is a later per-host
choice that needs Mos's go. The installer does not make this decision or write
this file. Replace `<dataRoot>` with the configured data root and `<business>`
with this host's business id. If the file already exists, stop and review the
existing decision; this command refuses to overwrite it.
```sh
sudo -u mosaic -H /bin/sh <<'MOSAIC_NOTIFY'
cd / || exit 1
umask 077
notify_root='<dataRoot>/notify'
notify_dir="$notify_root/<business>"
mkdir -p -m 0700 -- "$notify_root" && chmod 0700 -- "$notify_root" || exit 1
mkdir -p -m 0700 -- "$notify_dir" && chmod 0700 -- "$notify_dir" || exit 1
(set -C; printf '%s\n' '{"notifyVersion": 1, "binding": null}' > "$notify_dir/notify.json") || exit 1
chmod 0600 -- "$notify_dir/notify.json"
MOSAIC_NOTIFY
```
Precheck line: `ok notifier config <dataRoot>/notify/<business>/notify.json validates (directory 0700, file 0600)`.
## 10. Console start
None by hand. I2's `case2-unit.sh` enables both units, starts the bus, then the Console (Q189, mos-1122).
@@ -0,0 +1,31 @@
# Brief: a GitHub consumer for row 297's protected token callback
Sage, 2026-10-23, filed for Mos (mos-1525). Post-10-27.
## Brief: a GitHub consumer for Broker.githubWithToken
Row 297 builds the GitHub grant method and stops at the trusted host API
`Broker.githubWithToken(business, agent, fn)` in `packages/bus/src/broker.mjs`.
Nothing calls it yet: no git helper, mirror push or publication uses a GitHub
token (violet BUILD.md, "Consumer gap for Sage's follow-up row").
This row adds the first consumer.
- It gets the token only through `githubWithToken`. It never reads the
protected generation files, and never routes GitHub through the generic
Gitea Credentials path, which has calendar-day dates and fork-specific
identity.
- It honors the callback's cancellation signal. The signal aborts on revoke and
at the token safety deadline, so the consumer stops work and drops the token
when it fires. No operation is retried with a token past its expiry.
- No socket verb returns a token, and no consumer output, log or run record
holds one.
Design comes first, with the consumer named (a git helper or mirror push), for
Dewey's review. Tests cover: a revoke mid-call aborts the consumer; a call
straddling the safety deadline aborts; an expired grant refuses before any
network call; and no token text appears in output or the store.
Gate: Dewey approves the design and the manifest. The touched suites and every
`scripts/test-*.sh` are green on Sage's gate, and mutants that drop the signal
check or the expiry check are killed. Starts after row 297 is done.
+33
View File
@@ -678,3 +678,36 @@ claim aren't one (design D1's alternative). Bound to the next row that changes t
Morgana, row 306 BUILD.md (#1813, landed 2d0318a44). A revoked run that also has a pending limit stop gets
`#limitStop`'s `not-holder` on `message.receive` and `state`, because that check runs before `#agent`. It's
rare and the run still stops. Bound to the next row that changes `#limitStop` or the order of those checks.
## Row226 N1: the install id regex is narrower than ID_PATTERN
Webby, row 226 round 1 (webby-0225, #1727, landed 661890e90). case2-install.sh:56 refuses a business id
with `.` or `_`, which ID_PATTERN (vocabulary.mjs:199) and case2-precheck.sh:67 accept. Gosalyn confirmed
it isn't deliberate (gosalyn-0208). It fails closed, so a real id with either character is refused, not
mishandled. Bound to the next row that changes case2-install.sh: take the ID_PATTERN set, and add a test.
## Row226 N2: a first-run validate refusal leaves personas and the slot dir
Webby, row 226 round 1 (webby-0225). After a validate refusal on a first run, the staged personas and the
empty token slot dir stay behind, and a re-run counts them as present. Bound to the same follow-up as N1:
remove what this run created, as the business file path already does (install.sh:198/:202/:218).
## Row226 N3: the run sheet doesn't pin the clone to the copy's sha
Webby, row 226 round 1 (webby-0225). install.sh:217 validates with the checkout's scripts/mosaic, not the
copy's, and host-run-sheet.md:237 never says the clone must be at `<sha>`. Bound to the same follow-up:
a HEAD check in the install or one clause on the run sheet.
## Row226 N4: --library-status refuses after a 5 s lock wait
Webby, row 226 round 1 (webby-0225). If the bus holds the library lock for more than 5 s,
`case2-precheck.sh --library-status` refuses with `ERROR library-sync-busy`. A re-run clears it. Recorded
as a known operator step, no fix bound.
## Row283 N1: SURVEY-RUN.md shows the scopes line without supporting
Webby, row 283 round 1 (webby-0267, #1788, review note queue-283/REVIEW-manifest-b2a1f4092.md on
webby/reviews c6ea27aab). docs/case2/SURVEY-RUN.md:96 documents the `--mapping` line as
`scopes canon .., playground .., unmapped ..; indexed ..`, which predates the supporting scope 283 adds.
Nothing reads the file (decision 137). Bound to the next docs commit that touches SURVEY-RUN.md: add the
supporting count to that line.
+13 -12
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 3636 by `queue render`. Do not edit between the markers; change the queue with `scripts/mosaic queue`.
Generated from `docs/plans/queue.json` revision 3677 by `queue render`. Do not edit between the markers; change the queue with `scripts/mosaic queue`.
| # | Piece | Owner | Issues | State | After | Gate | Brief | Note |
|---|---|---|---|---|---|---|---|---|
@@ -252,18 +252,18 @@ Generated from `docs/plans/queue.json` revision 3636 by `queue render`. Do not e
| 213 | Case 2 C2-08 creative roles, persona file and block, M1, M2, M6, M10, prompt passed by file | morgana; reviewers webby | #1714 | done | — | Webby approves the manifest. The business, bus and harness suites, `scripts/test-config.sh` and every `scripts/test-*.sh` are green on Sage's gate, and the `models` mutant is killed. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-08 creative roles, persona file and block, M1, M2, M6, M10, prompt passed by file | LANDED 361ba2387 (39 paths incl. 4 packet files). Patch-id f313e69b6f7e = c771a36f4..2b3e67e36; verify-commit ok, 35 manifest paths. Same combined gate as 195: business 200/0 bus 542/0 harness 140/0 cli 663/0, test-config 31/0 and all 10 scripts green, models mutant L1 killed. Webby N2 is row 235 acceptance (mos-1163). |
| 214 | Case 2 C2-11a editor wikilink node, after measuring whether [[ round-trips | herb; reviewers filbert | #1715 | in-progress | — | Filbert approves the manifest. The webui suite is green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-11a editor wikilink node, after measuring whether [[ round-trips | — |
| 215 | Case 2 C2-04 the library entry in the business file, consumer skips, M9, mirror, library.file and library.status | herb; reviewers filbert | #1716 | done | — | Filbert approves the manifest. The business, seat, cli, project, bus and webui suites are green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-04 the library entry in the business file, consumer skips, M9, mirror, library.file and library.status | LANDED e54e05463 on 6096b051b (sage-2094). Patch-id 2f68188b6c9d equals a36523713..dde3445ab; 118 paths, blobs and modes equal; tree 5b1613245 equals merge-tree. No path overlap with rows 66/243; no hash pins either way. Gate: Filbert APPROVE rev 2924; herb-0095 grant sage-2071 2634 pass 0 fail, 1 webui skip (editor rebuild needs esbuild 0.28.2, env, not a 215 path), accepted. |
| 216 | Case 2 C2-05a lookup index and queries | researcher; reviewers filbert | #1717 | briefed | 268 done | Filbert approves the manifest. The library suite is green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-05a lookup index and queries | Rulings on phase A: agents/sage/work/queue-216/RULINGS.md (da9555ca2). r2 rebases on row 268's landing and adds the four PRD 1.14 playground tests. |
| 217 | Case 2 C2-06 the citation guard, the page store, the runner texts and the retry | launchpad; reviewers webby | #1718 | briefed | 268 done | Webby approves the manifest. The library, bus and harness suites are green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-06 the citation guard, the page store, the runner texts and the retry | — |
| 216 | Case 2 C2-05a lookup index and queries | researcher; reviewers filbert | #1717 | in-review, round 1, request posted | 268 done | Filbert approves the manifest. The library suite is green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-05a lookup index and queries | Rulings on phase A: agents/sage/work/queue-216/RULINGS.md (da9555ca2). r2 rebases on row 268's landing and adds the four PRD 1.14 playground tests. |
| 217 | Case 2 C2-06 the citation guard, the page store, the runner texts and the retry | beakley; reviewers webby | #1718 | briefed | 268 done, 283 done | Webby approves the manifest. The library, bus and harness suites are green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-06 the citation guard, the page store, the runner texts and the retry | — |
| 218 | Case 2 C2-12 the setup sheet for Melanie and the survey run sheet | sage; reviewers webby | #1719 | done | — | Doc only. Webby approves it against C2-01's real flags. Before landing, grep `packages/*/tests` and `scripts/test-*.sh` to confirm no test reads either file. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-12 the setup sheet for Melanie and the survey run sheet | LANDED f8276c5eb (pushed, on Darkwing sessions b65f3a0e8). Webby APPROVE r1-r3 (revs 2848, 2856, 2860; r3 candidate 1a38baf5af2a = 89ba79d94). Patch-id 18ba1e40ad29 seat 5c6f73ed7..89ba79d94 equals landed b65f3a0e8..f8276c5eb; three blobs equal 89ba79d94. Gate: doc only, 0 test readers (control 3). Melanie handover waits on C2-06 and Jason (#419). |
| 219 | Case 2 C2-09a one thread per creative instance | rocko; reviewers dewey | #1720 | done | — | Dewey approves the manifest. The bus, cli and webui suites are green on Sage's gate, along with every `scripts/test-*.sh`. Watch for hash pins on `bus.js` and `broker.mjs` (`packages/bus/tests/channel-proof.test.mjs` pins a webui file). (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-09a one thread per creative instance | LANDED 5fdfd7275 on refactor 10:40Z 10-23 (8 row commits cherry-picked onto 2efd127ce, range-diff all =, patch-ids match, tree e86a84a85 = gated merge 054da642f; top commit packet-only evidence). Gate manifest 4a2271dd: node 2523/0/2, ten shells 449/0, 24 mutants killed, audit ok (Rocko). Dewey approve rev 3507 c31108. N1 U6 persona label DEFERRED line follows. Slug fix Vikunja 590 and network leak 591 rows next. |
| 220 | Case 2 C2-10a no raw session view (M7) and no raw Discord engine (M8) | researcher; reviewers filbert | #1721 | briefed | — | Filbert approves the manifest. The conversation and discord suites and `scripts/test-discord.sh` are green on Sage's gate, along with every `scripts/test-*.sh`. No test reaches a Discord server. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-10a no raw session view (M7) and no raw Discord engine (M8) | Rulings on phase A: agents/sage/work/queue-220/RULINGS.md (da9555ca2). Scope adds packages/business/src/library-seats.mjs (imported by path, no index export), its test, and control-board serve.mjs. r2 building as a Haiku subagent. |
| 220 | Case 2 C2-10a no raw session view (M7) and no raw Discord engine (M8) | researcher; reviewers filbert | #1721 | in-review, round 1, request posted | — | Filbert approves the manifest. The conversation and discord suites and `scripts/test-discord.sh` are green on Sage's gate, along with every `scripts/test-*.sh`. No test reaches a Discord server. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-10a no raw session view (M7) and no raw Discord engine (M8) | Rulings on phase A: agents/sage/work/queue-220/RULINGS.md (da9555ca2). Scope adds packages/business/src/library-seats.mjs (imported by path, no index export), its test, and control-board serve.mjs. r2 building as a Haiku subagent. |
| 221 | Case 2 C2-20 Bean and Chet persona import, with a count-only dry run | gosalyn; reviewers webby | #1722 | done | — | Webby approves the manifest. The test file runs green on its own and the cli suite is green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-20 Bean and Chet persona import, with a count-only dry run | LANDED 300766450, pushed. Cand 8f403d0d4, manifest 20f05e3a6ad5, 22 paths, verify-commit ok, patch-id dfbb754c6fb4 equals 62ddc96cc..8f403d0d4. Gate 11:07:50-11:19Z on b1fece6f0: test file alone 11/0, harness 140/0, cli 677/0, all scripts green. Brief Gate met: webby-0073 approve, test file alone, cli, scripts. No live run in the Gate; the import itself waits on Jason. N1 N2 N4 go to C2-20b, N3 to DEFERRED. |
| 222 | Case 2 C2-03a fetch-only sync | megavolt; reviewers dewey | #1723 | done | — | Dewey approves the manifest. The library and bus suites are green on Sage's gate, along with every `scripts/test-*.sh`, and the mutant is killed. A search of the test run's output for the fixture token finds nothing. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-03a fetch-only sync | sage-2024 on megavolt-0089: core.createObject=rename in the fetch argv is accepted as a brief departure, since C2-04 inspectMirror refuses nlink 2. Dewey checks it. The askpass env keys, export SAFE in project/src/git.mjs, bus/tests/library-sync.test.mjs and the SURVEY-RUN user line are accepted. The 5 s client timeout residual goes in BUILD.md and Dewey rules fix or defer. Slot after 197 proof 3 and the 249 gate. |
| 223 | Case 2 C2-07 runner retry on a guard refusal, and the runner's notices | researcher; reviewers filbert | #1724 | blocked (from briefed): Q226: C2-07 folded into C2-06 (row 217). No work here; Jason parks it. | — | Filbert approves the manifest. The harness suite is green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-07 folded into C2-06 | — |
| 224 | Case 2 C2-05b broker lookup verbs and typed tools | della; reviewers dewey | #1725 | in-progress | 216 done | Dewey approves the manifest. The bus and harness suites are green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-05b broker lookup verbs and typed tools | — |
| 225 | Case 2 C2-10b Console chat: replies, citations, write notices and the status line | researcher; reviewers dewey | #1726 | briefed | — | Dewey approves the manifest. The webui suite is green on Sage's gate, along with every `scripts/test-*.sh`. Grep for hash pins on `bus.js` and `serve.mjs` before in-review. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-10b Console chat: replies, citations, write notices and the status line | — |
| 226 | Case 2 C2-13 install script and precheck for the host | gosalyn; reviewers webby | #1727 | in-progress | 215 done, 221 done, 222 done, 230 done | Webby approves the manifest. The test file and the cli suite are green on Sage's gate, along with every `scripts/test-*.sh`. The run on the host is a separate window with Jason's go. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-13b install script and library precheck | — |
| 227 | Case 2 C2-14 end-to-end acceptance on a scratch root | launchpad; reviewers dewey | #1728 | briefed | — | Dewey runs the script and approves. It is green on Sage's gate with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-14 end-to-end acceptance on a scratch root | — |
| 225 | Case 2 C2-10b Console chat: replies, citations, write notices and the status line | fethry; reviewers dewey | #1726 | briefed | 217 done | Dewey approves the manifest. The webui suite is green on Sage's gate, along with every `scripts/test-*.sh`. Grep for hash pins on `bus.js` and `serve.mjs` before in-review. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-10b Console chat: replies, citations, write notices and the status line | — |
| 226 | Case 2 C2-13 install script and precheck for the host | gosalyn; reviewers webby | #1727 | done | 215 done, 221 done, 222 done, 230 done | Webby approves the manifest. The test file and the cli suite are green on Sage's gate, along with every `scripts/test-*.sh`. The run on the host is a separate window with Jason's go. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-13b install script and library precheck | landed 661890e90 (replay of bfed0aa6c 893b4fd25 f3fabf51b onto 8352da88c, patch-ids equal, four blobs and modes equal to f3fabf51b) and fd3d2a99f (Darkwing 8a6321790 replay, mos-1519), tree 78fc5ad21 equals merge-tree. Webby APPROVE rev 3641. Sage gate GREEN 19:56Z on f3fabf51b: 17 package suites and 10 scripts, 0 fail. Webby N1-N4 DEFERRED e2b873390. #1727 closed. |
| 227 | Case 2 C2-14 end-to-end acceptance on a scratch root | gizmoduck; reviewers dewey | #1728 | in-progress | — | Dewey runs the script and approves. It is green on Sage's gate with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-14 end-to-end acceptance on a scratch root | — |
| 228 | Row A: ZAI_API_KEY passes through ENV_ALLOW to sessions, README line, pass-through test (XS, Haiku high) | researcher; reviewers webby | #1729 | done | — | Webby approves the manifest. The mutant is killed. The seat suite and the cli suite are green on Sage's gate with every scripts/test-*.sh. (sage) | `docs/plans/2026-10-20_case2-rows.md` § Brief: row A, ZAI_API_KEY in the session env allowlist | Landed 86249ac55 (pushed 07:25Z) on 59f817c32: the 3 manifest paths from cf18c42b9. Patch-id b6ffe31feb0d equals the candidate's; modes and blobs match; verify-commit ok (3 paths). Sage's combined 228+238 gate on c515273db plus both rows' 6 paths, 07:11:30Z to 07:23:34Z: seat 165/0, cli 640/0, all ten scripts/test-*.sh 585/0. |
| 229 | Row B: per-instance pi agent dir for Chet's Codex login, design only, starts after 10-27 (#428) | gosalyn; reviewers webby | #1730 | briefed | — | A live check, with Jason's or Mos's go, that two clients on one ChatGPT login don't revoke each other's refresh tokens. No build row starts before it passes. Held until after 10-27. (sage) | `docs/plans/2026-10-20_case2-rows.md` § Brief: row B, a per-instance pi agent dir for Chet's Codex login (#428) | — |
| 230 | Case 2 C2-13a unit render and host precheck (case2-unit.sh, case2-precheck.sh) | gosalyn; reviewers webby | #1732 | done | — | Webby approves the manifest. The test file and the cli suite are green on Sage's gate, along with every scripts/test-*.sh. A run on a host is a separate window with Jason's go. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-13a unit render and host precheck | Landed c8bc5cf4c on refactor (base 390ea5261), pushed. Patch-id f4b49eb68997 matches git diff 722136772..96768766c on the 5 manifest paths (manifest fbb0d7e42c67, Webby approve r2 rev 2563). packages/cli/README.md 3-way with row 199, hunks apart. Gosalyn gate on 96768766c: cli 541/0, scripts 429/0, mutants 10/10. Merged tree check: case2-unit 30/0, host 42/0. |
@@ -319,12 +319,12 @@ Generated from `docs/plans/queue.json` revision 3636 by `queue render`. Do not e
| 280 | Bus tests: the scratch sshd proves it owns its port (194 gate follow-up, MVP, mos-1270) | rocko; reviewers duckworth | #1785 | done | — | Duckworth approves the manifest. Both files run whole, the bus suite is green on Sage's gate, the 30-run repeat has no timeout, and M1 is killed. Row 194 b17's full gate then runs on top. Any red goes to Mos (mos-1270). (sage) | `docs/plans/2026-10-22_sshd-port-race.md` § Brief: the scratch sshd's port is ours before a test uses it | Landed b0fd28b6f 07:57:30Z, ff from da5d3651d: b765f5cb8 (Duckworth approve rev 3153, manifest 27806c05) + slim packet. Sage gate bus 656/0/0, ge 41, hr 26 (c30431). Repeat 2010/2010 under cli 1588/1588, no timeout; M1/M2 killed 4/4. Full evidence d6c924764 on rocko/row280-da5d-20261022. N2-N4 carry in BUILD.md. 194 b17 rebases on b0fd28b6f. |
| 281 | A fire or a delete revokes the instance's host and Gitea grants (194/236 follow-up, MVP, mos-1271) | megavolt; reviewers dewey | #1786 | done | 236 done | Dewey approves the design, then the manifest. Bus, business, cli and every touched test file run whole, plus the Docker gitea-grant test and every scripts/test-*.sh, green on Sage's gate. M1-M4 killed. Lands before .53 Case 1 hires a granted instance. Red goes to Sage, then Mos. (sage) | `docs/plans/2026-10-22_fire-revokes-grants.md` § Brief: no grant outlives its instance | LANDED e479521d3 ff on 385a7194c (17 commits, manifest b73d4c81). Slot on 7979fb41e green: bus 819, business 231, cli 837, dewey-bus 76 with gitea-grant-docker run, shells green under 3x limits, M1-M22 killed. M1 hang fixed in b4a (mos-1273). Skips: test-task row 244 live-provider pair, test-queue canonical root. DEFERRED: N1 host-unknown hint verb, N2 fire tool text, to the follow-up row. Dewey APPROVE c30614. |
| 282 | PRD REQ-LIB-6 rewritten to Q223 and Q226 (mos-1320 item 8) | gosalyn; reviewers webby | #1787 | done | — | Webby approves against the ruling. requirements.test.mjs and every scripts/test-*.sh that reads the PRD or requirements.json are green on Sage's gate. Jason gets a report. (sage) | `docs/plans/2026-10-20_case2-rows.md` § PRD REQ-LIB-6 rewritten to Q223 and Q226 (mos-1320 item 8) | Landed 647424442 on 86a665bdf: 1841f7d4e and 6b5b06819 rebased, patch-ids equal (4e50b4b6, 16a7a02f), range-diff =, tree 4f30ed10a = Gosalyn's gated trial tree. Webby APPROVE rev 3308 c30764 on 6b5b06819. Gate 19:06Z on the tip tree: bus+tasks requirements and tasks verbs 37/0/0, 0 skips; no scripts/test-*.sh reads the PRD (control ok). PRD 1.15. 1.16 is row 291, waits for Jason. |
| 283 | Case 2 C2-01d supporting folders, the supporting scope, the editor field and supportingPath (M12) | honker; reviewers webby | #1788 | briefed | 268 done, 282 done | Webby approves the manifest. The library and business suites are green on Sage's gate, along with every scripts/test-*.sh. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-01d supporting folders, the supporting scope, the editor field and supportingPath (M12) | — |
| 283 | Case 2 C2-01d supporting folders, the supporting scope, the editor field and supportingPath (M12) | honker; reviewers webby | #1788 | in-review, round 1, request posted | 268 done, 282 done | Webby approves the manifest. The library and business suites are green on Sage's gate, along with every scripts/test-*.sh. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-01d supporting folders, the supporting scope, the editor field and supportingPath (M12) | E1 (webby-0227): the manifest's existing test changes are survey.test.mjs:73 (mos-1330 re-pin, 28918824c) and business.test.mjs +4 (T7, library-supporting-other-universe). Candidate b2a1f4092. T2 receipts in agents/honker/work/queue-283/BUILD.md. |
| 284 | Case 2 C2-05c the supporting scope and blob in the lookups | della; reviewers dewey | #1789 | briefed | 224 done, 283 done | Dewey approves the manifest. Library, bus and harness suites and every scripts/test-*.sh green on Sage's gate. Grep packages/*/tests for the scope enum pin and ['canon', 'playground'] before in-review; update each. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-05c the supporting scope and blob in the lookups (sage-2833) | — |
| 285 | Case 2 C2-08b role changes for agent writes and the chief's read grant | scrooge; reviewers webby | #1790 | briefed | 213 done, 291 done | Webby approves the manifest. Business suite, test-config.sh and every scripts/test-*.sh green on Sage's gate. Grep for CREATIVE_KINDS and 64-hex pins of changed role files. Sage checks it against mos-1309 item 5. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-08b role changes for agent writes and the chief's read grant (mos-1309 item 5) | — |
| 286 | Case 2 C2-03b push and per-seat credentials for the library mirror (with R4, mos-1323) | ludwig; reviewers dewey | #1791 | briefed | 222 done | Dewey approves the manifest. Library and bus suites and every scripts/test-*.sh green on Sage's gate, mutant killed. A search of the run output for the fixture token finds nothing. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-03b push and per-seat credentials for the library mirror | — |
| 287 | Case 2 C2-21 the grant executor for a library project | megavolt; reviewers dewey | #1792 | briefed | 226 done, 285 done | Dewey approves the manifest. Bus suite, Gitea Docker test, install test and every scripts/test-*.sh green on Sage's gate. A search of the run output for the fixture admin and seat tokens finds nothing. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-21 the grant executor for a library project | — |
| 288 | Case 2 C2-02 agent writes, the notice, revert and her Console saves | honker; reviewers webby | #1793 | briefed | 215 done, 282 done, 283 done, 284 done, 285 done, 286 done | Webby approves the manifest. Library, bus and harness suites and every scripts/test-*.sh green on Sage's gate, mutants killed. A search of the run output for the fixture tokens finds nothing. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-02 agent writes, the notice, revert and her Console saves | — |
| 288 | Case 2 C2-02 agent writes, the notice, revert and her Console saves | bubba; reviewers webby | #1793 | briefed | 215 done, 282 done, 283 done, 284 done, 285 done, 286 done | Webby approves the manifest. Library, bus and harness suites and every scripts/test-*.sh green on Sage's gate, mutants killed. A search of the run output for the fixture tokens finds nothing. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-02 agent writes, the notice, revert and her Console saves | A2 revert contract (morgana-0199, sage-3596): library.revert is a human verb. Args {project, commit}, strict keys; project is the business.projects key (the chat repo link), not a UUID. Result {project, path, reverted, commit}. Refusals: 409 stale-base, and 409 library-revert-not-agent for an agent caller. Owner Bubba under Q245 (mos-1506). |
| 289 | Case 2 C2-11b Console library pages and her saves (Haiku subagent, decision 129) | researcher; reviewers filbert | #1794 | briefed | 214 done, 288 done | Filbert approves the manifest. The webui suite and every scripts/test-*.sh are green on Sage's gate. Grep for hash pins on bus.js and serve.mjs before in-review. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-11b Console library pages and her saves | — |
| 290 | Case 2 C2-12b the setup sheet rewrite for free chat, supporting folders and saves | louie; reviewers webby | #1795 | briefed | 217 done, 218 done, 285 done, 291 done | Doc only. Webby approves it against the ruling. Before landing, grep packages/*/tests and scripts/test-*.sh to confirm no test reads the file. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-12b the setup sheet rewrite for free chat, supporting folders and saves | — |
| 291 | PRD 1.16 library.write routes under REQ-LIB-6 (mos-1328) | gosalyn; reviewers webby | #1796 | done | 282 done | Webby approves the text. Jason says yes to the exact text (Mos asks him, mos-1328 step 3); no landing before that. requirements.test.mjs and every scripts/test-*.sh that reads the PRD or requirements.json are green on Sage's gate. Mos gets the landing commit. (sage) | `docs/plans/2026-10-22_prd-1-16-brief.md` § PRD 1.16 library.write routes under REQ-LIB-6 (mos-1328) | Landed 4874d1e83 on e909ef51a: 482e865ac replayed, patch-id equal (cef7de74), range-diff =. Webby APPROVE c30848 on 482e865ac; Jason approved the text in Q229. Gate 16:15Z on the tip tree: every package suite but queue, 0 fail 0 skipped (tasks 100, bus 891, cli 941, plugins 119, webui 392, business 231), every scripts/test-*.sh rc 0 (queue-root and row 244 skips only). PRD 1.16. 1.17 is row 308. |
@@ -332,9 +332,9 @@ Generated from `docs/plans/queue.json` revision 3636 by `queue render`. Do not e
| 293 | 574: maintenance-free test fixture commits (Vikunja 574) | gander; reviewers filbert | #1805 | done | — | Filbert approves. The four whole files, then the seat and cli suites, are green on Sage's gate. (sage) | `docs/plans/2026-10-23_mvp-readiness-rows.md` § Brief: 574, maintenance-free test fixture commits (Vikunja 574) | LANDED e51d11640 03:55Z 10-23 (cherry-pick of gander b23783f39..a9cae2bd6 onto 464010fbe; range-diff 6/6 equal, range patch-id equal). Filbert APPROVE c30982 cdda41f5; run 2 4 files 153/0, seat 202/0, cli 905/0, 312 flagged/0 maintenance children. N1: lifecycle.test needs TMPDIR of 50 bytes or less. Residual: queue store.mjs, discord git.mjs, harness tools.mjs unflagged. #1805 closed c31013, P19 574 c888. |
| 294 | FED R6: Console granted-user sign-in, user-cli transport, peer links | launchpad; reviewers duckworth | #1800 | done | — | Duckworth approves the manifest. The webui, cli and bus suites and every scripts/test-*.sh are green on Sage's gate, and the mutants are killed. (sage) | `docs/plans/2026-10-23_mvp-readiness-rows.md` § Brief: FED R6, Console granted-user sign-in | landed d3059c2c3 on refactor 07:04Z: cherry-pick of c902a767d..24b745447 onto 09d0d6faf, range-diff 11 =, patch-id 8371405a equal. Duckworth approve c31053 rev 3469 on a1ff5d67, C1/C2 met. Run 4 on 8616b9a4f from npm ci: webui 365, cli 909, bus 873, 10 scripts, M1-M11 killed. Merged tree: stack-release-external 25, stack-upgrade 21, stack-release 223/224 (R9 transient, row 1810) then 224/224 rerun under mos-1422. Follow-ups rows 300, 301. Closes #1800. |
| 295 | FED R4: revocation | morgana; reviewers dewey | #1801 | done | — | Dewey approves the manifest. The bus, cli and business suites and every scripts/test-*.sh are green on Sage's gate, and the mutants are killed. (sage) | `docs/plans/2026-10-23_mvp-readiness-rows.md` § Brief: FED R4, revocation | LANDED 2efd127ce on refactor 10:10Z 10-23 (9 commits cherry-picked onto 07802dc57, range-diff all =, patch-ids match, HEAD~1 tree a86592ffc = gated tree, top commit BUILD.md doc-only). Gate d68b4300d manifest e94c9aeb: 13 whole files, bus 886/0, business 231/0, cli 924/0, harness 151/0, all test-*.sh rc 0, M1-M16 killed, M8 per mos-1436. Dewey approve rev 3500 c31102. Re-pin precheck mos-1436 cond 11 binds. |
| 296 | FED R7: scratch two-instance acceptance | quackerjack; reviewers filbert | #1802 | briefed | 190 done, 294 done, 295 done | Filbert approves the manifest. The script test and every scripts/test-*.sh are green on Sage's gate, and no unit, port or socket of the script's outlives the run. (sage) | `docs/plans/2026-10-23_mvp-readiness-rows.md` § Brief: FED R7, scratch two-instance acceptance | — |
| 297 | GitHub grant method (Bushroot's half of #436b) | bushroot; reviewers dewey | #1803 | briefed | 198 done | Dewey approves the manifest. The touched suites and every scripts/test-*.sh are green on Sage's gate, and the mutants are killed. (sage) | `docs/plans/2026-10-23_mvp-readiness-rows.md` § Brief: the GitHub grant method (Bushroot's half of #436b) | — |
| 298 | Q228: chief-of-staff grant scope | megavolt; reviewers dewey | #1804 | briefed | 252 done | Dewey approves the manifest. The touched suites and every scripts/test-*.sh are green on Sage's gate, and the mutants are killed. (sage) | `docs/plans/2026-10-23_mvp-readiness-rows.md` § Brief: the chief-of-staff grant scope (Q228) | — |
| 296 | FED R7: scratch two-instance acceptance | lena; reviewers filbert | #1802 | briefed | 190 done, 294 done, 295 done | Filbert approves the manifest. The script test and every scripts/test-*.sh are green on Sage's gate, and no unit, port or socket of the script's outlives the run. (sage) | `docs/plans/2026-10-23_mvp-readiness-rows.md` § Brief: FED R7, scratch two-instance acceptance | — |
| 297 | GitHub grant method (Bushroot's half of #436b) | violet; reviewers dewey | #1803 | briefed | 198 done | Dewey approves the manifest. The touched suites and every scripts/test-*.sh are green on Sage's gate, and the mutants are killed. (sage) | `docs/plans/2026-10-23_mvp-readiness-rows.md` § Brief: the GitHub grant method (Bushroot's half of #436b) | — |
| 298 | Q228: chief-of-staff grant scope | boyd; reviewers dewey | #1804 | in-review, round 1, request posted | 252 done | Dewey approves the manifest. The touched suites and every scripts/test-*.sh are green on Sage's gate, and the mutants are killed. (sage) | `docs/plans/2026-10-23_mvp-readiness-rows.md` § Brief: the chief-of-staff grant scope (Q228) | — |
| 299 | 573: the P10 hop band (Vikunja 573) | gander; reviewers filbert | #1806 | done | 293 done | Filbert approves. The whole file under load at least 3 times, then the cli suite, are green on Sage's gate. (sage) | `docs/plans/2026-10-23_mvp-readiness-rows.md` § Brief: 573, the P10 hop band (Vikunja 573) | landed ffb2fedfb 08:03Z on 15e688cc0 via Filbert SESSIONS c1f910b4c; 5 commits = gander/row299-gate-277ace0b 63db96d8b (range-diff 5/5 =, tree differs only BUILD-LOG/SESSIONS); manifest 277ace0b sha256 -c OK. Gate green on ffa93b054: case1 whole 3x 53/0/0, cli whole 917/0/0 (59 files), P10 inner 2.27 outer 4.01, M1-M6 killed (gander-0189). Filbert approve rev 3485 c31084; N1 M1 1.23 s margin. |
| 300 | R6-N1: HTTP-level test of the user-session option swap (294 N1) | launchpad; reviewers duckworth | #1807 | done | 294 done | Duckworth approves the manifest; new test file whole, webui suite, mutant M12 killed (sage) | `docs/plans/2026-10-23_r6-followup-rows.md` § Brief: R6-N1, an HTTP-level test of the user-session option swap | Landed on refactor as 9d929911c..07315e651 (from launchpad/row300 a00b25b37, range-diff equal), pushed 5b846b048 12:28Z. Duckworth approved r2 (c31158, rev 3559). Sage gate on bcf3c1dac: user-option-swap 1/0, webui 375/0 with pinned esbuild and MOSAIC_REQUIRE_EDITOR_BUILD=1, M12 killed at :151. Run 1 red placed under mos-1272 (headers). N1 deferred: board proxy upstream has no recorder. |
| 301 | R6-N4: fold user-bus.mjs back into bus.mjs (294 N4) | launchpad; reviewers duckworth | #1808 | done | 294 done | Duckworth approves the manifest; channel-proof.test, webui suite and 294 user-transport tests whole and green; re-pin only with Mos go (sage) | `docs/plans/2026-10-23_r6-followup-rows.md` § Brief: R6-N4, fold user-bus.mjs back into bus.mjs | Landed on refactor as bb031ec84..5b846b048 (from launchpad/row301 acc97fb9c, range-diff equal), pushed 12:28Z. Re-pin of webui bus.mjs to 39fcb27c is its own commit 59f0e44f4 (mos-1449); fixture stubs per mos-1451. Duckworth approved r2 (c31159, rev 3560). Sage gate: channel-proof 2/0, webui 374/0 pinned esbuild, granted-user 19/0. Live unchanged; 301 makes its own re-pin window. |
@@ -355,6 +355,7 @@ Generated from `docs/plans/queue.json` revision 3636 by `queue render`. Do not e
| 316 | 571 W5: read-only flags, citations and chat suggestions (post-10-27) | unassigned | — | queued | 315 done | Independent Opus 5.5 reviewer, not the author, approves the manifest; the brief section's Gate and G1 pass on Sage's slot: touched test files whole, affected package suites green, named mutants killed. Post-10-27; MVP rows keep slot priority (sage) | `docs/plans/2026-10-21_q195-writing-editor.md` § W5 read-only flags, citations and chat suggestions | — |
| 317 | 571 W7: human one-way chapter import and Case 2 format routing, production import closed; the first real import opens only after W8 lands (post-10-27, mos-1470) | unassigned | — | queued | 314 done, 315 done | Independent Opus 5.5 reviewer, not the author, approves the manifest; the brief section's Gate and G1 pass on Sage's slot: touched test files whole, affected package suites green, named mutants killed. Post-10-27; MVP rows keep slot priority (sage) | `docs/plans/2026-10-21_q195-writing-editor.md` § W7 human one-way chapter import and Case 2 format routing | — |
| 318 | 571 W8: Console Connections view for Markdown and DOCX story files, before W7's first real import (post-10-27, mos-1470) | unassigned | — | queued | 314 done, 315 done, 317 done | Independent Opus 5.5 reviewer, not the author, approves the manifest; the brief section's Gate and G1 pass on Sage's slot: touched test files whole, affected package suites green, named mutants killed. Post-10-27; MVP rows keep slot priority (sage) | `docs/plans/2026-10-21_q195-writing-editor.md` § W8 Console Connections view for Markdown and DOCX story files | fenton-0017 carry: connections.test.mjs must test that links inside comments and tracked deletions are excluded. W8 follows W7's closed routing candidate and lands before W7's first real import opens (mos-1470) |
| 319 | GitHub consumer for row 297's protected githubWithToken callback, honoring expiry and cancellation (post-10-27) | unassigned; reviewers dewey | — | queued | 297 done | Dewey approves the design and the manifest. The touched suites and every scripts/test-*.sh are green on Sage's gate, and the signal and expiry mutants are killed. (sage) | `docs/plans/2026-10-23_github-consumer-brief.md` § Brief: a GitHub consumer for Broker.githubWithToken | — |
<!-- mosaic-queue:end -->
+1350 -44
View File
File diff suppressed because it is too large. Load diff
@@ -27,6 +27,10 @@ test('C2-04: library mappings load without roots, remain frozen, and retain mapp
assert.deepEqual(validateBusinessDocument(doc, file, { rolesDir: REPO_ROLES }).projects.books.universes.harbor.playground, ['Elsewhere/Playground/']);
doc.projects.books.universes.harbor.playground = ['Second World/Playground/'];
refuses(() => validateBusinessDocument(doc, file, { rolesDir: REPO_ROLES }), /library-playground-other-universe/);
// C2-01d: a supporting prefix inside another universe's book refuses with its own code.
doc.projects.books = mappingEntry();
doc.projects.books.universes.harbor.supporting = ['Second World/Notes/'];
refuses(() => validateBusinessDocument(doc, file, { rolesDir: REPO_ROLES }), /library-supporting-other-universe/);
doc.projects.books = { ...mappingEntry(), root: '/forbidden' };
refuses(() => validateBusinessDocument(doc, file, { rolesDir: REPO_ROLES }), /library-mapping-invalid/);
});
@@ -333,3 +333,51 @@ test("row 240 pins and usage: the shipped pins are r6's, the script reads the pi
assert.deepEqual([u.status, u.stderr], [2, "hostcheck: refusing: run it as root\n"]);
}
});
test("Vikunja 614 check: item 5 accepts dpkg's bare octal mode and leading-zero equivalents", (t) => {
for (const mode of ["750", "0750"]) {
const h = host(t);
put(join(h.hc, "statoverride"), `root mosaic ${mode} /usr/bin/bwrap\n`);
const r = run(h, "--check");
assert.equal(r.status, 0, r.out + r.err);
assert.deepEqual(fails(r), []);
assert.ok(r.lines.some((line) => line.startsWith("ok 5 ")), r.out);
}
});
test("Vikunja 614 enforce: dpkg's bare 750 leaves bwrap open and writes no DRIFT", (t) => {
const h = host(t);
put(join(h.hc, "statoverride"), "root mosaic 750 /usr/bin/bwrap\n");
const r = run(h, "--enforce");
assert.equal(r.status, 0, r.out + r.err);
assert.deepEqual(fails(r), []);
assert.equal(r.lines.at(-1), "hostcheck: all seven hold");
assert.deepEqual([calls(h, "apparmor_parser"), calls(h, "chown"), calls(h, "chmod")], [[], [], []]);
assert.equal(existsSync(drift(h)), false);
assert.equal(existsSync(hlog(h)), false);
});
test("Vikunja 614 check: item 5 refuses other modes, owners, groups, paths and malformed overrides", (t) => {
const overrides = [
...["755", "0755", "4750", "75", "7500", "075", "00750", "000750", "0780", "8", "0o750", "+750", "-750", "7x0", "750+0", ""].map((mode) => `root mosaic ${mode} /usr/bin/bwrap\n`),
"root mosaic 750 /usr/bin/bwrap\n",
"root\tmosaic 750 /usr/bin/bwrap\n",
"mosaic mosaic 750 /usr/bin/bwrap\n",
"root root 750 /usr/bin/bwrap\n",
"root mosaic 750 /usr/bin/other\n",
"root mosaic 750 /usr/bin/bwrap extra\n",
"root mosaic 750\n",
"root mosaic 750 /usr/bin/bwrap\nroot mosaic 750 /usr/bin/bwrap\n",
"\n",
"",
null,
];
for (const override of overrides) {
const h = host(t);
if (override === null) rmSync(join(h.hc, "statoverride"));
else put(join(h.hc, "statoverride"), override);
const r = run(h, "--check");
assert.equal(r.status, 1, `${JSON.stringify(override)}: ${r.out}${r.err}`);
assert.deepEqual(fails(r).map((line) => line.split(" ")[1]), ["5"], r.out);
}
});
+419
View File
@@ -0,0 +1,419 @@
// 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");
const notify = join(home, ".mosaic-dev", "notify", "acme");
mkdirSync(notify, { recursive: true, mode: 0o700 });
writeFileSync(join(notify, "notify.json"), '{"notifyVersion":1,"binding":null}\n', { mode: 0o600 });
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);
});
test("Vikunja 614: install preserves the explicit notifier decision bytes and mode and creates none when absent", (t) => {
const x = world(t);
const file = join(x.dataRoot, "notify", "acme", "notify.json");
const before = readFileSync(file), beforeMode = mode(file);
const r = install(x);
assert.equal(r.code, 0, r.out);
assert.deepEqual(readFileSync(file), before);
assert.equal(mode(file), beforeMode);
const absent = world(t), missing = join(absent.dataRoot, "notify", "acme", "notify.json");
rmSync(missing);
const fresh = install(absent);
assert.equal(fresh.code, 0, fresh.out);
assert.equal(existsSync(missing), false);
});
+68
View File
@@ -626,6 +626,9 @@ function host(t, { roles = {}, vars = {}, keyFile = `ZAI_API_KEY=${FIXTURE_KEY}\
script(join(bin, "id"), `if [ "$1" = -G ]; then echo "\${CASE2_TEST_GROUPS:-${GID}}"; else exec /usr/bin/id "$@"; fi`);
script(join(bin, "node"), `if [ "$1" = --version ]; then echo "\${CASE2_TEST_NODE:-v24.3.0}"; exit 0; fi\nexec '${process.execPath}' "$@"`);
script(join(bin, "bwrap"), `exit "\${CASE2_TEST_BWRAP:-0}"`);
const notify = join(home, ".mosaic-dev", "notify", "acme");
mkdirSync(notify, { recursive: true, mode: 0o700 });
writeFileSync(join(notify, "notify.json"), '{"notifyVersion":1,"binding":null}\n', { mode: 0o600 });
return { root, home, configDir, config, secrets, units, sys, bin, dataRoot: join(home, ".mosaic-dev") };
}
@@ -872,3 +875,68 @@ test("inside a session's sandbox a bind below 1024 fails with EACCES, and a bind
assert.equal(bindIn(LOW_PORT), "EACCES");
assert.equal(bindIn(high), "bound");
});
test("Vikunja 614 factory: scoped @anthropic-ai Claude path renders and passes installed read-back", (t) => {
const h = hub(t);
const claude = script(join(h.root, "opt", "claude-pin", "node_modules", "@anthropic-ai", "claude-code", "bin", "claude.exe"), "exit 0");
// The suite runs unprivileged. Model root ownership for this regular executable
// and its ancestors only; file type/executable checks and both unit readers are real.
const ancestors = [];
for (let p = claude; p !== "/"; p = dirname(p)) ancestors.push(p);
script(join(h.bin, "stat"), `if [ "$1" = -c ] && [ "$3" = -- ]; then
case "$4" in ${ancestors.map((p) => JSON.stringify(p)).join("|")})
case "$2" in %u) echo 0; exit ;; %a) echo 755; exit ;; esac ;;
esac
fi
exec /usr/bin/stat "$@"`);
const first = unit(h, { profile: "factory", extra: ["--claude", claude] });
assert.equal(first.code, 0, first.out + first.err);
assert.ok(lines(read(busFile(h)), "Environment").includes(`MOSAIC_CLAUDE_BIN=${claude}`));
const again = unit(h, { profile: "factory", copy: COPY_B, extra: ["--claude", claude] });
assert.equal(again.code, 0, again.out + again.err);
assert.ok(lines(read(busFile(h)), "Environment").includes(`MOSAIC_CLAUDE_BIN=${claude}`));
assert.deepEqual(lines(read(busFile(h)), "WorkingDirectory"), [copyDir(h, COPY_B)]);
});
test("Vikunja 614 factory: plain paths still refuse every named unsafe character", (t) => {
const h = hub(t);
for (const char of [" ", "%", "$", "\"", "'", "\\", "&", "|"]) {
refuses(unit(h, { profile: "factory", extra: ["--claude", `/opt/claude${char}bin`] }), /--claude contains a character the unit cannot carry/);
}
});
test("Vikunja 614 precheck: explicit null and named notifier decisions pass", (t) => {
for (const binding of [null, "host-notify"]) {
const x = host(t);
const dir = join(x.dataRoot, "notify", "acme");
mkdirSync(dir, { recursive: true, mode: 0o700 });
writeFileSync(join(dir, "notify.json"), JSON.stringify({ notifyVersion: 1, binding }), { mode: 0o600 });
const r = precheck(x);
assert.equal(r.code, 0, r.out);
assert.ok(r.out.includes(`ok notifier config ${dir}/notify.json validates (directory 0700, file 0600)`), r.out);
}
});
test("Vikunja 614 precheck: notifier config refuses missing, public, malformed and extra-key decisions", (t) => {
for (const fault of ["missing", "mode", "directory", "extra", "json", "binding", "version", "symlink"]) {
const x = host(t);
const dir = join(x.dataRoot, "notify", "acme"), file = join(dir, "notify.json");
mkdirSync(dir, { recursive: true, mode: 0o700 });
writeFileSync(file, '{"notifyVersion":1,"binding":null}\n', { mode: 0o600 });
if (fault === "missing") rmSync(file);
if (fault === "mode") chmodSync(file, 0o644);
if (fault === "directory") chmodSync(dir, 0o755);
if (fault === "extra") writeFileSync(file, '{"notifyVersion":1,"binding":null,"extra":true}\n');
if (fault === "json") writeFileSync(file, '{bad json\n');
if (fault === "binding") writeFileSync(file, '{"notifyVersion":1,"binding":"bad binding"}\n');
if (fault === "version") writeFileSync(file, '{"notifyVersion":2,"binding":null}\n');
if (fault === "symlink") {
const target = join(dir, "target.json");
writeFileSync(target, readFileSync(file), { mode: 0o600 });
rmSync(file); symlinkSync(target, file);
}
const r = precheck(x);
assert.equal(r.code, 1, `${fault}: ${r.out}`);
assert.match(r.out, /REFUSED notifier config:/, `${fault}: ${r.out}`);
}
});
+22
View File
@@ -347,3 +347,25 @@ test('row 232: check --units external reads the system units, the default reads
rmSync(argv);
}
});
test('Vikunja 614: bundle and checkout copy leave an unreadable caller directory before find',t=>{
const s=setup(t),cwd=join(s.root,'unreadable');mkdirSync(cwd);
const bundle=join(s.root,'release.bundle');git(s.repo,'bundle','create','-q',bundle,'refactor');
// Enter while searchable, then reproduce a cwd inherited across a uid drop:
// the caller can neither read nor search it. Absolute bundle lookup must work.
const enter='cd "$1" && chmod 000 . && [ ! -r . ] && [ ! -x . ] && shift && exec "$@"';
for(const [name,args] of [['bundle',['copy','--bundle',bundle,s.id]],['checkout',['copy',s.id]]]){
const copies=join(s.root,`copies-${name}`);
try{
const r=spawnSync('/bin/sh',['-c',enter,'stack-copy-cwd',cwd,join(s.repo,'scripts','stack-copy.sh'),...args,'--copies',copies],{env:s.env,encoding:'utf8',timeout:30000});
assert.equal(statSync(cwd).mode&0o555,0,'the inherited cwd is neither readable nor searchable');
assert.equal(r.status,0,r.stdout+r.stderr);
assert.doesNotMatch(r.stderr,/Failed to (restore initial working|change) directory|Permission denied/);
assert.ok(existsSync(join(copies,s.id,'scripts','mosaic')));
assert.equal(statSync(join(copies,s.id)).mode&0o777,0o555);
}finally{
// Restore even after a failed assertion/mutant, before setup's recursive cleanup.
chmodSync(cwd,0o700);
}
}
});
+45 -6
View File
@@ -31,33 +31,72 @@ deep-frozen copy. `where` names the entry in messages. `fail(code, message)` def
- `user` is the bot account name from design step 3, required for sync. It is optional in the
mapping and checked with row 199's account name pattern, `[A-Za-z0-9_.-]`, 1 to 100 characters.
- `vaultPrefix` is optional, one folder name.
- `editor` is optional, `{ "name", "email" }` plus an optional `"user"`: the ident her Console saves commit under
(C2-01d). Each value is text that is non-empty after trim, with no control character
(`U+0000`-`U+001F`, `U+007F`-`U+009F`), `<` or `>`, and no whitespace or git-stripped character at
either end. `user` is her Gitea login for the `library-human` slot (sage-2925), checked as the
entry's `user` is. `editor` and its `user` come back only when given; a save without them refuses
in C2-02.
- `types` maps a folder name to `character`, `place`, `research`, `tracker` or `summary`.
`manuscript` lists folder names and shares none with `types`.
- A universe has `name`, `books` and `playground`. A hybrid adds `kind: "hybrid"`, two or more
plain-universe `sources`, `constraints` (each one `{ "exclude": ... }` or
`{ "pin": { "entity", "field", "value" } }`) and `notes`, and may have `books: []`. A draft
universe has only `kind: "draft"`, `name` and one or more `playground` prefixes, and comes back as
`{ kind: "draft", name, books: [], playground }`. `kind` is absent, `"hybrid"` or `"draft"`.
`{ kind: "draft", name, books: [], playground, supporting }`. `kind` is absent, `"hybrid"` or `"draft"`.
- A playground prefix ends in `/` and has one of three levels (C2-01c). Book level lies inside one of
its own entry's book folders. Universe level, in a plain universe or a hybrid, lies outside every
book folder. A draft's prefixes all lie outside every book folder. No prefix holds another or is
listed twice, across every entry.
- Every universe kind may also have `supporting`, a list of prefixes where agents may write (C2-01d,
M12). It comes back on every universe, `[]` when absent. A supporting prefix follows the playground's
rules: it ends in `/`, has the same three levels, holds no other supporting prefix and is listed
once, across every entry.
- Between the two lists: a supporting prefix may equal a playground prefix of its own entry, and a
playground prefix may hold one of its own entry's supporting prefixes. A supporting prefix may not
hold a playground prefix, which scope would then never reach, and may not equal, hold or lie inside
another entry's playground prefix.
Refusals:
| Code | When |
|---|---|
| `library-playground-other-universe` | a playground prefix lies inside, or holds, a book folder of another entry |
| `library-mapping-invalid` | any other shape fault: unknown key, bad remote or branch, an unknown `kind`, a book folder in two universes, a book folder that holds another, a playground prefix that holds another or is listed twice, a prefix equal to a book folder or holding its own entry's book, a draft with `books` or with no prefix, a hybrid source that is unknown, a hybrid or a draft |
| `library-supporting-other-universe` | a supporting prefix lies inside, or holds, a book folder of another entry |
| `library-mapping-invalid` | any other shape fault: unknown key, bad remote or branch, an unknown `kind`, a book folder in two universes, a book folder that holds another, a playground or supporting prefix that holds another of its list or is listed twice, a prefix equal to a book folder or holding its own entry's book, a supporting prefix that holds a playground prefix or overlaps another entry's, a bad `editor`, a draft with `books` or with no prefix, a hybrid source that is unknown, a hybrid or a draft |
## Scope
`classify(mapping, path)` decides scope from the path alone. Only paths ending in lowercase `.md` are
mapped. It checks prefixes first. A path under any entry's playground prefix is `playground`, with that
entry as `universe` and the book folder holding the prefix as `book`, or `null` at universe level and
mapped. It checks prefixes first, supporting before playground. A path under any entry's supporting
prefix is `supporting`, and otherwise one under a playground prefix is `playground`. Either way that
entry is `universe` and the book folder holding the prefix is `book`, or `null` at universe level and
in a draft. Any other path under a book folder is `canon`. Everything else is `unmapped`. The type is
the first folder after the playground prefix, or after the book: a `types` name, `manuscript`, or
`null`.
the first folder after the prefix, or after the book: a `types` name, `manuscript`, or `null`.
## Agent write paths
`supportingPath(mapping, universe, path, tree)` returns `path` when an agent may write it in that
universe, else it refuses `library-write-story-path` (C2-01d, M12). `tree` is the array `listTree`
returns for the commit the write lands on; anything else is a TypeError. It reads no git. The checks
run in this order, so a path with two faults always names the same one:
1. a string of at most 1,024 UTF-8 bytes;
2. no control character (the `editor` class), and the message leaves the path out;
3. no backslash;
4. not absolute;
5. no empty, `.` or `..` segment, which also refuses `//` and a trailing `/`;
6. `classify` gives `supporting` in that same universe, which refuses unmapped, `.canvas`, `.base`
and attachment paths, canon, a playground-only folder and another universe's prefix;
7. no folder on the way, and not the path itself, is a symlink (`120000`) or gitlink (`160000`) in
`tree`;
8. no folder on the way, and not the path itself, matches a tree path or tree folder after
`normalize('NFC').toLowerCase()` unless it is that exact name. APFS folds case and Unicode form,
so her checkout would put the file somewhere else. A tree that already holds case twins refuses a
write to either (R7).
A delete's further rule, that the path exists at the tip as a `100644` blob, belongs to the C2-02
broker.
## Reader
+1
View File
@@ -2,6 +2,7 @@
export { LibraryError, RULES, problem, sortProblems } from './problems.mjs';
export { checkMapping, TYPES } from './mapping.mjs';
export { classify } from './scope.mjs';
export { supportingPath } from './supporting.mjs';
export { extractLinks, parseWikilink, resolver, formOf, isAttachment } from './links.mjs';
export { readLibrary, listTree, readTexts, parseFile, parseFrontMatter, headCommit, MAX_BLOB, LINK_FIELDS } from './reader.mjs';
export { renderItems, KINDS, NOTICES, UNREADABLE } from './render.mjs';
+64 -20
View File
@@ -1,8 +1,9 @@
// The library project entry of a business file (design 1.1): her folders mapped onto universes,
// books, type folders, manuscript folders and playground prefixes at three levels (ruling after r11,
// C2-01c): inside one of the entry's books, outside every book, or in a draft universe. A pure validator. Business file
// loading wires it in C2-04, which passes its own `fail` so the refusal reads as a business file
// problem. Agents can't write this entry (M9, C2-04).
// C2-01c): inside one of the entry's books, outside every book, or in a draft universe. Supporting
// prefixes (C2-01d) follow the same rules and are where agents may write (M12). A pure validator.
// Business file loading wires it in C2-04, which passes its own `fail` so the refusal reads as a
// business file problem. Agents can't write this entry (M9, C2-04).
import { raise } from './problems.mjs';
export const TYPES = Object.freeze(['character', 'place', 'research', 'tracker', 'summary']);
@@ -12,6 +13,12 @@ const BRANCH = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$/;
const USER = /^[A-Za-z0-9_.-]{1,100}$/;
const CONSTRAINT_KEYS = ['exclude', 'pin'];
const KINDS = ['hybrid', 'draft'];
// C2-01d (F41): the editor ident her Console saves commit under. Git drops `<` and `>` from an ident
// and commits under a different name, so they refuse here (webby-0169 R1).
const IDENT_BAD = /[\u0000-\u001f\u007f-\u009f<>]/;
// Git's ident.c crud() strips any byte <= 0x20 and , : ; < > " \ ' from both ends of an ident, so a
// value that starts or ends with one would save one name and commit another (sage-3601, sage-3606).
const IDENT_CRUD_END = /^[\u0000-\u0020,:;<>"\\']|[\u0000-\u0020,:;<>"\\']$/;
// A folder segment from her vault: spaces and capitals are fine, separators, control characters
// and dot names are not.
@@ -40,7 +47,7 @@ export function checkMapping(entry, where = 'library', { fail = raise } = {}) {
};
object(entry, '');
only(entry, ['kind', 'remote', 'branch', 'user', 'vaultPrefix', 'universes', 'types', 'manuscript'], 'entry');
only(entry, ['kind', 'remote', 'branch', 'user', 'vaultPrefix', 'editor', 'universes', 'types', 'manuscript'], 'entry');
if (entry.kind !== 'library') bad('kind must be "library"');
let url;
try { url = new URL(entry.remote); } catch { bad('remote must be an https:// URL'); }
@@ -52,6 +59,20 @@ export function checkMapping(entry, where = 'library', { fail = raise } = {}) {
if (Object.hasOwn(entry, 'user') && (typeof entry.user !== 'string' || !USER.test(entry.user)))
bad('user must be a plain account name');
if (Object.hasOwn(entry, 'vaultPrefix') && !segment(entry.vaultPrefix)) bad('vaultPrefix must be one folder name');
if (Object.hasOwn(entry, 'editor')) {
object(entry.editor, 'editor');
only(entry.editor, ['name', 'email', 'user'], 'editor');
// Her Gitea login for the library-human slot, which C2-02 passes in (sage-2925). Optional here.
if (Object.hasOwn(entry.editor, 'user') && (typeof entry.editor.user !== 'string' || !USER.test(entry.editor.user)))
bad('editor.user must be a plain account name');
for (const key of ['name', 'email']) {
const value = entry.editor[key];
if (typeof value !== 'string' || !value.trim() || IDENT_BAD.test(value))
bad(`editor.${key} must be non-empty text with no control character, < or >`);
if (value.trim() !== value || IDENT_CRUD_END.test(value))
bad(`editor.${key} must not start or end with whitespace or any of , : ; " ' \\`);
}
}
object(entry.types, 'types');
for (const [name, type] of Object.entries(entry.types)) {
@@ -64,6 +85,7 @@ export function checkMapping(entry, where = 'library', { fail = raise } = {}) {
object(entry.universes, 'universes');
if (!Object.keys(entry.universes).length) bad('universes must name at least one universe');
const universes = {}, bookOwner = new Map(), prefixes = [], prefixOwner = new Map();
const supports = [], supportOwner = new Map();
for (const [id, universe] of Object.entries(entry.universes)) {
const name = `universes.${id}`;
if (!ID.test(id)) bad(`universe id ${JSON.stringify(id)} is not a plain id`);
@@ -71,8 +93,8 @@ export function checkMapping(entry, where = 'library', { fail = raise } = {}) {
if (Object.hasOwn(universe, 'kind') && !KINDS.includes(universe.kind)) bad(`${name}.kind must be "hybrid" or "draft"`);
const hybrid = universe.kind === 'hybrid', draft = universe.kind === 'draft';
if (draft && Object.hasOwn(universe, 'books')) bad(`${name} is a draft and takes no books`);
only(universe, hybrid ? ['kind', 'name', 'sources', 'books', 'playground', 'constraints', 'notes']
: draft ? ['kind', 'name', 'playground'] : ['name', 'books', 'playground'], name);
only(universe, hybrid ? ['kind', 'name', 'sources', 'books', 'playground', 'supporting', 'constraints', 'notes']
: draft ? ['kind', 'name', 'playground', 'supporting'] : ['name', 'books', 'playground', 'supporting'], name);
if (typeof universe.name !== 'string' || !universe.name.trim() || universe.name.length > 128 || /[\u0000-\u001f]/.test(universe.name))
bad(`${name}.name must be a short display name`);
const books = draft ? [] : list(universe.books, `${name}.books`, folder, 'a folder path');
@@ -81,12 +103,14 @@ export function checkMapping(entry, where = 'library', { fail = raise } = {}) {
if (bookOwner.has(book)) bad(`book folder ${JSON.stringify(book)} is in two universes`);
bookOwner.set(book, id);
}
const playground = list(universe.playground ?? [], `${name}.playground`,
value => typeof value === 'string' && value.endsWith('/') && folder(value.slice(0, -1)), 'a folder path ending in /');
const prefix = value => typeof value === 'string' && value.endsWith('/') && folder(value.slice(0, -1));
const playground = list(universe.playground ?? [], `${name}.playground`, prefix, 'a folder path ending in /');
if (draft && !playground.length) bad(`${name}.playground must name at least one prefix`);
for (const prefix of playground) { prefixes.push(prefix); prefixOwner.set(prefix, id); }
if (draft) { universes[id] = { kind: 'draft', name: universe.name, books, playground }; continue; }
const out = { name: universe.name, books, playground };
for (const p of playground) { prefixes.push(p); prefixOwner.set(p, id); }
const supporting = list(universe.supporting ?? [], `${name}.supporting`, prefix, 'a folder path ending in /');
for (const p of supporting) { supports.push(p); supportOwner.set(p, id); }
if (draft) { universes[id] = { kind: 'draft', name: universe.name, books, playground, supporting }; continue; }
const out = { name: universe.name, books, playground, supporting };
if (hybrid) {
out.kind = 'hybrid';
out.sources = list(universe.sources, `${name}.sources`, value => typeof value === 'string' && ID.test(value), 'a universe id');
@@ -124,18 +148,36 @@ export function checkMapping(entry, where = 'library', { fail = raise } = {}) {
for (const a of books) for (const b of books) if (a !== b && holds(a, b)) bad(`book folder ${JSON.stringify(a)} holds book folder ${JSON.stringify(b)}`);
for (const a of prefixes) for (const b of prefixes) if (a !== b && holds(a, b)) bad(`playground ${JSON.stringify(a)} holds playground ${JSON.stringify(b)}`);
if (new Set(prefixes).size !== prefixes.length) bad('a playground prefix is listed twice');
for (const a of supports) for (const b of supports) if (a !== b && holds(a, b)) bad(`supporting ${JSON.stringify(a)} holds supporting ${JSON.stringify(b)}`);
if (new Set(supports).size !== supports.length) bad('a supporting prefix is listed twice');
// A prefix's level comes from where it sits (ruling after r11). Inside one of its own entry's books
// is book level, outside every book is universe level, and a draft's prefixes are all outside. Scope
// checks prefixes first (scope.mjs), so a prefix equal to or holding a book would turn that book
// into playground.
for (const [prefix, id] of prefixOwner) {
for (const [book, owner] of bookOwner) {
const at = `universes.${id}.playground ${JSON.stringify(prefix)}`;
if (prefix === `${book}/`) bad(`${at} is the whole book folder ${JSON.stringify(book)}`);
const inside = holds(book, prefix), holding = holds(prefix, book);
if ((inside || holding) && owner !== id)
fail('library-playground-other-universe', `${where} ${at} ${inside ? 'lies inside' : 'holds'} book folder ${JSON.stringify(book)} of universes.${owner}`);
if (holding) bad(`${at} holds its own book folder ${JSON.stringify(book)}`);
// into playground or supporting.
const againstBooks = (owners, list, code) => {
for (const [prefix, id] of owners) {
for (const [book, owner] of bookOwner) {
const at = `universes.${id}.${list} ${JSON.stringify(prefix)}`;
if (prefix === `${book}/`) bad(`${at} is the whole book folder ${JSON.stringify(book)}`);
const inside = holds(book, prefix), holding = holds(prefix, book);
if ((inside || holding) && owner !== id)
fail(code, `${where} ${at} ${inside ? 'lies inside' : 'holds'} book folder ${JSON.stringify(book)} of universes.${owner}`);
if (holding) bad(`${at} holds its own book folder ${JSON.stringify(book)}`);
}
}
};
againstBooks(prefixOwner, 'playground', 'library-playground-other-universe');
againstBooks(supportOwner, 'supporting', 'library-supporting-other-universe');
// C2-01d, between the lists. A supporting prefix may equal a playground prefix of its own entry, and
// a playground may hold one of its own entry's supporting prefixes. A supporting prefix that holds a
// playground would hide it, since scope checks supporting first. Any overlap across entries would
// give one path two universes.
for (const [s, sid] of supportOwner) {
for (const [p, pid] of prefixOwner) {
const at = `universes.${sid}.supporting ${JSON.stringify(s)}`;
if (sid !== pid && (s === p || holds(s, p) || holds(p, s)))
bad(`${at} overlaps playground ${JSON.stringify(p)} of universes.${pid}`);
if (sid === pid && s !== p && holds(s, p)) bad(`${at} holds playground ${JSON.stringify(p)}`);
}
}
@@ -143,6 +185,8 @@ export function checkMapping(entry, where = 'library', { fail = raise } = {}) {
kind: 'library', remote: entry.remote, branch: entry.branch,
...(Object.hasOwn(entry, 'user') ? { user: entry.user } : {}),
...(Object.hasOwn(entry, 'vaultPrefix') ? { vaultPrefix: entry.vaultPrefix } : {}),
...(Object.hasOwn(entry, 'editor') ? { editor: { name: entry.editor.name, email: entry.editor.email,
...(Object.hasOwn(entry.editor, 'user') ? { user: entry.editor.user } : {}) } } : {}),
universes, types: { ...entry.types }, manuscript,
});
}
+12 -6
View File
@@ -2,16 +2,22 @@
// Paths are repo-relative and compared exactly, case included (R7 handles case-only twins).
const inside = (folder, path) => path.startsWith(folder.endsWith('/') ? folder : `${folder}/`);
// { scope: 'playground' | 'canon', universe, book, type, folder } or { scope: 'unmapped' }.
// Prefixes come first (ruling after r11, C2-01c): a `.md` file under any entry's playground prefix is
// playground, with that entry as `universe` and the book folder that holds the prefix as `book`, or
// null at universe level and in a draft. Otherwise a `.md` file in a book folder is canon. `type` is
// the mapped type of the first folder under the prefix (playground) or under the book (canon),
// { scope: 'supporting' | 'playground' | 'canon', universe, book, type, folder } or { scope: 'unmapped' }.
// Prefixes come first (ruling after r11, C2-01c), supporting before playground (C2-01d, design 1.2):
// a `.md` file under any entry's supporting prefix is supporting, and otherwise one under a playground
// prefix is playground. Either way `universe` is that entry and `book` is the book folder that holds
// the prefix, or null at universe level and in a draft. Otherwise a `.md` file in a book folder is
// canon. `type` is the mapped type of the first folder under the prefix or under the book (canon),
// 'manuscript' for a manuscript folder, else null. `folder` is that folder name. checkMapping keeps
// prefixes from overlapping, so at most one matches.
// prefixes of one list from overlapping, and keeps a supporting prefix from holding a playground
// prefix, so the first match is the only one that counts.
export function classify(mapping, path) {
if (typeof path !== 'string' || !path.endsWith('.md')) return { scope: 'unmapped' };
const entries = Object.entries(mapping.universes);
for (const [universe, entry] of entries) {
const prefix = entry.supporting.find(p => inside(p, path));
if (prefix) return place(mapping, path, prefix, { scope: 'supporting', universe, book: entry.books.find(b => inside(b, prefix)) ?? null });
}
for (const [universe, entry] of entries) {
const prefix = entry.playground.find(p => inside(p, path));
if (prefix) return place(mapping, path, prefix, { scope: 'playground', universe, book: entry.books.find(b => inside(b, prefix)) ?? null });
+49
View File
@@ -0,0 +1,49 @@
// C2-01d (M12): the paths an agent may write. A path passes only under a supporting prefix of the
// named universe, and only if nothing in the tree could turn the write into something else: a
// symlink or gitlink on the way, or a name that differs from a tree name only in case or Unicode
// form (APFS folds both, so her checkout would put the file somewhere else). Pure; reads no git.
import { LibraryError } from './problems.mjs';
import { classify } from './scope.mjs';
const CONTROL = /[\u0000-\u001f\u007f-\u009f]/;
const MAX_BYTES = 1024;
const LINKS = new Set(['120000', '160000']);
const fold = name => name.normalize('NFC').toLowerCase();
// `tree` is the array listTree returns ({ mode, type, oid, size, path }). Returns `path` or throws
// library-write-story-path naming the class. Checks run in a fixed order, so a path with two faults
// always names the same one.
export function supportingPath(mapping, universe, path, tree) {
if (!Array.isArray(tree)) throw new TypeError('supportingPath needs the listTree array');
const refuse = message => { throw new LibraryError('library-write-story-path', message); };
if (typeof path !== 'string') refuse('the path must be a string');
const at = JSON.stringify(path);
if (Buffer.byteLength(path, 'utf8') > MAX_BYTES) refuse(`${at} is longer than ${MAX_BYTES} bytes`);
if (CONTROL.test(path)) refuse('the path holds a control character');
if (path.includes('\\')) refuse(`${at} holds a backslash`);
if (path.startsWith('/')) refuse(`${at} is absolute`);
const parts = path.split('/');
if (parts.some(part => part === '' || part === '.' || part === '..')) refuse(`${at} has an empty, . or .. segment`);
const place = classify(mapping, path);
if (place.scope !== 'supporting' || place.universe !== universe)
refuse(`${at} is not under a supporting folder of universe ${JSON.stringify(universe)}`);
const own = parts.map((_, i) => parts.slice(0, i + 1).join('/'));
const modes = new Map(tree.map(entry => [entry.path, entry.mode]));
for (const p of own) if (LINKS.has(modes.get(p))) refuse(`${at} goes through the symlink or gitlink ${JSON.stringify(p)}`);
const names = new Map();
for (const entry of tree) {
const segs = entry.path.split('/');
for (let i = 1; i <= segs.length; i++) {
const name = segs.slice(0, i).join('/');
const key = fold(name);
if (!names.has(key)) names.set(key, new Set());
names.get(key).add(name);
}
}
for (const p of own) {
const seen = names.get(fold(p));
if (seen && [...seen].some(name => name !== p))
refuse(`${at} differs only in case or Unicode form from ${JSON.stringify([...seen].find(name => name !== p))}`);
}
return path;
}
+2 -2
View File
@@ -62,7 +62,7 @@ export async function survey({ repo, commit, mapping }) {
forms, attachments, fields,
};
if (mapping) {
const scopes = { canon: 0, playground: 0, unmapped: 0 };
const scopes = { canon: 0, playground: 0, supporting: 0, unmapped: 0 };
for (const entry of entries) scopes[classify(mapping, entry.path).scope]++;
const library = await readLibrary({ repo, commit: head, mapping });
const steps = Object.fromEntries(STEPS.map(s => [s, 0]));
@@ -89,7 +89,7 @@ export function formatSurvey(s) {
out.push(`body links ${FORMS.map(f => `${f} ${s.forms[f]}`).join(', ')}; attachment ${s.attachments}`);
for (const [field, c] of Object.entries(s.fields)) out.push(`field ${field} wikilink ${c.wikilink}, name ${c.name}`);
if (s.scopes) {
out.push(`scopes canon ${s.scopes.canon}, playground ${s.scopes.playground}, unmapped ${s.scopes.unmapped}; indexed ${s.indexed}`);
out.push(`scopes canon ${s.scopes.canon}, playground ${s.scopes.playground}, supporting ${s.scopes.supporting}, unmapped ${s.scopes.unmapped}; indexed ${s.indexed}`);
out.push(`resolved ${STEPS.map(st => `${st} ${s.steps[st]}`).join(', ')}, unresolved ${s.steps.unresolved}`);
out.push(`problems ${Object.entries(s.problems).map(([code, n]) => `${code} ${n}`).join(', ')}`);
}
+106 -1
View File
@@ -1,5 +1,9 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { checkMapping, classify, LibraryError } from '../src/index.mjs';
import { mapping, mappingEntry } from './helpers.mjs';
@@ -45,7 +49,7 @@ test('C2-01c: universe-level prefixes lie outside every book, in a plain univers
test('C2-01c: a draft takes kind, name and playground only, and comes back frozen with books []', () => {
const m = mapping(e => { e.universes.idea = draft(); return e; });
assert.deepEqual(m.universes.idea, { kind: 'draft', name: 'New World', books: [], playground: ['Drafts/New World/'] });
assert.deepEqual(m.universes.idea, { kind: 'draft', name: 'New World', books: [], playground: ['Drafts/New World/'], supporting: [] });
assert.ok(Object.isFrozen(m.universes.idea) && Object.isFrozen(m.universes.idea.books) && Object.isFrozen(m.universes.idea.playground));
refuses(e => { e.universes.idea = { ...draft(), books: ['New World'] }; return e; }, 'library-mapping-invalid', /universes.idea is a draft and takes no books/);
refuses(e => { e.universes.idea = { ...draft(), books: [] }; return e; }, 'library-mapping-invalid', /takes no books/);
@@ -56,6 +60,107 @@ test('C2-01c: a draft takes kind, name and playground only, and comes back froze
refuses(e => { e.universes.harbor.kind = kind; return e; }, 'library-mapping-invalid', /universes.harbor.kind must be "hybrid" or "draft"/);
});
test('C2-01d: supporting prefixes at book level, universe level, in a hybrid and in a draft, [] when absent', () => {
const m = mapping(e => {
e.universes.harbor.supporting = ['Book One/Notes/', 'Harbor Notes/'];
e.universes.cross = { kind: 'hybrid', name: 'Cross', sources: ['harbor', 'second'], books: [], supporting: ['Crossover Notes/'] };
e.universes.idea = { ...draft(), supporting: ['Drafts/New World Notes/'] };
return e;
});
assert.deepEqual(m.universes.harbor.supporting, ['Book One/Notes/', 'Harbor Notes/']);
assert.deepEqual(m.universes.cross.supporting, ['Crossover Notes/']);
assert.deepEqual(m.universes.idea.supporting, ['Drafts/New World Notes/']);
assert.deepEqual(m.universes.second.supporting, []);
assert.ok(Object.isFrozen(m.universes.harbor.supporting) && Object.isFrozen(m.universes.second.supporting));
assert.deepEqual(mapping().universes.harbor.supporting, []);
});
test('C2-01d: each supporting prefix rule refuses with its code', () => {
const invalid = (change, pattern) => refuses(change, 'library-mapping-invalid', pattern);
invalid(e => { e.universes.harbor.supporting = ['Book One/Notes']; return e; }, /universes.harbor.supporting holds "Book One\/Notes", which is not a folder path ending in \//);
invalid(e => { e.universes.harbor.supporting = ['Book One/../Notes/']; return e; }, /which is not a folder path ending in \//);
invalid(e => { e.universes.harbor.supporting = 'Book One/Notes/'; return e; }, /universes.harbor.supporting must be a list/);
invalid(e => { e.universes.harbor.supporting = ['Book One/Notes/', 'Book One/Notes/Deep/']; return e; },
/supporting "Book One\/Notes\/" holds supporting "Book One\/Notes\/Deep\/"/);
invalid(e => { e.universes.harbor.supporting = ['Ideas/']; e.universes.idea = { ...draft(), supporting: ['Ideas/'] }; return e; },
/a supporting prefix is listed twice/);
invalid(e => { e.universes.harbor.supporting = ['Book One/']; return e; }, /universes.harbor.supporting "Book One\/" is the whole book folder "Book One"/);
invalid(e => { e.universes.harbor.books = ['Shelf/Book One']; e.universes.harbor.playground = []; e.universes.harbor.supporting = ['Shelf/']; return e; },
/universes.harbor.supporting "Shelf\/" holds its own book folder "Shelf\/Book One"/);
// Supporting is checked first in scope, so a supporting prefix holding a playground would hide it.
invalid(e => { e.universes.harbor.supporting = ['Book One/Workshop/']; e.universes.harbor.playground = ['Book One/Workshop/Playground/']; return e; },
/universes.harbor.supporting "Book One\/Workshop\/" holds playground "Book One\/Workshop\/Playground\/"/);
// Across entries any overlap would give one path two universes.
for (const [supporting, playground] of [['Ideas/', 'Ideas/'], ['Ideas/Deep/', 'Ideas/'], ['Ideas/', 'Ideas/Deep/']])
invalid(e => { e.universes.harbor.supporting = [supporting]; e.universes.idea = draft([playground]); return e; },
new RegExp(`universes.harbor.supporting "${supporting}" overlaps playground "${playground}" of universes.idea`));
refuses(e => { e.universes.harbor.supporting = ['Second World/Notes/']; return e; },
'library-supporting-other-universe', /universes.harbor.supporting "Second World\/Notes\/" lies inside book folder "Second World" of universes.second/);
refuses(e => { e.universes.second.books = ['Worlds/Second']; e.universes.harbor.supporting = ['Worlds/']; return e; },
'library-supporting-other-universe', /universes.harbor.supporting "Worlds\/" holds book folder "Worlds\/Second" of universes.second/);
refuses(e => { e.universes.idea = { ...draft(), supporting: ['Book One/Idea Notes/'] }; return e; },
'library-supporting-other-universe', /universes.idea.supporting "Book One\/Idea Notes\/" lies inside book folder "Book One" of universes.harbor/);
});
test('C2-01d: between the lists, a supporting prefix may equal its own playground, and a playground may hold its own supporting', () => {
const same = mapping(e => { e.universes.harbor.supporting = ['Book One/Playground/']; return e; });
assert.deepEqual([same.universes.harbor.playground, same.universes.harbor.supporting], [['Book One/Playground/'], ['Book One/Playground/']]);
const nested = mapping(e => { e.universes.harbor.supporting = ['Book One/Playground/Notes/']; return e; });
assert.deepEqual(nested.universes.harbor.supporting, ['Book One/Playground/Notes/']);
});
test('C2-01d: editor is optional, kept when given, and refuses anything but a plain name and email', () => {
assert.equal(Object.hasOwn(mapping(), 'editor'), false);
const editor = { name: 'Her Name', email: '[email protected]' };
const m = mapping(e => ({ ...e, editor }));
assert.deepEqual(m.editor, editor);
assert.notEqual(m.editor, editor);
assert.ok(Object.isFrozen(m.editor));
const invalid = (value, pattern) => refuses(e => ({ ...e, editor: value }), 'library-mapping-invalid', pattern);
for (const value of [null, 'Her Name', 7, ['Her Name', '[email protected]']]) invalid(value, /editor must be an object/);
invalid({ ...editor, signingKey: 'x' }, /editor has unknown key "signingKey"/);
// sage-2925: an optional Gitea login for her save, checked as the entry's user is.
assert.deepEqual(mapping(e => ({ ...e, editor: { ...editor, user: 'her.login-1' } })).editor, { ...editor, user: 'her.login-1' });
for (const user of ['', 'her login', 'her@host', 'a'.repeat(101), 7, null])
invalid({ ...editor, user }, /editor.user must be a plain account name/);
invalid({ name: 'Her Name' }, /editor.email must be non-empty text with no control character, < or >/);
invalid({ email: '[email protected]' }, /editor.name must be non-empty text/);
for (const bad of ['', ' ', 7, null, 'a\u0000b', 'a\nb', 'a\u001fb', 'a\u007fb', 'a\u0085b', 'a\u009fb', '<her>', 'a<b', 'a>b']) {
invalid({ ...editor, name: bad }, /editor.name must be non-empty text with no control character, < or >/);
invalid({ ...editor, email: bad }, /editor.email must be non-empty text with no control character, < or >/);
}
// sage-3601, sage-3606: git's crud() strips whitespace and , : ; < > " \ ' from both ends of an
// ident, so a value starting or ending with one refuses. Inner < > and controls refuse above.
const ends = [' ', '\u00a0', '\u3000', ',', ':', ';', '"', "'", '\\'];
for (const c of ends) for (const padded of [`${c}Her Name`, `Her Name${c}`]) {
invalid({ ...editor, name: padded }, /editor.name must not start or end with whitespace or any of , : ; " ' \\/);
invalid({ ...editor, email: padded }, /editor.email must not start or end with whitespace or any of , : ; " ' \\/);
}
for (const c of ['<', '>', '\n', '\t']) for (const padded of [`${c}Her Name`, `Her Name${c}`, `Her${c}Name`])
invalid({ ...editor, name: padded }, /editor.name must be non-empty text with no control character, < or >/);
assert.equal(mapping(e => ({ ...e, editor: { name: 'Her Middle Name', email: '[email protected]' } })).editor.name, 'Her Middle Name');
for (const name of ['Doe, Jane Q.', 'Jane: "Q" Doe', "O'Neil", 'Back\\slash Name'])
assert.equal(mapping(e => ({ ...e, editor: { name, email: '[email protected]' } })).editor.name, name);
});
// sage-3606: an accepted editor commits under exactly the saved ident, as author and committer. A
// future git that strips more from an ident fails here.
test('C2-01d: an accepted editor round-trips through a real git commit unchanged', t => {
const editor = mapping(e => ({ ...e, editor: { name: 'Doe, Jane Q.', email: '[email protected].' } })).editor;
const root = mkdtempSync(join(tmpdir(), 'library-ident-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
// An ident in the environment would win over -c, so the commit runs without one. GIT_DIR and its
// family would beat -C (inside a hook, say), and git reads no global or system config (webby-0236 E5).
const env = Object.fromEntries(Object.entries(process.env).filter(([k]) => !/^GIT_(AUTHOR|COMMITTER)_/.test(k)));
for (const k of ['GIT_DIR', 'GIT_WORK_TREE', 'GIT_COMMON_DIR', 'GIT_INDEX_FILE']) delete env[k];
Object.assign(env, { GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_NOSYSTEM: '1' });
const git = (...args) => execFileSync('git', ['-c', `user.name=${editor.name}`, '-c', `user.email=${editor.email}`,
'-c', 'commit.gpgsign=false', '-c', 'core.hooksPath=/dev/null', '-C', root, ...args], { encoding: 'utf8', env });
git('init', '-q');
git('commit', '-q', '--allow-empty', '-m', 'ident');
assert.equal(git('log', '-1', '--format=%an%n%ae%n%cn%n%ce'), `${editor.name}\n${editor.email}\n${editor.name}\n${editor.email}\n`);
});
test('no playground prefix holds another, and no book folder holds another', () => {
refuses(e => { e.universes.harbor.playground = ['Book One/Playground/', 'Book One/Playground/Deep/']; return e; },
'library-mapping-invalid', /holds playground/);
+41
View File
@@ -53,3 +53,44 @@ test('C2-01c: a canon file linking into a draft gives R6, and into its own unive
assert.deepEqual(codes(path), [], path);
}
});
// Supporting prefixes at three levels (C2-01d). Harbor's book-level prefix sits in both lists, and a
// second book-level prefix is supporting only.
const supporting = () => mapping(e => {
e.universes.harbor.playground = ['Book One/Playground/', 'Book One/Sketches/'];
e.universes.harbor.supporting = ['Book One/Playground/', 'Book One/Notes/', 'Harbor Notes/'];
e.universes.idea = { kind: 'draft', name: 'New World', playground: ['Drafts/New World/'], supporting: ['Drafts/New World Notes/'] };
return e;
});
test('C2-01d: each supporting level gives its universe, and its book or null', () => {
const m = supporting();
assert.deepEqual(classify(m, 'Book One/Notes/Characters/Mara.md'),
{ scope: 'supporting', universe: 'harbor', book: 'Book One', type: 'character', folder: 'Characters' });
assert.deepEqual(classify(m, 'Harbor Notes/Places/Island.md'),
{ scope: 'supporting', universe: 'harbor', book: null, type: 'place', folder: 'Places' });
assert.deepEqual(classify(m, 'Harbor Notes/Loose.md'), { scope: 'supporting', universe: 'harbor', book: null, type: null, folder: null });
assert.deepEqual(classify(m, 'Drafts/New World Notes/Chapters/Open.md'),
{ scope: 'supporting', universe: 'idea', book: null, type: 'manuscript', folder: 'Chapters' });
assert.deepEqual(classify(m, 'Drafts/New World/Ana.md'), { scope: 'playground', universe: 'idea', book: null, type: null, folder: null });
assert.deepEqual(classify(m, 'Book One/Places/Dock.md'), { scope: 'canon', universe: 'harbor', book: 'Book One', type: 'place', folder: 'Places' });
});
test('C2-01d: supporting comes before playground; a folder in both lists is supporting, one in the playground only stays playground', () => {
const m = supporting();
assert.deepEqual(classify(m, 'Book One/Playground/What If.md'),
{ scope: 'supporting', universe: 'harbor', book: 'Book One', type: null, folder: null });
assert.deepEqual(classify(m, 'Book One/Sketches/Research/Tides.md'),
{ scope: 'playground', universe: 'harbor', book: 'Book One', type: 'research', folder: 'Research' });
// A playground may hold a supporting prefix of its own entry: the inner folder is supporting.
const nested = mapping(e => { e.universes.harbor.supporting = ['Book One/Playground/Notes/']; return e; });
assert.equal(classify(nested, 'Book One/Playground/Notes/A.md').scope, 'supporting');
assert.equal(classify(nested, 'Book One/Playground/A.md').scope, 'playground');
});
test('C2-01d: only .md files are supporting; a .canvas or .base under a supporting prefix is unmapped', () => {
const m = supporting();
for (const path of ['Book One/Notes/Board.canvas', 'Harbor Notes/Table.base', 'Drafts/New World Notes/Map.canvas',
'Harbor Notes/cover.png', 'Harbor Notesx/a.md'])
assert.deepEqual(classify(m, path), { scope: 'unmapped' }, path);
});
@@ -0,0 +1,92 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { inspect } from 'node:util';
import { LibraryError, supportingPath } from '../src/index.mjs';
import { mapping } from './helpers.mjs';
// C2-01d (M12): the path checks an agent write passes. The tree is a listTree array built by hand,
// with a symlink, a gitlink, case twins and an NFC name, so no git is needed.
const m = mapping(e => {
e.universes.harbor.playground = ['Book One/Playground/', 'Book One/Sketches/'];
e.universes.harbor.supporting = ['Book One/Playground/', 'Book One/Notes/', 'Harbor Notes/'];
e.universes.second.supporting = ['Second Notes/'];
e.universes.idea = { kind: 'draft', name: 'New World', playground: ['Drafts/New World/'], supporting: ['Drafts/New World Notes/'] };
return e;
});
const blob = (path, mode = '100644') => ({ mode, type: mode === '160000' ? 'commit' : 'blob', oid: '0'.repeat(40), size: 1, path });
const tree = [
blob('Book One/Characters/Mara Vell.md'),
blob('Book One/Notes/Existing.md'),
blob('Book One/Notes/Linked', '120000'),
blob('Book One/Notes/Vendor', '160000'),
blob('Book One/Notes/Pointer.md', '120000'),
blob('Book One/Notes/Case/Twin.md'),
blob('Book One/Notes/Café.md'),
blob('Harbor Notes/Places/Island.md'),
];
const refuses = (universe, path, pattern) => assert.throws(() => supportingPath(m, universe, path, tree),
e => e instanceof LibraryError && e.code === 'library-write-story-path' && pattern.test(e.message), inspect(path));
test('C2-01d: a path passes at book level, universe level, in a draft and in a folder in both lists', () => {
for (const [universe, path] of [['harbor', 'Book One/Notes/Characters/New.md'], ['harbor', 'Book One/Notes/Existing.md'],
['harbor', 'Harbor Notes/Places/Island.md'], ['harbor', 'Harbor Notes/Loose.md'], ['idea', 'Drafts/New World Notes/Ana.md'],
['harbor', 'Book One/Playground/What If.md'], ['second', 'Second Notes/A.md'], ['harbor', 'Book One/Notes/Café.md']])
assert.equal(supportingPath(m, universe, path, tree), path);
assert.equal(supportingPath(m, 'harbor', 'Book One/Notes/x.md', []), 'Book One/Notes/x.md');
});
test('C2-01d: a path of exactly 1024 bytes passes, and 1025 bytes made of multi-byte characters refuses', () => {
const prefix = 'Harbor Notes/';
const exact = `${prefix}${'a'.repeat(1024 - prefix.length - 3)}.md`;
assert.equal(Buffer.byteLength(exact), 1024);
assert.equal(supportingPath(m, 'harbor', exact, tree), exact);
// é is two bytes: 1025 bytes but far fewer characters.
const name = 'é'.repeat((1025 - prefix.length - 3 - 1) / 2);
const long = `${prefix}${name}a.md`;
assert.equal(Buffer.byteLength(long), 1025);
assert.ok(long.length < 1024);
refuses('harbor', long, /is longer than 1024 bytes/);
});
test('C2-01d: a path outside the universe\'s supporting folders refuses', () => {
const outside = /is not under a supporting folder of universe/;
for (const path of ['Inbox Note.md', 'Book One/Notes/Board.canvas', 'Book One/Notes/Table.base', 'Book One/Notes/cover.png',
'Book One/Characters/Mara Vell.md', 'Book One/Sketches/Idea.md', 'Drafts/New World/Ana.md', 'Second Notes/A.md', 'Book One/Notes.md'])
refuses('harbor', path, outside);
refuses('idea', 'Book One/Notes/A.md', /universe "idea"/);
refuses('nowhere', 'Book One/Notes/A.md', /universe "nowhere"/);
});
test('C2-01d: malformed paths refuse by class, in order', () => {
refuses('harbor', '/Book One/Notes/A.md', /"\/Book One\/Notes\/A.md" is absolute/);
for (const path of ['Book One/Notes/../Notes/A.md', 'Book One/Notes/./A.md', 'Book One//Notes/A.md', 'Book One/Notes/A/', 'Book One/Notes/..'])
refuses('harbor', path, /has an empty, \. or \.\. segment/);
refuses('harbor', 'Book One/Notes/A\\B.md', /holds a backslash/);
for (const c of ['\u0000', '\u0001', '\n', '\u001f', '\u007f', '\u0085', '\u009f'])
assert.throws(() => supportingPath(m, 'harbor', `Book One/Notes/A${c}.md`, tree),
e => e.code === 'library-write-story-path' && e.message === 'library-write-story-path: the path holds a control character');
for (const path of [7, null, undefined, 1n, ['Book One/Notes/A.md']]) refuses('harbor', path, /the path must be a string/);
// A backslash in a path that is also absolute names the backslash: the order is fixed.
refuses('harbor', '/a\\b.md', /holds a backslash/);
assert.throws(() => supportingPath(m, 'harbor', 'Book One/Notes/A.md', { 'Book One/Notes/A.md': '100644' }), TypeError);
assert.throws(() => supportingPath(m, 'harbor', 'Book One/Notes/A.md'), TypeError);
});
test('C2-01d: a symlink or gitlink on the way, or at the path itself, refuses', () => {
refuses('harbor', 'Book One/Notes/Linked/A.md', /goes through the symlink or gitlink "Book One\/Notes\/Linked"/);
refuses('harbor', 'Book One/Notes/Vendor/Deep/A.md', /goes through the symlink or gitlink "Book One\/Notes\/Vendor"/);
refuses('harbor', 'Book One/Notes/Pointer.md', /goes through the symlink or gitlink "Book One\/Notes\/Pointer.md"/);
});
test('C2-01d: a name that differs from a tree name only in case or Unicode form refuses, at the file or any folder', () => {
refuses('harbor', 'Book One/Notes/existing.md', /differs only in case or Unicode form from "Book One\/Notes\/Existing.md"/);
refuses('harbor', 'Book One/Notes/case/New.md', /differs only in case or Unicode form from "Book One\/Notes\/Case"/);
refuses('harbor', 'Book One/Notes/CASE/Twin.md', /from "Book One\/Notes\/Case"/);
refuses('harbor', 'Harbor notes/A.md', /is not under a supporting folder/);
// NFD e + combining acute against the NFC name in the tree: one file on APFS.
refuses('harbor', 'Book One/Notes/Café.md', /differs only in case or Unicode form from "Book One\/Notes\/Café.md"/);
// A tree that already holds case twins refuses a write to either (R7).
const twins = [...tree, blob('Book One/Notes/existing.md')];
for (const path of ['Book One/Notes/Existing.md', 'Book One/Notes/existing.md'])
assert.throws(() => supportingPath(m, 'harbor', path, twins), e => e.code === 'library-write-story-path' && /differs only in case/.test(e.message));
});
+13 -2
View File
@@ -1,6 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { writeFileSync } from 'node:fs';
import { readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
@@ -70,12 +70,23 @@ test('with --mapping the survey adds scope, resolver step and problem counts', a
assert.equal(out.code, 0, out.stderr);
const tail = out.stdout.trimEnd().split('\n').slice(-3);
assert.deepEqual(tail, [
'scopes canon 5, playground 1, unmapped 3; indexed 6',
'scopes canon 5, playground 1, supporting 0, unmapped 3; indexed 6',
'resolved root 2, vault-prefix 1, relative 2, shortest 3, unresolved 0',
'problems R1 0, R2 3, R3 0, R4 0, R5 0, R6 0, R7 0']);
for (const s of NEVER) assert.ok(!out.stdout.includes(s), s);
});
test('C2-01d: with --mapping the survey counts supporting files', async t => {
const r = repo(t);
const entry = JSON.parse(readFileSync(join(FIXTURES, 'mapping.json'), 'utf8'));
entry.universes.harbor.supporting = ['Book One/Places/'];
const file = join(r.root, 'supporting.json');
writeFileSync(file, JSON.stringify(entry));
const out = await run(['survey', r.repo, '--mapping', file]);
assert.equal(out.code, 0, out.stderr);
assert.match(out.stdout, /^scopes canon 4, playground 1, supporting 1, unmapped 3; indexed 6$/m);
});
test('survey exit codes: usage 4, bad mapping 3, unreadable repo 2', async t => {
const r = repo(t);
for (const args of [[], ['scan', r.repo], ['survey'], ['survey', r.repo, '--mapping'], ['survey', r.repo, '--other'], ['survey', r.repo, r.repo]])
+2 -2
View File
@@ -16,7 +16,7 @@
# 3. `grep -rlw bwrap /etc/apparmor.d` lists that one file and nothing else.
# 4. A bwrap child started as mosaic under the units' no-new-privs reads the label
# `bwrap//&unpriv_bwrap (enforce)`, exactly.
# 5. The statoverride on /usr/bin/bwrap reads `root mosaic 0750`, and stat agrees.
# 5. The statoverride on /usr/bin/bwrap has root mosaic and octal mode 0750 (750 accepted), and stat agrees.
# 6. kernel.apparmor_restrict_unprivileged_userns and fs.protected_hardlinks are 1.
# 7. net.ipv4.ip_unprivileged_port_start is above the Console's --port in the installed unit, or
# above 1023, the highest --port case2-unit.sh takes, while no unit is installed.
@@ -114,7 +114,7 @@ item5() {
local so st
so="$(dpkg-statoverride --list "$BWRAP" 2>/dev/null)"
st="$(stat -c '%U %G %a' "$ROOT$BWRAP" 2>/dev/null)"
[ "$so" = "root mosaic 0750 $BWRAP" ] || { fail "5 the statoverride on $BWRAP reads '$so', not 'root mosaic 0750'"; return; }
[[ "$so" =~ ^root\ mosaic\ 0?750\ "$BWRAP"$ ]] || { fail "5 the statoverride on $BWRAP reads '$so', not 'root mosaic 0750'"; return; }
[ "$st" = "root mosaic 750" ] || { fail "5 $BWRAP is '$st', not 'root mosaic 750'"; return; }
pass "5 $BWRAP is root mosaic 0750, by the statoverride and by stat"
}
+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
+143 -3
View File
@@ -5,9 +5,11 @@
# 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,
# the explicit notifier config is valid at 0600 in a private 0700 directory;
# and factory refuses a business file with a library project
# (profile-library-business); the run root is 0700; XDG_DATA_HOME is unset or
# <home>/.local/share; node is 24 or later; bwrap runs; the unprivileged port
@@ -17,6 +19,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 +52,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; }
@@ -91,6 +173,11 @@ const say = (...f) => console.log(f.join("\t"));
let system;
try { system = loadSystem(); } catch (e) { say("CONFIG", "bad", e.message.split("\n")[0]); process.exit(0); }
say("CONFIG", "ok", system.dataRoot, configDir());
try {
const { readNotifyConfig } = await import(`${repo}/packages/cli/src/cli.mjs`);
readNotifyConfig(system.dataRoot, id);
say("NOTIFY", "ok");
} catch (e) { say("NOTIFY", "bad", e.message.split("\n")[0]); }
const file = businessFilePath(id);
let raw;
try { raw = JSON.parse(readFileSync(file, "utf8")); } catch (e) { say("BUSINESS", "bad", `cannot read ${file}: ${e.code ?? e.message}`); process.exit(0); }
@@ -100,6 +187,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)) {
@@ -121,6 +210,19 @@ case "$cfg" in
*) refuse "config: the check did not run: $(printf '%s' "$facts" | head -n 1)" ;;
esac
# Use the CLI's exact file/owner/mode/JSON rule; its journal also needs a private directory.
if [ -n "$DATA_ROOT" ]; then
notify_dir="$DATA_ROOT/notify/$BUSINESS"
notify="$(line NOTIFY)"
case "$notify" in
"NOTIFY ok")
if private_dir "$notify_dir"; then ok "notifier config $notify_dir/notify.json validates (directory 0700, file 0600)"
else refuse "notifier config: $notify_dir must be $ME's 0700 directory"; fi ;;
"NOTIFY bad "*) refuse "notifier config: ${notify#NOTIFY bad }" ;;
*) refuse "notifier config: the check did not run" ;;
esac
fi
# Tester and profile.
library="$(line LIBRARY | cut -f2)"
biz="$(line BUSINESS)"
@@ -138,6 +240,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)"
+1 -1
View File
@@ -59,7 +59,7 @@ WEBUI_UNIT=mosaic-webui.service
usage() { echo 'usage: case2-unit.sh --user NAME --home DIR --business ID --copy ID --node PATH [--profile factory|library] [--pm] [--claude PATH] -- SERVE FLAGS' >&2; exit 4; }
die() { echo "case2-unit: $*" >&2; exit 1; }
# A value the unit carries as itself: no space, %, $, quote, backslash, & or |.
plain() { case "$1" in *[!A-Za-z0-9/._+-]*) return 1 ;; esac; }
plain() { case "$1" in *[!A-Za-z0-9/._+@-]*) return 1 ;; esac; }
abs_plain() {
case "$1" in /*) ;; *) die "$2 must be an absolute path: $1" ;; esac
plain "$1" || die "$2 contains a character the unit cannot carry: $1"
+3
View File
@@ -59,6 +59,9 @@ copy_commit() {
# The copy of commit $2 from repository $1. CLEAN names a bundle's temporary repository, which the
# trap removes with the temporary copy.
copy_tree() {
# Paths are absolute here (including a bundle resolved by copy_bundle). The caller
# may have dropped uid from a directory this user cannot read; find must start elsewhere.
cd / || die 'cannot change to /; no copy written'
local full="$2" target tmp
target="$COPIES/$full"
[ ! -L "$target" ] || die "refusing: copy target is a symlink: $target"