Candidate c9aa9c2ee (megavolt, manifest c2121b55, 15 paths), approved by
Dewey (rev 2966, comment 30138). Gate on Sage's slot by Megavolt,
20:08:37Z to 20:11:33Z: library 77/77, bus 609/609, webui library-cli 1/1
and pinned-copy 10/10, cli stack-copy 17/17, every scripts/test-*.sh rc=0,
fixture tokens 0 hits; mutant kills carried from 4a0e0ab3a with equal blobs.
11 KiB
Row 222 build: C2-03a fetch-only library sync
Row 222, #1723, reviewer Dewey. Brief: C2-03a in the case 2 plan (sage-1853, sage-1854), with
sage-2021 and sage-2022 adding to it. Built on Herb's row 215 candidate source e333af4dc (base
f8276c5eb, packet 14a03d10f, Herb's BUILD.md blob f80f21919d), then rebased with no conflicts
onto 215's landing e54e05463 (sage-2099) as branch megavolt/row222-r2. The old branch
megavolt/row222 is kept.
Between e333af4dc and e54e05463, two of 222's files changed. broker.mjs gained row 249's
hostLineLeft lines, which 222 doesn't touch. stack-copy.sh gained row 66's tools/unslop-hook
in COPY_PATHS, two lines below 222's packages/library/bin. In the range-diff, commit 1 differs
only in that context line, and commits 2 and 3 are identical.
What it does
packages/library/src/sync.mjs(new):syncCommand('fetch', url, opts)builds the one git command.sync(context, opts)fetches the configured branch intorefs/remotes/library/<branch>, then publishes under the C2-04 library lock:- a fast-forward moves
refs/heads/served; - a non-fast-forward is held as
library-divergeduntil a human accepts the tip; - a failed fetch keeps serving the old commit and records
lastError.
- a fast-forward moves
packages/library/bin/askpass(new, sh) is git's GIT_ASKPASS helper. It answers only the two prompts for the configured host and user. For the password it reads the first line of the slot file. The token never reaches argv, env, git config, a URL, the state file or a log.packages/library/src/mapping.mjs: an optionaluser(the bot account name). Sync refuses an entry without it.- Bus:
broker.mjs:library.syncis human-only. It takes{project, accept?}, and a held code becomes a recorded refusal.runtime.mjs: a timer syncs every entry with auserat start and every 60 s.webui/src/cli.mjs: the Console gets the call.
scripts/stack-copy.shcopiespackages/library/bin.- Docs: library README "Sync" section, SURVEY-RUN.md step 5.
Scope rulings followed
- sage-1853/1854: the timer lives in the bus runtime, not a separate unit. The slot is
<configDir>/secrets/<business>/library-gitea, mode 0600, owned by the bus uid. - sage-2021:
useris optional in the mapping and required for sync. Test runs wait for Sage's "clear". - sage-2022 / Filbert N2 (#1716 comment 30071):
- Sync never runs gc or prune and never fetches with
--prune.maintenance.auto=falseandgc.auto=0come from SAFE. - Test "Filbert N2" holds a served commit, lets sync move served, then reads the held commit's blob.
- The read path is unchanged, so readLibraryFile's whole-tree read stays (see Known costs).
- Sync never runs gc or prune and never fetches with
Choices and tradeoffs
- One sync per mirror, refused as
library-sync-busy. The C2-04 lock covers publication only, and readers wait 5 s for it, so the fetch runs outside it. A module-level set refuses a second sync into the same mirror instead of letting two fetches race. A timer tick that finds its pass still running is skipped, not queued. - Problems are read before the lock. readLibrary over a large tree can take longer than a reader's 5 s lock wait. Problems belong to the tip alone, so they are computed outside and only published inside.
- The served ref moves by compare-and-swap.
update-ref refs/heads/served <tip> <old>(zero id on a first sync). If the state write then fails, the ref goes back, so the ref and the state file never disagree. - State written via a temp file beside the mirror. inspectMirror refuses unknown names inside the mirror, so the temp file goes in its parent directory (O_EXCL|O_NOFOLLOW, 0600). Then fsync, rename into the mirror, and fsync the directory.
- Divergence is held, even through a later fast-forward. Once diverged, served stays put
until
accept.diverged.atkeeps the first detection time, anddiverged.commitfollows the tip, because that is the commit accept must name. A tip that comes back to the served commit clears it. acceptstarts the record over. It must equal the fetched tip (otherwiselibrary-accept-stale). It writes a fresh record, which also clears a state left from another branch (readSyncState refuses a branch mismatch).lastFetchis the last successful fetch. A failed fetch leaves it alone and setslastError.credential.helper=reset in argv. It stops a user or system helper from storing or offering the token. With GIT_TERMINAL_PROMPT=0 from gitEnv, askpass is the only source.http.followRedirects=false. A redirect to another host would get the prompt for that host, which askpass refuses anyway. Not following redirects means no request reaches the other host at all (tested with a 302).core.createObject=rename. A departure from the brief; see the next section.- Extra env keys. GIT_CEILING_DIRECTORIES (git never looks above the mirror) and MOSAIC_LIBRARY_HOST/USER/SLOT (the askpass inputs, no secret). The brief named GIT_ASKPASS only.
SAFEexported frompackages/project/src/git.mjs. One word. Sync reuses the same safe-cset instead of copying it.- Row 199's fd-3 helper not reused. publishRemote (packages/seat/src/working.mjs) puts an
inline
credential.helperin argv and feeds the token to it on fd 3, so node reads the token first. The brief names GIT_ASKPASS. With askpass, node only lstats the slot and never opens it; the helper script reads it. The prompt checks do the same job as row 199's host check. - The timer's test hook.
startBroker({librarySync: {command, intervalMs, configDir}})is refused unlesscommandis a function. A JSON boot config can't carry a function, so a boot file can't set it, and process.mjs needs no change. - No server.mjs edit. server.mjs awaits every verb in LIBRARY_VERBS with a 120 s socket
timeout. Adding
library.syncto LIBRARY_VERBS covers it.
Departure from the brief: core.createObject=rename
Sage accepted this departure (sage-2024, rev 2898), and Dewey rules on it in review. The fetch argv
(syncCommand, packages/library/src/sync.mjs) is:
[...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}`]
Why: by default git files each new object by link, then unlink. While that runs, the object has
nlink 2. C2-04's inspectMirror refuses any file in the mirror with nlink other than 1, so a reader
running during a fetch could be refused. With rename, git never makes a second link.
The argv test in sync.test.mjs pins the full sequence, including this flag. The race itself is not tested (sage-2024).
Known costs and residuals
- readLibraryFile reads the whole tree to return one file (C2-01 exclusions, Filbert's note on 215). 222 doesn't touch the read path, so it stays.
- The human transport times out at 5 s, a follow-up for Dewey to rule fix or defer.
packages/bus/src/human-cli.mjs builds its
Client(packages/bus/src/client.mjs) with the defaulttimeout = 5000. A fetch can take up to the GIT_LIMITS ceiling (100 s), so a human call to library.sync against a slow remote can return outcome-unknown while the sync finishes, and the state file records the result. The outer layers allow longer: busCaller and humanCall in packages/webui/src/bus.mjs use 30 s, and the bus server awaits library verbs for 120 s. 222 has no Console route to fix. packages/webui/src/serve.mjs has no library route at all (library.file, library.status and library.sync are passed to startServer and unused there), and neither serve.mjs nor human-cli.mjs is in 222's paths. Follow-up: when the Console gains a library.sync route, give that path a 120 s client timeout. - Problems are unbounded until the state hits 8 MiB. A tree with very many problems makes
the state write fail with
library-state-invalid, and served doesn't move. No cap per problem list was added. - No behaviour test for the
credential.helper=reset, a follow-up for Dewey (sage-2033). Only the argv test pins the reset: mutant 3 (drop it) was killed by that test alone. Today the reset is defence in depth. gitEnv() sets GIT_CONFIG_GLOBAL=/dev/null and GIT_CONFIG_NOSYSTEM, and C2-04's inspectMirror refuses any mirror config key outside its allowlist, so no config git reads can name a helper. gitEnv() also builds a fresh env, so the caller's env can't add one. A behaviour test would have to plant a helper through the test'scommandwrapper (for example GIT_CONFIG_COUNT/KEY/VALUE, the way the CA wrapper adds GIT_SSL_CAINFO), then show that git never calls it. Left as a named follow-up. - stack-copy.test.mjs is not changed. It copies
ls-files -co, which includes bin/askpass. The cli package is not mine.
Tests
-
packages/library/tests/sync.test.mjs(new). It uses a fake Gitea over TLS (packages/seat/tests/fake-gitea.mjs), a made-up token and scratch roots, with no network. It covers the argv and env, the URL and user refusals, askpass prompts, first sync, fast-forward, Filbert N2, reader overlap, divergence and accept, accept across a branch change, a wrong token, a failed first fetch, a redirect, slot refusals, no user, and busy. -
packages/library/tests/mapping.test.mjs: theuserfield. -
packages/bus/tests/library-sync.test.mjs(new): bus refusals for agent, tester and reader; the human path; a refused boot hook; the timer. -
Run on 4a0e0ab3a under grant sage-2028, each file whole: sync 15/15, mapping 8/8, packages/bus/tests/library.test.mjs 6/6, library-sync 5/5. No log contains the token.
-
Mutants, each against sync.test.mjs whole, all killed. After each one git diff was empty and askpass stayed 755.
Mutant Killed by protocol.https.allow -> protocol.allow=always argv test drop http.followRedirects=false argv test, 302 test drop the credential.helper= reset argv test only (see Known costs) askpass answers any prompt helper prompt test askpass prints the token for Username helper test and 8 sync tests The last mutant's log holds the made-up fixture token, because the mutant printed it.
-
After the rebase onto 215's landing: these 4 files again, plus packages/library/tests/mirror.test.mjs, packages/webui/tests/library-cli.test.mjs, packages/webui/tests/pinned-copy.test.mjs and packages/cli/tests/stack-copy.test.mjs.