total-recall-brain 3.22.0 → 3.23.1
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/daemon-loop.mjs +26 -8
- 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/pid-lock.mjs +65 -0
- 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/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
|
@@ -12,8 +12,11 @@ import {
|
|
|
12
12
|
logElectionObservation,
|
|
13
13
|
fetchEnrollmentStatus,
|
|
14
14
|
enrollThisNode,
|
|
15
|
+
fetchAccessProposals,
|
|
16
|
+
importAccessFromSshConfig,
|
|
17
|
+
setNodeAccess,
|
|
15
18
|
} from '../api/mesh';
|
|
16
|
-
import { fetchHeadscaleNodes, fetchPreAuthKeys, fetchHeadscaleUsers, createPreAuthKey, deleteHeadscaleNode } from '../api/headscale';
|
|
19
|
+
import { fetchHeadscaleNodes, fetchPreAuthKeys, fetchHeadscaleUsers, createPreAuthKey, deleteHeadscaleNode, fetchHeadscalePolicy, saveHeadscalePolicy, buildMeshSshPolicy } from '../api/headscale';
|
|
17
20
|
import type {
|
|
18
21
|
MeshNode,
|
|
19
22
|
LeaderInfo,
|
|
@@ -21,6 +24,7 @@ import type {
|
|
|
21
24
|
MeshInterfaceSummary,
|
|
22
25
|
DeviceIoProfile,
|
|
23
26
|
EnrollmentStatus,
|
|
27
|
+
AccessProposal,
|
|
24
28
|
} from '../api/mesh';
|
|
25
29
|
import type { HeadscaleNode, PreAuthKey as HeadscalePreAuthKey, HeadscaleUser } from '../api/headscale';
|
|
26
30
|
import { MeshTopology } from '../components/MeshTopology';
|
|
@@ -39,13 +43,43 @@ interface ElectionLogEntry {
|
|
|
39
43
|
note: string;
|
|
40
44
|
}
|
|
41
45
|
|
|
46
|
+
/** Editable access fields, as strings so an empty box can clear a value. */
|
|
47
|
+
interface AccessDraft {
|
|
48
|
+
ssh_user: string;
|
|
49
|
+
ssh_port: string;
|
|
50
|
+
identity_file: string;
|
|
51
|
+
ssh_host: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const EMPTY_ACCESS_DRAFT: AccessDraft = {
|
|
55
|
+
ssh_user: '',
|
|
56
|
+
ssh_port: '',
|
|
57
|
+
identity_file: '',
|
|
58
|
+
ssh_host: '',
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
function accessDraftFromNode(node: MeshNode | null): AccessDraft {
|
|
62
|
+
const access = node?.access;
|
|
63
|
+
return {
|
|
64
|
+
ssh_user: access?.ssh_user || '',
|
|
65
|
+
ssh_port: access?.ssh_port ? String(access.ssh_port) : '',
|
|
66
|
+
identity_file: access?.identity_file || '',
|
|
67
|
+
ssh_host: access?.ssh_host || '',
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
42
71
|
export function MeshPage() {
|
|
43
72
|
const [activeTab, setActiveTab] = useState<'mesh'|'headscale-nodes'|'preauthkeys'|'users'>('mesh');
|
|
44
73
|
|
|
45
74
|
// Mesh State
|
|
46
75
|
const [meshNodes, setMeshNodes] = useState<MeshNode[]>([]);
|
|
47
76
|
const [leader, setLeader] = useState<LeaderInfo | null>(null);
|
|
48
|
-
|
|
77
|
+
// Selection is held as a hostname, not a node object: polling replaces the
|
|
78
|
+
// node list every few seconds, and a captured object goes stale the moment it
|
|
79
|
+
// does — the detail pane would keep showing the state at the time of click,
|
|
80
|
+
// including right after an edit was saved.
|
|
81
|
+
const [selectedHostname, setSelectedHostname] = useState<string | null>(null);
|
|
82
|
+
const selectedNode = meshNodes.find((node) => node.hostname === selectedHostname) || null;
|
|
49
83
|
const [latencyMs, setLatencyMs] = useState<Record<string, number | null>>({});
|
|
50
84
|
/** Rolling RTT samples per hostname for sparklines (from this node). */
|
|
51
85
|
const [latencyHistory, setLatencyHistory] = useState<Record<string, number[]>>({});
|
|
@@ -58,8 +92,21 @@ export function MeshPage() {
|
|
|
58
92
|
const [enrollment, setEnrollment] = useState<EnrollmentStatus | null>(null);
|
|
59
93
|
const [enrollBusy, setEnrollBusy] = useState(false);
|
|
60
94
|
const [enrollMsg, setEnrollMsg] = useState<string | null>(null);
|
|
95
|
+
// Mesh SSH is a property of the control server, not of this node, so it is
|
|
96
|
+
// tracked separately from enrollment.
|
|
97
|
+
const [sshPolicy, setSshPolicy] = useState<{ configured: boolean; fileMode?: boolean } | null>(null);
|
|
98
|
+
const [sshBusy, setSshBusy] = useState(false);
|
|
99
|
+
const [sshMsg, setSshMsg] = useState<string | null>(null);
|
|
61
100
|
const [lanRegisterBusy, setLanRegisterBusy] = useState(false);
|
|
62
101
|
const [lanRegisterMsg, setLanRegisterMsg] = useState<string | null>(null);
|
|
102
|
+
// A node whose login account is unknown refuses every connection in exactly
|
|
103
|
+
// the way an unreachable node does, so the gap is surfaced, not just stored.
|
|
104
|
+
const [accessProposals, setAccessProposals] = useState<AccessProposal[]>([]);
|
|
105
|
+
const [accessBusy, setAccessBusy] = useState(false);
|
|
106
|
+
const [accessMsg, setAccessMsg] = useState<string | null>(null);
|
|
107
|
+
const [accessDraft, setAccessDraft] = useState<AccessDraft>(EMPTY_ACCESS_DRAFT);
|
|
108
|
+
const [accessSaveBusy, setAccessSaveBusy] = useState(false);
|
|
109
|
+
const [accessSaveMsg, setAccessSaveMsg] = useState<string | null>(null);
|
|
63
110
|
const prevLeaderRef = useRef<string | null>(null);
|
|
64
111
|
const pollMsRef = useRef(POLL_BASE_MS);
|
|
65
112
|
const electionHistoryLoadedRef = useRef(false);
|
|
@@ -78,6 +125,8 @@ export function MeshPage() {
|
|
|
78
125
|
const [newKeyEphemeral, setNewKeyEphemeral] = useState(false);
|
|
79
126
|
const [newKeyExpiration, setNewKeyExpiration] = useState('2099-12-31T23:59:59Z');
|
|
80
127
|
|
|
128
|
+
const nodesMissingAccess = meshNodes.filter((node) => node.access_resolved?.complete === false);
|
|
129
|
+
|
|
81
130
|
const recordLeaderChange = useCallback((l: LeaderInfo | null, note: string) => {
|
|
82
131
|
if (!l) return;
|
|
83
132
|
const key = `${l.hostname}|${l.ip}`;
|
|
@@ -94,6 +143,15 @@ export function MeshPage() {
|
|
|
94
143
|
}
|
|
95
144
|
}, []);
|
|
96
145
|
|
|
146
|
+
// Selecting is the only thing that may overwrite the edit form. Doing it in a
|
|
147
|
+
// poll-driven effect instead would wipe whatever the user was typing every
|
|
148
|
+
// few seconds.
|
|
149
|
+
const selectNode = useCallback((node: MeshNode) => {
|
|
150
|
+
setSelectedHostname(node.hostname);
|
|
151
|
+
setAccessDraft(accessDraftFromNode(node));
|
|
152
|
+
setAccessSaveMsg(null);
|
|
153
|
+
}, []);
|
|
154
|
+
|
|
97
155
|
const pushLatencySamples = useCallback((sample: Record<string, number | null>) => {
|
|
98
156
|
setLatencyMs(sample);
|
|
99
157
|
setLatencyHistory((prev) => {
|
|
@@ -158,10 +216,23 @@ export function MeshPage() {
|
|
|
158
216
|
setUiHints(ioRes.ui_hints || ioRes.io?.ui_hints || []);
|
|
159
217
|
})
|
|
160
218
|
.catch(() => { /* optional */ });
|
|
219
|
+
// Neither of these should ever take the page down — both are optional
|
|
220
|
+
// capabilities that may simply not be configured yet.
|
|
221
|
+
void (async () => {
|
|
222
|
+
try {
|
|
223
|
+
const p = await fetchHeadscalePolicy();
|
|
224
|
+
setSshPolicy({ configured: p.configured, fileMode: p.fileMode });
|
|
225
|
+
} catch {
|
|
226
|
+
setSshPolicy(null);
|
|
227
|
+
}
|
|
228
|
+
})();
|
|
161
229
|
// Enrollment state drives the join banner; never fail the page on it.
|
|
162
230
|
fetchEnrollmentStatus()
|
|
163
231
|
.then(setEnrollment)
|
|
164
232
|
.catch(() => { /* optional */ });
|
|
233
|
+
fetchAccessProposals()
|
|
234
|
+
.then((res) => setAccessProposals(res.proposals || []))
|
|
235
|
+
.catch(() => { /* no ssh config on this host is normal */ });
|
|
165
236
|
pollMsRef.current = POLL_BASE_MS;
|
|
166
237
|
} else if (activeTab === 'headscale-nodes') {
|
|
167
238
|
const nodes = await fetchHeadscaleNodes();
|
|
@@ -226,6 +297,55 @@ export function MeshPage() {
|
|
|
226
297
|
}
|
|
227
298
|
}
|
|
228
299
|
|
|
300
|
+
async function handleImportAccess() {
|
|
301
|
+
setAccessBusy(true);
|
|
302
|
+
setAccessMsg(null);
|
|
303
|
+
try {
|
|
304
|
+
const res = await importAccessFromSshConfig();
|
|
305
|
+
setAccessMsg(
|
|
306
|
+
res.failed
|
|
307
|
+
? `Recorded ${res.saved} of ${res.attempted}; ${res.failed} could not be saved.`
|
|
308
|
+
: `Recorded a login account for ${res.saved} node(s).`,
|
|
309
|
+
);
|
|
310
|
+
await loadData();
|
|
311
|
+
} catch (err) {
|
|
312
|
+
setAccessMsg(err instanceof Error ? err.message : 'Could not import from ssh config');
|
|
313
|
+
} finally {
|
|
314
|
+
setAccessBusy(false);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async function handleSaveAccess(e: React.FormEvent) {
|
|
319
|
+
e.preventDefault();
|
|
320
|
+
if (!selectedNode) return;
|
|
321
|
+
setAccessSaveBusy(true);
|
|
322
|
+
setAccessSaveMsg(null);
|
|
323
|
+
try {
|
|
324
|
+
await setNodeAccess({ node: selectedNode.hostname, ...accessDraft });
|
|
325
|
+
setAccessSaveMsg('Saved to the node entity.');
|
|
326
|
+
await loadData();
|
|
327
|
+
} catch (err) {
|
|
328
|
+
setAccessSaveMsg(err instanceof Error ? err.message : 'Could not save access');
|
|
329
|
+
} finally {
|
|
330
|
+
setAccessSaveBusy(false);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async function handleEnableMeshSsh() {
|
|
335
|
+
setSshBusy(true);
|
|
336
|
+
setSshMsg(null);
|
|
337
|
+
try {
|
|
338
|
+
await saveHeadscalePolicy(buildMeshSshPolicy());
|
|
339
|
+
const next = await fetchHeadscalePolicy();
|
|
340
|
+
setSshPolicy({ configured: next.configured, fileMode: next.fileMode });
|
|
341
|
+
setSshMsg('Mesh SSH enabled. Nodes pick this up automatically.');
|
|
342
|
+
} catch (err) {
|
|
343
|
+
setSshMsg(err instanceof Error ? err.message : 'Could not enable mesh SSH');
|
|
344
|
+
} finally {
|
|
345
|
+
setSshBusy(false);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
229
349
|
async function handleEnroll() {
|
|
230
350
|
setEnrollBusy(true);
|
|
231
351
|
setEnrollMsg(null);
|
|
@@ -360,6 +480,81 @@ export function MeshPage() {
|
|
|
360
480
|
|
|
361
481
|
{activeTab === 'mesh' && (
|
|
362
482
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
|
|
483
|
+
{/* Without a policy the control server routes packets but authorises
|
|
484
|
+
nothing, so every machine falls back to its own SSH keys. */}
|
|
485
|
+
{sshPolicy && !sshPolicy.configured && (
|
|
486
|
+
<div className="alert alert-warning" data-testid="mesh-ssh-banner">
|
|
487
|
+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 16, flexWrap: 'wrap' }}>
|
|
488
|
+
<div>
|
|
489
|
+
<strong>Mesh SSH is off</strong>
|
|
490
|
+
<div style={{ fontSize: 13, marginTop: 4 }}>
|
|
491
|
+
{sshPolicy.fileMode
|
|
492
|
+
? 'The control server stores its policy in a file, so it cannot be managed here. Set policy.mode: database in the control server config to enable this.'
|
|
493
|
+
: 'Nodes can reach each other, but each still needs its own SSH keys. Enabling mesh SSH lets the control server authorise connections instead.'}
|
|
494
|
+
</div>
|
|
495
|
+
{sshMsg && (
|
|
496
|
+
<div style={{ fontSize: 13, marginTop: 8, color: 'var(--accent-hover)' }}>{sshMsg}</div>
|
|
497
|
+
)}
|
|
498
|
+
</div>
|
|
499
|
+
{!sshPolicy.fileMode && (
|
|
500
|
+
<button type="button" className="btn btn-primary" onClick={handleEnableMeshSsh} disabled={sshBusy}>
|
|
501
|
+
{sshBusy ? 'Enabling…' : 'Enable mesh SSH'}
|
|
502
|
+
</button>
|
|
503
|
+
)}
|
|
504
|
+
</div>
|
|
505
|
+
</div>
|
|
506
|
+
)}
|
|
507
|
+
{sshPolicy?.configured && sshMsg && (
|
|
508
|
+
<div className="alert alert-success" data-testid="mesh-ssh-ok">{sshMsg}</div>
|
|
509
|
+
)}
|
|
510
|
+
{/* The control server can say a node is up but not which account you
|
|
511
|
+
log in as, and connecting as the wrong one is refused exactly like
|
|
512
|
+
an unreachable host — so the gap is stated rather than discovered. */}
|
|
513
|
+
{nodesMissingAccess.length > 0 && (
|
|
514
|
+
<div className="alert alert-warning" data-testid="mesh-access-banner">
|
|
515
|
+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 16, flexWrap: 'wrap' }}>
|
|
516
|
+
<div>
|
|
517
|
+
<strong>
|
|
518
|
+
{nodesMissingAccess.length === 1
|
|
519
|
+
? '1 node has no recorded login account'
|
|
520
|
+
: `${nodesMissingAccess.length} nodes have no recorded login account`}
|
|
521
|
+
</strong>
|
|
522
|
+
<div style={{ fontSize: 13, marginTop: 4 }}>
|
|
523
|
+
Any node that tries to reach one of these is refused, in exactly the way an unreachable
|
|
524
|
+
machine refuses. {accessProposals.length > 0
|
|
525
|
+
? `This host's ssh config can supply ${accessProposals.length} of them.`
|
|
526
|
+
: 'Select a node below to record one.'}
|
|
527
|
+
</div>
|
|
528
|
+
<div style={{ fontSize: 12, marginTop: 6, color: 'var(--text-secondary)' }}>
|
|
529
|
+
{nodesMissingAccess.map((node) => node.hostname).join(', ')}
|
|
530
|
+
</div>
|
|
531
|
+
{accessProposals.length > 0 && (
|
|
532
|
+
<ul style={{ margin: '8px 0 0', paddingLeft: 18, fontSize: 12 }} data-testid="access-proposals">
|
|
533
|
+
{accessProposals.map((proposal) => (
|
|
534
|
+
<li key={proposal.hostname}>
|
|
535
|
+
<strong>{proposal.hostname}</strong> → {proposal.access.ssh_user}{' '}
|
|
536
|
+
<span style={{ color: 'var(--text-secondary)' }}>(from Host {proposal.matched_host})</span>
|
|
537
|
+
</li>
|
|
538
|
+
))}
|
|
539
|
+
</ul>
|
|
540
|
+
)}
|
|
541
|
+
{accessMsg && (
|
|
542
|
+
<div style={{ fontSize: 13, marginTop: 8, color: 'var(--accent-hover)' }}>{accessMsg}</div>
|
|
543
|
+
)}
|
|
544
|
+
</div>
|
|
545
|
+
{accessProposals.length > 0 && (
|
|
546
|
+
<button
|
|
547
|
+
type="button"
|
|
548
|
+
className="btn btn-primary"
|
|
549
|
+
onClick={handleImportAccess}
|
|
550
|
+
disabled={accessBusy}
|
|
551
|
+
>
|
|
552
|
+
{accessBusy ? 'Importing…' : 'Import from ssh config'}
|
|
553
|
+
</button>
|
|
554
|
+
)}
|
|
555
|
+
</div>
|
|
556
|
+
</div>
|
|
557
|
+
)}
|
|
363
558
|
{/* Which mesh node actually serves embeddings, and which model it uses. */}
|
|
364
559
|
<div className="card">
|
|
365
560
|
<EmbeddingProviderPanel />
|
|
@@ -371,8 +566,8 @@ export function MeshPage() {
|
|
|
371
566
|
nodes={meshNodes}
|
|
372
567
|
leader={leader}
|
|
373
568
|
latencyMs={latencyMs}
|
|
374
|
-
selectedHostname={
|
|
375
|
-
onSelectNode={
|
|
569
|
+
selectedHostname={selectedHostname}
|
|
570
|
+
onSelectNode={selectNode}
|
|
376
571
|
/>
|
|
377
572
|
</div>
|
|
378
573
|
<div className="card">
|
|
@@ -455,10 +650,92 @@ export function MeshPage() {
|
|
|
455
650
|
</dd>
|
|
456
651
|
<dt style={{ color: 'var(--text-secondary)' }}>Scope</dt>
|
|
457
652
|
<dd style={{ margin: 0 }}>{selectedNode.self ? 'This node' : 'Peer'}</dd>
|
|
653
|
+
<dt style={{ color: 'var(--text-secondary)' }}>Login</dt>
|
|
654
|
+
<dd style={{ margin: 0, fontFamily: 'monospace', fontSize: 12 }} data-testid="node-access-target">
|
|
655
|
+
{selectedNode.access_resolved?.complete
|
|
656
|
+
? selectedNode.access_resolved.target
|
|
657
|
+
: <span className="badge badge-offline">no login recorded</span>}
|
|
658
|
+
</dd>
|
|
659
|
+
<dt style={{ color: 'var(--text-secondary)' }}>Access source</dt>
|
|
660
|
+
<dd style={{ margin: 0 }}>
|
|
661
|
+
{selectedNode.access?.source && selectedNode.access.source !== 'unknown'
|
|
662
|
+
? selectedNode.access.source.replace('_', ' ')
|
|
663
|
+
: '—'}
|
|
664
|
+
</dd>
|
|
665
|
+
{/* Only the open-source build can serve control-server-authorised
|
|
666
|
+
SSH; the sandboxed GUI builds never start the server, so a
|
|
667
|
+
node running one still needs its own keys. */}
|
|
668
|
+
<dt style={{ color: 'var(--text-secondary)' }}>Mesh SSH</dt>
|
|
669
|
+
<dd style={{ margin: 0 }} data-testid="node-mesh-ssh">
|
|
670
|
+
{selectedNode.access_resolved?.mesh_ssh === 'available'
|
|
671
|
+
? 'authorised by the control server'
|
|
672
|
+
: selectedNode.access_resolved?.mesh_ssh === 'unsupported'
|
|
673
|
+
? `not served by this build (${selectedNode.access_resolved.tailscale_variant}) — needs its own keys`
|
|
674
|
+
: 'unknown'}
|
|
675
|
+
</dd>
|
|
458
676
|
</dl>
|
|
459
677
|
) : (
|
|
460
678
|
<p style={{ color: 'var(--text-secondary)' }}>Click a node in the topology to inspect it.</p>
|
|
461
679
|
)}
|
|
680
|
+
|
|
681
|
+
{selectedNode && (
|
|
682
|
+
<form onSubmit={handleSaveAccess} style={{ marginTop: 16, borderTop: '1px solid var(--border)', paddingTop: 12 }}>
|
|
683
|
+
<h4 style={{ margin: '0 0 4px' }}>Access</h4>
|
|
684
|
+
<p style={{ fontSize: 12, color: 'var(--text-secondary)', margin: '0 0 12px' }}>
|
|
685
|
+
Stored on the node entity, so every machine and agent resolves it the same way.
|
|
686
|
+
Leave a box empty to clear it. The address comes from the mesh unless you override it.
|
|
687
|
+
</p>
|
|
688
|
+
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
|
|
689
|
+
<label style={{ fontSize: 12 }}>
|
|
690
|
+
Login account
|
|
691
|
+
<input
|
|
692
|
+
className="input"
|
|
693
|
+
value={accessDraft.ssh_user}
|
|
694
|
+
placeholder="e.g. the account you ssh as"
|
|
695
|
+
onChange={(e) => setAccessDraft({ ...accessDraft, ssh_user: e.target.value })}
|
|
696
|
+
/>
|
|
697
|
+
</label>
|
|
698
|
+
<label style={{ fontSize: 12 }}>
|
|
699
|
+
Port
|
|
700
|
+
<input
|
|
701
|
+
className="input"
|
|
702
|
+
value={accessDraft.ssh_port}
|
|
703
|
+
placeholder="22"
|
|
704
|
+
inputMode="numeric"
|
|
705
|
+
onChange={(e) => setAccessDraft({ ...accessDraft, ssh_port: e.target.value })}
|
|
706
|
+
/>
|
|
707
|
+
</label>
|
|
708
|
+
<label style={{ fontSize: 12 }}>
|
|
709
|
+
Key file
|
|
710
|
+
<input
|
|
711
|
+
className="input"
|
|
712
|
+
value={accessDraft.identity_file}
|
|
713
|
+
placeholder="~/.ssh/id_ed25519"
|
|
714
|
+
onChange={(e) => setAccessDraft({ ...accessDraft, identity_file: e.target.value })}
|
|
715
|
+
/>
|
|
716
|
+
</label>
|
|
717
|
+
<label style={{ fontSize: 12 }}>
|
|
718
|
+
Address override
|
|
719
|
+
<input
|
|
720
|
+
className="input"
|
|
721
|
+
value={accessDraft.ssh_host}
|
|
722
|
+
placeholder={selectedNode.ip || 'mesh address'}
|
|
723
|
+
onChange={(e) => setAccessDraft({ ...accessDraft, ssh_host: e.target.value })}
|
|
724
|
+
/>
|
|
725
|
+
</label>
|
|
726
|
+
</div>
|
|
727
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginTop: 12 }}>
|
|
728
|
+
<button type="submit" className="btn btn-primary btn-sm" disabled={accessSaveBusy}>
|
|
729
|
+
{accessSaveBusy ? 'Saving…' : 'Save access'}
|
|
730
|
+
</button>
|
|
731
|
+
{accessSaveMsg && (
|
|
732
|
+
<span style={{ fontSize: 12, color: 'var(--accent-hover)' }} data-testid="access-save-msg">
|
|
733
|
+
{accessSaveMsg}
|
|
734
|
+
</span>
|
|
735
|
+
)}
|
|
736
|
+
</div>
|
|
737
|
+
</form>
|
|
738
|
+
)}
|
|
462
739
|
</div>
|
|
463
740
|
</div>
|
|
464
741
|
|
|
@@ -608,6 +885,7 @@ export function MeshPage() {
|
|
|
608
885
|
<th>Role</th>
|
|
609
886
|
<th>Status</th>
|
|
610
887
|
<th>Latency</th>
|
|
888
|
+
<th>Login</th>
|
|
611
889
|
<th>Operating System</th>
|
|
612
890
|
<th>Node</th>
|
|
613
891
|
</tr>
|
|
@@ -618,8 +896,9 @@ export function MeshPage() {
|
|
|
618
896
|
return (
|
|
619
897
|
<tr
|
|
620
898
|
key={node.hostname}
|
|
899
|
+
data-testid={`node-row-${node.hostname}`}
|
|
621
900
|
className={isNodeLeader ? 'is-leader' : ''}
|
|
622
|
-
onClick={() =>
|
|
901
|
+
onClick={() => selectNode(node)}
|
|
623
902
|
style={{ cursor: 'pointer' }}
|
|
624
903
|
>
|
|
625
904
|
<td style={{ fontWeight: 500, display: 'flex', alignItems: 'center', gap: '8px' }}>
|
|
@@ -640,6 +919,15 @@ export function MeshPage() {
|
|
|
640
919
|
<td style={{ color: 'var(--text-secondary)' }}>
|
|
641
920
|
{node.self ? '—' : latencyMs[node.hostname] != null ? `${latencyMs[node.hostname]} ms` : '—'}
|
|
642
921
|
</td>
|
|
922
|
+
<td style={{ fontFamily: 'monospace', fontSize: 12 }}>
|
|
923
|
+
{node.access_resolved?.complete ? (
|
|
924
|
+
node.access_resolved.target
|
|
925
|
+
) : (
|
|
926
|
+
<span className="badge badge-offline" title="Connecting will fail as though the node were unreachable">
|
|
927
|
+
no login
|
|
928
|
+
</span>
|
|
929
|
+
)}
|
|
930
|
+
</td>
|
|
643
931
|
<td style={{ color: 'var(--text-secondary)' }}>{node.os || 'Unknown'}</td>
|
|
644
932
|
<td style={{ color: 'var(--text-secondary)', fontSize: '13px' }}>{node.self ? 'This node' : 'Peer'}</td>
|
|
645
933
|
</tr>
|
|
@@ -647,7 +935,7 @@ export function MeshPage() {
|
|
|
647
935
|
})}
|
|
648
936
|
{meshNodes.length === 0 && !loading && (
|
|
649
937
|
<tr>
|
|
650
|
-
<td colSpan={
|
|
938
|
+
<td colSpan={8} style={{ textAlign: 'center', color: 'var(--text-secondary)', padding: '32px' }}>
|
|
651
939
|
No mesh nodes found
|
|
652
940
|
</td>
|
|
653
941
|
</tr>
|
|
@@ -34,12 +34,27 @@ describe('NetworkPage', () => {
|
|
|
34
34
|
beforeEach(() => {
|
|
35
35
|
vi.clearAllMocks();
|
|
36
36
|
vi.mocked(getNetworkStats).mockResolvedValue({
|
|
37
|
-
stats: {
|
|
38
|
-
|
|
37
|
+
stats: {
|
|
38
|
+
total: 2,
|
|
39
|
+
blocked: 0,
|
|
40
|
+
queueLength: 0,
|
|
41
|
+
active: 2,
|
|
42
|
+
completed: 0,
|
|
43
|
+
errors: 0,
|
|
44
|
+
timeouts: 0,
|
|
45
|
+
peakActive: 2,
|
|
46
|
+
peakQueue: 0,
|
|
47
|
+
domains: {},
|
|
48
|
+
},
|
|
49
|
+
audit_count: 0,
|
|
50
|
+
});
|
|
39
51
|
vi.mocked(getNetworkPolicy).mockResolvedValue({
|
|
52
|
+
id: 'test-policy',
|
|
40
53
|
blocked_domains: ['evil.com'],
|
|
41
|
-
max_global_concurrency: 20
|
|
42
|
-
|
|
54
|
+
max_global_concurrency: 20,
|
|
55
|
+
max_per_domain_concurrency: 4,
|
|
56
|
+
default_timeout_ms: 10000,
|
|
57
|
+
});
|
|
43
58
|
vi.mocked(getAuditLog).mockResolvedValue({
|
|
44
59
|
audit: []
|
|
45
60
|
});
|
|
@@ -44,8 +44,20 @@ describe('OnboardingPage', () => {
|
|
|
44
44
|
it('handles scan import mode', async () => {
|
|
45
45
|
vi.mocked(api.listMemory).mockResolvedValue([] as never);
|
|
46
46
|
vi.mocked(api.scanEnvSecrets).mockResolvedValue({
|
|
47
|
-
candidates: [
|
|
48
|
-
|
|
47
|
+
candidates: [
|
|
48
|
+
{
|
|
49
|
+
key: 'TEST_KEY',
|
|
50
|
+
provider: 'test',
|
|
51
|
+
masked: 'sk-***',
|
|
52
|
+
source_label: '.env',
|
|
53
|
+
source: '.env',
|
|
54
|
+
length: 12,
|
|
55
|
+
known: true,
|
|
56
|
+
},
|
|
57
|
+
],
|
|
58
|
+
count: 1,
|
|
59
|
+
sources_scanned: ['.env'],
|
|
60
|
+
});
|
|
49
61
|
|
|
50
62
|
renderWithRouter(<OnboardingPage />);
|
|
51
63
|
|
|
@@ -9,7 +9,7 @@ describe('SkillsPage', () => {
|
|
|
9
9
|
beforeEach(() => {
|
|
10
10
|
vi.resetAllMocks();
|
|
11
11
|
vi.mocked(api.listSkills).mockResolvedValue([
|
|
12
|
-
{
|
|
12
|
+
{ id: 'test-skill', name: 'test-skill', repo: 'test', source: 'A test skill' },
|
|
13
13
|
]);
|
|
14
14
|
});
|
|
15
15
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "total-recall-brain",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.23.1",
|
|
4
4
|
"description": "Personal AI system — database-free, SSSS-powered intelligence engine deployable to any POSIX host.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"workspaces": [
|
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
"dev": "node src/server/index.mjs",
|
|
40
40
|
"start": "node src/server/index.mjs",
|
|
41
41
|
"test": "vitest run",
|
|
42
|
+
"routes:manifest": "node scripts/generate-route-manifest.mjs",
|
|
42
43
|
"clean": "rm -rf scratch/* .remember/* *.log"
|
|
43
44
|
},
|
|
44
45
|
"repository": {
|
|
@@ -23,7 +23,8 @@ Rebuild the derived indexes and compile memory surfaces.
|
|
|
23
23
|
### 1.3 `connect`
|
|
24
24
|
Configure an IDE or external client to bind to the Total Recall local server.
|
|
25
25
|
* **Syntax**: `npx total-recall connect <client> [options]`
|
|
26
|
-
* **Supported Clients**: `cursor`, `claude-code`, `
|
|
26
|
+
* **Supported Clients**: `vscode`, `pi`, `hermes`, `openclaw`, `cursor`, `cline`, `claude-code`, `codex`, `antigravity`, `gemini`, `aider`, `http-api`, `obsidian`, `generic`.
|
|
27
|
+
Any host application not listed here connects through the generic `http-api` client.
|
|
27
28
|
* **Options**:
|
|
28
29
|
* `--brain <url>`: Override default brain server URL.
|
|
29
30
|
* `--token <PAT>`: Inject bearer PAT credentials for authenticating requests.
|