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.
- package/bin/total-recall.mjs +2 -0
- package/frontend/src/api/headscale.ts +68 -1
- package/frontend/src/api/mesh.spec.ts +27 -0
- package/frontend/src/api/mesh.ts +85 -0
- package/frontend/src/components/EmbeddingProviderPanel.tsx +21 -3
- package/frontend/src/pages/MeshPage.spec.tsx +164 -0
- package/frontend/src/pages/MeshPage.tsx +294 -6
- package/frontend/src/pages/NetworkPage.spec.tsx +19 -4
- package/frontend/src/pages/OnboardingPage.spec.tsx +14 -2
- package/frontend/src/pages/SkillsPage.spec.tsx +1 -1
- package/package.json +2 -1
- package/scaffold/.agent/skills/total-recall/references/api-reference.md +1 -1
- package/scaffold/.agent/skills/total-recall/references/cli-reference.md +2 -1
- package/scaffold/.agent/skills/total-recall/skills-registry/index.yaml +6 -2574
- package/src/cli/mesh.mjs +416 -0
- package/src/cli/repo-expert-generate.mjs +5 -1
- package/src/cli/reset-password.mjs +75 -6
- package/src/cli/secret.mjs +250 -7
- package/src/core/browser-session.mjs +68 -13
- package/src/core/headscale-client.mjs +87 -0
- package/src/core/mesh-access.mjs +261 -0
- package/src/core/mesh-enroll.mjs +74 -32
- package/src/core/mesh.mjs +164 -12
- package/src/core/schema.mjs +36 -0
- package/src/core/secrets-keychain.mjs +83 -0
- package/src/core/secrets-rekey.mjs +0 -0
- package/src/core/secrets-store.mjs +26 -6
- package/src/core/tailscale-cli.mjs +69 -0
- package/src/server/index.mjs +23 -4
- package/src/server/pid-lock.mjs +64 -0
- package/src/server/route-inventory.mjs +62 -0
- package/src/server/route-manifest.json +20 -0
- package/src/server/routes/headscale.mjs +32 -0
- package/src/server/routes/mesh.mjs +118 -0
package/src/cli/mesh.mjs
ADDED
|
@@ -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
|
-
|
|
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')
|
|
55
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
11
56
|
console.log(`
|
|
12
|
-
total-recall reset-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
|
-
|
|
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(`
|
|
71
|
-
console.log(` 👉
|
|
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(`
|
|
142
|
+
console.log(` 👉 Sign in with your new password.\n`);
|
|
74
143
|
}
|
|
75
144
|
}
|