run402 4.53.0 → 4.54.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 CHANGED
@@ -85,6 +85,7 @@ PLATFORM — everything else, and the things still finding a home
85
85
  cloud Cloud portability archive export (archives create/download/status)
86
86
  archives Inspect and verify portable project archives locally
87
87
  gitvault RETIRED — moved to repos (repo-surface-consolidation); every spelling answers COMMAND_MOVED/COMMAND_REMOVED
88
+ source-access RETIRED — moved into repos (recovery-bundle + access); every spelling answers COMMAND_MOVED
88
89
  buzz Buzz human/community/agent control-plane workflows
89
90
  apps Browse and manage the app marketplace
90
91
  ai AI translation and moderation tools
@@ -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);
@@ -1,5 +1,5 @@
1
1
  {
2
- "surface_version": "4.53.0",
2
+ "surface_version": "4.54.1",
3
3
  "verbs": [
4
4
  "repos create",
5
5
  "repos list",
@@ -15,7 +15,8 @@
15
15
  "repos access repair",
16
16
  "repos access revoke-key",
17
17
  "repos access declare-exposure",
18
- "repos recover"
18
+ "repos recover",
19
+ "repos recovery-bundle"
19
20
  ],
20
21
  "retired_spellings": [
21
22
  {
@@ -261,7 +261,7 @@ export const COMMAND_MANIFEST = [
261
261
  // COMMAND_REMOVED) redirect that dispatches nothing — see
262
262
  // RESERVED_SUBCOMMANDS below, and "gitvault" in SKIPPED_FAMILIES.
263
263
 
264
- // ── repos (the consolidated 12-verb family) ─
264
+ // ── repos (the consolidated 13-verb family) ─
265
265
  // Every verb needs a real principal keystore and, for most, an allocated
266
266
  // repo and a local git working tree, so the gate runs structural checks
267
267
  // only — an in-process behavioral run would either no-op against the
@@ -288,6 +288,9 @@ export const COMMAND_MANIFEST = [
288
288
  { path: ["repos", "access", "revoke-key"], positionals: [p("principal_id")], projectScoped: true, legacyPositionalProject: false, minimalArgs: ["prin_00000000000000000000000000000000"], runStyle: "sub", skipBehavioral: "owner+step-up-gated: declares a recipient's key revoked (org-scoped watermark) and drives a real epoch rotation off it" },
289
289
  { path: ["repos", "access", "declare-exposure"], positionals: [], projectScoped: true, legacyPositionalProject: false, minimalArgs: [], runStyle: "sub", skipBehavioral: "owner+step-up-gated: declares this vault's epoch secret exposed, forcing every subsequent ordinary push to refuse until a rotation lands" },
290
290
  { path: ["repos", "recover"], positionals: [p("source")], projectScoped: false, legacyPositionalProject: false, minimalArgs: ["s3://example-mirror-bucket", "--out", "__SCRATCH_DIR__/recover-out"], runStyle: "sub", skipBehavioral: "materializes a git repository from a mirror source, offline, with no server call" },
291
+ // Principal-scoped (one bundle covers every vault you can read), so no
292
+ // --project — deliberately unlike its eleven vault-scoped siblings.
293
+ { path: ["repos", "recovery-bundle"], positionals: [], projectScoped: false, legacyPositionalProject: false, minimalArgs: ["--out", "-"], runStyle: "sub", skipBehavioral: "exports the caller's live member recovery bundle (stamps recovery-posture export evidence server-side)" },
291
294
  { path: ["errors"], positionals: [p("fingerprint_id", { required: false })], projectScoped: true, legacyPositionalProject: false, minimalArgs: [], runStyle: "merged" },
292
295
 
293
296
  // ── jobs ─────────────────────────────────────────────────────────────────
@@ -468,6 +471,11 @@ export const SKIPPED_FAMILIES = {
468
471
  // of UNKNOWN_COMMAND) but has zero manifest entries, since a redirect
469
472
  // dispatches nothing.
470
473
  "gitvault": "retired; every subcommand answers COMMAND_MOVED/COMMAND_REMOVED naming its `repos`/git successor",
474
+ // Shipped in exactly one release (v4.54.0, live for hours) before the
475
+ // one-noun review caught it: a gateway route namespace is not a CLI noun.
476
+ // Both verbs answer COMMAND_MOVED into the repos family for one release,
477
+ // then the spelling is reserved and answers nothing.
478
+ "source-access": "retired same-day; `export` -> `repos recovery-bundle`, `status` -> `repos access` (member_custody block)",
471
479
  // `repo` singular resolves identically to `repos` — same
472
480
  // module, same case block in cli.mjs, so it needs no manifest of its own.
473
481
  "repo": "alias for `repos`, resolves identically (design D1)",
@@ -486,6 +494,8 @@ export const SKIPPED_FAMILIES = {
486
494
  * which the family list cannot express.
487
495
  */
488
496
  export const RESERVED_SUBCOMMANDS = {
497
+ "source-access:export": "moved to `repos recovery-bundle` — the artifact `repos recover --bundle` consumes belongs to the repos family",
498
+ "source-access:status": "moved to `repos access` — your own wrapper custody rides its member_custody block; the org advisory is `doctor --only recovery_posture`",
489
499
  "rooms:who": "renamed to `rooms join` — an interrogative must not name a write",
490
500
  "rooms:send": "moved to `messages send` — the verb acts on a message",
491
501
  // The routes and SDK methods for room list/get exist (agent-room-lifecycle);
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 repos recovery-bundle) 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
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * `run402 repos` — the consolidated encrypted-repository family. One noun,
3
- * twelve verbs, each one either a `gh repo` verb, a `git` verb meaning what
3
+ * thirteen verbs, each one either a `gh repo` verb, a `git` verb meaning what
4
4
  * it means in git, or a plain-English verb for an operation with no analog.
5
5
  * `repo` singular resolves identically (`cli.mjs` dispatches both spellings
6
6
  * here).
@@ -51,7 +51,7 @@ const COMMON_VALUE_FLAGS = ["--project", "--repo"];
51
51
  export const HELP = `run402 repos — your source, encrypted before it leaves the machine
52
52
 
53
53
  Usage:
54
- run402 repos <verb> [options] — twelve verbs, tiered by how often you reach for them:
54
+ run402 repos <verb> [options] — thirteen verbs, tiered by how often you reach for them:
55
55
 
56
56
  Common:
57
57
  run402 repos create [name] [--org <org_id>] [--dir <path>] [--tier <tier>] [--project <id>]
@@ -65,7 +65,9 @@ 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]
70
+ run402 repos recovery-bundle [--out <file> | --out -]
69
71
 
70
72
  Lifecycle:
71
73
  run402 repos rename <new_name> [--repo <repo_id> | --project <project_id>]
@@ -164,6 +166,34 @@ Subcommands:
164
166
  mirror's validity, never freshness — read both honesty statements
165
167
  before relying on the result. \`--human\` renders a short summary
166
168
  instead of JSON.
169
+ A human member under wrapper custody (no keystore) recovers with
170
+ their exported recovery bundle + source recovery code:
171
+ \`--bundle <file>\` (omit to use the mirror's own
172
+ member-recovery-bundles/ sidecar) + \`--code\` (prompted, hidden,
173
+ when omitted) + \`--receipt <pin.json>\` (the vault's one-shot
174
+ recovery receipt — key material never substitutes for the trust
175
+ anchor). A raw WebAuthn PRF output is NOT a supported input; a
176
+ code with no exported bundle refuses by name (a server-side
177
+ wrapper row that was never exported is not offline backup).
178
+ recovery-bundle
179
+ Export YOUR member recovery bundle
180
+ (r402s-member-recovery-bundle/v1): key identity + every ACTIVE
181
+ wrapper ciphertext — the file \`recover --bundle\` opens with the
182
+ source recovery code, kept SEPARATELY. A server-side wrapper row
183
+ alone is NOT offline backup; this export is. Writes
184
+ run402-source-recovery-bundle-<fingerprint>.json (0600) in the
185
+ cwd unless \`--out\` says otherwise (\`--out -\` prints only); the
186
+ full JSON always goes to stdout. Principal-scoped, not
187
+ repo-scoped (one bundle covers every vault you can read) — auth
188
+ is your control-plane session (\`run402 operator login
189
+ --loopback\` first; without one it answers for the active
190
+ WALLET's agent principal, normally no wrappers, and says so).
191
+ To make it travel WITH a mirror, copy it to
192
+ member-recovery-bundles/<name>.json under the mirrored prefix —
193
+ \`recover\` finds it there automatically. Enrollment/activation/
194
+ revocation are browser ceremonies: console.run402.com/account.
195
+ Your own wrapper custody also renders in \`repos access\` (its
196
+ member_custody block) when a control-plane session is cached.
167
197
  fsck Walks the head chain AND materializes
168
198
  the ref map, advancing BOTH
169
199
  local trust pins — reported EXPLICITLY as local_state_changed +
@@ -291,6 +321,19 @@ Options:
291
321
  --region <r> mirror / recover: AWS region for an s3:// destination
292
322
  --endpoint <url> mirror / recover: an S3-compatible endpoint override
293
323
  --out <dir> recover: where to materialize the recovered repository
324
+ --bundle <file> recover: an exported r402s-member-recovery-bundle/v1 (from
325
+ \`run402 repos recovery-bundle\` or the console's download).
326
+ Omit to use the mirror's member-recovery-bundles/ sidecar.
327
+ --code <SRC1-…> recover: the source recovery code that opens the bundle.
328
+ Prefer omitting it — with --bundle set it is prompted with
329
+ hidden input, so it never lands in shell history.
330
+ --receipt <file> recover: the vault's recovery-receipt pin as JSON (the
331
+ one-shot receipt from repo creation). Required for trusted
332
+ recovery when no keystore holds it — without any pin the
333
+ result is labeled unauthenticated_salvage.
334
+ --rp-id <host> recover: the seal-time ceremony host bound into the
335
+ wrapper context (default: the bundle's own rp_id, then
336
+ console.run402.com — where every wrapper is sealed today)
294
337
  --budget <n> fsck: heads walked in this call (write mode persists the
295
338
  verified prefix, so a budget-exceeded run resumes; a
296
339
  --no-write run does not, since nothing was persisted)
@@ -340,6 +383,8 @@ Examples:
340
383
  run402 repos gc
341
384
  run402 repos access --human
342
385
  run402 repos recover s3://acme-vault-mirror --out ./restored --human
386
+ run402 repos recovery-bundle --out ./bundle.json
387
+ run402 repos recover ./mirror-copy --out ./restored --receipt ./recovery-receipt.json --bundle ./bundle.json
343
388
  run402 repos delete --project prj_xyz --force
344
389
  `;
345
390
 
@@ -1556,6 +1601,19 @@ async function fsck(args) {
1556
1601
  if (result.mirror.data_loss_detected) {
1557
1602
  console.error(`DATA LOSS DETECTED: ${result.mirror.absences.filter((x) => x.adjudication === "unexplained_absence").length} object(s) are unexplained absences.`);
1558
1603
  }
1604
+ // gitvault-recovery-custody: member recovery-bundle sidecars, reported
1605
+ // as UNVERIFIED availability hints — nothing about them is chain-
1606
+ // authenticated; they only say bundle + source recovery code can
1607
+ // recover this mirror with no server.
1608
+ if (Array.isArray(result.mirror.member_recovery_bundles) && result.mirror.member_recovery_bundles.length > 0) {
1609
+ for (const b of result.mirror.member_recovery_bundles) {
1610
+ console.error(
1611
+ b.parse_error
1612
+ ? `member recovery bundle (unverified hint): ${b.key} — does not parse (${b.parse_error})`
1613
+ : `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`,
1614
+ );
1615
+ }
1616
+ }
1559
1617
  printMirrorHonesty(result.mirror);
1560
1618
  }
1561
1619
  printVerboseStats(a, sdk);
@@ -1676,6 +1734,28 @@ function formatAccessHuman(result) {
1676
1734
  return lines.join("\n");
1677
1735
  }
1678
1736
 
1737
+ /** One stderr line for the access read's "you" block — your own wrapper custody, or the honest reason it is absent. */
1738
+ function printMemberCustodySummary(mc) {
1739
+ if (!mc) return;
1740
+ if (!mc.available) {
1741
+ console.error(`you: (not included — ${mc.reason}) ${mc.hint}`);
1742
+ return;
1743
+ }
1744
+ if (!mc.encryption_key_id) {
1745
+ console.error(`you: ${mc.hint}`);
1746
+ return;
1747
+ }
1748
+ const active = mc.wrappers.filter((w) => w.state === "active");
1749
+ const pending = mc.wrappers.filter((w) => w.state === "pending");
1750
+ console.error(
1751
+ `you: ${mc.ek_fingerprint} (${mc.custody_scheme}, ${mc.state}) — ${active.length} active wrapper(s) [${active.map((w) => w.kind).join(", ") || "none"}]` +
1752
+ (pending.length > 0 ? `, ${pending.length} pending (unfinished enrollment — finish or it expires)` : "") + ".",
1753
+ );
1754
+ if (active.length > 0 && !active.some((w) => w.kind === "recovery_code")) {
1755
+ console.error("you: no recovery_code wrapper — a passkey-only key has no offline/no-server recovery path; add one at console.run402.com/account.");
1756
+ }
1757
+ }
1758
+
1679
1759
  async function accessRead(args) {
1680
1760
  const a = normalizeArgv(args);
1681
1761
  assertKnownFlags(a, [...COMMON_VALUE_FLAGS, "--human", "-v", "--verbose", "--help", "-h"], COMMON_VALUE_FLAGS);
@@ -1688,14 +1768,42 @@ async function accessRead(args) {
1688
1768
  const target = await vaultTarget(a);
1689
1769
  try {
1690
1770
  const result = await sdk.gitvault.access(target);
1771
+ // gitvault-recovery-custody — the "you" block: YOUR OWN wrapper custody
1772
+ // (kind/state per wrapper, custody scheme), rendered inside the family's
1773
+ // custody roster read. Principal-scoped, so it needs your control-plane
1774
+ // (human) session; without one it is honestly absent-with-reason rather
1775
+ // than silently missing or misleadingly answered as the agent principal.
1776
+ // Best-effort: an older gateway or a failed read never breaks `access`.
1777
+ const cp = loadLiveControlPlaneSession();
1778
+ if (cp) {
1779
+ try {
1780
+ const mine = await sdk.operator.session.sourceAccessWrappers({ token: cp.control_plane_session_token });
1781
+ result.member_custody = mine.encryption_key
1782
+ ? {
1783
+ available: true,
1784
+ encryption_key_id: mine.encryption_key.encryption_key_id,
1785
+ ek_fingerprint: mine.encryption_key.ek_fingerprint,
1786
+ custody_scheme: mine.encryption_key.custody_scheme,
1787
+ state: mine.encryption_key.state,
1788
+ wrappers: mine.wrappers.map((w) => ({ wrapper_id: w.wrapper_id, kind: w.kind, state: w.state, created_at: w.created_at, activated_at: w.activated_at })),
1789
+ }
1790
+ : { available: true, encryption_key_id: null, hint: "no source-access key enrolled — enroll at console.run402.com/account → Source access." };
1791
+ } catch (e) {
1792
+ result.member_custody = { available: false, reason: e?.code ?? "read_failed", hint: "your own wrapper custody could not be read (older gateway, or the session lacks it)." };
1793
+ }
1794
+ } else {
1795
+ result.member_custody = { available: false, reason: "no_control_plane_session", hint: "run 'run402 operator login --loopback' to include your own wrapper custody here." };
1796
+ }
1691
1797
  if (human) {
1692
1798
  console.log(formatAccessHuman(result));
1799
+ printMemberCustodySummary(result.member_custody);
1693
1800
  printVerboseStats(a, sdk);
1694
1801
  return;
1695
1802
  }
1696
1803
  printJson(sdk, result);
1697
1804
  await spillIfLarge(result.repo_id, "access", result);
1698
1805
  console.error(`${result.recipients.length} directory recipient(s), ${result.recipients.filter((r) => r.covered).length} covered on this repo.`);
1806
+ printMemberCustodySummary(result.member_custody);
1699
1807
  if (result.this_keystore) {
1700
1808
  console.error(`1 covering fingerprint is this machine's own keystore (the vault's writing principal, not in the org directory): ${result.this_keystore.fingerprint}`);
1701
1809
  }
@@ -1876,6 +1984,9 @@ function formatRecoverHuman(result, outDir) {
1876
1984
  if (result.data_loss_detected) {
1877
1985
  lines.push(`DATA LOSS DETECTED: ${result.absences.filter((x) => x.adjudication === "unexplained_absence").length} object(s) are unexplained absences.`);
1878
1986
  }
1987
+ if (result.member_recovery) {
1988
+ lines.push(`Decrypted via member recovery bundle${result.member_recovery.bundle_key ? ` ${result.member_recovery.bundle_key}` : ""} + source recovery code (no keystore).`);
1989
+ }
1879
1990
  lines.push(`Layout: ${result.layout}` + (result.layout === "bare" ? " (no working files — not a failed recovery)" : ""));
1880
1991
  if (result.retained_refs?.warning) {
1881
1992
  lines.push(`refs/r402/retain: ${result.retained_refs.warning}`);
@@ -1885,9 +1996,40 @@ function formatRecoverHuman(result, outDir) {
1885
1996
  return lines.join("\n");
1886
1997
  }
1887
1998
 
1999
+ /**
2000
+ * Read the source recovery code without echoing it (TTY) — a recovery code
2001
+ * is long-lived key material; it must never land in shell history (prefer
2002
+ * the prompt over `--code <value>`) and never echo into a scrollback.
2003
+ * Non-TTY stdin (piped) reads one line verbatim.
2004
+ */
2005
+ async function promptSourceRecoveryCode() {
2006
+ const { stdin, stderr } = process;
2007
+ if (!stdin.isTTY) {
2008
+ const chunks = [];
2009
+ for await (const c of stdin) {
2010
+ chunks.push(c);
2011
+ const s = Buffer.concat(chunks).toString("utf8");
2012
+ if (s.includes("\n")) return s.slice(0, s.indexOf("\n")).trim();
2013
+ }
2014
+ return Buffer.concat(chunks).toString("utf8").trim();
2015
+ }
2016
+ const { createInterface } = await import("node:readline");
2017
+ stderr.write("Source recovery code (SRC1-…, input hidden): ");
2018
+ return await new Promise((resolve) => {
2019
+ const rl = createInterface({ input: stdin, terminal: true });
2020
+ // Mute the echo: readline in terminal mode writes through _writeToOutput.
2021
+ rl._writeToOutput = () => {};
2022
+ rl.question("", (answer) => {
2023
+ rl.close();
2024
+ stderr.write("\n");
2025
+ resolve(answer.trim());
2026
+ });
2027
+ });
2028
+ }
2029
+
1888
2030
  async function recover(args) {
1889
2031
  const a = normalizeArgv(args);
1890
- const valueFlags = ["--out", "--repo", "--profile", "--region", "--endpoint"];
2032
+ const valueFlags = ["--out", "--repo", "--profile", "--region", "--endpoint", "--bundle", "--code", "--receipt", "--rp-id"];
1891
2033
  assertKnownFlags(a, [...valueFlags, "--ambient", "--human", "-v", "--verbose", "--help", "-h"], valueFlags);
1892
2034
  const [source] = requirePositionalCount(a, valueFlags, {
1893
2035
  min: 1, max: 1, command: "run402 repos recover <source> --out <dir>",
@@ -1906,6 +2048,18 @@ async function recover(args) {
1906
2048
  const repoId = flagValue(a, "--repo");
1907
2049
  const region = flagValue(a, "--region");
1908
2050
  const endpoint = flagValue(a, "--endpoint");
2051
+ // gitvault-recovery-custody — the human-member path: --bundle (the exported
2052
+ // r402s-member-recovery-bundle/v1; omit to use the mirror's own
2053
+ // member-recovery-bundles/ sidecar) + the source recovery code. --receipt
2054
+ // supplies the recovery-receipt pin when no keystore holds one (a member
2055
+ // has no keystore); --rp-id overrides the seal-time ceremony host.
2056
+ const bundlePath = flagValue(a, "--bundle");
2057
+ const receiptPath = flagValue(a, "--receipt");
2058
+ const rpId = flagValue(a, "--rp-id");
2059
+ let code = flagValue(a, "--code");
2060
+ const memberBundle = bundlePath != null ? readJsonFile("--bundle", bundlePath) : undefined;
2061
+ const recoveryReceipt = receiptPath != null ? readJsonFile("--receipt", receiptPath) : undefined;
2062
+ if (memberBundle !== undefined && code == null) code = await promptSourceRecoveryCode();
1909
2063
  try {
1910
2064
  const result = await sdk.gitvault.recover({
1911
2065
  source, out_dir: outDir,
@@ -1913,6 +2067,10 @@ async function recover(args) {
1913
2067
  ...(credential ? { credential } : {}),
1914
2068
  ...(region != null ? { region } : {}),
1915
2069
  ...(endpoint != null ? { endpoint } : {}),
2070
+ ...(memberBundle !== undefined ? { member_bundle: memberBundle } : {}),
2071
+ ...(code != null ? { source_recovery_code: code } : {}),
2072
+ ...(recoveryReceipt !== undefined ? { recovery_receipt: recoveryReceipt } : {}),
2073
+ ...(rpId != null ? { rp_id: rpId } : {}),
1916
2074
  });
1917
2075
  if (human) {
1918
2076
  console.log(formatRecoverHuman(result, outDir));
@@ -1924,6 +2082,9 @@ async function recover(args) {
1924
2082
  printJson(sdk, result);
1925
2083
  await spillIfLarge(result.repo_id, "recover", result);
1926
2084
  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)` : "") + ".");
2085
+ if (result.member_recovery) {
2086
+ 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.`);
2087
+ }
1927
2088
  if (result.data_loss_detected) {
1928
2089
  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
2090
  }
@@ -1946,6 +2107,59 @@ async function recover(args) {
1946
2107
  }
1947
2108
  }
1948
2109
 
2110
+ // ─── recovery-bundle (gitvault-recovery-custody — the export half of `recover`) ─
2111
+
2112
+ /**
2113
+ * `run402 repos recovery-bundle` — export YOUR member recovery bundle
2114
+ * (`r402s-member-recovery-bundle/v1`): key identity + every ACTIVE wrapper
2115
+ * ciphertext. Together with the source recovery code — kept SEPARATELY —
2116
+ * it is what `repos recover --bundle` opens with no run402 server; a
2117
+ * server-side wrapper row alone is NOT offline backup, this export is.
2118
+ *
2119
+ * Principal-scoped, not repo-scoped (one bundle covers every vault you are
2120
+ * a recipient of) — which is why the auth is your control-plane (human)
2121
+ * session (`run402 operator login --loopback`), not the wallet. Without a
2122
+ * session the request falls back to the active WALLET's agent principal,
2123
+ * which normally holds no wrappers — truthful, with a stderr note saying so.
2124
+ * Enrollment/activation/revocation are browser ceremonies at
2125
+ * console.run402.com/account → Source access; this verb is read-only.
2126
+ */
2127
+ function sourceAccessTokenOpts(commandLabel) {
2128
+ const cp = loadLiveControlPlaneSession();
2129
+ if (!cp) {
2130
+ console.error(
2131
+ `no control-plane (human) session — ${commandLabel} will answer for the active WALLET's agent principal, which normally holds no wrappers. Run 'run402 operator login --loopback' to act as yourself.`,
2132
+ );
2133
+ return {};
2134
+ }
2135
+ return { token: cp.control_plane_session_token };
2136
+ }
2137
+
2138
+ async function recoveryBundle(args) {
2139
+ const a = normalizeArgv(args);
2140
+ assertKnownFlags(a, ["--out", "--json", "--help", "-h", "-v", "--verbose"], ["--out"]);
2141
+ requirePositionalCount(a, ["--out"], { min: 0, max: 0, command: "run402 repos recovery-bundle", missing: "" });
2142
+ const out = flagValue(a, "--out");
2143
+ const sdk = getSdk();
2144
+ try {
2145
+ const bundle = await sdk.operator.session.sourceAccessRecoveryBundle(sourceAccessTokenOpts("recovery-bundle"));
2146
+ // Full JSON to stdout regardless — the pipe contract is sacred; the file
2147
+ // is the keep-a-copy convenience (0600 — the bundle is ciphertext the
2148
+ // platform cannot open, but it is still half of a recovery credential).
2149
+ console.log(JSON.stringify({ ...bundle, stats: sdkStats(sdk) }, null, 2));
2150
+ if (out !== "-") {
2151
+ const path = out ?? `run402-source-recovery-bundle-${(bundle.ek_fingerprint || "key").slice(0, 11)}.json`;
2152
+ writeFileSync(path, JSON.stringify(bundle, null, 2) + "\n", { mode: 0o600 });
2153
+ console.error(`bundle written to ${path} (0600).`);
2154
+ }
2155
+ console.error("keep this bundle SEPARATELY from your source recovery code — together they are equivalent to your member private key.");
2156
+ 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.");
2157
+ printVerboseStats(a, sdk);
2158
+ } catch (err) {
2159
+ reportSdkError(err);
2160
+ }
2161
+ }
2162
+
1949
2163
  // ─── dispatch ───────────────────────────────────────────────────────────────
1950
2164
 
1951
2165
  export async function run(sub, args) {
@@ -2003,6 +2217,10 @@ export async function run(sub, args) {
2003
2217
  await recover(argv);
2004
2218
  break;
2005
2219
  }
2220
+ case "recovery-bundle": {
2221
+ await recoveryBundle(argv);
2222
+ break;
2223
+ }
2006
2224
  default:
2007
2225
  failUnknownSubcommand("repos", sub, {
2008
2226
  hint: "Run `run402 repos --help` for usage.",
@@ -0,0 +1,70 @@
1
+ /**
2
+ * `run402 source-access` — RETIRED after exactly one release (v4.54.0, live
3
+ * for hours), moved into the `repos` family the same day it shipped.
4
+ *
5
+ * The family violated the one-noun consolidation the CLI just fought for
6
+ * (repo-surface-consolidation: 19 commands → one noun, twelve verbs): it
7
+ * pattern-matched the gateway's `/agent/v1/source-access/*` route namespace
8
+ * into a CLI noun, and the wire is organized by resource while the CLI is
9
+ * organized by user nouns. Both verbs live where a user actually looks:
10
+ *
11
+ * source-access export -> repos recovery-bundle (the artifact
12
+ * `repos recover --bundle` consumes; custody
13
+ * machinery of the repos family)
14
+ * source-access status -> repos access (its "you" block —
15
+ * your own wrapper custody, rendered inside the
16
+ * family's existing custody roster read)
17
+ *
18
+ * Same contract as the gitvault retirement (`cli/lib/gitvault.mjs`): every
19
+ * spelling answers a structured COMMAND_MOVED for exactly one release —
20
+ * typed next_actions, non-zero exit, EMPTY stdout — then the spelling is
21
+ * reserved and answers nothing at all. Never re-pointed at new behavior.
22
+ */
23
+ import { fail } from "./sdk-errors.mjs";
24
+ import { hasHelp } from "./argparse.mjs";
25
+
26
+ export const HELP = `run402 source-access — RETIRED (moved into the repos family)
27
+
28
+ Usage:
29
+ run402 source-access <anything> — answers a structured error naming its repos successor; see below.
30
+
31
+ Moved:
32
+ source-access export -> repos recovery-bundle (export your member recovery bundle)
33
+ source-access status -> repos access (your own wrapper custody rides its "you" block)
34
+
35
+ Run \`run402 repos --help\` for the current surface.
36
+ `;
37
+
38
+ const MOVED = {
39
+ export: {
40
+ command: "run402 repos recovery-bundle",
41
+ why: "The bundle's whole life is in the repos family — it is the artifact `repos recover --bundle` consumes and the sidecar a `repos mirror` carries.",
42
+ },
43
+ status: {
44
+ command: "run402 repos access",
45
+ why: "Your own wrapper custody now renders inside the family's existing custody roster read (its member_custody block); the org-level advisory is `run402 doctor --only recovery_posture`.",
46
+ },
47
+ };
48
+
49
+ export async function run(sub, args) {
50
+ const argv = Array.isArray(args) ? args : [];
51
+ if (!sub || hasHelp([sub, ...argv])) {
52
+ console.log(HELP);
53
+ process.exit(0);
54
+ }
55
+ const m = MOVED[sub];
56
+ if (m) {
57
+ fail({
58
+ code: "COMMAND_MOVED",
59
+ message: `run402 source-access ${sub} moved to ${m.command}.`,
60
+ hint: m.why,
61
+ details: { was: `source-access ${sub}`, now: m.command },
62
+ next_actions: [{ type: "use_moved_command", command: m.command, why: m.why }],
63
+ });
64
+ }
65
+ fail({
66
+ code: "UNKNOWN_SUBCOMMAND",
67
+ message: `run402 source-access ${sub}: unknown, and source-access itself is retired.`,
68
+ hint: "Run `run402 repos --help` for the current surface.",
69
+ });
70
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "run402",
3
- "version": "4.53.0",
3
+ "version": "4.54.1",
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