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
@@ -81,6 +81,7 @@ const COMMANDS = {
81
81
  relay: 'relay.mjs',
82
82
  uninstall: 'uninstall.mjs',
83
83
  map: 'map.mjs',
84
+ mesh: 'mesh.mjs',
84
85
  brain: 'brain.mjs',
85
86
  config: 'config.mjs',
86
87
  skill: 'skill.mjs',
@@ -109,6 +110,7 @@ function printHelp() {
109
110
  daemon <cmd> Background worker (start|stop|status)
110
111
  skill <cmd> Skills registry, deploy, multi-repo sync
111
112
  secret <cmd> Secrets store + usage (not the vault)
113
+ mesh <cmd> Control-server mesh: nodes, ACL policy, Tailscale SSH
112
114
  brain <cmd> Register / ensure any project brain
113
115
  status / doctor Health and diagnostics
114
116
 
@@ -1,4 +1,4 @@
1
- import { get, post, del } from './_base';
1
+ import { get, post, del, apiFetch } from './_base';
2
2
 
3
3
  export interface HeadscaleNode {
4
4
  id: string;
@@ -69,3 +69,70 @@ export async function fetchHeadscaleUsers(): Promise<HeadscaleUser[]> {
69
69
  const data = await get<UsersResponse>('/api/headscale/user');
70
70
  return data.users || [];
71
71
  }
72
+
73
+ export interface HeadscalePolicy {
74
+ policy: string | null;
75
+ updatedAt: string | null;
76
+ configured: boolean;
77
+ /** True when the server is in database mode but no policy has been written yet. */
78
+ unset?: boolean;
79
+ }
80
+
81
+ /**
82
+ * Read the control server's ACL policy.
83
+ *
84
+ * A 409 means the server is in `policy.mode: file`, where the policy lives on
85
+ * the server's disk and the API cannot manage it. That is a fixable
86
+ * configuration state rather than a failure, so it is surfaced as a typed
87
+ * result the UI can explain instead of a thrown error.
88
+ */
89
+ export async function fetchHeadscalePolicy(): Promise<HeadscalePolicy & { fileMode?: boolean }> {
90
+ try {
91
+ return await get<HeadscalePolicy>('/api/headscale/policy');
92
+ } catch (err) {
93
+ const status = (err as { status?: number })?.status;
94
+ if (status === 409) {
95
+ return { policy: null, updatedAt: null, configured: false, fileMode: true };
96
+ }
97
+ throw err;
98
+ }
99
+ }
100
+
101
+ export async function saveHeadscalePolicy(policy: string): Promise<HeadscalePolicy> {
102
+ const res = await apiFetch('/api/headscale/policy', {
103
+ method: 'PUT',
104
+ headers: { 'Content-Type': 'application/json' },
105
+ body: JSON.stringify({ policy }),
106
+ });
107
+ if (!res.ok) {
108
+ const detail = await res.json().catch(() => ({}));
109
+ const error = new Error((detail as { error?: string }).error || `Policy save failed (${res.status})`);
110
+ (error as { status?: number }).status = res.status;
111
+ throw error;
112
+ }
113
+ return res.json();
114
+ }
115
+
116
+ /**
117
+ * The single-operator policy: every mesh member reaches every member, and may
118
+ * SSH between them. Mesh membership is the trust boundary, so anything admitted
119
+ * to the tailnet inherits this — narrow it before admitting a node you do not
120
+ * control.
121
+ */
122
+ export function buildMeshSshPolicy(allowRoot = false): string {
123
+ return JSON.stringify(
124
+ {
125
+ acls: [{ action: 'accept', src: ['*'], dst: ['*:*'] }],
126
+ ssh: [
127
+ {
128
+ action: 'accept',
129
+ src: ['autogroup:member'],
130
+ dst: ['autogroup:self'],
131
+ users: allowRoot ? ['autogroup:nonroot', 'root'] : ['autogroup:nonroot'],
132
+ },
133
+ ],
134
+ },
135
+ null,
136
+ 2,
137
+ );
138
+ }
@@ -5,6 +5,9 @@ import {
5
5
  refreshElection,
6
6
  fetchElectionHistory,
7
7
  logElectionObservation,
8
+ fetchAccessProposals,
9
+ importAccessFromSshConfig,
10
+ setNodeAccess,
8
11
  } from './mesh';
9
12
  import * as base from './_base';
10
13
 
@@ -54,4 +57,28 @@ describe('mesh api', () => {
54
57
  note: 'observed',
55
58
  });
56
59
  });
60
+
61
+ it('fetchAccessProposals calls GET /api/mesh/access/proposals', async () => {
62
+ vi.mocked(base.get).mockResolvedValue({ proposals: [], missing_access: [], checked_at: 't' });
63
+ await fetchAccessProposals();
64
+ expect(base.get).toHaveBeenCalledWith('/api/mesh/access/proposals');
65
+ });
66
+
67
+ // Proposing and applying are separate calls on purpose: a wrong login written
68
+ // to the vault is worse than none, because it then looks authored.
69
+ it('importAccessFromSshConfig calls POST /api/mesh/access/import', async () => {
70
+ vi.mocked(base.post).mockResolvedValue({ success: true, attempted: 0, saved: 0, failed: 0, results: [] });
71
+ await importAccessFromSshConfig();
72
+ expect(base.post).toHaveBeenCalledWith('/api/mesh/access/import', {});
73
+ });
74
+
75
+ it('setNodeAccess posts the fields it was given, so an empty one can clear', async () => {
76
+ vi.mocked(base.post).mockResolvedValue({ success: true, path: 'p', access: {} });
77
+ await setNodeAccess({ node: 'node-b.mesh', ssh_user: 'operator', identity_file: '' });
78
+ expect(base.post).toHaveBeenCalledWith('/api/mesh/access', {
79
+ node: 'node-b.mesh',
80
+ ssh_user: 'operator',
81
+ identity_file: '',
82
+ });
83
+ });
57
84
  });
@@ -8,6 +8,40 @@ export interface MeshInterfaceSummary {
8
8
  ipv6?: string[];
9
9
  }
10
10
 
11
+ export type TailscaleVariant = 'daemon' | 'sandboxed' | 'missing' | 'unknown';
12
+ export type MeshSshCapability = 'available' | 'unsupported' | 'unknown';
13
+ export type AccessSource = 'probe' | 'ssh_config' | 'manual' | 'unknown';
14
+
15
+ /**
16
+ * How to log in to a node — stored on the node entity, because the control
17
+ * server knows a machine exists but not which account you reach it as.
18
+ */
19
+ export interface MeshNodeAccess {
20
+ ssh_user?: string | null;
21
+ ssh_port?: number | null;
22
+ ssh_host?: string | null;
23
+ identity_file?: string | null;
24
+ tailscale_variant?: TailscaleVariant;
25
+ mesh_ssh?: MeshSshCapability;
26
+ source?: AccessSource;
27
+ verified_at?: string | null;
28
+ }
29
+
30
+ /** Server-resolved connection details; address precedence is decided there. */
31
+ export interface ResolvedNodeAccess {
32
+ user: string | null;
33
+ host: string | null;
34
+ port: number;
35
+ identity_file: string | null;
36
+ mesh_ssh: MeshSshCapability;
37
+ tailscale_variant: TailscaleVariant;
38
+ source: AccessSource;
39
+ verified_at: string | null;
40
+ /** False when the login account is unknown — the node will refuse you. */
41
+ complete: boolean;
42
+ target: string | null;
43
+ }
44
+
11
45
  export interface MeshNode {
12
46
  hostname: string;
13
47
  ip: string | null;
@@ -31,6 +65,9 @@ export interface MeshNode {
31
65
  /** Device I/O profile for agent UI generation (screen/touch/mic/…). */
32
66
  io?: DeviceIoProfile | null;
33
67
  ui_hints?: string[];
68
+ /** Raw access record from the vault entity (absent until one is recorded). */
69
+ access?: MeshNodeAccess | null;
70
+ access_resolved?: ResolvedNodeAccess;
34
71
  }
35
72
 
36
73
  export interface DeviceIoProfile {
@@ -79,6 +116,54 @@ export async function fetchNodes(): Promise<MeshNode[]> {
79
116
  return data.nodes;
80
117
  }
81
118
 
119
+ export interface AccessProposal {
120
+ hostname: string;
121
+ ip: string | null;
122
+ /** The `Host` block the login was taken from, so the guess is auditable. */
123
+ matched_host: string;
124
+ access: MeshNodeAccess;
125
+ }
126
+
127
+ /** Logins this host's ssh config could supply for nodes that have none. */
128
+ export async function fetchAccessProposals(): Promise<{
129
+ proposals: AccessProposal[];
130
+ missing_access: string[];
131
+ checked_at: string;
132
+ }> {
133
+ return get('/api/mesh/access/proposals');
134
+ }
135
+
136
+ /** Apply every ssh-config proposal to its node entity. */
137
+ export async function importAccessFromSshConfig(): Promise<{
138
+ success: boolean;
139
+ attempted: number;
140
+ saved: number;
141
+ failed: number;
142
+ results: Array<{
143
+ hostname: string;
144
+ ssh_user: string | null;
145
+ matched_host: string;
146
+ written: boolean;
147
+ reason: string | null;
148
+ }>;
149
+ }> {
150
+ return post('/api/mesh/access/import', {});
151
+ }
152
+
153
+ /**
154
+ * Record how to reach a node. Omit a field to leave it alone; send it empty to
155
+ * clear it.
156
+ */
157
+ export async function setNodeAccess(body: {
158
+ node: string;
159
+ ssh_user?: string;
160
+ ssh_port?: string | number;
161
+ ssh_host?: string;
162
+ identity_file?: string;
163
+ }): Promise<{ success: boolean; path: string; access: MeshNodeAccess }> {
164
+ return post('/api/mesh/access', body);
165
+ }
166
+
82
167
  export async function refreshElection(): Promise<LeaderInfo> {
83
168
  const data = await post<{ leader: LeaderInfo | null }>('/api/mesh/election/refresh', {});
84
169
  if (!data.leader) throw new Error('No online mesh leader is available');
@@ -29,17 +29,35 @@ export function EmbeddingProviderPanel() {
29
29
  const [error, setError] = useState<string | null>(null)
30
30
  const [busy, setBusy] = useState(false)
31
31
 
32
- const load = useCallback(async () => {
32
+ /**
33
+ * `shouldApply` lets the mount effect drop a response that arrived after the
34
+ * panel went away, or after a newer request superseded it — a fetch started
35
+ * on mount has no other way to know it is no longer the current one.
36
+ */
37
+ const load = useCallback(async (shouldApply: () => boolean = () => true) => {
33
38
  try {
34
- setStatus(await fetchEmbeddingProvider())
39
+ const next = await fetchEmbeddingProvider()
40
+ if (!shouldApply()) return
41
+ setStatus(next)
35
42
  setError(null)
36
43
  } catch (err) {
44
+ if (!shouldApply()) return
37
45
  setError(err instanceof Error ? err.message : String(err))
38
46
  }
39
47
  }, [])
40
48
 
41
49
  useEffect(() => {
42
- void load()
50
+ let active = true
51
+ // Deferred a tick rather than called from the effect body: loading straight
52
+ // from an effect trips react-hooks/set-state-in-effect, and this is how the
53
+ // rest of the app loads on mount (see BrainSelector).
54
+ const timer = setTimeout(() => {
55
+ void load(() => active)
56
+ }, 0)
57
+ return () => {
58
+ active = false
59
+ clearTimeout(timer)
60
+ }
43
61
  }, [load])
44
62
 
45
63
  const onPin = useCallback(
@@ -8,6 +8,9 @@ import {
8
8
  refreshElection,
9
9
  fetchEnrollmentStatus,
10
10
  enrollThisNode,
11
+ fetchAccessProposals,
12
+ importAccessFromSshConfig,
13
+ setNodeAccess,
11
14
  } from '../api/mesh';
12
15
  import { fetchHeadscaleNodes, deleteHeadscaleNode, fetchPreAuthKeys, createPreAuthKey, fetchHeadscaleUsers } from '../api/headscale';
13
16
 
@@ -68,6 +71,15 @@ vi.mock('../api/mesh', () => ({
68
71
  checked_at: new Date().toISOString(),
69
72
  }),
70
73
  enrollThisNode: vi.fn(),
74
+ // Default to a host with no ssh config to import from, so the access banner
75
+ // stays out of the way of tests asserting on other things.
76
+ fetchAccessProposals: vi.fn().mockResolvedValue({
77
+ proposals: [],
78
+ missing_access: [],
79
+ checked_at: new Date().toISOString(),
80
+ }),
81
+ importAccessFromSshConfig: vi.fn(),
82
+ setNodeAccess: vi.fn(),
71
83
  }));
72
84
 
73
85
  vi.mock('../api/headscale', () => ({
@@ -76,6 +88,11 @@ vi.mock('../api/headscale', () => ({
76
88
  fetchPreAuthKeys: vi.fn(),
77
89
  createPreAuthKey: vi.fn(),
78
90
  fetchHeadscaleUsers: vi.fn(),
91
+ // Mesh SSH policy: default to "already configured" so the banner stays out of
92
+ // the way of tests that are asserting on other things.
93
+ fetchHeadscalePolicy: vi.fn().mockResolvedValue({ policy: '{}', configured: true }),
94
+ saveHeadscalePolicy: vi.fn().mockResolvedValue({ policy: '{}', configured: true }),
95
+ buildMeshSshPolicy: vi.fn(() => '{}'),
79
96
  }));
80
97
 
81
98
  describe('MeshPage', () => {
@@ -283,4 +300,151 @@ describe('MeshPage', () => {
283
300
  expect(screen.queryByRole('button', { name: /enroll this node/i })).not.toBeInTheDocument();
284
301
  });
285
302
  });
303
+
304
+ // A node reached with the wrong login refuses the connection in exactly the
305
+ // way an unreachable node does. The page has to say so, or the operator
306
+ // debugs the network instead of the one missing fact.
307
+ describe('node access', () => {
308
+ const reachable = {
309
+ hostname: 'node-a.mesh',
310
+ ip: '100.64.0.1',
311
+ online: true,
312
+ self: false,
313
+ os: 'linux',
314
+ access: { ssh_user: 'operator', source: 'ssh_config' as const },
315
+ access_resolved: {
316
+ user: 'operator',
317
+ host: '100.64.0.1',
318
+ port: 22,
319
+ identity_file: null,
320
+ mesh_ssh: 'unknown' as const,
321
+ tailscale_variant: 'unknown' as const,
322
+ source: 'ssh_config' as const,
323
+ verified_at: null,
324
+ complete: true,
325
+ target: 'operator@100.64.0.1',
326
+ },
327
+ };
328
+ const noLogin = {
329
+ hostname: 'node-b.mesh',
330
+ ip: '100.64.0.2',
331
+ online: true,
332
+ self: false,
333
+ os: 'linux',
334
+ access_resolved: { ...reachable.access_resolved, user: null, host: '100.64.0.2', source: 'unknown' as const, complete: false, target: null },
335
+ };
336
+
337
+ beforeEach(() => {
338
+ vi.mocked(fetchLeader).mockResolvedValue({ hostname: 'node-a.mesh', ip: '100.64.0.1' });
339
+ });
340
+
341
+ it('shows the resolved login target for a node that has one', async () => {
342
+ vi.mocked(fetchMeshNodes).mockResolvedValue([reachable]);
343
+ render(<MeshPage />);
344
+ await waitFor(() => expect(screen.getAllByText('operator@100.64.0.1').length).toBeGreaterThan(0));
345
+ expect(screen.queryByTestId('mesh-access-banner')).not.toBeInTheDocument();
346
+ });
347
+
348
+ it('warns about nodes with no recorded login account', async () => {
349
+ vi.mocked(fetchMeshNodes).mockResolvedValue([reachable, noLogin]);
350
+ render(<MeshPage />);
351
+ const banner = await screen.findByTestId('mesh-access-banner');
352
+ expect(banner).toHaveTextContent('1 node has no recorded login account');
353
+ expect(banner).toHaveTextContent('node-b.mesh');
354
+ // Nothing to import from: the button must not appear and imply otherwise.
355
+ expect(screen.queryByRole('button', { name: /import from ssh config/i })).not.toBeInTheDocument();
356
+ });
357
+
358
+ it('offers the ssh config as a source and imports it on click', async () => {
359
+ vi.mocked(fetchMeshNodes).mockResolvedValue([noLogin]);
360
+ vi.mocked(fetchAccessProposals).mockResolvedValue({
361
+ proposals: [
362
+ { hostname: 'node-b.mesh', ip: '100.64.0.2', matched_host: 'node-b', access: { ssh_user: 'operator', source: 'ssh_config' } },
363
+ ],
364
+ missing_access: ['node-b.mesh'],
365
+ checked_at: new Date().toISOString(),
366
+ });
367
+ vi.mocked(importAccessFromSshConfig).mockResolvedValue({
368
+ success: true, attempted: 1, saved: 1, failed: 0,
369
+ results: [{ hostname: 'node-b.mesh', ssh_user: 'operator', matched_host: 'node-b', written: true, reason: null }],
370
+ });
371
+
372
+ render(<MeshPage />);
373
+ // The proposal is shown before it is applied — a wrong login written to
374
+ // the vault is worse than none, because it looks authored.
375
+ const proposals = await screen.findByTestId('access-proposals');
376
+ expect(proposals).toHaveTextContent('from Host node-b');
377
+
378
+ await userEvent.click(screen.getByRole('button', { name: /import from ssh config/i }));
379
+ await waitFor(() => expect(importAccessFromSshConfig).toHaveBeenCalled());
380
+ expect(await screen.findByText(/Recorded a login account for 1 node/)).toBeInTheDocument();
381
+ });
382
+
383
+ it('reports a partial import honestly rather than as a success', async () => {
384
+ vi.mocked(fetchMeshNodes).mockResolvedValue([noLogin]);
385
+ vi.mocked(fetchAccessProposals).mockResolvedValue({
386
+ proposals: [
387
+ { hostname: 'node-b.mesh', ip: '100.64.0.2', matched_host: 'node-b', access: { ssh_user: 'operator', source: 'ssh_config' } },
388
+ ],
389
+ missing_access: ['node-b.mesh'],
390
+ checked_at: new Date().toISOString(),
391
+ });
392
+ vi.mocked(importAccessFromSshConfig).mockResolvedValue({
393
+ success: false, attempted: 2, saved: 1, failed: 1,
394
+ results: [],
395
+ });
396
+
397
+ render(<MeshPage />);
398
+ await userEvent.click(await screen.findByRole('button', { name: /import from ssh config/i }));
399
+ expect(await screen.findByText(/Recorded 1 of 2; 1 could not be saved/)).toBeInTheDocument();
400
+ });
401
+
402
+ it('records a login typed for the selected node', async () => {
403
+ vi.mocked(fetchMeshNodes).mockResolvedValue([noLogin]);
404
+ vi.mocked(setNodeAccess).mockResolvedValue({
405
+ success: true,
406
+ path: 'system/mesh-nodes/node-b.md',
407
+ access: { ssh_user: 'operator' },
408
+ });
409
+
410
+ render(<MeshPage />);
411
+ await userEvent.click(await screen.findByTestId('node-row-node-b.mesh'));
412
+
413
+ await userEvent.type(screen.getByLabelText(/login account/i), 'operator');
414
+ await userEvent.type(screen.getByLabelText(/^port$/i), '2222');
415
+ await userEvent.click(screen.getByRole('button', { name: /save access/i }));
416
+
417
+ await waitFor(() =>
418
+ expect(setNodeAccess).toHaveBeenCalledWith({
419
+ node: 'node-b.mesh',
420
+ ssh_user: 'operator',
421
+ ssh_port: '2222',
422
+ identity_file: '',
423
+ ssh_host: '',
424
+ }),
425
+ );
426
+ expect(await screen.findByTestId('access-save-msg')).toHaveTextContent('Saved to the node entity');
427
+ });
428
+
429
+ // Polling replaces the node list on every cycle; a captured node object
430
+ // would keep the pane on the state at the moment of the click — including
431
+ // straight after an edit, where it would show the value it just replaced.
432
+ it('keeps the detail pane on live data after a reload', async () => {
433
+ vi.mocked(refreshElection).mockResolvedValue({ hostname: 'node-a.mesh', ip: '100.64.0.1' });
434
+ vi.mocked(fetchMeshNodes)
435
+ .mockResolvedValueOnce([noLogin])
436
+ .mockResolvedValue([
437
+ { ...noLogin, access: { ssh_user: 'operator' }, access_resolved: reachable.access_resolved },
438
+ ]);
439
+
440
+ render(<MeshPage />);
441
+ await userEvent.click(await screen.findByTestId('node-row-node-b.mesh'));
442
+ expect(await screen.findByTestId('node-access-target')).toHaveTextContent('no login recorded');
443
+
444
+ await userEvent.click(screen.getByText('Refresh Election'));
445
+ await waitFor(() =>
446
+ expect(screen.getByTestId('node-access-target')).toHaveTextContent('operator@100.64.0.1'),
447
+ );
448
+ });
449
+ });
286
450
  });