20 KiB
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:32syncCommand(kind, url, {mirror, branch, user, slot}). It refuses any kind butfetch. The env passesMOSAIC_LIBRARY_HOST,_USERand_SLOTtobin/askpass.bin/askpasschecks-fand! -Lon the slot, then reads it. It doesn't check the uid or the mode.sync.mjs:51slotPresentchecks 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 callsthis.#library.sync(context, {accept})(broker.mjs:4630), and the timer callslibraryModule.syncdirectly (runtime.mjs:533). - The sync state file has a strict key list in
packages/library/src/mirror.mjs:222. chief-of-staffis a definition (STAFF_TO,broker.mjs:240), not a fixed instance name.- A seat's Gitea user is in
gitea_grants.userandgitea_users. Its token file is<storeRoot>/<business>/<agent>/gitea(gitea-grant.mjs:379).giteaKeys.held(business, agent)answers the held case.
Plan
syncCommand(kind, url, {mirror, branch, user, file, commit}).kindisfetchorpush. SameSAFEprefix,-ckeys and env for both. A push ispush --porcelain --no-verify --end-of-options <url> <commit>:refs/heads/<branch>, with no+.commitmust be a full sha. The env var staysMOSAIC_LIBRARY_SLOTbut now names whichever file answers: a seat file, the chief's file, slotlibrary-giteaor slotlibrary-human.push(context, {commit, credential, retried, command})insync.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}. Withretried: true, the same rejection refusesstale-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-invalidbefore any git call.
bin/askpassgetsreadSecretFile'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 throughstat -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).- Credential choice for the fetch, as a pure function
fetchCredential(store, business, keys)in the bus. The broker exposes it, andruntime.mjspasses its answer tosyncfor 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 readsgitea_grants. - No evidence: slot
library-gitea,{kind: 'slot'}. - Evidence, a live
gitea_grantsrow 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.
- The chief is the one instance whose definition is
- The sync state gets
credential: {kind, user} | null.library.statusshows it.library-fetch-no-grantshows aslastError.code. This changesmirror.mjs's key check (Q3). - Slot
library-humanis<configDir>/secrets/<business>/library-human.humanCredential(...)refuseslibrary-human-missingbefore 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_grantsrow are gone, so I can't name the agent for the evidence check. My recommendation fails safe: with no chief instance, the fetch refuseslibrary-fetch-no-grantwhen any gitea grant event exists in the business, and uses the slot only when none exists. - Q3. Files owned names
sync.mjs,askpass,broker.mjsand the tests. The choice also needspackages/bus/src/runtime.mjs(syncOptionsand the timer's call) andpackages/library/src/mirror.mjs(the state key list forcredential). I'd add both to the manifest, with only those hunks changed. - Q3: approved, sage-2922. See above.
- Q4: sage-2922 allowed
useronly if design 7.2 or the ruling after r11 makes it her account. Neither does.packages/library/README.md:31at94c33a43csays "useris 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 witheditor, names the field. That needs a mapping schema change, which is Sage's call. Ruled sage-2925: accepted.humanCredential(context, {user})refuseslibrary-human-missingbefore any git call when the slot is missing or bad oruseris missing. This row adds no mapping field. Row 283 (C2-01d) adds an optionaleditor.user, and row 288 (C2-02) passes it in. - Q4 (original). Her Gitea user for
library-human. The entry'suser(mapping.mjs:52) is today the sync user, the bot's. The design nameseditor {name, email}(C2-11a) but no Gitea login. My recommendation: the entry'suserbecomes her Gitea login, used only with slotlibrary-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) withstat -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 aftertest -fblocks the open. Neither side uses O_NONBLOCK. Swap test T5b: the direct helper test puts astatshim 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
rolesentry whose definition ischief-of-staffends 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_grantsrow. - With no
rolesentry of that definition (deleted, or never a chief), any gitea grant event in the business refuseslibrary-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 afterinstance.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/shis bash, and GNU coreutils stat 9.11 givesstat -L -c '%d:%i %u %a %s %F' /dev/fd/3on 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:syncCommandtakespushwith a full-shacommit;push();humanCredential();sync()takescredential;HUMAN_SLOT,CREDENTIAL_KINDS. Theslotopt andMOSAIC_LIBRARY_SLOTkeep 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: optionalcredentialin the state key check. Nothing renamed.packages/bus/src/broker.mjs:LIBRARY_FETCH_FIXandlibraryFetchCredential(business).packages/bus/src/runtime.mjs:syncOptionspasses the broker's choice to the verb and the timer. The timer's held line addsfixforlibrary-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'sslotDiruses.BusErrorcarries 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 answerslibrary-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 hascredentials: [], 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.firecan'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 isboss(definition pm, the chief's authority), and boss fires the one listed chief-of-staff instance. Step G deletes the row (row 281),roles.chiefstays, the fetch refuses. Only launch.by may call instance.fire (role-not-chief, broker.mjs:4308 at94c33a43c) 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.revokeon 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.stoppedwith nofire.done, held, two chiefs and another project stay unit checks on the choice function with SQL fixtures.
- Revoked:
- "Another uid" in T5: the suite can't chown, so a PATH
statshim reports uid+1 for the open file. The realid -ucompare 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'srunningguard (dewey-0864 R1, in this row per sage-2947). While a sync of the mirror runs it refuseslibrary-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.
syncwith 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, statecredential: null). - webby-0151 R3: T9 reads
library.statusforcredentialandlastError. - 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 insyncCommand. Expected to fail: the argv test, and T2 (the forced push lands, soracedis 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 base94c33a43c. 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
accessStampandcompactions, 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 tos.usersessions, 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.
- broker.mjs: the constructor gains
- 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
45dca69f6still 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.