Compare commits

...
Author SHA1 Message Date
jason.woltje 2e3a5ddd45 Row 286 r3: fix new credential fixtures and record green CLEAR gate with killed mutants 2026-10-24 05:40:18 -05:00
jason.woltje 4b8261f415 Row 286 r3: record successful canonical queue start and pre-CLEAR push receipt 2026-10-24 04:59:09 -05:00
jason.woltje 29e830cac8 Row 286 r3: transplant evidence, conflict resolution and static reader drift; untested pending CLEAR 2026-10-24 04:58:14 -05:00
jason.woltje 7842700fb6 Row 286 C2-03b r2: rebase onto 45dca69f6 (sage-3579). The range-diff is all =. Drift check of the paths the tests read is in BUILD.md. Unrun. 2026-10-24 04:55:16 -05:00
jason.woltje 888548607e Row 286 C2-03b: BUILD.md notes for dewey-0869 and sage-2951 (fired-chief path :4308, marker-gated busy test, T9 scratch roles) 2026-10-24 04:55:16 -05:00
jason.woltje f2a43d69d9 Row 286 C2-03b: fold dewey-0869 and sage-2951 (marker-gated busy test, T9 scratch roles with a chief gitea credential, fired-chief path cited) 2026-10-24 04:55:16 -05:00
jason.woltje d037e98d4c Row 286 C2-03b: cite sage-2947 (T7 real verbs, R1 in this row, 285/286 rebase rule) 2026-10-24 04:55:16 -05:00
jason.woltje 90b70a6a6c Row 286 C2-03b: fold dewey-0864 (T7/T9 through grants.revoke and instance.fire, race fetch takes the sync guard, two-chief code, id -u shim note) 2026-10-24 04:55:16 -05:00
jason.woltje bb29ee2d95 Row 286: library.status shows the credential (webby-0151 R3); fired chief revokes its row as row 281 does; unrun 2026-10-24 04:55:16 -05:00
jason.woltje cca76fa95f Row 286 C2-03b: library push, per-seat credentials, the chief's fetch credential, askpass fd check (#1791, sage-2922/2925/2928, dewey-0860); unrun 2026-10-24 04:55:16 -05:00
jason.woltje 8ca4429594 Row 286 C2-03b: sage-2928 rulings (sh helper, chief by roles definition, departure noted) 2026-10-24 04:55:04 -05:00
jason.woltje c2c4ac6793 Row 286 C2-03b: fold dewey-0860 (Q1 sh fd/inode check, Q2 chief by roles definition, N1-N3) 2026-10-24 04:55:04 -05:00
jason.woltje 6ce15eddd5 Row 286 C2-03b: sage-2925 Q4 ruling (humanCredential takes her login; editor.user is row 283's) 2026-10-24 04:55:04 -05:00
jason.woltje e155e3245b Row 286 C2-03b: sage-2922 files-owned extension; Q4 back to Sage (user is the bot fetch identity) 2026-10-24 04:55:04 -05:00
jason.woltje 3326d9487b Row 286 C2-03b: build plan and four scope questions (BUILD.md only) 2026-10-24 04:55:04 -05:00
19 changed files with 2776 additions and 23 deletions

No files matched your search

+4
View File
@@ -5476,3 +5476,7 @@ remain; no live state, schema or bus policy change.
2026-10-24 | Goldie | Row265 / #1770 verified (mos-1567, sage-3942) | Approved correction adds solely address import pin + Row265 comment to project-runtime.test and one pure-source RUNNER_FILES mount; new block checks parser has no imports. Serialized fresh-short-TMP timeout-k10: unchanged sandbox10/10, parser15/15, rooms18/18, full bus957/957, twelve named refusal mutants killed (each Node1, driver0), restored parser15/15 + rooms18/18. All positives zero fail/skip/cancel; every mutation restored full broker SHA71b32ee4. Old first-red evidence retained, no other old-test change. Slot returned goldie-0034; Boyd packet explicitly includes mount-list review, candidate push next.
2026-10-24T07:18:53Z | Sage | Row 327 (#1825, Bradford, Poe and Dewey) LANDED 68c08b4e7. Before: refactor at 94d9e90a6; the push guard was a scratch script with no reviewed-binary check. After: scripts/push-check.sh and scripts/test-push-check.sh on refactor, 3 commits cherry-picked onto 6a5e12511 with blobs equal to the round 3 tip d858c9f8b, plus doc commits 76f84465f and 68c08b4e7. Binaries are found by numstat or a NUL in the first 8000 committed bytes, regardless of attributes; each needs its sha256 and path in an approved latest-round candidate; an unconfirmed queue tail exits 2. Reviews: Poe c31525 rev 3882, Dewey c31526 rev 3883. Gate: 1st run 06:57Z RED, not the candidate: canonical node_modules was empty from 05:45:15Z (sage-4023). Rerun with its own root npm ci GREEN 07:15:49Z: 17 package suites 0 failures (cli 986, plugins 119, webui 418), 11 scripts exit 0, push-check 6 mutants KILLED; skips are the queue canonical-root check and the row 244 live provider cases. The landed push-check.sh passed its own push (NO_BINARY, NO_ATTRIBUTION, PUSH_READY). mos-1575: gates install their own node_modules; canonical restored with npm ci (128 packages).
- 2026-10-24 (rockerduck, row 286 / #1791, r3 before gate): transplanted Ludwig 45dca69f6..573fa0be0 onto b19ffa04f, preserved newer exports in sole index.mjs conflict; plain-text range-diff, patch IDs and reader/pin drift inventory in agents/rockerduck/work/queue-286/BUILD.md. UNTESTED by sage-4040: no setup or tests until CLEAR.
- 2026-10-24 (rockerduck, row 286 / #1791, r3 after sage-4054 CLEAR): own-worktree installs; library 140/140 and bus 963/963; M1 forced-refspec and M2 missing-inode-compare killed at semantic assertions, restored before final suites; all gate-output fixture searches zero. Four first-run reds only in new credential test; setup corrections and intermediate findings recorded in agents/rockerduck/work/queue-286/BUILD.md. No old test edited during gate; shell/CLI suites remain Sage landing gate.
+277
View File
@@ -0,0 +1,277 @@
# Row 286: C2-03b push and per-seat credentials for the library mirror (#1791)
Owner Ludwig. Reviewer Dewey. Gate owner Sage (sage-2909).
Brief: `docs/plans/2026-10-20_case2-rows.md`, "## C2-03b", at 94c33a43c. Design 7.2 and the ruling
after r11 "agents never write story" in `agents/gosalyn/work/case2/DESIGN.md` at b68d79663.
Base: `refactor` at 94c33a43c. The brief cites aca16404c, and lines have moved since:
`GRANT_EVIDENCE` is `broker.mjs:253` now, not `:246`.
Status: code written, local and unrun (18:48Z on). No node until Sage's CLEAR, after row 258's run.
## What the code does today (94c33a43c)
- `packages/library/src/sync.mjs:32` `syncCommand(kind, url, {mirror, branch, user, slot})`. It refuses
any kind but `fetch`. The env passes `MOSAIC_LIBRARY_HOST`, `_USER` and `_SLOT` to `bin/askpass`.
- `bin/askpass` checks `-f` and `! -L` on the slot, then reads it. It doesn't check the uid or the mode.
- `sync.mjs:51` `slotPresent` checks lstat: a regular file, our uid, no group or other bits.
- The slot path is built in `packages/bus/src/runtime.mjs:211-217` (`syncOptions`) as
`<configDir>/secrets/<business>/library-gitea`. The broker only calls `this.#library.sync(context,
{accept})` (`broker.mjs:4630`), and the timer calls `libraryModule.sync` directly (`runtime.mjs:533`).
- The sync state file has a strict key list in `packages/library/src/mirror.mjs:222`.
- `chief-of-staff` is a definition (`STAFF_TO`, `broker.mjs:240`), not a fixed instance name.
- A seat's Gitea user is in `gitea_grants.user` and `gitea_users`. Its token file is
`<storeRoot>/<business>/<agent>/gitea` (`gitea-grant.mjs:379`). `giteaKeys.held(business, agent)`
answers the held case.
## Plan
1. `syncCommand(kind, url, {mirror, branch, user, file, commit})`. `kind` is `fetch` or `push`. Same
`SAFE` prefix, `-c` keys and env for both. A push is
`push --porcelain --no-verify --end-of-options <url> <commit>:refs/heads/<branch>`, with no `+`.
`commit` must be a full sha. The env var stays `MOSAIC_LIBRARY_SLOT` but now names whichever file
answers: a seat file, the chief's file, slot `library-gitea` or slot `library-human`.
2. `push(context, {commit, credential, retried, command})` in `sync.mjs`. git's porcelain line for
the ref decides:
- `!` with `[rejected]` and `(fetch first)` or `(non-fast-forward)` means the tip moved. Fetch once
with the same credential and return `{pushed: false, raced: true, tip}`. With `retried: true`, the
same rejection refuses `stale-base`. C2-02 owns the re-apply.
- Any other failure refuses `library-push-failed`. git's stderr isn't kept.
- A remote that fails C2-04's URL rules refuses `library-remote-invalid` before any git call.
3. `bin/askpass` gets `readSecretFile`'s checks, the same way: no symlink, the bus's uid, mode 0600.
In sh: `test -f && test ! -L && test -O`, plus a mode check through `stat -c %a`. Only the path
reaches that argv. The user check stays an exact match on the one configured user. The check and the
read happen at different times (TOCTOU). The bus owns the directories, so I take that as the
accepted limit (Q1).
4. Credential choice for the fetch, as a pure function `fetchCredential(store, business, keys)` in the
bus. The broker exposes it, and `runtime.mjs` passes its answer to `sync` for the verb and the timer:
- The chief is the one instance whose definition is `chief-of-staff`.
- Evidence: `SELECT 1 FROM events WHERE business=? AND ${GRANT_EVIDENCE} AND
json_extract(body,'$.method')='gitea' AND json_extract(body,'$.agent')=?`, of any action. It
names no project and never reads `gitea_grants`.
- No evidence: slot `library-gitea`, `{kind: 'slot'}`.
- Evidence, a live `gitea_grants` row for the chief and not held: `{kind: 'chief', user, file}`,
with the user from that row. The fallback test never reads the table. This path does, because it
needs the user.
- Evidence and anything else (revoked, fired or held): `library-fetch-no-grant`, before any file is
opened.
- No chief instance (fired): Q2.
5. The sync state gets `credential: {kind, user} | null`. `library.status` shows it.
`library-fetch-no-grant` shows as `lastError.code`. This changes `mirror.mjs`'s key check (Q3).
6. Slot `library-human` is `<configDir>/secrets/<business>/library-human`. `humanCredential(...)`
refuses `library-human-missing` before any git call when the slot is missing or fails the file checks
(lstat: regular file, our uid, 0600). Her Gitea user: Q4.
## Files owned extension (sage-2922)
Added to the brief's list: `packages/bus/src/runtime.mjs` (`syncOptions` at :211-217 and the timer's
call at :533) and `packages/library/src/mirror.mjs` (the state key list at :222). sage-2922 wrote
`packages/library/src/runtime.mjs`, but the file is `packages/bus/src/runtime.mjs`. In mirror.mjs
the row 222 contract holds: keys are added and none renamed. Before in-review I grep `packages/*/tests`
for hash pins and path reads on both files and check them against every in-review manifest.
## Scope questions (for Dewey and Sage before code)
- Q1. The askpass TOCTOU above. Is it accepted as is, or should the helper be a node script that does
O_NOFOLLOW plus fstat? If node, it would read the token in process and print it to git's pipe.
- Q2. Fired chief. The instance and its `gitea_grants` row are gone, so I can't name the agent for the
evidence check. My recommendation fails safe: with no chief instance, the fetch refuses
`library-fetch-no-grant` when any gitea grant event exists in the business, and uses the slot only
when none exists.
- Q3. Files owned names `sync.mjs`, `askpass`, `broker.mjs` and the tests. The choice also needs
`packages/bus/src/runtime.mjs` (`syncOptions` and the timer's call) and
`packages/library/src/mirror.mjs` (the state key list for `credential`). I'd add both to the
manifest, with only those hunks changed.
- Q3: approved, sage-2922. See above.
- Q4: sage-2922 allowed `user` only if design 7.2 or the ruling after r11 makes it her account. Neither
does. `packages/library/README.md:31` at 94c33a43c says "`user` is the bot account name from design
step 3, required for sync", so it is the fetch identity and can't be reused for her save. Back to Sage.
My recommendation: `humanCredential(context, {user})` takes her login as a parameter, so this row adds no
mapping field. The row that adds the save, C2-02 or C2-11a with `editor`, names the field. That
needs a mapping schema change, which is Sage's call.
Ruled sage-2925: accepted. `humanCredential(context, {user})` refuses `library-human-missing` before
any git call when the slot is missing or bad or `user` is missing. This row adds no mapping field.
Row 283 (C2-01d) adds an optional `editor.user`, and row 288 (C2-02) passes it in.
- Q4 (original). Her Gitea user for `library-human`. The entry's `user` (`mapping.mjs:52`) is today the sync
user, the bot's. The design names `editor {name, email}` (C2-11a) but no Gitea login. My
recommendation: the entry's `user` becomes her Gitea login, used only with slot `library-human`. The
fallback fetch keeps that same user too, because the bot slot was set up for it. Otherwise this row
takes the user as a parameter and C2-02 or C2-12b names the field.
## Tests (brief list, mapped)
T1 push lands a fast-forward. T2 a moved tip reports the race. T3 a second race refuses `stale-base`.
T4 a dead remote refuses `library-push-failed`. T5 askpass answers with the named seat's user and file,
and refuses another seat's user, a symlink, 0644 and another uid (with a fallback note if the test can't
chown). T6 the fetch uses the chief's file with a live grant, and the slot only with no chief event.
T7 revoked (through `grants.revoke`), fired (through `instance.fire`) and held each refuse
`library-fetch-no-grant` with a valid slot present, and each asserts the `gitea_grants` row is gone
first. T8 a chief grant on another project ends the fallback. T9 T7's three cases with the slot at 0000.
T10 a missing `library-human` refuses `library-human-missing` and runs no git. T11 a token search of
argv, the mirror config, the run log and the test output. Mutant: a `+` added to the push refspec fails.
## Gate
Dewey approves the manifest. The library and bus suites, plus every `scripts/test-*.sh`, pass on Sage's
gate. The mutant dies. A token search of the run's output finds nothing.
## Early look (dewey-0860), folded
Q1, askpass: sh, with no gap between the check and the read. The helper keeps `test -f` and then:
- opens the slot with `exec 3<"$slot"`;
- stats the open file with `stat -L -c '%d:%i %u %a %s %F' /dev/fd/3`, and the path itself (an lstat)
with `stat -c '%d:%i' -- "$slot"`;
- refuses unless the two dev:inode values match, the type is regular, the uid is `id -u`, the mode is
600 and the size is at most 16384;
- reads the token from fd 3.
A symlink or a swap at either moment changes the inode, and the token comes from the file that was
checked. This matches `readSecretFile` (`gitea-grant.mjs:167-181`).
Residual, the same on both paths: a FIFO swapped in after `test -f` blocks the open. Neither side uses
O_NONBLOCK.
Swap test T5b: the direct helper test puts a `stat` shim on its own PATH. On its first call the shim
renames another file over the slot, then runs the real stat. The helper must exit 1 and print nothing.
Mutant M2 drops the inode compare and must fail T5b.
Q2, the chief: found through `roles` by definition, not through `launch.instances`. A fire only delists
the name (`packages/business/src/instance-settings.mjs:355`), and `roles[name]` stays. Only a delete
removes it (`deletedDocument`, :70).
- Gitea grant evidence for any `roles` entry whose definition is `chief-of-staff` ends the fallback.
That includes a fired chief beside a new listed one.
- A live credential comes only from the listed chief's live, unheld `gitea_grants` row.
- With no `roles` entry of that definition (deleted, or never a chief), any gitea grant event in the
business refuses `library-fetch-no-grant`.
- Cost, for Sage: row 236 lets seat gitea grants exist before C2-21. So a business with no chief
entry and one seat grant loses the slot fallback. That fails safe, but it narrows "an install that
runs before C2-21 still syncs".
- T7's fired case also asserts that `roles[chief]` still exists after `instance.fire`.
N1: the `credential` key is optional in `mirror.mjs`'s exact `keys(...)` check (:221), so a pre-286
state file still reads. The old fixtures have no `credential`: `library-sync.test.mjs:18`,
`library.test.mjs:106` and `mirror.test.mjs:13`. New test T12 reads a pre-286 state file. Row 224
(Della, 23816cf28) also changes `packages/bus/src/runtime.mjs`. Whichever lands second rebases and reruns
bus whole.
N2: `[remote rejected]`, a hook refusal, is `library-push-failed`, not a race. Only `[rejected]` with
`(fetch first)` or `(non-fast-forward)` counts as a race. The evidence query depends on
`body.method` and `body.agent`. gitea `grant.applied` carries both at `broker.mjs:2479` and
`grant.revoked` at :1862 and :2797. The spend event (`operation: 'grant.gitea'`, :1093) carries
neither, so it doesn't match.
N3: `fetchCredential` reads the store, so it is the bus's choice function, not a pure one.
## Rulings sage-2928
- Q1: Dewey's fix, in sh. On dragon-lin `/bin/sh` is bash, and GNU coreutils stat 9.11 gives
`stat -L -c '%d:%i %u %a %s %F' /dev/fd/3` on an open fd. Checked at 18:47Z with a scratch file, not
the repo. The helper runs as git's child in the bus process, not under bwrap. T5b stages the swap with
the PATH stat shim. If a run shows the swap can't be staged reliably, BUILD.md names M2, which drops the
fd compare, and it gets killed.
- Q2: chief by `roles[*].definition === 'chief-of-staff'`. The blanket refusal applies only with no such
entry. Departure from the brief's "an install that runs before C2-21 still syncs", accepted in
sage-2928: it refuses rather than widens, and every install enrolls the chief at initial configuration
(F40, Q226). The refusal's message names the fix: "add the chief of staff, or grant the library
through C2-21".
- N1: as above.
## Build notes (code written; nothing run yet; dewey-0864 folded)
Files changed:
- `packages/library/src/sync.mjs`: `syncCommand` takes `push` with a full-sha `commit`; `push()`;
`humanCredential()`; `sync()` takes `credential`; `HUMAN_SLOT`, `CREDENTIAL_KINDS`. The `slot` opt
and `MOSAIC_LIBRARY_SLOT` keep their names and now name whichever file answers.
- `packages/library/src/index.mjs`: the new exports.
- `packages/library/bin/askpass`: Dewey's fd 3 check.
- `packages/library/src/mirror.mjs`: optional `credential` in the state key check. Nothing renamed.
- `packages/bus/src/broker.mjs`: `LIBRARY_FETCH_FIX` and `libraryFetchCredential(business)`.
- `packages/bus/src/runtime.mjs`: `syncOptions` passes the broker's choice to the verb and the timer.
The timer's held line adds `fix` for `library-fetch-no-grant`.
- `packages/library/README.md`: "Push and credentials (C2-03b)".
- Tests: `packages/library/tests/sync.test.mjs` (appended), `packages/bus/tests/library-credential.test.mjs` (new).
Choices made without a ruling. Each is small and reversible:
- `humanCredential(context, { user, configDir })`: sage-2925 named `{user}`. The slot path needs the
config dir, and the library package doesn't know it, so the caller passes it. That's the same
source runtime's `slotDir` uses.
- `BusError` carries only a code, so the sage-2928 fix text can't ride on the verb's refusal. It
goes in the timer's log line (`fix`) and in the README. The verb answers `library-fetch-no-grant`.
- T7 and T9 run the real verbs (dewey-0864, ruled by sage-2947: no SQL staging; replaces the
staged state in cefebf65b). Until row 285 lands, the test passes its own definitions; once it lands
the real chief definition carries gitea. Whichever of 285 and 286 lands second rebases and reruns
library and bus whole (sage-2947). The chief's grant goes through decision.raise and the human's yes against fake-gitea-api.mjs. The test's own
definitions give chief-of-staff a gitea credential, as gitea-grant.test.mjs gives coder one. The
real roles/chief-of-staff.json has `credentials: []`, so a live chief can't get one before C2-21.
- Revoked: `grants.revoke`, then the row is gone and the fetch refuses.
- Fired: `instance.fire` can't fire the launch.by chief. Only the launch.by role calls it, and it
refuses its own name (`organization-instance-referenced`, broker.mjs `#chiefLifecycle`). So the
test's launch.by is `boss` (definition pm, the chief's authority), and boss fires the one listed
chief-of-staff instance. Step G deletes the row (row 281), `roles.chief` stays, the fetch refuses.
Only launch.by may call instance.fire (`role-not-chief`, broker.mjs:4308 at 94c33a43c) and it
refuses its own name (:4335), so this is the only fired chief the verb allows. Accepted by
sage-2951 as brief owner and read the same way in dewey-0869.
- T9's revoked case runs `grants.revoke` on the runtime's own data root and store, between starts.
T9's runtime gets a scratch roles/ whose chief-of-staff carries `{service: gitea}`, as row 285
ships it (case2-rows.md:1823-1824), so its definitions match the grant's (dewey-0869). After a
rebase onto 285 the copy needs no edit.
- Delisted with the row still there, a `fire.stopped` with no `fire.done`, held, two chiefs and
another project stay unit checks on the choice function with SQL fixtures.
- "Another uid" in T5: the suite can't chown, so a PATH `stat` shim reports uid+1 for the open file.
The real `id -u` compare is covered only through that shim (dewey-0864).
- Two listed chiefs, or none listed, refuse `library-fetch-no-grant` (T8).
- The race fetch inside `push()` takes sync's `running` guard (dewey-0864 R1, in this row per
sage-2947). While a sync of the mirror runs it refuses `library-sync-busy`, fetches nothing, and
the caller writes again. Test: "race fetch: while a sync of the mirror runs". Its sync fetch is a
git alias that touches a marker, then waits for a release file. The push starts after the marker
exists and the release comes after the refusal, so nothing depends on timing (dewey-0869).
- The slot at 0000: T9 covers the revoked case at runtime. Fired and held refuse inside the choice
function, which takes no path and opens no file. `sync` with any `{ refused }` runs no git (library
test, valid slot present).
- First-run reds in the new tests are mine to fix in setup and report afterwards (mos-1272).
- The chief's evidence is any gitea grant event naming the chief, of any action (grant.applied,
grant.revoked and the rest), per the plan. The spend event names no agent, so it doesn't count.
Test map:
- library sync.test.mjs: argv (push), T1, T2+T3, T4 (dead remote, hook, wrong token), refused or
unsafe credential runs no git, T10, fetch credential and state with N1/T12, T5+T5b.
- library sync.test.mjs also: race fetch busy (R1).
- bus library-credential.test.mjs: T6, T7 (real revoke and fire), T7 choice checks, T8, departure,
T9 runtime (real grant, chief file and user via timer and verb; real revoke, slot 0000: no git,
log `fix`, state `credential: null`).
- webby-0151 R3: T9 reads `library.status` for `credential` and `lastError`.
- T11 token search: T1 scans the data root and every file but the seat's; T9 scans the log lines.
The gate's output search uses the fixture tokens `fixture-token-*-made-up-00{42..49}`.
Mutants (each must die on a semantic assertion):
- M1: `+` before the push refspec in `syncCommand`. Expected to fail: the argv test, and T2 (the
forced push lands, so `raced` is never reported and her commit is gone).
- M2: drop `test "$1" = "$path"` from askpass. Expected to fail: T5b (the helper prints the checked
file's token).
## r2 rebase (sage-3579)
Sage ruled that 286 now goes before 258, because 222 is its only after row. Branch ludwig/row286-r2
rebases b9506b352 (base 94c33a43c) onto origin/refactor 45dca69f6. The rebase had no conflicts.
- Range-diff 94c33a43c..b9506b352 against 45dca69f6..r2: all 11 commits are `=`, so no patch
changed.
- Rows 254 (331597709) and 268 (c27207f6e) were already in the old base 94c33a43c. Between the two
bases nothing changed under packages/library.
- Paths the two test files import or read, diffed 94c33a43c..45dca69f6: bus broker.mjs, runtime.mjs
and store.mjs changed. None of the others changed: library/*, bus credentials, gitea-grant,
mapping, mirror, reader, survey, sync, the fake-gitea-api, helpers and host-fixtures modules,
business helpers (rolesCopy, systemFor), project git, seat checkout and fake-gitea, and roles/.
- broker.mjs: the constructor gains `accessStamp` and `compactions`, both defaulting to null, and
my tests pass neither. No base hunk falls in the fire job, the revoke job or the instance.fire
verb. Old :4308/:4335 are now :4852/:4879, with the same text. The new console-cap check refuses
a non-console session only on CONSOLE_VERBS, and the library verbs are not among them. The
request path's access re-read applies only to `s.user` sessions, and my tests open none.
- runtime.mjs: consoleOwners (default []), the access timer, compactions and the session-clear
hooks. The Credentials and giteaStart wiring that T9 uses is unchanged.
- store.mjs: schema 3l to 3n (row 206 thread_shares, row 271 session_ops and message_cancels).
Each test opens a fresh Store, and none pins a schema version or step count.
- Hash pins: no test or script line pairs a 64-hex value with a path I change. The positive
control finds 64-hex pins in, for example, account-migration.test.mjs.
- Row 285 has not landed. roles/chief-of-staff.json at 45dca69f6 still names no gitea credential,
so T9 keeps its scratch roles copy (sage-2947).
- sage-3589 freezes stack-release.sh, webui-service.sh, bus-service.sh, stack-copy.sh and common.sh.
This row changes none of them.
- Still to run, on CLEAR: library and bus whole, every scripts/test-*.sh, M1 and M2, and the token
search, all on r2.
+524
View File
@@ -0,0 +1,524 @@
# Row 286 / C2-03b / Gitea #1791 — Rockerduck r3
2026-10-24. Base b19ffa04f; transplanted tip 7842700fb. Status: UNTESTED, awaiting Sage CLEAR.
## Authority and boundaries
Sage-4040 authorizes transplant, conflict resolution and push only rockerduck/row286-r3 through the configured repository credential helper. No force push. No tests, npm, mutants or setup before CLEAR; rows 296 and 217 precede this gate. Ludwig BUILD.md and sage-2922/2925/2928/2947/2951, dewey-0860/0864/0869 remain binding. Evidence here is plain text.
## Overlaps and resolution
- broker.mjs: automatic merge. Keep newer addressed project rooms (row 215), grant-scope (298), revoke-under-writer-lock (239), and add libraryFetchCredential near GRANT_EVIDENCE/reconcileGrants. The added method uses existing grant-event and firing checks; no scope or lifecycle authority changes.
- runtime.mjs: automatic merge. Keep newer grantScope wiring and revocation-under-lock comments; add chief credential selection to both verb and timer syncOptions and the refusal fix text.
- library/README.md: automatic merge. Preserve newer supporting/editor.user mapping and query/index documentation; add push/credential section.
- library/src/index.mjs: sole content conflict in 61ef385e4. Keep supportingPath and query/index exports from base, replace only the sync export with the expanded push/credential export.
- Rows 222/215: mirror.mjs and sync.mjs unchanged between 45dca69f6 and b19ffa04f; the transplant adds optional credential state without renaming keys and keeps all mirror locking/read behavior. The newer index/query layer remains exported.
## Test preservation
The complete pre-existing test declaration block in library/tests/sync.test.mjs is byte-identical to b19ffa04f. No other old test file changed. Ludwig adds fs/API imports and extends shared world() to pass users to fakeGitea; default remains the original user/token pair. These are inherited setup edits, not assertion edits; their ruling context is Ludwig BUILD.md (sage-2947/dewey-0864) and the appended C2-03b block. No additional test edit made during transplant.
## Range-diff
Command: git range-diff --no-color 45dca69f6..573fa0be0 b19ffa04f..7842700fb
1: 4faa3f6e9 = 1: 3326d9487 Row 286 C2-03b: build plan and four scope questions (BUILD.md only)
2: b3cf04b37 = 2: e155e3245 Row 286 C2-03b: sage-2922 files-owned extension; Q4 back to Sage (user is the bot fetch identity)
3: dd3e3c096 = 3: 6ce15eddd Row 286 C2-03b: sage-2925 Q4 ruling (humanCredential takes her login; editor.user is row 283's)
4: edec640d2 = 4: c2c4ac679 Row 286 C2-03b: fold dewey-0860 (Q1 sh fd/inode check, Q2 chief by roles definition, N1-N3)
5: d46b6d6ef = 5: 8ca442959 Row 286 C2-03b: sage-2928 rulings (sh helper, chief by roles definition, departure noted)
6: 61ef385e4 ! 6: cca76fa95 Row 286 C2-03b: library push, per-seat credentials, the chief's fetch credential, askpass fd check (#1791, sage-2922/2925/2928, dewey-0860); unrun
@@ packages/library/src/index.mjs: export { readLibrary, listTree, readTexts, parse
export { SYNC_STATE_FILE, mirrorPath, ensureMirror, withLibraryLock, servedCommit, readSyncState, libraryStatus, readLibraryFile } from './mirror.mjs';
-export { syncCommand, sync, ASKPASS, SLOT, SYNC_INTERVAL_MS } from './sync.mjs';
+export { syncCommand, sync, push, humanCredential, ASKPASS, SLOT, HUMAN_SLOT, CREDENTIAL_KINDS, SYNC_INTERVAL_MS } from './sync.mjs';
+ export { buildIndex, indexFor, clearIndexCache, indexCacheSize } from './index-build.mjs';
+ export { sees, find, get, search, manuscript, universeInfo, QUERY_REPLY_CAP, LOOKUP_LIMIT, MANUSCRIPT_LINES } from './query.mjs';
## packages/library/src/mirror.mjs ##
@@ packages/library/src/mirror.mjs: async function readStateHeld(mirror, { branch } = {}) {
7: b0d21c41a = 7: bb29ee2d9 Row 286: library.status shows the credential (webby-0151 R3); fired chief revokes its row as row 281 does; unrun
8: 44edead6d = 8: 90b70a6a6 Row 286 C2-03b: fold dewey-0864 (T7/T9 through grants.revoke and instance.fire, race fetch takes the sync guard, two-chief code, id -u shim note)
9: ae48662af = 9: d037e98d4 Row 286 C2-03b: cite sage-2947 (T7 real verbs, R1 in this row, 285/286 rebase rule)
10: a1876928f = 10: f2a43d69d Row 286 C2-03b: fold dewey-0869 and sage-2951 (marker-gated busy test, T9 scratch roles with a chief gitea credential, fired-chief path cited)
11: 4d6c8234f = 11: 888548607 Row 286 C2-03b: BUILD.md notes for dewey-0869 and sage-2951 (fired-chief path :4308, marker-gated busy test, T9 scratch roles)
12: 573fa0be0 = 12: 7842700fb Row 286 C2-03b r2: rebase onto 45dca69f6 (sage-3579). The range-diff is all =. Drift check of the paths the tests read is in BUILD.md. Unrun.
## Stable patch IDs
Original commit | transplanted commit | original patch-id | transplanted patch-id
4faa3f6e9a4e9ebf5f3cbac85919362cbef2bd7a | 3326d9487b88d946355d723b71560cdb056e515b | 020d56ada6149af45293d28d288baeaae8cacd3f | 020d56ada6149af45293d28d288baeaae8cacd3f
b3cf04b371ee17492b9d1eada1fe540d43493704 | e155e3245b7863331336896344eb87d4029bac59 | 9ad37bcea2d6a6d2758ff7deb90a561e544c910d | 9ad37bcea2d6a6d2758ff7deb90a561e544c910d
dd3e3c09632ee8ae583e17111384a36b209e6b0e | 6ce15eddd59d538eeb3612c7b08a9845f7c9b974 | 7cb2e6caab1d9caa7c0f618770bde86c00dfd425 | 7cb2e6caab1d9caa7c0f618770bde86c00dfd425
edec640d293c99dd5643af9c530e97aaa9b2f786 | c2c4ac6793523d22c48969815ac044a8195cde27 | 198983987d198ff0b28f12fb5071e683f597edd7 | 198983987d198ff0b28f12fb5071e683f597edd7
d46b6d6ef6ce762d67b021b0add25e70d0b2634d | 8ca4429594d74ddca78411d258f8c3686befac3d | 8666bced0fb4fc3b65c2cedcfd68a563cdf9ecdc | 8666bced0fb4fc3b65c2cedcfd68a563cdf9ecdc
61ef385e4a43db5776690ae9e32fa175b52ce5c3 | cca76fa95f41ab848be3735d3a4caa47c1c298f9 | 30f610a11c1a65ae4529e69a9f4c99b1834fb7e4 | a62594f0f33721ddb48ae09df8f42b8889122bfd
b0d21c41a9652a7a11a82f1cfbaa2bb9f9d0a254 | bb29ee2d958578c4e828dc3b1f0857d99f32ae91 | 6d37b5dc2589652b8046de34b74de52ccde98dd6 | 6d37b5dc2589652b8046de34b74de52ccde98dd6
44edead6d8039931152b998a2ace9d38c311a7bd | 90b70a6a6c9eefec4f7292602c1eb8431854ba88 | a99a4af887430d7e42b2874c0c51f9e6b47dcb74 | a99a4af887430d7e42b2874c0c51f9e6b47dcb74
ae48662afa3f013e282dc2424a465d89db044584 | d037e98d4cf752cb256a0542fd3fd967f54406db | b8eb3b7f746daee6d4827069be5fbd71cf05f360 | b8eb3b7f746daee6d4827069be5fbd71cf05f360
a1876928f450ba8eb67431c78527490c7979b157 | f2a43d69d932b001e3277caf9f7561664a852f5a | fa21b69573c0c18c413e5b6d5ce73d714b27d406 | fa21b69573c0c18c413e5b6d5ce73d714b27d406
4d6c8234fa49ee0cf3a68ad71474a348ca2d2230 | 888548607ece528491687cd557f5b6f5a8ce4f92 | e7ec71e368888b24693534cc16ed01229d882974 | e7ec71e368888b24693534cc16ed01229d882974
573fa0be06ebc1f82b0c77702594226b93031d84 | 7842700fb62764fd9c06e037ba8fec505b558477 | b0e9ed4fb8c866be49b1bc6e66dd618dde07cf99 | b0e9ed4fb8c866be49b1bc6e66dd618dde07cf99
## Drift inventory: test readers
Static scan of tracked test files and scripts: literal relative imports/paths resolved from each reader, changed-path suffixes, basename references, and joined quoted path components. The conservative basename pass includes join() readers and computed-path candidates. Entries list file and matching line numbers only; no source values or fixture tokens are printed. Direct imports count as readers. Transitive importers are listed separately.
### packages/bus/src/broker.mjs
agents/darkwing/work/slice1-s2-review/probes-r2.test.mjs: 10
agents/darkwing/work/slice1-s2-review/probes.test.mjs: 10
agents/darkwing/work/slice1-s2b-review/probes-r2.test.mjs: 7
agents/darkwing/work/slice1-s2b-review/probes-s2b.test.mjs: 7
agents/darkwing/work/slice1-s2c-review/probes-s2c.test.mjs: 7
agents/dewey/work/queue-139/mutants/m1-no-spec-check.txt: 318, 319, 320, 321, 323, 334, 335, 336, 337, 339, 350, 351, 352, 353, 355, 387, 388, 389, 390, 392, 470, 471, 472, 473, 475, 486, 487, 488, 489, 491, 502, 503, 504, 505, 507, 518, 519, 520, 522, 534, 535, 536, 538, 550, 551, 552, 553, 555
agents/dewey/work/queue-139/r2/mutants/m1-no-spec-check.txt: 323, 324, 325, 326, 328, 339, 340, 341, 342, 344, 355, 356, 357, 358, 360, 392, 393, 394, 395, 397, 473, 474, 475, 476, 478, 489, 490, 491, 492, 494, 505, 506, 507, 508, 510, 521, 522, 523, 525, 537, 538, 539, 541
agents/herb/work/queue-215/granted-t34/library.test-baseline-focused.log: 31, 32, 33, 35, 49, 50, 51, 53
agents/herb/work/queue-215/granted-t34/library.test-baseline-r2.log: 39, 40, 41, 43, 57, 58, 59, 61
agents/herb/work/queue-215/granted-t34/library.test-baseline.log: 21, 22, 35, 36, 51, 52, 96, 97
agents/rocko/work/queue-138/r3/mutants/26-record-spec-refusal.log: 32
agents/rocko/work/queue-138/r4/mutants/26-record-spec-refusal.log: 33
docs/plans/2026-10-12_pm-test-fixes.md: 21, 37, 101, 103, 113
docs/plans/2026-10-13_retest-faults.md: 85, 107
packages/bus/tests/access-revoke.test.mjs: 14
packages/bus/tests/agents.test.mjs: 9
packages/bus/tests/approval-binding.test.mjs: 7
packages/bus/tests/broker.test.mjs: 10
packages/bus/tests/chief-of-staff.test.mjs: 10
packages/bus/tests/comment-cursors.test.mjs: 7
packages/bus/tests/console-decide.test.mjs: 14
packages/bus/tests/creative.test.mjs: 8
packages/bus/tests/credential-status.test.mjs: 12
packages/bus/tests/credential-verbs.test.mjs: 11
packages/bus/tests/csuite-messaging.test.mjs: 12
packages/bus/tests/daily-quota-runtime.test.mjs: 10
packages/bus/tests/decision-close.test.mjs: 9
packages/bus/tests/decision-route.test.mjs: 12
packages/bus/tests/decisions.test.mjs: 9
packages/bus/tests/end-launch.test.mjs: 9
packages/bus/tests/fire-revokes-grants.test.mjs: 15
packages/bus/tests/gitea-grant-docker.test.mjs: 15
packages/bus/tests/gitea-grant.test.mjs: 12
packages/bus/tests/grant-enroll.test.mjs: 10
packages/bus/tests/grant-executor.test.mjs: 15
packages/bus/tests/grant-host.test.mjs: 10
packages/bus/tests/grant-migration.test.mjs: 10
packages/bus/tests/grant-scope.test.mjs: 14
packages/bus/tests/hard-waits.test.mjs: 7
packages/bus/tests/host-fixtures.mjs: 6
packages/bus/tests/host-run.test.mjs: 16, 181
packages/bus/tests/instances-verb.test.mjs: 11
packages/bus/tests/land.test.mjs: 18
packages/bus/tests/launch-notice.test.mjs: 8
packages/bus/tests/launcher-released.test.mjs: 8
packages/bus/tests/library-credential.test.mjs: 18
packages/bus/tests/library-sync.test.mjs: 11
packages/bus/tests/library.test.mjs: 8
packages/bus/tests/lifecycle-verbs.test.mjs: 11
packages/bus/tests/limit-checks.test.mjs: 323
packages/bus/tests/limit-stop.test.mjs: 12
packages/bus/tests/limits.test.mjs: 7
packages/bus/tests/message-citation.test.mjs: 6
packages/bus/tests/message-filter.test.mjs: 12
packages/bus/tests/messages.test.mjs: 3
packages/bus/tests/messaging.test.mjs: 8
packages/bus/tests/names.test.mjs: 8
packages/bus/tests/peer-transport.test.mjs: 9, 47
packages/bus/tests/plugins-verb.test.mjs: 9
packages/bus/tests/processing.test.mjs: 6
packages/bus/tests/project-await.test.mjs: 7
packages/bus/tests/project-files.test.mjs: 8
packages/bus/tests/project-runtime.test.mjs: 13
packages/bus/tests/publish.test.mjs: 14, 221
packages/bus/tests/reload.test.mjs: 9
packages/bus/tests/replies.test.mjs: 11
packages/bus/tests/requirements.test.mjs: 10
packages/bus/tests/review.test.mjs: 14
packages/bus/tests/revoke-window.test.mjs: 13
packages/bus/tests/rooms.test.mjs: 8
packages/bus/tests/routes-verb.test.mjs: 13
packages/bus/tests/session-clear.test.mjs: 10
packages/bus/tests/session-compactions.test.mjs: 9
packages/bus/tests/sessions.test.mjs: 10
packages/bus/tests/single-use.test.mjs: 7
packages/bus/tests/spec-fixtures.mjs: 6
packages/bus/tests/staff-request.test.mjs: 8
packages/bus/tests/state.test.mjs: 7
packages/bus/tests/task-page.test.mjs: 7
packages/bus/tests/task-read.test.mjs: 7
packages/bus/tests/tasks.test.mjs: 9
packages/bus/tests/tester-threads.test.mjs: 11, 284
packages/bus/tests/tester-writes.test.mjs: 10
packages/bus/tests/tester.test.mjs: 10
packages/bus/tests/thread-claims.test.mjs: 11
packages/bus/tests/thread-migration.test.mjs: 11
packages/bus/tests/thread-shares.test.mjs: 14
packages/bus/tests/threads.test.mjs: 9
packages/bus/tests/transport.test.mjs: 8
packages/bus/tests/usage-broker.test.mjs: 7
packages/bus/tests/user-principal.test.mjs: 16
packages/bus/tests/viewer.test.mjs: 14
packages/bus/tests/web.test.mjs: 8
packages/bus/tests/workflow-roles.test.mjs: 11
packages/business/tests/workflow-roles.test.mjs: 115, 136
packages/cli/tests/compactions-launch.test.mjs: 24
packages/cli/tests/helpers.mjs: 10
packages/cli/tests/idle-stop-race.test.mjs: 17
packages/cli/tests/instances.test.mjs: 13
packages/cli/tests/lifecycle.test.mjs: 19
packages/cli/tests/processing-cli.test.mjs: 7
packages/cli/tests/revoke-window.test.mjs: 19
packages/cli/tests/session-clear.test.mjs: 18
packages/cli/tests/session-cli.test.mjs: 94
packages/cli/tests/session-launch.test.mjs: 21
packages/cli/tests/stack-release.test.mjs: 1993
packages/cli/tests/tester-reads.test.mjs: 12
packages/cli/tests/tester-threads.test.mjs: 14
packages/cli/tests/tester.test.mjs: 7
packages/cli/tests/thread-cli.test.mjs: 14
packages/cli/tests/thread-launch.test.mjs: 19
packages/cli/tests/thread-revoke.test.mjs: 18
packages/harness/tests/runner.test.mjs: 14
packages/harness/tests/tools.test.mjs: 4
packages/seat/tests/sandbox.test.mjs: 15
packages/seat/tests/session.test.mjs: 11
packages/tasks/tests/fields.test.mjs: 10, 226
packages/tasks/tests/world.mjs: 5
packages/webui/tests/board-held.test.mjs: 20
packages/webui/tests/bus-browser.test.mjs: 11
packages/webui/tests/bus.test.mjs: 13
packages/webui/tests/chat-sessions-access.test.mjs: 10
packages/webui/tests/chat.test.mjs: 191
packages/webui/tests/decide-browser.test.mjs: 14
packages/webui/tests/granted-user.test.mjs: 16
packages/webui/tests/instances.test.mjs: 15
packages/webui/tests/routes-browser.test.mjs: 15
packages/webui/tests/routes.test.mjs: 12
packages/webui/tests/send-browser.test.mjs: 13
packages/webui/tests/sso-browser.test.mjs: 14
packages/webui/tests/tester-fixture.mjs: 12
packages/webui/tests/thread-sharing-fixture.mjs: 6
packages/webui/tests/user-option-swap.test.mjs: 14
Transitive import readers: packages/bus/tests/access-revoke.test.mjs, packages/bus/tests/agents.test.mjs, packages/bus/tests/approval-binding.test.mjs, packages/bus/tests/broker.test.mjs, packages/bus/tests/business.test.mjs, packages/bus/tests/channel-proof.test.mjs, packages/bus/tests/chief-of-staff.test.mjs, packages/bus/tests/comment-cursors.test.mjs, packages/bus/tests/console-decide.test.mjs, packages/bus/tests/creative.test.mjs, packages/bus/tests/credential-claim.test.mjs, packages/bus/tests/credential-status.test.mjs, packages/bus/tests/credential-store.test.mjs, packages/bus/tests/credential-verbs.test.mjs, packages/bus/tests/credentials.test.mjs, packages/bus/tests/csuite-messaging.test.mjs, packages/bus/tests/daily-quota-runtime.test.mjs, packages/bus/tests/decision-close.test.mjs, packages/bus/tests/decision-route.test.mjs, packages/bus/tests/decisions.test.mjs, packages/bus/tests/end-launch.test.mjs, packages/bus/tests/fire-revokes-grants.test.mjs, packages/bus/tests/gitea-grant-docker.test.mjs, packages/bus/tests/gitea-grant.test.mjs, packages/bus/tests/grant-enroll.test.mjs, packages/bus/tests/grant-executor.test.mjs, packages/bus/tests/grant-host.test.mjs, packages/bus/tests/grant-migration.test.mjs, packages/bus/tests/grant-scope-runtime.test.mjs, packages/bus/tests/grant-scope.test.mjs, packages/bus/tests/hard-waits.test.mjs, packages/bus/tests/host-fixtures.mjs, packages/bus/tests/host-run.test.mjs, packages/bus/tests/human.test.mjs, packages/bus/tests/instances-verb.test.mjs, packages/bus/tests/land.test.mjs, packages/bus/tests/launch-notice.test.mjs, packages/bus/tests/launcher-released.test.mjs, packages/bus/tests/library-credential.test.mjs, packages/bus/tests/library-sync.test.mjs, packages/bus/tests/library.test.mjs, packages/bus/tests/lifecycle-revoke.test.mjs, packages/bus/tests/lifecycle-verbs.test.mjs, packages/bus/tests/limit-stop.test.mjs, packages/bus/tests/limits-runtime.test.mjs, packages/bus/tests/limits.test.mjs, packages/bus/tests/message-citation.test.mjs, packages/bus/tests/message-filter.test.mjs, packages/bus/tests/messages.test.mjs, packages/bus/tests/messaging.test.mjs, packages/bus/tests/names.test.mjs, packages/bus/tests/peer-transport.test.mjs, packages/bus/tests/peercred.test.mjs, packages/bus/tests/plugins-verb.test.mjs, packages/bus/tests/principal.test.mjs, packages/bus/tests/process.test.mjs, packages/bus/tests/processing-runtime.test.mjs, packages/bus/tests/processing-timeout.test.mjs, packages/bus/tests/processing.test.mjs, packages/bus/tests/project-await.test.mjs, packages/bus/tests/project-files.test.mjs, packages/bus/tests/project-runtime.test.mjs, packages/bus/tests/publish.test.mjs, packages/bus/tests/reload.test.mjs, packages/bus/tests/replies.test.mjs, packages/bus/tests/requirements.test.mjs, packages/bus/tests/review.test.mjs, packages/bus/tests/revoke-window.test.mjs, packages/bus/tests/rooms.test.mjs, packages/bus/tests/routes-verb.test.mjs, packages/bus/tests/session-clear.test.mjs, packages/bus/tests/session-compactions.test.mjs, packages/bus/tests/sessions.test.mjs, packages/bus/tests/single-use.test.mjs, packages/bus/tests/spec-fixtures.mjs, packages/bus/tests/spec-review.test.mjs, packages/bus/tests/spec-socket.test.mjs, packages/bus/tests/spec.test.mjs, packages/bus/tests/staff-request.test.mjs, packages/bus/tests/state.test.mjs, packages/bus/tests/task-page.test.mjs, packages/bus/tests/task-read.test.mjs, packages/bus/tests/tasks.test.mjs, packages/bus/tests/tester-threads.test.mjs, packages/bus/tests/tester-writes.test.mjs, packages/bus/tests/tester.test.mjs, packages/bus/tests/thread-claims.test.mjs, packages/bus/tests/thread-migration.test.mjs, packages/bus/tests/thread-shares.test.mjs, packages/bus/tests/threads.test.mjs, packages/bus/tests/transport.test.mjs, packages/bus/tests/usage-broker.test.mjs, packages/bus/tests/usage-runtime.test.mjs, packages/bus/tests/user-principal.test.mjs, packages/bus/tests/viewer.test.mjs, packages/bus/tests/web.test.mjs, packages/bus/tests/workflow-roles.test.mjs, packages/business/tests/case1-business-file.test.mjs, packages/cli/tests/access-boot.test.mjs, packages/cli/tests/access.test.mjs, packages/cli/tests/app.test.mjs, packages/cli/tests/boot-status.test.mjs, packages/cli/tests/case1-accept.test.mjs, packages/cli/tests/case1-hostcheck.test.mjs, packages/cli/tests/case2-install.test.mjs, packages/cli/tests/case2-persona-import.test.mjs, packages/cli/tests/case2-unit.test.mjs, packages/cli/tests/channel-cli.test.mjs, packages/cli/tests/channel-fixture.mjs, packages/cli/tests/channel-recovery.test.mjs, packages/cli/tests/cli.test.mjs, packages/cli/tests/compactions-launch.test.mjs, packages/cli/tests/config.test.mjs, packages/cli/tests/credentials.test.mjs, packages/cli/tests/fixtures/launch-host.mjs, packages/cli/tests/follow.test.mjs, packages/cli/tests/git-socket.test.mjs, packages/cli/tests/grants-scope.test.mjs, packages/cli/tests/grants.test.mjs, packages/cli/tests/helpers.mjs, packages/cLine truncated
### packages/bus/src/runtime.mjs
agents/herb/work/queue-215/granted-t34/library.test-baseline.log: 71, 98, 114
agents/herb/work/queue-215/granted-t34/library.test-green-r2.log: 37, 51
agents/rocko/work/vikunja-502/tests/test_timeout_cause.py: 20
packages/bus/tests/access-revoke.test.mjs: 15
packages/bus/tests/chief-of-staff.test.mjs: 12
packages/bus/tests/creative.test.mjs: 9
packages/bus/tests/credential-verbs.test.mjs: 10
packages/bus/tests/daily-quota-runtime.test.mjs: 17
packages/bus/tests/gitea-grant.test.mjs: 188
packages/bus/tests/grant-executor.test.mjs: 200
packages/bus/tests/grant-scope-runtime.test.mjs: 8
packages/bus/tests/host-run.test.mjs: 19, 429
packages/bus/tests/instances-verb.test.mjs: 62
packages/bus/tests/library-credential.test.mjs: 21, 98
packages/bus/tests/library-sync.test.mjs: 12
packages/bus/tests/library.test.mjs: 11
packages/bus/tests/lifecycle-revoke.test.mjs: 11
packages/bus/tests/lifecycle-verbs.test.mjs: 368
packages/bus/tests/limit-checks.test.mjs: 323
packages/bus/tests/limits-runtime.test.mjs: 15
packages/bus/tests/limits.test.mjs: 20, 27
packages/bus/tests/plugins-verb.test.mjs: 34, 69
packages/bus/tests/processing-runtime.test.mjs: 8
packages/bus/tests/project-runtime.test.mjs: 6
packages/bus/tests/publish.test.mjs: 234
packages/bus/tests/reload.test.mjs: 11
packages/bus/tests/routes-verb.test.mjs: 93, 103
packages/bus/tests/session-compactions.test.mjs: 12
packages/bus/tests/tasks.test.mjs: 11
packages/bus/tests/tester-writes.test.mjs: 11
packages/bus/tests/tester.test.mjs: 13
packages/bus/tests/usage-runtime.test.mjs: 6
packages/bus/tests/web.test.mjs: 283
packages/business/tests/case1-business-file.test.mjs: 10
packages/cli/tests/access-boot.test.mjs: 11
packages/cli/tests/access.test.mjs: 11
packages/cli/tests/case2-install.test.mjs: 340
packages/cli/tests/channel-fixture.mjs: 38, 49
packages/cli/tests/compactions-launch.test.mjs: 25
packages/cli/tests/idle-stop-race.test.mjs: 18
packages/cli/tests/instances.test.mjs: 14
packages/cli/tests/lifecycle.test.mjs: 20
packages/cli/tests/optional-tracker.test.mjs: 7
packages/cli/tests/publish-socket.test.mjs: 13
packages/cli/tests/revoke-window.test.mjs: 20
packages/cli/tests/session-clear.test.mjs: 19
packages/cli/tests/session-launch.test.mjs: 22, 393
packages/cli/tests/stack-copy.test.mjs: 152, 158
packages/cli/tests/tester-reads.test.mjs: 13
packages/cli/tests/tester-threads.test.mjs: 15
packages/cli/tests/thread-launch.test.mjs: 21
packages/cli/tests/thread-revoke.test.mjs: 19
packages/webui/tests/instances.test.mjs: 16
packages/webui/tests/routes-browser.test.mjs: 16
packages/webui/tests/routes.test.mjs: 13
packages/webui/tests/tester-fixture.mjs: 11
Transitive import readers: packages/bus/tests/access-revoke.test.mjs, packages/bus/tests/chief-of-staff.test.mjs, packages/bus/tests/creative.test.mjs, packages/bus/tests/credential-verbs.test.mjs, packages/bus/tests/daily-quota-runtime.test.mjs, packages/bus/tests/grant-scope-runtime.test.mjs, packages/bus/tests/host-run.test.mjs, packages/bus/tests/instances-verb.test.mjs, packages/bus/tests/library-credential.test.mjs, packages/bus/tests/library-sync.test.mjs, packages/bus/tests/library.test.mjs, packages/bus/tests/lifecycle-revoke.test.mjs, packages/bus/tests/lifecycle-verbs.test.mjs, packages/bus/tests/limits-runtime.test.mjs, packages/bus/tests/limits.test.mjs, packages/bus/tests/plugins-verb.test.mjs, packages/bus/tests/processing-runtime.test.mjs, packages/bus/tests/project-runtime.test.mjs, packages/bus/tests/publish.test.mjs, packages/bus/tests/reload.test.mjs, packages/bus/tests/routes-verb.test.mjs, packages/bus/tests/tasks.test.mjs, packages/bus/tests/tester-writes.test.mjs, packages/bus/tests/tester.test.mjs, packages/bus/tests/usage-runtime.test.mjs, packages/bus/tests/web.test.mjs, packages/business/tests/case1-business-file.test.mjs, packages/cli/tests/access-boot.test.mjs, packages/cli/tests/access.test.mjs, packages/cli/tests/compactions-launch.test.mjs, packages/cli/tests/idle-stop-race.test.mjs, packages/cli/tests/instances.test.mjs, packages/cli/tests/lifecycle.test.mjs, packages/cli/tests/optional-tracker.test.mjs, packages/cli/tests/publish-socket.test.mjs, packages/cli/tests/revoke-window.test.mjs, packages/cli/tests/session-clear.test.mjs, packages/cli/tests/session-launch.test.mjs, packages/cli/tests/tester-reads.test.mjs, packages/cli/tests/tester-threads.test.mjs, packages/cli/tests/thread-launch.test.mjs, packages/cli/tests/thread-revoke.test.mjs, packages/webui/tests/instances.test.mjs, packages/webui/tests/proof-cli.test.mjs, packages/webui/tests/routes-browser.test.mjs, packages/webui/tests/routes.test.mjs, packages/webui/tests/tester-browser.test.mjs, packages/webui/tests/tester-cli.test.mjs, packages/webui/tests/tester-fixture.mjs, packages/webui/tests/tester-listener.test.mjs
### packages/library/README.md
docs/plans/2026-10-12_pm-test-fixes.md: 43, 44, 117, 118, 209, 273, 387, 389
docs/plans/2026-10-13_post-retest-follow-ups.md: 33, 152, 161, 165
docs/plans/2026-10-13_retest-faults.md: 48, 111
docs/plans/act-1-tests/2026-09-07_act-04-darkwing-test-readiness-review.md: 7, 10
packages/bus/tests/project-files.test.mjs: 17, 68, 97, 101, 103, 104, 121, 124
packages/bus/tests/spec-review.test.mjs: 61, 62
packages/bus/tests/tester-writes.test.mjs: 152
packages/cli/tests/host.test.mjs: 855
packages/discord/tests/git.test.mjs: 99, 176, 179, 226, 235, 242, 251, 253, 256, 258
packages/discord/tests/tools.test.mjs: 27, 37, 53, 68, 81, 85, 105, 107, 108, 113, 131, 132, 133, 137, 142, 189, 287
packages/project/tests/human-file.test.mjs: 36, 58, 59, 72, 74, 112, 114
packages/project/tests/read.test.mjs: 77, 85
packages/queue/tests/fixtures/genesis-render.md: 12
packages/queue/tests/fixtures/mosaic-pre-a2.sh: 3
packages/queue/tests/fixtures/queue-marked.md: 6, 50
packages/seat/tests/landing.test.mjs: 59, 60
packages/webui/tests/bus-browser.test.mjs: 77
packages/webui/tests/conversation.test.mjs: 23, 50
packages/webui/tests/pinned-copy.test.mjs: 77, 78, 151, 222
packages/webui/tests/project-file.test.mjs: 17, 111
scripts/test-conductor.sh: 69, 77, 88, 110, 117, 128
scripts/test-discord.sh: 269
scripts/test-extension-package.sh: 31, 34, 44, 47, 50, 61
scripts/test-land-work.sh: 46, 56, 89, 301, 304, 305, 306, 313, 321
v1/packages/mosaic/__tests__/platform/file-ops.test.ts: 48, 58, 81, 86
v1/packages/mosaic/framework/tools/qa/test-deps-preflight.sh: 104, 105
v1/packages/mosaic/src/config/file-adapter.test.ts: 33, 69, 113, 118
v1/packages/mosaic/src/fleet/fleet-documentation.spec.ts: 13
v1/packages/mosaic/src/framework/manifest-parity.spec.ts: 96, 295
v1/packages/mosaic/src/installation-documentation.spec.ts: 40
Transitive import readers: none
### packages/library/bin/askpass
packages/library/tests/sync.test.mjs: 106, 498
Transitive import readers: none
### packages/library/src/index.mjs
agents/dewey/work/queue-125/r3/singles/packages%plugins%tests%loader.test.mjs.log: 3, 4, 7
agents/dewey/work/queue-131/r1/mutants/11-symlink-test.log: 3, 4, 7, 27
agents/dewey/work/queue-131/r2/mutants/11-symlink-test.log: 3, 4, 7, 29
packages/bus/tests/broker.test.mjs: 8
packages/bus/tests/channel-proof.test.mjs: 43
packages/bus/tests/library-credential.test.mjs: 24
packages/bus/tests/library-sync.test.mjs: 15
packages/bus/tests/library.test.mjs: 13
packages/bus/tests/project-runtime.test.mjs: 9
packages/bus/tests/spec-fixtures.mjs: 7
packages/bus/tests/workflow-roles.test.mjs: 13
packages/business/tests/agent-identity.test.mjs: 6
packages/business/tests/assistant.test.mjs: 9
packages/business/tests/business.test.mjs: 7, 225
packages/business/tests/case1-business-file.test.mjs: 7
packages/business/tests/chief-of-staff.test.mjs: 11
packages/business/tests/cli.test.mjs: 6
packages/business/tests/credentials.test.mjs: 5
packages/business/tests/csuite.test.mjs: 9
packages/business/tests/hard-waits.test.mjs: 8
packages/business/tests/hosts.test.mjs: 7
packages/business/tests/library-seats.test.mjs: 12
packages/business/tests/optional-tracker.test.mjs: 7
packages/business/tests/project-repo.test.mjs: 6
packages/business/tests/project.test.mjs: 4
packages/business/tests/record-grants.test.mjs: 4
packages/business/tests/resolve.test.mjs: 6
packages/business/tests/role.test.mjs: 5
packages/business/tests/vars.test.mjs: 3
packages/business/tests/workflow-roles.test.mjs: 9
packages/cli/tests/access-boot.test.mjs: 12
packages/cli/tests/case2-install.test.mjs: 16
packages/cli/tests/compactions-launch.test.mjs: 31
packages/cli/tests/git-socket.test.mjs: 19
packages/cli/tests/helpers.mjs: 136
packages/cli/tests/launch-routes.test.mjs: 14
packages/cli/tests/launcher.test.mjs: 695
packages/cli/tests/optional-tracker.test.mjs: 9
packages/cli/tests/stack-copy.test.mjs: 41, 52
packages/harness/tests/helpers.mjs: 11
packages/library/tests/links.test.mjs: 3
packages/library/tests/mapping.test.mjs: 7
packages/library/tests/reader.test.mjs: 5
packages/library/tests/render.test.mjs: 3
packages/library/tests/scope.test.mjs: 3
packages/library/tests/supporting.test.mjs: 4
packages/library/tests/survey.test.mjs: 7
packages/plugins/tests/channel.test.mjs: 74, 100, 101, 105, 198, 199
packages/plugins/tests/daily-quota.test.mjs: 7
packages/plugins/tests/harness-compactions.test.mjs: 15, 16, 67
packages/plugins/tests/helpers.mjs: 33
packages/plugins/tests/loader.test.mjs: 73, 78, 82, 88, 89, 121, 126, 128, 151
packages/plugins/tests/token-limit.test.mjs: 5
packages/plugins/tests/transcript.test.mjs: 8
packages/project/tests/human-file.test.mjs: 7
packages/project/tests/init-failure.test.mjs: 7
packages/project/tests/project.test.mjs: 7
packages/project/tests/read.test.mjs: 7
packages/project/tests/tester-init.test.mjs: 9
packages/runs/tests/runs.test.mjs: 7
packages/runs/tests/state.test.mjs: 5
packages/seat/tests/compactions-budget.test.mjs: 15
packages/seat/tests/compactions-ledger.test.mjs: 19
packages/seat/tests/compactions.test.mjs: 14, 15
packages/tasks/tests/adapter.test.mjs: 7
packages/tasks/tests/fields.test.mjs: 11
packages/tasks/tests/tree.test.mjs: 5
packages/webui/tests/tester-fixture.mjs: 8, 10
Transitive import readers: packages/bus/tests/access-revoke.test.mjs, packages/bus/tests/chief-of-staff.test.mjs, packages/bus/tests/creative.test.mjs, packages/bus/tests/credential-verbs.test.mjs, packages/bus/tests/daily-quota-runtime.test.mjs, packages/bus/tests/grant-scope-runtime.test.mjs, packages/bus/tests/host-run.test.mjs, packages/bus/tests/instances-verb.test.mjs, packages/bus/tests/library-credential.test.mjs, packages/bus/tests/library-sync.test.mjs, packages/bus/tests/library.test.mjs, packages/bus/tests/lifecycle-revoke.test.mjs, packages/bus/tests/lifecycle-verbs.test.mjs, packages/bus/tests/limits-runtime.test.mjs, packages/bus/tests/limits.test.mjs, packages/bus/tests/plugins-verb.test.mjs, packages/bus/tests/processing-runtime.test.mjs, packages/bus/tests/project-runtime.test.mjs, packages/bus/tests/publish.test.mjs, packages/bus/tests/reload.test.mjs, packages/bus/tests/routes-verb.test.mjs, packages/bus/tests/tasks.test.mjs, packages/bus/tests/tester-writes.test.mjs, packages/bus/tests/tester.test.mjs, packages/bus/tests/usage-runtime.test.mjs, packages/bus/tests/web.test.mjs, packages/business/tests/case1-business-file.test.mjs, packages/cli/tests/access-boot.test.mjs, packages/cli/tests/access.test.mjs, packages/cli/tests/case2-install.test.mjs, packages/cli/tests/compactions-launch.test.mjs, packages/cli/tests/idle-stop-race.test.mjs, packages/cli/tests/instances.test.mjs, packages/cli/tests/lifecycle.test.mjs, packages/cli/tests/optional-tracker.test.mjs, packages/cli/tests/publish-socket.test.mjs, packages/cli/tests/revoke-window.test.mjs, packages/cli/tests/session-clear.test.mjs, packages/cli/tests/session-launch.test.mjs, packages/cli/tests/tester-reads.test.mjs, packages/cli/tests/tester-threads.test.mjs, packages/cli/tests/thread-launch.test.mjs, packages/cli/tests/thread-revoke.test.mjs, packages/library/tests/links.test.mjs, packages/library/tests/mapping.test.mjs, packages/library/tests/reader.test.mjs, packages/library/tests/render.test.mjs, packages/library/tests/scope.test.mjs, packages/library/tests/supporting.test.mjs, packages/library/tests/survey.test.mjs, packages/webui/tests/instances.test.mjs, packages/webui/tests/proof-cli.test.mjs, packages/webui/tests/routes-browser.test.mjs, packages/webui/tests/routes.test.mjs, packages/webui/tests/tester-browser.test.mjs, packages/webui/tests/tester-cli.test.mjs, packages/webui/tests/tester-fixture.mjs, packages/webui/tests/tester-listener.test.mjs
### packages/library/src/mirror.mjs
agents/herb/work/queue-215/granted-t34/mirror.test-baseline-r2.log: 5, 17
agents/herb/work/queue-215/granted-t34/mirror.test-baseline.log: 5, 17
agents/herb/work/queue-215/granted-t34/mirror.test-green.log: 40
packages/library/tests/mirror.test.mjs: 9, 12
packages/library/tests/sync.test.mjs: 14
Transitive import readers: packages/bus/tests/access-revoke.test.mjs, packages/bus/tests/chief-of-staff.test.mjs, packages/bus/tests/creative.test.mjs, packages/bus/tests/credential-verbs.test.mjs, packages/bus/tests/daily-quota-runtime.test.mjs, packages/bus/tests/grant-scope-runtime.test.mjs, packages/bus/tests/host-run.test.mjs, packages/bus/tests/instances-verb.test.mjs, packages/bus/tests/library-credential.test.mjs, packages/bus/tests/library-sync.test.mjs, packages/bus/tests/library.test.mjs, packages/bus/tests/lifecycle-revoke.test.mjs, packages/bus/tests/lifecycle-verbs.test.mjs, packages/bus/tests/limits-runtime.test.mjs, packages/bus/tests/limits.test.mjs, packages/bus/tests/plugins-verb.test.mjs, packages/bus/tests/processing-runtime.test.mjs, packages/bus/tests/project-runtime.test.mjs, packages/bus/tests/publish.test.mjs, packages/bus/tests/reload.test.mjs, packages/bus/tests/routes-verb.test.mjs, packages/bus/tests/tasks.test.mjs, packages/bus/tests/tester-writes.test.mjs, packages/bus/tests/tester.test.mjs, packages/bus/tests/usage-runtime.test.mjs, packages/bus/tests/web.test.mjs, packages/business/tests/case1-business-file.test.mjs, packages/cli/tests/access-boot.test.mjs, packages/cli/tests/access.test.mjs, packages/cli/tests/case2-install.test.mjs, packages/cli/tests/compactions-launch.test.mjs, packages/cli/tests/idle-stop-race.test.mjs, packages/cli/tests/instances.test.mjs, packages/cli/tests/lifecycle.test.mjs, packages/cli/tests/optional-tracker.test.mjs, packages/cli/tests/publish-socket.test.mjs, packages/cli/tests/revoke-window.test.mjs, packages/cli/tests/session-clear.test.mjs, packages/cli/tests/session-launch.test.mjs, packages/cli/tests/tester-reads.test.mjs, packages/cli/tests/tester-threads.test.mjs, packages/cli/tests/thread-launch.test.mjs, packages/cli/tests/thread-revoke.test.mjs, packages/library/tests/links.test.mjs, packages/library/tests/mapping.test.mjs, packages/library/tests/mirror.test.mjs, packages/library/tests/reader.test.mjs, packages/library/tests/render.test.mjs, packages/library/tests/scope.test.mjs, packages/library/tests/supporting.test.mjs, packages/library/tests/survey.test.mjs, packages/library/tests/sync.test.mjs, packages/webui/tests/instances.test.mjs, packages/webui/tests/proof-cli.test.mjs, packages/webui/tests/routes-browser.test.mjs, packages/webui/tests/routes.test.mjs, packages/webui/tests/tester-browser.test.mjs, packages/webui/tests/tester-cli.test.mjs, packages/webui/tests/tester-fixture.mjs, packages/webui/tests/tester-listener.test.mjs
### packages/library/src/sync.mjs
packages/library/tests/sync.test.mjs: 13
packages/tasks/tests/tree.test.mjs: 6, 385, 421
Transitive import readers: packages/bus/tests/access-revoke.test.mjs, packages/bus/tests/chief-of-staff.test.mjs, packages/bus/tests/creative.test.mjs, packages/bus/tests/credential-verbs.test.mjs, packages/bus/tests/daily-quota-runtime.test.mjs, packages/bus/tests/grant-scope-runtime.test.mjs, packages/bus/tests/host-run.test.mjs, packages/bus/tests/instances-verb.test.mjs, packages/bus/tests/library-credential.test.mjs, packages/bus/tests/library-sync.test.mjs, packages/bus/tests/library.test.mjs, packages/bus/tests/lifecycle-revoke.test.mjs, packages/bus/tests/lifecycle-verbs.test.mjs, packages/bus/tests/limits-runtime.test.mjs, packages/bus/tests/limits.test.mjs, packages/bus/tests/plugins-verb.test.mjs, packages/bus/tests/processing-runtime.test.mjs, packages/bus/tests/project-runtime.test.mjs, packages/bus/tests/publish.test.mjs, packages/bus/tests/reload.test.mjs, packages/bus/tests/routes-verb.test.mjs, packages/bus/tests/tasks.test.mjs, packages/bus/tests/tester-writes.test.mjs, packages/bus/tests/tester.test.mjs, packages/bus/tests/usage-runtime.test.mjs, packages/bus/tests/web.test.mjs, packages/business/tests/case1-business-file.test.mjs, packages/cli/tests/access-boot.test.mjs, packages/cli/tests/access.test.mjs, packages/cli/tests/case2-install.test.mjs, packages/cli/tests/compactions-launch.test.mjs, packages/cli/tests/idle-stop-race.test.mjs, packages/cli/tests/instances.test.mjs, packages/cli/tests/lifecycle.test.mjs, packages/cli/tests/optional-tracker.test.mjs, packages/cli/tests/publish-socket.test.mjs, packages/cli/tests/revoke-window.test.mjs, packages/cli/tests/session-clear.test.mjs, packages/cli/tests/session-launch.test.mjs, packages/cli/tests/tester-reads.test.mjs, packages/cli/tests/tester-threads.test.mjs, packages/cli/tests/thread-launch.test.mjs, packages/cli/tests/thread-revoke.test.mjs, packages/library/tests/links.test.mjs, packages/library/tests/mapping.test.mjs, packages/library/tests/reader.test.mjs, packages/library/tests/render.test.mjs, packages/library/tests/scope.test.mjs, packages/library/tests/supporting.test.mjs, packages/library/tests/survey.test.mjs, packages/library/tests/sync.test.mjs, packages/webui/tests/instances.test.mjs, packages/webui/tests/proof-cli.test.mjs, packages/webui/tests/routes-browser.test.mjs, packages/webui/tests/routes.test.mjs, packages/webui/tests/tester-browser.test.mjs, packages/webui/tests/tester-cli.test.mjs, packages/webui/tests/tester-fixture.mjs, packages/webui/tests/tester-listener.test.mjs
## 64-hex pin scan
Scanned every tracked test/script containing a literal 64-hex value; candidate reader intersections listed below. Hash values are deliberately omitted. A pin elsewhere in a candidate file is included conservatively.
packages/bus/tests/channel-proof.test.mjs: literal 64-hex at lines 21, 22, 24, 35
packages/bus/tests/grant-migration.test.mjs: literal 64-hex at lines 31
packages/bus/tests/session-clear.test.mjs: literal 64-hex at lines 496
packages/bus/tests/sessions.test.mjs: literal 64-hex at lines 326
packages/bus/tests/thread-migration.test.mjs: literal 64-hex at lines 46
packages/bus/tests/thread-shares.test.mjs: literal 64-hex at lines 370
packages/plugins/tests/harness-compactions.test.mjs: literal 64-hex at lines 21, 22, 23, 24, 25
packages/queue/tests/fixtures/queue-marked.md: literal 64-hex at lines 119
packages/seat/tests/compactions-ledger.test.mjs: literal 64-hex at lines 28, 38, 48, 58, 88, 181
No pin was changed. Any old-test failure will be reported to Sage before remediation.
## Operational record
- queue next from worktree refused as expected; rerun from canonical named row 286 (also warned view unknown).
- first queue move refused with lock held by magica-285-start; same operation retried, no lock bypass.
- No npm, test script, test runner or mutant executed. No canonical npm/rm/node_modules linking.
- Static source listing accidentally printed a made-up fixture literal in tool output; reported to Sage (rockerduck-0001). No real credential accessed. Subsequent source output redacts fixtures. Gate output search must report counts only.
- The rockerduck-0001 message header mistakenly used 09:57:30Z; actual date -u receipt was 09:55:50Z. This append corrects the timestamp without rewriting the message.
## Multiline join reader scan
The join() scan below also joins quoted components across line breaks (up to the next closing parenthesis); file:line only.
## Dependency drift of the two row-286 tests
packages/library/tests/sync.test.mjs
packages/seat/tests/fake-gitea.mjs: unchanged in newer base
packages/project/src/git.mjs: unchanged in newer base
packages/library/tests/helpers.mjs: unchanged in newer base
packages/library/src/sync.mjs: unchanged in newer base
packages/library/src/mirror.mjs: unchanged in newer base
packages/library/src/reader.mjs: unchanged in newer base
packages/library/src/mapping.mjs: changed in newer base
packages/library/src/survey.mjs: changed in newer base
packages/bus/tests/library-credential.test.mjs
packages/bus/src/store.mjs: unchanged in newer base
packages/bus/src/broker.mjs: changed in newer base
packages/bus/src/credentials.mjs: unchanged in newer base
packages/bus/src/gitea-grant.mjs: unchanged in newer base
packages/bus/src/runtime.mjs: changed in newer base
packages/seat/src/checkout.mjs: unchanged in newer base
packages/library/tests/helpers.mjs: unchanged in newer base
packages/library/src/index.mjs: changed in newer base
packages/bus/tests/fake-gitea-api.mjs: unchanged in newer base
packages/bus/tests/host-fixtures.mjs: unchanged in newer base
packages/business/tests/helpers.mjs: unchanged in newer base
The changed reader/mapping/survey interfaces add supporting scope and editor.user; the tests continue to use checkMapping/mappingEntry rather than hand-coded validated objects. Store schema additions are exercised with fresh scratch stores. Row 285 has not landed in b19ffa04f; retain the ruled scratch chief definition. No test execution implied by this static check.
## Pre-CLEAR handoff receipt
- Third same-op canonical queue retry succeeded: ok rockerduck-286-start rev 3890 row 286 briefed→in-progress. No lock alteration.
- First push succeeded through the repository helper; remote ls-remote confirms 29e830cac8f9ad1cd7e449eefd6053c7c2b841ab on rockerduck/row286-r3. Ready-for-CLEAR sent to Sage as rockerduck-0002.
- git diff --check passed. Both changed test files compare byte-identically to Ludwig 573fa0be0; pre-existing sync test bodies compare byte-identically to b19ffa04f. These are static checks only.
- No setup, npm, tests or mutants run. Remain at the explicit CLEAR boundary.
## CLEAR gate (sage-4054, 2026-10-24)
Sage released the slot at 10:33:37Z for 4b8261f41. Dependencies installed only in this worktree:
- Root: npm ci --ignore-scripts --no-audit --no-fund -> 0 (128 packages).
- packages/webui: same command -> 0 (2 packages).
- Short TMPDIR created using export TMPDIR=$(mktemp -d /mnt/storage/scratch/tmp/XXXXXX).
- Commands: timeout -k 10 900 node --test packages/library/tests/*.test.mjs;
timeout -k 10 1800 node --test packages/bus/tests/*.test.mjs.
- No scripts/test-*.sh run by this seat: Sage runs every shell suite at landing; CLI counts from that landing gate (mos-1561).
- Queue commands are no longer wrapped in timeout (sage-4054); no lock edits.
First-run results: library 140/140; bus 959 passed, four failed, all in the NEW
packages/bus/tests/library-credential.test.mjs. No old-test red. New-test setup fixes,
placed under sage-4054 / mos-1272 without changing production policy:
1. T7/T9 grant-chief-only at original line 150: boss is definition pm, and grant raises
require chief-of-staff. Bind/claim the existing chief and let it raise its own grant;
boss retains the separately ruled fire role. No SQL staging for grants/revokes/fire.
2. T7 rehire at original line 215 and T8 two-chief at original line 234: Broker clones
its input. Explicitly reloadBusiness after changing each fixture organization.
3. Next T9 run reached runtime and exposed its app project's missing checkout root
(project-path). Give verbDoc an explicit scratch root and app-checkout path.
4. Next T9 assertion read credential before the initial timer published: command
construction only marks the start of the fetch. Wait for the completed sync log.
Also register runtime.close with t.after so assertion failure cannot retain its timer.
5. Reopen of the shared T9 store found the fixture runs still claimed (role-already-held).
Release those runs through role.release before closing/reopening the fixture broker.
Focused credential reruns exposed each newly reached setup path; final focused result
6/6. The intermediate fixture run with a failed assertion retained a timer; its own
180-second timeout process was terminated and the next run registered runtime cleanup.
No other seat/process or canonical state was changed.
Final complete suites after fixes and restored mutants:
- library: 140 tests, 140 pass, 0 fail/cancelled/skipped/todo; exit 0.
- bus: 963 tests, 963 pass, 0 fail/cancelled/skipped/todo; exit 0.
- Both production trees match pre-gate 4b8261f41 byte-for-byte (git diff --quiet).
- The pre-existing sync test declaration block still matches b19ffa04f byte-for-byte.
Mutants (sequential, restored in finally before final suites):
- M1 adds + only to syncCommand's push refspec. timeout -k 10 180 node --test,
pattern 'C2-03b argv|C2-03b T2', sync.test.mjs: exit 1, 2/2 semantic failures
at argv assertion line 331 and race result assertion line 365.
- M2 removes only askpass's inode comparison. Same timeout, pattern 'C2-03b T5 helper':
exit 1, swapped-file refusal assertion at lines 510/526 fails.
- A scratch Node reporter prints failed test names, assertion locations and counts only;
it excludes assertion values so the deliberately broken helper cannot print a fixture
token in diagnostics. Both mutants' output searches found zero matches.
Evidence: gate-library.txt and gate-bus.txt contain final full text output;
mutant-M1.txt and mutant-M2.txt contain mutant failure locations/counts. token-search.txt
records exact fixture-string and general-pattern match counts for every captured run.
Six exact fixture values were checked in memory; every count is zero. T1/T9 also cover
argv, mirror config, data-root files and runtime log non-disclosure as specified in Ludwig's
T11 map. No fixture values are printed by the search.
The static-output slip reported before CLEAR remains in the earlier operational record;
it is not represented as a clean source-inspection history. The gate-run outputs are clean.
Candidate manifest covers all nine changed packages paths, inherited Ludwig rulings,
this BUILD and gate receipts. Shared append-only BUILD-LOG/SESSIONS are excluded from
candidate hashes so unrelated later appends do not invalidate the review.
Scratch run directory retained for landing audit: /mnt/storage/scratch/tmp/FcfQ9q
@@ -0,0 +1,16 @@
7fab157be380502b726c4a84772bfb667850766062e4952b73f172f5ada363ed agents/ludwig/work/queue-286/BUILD.md
7ce8537c45ab0dc66a6a33c1f0db838506c96e4fe313dc197755aa7aa0dd41cb agents/rockerduck/work/queue-286/BUILD.md
e5026111e3c84f1294c80631fc5d5dad6dc77166209acdfd07c6ea8755469e19 agents/rockerduck/work/queue-286/gate-bus.txt
4e191637baa4d8683679fd6e84d81e29ec6fe64faf38b4b33f9b5965fce1dfcf agents/rockerduck/work/queue-286/gate-library.txt
50291819572752733a1c33e846e1e1e8d66e89b677a6a6d0cc7d2402e8fa82ea agents/rockerduck/work/queue-286/mutant-M1.txt
ade72e9641ac5ee97b1ceaa266d54b48d28d0d613f415a030547963664a04646 agents/rockerduck/work/queue-286/mutant-M2.txt
b3b9a19825a43f33942746bbaaa93670453d4f21b63005d915d9cf92523e9143 agents/rockerduck/work/queue-286/token-search.txt
b6c4c71e83d90a4415337cd5bad93200232ff3a74e0bdc303c1351c2f1a4aae4 packages/bus/src/broker.mjs
a93e322bd27fea2a5fe6a2f89aad04d9cf96b6cc2004dc1c663af5a46494fb09 packages/bus/src/runtime.mjs
44e62c044caf66358c4469cd1549aebadaa1bc22e681e4e2ce1c8e07dae649b4 packages/bus/tests/library-credential.test.mjs
d9c523d830c57df9fedf5cb42e6c74999fde529751e8a5eea95b3ae097d8548d packages/library/README.md
5c9b528fd69a2493f04933fc84f352b1101d276d69d9982806975def614fd740 packages/library/bin/askpass
978c7a404baeb52c472948a57a606c0881793c25ee85feef68b681f9ea15fc05 packages/library/src/index.mjs
0bea0f3553ecfca8bee81e545faf5142e5fa6521ea971fbd2d8a78932d644f2b packages/library/src/mirror.mjs
784af63a417b547b195ca835878daeba5b696b42a83a4c95de6728a0efa75e38 packages/library/src/sync.mjs
c8500795d80f56536f0ffd17bcad4f38778d4b06ec39030200c17f31c750ac94 packages/library/tests/sync.test.mjs
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,149 @@
✔ the same commit, repo path and mapping return the same index, and only mapped files are in it (38.910506ms)
✔ a different repo path, mapping or commit gets its own index (58.789692ms)
✔ the cache holds at most eight indexes and evicts the least recently used (78.224098ms)
✔ a failed read is not kept in the cache (15.140975ms)
✔ a missing or unchecked mapping is refused before any read (12.020832ms)
✔ a 3,000-file rebuild completes and its time is reported (2900.628078ms)
ℹ 3,000-file index rebuild: 2680 ms
✔ resolver step 1: a path from the repo root, with or without .md (1.1254ms)
✔ resolver step 2: the same path with vaultPrefix/ stripped (0.211505ms)
✔ resolver step 3: a path relative to the linking file, never above the repo root (0.200699ms)
✔ resolver step 4: a base name that matches exactly one indexed file (0.260396ms)
✔ a link into another of her repos resolves to nothing (0.191988ms)
✔ wikilink forms, embeds, and links skipped inside code (1.098741ms)
✔ only a body embed whose target has an extension other than .md, in any case, is an attachment (0.349473ms)
✔ the fixture mapping checks and comes back frozen (2.412937ms)
✔ C2-01c: a prefix inside or holding another entry's book refuses library-playground-other-universe (1.155505ms)
✔ C2-01c: a prefix equal to a book folder, or holding its own, refuses library-mapping-invalid (0.650366ms)
✔ C2-01c: universe-level prefixes lie outside every book, in a plain universe and in a hybrid with no books (0.710421ms)
✔ C2-01c: a draft takes kind, name and playground only, and comes back frozen with books [] (1.484212ms)
✔ C2-01d: supporting prefixes at book level, universe level, in a hybrid and in a draft, [] when absent (0.537029ms)
✔ C2-01d: each supporting prefix rule refuses with its code (1.971647ms)
✔ C2-01d: between the lists, a supporting prefix may equal its own playground, and a playground may hold its own supporting (0.392589ms)
✔ C2-01d: editor is optional, kept when given, and refuses anything but a plain name and email (5.280718ms)
✔ C2-01d: an accepted editor round-trips through a real git commit unchanged (15.73724ms)
✔ no playground prefix holds another, and no book folder holds another (0.934567ms)
✔ shape refusals: kind, remote, branch, unknown keys, types, manuscript, folder names (1.116891ms)
✔ C2-03a sage-2021: user is optional, kept when given, and must be a plain account name (0.777602ms)
✔ a hybrid universe is a shape the query code can take (1.4), with sources that must exist (1.069424ms)
✔ a caller-supplied fail receives the code, as business file loading will pass it (C2-04) (0.375805ms)
✔ scope comes from the path alone (design 1.2) (0.696744ms)
✔ C2-04 mirror: exact default path, bare without checkout or remote, and idempotent initialization (59.246676ms)
✔ C2-04 mirror: simultaneous creators publish one intact mirror and leave no staging paths (42.751841ms)
✔ C2-04 mirror: skipped publication accepts both coreutils statuses without removing the winner (137.796639ms)
✔ C2-04 mirror: preserve occupied directories and reject symlink ancestors and code overlaps (16.53077ms)
✔ C2-04 mirror: existing metadata accepts only bare Git plus state and the precreated lock (29.399708ms)
✔ C2-04 state: missing state is empty only while the served ref is absent (51.916373ms)
✔ C2-04 state: valid problems and sync metadata return no content and change no state bytes (46.274776ms)
✔ C2-04 state: strict keys, types, times, branch, commit and fixed R messages fail closed (131.367058ms)
✔ C2-04 state: symlink, hardlink, non-private state and missing lock refuse without replacement (37.826957ms)
✔ C2-04 file: served commit, not HEAD or a caller commit, owns the exact returned text (84.427946ms)
✔ C2-04 file: R1, R2 and R7 exclusions and never-synced mirrors cannot return file bodies (100.550125ms)
✔ C2-04 lock: a first-sync and fast-forward reader wait for a consistent pair across processes (258.847103ms)
✔ C2-04 lock: same-process waits, timeout code, callback expiry and throw release (5032.288443ms)
✔ C2-04 lock: owner death releases the kernel lock without replacing its file (54.703792ms)
✔ C2-04 lock: a missing flock binary becomes a handled refusal (50.127819ms)
✔ C2-04 lock: an unawaited scoped read drains before the lock is released (19.957011ms)
✔ the query reply cap equals the harness reply cap (1.19734ms)
✔ find is bound to its universe and refuses a call without one (53.893139ms)
✔ every result carries its scope, and playground appears only when asked for (32.313169ms)
✔ unmapped files never appear in any reply (27.837074ms)
✔ a hybrid sees its sources canon, minus exclusions, and no playground (30.347014ms)
✔ a hybrid gets a source file it can see, and not an excluded or foreign one (41.713814ms)
✔ get lists visible links both ways and drops a target another universe cannot see (41.538976ms)
✔ a long list pages by position, never cuts an item, and returns 50 per page (162.791386ms)
✔ a page stays under the reply cap when items are large (78.463045ms)
✔ a search line too long to fit alone comes back marked, with no excerpt, and the other hits still return (29.164591ms)
✔ a find item that cannot fit alone is refused, not cut (28.322729ms)
✔ get pages its lines, and the next line picks up where the page stopped (30.368574ms)
✔ get refuses a file whose front matter alone is over the cap, and a line alone is over the cap (28.128026ms)
✔ get pages the links of a hub note, every page carries the front matter, and the pages add up (869.902906ms)
✔ get rejects a bad links_from (35.196585ms)
✔ search returns scoped line hits, case-insensitively, and refuses empty text (28.459891ms)
✔ a manuscript call returns one chapter, at most 400 lines, and names its next line (46.946258ms)
✔ a manuscript call without a chapter returns the first chapter in path order and names the next (26.573024ms)
✔ a chapter name that matches two visible files is refused (24.016997ms)
✔ a hybrid reads a source chapter through its declared sources only (35.070966ms)
✔ universe info reports a hybrid sources, constraints and notes, and refuses an unknown universe (13.168062ms)
✔ an exclusion hides what it names from a hybrid and from nothing else (36.359286ms)
✔ PRD 1.14: a lookup that names a book leaves book-null files out (30.463487ms)
✔ PRD 1.14: library_manuscript never reads a book-null file (29.534997ms)
✔ PRD 1.14: a draft universe has no canon, so its lookups return files only with scope playground (29.89583ms)
✔ PRD 1.14: library_universe on a draft returns its kind, name and prefixes (14.363564ms)
✔ a hybrid gets a source playground file as null, while the source gets it with scope playground (37.6646ms)
✔ the fixture reads clean: every resolver step, appears_in and relationships wikilinks indexed (48.455238ms)
✔ R1: executables, symlinks, gitlinks, blobs over 1 MiB and non-UTF-8 text are not indexed (76.694953ms)
✔ R2: unmapped paths are reported and not indexed (26.809308ms)
✔ R3: front matter not fully parsed stays raw, with the line where parsing stopped (31.113448ms)
✔ front matter forms the reader accepts and refuses (0.475374ms)
✔ R4: a link to no file, to more than one, or into another repo is reported and not followed (29.384746ms)
✔ a block list link carries its item line, a scalar or flow link its key line, and problems follow (28.900438ms)
✔ an unresolved attachment embed gives no problem; a link or front matter value to one is R4; a note embed resolves (29.126382ms)
✔ an indexed Dr.Quinn.md resolves as a body link, a block list link and an embed (29.274318ms)
✔ R5: a canon file that refers into the playground is reported, and the link still goes by path (29.580541ms)
✔ R6: a canon file that refers into another universe is reported; a hybrid may refer into its sources (24.571442ms)
✔ R7: two paths that differ only in case are both left out (31.165932ms)
✔ the reader reads the commit, never the working tree, and refuses a bad commit id (28.951224ms)
✔ a bare mirror reads the same as the clone (46.972003ms)
✔ renderItems snapshot: fact (1.417516ms)
✔ renderItems snapshot: finding (0.276536ms)
✔ renderItems snapshot: correction (0.22018ms)
✔ renderItems snapshot: question (0.181125ms)
✔ renderItems snapshot: idea (0.204383ms)
✔ renderItems numbers several items and takes a parsed body as well as a string (1.006969ms)
✔ without options a path cite has no badge and is never marked changed (0.236544ms)
✔ renderItems snapshot: notice no-cited-answer (0.194799ms)
✔ renderItems snapshot: notice no-answer (0.230859ms)
✔ renderItems snapshot: notice turn-failed (0.2386ms)
✔ a body without the reply shape shows a fixed line and none of its text (0.636756ms)
✔ C2-01c: each level gives its universe, and its book or null (3.423205ms)
✔ C2-01c: prefixes come first: in a book that holds a prefix, a file outside it is canon and one under it playground (0.57979ms)
✔ C2-01c: a canon file linking into a draft gives R6, and into its own universe-level prefix gives R5 (50.419041ms)
✔ C2-01d: each supporting level gives its universe, and its book or null (0.8375ms)
✔ C2-01d: supporting comes before playground; a folder in both lists is supporting, one in the playground only stays playground (0.479654ms)
✔ C2-01d: only .md files are supporting; a .canvas or .base under a supporting prefix is unmapped (0.290969ms)
✔ C2-01d: a path passes at book level, universe level, in a draft and in a folder in both lists (2.074148ms)
✔ C2-01d: a path of exactly 1024 bytes passes, and 1025 bytes made of multi-byte characters refuses (0.982879ms)
✔ C2-01d: a path outside the universe's supporting folders refuses (0.71389ms)
✔ C2-01d: malformed paths refuse by class, in order (0.646566ms)
✔ C2-01d: a symlink or gitlink on the way, or at the path itself, refuses (0.31072ms)
✔ C2-01d: a name that differs from a tree name only in case or Unicode form refuses, at the file or any folder (1.011818ms)
✔ the survey prints folder names, counts, key sets and link-form counts, and never text (49.619419ms)
✔ folder names print DEL, C1 and bidi controls, U+061C among them, as escapes (26.757812ms)
✔ an embed of an attachment counts as an embed and as an attachment; a plain link to one does not (19.118043ms)
✔ with --mapping the survey adds scope, resolver step and problem counts (41.583182ms)
✔ C2-01d: with --mapping the survey counts supporting files (37.584205ms)
✔ survey exit codes: usage 4, bad mapping 3, unreadable repo 2 (15.886586ms)
✔ scripts/mosaic library survey reaches the survey (98.91682ms)
✔ C2-03a argv: the fixed sequence, one https allow, no other protocol key (2.475406ms)
✔ C2-03a argv: only an https URL with a plain host, no userinfo, and a plain user (0.920886ms)
✔ C2-03a helper: answers only the exact prompts for the configured host and user (dewey-0765) (40.63027ms)
✔ C2-03a first sync: served is the fetched tip, HEAD is served, the survey reads it, and the token stays in the slot (164.393615ms)
✔ C2-03a fast-forward: served moves, problems are rebuilt, and a repeat changes only lastFetch (242.672377ms)
✔ C2-03a Filbert N2: a read that took served before a sync still finds its objects after served moves (201.409814ms)
✔ C2-03a overlap: a reader during a sync sees the old pair or the new one, never a mix (182.181215ms)
✔ C2-03a diverged: a non-fast-forward keeps serving and holds until a human accepts the tip (414.515618ms)
✔ C2-03a accept starts the record over, so a changed branch gets a fresh state (174.335397ms)
✔ C2-03a fetch failure: a wrong token keeps serving and records the error, lastFetch unchanged (200.468866ms)
✔ C2-03a a failed first fetch records the error with nothing served (60.013342ms)
✔ C2-03a a 302 to another host fails the fetch and the other host sees nothing (63.037701ms)
✔ C2-03a the slot: missing or unsafe refuses before git, and sync never opens it (59.780442ms)
✔ C2-03a sage-2021: an entry with no user refuses library-remote-invalid and runs no git (31.991782ms)
✔ C2-03a one sync per mirror: an overlapping call refuses library-sync-busy (127.65035ms)
✔ C2-03b argv: a push sends one full commit to the branch, fast-forward only, with the fetch prefix (0.498418ms)
✔ C2-03b T1 push: a fast-forward lands with the seat's own user and file, and moves no local ref (123.563433ms)
✔ C2-03b T2 and T3: a moved tip reports the race after one fetch, and a second race refuses stale-base (201.045487ms)
✔ C2-03b race fetch: while a sync of the mirror runs, the race refuses library-sync-busy (217.586092ms)
✔ C2-03b T4: a dead remote, a hook refusal and a wrong token each refuse library-push-failed (150.972736ms)
✔ C2-03b push: a refused, missing or unsafe credential runs no git (91.830589ms)
✔ C2-03b T10 humanCredential: her login and a 0600 slot library-human, or library-human-missing (0.868836ms)
✔ C2-03b fetch credential: the state names the kind and user, a refusal runs no git, and a pre-286 state still reads (134.468925ms)
✔ C2-03b T5 helper: the named seat's user and file, never a link, 0644, another uid, or a swapped file (27.628362ms)
ℹ tests 140
ℹ suites 0
ℹ pass 140
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 6220.993116
@@ -0,0 +1,6 @@
FAIL C2-03b argv: a push sends one full commit to the branch, fast-forward only, with the fetch prefix ERR_TEST_FAILURE
at TestContext.<anonymous> (file:///home/jwoltje/.t3/worktrees/mosaic-stack/rockerduck-row286-r3/packages/library/tests/sync.test.mjs:331:10)
FAIL C2-03b T2 and T3: a moved tip reports the race after one fetch, and a second race refuses stale-base ERR_TEST_FAILURE
at TestContext.<anonymous> (file:///home/jwoltje/.t3/worktrees/mosaic-stack/rockerduck-row286-r3/packages/library/tests/sync.test.mjs:365:10)
SUMMARY {"tests":2,"failed":2,"passed":0,"cancelled":0,"skipped":0,"todo":0,"topLevel":2,"suites":0}
SUMMARY {"tests":2,"failed":2,"passed":0,"cancelled":0,"skipped":0,"todo":0,"topLevel":2,"suites":0}
@@ -0,0 +1,5 @@
FAIL C2-03b T5 helper: the named seat's user and file, never a link, 0644, another uid, or a swapped file ERR_TEST_FAILURE
at refused (file:///home/jwoltje/.t3/worktrees/mosaic-stack/rockerduck-row286-r3/packages/library/tests/sync.test.mjs:510:40)
at TestContext.<anonymous> (file:///home/jwoltje/.t3/worktrees/mosaic-stack/rockerduck-row286-r3/packages/library/tests/sync.test.mjs:526:3)
SUMMARY {"tests":1,"failed":1,"passed":0,"cancelled":0,"skipped":0,"todo":0,"topLevel":1,"suites":0}
SUMMARY {"tests":1,"failed":1,"passed":0,"cancelled":0,"skipped":0,"todo":0,"topLevel":1,"suites":0}
@@ -0,0 +1,12 @@
Fixture exact values checked: 6
M1.log: exact=0, pattern=0
M2.log: exact=0, pattern=0
bus-final.log: exact=0, pattern=0
bus.log: exact=0, pattern=0
credential-rerun.log: exact=0, pattern=0
credential-rerun2.log: exact=0, pattern=0
credential-rerun3.log: exact=0, pattern=0
credential-rerun4.log: exact=0, pattern=0
credential-t9.log: exact=0, pattern=0
library-final.log: exact=0, pattern=0
library.log: exact=0, pattern=0
+4
View File
@@ -1626,3 +1626,7 @@ are never rewritten or removed; corrections are new entries.
2026-10-24T04:27Z | Goldie (a13dd6d5-5e73-4b7d-8318-02abeff24940) | Row265 / #1770 C2-09b rooms | Goldie owns build, Boyd reviews under sage-3915. New goldie/row265 worktree at ec86f2c84, claim rev3814; room verbs/parser/new tests prepared, no old tests changed. Sage-3932 slot: new parser14/14 and rooms18/18 passed; whole bus in progress before refusal mutants, stop rule active.
2026-10-24T04:35Z | Goldie (a13dd6d5-5e73-4b7d-8318-02abeff24940) | Row265 / #1770 verified | First slot stopped at project-runtime import pin. mos-1567 narrowly authorizes pin and pure-source mount correction. sage-3942 rerun: sandbox10/10, parser15/15, rooms18/18, bus957/957, twelve refusal mutants killed/restored SHA71b32ee4, parser15/15 + rooms18/18 again. Slot returned goldie-0034; no further test runs. Boyd reviews RUNNER_FILES alongside rooms; own-branch push/claimant in-review next.
2026-10-24T07:18:53Z | Sage (1ef1e4f8) | Row 327 / #1825 push guard landed (mos-1572 option A, mos-1575) | Gate went red 06:57Z on an empty canonical node_modules (Glomgold's webui npm ci through a link to canonical, glomgold-0011); rerun with its own npm ci GREEN 07:15:49Z on 1bd214e38; pushed 68c08b4e7 07:18:03Z; #1825 closed c31530; canonical restored (128 packages); brief template rule 4db23a51f | landed
- 2026-10-24 — rockerduck (Codex, a403aa78-b550-4bab-a613-7ea3fd75ec79), row 286 C2-03b / #1791 r3: twelve-commit transplant and static drift evidence prepared; UNTESTED, awaiting Sage CLEAR after own-branch push; no npm/tests run.
- 2026-10-24 — rockerduck (Codex, a403aa78-b550-4bab-a613-7ea3fd75ec79), row 286 / #1791 r3 CLEAR gate: library 140/140, bus 963/963, both mutants killed/restored, gate-output fixture matches zero; new-test setup fixes only; candidate prepared for Boyd review and Sage landing.
+30
View File
@@ -260,6 +260,9 @@ const grantName = (body) => [body?.action, body?.operation].some(n => typeof n =
// Every grant-evidence read filters on this (duckworth-0067 N1): a look-alike under another kind or
// actor is not evidence.
export const GRANT_EVIDENCE = "kind='action.allowed' AND actor_role IS NULL AND actor_run='grant'";
// C2-03b: what a library-fetch-no-grant refusal tells the human (sage-2928). A BusError carries only its
// code, so the timer's log line and the library README carry this text.
export const LIBRARY_FETCH_FIX = 'add the chief of staff, or grant the library through C2-21';
// r6 R4: the host record's identity, hashed at raise and at each later check.
export const registrySha256 = ({ address, port, user, sudo, hostKeys }) =>
createHash('sha256').update(JSON.stringify({ address, port, user, sudo: !!sudo, hostKeys })).digest('hex');
@@ -2027,6 +2030,33 @@ export class Broker {
// grant.host.enrolled or grant.failed died after its registry step: the key file and the record
// go, and it ends grant.failed interrupted. A resolved, unspent one is spent and ends the same way.
// Neither runs late.
// C2-03b (#1791, design 7.2): the credential the library fetch runs with, the bus's choice and not a
// pure one: it reads the store. The chief is any roles entry of definition STAFF_TO; a fire keeps it
// there (dewey-0860 Q2). Gitea grant evidence for a chief ends the slot fallback, and only the one
// listed chief's live, unheld gitea_grants row answers. With no chief entry, any gitea grant evidence
// refuses: a departure from the brief's pre-C2-21 fallback, accepted in sage-2928.
libraryFetchCredential(business) {
const b = this.#business(business);
const chiefs = Object.entries(b.roles ?? {}).filter(([, r]) => r?.definition === STAFF_TO).map(([name]) => name);
const evidence = (agent) =>
this.#store.get(
`SELECT 1 FROM events WHERE business=? AND ${GRANT_EVIDENCE} AND json_extract(body,'$.method')='gitea' AND (? IS NULL OR json_extract(body,'$.agent')=?) LIMIT 1`,
business,
agent,
agent,
);
const refused = { refused: 'library-fetch-no-grant', fix: LIBRARY_FETCH_FIX };
if (!chiefs.length) return evidence(null) ? refused : { kind: 'slot' };
if (!chiefs.some((agent) => evidence(agent))) return { kind: 'slot' };
const listed = chiefs.filter(
(agent) => !this.#firing(business, agent) && (!Array.isArray(b.launch?.instances) || b.launch.instances.includes(agent) || b.launch.by === agent),
);
if (listed.length !== 1) return refused;
const [agent] = listed;
const live = this.#store.get('SELECT user FROM gitea_grants WHERE business=? AND agent=?', business, agent);
if (!live || !this.#giteaKeys || this.#giteaKeys.held(business, agent)) return refused;
return { kind: 'chief', agent, user: live.user };
}
reconcileGrants() {
const ended = [];
for (const business of Object.keys(this.#businesses)) {
+14 -4
View File
@@ -4,7 +4,7 @@ import { fileURLToPath } from 'node:url';
import { Store } from './store.mjs';
import * as projectModule from '../../project/src/index.mjs';
import * as libraryModule from '../../library/src/index.mjs';
import { Broker, BusError } from './broker.mjs';
import { Broker, BusError, LIBRARY_FETCH_FIX } from './broker.mjs';
import { Credentials } from './credentials.mjs';
import { serve } from './server.mjs';
import { launchSettings } from '../../business/src/launch-settings.mjs';
@@ -24,7 +24,7 @@ import { busBusiness } from './business.mjs';
import { webService } from './web.mjs';
import { hostExecutor, sweepRunDir } from './host-run.mjs';
import { grantKeys, hostGrant, hostReference, sweepHostKeys } from './host-grant.mjs';
import { giteaGrant, giteaKeys, giteaStart } from './gitea-grant.mjs';
import { giteaGrant, giteaKeys, giteaStart, giteaReference } from './gitea-grant.mjs';
import { verifyPrincipal } from './principal.mjs';
import { loadAccess } from '../../business/src/access.mjs';
import { inspectInterpreter, recordInterpreterStart } from './interpreter.mjs';
@@ -223,9 +223,18 @@ export async function startBroker({
}
// C2-03a: the slot is <configDir>/secrets/<business>/library-gitea (design 10 step 4). Sync only
// checks that it is there; the askpass helper reads it.
// C2-03b: the broker chooses. The chief's grant store file once a chief has Gitea grant evidence,
// the slot before that, or a refusal sync records. The answer carries no file to sync but this one.
const slotDir = librarySync?.configDir ?? organization?.dir ?? null;
const fetchCredential = business => {
const choice = broker.libraryFetchCredential(business.id);
if (choice.kind === 'chief')
return { kind: 'chief', user: choice.user, file: storeRoot ? giteaReference(storeRoot, business.id, choice.agent).file : null };
if (choice.kind === 'slot') return { kind: 'slot', file: slotDir ? join(slotDir, 'secrets', business.id, libraryModule.SLOT) : null };
return { refused: choice.refused };
};
const syncOptions = business => ({
slot: slotDir ? join(slotDir, 'secrets', business.id, libraryModule.SLOT) : null,
credential: fetchCredential(business),
...(librarySync ? { command: librarySync.command } : {}),
});
// Row 194 (#1692, D3): after every write that commits, the broker takes the business as a restart
@@ -576,7 +585,8 @@ export async function startBroker({
for (const { business, project } of timed) {
try {
const { code } = await libraryModule.sync({ business, project, dataRoot }, syncOptions(business));
if (code !== null) syncLog({ event: 'library-sync-held', business: business.id, project, code });
if (code !== null) syncLog({ event: 'library-sync-held', business: business.id, project, code,
...(code === 'library-fetch-no-grant' ? { fix: LIBRARY_FETCH_FIX } : {}) });
} catch (e) {
if (e?.code === 'library-sync-busy') continue;
syncLog({ event: 'library-sync-refused', business: business.id, project,
@@ -0,0 +1,334 @@
// C2-03b (#1791, design 7.2): the credential the library fetch runs with. The chief's grant store file
// once a chief has Gitea grant evidence, slot library-gitea before that, and a refusal otherwise.
// T7's revoked and fired cases and T9's revoked case run the broker's real paths (dewey-0864): the
// chief's grant through decision.raise and the human's yes against the fake Gitea in fake-gitea-api.mjs
// on 127.0.0.1, then grants.revoke or instance.fire. roles/chief-of-staff.json names no gitea credential,
// so these tests give chief-of-staff one in their own definitions, as gitea-grant.test.mjs gives coder
// one. The choice function's other checks (listed, a fire under way, held, two chiefs, another project)
// use fixtures: the evidence and the gitea_grants row go in by SQL, as gitea-grant.test.mjs does for
// decisions. Tokens are made up or the fake's, and only ever compared.
import test from 'node:test';
import assert from 'node:assert/strict';
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { randomBytes } from 'node:crypto';
import { Store } from '../src/store.mjs';
import { Broker, GRANT_EVIDENCE, LIBRARY_FETCH_FIX, writeGrantEvent } from '../src/broker.mjs';
import { Credentials } from '../src/credentials.mjs';
import { giteaGrant, giteaKeys, giteaStart } from '../src/gitea-grant.mjs';
import { startBroker } from '../src/runtime.mjs';
import { gitEnv } from '../../seat/src/checkout.mjs';
import { mappingEntry, repo } from '../../library/tests/helpers.mjs';
import * as library from '../../library/src/index.mjs';
import { fakeGiteaApi } from './fake-gitea-api.mjs';
import { grantDecision } from './host-fixtures.mjs';
import { rolesCopy, systemFor } from '../../business/tests/helpers.mjs';
const AT = '2026-10-22T00:00:00.000Z';
const REV = 'b'.repeat(64);
const SLOT_TOKEN = 'fixture-token-2c90-made-up-0048';
const role = { authority: { withinRole: [], crossRole: [] } };
const none = { authority: { withinRole: ['message.send'], crossRole: [] } };
const options = [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }];
const chiefAuthority = JSON.parse(readFileSync(fileURLToPath(new URL('../../../roles/chief-of-staff.json', import.meta.url)), 'utf8')).authority;
const SWITCH = { version: 1, halted: false, generation: '0b6e7f6e-4c1d-4a8e-9a51-6d2f3c4b5a69' };
const dayFrom = (n) => new Date(Date.now() + n * 86400000).toISOString().slice(0, 10);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function until(fn, ms = 10000) {
const end = Date.now() + ms;
while (!fn()) {
if (Date.now() > end) throw new Error('timed out');
await sleep(10);
}
}
const makeBusiness = ({ chief = true, instances = null } = {}) => ({
id: 'acme', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' },
roles: { ...(chief ? { chief: { definition: 'chief-of-staff', ...role } } : {}), coder: { definition: 'coder', ...role } },
projects: { books: { ...mappingEntry(), user: 'books-bot' } },
...(instances ? { launch: { instances } } : {}),
});
// A Gitea grant for `agent` as row 236 leaves it: the decision, the user and grant rows, and
// grant.applied. `project` is the grant's target; the evidence check names no project.
function grant(store, agent, { user = agent + '-bot', project = 'books', row = true } = {}) {
const decision = grantDecision(store, { business: 'acme', grant: { method: 'gitea', agent, target: { projects: [project] }, rotateBy: '2099-01-01' } });
writeGrantEvent(store, AT, 'acme', { operation: 'grant.gitea', decision }, decision);
writeGrantEvent(store, AT, 'acme', { action: 'grant.applied', method: 'gitea', agent, grantDecision: decision, user, projects: [project] }, decision);
if (!row) return decision;
store.run('INSERT INTO gitea_users(business,agent,user,id,nonce,decision) VALUES(?,?,?,?,?,?)', 'acme', agent, user, 7, randomBytes(8).toString('hex'), decision);
store.run('INSERT INTO gitea_grants(business,agent,user,token_id,token_name,projects,rotate_by,decision) VALUES(?,?,?,?,?,?,?,?)',
'acme', agent, user, 11, 'mosaic-' + agent, JSON.stringify([project]), '2099-01-01', decision);
return decision;
}
function world(t, options) {
const root = mkdtempSync(join(tmpdir(), 'bus-library-credential-')), store = new Store(root);
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
const business = makeBusiness(options);
const held = new Set();
const broker = new Broker({ store, businesses: { acme: business }, giteaKeys: { held: (b, a) => held.has(b + '/' + a) } });
return { store, business, broker, held, choose: () => broker.libraryFetchCredential('acme') };
}
// The business for the real paths. `boss` is launch.by: instance.fire is the launch.by role's verb and
// refuses its own name (organization-instance-referenced), so the fired chief is the one listed
// chief-of-staff instance, fired by boss. The grant targets project app; books is the library.
function verbDoc(url, root) {
return {
id: 'acme', human: 'jason', arbiters: { technical: 'boss', delivery: 'boss' },
roles: {
boss: { definition: 'pm', authority: chiefAuthority },
chief: { definition: 'chief-of-staff', ...none, identity: { name: 'Chief', email: '[email protected]', gitea: { url, user: 'acme-chief' } }, publish: ['app'] },
coder: { definition: 'coder', ...none },
},
definitions: {
'chief-of-staff': { roleVersion: 2, credentials: [{ service: 'gitea' }] },
pm: { roleVersion: 2, credentials: [] },
coder: { roleVersion: 2, credentials: [] },
},
// sage-4054 / mos-1272: runtime validates every code project's absolute checkout root.
projects: { app: { root: join(root, 'app-checkout'), remote: `${url}/acme/app` }, books: { ...mappingEntry(), user: 'books-bot' } },
launch: { by: 'boss', instances: ['chief', 'coder'] },
};
}
// A bare broker on the real grant paths, as gitea-grant.test.mjs and fire-revokes-grants.test.mjs run
// it. A second one on the same data root and store picks the grant up as runtime.mjs does: giteaStart,
// then Credentials with its references and held list. The admin slot sits outside storeRoot's config
// dir, so a runtime started on that dir finds no admin slot.
async function verbWorld(t, opts = {}) {
const dir = mkdtempSync(join(tmpdir(), 'bus-library-verbs-'));
t.after(() => rmSync(dir, { recursive: true, force: true }));
let gitea = opts.gitea;
if (!gitea) {
gitea = await fakeGiteaApi(t, join(dir, 'tls'));
gitea.addUser('acme');
gitea.addRepo('acme', 'app');
}
const adminDir = join(dir, 'admin');
mkdirSync(adminDir, { mode: 0o700 });
const dataRoot = opts.dataRoot ?? join(dir, 'data');
mkdirSync(dataRoot, { recursive: true, mode: 0o700 });
const storeRoot = opts.storeRoot ?? join(dir, 'secrets', 'agents');
const doc = opts.doc ?? verbDoc(gitea.url, dir);
const store = new Store(dataRoot);
const references = {};
const { held } = giteaStart(store, storeRoot, { acme: doc }, references);
const credentials = new Credentials({ references, storeRoot, dataRoot, held });
let open = true;
const close = () => {
if (!open) return;
open = false;
credentials.close();
store.close();
};
t.after(close);
const grantApi = giteaGrant({ configDir: adminDir, ca: gitea.ca, timeoutMs: 5000 });
grantApi.writeAdmin('acme', gitea.adminToken, { url: gitea.url, account: 'mosaic-grant', rotateBy: dayFrom(60), setAt: AT });
let broker;
const organization = (business, op, args) => {
if (op === 'instance.fire') {
doc.launch.instances = doc.launch.instances.filter((n) => n !== args.name);
broker.reloadBusiness('acme', doc);
return { revision: REV, transaction: 't-fire' };
}
return { revision: REV };
};
broker = new Broker({
store, businesses: { acme: structuredClone(doc) }, secretCheck: (value) => credentials.assertClean(value), organization,
processing: { read: () => SWITCH }, giteaGrant: grantApi, giteaKeys: giteaKeys({ credentials, storeRoot }),
});
const boss = broker.bindLaunch({ business: 'acme', role: 'boss', run: 'boss-run', harness: 'pi', address: 'boss' });
broker.request(boss, { verb: 'role.claim' });
// sage-4054 / mos-1272: grant raises require the chief definition, independent of launch.by's fire authority.
const chief = broker.bindLaunch({ business: 'acme', role: 'chief', run: 'chief-run', harness: 'pi', address: 'chief' });
broker.request(chief, { verb: 'role.claim' });
const human = broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true });
const events = () => store.all(`SELECT body FROM events WHERE business='acme' AND ${GRANT_EVIDENCE} ORDER BY seq`).map((e) => JSON.parse(e.body));
const ended = (id) => events().findLast((e) => e.grantDecision === id && ['grant.applied', 'grant.failed'].includes(e.action));
const row = () => store.get("SELECT 1 FROM gitea_grants WHERE business='acme' AND agent='chief'");
const approveChief = async () => {
const d = await broker.request(chief, {
verb: 'decision.raise',
args: {
action: 'credential.mint', question: 'Grant chief Gitea?', options, recommendation: 'yes', blocking: false,
grant: { method: 'gitea', agent: 'chief', target: { projects: ['app'] }, rotateBy: dayFrom(30) },
},
});
broker.request(human, { verb: 'decision.resolve', args: { id: d.id, choice: 'yes' } });
await until(() => ended(d.id));
await broker.giteaIdle();
assert.equal(ended(d.id).action, 'grant.applied', 'the chief\'s grant landed');
assert.ok(row(), 'with its gitea_grants row');
};
const revoke = async () => {
await broker.request(human, { verb: 'grants.revoke', args: { service: 'gitea', agent: 'chief', reason: 'test' } });
await broker.giteaIdle();
assert.equal(row(), undefined, 'grants.revoke deleted the row');
assert.ok(events().some((e) => e.action === 'grant.revoked' && e.agent === 'chief'));
};
const fire = async () => {
await broker.request(boss, { verb: 'instance.fire', args: { name: 'chief', reason: 'the work is done' } });
await until(() => !doc.launch.instances.includes('chief'));
await broker.grantsIdle();
assert.equal(row(), undefined, 'the fire\'s step G deleted the row (row 281)');
};
// sage-4054 / mos-1272: a second fixture broker on this store needs the previous runs released.
const release = () => {
broker.request(chief, { verb: 'role.release' });
broker.request(boss, { verb: 'role.release' });
};
return { gitea, doc, store, broker, storeRoot, row, approveChief, revoke, fire, close, release, choose: () => broker.libraryFetchCredential('acme') };
}
const REFUSED = { refused: 'library-fetch-no-grant', fix: LIBRARY_FETCH_FIX };
test('C2-03b T6: the slot before any chief evidence, then the listed chief\'s live grant', t => {
const w = world(t);
assert.deepEqual(w.choose(), { kind: 'slot' });
grant(w.store, 'coder');
assert.deepEqual(w.choose(), { kind: 'slot' }, 'a seat grant is not the chief\'s: the chief entry keeps the fallback');
grant(w.store, 'chief', { user: 'chief-bot' });
assert.deepEqual(w.choose(), { kind: 'chief', agent: 'chief', user: 'chief-bot' });
assert.deepEqual(new Broker({ store: w.store, businesses: { acme: w.business } }).libraryFetchCredential('acme'), REFUSED,
'a bus with no grant store refuses rather than guess');
assert.throws(() => w.broker.libraryFetchCredential('other'), { code: 'unknown-business' });
assert.equal(LIBRARY_FETCH_FIX, 'add the chief of staff, or grant the library through C2-21');
});
test('C2-03b T7: a chief revoked through grants.revoke or fired through instance.fire refuses, and the fallback stays ended', { timeout: 60000 }, async t => {
const revoked = await verbWorld(t);
assert.deepEqual(revoked.choose(), { kind: 'slot' });
await revoked.approveChief();
assert.deepEqual(revoked.choose(), { kind: 'chief', agent: 'chief', user: 'acme-chief' });
await revoked.revoke();
assert.deepEqual(revoked.choose(), REFUSED, 'revoked');
const fired = await verbWorld(t);
await fired.approveChief();
assert.deepEqual(fired.choose(), { kind: 'chief', agent: 'chief', user: 'acme-chief' });
await fired.fire();
assert.equal(fired.doc.launch.instances.includes('chief'), false, 'delisted');
assert.ok(fired.doc.roles.chief, 'roles[chief] remains after a fire, so its evidence still ends the fallback');
assert.deepEqual(fired.choose(), REFUSED, 'fired');
});
test('C2-03b T7 choice checks: delisted, a fire under way and held each refuse; a rehire lists the chief again', t => {
const delisted = world(t, { instances: ['coder'] });
grant(delisted.store, 'chief');
assert.deepEqual(delisted.choose(), REFUSED, 'delisted with the row still there');
delisted.business.launch.instances.push('chief');
// sage-4054 / mos-1272: Broker clones input; publish the fixture's organization change.
delisted.broker.reloadBusiness('acme', delisted.business);
assert.deepEqual(delisted.choose(), { kind: 'chief', agent: 'chief', user: 'chief-bot' }, 'a rehire lists it again');
delisted.store.run("INSERT INTO events(id,at,business,kind,actor_role,actor_run,subject,body) VALUES('f1',?,?,'action.allowed','chief','run','chief',?)",
AT, 'acme', JSON.stringify({ action: 'fire.stopped' }));
assert.deepEqual(delisted.choose(), REFUSED, 'a fire under way');
const held = world(t);
grant(held.store, 'chief');
held.held.add('acme/chief');
assert.deepEqual(held.choose(), REFUSED, 'held');
});
test('C2-03b T8: a chief grant on another project ends the fallback, and two listed chiefs refuse library-fetch-no-grant', t => {
const w = world(t);
grant(w.store, 'chief', { project: 'other-project', row: false });
assert.deepEqual(w.choose(), REFUSED);
const two = world(t);
grant(two.store, 'chief');
assert.deepEqual(two.choose(), { kind: 'chief', agent: 'chief', user: 'chief-bot' });
two.business.roles.second = { definition: 'chief-of-staff', ...role };
two.broker.reloadBusiness('acme', two.business); // sage-4054 / mos-1272: publish the fixture change
assert.deepEqual(two.choose(), REFUSED, 'two chief-of-staff entries, both listed: one chief only');
two.business.launch = { instances: ['coder'] };
two.broker.reloadBusiness('acme', two.business); // sage-4054 / mos-1272
assert.deepEqual(two.choose(), REFUSED, 'neither listed');
});
test('C2-03b departure (sage-2928): with no chief entry, any Gitea grant evidence refuses', t => {
const w = world(t, { chief: false });
assert.deepEqual(w.choose(), { kind: 'slot' });
grant(w.store, 'coder');
assert.deepEqual(w.choose(), REFUSED);
});
// T9 and the runtime wiring. The timer and library.sync both take the broker's choice: the chief's
// store file, the slot, or a refusal that runs no git even with the slot at 0000. The grant and its
// revoke run through the real paths on the runtime's own data root and store.
test('C2-03b T9 runtime: the chief\'s store file and user, then a real revoke that runs no git with the slot at 0000', { timeout: 60000 }, async t => {
const f = repo(t), root = f.root, dataRoot = join(root, 'data');
mkdirSync(dataRoot, { mode: 0o700 });
const storeRoot = join(root, 'secrets', 'agents');
// A scratch roles/ whose chief-of-staff carries a gitea credential, as row 285 ships it
// (case2-rows.md:1823-1824), so the runtime's definitions match the ones the grant ran under
// (dewey-0869). Rebased onto 285, the copy needs no edit.
const rolesDir = rolesCopy(root);
const chiefRole = join(rolesDir, 'chief-of-staff.json');
const chiefDefinition = JSON.parse(readFileSync(chiefRole, 'utf8'));
if (!chiefDefinition.credentials.some(c => c.service === 'gitea')) chiefDefinition.credentials.push({ service: 'gitea' });
writeFileSync(chiefRole, JSON.stringify(chiefDefinition, null, 2) + '\n');
const slot = join(root, 'secrets', 'acme', library.SLOT);
mkdirSync(join(root, 'secrets', 'acme'), { recursive: true, mode: 0o700 });
writeFileSync(slot, SLOT_TOKEN + '\n', { mode: 0o600 });
const granted = await verbWorld(t, { dataRoot, storeRoot });
await granted.approveChief();
granted.release();
granted.close();
const gitea = granted.gitea;
const chiefFile = join(storeRoot, 'acme', 'chief', 'gitea');
assert.ok(existsSync(chiefFile), 'the grant wrote the chief\'s store file');
const tokens = () => [SLOT_TOKEN, ...gitea.issued];
const calls = [], lines = [], write = process.stderr.write;
const command = (kind, url, opts) => { calls.push({ kind, opts }); return { argv: ['--version'], env: gitEnv() }; };
process.stderr.write = function (chunk, ...rest) {
if (String(chunk).startsWith('library: ')) { lines.push(String(chunk)); return true; }
return write.call(this, chunk, ...rest);
};
t.after(() => { process.stderr.write = write; });
const start = () => startBroker({ dataRoot, businesses: { acme: verbDoc(gitea.url, root) }, organization: { dir: root, rolesDir, system: systemFor(root) },
librarySync: { command, intervalMs: 50 } });
const untilAsync = async (fn) => { const end = Date.now() + 10000; while (!fn() && Date.now() < end) await sleep(10); };
let runtime = await start();
t.after(async () => { await runtime.close(); }); // sage-4054 / mos-1272: close even on assertion failure
// sage-4054 / mos-1272: command construction precedes publication; wait for the completed sync log.
await untilAsync(() => lines.length >= 1);
const human = runtime.broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true });
await runtime.broker.request(human, { verb: 'library.sync', args: { project: 'books' } }).catch(e => e);
// webby-0151 R3: library.status names the kind and user of the last fetch, never the file or value.
const shown = await runtime.broker.request(human, { verb: 'library.status', args: { project: 'books' } });
assert.deepEqual(shown.credential, { kind: 'chief', user: 'acme-chief' });
assert.equal(JSON.stringify(shown).includes(chiefFile) || tokens().some(tok => JSON.stringify(shown).includes(tok)), false);
await runtime.close();
assert.ok(calls.length >= 2, 'the timer and the verb');
assert.ok(calls.every(c => c.kind === 'fetch' && c.opts.slot === chiefFile && c.opts.user === 'acme-chief'), 'the chief\'s file and user, never the slot');
// Revoked through grants.revoke: the next start refuses before git, and the log names the fix.
const revoking = await verbWorld(t, { dataRoot, storeRoot, gitea });
await revoking.revoke();
revoking.release();
revoking.close();
chmodSync(slot, 0o000);
calls.length = 0; lines.length = 0;
runtime = await start();
await untilAsync(() => lines.length >= 1);
const human2 = runtime.broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true });
await assert.rejects(runtime.broker.request(human2, { verb: 'library.sync', args: { project: 'books' } }), { code: 'library-fetch-no-grant' });
const after = await runtime.broker.request(human2, { verb: 'library.status', args: { project: 'books' } });
assert.equal(after.lastError.code, 'library-fetch-no-grant');
assert.equal(after.credential, null);
await runtime.close();
assert.equal(calls.length, 0, 'no git with a refusal');
assert.equal(statSync(slot).mode & 0o777, 0, 'the slot was never fixed up');
assert.deepEqual(JSON.parse(lines[0].slice('library: '.length)),
{ event: 'library-sync-held', business: 'acme', project: 'books', code: 'library-fetch-no-grant', fix: LIBRARY_FETCH_FIX });
for (const line of lines) assert.equal(tokens().some(tok => line.includes(tok)), false);
const mirror = library.mirrorPath({ business: verbDoc(gitea.url, root), project: 'books', dataRoot });
const state = await library.readSyncState(mirror, { branch: 'main' });
assert.equal(state.lastError.code, 'library-fetch-no-grant');
assert.equal(state.credential, null);
});
+39
View File
@@ -219,6 +219,45 @@ and `library.status` shows the result once it ends. A sync that outlasts the 120
`request-timeout` from the server and still finishes the same way. The Console has no library route yet: `cli.mjs`
passes `librarySync` to `startServer`, and `serve.mjs` doesn't read it.
### Push and credentials (C2-03b)
`push(context, { commit, credential, retried })` sends a full commit id, already in the mirror, to the
entry's `branch` as `push --porcelain --no-verify --no-follow-tags --end-of-options <url>
<commit>:refs/heads/<branch>`, through the same prefix, env and helper as the fetch. The refspec has no
`+`, so the remote takes a fast-forward only.
| git says | Answer |
|---|---|
| the push landed | `{ pushed: true }` |
| `[rejected] (fetch first)` or `(non-fast-forward)` | one fetch with the same credential, then `{ pushed: false, raced: true, tip }` |
| the same, with `retried: true` | `stale-base` |
| the same, while a sync of the mirror runs | `library-sync-busy`, with no fetch |
| anything else, `[remote rejected]` (a hook) included | `library-push-failed` |
Push moves no local ref; C2-02 re-applies and moves `served`. git's output is never kept.
A credential is `{ kind, user, file }`, with `kind` one of `slot`, `chief`, `seat` or `human`, or the
bus's `{ refused: code }`. The helper reads only `file`. It opens the file on fd 3 and refuses unless
the open file and the path have the same device and inode, and the file is regular, the bus's uid's,
mode 0600 and at most 16384 bytes, as `readSecretFile` in `packages/bus/src/gitea-grant.mjs` checks a
grant store file. A FIFO swapped in after `test -f` blocks the open, the same residual
`readSecretFile` has.
- A seat writes with its own Gitea user and grant store file, `<storeRoot>/<business>/<agent>/gitea`.
- `humanCredential(context, { user, configDir })` gives her save its credential: her Gitea login and
`<configDir>/secrets/<business>/library-human`. A missing login, or a slot that is missing, a link,
another uid's or not 0600, refuses `library-human-missing` before any git call.
- The fetch takes the broker's choice, `libraryFetchCredential(business)`. The chief is a `roles`
entry whose definition is `chief-of-staff`; a fire keeps it there. With no Gitea grant evidence for
a chief, the fetch uses slot `library-gitea` and the entry's `user`. Once there is some, only the one
listed chief's live, unheld grant answers, with that grant's user and store file. Anything else
refuses `library-fetch-no-grant`, and so does any Gitea grant evidence in a business with no chief
entry. That refusal opens no file and runs no git. The bus answers its code; the timer's log line
adds `fix: "add the chief of staff, or grant the library through C2-21"`.
The sync state adds `credential`, `{ kind, user }` of the last fetch, or `null` when none ran. It is
optional, so a state written before C2-03b still reads.
## Tests
```sh
+13 -1
View File
@@ -2,6 +2,11 @@
# C2-03a: git's GIT_ASKPASS for library sync (packages/library/src/sync.mjs). It answers only the
# Username and Password prompts for the configured host and user, and exits 1 on anything else.
# The env names the slot path, never the token. printf is a shell builtin, so the token is in no argv.
# C2-03b: the slot is whichever file the bus chose (a grant store file, library-gitea or library-human),
# read with readSecretFile's checks (gitea-grant.mjs): a regular file the bus's uid owns, mode 0600, at
# most 16384 bytes. The checks run on the open fd 3, and its dev:inode must match the path's own
# (an lstat), so a link or a swap before the open refuses and the token comes from the checked file
# (dewey-0860). Residual, as in readSecretFile: a FIFO swapped in after test -f blocks the open.
host=$MOSAIC_LIBRARY_HOST user=$MOSAIC_LIBRARY_USER slot=$MOSAIC_LIBRARY_SLOT
test -n "$host" && test -n "$user" && test -n "$slot" || exit 1
case $host in *[!a-z0-9.:-]*) exit 1 ;; esac
@@ -10,7 +15,14 @@ case $1 in
"Username for 'https://$host': ") printf '%s\n' "$user" ;;
"Password for 'https://$user@$host': ")
test -f "$slot" && test ! -L "$slot" || exit 1
IFS= read -r token < "$slot" || test -n "$token" || exit 1
exec 3<"$slot" || exit 1
open=$(stat -L -c '%d:%i %u %a %s %F' /dev/fd/3 2>/dev/null) || exit 1
path=$(stat -c '%d:%i' -- "$slot" 2>/dev/null) || exit 1
set -- $open
test "$1" = "$path" && test "$2" = "$(id -u)" && test "$3" = 600 && test "$4" -le 16384 \
&& test "$5 $6" = "regular file" && test $# -eq 6 || exit 1
IFS= read -r token <&3 || test -n "$token" || exit 1
exec 3<&-
test -n "$token" || exit 1
printf '%s\n' "$token" ;;
*) exit 1 ;;
+1 -1
View File
@@ -8,6 +8,6 @@ export { readLibrary, listTree, readTexts, parseFile, parseFrontMatter, headComm
export { renderItems, KINDS, NOTICES, UNREADABLE } from './render.mjs';
export { survey, formatSurvey, surveyMain } from './survey.mjs';
export { SYNC_STATE_FILE, mirrorPath, ensureMirror, withLibraryLock, servedCommit, readSyncState, libraryStatus, readLibraryFile } from './mirror.mjs';
export { syncCommand, sync, ASKPASS, SLOT, SYNC_INTERVAL_MS } from './sync.mjs';
export { syncCommand, sync, push, humanCredential, ASKPASS, SLOT, HUMAN_SLOT, CREDENTIAL_KINDS, SYNC_INTERVAL_MS } from './sync.mjs';
export { buildIndex, indexFor, clearIndexCache, indexCacheSize } from './index-build.mjs';
export { sees, find, get, search, manuscript, universeInfo, QUERY_REPLY_CAP, LOOKUP_LIMIT, MANUSCRIPT_LINES } from './query.mjs';
+5 -1
View File
@@ -219,7 +219,11 @@ async function readStateHeld(mirror, { branch } = {}) {
value = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes.subarray(0, used)));
} catch { fail('library-state-invalid', 'invalid sync state'); }
finally { if (fd !== undefined) closeSync(fd); }
if (!keys(value, ['version', 'branch', 'served', 'lastFetch', 'lastError', 'diverged', 'problems'])
// C2-03b adds `credential`, { kind, user } or null. It is optional, so a state from before it reads.
const listed = ['version', 'branch', 'served', 'lastFetch', 'lastError', 'diverged', 'problems'];
if (!keys(value, Object.hasOwn(value ?? {}, 'credential') ? [...listed, 'credential'] : listed)
|| !optional(value.credential ?? null, v => keys(v, ['kind', 'user']) && ['slot', 'chief', 'seat', 'human'].includes(v.kind)
&& typeof v.user === 'string' && /^[A-Za-z0-9_.-]{1,100}$/.test(v.user))
|| value.version !== 1 || value.branch !== branch || value.served !== served
|| !optional(value.served, v => typeof v === 'string' && SHA.test(v)) || !optional(value.lastFetch, utc)
|| !optional(value.lastError, v => keys(v, ['code', 'at']) && typeof v.code === 'string' && /^[a-z-]{1,64}$/.test(v.code) && utc(v.at))
+99 -12
View File
@@ -2,6 +2,9 @@
// configured branch into refs/remotes/library/<branch>. A fast-forward moves refs/heads/served,
// a non-fast-forward holds it until a human accepts, and a failed fetch keeps serving. The token
// never reaches argv, env, config, a URL or a log: git's GIT_ASKPASS helper reads it from the slot.
// C2-03b: the same command pushes, fast-forward only, and the file the helper reads is the one
// credential the caller chose: a seat's grant store file, the chief's for the fetch, slot
// library-gitea before any chief grant, or slot library-human for her save (ruling after r11).
import { constants, closeSync, fsyncSync, lstatSync, openSync, renameSync, rmSync, writeSync } from 'node:fs';
import { randomBytes } from 'node:crypto';
import { dirname, join } from 'node:path';
@@ -15,6 +18,8 @@ import { SYNC_STATE_FILE, ensureMirror, withLibraryLock, servedCommit } from './
export const ASKPASS = fileURLToPath(new URL('../bin/askpass', import.meta.url));
export const SLOT = 'library-gitea';
export const HUMAN_SLOT = 'library-human';
export const CREDENTIAL_KINDS = Object.freeze(['slot', 'chief', 'seat', 'human']);
export const SYNC_INTERVAL_MS = 60_000;
// Row 199's patterns (packages/seat/src/working.mjs).
const HOST = /^[a-z0-9.-]{1,253}(:[0-9]{1,5})?$/;
@@ -28,9 +33,11 @@ const fail = (code, message = code) => { throw new LibraryError(code, message);
// fetch into the same mirror is refused here rather than racing the first.
const running = new Set();
// The one command sync runs against her remote. `kind` is 'fetch'; there is no other.
export function syncCommand(kind, url, { mirror, branch, user, slot }) {
if (kind !== 'fetch') fail('library-remote-invalid', 'sync only fetches');
// The one command builder for her remote. `kind` is 'fetch' or 'push'. A push sends `commit` to the
// branch with no '+', so the remote takes a fast-forward only (D6). `slot` is the file the helper reads.
export function syncCommand(kind, url, { mirror, branch, user, slot, commit = null }) {
if (kind !== 'fetch' && kind !== 'push') fail('library-remote-invalid', 'sync fetches or pushes');
if (kind === 'push' && (typeof commit !== 'string' || !SHA.test(commit))) fail('library-remote-invalid', 'a push takes a full commit id');
let parsed;
try { parsed = new URL(url); } catch { fail('library-remote-invalid', 'remote must be an https:// URL'); }
if (parsed.protocol !== 'https:' || parsed.username || parsed.password || parsed.search || parsed.hash
@@ -38,10 +45,13 @@ export function syncCommand(kind, url, { mirror, branch, user, slot }) {
fail('library-remote-invalid', 'remote must be https:// with a plain host and no credentials');
// core.createObject=rename: git's default files each object by link then unlink, and a reader's
// inspectMirror (C2-04) refuses a file with two links while that runs.
const command = kind === 'fetch'
? ['fetch', '--no-tags', '--no-write-fetch-head', '--no-recurse-submodules', '--end-of-options',
parsed.href, `+refs/heads/${branch}:refs/remotes/library/${branch}`]
: ['push', '--porcelain', '--no-verify', '--no-follow-tags', '--end-of-options', parsed.href, `${commit}:refs/heads/${branch}`];
return {
argv: [...SAFE, '-c', 'protocol.https.allow=always', '-c', 'http.followRedirects=false', '-c', 'credential.helper=',
'-c', 'core.createObject=rename', '-C', mirror, 'fetch', '--no-tags', '--no-write-fetch-head', '--no-recurse-submodules', '--end-of-options',
parsed.href, `+refs/heads/${branch}:refs/remotes/library/${branch}`],
'-c', 'core.createObject=rename', '-C', mirror, ...command],
env: { ...gitEnv(), LC_ALL: 'C', GIT_CEILING_DIRECTORIES: dirname(mirror), GIT_ASKPASS: ASKPASS,
MOSAIC_LIBRARY_HOST: parsed.host, MOSAIC_LIBRARY_USER: user, MOSAIC_LIBRARY_SLOT: slot },
};
@@ -53,6 +63,32 @@ function slotPresent(slot) {
catch { return false; }
}
// A chosen credential, { kind, user, file }, or { refused: code } when the bus refused one.
function checkCredential(credential) {
if (credential === null || typeof credential !== 'object') fail('library-credential-missing', 'no credential chosen');
if (Object.hasOwn(credential, 'refused')) return credential;
const { kind, user, file } = credential;
if (!CREDENTIAL_KINDS.includes(kind) || (user !== undefined && (typeof user !== 'string' || !NAME.test(user)))
|| (file !== null && typeof file !== 'string'))
fail('library-credential-missing', 'credential must name a kind, a plain user and a file');
return credential;
}
// C2-03b: her save and revert push with slot library-human and her Gitea login. A missing login, or a
// slot that is missing, a link, another uid's or not 0600, refuses before any git call. Like sync, this
// never opens the slot; the helper reads it with readSecretFile's checks.
export function humanCredential(context, { user, configDir }) {
const missing = message => fail('library-human-missing', message);
if (typeof user !== 'string' || !NAME.test(user)) missing('her Gitea login is not configured');
if (typeof configDir !== 'string' || typeof context?.business?.id !== 'string') missing('slot library-human is not configured');
const file = join(configDir, 'secrets', context.business.id, HUMAN_SLOT);
let st;
try { st = lstatSync(file); } catch { missing('slot library-human is missing'); }
if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o777) !== 0o600 || st.size > 16384)
missing('slot library-human must be a regular 0600 file the bus owns');
return { kind: 'human', user, file };
}
async function isAncestor(mirror, older, newer) {
const result = await runGit([...SAFE, '-C', mirror, 'merge-base', '--is-ancestor', older, newer],
{ ...gitEnv(), GIT_CEILING_DIRECTORIES: dirname(mirror) });
@@ -89,19 +125,27 @@ const errorCode = error => typeof error?.code === 'string' && CODE.test(error.co
// 'library-credential-missing', 'library-diverged' or a reader refusal). It throws for a bad
// entry, a busy mirror, a stale --accept or an unreadable state. Sync never runs gc or prune:
// a reader holds `served` after the lock is released and reads its objects later (Filbert N2).
export async function sync(context, { slot, accept = null, command = syncCommand, now = () => new Date(), limits = GIT_LIMITS } = {}) {
// C2-03b: `credential` is the bus's choice for the fetch, { kind, user, file } or { refused: code }.
// A refusal (library-fetch-no-grant) is recorded like any held fetch, and no file is opened. Without
// it, `slot` is slot library-gitea with the entry's user, as in C2-03a. The state names the kind and
// the user the last fetch ran with, never the file or the value.
export async function sync(context, { slot, credential = null, accept = null, command = syncCommand, now = () => new Date(), limits = GIT_LIMITS } = {}) {
const mirror = await ensureMirror(context);
const mapping = checkMapping(context.business.projects[context.project]);
const { branch, user } = mapping;
if (typeof user !== 'string') fail('library-remote-invalid', 'library entry has no user for sync');
const { branch } = mapping;
if (typeof mapping.user !== 'string') fail('library-remote-invalid', 'library entry has no user for sync');
const chosen = credential === null ? { kind: 'slot', file: typeof slot === 'string' ? slot : null } : checkCredential(credential);
const user = chosen.user ?? mapping.user;
if (accept !== null && (typeof accept !== 'string' || !SHA.test(accept))) fail('library-accept-invalid', 'accept takes a full commit id');
if (running.has(mirror)) fail('library-sync-busy', 'a sync of this library is running');
running.add(mirror);
try {
let code = null, tip = null, read = null;
if (typeof slot !== 'string' || !slotPresent(slot)) code = 'library-credential-missing';
let code = null, tip = null, read = null, used = null;
if (Object.hasOwn(chosen, 'refused')) code = typeof chosen.refused === 'string' && CODE.test(chosen.refused) ? chosen.refused : 'library-fetch-no-grant';
else if (typeof chosen.file !== 'string' || !slotPresent(chosen.file)) code = 'library-credential-missing';
else {
const { argv, env } = command('fetch', mapping.remote, { mirror, branch, user, slot });
used = { kind: chosen.kind, user };
const { argv, env } = command('fetch', mapping.remote, { mirror, branch, user, slot: chosen.file });
const result = await runGit(argv, env, limits);
// git's stderr is never kept or logged: the code is the whole record.
if (!result.ok) code = 'library-fetch-failed';
@@ -127,7 +171,7 @@ export async function sync(context, { slot, accept = null, command = syncCommand
const prev = accept !== null
? { version: 1, branch, served, lastFetch: null, lastError: null, diverged: null, problems: [] }
: await readSyncState({ branch });
let next = { ...prev }, target = served;
let next = { ...prev, credential: used }, target = served;
if (code !== null) next.lastError = { code, at };
else if (accept !== null) {
const problems = await problemsOf();
@@ -158,3 +202,46 @@ export async function sync(context, { slot, accept = null, command = syncCommand
});
} finally { running.delete(mirror); }
}
// C2-03b: push `commit`, already in the mirror, to her branch with the writer's own credential. It
// resolves { pushed: true } or, when the remote tip moved, { pushed: false, raced: true, tip } after
// one fetch with the same credential; the caller re-applies once and calls again with retried: true,
// and a second race refuses stale-base. The fetch refuses library-sync-busy while a sync of the same
// mirror runs. A hook refusal ([remote rejected]) or any other failure
// refuses library-push-failed, and git's output is never kept. It moves no local ref: C2-02 moves the
// remote-tracking and served refs after a push.
export async function push(context, { commit, credential, retried = false, command = syncCommand, limits = GIT_LIMITS } = {}) {
const mirror = await ensureMirror(context);
const mapping = checkMapping(context.business.projects[context.project]);
const { branch } = mapping;
const chosen = checkCredential(credential);
if (Object.hasOwn(chosen, 'refused'))
fail(typeof chosen.refused === 'string' && CODE.test(chosen.refused) ? chosen.refused : 'library-credential-missing', 'the bus refused a credential');
if (typeof chosen.user !== 'string') fail('library-credential-missing', 'a push names its user');
if (typeof chosen.file !== 'string' || !slotPresent(chosen.file))
fail(chosen.kind === 'human' ? 'library-human-missing' : 'library-credential-missing', 'credential file missing or unsafe');
if (typeof commit !== 'string' || !SHA.test(commit)) fail('library-remote-invalid', 'a push takes a full commit id');
const opts = { mirror, branch, user: chosen.user, slot: chosen.file };
const { argv, env } = command('push', mapping.remote, { ...opts, commit });
const result = await runGit(argv, env, limits);
if (result.ok) return { pushed: true };
// --porcelain: one tab-separated line per ref on stdout, '!' and the summary for a refusal.
const line = String(result.out ?? '').split('\n').find(l => l.split('\t')[1] === `${commit}:refs/heads/${branch}`);
const summary = line?.split('\t')[2] ?? '';
if (!line?.startsWith('!\t') || !/^\[rejected\] \((fetch first|non-fast-forward)\)$/.test(summary))
fail('library-push-failed', 'push refused');
if (retried) fail('stale-base', 'her branch moved again during the write');
// The race fetch writes the mirror, so it takes sync's guard (dewey-0864 R1). A held guard refuses
// library-sync-busy and the caller tries the write again.
if (running.has(mirror)) fail('library-sync-busy', 'a sync of this library is running');
running.add(mirror);
try {
const fetch = command('fetch', mapping.remote, opts);
if (!(await runGit(fetch.argv, fetch.env, limits)).ok) fail('library-push-failed', 'fetch after a moved tip failed');
let tip;
try { tip = await git(mirror, ['rev-parse', '--verify', '--end-of-options', `refs/remotes/library/${branch}^{commit}`]); }
catch { fail('library-push-failed', 'fetch after a moved tip found no branch'); }
if (!SHA.test(tip)) fail('library-push-failed', 'fetch after a moved tip found no branch');
return { pushed: false, raced: true, tip };
} finally { running.delete(mirror); }
}
+227 -4
View File
@@ -3,14 +3,14 @@
// argv and adds the CA to its env. The token is made up and must appear nowhere but the slot.
import test, { before, after } from 'node:test';
import assert from 'node:assert/strict';
import { accessSync, constants, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs';
import { accessSync, chmodSync, constants, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { spawnSync } from 'node:child_process';
import { scratchCa, fakeGitea } from '../../seat/tests/fake-gitea.mjs';
import { SAFE } from '../../project/src/git.mjs';
import { mappingEntry, repo, git, BASE } from './helpers.mjs';
import { syncCommand, sync, ASKPASS, SLOT, SYNC_INTERVAL_MS } from '../src/sync.mjs';
import { syncCommand, sync, push, humanCredential, ASKPASS, SLOT, HUMAN_SLOT, SYNC_INTERVAL_MS } from '../src/sync.mjs';
import { mirrorPath, readSyncState, readLibraryFile, SYNC_STATE_FILE } from '../src/mirror.mjs';
import { readLibrary } from '../src/reader.mjs';
import { checkMapping } from '../src/mapping.mjs';
@@ -25,9 +25,9 @@ after(() => rmSync(scratch, { recursive: true, force: true }));
const clock = () => { let n = 0; return () => new Date(Date.UTC(2026, 9, 21, 12, 0, n++)); };
async function world(t, { token = TOKEN, redirect = null, user = USER } = {}) {
async function world(t, { token = TOKEN, redirect = null, user = USER, users = { [USER]: token } } = {}) {
const f = repo(t);
const gitea = await fakeGitea({ dir: join(f.root, 'gitea'), tls, user: USER, token, redirect });
const gitea = await fakeGitea({ dir: join(f.root, 'gitea'), tls, user: USER, token, users, redirect });
t.after(() => gitea.close());
const remote = gitea.repo('writer', 'books');
mkdirSync(dirname(remote), { recursive: true });
@@ -304,3 +304,226 @@ test('C2-03a one sync per mirror: an overlapping call refuses library-sync-busy'
assert.equal(results.find(r => r.status === 'rejected').reason.code, 'library-sync-busy');
assert.equal((await w.run()).code, null, 'the mirror is free again afterwards');
});
// C2-03b (#1791): push and per-seat credentials. A seat's grant store file is the token plus "\n", as
// Credentials writes it. SEAT_TOKEN is made up too and must also appear nowhere but its file.
const SEAT = 'writer-seat';
const SEAT_TOKEN = 'fixture-token-71ae-made-up-0044';
async function writeWorld(t) {
const w = await world(t, { users: { [USER]: TOKEN, [SEAT]: SEAT_TOKEN } });
const seatFile = join(w.root, 'store', 'acme', SEAT, 'gitea');
mkdirSync(dirname(seatFile), { recursive: true, mode: 0o700 });
writeFileSync(seatFile, SEAT_TOKEN + '\n', { mode: 0o600 });
const seat = { kind: 'seat', user: SEAT, file: seatFile };
assert.equal((await w.run()).code, null);
// A commit on top of `parent` in the mirror, as C2-02's apply would leave it.
const child = (parent, text = 'next') => git(w.mirror(), 'commit-tree', `${parent}^{tree}`, '-p', parent, '-m', text);
const remoteTip = () => git(w.remote, 'rev-parse', 'refs/heads/main');
const write = (options) => push(w.context, { command: w.command, credential: seat, ...options });
return { ...w, seatFile, seat, child, remoteTip, write };
}
test('C2-03b argv: a push sends one full commit to the branch, fast-forward only, with the fetch prefix', () => {
const opts = { mirror: '/srv/data/projects/acme/books', branch: 'main', user: SEAT, slot: '/srv/store/acme/writer-seat/gitea' };
const commit = 'c'.repeat(40);
const { argv, env } = syncCommand('push', 'https://git.example.invalid/writer/books.git', { ...opts, commit });
assert.deepEqual(argv, [...SAFE, '-c', 'protocol.https.allow=always', '-c', 'http.followRedirects=false', '-c', 'credential.helper=',
'-c', 'core.createObject=rename', '-C', opts.mirror, 'push', '--porcelain', '--no-verify', '--no-follow-tags', '--end-of-options',
'https://git.example.invalid/writer/books.git', `${commit}:refs/heads/main`]);
assert.equal(argv.some(a => a.startsWith('+') || a === '--force' || a === '-f' || a.startsWith('--force-')), false, 'no forced update (D6)');
assert.equal(env.MOSAIC_LIBRARY_SLOT, opts.slot);
assert.equal(env.MOSAIC_LIBRARY_USER, SEAT);
for (const bad of [null, undefined, 'main', 'c'.repeat(39), 'C'.repeat(40), `${'c'.repeat(40)}:refs/heads/x`])
assert.throws(() => syncCommand('push', 'https://git.example.invalid/b.git', { ...opts, commit: bad }), { code: 'library-remote-invalid' }, String(bad));
assert.throws(() => syncCommand('gc', 'https://git.example.invalid/b.git', opts), { code: 'library-remote-invalid' });
});
test('C2-03b T1 push: a fast-forward lands with the seat\'s own user and file, and moves no local ref', async t => {
const w = await writeWorld(t);
const before = git(w.mirror(), 'for-each-ref', '--format=%(refname) %(objectname)');
const next = w.child(w.commit);
w.gitea.requests.length = 0;
assert.deepEqual(await w.write({ commit: next }), { pushed: true });
assert.equal(w.remoteTip(), next);
assert.ok(w.gitea.requests.length > 0);
assert.ok(w.gitea.requests.every(r => r.user === null || r.user === SEAT), 'only the seat\'s user reaches the remote');
assert.ok(w.gitea.requests.some(r => r.user === SEAT));
assert.equal(git(w.mirror(), 'for-each-ref', '--format=%(refname) %(objectname)'), before, 'C2-02 moves the local refs, not push');
assert.equal(holdsToken(w.dataRoot), null, 'the bot token sits only in the slot');
for (const name of readdirSync(w.root, { recursive: true })) {
const file = join(w.root, name);
if (file !== w.seatFile && statSync(file).isFile()) assert.equal(readFileSync(file).includes(SEAT_TOKEN), false, file);
}
});
test('C2-03b T2 and T3: a moved tip reports the race after one fetch, and a second race refuses stale-base', async t => {
const w = await writeWorld(t);
const moved = w.push({ 'Inbox Note.md': 'she wrote more\n' });
const mine = w.child(w.commit);
const raced = await w.write({ commit: mine });
assert.deepEqual(raced, { pushed: false, raced: true, tip: moved });
assert.equal(w.remoteTip(), moved, 'her commit stands: nothing forced it out');
assert.equal(git(w.mirror(), 'rev-parse', 'refs/remotes/library/main'), moved, 'the one fetch ran with the same credential');
const again = w.push({ 'Inbox Note.md': 'and more\n' });
await assert.rejects(w.write({ commit: w.child(moved), retried: true }), { code: 'stale-base' });
assert.equal(w.remoteTip(), again);
});
// dewey-0864 R1: the race fetch takes sync's guard. A sync holds it: its fetch is a git alias that
// touches a marker, then waits for a release file. The push starts only once the marker exists and
// the release comes only after the refusal, so no step depends on timing (dewey-0869).
test('C2-03b race fetch: while a sync of the mirror runs, the race refuses library-sync-busy', async t => {
const w = await writeWorld(t);
const moved = w.push({ 'Inbox Note.md': 'she wrote more\n' });
const before = git(w.mirror(), 'rev-parse', 'refs/remotes/library/main');
const mark = join(w.root, 'hold.mark'), release = join(w.root, 'hold.release');
const hold = '!f() { touch "$LIB_HOLD_MARK"; i=0; while [ ! -e "$LIB_HOLD_RELEASE" ] && [ $i -lt 1200 ]; do sleep 0.05; i=$((i+1)); done; }; f';
const slow = (kind, url, opts) => {
const real = w.command(kind, url, opts);
return { argv: ['-c', 'alias.hold=' + hold, 'hold'], env: { ...real.env, LIB_HOLD_MARK: mark, LIB_HOLD_RELEASE: release } };
};
const syncing = sync(w.context, { slot: w.slot, command: slow });
t.after(() => { try { writeFileSync(release, ''); } catch {} });
const end = Date.now() + 30000;
while (!existsSync(mark) && Date.now() < end) await new Promise(r => setTimeout(r, 10));
assert.ok(existsSync(mark), 'the sync is inside its fetch, holding the guard');
await assert.rejects(w.write({ commit: w.child(w.commit) }), { code: 'library-sync-busy' });
assert.equal(w.remoteTip(), moved, 'her commit stands');
writeFileSync(release, '');
await syncing.catch(() => {});
assert.equal(git(w.mirror(), 'rev-parse', 'refs/remotes/library/main'), before, 'the refused race fetched nothing');
assert.deepEqual(await w.write({ commit: w.child(w.commit) }), { pushed: false, raced: true, tip: moved }, 'free again afterwards');
});
test('C2-03b T4: a dead remote, a hook refusal and a wrong token each refuse library-push-failed', async t => {
const w = await writeWorld(t);
// N2: [remote rejected] is a hook, not a race.
const hook = join(w.remote, 'hooks', 'pre-receive');
writeFileSync(hook, '#!/bin/sh\necho no >&2\nexit 1\n', { mode: 0o755 });
const next = w.child(w.commit);
await assert.rejects(w.write({ commit: next }), { code: 'library-push-failed' });
await assert.rejects(w.write({ commit: next, retried: true }), { code: 'library-push-failed' }, 'never stale-base for a hook');
rmSync(hook);
writeFileSync(w.seatFile, 'fixture-token-wrong-0045\n', { mode: 0o600 });
await assert.rejects(w.write({ commit: next }), { code: 'library-push-failed' });
writeFileSync(w.seatFile, SEAT_TOKEN + '\n', { mode: 0o600 });
await w.gitea.close();
await assert.rejects(w.write({ commit: next }), { code: 'library-push-failed' });
assert.equal(w.remoteTip(), w.commit);
});
test('C2-03b push: a refused, missing or unsafe credential runs no git', async t => {
const w = await writeWorld(t);
let called = 0;
const command = (...a) => { called++; return w.command(...a); };
const next = w.child(w.commit);
w.gitea.requests.length = 0;
const cases = [
[{ refused: 'library-fetch-no-grant' }, 'library-fetch-no-grant'],
[null, 'library-credential-missing'],
[{ kind: 'seat', file: w.seatFile }, 'library-credential-missing'],
[{ kind: 'seat', user: 'a b', file: w.seatFile }, 'library-credential-missing'],
[{ kind: 'nobody', user: SEAT, file: w.seatFile }, 'library-credential-missing'],
[{ ...w.seat, file: join(w.root, 'absent') }, 'library-credential-missing'],
[{ kind: 'human', user: 'her-login', file: join(w.root, 'absent') }, 'library-human-missing'],
];
for (const [credential, code] of cases)
await assert.rejects(push(w.context, { commit: next, credential, command }), { code }, JSON.stringify(credential));
chmodSync(w.seatFile, 0o644);
await assert.rejects(push(w.context, { commit: next, credential: w.seat, command }), { code: 'library-credential-missing' });
chmodSync(w.seatFile, 0o600);
await assert.rejects(push(w.context, { commit: 'main', credential: w.seat, command }), { code: 'library-remote-invalid' });
assert.equal(called, 0);
assert.equal(w.gitea.requests.length, 0);
});
test('C2-03b T10 humanCredential: her login and a 0600 slot library-human, or library-human-missing', t => {
const configDir = mkdtempSync(join(tmpdir(), 'library-human-'));
t.after(() => rmSync(configDir, { recursive: true, force: true }));
const context = { business: { id: 'acme' }, project: 'books' };
const file = join(configDir, 'secrets', 'acme', HUMAN_SLOT);
assert.throws(() => humanCredential(context, { user: 'her-login', configDir }), { code: 'library-human-missing' }, 'missing');
mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
writeFileSync(file, 'fixture-token-human-0046\n', { mode: 0o600 });
assert.deepEqual(humanCredential(context, { user: 'her-login', configDir }), { kind: 'human', user: 'her-login', file });
for (const user of [undefined, null, '', 'a b', 'her@x'])
assert.throws(() => humanCredential(context, { user, configDir }), { code: 'library-human-missing' }, String(user));
assert.throws(() => humanCredential(context, { user: 'her-login' }), { code: 'library-human-missing' });
chmodSync(file, 0o644);
assert.throws(() => humanCredential(context, { user: 'her-login', configDir }), { code: 'library-human-missing' }, '0644');
chmodSync(file, 0o600);
const real = join(configDir, 'real'); writeFileSync(real, 'x\n', { mode: 0o600 });
rmSync(file); symlinkSync(real, file);
assert.throws(() => humanCredential(context, { user: 'her-login', configDir }), { code: 'library-human-missing' }, 'a link');
});
test('C2-03b fetch credential: the state names the kind and user, a refusal runs no git, and a pre-286 state still reads', async t => {
const w = await world(t, { users: { [USER]: TOKEN, [SEAT]: SEAT_TOKEN } });
const first = await w.run();
assert.equal(first.code, null);
assert.deepEqual(first.state.credential, { kind: 'slot', user: USER });
assert.deepEqual((await w.state()).credential, { kind: 'slot', user: USER });
// The chief's grant store file, with the chief's Gitea user.
const chiefFile = join(w.root, 'store', 'acme', 'chief', 'gitea');
mkdirSync(dirname(chiefFile), { recursive: true, mode: 0o700 });
writeFileSync(chiefFile, SEAT_TOKEN + '\n', { mode: 0o600 });
w.gitea.requests.length = 0;
const chief = await w.run({ slot: undefined, credential: { kind: 'chief', user: SEAT, file: chiefFile } });
assert.equal(chief.code, null);
assert.deepEqual(chief.state.credential, { kind: 'chief', user: SEAT });
assert.ok(w.gitea.requests.some(r => r.user === SEAT) && w.gitea.requests.every(r => r.user !== USER));
w.gitea.requests.length = 0;
let called = 0;
const refused = await sync(w.context, { credential: { refused: 'library-fetch-no-grant' }, slot: w.slot,
command: (...a) => { called++; return w.command(...a); }, now: clock() });
assert.equal(refused.code, 'library-fetch-no-grant');
assert.equal(refused.state.lastError.code, 'library-fetch-no-grant');
assert.equal(refused.state.credential, null);
assert.equal(called, 0);
assert.equal(w.gitea.requests.length, 0, 'a refusal opens no file and reaches no remote, even with a valid slot');
// N1: a state file written before C2-03b has no credential key and still reads.
const file = join(w.mirror(), SYNC_STATE_FILE);
const { credential, ...old } = JSON.parse(readFileSync(file, 'utf8'));
assert.equal(credential, null);
writeFileSync(file, JSON.stringify(old) + '\n');
assert.deepEqual(await w.state(), old);
writeFileSync(file, JSON.stringify({ ...old, credential: { kind: 'slot', user: USER, file: w.slot } }) + '\n');
await assert.rejects(w.state(), { code: 'library-state-invalid' }, 'the state never names a file');
});
// T5 and T5b: the helper on a seat's file. `stat` is the one external command between the open and the
// read, so a shim on the helper's PATH stands in for another uid and stages a swap.
test('C2-03b T5 helper: the named seat\'s user and file, never a link, 0644, another uid, or a swapped file', t => {
const dir = mkdtempSync(join(tmpdir(), 'library-askpass-seat-'));
t.after(() => rmSync(dir, { recursive: true, force: true }));
const file = join(dir, 'gitea'), other = join(dir, 'other');
writeFileSync(file, SEAT_TOKEN + '\n', { mode: 0o600 });
writeFileSync(other, 'fixture-token-other-0047\n', { mode: 0o600 });
const bin = join(dir, 'bin'); mkdirSync(bin);
const realStat = spawnSync('sh', ['-c', 'command -v stat'], { encoding: 'utf8' }).stdout.trim();
const env = { PATH: '/usr/bin:/bin', MOSAIC_LIBRARY_HOST: '127.0.0.1:4430', MOSAIC_LIBRARY_USER: SEAT, MOSAIC_LIBRARY_SLOT: file };
const prompt = `Password for 'https://${SEAT}@127.0.0.1:4430': `;
const ask = (change = {}, p = prompt) => spawnSync(ASKPASS, [p], { env: { ...env, ...change }, encoding: 'utf8' });
assert.equal(ask().stdout, SEAT_TOKEN + '\n');
assert.equal(ask({}, `Username for 'https://127.0.0.1:4430': `).stdout, SEAT + '\n');
const refused = (r, why) => { assert.equal(r.status, 1, why); assert.equal(r.stdout, '', why); };
refused(ask({}, `Password for 'https://${USER}@127.0.0.1:4430': `), 'another seat\'s user');
const link = join(dir, 'link'); symlinkSync(file, link);
refused(ask({ MOSAIC_LIBRARY_SLOT: link }), 'a link');
chmodSync(file, 0o644); refused(ask(), '0644');
chmodSync(file, 0o640); refused(ask(), '0640');
chmodSync(file, 0o600);
writeFileSync(other, 'x'.repeat(16385), { mode: 0o600 });
refused(ask({ MOSAIC_LIBRARY_SLOT: other }), 'over 16384 bytes');
writeFileSync(other, 'fixture-token-other-0047\n', { mode: 0o600 });
// Another uid: this suite can't chown, so the shim reports uid+1 for the open file.
writeFileSync(join(bin, 'stat'), `#!/bin/sh\ncase $1 in -L) out=$(${realStat} "$@") || exit 1; set -- $out; printf '%s %s %s %s %s %s\\n' "$1" "$(($2 + 1))" "$3" "$4" "$5" "$6" ;; *) exec ${realStat} "$@" ;; esac\n`, { mode: 0o755 });
refused(ask({ PATH: `${bin}:/usr/bin:/bin` }), 'another uid');
// T5b, the swap: on its first call the shim renames another file over the slot, then stats.
const marker = join(dir, 'swapped');
writeFileSync(join(bin, 'stat'), `#!/bin/sh\nif [ ! -e '${marker}' ]; then : > '${marker}'; mv -f '${other}' '${file}'; fi\nexec ${realStat} "$@"\n`, { mode: 0o755 });
refused(ask({ PATH: `${bin}:/usr/bin:/bin` }), 'a file swapped between the check and the read');
assert.ok(statSync(marker).isFile(), 'the swap ran');
assert.equal(readFileSync(file, 'utf8'), 'fixture-token-other-0047\n', 'the slot path names the other file now');
});