total-recall-brain 3.41.0 → 3.41.2

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 (51) hide show
  1. package/package.json +1 -1
  2. package/scaffold/.agent/skills/meta-harness/references/optimized/original.md +1 -1
  3. package/scaffold/.agent/skills/total-recall/plugins/auth/README.md +10 -0
  4. package/scaffold/.agent/skills/total-recall/plugins/auth/package-lock.json +1456 -12
  5. package/scaffold/.agent/skills/total-recall/plugins/auth/package.json +4 -4
  6. package/scaffold/.agent/skills/total-recall/plugins/auth/plugin.json +1 -1
  7. package/scaffold/.agent/skills/total-recall/plugins/auth/src/cli-secret.mjs +210 -27
  8. package/scaffold/.agent/skills/total-recall/plugins/auth/src/provider-catalog.mjs +10 -1
  9. package/scaffold/.agent/skills/total-recall/plugins/auth/src/rotation-capability.mjs +15 -0
  10. package/scaffold/.agent/skills/total-recall/plugins/auth/src/secrets-env-export.mjs +78 -6
  11. package/scaffold/.agent/skills/total-recall/plugins/auth/src/secrets-merge.mjs +131 -0
  12. package/scaffold/.agent/skills/total-recall/plugins/auth/src/secrets-remote-deploy.mjs +111 -2
  13. package/scaffold/.agent/skills/total-recall/plugins/auth/src/secrets-store.mjs +26 -5
  14. package/scaffold/.agent/skills/total-recall/plugins/code-quality/package-lock.json +4 -4
  15. package/scaffold/.agent/skills/total-recall/plugins/composable-cli/package-lock.json +4 -4
  16. package/scaffold/.agent/skills/total-recall/plugins/embeddings/package-lock.json +4 -4
  17. package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/package-lock.json +4 -4
  18. package/scaffold/.agent/skills/total-recall/plugins/mesh/README.md +10 -0
  19. package/scaffold/.agent/skills/total-recall/plugins/mesh/{src/core/headscale-server.mjs → capability/control-server.mjs} +5 -2
  20. package/scaffold/.agent/skills/total-recall/plugins/mesh/capability/headscale-api.mjs +192 -0
  21. package/scaffold/.agent/skills/total-recall/plugins/mesh/capability/index.mjs +46 -0
  22. package/scaffold/.agent/skills/total-recall/plugins/mesh/cli.mjs +9 -1
  23. package/scaffold/.agent/skills/total-recall/plugins/mesh/package-lock.json +23 -22
  24. package/scaffold/.agent/skills/total-recall/plugins/mesh/package.json +6 -3
  25. package/scaffold/.agent/skills/total-recall/plugins/mesh/plugin.json +242 -8
  26. package/scaffold/.agent/skills/total-recall/plugins/mesh/src/cli/mesh.mjs +14 -9
  27. package/scaffold/.agent/skills/total-recall/plugins/mesh/src/cli/server.mjs +1 -1
  28. package/scaffold/.agent/skills/total-recall/plugins/mesh/src/core/credentials.mjs +4 -2
  29. package/scaffold/.agent/skills/total-recall/plugins/mesh/src/core/headscale-client.mjs +34 -181
  30. package/scaffold/.agent/skills/total-recall/plugins/mesh/src/core/mesh-auth.mjs +2 -2
  31. package/scaffold/.agent/skills/total-recall/plugins/mesh/src/core/presence.mjs +5 -5
  32. package/scaffold/.agent/skills/total-recall/plugins/mesh/src/core/screen-view.mjs +78 -0
  33. package/scaffold/.agent/skills/total-recall/plugins/mesh/ui/DESIGN.md +211 -12
  34. package/scaffold/.agent/skills/total-recall/plugins/mesh/ui/card.js +323 -388
  35. package/scaffold/.agent/skills/total-recall/plugins/openwiki/README.md +12 -1
  36. package/scaffold/.agent/skills/total-recall/plugins/openwiki/cli.mjs +7 -4
  37. package/scaffold/.agent/skills/total-recall/plugins/openwiki/package-lock.json +6 -6
  38. package/scaffold/.agent/skills/total-recall/plugins/openwiki/package.json +3 -2
  39. package/scaffold/.agent/skills/total-recall/plugins/openwiki/plugin.json +34 -1
  40. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/bootstrap.mjs +99 -0
  41. package/scaffold/.agent/skills/total-recall/plugins/openwiki/templates/openwiki/README.md +10 -0
  42. package/scaffold/.agent/skills/total-recall/plugins/openwiki/templates/openwiki/architecture.md +5 -0
  43. package/scaffold/.agent/skills/total-recall/plugins/openwiki/templates/openwiki/ide.md +10 -0
  44. package/scaffold/.agent/skills/total-recall/plugins/openwiki/templates/openwiki/memory.md +11 -0
  45. package/scaffold/.agent/skills/total-recall/plugins/openwiki/templates/openwiki/secrets.md +7 -0
  46. package/scaffold/.agent/skills/total-recall/plugins/openwiki/templates/openwiki/skills.md +7 -0
  47. package/scaffold/.agent/skills/total-recall/plugins/security/package-lock.json +4 -4
  48. package/scaffold/.agent/skills/total-recall/references/auth.md +2 -0
  49. package/src/cli/brain.mjs +68 -0
  50. package/src/cli/connect.mjs +10 -39
  51. package/src/core/surface.mjs +2 -2
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "tr-plugin-auth",
3
- "version": "1.2.1",
3
+ "version": "1.2.3",
4
4
  "description": "White-label Total Recall auth plugin: Passkey biometric FIDO2 and Google OAuth2 authentication.",
5
5
  "type": "module",
6
6
  "main": "cli.mjs",
7
7
  "scripts": {
8
- "test": "node --test test/*.test.mjs"
8
+ "test": "node --test test/*.test.mjs && vitest run test"
9
9
  },
10
10
  "keywords": [
11
11
  "total-recall-plugin",
@@ -28,10 +28,10 @@
28
28
  "plugin.json",
29
29
  "README.md",
30
30
  "src/",
31
- "test/",
32
31
  "scripts/",
33
32
  "!src/**/*.spec.mjs",
34
- "ui/"
33
+ "ui/",
34
+ "!src/**/*.test.mjs"
35
35
  ],
36
36
  "publishConfig": {
37
37
  "access": "public"
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://github.com/gregiteen/total-recall/blob/main/metadata.plugin.schema.json",
3
3
  "id": "auth",
4
4
  "name": "Auth & Secrets",
5
- "version": "1.2.1",
5
+ "version": "1.2.3",
6
6
  "description": "Hardware WebAuthn biometric FIDO2 passkeys, OAuth session security, and access control.",
7
7
  "author": "Total Recall contributors",
8
8
  "license": "MIT",
@@ -15,6 +15,8 @@
15
15
  import {
16
16
  setSecret,
17
17
  getSecret,
18
+ secretValueFingerprint,
19
+ resolveSecretKey,
18
20
  listSecretsMeta,
19
21
  rotateSecret,
20
22
  deleteSecret,
@@ -55,9 +57,11 @@ import {
55
57
  removeRemoteTarget,
56
58
  deployEnvToRemote,
57
59
  deployKeyToRemotes,
60
+ verifyRemote,
61
+ importFromPm2,
58
62
  } from './secrets-remote-deploy.mjs';
59
63
  import { listProviders, getProvider } from './provider-catalog.mjs';
60
- import { resolveBrainDir, parseLayerFlag, detectProjectBrain } from './agent-dir.mjs';
64
+ import { parseLayerFlag, detectProjectBrain } from './agent-dir.mjs';
61
65
  import { hostSecretsBrainDir } from './host-secrets-store.mjs';
62
66
  import fs from 'node:fs';
63
67
  import { ensureProjectId } from './project-id.mjs';
@@ -233,12 +237,21 @@ function printHelp() {
233
237
  list Show configured targets (no secret values)
234
238
  remove <name> Delete a target
235
239
  deploy <name> Push the SSOT projection to that target now
236
- [--dry-run] [--keys k1,k2]
240
+ [--dry-run] [--keys k1,k2] [--path <repo>]
241
+ verify <name> Compare the deployed file with the store by fingerprint
242
+ (match/differs/missing/duplicate; no values) [--keys] [--path]
243
+ import-pm2 <name> Move named keys from a pm2 app's captured env into the store
244
+ --app <pm2 name> --keys k1,k2 [--apply] [--overwrite] [--repo r]
245
+ merge-store <dir> Fold a stray per-repo store into this one (dry run unless --apply).
246
+ Missing keys are imported, equal keys gain the binding, differing
247
+ keys are conflicts: settle with --take K or --rename SRC=DEST
248
+ --repo <name> Bind imported keys to this repo
249
+ --bind-same Also bind keys whose value already matches (only for an app's own store)
250
+ --dates-from-source Restore an older value date for keys whose value is identical
237
251
  diff-env Compare repo .env files against the store (no values printed)
238
252
  --path <dir> Single repo (default: every project-registry path)
239
253
  --filename .env File to compare (default .env; server/<name> also checked)
240
254
  --strict Exit 1 on any drift or unadopted key
241
- --layer project Compare against the project-local store (default: global)
242
255
  import-env Import selected local .env credentials into auth
243
256
  --all / --file / --overwrite / --dry-run
244
257
  catalog Full catalog: keys, providers, usage, rotation, cost, tracking
@@ -258,6 +271,7 @@ function printHelp() {
258
271
  --shared-ok Allow this key to share its value with other secret names
259
272
  --no-shared-ok
260
273
  --repo <name> Bind to exactly ONE product repo (not multi)
274
+ --unbind <repo> Remove a repo binding (comma separated for several)
261
275
  --repos <name> Same as --repo (single name only; multi rejected)
262
276
  --tier <id> Subscription tier
263
277
  --monthly-cost <n> Planned monthly $
@@ -272,6 +286,10 @@ function printHelp() {
272
286
  --headscale-url <https-url>
273
287
  rotation-due List keys overdue for rotation
274
288
  --enqueue Create daemon tasks + browser-use prompts for each due key
289
+ fingerprint Identify a credential without printing it: length, type
290
+ prefix, fingerprint and the store keys holding the same
291
+ value. --from-clipboard | --stdin; --expect-prefix <p>
292
+ and --not <KEY> fail (exit 1) on a wrong value.
275
293
  rotate-browser <key> Drive the provider console in TR's browser and rotate
276
294
  --print-only Only print instructions
277
295
  --headless Allow headless for verified recipes
@@ -379,7 +397,7 @@ function parseArgs(args) {
379
397
  let i = 1;
380
398
  if (out.command === 'remote') {
381
399
  if (args[i] && !args[i].startsWith('--')) out.subcommand = args[i++];
382
- if (['add', 'remove', 'rm', 'deploy'].includes(out.subcommand) && args[i] && !args[i].startsWith('--')) {
400
+ if (['add', 'remove', 'rm', 'deploy', 'verify', 'import-pm2'].includes(out.subcommand) && args[i] && !args[i].startsWith('--')) {
383
401
  out.key = args[i++]; // reused as the remote target name
384
402
  }
385
403
  }
@@ -395,6 +413,7 @@ function parseArgs(args) {
395
413
  'rm',
396
414
  'generate',
397
415
  'account-sync',
416
+ 'merge-store',
398
417
  ].includes(out.command) &&
399
418
  args[i] &&
400
419
  !args[i].startsWith('--')
@@ -458,6 +477,21 @@ function parseArgs(args) {
458
477
  case '--dry-run':
459
478
  out.dryRun = true;
460
479
  break;
480
+ case '--apply':
481
+ out.apply = true;
482
+ break;
483
+ case '--bind-same':
484
+ out.bindSame = true;
485
+ break;
486
+ case '--dates-from-source':
487
+ out.datesFromSource = true;
488
+ break;
489
+ case '--unbind':
490
+ out.unbind = (out.unbind || []).concat(String(args[++i] || '').split(',').map((v) => v.trim()).filter(Boolean));
491
+ break;
492
+ case '--take':
493
+ out.take = String(args[++i] || '').split(',').map((v) => v.trim()).filter(Boolean);
494
+ break;
461
495
  case '--overwrite':
462
496
  out.overwrite = true;
463
497
  break;
@@ -507,6 +541,9 @@ function parseArgs(args) {
507
541
  case '--label':
508
542
  out.label = args[++i];
509
543
  break;
544
+ case '--provides':
545
+ out.provides = args[++i];
546
+ break;
510
547
  case '--project':
511
548
  out.project = args[++i];
512
549
  break;
@@ -517,8 +554,7 @@ function parseArgs(args) {
517
554
  out.path = args[++i];
518
555
  break;
519
556
  case '--layer':
520
- out.layer = args[++i];
521
- break;
557
+ throw new Error('Secrets have one store; --layer does not apply');
522
558
  case '--all-projects':
523
559
  out.allProjects = true;
524
560
  break;
@@ -607,6 +643,15 @@ function parseArgs(args) {
607
643
  case '--stdin':
608
644
  out.stdin = true;
609
645
  break;
646
+ case '--app':
647
+ out.app = args[++i];
648
+ break;
649
+ case '--expect-prefix':
650
+ out.expectPrefix = args[++i];
651
+ break;
652
+ case '--not':
653
+ out.notKey = args[++i];
654
+ break;
610
655
  case '--launch-agent':
611
656
  out.launchAgents.push(args[++i]);
612
657
  break;
@@ -637,6 +682,21 @@ function parseArgs(args) {
637
682
  return out;
638
683
  }
639
684
 
685
+ /** The host's single secrets store; TR_SECRETS_BRAIN is the only override (deploy/ops/tests). */
686
+ export function secretsStoreDir() {
687
+ return hostSecretsBrainDir();
688
+ }
689
+
690
+ /** Name of the repo the command runs in (its project root's directory name), or null outside one. */
691
+ function currentRepoName() {
692
+ try {
693
+ const project = detectProjectBrain(process.cwd());
694
+ return project ? path.basename(project.projectRoot || path.dirname(path.dirname(project.agentDir))) : null;
695
+ } catch {
696
+ return null;
697
+ }
698
+ }
699
+
640
700
  /** Id of the project the command runs in (created on first use), or null outside a project. */
641
701
  function currentProjectId() {
642
702
  try {
@@ -649,24 +709,30 @@ function currentProjectId() {
649
709
 
650
710
  export default async function secretCli(argv) {
651
711
  const { layer, remainingArgs } = parseLayerFlag(argv);
652
- const opts = parseArgs(remainingArgs);
712
+ let opts;
713
+ try {
714
+ opts = parseArgs(remainingArgs);
715
+ } catch (err) {
716
+ console.error(`❌ ${err.message}`);
717
+ process.exit(1);
718
+ }
653
719
  if (opts.help || !opts.command) {
654
720
  printHelp();
655
721
  return;
656
722
  }
657
723
 
658
- let brainDir;
659
- try {
660
- if (layer === 'project') throw new Error('Secrets have one host store; --project does not apply.');
661
- brainDir = hostSecretsBrainDir();
662
- } catch (err) {
663
- console.error(`❌ ${err.message}`);
724
+ // One store per host. A project brain in the cwd never selects a different
725
+ // secrets.enc: that is how per-repo stores appeared and drifted from the real
726
+ // one. Repo binding lives in each key's metadata (`meta <key> --repo`).
727
+ if (layer === 'project') {
728
+ console.error('❌ Secrets have one store; --project does not apply. Bind a key to a repo with `auth secret meta <key> --repo <name>`.');
664
729
  process.exit(1);
665
730
  }
731
+ const brainDir = secretsStoreDir();
666
732
 
667
- if (opts.stdin) {
733
+ if (opts.stdin && opts.command !== 'fingerprint') {
668
734
  if (!['set', 'rotate', 'rekey'].includes(opts.command)) {
669
- console.error('❌ --stdin is only valid with `set`, `rotate`, or `rekey`');
735
+ console.error('❌ --stdin is only valid with `set`, `rotate`, `rekey`, or `fingerprint`');
670
736
  process.exit(1);
671
737
  }
672
738
  if (opts.command !== 'rekey' && !opts.key) {
@@ -776,7 +842,8 @@ export default async function secretCli(argv) {
776
842
  console.error('Usage: total-recall auth secret set <key> <value>');
777
843
  process.exit(1);
778
844
  }
779
- await setSecret(brainDir, opts.key, opts.value, {
845
+ const setKey = await resolveSecretKey(brainDir, opts.key, currentRepoName());
846
+ await setSecret(brainDir, setKey, opts.value, {
780
847
  tags: opts.tags != null ? opts.tags : undefined,
781
848
  project_id: currentProjectId(),
782
849
  provider: opts.provider || inferProvider(opts.key),
@@ -793,7 +860,7 @@ export default async function secretCli(argv) {
793
860
  project_path: opts.project,
794
861
  headscale_url: opts.headscale_url,
795
862
  });
796
- console.log(`\n ✅ Secret set: ${opts.key}`);
863
+ console.log(`\n ✅ Secret set: ${setKey}${setKey !== opts.key ? ` (provides ${opts.key} for this repo)` : ''}`);
797
864
  console.log(` store: ${resolveSecretsPath(brainDir)}`);
798
865
  console.log(` (value not printed)\n`);
799
866
  return;
@@ -1019,6 +1086,14 @@ export default async function secretCli(argv) {
1019
1086
  }
1020
1087
  const patch = {};
1021
1088
  if (opts.repos.length) patch.repos = opts.repos;
1089
+ if (opts.unbind?.length) {
1090
+ const row = (await listSecretsMeta(brainDir)).find((r) => r.key === opts.key);
1091
+ if (!row) {
1092
+ console.error(`❌ Secret not found: ${opts.key}`);
1093
+ process.exit(1);
1094
+ }
1095
+ patch.repos = (patch.repos || row.repos || []).filter((r) => !opts.unbind.includes(r));
1096
+ }
1022
1097
  if (opts.tier != null) patch.subscription_tier = opts.tier;
1023
1098
  if (opts.monthly_cost != null) patch.monthly_cost_usd = opts.monthly_cost;
1024
1099
  if (opts.monthly_cap != null) patch.monthly_cap_usd = opts.monthly_cap;
@@ -1028,6 +1103,7 @@ export default async function secretCli(argv) {
1028
1103
  if (opts.notes) patch.notes = opts.notes;
1029
1104
  if (opts.tags != null) patch.tags = opts.tags;
1030
1105
  if (opts.label) patch.label = opts.label;
1106
+ if (opts.provides !== undefined) patch.provides = opts.provides;
1031
1107
  if (opts.project) patch.project_path = opts.project;
1032
1108
  if (opts.provider) patch.provider = opts.provider;
1033
1109
  if (opts.scope) patch.scope = opts.scope;
@@ -1081,7 +1157,8 @@ export default async function secretCli(argv) {
1081
1157
  process.exit(1);
1082
1158
  }
1083
1159
  const project_id = currentProjectId();
1084
- const r = await getSecret(brainDir, opts.key, { project_id });
1160
+ const resolved = await resolveSecretKey(brainDir, opts.key, currentRepoName());
1161
+ const r = await getSecret(brainDir, resolved, { project_id });
1085
1162
  if (opts.json) {
1086
1163
  console.log(JSON.stringify(r.found ? { found: true, value: r.value } : { found: false }));
1087
1164
  return;
@@ -1097,6 +1174,13 @@ export default async function secretCli(argv) {
1097
1174
 
1098
1175
  if (opts.command === 'list') {
1099
1176
  const rows = await listSecretsMeta(brainDir);
1177
+ if (opts.json) {
1178
+ // Metadata for monitoring: bindings, tags, provides, provider and
1179
+ // verification. Never the value, its full hash or a masked preview.
1180
+ const safe = rows.map(({ masked, fingerprint_full, value, ...row }) => row);
1181
+ console.log(JSON.stringify({ store: resolveSecretsPath(brainDir), keys: safe }, null, 2));
1182
+ return;
1183
+ }
1100
1184
  if (!rows.length) {
1101
1185
  console.log(`\n No secrets in ${resolveSecretsPath(brainDir)}\n`);
1102
1186
  return;
@@ -1125,6 +1209,33 @@ export default async function secretCli(argv) {
1125
1209
  return;
1126
1210
  }
1127
1211
 
1212
+ if (opts.command === 'fingerprint') {
1213
+ // Identify a credential without seeing it: which store keys hold the same
1214
+ // value, its length and its non-secret type prefix. Lets an operator check
1215
+ // the clipboard holds the new key before `rotate --from-clipboard`.
1216
+ const value = opts.fromClipboard ? readSystemClipboard() : opts.stdin ? readStdinValue() : '';
1217
+ if (!value) {
1218
+ console.error('Usage: total-recall auth secret fingerprint --from-clipboard|--stdin [--expect-prefix <p>] [--not <KEY>] [--json]');
1219
+ process.exit(1);
1220
+ }
1221
+ const fp = secretValueFingerprint(value);
1222
+ const matches = (await listSecretsMeta(brainDir)).filter((row) => row.fingerprint_full === fp).map((row) => row.key);
1223
+ const prefix = (value.match(/^(?:(?:sk|rk|pk)_(?:live|test)_|whsec_|ghp_|github_pat_|npm_|dop_v1_|sk-or-v1-|sk-ant-|AIza|tr_)/) || [null])[0];
1224
+ const r = { length: value.length, prefix, fingerprint: fp.slice(0, 12), whitespace: /\s/.test(value), store_matches: matches };
1225
+ const problems = [];
1226
+ if (opts.expectPrefix && !value.startsWith(opts.expectPrefix)) problems.push(`does not start with ${opts.expectPrefix}`);
1227
+ if (opts.notKey && matches.includes(opts.notKey)) problems.push(`equals the current ${opts.notKey}`);
1228
+ if (opts.json) console.log(JSON.stringify({ ...r, ok: !problems.length, problems }, null, 2));
1229
+ else {
1230
+ console.log(`\n 🔏 length ${r.length} prefix ${r.prefix || '(none)'} fingerprint ${r.fingerprint}${r.whitespace ? ' CONTAINS WHITESPACE' : ''}`);
1231
+ console.log(` store keys with this value: ${matches.join(', ') || 'none'}`);
1232
+ for (const p of problems) console.log(` ❌ ${p}`);
1233
+ console.log('');
1234
+ }
1235
+ if (problems.length) process.exitCode = 1;
1236
+ return;
1237
+ }
1238
+
1128
1239
  if (opts.command === 'remote') {
1129
1240
  if (!opts.subcommand) {
1130
1241
  console.error(
@@ -1132,7 +1243,9 @@ export default async function secretCli(argv) {
1132
1243
  ' add <name> --host <h> --path </remote/dir> [--user root] [--port 22] [--filename .env] [--restart-cmd "..."]\n' +
1133
1244
  ' list\n' +
1134
1245
  ' remove <name>\n' +
1135
- ' deploy <name> [--dry-run] [--keys k1,k2]',
1246
+ ' deploy <name> [--dry-run] [--keys k1,k2] [--path <repo>]\n' +
1247
+ ' verify <name> [--keys k1,k2] [--path <repo>] [--json]\n' +
1248
+ ' import-pm2 <name> --app <pm2 name> --keys k1,k2 [--apply] [--overwrite] [--repo r] [--json]',
1136
1249
  );
1137
1250
  process.exit(1);
1138
1251
  }
@@ -1198,6 +1311,7 @@ export default async function secretCli(argv) {
1198
1311
  dryRun: opts.dryRun,
1199
1312
  keys: opts.keys.length ? opts.keys : undefined,
1200
1313
  rename: opts.rename,
1314
+ projectPath: opts.path ? path.resolve(opts.path) : undefined,
1201
1315
  });
1202
1316
  if (r.dryRun) {
1203
1317
  console.log(`\n 📤 dry-run remote deploy → ${r.target} (${r.host})`);
@@ -1211,6 +1325,50 @@ export default async function secretCli(argv) {
1211
1325
  return;
1212
1326
  }
1213
1327
 
1328
+ if (opts.subcommand === 'verify') {
1329
+ if (!opts.key) {
1330
+ console.error('Usage: total-recall auth secret remote verify <name> [--keys k1,k2] [--path <repo>] [--json]');
1331
+ process.exit(1);
1332
+ }
1333
+ const r = await verifyRemote(brainDir, opts.key, {
1334
+ keys: opts.keys.length ? opts.keys : undefined,
1335
+ projectPath: opts.path ? path.resolve(opts.path) : undefined,
1336
+ });
1337
+ if (opts.json) console.log(JSON.stringify(r, null, 2));
1338
+ else {
1339
+ console.log(`\n 🔎 ${r.target} (${r.host}:${r.remoteFile})`);
1340
+ for (const row of r.rows) {
1341
+ const mark = row.status === 'match' && !row.duplicates ? '✅' : '❌';
1342
+ console.log(` ${mark} ${row.key.padEnd(36)} ${row.status}${row.duplicates ? ` (+${row.duplicates} duplicate)` : ''}`);
1343
+ }
1344
+ console.log(` ${r.remoteOnly.length} key(s) on the remote are not in the projection`);
1345
+ console.log(` ${r.ok ? 'in sync' : 'DRIFT'} (values compared by fingerprint; none printed)\n`);
1346
+ }
1347
+ if (!r.ok) process.exitCode = 1;
1348
+ return;
1349
+ }
1350
+
1351
+ if (opts.subcommand === 'import-pm2') {
1352
+ if (!opts.key || !opts.app || !opts.keys.length) {
1353
+ console.error('Usage: total-recall auth secret remote import-pm2 <name> --app <pm2 name> --keys k1,k2 [--apply] [--overwrite] [--repo r] [--json]');
1354
+ process.exit(1);
1355
+ }
1356
+ const r = await importFromPm2(brainDir, opts.key, {
1357
+ app: opts.app,
1358
+ keys: opts.keys,
1359
+ apply: opts.apply,
1360
+ overwrite: opts.overwrite,
1361
+ repos: opts.repos,
1362
+ });
1363
+ if (opts.json) console.log(JSON.stringify(r, null, 2));
1364
+ else {
1365
+ console.log(`\n 📥 pm2 ${r.app} on ${r.target}${opts.apply ? '' : ' (dry run; add --apply to store)'}`);
1366
+ for (const row of r.rows) console.log(` • ${row.key.padEnd(36)} ${row.status}${row.applied ? ' → stored' : ''}`);
1367
+ console.log(' (values read in-process; none printed)\n');
1368
+ }
1369
+ return;
1370
+ }
1371
+
1214
1372
  console.error(`Unknown remote subcommand: ${opts.subcommand}`);
1215
1373
  process.exit(1);
1216
1374
  }
@@ -1515,17 +1673,42 @@ export default async function secretCli(argv) {
1515
1673
  return;
1516
1674
  }
1517
1675
 
1676
+ if (opts.command === 'merge-store') {
1677
+ if (!opts.key) {
1678
+ console.error('❌ Usage: auth secret merge-store <source brain dir> [--repo <name>] [--tags a,b] [--rename SRC=DEST] [--take K1,K2] [--apply] [--json]');
1679
+ process.exit(1);
1680
+ }
1681
+ const { mergeStore } = await import('./secrets-merge.mjs');
1682
+ const report = await mergeStore(brainDir, path.resolve(opts.key), {
1683
+ apply: opts.apply === true,
1684
+ bindSame: opts.bindSame === true,
1685
+ datesFromSource: opts.datesFromSource === true,
1686
+ repo: opts.repos.length ? opts.repos : undefined,
1687
+ tags: opts.tags,
1688
+ rename: opts.rename,
1689
+ take: opts.take,
1690
+ actor: 'cli',
1691
+ });
1692
+ if (opts.json) {
1693
+ console.log(JSON.stringify(report, null, 2));
1694
+ } else {
1695
+ console.log(`\n merge-store ${report.applied ? 'applied' : 'dry run (add --apply to write)'} → ${resolveSecretsPath(brainDir)}`);
1696
+ console.log(` imported ${report.imported.length} · renamed ${report.renamed.length} · taken ${report.taken.length} · same ${report.same.length}${report.redated ? ` (${report.redated} redated)` : ''} · conflicts ${report.conflicts.length}`);
1697
+ for (const c of report.conflicts) {
1698
+ console.log(` ⚠️ ${c.key}: target ${c.target.fingerprint} (updated ${c.target.updated_at || '?'}, ${c.target.tracking_status || 'unverified'}) vs source ${c.source.fingerprint} (updated ${c.source.updated_at || '?'}, ${c.source.tracking_status || 'unverified'})`);
1699
+ }
1700
+ console.log('');
1701
+ }
1702
+ if (report.conflicts.length) process.exitCode = 2;
1703
+ return;
1704
+ }
1705
+
1518
1706
  if (opts.command === 'diff-env') {
1519
1707
  const { diffEnvAgainstStore, resolveEnvFiles } = await import('./secrets-env-diff.mjs');
1520
1708
  const { loadProjectRegistry } = await import('./secrets-env-export.mjs');
1521
- const { globalBrainDir } = await import('./config.mjs');
1522
-
1523
- // Compare against the GLOBAL store, not whichever layer the cwd resolves
1524
- // to. A repo with its own .agent/ shadows the global store, so running
1525
- // this from inside one would diff .env files against a project-local
1526
- // store and report drift that is really just two different stores —
1527
- // which is how the real drift stayed invisible. --layer project opts in.
1528
- const compareBrainDir = opts.layer === 'project' ? brainDir : globalBrainDir;
1709
+
1710
+ // Always the single store: a repo's own .agent/ never selects another one.
1711
+ const compareBrainDir = brainDir;
1529
1712
 
1530
1713
  const roots = opts.path
1531
1714
  ? [path.resolve(opts.path)]
@@ -15,6 +15,10 @@
15
15
  * class?: 'provider_api' | 'provider_browser' | 'self_generated' | 'manual',
16
16
  * high_risk?: boolean,
17
17
  * manual_keys?: string[],
18
+ * // Keys the provider issues that a name pattern would otherwise class as self-minted
19
+ * // (a Stripe webhook signing secret ends in _WEBHOOK_SECRET but only Stripe can issue it),
20
+ * // with the console page where each is rolled.
21
+ * issued_keys?: { [key: string]: string },
18
22
  * // A provider that lets the account mint and revoke its own keys over its API. The management
19
23
  * // credential is a separate stored secret; without it the provider cannot rotate itself, which
20
24
  * // is what made 'provider_api' a class with no implementation.
@@ -345,7 +349,12 @@ export const PROVIDER_CATALOG = [
345
349
  notes: 'Live keys are payment-critical. Roll with a grace window; never revoke before export confirms.',
346
350
  },
347
351
  // Stripe exposes no API to mint or roll secret keys — dashboard only, by design.
348
- rotation: { class: 'provider_browser', high_risk: true },
352
+ rotation: {
353
+ class: 'provider_browser',
354
+ high_risk: true,
355
+ // Signing secrets are issued per webhook endpoint; a self-minted value fails every signature check.
356
+ issued_keys: { STRIPE_WEBHOOK_SECRET: 'https://dashboard.stripe.com/webhooks' },
357
+ },
349
358
  default_monthly_cap_usd: null,
350
359
  tiers: [{ id: 'payg', label: 'Pay as you go', monthly_usd: null }],
351
360
  },
@@ -115,6 +115,21 @@ export function getRotationPlan(key, meta = {}) {
115
115
  };
116
116
  }
117
117
 
118
+ const issuer = meta.provider ? getProvider(meta.provider) : providerForKeyName(k);
119
+ const issuedConsole = Object.entries(issuer?.rotation?.issued_keys || {})
120
+ .find(([name]) => name.toUpperCase() === k.toUpperCase())?.[1];
121
+ if (issuedConsole) {
122
+ return {
123
+ ...base,
124
+ class: 'provider_browser',
125
+ provider: issuer.id,
126
+ console_url: issuedConsole,
127
+ high_risk: !!issuer.rotation.high_risk,
128
+ automatable: true,
129
+ reason: `${issuer.name} issues this value; roll it in the console, never mint it locally`,
130
+ };
131
+ }
132
+
118
133
  if (matchesAny(k, SELF_GENERATED_PATTERNS)) {
119
134
  return {
120
135
  ...base,
@@ -26,26 +26,58 @@ function envHeader() {
26
26
  `;
27
27
  }
28
28
 
29
+ const ASSIGNMENT = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/;
30
+
31
+ function assignedKey(line) {
32
+ const m = ASSIGNMENT.exec(line);
33
+ return m ? m[1] : null;
34
+ }
35
+
36
+ /** Drop assignments of `keys` from hand-managed text so the managed block is the only definition. */
37
+ function withoutKeys(text, keys) {
38
+ if (!keys.size) return text;
39
+ return text.split('\n').filter((line) => !keys.has(assignedKey(line))).join('\n');
40
+ }
41
+
29
42
  /**
30
43
  * Merge a managed TR secrets block into an existing .env without wiping
31
44
  * non-secret / hand-managed keys outside the BEGIN/END markers.
32
45
  *
46
+ * A key the block defines is removed from the text outside the block: dotenv
47
+ * loaders disagree on whether the first or last assignment wins, so a stale
48
+ * copy left outside would shadow the rotated value under some of them.
49
+ * With `partial`, the block carries only some keys (a `--keys` deploy), so the
50
+ * previous block's other entries are kept rather than dropped.
51
+ *
33
52
  * @param {string} existing - current file contents (or '')
34
53
  * @param {string} managedBody - full block including BEGIN/END marks
54
+ * @param {{ partial?: boolean }} [opts]
35
55
  * @returns {string}
36
56
  */
37
- export function mergeEnvManagedBlock(existing, managedBody) {
38
- const managed = String(managedBody || '').trimEnd() + '\n';
57
+ export function mergeEnvManagedBlock(existing, managedBody, { partial = false } = {}) {
58
+ let managed = String(managedBody || '').trimEnd() + '\n';
39
59
  const text = existing == null ? '' : String(existing);
40
60
 
41
61
  if (!text.trim()) return managed;
42
62
 
43
63
  const beginIdx = text.indexOf(BEGIN_MARK);
44
64
  const endIdx = text.indexOf(END_MARK);
65
+ const hasBlock = beginIdx !== -1 && endIdx !== -1 && endIdx > beginIdx;
66
+
67
+ if (partial && hasBlock) {
68
+ const fresh = new Set(managed.split('\n').map(assignedKey).filter(Boolean));
69
+ const kept = text.slice(beginIdx, endIdx).split('\n')
70
+ .filter((line) => { const key = assignedKey(line); return key && !fresh.has(key); });
71
+ if (kept.length) {
72
+ const end = managed.lastIndexOf(END_MARK);
73
+ managed = `${managed.slice(0, end)}${kept.join('\n')}\n${managed.slice(end)}`;
74
+ }
75
+ }
76
+ const managedKeys = new Set(managed.split('\n').map(assignedKey).filter(Boolean));
45
77
 
46
- if (beginIdx !== -1 && endIdx !== -1 && endIdx > beginIdx) {
47
- const before = text.slice(0, beginIdx).replace(/\s+$/, '');
48
- const after = text.slice(endIdx + END_MARK.length).replace(/^\r?\n?/, '');
78
+ if (hasBlock) {
79
+ const before = withoutKeys(text.slice(0, beginIdx), managedKeys).replace(/\s+$/, '');
80
+ const after = withoutKeys(text.slice(endIdx + END_MARK.length).replace(/^\r?\n?/, ''), managedKeys);
49
81
  const parts = [];
50
82
  if (before) parts.push(before.endsWith('\n') ? before : `${before}\n`);
51
83
  parts.push(managed.endsWith('\n') ? managed : `${managed}\n`);
@@ -56,10 +88,43 @@ export function mergeEnvManagedBlock(existing, managedBody) {
56
88
  }
57
89
 
58
90
  // No prior TR block — append managed section after existing content
59
- const base = text.endsWith('\n') ? text : `${text}\n`;
91
+ const rest = withoutKeys(text, managedKeys);
92
+ const base = rest.endsWith('\n') ? rest : `${rest}\n`;
60
93
  return `${base}\n${managed}`;
61
94
  }
62
95
 
96
+ /**
97
+ * Refuse a shared plugin path as a secret-projection target.
98
+ *
99
+ * The shared plugin folder lives at `<agentDir>/skills/total-recall/plugins` and
100
+ * normally holds one symlink per plugin pointing at that plugin's owner
101
+ * checkout. A projection written there lands in a plugin repository, which is
102
+ * not an application and must never hold an application's secrets.
103
+ *
104
+ * @param {string} abs absolute, existing directory
105
+ */
106
+ export function assertNotSharedPluginPath(abs) {
107
+ const agentDir = process.env.AGENT_DIR || process.env._TR_TEST_AGENT_DIR || path.join(os.homedir(), '.agent');
108
+ const pluginsRoot = path.join(agentDir, 'skills', 'total-recall', 'plugins');
109
+ const candidates = new Set([path.resolve(abs)]);
110
+ try { candidates.add(fs.realpathSync(abs)); } catch { /* unreadable path */ }
111
+ for (const candidate of candidates) {
112
+ if (candidate === pluginsRoot || candidate.startsWith(pluginsRoot + path.sep)) {
113
+ throw new Error(`Refusing to write secrets into the shared plugin folder: ${abs}. Name the application repository with --path.`);
114
+ }
115
+ let entries = [];
116
+ try { entries = fs.readdirSync(pluginsRoot, { withFileTypes: true }); } catch { return; }
117
+ for (const entry of entries) {
118
+ if (!entry.isSymbolicLink()) continue;
119
+ let target;
120
+ try { target = fs.realpathSync(path.join(pluginsRoot, entry.name)); } catch { continue; }
121
+ if (candidate === target || candidate.startsWith(target + path.sep)) {
122
+ throw new Error(`Refusing to write secrets into the plugin checkout ${target} (reached as ${abs}). Name the application repository with --path.`);
123
+ }
124
+ }
125
+ }
126
+ }
127
+
63
128
  /**
64
129
  * Resolve a project slug from a filesystem path (last segment).
65
130
  * @param {string} projectPath
@@ -172,6 +237,8 @@ ${exampleLines.join('\n')}${exampleLines.length ? '\n' : ''}`;
172
237
  keys: selected.map((s) => s.key),
173
238
  count: selected.length,
174
239
  store: resolveSecretsPath(brainDir),
240
+ // [outKey, value] pairs for in-process comparison (remote verify). Never log them.
241
+ entries: selected.map(({ outKey, value }) => [outKey, value]),
175
242
  };
176
243
  }
177
244
 
@@ -194,6 +261,11 @@ export async function exportEnvToProject(brainDir, projectPath, opts = {}) {
194
261
  if (!fs.existsSync(abs) || !fs.statSync(abs).isDirectory()) {
195
262
  throw new Error(`Project path is not a directory: ${abs}`);
196
263
  }
264
+ // A secret projection belongs to a repository. The shared plugin folder links
265
+ // every plugin to its owner checkout, so exporting with the default cwd —
266
+ // inside a plugin — wrote a plaintext .env of the whole store into 30 plugin
267
+ // repositories. Refuse that target rather than requiring a flag to avoid it.
268
+ assertNotSharedPluginPath(abs);
197
269
 
198
270
  const filename = opts.filename || '.env';
199
271
  const target = {