total-recall-brain 3.41.6 → 3.42.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 (86) hide show
  1. package/package.json +1 -1
  2. package/scaffold/.agent/skills/total-recall/plugins/auth/package-lock.json +3118 -17
  3. package/scaffold/.agent/skills/total-recall/plugins/auth/package.json +7 -2
  4. package/scaffold/.agent/skills/total-recall/plugins/auth/src/cli-secret.mjs +84 -40
  5. package/scaffold/.agent/skills/total-recall/plugins/auth/src/exit-after-flush.mjs +22 -0
  6. package/scaffold/.agent/skills/total-recall/plugins/auth/src/provider-api-rotation.mjs +61 -6
  7. package/scaffold/.agent/skills/total-recall/plugins/auth/src/provider-catalog.mjs +1 -0
  8. package/scaffold/.agent/skills/total-recall/plugins/auth/src/secrets-remote-deploy.mjs +8 -0
  9. package/scaffold/.agent/skills/total-recall/plugins/auth/src/secrets-rotate.mjs +20 -1
  10. package/scaffold/.agent/skills/total-recall/plugins/auth/src/secrets-store.mjs +3 -0
  11. package/scaffold/.agent/skills/total-recall/plugins/code-quality/core/state.mjs +15 -7
  12. package/scaffold/.agent/skills/total-recall/plugins/code-quality/package-lock.json +4 -4
  13. package/scaffold/.agent/skills/total-recall/plugins/composable-cli/package-lock.json +4 -4
  14. package/scaffold/.agent/skills/total-recall/plugins/embeddings/package-lock.json +4 -4
  15. package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/package-lock.json +4 -4
  16. package/scaffold/.agent/skills/total-recall/plugins/mesh/package-lock.json +4 -4
  17. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/README.md +51 -7
  18. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/cli.mjs +67 -25
  19. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/package-lock.json +4 -4
  20. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/plugin.json +52 -6
  21. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/skills/meta-harness/SKILL.md +1 -1
  22. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/cascade.mjs +21 -8
  23. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/compat-harness.mjs +110 -0
  24. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/coordinator.mjs +48 -22
  25. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/dispatch.mjs +9 -4
  26. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/documentation.mjs +21 -9
  27. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/executor.mjs +9 -5
  28. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/gate.mjs +10 -4
  29. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/learner.mjs +30 -3
  30. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/paths.mjs +6 -1
  31. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/pool.mjs +19 -3
  32. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/process-manager.mjs +222 -0
  33. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/process-worker.mjs +18 -0
  34. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/reasoning.mjs +94 -0
  35. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/remote-operation.mjs +70 -0
  36. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/review.mjs +60 -19
  37. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/router.mjs +32 -7
  38. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/termination.mjs +24 -0
  39. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/threads.mjs +88 -125
  40. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/worker.mjs +110 -29
  41. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/worktree.mjs +35 -0
  42. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/ui/card.js +95 -5
  43. package/scaffold/.agent/skills/total-recall/plugins/openwiki/cli.mjs +13 -1
  44. package/scaffold/.agent/skills/total-recall/plugins/openwiki/package-lock.json +5 -5
  45. package/scaffold/.agent/skills/total-recall/plugins/openwiki/plugin.json +78 -0
  46. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/context-provider.mjs +113 -0
  47. package/scaffold/.agent/skills/total-recall/plugins/security/package-lock.json +4 -4
  48. package/src/cli/agent.mjs +11 -6
  49. package/src/cli/compact.mjs +3 -3
  50. package/src/cli/context.mjs +52 -19
  51. package/src/cli/harness.mjs +65 -147
  52. package/src/cli/init.mjs +4 -2
  53. package/src/cli/preflight.mjs +56 -13
  54. package/src/core/agent-manager.mjs +23 -329
  55. package/src/core/context-compactor.mjs +2 -1
  56. package/src/core/context-compiler.mjs +16 -25
  57. package/src/core/context-providers.mjs +69 -0
  58. package/src/core/meta-harness.mjs +27 -749
  59. package/src/core/plugin-runner.mjs +12 -5
  60. package/src/core/plugin-runtime.mjs +4 -2
  61. package/src/core/runtime.mjs +30 -604
  62. package/src/core/task-context.mjs +122 -0
  63. package/src/server/routes/context.mjs +4 -1
  64. package/scaffold/.agent/skills/decision/SKILL.md +0 -20
  65. package/scaffold/.agent/skills/decision/references/integration.md +0 -25
  66. package/scaffold/.agent/skills/decision/references/use-cases.md +0 -20
  67. package/scaffold/.agent/skills/meta-harness/SKILL.md +0 -15
  68. package/scaffold/.agent/skills/meta-harness/references/optimized/index.md +0 -20
  69. package/scaffold/.agent/skills/meta-harness/references/optimized/manifest.json +0 -79
  70. package/scaffold/.agent/skills/meta-harness/references/optimized/original.md +0 -135
  71. package/scaffold/.agent/skills/meta-harness/references/optimized/section-01.md +0 -5
  72. package/scaffold/.agent/skills/meta-harness/references/optimized/section-02.md +0 -17
  73. package/scaffold/.agent/skills/meta-harness/references/optimized/section-03.md +0 -8
  74. package/scaffold/.agent/skills/meta-harness/references/optimized/section-04.md +0 -2
  75. package/scaffold/.agent/skills/meta-harness/references/optimized/section-05.md +0 -6
  76. package/scaffold/.agent/skills/meta-harness/references/optimized/section-06.md +0 -14
  77. package/scaffold/.agent/skills/meta-harness/references/optimized/section-07.md +0 -8
  78. package/scaffold/.agent/skills/meta-harness/references/optimized/section-08.md +0 -4
  79. package/scaffold/.agent/skills/meta-harness/references/optimized/section-09.md +0 -20
  80. package/scaffold/.agent/skills/meta-harness/references/optimized/section-10.md +0 -6
  81. package/scaffold/.agent/skills/meta-harness/references/optimized/section-11.md +0 -5
  82. package/scaffold/.agent/skills/meta-harness/references/optimized/section-12.md +0 -7
  83. package/scaffold/.agent/skills/meta-harness/references/optimized/section-13.md +0 -4
  84. package/scaffold/.agent/skills/meta-harness/references/optimized/section-14.md +0 -7
  85. package/scaffold/.agent/skills/meta-harness/references/optimized/section-15.md +0 -9
  86. package/scaffold/.agent/skills/meta-harness/references/optimized/section-16.md +0 -5
@@ -5,7 +5,8 @@
5
5
  "type": "module",
6
6
  "main": "cli.mjs",
7
7
  "scripts": {
8
- "test": "node --test test/*.test.mjs && vitest run test"
8
+ "test": "node --test test/*.test.mjs && vitest run test",
9
+ "ssss": "ssss"
9
10
  },
10
11
  "keywords": [
11
12
  "total-recall-plugin",
@@ -21,7 +22,8 @@
21
22
  "express": "^5.2.1",
22
23
  "express-rate-limit": "^8.5.1",
23
24
  "jsonwebtoken": "^9.0.3",
24
- "yaml": "^2.9.0"
25
+ "yaml": "^2.9.0",
26
+ "@gregiteen/ssss-cli": "0.14.4"
25
27
  },
26
28
  "files": [
27
29
  "cli.mjs",
@@ -35,5 +37,8 @@
35
37
  ],
36
38
  "publishConfig": {
37
39
  "access": "public"
40
+ },
41
+ "overrides": {
42
+ "@ssss/cli": "npm:@gregiteen/ssss-cli@0.14.4"
38
43
  }
39
44
  }
@@ -12,6 +12,7 @@
12
12
  * npx total-recall auth secret usage-record --provider x --cost 0.01
13
13
  */
14
14
 
15
+ import { exitAfterFlush } from './exit-after-flush.mjs';
15
16
  import {
16
17
  setSecret,
17
18
  getSecret,
@@ -242,6 +243,7 @@ function printHelp() {
242
243
  [--dry-run] [--keys k1,k2] [--path <repo>]
243
244
  verify <name> Compare the deployed file with the store by fingerprint
244
245
  (match/differs/missing/duplicate; no values) [--keys] [--path]
246
+ --find-fingerprint <fp> names every remote key holding that value
245
247
  import-pm2 <name> Move named keys from a pm2 app's captured env into the store
246
248
  --app <pm2 name> --keys k1,k2 [--apply] [--overwrite] [--repo r]
247
249
  merge-store <dir> Fold a stray per-repo store into this one (dry run unless --apply).
@@ -251,6 +253,7 @@ function printHelp() {
251
253
  --bind-same Also bind keys whose value already matches (only for an app's own store)
252
254
  --dates-from-source Restore an older value date for keys whose value is identical
253
255
  diff-env Compare repo .env files against the store (no values printed)
256
+ --find-fingerprint <fp> Instead, name every key holding that value
254
257
  --path <dir> Single repo (default: every project-registry path)
255
258
  --filename .env File to compare (default .env; server/<name> also checked)
256
259
  --strict Exit 1 on any drift or unadopted key
@@ -299,6 +302,10 @@ function printHelp() {
299
302
  --print-only Only print instructions
300
303
  --headless Allow headless for verified recipes
301
304
  rotate-auto <key> Rotate by whatever method the key's class supports
305
+ provider-keys <provider> List the provider account's keys (no values) and which
306
+ store names hold them; a key held by none is stale
307
+ --revoke <hash> Revoke one key the store no longer holds
308
+ --json Machine-readable output
302
309
  rotation-status Rotation coverage for every key in the vault
303
310
  --json Machine-readable output
304
311
  browser-logout Delete TR's persistent browser profile (all sessions)
@@ -415,6 +422,7 @@ function parseArgs(args) {
415
422
  'meta',
416
423
  'rotate-browser',
417
424
  'rotate-auto',
425
+ 'provider-keys',
418
426
  'rm',
419
427
  'generate',
420
428
  'account-sync',
@@ -609,6 +617,12 @@ function parseArgs(args) {
609
617
  case '--chrome':
610
618
  out.chrome = true;
611
619
  break;
620
+ case '--find-fingerprint':
621
+ out.findFingerprint = args[++i];
622
+ break;
623
+ case '--revoke':
624
+ out.revokeHash = args[++i];
625
+ break;
612
626
  case '--revoke-old':
613
627
  out.revokeOld = true;
614
628
  break;
@@ -722,7 +736,7 @@ export default async function secretCli(argv) {
722
736
  opts = parseArgs(remainingArgs);
723
737
  } catch (err) {
724
738
  console.error(`❌ ${err.message}`);
725
- process.exit(1);
739
+ await exitAfterFlush(1);
726
740
  }
727
741
  if (opts.help || !opts.command) {
728
742
  printHelp();
@@ -734,23 +748,23 @@ export default async function secretCli(argv) {
734
748
  // one. Repo binding lives in each key's metadata (`meta <key> --repo`).
735
749
  if (layer === 'project') {
736
750
  console.error('❌ Secrets have one store; --project does not apply. Bind a key to a repo with `auth secret meta <key> --repo <name>`.');
737
- process.exit(1);
751
+ await exitAfterFlush(1);
738
752
  }
739
753
  const brainDir = secretsStoreDir();
740
754
 
741
755
  if (opts.stdin && opts.command !== 'fingerprint') {
742
756
  if (!['set', 'rotate', 'rekey'].includes(opts.command)) {
743
757
  console.error('❌ --stdin is only valid with `set`, `rotate`, `rekey`, or `fingerprint`');
744
- process.exit(1);
758
+ await exitAfterFlush(1);
745
759
  }
746
760
  if (opts.command !== 'rekey' && !opts.key) {
747
761
  console.error(`Usage: total-recall auth secret ${opts.command} <key> --stdin`);
748
- process.exit(1);
762
+ await exitAfterFlush(1);
749
763
  }
750
764
  opts.value = readStdinValue();
751
765
  if (!opts.value) {
752
766
  console.error('❌ --stdin read an empty value');
753
- process.exit(1);
767
+ await exitAfterFlush(1);
754
768
  }
755
769
  }
756
770
 
@@ -798,7 +812,7 @@ export default async function secretCli(argv) {
798
812
  console.error(
799
813
  '❌ No persistent master-password carrier found. Use --launch-agent <path>, --env-file <path> or --keychain <service>.',
800
814
  );
801
- process.exit(1);
815
+ await exitAfterFlush(1);
802
816
  }
803
817
 
804
818
  const currentPasswords = [
@@ -808,12 +822,12 @@ export default async function secretCli(argv) {
808
822
  const oldPassword = currentPasswords[0] || process.env.TR_SECRETS_PASSWORD;
809
823
  if (!oldPassword) {
810
824
  console.error('❌ The current master password is unavailable from the selected carriers.');
811
- process.exit(1);
825
+ await exitAfterFlush(1);
812
826
  }
813
827
  if (currentPasswords.some((value) => value !== oldPassword)) {
814
828
  console.error('❌ Selected credential carriers do not contain the same current master password.');
815
829
  console.error(' Every carrier must hold the same password before one can replace it.');
816
- process.exit(1);
830
+ await exitAfterFlush(1);
817
831
  }
818
832
 
819
833
  const defaultBrainDirs = [
@@ -848,7 +862,7 @@ export default async function secretCli(argv) {
848
862
  if (opts.command === 'set') {
849
863
  if (!opts.key || opts.value == null) {
850
864
  console.error('Usage: total-recall auth secret set <key> <value>');
851
- process.exit(1);
865
+ await exitAfterFlush(1);
852
866
  }
853
867
  const setKey = await resolveSecretKey(brainDir, opts.key, currentRepoName());
854
868
  await setSecret(brainDir, setKey, opts.value, {
@@ -877,7 +891,7 @@ export default async function secretCli(argv) {
877
891
  if (opts.command === 'generate') {
878
892
  if (!opts.key) {
879
893
  console.error('Usage: total-recall auth secret generate <key> [--bytes 32] [--format hex|base64|base64url]');
880
- process.exit(1);
894
+ await exitAfterFlush(1);
881
895
  }
882
896
  const crypto = await import('node:crypto');
883
897
  const val = crypto.randomBytes(opts.bytes).toString(opts.format);
@@ -1005,7 +1019,7 @@ export default async function secretCli(argv) {
1005
1019
  if (r.usage) console.log(` usage: ${JSON.stringify(r.usage)}`);
1006
1020
  if (r.subscription) console.log(` subscription: ${JSON.stringify(r.subscription)}`);
1007
1021
  console.log('');
1008
- if (r.tracking_status === 'error') process.exit(1);
1022
+ if (r.tracking_status === 'error') await exitAfterFlush(1);
1009
1023
  return;
1010
1024
  }
1011
1025
  console.log('\n 🔄 Live provider account/usage sync (all set secrets)…\n');
@@ -1025,7 +1039,7 @@ export default async function secretCli(argv) {
1025
1039
  console.log(
1026
1040
  `\n tracking-health: healthy=${health.healthy} ok=${health.ok} exempt=${health.exempt} errors=${health.errors}\n`,
1027
1041
  );
1028
- if (!report.healthy) process.exit(1);
1042
+ if (!report.healthy) await exitAfterFlush(1);
1029
1043
  return;
1030
1044
  }
1031
1045
 
@@ -1049,7 +1063,7 @@ export default async function secretCli(argv) {
1049
1063
  }
1050
1064
  }
1051
1065
  console.log('');
1052
- if (!health.healthy || !shared.healthy) process.exit(1);
1066
+ if (!health.healthy || !shared.healthy) await exitAfterFlush(1);
1053
1067
  return;
1054
1068
  }
1055
1069
 
@@ -1095,14 +1109,14 @@ export default async function secretCli(argv) {
1095
1109
  } else {
1096
1110
  console.log('\n No shared credential values detected.\n');
1097
1111
  }
1098
- if (!shared.healthy) process.exit(1);
1112
+ if (!shared.healthy) await exitAfterFlush(1);
1099
1113
  return;
1100
1114
  }
1101
1115
 
1102
1116
  if (opts.command === 'meta') {
1103
1117
  if (!opts.key) {
1104
1118
  console.error('Usage: total-recall auth secret meta <key> [--repo ...] [--tier ...]');
1105
- process.exit(1);
1119
+ await exitAfterFlush(1);
1106
1120
  }
1107
1121
  const patch = {};
1108
1122
  if (opts.repos.length) patch.repos = opts.repos;
@@ -1110,7 +1124,7 @@ export default async function secretCli(argv) {
1110
1124
  const row = (await listSecretsMeta(brainDir)).find((r) => r.key === opts.key);
1111
1125
  if (!row) {
1112
1126
  console.error(`❌ Secret not found: ${opts.key}`);
1113
- process.exit(1);
1127
+ await exitAfterFlush(1);
1114
1128
  }
1115
1129
  patch.repos = (patch.repos || row.repos || []).filter((r) => !opts.unbind.includes(r));
1116
1130
  }
@@ -1174,7 +1188,7 @@ export default async function secretCli(argv) {
1174
1188
  if (opts.command === 'get') {
1175
1189
  if (!opts.key) {
1176
1190
  console.error('Usage: total-recall auth secret get <key>');
1177
- process.exit(1);
1191
+ await exitAfterFlush(1);
1178
1192
  }
1179
1193
  const project_id = currentProjectId();
1180
1194
  const resolved = await resolveSecretKey(brainDir, opts.key, currentRepoName());
@@ -1185,7 +1199,7 @@ export default async function secretCli(argv) {
1185
1199
  }
1186
1200
  if (!r.found) {
1187
1201
  console.error(` Secret not found: ${opts.key}`);
1188
- process.exit(1);
1202
+ await exitAfterFlush(1);
1189
1203
  }
1190
1204
  // Print value only — user explicitly asked
1191
1205
  console.log(r.value);
@@ -1264,7 +1278,7 @@ export default async function secretCli(argv) {
1264
1278
  const value = opts.fromClipboard ? readSystemClipboard() : opts.stdin ? readStdinValue() : '';
1265
1279
  if (!value) {
1266
1280
  console.error('Usage: total-recall auth secret fingerprint --from-clipboard|--stdin [--expect-prefix <p>] [--not <KEY>] [--json]');
1267
- process.exit(1);
1281
+ await exitAfterFlush(1);
1268
1282
  }
1269
1283
  const fp = secretValueFingerprint(value);
1270
1284
  const matches = (await listSecretsMeta(brainDir)).filter((row) => row.fingerprint_full === fp).map((row) => row.key);
@@ -1295,7 +1309,7 @@ export default async function secretCli(argv) {
1295
1309
  ' verify <name> [--keys k1,k2] [--path <repo>] [--json]\n' +
1296
1310
  ' import-pm2 <name> --app <pm2 name> --keys k1,k2 [--apply] [--overwrite] [--repo r] [--json]',
1297
1311
  );
1298
- process.exit(1);
1312
+ await exitAfterFlush(1);
1299
1313
  }
1300
1314
 
1301
1315
  if (opts.subcommand === 'add') {
@@ -1303,7 +1317,7 @@ export default async function secretCli(argv) {
1303
1317
  console.error(
1304
1318
  'Usage: total-recall auth secret remote add <name> --host <host> --path </remote/dir> [--user root] [--port 22] [--filename .env] [--restart-cmd "..."]',
1305
1319
  );
1306
- process.exit(1);
1320
+ await exitAfterFlush(1);
1307
1321
  }
1308
1322
  const { path: targetsPath } = addRemoteTarget(brainDir, {
1309
1323
  name: opts.key,
@@ -1343,7 +1357,7 @@ export default async function secretCli(argv) {
1343
1357
  if (opts.subcommand === 'remove' || opts.subcommand === 'rm') {
1344
1358
  if (!opts.key) {
1345
1359
  console.error('Usage: total-recall auth secret remote remove <name>');
1346
- process.exit(1);
1360
+ await exitAfterFlush(1);
1347
1361
  }
1348
1362
  const r = removeRemoteTarget(brainDir, opts.key);
1349
1363
  console.log(r.removed ? ` ✅ Removed remote target: ${opts.key}` : ` Remote target not found: ${opts.key}`);
@@ -1353,7 +1367,7 @@ export default async function secretCli(argv) {
1353
1367
  if (opts.subcommand === 'deploy') {
1354
1368
  if (!opts.key) {
1355
1369
  console.error('Usage: total-recall auth secret remote deploy <name> [--dry-run] [--keys k1,k2]');
1356
- process.exit(1);
1370
+ await exitAfterFlush(1);
1357
1371
  }
1358
1372
  const r = await deployEnvToRemote(brainDir, opts.key, {
1359
1373
  dryRun: opts.dryRun,
@@ -1376,10 +1390,11 @@ export default async function secretCli(argv) {
1376
1390
  if (opts.subcommand === 'verify') {
1377
1391
  if (!opts.key) {
1378
1392
  console.error('Usage: total-recall auth secret remote verify <name> [--keys k1,k2] [--path <repo>] [--json]');
1379
- process.exit(1);
1393
+ await exitAfterFlush(1);
1380
1394
  }
1381
1395
  const r = await verifyRemote(brainDir, opts.key, {
1382
1396
  keys: opts.keys.length ? opts.keys : undefined,
1397
+ findFingerprint: opts.findFingerprint,
1383
1398
  projectPath: opts.path ? path.resolve(opts.path) : undefined,
1384
1399
  });
1385
1400
  if (opts.json) console.log(JSON.stringify(r, null, 2));
@@ -1390,6 +1405,7 @@ export default async function secretCli(argv) {
1390
1405
  console.log(` ${mark} ${row.key.padEnd(36)} ${row.status}${row.duplicates ? ` (+${row.duplicates} duplicate)` : ''}`);
1391
1406
  }
1392
1407
  console.log(` ${r.remoteOnly.length} key(s) on the remote are not in the projection`);
1408
+ if (r.holders) console.log(` holders of that fingerprint: ${r.holders.join(', ') || 'none'}`);
1393
1409
  console.log(` ${r.ok ? 'in sync' : 'DRIFT'} (values compared by fingerprint; none printed)\n`);
1394
1410
  }
1395
1411
  if (!r.ok) process.exitCode = 1;
@@ -1399,7 +1415,7 @@ export default async function secretCli(argv) {
1399
1415
  if (opts.subcommand === 'import-pm2') {
1400
1416
  if (!opts.key || !opts.app || !opts.keys.length) {
1401
1417
  console.error('Usage: total-recall auth secret remote import-pm2 <name> --app <pm2 name> --keys k1,k2 [--apply] [--overwrite] [--repo r] [--json]');
1402
- process.exit(1);
1418
+ await exitAfterFlush(1);
1403
1419
  }
1404
1420
  const r = await importFromPm2(brainDir, opts.key, {
1405
1421
  app: opts.app,
@@ -1418,14 +1434,14 @@ export default async function secretCli(argv) {
1418
1434
  }
1419
1435
 
1420
1436
  console.error(`Unknown remote subcommand: ${opts.subcommand}`);
1421
- process.exit(1);
1437
+ await exitAfterFlush(1);
1422
1438
  }
1423
1439
 
1424
1440
  if (opts.command === 'rotate') {
1425
1441
  if (!opts.key) {
1426
1442
  console.error('Usage: total-recall auth secret rotate <key> [--export-env|--export-all|--remote <name>]');
1427
1443
  console.error(' The new value is read from a prompt or stdin — never from argv.');
1428
- process.exit(1);
1444
+ await exitAfterFlush(1);
1429
1445
  }
1430
1446
  // A credential passed as an argument is disclosed the moment it is typed:
1431
1447
  // argv is readable by any user via `ps`, is saved to shell history, and is
@@ -1439,7 +1455,7 @@ export default async function secretCli(argv) {
1439
1455
  opts.value = readSystemClipboard();
1440
1456
  if (!opts.value) {
1441
1457
  console.error('Error: clipboard is empty — copy the new credential first.');
1442
- process.exit(1);
1458
+ await exitAfterFlush(1);
1443
1459
  }
1444
1460
  console.log(` 📋 Read ${opts.value.length} chars from the clipboard (value not shown).`);
1445
1461
  }
@@ -1455,7 +1471,7 @@ export default async function secretCli(argv) {
1455
1471
  }
1456
1472
  if (!opts.value) {
1457
1473
  console.error('Error: empty value — aborting rotation.');
1458
- process.exit(1);
1474
+ await exitAfterFlush(1);
1459
1475
  }
1460
1476
  if (opts.remote) {
1461
1477
  await rotateSecret(brainDir, opts.key, opts.value, { provider: opts.provider, scope: opts.scope });
@@ -1508,7 +1524,7 @@ export default async function secretCli(argv) {
1508
1524
  if (opts.command === 'rotate-browser' || opts.command === 'rotate-auto') {
1509
1525
  if (!opts.key) {
1510
1526
  console.error(`Usage: total-recall auth secret ${opts.command} <key>`);
1511
- process.exit(1);
1527
+ await exitAfterFlush(1);
1512
1528
  }
1513
1529
 
1514
1530
  if (opts.printOnly) {
@@ -1536,17 +1552,34 @@ export default async function secretCli(argv) {
1536
1552
  if (!r.ok) {
1537
1553
  console.error(`\n ❌ ${r.error}`);
1538
1554
  if (r.console_url) console.error(` Console: ${r.console_url}`);
1539
- process.exit(1);
1555
+ await exitAfterFlush(1);
1540
1556
  }
1541
1557
  console.log(`\n ✅ Rotated ${r.key}` + (r.method ? ` via ${r.method}` : ''));
1542
1558
  if (r.verified === true) console.log(' verified against provider API');
1543
- if (r.revoked) console.log(` revoked ${r.revoked} deprecated credential(s) from provider console`);
1559
+ if (r.revoked?.revoked === true) console.log(' revoked the previous key at the provider');
1560
+ else if (r.revoked?.attempted) console.log(` ⚠️ The previous key was NOT revoked (${r.revoked.status ? `HTTP ${r.revoked.status}` : r.revoked.error || 'provider refused'}); it is still live.`);
1561
+ else if (r.revoked && typeof r.revoked === 'object') console.log(` ⚠️ The previous key was NOT revoked and is still live: ${r.revoked.reason}`);
1562
+ else if (typeof r.revoked === 'number' && r.revoked) console.log(` revoked ${r.revoked} deprecated credential(s) from provider console`);
1544
1563
  if (r.supervised) console.log(' (supervised — recipe not yet auto-verified)');
1545
1564
  if (r.exports?.length) console.log(` exported to ${r.exports.length} project(s)`);
1546
1565
  if (r.revoke_hint && !r.revoked) console.log(`\n ⚠️ ${r.revoke_hint}`);
1547
1566
  return;
1548
1567
  }
1549
1568
 
1569
+ if (opts.command === 'provider-keys') {
1570
+ if (!opts.key) {
1571
+ console.error('Usage: total-recall auth secret provider-keys <provider> [--revoke <hash>] [--json]');
1572
+ process.exitCode = 1;
1573
+ return;
1574
+ }
1575
+ const { manageProviderKeys } = await import('./secrets-rotate.mjs');
1576
+ const result = await manageProviderKeys(brainDir, opts.key, { revoke: opts.revokeHash });
1577
+ if (opts.json) console.log(JSON.stringify(result, null, 2));
1578
+ else if (opts.revokeHash) console.log(`\n ✅ Revoked ${result.name || result.label} (${result.revoked.slice(0, 12)})`);
1579
+ else for (const item of result.keys) console.log(` ${item.hash.slice(0, 12)} ${String(item.name || item.label).padEnd(44)} ${item.disabled ? 'disabled' : 'active '} ${item.stored_as.length ? item.stored_as.join(',') : 'NOT IN STORE'}`);
1580
+ return;
1581
+ }
1582
+
1550
1583
  if (opts.command === 'rotation-status') {
1551
1584
  const { planAll, summarizePlans } = await import('./rotation-capability.mjs');
1552
1585
  const all = await loadSecrets(brainDir);
@@ -1582,12 +1615,12 @@ export default async function secretCli(argv) {
1582
1615
  if (opts.command === 'delete' || opts.command === 'rm') {
1583
1616
  if (!opts.key) {
1584
1617
  console.error('Usage: total-recall auth secret delete <key>');
1585
- process.exit(1);
1618
+ await exitAfterFlush(1);
1586
1619
  }
1587
1620
  const r = await deleteSecret(brainDir, opts.key);
1588
1621
  if (!r.found) {
1589
1622
  console.error(` Secret not found: ${opts.key}`);
1590
- process.exit(1);
1623
+ await exitAfterFlush(1);
1591
1624
  }
1592
1625
  console.log(` ✅ Deleted: ${opts.key}`);
1593
1626
  return;
@@ -1724,7 +1757,7 @@ export default async function secretCli(argv) {
1724
1757
  if (opts.command === 'merge-store') {
1725
1758
  if (!opts.key) {
1726
1759
  console.error('❌ Usage: auth secret merge-store <source brain dir> [--repo <name>] [--tags a,b] [--rename SRC=DEST] [--take K1,K2] [--apply] [--json]');
1727
- process.exit(1);
1760
+ await exitAfterFlush(1);
1728
1761
  }
1729
1762
  const { mergeStore } = await import('./secrets-merge.mjs');
1730
1763
  const report = await mergeStore(brainDir, path.resolve(opts.key), {
@@ -1770,6 +1803,17 @@ export default async function secretCli(argv) {
1770
1803
  return;
1771
1804
  }
1772
1805
 
1806
+ if (opts.findFingerprint) {
1807
+ const { holdersOfFingerprint, parseDotenv } = await import('./secrets-remote-deploy.mjs');
1808
+ const found = files.flatMap((file) => {
1809
+ const name = typeof file === 'string' ? file : file.path;
1810
+ return holdersOfFingerprint(parseDotenv(fs.readFileSync(name, 'utf8')), opts.findFingerprint).map((key) => ({ file: name, key }));
1811
+ });
1812
+ for (const row of found) console.log(` ${row.file} ${row.key}`);
1813
+ console.log(` ${found.length} holder(s) of that fingerprint in ${files.length} file(s)`);
1814
+ return;
1815
+ }
1816
+
1773
1817
  const { totals, rows, storeKeyCount } = await diffEnvAgainstStore(compareBrainDir, files);
1774
1818
 
1775
1819
  console.log(`\n Secrets store vs .env — ${files.length} file(s), ${storeKeyCount} keys in store`);
@@ -1817,7 +1861,7 @@ export default async function secretCli(argv) {
1817
1861
  console.log('');
1818
1862
  if (opts.strict && (totals.drift > 0 || totals.only_env > 0)) {
1819
1863
  console.error(' diff-env --strict: store is not the source of truth for every key.');
1820
- process.exit(1);
1864
+ await exitAfterFlush(1);
1821
1865
  }
1822
1866
  return;
1823
1867
  }
@@ -1830,7 +1874,7 @@ export default async function secretCli(argv) {
1830
1874
  if (opts.file) {
1831
1875
  if (!fs.existsSync(opts.file)) {
1832
1876
  console.error(` File not found: ${opts.file}`);
1833
- process.exit(1);
1877
+ await exitAfterFlush(1);
1834
1878
  }
1835
1879
  const text = fs.readFileSync(opts.file, 'utf8');
1836
1880
  const map = parseEnvText(text);
@@ -1940,16 +1984,16 @@ export default async function secretCli(argv) {
1940
1984
  console.error(` ❌ LEAK in ${t.file}: keys ${r.keys.join(', ')}`);
1941
1985
  }
1942
1986
  }
1943
- if (bad) process.exit(1);
1987
+ if (bad) await exitAfterFlush(1);
1944
1988
  console.log(` ✅ No secret values found in ${texts.length} surface/openwiki file(s)`);
1945
1989
  return;
1946
1990
  }
1947
1991
 
1948
1992
  console.error(`Unknown secret command: ${opts.command}`);
1949
1993
  printHelp();
1950
- process.exit(1);
1994
+ await exitAfterFlush(1);
1951
1995
  } catch (err) {
1952
1996
  console.error(`❌ ${err.message}`);
1953
- process.exit(1);
1997
+ await exitAfterFlush(1);
1954
1998
  }
1955
1999
  }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Exit once everything already written to stdout and stderr has left the process.
3
+ *
4
+ * process.exit() drops whatever is still queued. On macOS a pipe write is
5
+ * asynchronous and the pipe takes 64 KiB at once, so a report followed by
6
+ * process.exit(1) reaches its reader cut off. The host reads this CLI through a pipe.
7
+ *
8
+ * The returned promise never settles, so `await exitAfterFlush(code)` stops the
9
+ * caller the way process.exit() does. A reader that has gone away is not an error.
10
+ */
11
+ function drained(stream) {
12
+ return new Promise((resolve) => {
13
+ stream.on('error', resolve);
14
+ if (stream.destroyed || !stream.writable || stream.writableLength === 0) return resolve();
15
+ stream.write('', resolve);
16
+ });
17
+ }
18
+
19
+ export function exitAfterFlush(code = process.exitCode ?? 0) {
20
+ Promise.all([drained(process.stdout), drained(process.stderr)]).then(() => process.exit(code));
21
+ return new Promise(() => {});
22
+ }
@@ -47,7 +47,7 @@ function pick(obj, dotted) {
47
47
  */
48
48
  export async function rotateViaProviderApi({
49
49
  brainDir, key, managementKeyName, recipe, baseUrl,
50
- readSecret, storeSecret, secretMeta, recordKeyId, fetchImpl = fetch,
50
+ readSecret, storeSecret, secretMeta, listRows, recordKeyId, fetchImpl = fetch,
51
51
  }) {
52
52
  const management = await readSecret(brainDir, managementKeyName);
53
53
  if (!management) {
@@ -87,6 +87,21 @@ export async function rotateViaProviderApi({
87
87
  return { ok: false, error: `Could not reach the provider API: ${error.message}` };
88
88
  }
89
89
 
90
+ // Read the outgoing key's id before recording the new one, or the revoke would target the new key.
91
+ const prior = await secretMeta(brainDir, key);
92
+ let priorId = prior?.provider_key_id || prior?.key_id || null;
93
+ // A key minted before ids were kept can still be found: the provider's hash is the value's digest.
94
+ if (!priorId && prior?.fingerprint && recipe.list) {
95
+ try {
96
+ const res = await fetchImpl(`${baseUrl}${recipe.list.path}`, { headers: auth });
97
+ const items = res.ok ? pick(await res.json(), recipe.list.items_path) : null;
98
+ priorId = (Array.isArray(items) ? items : []).find((item) => String(item.hash).startsWith(prior.fingerprint))?.hash || null;
99
+ } catch { /* reported below as an unaddressable key */ }
100
+ }
101
+ const twins = prior?.fingerprint && listRows
102
+ ? (await listRows(brainDir)).filter((row) => row.key !== key && row.fingerprint === prior.fingerprint).map((row) => row.key)
103
+ : [];
104
+
90
105
  // Store first, revoke second.
91
106
  let exports;
92
107
  try {
@@ -103,7 +118,7 @@ export async function rotateViaProviderApi({
103
118
  };
104
119
  }
105
120
 
106
- const revoked = await revokeOld({ auth, recipe, created, secretMeta, brainDir, key, fetchImpl, baseUrl });
121
+ const revoked = await revokeOld({ auth, recipe, created, priorId, twins, fetchImpl, baseUrl });
107
122
 
108
123
  return {
109
124
  ok: true,
@@ -117,15 +132,14 @@ export async function rotateViaProviderApi({
117
132
  }
118
133
 
119
134
  /** Best-effort revoke of the previous credential; never fails the rotation. */
120
- async function revokeOld({ auth, recipe, created, secretMeta, brainDir, key, fetchImpl, baseUrl }) {
135
+ async function revokeOld({ auth, recipe, created, priorId, twins = [], fetchImpl, baseUrl }) {
121
136
  if (!recipe.revoke) return { attempted: false, reason: 'provider declares no revoke endpoint' };
122
- const prior = await secretMeta(brainDir, key);
123
- const priorId = prior?.provider_key_id || prior?.key_id || null;
137
+ if (twins.length) return { attempted: false, reason: `the old value is still stored under ${twins.join(', ')}; rotate those, then revoke it with provider-keys --revoke` };
124
138
  if (!priorId) {
125
139
  return {
126
140
  attempted: false,
127
141
  reason: 'the store does not record the previous key id, so the old key cannot be addressed; '
128
- + 'revoke it in the provider console',
142
+ + 'find it with `auth secret provider-keys` and revoke it there',
129
143
  };
130
144
  }
131
145
  try {
@@ -138,3 +152,44 @@ async function revokeOld({ auth, recipe, created, secretMeta, brainDir, key, fet
138
152
  return { attempted: true, revoked: false, error: error.message };
139
153
  }
140
154
  }
155
+
156
+ async function managementContext(provider, readSecret, brainDir) {
157
+ const api = provider?.rotation?.api;
158
+ if (!api?.list || !api?.revoke) throw new Error(`${provider?.name || 'This provider'} declares no key-management API`);
159
+ const management = await readSecret(brainDir, provider.rotation.management_key);
160
+ if (!management) throw new Error(`No management credential in the store: ${provider.rotation.management_key}`);
161
+ return { api, auth: { Authorization: `Bearer ${management}` } };
162
+ }
163
+
164
+ /** Every key on the provider account, marked with the store names holding it. No key value is ever returned. */
165
+ export async function listProviderKeys({ brainDir, provider, readSecret, listRows, fetchImpl = fetch }) {
166
+ const { api, auth } = await managementContext(provider, readSecret, brainDir);
167
+ const rows = await listRows(brainDir);
168
+ const keys = [];
169
+ for (let offset = 0; ; offset += api.list.page_size) {
170
+ const res = await fetchImpl(`${api.base_url}${api.list.path}?offset=${offset}`, { headers: auth });
171
+ if (!res.ok) throw new Error(`Provider refused to list keys (HTTP ${res.status})`);
172
+ const page = pick(await res.json(), api.list.items_path);
173
+ if (!Array.isArray(page)) throw new Error(`Provider response carried no key list at ${api.list.items_path}`);
174
+ keys.push(...page);
175
+ if (page.length < api.list.page_size) break;
176
+ }
177
+ return keys.map((item) => {
178
+ const held = rows.filter((row) => row.provider_key_id === item.hash || (row.fingerprint && String(item.hash).startsWith(row.fingerprint))).map((row) => row.key);
179
+ return { hash: item.hash, name: item.name ?? null, label: item.label ?? null, disabled: item.disabled === true, usage: item.usage ?? null, last_used_at: item.last_used_at ?? null, created_at: item.created_at ?? null, stored_as: held };
180
+ });
181
+ }
182
+
183
+ /** Revoke one key by its unique hash prefix. A key the store still holds is refused: rotate it instead. */
184
+ export async function revokeProviderKey({ brainDir, provider, hash, readSecret, listRows, fetchImpl = fetch }) {
185
+ if (typeof hash !== 'string' || hash.length < 8) throw new Error('Give at least 8 characters of the key hash');
186
+ const keys = await listProviderKeys({ brainDir, provider, readSecret, listRows, fetchImpl });
187
+ const matches = keys.filter((item) => item.hash.startsWith(hash));
188
+ if (matches.length !== 1) throw new Error(`Hash ${hash} matches ${matches.length} provider keys; exactly one is required`);
189
+ const [target] = matches;
190
+ if (target.stored_as.length) throw new Error(`Refusing to revoke a key the store still holds: ${target.stored_as.join(', ')}. Rotate it instead.`);
191
+ const { api, auth } = await managementContext(provider, readSecret, brainDir);
192
+ const res = await fetchImpl(`${api.base_url}${api.revoke.path(target.hash)}`, { method: api.revoke.method || 'DELETE', headers: auth });
193
+ if (!res.ok) throw new Error(`Provider refused to revoke the key (HTTP ${res.status})`);
194
+ return { ok: true, revoked: target.hash, name: target.name, label: target.label };
195
+ }
@@ -122,6 +122,7 @@ export const PROVIDER_CATALOG = [
122
122
  value_path: 'key',
123
123
  id_path: 'data.hash',
124
124
  },
125
+ list: { path: '/keys', items_path: 'data', page_size: 100 },
125
126
  revoke: {
126
127
  method: 'DELETE',
127
128
  path: (id) => `/keys/${id}`,
@@ -262,6 +262,13 @@ function findTarget(brainDir, targetName) {
262
262
  return target;
263
263
  }
264
264
 
265
+ /** Names in a parsed env file whose value has this fingerprint: how to find a credential's consumers without reading it. */
266
+ export function holdersOfFingerprint(remote, fingerprint) {
267
+ const wanted = String(fingerprint);
268
+ if (wanted.length < 8) throw new Error('--find-fingerprint needs at least 8 characters');
269
+ return [...remote.entries()].filter(([, found]) => secretValueFingerprint(found.value)?.startsWith(wanted)).map(([key]) => key);
270
+ }
271
+
265
272
  /**
266
273
  * Compare a deployed remote file with what the store would project there, key
267
274
  * by key, by value fingerprint. Values never leave this process.
@@ -294,6 +301,7 @@ export async function verifyRemote(brainDir, targetName, opts = {}) {
294
301
  remoteFile,
295
302
  rows,
296
303
  remoteOnly: [...remote.keys()].filter((key) => !projected.has(key)),
304
+ ...(opts.findFingerprint ? { holders: holdersOfFingerprint(remote, opts.findFingerprint) } : {}),
297
305
  ok: rows.every((row) => row.status === 'match' && !row.duplicates),
298
306
  };
299
307
  }
@@ -23,7 +23,7 @@ import { getProvider, providerForKeyName } from './provider-catalog.mjs';
23
23
  import { atomicWrite, safeStringify } from './vault.mjs';
24
24
  import { getRotationPlan, generateSecretValue } from './rotation-capability.mjs';
25
25
  import { getRecipe, valueLooksValid } from './provider-rotation-recipes.mjs';
26
- import { rotateViaProviderApi } from './provider-api-rotation.mjs';
26
+ import { rotateViaProviderApi, listProviderKeys, revokeProviderKey } from './provider-api-rotation.mjs';
27
27
  import {
28
28
  launchRotationContext,
29
29
  openConsole,
@@ -470,6 +470,7 @@ export async function rotateAuto(brainDir, key, opts = {}) {
470
470
  actor: o.actor, exportEnv: opts.exportEnv !== false,
471
471
  }),
472
472
  secretMeta: async (brain, name) => (await listSecretsMeta(brain)).find((r) => r.key === name) || null,
473
+ listRows: listSecretsMeta,
473
474
  // Remember which key the provider minted, so the next rotation can revoke this one instead of
474
475
  // reporting that the previous credential cannot be addressed.
475
476
  recordKeyId: async (brain, name, id) => {
@@ -492,3 +493,21 @@ export async function rotateAuto(brainDir, key, opts = {}) {
492
493
  console_url: plan.console_url,
493
494
  };
494
495
  }
496
+
497
+ const storedSecret = async (brain, name) => {
498
+ try {
499
+ const found = await getSecret(brain, name);
500
+ if (!found || found.found === false) return null;
501
+ const value = found.value ?? found;
502
+ return typeof value === 'string' && value ? value : null;
503
+ } catch { return null; }
504
+ };
505
+
506
+ /** List the provider account's keys, or revoke one that the store no longer holds. */
507
+ export async function manageProviderKeys(brainDir, providerId, { revoke, fetchImpl } = {}) {
508
+ const provider = getProvider(providerId);
509
+ if (!provider) throw new Error(`Unknown provider: ${providerId}`);
510
+ const context = { brainDir, provider, readSecret: storedSecret, listRows: listSecretsMeta, ...(fetchImpl ? { fetchImpl } : {}) };
511
+ if (revoke) return revokeProviderKey({ ...context, hash: revoke });
512
+ return { ok: true, provider: provider.id, keys: await listProviderKeys(context) };
513
+ }