diff --git a/agents/ludwig/work/queue-286/BUILD.md b/agents/ludwig/work/queue-286/BUILD.md new file mode 100644 index 000000000..b2292ad89 --- /dev/null +++ b/agents/ludwig/work/queue-286/BUILD.md @@ -0,0 +1,97 @@ +# 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: plan only. No code and no node yet. Row 258 comes first in my order. + +## 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 + `/secrets//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 + `///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 :refs/heads/`, 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 `/secrets//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. + +## 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. +- Q4. 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.