run402 4.63.1 → 4.64.0

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.
Files changed (53) hide show
  1. package/gitvault-surface.json +2 -1
  2. package/lib/doctor.mjs +28 -15
  3. package/lib/gitvault-capabilities.mjs +8 -0
  4. package/lib/remote-helper-session.mjs +138 -30
  5. package/lib/repos.mjs +122 -9
  6. package/package.json +1 -1
  7. package/sdk/dist/errors.d.ts +4 -2
  8. package/sdk/dist/errors.d.ts.map +1 -1
  9. package/sdk/dist/errors.js.map +1 -1
  10. package/sdk/dist/index.d.ts +1 -1
  11. package/sdk/dist/index.d.ts.map +1 -1
  12. package/sdk/dist/index.js +1 -1
  13. package/sdk/dist/index.js.map +1 -1
  14. package/sdk/dist/namespaces/gitvault.crypto.d.ts +80 -0
  15. package/sdk/dist/namespaces/gitvault.crypto.d.ts.map +1 -1
  16. package/sdk/dist/namespaces/gitvault.crypto.js +80 -0
  17. package/sdk/dist/namespaces/gitvault.crypto.js.map +1 -1
  18. package/sdk/dist/namespaces/gitvault.d.ts +219 -3
  19. package/sdk/dist/namespaces/gitvault.d.ts.map +1 -1
  20. package/sdk/dist/namespaces/gitvault.js +282 -5
  21. package/sdk/dist/namespaces/gitvault.js.map +1 -1
  22. package/sdk/dist/node/gitvault-byo-config.d.ts +29 -0
  23. package/sdk/dist/node/gitvault-byo-config.d.ts.map +1 -0
  24. package/sdk/dist/node/gitvault-byo-config.js +100 -0
  25. package/sdk/dist/node/gitvault-byo-config.js.map +1 -0
  26. package/sdk/dist/node/gitvault-byo-probe.d.ts +20 -0
  27. package/sdk/dist/node/gitvault-byo-probe.d.ts.map +1 -0
  28. package/sdk/dist/node/gitvault-byo-probe.js +57 -0
  29. package/sdk/dist/node/gitvault-byo-probe.js.map +1 -0
  30. package/sdk/dist/node/gitvault-creation-journal.d.ts +43 -0
  31. package/sdk/dist/node/gitvault-creation-journal.d.ts.map +1 -1
  32. package/sdk/dist/node/gitvault-creation-journal.js +34 -3
  33. package/sdk/dist/node/gitvault-creation-journal.js.map +1 -1
  34. package/sdk/dist/node/gitvault-degraded-read.d.ts +120 -0
  35. package/sdk/dist/node/gitvault-degraded-read.d.ts.map +1 -0
  36. package/sdk/dist/node/gitvault-degraded-read.js +164 -0
  37. package/sdk/dist/node/gitvault-degraded-read.js.map +1 -0
  38. package/sdk/dist/node/gitvault-mirror-backend.d.ts +73 -0
  39. package/sdk/dist/node/gitvault-mirror-backend.d.ts.map +1 -1
  40. package/sdk/dist/node/gitvault-mirror-backend.js +114 -8
  41. package/sdk/dist/node/gitvault-mirror-backend.js.map +1 -1
  42. package/sdk/dist/node/gitvault-mirror-config.d.ts +17 -1
  43. package/sdk/dist/node/gitvault-mirror-config.d.ts.map +1 -1
  44. package/sdk/dist/node/gitvault-mirror-config.js +21 -0
  45. package/sdk/dist/node/gitvault-mirror-config.js.map +1 -1
  46. package/sdk/dist/node/gitvault-mirror.d.ts +42 -0
  47. package/sdk/dist/node/gitvault-mirror.d.ts.map +1 -1
  48. package/sdk/dist/node/gitvault-mirror.js +162 -1
  49. package/sdk/dist/node/gitvault-mirror.js.map +1 -1
  50. package/sdk/dist/node/gitvault-publication.d.ts +51 -1
  51. package/sdk/dist/node/gitvault-publication.d.ts.map +1 -1
  52. package/sdk/dist/node/gitvault-publication.js +88 -6
  53. package/sdk/dist/node/gitvault-publication.js.map +1 -1
@@ -1,5 +1,5 @@
1
1
  {
2
- "surface_version": "4.63.1",
2
+ "surface_version": "4.64.0",
3
3
  "verbs": [
4
4
  "repos create",
5
5
  "repos list",
@@ -68,6 +68,7 @@
68
68
  "revocation_live": true,
69
69
  "human_envelope_add_live": true,
70
70
  "mirror_live": true,
71
+ "byo_live": true,
71
72
  "recover_live": true,
72
73
  "allocation": "create_immediate",
73
74
  "snapshot_dirty_default": "refuse"
package/lib/doctor.mjs CHANGED
@@ -12,6 +12,7 @@
12
12
  */
13
13
 
14
14
  import { existsSync, statSync } from "node:fs";
15
+ import { GITVAULT_BYO_NO_PAYLOAD_COPY_STATEMENT } from "#sdk";
15
16
  import { configDir, readAllowance, loadKeyStore } from "./config.mjs";
16
17
  import { resolveGitvaultTarget } from "./gitvault-target.mjs";
17
18
  import { getSdk } from "./sdk.mjs";
@@ -665,6 +666,10 @@ export async function run(sub, args = []) {
665
666
  project_id: gv.project_id ?? projectId,
666
667
  repo_id: gv.repo_id,
667
668
  vault: gv.vault === null ? null : "allocated",
669
+ // gitvault-byo-primary-bucket task 3.5 — absent-or-"managed" is
670
+ // byte-identical to before this fold for every non-BYO vault.
671
+ storage_profile: gv.vault?.storage_profile ?? null,
672
+ byo_destination: gv.vault?.byo_destination ?? null,
668
673
  gitvault_policy: gv.gitvault_policy,
669
674
  keystore_root: gv.keystore.root,
670
675
  can_sign: gv.keystore.can_sign,
@@ -704,30 +709,34 @@ export async function run(sub, args = []) {
704
709
  // doctor-persistent `grandfathered` advisory it owns.
705
710
  for (const w of gv.warnings ?? []) gaps.push(`${w.kind}: ${w.message}`);
706
711
 
707
- // gitvault-mirror-and-recover task 4.3: mirror currency, reported
708
- // ALONGSIDE (never in place of) the deploy-related gaps above, and
709
- // never blocking `run402 deploy`'s own gate — the vault lane's
710
- // outcome is unaffected regardless of mirror state (design D6).
711
- // `mirror_currency` mirrors `mirror status`'s own tri-state:
712
- // `current` / `stale` / `unknown` (mirror unreachable or vault
713
- // unread) only STALE is actionable enough to become a warning; NO
714
- // mirror configured is a purely informational, ungated advisory
715
- // most vaults have never opted in, and that is a normal shape.
712
+ // gitvault-mirror-and-recover task 4.3 + gitvault-mirror-default:
713
+ // mirror currency, reported ALONGSIDE (never in place of) the
714
+ // deploy-related gaps above, and never blocking `run402 deploy`'s
715
+ // own gate — the vault lane's outcome is unaffected regardless of
716
+ // mirror state (design D6). `mirror_currency` mirrors `mirror
717
+ // status`'s own tri-state: `current` / `stale` / `unknown` (mirror
718
+ // unreachable or vault unread). Only STALE is actionable enough to
719
+ // become a warning gap; a vault with no successful mirror copy yet
720
+ // carries the SDK-computed `vault_unmirrored` finding named and
721
+ // standing (gitvault-mirror-default supersedes the old anonymous
722
+ // `advisory` string), echoed verbatim, and deliberately NOT pushed
723
+ // into `gaps`: informational, never blocking, computed client-side
724
+ // only, cleared by the first successful mirror write or sync.
716
725
  if (gv.vault !== null && value.repo_id) {
717
726
  try {
718
- const mirrorStatus = await getSdk().gitvault.mirrorStatus({ repo_id: value.repo_id });
727
+ const mirrorStatus = await getSdk().gitvault.mirrorStatus({ repo_id: value.repo_id, is_byo: value.storage_profile === "byo" });
719
728
  value.gitvault_mirror = {
720
729
  configured: mirrorStatus.configured,
721
730
  destination: mirrorStatus.destination,
722
731
  mirrored_generation: mirrorStatus.mirrored_generation,
723
732
  newest_generation: mirrorStatus.newest_generation,
724
733
  is_current: mirrorStatus.is_current,
734
+ last_success_at: mirrorStatus.last_success_at,
735
+ finding: mirrorStatus.finding,
725
736
  validity_not_freshness: mirrorStatus.validity_not_freshness,
726
737
  keystore_still_required: mirrorStatus.keystore_still_required,
727
738
  };
728
- if (!mirrorStatus.configured) {
729
- value.gitvault_mirror.advisory = "no ciphertext mirror is configured for this vault — the exit ramp is opt-in; 'run402 repos mirror <destination>' to configure one.";
730
- } else if (mirrorStatus.is_current === false) {
739
+ if (mirrorStatus.is_current === false) {
731
740
  gaps.push(`the ciphertext mirror at ${mirrorStatus.destination} is STALE (mirrored generation ${mirrorStatus.mirrored_generation ?? "(none)"}, vault newest ${mirrorStatus.newest_generation ?? "(none)"}) — ${mirrorStatus.closing_command}`);
732
741
  }
733
742
  } catch {
@@ -736,15 +745,19 @@ export async function run(sub, args = []) {
736
745
  }
737
746
  }
738
747
 
748
+ // gitvault-byo-primary-bucket task 3.5 — unconditional, independent
749
+ // of mirror status (D7); imported from the canonical constants, never
750
+ // paraphrased.
751
+ const byoDisclosure = value.storage_profile === "byo" ? ` Storage: byo (${value.byo_destination ?? "(unknown)"}) — ${GITVAULT_BYO_NO_PAYLOAD_COPY_STATEMENT}` : "";
739
752
  checks.push({
740
753
  name: "gitvault",
741
754
  status: gaps.length > 0 ? "warning" : "ok",
742
755
  value: gaps.length > 0 ? { ...value, gaps } : value,
743
- hint: gv.vault === null
756
+ hint: (gv.vault === null
744
757
  ? `No vault for this project (that is a normal shape). Allocate one with 'run402 repos create --project <id>'. Keystore: ${gv.keystore.root}`
745
758
  : gv.durability_statement
746
759
  ? `Back up ${gv.keystore.root} anyway — ${gv.durability_statement} (covering_recipients: ${gv.covering_recipients})`
747
- : `Back up ${gv.keystore.root} — whole-machine or whole-keystore loss is terminal for vault history.`,
760
+ : `Back up ${gv.keystore.root} — whole-machine or whole-keystore loss is terminal for vault history.`) + byoDisclosure,
748
761
  });
749
762
  } catch (err) {
750
763
  // A gateway without gitvault, an unreachable API, or a project this
@@ -28,6 +28,14 @@ export const GITVAULT_CAPABILITIES = {
28
28
  // `repos mirror` reads/writes a mirror destination beside the keystore and
29
29
  // moves real bytes into a customer-owned bucket.
30
30
  mirror_live: true,
31
+ // `repos create --byo <s3://bucket/prefix>` allocates a
32
+ // `storage_profile: "byo"` vault whose payload ciphertext is written by
33
+ // the client straight to the customer's own bucket (create-only probe
34
+ // fail-closed before allocation; chain-copy dual-write; attested finalize
35
+ // with `storage_verification: "client_attested"`; degraded reads from the
36
+ // destination; fsck absence check) — shipped end to end in the same
37
+ // change that flips this flag (gitvault-byo-primary-bucket, Phase 3).
38
+ byo_live: true,
31
39
  // `repos recover` materializes a git repository from a mirror source,
32
40
  // offline, with no server call.
33
41
  recover_live: true,
@@ -112,6 +112,23 @@
112
112
  * is refused rather than silently pointed at the current directory. Only
113
113
  * `capabilities`, `option` and `list` work without one, which is exactly
114
114
  * the set `git ls-remote <url>` outside a checkout needs.
115
+ *
116
+ * DEGRADED READ MODE (gitvault-byo-primary-bucket, design D4). `list` and
117
+ * `fetch` each wrap their own live gateway read in
118
+ * `getSdk().gitvault.withDegradedRead` — on a NETWORK-CLASS failure (never a
119
+ * 401/403/404 or any other 4xx; one bounded retry runs first so a transient
120
+ * blip does not flap between sources) with a mirror configured for this
121
+ * vault, the read is served from it instead via the SAME `r402s-recover`
122
+ * engine `run402 repos recover` uses — `git fetch`/`clone` keep working
123
+ * while run402 is down. The result is marked `degraded: true` with source
124
+ * provenance, and exactly one stderr line names the fallback and its source
125
+ * (`gitvaultDegradedReadNote`). Local trust pins never advance past what the
126
+ * mirror copy itself chain-verifies (the fallback engine never touches the
127
+ * live vault's own keystore pins at all — see `gitvault-degraded-read.ts`'s
128
+ * own doc comment). A vault with no mirror configured is BYTE-IDENTICAL to
129
+ * today: the original gateway error surfaces unchanged. `push` is UNTOUCHED
130
+ * by this — writes are never rerouted; admission always requires the
131
+ * gateway.
115
132
  */
116
133
 
117
134
  import { createInterface } from "node:readline";
@@ -127,7 +144,7 @@ const nodeModP = import("#sdk/node");
127
144
  const configModP = import("./config.mjs");
128
145
  const { getSdk } = await sdkModP;
129
146
  const { resolveWalletCore, enforceWalletExistsCore, WalletSelectionError } = await walletModP;
130
- const { gitvaultRemoteAddressForm, gitvaultSlugReleasedInfo, parseGitvaultRemoteUrl } = await isoModP;
147
+ const { gitvaultRemoteAddressForm, gitvaultSlugReleasedInfo, parseGitvaultRemoteUrl, gitvaultDegradedReadNote } = await isoModP;
131
148
  const { GITVAULT_R402_REF_NAMESPACE, hardenedGit, resolveGitInvocationRepo, readPinnedGitvaultRepo, pinGitvaultRepo, readGitvaultRestoreMarker, readGitvaultAutoGcThreshold } = await nodeModP;
132
149
  const { allowanceFile, projectCredentialsFile, profileStateFile } = await configModP;
133
150
 
@@ -524,7 +541,7 @@ async function main(argv, { onBackgroundWork } = {}) {
524
541
  */
525
542
  const openVault = async (repoDir) => {
526
543
  const result = await getCachedSdk().gitvault.resolveOrCreateAddress({ address, allow_create: false, ...(repoDir ? { repo_dir: repoDir } : {}) });
527
- return { vault: result.handle.vault, resolution: result.resolution };
544
+ return { vault: result.handle.vault, keystore: result.handle.keystore, repo_id: result.handle.repo_id, resolution: result.resolution };
528
545
  };
529
546
 
530
547
  /**
@@ -569,7 +586,10 @@ async function main(argv, { onBackgroundWork } = {}) {
569
586
  note(`vault ${result.handle.repo_id} allocated (genesis ${result.created.genesis_sha256}) — one-shot recovery receipt, keep many copies:`);
570
587
  note(JSON.stringify(result.created.recovery_receipt));
571
588
  try {
572
- const { getGitvaultKeystoreRoot } = await import("#sdk/node");
589
+ const { getGitvaultKeystoreRoot, GITVAULT_MIRROR_SETUP_HINT } = await import("#sdk/node");
590
+ // gitvault-mirror-default: every allocation teaches the mirror door
591
+ // beside the recovery receipt, lazy push-to-create included.
592
+ note(GITVAULT_MIRROR_SETUP_HINT);
573
593
  note(`keystore: ${getGitvaultKeystoreRoot()} — back this up; whole-machine or whole-keystore loss is terminal for vault history until human envelopes ship`);
574
594
  } catch {
575
595
  // Never let a diagnostic line fail a push that already allocated successfully.
@@ -613,13 +633,38 @@ async function main(argv, { onBackgroundWork } = {}) {
613
633
  }
614
634
  }
615
635
  const materializeOpts = fetchMarker ? { deltaSince: fetchMarker.generation } : {};
616
- let vault;
617
- let state;
636
+ let opened;
618
637
  try {
619
- const opened = await openVault(repoDir ?? undefined);
620
- vault = opened.vault;
638
+ opened = await openVault(repoDir ?? undefined);
639
+ } catch (err) {
640
+ // An unallocated vault is not an error here: `list` is the read half of
641
+ // the protocol dance and must never create anything on its own (D2
642
+ // scopes lazy creation to `push`). Reporting it as an EMPTY ref set is
643
+ // exactly what a fresh repository looks like to git, and `push` still
644
+ // runs `list` first either way — this is what lets a first push land in
645
+ // one command instead of `list` failing the whole exchange before
646
+ // `push` ever gets a turn. Nothing to share with `push` either
647
+ // (design D7): an unallocated vault has no base to reuse.
648
+ if (isVaultNotFound(err)) {
649
+ endBlock();
650
+ return;
651
+ }
652
+ throw err;
653
+ }
654
+ let vault = opened.vault;
655
+
656
+ // gitvault-byo-primary-bucket (design D4, task 3.4 — mirror half): the
657
+ // live materialize (with its own stale-pin retry, unchanged from before
658
+ // this change) is the "attemptLive" `withDegradedRead` wraps — on a
659
+ // NETWORK-CLASS failure (never 4xx) with a mirror configured for this
660
+ // vault, it falls back to `r402s-recover`'s engine against it instead of
661
+ // failing the whole `list`/`clone`. `repoDir` is required for the
662
+ // fallback to materialize into (`out_dir`); a repo-free `list` (bare
663
+ // `git ls-remote`) simply has none, so the fallback never runs and the
664
+ // original error surfaces exactly as it always did.
665
+ const attemptLive = async () => {
621
666
  try {
622
- state = await vault.materialize(materializeOpts);
667
+ return await vault.materialize(materializeOpts);
623
668
  } catch (err) {
624
669
  // An OFFLINE (id-carrying pin) resolution discovers a stale pin on
625
670
  // its FIRST repo-scoped read (client-surface spec, id-pinning
@@ -634,30 +679,54 @@ async function main(argv, { onBackgroundWork } = {}) {
634
679
  if (!recovered) throw err;
635
680
  note(`pinned vault ${opened.resolution.repo_id} no longer resolves — re-resolved to ${recovered.resolution.repo_id}, retrying`);
636
681
  vault = recovered.handle.vault;
637
- state = await vault.materialize(materializeOpts);
682
+ opened = { ...opened, vault: recovered.handle.vault, keystore: recovered.handle.keystore, repo_id: recovered.handle.repo_id, resolution: recovered.resolution };
683
+ return await vault.materialize(materializeOpts);
638
684
  }
685
+ };
686
+
687
+ let outcome;
688
+ try {
689
+ outcome = await getCachedSdk().gitvault.withDegradedRead({ attemptLive, keystore: opened.keystore, repo_id: opened.repo_id, out_dir: repoDir });
639
690
  } catch (err) {
640
- // An unallocated vault is not an error here: `list` is the read half of
641
- // the protocol dance and must never create anything on its own (D2
642
- // scopes lazy creation to `push`). Reporting it as an EMPTY ref set is
643
- // exactly what a fresh repository looks like to git, and `push` still
644
- // runs `list` first either way — this is what lets a first push land in
645
- // one command instead of `list` failing the whole exchange before
646
- // `push` ever gets a turn. Nothing to share with `push` either
647
- // (design D7): an unallocated vault has no base to reuse.
691
+ // Same "an unallocated vault is not an error" reasoning as above a
692
+ // 404 discovered only once materialize actually runs takes this same
693
+ // empty-ref-set path (network-class errors never reach here at all:
694
+ // `withDegradedRead` only rethrows them after exhausting its own
695
+ // fallback, and a 404 is never network-class).
648
696
  if (isVaultNotFound(err)) {
649
697
  endBlock();
650
698
  return;
651
699
  }
652
700
  throw err;
653
701
  }
654
- // gitvault-session-state-reuse design D1: `fetchState`/`fetchMarker`
655
- // extend the SAME session-scoped handoff `push` already reuses (`base`)
656
- // — the `fetch` phase of THIS session reuses this response instead of
657
- // issuing its own state read. Session-scoped only: dropped the moment a
658
- // push admits in this same session (see `runPush`'s reset below).
659
- if (repoDir) sharedListSession = { repoDir, walletName: resolvedWallet?.name ?? null, vault, base: state, fetchMarker, fetchState: state };
660
- const refs = state.refs ?? {};
702
+
703
+ let refs;
704
+ let head;
705
+ if (outcome.degraded) {
706
+ // Exactly ONE stderr line naming the degraded read and its source
707
+ // (design D4) the validity-not-freshness limit rides inside the
708
+ // canonical statement this composes, verbatim.
709
+ note(gitvaultDegradedReadNote(outcome.result.source));
710
+ refs = outcome.result.refs ?? {};
711
+ head = outcome.result.head_target;
712
+ // No live vault instance materialized this pass — nothing safe to
713
+ // share with a following `push` (design D7's own "unallocated vault"
714
+ // reasoning applies equally to a degraded one: writes are never
715
+ // rerouted, so `push` must resolve and materialize live on its own).
716
+ } else {
717
+ const state = outcome.live;
718
+ refs = state.refs ?? {};
719
+ head = state.head_target;
720
+ // gitvault-session-state-reuse design D1: `fetchState`/`fetchMarker`
721
+ // extend the SAME session-scoped handoff `push` already reuses (`base`)
722
+ // — the `fetch` phase of THIS session reuses this response instead of
723
+ // issuing its own state read. Session-scoped only: dropped the moment a
724
+ // push admits in this same session (see `runPush`'s reset below).
725
+ // `keystore`/`repo_id` ride along so `fetch`'s own degraded-read wrap
726
+ // can fall back without re-resolving.
727
+ if (repoDir) sharedListSession = { repoDir, walletName: resolvedWallet?.name ?? null, vault, keystore: opened.keystore, repo_id: opened.repo_id, base: state, fetchMarker, fetchState: state };
728
+ }
729
+
661
730
  for (const ref of Object.keys(refs).sort()) out(`${refs[ref]} ${ref}`);
662
731
  // A snapshot-only vault holds protocol refs but no branch heads, so a
663
732
  // plain `git clone` prints "cloned an empty repository" with no hint the
@@ -667,7 +736,6 @@ async function main(argv, { onBackgroundWork } = {}) {
667
736
  note(`this vault has no branch heads yet — its history lives on ${refNames.sort()[0]}`);
668
737
  note(`fetch it with: git fetch <remote> '+${refNames.sort()[0]}:${refNames.sort()[0]}' && git checkout -b restored ${refNames.sort()[0]}`);
669
738
  }
670
- const head = state.head_target;
671
739
  // A symref is only advertised when its target is actually present:
672
740
  // pointing HEAD at a ref that does not exist is what an empty repository
673
741
  // looks like, and git reads the empty list correctly on its own.
@@ -700,15 +768,55 @@ async function main(argv, { onBackgroundWork } = {}) {
700
768
  // different repository/wallet, or a push that already admitted in this
701
769
  // same session — see `runPush`'s reset) falls back to the vault's own
702
770
  // read, unchanged.
771
+ //
772
+ // gitvault-byo-primary-bucket (design D4, task 3.4 — mirror half):
773
+ // either path's actual chain/payload read is the "attemptLive" that
774
+ // `withDegradedRead` wraps — `gitvault.restore(...)` is exactly `open()`
775
+ // followed by `handle.vault.restoreObjectsInto(target_dir)` (see that
776
+ // method's own one-line body), decomposed here so the SECOND half can be
777
+ // wrapped, with byte-identical resolution to today on the live-success
778
+ // path. On a NETWORK-CLASS failure (never 4xx) with a mirror configured
779
+ // for this vault, the fetch is served from it instead of failing the
780
+ // whole clone; the destination it materialized into is the SAME
781
+ // `repoDir` git already prepared, so `git fetch`/`clone` completes
782
+ // exactly as if the live path had run.
703
783
  const shared = sharedListSession && sharedListSession.repoDir === repoDir && sharedListSession.walletName === (resolvedWallet?.name ?? null) ? sharedListSession : null;
704
- const restored = shared
705
- ? await shared.vault.restoreObjectsInto(repoDir, { marker: shared.fetchMarker, state: shared.fetchState })
706
- : await getCachedSdk().gitvault.restore({ ...target, repo_dir: repoDir, target_dir: repoDir });
784
+ let attemptLive;
785
+ let degradedKeystore;
786
+ let degradedRepoId;
787
+ if (shared) {
788
+ attemptLive = () => shared.vault.restoreObjectsInto(repoDir, { marker: shared.fetchMarker, state: shared.fetchState });
789
+ degradedKeystore = shared.keystore;
790
+ degradedRepoId = shared.repo_id;
791
+ } else {
792
+ const handle = await getCachedSdk().gitvault.open({ ...target, repo_dir: repoDir });
793
+ attemptLive = () => handle.vault.restoreObjectsInto(repoDir);
794
+ degradedKeystore = handle.keystore;
795
+ degradedRepoId = handle.repo_id;
796
+ }
797
+ const outcome = await getCachedSdk().gitvault.withDegradedRead({
798
+ attemptLive,
799
+ keystore: degradedKeystore,
800
+ repo_id: degradedRepoId,
801
+ out_dir: repoDir,
802
+ });
803
+ let restored;
804
+ if (outcome.degraded) {
805
+ // Exactly ONE stderr line naming the degraded read and its source
806
+ // (design D4) — the validity-not-freshness limit rides inside the
807
+ // canonical statement this composes, verbatim. A later `git push`
808
+ // still requires the gateway unchanged — this function only ever
809
+ // serves reads; `runPush` below is untouched by this change.
810
+ note(gitvaultDegradedReadNote(outcome.result.source));
811
+ restored = { generation: outcome.result.generation, retained_refs: outcome.result.retained_refs };
812
+ } else {
813
+ restored = { generation: outcome.live.generation, retained_refs: outcome.live.retained_refs };
814
+ }
707
815
  if (verbosity >= 1) note(`restored generation ${restored.generation}`);
708
816
  // clone-installs-retained-refs D3: a bookkeeping failure here degrades to
709
817
  // exactly today's (pre-change) behavior — one stderr note, fetch still
710
- // completes. `restored.retained_refs` is never absent (the SDK always
711
- // returns a result, never throws for this step).
818
+ // completes. `restored.retained_refs` is never absent (both the live and
819
+ // the degraded path always return a result, never throw for this step).
712
820
  if (restored.retained_refs?.warning) note(restored.retained_refs.warning);
713
821
  else if (verbosity >= 1 && (restored.retained_refs?.written.length > 0 || restored.retained_refs?.deleted.length > 0)) {
714
822
  note(`refs/r402/retain: +${restored.retained_refs.written.length} -${restored.retained_refs.deleted.length} (${restored.retained_refs.retained_count} retained tip(s) total)`);
package/lib/repos.mjs CHANGED
@@ -32,7 +32,13 @@ import { resolveOrgId, resolveOwningOrgId } from "./org-context.mjs";
32
32
  import { resolveGitvaultTarget } from "./gitvault-target.mjs";
33
33
  import { nextAction, claimOrgSlugAction, claimRepoNameAction } from "./next-actions.mjs";
34
34
  import { printKeystoreLocation } from "./gitvault.mjs";
35
- import { gitvaultRemoteUrlForRepo } from "#sdk";
35
+ import {
36
+ GITVAULT_BYO_HEADLINE_STATEMENT,
37
+ GITVAULT_BYO_NO_PAYLOAD_COPY_STATEMENT,
38
+ GITVAULT_BYO_UNMIRRORED_REMEDY_STATEMENT,
39
+ GITVAULT_MIRROR_SETUP_HINT,
40
+ gitvaultRemoteUrlForRepo,
41
+ } from "#sdk";
36
42
  import { sdkStats, printVerboseStats, isVerbose } from "./stats.mjs";
37
43
  import {
38
44
  normalizeArgv,
@@ -55,6 +61,7 @@ Usage:
55
61
 
56
62
  Common:
57
63
  run402 repos create [name] [--org <org_id>] [--dir <path>] [--tier <tier>] [--project <id>]
64
+ [--byo <s3://bucket/prefix>] [--profile <name> | --ambient] [--region <r>] [--endpoint <url>]
58
65
  run402 repos view [--project <id>] [--repo <repo_id>] [--human]
59
66
  run402 repos list [--org <org_id>] [--human]
60
67
 
@@ -99,7 +106,15 @@ Subcommands:
99
106
  nothing usable can be derived, this is a structured error naming
100
107
  exactly one next_action, never a guess. The response's next_action
101
108
  is the exact \`git push\` to run. Nothing is deployed, ever, unless
102
- you separately choose to.
109
+ you separately choose to. \`--byo <s3://bucket/prefix>\` allocates a
110
+ BYO (bring-your-own-bucket) vault: source ciphertext is written
111
+ ONLY to your own bucket, never run402's — run402 holds the small
112
+ signed chain only. The destination is PROBED before anything is
113
+ allocated (create-only writes honored, versioning disabled, write
114
+ permitted) and refuses closed on any failed property. Fewer
115
+ copies than a managed vault by construction (the platform holds
116
+ no payload copy at all); \`run402 repos mirror <destination>\`
117
+ still works unchanged as your second customer-held location.
103
118
  view Side-effect-free: what this machine and the control plane each
104
119
  believe about the repo — allocation, policy, whether this keystore
105
120
  can sign, the authenticated and materialized pins, the mirror
@@ -206,6 +221,14 @@ Subcommands:
206
221
  normal writing mode, so a budget-exceeded run resumes). \`--mirror\`
207
222
  additionally runs the keyless mirror integrity probe — it proves
208
223
  the mirror's VALIDITY, never its FRESHNESS, and says so.
224
+ For a BYO vault (\`repos create --byo\`), fsck ALSO adjudicates
225
+ the customer's own bucket against run402's signed chain —
226
+ automatic, no flag needed. No local BYO credentials on this
227
+ machine reports an explicit NOT CHECKED line, never a failure;
228
+ a confirmed absence FAILS fsck with GITVAULT_BYO_OBJECT_MISSING
229
+ naming exactly what's missing. Runs under \`--no-write\` too (a
230
+ pure HEAD-check read). See the result's \`byo_presence\` block
231
+ (also on \`--human\`); absent entirely for a managed vault.
209
232
  In write mode (not \`--no-write\`), when this keystore holds a
210
233
  local encryption identity, fsck ALSO submits its own
211
234
  chain-verified/decryptable generations as a proof-of-open receipt
@@ -557,6 +580,13 @@ async function formatRepoHuman(s, mirror) {
557
580
  const objectCount = storage?.objects ? Object.values(storage.objects).reduce((sum, n) => sum + Number(n), 0) : null;
558
581
  lines.push(storage ? `Storage: ${storage.source_bytes} byte(s)${objectCount != null ? ` across ${objectCount} object(s)` : ""}` : "Storage: unknown");
559
582
 
583
+ // gitvault-byo-primary-bucket task 3.5 — unconditional, independent of
584
+ // mirror status (D7).
585
+ if (s.vault.storage_profile === "byo") {
586
+ lines.push(`Storage profile: byo (${s.vault.byo_destination ?? "(unknown)"}) — ${GITVAULT_BYO_HEADLINE_STATEMENT}`);
587
+ lines.push(GITVAULT_BYO_NO_PAYLOAD_COPY_STATEMENT);
588
+ }
589
+
560
590
  const decryptPart = !s.keystore.holds_repo_key
561
591
  ? "CANNOT decrypt (no key in this machine's keystore)"
562
592
  : s.keystore.can_sign
@@ -564,7 +594,11 @@ async function formatRepoHuman(s, mirror) {
564
594
  : "can decrypt (read-only — no signing key)";
565
595
  lines.push(`This machine: ${decryptPart}. Policy: ${s.gitvault_policy ?? "(none)"}`);
566
596
 
567
- if (mirror?.configured) {
597
+ // gitvault-mirror-default: the SDK-computed vault_unmirrored finding is
598
+ // echoed verbatim (never rephrased here) — informational, never blocking.
599
+ if (mirror?.finding) {
600
+ lines.push(`Mirror (${mirror.finding.kind}): ${mirror.finding.message} — ${mirror.finding.setup_command}`);
601
+ } else if (mirror?.configured) {
568
602
  const currency = mirror.is_current === true ? "current" : mirror.is_current === false ? "STALE" : "unknown";
569
603
  lines.push(`Mirror: ${mirror.destination} (${currency})`);
570
604
  }
@@ -665,7 +699,22 @@ async function inferRepoName(dir) {
665
699
 
666
700
  // ─── create ─────────────────────────────────────────────────────────────────
667
701
 
668
- const CREATE_VALUE_FLAGS = ["--org", "--dir", "--tier", "--idempotency-key", "--project"];
702
+ const CREATE_VALUE_FLAGS = ["--org", "--dir", "--tier", "--idempotency-key", "--project", "--byo", "--profile", "--region", "--endpoint"];
703
+
704
+ /** gitvault-byo-primary-bucket task 3.5 — `--byo <destination>` + the SAME credential/region/endpoint flags `repos mirror` already uses. `undefined` when `--byo` was not passed (byte-identical to today). */
705
+ function resolveByoOption(a) {
706
+ const destinationUrl = flagValue(a, "--byo");
707
+ if (destinationUrl == null) return undefined;
708
+ const credential = resolveMirrorCredential(a);
709
+ const region = flagValue(a, "--region");
710
+ const endpoint = flagValue(a, "--endpoint");
711
+ return {
712
+ destination_url: destinationUrl,
713
+ ...(credential ? { credential } : {}),
714
+ ...(region != null ? { region } : {}),
715
+ ...(endpoint != null ? { endpoint } : {}),
716
+ };
717
+ }
669
718
 
670
719
  async function printCreateResult({ sdk, projectId, vault, adopted, name, verboseArgv }) {
671
720
  let address = null;
@@ -689,7 +738,15 @@ async function printCreateResult({ sdk, projectId, vault, adopted, name, verbose
689
738
  ? nextAction("push_repo", { command: `git push -u ${vault.remote.name} HEAD`, why: "Publish the current branch to the encrypted Run402 remote." })
690
739
  : null;
691
740
  const claimAction = address ? null : orgSlug ? claimRepoNameAction(projectId) : claimOrgSlugAction();
692
- const nextActions = [pushAction, claimAction].filter(Boolean);
741
+ // gitvault-byo-primary-bucket task 3.5: a BYO vault's "add a copy" remedy
742
+ // names a SECOND customer-held location (D7) — the plain mirror hint
743
+ // frames the mirror as the FIRST custody-held copy, which is false once
744
+ // the vault's own primary bucket already is one.
745
+ const isByo = vault.storage_profile === "byo";
746
+ const mirrorAction = isByo
747
+ ? nextAction("configure_mirror", { command: "run402 repos mirror <destination>", why: GITVAULT_BYO_UNMIRRORED_REMEDY_STATEMENT })
748
+ : nextAction("configure_mirror", { command: "run402 repos mirror <destination>", why: GITVAULT_MIRROR_SETUP_HINT });
749
+ const nextActions = [pushAction, mirrorAction, claimAction].filter(Boolean);
693
750
 
694
751
  // Secret-bearing (recovery_receipt): built fresh every call, printed once,
695
752
  // and never spilled into any cache path — see spillIfLarge's own doc
@@ -703,6 +760,8 @@ async function printCreateResult({ sdk, projectId, vault, adopted, name, verbose
703
760
  genesis_sha256: vault.genesis_sha256,
704
761
  recovery_receipt: vault.recovery_receipt,
705
762
  terminal_loss_statement: vault.terminal_loss_statement,
763
+ storage_profile: vault.storage_profile,
764
+ byo_destination: vault.byo_destination,
706
765
  deployed: false,
707
766
  next_actions: nextActions,
708
767
  };
@@ -716,6 +775,13 @@ async function printCreateResult({ sdk, projectId, vault, adopted, name, verbose
716
775
  else console.error(`no address claimed — run 'run402 repos rename <name> --project ${projectId}' to claim one`);
717
776
  if (vault.remote) console.error(`remote '${vault.remote.name}' -> ${vault.remote.url} (${vault.remote.reason})`);
718
777
  if (pushAction) console.error(`next: ${pushAction.command}`);
778
+ if (isByo) {
779
+ console.error(`storage: byo (${vault.byo_destination}) — ${GITVAULT_BYO_HEADLINE_STATEMENT}`);
780
+ console.error(GITVAULT_BYO_NO_PAYLOAD_COPY_STATEMENT);
781
+ console.error(GITVAULT_BYO_UNMIRRORED_REMEDY_STATEMENT);
782
+ } else {
783
+ console.error(GITVAULT_MIRROR_SETUP_HINT);
784
+ }
719
785
  console.error("");
720
786
  console.error(vault.terminal_loss_statement);
721
787
  await printKeystoreLocation();
@@ -736,7 +802,8 @@ async function createAdopt(projectId, dir, a) {
736
802
  });
737
803
  }
738
804
  try {
739
- const vault = await sdk.gitvault.init({ org_id: orgId, project_id: projectId, repo_dir: dir });
805
+ const byo = resolveByoOption(a);
806
+ const vault = await sdk.gitvault.init({ org_id: orgId, project_id: projectId, repo_dir: dir, ...(byo ? { byo } : {}) });
740
807
  await printCreateResult({ sdk, projectId, vault, adopted: true, name: null, verboseArgv: a });
741
808
  } catch (err) {
742
809
  reportSdkError(err);
@@ -775,7 +842,8 @@ async function createProvision(name, dir, a) {
775
842
  }
776
843
 
777
844
  try {
778
- const vault = await sdk.gitvault.init({ org_id: effectiveOrgId, project_id: provisioned.project_id, repo_dir: dir });
845
+ const byo = resolveByoOption(a);
846
+ const vault = await sdk.gitvault.init({ org_id: effectiveOrgId, project_id: provisioned.project_id, repo_dir: dir, ...(byo ? { byo } : {}) });
779
847
  await printCreateResult({ sdk, projectId: provisioned.project_id, vault, adopted: false, name, verboseArgv: a });
780
848
  } catch (err) {
781
849
  reportSdkError(err);
@@ -784,7 +852,7 @@ async function createProvision(name, dir, a) {
784
852
 
785
853
  async function create(args) {
786
854
  const a = normalizeArgv(args);
787
- assertKnownFlags(a, [...CREATE_VALUE_FLAGS, "--help", "-h", "-v", "--verbose"], CREATE_VALUE_FLAGS);
855
+ assertKnownFlags(a, [...CREATE_VALUE_FLAGS, "--ambient", "--help", "-h", "-v", "--verbose"], CREATE_VALUE_FLAGS);
788
856
  const positionals = requirePositionalCount(a, CREATE_VALUE_FLAGS, {
789
857
  min: 0, max: 1, command: "run402 repos create [name]", missing: "",
790
858
  });
@@ -936,10 +1004,11 @@ async function view(args) {
936
1004
  // Design D3: `view` NEVER passes `refs: true` — it is side-effect-free
937
1005
  // by construction, not by convention. Materialization belongs to `fsck`.
938
1006
  const s = await sdk.gitvault.status(target);
1007
+ const isByo = s.vault?.storage_profile === "byo";
939
1008
  let mirror = null;
940
1009
  if (s.repo_id) {
941
1010
  try {
942
- mirror = await sdk.gitvault.mirrorStatus({ ...target, repo_id: s.repo_id });
1011
+ mirror = await sdk.gitvault.mirrorStatus({ ...target, repo_id: s.repo_id, is_byo: isByo });
943
1012
  } catch {
944
1013
  // best-effort — a mirror read failure never fails `view`
945
1014
  }
@@ -969,10 +1038,22 @@ async function view(args) {
969
1038
  if (s.pinned) {
970
1039
  console.error(`pinned: repo_id ${s.pinned.repo_id}` + (s.pinned.resolved_from ? ` (resolved from run402::${s.pinned.resolved_from.org_slug}/${s.pinned.resolved_from.repo_name})` : ""));
971
1040
  }
1041
+ // gitvault-byo-primary-bucket task 3.5: the no-payload-copy disclosure —
1042
+ // unconditional and independent of mirror status (D7), never folded
1043
+ // into the mirror finding below (that's a SEPARATE fact: "is there a
1044
+ // second copy", not "is there any platform-held copy at all").
1045
+ if (isByo) {
1046
+ console.error(`storage: byo (${s.vault?.byo_destination ?? "(unknown)"}) — ${GITVAULT_BYO_HEADLINE_STATEMENT}`);
1047
+ console.error(GITVAULT_BYO_NO_PAYLOAD_COPY_STATEMENT);
1048
+ }
972
1049
  if (mirror?.configured) {
973
1050
  const currency = mirror.is_current === true ? "current" : mirror.is_current === false ? `STALE — ${mirror.closing_command}` : "unknown (mirror unreachable or vault unread)";
974
1051
  console.error(`mirror ${mirror.destination}: mirrored generation ${mirror.mirrored_generation ?? "(none)"}, vault newest ${mirror.newest_generation ?? "(none)"} — ${currency}`);
975
1052
  }
1053
+ // gitvault-mirror-default: echoed verbatim from the SDK, exactly like the
1054
+ // vault warnings below — informational, never blocking, and it clears on
1055
+ // the first successful mirror write or sync.
1056
+ if (mirror?.finding) console.error(`finding (${mirror.finding.kind}): ${mirror.finding.message} — ${mirror.finding.setup_command}`);
976
1057
  for (const w of s.warnings) console.error(`warning (${w.kind}): ${w.message}`);
977
1058
  for (const n of combinedNextActions) console.error(`next: ${n.why ?? n.action ?? n.type}${n.command ? ` — ${n.command}` : ""}`);
978
1059
  printVerboseStats(a, sdk);
@@ -1292,6 +1373,9 @@ async function snapshot(args) {
1292
1373
  console.error("");
1293
1374
  console.error(`repo allocated (genesis ${created.genesis_sha256}) — one-shot recovery receipt, keep many copies:`);
1294
1375
  console.error(JSON.stringify(created.recovery_receipt));
1376
+ // gitvault-mirror-default: lazy allocation is a birth too — the mirror
1377
+ // one-liner rides beside the recovery receipt here as well.
1378
+ console.error(GITVAULT_MIRROR_SETUP_HINT);
1295
1379
  await printKeystoreLocation();
1296
1380
  console.error("");
1297
1381
  },
@@ -1597,6 +1681,25 @@ function formatOpenProofLine(openProof) {
1597
1681
  return `proof-of-open: not submitted — ${openProof.error.code}: ${openProof.error.message}`;
1598
1682
  }
1599
1683
 
1684
+ /**
1685
+ * gitvault-byo-primary-bucket task 3.3: one line reporting `fsck`'s BYO
1686
+ * presence check — always present for a BYO vault (never blank), absent for
1687
+ * a managed one (`result.byo_presence` is `undefined` there, so this
1688
+ * returns `null` and no line prints — zero output change for managed
1689
+ * vaults). A MISSING-object verdict never reaches here: it throws
1690
+ * `GITVAULT_BYO_OBJECT_MISSING` before `fsck` returns a result at all, so
1691
+ * this line only ever reports the two non-failure outcomes — checked-clean
1692
+ * or explicitly not-checked. Shared by `--human` and the default JSON-mode
1693
+ * stderr summary, same convention as `formatOpenProofLine` above.
1694
+ */
1695
+ function formatByoPresenceLine(byoPresence) {
1696
+ if (!byoPresence) return null;
1697
+ if (!byoPresence.verified) {
1698
+ return `BYO storage: NOT CHECKED — ${byoPresence.not_checked_reason}`;
1699
+ }
1700
+ return `BYO storage: verified ${byoPresence.checked_count} object(s) present at ${byoPresence.destination}.`;
1701
+ }
1702
+
1600
1703
  /** `repos fsck --human`: the same verdict the stderr lines already carry, condensed into one block. */
1601
1704
  function formatFsckHuman(result, mirrorRequested) {
1602
1705
  const lines = [`Repo: ${result.repo_id}`];
@@ -1633,6 +1736,8 @@ function formatFsckHuman(result, mirrorRequested) {
1633
1736
  }
1634
1737
  const openProofLine = formatOpenProofLine(result.open_proof);
1635
1738
  if (openProofLine) lines.push(openProofLine);
1739
+ const byoPresenceLine = formatByoPresenceLine(result.byo_presence);
1740
+ if (byoPresenceLine) lines.push(byoPresenceLine);
1636
1741
  return lines.join("\n");
1637
1742
  }
1638
1743
 
@@ -1690,6 +1795,14 @@ async function fsck(args) {
1690
1795
  // status as a mirror probe's failure would be).
1691
1796
  const openProofLine = formatOpenProofLine(result.open_proof);
1692
1797
  if (openProofLine) console.error(openProofLine);
1798
+ // gitvault-byo-primary-bucket task 3.3: absent (formats to `null`) for a
1799
+ // managed vault — `result.byo_presence` is `undefined` there, so this
1800
+ // line never prints and JSON-mode output for a managed vault is
1801
+ // unchanged. A missing-object verdict never reaches this line at all —
1802
+ // it throws `GITVAULT_BYO_OBJECT_MISSING` before `fsck` returns, caught
1803
+ // by this function's own `reportSdkError(err)` below.
1804
+ const byoPresenceLine = formatByoPresenceLine(result.byo_presence);
1805
+ if (byoPresenceLine) console.error(byoPresenceLine);
1693
1806
  if (mirrorRequested && result.mirror) {
1694
1807
  console.error(`mirror: recoverable generation ${result.mirror.recovered_generation}${result.mirror.chain_break ? ` (chain break at ${result.mirror.chain_break.generation}: ${result.mirror.chain_break.reason})` : ""}.`);
1695
1808
  if (result.mirror.data_loss_detected) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "run402",
3
- "version": "4.63.1",
3
+ "version": "4.64.0",
4
4
  "description": "CLI for Run402 — full-stack backend infrastructure for AI agents: Postgres, auth, storage, serverless functions and atomic deploys. Paid with x402/MPP. Includes $0.03 image generation.",
5
5
  "type": "module",
6
6
  "bin": {