total-recall-brain 3.41.3 → 3.41.5

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 (96) hide show
  1. package/bin/total-recall.mjs +12 -2
  2. package/docs/reference/cli-reference.md +18 -0
  3. package/frontend/dist/assets/{index-DFenQQEQ.js → index-WT3gs-x6.js} +75 -70
  4. package/frontend/dist/index.html +1 -1
  5. package/package.json +7 -5
  6. package/scaffold/.agent/skills/total-recall/plugins/auth/package-lock.json +2 -2
  7. package/scaffold/.agent/skills/total-recall/plugins/auth/package.json +1 -1
  8. package/scaffold/.agent/skills/total-recall/plugins/auth/plugin.json +1 -1
  9. package/scaffold/.agent/skills/total-recall/plugins/auth/src/cli-secret.mjs +48 -0
  10. package/scaffold/.agent/skills/total-recall/plugins/auth/src/login-projections.mjs +42 -0
  11. package/scaffold/.agent/skills/total-recall/plugins/auth/src/mesh-secrets.mjs +18 -0
  12. package/scaffold/.agent/skills/total-recall/plugins/auth/src/routes/secrets.mjs +5 -2
  13. package/scaffold/.agent/skills/total-recall/plugins/auth/src/secrets-carriers.mjs +76 -0
  14. package/scaffold/.agent/skills/total-recall/plugins/auth/src/secrets-prune.mjs +37 -0
  15. package/scaffold/.agent/skills/total-recall/plugins/auth/src/secrets-sync.mjs +53 -17
  16. package/scaffold/.agent/skills/total-recall/plugins/code-quality/package-lock.json +3114 -36
  17. package/scaffold/.agent/skills/total-recall/plugins/code-quality/package.json +1 -1
  18. package/scaffold/.agent/skills/total-recall/plugins/composable-cli/package-lock.json +3114 -36
  19. package/scaffold/.agent/skills/total-recall/plugins/composable-cli/package.json +1 -1
  20. package/scaffold/.agent/skills/total-recall/plugins/embeddings/package-lock.json +3131 -28
  21. package/scaffold/.agent/skills/total-recall/plugins/embeddings/package.json +6 -2
  22. package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/package-lock.json +3132 -29
  23. package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/package.json +5 -1
  24. package/scaffold/.agent/skills/total-recall/plugins/mesh/package-lock.json +3164 -73
  25. package/scaffold/.agent/skills/total-recall/plugins/mesh/package.json +5 -3
  26. package/scaffold/.agent/skills/total-recall/plugins/mesh/plugin.json +1 -1
  27. package/scaffold/.agent/skills/total-recall/plugins/mesh/src/cli/grants.mjs +7 -3
  28. package/scaffold/.agent/skills/total-recall/plugins/mesh/src/cli/mesh.mjs +61 -38
  29. package/scaffold/.agent/skills/total-recall/plugins/mesh/src/core/leader-election.mjs +57 -2
  30. package/scaffold/.agent/skills/total-recall/plugins/mesh/src/core/mesh.mjs +220 -36
  31. package/scaffold/.agent/skills/total-recall/plugins/mesh/src/server/routes/mesh.mjs +7 -2
  32. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/README.md +65 -0
  33. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/capability/BRICK.md +59 -0
  34. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/capability/index.mjs +534 -0
  35. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/cli.mjs +139 -0
  36. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/package-lock.json +5937 -0
  37. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/package.json +26 -0
  38. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/plugin.json +376 -0
  39. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/skills/meta-harness/SKILL.md +37 -0
  40. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/adapters/dsh.mjs +446 -0
  41. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/alerts.mjs +23 -0
  42. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/coordinator.mjs +398 -0
  43. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/documentation.mjs +172 -0
  44. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/executor.mjs +82 -0
  45. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/learner.mjs +90 -0
  46. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/router.mjs +415 -0
  47. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/runtime.mjs +36 -0
  48. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/setup.mjs +35 -0
  49. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/tracker.mjs +133 -0
  50. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/worker.mjs +233 -0
  51. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/ui/DESIGN.md +32 -0
  52. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/ui/card.js +845 -0
  53. package/scaffold/.agent/skills/total-recall/plugins/openwiki/capability/BRICK.md +64 -0
  54. package/scaffold/.agent/skills/total-recall/plugins/openwiki/capability/index.mjs +250 -0
  55. package/scaffold/.agent/skills/total-recall/plugins/openwiki/cli.mjs +619 -41
  56. package/scaffold/.agent/skills/total-recall/plugins/openwiki/package-lock.json +3134 -31
  57. package/scaffold/.agent/skills/total-recall/plugins/openwiki/package.json +9 -3
  58. package/scaffold/.agent/skills/total-recall/plugins/openwiki/plugin.json +413 -14
  59. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/config.mjs +129 -0
  60. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/generate.mjs +159 -0
  61. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/graph.mjs +197 -0
  62. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/lint.mjs +153 -0
  63. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/mesh-vaults.mjs +261 -0
  64. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/refresh.mjs +502 -0
  65. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/search.mjs +114 -0
  66. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/token.mjs +96 -0
  67. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/upstream-watcher.mjs +375 -0
  68. package/scaffold/.agent/skills/total-recall/plugins/openwiki/ui/DESIGN.md +35 -22
  69. package/scaffold/.agent/skills/total-recall/plugins/openwiki/ui/card.js +1225 -364
  70. package/scaffold/.agent/skills/total-recall/plugins/openwiki/vendor/openwiki.mjs +8987 -0
  71. package/scaffold/.agent/skills/total-recall/plugins/security/package-lock.json +3114 -83
  72. package/scaffold/.agent/skills/total-recall/plugins/security/package.json +5 -1
  73. package/src/cli/compact.mjs +108 -0
  74. package/src/cli/config.mjs +3 -0
  75. package/src/cli/connect.mjs +12 -116
  76. package/src/cli/context.mjs +52 -23
  77. package/src/cli/forget.mjs +2 -0
  78. package/src/cli/harness.mjs +0 -1
  79. package/src/cli/plugin/create.mjs +56 -3
  80. package/src/cli/plugin/index.mjs +15 -3
  81. package/src/cli/plugin/monetization.mjs +232 -12
  82. package/src/cli/preflight.mjs +161 -0
  83. package/src/cli/release.mjs +386 -0
  84. package/src/core/cli-commands.mjs +4 -0
  85. package/src/core/context-compactor.mjs +143 -0
  86. package/src/core/context-compiler.mjs +8 -1
  87. package/src/core/default-plugins.mjs +1 -1
  88. package/src/core/meta-harness.mjs +16 -38
  89. package/src/core/plugin-dependencies.mjs +6 -1
  90. package/src/core/preflight-gate.mjs +77 -0
  91. package/src/core/preflight-receipt.mjs +123 -0
  92. package/src/core/runtime.mjs +0 -3
  93. package/src/core/surface.mjs +39 -2
  94. package/src/server/invoke-durability.mjs +39 -0
  95. package/src/server/invoke-write-child.mjs +25 -0
  96. package/src/server/routes/invoke.mjs +7 -0
@@ -1,14 +1,16 @@
1
1
  {
2
2
  "name": "tr-plugin-mesh",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
4
4
  "type": "module",
5
5
  "description": "Headscale and Tailscale SSH control-server mesh administration and node discovery",
6
6
  "scripts": {
7
- "test": "node --test test/*.test.mjs && vitest run"
7
+ "test": "node --test test/*.test.mjs && vitest run",
8
+ "ssss": "ssss"
8
9
  },
9
10
  "license": "MIT",
10
11
  "dependencies": {
11
- "express": "^5.2.1"
12
+ "express": "^5.2.1",
13
+ "@gregiteen/ssss-cli": "0.14.4"
12
14
  },
13
15
  "files": [
14
16
  "cli.mjs",
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://github.com/gregiteen/total-recall/blob/main/metadata.plugin.schema.json",
3
3
  "id": "mesh",
4
4
  "name": "Headscale",
5
- "version": "0.2.4",
5
+ "version": "0.2.6",
6
6
  "description": "Control-server mesh administration, Tailscale SSH, and peer node discovery.",
7
7
  "license": "MIT",
8
8
  "requires": [
@@ -2,10 +2,12 @@
2
2
  * `mesh grants` — which enrolled machines may read which secret keys.
3
3
  *
4
4
  * mesh grants list
5
- * mesh grants add (--node <name> | --tag <tag:x> | --user <login>) KEY [KEY...]
5
+ * mesh grants add (--node <name> | --tag <tag:x> | --user <login>) (KEY | --repo <name>)...
6
6
  * mesh grants remove (--node <name> | --tag <tag:x> | --user <login>) [KEY...]
7
7
  *
8
- * Keys may end in `*` to match a prefix. Nothing is granted by default.
8
+ * Keys may end in `*` to match a prefix. `--repo <name>` grants every secret whose `repos`
9
+ * metadata names that repository (stored as `repo:<name>`, resolved on the leader). Prefer it to
10
+ * `*`: a node that leaks then exposes only what its repositories use. Nothing is granted by default.
9
11
  */
10
12
  import { grantKeys, loadPolicy, revokeKeys, savePolicy, policyPath } from '../core/mesh-identity.mjs';
11
13
 
@@ -15,13 +17,14 @@ function parse(args) {
15
17
  for (let i = 0; i < args.length; i += 1) {
16
18
  const arg = args[i];
17
19
  if (arg === '--node' || arg === '--tag' || arg === '--user') selector[arg.slice(2)] = args[(i += 1)];
20
+ else if (arg === '--repo') keys.push(`repo:${args[(i += 1)]}`);
18
21
  else if (arg.startsWith('--')) throw new Error(`Unknown option ${arg}`);
19
22
  else keys.push(arg);
20
23
  }
21
24
  return { selector, keys };
22
25
  }
23
26
 
24
- export async function grantsCli(args, brainDir, out = console.log) {
27
+ export async function grantsCli(args, brainDir, out = console.log, warn = console.error) {
25
28
  const action = args[0] || 'list';
26
29
  if (action === 'list') {
27
30
  out(JSON.stringify({ path: policyPath(brainDir), ...loadPolicy(brainDir) }));
@@ -29,6 +32,7 @@ export async function grantsCli(args, brainDir, out = console.log) {
29
32
  }
30
33
  if (action !== 'add' && action !== 'remove') throw new Error('Usage: mesh grants list|add|remove');
31
34
  const { selector, keys } = parse(args.slice(1));
35
+ if (action === 'add' && keys.includes('*')) warn('Warning: "*" gives this machine every secret; if it leaks, every secret must be rotated. Prefer --repo <name> or named keys.');
32
36
  const current = loadPolicy(brainDir);
33
37
  const next = action === 'add' ? grantKeys(current, selector, keys) : revokeKeys(current, selector, keys);
34
38
  savePolicy(brainDir, next);
@@ -26,9 +26,10 @@ import {
26
26
  buildMeshSshPolicy,
27
27
  } from '../core/headscale-client.mjs';
28
28
  import {
29
- findMeshNode,
29
+ isSelfNode,
30
30
  listEnrichedMeshNodes,
31
31
  meshVaultRoot,
32
+ resolveMeshNode,
32
33
  setMeshNodeAccess,
33
34
  } from '../core/mesh.mjs';
34
35
  import {
@@ -51,6 +52,9 @@ function printHelp() {
51
52
  nodes List mesh nodes and how to reach each one
52
53
  ssh <node> [cmd…] Open a session using the node's recorded access
53
54
  exec <node> <cmd…> Execute a command on a node non-interactively (--json)
55
+ leader [--json] Show the elected leader and its server port
56
+ leader pin <ip> [--port <n>]
57
+ Pin the leader node and the port its server answers on
54
58
  access <node> Show resolved access for a node
55
59
  access <node> --user <u> [--port <n>] [--host <h>] [--identity <path>]
56
60
  Record how to reach a node
@@ -368,22 +372,19 @@ export default async function meshCli(argv = []) {
368
372
  return;
369
373
  }
370
374
 
371
- // Try enriched nodes first, then fall back to the Headscale registry
372
- // for givenName matches (the friendly name shown in `mesh nodes`).
373
- let node = findMeshNode(target, vaultRoot);
374
- if (!node) {
375
- let registry = null;
376
- try {
377
- const data = await headscaleFetch(
378
- '/api/v1/node', {}, brainDir,
379
- );
380
- registry = data?.nodes || [];
381
- } catch {}
382
- node = findMeshNode(target, vaultRoot, registry);
375
+ let node;
376
+ try {
377
+ node = await resolveMeshNode(target, { vaultRoot, brainDir });
378
+ } catch (err) {
379
+ fail(err.message, 'Run `total-recall mesh nodes` to list them.');
380
+ return;
383
381
  }
384
382
 
385
- if (!node) {
386
- fail(`No mesh node matches "${target}".`, 'Run `total-recall mesh nodes` to list them.');
383
+ if (isSelfNode(node)) {
384
+ fail(
385
+ `"${node.hostname}" is this machine — the mesh never tunnels a node into itself.`,
386
+ 'Run the command locally; `mesh ssh`/`mesh exec` address peers.',
387
+ );
387
388
  return;
388
389
  }
389
390
 
@@ -416,7 +417,7 @@ export default async function meshCli(argv = []) {
416
417
 
417
418
  try {
418
419
  const { execMeshCommand } = await import('../core/mesh.mjs');
419
- const res = await execMeshCommand(target, cmd, { vaultRoot });
420
+ const res = await execMeshCommand(target, cmd, { vaultRoot, brainDir });
420
421
  if (jsonMode) {
421
422
  console.log(JSON.stringify(res, null, 2));
422
423
  } else {
@@ -446,14 +447,21 @@ export default async function meshCli(argv = []) {
446
447
  if (target === 'discover') {
447
448
  const { discoverNodeAccess } = await import('../core/mesh-discover.mjs');
448
449
  const wanted = args.find((a) => !a.startsWith('-'));
449
- const targets = wanted
450
- ? [findMeshNode(wanted, vaultRoot)].filter(Boolean)
451
- : listEnrichedMeshNodes(vaultRoot).filter(
452
- (n) => !n.self && n.ip && n.online !== false && !n.access?.verified_at,
453
- );
450
+ let targets;
451
+ if (wanted) {
452
+ try {
453
+ targets = [await resolveMeshNode(wanted, { vaultRoot, brainDir })];
454
+ } catch (err) {
455
+ fail(err.message, 'Run `total-recall mesh nodes` to list them.');
456
+ return;
457
+ }
458
+ } else {
459
+ targets = listEnrichedMeshNodes(vaultRoot).filter(
460
+ (n) => !n.self && n.ip && n.online !== false && !n.access?.verified_at,
461
+ );
462
+ }
454
463
  if (!targets.length) {
455
- console.log(wanted ? `No mesh node matches "${wanted}".` : 'Every online node already has a verified login.');
456
- if (wanted) process.exitCode = 1;
464
+ console.log('Every online node already has a verified login.');
457
465
  return;
458
466
  }
459
467
  let missing = 0;
@@ -545,9 +553,11 @@ export default async function meshCli(argv = []) {
545
553
  const identity = readOption('--identity');
546
554
 
547
555
  if (!user && !port && !host && !identity) {
548
- const node = findMeshNode(target, vaultRoot);
549
- if (!node) {
550
- fail(`No mesh node matches "${target}".`);
556
+ let node;
557
+ try {
558
+ node = await resolveMeshNode(target, { vaultRoot, brainDir });
559
+ } catch (err) {
560
+ fail(err.message, 'Run `total-recall mesh nodes` to list them.');
551
561
  return;
552
562
  }
553
563
  console.log(JSON.stringify(resolveNodeAccess(node), null, 2));
@@ -560,13 +570,12 @@ export default async function meshCli(argv = []) {
560
570
  if (host) patch.ssh_host = host;
561
571
  if (identity) patch.identity_file = identity;
562
572
 
563
- const result = await setMeshNodeAccess(target, patch, { vaultRoot });
573
+ const result = await setMeshNodeAccess(target, patch, { vaultRoot, brainDir });
564
574
  if (!result.written) {
575
+ const unresolved = result.reason === 'node-not-found' || result.reason === 'node-ambiguous';
565
576
  fail(
566
- `Could not record access for "${target}".`,
567
- result.reason === 'node-not-found'
568
- ? 'Run `total-recall mesh nodes` to see known nodes.'
569
- : 'The vault write was rejected.',
577
+ unresolved ? result.error : `Could not record access for "${target}".`,
578
+ unresolved ? 'Run `total-recall mesh nodes` to see known nodes.' : 'The vault write was rejected.',
570
579
  );
571
580
  return;
572
581
  }
@@ -591,7 +600,19 @@ export default async function meshCli(argv = []) {
591
600
  }
592
601
 
593
602
  if (command === 'leader') {
594
- const { getLeaderInfo, isLeader } = await import('../core/leader-election.mjs');
603
+ const { getLeaderInfo, isLeader, setLeaderPin } = await import('../core/leader-election.mjs');
604
+ if (args[0] === 'pin') {
605
+ const ip = args[1];
606
+ const portIndex = args.indexOf('--port');
607
+ const port = portIndex >= 0 ? args[portIndex + 1] : undefined;
608
+ if (!ip || ip.startsWith('-')) {
609
+ fail('Usage: total-recall mesh leader pin <mesh-ip> [--port <leader server port>]');
610
+ return;
611
+ }
612
+ const pinned = setLeaderPin({ ip, port });
613
+ console.log(`Leader pinned to ${pinned.leaderIp}${pinned.leaderPort ? `:${pinned.leaderPort}` : ''} in ${pinned.file}`);
614
+ return;
615
+ }
595
616
  const leaderInfo = await getLeaderInfo();
596
617
  const thisIsLeader = await isLeader();
597
618
  if (args.includes('--json')) {
@@ -602,7 +623,7 @@ export default async function meshCli(argv = []) {
602
623
  console.log('No leader elected (no mesh nodes online).');
603
624
  return;
604
625
  }
605
- console.log(`👑 Current Cluster Leader: ${leaderInfo.hostname} (${leaderInfo.ip})`);
626
+ console.log(`👑 Current Cluster Leader: ${leaderInfo.hostname} (${leaderInfo.ip}${leaderInfo.port ? `:${leaderInfo.port}` : ''})`);
606
627
  console.log(` Strategy: ${leaderInfo.strategy}`);
607
628
  console.log(` Local node: ${thisIsLeader ? 'LEADER (this node runs cluster workloads)' : 'FOLLOWER'}`);
608
629
  return;
@@ -611,18 +632,20 @@ export default async function meshCli(argv = []) {
611
632
  if (command === 'ping' || command === 'latency') {
612
633
  const target = args.find((a) => !a.startsWith('-'));
613
634
  const isJson = args.includes('--json');
614
- const { getMeshPeers, findMeshNode } = await import('../core/mesh.mjs');
635
+ const { getMeshPeers } = await import('../core/mesh.mjs');
615
636
  const { throttledFetch } = await import('../core/throttled-fetch.mjs');
616
637
 
617
638
  const port = Number(process.env.TR_SERVER_PORT || process.env.PORT || 3000);
618
639
 
619
640
  if (target) {
620
- const node = findMeshNode(target, vaultRoot);
621
- if (!node) {
622
- fail(`No mesh node matches "${target}".`);
641
+ let node;
642
+ try {
643
+ node = await resolveMeshNode(target, { vaultRoot, brainDir });
644
+ } catch (err) {
645
+ fail(err.message, 'Run `total-recall mesh nodes` to list them.');
623
646
  return;
624
647
  }
625
- if (node.self) {
648
+ if (isSelfNode(node)) {
626
649
  if (isJson) {
627
650
  console.log(JSON.stringify({ node: node.hostname, ip: node.ip, latency_ms: 0, self: true, reachable: true }, null, 2));
628
651
  } else {
@@ -60,6 +60,58 @@ export function leaderPin(brain = brainDir) {
60
60
  }
61
61
  }
62
62
 
63
+ function validPort(value) {
64
+ const port = Number(value);
65
+ return Number.isInteger(port) && port > 0 && port <= 65535 ? port : null;
66
+ }
67
+
68
+ /**
69
+ * Port the pinned leader's Total Recall server answers on.
70
+ *
71
+ * Followers used to reach the leader on their OWN configured port, which only
72
+ * works when every node happens to use the same one. The droplet serves on 3900
73
+ * while devices default to 3000, so every follower's secrets sync failed with a
74
+ * bare "fetch failed" once a minute, forever. The pin names the port alongside
75
+ * the IP (`TR_LEADER_PORT`, or `leaderPort` in config/leader.json).
76
+ */
77
+ export function leaderPort(brain = brainDir) {
78
+ const raw = String(process.env.TR_LEADER_PORT || '').trim();
79
+ if (raw) return validPort(raw);
80
+ try {
81
+ const file = path.join(brain, 'config', 'leader.json');
82
+ if (!fs.existsSync(file)) return null;
83
+ return validPort(JSON.parse(fs.readFileSync(file, 'utf8'))?.leaderPort);
84
+ } catch {
85
+ return null;
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Record the operator pin in <brain>/config/leader.json, keeping any note.
91
+ * The env vars still win when the supervisor sets them.
92
+ */
93
+ export function setLeaderPin({ ip, port } = {}, brain = brainDir) {
94
+ const octets = String(ip || '').split('.').map(Number);
95
+ if (octets.length !== 4 || octets.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
96
+ throw new Error(`Leader pin needs a mesh IPv4 address, got "${ip}"`);
97
+ }
98
+ if (port !== undefined && port !== null && !validPort(port)) throw new Error(`Invalid leader port "${port}"`);
99
+ const file = path.join(brain, 'config', 'leader.json');
100
+ let current = {};
101
+ try { current = JSON.parse(fs.readFileSync(file, 'utf8')) || {}; } catch {}
102
+ const next = { ...current, leaderIp: String(ip).trim(), updated_at: new Date().toISOString() };
103
+ if (port !== undefined && port !== null) next.leaderPort = validPort(port);
104
+ fs.mkdirSync(path.dirname(file), { recursive: true });
105
+ const temporary = `${file}.${process.pid}.tmp`;
106
+ fs.writeFileSync(temporary, `${JSON.stringify(next, null, 2)}\n`);
107
+ fs.renameSync(temporary, file);
108
+ return { file, ...next };
109
+ }
110
+
111
+ function withPort(info, port) {
112
+ return port ? { ...info, port } : info;
113
+ }
114
+
63
115
  function compareNodes(a, b) {
64
116
  const ipOrder = String(a.ip || '').localeCompare(String(b.ip || ''), undefined, { numeric: true });
65
117
  return ipOrder || String(a.hostname || '').localeCompare(String(b.hostname || ''));
@@ -77,11 +129,14 @@ export async function getLeaderInfo() {
77
129
  if (pin) {
78
130
  const pinned = nodes.find((node) => node.ip === pin);
79
131
  if (!pinned) return null;
80
- return { hostname: pinned.hostname, ip: pinned.ip, strategy: 'pinned-mesh-ip' };
132
+ return withPort(
133
+ { hostname: pinned.hostname, ip: pinned.ip, strategy: 'pinned-mesh-ip' },
134
+ leaderPort() || validPort(pinned.brain_port),
135
+ );
81
136
  }
82
137
 
83
138
  const leader = [...nodes].sort(compareNodes)[0];
84
- return { hostname: leader.hostname, ip: leader.ip, strategy: 'lowest-mesh-ip' };
139
+ return withPort({ hostname: leader.hostname, ip: leader.ip, strategy: 'lowest-mesh-ip' }, validPort(leader.brain_port));
85
140
  }
86
141
 
87
142
  /**
@@ -86,7 +86,13 @@ export function meshNodeKey(hostname) {
86
86
  function normalizeNode(node, self = false) {
87
87
  return {
88
88
  hostname: normalizeHostname(node?.DNSName) || node?.HostName || null,
89
+ // The name the machine reports for itself, which is what the control
90
+ // server lists it under. It differs from the DNS name whenever the node was
91
+ // renamed (a droplet listed as `portfolio-site` answers as `cloud.mesh…`),
92
+ // and it is not unique, so it is an identity to match, never a key.
93
+ os_hostname: node?.HostName || null,
89
94
  ip: node?.TailscaleIPs?.[0] || null,
95
+ ips: Array.isArray(node?.TailscaleIPs) ? node.TailscaleIPs : [],
90
96
  online: !!node?.Online,
91
97
  self,
92
98
  os: node?.OS || null,
@@ -226,7 +232,9 @@ function enrichPeerWithEntity(peer, ent, extra = {}) {
226
232
  : [];
227
233
  return {
228
234
  hostname: peer.hostname,
235
+ os_hostname: peer.os_hostname || null,
229
236
  ip: peer.ip || ent?.ip || null,
237
+ ips: Array.isArray(peer.ips) && peer.ips.length ? peer.ips : [peer.ip || ent?.ip].filter(Boolean),
230
238
  lan_ip: peer.lan_ip || ent?.lan_ip || null,
231
239
  brain_port: ent?.brain_port || null,
232
240
  online: !!peer.online,
@@ -308,47 +316,178 @@ function findEntityForSelf(self, vaultRoot) {
308
316
  return entityForPeer(self, entities, otherHosts);
309
317
  }
310
318
 
311
- /** Find an enriched node (live + vault) by hostname, registry name, or address. */
312
- export function findMeshNode(nameOrAddress, vaultRoot = meshVaultRoot(), registry = null) {
313
- const wanted = String(nameOrAddress || '').trim();
314
- if (!wanted) return null;
315
- const key = meshNodeKey(wanted);
316
- const nodes = listEnrichedMeshNodes(vaultRoot);
319
+ const isAddressLike = (value) => value.includes(':') || /^\d{1,3}(\.\d{1,3}){3}$/.test(value);
320
+ const cleanName = (value) => String(value).trim().replace(/\.$/, '').toLowerCase();
317
321
 
318
- // 1. Match by Tailscale IP or LAN IP
319
- let found = nodes.find((n) => String(n.ip) === wanted || String(n.lan_ip) === wanted);
320
- if (found) return found;
322
+ function meshNodeError(code, message) {
323
+ const err = new Error(message);
324
+ err.code = code;
325
+ return err;
326
+ }
321
327
 
322
- // 2. Match by key derived from enriched hostname (Tailscale DNSName first part)
323
- found = nodes.find((n) => meshNodeKey(n.hostname) === key);
324
- if (found) return found;
328
+ /** A control-server entry no live peer or entity accounts for. */
329
+ function registryOnlyNode(entry) {
330
+ const addresses = (entry.ipAddresses || []).map(String);
331
+ const name = entry.givenName || entry.name || null;
332
+ return {
333
+ hostname: name,
334
+ os_hostname: entry.name || null,
335
+ ip: addresses[0] || null,
336
+ ips: addresses,
337
+ lan_ip: null,
338
+ online: entry.online === true,
339
+ self: false,
340
+ title: name,
341
+ has_entity: false,
342
+ vault_only: false,
343
+ registry_only: true,
344
+ registry_id: entry.id == null ? null : String(entry.id),
345
+ access: null,
346
+ };
347
+ }
325
348
 
326
- // 3. Match by entity title
327
- found = nodes.find((n) => n.title && meshNodeKey(n.title) === key);
328
- if (found) return found;
349
+ /**
350
+ * Every node reference can be resolved against, each with the identities an
351
+ * operator might type: what the local daemon reports (DNS name, the machine's
352
+ * own hostname, every address), what the vault records (title, LAN address),
353
+ * and what the control server lists (`name`, `givenName`, `id`).
354
+ *
355
+ * Registry entries join a node by a shared address. An entry nothing claims
356
+ * becomes a node of its own, so a machine only the control server knows about
357
+ * still resolves; it carries no access, which callers report as such.
358
+ */
359
+ function meshNodeCandidates(nodes = [], registry = null) {
360
+ const entries = Array.isArray(registry) ? registry : [];
361
+ const claimed = new Set();
362
+ const identities = (node, entry) => {
363
+ const names = [node.hostname, node.os_hostname, node.title, entry?.name, entry?.givenName].filter(Boolean);
364
+ return {
365
+ addresses: new Set(
366
+ [node.ip, node.lan_ip, ...(node.ips || []), ...(entry?.ipAddresses || [])]
367
+ .filter(Boolean)
368
+ .map((v) => String(v).toLowerCase()),
369
+ ),
370
+ names: new Set(names.map(cleanName)),
371
+ keys: new Set(names.map(meshNodeKey).filter(Boolean)),
372
+ id: entry?.id == null || entry.id === '' ? null : String(entry.id),
373
+ };
374
+ };
329
375
 
330
- // 4. When a registry (Headscale API) is available, match by givenName or name
331
- if (registry && Array.isArray(registry)) {
332
- const entry = registry.find(
333
- (r) => (r.givenName && meshNodeKey(r.givenName) === key) ||
334
- (r.name && meshNodeKey(r.name) === key)
376
+ const candidates = nodes.map((node) => {
377
+ const own = new Set([node.ip, ...(node.ips || [])].filter(Boolean).map(String));
378
+ const index = entries.findIndex(
379
+ (e, i) => !claimed.has(i) && (e.ipAddresses || []).some((ip) => own.has(String(ip))),
335
380
  );
336
- if (entry) {
337
- const addresses = entry.ipAddresses || [];
338
- found = addresses.map((ip) => nodes.find((n) => String(n.ip) === String(ip))).find(Boolean);
339
- if (found) return found;
340
- // Return a synthetic node from registry details
341
- return {
342
- hostname: entry.givenName || entry.name || wanted,
343
- ip: addresses[0] || null,
344
- title: entry.givenName || entry.name || '',
345
- };
381
+ if (index !== -1) claimed.add(index);
382
+ return { node, ids: identities(node, index === -1 ? null : entries[index]) };
383
+ });
384
+ entries.forEach((entry, i) => {
385
+ if (claimed.has(i)) return;
386
+ const node = registryOnlyNode(entry);
387
+ candidates.push({ node, ids: identities(node, entry) });
388
+ });
389
+ return candidates;
390
+ }
391
+
392
+ /**
393
+ * Resolve a node reference against every identity `mesh nodes` can print.
394
+ *
395
+ * Precedence, first tier with a match wins: exact address, exact whole name,
396
+ * first-label key (never for an address, whose first label is shared by every
397
+ * node on the range), then the control server's node id.
398
+ *
399
+ * A machine's own hostname is not unique (two phones both report `localhost`),
400
+ * so a reference that matches two machines in the deciding tier is refused
401
+ * rather than resolved to whichever came first: `exec` would otherwise run on
402
+ * the wrong host. A vault-only record that shares a match with a live or
403
+ * registered node is set aside first — Headscale reassigns a freed address, so
404
+ * a stale record at that address describes the machine that held it before.
405
+ *
406
+ * Pure: callers supply the nodes and, optionally, the registry.
407
+ *
408
+ * @returns {object|null} the matching node; throws MESH_NODE_AMBIGUOUS
409
+ */
410
+ export function matchMeshNode(nameOrAddress, nodes = [], registry = null) {
411
+ const wanted = String(nameOrAddress || '').trim();
412
+ if (!wanted) return null;
413
+ const lower = wanted.toLowerCase();
414
+ const exact = cleanName(wanted);
415
+ const key = isAddressLike(wanted) ? '' : meshNodeKey(wanted);
416
+ const candidates = meshNodeCandidates(nodes, registry);
417
+
418
+ const tiers = [
419
+ (ids) => ids.addresses.has(lower),
420
+ (ids) => ids.names.has(exact),
421
+ (ids) => !!key && ids.keys.has(key),
422
+ (ids) => ids.id === wanted,
423
+ ];
424
+
425
+ for (const matches of tiers) {
426
+ const hits = candidates.filter((c) => matches(c.ids));
427
+ if (!hits.length) continue;
428
+ const current = hits.filter((c) => !c.node.vault_only);
429
+ const pool = current.length ? current : hits;
430
+ if (pool.length > 1) {
431
+ const names = pool.map((c) => `${c.node.hostname || '?'} (${c.node.ip || 'no address'})`);
432
+ throw meshNodeError(
433
+ 'MESH_NODE_AMBIGUOUS',
434
+ `"${wanted}" matches more than one mesh node: ${names.join(', ')}. Use its address or full name.`,
435
+ );
346
436
  }
437
+ return pool[0].node;
347
438
  }
348
-
349
439
  return null;
350
440
  }
351
441
 
442
+ /** Find an enriched node (live + vault, plus `registry` when given) synchronously. */
443
+ export function findMeshNode(nameOrAddress, vaultRoot = meshVaultRoot(), registry = null) {
444
+ return matchMeshNode(nameOrAddress, listEnrichedMeshNodes(vaultRoot), registry);
445
+ }
446
+
447
+ async function fetchMeshRegistry(brainDir) {
448
+ const { headscaleFetch } = await import('./headscale-client.mjs');
449
+ const data = await headscaleFetch('/api/v1/node', {}, brainDir);
450
+ return Array.isArray(data?.nodes) ? data.nodes : [];
451
+ }
452
+
453
+ /**
454
+ * The one resolver every command that takes a node goes through.
455
+ *
456
+ * Local discovery and the vault answer first, so a name this machine already
457
+ * knows never needs the control-server credential. Only on a miss is the
458
+ * control server asked — that is where registry-only names and node ids live.
459
+ * When it cannot be asked, the not-found error says why, so a locked secrets
460
+ * store is never reported as a machine that does not exist.
461
+ *
462
+ * @param {string} nameOrAddress
463
+ * @param {{vaultRoot?: string, brainDir?: string, nodes?: object[], fetchRegistry?: Function}} [options]
464
+ * @returns {Promise<object>} the node; throws MESH_NODE_NOT_FOUND or MESH_NODE_AMBIGUOUS
465
+ */
466
+ export async function resolveMeshNode(nameOrAddress, options = {}) {
467
+ const wanted = String(nameOrAddress || '').trim();
468
+ if (!wanted) throw meshNodeError('MESH_NODE_NOT_FOUND', 'A mesh node name or address is required.');
469
+ const nodes = options.nodes || listEnrichedMeshNodes(options.vaultRoot || meshVaultRoot());
470
+
471
+ const local = matchMeshNode(wanted, nodes);
472
+ if (local) return local;
473
+
474
+ let registry = null;
475
+ let registryError = null;
476
+ try {
477
+ registry = await (options.fetchRegistry || fetchMeshRegistry)(options.brainDir);
478
+ } catch (err) {
479
+ registryError = err;
480
+ }
481
+ const found = registry ? matchMeshNode(wanted, nodes, registry) : null;
482
+ if (found) return found;
483
+
484
+ throw meshNodeError(
485
+ 'MESH_NODE_NOT_FOUND',
486
+ `No mesh node matches "${wanted}".` +
487
+ (registryError ? ` The control server could not be asked: ${registryError.message}` : ''),
488
+ );
489
+ }
490
+
352
491
  /**
353
492
  * Record how to reach a node.
354
493
  *
@@ -358,8 +497,14 @@ export function findMeshNode(nameOrAddress, vaultRoot = meshVaultRoot(), registr
358
497
  */
359
498
  export async function setMeshNodeAccess(nameOrAddress, accessPatch, options = {}) {
360
499
  const vaultRoot = options.vaultRoot || meshVaultRoot();
361
- const node = findMeshNode(nameOrAddress, vaultRoot);
362
- if (!node) return { written: false, reason: 'node-not-found' };
500
+ let node;
501
+ try {
502
+ node = await resolveMeshNode(nameOrAddress, { vaultRoot, brainDir: options.brainDir });
503
+ } catch (err) {
504
+ if (err.code === 'MESH_NODE_NOT_FOUND') return { written: false, reason: 'node-not-found', error: err.message };
505
+ if (err.code === 'MESH_NODE_AMBIGUOUS') return { written: false, reason: 'node-ambiguous', error: err.message };
506
+ throw err;
507
+ }
363
508
 
364
509
  const entities = listMeshNodeEntities(vaultRoot);
365
510
  const otherHosts = new Set(
@@ -679,11 +824,50 @@ export async function patchOwnMeshNode(options = {}) {
679
824
  * @param {boolean} [options.loginShell=true]
680
825
  * @returns {Promise<{ node: string, hostname: string, ip: string, exitCode: number, success: boolean, stdout: string, stderr: string }>}
681
826
  */
827
+ /**
828
+ * Is `node` the machine running this command?
829
+ *
830
+ * Every host reaches itself through its own sshd, so `mesh ssh`/`mesh exec`
831
+ * aimed at self is a misconfiguration, not a remote call: it burns an auth
832
+ * round-trip, fails under `BatchMode` when this host holds no key for its own
833
+ * account, and hides the real intent (work that belongs to the local machine).
834
+ *
835
+ * Identity comes only from what the local daemon and registry report: the
836
+ * `self` flag, an exact address, or a whole hostname under any of its MagicDNS
837
+ * forms. Short labels are compared for HOSTNAMES ONLY — applied to an address
838
+ * they would compare the first octet, which every node on a shared range has
839
+ * in common, and match unrelated peers. Never key on a device name, a tailnet
840
+ * domain or an address range.
841
+ */
842
+ export function isSelfNode(node, selfOverride) {
843
+ if (!node) return false;
844
+ if (node.self) return true;
845
+ const self = selfOverride === undefined ? getMeshSelf() : selfOverride;
846
+ if (!self) return false;
847
+
848
+ const isAddress = (value) => value.includes(':') || /^\d{1,3}(\.\d{1,3}){3}$/.test(value);
849
+ const clean = (v) => String(v || '').replace(/\.$/, '').toLowerCase();
850
+ const selfValues = [self.hostname, self.ip, self.name].filter(Boolean).map(clean);
851
+ const selfAddresses = new Set(selfValues.filter(isAddress));
852
+ const selfNames = new Set(selfValues.filter((v) => !isAddress(v)));
853
+ const selfLabels = new Set([...selfNames].map((v) => v.split('.')[0]));
854
+
855
+ return [node.hostname, node.ip, node.name].filter(Boolean).map(clean).some((candidate) => {
856
+ if (selfAddresses.has(candidate)) return true;
857
+ if (selfNames.has(candidate)) return true;
858
+ // A hostname may arrive fully qualified or bare; an address may not.
859
+ return !isAddress(candidate) && selfLabels.has(candidate.split('.')[0]);
860
+ });
861
+ }
862
+
682
863
  export async function execMeshCommand(target, command, options = {}) {
683
864
  const { vaultRoot = meshVaultRoot(), timeoutMs = 60_000, loginShell = true } = options;
684
- const node = findMeshNode(target, vaultRoot);
685
- if (!node) {
686
- throw new Error(`No mesh node matches "${target}".`);
865
+ const node = await resolveMeshNode(target, { vaultRoot, brainDir: options.brainDir });
866
+ if (isSelfNode(node)) {
867
+ throw new Error(
868
+ `"${node.hostname}" is this machine, and the mesh never tunnels a node into itself. `
869
+ + 'Run the command locally; the mesh CLI addresses peers.',
870
+ );
687
871
  }
688
872
  const resolved = resolveNodeAccess(node);
689
873
  if (!resolved.complete) {
@@ -172,9 +172,14 @@ router.post('/api/mesh/access', requireAuth, requireScope('config:write'), async
172
172
  const result = await setMeshNodeAccess(target, patch, { vaultRoot: meshVaultRoot() });
173
173
  if (!result.written) {
174
174
  const notFound = result.reason === 'node-not-found';
175
- res.status(notFound ? 404 : 500).json({
175
+ const ambiguous = result.reason === 'node-ambiguous';
176
+ res.status(notFound ? 404 : ambiguous ? 409 : 500).json({
176
177
  success: false,
177
- message: notFound ? `No mesh node matches "${target}".` : 'The vault write was rejected.',
178
+ message: notFound
179
+ ? `No mesh node matches "${target}".`
180
+ : ambiguous
181
+ ? result.error
182
+ : 'The vault write was rejected.',
178
183
  reason: result.reason || null,
179
184
  });
180
185
  return;