run402 4.68.3 → 4.69.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli.mjs +2 -2
- package/core-dist/allowance-auth.js +1 -1
- package/core-dist/allowance.js +1 -1
- package/core-dist/config.js +4 -4
- package/core-dist/keystore.js +7 -7
- package/core-dist/profiles.js +1 -1
- package/core-dist/redact.js +6 -7
- package/git-remote-run402.mjs +2 -2
- package/gitvault-surface.json +2 -1
- package/lib/agent.mjs +1 -1
- package/lib/allowance.mjs +3 -4
- package/lib/argparse.mjs +7 -9
- package/lib/billing.mjs +33 -35
- package/lib/buzz-notifications.mjs +3 -2
- package/lib/buzz.mjs +14 -8
- package/lib/buzz.test.mjs +1 -1
- package/lib/command-manifest.mjs +38 -20
- package/lib/config.mjs +1 -1
- package/lib/deploy-v2.mjs +3 -3
- package/lib/doctor-source-scan.mjs +1 -1
- package/lib/doctor.mjs +42 -25
- package/lib/email.mjs +2 -3
- package/lib/harness-context.mjs +1 -2
- package/lib/init.mjs +1 -1
- package/lib/next-actions.mjs +1 -1
- package/lib/org-context.mjs +83 -2
- package/lib/org.mjs +231 -114
- package/lib/projects.mjs +2 -2
- package/lib/remote-helper-session.mjs +26 -32
- package/lib/repos.mjs +117 -15
- package/lib/rooms-context.mjs +5 -5
- package/lib/sdk-errors.mjs +1 -2
- package/lib/status.mjs +1 -1
- package/lib/update-check.test.mjs +1 -1
- package/lib/wallet-context.mjs +10 -10
- package/lib/wallet-context.test.mjs +6 -7
- package/lib/wallets.mjs +2 -2
- package/lib/webhooks.mjs +2 -2
- package/package.json +1 -1
- package/sdk/core-dist/allowance-auth.js +1 -1
- package/sdk/core-dist/allowance.js +1 -1
- package/sdk/core-dist/config.js +4 -4
- package/sdk/core-dist/keystore.js +7 -7
- package/sdk/core-dist/profiles.js +1 -1
- package/sdk/core-dist/redact.js +6 -7
- package/sdk/dist/delegate-credentials.d.ts +1 -1
- package/sdk/dist/delegate-credentials.js +1 -1
- package/sdk/dist/errors.d.ts +10 -4
- package/sdk/dist/errors.d.ts.map +1 -1
- package/sdk/dist/errors.js +2 -3
- package/sdk/dist/errors.js.map +1 -1
- package/sdk/dist/kernel.js +1 -1
- package/sdk/dist/kernel.js.map +1 -1
- package/sdk/dist/namespaces/allowance.d.ts +1 -1
- package/sdk/dist/namespaces/allowance.js +1 -1
- package/sdk/dist/namespaces/allowance.js.map +1 -1
- package/sdk/dist/namespaces/assets.js +1 -1
- package/sdk/dist/namespaces/assets.js.map +1 -1
- package/sdk/dist/namespaces/assets.types.d.ts +1 -1
- package/sdk/dist/namespaces/ci.d.ts.map +1 -1
- package/sdk/dist/namespaces/ci.js +4 -6
- package/sdk/dist/namespaces/ci.js.map +1 -1
- package/sdk/dist/namespaces/deploy.js +7 -8
- package/sdk/dist/namespaces/deploy.js.map +1 -1
- package/sdk/dist/namespaces/gitvault.crypto.d.ts +86 -3
- package/sdk/dist/namespaces/gitvault.crypto.d.ts.map +1 -1
- package/sdk/dist/namespaces/gitvault.crypto.js +102 -3
- package/sdk/dist/namespaces/gitvault.crypto.js.map +1 -1
- package/sdk/dist/namespaces/gitvault.d.ts +197 -17
- package/sdk/dist/namespaces/gitvault.d.ts.map +1 -1
- package/sdk/dist/namespaces/gitvault.js +698 -58
- package/sdk/dist/namespaces/gitvault.js.map +1 -1
- package/sdk/dist/namespaces/gitvault.types.d.ts +91 -3
- package/sdk/dist/namespaces/gitvault.types.d.ts.map +1 -1
- package/sdk/dist/namespaces/gitvault.types.js.map +1 -1
- package/sdk/dist/namespaces/operator.d.ts +1 -1
- package/sdk/dist/namespaces/operator.js +1 -1
- package/sdk/dist/namespaces/org.d.ts +14 -1
- package/sdk/dist/namespaces/org.d.ts.map +1 -1
- package/sdk/dist/namespaces/org.js +15 -0
- package/sdk/dist/namespaces/org.js.map +1 -1
- package/sdk/dist/namespaces/org.types.d.ts +30 -0
- package/sdk/dist/namespaces/org.types.d.ts.map +1 -1
- package/sdk/dist/namespaces/projects.types.d.ts +1 -1
- package/sdk/dist/namespaces/subdomains.js +1 -1
- package/sdk/dist/namespaces/subdomains.js.map +1 -1
- package/sdk/dist/namespaces/transfers.d.ts +3 -4
- package/sdk/dist/namespaces/transfers.d.ts.map +1 -1
- package/sdk/dist/namespaces/transfers.js +1 -2
- package/sdk/dist/namespaces/transfers.js.map +1 -1
- package/sdk/dist/node/actions-node.d.ts.map +1 -1
- package/sdk/dist/node/actions-node.js +3 -4
- package/sdk/dist/node/actions-node.js.map +1 -1
- package/sdk/dist/node/deploy-manifest.js +2 -2
- package/sdk/dist/node/deploy-manifest.js.map +1 -1
- package/sdk/dist/node/gitvault-address.js +2 -2
- package/sdk/dist/node/gitvault-address.js.map +1 -1
- package/sdk/dist/node/gitvault-creation-journal.d.ts +1 -1
- package/sdk/dist/node/gitvault-creation-journal.js +2 -2
- package/sdk/dist/node/gitvault-creation-journal.js.map +1 -1
- package/sdk/dist/node/gitvault-deploy.d.ts +2 -2
- package/sdk/dist/node/gitvault-deploy.js +2 -2
- package/sdk/dist/node/gitvault-handoff.d.ts +140 -1
- package/sdk/dist/node/gitvault-handoff.d.ts.map +1 -1
- package/sdk/dist/node/gitvault-handoff.js +226 -22
- package/sdk/dist/node/gitvault-handoff.js.map +1 -1
- package/sdk/dist/node/gitvault-keystore.d.ts +78 -3
- package/sdk/dist/node/gitvault-keystore.d.ts.map +1 -1
- package/sdk/dist/node/gitvault-keystore.js +46 -5
- package/sdk/dist/node/gitvault-keystore.js.map +1 -1
- package/sdk/dist/node/gitvault-mirror.d.ts.map +1 -1
- package/sdk/dist/node/gitvault-mirror.js +3 -5
- package/sdk/dist/node/gitvault-mirror.js.map +1 -1
- package/sdk/dist/node/gitvault-profile-scan.js +1 -1
- package/sdk/dist/node/gitvault-publication.d.ts +379 -21
- package/sdk/dist/node/gitvault-publication.d.ts.map +1 -1
- package/sdk/dist/node/gitvault-publication.js +664 -41
- package/sdk/dist/node/gitvault-publication.js.map +1 -1
- package/sdk/dist/node/gitvault-recover.d.ts.map +1 -1
- package/sdk/dist/node/gitvault-recover.js +7 -10
- package/sdk/dist/node/gitvault-recover.js.map +1 -1
- package/sdk/dist/node/gitvault-restore.d.ts.map +1 -1
- package/sdk/dist/node/gitvault-restore.js +5 -2
- package/sdk/dist/node/gitvault-restore.js.map +1 -1
- package/sdk/dist/node/gitvault-snapshot.d.ts +16 -2
- package/sdk/dist/node/gitvault-snapshot.d.ts.map +1 -1
- package/sdk/dist/node/gitvault-snapshot.js +23 -2
- package/sdk/dist/node/gitvault-snapshot.js.map +1 -1
- package/sdk/dist/node/gitvault-writer-state.d.ts +201 -0
- package/sdk/dist/node/gitvault-writer-state.d.ts.map +1 -0
- package/sdk/dist/node/gitvault-writer-state.js +357 -0
- package/sdk/dist/node/gitvault-writer-state.js.map +1 -0
- package/sdk/dist/node/index.d.ts +5 -1
- package/sdk/dist/node/index.d.ts.map +1 -1
- package/sdk/dist/node/index.js +21 -2
- package/sdk/dist/node/index.js.map +1 -1
- package/sdk/dist/node/paid-fetch.d.ts.map +1 -1
- package/sdk/dist/node/paid-fetch.js +3 -4
- package/sdk/dist/node/paid-fetch.js.map +1 -1
- package/sdk/dist/node/sites-node.js +1 -1
- package/sdk/dist/node/sites-node.js.map +1 -1
|
@@ -28,8 +28,31 @@
|
|
|
28
28
|
* docs/agent-response-design.md a secret-bearing response is never cached,
|
|
29
29
|
* never persisted into an agent-surface result store, and never logged.
|
|
30
30
|
*/
|
|
31
|
-
import { LocalError, isRun402Error } from "../errors.js";
|
|
32
|
-
import { GITVAULT_BYO_UNMIRRORED_REMEDY_STATEMENT, GITVAULT_DEGRADED_READ_STATEMENT, GITVAULT_DURABILITY_STATEMENT, GITVAULT_MIRROR_KEYSTORE_STILL_REQUIRED_STATEMENT, GITVAULT_MIRROR_VALIDITY_NOT_FRESHNESS_STATEMENT, GITVAULT_TERMINAL_LOSS_DOCTOR_TEXT, GITVAULT_TERMINAL_LOSS_STATEMENT, GITVAULT_UNMIRRORED_FINDING_STATEMENT, bytesToHex, hexToBytes, parseGitvaultStrict, randomBytes, sha256Hex, verifyGitvaultObject, toBase64url, } from "./gitvault.crypto.js";
|
|
31
|
+
import { LocalError, isRun402Error, isNetworkError } from "../errors.js";
|
|
32
|
+
import { GITVAULT_BYO_UNMIRRORED_REMEDY_STATEMENT, GITVAULT_DEGRADED_READ_STATEMENT, GITVAULT_DURABILITY_STATEMENT, GITVAULT_MIRROR_KEYSTORE_STILL_REQUIRED_STATEMENT, GITVAULT_MIRROR_VALIDITY_NOT_FRESHNESS_STATEMENT, GITVAULT_TERMINAL_LOSS_DOCTOR_TEXT, GITVAULT_TERMINAL_LOSS_STATEMENT, GITVAULT_UNMIRRORED_FINDING_STATEMENT, bytesToHex, computeKeystorePossessionProof, computeSigningKeyPossessionSignature, ed25519PublicKey, fromBase64url, hexToBytes, jcs, parseGitvaultStrict, randomBytes, sha256Hex, verifyGitvaultObject, toBase64url, } from "./gitvault.crypto.js";
|
|
33
|
+
/**
|
|
34
|
+
* gitvault-agent-envelopes — per-process memo for the session-start
|
|
35
|
+
* fulfilment (`<keystore root>|<repo_id>`) and the enroll-if-absent step
|
|
36
|
+
* (`<keystore root>`), so a process that opens the same vault many times
|
|
37
|
+
* (a `git push` runs several verbs) reconciles and enrolls once.
|
|
38
|
+
*/
|
|
39
|
+
const SESSION_RECONCILED = new Set();
|
|
40
|
+
/**
|
|
41
|
+
* Round 3 blocker 2: integrity verdicts must PROPAGATE through every
|
|
42
|
+
* best-effort reconcile boundary — converting tampering evidence into
|
|
43
|
+
* `skipped_error` at the outer catch is exactly the swallow the inner
|
|
44
|
+
* verifier exists to prevent. Availability and local-policy failures stay
|
|
45
|
+
* best-effort.
|
|
46
|
+
*/
|
|
47
|
+
const RECONCILE_FATAL_CODES = new Set([
|
|
48
|
+
"GITVAULT_ENVELOPE_ALTERED",
|
|
49
|
+
"GITVAULT_SIGNATURE_INVALID",
|
|
50
|
+
"VAULT_CREATION_CONFLICT",
|
|
51
|
+
]);
|
|
52
|
+
function rethrowFatalReconcile(e) {
|
|
53
|
+
if (isRun402Error(e) && RECONCILE_FATAL_CODES.has(String(e.code)))
|
|
54
|
+
throw e;
|
|
55
|
+
}
|
|
33
56
|
/** A keystore path, or `null` when there is no id to derive it from (or it is malformed). */
|
|
34
57
|
function safePath(derive, repoId) {
|
|
35
58
|
if (!repoId)
|
|
@@ -419,6 +442,47 @@ export class Gitvault {
|
|
|
419
442
|
method: "POST", body: input, context: "re-pinning a gitvault recipient",
|
|
420
443
|
});
|
|
421
444
|
}
|
|
445
|
+
/**
|
|
446
|
+
* gitvault-agent-envelopes D3 — a KEY-HOLDER explicitly accepts a recipient's
|
|
447
|
+
* CHANGED key. The session-start reconcile refuses to wrap under a
|
|
448
|
+
* fingerprint that differs from this keystore's TOFU pin
|
|
449
|
+
* (`pinned_key_mismatch`) — that refusal is the substitution defence and
|
|
450
|
+
* is never bypassed automatically, not even after an owner's revoke: from
|
|
451
|
+
* this machine's view an owner-driven re-key and a platform substitution
|
|
452
|
+
* look identical, and the audit event is not proof against the platform.
|
|
453
|
+
* Acceptance is a deliberate act naming the new fingerprint (the
|
|
454
|
+
* out-of-band verification point — read it back with the recipient over any
|
|
455
|
+
* channel; it is public data): this records the D197 re-pin receipt with the
|
|
456
|
+
* gateway AND moves the local pin, so the next reconcile wraps. Refuses when
|
|
457
|
+
* `new_fingerprint` is not what the org directory currently serves for the
|
|
458
|
+
* principal (a stale or mistyped fingerprint never pins).
|
|
459
|
+
*/
|
|
460
|
+
async acceptRecipientKeyChange(options) {
|
|
461
|
+
const repoId = await this.#resolveRepoId(options);
|
|
462
|
+
const { GitvaultKeystore } = await this.#keystore();
|
|
463
|
+
const keystore = new GitvaultKeystore(options.keystore_root !== undefined ? { rootDir: options.keystore_root } : {});
|
|
464
|
+
const repo = keystore.readRepo(repoId);
|
|
465
|
+
if (!repo)
|
|
466
|
+
throw new LocalError("this keystore holds no repo state for the vault — only a key-holder can accept a recipient's key change", "accepting a recipient key change", { code: "GITVAULT_REPO_STATE_MISSING", details: { repo_id: repoId } });
|
|
467
|
+
const record = await this.get(repoId);
|
|
468
|
+
const directory = await this.#client.request(`/orgs/v1/${encodeURIComponent(record.org_id)}/encryption-keys`, { context: "reading the org encryption-key directory" });
|
|
469
|
+
const served = directory.keys.find((k) => k.principal_id === options.principal_id)?.ek_fingerprint ?? null;
|
|
470
|
+
if (served !== options.new_fingerprint) {
|
|
471
|
+
throw new LocalError(`the org directory currently serves ${served ?? "no key"} for principal ${options.principal_id}, not ${options.new_fingerprint} — refusing to pin a fingerprint the directory does not vouch for`, "accepting a recipient key change", { code: "PIN_CHANGE_UNCONFIRMED", details: { repo_id: repoId, principal_id: options.principal_id, directory_fingerprint: served, requested_fingerprint: options.new_fingerprint } });
|
|
472
|
+
}
|
|
473
|
+
const pins = { ...(repo.envelope_recipient_pins ?? {}) };
|
|
474
|
+
const old = pins[options.principal_id] ?? null;
|
|
475
|
+
let receipt = null;
|
|
476
|
+
if (old && old !== options.new_fingerprint) {
|
|
477
|
+
receipt = await this.repinRecipient(repoId, options.principal_id, { old_ek_fingerprint: old, new_fingerprint: options.new_fingerprint }).catch(() => null);
|
|
478
|
+
}
|
|
479
|
+
else if (!old) {
|
|
480
|
+
receipt = await this.confirmRecipient(repoId, options.principal_id, options.new_fingerprint).catch(() => null);
|
|
481
|
+
}
|
|
482
|
+
pins[options.principal_id] = options.new_fingerprint;
|
|
483
|
+
keystore.updateRepo(repoId, { envelope_recipient_pins: pins });
|
|
484
|
+
return { repo_id: repoId, principal_id: options.principal_id, old_fingerprint: old, new_fingerprint: options.new_fingerprint, receipt };
|
|
485
|
+
}
|
|
422
486
|
/**
|
|
423
487
|
* `POST …/recipients/:principal_id/key-revocation` (D199) — declares
|
|
424
488
|
* `reason:"recipient_key_revoked"` admissible for the NEXT rotation this
|
|
@@ -504,6 +568,10 @@ export class Gitvault {
|
|
|
504
568
|
const transport = createGitvaultHttpTransport(this.#client, {
|
|
505
569
|
onObjectStoreOriginObserved: (rid, origins) => keystore.recordObjectStoreOrigins(rid, origins),
|
|
506
570
|
});
|
|
571
|
+
// gitvault-agent-envelopes D3: enroll the keystore's key if this principal
|
|
572
|
+
// has none (publish + possession proof, one round trip each); a differing
|
|
573
|
+
// current key REFUSES here — rotation is never automatic.
|
|
574
|
+
const enrollment = await this.#ensureEnrolled(keystore);
|
|
507
575
|
const vault = new GitvaultVault({
|
|
508
576
|
keystore,
|
|
509
577
|
transport,
|
|
@@ -512,7 +580,245 @@ export class Gitvault {
|
|
|
512
580
|
...(options.verification_budget !== undefined ? { verification_budget: options.verification_budget } : {}),
|
|
513
581
|
...(options.service_public_key !== undefined ? { service_public_key: options.service_public_key } : {}),
|
|
514
582
|
});
|
|
515
|
-
|
|
583
|
+
// D4: a cold keystore restores from its own envelope (or fails
|
|
584
|
+
// GITVAULT_ENVELOPE_PENDING with the exact next actions). The lazy
|
|
585
|
+
// fallback is an ALLOWLIST of typed transport/absence states, not a
|
|
586
|
+
// denylist of known verdicts (consult round 2 §5): a network fault, an
|
|
587
|
+
// older gateway missing the route (404/501), or a vault with no admitted
|
|
588
|
+
// genesis yet (CHAIN_BROKEN — not born, nothing to restore or disclose)
|
|
589
|
+
// leave the handle lazy so the verb fails exactly where it always did
|
|
590
|
+
// (`GITVAULT_REPO_STATE_MISSING` at first use). EVERYTHING else —
|
|
591
|
+
// integrity failures, parse failures, programming errors — propagates.
|
|
592
|
+
// A keystore with NO identity has nothing to restore and nothing that
|
|
593
|
+
// could be disclosed — stay lazy and let the verb hit the ordinary
|
|
594
|
+
// KEYSTORE_MISSING wall (with its cross-profile hint) exactly where it
|
|
595
|
+
// always did, AFTER the caller's own address-pin writes.
|
|
596
|
+
let restored = null;
|
|
597
|
+
try {
|
|
598
|
+
restored = keystore.readIdentity() ? await vault.ensureRepoState() : null;
|
|
599
|
+
}
|
|
600
|
+
catch (e) {
|
|
601
|
+
const code = isRun402Error(e) ? e.code : undefined;
|
|
602
|
+
const status = isRun402Error(e) ? (e.status ?? null) : null;
|
|
603
|
+
const transportAbsence = isNetworkError(e) || status === 404 || status === 501;
|
|
604
|
+
if (code === "CHAIN_BROKEN" || transportAbsence) {
|
|
605
|
+
restored = null;
|
|
606
|
+
}
|
|
607
|
+
else {
|
|
608
|
+
throw e;
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
// D5: a key-holder fulfils pending recipients once per process per vault.
|
|
612
|
+
// A just-restored keystore holds K_repo too, but it just LEARNED it from
|
|
613
|
+
// someone else's wrap — it is not the party that owes fulfilment on this
|
|
614
|
+
// open, and wrapping from a first-contact restore before verifying the
|
|
615
|
+
// chain would be premature; the next open in this process does it.
|
|
616
|
+
const policy = options.reconcile ?? "auto";
|
|
617
|
+
// Consult round 2 §4: reconcile WRAPS K_repo — a disclosure — so it runs
|
|
618
|
+
// only when this open VERIFIED custody continuity against the gateway
|
|
619
|
+
// (whoami answered and the current key is this keystore's). A transport
|
|
620
|
+
// fault or unresolved principal is never fail-open into a wrap.
|
|
621
|
+
// `signing_republished` (rev 47) belongs in this allowlist too: it is
|
|
622
|
+
// reached only when the ENCRYPTION half was already active (continuity
|
|
623
|
+
// already held) and a fresh, gateway-verified Ed25519 possession
|
|
624
|
+
// signature was just proven for the signing half — strictly MORE
|
|
625
|
+
// evidence of custody than `already_active` alone, not less.
|
|
626
|
+
const custodyVerified = enrollment.outcome === "already_active" || enrollment.outcome === "enrolled" || enrollment.outcome === "activated_pending" || enrollment.outcome === "signing_republished";
|
|
627
|
+
// Scoped to what was actually verified: keystore + vault + the exact
|
|
628
|
+
// fingerprint whose continuity this open proved. Marked ONLY after a
|
|
629
|
+
// successful reconcile — a transient failure stays retryable, and a
|
|
630
|
+
// `deferred` policy never consumes the marker.
|
|
631
|
+
const sessionKey = `${keystore.rootDir}|${repoId}|${enrollment.ek_fingerprint ?? "none"}`;
|
|
632
|
+
let reconcileRecipients = null;
|
|
633
|
+
if (policy === "forbidden") {
|
|
634
|
+
reconcileRecipients = { attempted: false, outcome: "forbidden" };
|
|
635
|
+
}
|
|
636
|
+
else if (!restored && !custodyVerified) {
|
|
637
|
+
reconcileRecipients = { attempted: false, outcome: "custody_unverified", ...(enrollment.error !== undefined ? { error: enrollment.error } : {}) };
|
|
638
|
+
}
|
|
639
|
+
else if (!restored && !SESSION_RECONCILED.has(sessionKey)) {
|
|
640
|
+
if (policy === "deferred") {
|
|
641
|
+
let pendingCount = null;
|
|
642
|
+
try {
|
|
643
|
+
const coverage = await transport.listEnvelopeRecipients({ repo_id: repoId });
|
|
644
|
+
const covered = new Set(coverage.recipient_fingerprints);
|
|
645
|
+
pendingCount = coverage.desired ? coverage.desired.filter((d) => d.status === "active" && d.ek_fingerprint && !covered.has(d.ek_fingerprint)).length : null;
|
|
646
|
+
}
|
|
647
|
+
catch {
|
|
648
|
+
pendingCount = null;
|
|
649
|
+
}
|
|
650
|
+
reconcileRecipients = { attempted: false, outcome: "deferred_by_local_policy", pending_count: pendingCount };
|
|
651
|
+
}
|
|
652
|
+
else {
|
|
653
|
+
try {
|
|
654
|
+
const result = await vault.reconcileEnvelopeRecipients();
|
|
655
|
+
reconcileRecipients = { attempted: true, outcome: "reconciled", result };
|
|
656
|
+
SESSION_RECONCILED.add(sessionKey);
|
|
657
|
+
}
|
|
658
|
+
catch (e) {
|
|
659
|
+
rethrowFatalReconcile(e);
|
|
660
|
+
reconcileRecipients = { attempted: true, outcome: "skipped_error", error: e instanceof Error ? e.message : String(e) };
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
// gitvault-multi-writer (task 5.7) — the SAME "session-start"/"read"
|
|
665
|
+
// wiring point as the envelope reconcile above, but a genuinely simpler
|
|
666
|
+
// policy: no disclosure risk, so only `forbidden` is honored — no
|
|
667
|
+
// custody gate, no `deferred`/memoization branch.
|
|
668
|
+
const writerReconcile = policy === "forbidden" ? { attempted: false, outcome: "forbidden" } : await this.#tryReconcileWriterAdmissions(vault);
|
|
669
|
+
return { repo_id: repoId, keystore, transport, vault, restored, reconcile_recipients: reconcileRecipients, writer_reconcile: writerReconcile, enrollment };
|
|
670
|
+
}
|
|
671
|
+
/**
|
|
672
|
+
* gitvault-agent-envelopes D3 — enroll-if-absent, checked FRESH on every
|
|
673
|
+
* open (consult round 2 §4: memoizing this decision let a long-lived
|
|
674
|
+
* process keep trusting a key an owner had since revoked). Reads
|
|
675
|
+
* `GET /agent/v1/whoami` (the gateway folds the principal's current key
|
|
676
|
+
* metadata in), then:
|
|
677
|
+
* - no current key → publish the keystore's X25519 key as
|
|
678
|
+
* `custody_scheme: keystore_v1`, answer the ECDH possession challenge,
|
|
679
|
+
* activate;
|
|
680
|
+
* - current key = this keystore's key, still `pending` → finish the
|
|
681
|
+
* activation (a crashed earlier enrollment);
|
|
682
|
+
* - current key = this keystore's key, `active` → no-op;
|
|
683
|
+
* - current key ≠ this keystore's key → `GITVAULT_KEY_ROTATION_REQUIRED`.
|
|
684
|
+
* Never published as a rotation: a stolen wallet key must not silently
|
|
685
|
+
* replace a member's decryption identity. The remedies are the
|
|
686
|
+
* gateway's — restore the keystore backup; an org owner revokes the
|
|
687
|
+
* stale key; or, for a sole-member principal, `replace_current`.
|
|
688
|
+
* A credential that resolves to no enrolling principal (a service key)
|
|
689
|
+
* skips enrollment — reads keep working; nothing pretends to be enrolled.
|
|
690
|
+
*/
|
|
691
|
+
async #ensureEnrolled(keystore) {
|
|
692
|
+
let identity = keystore.readIdentity();
|
|
693
|
+
let whoami;
|
|
694
|
+
try {
|
|
695
|
+
whoami = await this.#client.request("/agent/v1/whoami", { context: "resolving the enrolling principal" });
|
|
696
|
+
}
|
|
697
|
+
catch (e) {
|
|
698
|
+
// An older gateway, a credential whoami refuses, or a transport fault:
|
|
699
|
+
// enrollment is best-effort on the way IN — the verb itself still runs
|
|
700
|
+
// (and a cold open will say `GITVAULT_ENVELOPE_PENDING` honestly if it
|
|
701
|
+
// needed an envelope this principal never enrolled for). A keystore
|
|
702
|
+
// with no identity reaches the ordinary KEYSTORE_MISSING wall unchanged.
|
|
703
|
+
return { outcome: identity ? "skipped_error" : "skipped_no_identity", ek_fingerprint: identity?.encryption_fingerprint ?? null, signing_fingerprint: identity?.signing_fingerprint ?? null, error: e instanceof Error ? e.message : String(e) };
|
|
704
|
+
}
|
|
705
|
+
if (!whoami.principal) {
|
|
706
|
+
return { outcome: "skipped_no_principal", ek_fingerprint: identity?.encryption_fingerprint ?? null, signing_fingerprint: identity?.signing_fingerprint ?? null };
|
|
707
|
+
}
|
|
708
|
+
if (whoami.principal.type !== "human" && whoami.principal.type !== "agent") {
|
|
709
|
+
// Only custody-eligible principal types enroll (consult round 2 §4):
|
|
710
|
+
// the gateway would refuse the publish anyway, but by then an orphan
|
|
711
|
+
// identity would already exist on disk.
|
|
712
|
+
return { outcome: "skipped_not_enrollable", ek_fingerprint: identity?.encryption_fingerprint ?? null, signing_fingerprint: identity?.signing_fingerprint ?? null };
|
|
713
|
+
}
|
|
714
|
+
if (identity?.enrolled_principal_id && identity.enrolled_principal_id !== whoami.principal.id) {
|
|
715
|
+
throw new LocalError(`this keystore identity enrolled as principal ${identity.enrolled_principal_id}, but the active credential resolves to ${whoami.principal.id} — refusing cross-profile reuse of a custody identity`, "enrolling the keystore encryption key", { code: "GITVAULT_IDENTITY_PROFILE_MISMATCH", details: { enrolled_principal_id: identity.enrolled_principal_id, principal_id: whoami.principal.id, keystore_root: keystore.rootDir } });
|
|
716
|
+
}
|
|
717
|
+
const current = whoami.encryption_key ?? null;
|
|
718
|
+
// A member joining from a FRESH machine has no keystore identity yet.
|
|
719
|
+
// Mint one only now — a real gateway confirmed an enrollable principal
|
|
720
|
+
// with no published key, so this identity is about to become that
|
|
721
|
+
// principal's key. (Vault creation mints its own via the six-stage
|
|
722
|
+
// journal; nothing else ever mints.) A principal that already HAS a
|
|
723
|
+
// published key but no local identity lost its keystore — that is the
|
|
724
|
+
// rotation case below, never a fresh mint.
|
|
725
|
+
if (!identity && !current)
|
|
726
|
+
identity = keystore.ensureIdentity();
|
|
727
|
+
if (!identity) {
|
|
728
|
+
throw new LocalError(`this principal has a published encryption key (${current.ek_fingerprint}) but this keystore holds no identity — the keystore that enrolled it is not this one; rotation is never automatic`, "enrolling the keystore encryption key", {
|
|
729
|
+
code: "GITVAULT_KEY_ROTATION_REQUIRED",
|
|
730
|
+
details: { current_ek_fingerprint: current.ek_fingerprint, current_state: current.state, local_ek_fingerprint: null, keystore_root: keystore.rootDir },
|
|
731
|
+
next_actions: [
|
|
732
|
+
{ type: "edit_request", why: "restore the keystore backup that holds the current key (~/.config/run402/gitvault or the wallet profile's gitvault dir)" },
|
|
733
|
+
{ type: "edit_request", why: "have an org owner revoke the stale key: DELETE /orgs/v1/:org_id/members/:principal_id/encryption-key (owner + step-up); your next gitvault operation then enrolls a fresh key" },
|
|
734
|
+
{ type: "edit_request", why: "if this principal is the sole custody-eligible member of every org it belongs to: POST /agent/v1/whoami/encryption-key with replace_current: true" },
|
|
735
|
+
],
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
if (current && current.ek_fingerprint !== identity.encryption_fingerprint) {
|
|
739
|
+
throw new LocalError(`this principal's published encryption key (${current.ek_fingerprint}) is not this keystore's key (${identity.encryption_fingerprint}); rotation is never automatic`, "enrolling the keystore encryption key", {
|
|
740
|
+
code: "GITVAULT_KEY_ROTATION_REQUIRED",
|
|
741
|
+
details: { current_ek_fingerprint: current.ek_fingerprint, current_state: current.state, local_ek_fingerprint: identity.encryption_fingerprint, keystore_root: keystore.rootDir },
|
|
742
|
+
next_actions: [
|
|
743
|
+
{ type: "edit_request", why: "restore the keystore backup that holds the current key (~/.config/run402/gitvault or the wallet profile's gitvault dir)" },
|
|
744
|
+
{ type: "edit_request", why: "have an org owner revoke the stale key: DELETE /orgs/v1/:org_id/members/:principal_id/encryption-key (owner + step-up); your next gitvault operation then enrolls this key" },
|
|
745
|
+
{ type: "edit_request", why: "if this principal is the sole custody-eligible member of every org it belongs to: POST /agent/v1/whoami/encryption-key with replace_current: true" },
|
|
746
|
+
],
|
|
747
|
+
});
|
|
748
|
+
}
|
|
749
|
+
// gitvault-multi-writer rev 47 (task 5.3, design D9) — the signing half's
|
|
750
|
+
// OWN continuity check, parallel to the encryption half's: does the
|
|
751
|
+
// directory's currently-published signing_fingerprint already equal
|
|
752
|
+
// this keystore's? UNLIKE the encryption half this is never a rotation
|
|
753
|
+
// refusal (D9: the signing half is always freely republishable) — a
|
|
754
|
+
// mismatch just means the publish call below is still needed, even
|
|
755
|
+
// when the encryption half is already fully active.
|
|
756
|
+
const needsSigningPublish = identity.signing_fingerprint !== (current?.signing_fingerprint ?? null);
|
|
757
|
+
if (current && current.state === "active" && !needsSigningPublish) {
|
|
758
|
+
keystore.bindIdentityPrincipal(whoami.principal.id);
|
|
759
|
+
return { outcome: "already_active", ek_fingerprint: current.ek_fingerprint, signing_fingerprint: identity.signing_fingerprint };
|
|
760
|
+
}
|
|
761
|
+
const keypair = keystore.encryptionKeypair(identity);
|
|
762
|
+
if (!keypair) {
|
|
763
|
+
throw new LocalError("the keystore identity has no X25519 private key — it cannot enroll or open envelopes", "enrolling the keystore encryption key", { code: "VAULT_UNRECOVERABLE", details: { statement: GITVAULT_TERMINAL_LOSS_STATEMENT } });
|
|
764
|
+
}
|
|
765
|
+
// The signing half rides the SAME publish call as the encryption half
|
|
766
|
+
// (task 4.1's gateway contract: one round trip, both halves, all-or-
|
|
767
|
+
// none). `keystore.signingKeypair` is `null` only for a read-only
|
|
768
|
+
// recovery identity that lost its signing seed — that keystore can
|
|
769
|
+
// still enroll its encryption half (existing behavior, unchanged); it
|
|
770
|
+
// simply cannot become a vault writer until the seed is recovered.
|
|
771
|
+
const signingKeypair = keystore.signingKeypair(identity);
|
|
772
|
+
const signingFields = signingKeypair
|
|
773
|
+
? {
|
|
774
|
+
signing_pubkey: identity.signing_pubkey,
|
|
775
|
+
signing_fingerprint: identity.signing_fingerprint,
|
|
776
|
+
possession_signature: computeSigningKeyPossessionSignature({
|
|
777
|
+
signing_seed: signingKeypair.seed,
|
|
778
|
+
principal_id: whoami.principal.id,
|
|
779
|
+
signing_pubkey: identity.signing_pubkey,
|
|
780
|
+
encryption_pubkey: identity.encryption_pubkey,
|
|
781
|
+
}),
|
|
782
|
+
}
|
|
783
|
+
: {};
|
|
784
|
+
try {
|
|
785
|
+
const published = await this.#client.request("/agent/v1/whoami/encryption-key", { method: "POST", body: { public_key: identity.encryption_pubkey, ek_fingerprint: identity.encryption_fingerprint, custody_scheme: "keystore_v1", ...signingFields }, context: "publishing the keystore encryption key" });
|
|
786
|
+
if (published.state !== "active") {
|
|
787
|
+
if (!published.activation) {
|
|
788
|
+
throw new LocalError("the gateway published the key as pending without an activation challenge", "enrolling the keystore encryption key", { code: "KEY_NOT_PENDING", details: { state: published.state } });
|
|
789
|
+
}
|
|
790
|
+
const proof = computeKeystorePossessionProof({
|
|
791
|
+
private_key: keypair.private_key,
|
|
792
|
+
epk_b64u: published.activation.epk,
|
|
793
|
+
challenge_id: published.activation.challenge_id,
|
|
794
|
+
encryption_key_id: published.encryption_key_id,
|
|
795
|
+
public_key_b64u: identity.encryption_pubkey,
|
|
796
|
+
});
|
|
797
|
+
await this.#client.request("/agent/v1/whoami/encryption-key/activate", {
|
|
798
|
+
method: "POST",
|
|
799
|
+
body: { challenge_id: published.activation.challenge_id, proof },
|
|
800
|
+
context: "proving possession of the keystore encryption key",
|
|
801
|
+
});
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
catch (e) {
|
|
805
|
+
// The ONE refusal that must surface is a rotation (the gateway saw a
|
|
806
|
+
// different current key than whoami just reported — a race with a
|
|
807
|
+
// concurrent publish); everything else stays best-effort.
|
|
808
|
+
if (isRun402Error(e) && e.code === "KEY_ROTATION_REQUIRED")
|
|
809
|
+
throw e;
|
|
810
|
+
return { outcome: "skipped_error", ek_fingerprint: identity.encryption_fingerprint, signing_fingerprint: identity.signing_fingerprint ?? null, error: e instanceof Error ? e.message : String(e) };
|
|
811
|
+
}
|
|
812
|
+
keystore.bindIdentityPrincipal(whoami.principal.id);
|
|
813
|
+
// The early `already_active` return above only fires when NEITHER half
|
|
814
|
+
// needs work, so reaching here with `current.state === "active"` means
|
|
815
|
+
// specifically that the ENCRYPTION half was already fine and only the
|
|
816
|
+
// signing half triggered this publish — `signing_republished`, not
|
|
817
|
+
// `activated_pending` (which would wrongly imply an ECDH activation
|
|
818
|
+
// challenge was just completed; none was, `published.state` was
|
|
819
|
+
// already `"active"` and the activation branch above never ran).
|
|
820
|
+
const outcome = !current ? "enrolled" : current.state === "active" ? "signing_republished" : "activated_pending";
|
|
821
|
+
return { outcome, ek_fingerprint: identity.encryption_fingerprint, signing_fingerprint: identity.signing_fingerprint ?? null };
|
|
516
822
|
}
|
|
517
823
|
/**
|
|
518
824
|
* Open a vault, allocating it first when it does not exist yet (D2 — lazy
|
|
@@ -1013,6 +1319,23 @@ export class Gitvault {
|
|
|
1013
1319
|
else if (options.refs === true && !holdsRepoKey) {
|
|
1014
1320
|
warnings.push({ kind: "refs_unavailable", message: "this machine does not hold K_repo for the vault, so its ref map cannot be decrypted here" });
|
|
1015
1321
|
}
|
|
1322
|
+
// gitvault-agent-envelopes D3/D5: a KEY-HOLDER's ordinary read is a
|
|
1323
|
+
// session start — enroll this keystore's key if absent and fulfil every
|
|
1324
|
+
// pending desired recipient once per process (best-effort, reported,
|
|
1325
|
+
// never folded into the observation above). `status` on a machine that
|
|
1326
|
+
// holds no K_repo has nothing to fulfil and stays a pure read.
|
|
1327
|
+
let reconcileRecipients = null;
|
|
1328
|
+
let enrollment = null;
|
|
1329
|
+
if (repoId && holdsRepoKey && options.reconcile !== "forbidden") {
|
|
1330
|
+
try {
|
|
1331
|
+
const handle = await this.open({ ...options, repo_id: repoId });
|
|
1332
|
+
reconcileRecipients = handle.reconcile_recipients;
|
|
1333
|
+
enrollment = handle.enrollment;
|
|
1334
|
+
}
|
|
1335
|
+
catch (e) {
|
|
1336
|
+
warnings.push({ kind: "reconcile_unavailable", message: `the session-start envelope fulfilment could not run: ${e instanceof Error ? e.message : String(e)}` });
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1016
1339
|
const nextActions = [];
|
|
1017
1340
|
// `run402 init` scaffolds the git remote and deliberately allocates
|
|
1018
1341
|
// nothing; pointing at it here sent users to a command that silently did
|
|
@@ -1027,6 +1350,8 @@ export class Gitvault {
|
|
|
1027
1350
|
repo_id: repoId,
|
|
1028
1351
|
project_id: options.project_id ?? record?.project_id ?? null,
|
|
1029
1352
|
vault: record,
|
|
1353
|
+
reconcile_recipients: reconcileRecipients,
|
|
1354
|
+
enrollment,
|
|
1030
1355
|
keystore: {
|
|
1031
1356
|
present: keystorePresent,
|
|
1032
1357
|
identity_fingerprint: identityFingerprint,
|
|
@@ -1142,7 +1467,13 @@ export class Gitvault {
|
|
|
1142
1467
|
// with no signing key) is reported BESIDE the vault result, never a
|
|
1143
1468
|
// `push()` throw, same non-blocking contract as the mirror hook above.
|
|
1144
1469
|
const reconcileRecipients = await this.#tryReconcileEnvelopeRecipients(handle.vault);
|
|
1145
|
-
|
|
1470
|
+
// gitvault-multi-writer (task 5.7) — the SAME best-effort contract,
|
|
1471
|
+
// fired right beside the encryption-recipient reconcile above: a fresh
|
|
1472
|
+
// push proves this session's own key is an active writer, so this is
|
|
1473
|
+
// the natural place a just-admitted member's own next push clears
|
|
1474
|
+
// whatever candidacy it left behind for OTHER pending members too.
|
|
1475
|
+
const writerReconcile = await this.#tryReconcileWriterAdmissions(handle.vault);
|
|
1476
|
+
return { ...result, snapshot, gitvault_commit: snapshot.oid, gitvault_commit_line: line, mirror_push: mirrorPush, byo_chain_copy: byoChainCopy, reconcile_recipients: reconcileRecipients, writer_reconcile: writerReconcile };
|
|
1146
1477
|
}
|
|
1147
1478
|
// ── Handoff / resume (kygit-handoff design D1-D10) ─────────────────────────
|
|
1148
1479
|
/**
|
|
@@ -1159,7 +1490,7 @@ export class Gitvault {
|
|
|
1159
1490
|
*/
|
|
1160
1491
|
async handoff(options) {
|
|
1161
1492
|
const [{ deployRefTransaction }, { captureHandoffSnapshot, snapshotCommitment }, ho] = await Promise.all([this.#publication(), this.#snapshot(), this.#handoff()]);
|
|
1162
|
-
const { assembleHandoffKey, deriveHandoffSecrets,
|
|
1493
|
+
const { assembleHandoffKey, deriveHandoffSecrets, sealHandoffEnvelopeV2, assertHandoffNoteHasNoSecret, buildWriterAdmissionGrant, deriveWriterAdmissionSeed } = ho;
|
|
1163
1494
|
const handle = options.address ? (await this.resolveOrCreateAddress({ ...options, address: options.address, allow_create: false })).handle : await this.open(options);
|
|
1164
1495
|
const repoDir = options.repo_dir ?? process.cwd();
|
|
1165
1496
|
const repoFile = handle.keystore.readRepo(handle.repo_id);
|
|
@@ -1167,6 +1498,34 @@ export class Gitvault {
|
|
|
1167
1498
|
throw new LocalError(`no local key material for ${handle.repo_id} — this principal is not yet a member with a materialized envelope (push once first)`, "minting a handoff", { code: "GITVAULT_VAULT_UNRESOLVED" });
|
|
1168
1499
|
}
|
|
1169
1500
|
const kRepo = hexToBytes(repoFile.k_repo_hex);
|
|
1501
|
+
// gitvault-multi-writer rev 47 (task 5.5, design D4): only an ACTIVE
|
|
1502
|
+
// WRITER may mint — the gateway enforces this authoritatively at mint
|
|
1503
|
+
// time (`403 HANDOFF_MINT_REQUIRES_WRITER`), but failing that late
|
|
1504
|
+
// would mean this call already paid for a checkpoint capture + push +
|
|
1505
|
+
// envelope seal for nothing. `writer_set_pin` is freshly set by the
|
|
1506
|
+
// `verifyToNewest` this vault's `open()` above just ran (never stale by
|
|
1507
|
+
// more than this session's own most recent verify), so checking it
|
|
1508
|
+
// here is a cheap, typed, LOCAL fail-fast — never the check's source of
|
|
1509
|
+
// truth, which stays server-side.
|
|
1510
|
+
const identity = handle.keystore.readIdentity();
|
|
1511
|
+
const localWriterKeyId = identity?.signing_fingerprint ?? null;
|
|
1512
|
+
if (!localWriterKeyId || !repoFile.writer_set_pin?.writers.some((w) => w.writer_key_id === localWriterKeyId)) {
|
|
1513
|
+
throw new LocalError("this keystore's signing key is not an admitted writer of this vault — minting a handoff requires an ACTIVE writer (design D4); an existing writer must add this key first (`run402 org members add` / repos access sync), or push once to reconcile if a pending admission already exists", "minting a handoff", { code: "GITVAULT_WRITER_NOT_ADMITTED" });
|
|
1514
|
+
}
|
|
1515
|
+
// The grant's `minted_role` must EXACTLY predict what the gateway's own
|
|
1516
|
+
// role-attenuation (`services/gitvault/claims.ts mintHandoff`) will
|
|
1517
|
+
// compute, or the grant fails VALIDATION_FAILED after this call has
|
|
1518
|
+
// already paid for the checkpoint push below — `predictMintedRole`
|
|
1519
|
+
// mirrors that formula against the SAME `ORG_ROLE_RANK` lattice
|
|
1520
|
+
// `gitvault-writer-state.ts` already uses elsewhere in this codebase.
|
|
1521
|
+
const { predictMintedRole } = await this.#writerState();
|
|
1522
|
+
const vaultRecord = await this.get(handle.repo_id);
|
|
1523
|
+
const who = await this.#client.request("/agent/v1/whoami", { context: "resolving this principal's org role for the handoff grant" });
|
|
1524
|
+
const membership = who.memberships.find((m) => m.org_id === vaultRecord.org_id && m.status === "active");
|
|
1525
|
+
if (!membership) {
|
|
1526
|
+
throw new LocalError(`this principal has no active membership on ${vaultRecord.org_id} — an active writer must also be an active org member to mint a handoff`, "minting a handoff", { code: "GITVAULT_ACCESS_DENIED" });
|
|
1527
|
+
}
|
|
1528
|
+
const grantMintedRole = predictMintedRole(options.role, membership.role);
|
|
1170
1529
|
const snapshot = await captureHandoffSnapshot({
|
|
1171
1530
|
dir: repoDir,
|
|
1172
1531
|
...(options.includeSensitive !== undefined ? { includeSensitive: options.includeSensitive } : {}),
|
|
@@ -1205,14 +1564,50 @@ export class Gitvault {
|
|
|
1205
1564
|
const handoffId = randomHandoffUuid();
|
|
1206
1565
|
const { key, handoff_id_bytes, master_secret } = assembleHandoffKey(handoffId, randomBytes(32));
|
|
1207
1566
|
const secrets = deriveHandoffSecrets(handoff_id_bytes, master_secret);
|
|
1208
|
-
|
|
1209
|
-
|
|
1567
|
+
// gitvault-multi-writer rev 47 (task 5.5, design D4) — the MINTER's own
|
|
1568
|
+
// writer key signs `writer_admission_grant`, authorizing whoever claims
|
|
1569
|
+
// this handoff to become a writer (D224/D225). This is a HARD
|
|
1570
|
+
// requirement distinct from the earlier `writer_set_pin` presence
|
|
1571
|
+
// check: that check only proves the fingerprint is admitted, not that
|
|
1572
|
+
// THIS checkout still holds the seed to sign with — a read-only
|
|
1573
|
+
// recovery identity (no local `signing_seed_hex`) can be an admitted
|
|
1574
|
+
// writer's fingerprint yet be structurally unable to mint from here.
|
|
1575
|
+
//
|
|
1576
|
+
// Built BEFORE the envelope is sealed — design D4's "no hash cycle:
|
|
1577
|
+
// grant first, then seal" — so the v2 envelope below can embed this
|
|
1578
|
+
// grant's own stored-bytes SHA-256, letting the claimant's `resume()`
|
|
1579
|
+
// (task 5.6) cross-check the claim response's grant against what this
|
|
1580
|
+
// call actually sealed, independent of anything the gateway could alter.
|
|
1581
|
+
const signingKeypair = handle.keystore.signingKeypair(identity); // non-null: the writer precheck above already required identity.signing_fingerprint
|
|
1582
|
+
if (!signingKeypair) {
|
|
1583
|
+
throw new LocalError("this keystore has no local signing seed — it can read the vault's writer identity but cannot sign a writer_admission_grant from here (a read-only recovery identity); mint from a checkout that holds the full signing seed", "minting a handoff", { code: "VAULT_UNRECOVERABLE" });
|
|
1584
|
+
}
|
|
1585
|
+
const handoffAdmissionSeed = deriveWriterAdmissionSeed(handoff_id_bytes, master_secret);
|
|
1586
|
+
const grant = buildWriterAdmissionGrant({
|
|
1587
|
+
repo_id: handle.repo_id,
|
|
1588
|
+
handoff_id: handoffId,
|
|
1589
|
+
auth_hash: secrets.auth_hash_hex,
|
|
1590
|
+
checkpoint_generation: pushResult.generation,
|
|
1591
|
+
checkpoint_head_sha256: pushResult.head_sha256,
|
|
1592
|
+
minted_role: grantMintedRole,
|
|
1593
|
+
// Mirrors the gateway's own HANDOFF_DEFAULT_TTL_SECONDS (3600) —
|
|
1594
|
+
// `claim_not_after` is NOT cross-validated against the mint's actual
|
|
1595
|
+
// TTL server-side (only well-formedness is checked), so this only
|
|
1596
|
+
// needs to be a reasonable bound, not an exact prediction.
|
|
1597
|
+
claim_not_after: new Date(Date.now() + (options.ttlSeconds ?? 3600) * 1000).toISOString(),
|
|
1598
|
+
grantor_signing_seed: signingKeypair.seed,
|
|
1599
|
+
handoff_admission_pubkey: ed25519PublicKey(handoffAdmissionSeed),
|
|
1600
|
+
});
|
|
1601
|
+
const grantSha256Local = sha256Hex(jcs(grant));
|
|
1602
|
+
const sealed = sealHandoffEnvelopeV2(handoff_id_bytes, secrets.wrap_key, {
|
|
1603
|
+
v: 2,
|
|
1210
1604
|
kind: "handoff",
|
|
1211
1605
|
repo_id: handle.repo_id,
|
|
1212
1606
|
epoch: repoFile.epoch,
|
|
1213
1607
|
k_e_hex: repoFile.k_repo_hex,
|
|
1214
1608
|
checkpoint: { generation: pushResult.generation, commit_oid: snapshot.oid },
|
|
1215
1609
|
note_schema: "kygit.handoff-note.v1",
|
|
1610
|
+
writer_admission_grant_sha256: grantSha256Local,
|
|
1216
1611
|
});
|
|
1217
1612
|
// The wire shape is the gateway's documented one (llms-full.txt
|
|
1218
1613
|
// "Handoff / resume"): `role` (the minted role), `repo_id` / `org_id` /
|
|
@@ -1227,14 +1622,22 @@ export class Gitvault {
|
|
|
1227
1622
|
...(options.ttlSeconds !== undefined ? { expires_in_seconds: options.ttlSeconds } : {}),
|
|
1228
1623
|
checkpoint: { generation: pushResult.generation, snapshot_oid_hmac: snapshotOidHmac },
|
|
1229
1624
|
sealed_envelope: sealed.sealed_envelope,
|
|
1230
|
-
envelope_kind: sealed.envelope_kind
|
|
1625
|
+
envelope_kind: sealed.envelope_kind,
|
|
1231
1626
|
auth_hash: secrets.auth_hash_hex,
|
|
1627
|
+
writer_admission_grant: toBase64url(jcs(grant)),
|
|
1232
1628
|
},
|
|
1233
1629
|
context: "minting a handoff key",
|
|
1234
1630
|
});
|
|
1235
1631
|
if (response.handoff_id !== handoffId) {
|
|
1236
1632
|
throw new LocalError(`the gateway minted a different handoff_id (${response.handoff_id}) than requested (${handoffId}) — the assembled key would not match; retry`, "minting a handoff key", { code: "HANDOFF_ID_MISMATCH", details: { requested: handoffId, minted: response.handoff_id } });
|
|
1237
1633
|
}
|
|
1634
|
+
// The gateway names back the SHA-256 of the EXACT writer_admission_grant
|
|
1635
|
+
// bytes it stored — verified against this call's own local computation
|
|
1636
|
+
// (never the other way around) so a gateway that silently altered the
|
|
1637
|
+
// grant is caught here, before the key is ever handed to a recipient.
|
|
1638
|
+
if (response.writer_admission_grant_sha256 !== grantSha256Local) {
|
|
1639
|
+
throw new LocalError(`the gateway echoed a writer_admission_grant_sha256 (${response.writer_admission_grant_sha256}) that does not match what this call sent (${grantSha256Local}) — the stored grant may not be the one this call signed; do not distribute this handoff key`, "minting a handoff key", { code: "HANDOFF_MINT_GRANT_MISMATCH", details: { expected: grantSha256Local, received: response.writer_admission_grant_sha256 } });
|
|
1640
|
+
}
|
|
1238
1641
|
return {
|
|
1239
1642
|
handoff_key: key,
|
|
1240
1643
|
handoff_id: response.handoff_id,
|
|
@@ -1292,49 +1695,129 @@ export class Gitvault {
|
|
|
1292
1695
|
* git-config pins only → the session-start reconcile so a principal
|
|
1293
1696
|
* envelope supersedes the bearer one.
|
|
1294
1697
|
*/
|
|
1698
|
+
/**
|
|
1699
|
+
* gitvault-multi-writer rev 47 (task 5.6, design D4/D5) — order: parse →
|
|
1700
|
+
* ensure wallet → ensure identity → derive auth/wrap/admission → build
|
|
1701
|
+
* acceptance → claim → verify grant → open envelope → check the grant
|
|
1702
|
+
* hash → persist repo file with `pending_writer_admission` → clone →
|
|
1703
|
+
* verify chain → submit the ref-neutral activation head → reconcile
|
|
1704
|
+
* principal envelope → apply the checkpoint. Reported as
|
|
1705
|
+
* `writer_activation` and `reconcile_recipients` (D5's "recipient
|
|
1706
|
+
* coverage"), two blocks, because they are two properties.
|
|
1707
|
+
*
|
|
1708
|
+
* Crash-resumable by construction, not by a special-cased retry branch:
|
|
1709
|
+
* the acceptance is derived fresh every call from the (deterministic)
|
|
1710
|
+
* admission seed + this checkout's own identity, so re-running this
|
|
1711
|
+
* ENTIRE method after a crash anywhere before activation lands is safe —
|
|
1712
|
+
* the claim route is itself idempotent (same-claimant replay returns the
|
|
1713
|
+
* SAME grant). The one step that is NOT safely repeatable is submitting
|
|
1714
|
+
* the activation head a second time (the chain burns `handoff_id`
|
|
1715
|
+
* single-use) — `submitWriterActivationHead` handles that by checking
|
|
1716
|
+
* whether this checkout's own key is ALREADY in the freshly-verified
|
|
1717
|
+
* writer set before ever building a transition, exactly mirroring
|
|
1718
|
+
* `handoff()`'s own writer precheck (task 5.5), just inverted: there
|
|
1719
|
+
* "not yet a writer" refuses; here "already a writer" means skip.
|
|
1720
|
+
*/
|
|
1295
1721
|
async resume(options) {
|
|
1296
|
-
const [ho, { GitvaultKeystore },
|
|
1297
|
-
const { parseHandoffKey, deriveHandoffSecrets,
|
|
1722
|
+
const [ho, { GitvaultKeystore }, restore] = await Promise.all([this.#handoff(), this.#keystore(), this.#restore()]);
|
|
1723
|
+
const { parseHandoffKey, deriveHandoffSecrets, deriveWriterAdmissionSeed, buildWriterAcceptance, openHandoffEnvelopeV2 } = ho;
|
|
1298
1724
|
const { cloneGitvaultRemote, applyHandoffCheckpoint, resolveResumeTargetDir, readGitCommitMessage } = restore;
|
|
1725
|
+
// parse
|
|
1299
1726
|
const parsed = parseHandoffKey(options.key);
|
|
1300
|
-
|
|
1301
|
-
//
|
|
1302
|
-
//
|
|
1303
|
-
//
|
|
1304
|
-
//
|
|
1305
|
-
//
|
|
1306
|
-
//
|
|
1307
|
-
//
|
|
1308
|
-
//
|
|
1309
|
-
//
|
|
1310
|
-
//
|
|
1727
|
+
// ensure wallet — A fresh machine has no wallet, and the claim route
|
|
1728
|
+
// accepts ONLY a SIWX wallet signature (a control-plane session,
|
|
1729
|
+
// delegate, or service key is refused HANDOFF_CLAIM_REQUIRES_WALLET —
|
|
1730
|
+
// the keystore key the claim publishes is what makes the recipient a
|
|
1731
|
+
// real key-holder). Nothing upstream creates the allowance for an
|
|
1732
|
+
// unpaid request, so `resume` does it here, exactly as `repos resume
|
|
1733
|
+
// --help` promises: a keypair written to the allowance file, no
|
|
1734
|
+
// faucet, no tier, no payment. Without it a bare machine answers
|
|
1735
|
+
// AUTH_REQUIRED. A provider without
|
|
1736
|
+
// allowance support (an isomorphic one) is left alone — it
|
|
1737
|
+
// authenticates however it authenticates.
|
|
1311
1738
|
await this.#ensureLocalWallet(options.onLine);
|
|
1739
|
+
// ensure identity — BEFORE claim (D5): the acceptance below needs this
|
|
1740
|
+
// checkout's own signing key, which the claim REQUEST body carries.
|
|
1741
|
+
const keystore = new GitvaultKeystore(options.keystore_root !== undefined ? { rootDir: options.keystore_root } : {});
|
|
1742
|
+
const identity = keystore.ensureIdentity();
|
|
1743
|
+
const signingKeypair = keystore.signingKeypair(identity);
|
|
1744
|
+
if (!signingKeypair) {
|
|
1745
|
+
throw new LocalError("this keystore has no local signing seed — resuming a gitvault-multi-writer handoff requires signing writer_acceptance with a full Ed25519 seed, which a read-only recovery identity does not hold", "resuming a handoff", { code: "VAULT_UNRECOVERABLE" });
|
|
1746
|
+
}
|
|
1747
|
+
const claimantEncryptionPubkeyRaw = fromBase64url(identity.encryption_pubkey, "identity.encryption_pubkey");
|
|
1748
|
+
// derive auth/wrap/admission
|
|
1749
|
+
const secrets = deriveHandoffSecrets(parsed.handoff_id_bytes, parsed.master_secret);
|
|
1750
|
+
const admissionSeed = deriveWriterAdmissionSeed(parsed.handoff_id_bytes, parsed.master_secret);
|
|
1751
|
+
// build acceptance — before claim: design D4's own point is that the
|
|
1752
|
+
// claimant can construct BOTH signatures before ever seeing the stored
|
|
1753
|
+
// grant, since `handoff_id`/`auth_hash` are already independently known.
|
|
1754
|
+
const acceptance = buildWriterAcceptance({
|
|
1755
|
+
handoff_id: parsed.handoff_id,
|
|
1756
|
+
auth_hash: secrets.auth_hash_hex,
|
|
1757
|
+
admission_seed: admissionSeed,
|
|
1758
|
+
claimant_signing_seed: signingKeypair.seed,
|
|
1759
|
+
claimant_encryption_pubkey_raw: claimantEncryptionPubkeyRaw,
|
|
1760
|
+
});
|
|
1761
|
+
// claim
|
|
1312
1762
|
const claim = await this.#client.request(`/gitvault/v1/handoffs/${encodeURIComponent(parsed.handoff_id)}/claim`, {
|
|
1313
1763
|
method: "POST",
|
|
1314
1764
|
// Base64url, per the documented wire contract (openapi: "Base64url — the
|
|
1315
1765
|
// HKDF-derived auth_secret half of the parsed kgh1_ key"). The gateway
|
|
1316
1766
|
// decodes base64/base64url and substitutes 32 zero bytes for anything
|
|
1317
|
-
// else, so a hex-encoded secret
|
|
1318
|
-
//
|
|
1319
|
-
//
|
|
1320
|
-
|
|
1321
|
-
body: { auth_secret: toBase64url(secrets.auth_secret) },
|
|
1767
|
+
// else, so a hex-encoded secret never matches any stored hash and every
|
|
1768
|
+
// claim answers HANDOFF_KEY_INVALID. Each side's own tests can pass
|
|
1769
|
+
// independently; only the cross-side vector below catches this class.
|
|
1770
|
+
body: { auth_secret: toBase64url(secrets.auth_secret), writer_acceptance: toBase64url(jcs(acceptance)) },
|
|
1322
1771
|
context: "claiming a handoff key",
|
|
1323
1772
|
});
|
|
1324
1773
|
// The claim names the vault by id only (no slug-form address rides the
|
|
1325
1774
|
// wire), so the default target directory falls back to the vault id —
|
|
1326
1775
|
// `--to <dir>` names it explicitly.
|
|
1327
1776
|
const vault = handoffVaultFromWire(claim);
|
|
1328
|
-
|
|
1777
|
+
// verify grant — a light structural/binding check, NOT the full
|
|
1778
|
+
// cryptographic verification (which needs the vault's writer set to
|
|
1779
|
+
// resolve the grantor's pubkey, and therefore waits until the chain is
|
|
1780
|
+
// walked below — protocol §4.17's own admission ordering, not skipped,
|
|
1781
|
+
// just not reachable from here yet).
|
|
1782
|
+
let grantBytes;
|
|
1783
|
+
try {
|
|
1784
|
+
grantBytes = fromBase64url(claim.writer_admission_grant, "writer_admission_grant");
|
|
1785
|
+
}
|
|
1786
|
+
catch {
|
|
1787
|
+
throw new LocalError("the claim response's writer_admission_grant is not valid base64url", "resuming a handoff", { code: "VALIDATION_FAILED", details: { field: "writer_admission_grant" } });
|
|
1788
|
+
}
|
|
1789
|
+
let grant;
|
|
1790
|
+
try {
|
|
1791
|
+
grant = JSON.parse(new TextDecoder().decode(grantBytes));
|
|
1792
|
+
}
|
|
1793
|
+
catch {
|
|
1794
|
+
throw new LocalError("the claim response's writer_admission_grant does not decode to valid JSON", "resuming a handoff", { code: "VALIDATION_FAILED", details: { field: "writer_admission_grant" } });
|
|
1795
|
+
}
|
|
1796
|
+
if (grant.handoff_id !== parsed.handoff_id || grant.auth_hash !== secrets.auth_hash_hex || grant.repo_id !== vault.vault_id) {
|
|
1797
|
+
throw new LocalError("the claim response's writer_admission_grant does not bind this handoff — refusing", "resuming a handoff", { code: "VALIDATION_FAILED", details: { field: "writer_admission_grant" } });
|
|
1798
|
+
}
|
|
1799
|
+
const grantSha256 = sha256Hex(grantBytes);
|
|
1800
|
+
// open envelope — v2 (task 5.4): the v2 payload's own
|
|
1801
|
+
// writer_admission_grant_sha256 is what the next step cross-checks;
|
|
1802
|
+
// opening under the SAME v1/v2-requiring rule that refuses a
|
|
1803
|
+
// pre-gitvault-multi-writer mint's envelope outright.
|
|
1804
|
+
const payload = openHandoffEnvelopeV2(parsed.handoff_id_bytes, secrets.wrap_key, claim.sealed_envelope, claim.envelope_kind);
|
|
1329
1805
|
if (payload.repo_id !== vault.vault_id) {
|
|
1330
1806
|
throw new LocalError("the opened envelope's repo_id does not match the claim response's vault — refusing", "resuming a handoff", { code: "HANDOFF_ENVELOPE_INVALID" });
|
|
1331
1807
|
}
|
|
1332
|
-
|
|
1333
|
-
|
|
1808
|
+
// check the grant hash — design D4's own integrity binding: the SEALED
|
|
1809
|
+
// envelope (wrap_key-authenticated, the gateway never holds wrap_key)
|
|
1810
|
+
// names the grant it was minted alongside; if the claim response's
|
|
1811
|
+
// grant doesn't match, something between mint and claim substituted a
|
|
1812
|
+
// different one — refuse before spending anything on this handoff.
|
|
1813
|
+
if (payload.writer_admission_grant_sha256 !== grantSha256) {
|
|
1814
|
+
throw new LocalError(`the claim response's writer_admission_grant (sha256 ${grantSha256}) does not match the hash sealed into the envelope at mint time (${payload.writer_admission_grant_sha256}) — refusing to activate under a substituted grant`, "resuming a handoff", { code: "HANDOFF_CLAIM_WRITER_KEY_MISMATCH", details: { expected: payload.writer_admission_grant_sha256, received: grantSha256 } });
|
|
1815
|
+
}
|
|
1334
1816
|
// Genesis must be pinned before ANY materialize call can succeed
|
|
1335
1817
|
// (`GitvaultVault.genesis()` requires a keystore repo file — this is
|
|
1336
1818
|
// the one read that happens BEFORE one exists, via the transport
|
|
1337
1819
|
// directly, mirroring `restoreRepoFromEnvelope`'s own signature check).
|
|
1820
|
+
const { createGitvaultHttpTransport } = await this.#publication();
|
|
1338
1821
|
const transport = createGitvaultHttpTransport(this.#client);
|
|
1339
1822
|
const genesisBytes = await transport.getGenesis({ repo_id: vault.vault_id });
|
|
1340
1823
|
if (!genesisBytes) {
|
|
@@ -1345,7 +1828,13 @@ export class Gitvault {
|
|
|
1345
1828
|
throw new LocalError("vault_genesis signature does not verify", "resuming a handoff", { code: "GITVAULT_SIGNATURE_INVALID", details: { repo_id: vault.vault_id } });
|
|
1346
1829
|
}
|
|
1347
1830
|
const genesisSha = sha256Hex(genesisBytes);
|
|
1348
|
-
//
|
|
1831
|
+
// persist repo file with pending_writer_admission — BEFORE clone/chain
|
|
1832
|
+
// work below (design D10's "write to keystore before touching disk",
|
|
1833
|
+
// extended): a crash from here on leaves a durable record of the
|
|
1834
|
+
// ALREADY-VERIFIED grant, so a retry never needs to re-claim (the
|
|
1835
|
+
// acceptance is trivially re-derivable from data already in hand, per
|
|
1836
|
+
// this method's own doc comment above; only the grant is not).
|
|
1837
|
+
const myWriterKeyId = identity.signing_fingerprint;
|
|
1349
1838
|
keystore.saveRepo({
|
|
1350
1839
|
repo_id: vault.vault_id,
|
|
1351
1840
|
org_id: vault.organization_id,
|
|
@@ -1357,22 +1846,61 @@ export class Gitvault {
|
|
|
1357
1846
|
head_pin: null,
|
|
1358
1847
|
last_ref_transaction: null,
|
|
1359
1848
|
provenance: "restored_from_handoff",
|
|
1849
|
+
pending_writer_admission: { handoff_id: parsed.handoff_id, writer_admission_grant: grant, claimed_writer_key_id: myWriterKeyId },
|
|
1360
1850
|
});
|
|
1361
1851
|
const targetDir = await resolveResumeTargetDir(options.to, vault.address, vault.vault_id);
|
|
1362
1852
|
options.onLine?.(`resuming into ${targetDir}`);
|
|
1363
1853
|
const remoteUrl = gitvaultRemoteUrl(vault.organization_id, vault.project_id);
|
|
1364
1854
|
await cloneGitvaultRemote(remoteUrl, targetDir);
|
|
1365
|
-
const restored = await applyHandoffCheckpoint({ dir: targetDir, stash_oid: payload.checkpoint.commit_oid });
|
|
1366
1855
|
// Local-only pins (design D10) — never a worktree file, never the
|
|
1367
1856
|
// global active project. Reuses the SAME pin-writer every other
|
|
1368
1857
|
// gitvault resolution path uses, which also writes `r402.room`.
|
|
1369
1858
|
const { pinGitvaultRepo } = await this.#address();
|
|
1370
1859
|
const addressParts = vault.address ? vault.address.split("/") : null;
|
|
1371
1860
|
await pinGitvaultRepo(targetDir, vault.vault_id, addressParts && addressParts.length === 2 ? { org_slug: addressParts[0], repo_name: addressParts[1] } : undefined, { project_id: vault.project_id, org_id: vault.organization_id });
|
|
1372
|
-
//
|
|
1373
|
-
//
|
|
1374
|
-
|
|
1861
|
+
// verify chain — `open()` here mainly constructs the `GitvaultVault`
|
|
1862
|
+
// instance: its own `ensureRepoState()` (the cold-open restore path)
|
|
1863
|
+
// no-ops the instant it sees a repo file already on disk — and the
|
|
1864
|
+
// `saveRepo` call above just wrote one. The chain walk this method
|
|
1865
|
+
// actually needs happens a moment later, the FIRST time
|
|
1866
|
+
// `submitWriterActivationHead` calls `materialize()` on `handle.vault`
|
|
1867
|
+
// below — `materialize()` unconditionally runs `verifyToNewest()` on
|
|
1868
|
+
// every call, cold-open or not, which is what freshly pins
|
|
1869
|
+
// `writer_set_pin`. `options.reconcile: "forbidden"` here: the
|
|
1870
|
+
// encryption-envelope reconcile is deliberately deferred to its own
|
|
1871
|
+
// explicit step AFTER activation (D5's ordering), not run implicitly
|
|
1872
|
+
// and possibly twice.
|
|
1873
|
+
const handle = await this.open({ repo_id: vault.vault_id, repo_dir: targetDir, keystore_root: options.keystore_root, reconcile: "forbidden" });
|
|
1874
|
+
// `added_writer.principal_id` names the claimant's OWN control-plane
|
|
1875
|
+
// principal — the claim response never carries it (its `membership`
|
|
1876
|
+
// block names the ORG, not the principal), so resolve it fresh here,
|
|
1877
|
+
// the same one-call pattern `handoff()` already uses for its own role
|
|
1878
|
+
// resolution (task 5.5).
|
|
1879
|
+
const who = await this.#client.request("/agent/v1/whoami", { context: "resolving this principal's id for the writer activation head" });
|
|
1880
|
+
// submit the ref-neutral activation head
|
|
1881
|
+
const activation = await handle.vault.submitWriterActivationHead({
|
|
1882
|
+
addedWriterKeyId: myWriterKeyId,
|
|
1883
|
+
addedSigningPubkeyB64u: identity.signing_pubkey,
|
|
1884
|
+
addedPrincipalId: who.principal.id,
|
|
1885
|
+
handoffId: parsed.handoff_id,
|
|
1886
|
+
grant,
|
|
1887
|
+
acceptance: acceptance,
|
|
1888
|
+
});
|
|
1889
|
+
const activationGeneration = activation.outcome === "activated" ? activation.result.generation : activation.generation;
|
|
1890
|
+
// Clearing pending_writer_admission now that the activation head is
|
|
1891
|
+
// (or already was — the idempotent-skip case) admitted mirrors
|
|
1892
|
+
// writer_status flipping to "active" at the same moment, per this
|
|
1893
|
+
// field's own doc comment in gitvault-keystore.ts.
|
|
1894
|
+
keystore.updateRepo(vault.vault_id, { pending_writer_admission: null });
|
|
1895
|
+
// reconcile principal envelope — the bearer envelope is superseded
|
|
1896
|
+
// within minutes of use; run the same reconcile `push()` runs,
|
|
1897
|
+
// best-effort (never a `resume()` throw), NOW that this checkout is an
|
|
1898
|
+
// admitted writer and the reconcile's own wrap step is meaningful.
|
|
1375
1899
|
const reconcile = await this.#tryReconcileEnvelopeRecipients(handle.vault);
|
|
1900
|
+
// apply the checkpoint — LAST (D5): a failure anywhere above this line
|
|
1901
|
+
// leaves the working tree untouched (freshly cloned, nothing stashed),
|
|
1902
|
+
// the cleanest possible state to retry `resume()` from.
|
|
1903
|
+
const restored = await applyHandoffCheckpoint({ dir: targetDir, stash_oid: payload.checkpoint.commit_oid });
|
|
1376
1904
|
const senderIsOwner = claim.membership.role === "owner";
|
|
1377
1905
|
const nextActions = [...(claim.next_actions ?? [])];
|
|
1378
1906
|
if (claim.kind === "handoff" && senderIsOwner && !nextActions.some((a) => a.type === "remove_member")) {
|
|
@@ -1406,6 +1934,7 @@ export class Gitvault {
|
|
|
1406
1934
|
membership: handoffMembershipFromWire(claim.membership),
|
|
1407
1935
|
members: claim.members ?? [],
|
|
1408
1936
|
expires_at: claim.expires_at,
|
|
1937
|
+
writer_activation: { outcome: "active", writer_key_id: myWriterKeyId, generation: activationGeneration },
|
|
1409
1938
|
reconcile_recipients: reconcile,
|
|
1410
1939
|
next_actions: nextActions,
|
|
1411
1940
|
};
|
|
@@ -1474,18 +2003,46 @@ export class Gitvault {
|
|
|
1474
2003
|
return { attempted: false, outcome: "skipped_no_mirror", error: e instanceof Error ? e.message : String(e) };
|
|
1475
2004
|
}
|
|
1476
2005
|
}
|
|
1477
|
-
/** Best-effort envelope-recipient reconcile:
|
|
2006
|
+
/** Best-effort envelope-recipient reconcile: availability and local-policy failures never surface as a `push()` throw (mirroring {@link #tryMirrorPush}), but INTEGRITY verdicts do — `RECONCILE_FATAL_CODES` propagates tampering evidence instead of demoting it to `skipped_error` (round 3 blocker 2). */
|
|
1478
2007
|
async #tryReconcileEnvelopeRecipients(vault) {
|
|
1479
2008
|
try {
|
|
1480
2009
|
const result = await vault.reconcileEnvelopeRecipients();
|
|
1481
2010
|
return { attempted: true, outcome: "reconciled", result };
|
|
1482
2011
|
}
|
|
1483
2012
|
catch (e) {
|
|
2013
|
+
rethrowFatalReconcile(e);
|
|
2014
|
+
return { attempted: false, outcome: "skipped_error", error: e instanceof Error ? e.message : String(e) };
|
|
2015
|
+
}
|
|
2016
|
+
}
|
|
2017
|
+
/**
|
|
2018
|
+
* gitvault-multi-writer (task 5.7) — best-effort writer-admission
|
|
2019
|
+
* reconcile, the SAME non-blocking contract as {@link
|
|
2020
|
+
* #tryReconcileEnvelopeRecipients} beside it: availability failures
|
|
2021
|
+
* (network, an older gateway missing `pending_writers`/`signing_pubkey`,
|
|
2022
|
+
* a CAS-conflict-exhausted admission) never surface as a `push()`/
|
|
2023
|
+
* `deploy()`/`open()` throw — `GitvaultVault.reconcileWriterAdmissions()`
|
|
2024
|
+
* ALREADY returns an empty result rather than throwing for the ordinary
|
|
2025
|
+
* "I am not a writer yet" case, so `skipped_error` here is reserved for
|
|
2026
|
+
* genuinely unexpected failures (a transport fault mid-loop, a thrown
|
|
2027
|
+
* chain-integrity verdict from the `materialize()` this reconcile's own
|
|
2028
|
+
* admission attempts re-run). `rethrowFatalReconcile` is reused as-is:
|
|
2029
|
+
* its `RECONCILE_FATAL_CODES` set is envelope-specific today (no writer-
|
|
2030
|
+
* chain-integrity code is in it yet), so this call is a no-op unless a
|
|
2031
|
+
* future code is added there — cheap, forward-compatible, and consistent
|
|
2032
|
+
* with the sibling method rather than a silently different policy.
|
|
2033
|
+
*/
|
|
2034
|
+
async #tryReconcileWriterAdmissions(vault) {
|
|
2035
|
+
try {
|
|
2036
|
+
const result = await vault.reconcileWriterAdmissions();
|
|
2037
|
+
return { attempted: true, outcome: "reconciled", result };
|
|
2038
|
+
}
|
|
2039
|
+
catch (e) {
|
|
2040
|
+
rethrowFatalReconcile(e);
|
|
1484
2041
|
return { attempted: false, outcome: "skipped_error", error: e instanceof Error ? e.message : String(e) };
|
|
1485
2042
|
}
|
|
1486
2043
|
}
|
|
1487
2044
|
/**
|
|
1488
|
-
* A REAL preview of what {@link push} would publish
|
|
2045
|
+
* A REAL preview of what {@link push} would publish
|
|
1489
2046
|
* — never publishes anything, and never allocates. `run402 gitvault
|
|
1490
2047
|
* snapshot --dry-run` and `git-remote-run402`'s `option dry-run true` are
|
|
1491
2048
|
* both thin adapters over this method.
|
|
@@ -1685,7 +2242,7 @@ export class Gitvault {
|
|
|
1685
2242
|
}
|
|
1686
2243
|
/**
|
|
1687
2244
|
* Open this vault's compaction headroom grant, shared by `compact()` and
|
|
1688
|
-
* `prune({ submit })`
|
|
2245
|
+
* `prune({ submit })` — both write ceremony
|
|
1689
2246
|
* objects into the vault and both need the same single-flight-per-vault
|
|
1690
2247
|
* headroom slot. Four outcomes:
|
|
1691
2248
|
* - opened: the grant is returned, carrying `effective_pool_limit_bytes`.
|
|
@@ -1947,7 +2504,7 @@ export class Gitvault {
|
|
|
1947
2504
|
implementation_version: prune.GITVAULT_SDK_VERIFIER_VERSION,
|
|
1948
2505
|
}, handle.vault.signer());
|
|
1949
2506
|
const intent = prune.buildPruneIntent(core, [ours, options.submit.verifier_receipt], handle.vault.signer());
|
|
1950
|
-
//
|
|
2507
|
+
// The submission writes ~8 KB of ceremony
|
|
1951
2508
|
// objects (both verifier receipts + the intent) into the vault — on a
|
|
1952
2509
|
// vault that is over-quota BECAUSE it just compacted, that write is
|
|
1953
2510
|
// refused QUOTA_EXCEEDED, so the deletion's own paperwork is blocked by
|
|
@@ -2004,7 +2561,7 @@ export class Gitvault {
|
|
|
2004
2561
|
await handle.vault.transport.uploadObjects({ repo_id: handle.repo_id, objects });
|
|
2005
2562
|
}
|
|
2006
2563
|
catch (e) {
|
|
2007
|
-
//
|
|
2564
|
+
// Receipt object ids are DETERMINISTIC —
|
|
2008
2565
|
// the same core + the same signer produce the same id on every retry —
|
|
2009
2566
|
// and Ed25519 signing is deterministic too, so the SAME id can only
|
|
2010
2567
|
// mean the SAME bytes when nothing about the receipt's content
|
|
@@ -2081,11 +2638,10 @@ export class Gitvault {
|
|
|
2081
2638
|
}
|
|
2082
2639
|
/**
|
|
2083
2640
|
* `repos fsck` (repo-surface-consolidation D2/D3): walk the head chain
|
|
2084
|
-
*
|
|
2085
|
-
* true})` used to do before `--refs` was removed from `view`), reporting
|
|
2641
|
+
* AND materialize the ref map, reporting
|
|
2086
2642
|
* BOTH local trust pins — authenticated and materialized — before and
|
|
2087
2643
|
* after, with an explicit `local_state_changed` flag. This is the one
|
|
2088
|
-
* place chain materialization and pin advance live
|
|
2644
|
+
* place chain materialization and pin advance live; `view` never
|
|
2089
2645
|
* calls it.
|
|
2090
2646
|
*
|
|
2091
2647
|
* `options.write` (default `true`) is the inverse of the CLI's
|
|
@@ -2129,7 +2685,10 @@ export class Gitvault {
|
|
|
2129
2685
|
: "pass repo_id, or project_id to resolve it from the control plane", "running gitvault fsck", { code: "GITVAULT_VAULT_UNRESOLVED", details: { project_id: options.project_id ?? null } });
|
|
2130
2686
|
}
|
|
2131
2687
|
const write = options.write ?? true;
|
|
2132
|
-
|
|
2688
|
+
// gitvault-agent-envelopes D5: fsck is observational — it never wraps an
|
|
2689
|
+
// envelope, in write mode or not. Investigating a suspicious pending
|
|
2690
|
+
// recipient must not complete the disclosure being investigated.
|
|
2691
|
+
const handle = await this.open({ ...options, repo_id: repoId, reconcile: "forbidden" });
|
|
2133
2692
|
const state = await handle.vault.verifyToNewest({ persist: write, decryptValidate: true, strict: false });
|
|
2134
2693
|
const mirror = options.mirror ? await this.mirrorVerify({ ...options, repo_id: repoId }) : null;
|
|
2135
2694
|
const decrypt = state.decrypt; // decryptValidate: true always populates this
|
|
@@ -2251,12 +2810,10 @@ export class Gitvault {
|
|
|
2251
2810
|
* gateway `public_key` gap, and why this is a workaround rather than the
|
|
2252
2811
|
* eventual epoch-rotation design).
|
|
2253
2812
|
*
|
|
2254
|
-
* `run402 gitvault reconcile`
|
|
2255
|
-
*
|
|
2256
|
-
*
|
|
2257
|
-
*
|
|
2258
|
-
* pointing at `repos access` for inspection. This method itself is
|
|
2259
|
-
* unchanged and un-retired: `deploy()` still runs it, best-effort,
|
|
2813
|
+
* `run402 gitvault reconcile` has no `repos` equivalent — `reconcile` is
|
|
2814
|
+
* a workaround, not a permanent verb — and answers `COMMAND_REMOVED`
|
|
2815
|
+
* pointing at `repos access` for inspection. This method itself stays:
|
|
2816
|
+
* `deploy()` runs it, best-effort,
|
|
2260
2817
|
* whenever a deploy lands a new generation in the vault — design D5's
|
|
2261
2818
|
* "deploy time" hook, "the same 'one command every agent runs' argument
|
|
2262
2819
|
* that decided deploy-implies-capture." `push()` (capture-and-publish
|
|
@@ -2270,6 +2827,27 @@ export class Gitvault {
|
|
|
2270
2827
|
const handle = await this.open(options);
|
|
2271
2828
|
return handle.vault.reconcileEnvelopeRecipients();
|
|
2272
2829
|
}
|
|
2830
|
+
/**
|
|
2831
|
+
* gitvault-multi-writer (task 5.7) — admit every eligible `pending_writers[]`
|
|
2832
|
+
* candidate (active org membership at role developer+, a published,
|
|
2833
|
+
* possession-verified signing key, not yet in the writer set) via a fresh
|
|
2834
|
+
* `add_writer_key{"writer"}` head per candidate. This session's OWN key
|
|
2835
|
+
* must already be an active writer — see {@link
|
|
2836
|
+
* import("../node/gitvault-publication.js").GitvaultVault.
|
|
2837
|
+
* reconcileWriterAdmissions}'s doc comment for why that is checked
|
|
2838
|
+
* locally, once, rather than surfaced as N identical gateway refusals.
|
|
2839
|
+
*
|
|
2840
|
+
* The explicit standalone entry point for the SAME reconcile task 5.7
|
|
2841
|
+
* also wires onto session-start/push/snapshot/deploy — calling it here
|
|
2842
|
+
* is idempotent-safe alongside whatever `open()` above already ran
|
|
2843
|
+
* internally (an already-admitted candidate is simply reported under
|
|
2844
|
+
* `already_covered`, never re-admitted), mirroring {@link
|
|
2845
|
+
* reconcileEnvelopeRecipients}'s own identical redundancy-tolerant shape.
|
|
2846
|
+
*/
|
|
2847
|
+
async reconcile(options = {}) {
|
|
2848
|
+
const handle = await this.open(options);
|
|
2849
|
+
return handle.vault.reconcileWriterAdmissions();
|
|
2850
|
+
}
|
|
2273
2851
|
/**
|
|
2274
2852
|
* Drive one epoch rotation (D193-D203, rev 42) — the client half of
|
|
2275
2853
|
* epoch rotation: sample a fresh `K_e`, compute the H-partition from live
|
|
@@ -2312,8 +2890,8 @@ export class Gitvault {
|
|
|
2312
2890
|
*
|
|
2313
2891
|
* **This publish is an ORDINARY admission** and is therefore itself
|
|
2314
2892
|
* refused `EPOCH_ROTATION_REQUIRED` while this vault has a migration/
|
|
2315
|
-
* revocation/exposure condition outstanding (D193)
|
|
2316
|
-
*
|
|
2893
|
+
* revocation/exposure condition outstanding (D193).
|
|
2894
|
+
* `#enrichEpochRotationRequiredForPinManifest`
|
|
2317
2895
|
* below decorates that refusal with the remedy: fold the SAME receipt
|
|
2318
2896
|
* into `rotateEpoch({..., pending_confirmations: [...]})` instead, which
|
|
2319
2897
|
* durably publishes it on a `rotate_epoch` admission (the gate's own
|
|
@@ -2356,6 +2934,28 @@ export class Gitvault {
|
|
|
2356
2934
|
const repoId = await this.#resolveRepoId(options);
|
|
2357
2935
|
const record = await this.get(repoId).catch(() => null);
|
|
2358
2936
|
const orgId = record?.org_id ?? null;
|
|
2937
|
+
// gitvault-agent-envelopes D3/D5: `access` is one of the ordinary reads a
|
|
2938
|
+
// KEY-HOLDER's session starts with — enroll this keystore's key if absent
|
|
2939
|
+
// and fulfil every pending desired recipient once per process, BEFORE the
|
|
2940
|
+
// roster below is read, so the roster reflects the fulfilment. Reported,
|
|
2941
|
+
// never folded into the roster; a machine holding no K_repo stays a pure
|
|
2942
|
+
// read.
|
|
2943
|
+
let reconcileRecipients = null;
|
|
2944
|
+
let enrollment = null;
|
|
2945
|
+
if (options.reconcile !== "forbidden") {
|
|
2946
|
+
try {
|
|
2947
|
+
const { GitvaultKeystore } = await this.#keystore();
|
|
2948
|
+
const probe = new GitvaultKeystore(options.keystore_root !== undefined ? { rootDir: options.keystore_root } : {});
|
|
2949
|
+
if (probe.readRepo(repoId)) {
|
|
2950
|
+
const handle = await this.open({ ...options, repo_id: repoId });
|
|
2951
|
+
reconcileRecipients = handle.reconcile_recipients;
|
|
2952
|
+
enrollment = handle.enrollment;
|
|
2953
|
+
}
|
|
2954
|
+
}
|
|
2955
|
+
catch {
|
|
2956
|
+
// best-effort — the roster read below is the verb's own result
|
|
2957
|
+
}
|
|
2958
|
+
}
|
|
2359
2959
|
const [directory, coverage] = await Promise.all([
|
|
2360
2960
|
orgId
|
|
2361
2961
|
? this.#client.request(`/orgs/v1/${encodeURIComponent(orgId)}/encryption-keys`, { context: "reading the org encryption-key directory" })
|
|
@@ -2423,7 +3023,41 @@ export class Gitvault {
|
|
|
2423
3023
|
// not orphaned/external, it is provably this machine, so it is broken
|
|
2424
3024
|
// out into `this_keystore` instead of left to read as a misconfiguration.
|
|
2425
3025
|
const unmatchedRaw = coverage.recipient_fingerprints.filter((fp) => !directoryFingerprints.has(fp) && !staleAccessFingerprints.has(fp));
|
|
2426
|
-
|
|
3026
|
+
// gitvault-agent-envelopes: THIS machine's keystore is reported for every
|
|
3027
|
+
// principal type — enrolled or not, covered or not — never `null` for a
|
|
3028
|
+
// real keystore (only when there is no local identity at all). The
|
|
3029
|
+
// directory now lists agent keys too, so "own fingerprint in the
|
|
3030
|
+
// directory" is the enrolled case; an unenrolled creator's genesis
|
|
3031
|
+
// envelope still shows as covered-but-unenrolled.
|
|
3032
|
+
let publishState = "unknown";
|
|
3033
|
+
if (ownFingerprint !== null) {
|
|
3034
|
+
try {
|
|
3035
|
+
const who = await this.#client.request("/agent/v1/whoami", { context: "reading this keystore's enrollment state" });
|
|
3036
|
+
const key = who.encryption_key ?? null;
|
|
3037
|
+
publishState = !key ? "absent" : key.ek_fingerprint !== ownFingerprint ? "rotation_required" : key.state === "active" ? "active" : "pending";
|
|
3038
|
+
}
|
|
3039
|
+
catch {
|
|
3040
|
+
publishState = "unknown";
|
|
3041
|
+
}
|
|
3042
|
+
}
|
|
3043
|
+
const thisKeystore = ownFingerprint !== null
|
|
3044
|
+
? {
|
|
3045
|
+
fingerprint: ownFingerprint,
|
|
3046
|
+
enrolled: publishState === "active",
|
|
3047
|
+
publish_state: publishState,
|
|
3048
|
+
covered_on_this_vault: covered.has(ownFingerprint),
|
|
3049
|
+
covered: covered.has(ownFingerprint),
|
|
3050
|
+
...(!covered.has(ownFingerprint) && publishState === "active"
|
|
3051
|
+
? { next_actions: [
|
|
3052
|
+
{ action: "run402 repos access", why: "poll — covered_on_this_vault flips true once a key-holder has wrapped this vault to your key" },
|
|
3053
|
+
{ action: "ask a key-holder to run any gitvault operation (run402 repos view / git push)", why: "a key-holding client wraps every pending desired recipient on its next operation" },
|
|
3054
|
+
] }
|
|
3055
|
+
: {}),
|
|
3056
|
+
...(publishState === "rotation_required"
|
|
3057
|
+
? { next_actions: [{ action: "restore the keystore backup, or have an org owner revoke the stale key (DELETE /orgs/v1/:org_id/members/:principal_id/encryption-key)", why: "this keystore's key differs from the principal's published key; rotation is never automatic" }] }
|
|
3058
|
+
: {}),
|
|
3059
|
+
}
|
|
3060
|
+
: null;
|
|
2427
3061
|
const unmatched = thisKeystore ? unmatchedRaw.filter((fp) => fp !== thisKeystore.fingerprint) : unmatchedRaw;
|
|
2428
3062
|
const gap = envelopeStateAvailable
|
|
2429
3063
|
? "history_scope (which epochs each recipient can read) is not available: this read has no per-epoch view, only the vault's " +
|
|
@@ -2442,6 +3076,8 @@ export class Gitvault {
|
|
|
2442
3076
|
return {
|
|
2443
3077
|
repo_id: repoId,
|
|
2444
3078
|
org_id: orgId,
|
|
3079
|
+
reconcile_recipients: reconcileRecipients,
|
|
3080
|
+
enrollment,
|
|
2445
3081
|
recipients,
|
|
2446
3082
|
unmatched_covered_fingerprints: unmatched,
|
|
2447
3083
|
this_keystore: thisKeystore,
|
|
@@ -2495,14 +3131,15 @@ export class Gitvault {
|
|
|
2495
3131
|
// function so the outcome-gating is unit-testable with fake thunks,
|
|
2496
3132
|
// without standing up a live vault — see gitvault-deploy-hooks.test.ts.
|
|
2497
3133
|
const hooked = await attachGitvaultDeployHooks(result, () => this.#tryMirrorPush(handle.repo_id, handle.keystore), () => this.#tryReconcileEnvelopeRecipients(handle.vault));
|
|
2498
|
-
// gitvault-byo-primary-bucket task 3.3
|
|
2499
|
-
// `attachGitvaultDeployHooks` (kept
|
|
2500
|
-
// outcome-gating contract stays
|
|
2501
|
-
// deploy actually land a generation"
|
|
2502
|
-
// already encodes, so no new
|
|
3134
|
+
// gitvault-byo-primary-bucket task 3.3 / gitvault-multi-writer task 5.7
|
|
3135
|
+
// — composed AFTER, not inside, `attachGitvaultDeployHooks` (kept
|
|
3136
|
+
// untouched so its own unit-tested outcome-gating contract stays
|
|
3137
|
+
// byte-for-byte): the SAME "did this deploy actually land a generation"
|
|
3138
|
+
// gate `mirror_push`'s presence already encodes, so no new
|
|
3139
|
+
// outcome-inspection logic here.
|
|
2503
3140
|
if (hooked.mirror_push === undefined)
|
|
2504
3141
|
return hooked;
|
|
2505
|
-
return { ...hooked, byo_chain_copy: await this.#tryByoChainCopyPush(handle.repo_id, handle.keystore) };
|
|
3142
|
+
return { ...hooked, byo_chain_copy: await this.#tryByoChainCopyPush(handle.repo_id, handle.keystore), writer_reconcile: await this.#tryReconcileWriterAdmissions(handle.vault) };
|
|
2506
3143
|
}
|
|
2507
3144
|
/**
|
|
2508
3145
|
* Drain every unvaulted-override journal on this machine: push the exact
|
|
@@ -2748,6 +3385,9 @@ export class Gitvault {
|
|
|
2748
3385
|
#handoff() {
|
|
2749
3386
|
return nodeOnly(() => import("../node/gitvault-handoff.js"), "handoff");
|
|
2750
3387
|
}
|
|
3388
|
+
#writerState() {
|
|
3389
|
+
return nodeOnly(() => import("../node/gitvault-writer-state.js"), "handoff");
|
|
3390
|
+
}
|
|
2751
3391
|
#restore() {
|
|
2752
3392
|
return nodeOnly(() => import("../node/gitvault-restore.js"), "resume");
|
|
2753
3393
|
}
|