run402 4.52.0 → 4.54.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 (35) hide show
  1. package/cli.mjs +6 -0
  2. package/git-remote-run402.mjs +28 -5
  3. package/gitvault-surface.json +1 -1
  4. package/lib/command-manifest.mjs +2 -0
  5. package/lib/doctor.mjs +66 -6
  6. package/lib/repos.mjs +92 -2
  7. package/lib/source-access.mjs +159 -0
  8. package/package.json +1 -1
  9. package/sdk/dist/namespaces/gitvault.crypto.d.ts +41 -0
  10. package/sdk/dist/namespaces/gitvault.crypto.d.ts.map +1 -1
  11. package/sdk/dist/namespaces/gitvault.crypto.js +135 -0
  12. package/sdk/dist/namespaces/gitvault.crypto.js.map +1 -1
  13. package/sdk/dist/namespaces/gitvault.d.ts +32 -0
  14. package/sdk/dist/namespaces/gitvault.d.ts.map +1 -1
  15. package/sdk/dist/namespaces/gitvault.js +70 -6
  16. package/sdk/dist/namespaces/gitvault.js.map +1 -1
  17. package/sdk/dist/namespaces/operator-session.d.ts +58 -0
  18. package/sdk/dist/namespaces/operator-session.d.ts.map +1 -1
  19. package/sdk/dist/namespaces/operator-session.js +29 -0
  20. package/sdk/dist/namespaces/operator-session.js.map +1 -1
  21. package/sdk/dist/node/gitvault-address.d.ts +47 -0
  22. package/sdk/dist/node/gitvault-address.d.ts.map +1 -1
  23. package/sdk/dist/node/gitvault-address.js +80 -14
  24. package/sdk/dist/node/gitvault-address.js.map +1 -1
  25. package/sdk/dist/node/gitvault-member-bundle.d.ts +88 -0
  26. package/sdk/dist/node/gitvault-member-bundle.d.ts.map +1 -0
  27. package/sdk/dist/node/gitvault-member-bundle.js +168 -0
  28. package/sdk/dist/node/gitvault-member-bundle.js.map +1 -0
  29. package/sdk/dist/node/gitvault-publication.d.ts.map +1 -1
  30. package/sdk/dist/node/gitvault-publication.js +16 -1
  31. package/sdk/dist/node/gitvault-publication.js.map +1 -1
  32. package/sdk/dist/node/gitvault-recover.d.ts +41 -1
  33. package/sdk/dist/node/gitvault-recover.d.ts.map +1 -1
  34. package/sdk/dist/node/gitvault-recover.js +73 -11
  35. package/sdk/dist/node/gitvault-recover.js.map +1 -1
package/cli.mjs CHANGED
@@ -68,6 +68,7 @@ AUTHORITY — who may act, and with what credential
68
68
  auth Manage project user authentication (magic link, passwords, settings)
69
69
  ci Link GitHub Actions OIDC deploy bindings
70
70
  operator Operator (human/email) session — login, then overview across your wallets
71
+ source-access Your gitvault member-key wrappers (status) + recovery bundle (export)
71
72
 
72
73
  DELIVER — reach a human when something happens
73
74
  deliveries Did a notification actually land (list, get)
@@ -438,6 +439,11 @@ switch (cmd) {
438
439
  await run(sub, rest);
439
440
  break;
440
441
  }
442
+ case "source-access": {
443
+ const { run } = await import("./lib/source-access.mjs");
444
+ await run(sub, rest);
445
+ break;
446
+ }
441
447
  case "auth": {
442
448
  const { run } = await import("./lib/auth.mjs");
443
449
  await run(sub, rest);
@@ -409,7 +409,7 @@ async function main(argv) {
409
409
  */
410
410
  const openVault = async (repoDir) => {
411
411
  const result = await getSdk().gitvault.resolveOrCreateAddress({ address, allow_create: false, ...(repoDir ? { repo_dir: repoDir } : {}) });
412
- return result.handle.vault;
412
+ return { vault: result.handle.vault, resolution: result.resolution };
413
413
  };
414
414
 
415
415
  /**
@@ -448,7 +448,7 @@ async function main(argv) {
448
448
  addressForm === "id"
449
449
  ? await getSdk().gitvault.openOrCreate({ ...target, repo_dir: repoDir })
450
450
  : await getSdk().gitvault.resolveOrCreateAddress({ address, repo_dir: repoDir, allow_create: true });
451
- if (addressForm === "id" && repoDir) await pinGitvaultRepo(repoDir, result.handle.repo_id);
451
+ if (addressForm === "id" && repoDir) await pinGitvaultRepo(repoDir, result.handle.repo_id, undefined, { project_id: target.project_id, org_id: target.org_id });
452
452
  if (!result.found && result.created) {
453
453
  note("");
454
454
  note(`vault ${result.handle.repo_id} allocated (genesis ${result.created.genesis_sha256}) — one-shot recovery receipt, keep many copies:`);
@@ -485,8 +485,26 @@ async function main(argv) {
485
485
  let vault;
486
486
  let state;
487
487
  try {
488
- vault = await openVault(repoDir ?? undefined);
489
- state = await vault.materialize();
488
+ const opened = await openVault(repoDir ?? undefined);
489
+ vault = opened.vault;
490
+ try {
491
+ state = await vault.materialize();
492
+ } catch (err) {
493
+ // An OFFLINE (id-carrying pin) resolution discovers a stale pin on
494
+ // its FIRST repo-scoped read (client-surface spec, id-pinning
495
+ // requirement): recover once — clear the pin, re-resolve — and retry
496
+ // only when re-resolution lands on a DIFFERENT vault; a same-id
497
+ // answer means the pin was fine and the refusal below is real. git
498
+ // always runs `list` first in a helper session, so this one site
499
+ // heals the pin for the `fetch`/`push` that follows it.
500
+ const recovered = repoDir && opened.resolution?.offline
501
+ ? await getSdk().gitvault.recoverStalePin({ address, repo_dir: repoDir, resolution: opened.resolution, error: err })
502
+ : null;
503
+ if (!recovered) throw err;
504
+ note(`pinned vault ${opened.resolution.repo_id} no longer resolves — re-resolved to ${recovered.resolution.repo_id}, retrying`);
505
+ vault = recovered.handle.vault;
506
+ state = await vault.materialize();
507
+ }
490
508
  } catch (err) {
491
509
  // An unallocated vault is not an error here: `list` is the read half of
492
510
  // the protocol dance and must never create anything on its own (D2
@@ -594,7 +612,7 @@ async function main(argv) {
594
612
  // first) — only the sizing is unavailable.
595
613
  let vault;
596
614
  try {
597
- vault = await openVault(repoDir);
615
+ vault = (await openVault(repoDir)).vault;
598
616
  } catch (err) {
599
617
  if (!isVaultNotFound(err)) throw err;
600
618
  note("dry-run: no vault allocated for this project yet — a real push would allocate one (push-to-create) before publishing; object/byte sizing is not knowable until then");
@@ -680,6 +698,11 @@ async function main(argv) {
680
698
  // The transaction is atomic, so a failure failed every ref in it. Report
681
699
  // it against each one rather than letting some look like they landed.
682
700
  if (err?.code === "GIT_INVOCATION_REPO_UNRESOLVED") repoRefusalNote(err);
701
+ // Force-spelling truth (gitvault-force-spelling-and-pin-fold): render
702
+ // the SDK's own `git push --force` next_action beside git's per-ref
703
+ // rejection — humans read stderr, agents read the structured error.
704
+ const forceHint = Array.isArray(err?.body?.next_actions) ? err.body.next_actions.find((a) => a?.action === "git push --force") : null;
705
+ if (forceHint?.why) note(`hint: ${forceHint.action} — ${forceHint.why}`);
683
706
  const reason = describeError(err);
684
707
  for (const spec of allowed) out(`error ${spec.dst} ${reason}`);
685
708
  }
@@ -1,5 +1,5 @@
1
1
  {
2
- "surface_version": "4.52.0",
2
+ "surface_version": "4.54.0",
3
3
  "verbs": [
4
4
  "repos create",
5
5
  "repos list",
@@ -392,6 +392,8 @@ export const COMMAND_MANIFEST = [
392
392
  { path: ["agent", "status"], positionals: [], projectScoped: false, legacyPositionalProject: false, minimalArgs: [] },
393
393
  { path: ["agent", "verify-email"], positionals: [], projectScoped: false, legacyPositionalProject: false, minimalArgs: [] },
394
394
  { path: ["agent", "passkey"], positionals: [p("action")], projectScoped: false, legacyPositionalProject: false, minimalArgs: ["enroll"] },
395
+ { path: ["source-access", "status"], positionals: [], projectScoped: false, legacyPositionalProject: false, minimalArgs: [], runStyle: "sub", skipBehavioral: "reads the caller's live source-access wrapper set from the gateway" },
396
+ { path: ["source-access", "export"], positionals: [], projectScoped: false, legacyPositionalProject: false, minimalArgs: ["--out", "-"], runStyle: "sub", skipBehavioral: "exports the live member recovery bundle (stamps recovery-posture export evidence server-side)" },
395
397
  { path: ["operator", "login"], positionals: [], projectScoped: false, legacyPositionalProject: false, minimalArgs: [], skipBehavioral: "opens a browser / loopback listener" },
396
398
  { path: ["operator", "logout"], positionals: [], projectScoped: false, legacyPositionalProject: false, minimalArgs: [] },
397
399
  { path: ["operator", "overview"], positionals: [], projectScoped: false, legacyPositionalProject: false, minimalArgs: [] },
package/lib/doctor.mjs CHANGED
@@ -51,6 +51,7 @@ const DOCTOR_CHECK_NAMES = [
51
51
  "tier",
52
52
  "operator_health",
53
53
  "runtime_staleness",
54
+ "recovery_posture",
54
55
  "gitvault",
55
56
  "source_scan",
56
57
  ];
@@ -132,6 +133,12 @@ Checks performed:
132
133
  - Function runtime staleness: deployed functions running an older platform
133
134
  runtime than the current gateway build (refresh with 'run402 functions
134
135
  rebuild --all'; re-bundles from your stored source, no source change)
136
+ - Recovery posture: per vault-owning org, whether a human owner has a
137
+ working control-plane login and whether any member holds a working
138
+ source-access key (wrapper custody), plus a legacy-custody warning —
139
+ the org's disaster backstops if the agent machine dies. Evidence
140
+ levels: "configured" is what the platform verified, never proof an
141
+ off-platform passkey or saved code still exists.
135
142
  - gitvault: the active project's vault — activation policy, whether THIS
136
143
  machine can produce the capture a 'required' policy demands, open
137
144
  unvaulted-override journals, and where the keystore lives (back it up:
@@ -406,12 +413,13 @@ export async function run(sub, args = []) {
406
413
  }
407
414
 
408
415
  // 6. Operator health snapshot (v1.55 + v1.56 verification attempt detail).
409
- // Both checks below ride the SAME operator-status read (runtime_staleness
410
- // reuses the response operator_health already pulled), so the whole block
411
- // is gated on wanting EITHER — --only runtime_staleness alone still needs
412
- // this read, but --only-ing neither skips it entirely, same "don't do the work of a
413
- // check nobody asked for" discipline the rest of --only follows.
414
- if (wanted("operator_health") || wanted("runtime_staleness")) try {
416
+ // The checks below all ride the SAME operator-status read (runtime_staleness
417
+ // and recovery_posture reuse the response operator_health already pulled),
418
+ // so the whole block is gated on wanting ANY — --only runtime_staleness
419
+ // alone still needs this read, but --only-ing none skips it entirely, same
420
+ // "don't do the work of a check nobody asked for" discipline the rest of
421
+ // --only follows.
422
+ if (wanted("operator_health") || wanted("runtime_staleness") || wanted("recovery_posture")) try {
415
423
  const sdk = getSdk();
416
424
  const status = await sdk.admin.getOperatorStatus();
417
425
  const gaps = [];
@@ -502,6 +510,53 @@ export async function run(sub, args = []) {
502
510
  });
503
511
  }
504
512
  }
513
+
514
+ // 6c. Org recovery posture (gitvault-recovery-custody). One entry per
515
+ // vault-owning org the caller can see; rides the same operator-status
516
+ // read. Evidence levels, not guarantees: "configured" names what the
517
+ // platform VERIFIED — it can never observe whether an off-platform
518
+ // passkey or saved code still exists. The two headline facts mirror the
519
+ // feed events org_recovery_posture_degraded/_recovered; each gap line
520
+ // carries its remedy (Anticipatory), same shape as the reachability gaps
521
+ // above.
522
+ if (wanted("recovery_posture")) {
523
+ const posture = status.recovery_posture;
524
+ if (!Array.isArray(posture)) {
525
+ // Gateway older than gitvault-recovery-custody doesn't surface it.
526
+ checks.push({
527
+ name: "recovery_posture",
528
+ status: "skipped",
529
+ ...(verbose && { hint: "operator status has no 'recovery_posture' block; requires a gitvault-recovery-custody gateway." }),
530
+ });
531
+ } else if (posture.length === 0) {
532
+ // No vault-owning org in the caller's view — nothing to lose, nothing to advise.
533
+ checks.push({ name: "recovery_posture", status: "ok", value: { orgs: [] } });
534
+ } else {
535
+ const gaps = [];
536
+ for (const org of posture) {
537
+ const label = `org ${org.org_id} (${org.vault_count} vault${org.vault_count === 1 ? "" : "s"})`;
538
+ if (org.control_plane_configured === false) {
539
+ gaps.push(`${label}: no human owner with a working control-plane login — if this org's agent machine dies, nobody can sign in to recover it. Invite a backup human (run402 org invite create ${org.org_id} --email <their-email> --role owner) and have them complete login at console.run402.com.`);
540
+ }
541
+ if (org.source_backup_configured === false) {
542
+ gaps.push(`${label}: no human member holds a working source-access key — vault history has no member-side decryption backup. Have a member complete source enrollment at console.run402.com/account → Source access.`);
543
+ }
544
+ if (org.custody_legacy_present === true) {
545
+ gaps.push(`${label}: a member key is still on single-credential legacy custody (one passkey, no recovery code — losing that one credential loses source access). Re-enroll at console.run402.com/account to move to wrapper custody with a recovery code.`);
546
+ }
547
+ }
548
+ checks.push(
549
+ gaps.length > 0
550
+ ? {
551
+ name: "recovery_posture",
552
+ status: "warning",
553
+ value: { orgs: posture, gaps },
554
+ hint: "These are the org's disaster-recovery backstops — the same facts arrive as org_recovery_posture_degraded/_recovered feed events. After enrolling, export the recovery bundle (run402 source-access export) and store it separately from the code.",
555
+ }
556
+ : { name: "recovery_posture", status: "ok", value: { orgs: posture } },
557
+ );
558
+ }
559
+ }
505
560
  } catch (err) {
506
561
  // Operator status endpoint may not be reachable if the operator-binding
507
562
  // substrate isn't deployed yet on the target API. Don't fail the whole
@@ -518,6 +573,11 @@ export async function run(sub, args = []) {
518
573
  status: "skipped",
519
574
  message: describeCheckFailure("operator status check", err),
520
575
  });
576
+ if (wanted("recovery_posture")) checks.push({
577
+ name: "recovery_posture",
578
+ status: "skipped",
579
+ message: describeCheckFailure("operator status check", err),
580
+ });
521
581
  }
522
582
 
523
583
  // 6c. gitvault (add-gitvault). Doctor was completely silent about the vault
package/lib/repos.mjs CHANGED
@@ -65,7 +65,8 @@ Then plain git, forever:
65
65
  Occasional:
66
66
  run402 repos snapshot [--project <id>] [--repo <repo_id>] [--message <text>] [--checkpoint] [--dry-run] [--allow-dirty] [--manifest-out <path>]
67
67
  run402 repos mirror [<destination>] [--off] [--backfill] [--profile <name> | --ambient] [--region <r>] [--endpoint <url>] [--project <id>] [--repo <repo_id>]
68
- run402 repos recover <source> --out <dir> [--repo <repo_id>] [--profile <name> | --ambient] [--region <r>] [--endpoint <url>] [--human]
68
+ run402 repos recover <source> --out <dir> [--repo <repo_id>] [--profile <name> | --ambient] [--region <r>] [--endpoint <url>]
69
+ [--bundle <file>] [--code <SRC1-…>] [--receipt <file>] [--rp-id <host>] [--human]
69
70
 
70
71
  Lifecycle:
71
72
  run402 repos rename <new_name> [--repo <repo_id> | --project <project_id>]
@@ -164,6 +165,15 @@ Subcommands:
164
165
  mirror's validity, never freshness — read both honesty statements
165
166
  before relying on the result. \`--human\` renders a short summary
166
167
  instead of JSON.
168
+ A human member under wrapper custody (no keystore) recovers with
169
+ their exported recovery bundle + source recovery code:
170
+ \`--bundle <file>\` (omit to use the mirror's own
171
+ member-recovery-bundles/ sidecar) + \`--code\` (prompted, hidden,
172
+ when omitted) + \`--receipt <pin.json>\` (the vault's one-shot
173
+ recovery receipt — key material never substitutes for the trust
174
+ anchor). A raw WebAuthn PRF output is NOT a supported input; a
175
+ code with no exported bundle refuses by name (a server-side
176
+ wrapper row that was never exported is not offline backup).
167
177
  fsck Walks the head chain AND materializes
168
178
  the ref map, advancing BOTH
169
179
  local trust pins — reported EXPLICITLY as local_state_changed +
@@ -291,6 +301,19 @@ Options:
291
301
  --region <r> mirror / recover: AWS region for an s3:// destination
292
302
  --endpoint <url> mirror / recover: an S3-compatible endpoint override
293
303
  --out <dir> recover: where to materialize the recovered repository
304
+ --bundle <file> recover: an exported r402s-member-recovery-bundle/v1 (from
305
+ \`run402 source-access export\` or the console's download).
306
+ Omit to use the mirror's member-recovery-bundles/ sidecar.
307
+ --code <SRC1-…> recover: the source recovery code that opens the bundle.
308
+ Prefer omitting it — with --bundle set it is prompted with
309
+ hidden input, so it never lands in shell history.
310
+ --receipt <file> recover: the vault's recovery-receipt pin as JSON (the
311
+ one-shot receipt from repo creation). Required for trusted
312
+ recovery when no keystore holds it — without any pin the
313
+ result is labeled unauthenticated_salvage.
314
+ --rp-id <host> recover: the seal-time ceremony host bound into the
315
+ wrapper context (default: the bundle's own rp_id, then
316
+ console.run402.com — where every wrapper is sealed today)
294
317
  --budget <n> fsck: heads walked in this call (write mode persists the
295
318
  verified prefix, so a budget-exceeded run resumes; a
296
319
  --no-write run does not, since nothing was persisted)
@@ -340,6 +363,7 @@ Examples:
340
363
  run402 repos gc
341
364
  run402 repos access --human
342
365
  run402 repos recover s3://acme-vault-mirror --out ./restored --human
366
+ run402 repos recover ./mirror-copy --out ./restored --receipt ./recovery-receipt.json --bundle ./run402-source-recovery-bundle.json
343
367
  run402 repos delete --project prj_xyz --force
344
368
  `;
345
369
 
@@ -1556,6 +1580,19 @@ async function fsck(args) {
1556
1580
  if (result.mirror.data_loss_detected) {
1557
1581
  console.error(`DATA LOSS DETECTED: ${result.mirror.absences.filter((x) => x.adjudication === "unexplained_absence").length} object(s) are unexplained absences.`);
1558
1582
  }
1583
+ // gitvault-recovery-custody: member recovery-bundle sidecars, reported
1584
+ // as UNVERIFIED availability hints — nothing about them is chain-
1585
+ // authenticated; they only say bundle + source recovery code can
1586
+ // recover this mirror with no server.
1587
+ if (Array.isArray(result.mirror.member_recovery_bundles) && result.mirror.member_recovery_bundles.length > 0) {
1588
+ for (const b of result.mirror.member_recovery_bundles) {
1589
+ console.error(
1590
+ b.parse_error
1591
+ ? `member recovery bundle (unverified hint): ${b.key} — does not parse (${b.parse_error})`
1592
+ : `member recovery bundle (unverified hint): ${b.key} — ${b.ek_fingerprint} [${b.wrapper_kinds.join(", ")}]; recover with \`run402 repos recover <source> --receipt <pin.json>\` + the source recovery code`,
1593
+ );
1594
+ }
1595
+ }
1559
1596
  printMirrorHonesty(result.mirror);
1560
1597
  }
1561
1598
  printVerboseStats(a, sdk);
@@ -1876,6 +1913,9 @@ function formatRecoverHuman(result, outDir) {
1876
1913
  if (result.data_loss_detected) {
1877
1914
  lines.push(`DATA LOSS DETECTED: ${result.absences.filter((x) => x.adjudication === "unexplained_absence").length} object(s) are unexplained absences.`);
1878
1915
  }
1916
+ if (result.member_recovery) {
1917
+ lines.push(`Decrypted via member recovery bundle${result.member_recovery.bundle_key ? ` ${result.member_recovery.bundle_key}` : ""} + source recovery code (no keystore).`);
1918
+ }
1879
1919
  lines.push(`Layout: ${result.layout}` + (result.layout === "bare" ? " (no working files — not a failed recovery)" : ""));
1880
1920
  if (result.retained_refs?.warning) {
1881
1921
  lines.push(`refs/r402/retain: ${result.retained_refs.warning}`);
@@ -1885,9 +1925,40 @@ function formatRecoverHuman(result, outDir) {
1885
1925
  return lines.join("\n");
1886
1926
  }
1887
1927
 
1928
+ /**
1929
+ * Read the source recovery code without echoing it (TTY) — a recovery code
1930
+ * is long-lived key material; it must never land in shell history (prefer
1931
+ * the prompt over `--code <value>`) and never echo into a scrollback.
1932
+ * Non-TTY stdin (piped) reads one line verbatim.
1933
+ */
1934
+ async function promptSourceRecoveryCode() {
1935
+ const { stdin, stderr } = process;
1936
+ if (!stdin.isTTY) {
1937
+ const chunks = [];
1938
+ for await (const c of stdin) {
1939
+ chunks.push(c);
1940
+ const s = Buffer.concat(chunks).toString("utf8");
1941
+ if (s.includes("\n")) return s.slice(0, s.indexOf("\n")).trim();
1942
+ }
1943
+ return Buffer.concat(chunks).toString("utf8").trim();
1944
+ }
1945
+ const { createInterface } = await import("node:readline");
1946
+ stderr.write("Source recovery code (SRC1-…, input hidden): ");
1947
+ return await new Promise((resolve) => {
1948
+ const rl = createInterface({ input: stdin, terminal: true });
1949
+ // Mute the echo: readline in terminal mode writes through _writeToOutput.
1950
+ rl._writeToOutput = () => {};
1951
+ rl.question("", (answer) => {
1952
+ rl.close();
1953
+ stderr.write("\n");
1954
+ resolve(answer.trim());
1955
+ });
1956
+ });
1957
+ }
1958
+
1888
1959
  async function recover(args) {
1889
1960
  const a = normalizeArgv(args);
1890
- const valueFlags = ["--out", "--repo", "--profile", "--region", "--endpoint"];
1961
+ const valueFlags = ["--out", "--repo", "--profile", "--region", "--endpoint", "--bundle", "--code", "--receipt", "--rp-id"];
1891
1962
  assertKnownFlags(a, [...valueFlags, "--ambient", "--human", "-v", "--verbose", "--help", "-h"], valueFlags);
1892
1963
  const [source] = requirePositionalCount(a, valueFlags, {
1893
1964
  min: 1, max: 1, command: "run402 repos recover <source> --out <dir>",
@@ -1906,6 +1977,18 @@ async function recover(args) {
1906
1977
  const repoId = flagValue(a, "--repo");
1907
1978
  const region = flagValue(a, "--region");
1908
1979
  const endpoint = flagValue(a, "--endpoint");
1980
+ // gitvault-recovery-custody — the human-member path: --bundle (the exported
1981
+ // r402s-member-recovery-bundle/v1; omit to use the mirror's own
1982
+ // member-recovery-bundles/ sidecar) + the source recovery code. --receipt
1983
+ // supplies the recovery-receipt pin when no keystore holds one (a member
1984
+ // has no keystore); --rp-id overrides the seal-time ceremony host.
1985
+ const bundlePath = flagValue(a, "--bundle");
1986
+ const receiptPath = flagValue(a, "--receipt");
1987
+ const rpId = flagValue(a, "--rp-id");
1988
+ let code = flagValue(a, "--code");
1989
+ const memberBundle = bundlePath != null ? readJsonFile("--bundle", bundlePath) : undefined;
1990
+ const recoveryReceipt = receiptPath != null ? readJsonFile("--receipt", receiptPath) : undefined;
1991
+ if (memberBundle !== undefined && code == null) code = await promptSourceRecoveryCode();
1909
1992
  try {
1910
1993
  const result = await sdk.gitvault.recover({
1911
1994
  source, out_dir: outDir,
@@ -1913,6 +1996,10 @@ async function recover(args) {
1913
1996
  ...(credential ? { credential } : {}),
1914
1997
  ...(region != null ? { region } : {}),
1915
1998
  ...(endpoint != null ? { endpoint } : {}),
1999
+ ...(memberBundle !== undefined ? { member_bundle: memberBundle } : {}),
2000
+ ...(code != null ? { source_recovery_code: code } : {}),
2001
+ ...(recoveryReceipt !== undefined ? { recovery_receipt: recoveryReceipt } : {}),
2002
+ ...(rpId != null ? { rp_id: rpId } : {}),
1916
2003
  });
1917
2004
  if (human) {
1918
2005
  console.log(formatRecoverHuman(result, outDir));
@@ -1924,6 +2011,9 @@ async function recover(args) {
1924
2011
  printJson(sdk, result);
1925
2012
  await spillIfLarge(result.repo_id, "recover", result);
1926
2013
  console.error(`recovered generation ${result.recovered_generation} for ${result.repo_id} into ${outDir}` + (result.chain_break ? ` (chain break at ${result.chain_break.generation} — fell back to the newest fully-verified generation)` : "") + ".");
2014
+ if (result.member_recovery) {
2015
+ console.error(`decrypted via member recovery bundle${result.member_recovery.bundle_key ? ` ${result.member_recovery.bundle_key}` : ""} (wrapper ${result.member_recovery.wrapper_id}, ${result.member_recovery.ek_fingerprint}) + source recovery code — no keystore involved.`);
2016
+ }
1927
2017
  if (result.data_loss_detected) {
1928
2018
  console.error(`DATA LOSS DETECTED: ${result.absences.filter((x) => x.adjudication === "unexplained_absence").length} object(s) are unexplained absences — see "absences" in the result above.`);
1929
2019
  }
@@ -0,0 +1,159 @@
1
+ /**
2
+ * `run402 source-access` — a human member's source-access custody, read side.
3
+ *
4
+ * Gateway subsystem: gitvault-recovery-custody (/agent/v1/source-access/*).
5
+ * A member's gitvault decryption key exists only as sealed `swrap2_` wrappers
6
+ * (passkey PRF and/or source recovery code). Enrollment, activation, and
7
+ * revocation are BROWSER ceremonies (WebAuthn) at console.run402.com/account
8
+ * — this command family is deliberately read-only: `status` (what wrappers
9
+ * exist, their states, the custody scheme) and `export` (the versioned
10
+ * member recovery bundle that makes the recovery code work with NO run402
11
+ * server). A server-side wrapper row alone is NOT offline backup — the
12
+ * exported bundle, kept in your own storage separately from the code, is.
13
+ *
14
+ * Auth: your control-plane (human) session — `run402 operator login
15
+ * --loopback` first. Without one the request falls back to the active
16
+ * WALLET identity, which answers for the AGENT principal (normally no
17
+ * wrappers) — truthful, but probably not what a human wanted; a stderr note
18
+ * says so.
19
+ */
20
+ import { writeFileSync } from "node:fs";
21
+ import { getSdk } from "./sdk.mjs";
22
+ import { reportSdkError } from "./sdk-errors.mjs";
23
+ import {
24
+ normalizeArgv,
25
+ hasHelp,
26
+ assertKnownFlags,
27
+ flagValue,
28
+ requirePositionalCount,
29
+ failUnknownSubcommand,
30
+ } from "./argparse.mjs";
31
+ import { readControlPlaneSession, isControlPlaneSessionExpired } from "../core-dist/control-plane-session.js";
32
+
33
+ const HELP = `run402 source-access — your source-access key wrappers (gitvault member custody)
34
+
35
+ Usage:
36
+ run402 source-access status
37
+ run402 source-access export [--out <file> | --out -]
38
+
39
+ Commands:
40
+ status Your source-access key + wrapper set: custody scheme, each
41
+ wrapper's kind (webauthn_prf / recovery_code) and state
42
+ (pending / active / revoked). Principal-scoped — only ever YOUR
43
+ wrappers. Read-only.
44
+ export Download your versioned member recovery bundle
45
+ (r402s-member-recovery-bundle/v1): key identity + every ACTIVE
46
+ wrapper ciphertext. Together with your source recovery code —
47
+ kept SEPARATELY — it recovers your vaults with no run402 server
48
+ (\`run402 repos recover <mirror> --bundle <file> --receipt <pin>\`).
49
+ Writes run402-source-recovery-bundle-<fingerprint>.json (0600) in
50
+ the current directory unless --out says otherwise; the full JSON
51
+ also goes to stdout (pipe contract). To make the bundle travel
52
+ WITH a vault mirror, copy it to member-recovery-bundles/<name>.json
53
+ under the mirrored prefix — recover finds it there automatically.
54
+
55
+ Options:
56
+ --out <file> export: where to write the bundle (0600). \`--out -\` skips
57
+ the file and prints to stdout only.
58
+ --json Already the default output; accepted for consistency.
59
+
60
+ Auth:
61
+ Sign in as YOURSELF first: run402 operator login --loopback
62
+ (Without a control-plane session the call answers for the active WALLET's
63
+ agent principal — normally an empty wrapper set.)
64
+
65
+ Enrollment / activation / revocation / code replacement are browser
66
+ ceremonies: console.run402.com/account → Source access.
67
+
68
+ Examples:
69
+ run402 operator login --loopback
70
+ run402 source-access status
71
+ run402 source-access export --out ./bundle.json
72
+ `;
73
+
74
+ /** The cached control-plane WRITE session's bearer, or null (falls back to wallet SIWX with a stderr note). */
75
+ function controlPlaneToken() {
76
+ const cp = readControlPlaneSession();
77
+ if (cp && !isControlPlaneSessionExpired(cp, Date.now())) return cp.control_plane_session_token;
78
+ return null;
79
+ }
80
+
81
+ function tokenOptsWithNote(command) {
82
+ const token = controlPlaneToken();
83
+ if (!token) {
84
+ process.stderr.write(
85
+ `no control-plane (human) session — ${command} will answer for the active WALLET's agent principal, which normally holds no wrappers. Run 'run402 operator login --loopback' to see your own.\n`,
86
+ );
87
+ return {};
88
+ }
89
+ return { token };
90
+ }
91
+
92
+ async function status(args) {
93
+ assertKnownFlags(args, ["--json", "--help", "-h"]);
94
+ requirePositionalCount(args, [], { min: 0, max: 0, command: "run402 source-access status", missing: "" });
95
+ const sdk = getSdk();
96
+ try {
97
+ const result = await sdk.operator.session.sourceAccessWrappers(tokenOptsWithNote("status"));
98
+ console.log(JSON.stringify(result, null, 2));
99
+ if (!result.encryption_key) {
100
+ console.error("no source-access key enrolled — enroll at console.run402.com/account → Source access.");
101
+ return;
102
+ }
103
+ const active = result.wrappers.filter((w) => w.state === "active");
104
+ const pending = result.wrappers.filter((w) => w.state === "pending");
105
+ console.error(
106
+ `${result.encryption_key.ek_fingerprint} (${result.encryption_key.custody_scheme}, ${result.encryption_key.state}): ` +
107
+ `${active.length} active wrapper(s) [${active.map((w) => w.kind).join(", ") || "none"}]` +
108
+ (pending.length > 0 ? `, ${pending.length} pending (unfinished enrollment — finish or it expires)` : "") + ".",
109
+ );
110
+ if (active.length > 0 && !active.some((w) => w.kind === "recovery_code")) {
111
+ console.error("no recovery_code wrapper — a passkey-only key has no offline/no-server recovery path; add a recovery code at console.run402.com/account.");
112
+ }
113
+ } catch (err) {
114
+ reportSdkError(err);
115
+ }
116
+ }
117
+
118
+ async function exportBundle(args) {
119
+ assertKnownFlags(args, ["--out", "--json", "--help", "-h"], ["--out"]);
120
+ requirePositionalCount(args, ["--out"], { min: 0, max: 0, command: "run402 source-access export", missing: "" });
121
+ const out = flagValue(args, "--out");
122
+ const sdk = getSdk();
123
+ try {
124
+ const bundle = await sdk.operator.session.sourceAccessRecoveryBundle(tokenOptsWithNote("export"));
125
+ // Full JSON to stdout regardless — the pipe contract is sacred; the file
126
+ // is the keep-a-copy convenience (0600 — the bundle is ciphertext the
127
+ // platform cannot open, but it is still half of a recovery credential).
128
+ console.log(JSON.stringify(bundle, null, 2));
129
+ if (out !== "-") {
130
+ const path = out ?? `run402-source-recovery-bundle-${(bundle.ek_fingerprint || "key").slice(0, 11)}.json`;
131
+ writeFileSync(path, JSON.stringify(bundle, null, 2) + "\n", { mode: 0o600 });
132
+ console.error(`bundle written to ${path} (0600).`);
133
+ }
134
+ console.error("keep this bundle SEPARATELY from your source recovery code — together they are equivalent to your member private key.");
135
+ console.error("to make it travel with a vault mirror: copy it to member-recovery-bundles/<name>.json under the mirrored prefix; `run402 repos recover` finds it there.");
136
+ } catch (err) {
137
+ reportSdkError(err);
138
+ }
139
+ }
140
+
141
+ export async function run(sub, args = []) {
142
+ args = normalizeArgv(args);
143
+ if (!sub || sub === "--help" || sub === "-h" || hasHelp(args)) {
144
+ console.log(HELP);
145
+ process.exit(0);
146
+ }
147
+ switch (sub) {
148
+ case "status":
149
+ await status(args);
150
+ break;
151
+ case "export":
152
+ await exportBundle(args);
153
+ break;
154
+ default:
155
+ failUnknownSubcommand("source-access", sub, {
156
+ hint: "Run `run402 source-access --help` for usage (subcommands: status, export).",
157
+ });
158
+ }
159
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "run402",
3
- "version": "4.52.0",
3
+ "version": "4.54.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": {
@@ -562,5 +562,46 @@ export declare function checkPinManifestConservation(prior: readonly {
562
562
  /** The synthetic ledger id a path-addressed `key_envelope` gets — mirrors the gateway's `keyEnvelopeLedgerId` exactly (drift here breaks receipt pairing at upload finalize). */
563
563
  export declare function keyEnvelopeLedgerId(epoch: string, fingerprint: string, rotationId: string | null): string;
564
564
  export declare function pinManifestLedgerId(pinManifestVersion: string): string;
565
+ export declare const SOURCE_WRAP_BLOB_PREFIX = "swrap2_";
566
+ export declare const SOURCE_RC_DISPLAY_PREFIX = "SRC1";
567
+ export type SourceWrapperKind = "webauthn_prf" | "recovery_code";
568
+ export interface SourceWrapperContextFields {
569
+ /** The seal-time ceremony host (`location.hostname` of the sealing page — `console.run402.com` for every wrapper sealed today). */
570
+ rp_id: string;
571
+ principal_id: string;
572
+ encryption_key_id: string;
573
+ wrapper_id: string;
574
+ kind: SourceWrapperKind;
575
+ /** WebAuthn `public_subject` for `webauthn_prf` wrappers; always null in the JCS for `recovery_code`. */
576
+ credential_subject: string | null;
577
+ /** The raw 32-byte member public key — the context binds its FULL SHA-256, never the truncated `ek_` fingerprint. */
578
+ member_public_key: Uint8Array;
579
+ }
580
+ /** The pinned canonical context for one wrapper — BOTH the KEK HKDF-info suffix AND the AEAD AAD. */
581
+ export declare function buildSourceWrapperContext(fields: SourceWrapperContextFields): Uint8Array;
582
+ /**
583
+ * Open a `swrap2_...` wrapper blob back into the 32-byte member scalar.
584
+ * `ikm` is the UTF-8 bytes of the normalized code CORE (`recovery_code`) or
585
+ * the raw PRF output (`webauthn_prf` — only ever exercised by browser
586
+ * surfaces; offline recovery refuses raw PRF as an input by policy, at the
587
+ * recover layer). A failed AEAD is `WRAPPER_DID_NOT_OPEN` — the truthful
588
+ * cause-neutral error: wrong code, corrupt blob, and wrong context (rp_id
589
+ * included) are indistinguishable here. The caller MUST compare the derived
590
+ * FULL public key against the published one before trusting the scalar.
591
+ */
592
+ export declare function openSourceWrapper(input: {
593
+ kind: SourceWrapperKind;
594
+ ikm: Uint8Array;
595
+ blob: string;
596
+ context: Uint8Array;
597
+ }): Uint8Array;
598
+ /**
599
+ * Pinned normalization (ONE canonical accepted form): uppercase; strip every
600
+ * char outside [0-9A-Z]; map I→1, L→1, O→0; drop a leading "SRC1" when the
601
+ * result is 37 chars; require exactly 33 chars; validate the check character
602
+ * (`RECOVERY_CODE_CHECKSUM_INVALID` — a local typo, caught before any KEK
603
+ * derivation or wrapper read). Returns the 32-char CORE (the KEK ikm).
604
+ */
605
+ export declare function normalizeSourceRecoveryCode(input: string): string;
565
606
  export { bytesToHex, hexToBytes };
566
607
  //# sourceMappingURL=gitvault.crypto.d.ts.map