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