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
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Merge a stray secrets store into the host's single store.
3
+ *
4
+ * Per-repo stores grew when the CLI resolved its store from the cwd. This
5
+ * folds one of them back in without losing or silently replacing anything:
6
+ * a key the target lacks is imported with its metadata; a key with the same
7
+ * value only gains the source's repo binding and tags; a key whose value
8
+ * differs is a conflict and is never overwritten unless the operator names it,
9
+ * either taking the source value (`take`) or importing it under a new,
10
+ * app-specific name (`rename`). Values are never returned or logged.
11
+ */
12
+ import crypto from 'node:crypto';
13
+ import { loadSecrets, saveSecrets, ensureMeta, appendAudit, META_KEY } from './secrets-gateway.mjs';
14
+ import { mergeSecretMeta, normalizeReposBinding, normalizeTags } from './secrets-store.mjs';
15
+
16
+ const KEY_PATTERN = /^[a-zA-Z][a-zA-Z0-9_.-]*$/;
17
+
18
+ /** Values can be strings or JSON (account lists); compare by content, not reference. */
19
+ const sameValue = (a, b) => (typeof a === 'string' || typeof b === 'string' ? a === b : JSON.stringify(a) === JSON.stringify(b));
20
+
21
+ function fingerprint(value) {
22
+ return crypto.createHash('sha256').update(typeof value === 'string' ? value : JSON.stringify(value)).digest('hex').slice(0, 12);
23
+ }
24
+
25
+ /** The metadata worth showing an operator to decide a conflict. */
26
+ function summary(meta = {}, value) {
27
+ return {
28
+ fingerprint: value ? fingerprint(value) : null,
29
+ provider: meta.provider || null,
30
+ repos: Array.isArray(meta.repos) ? meta.repos : [],
31
+ tags: Array.isArray(meta.tags) ? meta.tags : [],
32
+ created_at: meta.created_at || null,
33
+ updated_at: meta.updated_at || null,
34
+ rotated_at: meta.rotated_at || null,
35
+ tracking_status: meta.tracking_status || null,
36
+ tracking_synced_at: meta.tracking_synced_at || null,
37
+ key_valid: meta.key_valid ?? null,
38
+ };
39
+ }
40
+
41
+ /**
42
+ * @param {string} targetDir brain dir of the single store
43
+ * @param {string} sourceDir brain dir of the stray store
44
+ * @param {{ apply?: boolean, bindSame?: boolean, datesFromSource?: boolean, repo?: string, tags?: string[]|string, rename?: Record<string,string>,
45
+ * take?: string[], actor?: string }} [opts]
46
+ * @returns {Promise<{ applied: boolean, imported: object[], same: object[], conflicts: object[], renamed: object[], taken: object[] }>}
47
+ */
48
+ export async function mergeStore(targetDir, sourceDir, opts = {}) {
49
+ if (!targetDir || !sourceDir) throw new Error('merge-store needs a target and a source store');
50
+ if (targetDir === sourceDir) throw new Error('The source store is the target store');
51
+ const rename = opts.rename || {};
52
+ const take = new Set(opts.take || []);
53
+ for (const [from, to] of Object.entries(rename)) {
54
+ if (!KEY_PATTERN.test(from) || !KEY_PATTERN.test(to)) throw new Error(`Invalid rename ${from}=${to}`);
55
+ if (take.has(from)) throw new Error(`${from} cannot be both renamed and taken`);
56
+ }
57
+ const bindRepo = opts.repo ? normalizeReposBinding(opts.repo) : [];
58
+ const extraTags = opts.tags ? normalizeTags(opts.tags) : [];
59
+
60
+ const source = ensureMeta(await loadSecrets(sourceDir));
61
+ const target = ensureMeta(await loadSecrets(targetDir));
62
+ const sourceKeys = Object.keys(source).filter((k) => k !== META_KEY);
63
+ for (const k of [...Object.keys(rename), ...take]) {
64
+ if (!sourceKeys.includes(k)) throw new Error(`${k} is not in the source store`);
65
+ }
66
+
67
+ const report = { applied: false, imported: [], same: [], conflicts: [], renamed: [], taken: [] };
68
+ const now = new Date().toISOString();
69
+ const withBinding = (meta) => ({
70
+ repos: [...new Set([...(meta.repos || []), ...bindRepo])],
71
+ tags: [...new Set([...(meta.tags || []), ...extraTags])],
72
+ });
73
+
74
+ for (const key of sourceKeys) {
75
+ const value = source[key];
76
+ if (value === undefined || value === null || value === '') continue;
77
+ const sMeta = source[META_KEY].keys[key] || {};
78
+ const name = rename[key] || key;
79
+ const exists = name in target && target[name] !== '' && target[name] != null;
80
+ const tMeta = target[META_KEY].keys[name] || {};
81
+
82
+ if (rename[key]) {
83
+ if (exists && !sameValue(target[name], value)) throw new Error(`Rename target ${name} already holds a different value`);
84
+ target[name] = value;
85
+ target[META_KEY].keys[name] = mergeSecretMeta({ ...sMeta, ...tMeta }, { ...withBinding(sMeta), scope: 'global', created_at: sMeta.created_at || now, updated_at: now, notes: sMeta.notes || `merged from ${key}`, provides: key });
86
+ report.renamed.push({ from: key, to: name, ...summary(sMeta, value) });
87
+ continue;
88
+ }
89
+ if (!exists) {
90
+ target[name] = value;
91
+ target[META_KEY].keys[name] = mergeSecretMeta(sMeta, { ...withBinding(sMeta), scope: 'global', created_at: sMeta.created_at || now, updated_at: sMeta.updated_at || now });
92
+ report.imported.push({ key: name, ...summary(sMeta, value) });
93
+ continue;
94
+ }
95
+ if (sameValue(target[name], value)) {
96
+ // A store that was a copy of the global one says nothing about which keys
97
+ // the repo uses, so equal keys gain its binding only when asked.
98
+ if (opts.bindSame) target[META_KEY].keys[name] = mergeSecretMeta(tMeta, {
99
+ repos: [...new Set([...(tMeta.repos || []), ...(sMeta.repos || []), ...bindRepo])],
100
+ tags: [...new Set([...(tMeta.tags || []), ...(sMeta.tags || []), ...extraTags])],
101
+ });
102
+ // The value dates belong to the value: when it is identical, an older
103
+ // source date is the true one (repairs a date a label edit bumped).
104
+ if (opts.datesFromSource) {
105
+ const cur = target[META_KEY].keys[name] || {};
106
+ const fix = {};
107
+ for (const f of ['updated_at', 'rotated_at']) if (sMeta[f] && (!cur[f] || sMeta[f] < cur[f])) fix[f] = sMeta[f];
108
+ if (Object.keys(fix).length) { target[META_KEY].keys[name] = { ...cur, ...fix }; report.redated = (report.redated || 0) + 1; }
109
+ }
110
+ report.same.push({ key: name });
111
+ continue;
112
+ }
113
+ if (take.has(key)) {
114
+ const replaced = summary(tMeta, target[name]);
115
+ target[name] = value;
116
+ target[META_KEY].keys[name] = mergeSecretMeta(tMeta, { ...withBinding(tMeta), updated_at: now, notes: tMeta.notes || 'value taken from a merged store' });
117
+ report.taken.push({ key: name, replaced, with: summary(sMeta, value) });
118
+ continue;
119
+ }
120
+ report.conflicts.push({ key: name, target: summary(tMeta, target[name]), source: summary(sMeta, value) });
121
+ }
122
+
123
+ if (opts.apply) {
124
+ await saveSecrets(targetDir, target);
125
+ for (const row of [...report.imported, ...report.renamed, ...report.taken]) {
126
+ appendAudit(targetDir, { action: 'merge', key: row.key || row.to, from_key: row.from || null, actor: opts.actor || 'cli' });
127
+ }
128
+ report.applied = true;
129
+ }
130
+ return report;
131
+ }
@@ -18,7 +18,7 @@ import fs from 'node:fs';
18
18
  import path from 'node:path';
19
19
  import { spawnSync } from 'node:child_process';
20
20
  import { buildEnvProjection, mergeEnvManagedBlock, projectSlugFromPath } from './secrets-env-export.mjs';
21
- import { resolveSecretsPath } from './secrets-store.mjs';
21
+ import { resolveSecretsPath, loadSecrets, setSecret, secretValueFingerprint } from './secrets-store.mjs';
22
22
  import { authState } from './metadata-state.mjs';
23
23
 
24
24
  /**
@@ -114,6 +114,7 @@ function runSsh(target, remoteCommand, { input } = {}) {
114
114
  input: input != null ? input : undefined,
115
115
  encoding: 'utf8',
116
116
  timeout: 20000,
117
+ maxBuffer: 16 * 1024 * 1024,
117
118
  });
118
119
  return result;
119
120
  }
@@ -128,6 +129,7 @@ function quoteRemotePath(p) {
128
129
  */
129
130
  function fetchRemoteFile(target, remoteFile) {
130
131
  const r = runSsh(target, `cat ${quoteRemotePath(remoteFile)} 2>/dev/null || true`);
132
+ if (r.error || r.status !== 0) throw new Error(`SSH read failed for target "${target.name}": ${r.error?.message || (r.stderr || '').slice(0, 300)}`);
131
133
  if (r.error) throw new Error(`SSH connect failed for target "${target.name}": ${r.error.message}`);
132
134
  return r.stdout || '';
133
135
  }
@@ -209,7 +211,7 @@ export async function deployEnvToRemote(brainDir, targetName, opts = {}) {
209
211
  }
210
212
 
211
213
  const existing = fetchRemoteFile(target, remoteFile);
212
- const merged = mergeEnvManagedBlock(existing, projection.body);
214
+ const merged = mergeEnvManagedBlock(existing, projection.body, { partial: Boolean(opts.keys?.length) });
213
215
  writeRemoteFile(target, remoteFile, merged);
214
216
 
215
217
  let restarted = false;
@@ -232,6 +234,113 @@ export async function deployEnvToRemote(brainDir, targetName, opts = {}) {
232
234
  };
233
235
  }
234
236
 
237
+ /**
238
+ * Parse dotenv text into key -> { value, count }. The last assignment wins, as
239
+ * in dotenv; `count` exposes duplicates, which loaders resolve differently.
240
+ */
241
+ export function parseDotenv(text) {
242
+ const out = new Map();
243
+ for (const raw of String(text || '').split(/\r?\n/)) {
244
+ const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(raw);
245
+ if (!m) continue;
246
+ let value = m[2].trim();
247
+ if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) {
248
+ try { value = JSON.parse(value); } catch { value = value.slice(1, -1); }
249
+ } else if (value.length >= 2 && value.startsWith("'") && value.endsWith("'")) {
250
+ value = value.slice(1, -1);
251
+ } else {
252
+ value = value.replace(/\s+#.*$/, '');
253
+ }
254
+ out.set(m[1], { value, count: (out.get(m[1])?.count || 0) + 1 });
255
+ }
256
+ return out;
257
+ }
258
+
259
+ function findTarget(brainDir, targetName) {
260
+ const target = loadRemoteTargets(brainDir).find((t) => t.name === targetName);
261
+ if (!target) throw new Error(`Unknown remote target "${targetName}". List them with: total-recall auth secret remote list`);
262
+ return target;
263
+ }
264
+
265
+ /**
266
+ * Compare a deployed remote file with what the store would project there, key
267
+ * by key, by value fingerprint. Values never leave this process.
268
+ *
269
+ * @param {string} brainDir
270
+ * @param {string} targetName
271
+ * @param {{ keys?: string[], projectPath?: string }} opts
272
+ */
273
+ export async function verifyRemote(brainDir, targetName, opts = {}) {
274
+ const target = findTarget(brainDir, targetName);
275
+ const projectPath = opts.projectPath || process.cwd();
276
+ const projection = await buildEnvProjection(brainDir, {
277
+ includeGlobal: target.includeGlobal !== false,
278
+ keys: opts.keys?.length ? opts.keys : target.keys,
279
+ projectPath,
280
+ projectSlug: projectSlugFromPath(projectPath),
281
+ });
282
+ const remoteFile = path.posix.join(target.remotePath, target.filename || '.env');
283
+ const remote = parseDotenv(fetchRemoteFile(target, remoteFile));
284
+ const projected = new Set(projection.entries.map(([key]) => key));
285
+ const rows = projection.entries.map(([key, value]) => {
286
+ const found = remote.get(key);
287
+ const status = !found ? 'missing'
288
+ : secretValueFingerprint(found.value) === secretValueFingerprint(value) ? 'match' : 'differs';
289
+ return { key, status, duplicates: found ? found.count - 1 : 0 };
290
+ });
291
+ return {
292
+ target: target.name,
293
+ host: target.host,
294
+ remoteFile,
295
+ rows,
296
+ remoteOnly: [...remote.keys()].filter((key) => !projected.has(key)),
297
+ ok: rows.every((row) => row.status === 'match' && !row.duplicates),
298
+ };
299
+ }
300
+
301
+ /**
302
+ * Move named keys out of a pm2 app's captured environment into the store, so
303
+ * the app can run from the store's projection instead. pm2 keeps the
304
+ * environment a process was first started with and lets it override the
305
+ * app's env file, which hides stale and unneeded credentials from the store.
306
+ * Only the keys asked for are read; nothing is printed.
307
+ *
308
+ * @param {string} brainDir
309
+ * @param {string} targetName
310
+ * @param {{ app: string, keys: string[], apply?: boolean, overwrite?: boolean, repos?: string[] }} opts
311
+ */
312
+ export async function importFromPm2(brainDir, targetName, opts = {}) {
313
+ if (!opts.app) throw new Error('import-pm2 requires --app <pm2 process name>');
314
+ if (!opts.keys?.length) throw new Error('import-pm2 requires --keys: import only what the app needs');
315
+ const target = findTarget(brainDir, targetName);
316
+ const r = runSsh(target, 'pm2 jlist');
317
+ if (r.error || r.status !== 0) throw new Error(`pm2 jlist failed on "${target.name}": ${r.error?.message || (r.stderr || '').slice(0, 300)}`);
318
+ const raw = r.stdout || '';
319
+ let list;
320
+ // pm2 may print "[PM2] …" notices before the JSON array; start where the array does.
321
+ const start = raw.search(/^\[\s*(?:\{|\])/m);
322
+ try { list = JSON.parse(raw.slice(start)); } catch { throw new Error('pm2 jlist did not return JSON'); }
323
+ if (start === -1 || !Array.isArray(list)) throw new Error('pm2 jlist did not return JSON');
324
+ const proc = list.find((p) => p.name === opts.app);
325
+ if (!proc) throw new Error(`pm2 process "${opts.app}" not found on "${target.name}"`);
326
+ const env = proc.pm2_env?.env || {};
327
+ const secrets = await loadSecrets(brainDir);
328
+ const rows = [];
329
+ for (const key of opts.keys) {
330
+ const value = env[key] ?? proc.pm2_env?.[key];
331
+ if (value == null || value === '') { rows.push({ key, status: 'absent', applied: false }); continue; }
332
+ const stored = secrets[key];
333
+ const status = stored == null || stored === '' ? 'new' : String(stored) === String(value) ? 'same' : 'conflict';
334
+ let applied = false;
335
+ if (opts.apply && (status === 'new' || (status === 'conflict' && opts.overwrite))) {
336
+ await setSecret(brainDir, key, String(value), opts.repos?.length ? { repos: opts.repos } : {});
337
+ applied = true;
338
+ }
339
+ rows.push({ key, status, applied });
340
+ }
341
+ return { target: target.name, app: opts.app, rows };
342
+ }
343
+
235
344
  /**
236
345
  * Deploy to every remote target that has this key bound via `keys`, or to
237
346
  * every target when the key list is unset (target opts into "all keys").
@@ -80,6 +80,9 @@ export function mergeSecretMeta(prev = {}, patch = {}) {
80
80
  });
81
81
  assign('auto_rotate', (v) => !!v);
82
82
  assign('notes', (v) => (v == null || v === '' ? null : String(v)));
83
+ // The app-facing name a production key stands in for: GRO_CRM_TICKETS_SIGNING_SECRET
84
+ // provides TICKETS_SIGNING_SECRET to the repos it is bound to (see resolveSecretKey).
85
+ assign('provides', (v) => (v == null || v === '' ? null : String(v)));
83
86
  assign('project_path', (v) => (v == null || v === '' ? null : String(v)));
84
87
  assign('headscale_url', (v) => (v == null || v === '' ? null : String(v)));
85
88
  // Command that prints the headscale server log. Watch mode needs it because
@@ -478,11 +481,13 @@ export async function updateSecretMeta(brainDir, key, patch = {}, opts = {}) {
478
481
  throw new Error(`Secret not found: ${key}`);
479
482
  }
480
483
  const prev = secrets[META_KEY].keys[key] || {};
481
- secrets[META_KEY].keys[key] = mergeSecretMeta(prev, {
482
- ...patch,
483
- updated_at: new Date().toISOString(),
484
- created_at: prev.created_at || new Date().toISOString(),
485
- });
484
+ // updated_at dates the value (set, rotate). A metadata write — labels, account
485
+ // sync, tracking — records meta_updated_at instead, so it never makes a stale
486
+ // value look fresh or pushes back its rotation.
487
+ secrets[META_KEY].keys[key] = {
488
+ ...mergeSecretMeta(prev, { ...patch, created_at: prev.created_at || new Date().toISOString() }),
489
+ meta_updated_at: new Date().toISOString(),
490
+ };
486
491
  await saveSecrets(brainDir, secrets);
487
492
  appendAudit(brainDir, {
488
493
  action: 'meta',
@@ -555,6 +560,22 @@ export async function setSecret(brainDir, key, value, opts = {}) {
555
560
  return { key, set: true };
556
561
  }
557
562
 
563
+ /**
564
+ * The store key that answers `key` for one repo. Production services own
565
+ * distinct credentials under app-specific names; a key bound to the repo whose
566
+ * `provides` is `key` wins over the plain (developer) key. Two such keys for the
567
+ * same repo and name are ambiguous and refused, never guessed between.
568
+ */
569
+ export async function resolveSecretKey(brainDir, key, repo) {
570
+ if (!repo) return key;
571
+ const secrets = ensureMeta(await loadSecrets(brainDir));
572
+ const meta = secrets[META_KEY].keys;
573
+ const hits = Object.keys(meta).filter((k) => k !== key && meta[k]?.provides === key
574
+ && Array.isArray(meta[k].repos) && meta[k].repos.includes(repo) && secrets[k] != null && secrets[k] !== '');
575
+ if (hits.length > 1) throw new Error(`${hits.join(' and ')} both provide ${key} for ${repo}; keep one`);
576
+ return hits[0] || key;
577
+ }
578
+
558
579
  /**
559
580
  * Get a secret value (CLI only — do not inject into surfaces).
560
581
  */
@@ -12,7 +12,7 @@
12
12
  "@gregiteen/ssss-cli": "0.11.2"
13
13
  },
14
14
  "devDependencies": {
15
- "total-recall-brain": "^3.40.7"
15
+ "total-recall-brain": "3.41.1"
16
16
  }
17
17
  },
18
18
  "node_modules/@gregiteen/ssss-cli": {
@@ -2595,9 +2595,9 @@
2595
2595
  }
2596
2596
  },
2597
2597
  "node_modules/total-recall-brain": {
2598
- "version": "3.40.7",
2599
- "resolved": "https://registry.npmjs.org/total-recall-brain/-/total-recall-brain-3.40.7.tgz",
2600
- "integrity": "sha512-G+sEWXVucI5Zs8uV1ie8P+mV/6cncWX76ES72T5dnPB3sFAwlcG9ous2SYTUaYUOETGB2vHTSo3cV4XWkHQ0Ww==",
2598
+ "version": "3.41.1",
2599
+ "resolved": "https://registry.npmjs.org/total-recall-brain/-/total-recall-brain-3.41.1.tgz",
2600
+ "integrity": "sha512-QraXzcZpCTMssx1gaz8xjvSKf5KqirAaxzzER/ICBrWvawdNTLUDDMsVEYQUA/7thgwtL1QVIxU4dpfCGZwp+g==",
2601
2601
  "dev": true,
2602
2602
  "license": "MIT",
2603
2603
  "workspaces": [
@@ -12,7 +12,7 @@
12
12
  "@gregiteen/ssss-cli": "0.11.2"
13
13
  },
14
14
  "devDependencies": {
15
- "total-recall-brain": "^3.40.7"
15
+ "total-recall-brain": "3.41.1"
16
16
  }
17
17
  },
18
18
  "node_modules/@gregiteen/ssss-cli": {
@@ -2595,9 +2595,9 @@
2595
2595
  }
2596
2596
  },
2597
2597
  "node_modules/total-recall-brain": {
2598
- "version": "3.40.7",
2599
- "resolved": "https://registry.npmjs.org/total-recall-brain/-/total-recall-brain-3.40.7.tgz",
2600
- "integrity": "sha512-G+sEWXVucI5Zs8uV1ie8P+mV/6cncWX76ES72T5dnPB3sFAwlcG9ous2SYTUaYUOETGB2vHTSo3cV4XWkHQ0Ww==",
2598
+ "version": "3.41.1",
2599
+ "resolved": "https://registry.npmjs.org/total-recall-brain/-/total-recall-brain-3.41.1.tgz",
2600
+ "integrity": "sha512-QraXzcZpCTMssx1gaz8xjvSKf5KqirAaxzzER/ICBrWvawdNTLUDDMsVEYQUA/7thgwtL1QVIxU4dpfCGZwp+g==",
2601
2601
  "dev": true,
2602
2602
  "license": "MIT",
2603
2603
  "workspaces": [
@@ -9,7 +9,7 @@
9
9
  "version": "0.1.0",
10
10
  "license": "MIT",
11
11
  "devDependencies": {
12
- "total-recall-brain": "3.40.7"
12
+ "total-recall-brain": "3.41.1"
13
13
  }
14
14
  },
15
15
  "node_modules/@hexagon/base64": {
@@ -2566,9 +2566,9 @@
2566
2566
  }
2567
2567
  },
2568
2568
  "node_modules/total-recall-brain": {
2569
- "version": "3.40.7",
2570
- "resolved": "https://registry.npmjs.org/total-recall-brain/-/total-recall-brain-3.40.7.tgz",
2571
- "integrity": "sha512-G+sEWXVucI5Zs8uV1ie8P+mV/6cncWX76ES72T5dnPB3sFAwlcG9ous2SYTUaYUOETGB2vHTSo3cV4XWkHQ0Ww==",
2569
+ "version": "3.41.1",
2570
+ "resolved": "https://registry.npmjs.org/total-recall-brain/-/total-recall-brain-3.41.1.tgz",
2571
+ "integrity": "sha512-QraXzcZpCTMssx1gaz8xjvSKf5KqirAaxzzER/ICBrWvawdNTLUDDMsVEYQUA/7thgwtL1QVIxU4dpfCGZwp+g==",
2572
2572
  "dev": true,
2573
2573
  "license": "MIT",
2574
2574
  "workspaces": [
@@ -9,7 +9,7 @@
9
9
  "version": "1.1.2",
10
10
  "license": "MIT",
11
11
  "devDependencies": {
12
- "total-recall-brain": "3.40.7",
12
+ "total-recall-brain": "3.41.1",
13
13
  "vitest": "^4.1.10"
14
14
  }
15
15
  },
@@ -3560,9 +3560,9 @@
3560
3560
  }
3561
3561
  },
3562
3562
  "node_modules/total-recall-brain": {
3563
- "version": "3.40.7",
3564
- "resolved": "https://registry.npmjs.org/total-recall-brain/-/total-recall-brain-3.40.7.tgz",
3565
- "integrity": "sha512-G+sEWXVucI5Zs8uV1ie8P+mV/6cncWX76ES72T5dnPB3sFAwlcG9ous2SYTUaYUOETGB2vHTSo3cV4XWkHQ0Ww==",
3563
+ "version": "3.41.1",
3564
+ "resolved": "https://registry.npmjs.org/total-recall-brain/-/total-recall-brain-3.41.1.tgz",
3565
+ "integrity": "sha512-QraXzcZpCTMssx1gaz8xjvSKf5KqirAaxzzER/ICBrWvawdNTLUDDMsVEYQUA/7thgwtL1QVIxU4dpfCGZwp+g==",
3566
3566
  "dev": true,
3567
3567
  "license": "MIT",
3568
3568
  "workspaces": [
@@ -45,3 +45,13 @@ This copies the plugin's source, skills, tests and docs into `bricks/mesh` and r
45
45
  **Use it directly with Total Recall (for your own machines and workflow).** Install it with `total-recall plugin install https://github.com/gregiteen/tr-plugin-mesh.git` and run `total-recall mesh`. Manage its credentials with `total-recall auth secret` and its skills with `total-recall skill`.
46
46
 
47
47
  Native-only state: new installations use validated SSSS records and the shared auth owner. Legacy imports and compatibility fallback formats are not supported.
48
+
49
+ ## Mesh screens and integration declarations
50
+
51
+ The card defines Machines, Flows, Placement, Boundary and Join. Machines separates control membership, path checks, recorded login and actual execution. Hardware cells retain their measurement dates; failed or untested runtime probes remain unknown. Placement filters confirmed capabilities and includes the integration matrix declared in `plugin.json.mesh`.
52
+
53
+ `total-recall mesh view <machines|flows|placement|boundary|join> --json` reads fixed owner commands. Each source carries its command, attempt time and either data or an explicit error. `mesh.provides` references declared operations/runtime modules; `mesh.integrations` identifies each owner and the meaning of its evidence; `mesh.endpoints` identifies served peer routes. These are declarations, not primitive grants or proof every provider action works. Read-only views never perform enrollment, policy changes, grant edits, deployments or secret transfers.
54
+
55
+ The dashboard supplies frozen `host.operations`. Read-only cards show CLI handoffs for login administration and re-enrollment. A writable host may expose those declared actions; re-enrollment then requires target-specific confirmation. Enrollment keys never enter the card.
56
+
57
+ Auth's `host-secrets-store` runtime supplies the single host-store selector for control credentials and mesh-sync authentication. Project `AGENT_DIR`/`TR_BRAIN` cannot divert these reads. Native SSSS audit contention is serialized by the generic host; errors still fail closed. The corresponding source runtime must be installed; missing modules never select a fallback implementation.
@@ -117,9 +117,12 @@ export async function provisionHeadscale(target, { exec = sshExec(target), fetch
117
117
  await must(exec('headscale configtest'), 'headscale configtest');
118
118
 
119
119
  log('start');
120
- await must(exec('systemctl enable --now headscale'), 'Starting Headscale');
120
+ // The package starts Headscale at install with the example config (loopback :8080);
121
+ // `enable --now` leaves that process running, so restart to load the written config.
122
+ await must(exec('systemctl enable headscale && systemctl restart headscale'), 'Starting Headscale');
121
123
  let healthy = false;
122
- for (let i = 0; i < 20 && !healthy; i++) {
124
+ // The first start also obtains the Let's Encrypt certificate, so allow it about a minute.
125
+ for (let i = 0; i < 40 && !healthy; i++) {
123
126
  healthy = (await exec(`curl -fsS https://${target.domain}/health`)).code === 0;
124
127
  if (!healthy) await wait(1500);
125
128
  }
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Headscale control-server API, host-free.
3
+ *
4
+ * The caller supplies the control-server URL, the API key and the HTTP
5
+ * transport, so this module reads no credential store and no environment. The
6
+ * Total Recall host resolves the key from its auth plugin and passes it in;
7
+ * a vendored copy passes its own.
8
+ */
9
+
10
+ const DEFAULT_TIMEOUT_MS = 10_000;
11
+
12
+ /**
13
+ * Reject non-HTTPS control servers (loopback excepted for local development).
14
+ * Bearer tokens must never travel in cleartext over a network hop.
15
+ */
16
+ export function assertSecureControlUrl(url) {
17
+ const parsed = new URL(url);
18
+ const isLoopback = ['localhost', '127.0.0.1', '::1'].includes(parsed.hostname);
19
+ if (parsed.protocol !== 'https:' && !(isLoopback && parsed.protocol === 'http:')) {
20
+ throw new Error('Headscale URL must use HTTPS (HTTP is allowed only for loopback development)');
21
+ }
22
+ return parsed;
23
+ }
24
+
25
+ /** Strip a trailing slash so path joining stays predictable. */
26
+ export function normalizeControlUrl(url) {
27
+ const value = String(url || '').trim();
28
+ return value.endsWith('/') ? value.slice(0, -1) : value;
29
+ }
30
+
31
+ /** A fetch with a deadline, used when the caller supplies no transport. */
32
+ function timedFetch(fetchImpl) {
33
+ return async (url, options = {}) => {
34
+ const controller = new AbortController();
35
+ const timer = setTimeout(() => controller.abort(), options.timeoutMs || DEFAULT_TIMEOUT_MS);
36
+ try {
37
+ return await fetchImpl(url, { ...options, signal: controller.signal });
38
+ } finally {
39
+ clearTimeout(timer);
40
+ }
41
+ };
42
+ }
43
+
44
+ /**
45
+ * Bind a client to one control server.
46
+ * @param {{ url: string, token: string, fetch?: (url: string, options: object) => Promise<Response> }} options
47
+ * `fetch` receives `timeoutMs` in its options and is responsible for honouring it.
48
+ */
49
+ export function createHeadscaleApi({ url, token, fetch: transport } = {}) {
50
+ const base = normalizeControlUrl(url);
51
+ if (!base) throw new Error('A Headscale control server URL is required');
52
+ assertSecureControlUrl(base);
53
+ if (typeof token !== 'string' || !token) throw new Error('A Headscale API key is required');
54
+ const send = transport || timedFetch(globalThis.fetch);
55
+
56
+ /** Authenticated JSON request; a non-2xx answer throws with `status` and a bounded `detail`. */
57
+ async function request(path, options = {}) {
58
+ const cleanPath = path.startsWith('/') ? path : `/${path}`;
59
+ const res = await send(`${base}${cleanPath}`, {
60
+ ...options,
61
+ headers: {
62
+ ...options.headers,
63
+ Authorization: `Bearer ${token}`,
64
+ Accept: 'application/json',
65
+ 'Content-Type': 'application/json',
66
+ },
67
+ timeoutMs: options.timeoutMs || DEFAULT_TIMEOUT_MS,
68
+ });
69
+ if (!res.ok) {
70
+ const errorText = await res.text().catch(() => '');
71
+ const error = new Error(`Headscale API error (${res.status} ${res.statusText})`);
72
+ error.status = res.status;
73
+ error.detail = errorText.slice(0, 500);
74
+ throw error;
75
+ }
76
+ const contentType = res.headers.get('content-type') || '';
77
+ if (contentType.includes('application/json')) return res.json();
78
+ throw new Error('Control server returned a non-JSON response');
79
+ }
80
+
81
+ /**
82
+ * Resolve a control-server user to both of its identifying forms.
83
+ * User-facing names resolve to the current API numeric ID.
84
+ */
85
+ async function resolveUser(requested) {
86
+ const data = await request('/api/v1/user');
87
+ const users = data?.users || [];
88
+ if (requested) {
89
+ const match = users.find(
90
+ (u) => String(u.id) === String(requested) || u.name === requested || (u.email && u.email === requested),
91
+ );
92
+ if (match) return { id: String(match.id), name: match.name };
93
+ // Unknown to us — pass it through and let the control server decide.
94
+ return { id: String(requested), name: String(requested) };
95
+ }
96
+ if (!users.length) return null;
97
+ return { id: String(users[0].id), name: users[0].name };
98
+ }
99
+
100
+ /** Mint using the current numeric-user API without older-version retries. */
101
+ async function createPreAuthKey(options = {}) {
102
+ const ttlMinutes = Number.isFinite(options.ttlMinutes) ? options.ttlMinutes : 10;
103
+ const expiration = options.expiration || new Date(Date.now() + ttlMinutes * 60_000).toISOString();
104
+ const ref = options.userRef || await resolveUser(options.user);
105
+ if (!ref?.id || !/^[0-9]+$/.test(String(ref.id))) throw new Error('Select a registered numeric control-server user');
106
+ const user = String(ref.id);
107
+ const data = await request('/api/v1/preauthkey', {
108
+ method: 'POST', body: JSON.stringify({ user, reusable: options.reusable === true,
109
+ ephemeral: options.ephemeral === true, expiration, aclTags: options.aclTags || [] }),
110
+ });
111
+ const key = data?.preAuthKey?.key;
112
+ if (typeof key !== 'string' || !key) throw new Error('Headscale did not return a pre-auth key');
113
+ return { key, expiration, user, raw: data };
114
+ }
115
+
116
+ /**
117
+ * Read the control server's ACL policy.
118
+ *
119
+ * Requires the server to run with `policy.mode: database` — in `file` mode the
120
+ * policy lives on the control server's disk and the API cannot manage it.
121
+ *
122
+ * Headscale returns a 500 (not a 404) when database mode is on but the policy
123
+ * table is still empty, so an unset policy is normalised to `policy: null`
124
+ * rather than thrown. That distinction matters: "no policy yet" is the expected
125
+ * first-run state, whereas a real transport or auth failure must still surface.
126
+ */
127
+ async function getPolicy() {
128
+ try {
129
+ const data = await request('/api/v1/policy');
130
+ return { policy: data?.policy ?? null, updatedAt: data?.updatedAt ?? null, configured: Boolean(data?.policy), raw: data };
131
+ } catch (err) {
132
+ const detail = String(err.detail || '');
133
+ const emptyPolicyTable = err.status === 404 || (err.status === 500 && /policy|not found|no such/i.test(detail));
134
+ if (emptyPolicyTable) return { policy: null, updatedAt: null, configured: false, unset: true, raw: null };
135
+ if (err.status === 400 && /database/i.test(detail)) {
136
+ const modeError = new Error('Headscale is running in file policy mode; set `policy.mode: database` to manage the policy over the API');
137
+ modeError.status = err.status;
138
+ modeError.detail = err.detail;
139
+ modeError.code = 'POLICY_MODE_FILE';
140
+ throw modeError;
141
+ }
142
+ throw err;
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Replace the control server's ACL policy.
148
+ *
149
+ * `policy` may be an object (serialised here) or an already-formatted
150
+ * HuJSON/JSON string. Headscale validates server-side and rejects the write if
151
+ * the policy's own `tests` block fails, so a bad policy never lands.
152
+ */
153
+ async function setPolicy(policy) {
154
+ if (!policy) throw new Error('A policy is required');
155
+ const serialised = typeof policy === 'string' ? policy : JSON.stringify(policy, null, 2);
156
+ const data = await request('/api/v1/policy', { method: 'PUT', body: JSON.stringify({ policy: serialised }) });
157
+ return { policy: data?.policy ?? serialised, updatedAt: data?.updatedAt ?? null, raw: data };
158
+ }
159
+
160
+ return { url: base, request, resolveUser, createPreAuthKey, getPolicy, setPolicy };
161
+ }
162
+
163
+ /**
164
+ * Build a policy that lets every mesh member reach every other member, and use
165
+ * Tailscale SSH between them as any non-root user.
166
+ *
167
+ * This is deliberately the simple single-operator shape: one person's own
168
+ * machines, all equally trusted. It is NOT appropriate for a multi-tenant
169
+ * tailnet — headscale membership is the trust boundary, so anything admitted to
170
+ * the mesh inherits this access. Narrow `src`/`dst` before adding a node you do
171
+ * not control.
172
+ *
173
+ * root is excluded via `autogroup:nonroot`: SSH-ing straight to root over the
174
+ * mesh removes the audit trail that makes per-user access worth having.
175
+ */
176
+ export function buildMeshSshPolicy({ allowRoot = false } = {}) {
177
+ return {
178
+ acls: [{ action: 'accept', src: ['*'], dst: ['*:*'] }],
179
+ ssh: [
180
+ {
181
+ action: 'accept',
182
+ // autogroup:member, not autogroup:self. 'self' limits a member to SSH on their own
183
+ // devices, so no node on the mesh could reach any other one and every cross-machine
184
+ // operation fell back to key auth that had never been set up — which reads as the peer
185
+ // being unreachable. The point of this policy is that members reach each other.
186
+ src: ['autogroup:member'],
187
+ dst: ['autogroup:member'],
188
+ users: allowRoot ? ['autogroup:nonroot', 'root'] : ['autogroup:nonroot'],
189
+ },
190
+ ],
191
+ };
192
+ }