Compare commits

...
Author SHA1 Message Date
jason.woltje 67e0844060 Row 286 r4: record transplant onto landed row217, hunk comparisons and refreshed untested manifest 2026-10-24 06:24:28 -05:00
jason.woltje 02f1c2df63 Row 286 r3: fix new credential fixtures and record green CLEAR gate with killed mutants 2026-10-24 06:22:49 -05:00
jason.woltje b7d3949285 Row 286 r3: record successful canonical queue start and pre-CLEAR push receipt 2026-10-24 06:22:49 -05:00
jason.woltje ed94e24de0 Row 286 r3: transplant evidence, conflict resolution and static reader drift; untested pending CLEAR 2026-10-24 06:22:49 -05:00
jason.woltje fd04dba8bd 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 06:22:44 -05:00
jason.woltje 674f3d6388 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 06:22:44 -05:00
jason.woltje 2e2f71e6fd 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 06:22:44 -05:00
jason.woltje 0d1d997141 Row 286 C2-03b: cite sage-2947 (T7 real verbs, R1 in this row, 285/286 rebase rule) 2026-10-24 06:22:44 -05:00
jason.woltje 6b731a3052 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 06:22:44 -05:00
jason.woltje 4488f8ab08 Row 286: library.status shows the credential (webby-0151 R3); fired chief revokes its row as row 281 does; unrun 2026-10-24 06:22:44 -05:00
jason.woltje a7f274b005 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 06:22:44 -05:00
jason.woltje 299d7f50fe Row 286 C2-03b: sage-2928 rulings (sh helper, chief by roles definition, departure noted) 2026-10-24 06:22:44 -05:00
jason.woltje 6c99ccc7bd Row 286 C2-03b: fold dewey-0860 (Q1 sh fd/inode check, Q2 chief by roles definition, N1-N3) 2026-10-24 06:22:44 -05:00
jason.woltje d0d25b04ba Row 286 C2-03b: sage-2925 Q4 ruling (humanCredential takes her login; editor.user is row 283's) 2026-10-24 06:22:44 -05:00
jason.woltje 764debfe17 Row 286 C2-03b: sage-2922 files-owned extension; Q4 back to Sage (user is the bot fetch identity) 2026-10-24 06:22:44 -05:00
jason.woltje e6b7abf9b2 Row 286 C2-03b: build plan and four scope questions (BUILD.md only) 2026-10-24 06:22:44 -05:00
21 changed files with 3425 additions and 23 deletions

No files matched your search

+6
View File
@@ -5501,3 +5501,9 @@ claim. Receipts and limitations in agents/lena/work/queue-296/BUILD.md and run-r
2026-10-24 | Beakley (88b97f65) | Row 217 round 2, sage-4037 | Rebased published dde5b8496 onto e0c6142f3 on new beakley/row217-r2; both append-log histories retained. Conflicts preserve landed grant dispatch, revocation hook and lookup exports. Added E4 citation-unavailable product path and real supporting-scope/index comparison tests after row283 8d2856ee3. Untested: no Node/npm/tests/mutants/build; ready for Sage CLEAR after Lena296. Full range-diff and honest discarded local rebase correction in agents/beakley/work/queue-217/BUILD.md.
2026-10-24T10:21Z | Beakley (88b97f65) | Row 217 round2 slot verified, sage-4042/4043 | Source 2d274a5b6: touched57/57, whole library142/142 business270/270 bus974/974 cli988/988 harness172/172 seat218/218, all zero fail/skip/cancel. M1-M5 killed, controls/restored controls green, product hashes restored after each; E4 RED stand-in observed. Only first-red was new runtime fixture invalid-authority, corrected and retained in receipts; no old test failed/edited. Local root/webui npm ci, short TMPDIR, timeout-k10; no canonical npm/rm or module link. Full evidence agents/beakley/work/queue-217/run-r2; Sage owns scripts landing gate and formal review.
- 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.
- 2026-10-24 (rockerduck, row286 / #1791, r4 before gate, sage-4079): all 15 r3 commits transplanted to d0a6a87cd on new rockerduck/row286-r4. Three product overlaps auto-merged with row217 preserved; two append-log conflicts retain both histories. Range-diff and adjacent hunk evidence in agents/rockerduck/work/queue-286/BUILD.md. UNTESTED r4: Git-only until own-branch push, library/bus rerun waits for CLEAR after row285.
+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.
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,18 @@
7fab157be380502b726c4a84772bfb667850766062e4952b73f172f5ada363ed agents/ludwig/work/queue-286/BUILD.md
8cc9c6568b4f76b120fe00cb9b6d7e38f8dcf247d4f326bb6d5a766cf7a1f94a agents/rockerduck/work/queue-286/BUILD.md
1698321de2b17251ae3dd55bf3b79e03770d20d932fb60d514ee58700f80fb3b agents/rockerduck/work/queue-286/candidate-r3.sha256
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
f640bc30c0e07dad11d57f8f00397a76638f5fbc4c71fc56e748f9cb6f4e30e1 agents/rockerduck/work/queue-286/range-diff-r4.txt
b3b9a19825a43f33942746bbaaa93670453d4f21b63005d915d9cf92523e9143 agents/rockerduck/work/queue-286/token-search.txt
f7f8122406b8f3588837ffc7557c6a4a58ddd863810b38cfd1138972c3a89fe2 packages/bus/src/broker.mjs
19f69252b3d0ea8d8098044a4ff7e2a6ba5d4fd7a4995bf0494395ad8a1a065c packages/bus/src/runtime.mjs
44e62c044caf66358c4469cd1549aebadaa1bc22e681e4e2ce1c8e07dae649b4 packages/bus/tests/library-credential.test.mjs
d9c523d830c57df9fedf5cb42e6c74999fde529751e8a5eea95b3ae097d8548d packages/library/README.md
5c9b528fd69a2493f04933fc84f352b1101d276d69d9982806975def614fd740 packages/library/bin/askpass
143cafb4984921f6a6e45adcbb812ac402f2ad146ea58de4a0cb8e7c67a6b002 packages/library/src/index.mjs
0bea0f3553ecfca8bee81e545faf5142e5fa6521ea971fbd2d8a78932d644f2b packages/library/src/mirror.mjs
784af63a417b547b195ca835878daeba5b696b42a83a4c95de6728a0efa75e38 packages/library/src/sync.mjs
c8500795d80f56536f0ffd17bcad4f38778d4b06ec39030200c17f31c750ac94 packages/library/tests/sync.test.mjs
@@ -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,69 @@
1: 3326d9487 = 1: e6b7abf9b Row 286 C2-03b: build plan and four scope questions (BUILD.md only)
2: e155e3245 = 2: 764debfe1 Row 286 C2-03b: sage-2922 files-owned extension; Q4 back to Sage (user is the bot fetch identity)
3: 6ce15eddd = 3: d0d25b04b Row 286 C2-03b: sage-2925 Q4 ruling (humanCredential takes her login; editor.user is row 283's)
4: c2c4ac679 = 4: 6c99ccc7b Row 286 C2-03b: fold dewey-0860 (Q1 sh fd/inode check, Q2 chief by roles definition, N1-N3)
5: 8ca442959 = 5: 299d7f50f Row 286 C2-03b: sage-2928 rulings (sh helper, chief by roles definition, departure noted)
6: cca76fa95 ! 6: a7f274b00 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 { 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';
+ export { REPLY_LIMITS, resolveReplyLimits, checkReplyLimits } from './limits.mjs';
## packages/library/src/mirror.mjs ##
@@ packages/library/src/mirror.mjs: async function readStateHeld(mirror, { branch } = {}) {
7: bb29ee2d9 = 7: 4488f8ab0 Row 286: library.status shows the credential (webby-0151 R3); fired chief revokes its row as row 281 does; unrun
8: 90b70a6a6 = 8: 6b731a305 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: d037e98d4 = 9: 0d1d99714 Row 286 C2-03b: cite sage-2947 (T7 real verbs, R1 in this row, 285/286 rebase rule)
10: f2a43d69d = 10: 2e2f71e6f 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: 888548607 = 11: 674f3d638 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: 7842700fb = 12: fd04dba8b 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.
13: 29e830cac ! 13: ed94e24de Row 286 r3: transplant evidence, conflict resolution and static reader drift; untested pending CLEAR
@@ Commit message
Row 286 r3: transplant evidence, conflict resolution and static reader drift; untested pending CLEAR
## BUILD-LOG.md ##
-@@ BUILD-LOG.md: remain; no live state, schema or bus policy change.
+@@ BUILD-LOG.md: claim. Receipts and limitations in agents/lena/work/queue-296/BUILD.md and run-r
+ 2026-10-24 | Beakley (88b97f65) | Row 217 round 2, sage-4037 | Rebased published dde5b8496 onto e0c6142f3 on new beakley/row217-r2; both append-log histories retained. Conflicts preserve landed grant dispatch, revocation hook and lookup exports. Added E4 citation-unavailable product path and real supporting-scope/index comparison tests after row283 8d2856ee3. Untested: no Node/npm/tests/mutants/build; ready for Sage CLEAR after Lena296. Full range-diff and honest discarded local rebase correction in agents/beakley/work/queue-217/BUILD.md.
- 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-24T10:21Z | Beakley (88b97f65) | Row 217 round2 slot verified, sage-4042/4043 | Source 2d274a5b6: touched57/57, whole library142/142 business270/270 bus974/974 cli988/988 harness172/172 seat218/218, all zero fail/skip/cancel. M1-M5 killed, controls/restored controls green, product hashes restored after each; E4 RED stand-in observed. Only first-red was new runtime fixture invalid-authority, corrected and retained in receipts; no old test failed/edited. Local root/webui npm ci, short TMPDIR, timeout-k10; no canonical npm/rm or module link. Full evidence agents/beakley/work/queue-217/run-r2; Sage owns scripts landing gate and formal review.
+
+- 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.
@@ agents/rockerduck/work/queue-286/BUILD.md (new)
## docs/SESSIONS.md ##
@@ docs/SESSIONS.md: 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 | Beakley (88b97f65) | Row 217 round 2 under sage-4037 | New beakley/row217-r2 rebased onto e0c6142f3, retained both log histories, E4 product fix and supporting/index tests drafted. No Node/npm/tests/mutants run; ready for CLEAR; local-only dependency installs required on CLEAR, old-test red goes to Sage.
+
+ 2026-10-24T10:21Z | Beakley (88b97f65) | Row217 #1718 round2 slot | All touched57 and six whole suites2764 pass, zero skips/cancels; M1-M5 killed/restored, E4 RED stand-in observed. First-new runtime fixture red recorded and corrected; no old-test change/failure. Evidence in agents/beakley/work/queue-217/run-r2. Verified for review on beakley/row217-r2; Sage landing scripts/review remain.
+
+- 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.
14: 4b8261f41 = 14: b7d394928 Row 286 r3: record successful canonical queue start and pre-CLEAR push receipt
15: 2e3a5ddd4 ! 15: 02f1c2df6 Row 286 r3: fix new credential fixtures and record green CLEAR gate with killed mutants
@@ Commit message
Row 286 r3: fix new credential fixtures and record green CLEAR gate with killed mutants
## BUILD-LOG.md ##
-@@ BUILD-LOG.md: remain; no live state, schema or bus policy change.
- 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).
+@@ BUILD-LOG.md: claim. Receipts and limitations in agents/lena/work/queue-296/BUILD.md and run-r
+ 2026-10-24T10:21Z | Beakley (88b97f65) | Row 217 round2 slot verified, sage-4042/4043 | Source 2d274a5b6: touched57/57, whole library142/142 business270/270 bus974/974 cli988/988 harness172/172 seat218/218, all zero fail/skip/cancel. M1-M5 killed, controls/restored controls green, product hashes restored after each; E4 RED stand-in observed. Only first-red was new runtime fixture invalid-authority, corrected and retained in receipts; no old test failed/edited. Local root/webui npm ci, short TMPDIR, timeout-k10; no canonical npm/rm or module link. Full evidence agents/beakley/work/queue-217/run-r2; Sage owns scripts landing gate and formal review.
- 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.
+
@@ agents/rockerduck/work/queue-286/token-search.txt (new)
## docs/SESSIONS.md ##
@@ docs/SESSIONS.md: are never rewritten or removed; corrections are new entries.
- 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-24T10:21Z | Beakley (88b97f65) | Row217 #1718 round2 slot | All touched57 and six whole suites2764 pass, zero skips/cancels; M1-M5 killed/restored, E4 RED stand-in observed. First-new runtime fixture red recorded and corrected; no old-test change/failure. Evidence in agents/beakley/work/queue-217/run-r2. Verified for review on beakley/row217-r2; Sage landing scripts/review remain.
- 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.
+
@@ -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
+6
View File
@@ -1634,3 +1634,9 @@ are never rewritten or removed; corrections are new entries.
2026-10-24 | Beakley (88b97f65) | Row 217 round 2 under sage-4037 | New beakley/row217-r2 rebased onto e0c6142f3, retained both log histories, E4 product fix and supporting/index tests drafted. No Node/npm/tests/mutants run; ready for CLEAR; local-only dependency installs required on CLEAR, old-test red goes to Sage.
2026-10-24T10:21Z | Beakley (88b97f65) | Row217 #1718 round2 slot | All touched57 and six whole suites2764 pass, zero skips/cancels; M1-M5 killed/restored, E4 RED stand-in observed. First-new runtime fixture red recorded and corrected; no old-test change/failure. Evidence in agents/beakley/work/queue-217/run-r2. Verified for review on beakley/row217-r2; Sage landing scripts/review remain.
- 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.
- 2026-10-24 — rockerduck (a403aa78-b550-4bab-a613-7ea3fd75ec79), row286 / #1791 r4 under sage-4079: new branch from d0a6a87cd, 15 r3 commits transplanted, both append histories retained, product/test patch unchanged apart from newer base context; evidence/manifest regenerated; no setup/tests run, CLEAR pending.
+30
View File
@@ -271,6 +271,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');
@@ -2047,6 +2050,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
@@ -585,7 +594,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,7 +8,7 @@ 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';
export { REPLY_LIMITS, resolveReplyLimits, checkReplyLimits } from './limits.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');
});