Compare commits

...
Author SHA1 Message Date
jason.woltje b9506b3527 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-22 14:06:59 -05:00
jason.woltje bf1f015a9f 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-22 14:06:36 -05:00
jason.woltje 255305c378 Row 286 C2-03b: cite sage-2947 (T7 real verbs, R1 in this row, 285/286 rebase rule) 2026-10-22 14:05:18 -05:00
jason.woltje bcc93c8e62 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-22 14:04:44 -05:00
jason.woltje cefebf65b5 Row 286: library.status shows the credential (webby-0151 R3); fired chief revokes its row as row 281 does; unrun 2026-10-22 13:57:43 -05:00
jason.woltje b0b07da654 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-22 13:56:54 -05:00
jason.woltje 10c0c74640 Row 286 C2-03b: sage-2928 rulings (sh helper, chief by roles definition, departure noted) 2026-10-22 13:47:42 -05:00
jason.woltje f2dacb32af Row 286 C2-03b: fold dewey-0860 (Q1 sh fd/inode check, Q2 chief by roles definition, N1-N3) 2026-10-22 13:46:58 -05:00
jason.woltje 96976ea43d Row 286 C2-03b: sage-2925 Q4 ruling (humanCredential takes her login; editor.user is row 283's) 2026-10-22 13:46:05 -05:00
jason.woltje 5cacd5ca07 Row 286 C2-03b: sage-2922 files-owned extension; Q4 back to Sage (user is the bot fetch identity) 2026-10-22 13:45:21 -05:00
jason.woltje d0eb43a331 Row 286 C2-03b: build plan and four scope questions (BUILD.md only) 2026-10-22 13:42:32 -05:00
10 changed files with 991 additions and 23 deletions

No files matched your search

+246
View File
@@ -0,0 +1,246 @@
# 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).
+30
View File
@@ -251,6 +251,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');
@@ -1453,6 +1456,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';
@@ -23,7 +23,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';
@@ -210,9 +210,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
@@ -531,7 +540,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,317 @@
// 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) {
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: [] },
},
projects: { app: { 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);
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' });
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(boss, {
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)');
};
return { gitea, doc, store, broker, storeRoot, row, approveChief, revoke, fire, close, 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');
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 };
assert.deepEqual(two.choose(), REFUSED, 'two chief-of-staff entries, both listed: one chief only');
two.business.launch = { instances: ['coder'] };
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.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) }, 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();
await untilAsync(() => calls.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.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), 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
@@ -180,6 +180,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
@@ -7,4 +7,4 @@ 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';
+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');
});