total-recall-brain 3.41.3 → 3.41.4

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 (80) hide show
  1. package/bin/total-recall.mjs +10 -1
  2. package/frontend/dist/assets/{index-DFenQQEQ.js → index-CRzjQLu7.js} +77 -72
  3. package/frontend/dist/index.html +1 -1
  4. package/package.json +7 -5
  5. package/scaffold/.agent/skills/total-recall/plugins/auth/package-lock.json +2 -2
  6. package/scaffold/.agent/skills/total-recall/plugins/auth/package.json +1 -1
  7. package/scaffold/.agent/skills/total-recall/plugins/auth/plugin.json +1 -1
  8. package/scaffold/.agent/skills/total-recall/plugins/auth/src/cli-secret.mjs +48 -0
  9. package/scaffold/.agent/skills/total-recall/plugins/auth/src/login-projections.mjs +42 -0
  10. package/scaffold/.agent/skills/total-recall/plugins/auth/src/mesh-secrets.mjs +18 -0
  11. package/scaffold/.agent/skills/total-recall/plugins/auth/src/routes/secrets.mjs +5 -2
  12. package/scaffold/.agent/skills/total-recall/plugins/auth/src/secrets-carriers.mjs +76 -0
  13. package/scaffold/.agent/skills/total-recall/plugins/auth/src/secrets-prune.mjs +37 -0
  14. package/scaffold/.agent/skills/total-recall/plugins/auth/src/secrets-sync.mjs +53 -17
  15. package/scaffold/.agent/skills/total-recall/plugins/code-quality/package-lock.json +12 -12
  16. package/scaffold/.agent/skills/total-recall/plugins/code-quality/package.json +1 -1
  17. package/scaffold/.agent/skills/total-recall/plugins/composable-cli/package-lock.json +12 -12
  18. package/scaffold/.agent/skills/total-recall/plugins/composable-cli/package.json +1 -1
  19. package/scaffold/.agent/skills/total-recall/plugins/embeddings/package-lock.json +23 -8
  20. package/scaffold/.agent/skills/total-recall/plugins/embeddings/package.json +6 -2
  21. package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/package-lock.json +23 -8
  22. package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/package.json +5 -1
  23. package/scaffold/.agent/skills/total-recall/plugins/mesh/package-lock.json +23 -10
  24. package/scaffold/.agent/skills/total-recall/plugins/mesh/package.json +5 -3
  25. package/scaffold/.agent/skills/total-recall/plugins/mesh/plugin.json +1 -1
  26. package/scaffold/.agent/skills/total-recall/plugins/mesh/src/cli/grants.mjs +7 -3
  27. package/scaffold/.agent/skills/total-recall/plugins/mesh/src/cli/mesh.mjs +61 -38
  28. package/scaffold/.agent/skills/total-recall/plugins/mesh/src/core/leader-election.mjs +57 -2
  29. package/scaffold/.agent/skills/total-recall/plugins/mesh/src/core/mesh.mjs +220 -36
  30. package/scaffold/.agent/skills/total-recall/plugins/mesh/src/server/routes/mesh.mjs +7 -2
  31. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/README.md +65 -0
  32. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/capability/BRICK.md +59 -0
  33. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/capability/index.mjs +534 -0
  34. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/cli.mjs +139 -0
  35. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/package-lock.json +2849 -0
  36. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/package.json +26 -0
  37. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/plugin.json +376 -0
  38. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/skills/meta-harness/SKILL.md +37 -0
  39. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/adapters/dsh.mjs +446 -0
  40. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/alerts.mjs +23 -0
  41. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/coordinator.mjs +398 -0
  42. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/documentation.mjs +152 -0
  43. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/executor.mjs +82 -0
  44. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/learner.mjs +90 -0
  45. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/router.mjs +415 -0
  46. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/runtime.mjs +36 -0
  47. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/setup.mjs +35 -0
  48. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/tracker.mjs +133 -0
  49. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/worker.mjs +233 -0
  50. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/ui/DESIGN.md +32 -0
  51. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/ui/card.js +845 -0
  52. package/scaffold/.agent/skills/total-recall/plugins/openwiki/capability/BRICK.md +64 -0
  53. package/scaffold/.agent/skills/total-recall/plugins/openwiki/capability/index.mjs +250 -0
  54. package/scaffold/.agent/skills/total-recall/plugins/openwiki/cli.mjs +344 -14
  55. package/scaffold/.agent/skills/total-recall/plugins/openwiki/package-lock.json +2917 -140
  56. package/scaffold/.agent/skills/total-recall/plugins/openwiki/package.json +9 -3
  57. package/scaffold/.agent/skills/total-recall/plugins/openwiki/plugin.json +223 -5
  58. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/config.mjs +129 -0
  59. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/refresh.mjs +355 -0
  60. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/upstream-watcher.mjs +375 -0
  61. package/scaffold/.agent/skills/total-recall/plugins/openwiki/ui/DESIGN.md +35 -22
  62. package/scaffold/.agent/skills/total-recall/plugins/openwiki/ui/card.js +947 -323
  63. package/scaffold/.agent/skills/total-recall/plugins/security/package-lock.json +23 -8
  64. package/scaffold/.agent/skills/total-recall/plugins/security/package.json +5 -1
  65. package/src/cli/config.mjs +3 -0
  66. package/src/cli/connect.mjs +12 -116
  67. package/src/cli/harness.mjs +0 -1
  68. package/src/cli/plugin/create.mjs +56 -3
  69. package/src/cli/plugin/index.mjs +15 -3
  70. package/src/cli/plugin/monetization.mjs +232 -12
  71. package/src/cli/preflight.mjs +161 -0
  72. package/src/core/cli-commands.mjs +2 -0
  73. package/src/core/context-compiler.mjs +8 -1
  74. package/src/core/default-plugins.mjs +1 -1
  75. package/src/core/meta-harness.mjs +16 -38
  76. package/src/core/plugin-dependencies.mjs +6 -1
  77. package/src/core/preflight-gate.mjs +77 -0
  78. package/src/core/preflight-receipt.mjs +123 -0
  79. package/src/core/runtime.mjs +0 -3
  80. package/src/core/surface.mjs +38 -1
@@ -17,7 +17,7 @@
17
17
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
18
18
  <link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,600;12..96,800&family=Cabinet+Grotesk:wght@700;800&family=Chivo+Mono:wght@500;700&family=Cinzel:wght@600;700;900&family=Cormorant+Garamond:ital,wght@0,600;0,700;1,600;1,700&family=DM+Mono:wght@500&family=DM+Sans:wght@500;700&family=Fraunces:opsz,wght@9..144,600;9..144,800&family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;700&family=Lexend:wght@600;800&family=Newsreader:ital,opsz,wght@0,6..72,600;1,6..72,600&family=Orbitron:wght@700;900&family=Outfit:wght@500;700;800&family=Plus+Jakarta+Sans:wght@600;700;800&family=Space+Grotesk:wght@600;700&family=Syne:wght@700;800&display=swap" rel="stylesheet" />
19
19
  <title>Total Recall — Portable Memory</title>
20
- <script type="module" crossorigin src="/assets/index-DFenQQEQ.js"></script>
20
+ <script type="module" crossorigin src="/assets/index-CRzjQLu7.js"></script>
21
21
  <link rel="stylesheet" crossorigin href="/assets/index-BIQmqDKP.css">
22
22
  </head>
23
23
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "total-recall-brain",
3
- "version": "3.41.3",
3
+ "version": "3.41.4",
4
4
  "description": "The Vibe Coding Brain & Autonomous Software Factory: memory, rules, secrets, and 33 visual plugins automatically managed across all your devices.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -56,7 +56,8 @@
56
56
  "prepublishOnly": "npm run check:package && npm run check:dist && npm run check:ssss-registry && node scripts/check-scaffold-state.mjs",
57
57
  "check:ssss-registry": "node scripts/check-ssss-registry.mjs",
58
58
  "check:package": "node scripts/check-memory-package.mjs",
59
- "start:memory": "node src/server/memory-server.mjs"
59
+ "start:memory": "node src/server/memory-server.mjs",
60
+ "ssss": "ssss"
60
61
  },
61
62
  "repository": {
62
63
  "type": "git",
@@ -76,7 +77,7 @@
76
77
  "homepage": "https://github.com/gregiteen/total-recall#readme",
77
78
  "dependencies": {
78
79
  "@simplewebauthn/server": "^13.3.2",
79
- "@ssss/cli": "npm:@gregiteen/ssss-cli@0.12.1",
80
+ "@ssss/cli": "npm:@gregiteen/ssss-cli@0.14.3",
80
81
  "bcrypt": "^6.0.0",
81
82
  "better-sqlite3": "^12.11.1",
82
83
  "cookie-parser": "^1.4.7",
@@ -88,6 +89,7 @@
88
89
  "jsonwebtoken": "^9.0.3",
89
90
  "lodash-es": "^4.18.1",
90
91
  "natural": "^8.1.1",
92
+ "openwiki": "^0.7.3",
91
93
  "preact": "^10.29.7",
92
94
  "sqlite-vss": "^0.1.2",
93
95
  "ws": "^8.18.0",
@@ -103,8 +105,8 @@
103
105
  "@vitest/coverage-v8": "^4.1.12",
104
106
  "gray-matter": "^4.0.3",
105
107
  "jsdom": "^29.1.1",
106
- "react": "^19.2.7",
107
- "react-dom": "^19.2.7",
108
+ "react": "^19.3.1",
109
+ "react-dom": "^19.3.1",
108
110
  "react-router-dom": "^7.18.1",
109
111
  "recharts": "^3.9.2",
110
112
  "skills": "^1.5.7",
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "tr-plugin-auth",
3
- "version": "1.2.3",
3
+ "version": "1.2.5",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "tr-plugin-auth",
9
- "version": "1.2.3",
9
+ "version": "1.2.5",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@simplewebauthn/server": "^13.3.2",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tr-plugin-auth",
3
- "version": "1.2.4",
3
+ "version": "1.2.5",
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",
@@ -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.4",
5
+ "version": "1.2.5",
6
6
  "description": "Hardware WebAuthn biometric FIDO2 passkeys, OAuth session security, and access control.",
7
7
  "author": "Total Recall contributors",
8
8
  "license": "MIT",
@@ -51,6 +51,7 @@ import {
51
51
  enqueueRotationDueTasks,
52
52
  getBrowserRotateAssist,
53
53
  } from './secrets-rotate.mjs';
54
+ import { findAmbientPasswordExports, removeAmbientPasswordExports, carrierPassword } from './secrets-carriers.mjs';
54
55
  import {
55
56
  loadRemoteTargets,
56
57
  addRemoteTarget,
@@ -200,6 +201,7 @@ function printHelp() {
200
201
  generate <key> Generate a random secret for outside integrations
201
202
  get <key> [--json] Read selected-store value (audited); JSON reports found/value
202
203
  list List keys + metadata only (no values)
204
+ prune Read-only report: unbound, errored and duplicate keys
203
205
  rotate <key> Replace value and mark rotated. Prompts for the value;
204
206
  --stdin reads fd0, --from-clipboard reads the OS
205
207
  clipboard (use the browser you are already signed
@@ -286,6 +288,9 @@ function printHelp() {
286
288
  --headscale-url <https-url>
287
289
  rotation-due List keys overdue for rotation
288
290
  --enqueue Create daemon tasks + browser-use prompts for each due key
291
+ carriers Find shell profiles and /etc files that export the master
292
+ password to every process; --remove-ambient deletes them
293
+ once an owner-only carrier holds the same password.
289
294
  fingerprint Identify a credential without printing it: length, type
290
295
  prefix, fingerprint and the store keys holding the same
291
296
  value. --from-clipboard | --stdin; --expect-prefix <p>
@@ -643,6 +648,9 @@ function parseArgs(args) {
643
648
  case '--stdin':
644
649
  out.stdin = true;
645
650
  break;
651
+ case '--remove-ambient':
652
+ out.removeAmbient = true;
653
+ break;
646
654
  case '--app':
647
655
  out.app = args[++i];
648
656
  break;
@@ -1045,6 +1053,18 @@ export default async function secretCli(argv) {
1045
1053
  return;
1046
1054
  }
1047
1055
 
1056
+ if (opts.command === 'prune') {
1057
+ const { pruneCandidates, pruneSummary } = await import('./secrets-prune.mjs');
1058
+ const rows = (await listSecretsMeta(brainDir)).filter((r) => r.set);
1059
+ const candidates = pruneCandidates(rows);
1060
+ const sum = pruneSummary(rows, candidates);
1061
+ console.log(`\n prune report (read-only): ${sum.candidates} of ${sum.total} keys worth a look`);
1062
+ console.log(` unbound=${sum.unbound} error=${sum.errored} duplicated=${sum.duplicated}\n`);
1063
+ for (const c of candidates) console.log(` • ${c.key.padEnd(38)} ${c.provider || '-'} [${c.reasons.join('; ')}]`);
1064
+ console.log('\n Nothing was deleted. Remove a key with: auth secret delete <key>\n');
1065
+ return;
1066
+ }
1067
+
1048
1068
  if (opts.command === 'shared' || opts.command === 'shared-values' || opts.command === 'duplicates') {
1049
1069
  const shared = await getSharedValueHealth(brainDir);
1050
1070
  console.log(`\n ${shared.healthy ? '✅' : '❌'} ${shared.message}`);
@@ -1209,6 +1229,34 @@ export default async function secretCli(argv) {
1209
1229
  return;
1210
1230
  }
1211
1231
 
1232
+ if (opts.command === 'carriers') {
1233
+ // Files that make the master password ambient (shell profiles, /etc/environment).
1234
+ // Reports files and line numbers only; --remove-ambient deletes those exports once an
1235
+ // owner-only carrier is verified to hold the same password.
1236
+ if (opts.removeAmbient) {
1237
+ const r = removeAmbientPasswordExports();
1238
+ if (opts.json) console.log(JSON.stringify(r, null, 2));
1239
+ else {
1240
+ console.log(`\n ${r.removed.length ? '✅ Removed ambient master-password exports' : 'No ambient master-password exports found'}`);
1241
+ for (const row of r.removed) console.log(` ${row.file}: line ${row.lines.join(', ')}`);
1242
+ console.log('');
1243
+ }
1244
+ return;
1245
+ }
1246
+ const found = findAmbientPasswordExports();
1247
+ const carried = Boolean(carrierPassword());
1248
+ if (opts.json) console.log(JSON.stringify({ ambient: found, owner_only_carrier: carried }, null, 2));
1249
+ else {
1250
+ console.log(`\n Owner-only carrier holds the master password: ${carried ? 'yes' : 'NO'}`);
1251
+ if (!found.length) console.log(' No ambient exports in shell profiles or /etc');
1252
+ for (const row of found) console.log(` ❌ ${row.file}: line ${row.lines.join(', ')} exports it to every process started there`);
1253
+ if (found.length) console.log(' Remove with: total-recall auth secret carriers --remove-ambient');
1254
+ console.log('');
1255
+ }
1256
+ if (found.length) process.exitCode = 1;
1257
+ return;
1258
+ }
1259
+
1212
1260
  if (opts.command === 'fingerprint') {
1213
1261
  // Identify a credential without seeing it: which store keys hold the same
1214
1262
  // value, its length and its non-secret type prefix. Lets an operator check
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Tool logins derived from stored secrets.
3
+ *
4
+ * A secret that reaches a node (see secrets-sync.mjs) is only useful to people and
5
+ * agents there once the tool that uses it is signed in. This signs `gh` in from
6
+ * `github_token`, and wires git to it, so a token rotated once lands on every machine
7
+ * without anyone logging in by hand. Nothing here prints or logs the token.
8
+ */
9
+ import { execFile } from 'node:child_process';
10
+
11
+ const TIMEOUT_MS = 20_000;
12
+
13
+ /** Run a command, optionally feeding stdin; never rejects. */
14
+ export function defaultRun(cmd, args, input) {
15
+ return new Promise((resolve) => {
16
+ const child = execFile(cmd, args, { timeout: TIMEOUT_MS, env: { ...process.env, GH_PROMPT_DISABLED: '1', GH_NO_UPDATE_NOTIFIER: '1' } }, (error, stdout, stderr) => {
17
+ resolve({ ok: !error, code: error?.code ?? 0, stdout: String(stdout || ''), stderr: String(stderr || '') });
18
+ });
19
+ child.stdin?.end(input === undefined ? undefined : String(input));
20
+ });
21
+ }
22
+
23
+ const firstLine = (text) => String(text || '').split('\n').map((l) => l.trim()).find(Boolean) || 'unknown error';
24
+
25
+ /**
26
+ * Sign `gh` in with `token` when it is not already, then point git at it.
27
+ * @returns {{ status: 'no-token'|'gh-missing'|'current'|'logged-in'|'failed', error?: string }}
28
+ */
29
+ export async function projectGithubLogin({ token, run = defaultRun } = {}) {
30
+ const wanted = String(token || '').trim();
31
+ if (!wanted) return { status: 'no-token' };
32
+
33
+ const current = await run('gh', ['auth', 'token']);
34
+ if (current.code === 'ENOENT') return { status: 'gh-missing' };
35
+ if (current.ok && current.stdout.trim() === wanted) return { status: 'current' };
36
+
37
+ const login = await run('gh', ['auth', 'login', '--hostname', 'github.com', '--git-protocol', 'https', '--with-token'], wanted);
38
+ if (!login.ok) return { status: 'failed', error: firstLine(login.stderr).replaceAll(wanted, '***') };
39
+
40
+ await run('gh', ['auth', 'setup-git', '--hostname', 'github.com']);
41
+ return { status: 'logged-in' };
42
+ }
@@ -26,6 +26,24 @@ const FETCH_TIMEOUT_MS = 8_000;
26
26
 
27
27
  /* ---------- leader side ---------- */
28
28
 
29
+ /**
30
+ * A grant key `repo:<name>` stands for every secret whose `repos` metadata names that
31
+ * repository, so a machine is given the keys its repositories use rather than a list of
32
+ * names or `*`. Resolved on each request, so rebinding a key takes effect on the next sync.
33
+ */
34
+ export function expandRepoGrants(policy, rows) {
35
+ const byRepo = new Map();
36
+ for (const row of rows || []) for (const repo of row.repos || []) {
37
+ if (!byRepo.has(repo)) byRepo.set(repo, []);
38
+ byRepo.get(repo).push(row.key);
39
+ }
40
+ const grants = (policy?.grants || []).map((grant) => ({
41
+ ...grant,
42
+ keys: (grant.keys || []).flatMap((key) => (String(key).startsWith('repo:') ? (byRepo.get(String(key).slice(5)) || []) : [key])),
43
+ }));
44
+ return { ...policy, grants };
45
+ }
46
+
29
47
  function audit(brainDir, event) {
30
48
  try {
31
49
  appendAudit(brainDir, event);
@@ -26,7 +26,8 @@ import {
26
26
  } from '../secrets-store.mjs';
27
27
  import { logger } from '../logger.mjs';
28
28
  import { getMeshSyncAuthorization, requireMeshSyncAuth } from '../mesh-auth.mjs';
29
- import { createMeshIdentityMiddleware, createMeshListHandler, createMeshSecretHandler } from '../mesh-secrets.mjs';
29
+ import { createMeshIdentityMiddleware, createMeshListHandler, createMeshSecretHandler, expandRepoGrants } from '../mesh-secrets.mjs';
30
+ import { loadPolicy } from '../mesh-identity.mjs';
30
31
  import { throttledFetch } from '../throttled-fetch.mjs';
31
32
  import { listProviders, getProvider } from '../provider-catalog.mjs';
32
33
  import {
@@ -90,15 +91,17 @@ router.get('/api/secrets/checksum', requireMeshSyncAuth, async (req, res) => {
90
91
  // Mesh-identity secrets: the caller is the enrolled machine, not a shared
91
92
  // token. Each node gets only the keys its grant allows; see mesh-secrets.mjs.
92
93
  const requireMeshIdentity = createMeshIdentityMiddleware();
94
+ const meshPolicyFor = async (brainDir) => expandRepoGrants(await loadPolicy(brainDir), await listSecretsMeta(brainDir));
93
95
  router.get(
94
96
  '/api/secrets/mesh',
95
97
  requireMeshIdentity,
96
98
  createMeshListHandler({
97
99
  brainDir: BRAIN_DIR,
100
+ policyFor: meshPolicyFor,
98
101
  keysFor: async () => (await listSecretsMeta(BRAIN_DIR)).map((entry) => entry.key),
99
102
  }),
100
103
  );
101
- router.get('/api/secrets/mesh/:key', requireMeshIdentity, createMeshSecretHandler({ brainDir: BRAIN_DIR }));
104
+ router.get('/api/secrets/mesh/:key', requireMeshIdentity, createMeshSecretHandler({ brainDir: BRAIN_DIR, policyFor: meshPolicyFor }));
102
105
 
103
106
  router.get('/api/secrets/list', requireAuth, requireScope('keys:read', 'config:read'), async (req, res) => {
104
107
  try {
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Where the secrets-store master password lives on this host.
3
+ *
4
+ * The sanctioned carriers are owner-only: the macOS Keychain, the launchd
5
+ * agent plist and `~/.agent/tr.env`. An `export TR_SECRETS_PASSWORD=…` in a
6
+ * shell profile or in /etc/environment is ambient: every process started from
7
+ * a shell (or, for /etc/environment, every login) inherits it, and a process
8
+ * manager such as pm2 then captures it into each app it starts. This module
9
+ * finds those exports and removes them once a sanctioned carrier holds the
10
+ * same password. It reports files and line numbers, never values.
11
+ */
12
+ import fs from 'node:fs';
13
+ import os from 'node:os';
14
+ import path from 'node:path';
15
+ import { secretsPassword } from './secrets-gateway.mjs';
16
+
17
+ const EXPORT = /^\s*(?:export\s+)?(?:TR_SECRETS_PASSWORD|TR_MASTER_PASSWORD)\s*=/;
18
+ const PROFILES = ['.bashrc', '.bash_profile', '.profile', '.zshrc', '.zprofile', '.zshenv'];
19
+
20
+ /** Shell and system files that can make the password ambient. */
21
+ export function ambientCandidateFiles({ home = os.homedir(), etc = '/etc' } = {}) {
22
+ const files = PROFILES.map((name) => path.join(home, name));
23
+ files.push(path.join(etc, 'environment'), path.join(etc, 'profile'), path.join(etc, 'zshenv'));
24
+ try {
25
+ for (const name of fs.readdirSync(path.join(etc, 'profile.d'))) {
26
+ if (name.endsWith('.sh')) files.push(path.join(etc, 'profile.d', name));
27
+ }
28
+ } catch { /* no profile.d */ }
29
+ return files;
30
+ }
31
+
32
+ /** Lines that export the master password, by file. */
33
+ export function findAmbientPasswordExports(files = ambientCandidateFiles()) {
34
+ const found = [];
35
+ for (const file of files) {
36
+ let text;
37
+ try { text = fs.readFileSync(file, 'utf8'); } catch { continue; }
38
+ const lines = text.split('\n').map((line, index) => (EXPORT.test(line) ? index + 1 : 0)).filter(Boolean);
39
+ if (lines.length) found.push({ file, lines });
40
+ }
41
+ return found;
42
+ }
43
+
44
+ /** The password a sanctioned carrier returns with the environment ignored. */
45
+ export function carrierPassword(env = process.env, resolve = secretsPassword) {
46
+ const quiet = { ...env };
47
+ delete quiet.TR_SECRETS_PASSWORD;
48
+ delete quiet.TR_MASTER_PASSWORD;
49
+ return resolve({ env: quiet }) || null;
50
+ }
51
+
52
+ /**
53
+ * Remove ambient exports, only when a sanctioned carrier already returns the
54
+ * password this process was given; otherwise the store would be locked out.
55
+ *
56
+ * @param {{ files?: string[], env?: object, resolve?: Function }} [opts]
57
+ */
58
+ export function removeAmbientPasswordExports({ files = ambientCandidateFiles(), env = process.env, resolve = secretsPassword } = {}) {
59
+ const exports = findAmbientPasswordExports(files);
60
+ if (!exports.length) return { removed: [], carrier: 'not-checked' };
61
+ const current = env.TR_SECRETS_PASSWORD || env.TR_MASTER_PASSWORD;
62
+ const carried = carrierPassword(env, resolve);
63
+ if (!carried) throw new Error('No owner-only carrier (Keychain, launchd agent or ~/.agent/tr.env) holds the master password; set one with `auth secret rekey --env-file|--keychain` before removing ambient exports');
64
+ if (current && carried !== current) throw new Error('The owner-only carrier holds a different password than this environment; reconcile them before removing ambient exports');
65
+ const removed = [];
66
+ for (const { file, lines } of exports) {
67
+ const text = fs.readFileSync(file, 'utf8');
68
+ const kept = text.split('\n').filter((line) => !EXPORT.test(line)).join('\n');
69
+ const mode = fs.statSync(file).mode & 0o777;
70
+ const tmp = `${file}.tr-tmp-${process.pid}`;
71
+ fs.writeFileSync(tmp, kept, { mode });
72
+ fs.renameSync(tmp, file);
73
+ removed.push({ file, lines });
74
+ }
75
+ return { removed, carrier: 'verified' };
76
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * `auth secret prune`: a read-only report of secrets worth removing or consolidating.
3
+ *
4
+ * A key is a candidate when it is bound to no repository, its provider tracking reports an
5
+ * error (usually a dead key), or it shares a value with another key. Nothing is deleted;
6
+ * the report says why each key is listed so the owner decides.
7
+ */
8
+
9
+ /** @param {Array<{key:string, repos?:string[], tracking_status?:string|null, tracking_exempt?:boolean, fingerprint_full?:string|null, set?:boolean}>} rows */
10
+ export function pruneCandidates(rows) {
11
+ const byFingerprint = new Map();
12
+ for (const row of rows) {
13
+ if (!row.set || !row.fingerprint_full) continue;
14
+ if (!byFingerprint.has(row.fingerprint_full)) byFingerprint.set(row.fingerprint_full, []);
15
+ byFingerprint.get(row.fingerprint_full).push(row.key);
16
+ }
17
+ const out = [];
18
+ for (const row of rows) {
19
+ const reasons = [];
20
+ if (!(row.repos || []).length) reasons.push('unbound');
21
+ if (row.tracking_status === 'error' && !row.tracking_exempt) reasons.push('error');
22
+ const twins = (byFingerprint.get(row.fingerprint_full) || []).filter((key) => key !== row.key);
23
+ if (twins.length) reasons.push(`same value as ${twins.join(', ')}`);
24
+ if (reasons.length) out.push({ key: row.key, reasons, provider: row.provider || null });
25
+ }
26
+ return out.sort((a, b) => b.reasons.length - a.reasons.length || a.key.localeCompare(b.key));
27
+ }
28
+
29
+ export function pruneSummary(rows, candidates) {
30
+ return {
31
+ total: rows.length,
32
+ candidates: candidates.length,
33
+ unbound: candidates.filter((c) => c.reasons.includes('unbound')).length,
34
+ errored: candidates.filter((c) => c.reasons.includes('error')).length,
35
+ duplicated: candidates.filter((c) => c.reasons.some((r) => r.startsWith('same value'))).length,
36
+ };
37
+ }
@@ -4,20 +4,22 @@ import { getLeaderInfo, isLeader } from './leader-election.mjs';
4
4
  import { logger } from './logger.mjs';
5
5
  import { brainDir } from './config.mjs';
6
6
  import { resolveSecretsPath, loadSecrets, saveSecrets, ensureMeta, appendAudit, META_KEY } from './secrets-store.mjs';
7
+ import { projectGithubLogin } from './login-projections.mjs';
7
8
 
8
9
  const REQUEST_TIMEOUT_MS = 10_000;
10
+ const FETCH_CONCURRENCY = 8;
9
11
 
10
12
  function serverPort() {
11
13
  const value = Number(process.env.TR_SERVER_PORT || process.env.PORT || 3000);
12
14
  return Number.isInteger(value) && value > 0 && value <= 65535 ? value : 3000;
13
15
  }
14
16
 
15
- function meshUrl(ip, route) {
17
+ function meshUrl(ip, route, port = serverPort()) {
16
18
  const octets = String(ip || '').split('.').map(Number);
17
19
  if (octets.length !== 4 || octets.some((part) => !Number.isInteger(part) || part < 0 || part > 255) || octets[0] !== 100 || octets[1] < 64 || octets[1] > 127) {
18
20
  throw new Error('Leader IP is outside the Tailscale CGNAT range');
19
21
  }
20
- return `http://${ip}:${serverPort()}${route}`;
22
+ return `http://${ip}:${port}${route}`;
21
23
  }
22
24
 
23
25
  export function getSecretsChecksum() {
@@ -39,23 +41,42 @@ export function secretsSyncMode(env = process.env) {
39
41
  * anything. The node's store is never replaced wholesale and the node keeps
40
42
  * its own master password.
41
43
  */
42
- export async function syncGrantedSecrets(leaderIp, { dir = brainDir, fetchImpl = fetch } = {}) {
43
- const base = meshUrl(leaderIp, '/api/secrets/mesh');
44
- const listResponse = await fetchImpl(base, { signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
44
+ export async function syncGrantedSecrets(leaderIp, { dir = brainDir, fetchImpl = fetch, port } = {}) {
45
+ // The leader's port, never this node's: the droplet serves on 3900 while devices
46
+ // default to 3000, and assuming our own port made every sync fail.
47
+ const base = meshUrl(leaderIp, '/api/secrets/mesh', port || serverPort());
48
+ let listResponse;
49
+ try {
50
+ listResponse = await fetchImpl(base, { signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
51
+ } catch (err) {
52
+ // "fetch failed" alone named neither the address nor the reason.
53
+ throw new Error(`No Total Recall server answered at ${base}: ${err.cause?.code || err.message}`);
54
+ }
45
55
  if (!listResponse.ok) throw new Error(`Leader returned ${listResponse.status}`);
46
56
  const { keys } = await listResponse.json();
47
57
  if (!Array.isArray(keys)) throw new Error('Invalid grant list');
48
58
 
59
+ // Each key costs the leader a store decryption plus a mesh round trip (2-3 s from a laptop),
60
+ // so a few at a time keeps a full sync under a minute instead of several.
49
61
  const fetched = {};
50
- for (const key of keys) {
51
- const response = await fetchImpl(`${base}/${encodeURIComponent(key)}`, { signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
52
- if (!response.ok) throw new Error(`Leader returned ${response.status} for ${key}`);
53
- fetched[key] = (await response.json()).value;
54
- }
62
+ let next = 0;
63
+ const worker = async () => {
64
+ while (next < keys.length) {
65
+ const key = keys[next++];
66
+ const response = await fetchImpl(`${base}/${encodeURIComponent(key)}`, { signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
67
+ if (!response.ok) throw new Error(`Leader returned ${response.status} for ${key}`);
68
+ fetched[key] = (await response.json()).value;
69
+ }
70
+ };
71
+ await Promise.all(Array.from({ length: Math.min(FETCH_CONCURRENCY, keys.length) }, worker));
55
72
 
56
73
  const secrets = ensureMeta(await loadSecrets(dir));
57
- const previous = Array.isArray(secrets[META_KEY].mesh_granted) ? secrets[META_KEY].mesh_granted : [];
58
- const removed = previous.filter((key) => !(key in fetched) && key in secrets);
74
+ // Only keys this sync created may be removed when their grant is revoked. A key the node
75
+ // already held before it was ever granted is the node's own: revoking the grant must not
76
+ // delete it (on the node that owns the full store, that would wipe its secrets).
77
+ const previouslySynced = Array.isArray(secrets[META_KEY].mesh_synced) ? secrets[META_KEY].mesh_synced : [];
78
+ const created = Object.keys(fetched).filter((key) => !(key in secrets));
79
+ const removed = previouslySynced.filter((key) => !(key in fetched) && key in secrets);
59
80
  for (const key of removed) {
60
81
  delete secrets[key];
61
82
  delete secrets[META_KEY].keys?.[key];
@@ -66,16 +87,31 @@ export async function syncGrantedSecrets(leaderIp, { dir = brainDir, fetchImpl =
66
87
  secrets[key] = value;
67
88
  }
68
89
  secrets[META_KEY].mesh_granted = Object.keys(fetched);
90
+ secrets[META_KEY].mesh_synced = [...new Set([...previouslySynced.filter((key) => key in fetched), ...created])];
69
91
  await saveSecrets(dir, secrets);
70
92
  appendAudit(dir, { action: 'mesh_grant_sync', key: `(${keys.length} granted, ${removed.length} revoked)`, actor: 'mesh-sync' });
71
93
  return { granted: keys.length, updated, removed: removed.length };
72
94
  }
73
95
 
96
+ /** Sign in the tools that use synced secrets (today: gh from github_token). */
97
+ export async function projectLogins({ dir = brainDir, run } = {}) {
98
+ const secrets = await loadSecrets(dir);
99
+ const github = await projectGithubLogin({ token: secrets.github_token || secrets.GITHUB_TOKEN, run });
100
+ if (github.status === 'logged-in') appendAudit(dir, { action: 'login_projection', key: 'github_token -> gh', actor: 'mesh-sync' });
101
+ if (github.status === 'failed') logger.error('secrets-sync', 'gh login from github_token failed', { error: github.error });
102
+ return { github };
103
+ }
104
+
74
105
  export async function syncLoop() {
75
106
  secretsSyncMode();
76
- if (await isLeader()) return;
77
- const leaderInfo = await getLeaderInfo();
78
- if (!leaderInfo?.ip) return;
79
- try { await syncGrantedSecrets(leaderInfo.ip); }
80
- catch (err) { logger.error('secrets-sync', 'Failed to sync granted secrets', { error: err.message }); }
107
+ if (!(await isLeader())) {
108
+ const leaderInfo = await getLeaderInfo();
109
+ if (leaderInfo?.ip) {
110
+ try { await syncGrantedSecrets(leaderInfo.ip, { port: leaderInfo.port }); }
111
+ catch (err) { logger.error('secrets-sync', 'Failed to sync granted secrets', { error: err.message }); }
112
+ }
113
+ }
114
+ // Even when the leader is unreachable, the last synced token should still be signed in.
115
+ try { await projectLogins(); }
116
+ catch (err) { logger.error('secrets-sync', 'Login projection failed', { error: err.message }); }
81
117
  }
@@ -9,16 +9,16 @@
9
9
  "version": "0.2.2",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
- "@gregiteen/ssss-cli": "0.11.2"
12
+ "@gregiteen/ssss-cli": "0.14.3"
13
13
  },
14
14
  "devDependencies": {
15
- "total-recall-brain": "3.41.2"
15
+ "total-recall-brain": "3.41.3"
16
16
  }
17
17
  },
18
18
  "node_modules/@gregiteen/ssss-cli": {
19
- "version": "0.11.2",
20
- "resolved": "https://registry.npmjs.org/@gregiteen/ssss-cli/-/ssss-cli-0.11.2.tgz",
21
- "integrity": "sha512-DTkBPCOv9uN79YoRtsa/GnXPbbeHOU5PQcQ995axHg+SLWdkL6xmrTRs5Xxshcje/Iv6+lKkYOc9UL+Arn9kHg==",
19
+ "version": "0.14.3",
20
+ "resolved": "https://registry.npmjs.org/@gregiteen/ssss-cli/-/ssss-cli-0.14.3.tgz",
21
+ "integrity": "sha512-vvGgiNLjus3/xp+iotiGqbSG0R7+Q/bP5cP2Psa7Rhtm/vrMuM5B4LebYu7bPf3TzgCyTbv96PcxGWf6gxbW0Q==",
22
22
  "license": "ISC",
23
23
  "bin": {
24
24
  "ssss": "scripts/ssss.mjs"
@@ -364,9 +364,9 @@
364
364
  },
365
365
  "node_modules/@ssss/cli": {
366
366
  "name": "@gregiteen/ssss-cli",
367
- "version": "0.11.2",
368
- "resolved": "https://registry.npmjs.org/@gregiteen/ssss-cli/-/ssss-cli-0.11.2.tgz",
369
- "integrity": "sha512-DTkBPCOv9uN79YoRtsa/GnXPbbeHOU5PQcQ995axHg+SLWdkL6xmrTRs5Xxshcje/Iv6+lKkYOc9UL+Arn9kHg==",
367
+ "version": "0.12.1",
368
+ "resolved": "https://registry.npmjs.org/@gregiteen/ssss-cli/-/ssss-cli-0.12.1.tgz",
369
+ "integrity": "sha512-ff0L2LV4db1CfD4W3VmIJwT9fvrpuKDGDfhe6gtjrrgtRmo7BP7jYjRtH7yuQipKikyiP9LIvmVIaxvbjLTwkA==",
370
370
  "dev": true,
371
371
  "license": "ISC",
372
372
  "bin": {
@@ -2595,9 +2595,9 @@
2595
2595
  }
2596
2596
  },
2597
2597
  "node_modules/total-recall-brain": {
2598
- "version": "3.41.2",
2599
- "resolved": "https://registry.npmjs.org/total-recall-brain/-/total-recall-brain-3.41.2.tgz",
2600
- "integrity": "sha512-bwJFAFAyvk9ysf+5zhBzMxrKus1Hj1AygUWdBNt+vQbM2sfSEf6701sa30KwfGyU8+cMuN6YNT5Mn63mFZIi/w==",
2598
+ "version": "3.41.3",
2599
+ "resolved": "https://registry.npmjs.org/total-recall-brain/-/total-recall-brain-3.41.3.tgz",
2600
+ "integrity": "sha512-0uIh3WZE3dHxrKFTtRMxvSBLx7aV3j7ry9H9NxRShtCycwQ38T7+70Q/GJAqsdpwt894h8B6AAmWJnHwsFG3XQ==",
2601
2601
  "dev": true,
2602
2602
  "license": "MIT",
2603
2603
  "workspaces": [
@@ -2605,7 +2605,7 @@
2605
2605
  ],
2606
2606
  "dependencies": {
2607
2607
  "@simplewebauthn/server": "^13.3.2",
2608
- "@ssss/cli": "npm:@gregiteen/ssss-cli@0.11.2",
2608
+ "@ssss/cli": "npm:@gregiteen/ssss-cli@0.12.1",
2609
2609
  "bcrypt": "^6.0.0",
2610
2610
  "better-sqlite3": "^12.11.1",
2611
2611
  "cookie-parser": "^1.4.7",
@@ -11,7 +11,7 @@
11
11
  "validate": "ssss conformance --engine"
12
12
  },
13
13
  "dependencies": {
14
- "@gregiteen/ssss-cli": "0.12.1"
14
+ "@gregiteen/ssss-cli": "0.14.3"
15
15
  },
16
16
  "files": [
17
17
  "cli.mjs",
@@ -9,16 +9,16 @@
9
9
  "version": "0.2.2",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
- "@gregiteen/ssss-cli": "0.11.2"
12
+ "@gregiteen/ssss-cli": "0.14.3"
13
13
  },
14
14
  "devDependencies": {
15
- "total-recall-brain": "3.41.2"
15
+ "total-recall-brain": "3.41.3"
16
16
  }
17
17
  },
18
18
  "node_modules/@gregiteen/ssss-cli": {
19
- "version": "0.11.2",
20
- "resolved": "https://registry.npmjs.org/@gregiteen/ssss-cli/-/ssss-cli-0.11.2.tgz",
21
- "integrity": "sha512-DTkBPCOv9uN79YoRtsa/GnXPbbeHOU5PQcQ995axHg+SLWdkL6xmrTRs5Xxshcje/Iv6+lKkYOc9UL+Arn9kHg==",
19
+ "version": "0.14.3",
20
+ "resolved": "https://registry.npmjs.org/@gregiteen/ssss-cli/-/ssss-cli-0.14.3.tgz",
21
+ "integrity": "sha512-vvGgiNLjus3/xp+iotiGqbSG0R7+Q/bP5cP2Psa7Rhtm/vrMuM5B4LebYu7bPf3TzgCyTbv96PcxGWf6gxbW0Q==",
22
22
  "license": "ISC",
23
23
  "bin": {
24
24
  "ssss": "scripts/ssss.mjs"
@@ -364,9 +364,9 @@
364
364
  },
365
365
  "node_modules/@ssss/cli": {
366
366
  "name": "@gregiteen/ssss-cli",
367
- "version": "0.11.2",
368
- "resolved": "https://registry.npmjs.org/@gregiteen/ssss-cli/-/ssss-cli-0.11.2.tgz",
369
- "integrity": "sha512-DTkBPCOv9uN79YoRtsa/GnXPbbeHOU5PQcQ995axHg+SLWdkL6xmrTRs5Xxshcje/Iv6+lKkYOc9UL+Arn9kHg==",
367
+ "version": "0.12.1",
368
+ "resolved": "https://registry.npmjs.org/@gregiteen/ssss-cli/-/ssss-cli-0.12.1.tgz",
369
+ "integrity": "sha512-ff0L2LV4db1CfD4W3VmIJwT9fvrpuKDGDfhe6gtjrrgtRmo7BP7jYjRtH7yuQipKikyiP9LIvmVIaxvbjLTwkA==",
370
370
  "dev": true,
371
371
  "license": "ISC",
372
372
  "bin": {
@@ -2595,9 +2595,9 @@
2595
2595
  }
2596
2596
  },
2597
2597
  "node_modules/total-recall-brain": {
2598
- "version": "3.41.2",
2599
- "resolved": "https://registry.npmjs.org/total-recall-brain/-/total-recall-brain-3.41.2.tgz",
2600
- "integrity": "sha512-bwJFAFAyvk9ysf+5zhBzMxrKus1Hj1AygUWdBNt+vQbM2sfSEf6701sa30KwfGyU8+cMuN6YNT5Mn63mFZIi/w==",
2598
+ "version": "3.41.3",
2599
+ "resolved": "https://registry.npmjs.org/total-recall-brain/-/total-recall-brain-3.41.3.tgz",
2600
+ "integrity": "sha512-0uIh3WZE3dHxrKFTtRMxvSBLx7aV3j7ry9H9NxRShtCycwQ38T7+70Q/GJAqsdpwt894h8B6AAmWJnHwsFG3XQ==",
2601
2601
  "dev": true,
2602
2602
  "license": "MIT",
2603
2603
  "workspaces": [
@@ -2605,7 +2605,7 @@
2605
2605
  ],
2606
2606
  "dependencies": {
2607
2607
  "@simplewebauthn/server": "^13.3.2",
2608
- "@ssss/cli": "npm:@gregiteen/ssss-cli@0.11.2",
2608
+ "@ssss/cli": "npm:@gregiteen/ssss-cli@0.12.1",
2609
2609
  "bcrypt": "^6.0.0",
2610
2610
  "better-sqlite3": "^12.11.1",
2611
2611
  "cookie-parser": "^1.4.7",
@@ -10,7 +10,7 @@
10
10
  "validate": "ssss conformance --engine"
11
11
  },
12
12
  "dependencies": {
13
- "@gregiteen/ssss-cli": "0.12.1"
13
+ "@gregiteen/ssss-cli": "0.14.3"
14
14
  },
15
15
  "license": "MIT",
16
16
  "files": [