Compare commits

...
12 changed files with 549 additions and 5 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.
+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 |
+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.
+51 -2
View File
@@ -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,6 +241,21 @@ 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.
@@ -285,6 +310,30 @@ The installer key, `<configDir>/secrets/hosts/<business>/<hostId>/installer`, is
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).
@@ -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);
}
});
+18
View File
@@ -84,6 +84,9 @@ mkdir -p "$4/$2/scripts" && : >"$4/$2/scripts/mosaic" && echo "copied: $4/$2"`);
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"),
@@ -399,3 +402,18 @@ test("--library-status takes no --profile", (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);
}
}
});
+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"
}
+19
View File
@@ -9,6 +9,7 @@
#
# 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
@@ -172,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); }
@@ -204,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)"
+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"