OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 commits / 677c316

0.1.238: derive the vdrive key from durable state, so losing the drive store is a rebuild
publishTree opened `new Corestore(new Storage(storeDir))` with no primaryKey, so corestore minted a RANDOM seed and the drive key derived from it. The secret existed only inside `<releaseDir>/<repo>/.vdrive`, and nothing recorded how to regenerate it — so losing that directory killed the pear:// link permanently. Upstream `pear touch` has the same shape (a random name nobody records). The primaryKey now derives from the daemon's long-lived corestore plus the repo name: hash(NS, rootPrimaryKey, repo). That makes the link a function of state that already survives — backed up and replicated — rather than of a per-repo scratch store. Losing the drive store becomes a rebuild. Per-machine by design. Two daemons have different roots (tabertron is a linked device, stortron has its own vault), so this does NOT make the link reproducible across the fleet. That is the multisig question, deliberately deferred — see org #145, which now argues the near-term ask is a deliberate key, not multisig, because upstream's multisig is built for multi-party approval and `prologue` gives a migration path with provable continuity when we do want it. Two corestore behaviours are load-bearing and now asserted: - passing a primaryKey at all requires `unsafe: true` (corestore/index.js:268) - corestore REFUSES to open a store whose persisted seed differs, throwing "Another corestore is stored here" (index.js:417) rather than silently rekeying it — which would change a published link without saying so Only that mismatch is converted to a skip, with the store path in the message so the operator can move it aside deliberately. Everything else keeps propagating — in particular the seeder's exclusive store lock, which is TRANSIENT and is what drives the orchestrator's drop/publish/re-announce sequence. My first version caught every error and regressed test/vdrive-seeder.test.js from 7/7 to 6/7 by hiding the lock error; the narrowed catch restores it. Wiring note: `corestore` is NOT a mounted hyper-svc module, so it cannot be reached through `svc.modules`. Reaching for it there would have silently yielded null and published with a random key again — the exact failure this commit removes. Exposed as `svc._vdriveRootKey`, matching _sync/_releases/_vdriveSeeder, and threaded through both call sites (push auto-build and the manual trigger). No legacy compatibility path: the one existing random-keyed drive (ifsrenderer-linux) is not migrated here. Its next publish will report the mismatch and skip, leaving the release unaffected, until its store is moved aside deliberately. Nothing is deleted. Verified: tests written first, red for the right reason (missing export). Now deliberate-key 5/5 (16/16) including "losing the drive store no longer loses the link"; locations-travel 3/3 (13/13); ci 9/9 (23/23); walk-order 4/4 (11/11); seeder 7/7 (32/32). All four daemon modules parse and resolve under bare. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
677c31685bbab9a031b3c25259476d9fc5404282
by plankatron <fleeky@gmail.com> · 2026-08-26T01:04:15.000Z

Changed files (6) +231 -11

lib/ci-orchestrator.js +7 -2
……@@ -24,7 +24,7 @@ import os from '#node:os'
2424import { spawn } from '#node:child_process'
2525
2626import { loadCIConfig, pickChannel, deriveVersion, runSteps, collectArtifacts } from './ci-runner.js'
27import { publishVersionToDrive } from './vdrive-publish.js'
27import { publishVersionToDrive, deriveVdrivePrimaryKey } from './vdrive-publish.js'
2828
2929export async function buildAndPublish ({
3030 sync,
……@@ -35,6 +35,7 @@ export async function buildAndPublish ({
3535 version: forcedVersion,
3636 channel: forcedChannel,
3737 vdriveSeeder,
38 vdriveRootKey = null,
3839 onLog
3940} = {}) {
4041 if (!sync) throw new Error('buildAndPublish requires sync service')
……@@ -141,6 +142,9 @@ export async function buildAndPublish ({
141142 version,
142143 channel,
143144 vdrive: config.vdrive,
145 // Derived from the daemon's long-lived corestore, so losing the drive
146 // store is a rebuild rather than a permanently dead link (#145).
147 primaryKey: vdriveRootKey ? deriveVdrivePrimaryKey(vdriveRootKey, repo) : null,
144148 onLog
145149 })
146150 if (vdrive.skipped) log(onLog, `[ci] vdrive skipped: ${vdrive.reason}`)
……@@ -222,7 +226,7 @@ export async function snapshotRefs (repoDir) {
222226 * Only refs under refs/heads/ (branches) and refs/tags/ (tags) are
223227 * considered — refs/stash, refs/notes, etc. are ignored.
224228 */
225export async function autoBuildFromPush ({ sync, releaseManager, repo, before, after, onLog, pendingBuilds, vdriveSeeder } = {}) {
229export async function autoBuildFromPush ({ sync, releaseManager, repo, before, after, onLog, pendingBuilds, vdriveSeeder, vdriveRootKey = null } = {}) {
226230 if (!sync || !releaseManager) return { triggered: [] }
227231 const triggered = []
228232
……@@ -249,6 +253,7 @@ export async function autoBuildFromPush ({ sync, releaseManager, repo, before, a
249253 sync,
250254 releaseManager,
251255 vdriveSeeder,
256 vdriveRootKey,
252257 repo,
253258 ref: t.ref,
254259 isTag: t.isTag,
lib/handlers/releases.js +1 -0
……@@ -255,6 +255,7 @@ export function releaseHandlers (svc) {
255255 // cannot drop the seeder's exclusive lock on the store, and the new
256256 // drive is never announced. The push path threads this already.
257257 vdriveSeeder: svc._vdriveSeeder,
258 vdriveRootKey: svc._vdriveRootKey || null,
258259 repo,
259260 ref,
260261 isTag: !!isTag,
lib/pear-git.js +8 -0
……@@ -1225,6 +1225,11 @@ export async function startPearGit (opts = {}) {
12251225 // vdrive seeder — announce every repo drive that CI has published, so the
12261226 // pear:// links the build log prints actually resolve. Inert when no repo has
12271227 // opted in (`vdrive:` in ci.yaml): the release dirs simply contain no .vdrive.
1228 // Durable root for deriving each repo's vdrive key. corestore is NOT a mounted
1229 // module, so this cannot be reached via svc.modules — expose it explicitly, the
1230 // same way _sync/_releases/_vdriveSeeder are.
1231 svc._vdriveRootKey = corestore.primaryKey || null
1232
12281233 svc._vdriveSeeder = new VdriveSeeder({
12291234 releaseDir: svc._releases ? svc._releases.releaseDir : null,
12301235 wire: (repo, opened) => svc.wireVdrive(repo, opened),
……@@ -2707,6 +2712,9 @@ export async function startPearGit (opts = {}) {
27072712 const after = new Map([[params.ref, head]])
27082713 await autoBuildFromPush({
27092714 vdriveSeeder: svc._vdriveSeeder,
2715 // Durable root for the drive key: derived per repo, so losing a
2716 // drive store is a rebuild, not a dead pear:// link (#145).
2717 vdriveRootKey: svc._vdriveRootKey,
27102718 sync: svc._sync,
27112719 releaseManager: svc._releases,
27122720 pendingBuilds: svc._pendingBuilds,
lib/vdrive-publish.js +77 -8
……@@ -23,11 +23,53 @@
2323
2424import fsp from '#node:fs/promises'
2525import path from '#node:path'
26import crypto from 'hypercore-crypto'
2627
2728export function vdriveStoreDir (releaseDir, repo) {
2829 return path.join(releaseDir, repo, '.vdrive')
2930}
3031
32// Namespace so this can never collide with another derivation off the same root.
33const VDRIVE_NS = 'pear-git-v3/vdrive/1'
34
35/**
36 * Derive a drive's corestore primaryKey from durable state.
37 *
38 * The drive key derives from the store's primaryKey, and that primaryKey used to
39 * be RANDOM — minted by corestore on first open and persisted only inside
40 * `<releaseDir>/<repo>/.vdrive`. So losing that directory destroyed the pear://
41 * link permanently: nothing recorded the secret and nothing could regenerate it.
42 * (Upstream `pear touch` has the same shape — a random name nobody records.)
43 *
44 * Deriving it instead from the daemon's long-lived corestore primaryKey plus the
45 * repo name makes the link a pure function of state that already survives —
46 * backed up, replicated, and far more durable than a per-repo scratch store.
47 * Losing the drive store becomes a rebuild rather than data loss.
48 *
49 * Per-machine by design. Two daemons have different roots, so this does NOT make
50 * the link reproducible across the fleet — that is the multisig question (#145),
51 * deliberately deferred. What it buys is recoverability, which is the part that
52 * was actually urgent.
53 */
54// corestore's own wording when a store's persisted seed differs from the
55// primaryKey it was handed (corestore/index.js `_open`). Matched on the message
56// because it throws a plain Error with no code.
57function isKeyMismatch (err) {
58 return /another corestore is stored here/i.test((err && err.message) || '')
59}
60
61export function deriveVdrivePrimaryKey (rootPrimaryKey, repo) {
62 if (!rootPrimaryKey || rootPrimaryKey.byteLength !== 32) {
63 throw new Error('deriveVdrivePrimaryKey requires a 32-byte root primaryKey')
64 }
65 if (!repo) throw new Error('deriveVdrivePrimaryKey requires a repo name')
66 return crypto.hash([
67 Buffer.from(VDRIVE_NS),
68 rootPrimaryKey,
69 Buffer.from(repo)
70 ])
71}
72
3173/**
3274 * Record the drive in the version's manifest.json.
3375 *
……@@ -155,6 +197,7 @@ export async function publishVersionToDrive ({
155197 channel,
156198 vdrive,
157199 chunk,
200 primaryKey,
158201 onLog
159202} = {}) {
160203 if (!vdrive || !vdrive.enabled) return { ok: false, skipped: true, reason: 'not enabled' }
……@@ -189,14 +232,40 @@ export async function publishVersionToDrive ({
189232 const names = await walk(versionDir)
190233 if (names.length === 0) return { ok: false, skipped: true, reason: 'no artifacts to publish' }
191234
192 const result = await publishTree({
193 storeDir: vdriveStoreDir(releaseDir, repo),
194 // locations are recorded relative to the repo's release root, so a seeder
195 // can resolve them later without re-chunking anything
196 root: path.join(releaseDir, repo),
197 files: names.map((name) => ({ name, file: path.join(versionDir, name) })),
198 chunk: chunker
199 })
235 let result
236 try {
237 result = await publishTree({
238 storeDir: vdriveStoreDir(releaseDir, repo),
239 // locations are recorded relative to the repo's release root, so a seeder
240 // can resolve them later without re-chunking anything
241 root: path.join(releaseDir, repo),
242 files: names.map((name) => ({ name, file: path.join(versionDir, name) })),
243 chunk: chunker,
244 // `unsafe` is corestore's required acknowledgement for supplying a
245 // primaryKey at all. It is exactly what we want here: the key is the point.
246 corestoreOpts: primaryKey ? { primaryKey, unsafe: true } : undefined
247 })
248 } catch (err) {
249 // ONLY the key-mismatch case becomes a skip. corestore refuses to open a
250 // store whose persisted seed differs from the primaryKey it was handed
251 // ("Another corestore is stored here") rather than silently rekeying it,
252 // which would change the pear:// link without saying so. That is permanent
253 // and needs a deliberate operator action, so it is reported, not thrown.
254 //
255 // Everything else must keep propagating — in particular the seeder's
256 // exclusive store lock ("File descriptor could not be locked"), which is
257 // TRANSIENT and is what drives the orchestrator's drop/publish/re-announce
258 // sequence. Swallowing that into a skip would hide a condition the caller
259 // resolves by retrying. See test/vdrive-seeder.test.js.
260 if (!isKeyMismatch(err)) throw err
261 return {
262 ok: false,
263 skipped: true,
264 reason:
265 'drive store does not match the derived key (' + err.message + '). ' +
266 'Move ' + vdriveStoreDir(releaseDir, repo) + ' aside to mint a new drive.'
267 }
268 }
200269
201270 const annotated = await annotateManifest(releaseDir, repo, version, result)
202271 const locationsCopied = await copyLocations(releaseDir, repo, version)
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "pear-git-v3",
3 "version": "0.1.237",
3 "version": "0.1.238",
44 "description": "P2P git forge — multi-writer issue/org/release/peer-mirror daemon. Built bottom-up after the failed v2.",
55 "type": "module",
66 "main": "index.js",
test/vdrive-deliberate-key.test.js +137 -0
……@@ -0,0 +1,137 @@
1/**
2 * The drive key must be a deliberate input, not a random seed (org #145).
3 *
4 * `publishTree` opened `new Corestore(new Storage(storeDir))` with no primaryKey,
5 * so corestore minted a random seed and the drive key derived from it. Lose
6 * `<releaseDir>/<repo>/.vdrive` and the pear:// link died permanently — the
7 * secret existed only in the directory that was lost, and nothing anywhere
8 * recorded how to regenerate it.
9 *
10 * Deriving the primaryKey from the daemon's long-lived corestore plus the repo
11 * name makes the link a function of state that already survives. Losing the
12 * drive store stops being data loss and becomes a rebuild.
13 *
14 * Note corestore requires `unsafe: true` to accept a primaryKey at all, and
15 * refuses to open an existing store whose stored seed differs ("Another
16 * corestore is stored here"). Both are load-bearing and asserted here.
17 */
18import test from 'brittle'
19import fsp from '#node:fs/promises'
20import path from '#node:path'
21import os from '#node:os'
22import crypto from 'hypercore-crypto'
23
24import {
25 publishVersionToDrive,
26 vdriveStoreDir,
27 deriveVdrivePrimaryKey
28} from '../lib/vdrive-publish.js'
29
30function tmpdir (name) {
31 return fsp.mkdtemp(path.join(os.tmpdir(), 'pgv3-vdkey-' + name + '-'))
32}
33
34const PAYLOAD = Buffer.alloc(256 * 1024, 5)
35
36async function publish (releaseDir, repo, version, primaryKey) {
37 const versionDir = path.join(releaseDir, repo, 'versions', version)
38 await fsp.mkdir(versionDir, { recursive: true })
39 await fsp.writeFile(path.join(versionDir, 'app.bin'), PAYLOAD)
40 await fsp.writeFile(path.join(versionDir, 'notes.txt'), 'same bytes every time')
41 const { chunk } = await import('hyper-overlay/chunker')
42 return publishVersionToDrive({
43 releaseDir,
44 repo,
45 version,
46 channel: 'production',
47 vdrive: { enabled: true, channels: null },
48 primaryKey,
49 chunk
50 })
51}
52
53test('derivation is deterministic and namespaced per repo', (t) => {
54 const root = crypto.randomBytes(32)
55 const other = crypto.randomBytes(32)
56
57 const a = deriveVdrivePrimaryKey(root, 'demo')
58 const b = deriveVdrivePrimaryKey(root, 'demo')
59 t.alike(a, b, 'same inputs, same key')
60 t.is(a.byteLength, 32, 'and it is a 32-byte primaryKey')
61
62 t.unlike(deriveVdrivePrimaryKey(root, 'other'), a, 'a different repo differs')
63 t.unlike(deriveVdrivePrimaryKey(other, 'demo'), a, 'a different root differs')
64})
65
66test('losing the drive store no longer loses the link', async (t) => {
67 // The whole point. Publish, destroy the drive store, rebuild from the same
68 // deliberate key, and the pear:// link must come back identical.
69 const releaseDir = await tmpdir('recover')
70 const repo = 'demo'
71 const primaryKey = deriveVdrivePrimaryKey(crypto.randomBytes(32), repo)
72
73 const first = await publish(releaseDir, repo, '1.0.0', primaryKey)
74 t.is(first.ok, true, 'published')
75 t.ok(first.link.startsWith('pear://'), 'has a link')
76
77 // simulate losing the store (a temp dir this test created, not user state)
78 await fsp.rm(vdriveStoreDir(releaseDir, repo), { recursive: true, force: true })
79
80 const again = await publish(releaseDir, repo, '1.0.0', primaryKey)
81 t.is(again.ok, true, 'republished after the store was gone')
82 t.is(again.link, first.link, 'and the link is the SAME')
83
84 await fsp.rm(releaseDir, { recursive: true, force: true })
85})
86
87test('the same key and content reproduce the link on another machine', async (t) => {
88 // Two independent release dirs stand in for two machines holding the same
89 // artifacts. Same deliberate key + same bytes => same link.
90 const repo = 'demo'
91 const primaryKey = deriveVdrivePrimaryKey(crypto.randomBytes(32), repo)
92
93 const one = await tmpdir('m1')
94 const two = await tmpdir('m2')
95
96 const a = await publish(one, repo, '1.0.0', primaryKey)
97 const b = await publish(two, repo, '1.0.0', primaryKey)
98
99 t.is(a.ok && b.ok, true, 'both published')
100 t.is(b.link, a.link, 'identical link from an independent build')
101
102 await fsp.rm(one, { recursive: true, force: true })
103 await fsp.rm(two, { recursive: true, force: true })
104})
105
106test('a different key yields a different drive', async (t) => {
107 const repo = 'demo'
108 const one = await tmpdir('k1')
109 const two = await tmpdir('k2')
110
111 const a = await publish(one, repo, '1.0.0', deriveVdrivePrimaryKey(crypto.randomBytes(32), repo))
112 const b = await publish(two, repo, '1.0.0', deriveVdrivePrimaryKey(crypto.randomBytes(32), repo))
113
114 t.is(a.ok && b.ok, true, 'both published')
115 t.unlike(b.link, a.link, 'different keys, different links')
116
117 await fsp.rm(one, { recursive: true, force: true })
118 await fsp.rm(two, { recursive: true, force: true })
119})
120
121test('a store minted under a different key is refused, not silently rekeyed', async (t) => {
122 // corestore throws "Another corestore is stored here" rather than adopting the
123 // new key. Publishing must surface that as a clear, actionable skip instead of
124 // taking the release down with it.
125 const releaseDir = await tmpdir('mismatch')
126 const repo = 'demo'
127
128 const first = await publish(releaseDir, repo, '1.0.0', deriveVdrivePrimaryKey(crypto.randomBytes(32), repo))
129 t.is(first.ok, true, 'first publish created the store')
130
131 const second = await publish(releaseDir, repo, '2.0.0', deriveVdrivePrimaryKey(crypto.randomBytes(32), repo))
132 t.is(second.ok, false, 'a mismatched key does not silently rekey the drive')
133 t.ok(second.skipped, 'it is reported as skipped')
134 t.ok(/another corestore|stored here|key/i.test(second.reason || ''), 'with a reason naming the cause: ' + second.reason)
135
136 await fsp.rm(releaseDir, { recursive: true, force: true })
137})