total-recall-brain 3.22.0 → 3.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/bin/total-recall.mjs +2 -0
  2. package/frontend/src/api/headscale.ts +68 -1
  3. package/frontend/src/api/mesh.spec.ts +27 -0
  4. package/frontend/src/api/mesh.ts +85 -0
  5. package/frontend/src/components/EmbeddingProviderPanel.tsx +21 -3
  6. package/frontend/src/pages/MeshPage.spec.tsx +164 -0
  7. package/frontend/src/pages/MeshPage.tsx +294 -6
  8. package/frontend/src/pages/NetworkPage.spec.tsx +19 -4
  9. package/frontend/src/pages/OnboardingPage.spec.tsx +14 -2
  10. package/frontend/src/pages/SkillsPage.spec.tsx +1 -1
  11. package/package.json +2 -1
  12. package/scaffold/.agent/skills/total-recall/references/api-reference.md +1 -1
  13. package/scaffold/.agent/skills/total-recall/references/cli-reference.md +2 -1
  14. package/scaffold/.agent/skills/total-recall/skills-registry/index.yaml +6 -2574
  15. package/src/cli/mesh.mjs +416 -0
  16. package/src/cli/repo-expert-generate.mjs +5 -1
  17. package/src/cli/reset-password.mjs +75 -6
  18. package/src/cli/secret.mjs +250 -7
  19. package/src/core/browser-session.mjs +68 -13
  20. package/src/core/headscale-client.mjs +87 -0
  21. package/src/core/mesh-access.mjs +261 -0
  22. package/src/core/mesh-enroll.mjs +74 -32
  23. package/src/core/mesh.mjs +164 -12
  24. package/src/core/schema.mjs +36 -0
  25. package/src/core/secrets-keychain.mjs +83 -0
  26. package/src/core/secrets-rekey.mjs +0 -0
  27. package/src/core/secrets-store.mjs +26 -6
  28. package/src/core/tailscale-cli.mjs +69 -0
  29. package/src/server/index.mjs +23 -4
  30. package/src/server/pid-lock.mjs +64 -0
  31. package/src/server/route-inventory.mjs +62 -0
  32. package/src/server/route-manifest.json +20 -0
  33. package/src/server/routes/headscale.mjs +32 -0
  34. package/src/server/routes/mesh.mjs +118 -0
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Express route inventory.
3
+ *
4
+ * The committed `route-manifest.json` is a tripwire for accidental route
5
+ * changes: anything added or removed shows up as a failing spec rather than as
6
+ * a silent change to the public API surface.
7
+ *
8
+ * Extraction lives here, rather than inside the spec, because the manifest has
9
+ * to be regenerated whenever a route legitimately changes. When the spec owned
10
+ * the only copy of this logic the manifest could only be updated by hand, which
11
+ * is how two real routes came to be missing from it — the tripwire failed, and
12
+ * the failure was invisible to anyone running a subset of the suite.
13
+ */
14
+
15
+ import path from 'node:path';
16
+ import { fileURLToPath } from 'node:url';
17
+
18
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
19
+
20
+ /** The single source of truth both the spec and the generator read/write. */
21
+ export const MANIFEST_PATH = path.join(__dirname, 'route-manifest.json');
22
+
23
+ /** Flatten an Express router stack into `{ method, path }` entries. */
24
+ export function extractRoutes(router) {
25
+ const routes = [];
26
+
27
+ function traverse(stack) {
28
+ if (!stack || !Array.isArray(stack)) return;
29
+
30
+ for (const layer of stack) {
31
+ if (layer.route) {
32
+ const pathStr = layer.route.path;
33
+ const methods = Object.keys(layer.route.methods)
34
+ .filter((method) => layer.route.methods[method])
35
+ .map((method) => method.toUpperCase());
36
+
37
+ for (const method of methods) {
38
+ routes.push({ method, path: pathStr });
39
+ }
40
+ } else if (layer.name === 'router' && layer.handle && layer.handle.stack) {
41
+ // Sub-router mounted via router.use()
42
+ traverse(layer.handle.stack);
43
+ }
44
+ }
45
+ }
46
+
47
+ traverse(router.stack);
48
+ return routes;
49
+ }
50
+
51
+ /**
52
+ * Order routes deterministically.
53
+ *
54
+ * Router registration order is an implementation detail that shifts whenever
55
+ * imports are reordered, so both sides sort before comparing.
56
+ */
57
+ export function sortRoutes(routes) {
58
+ return [...routes].sort((a, b) => {
59
+ const byPath = a.path.localeCompare(b.path);
60
+ return byPath !== 0 ? byPath : a.method.localeCompare(b.method);
61
+ });
62
+ }
@@ -187,6 +187,14 @@
187
187
  "method": "DELETE",
188
188
  "path": "/api/headscale/node/:id"
189
189
  },
190
+ {
191
+ "method": "GET",
192
+ "path": "/api/headscale/policy"
193
+ },
194
+ {
195
+ "method": "PUT",
196
+ "path": "/api/headscale/policy"
197
+ },
190
198
  {
191
199
  "method": "GET",
192
200
  "path": "/api/headscale/preauthkey"
@@ -275,6 +283,18 @@
275
283
  "method": "GET",
276
284
  "path": "/api/memory/stats"
277
285
  },
286
+ {
287
+ "method": "POST",
288
+ "path": "/api/mesh/access"
289
+ },
290
+ {
291
+ "method": "POST",
292
+ "path": "/api/mesh/access/import"
293
+ },
294
+ {
295
+ "method": "GET",
296
+ "path": "/api/mesh/access/proposals"
297
+ },
278
298
  {
279
299
  "method": "GET",
280
300
  "path": "/api/mesh/election/history"
@@ -5,6 +5,8 @@ import { logger } from '../../core/logger.mjs';
5
5
  import {
6
6
  headscaleFetch,
7
7
  headscaleFetchWithLegacyFallback,
8
+ getHeadscalePolicy,
9
+ setHeadscalePolicy,
8
10
  } from '../../core/headscale-client.mjs';
9
11
 
10
12
  const router = express.Router();
@@ -79,4 +81,34 @@ router.get('/api/headscale/user', requireScope('config:read'), async (req, res)
79
81
  }
80
82
  });
81
83
 
84
+ // The ACL policy is what lets the control server authorise SSH itself instead
85
+ // of every host falling back to its own authorized_keys. Requires the control
86
+ // server to run with `policy.mode: database`.
87
+ router.get('/api/headscale/policy', requireScope('config:read'), async (req, res) => {
88
+ try {
89
+ res.json(await getHeadscalePolicy(BRAIN_DIR));
90
+ } catch (err) {
91
+ logger.error('headscale', `GET /policy failed: ${err.message}`);
92
+ // A file-mode server is a configuration problem the caller can fix, so it
93
+ // is a 409 rather than an opaque 500.
94
+ const status = err.code === 'POLICY_MODE_FILE' ? 409 : 500;
95
+ res.status(status).json({ error: err.message, code: err.code });
96
+ }
97
+ });
98
+
99
+ router.put('/api/headscale/policy', requireScope('config:write'), async (req, res) => {
100
+ try {
101
+ const policy = req.body?.policy;
102
+ if (!policy) {
103
+ res.status(400).json({ error: 'A `policy` field is required' });
104
+ return;
105
+ }
106
+ res.json(await setHeadscalePolicy(policy, BRAIN_DIR));
107
+ } catch (err) {
108
+ logger.error('headscale', `PUT /policy failed: ${err.message}`);
109
+ const status = err.code === 'POLICY_MODE_FILE' ? 409 : 500;
110
+ res.status(status).json({ error: err.message, code: err.code });
111
+ }
112
+ });
113
+
82
114
  export default router;
@@ -9,7 +9,13 @@ import {
9
9
  listMeshNodeEntities,
10
10
  attachSelfInterfaces,
11
11
  normalizeHostname,
12
+ setMeshNodeAccess,
12
13
  } from '../../core/mesh.mjs';
14
+ import {
15
+ proposeAccessFromSshConfig,
16
+ readSshConfig,
17
+ resolveNodeAccess,
18
+ } from '../../core/mesh-access.mjs';
13
19
  import { throttledFetch } from '../../core/throttled-fetch.mjs';
14
20
  import { defaultVaultRoot } from '../../core/vfs-documents.mjs';
15
21
  import {
@@ -104,13 +110,125 @@ router.get('/api/mesh/nodes', requireAuth, requireScope('config:read'), async (r
104
110
  } catch {
105
111
  // io optional
106
112
  }
113
+ // Resolve "how do I actually reach this" server-side. The precedence rules
114
+ // (explicit host over mesh address over LAN address) are domain logic; having
115
+ // the UI reimplement them is how two answers to the same question appear.
116
+ nodes = nodes.map((node) => ({ ...node, access_resolved: resolveNodeAccess(node) }));
107
117
  const entities = listMeshNodeEntities(vaultRoot);
108
118
  res.json({
109
119
  nodes,
110
120
  entity_count: entities.length,
121
+ missing_access_count: nodes.filter((node) => !node.access_resolved.complete).length,
111
122
  });
112
123
  });
113
124
 
125
+ /**
126
+ * Turn a request body into an access patch.
127
+ *
128
+ * A key that is present but empty clears the stored value; a key that is absent
129
+ * leaves it untouched. Without that distinction a value could be set from the
130
+ * UI but never taken back.
131
+ */
132
+ function accessPatchFromBody(body = {}) {
133
+ const patch = { source: 'manual' };
134
+ const text = (value) => String(value ?? '').trim() || null;
135
+ if ('ssh_user' in body) patch.ssh_user = text(body.ssh_user);
136
+ if ('ssh_host' in body) patch.ssh_host = text(body.ssh_host);
137
+ if ('identity_file' in body) patch.identity_file = text(body.identity_file);
138
+ if ('ssh_port' in body) {
139
+ const raw = text(body.ssh_port);
140
+ patch.ssh_port = raw === null ? null : Number.parseInt(raw, 10);
141
+ }
142
+ return patch;
143
+ }
144
+
145
+ /**
146
+ * Record how to reach a node — the one fact the control server cannot supply.
147
+ * Body: { node, ssh_user?, ssh_port?, ssh_host?, identity_file? }
148
+ */
149
+ router.post('/api/mesh/access', requireAuth, requireScope('config:write'), async (req, res) => {
150
+ const target = String(req.body?.node || '').trim();
151
+ if (!target) {
152
+ res.status(400).json({ success: false, message: 'node is required' });
153
+ return;
154
+ }
155
+
156
+ const patch = accessPatchFromBody(req.body || {});
157
+ if (patch.ssh_port != null && !(Number.isInteger(patch.ssh_port) && patch.ssh_port > 0 && patch.ssh_port <= 65535)) {
158
+ res.status(400).json({ success: false, message: 'ssh_port must be between 1 and 65535' });
159
+ return;
160
+ }
161
+
162
+ try {
163
+ const result = await setMeshNodeAccess(target, patch, { vaultRoot: defaultVaultRoot() });
164
+ if (!result.written) {
165
+ const notFound = result.reason === 'node-not-found';
166
+ res.status(notFound ? 404 : 500).json({
167
+ success: false,
168
+ message: notFound ? `No mesh node matches "${target}".` : 'The vault write was rejected.',
169
+ reason: result.reason || null,
170
+ });
171
+ return;
172
+ }
173
+ res.json({ success: true, path: result.path, access: result.access });
174
+ } catch (err) {
175
+ res.status(500).json({ success: false, message: err.message || 'access write failed' });
176
+ }
177
+ });
178
+
179
+ /**
180
+ * Which nodes could be given a login account from this host's ssh config.
181
+ *
182
+ * Read-only, and deliberately separate from applying: importing a config is a
183
+ * judgement call, and a wrong login written to the vault is worse than none at
184
+ * all — it fails exactly like an unreachable host, only now it looks authored.
185
+ */
186
+ router.get('/api/mesh/access/proposals', requireAuth, requireScope('config:read'), async (_req, res) => {
187
+ try {
188
+ const nodes = listEnrichedMeshNodes(defaultVaultRoot());
189
+ res.json({
190
+ proposals: proposeAccessFromSshConfig(nodes, readSshConfig()),
191
+ missing_access: nodes
192
+ .filter((node) => !resolveNodeAccess(node).complete)
193
+ .map((node) => node.hostname),
194
+ checked_at: new Date().toISOString(),
195
+ });
196
+ } catch (err) {
197
+ res.status(500).json({ success: false, message: err.message || 'access proposal failed' });
198
+ }
199
+ });
200
+
201
+ /** Apply every proposal from this host's ssh config to the node entities. */
202
+ router.post('/api/mesh/access/import', requireAuth, requireScope('config:write'), async (_req, res) => {
203
+ try {
204
+ const vaultRoot = defaultVaultRoot();
205
+ const proposals = proposeAccessFromSshConfig(listEnrichedMeshNodes(vaultRoot), readSshConfig());
206
+ const results = [];
207
+ for (const proposal of proposals) {
208
+ const result = await setMeshNodeAccess(proposal.hostname, proposal.access, { vaultRoot });
209
+ results.push({
210
+ hostname: proposal.hostname,
211
+ ssh_user: proposal.access.ssh_user,
212
+ matched_host: proposal.matched_host,
213
+ written: Boolean(result.written),
214
+ reason: result.reason || null,
215
+ });
216
+ }
217
+ const saved = results.filter((r) => r.written).length;
218
+ // Counting attempts as successes is how a failed write once got announced
219
+ // as a success; the caller gets both numbers.
220
+ res.json({
221
+ success: saved === proposals.length,
222
+ attempted: proposals.length,
223
+ saved,
224
+ failed: proposals.length - saved,
225
+ results,
226
+ });
227
+ } catch (err) {
228
+ res.status(500).json({ success: false, message: err.message || 'access import failed' });
229
+ }
230
+ });
231
+
114
232
  /**
115
233
  * Enrollment state of THIS node on the mesh control server.
116
234
  * Reports whether an unattended enrollment is possible and, when it is not,