Files

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: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.