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,416 @@
1
+ /**
2
+ * total-recall mesh — control-server (headscale) mesh administration.
3
+ *
4
+ * Node and pre-auth-key management already existed over REST; this adds the
5
+ * missing piece, the ACL policy, which is what turns the mesh from "a network"
6
+ * into "a network that also authorises SSH". Without a policy, headscale routes
7
+ * packets but every host still falls back to its own sshd and per-machine
8
+ * authorized_keys — which defeats most of the point of running a control server.
9
+ *
10
+ * Policy management requires the control server to run with
11
+ * `policy.mode: database`. In `file` mode the policy lives on the server's disk
12
+ * and the API cannot manage it; the commands below say so explicitly rather
13
+ * than failing obscurely.
14
+ */
15
+
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+ import { spawnSync } from 'node:child_process';
19
+ import { resolveBrainDir, parseLayerFlag } from './agent-dir.mjs';
20
+ import {
21
+ describeHeadscaleAvailability,
22
+ headscaleFetchWithLegacyFallback,
23
+ createHeadscalePreAuthKey,
24
+ getHeadscalePolicy,
25
+ setHeadscalePolicy,
26
+ buildMeshSshPolicy,
27
+ } from '../core/headscale-client.mjs';
28
+ import { findMeshNode, listEnrichedMeshNodes, setMeshNodeAccess } from '../core/mesh.mjs';
29
+ import {
30
+ buildSshArgs,
31
+ formatAccessTarget,
32
+ proposeAccessFromSshConfig,
33
+ readSshConfig,
34
+ resolveNodeAccess,
35
+ } from '../core/mesh-access.mjs';
36
+
37
+ function printHelp() {
38
+ console.log(`
39
+ total-recall mesh — control-server (headscale) administration
40
+
41
+ Usage: total-recall mesh <command> [options]
42
+
43
+ status Control-server reachability and credential state
44
+ nodes List mesh nodes and how to reach each one
45
+ ssh <node> [cmd…] Open a session using the node's recorded access
46
+ access <node> Show resolved access for a node
47
+ access <node> --user <u> [--port <n>] [--host <h>] [--identity <path>]
48
+ Record how to reach a node
49
+ access import Propose access from ~/.ssh/config (--apply to save)
50
+ preauthkey Mint an enrollment key for 'tailscale up --authkey'
51
+ --reusable usable by more than one node
52
+ --ephemeral node is removed when it goes offline
53
+ policy get Show the current ACL policy
54
+ policy set --file <p> Replace the policy from a file ("-" reads stdin)
55
+ policy init-ssh Write a policy enabling Tailscale SSH across the mesh
56
+ --allow-root also permit SSH as root (discouraged)
57
+ --dry-run print the policy without applying it
58
+
59
+ Notes:
60
+ Policy commands need the control server running with policy.mode: database.
61
+ Mesh membership is the trust boundary — every node admitted to the tailnet
62
+ inherits whatever the policy grants. Audit 'mesh nodes' before widening it.
63
+
64
+ A node is only as reachable as its login account is known. The control
65
+ server cannot supply that, so 'access' records it on the node entity where
66
+ every machine and agent resolves it the same way.
67
+
68
+ Examples:
69
+ npx total-recall mesh nodes
70
+ npx total-recall mesh access import
71
+ npx total-recall mesh ssh macmini
72
+ npx total-recall mesh ssh macmini 'uptime'
73
+ npx total-recall mesh policy init-ssh
74
+ `);
75
+ }
76
+
77
+ function fail(message, hint) {
78
+ console.error(`❌ ${message}`);
79
+ if (hint) console.error(` ${hint}`);
80
+ process.exitCode = 1;
81
+ }
82
+
83
+ // A file-mode control server cannot accept an API-written policy. That is a
84
+ // server configuration problem, not a user error, so say what to change.
85
+ function reportPolicyModeError(err) {
86
+ if (err.code === 'POLICY_MODE_FILE') {
87
+ fail(
88
+ 'Control server is in file policy mode.',
89
+ 'Set `policy.mode: database` in the control server config and restart it.',
90
+ );
91
+ return true;
92
+ }
93
+ return false;
94
+ }
95
+
96
+ export default async function meshCli(argv = []) {
97
+ const args = [...argv];
98
+ if (!args.length || args[0] === '--help' || args[0] === '-h') {
99
+ printHelp();
100
+ return;
101
+ }
102
+
103
+ const layer = parseLayerFlag(args);
104
+ const brainDir = resolveBrainDir(layer);
105
+ // Node entities must be read and written in the brain the caller selected;
106
+ // falling back to the default root silently splits one machine's facts
107
+ // across two brains depending on which command touched it last.
108
+ const vaultRoot = path.join(brainDir, 'memory-vault');
109
+ const command = args.shift();
110
+
111
+ try {
112
+ if (command === 'status') {
113
+ const info = await describeHeadscaleAvailability(brainDir);
114
+ console.log(JSON.stringify(info, null, 2));
115
+ return;
116
+ }
117
+
118
+ if (command === 'nodes') {
119
+ // The control server is the authoritative registry, but local discovery
120
+ // plus the vault can already answer "what is on my mesh and how do I
121
+ // reach it". Requiring a credential for that turns a routine listing
122
+ // into a dead end whenever the secrets store is locked.
123
+ let registry = null;
124
+ try {
125
+ const data = await headscaleFetchWithLegacyFallback(
126
+ '/api/v1/node',
127
+ '/api/v1/machine',
128
+ {},
129
+ brainDir,
130
+ );
131
+ registry = data?.nodes || data?.machines || [];
132
+ } catch (err) {
133
+ console.error(`⚠️ Control server unavailable: ${err.message}`);
134
+ console.error(' Falling back to local discovery.\n');
135
+ }
136
+
137
+ // Access lives in the vault, not the control server, so the listing is
138
+ // joined against local entities by address.
139
+ const enriched = listEnrichedMeshNodes(vaultRoot);
140
+ const byIp = new Map();
141
+ for (const node of enriched) {
142
+ if (node.ip) byIp.set(String(node.ip), node);
143
+ }
144
+
145
+ const rows = registry
146
+ ? registry.map((node) => {
147
+ const addresses = node.ipAddresses || [];
148
+ return {
149
+ id: node.id,
150
+ name: node.name || node.givenName || '-',
151
+ address: addresses[0] || null,
152
+ entity: addresses.map((ip) => byIp.get(String(ip))).find(Boolean) || null,
153
+ };
154
+ })
155
+ : enriched.map((node) => ({
156
+ id: '-',
157
+ name: node.hostname || '-',
158
+ address: node.ip || null,
159
+ entity: node,
160
+ }));
161
+
162
+ if (!rows.length) {
163
+ console.log('No nodes found.');
164
+ return;
165
+ }
166
+
167
+ let missing = 0;
168
+ for (const row of rows) {
169
+ const resolved = resolveNodeAccess(row.entity || { ip: row.address });
170
+ if (!resolved.complete) missing += 1;
171
+ console.log(
172
+ ` ${String(row.id).padEnd(4)} ` +
173
+ `${String(row.name).padEnd(38)} ` +
174
+ `${String(row.address || '-').padEnd(16)} ` +
175
+ `${formatAccessTarget(resolved)}`,
176
+ );
177
+ }
178
+
179
+ if (missing) {
180
+ console.log('');
181
+ console.log(
182
+ `${missing} node(s) have no recorded login account — connecting to one will fail`,
183
+ );
184
+ console.log("as though it were unreachable. Run 'total-recall mesh access import'.");
185
+ }
186
+ return;
187
+ }
188
+
189
+ // Connecting should not require the operator to remember, or rediscover,
190
+ // which account a given machine uses.
191
+ if (command === 'ssh') {
192
+ const target = args.shift();
193
+ if (!target) {
194
+ fail('`ssh` requires a node name or address.', 'Run `total-recall mesh nodes`.');
195
+ return;
196
+ }
197
+ const node = findMeshNode(target, vaultRoot);
198
+ if (!node) {
199
+ fail(`No mesh node matches "${target}".`, 'Run `total-recall mesh nodes` to list them.');
200
+ return;
201
+ }
202
+
203
+ const resolved = resolveNodeAccess(node);
204
+ if (!resolved.complete) {
205
+ fail(
206
+ `No login account recorded for ${node.hostname}.`,
207
+ `Set one with: total-recall mesh access ${target} --user <login>`,
208
+ );
209
+ return;
210
+ }
211
+
212
+ const sshArgs = buildSshArgs(resolved, {
213
+ command: args.length ? args.join(' ') : null,
214
+ });
215
+ const res = spawnSync('ssh', sshArgs, { stdio: 'inherit' });
216
+ process.exitCode = res.status ?? 1;
217
+ return;
218
+ }
219
+
220
+ if (command === 'access') {
221
+ const target = args.shift();
222
+ if (!target) {
223
+ fail('`access` requires a node name, or `import`.');
224
+ return;
225
+ }
226
+
227
+ if (target === 'import') {
228
+ const nodes = listEnrichedMeshNodes(vaultRoot);
229
+ const proposals = proposeAccessFromSshConfig(nodes, readSshConfig());
230
+ if (!proposals.length) {
231
+ console.log('No new access could be inferred from ~/.ssh/config.');
232
+ return;
233
+ }
234
+
235
+ const apply = args.includes('--apply');
236
+ let saved = 0;
237
+ let failed = 0;
238
+ for (const proposal of proposals) {
239
+ console.log(
240
+ ` ${String(proposal.hostname).padEnd(24)} ${proposal.access.ssh_user}` +
241
+ ` (from Host ${proposal.matched_host})`,
242
+ );
243
+ if (apply) {
244
+ const result = await setMeshNodeAccess(proposal.hostname, proposal.access, { vaultRoot });
245
+ if (result.written) {
246
+ saved += 1;
247
+ } else {
248
+ failed += 1;
249
+ console.error(` ⚠️ could not save: ${result.reason || 'write failed'}`);
250
+ }
251
+ }
252
+ }
253
+
254
+ console.log('');
255
+ if (!apply) {
256
+ console.log('Re-run with --apply to record these on the node entities.');
257
+ return;
258
+ }
259
+ // Reporting a flat total here regardless of outcome is how a failed
260
+ // write got announced as a success.
261
+ console.log(`Recorded access for ${saved} of ${proposals.length} node(s).`);
262
+ if (failed) {
263
+ fail(`${failed} node(s) could not be saved.`);
264
+ }
265
+ return;
266
+ }
267
+
268
+ const readOption = (flag) => {
269
+ const index = args.indexOf(flag);
270
+ return index === -1 ? null : args[index + 1] || null;
271
+ };
272
+ const user = readOption('--user');
273
+ const port = readOption('--port');
274
+ const host = readOption('--host');
275
+ const identity = readOption('--identity');
276
+
277
+ if (!user && !port && !host && !identity) {
278
+ const node = findMeshNode(target, vaultRoot);
279
+ if (!node) {
280
+ fail(`No mesh node matches "${target}".`);
281
+ return;
282
+ }
283
+ console.log(JSON.stringify(resolveNodeAccess(node), null, 2));
284
+ return;
285
+ }
286
+
287
+ const patch = { source: 'manual' };
288
+ if (user) patch.ssh_user = user;
289
+ if (port) patch.ssh_port = Number.parseInt(port, 10);
290
+ if (host) patch.ssh_host = host;
291
+ if (identity) patch.identity_file = identity;
292
+
293
+ const result = await setMeshNodeAccess(target, patch, { vaultRoot });
294
+ if (!result.written) {
295
+ fail(
296
+ `Could not record access for "${target}".`,
297
+ result.reason === 'node-not-found'
298
+ ? 'Run `total-recall mesh nodes` to see known nodes.'
299
+ : 'The vault write was rejected.',
300
+ );
301
+ return;
302
+ }
303
+ console.log(`✅ Access recorded for ${target} (${result.path}).`);
304
+ return;
305
+ }
306
+
307
+ // Enrolling a node otherwise means an interactive browser login against the
308
+ // control server. A pre-auth key makes `tailscale up` a single
309
+ // copy-pasteable command, which matters most on a machine you are standing
310
+ // in front of precisely because you cannot reach it yet.
311
+ if (command === 'preauthkey') {
312
+ const reusable = args.includes('--reusable');
313
+ const ephemeral = args.includes('--ephemeral');
314
+ const { key, expiration } = await createHeadscalePreAuthKey(
315
+ { reusable, ephemeral },
316
+ brainDir,
317
+ );
318
+ console.log(key);
319
+ console.error(`(expires ${expiration}${reusable ? ', reusable' : ', single-use'})`);
320
+ return;
321
+ }
322
+
323
+ if (command !== 'policy') {
324
+ fail(`Unknown mesh command: ${command}`, 'Run `total-recall mesh --help`.');
325
+ return;
326
+ }
327
+
328
+ const sub = args.shift();
329
+
330
+ if (sub === 'get') {
331
+ let current;
332
+ try {
333
+ current = await getHeadscalePolicy(brainDir);
334
+ } catch (err) {
335
+ if (reportPolicyModeError(err)) return;
336
+ throw err;
337
+ }
338
+ if (!current.configured) {
339
+ console.log('No policy is set on the control server.');
340
+ console.log('Run `total-recall mesh policy init-ssh` to enable Tailscale SSH.');
341
+ return;
342
+ }
343
+ console.log(current.policy);
344
+ return;
345
+ }
346
+
347
+ if (sub === 'set') {
348
+ const fileIndex = args.indexOf('--file');
349
+ if (fileIndex === -1 || !args[fileIndex + 1]) {
350
+ fail('`policy set` requires --file <path> (or --file - for stdin).');
351
+ return;
352
+ }
353
+ const source = args[fileIndex + 1];
354
+ const body =
355
+ source === '-'
356
+ ? fs.readFileSync(0, 'utf8')
357
+ : fs.readFileSync(source, 'utf8');
358
+ if (!body.trim()) {
359
+ fail('Refusing to write an empty policy.');
360
+ return;
361
+ }
362
+ try {
363
+ await setHeadscalePolicy(body, brainDir);
364
+ } catch (err) {
365
+ if (reportPolicyModeError(err)) return;
366
+ throw err;
367
+ }
368
+ console.log('✅ Policy updated.');
369
+ return;
370
+ }
371
+
372
+ if (sub === 'init-ssh') {
373
+ const allowRoot = args.includes('--allow-root');
374
+ const policy = buildMeshSshPolicy({ allowRoot });
375
+
376
+ if (args.includes('--dry-run')) {
377
+ console.log(JSON.stringify(policy, null, 2));
378
+ return;
379
+ }
380
+
381
+ // Never silently clobber a policy someone deliberately wrote.
382
+ let existing;
383
+ try {
384
+ existing = await getHeadscalePolicy(brainDir);
385
+ } catch (err) {
386
+ if (reportPolicyModeError(err)) return;
387
+ throw err;
388
+ }
389
+ if (existing.configured && !args.includes('--force')) {
390
+ fail(
391
+ 'A policy already exists on the control server.',
392
+ 'Review it with `mesh policy get`, then re-run with --force to replace it.',
393
+ );
394
+ return;
395
+ }
396
+
397
+ try {
398
+ await setHeadscalePolicy(policy, brainDir);
399
+ } catch (err) {
400
+ if (reportPolicyModeError(err)) return;
401
+ throw err;
402
+ }
403
+
404
+ console.log('✅ Mesh SSH policy applied.');
405
+ if (allowRoot) console.log('⚠️ root SSH is permitted by this policy.');
406
+ console.log('');
407
+ console.log('Each node must now advertise SSH to accept mesh-authorised sessions:');
408
+ console.log(' tailscale up --ssh (re-run with the existing flags on that node)');
409
+ return;
410
+ }
411
+
412
+ fail(`Unknown policy command: ${sub ?? '(none)'}`, 'Run `total-recall mesh --help`.');
413
+ } catch (err) {
414
+ fail(err.message, err.detail ? String(err.detail).slice(0, 200) : undefined);
415
+ }
416
+ }
@@ -331,7 +331,11 @@ export function generateSkillMd(scan, repoRoot) {
331
331
  lines.push(' file before executing.');
332
332
  lines.push('repo_scoped: true');
333
333
  lines.push(`generated_at: ${new Date().toISOString()}`);
334
- lines.push(`generated_from: ${repoRoot}`);
334
+ // Record the repo by name, never by absolute path. SKILL.md is committed, so
335
+ // an absolute root bakes the generating machine's home directory into the
336
+ // repository — a private detail that also churns the diff on every machine
337
+ // that regenerates it.
338
+ lines.push(`generated_from: ${path.basename(path.resolve(repoRoot))}`);
335
339
  lines.push('---');
336
340
  lines.push('');
337
341
  lines.push(`# ${scan.name || path.basename(repoRoot)} — Codebase Architecture`);
@@ -6,23 +6,89 @@ import yaml from 'yaml';
6
6
  import { BCRYPT_COST } from '../server/auth.mjs';
7
7
  import { brainDir } from '../core/config.mjs';
8
8
 
9
+ /**
10
+ * Read a password without echoing it, and without it ever becoming an argv
11
+ * element. `argv` is world-readable via `ps`, is written to shell history, and
12
+ * is captured verbatim by any agent driving this CLI — so a password passed as
13
+ * an argument is disclosed the moment it is typed. Prompting keeps it in this
14
+ * process's memory only.
15
+ */
16
+ function promptPassword(label) {
17
+ return new Promise((resolve, reject) => {
18
+ const input = process.stdin;
19
+ if (!input.isTTY) {
20
+ // Piped/automated use: read one line from stdin.
21
+ let buf = '';
22
+ input.setEncoding('utf8');
23
+ input.on('data', (d) => (buf += d));
24
+ input.on('end', () => resolve(buf.split('\n')[0].trim()));
25
+ input.on('error', reject);
26
+ return;
27
+ }
28
+ process.stdout.write(label);
29
+ input.setRawMode(true);
30
+ input.resume();
31
+ input.setEncoding('utf8');
32
+ let buf = '';
33
+ const onData = (ch) => {
34
+ if (ch === '\n' || ch === '\r' || ch === '\u0004') {
35
+ input.setRawMode(false);
36
+ input.pause();
37
+ input.removeListener('data', onData);
38
+ process.stdout.write('\n');
39
+ resolve(buf);
40
+ } else if (ch === '\u0003') {
41
+ input.setRawMode(false);
42
+ process.stdout.write('\n');
43
+ process.exit(130);
44
+ } else if (ch === '\u007f' || ch === '\b') {
45
+ buf = buf.slice(0, -1);
46
+ } else {
47
+ buf += ch;
48
+ }
49
+ };
50
+ input.on('data', onData);
51
+ });
52
+ }
53
+
9
54
  export default async function resetPassword(args) {
10
- if (args.includes('--help') || args.includes('-h') || args.length === 0) {
55
+ if (args.includes('--help') || args.includes('-h')) {
11
56
  console.log(`
12
- total-recall reset-password "new_password"
57
+ total-recall reset-password
13
58
 
14
59
  Resets the dashboard admin password in ~/.agent/skills/total-recall/config/security.yml.
60
+ Prompts for the new password; it is never taken as an argument.
15
61
  Sets force_password_reset to true so you will be prompted to change it upon first login.
62
+
63
+ For automation, pipe it instead: echo "\$NEW_PW" | total-recall reset-password
16
64
  `);
17
65
  return;
18
66
  }
19
67
 
20
- const password = args.join(' ');
68
+ if (args.length > 0) {
69
+ console.error(
70
+ 'Error: reset-password no longer accepts the password as an argument —\n' +
71
+ ' argv is readable by any user via `ps` and is saved to shell history.\n' +
72
+ ' Run `total-recall reset-password` with no arguments to be prompted,\n' +
73
+ ' or pipe it: echo "$NEW_PW" | total-recall reset-password',
74
+ );
75
+ process.exit(1);
76
+ }
77
+
78
+ const password = await promptPassword('New dashboard password: ');
21
79
  if (password.length < 8) {
22
80
  console.error('Error: Password must be at least 8 characters.');
23
81
  process.exit(1);
24
82
  }
25
83
 
84
+ if (process.stdin.isTTY) {
85
+ const confirm = await promptPassword('Confirm password: ');
86
+ if (confirm !== password) {
87
+ console.error('Error: Passwords do not match.');
88
+ process.exit(1);
89
+ }
90
+ }
91
+
26
92
  const configDir = path.join(brainDir, 'config');
27
93
  const securityPath = path.join(configDir, 'security.yml');
28
94
 
@@ -64,12 +130,15 @@ export default async function resetPassword(args) {
64
130
 
65
131
  fs.writeFileSync(securityPath, yaml.stringify(config), { encoding: 'utf8', mode: 0o600 });
66
132
 
133
+ // Never echo the password back. The operator just typed it; repeating it to
134
+ // stdout puts it into scrollback, into any log capturing this command, and
135
+ // into the transcript of any agent that ran it.
67
136
  console.log(`\n ✅ Success! Dashboard admin password reset.`);
68
137
  console.log(` 📍 Saved to: ${securityPath}`);
69
138
  if (isDefault) {
70
- console.log(` 🔑 Next login password: "${password}" (you will be forced to change it on login)`);
71
- console.log(` 👉 Access your dashboard and enter this password to log in and set your permanent password.\n`);
139
+ console.log(` ⚠️ You set the well-known default password.`);
140
+ console.log(` 👉 Sign in with it once; you will be forced to change it immediately.\n`);
72
141
  } else {
73
- console.log(` 🔑 Next login password: "${password}" (you can sign in directly now!)\n`);
142
+ console.log(` 👉 Sign in with your new password.\n`);
74
143
  }
75
144
  }