Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9506b3527 | ||
|
|
bf1f015a9f | ||
|
|
255305c378 | ||
|
|
bcc93c8e62 | ||
|
|
cefebf65b5 | ||
|
|
b0b07da654 | ||
|
|
10c0c74640 | ||
|
|
f2dacb32af | ||
|
|
96976ea43d | ||
|
|
5cacd5ca07 | ||
|
|
d0eb43a331 |
No files matched your search
@@ -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).
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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 ;;
|
||||
|
||||
@@ -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';
|
||||
@@ -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))
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
Reference in new issue
Block a user