total-recall-brain 3.24.2 → 3.25.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.
@@ -17,7 +17,7 @@
17
17
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
18
18
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
19
19
  <title>Total Recall — Portable Memory</title>
20
- <script type="module" crossorigin src="/assets/index-BQL-opnK.js"></script>
20
+ <script type="module" crossorigin src="/assets/index-DWCNvgnv.js"></script>
21
21
  <link rel="stylesheet" crossorigin href="/assets/index-C7_Xpk27.css">
22
22
  </head>
23
23
  <body>
@@ -306,3 +306,27 @@ export async function registerLanMeshNodes(opts?: { probe?: boolean; limit?: num
306
306
  limit: opts?.limit ?? 32,
307
307
  });
308
308
  }
309
+
310
+ /** A short-lived bearer credential that lets a NEW device join the tailnet. */
311
+ export interface PreAuthKey {
312
+ success: boolean;
313
+ key: string;
314
+ expiration: string;
315
+ reusable: boolean;
316
+ ephemeral: boolean;
317
+ ttl_minutes: number;
318
+ /** Control-server URL to enter on the device. Never carries the token. */
319
+ login_server: string | null;
320
+ }
321
+
322
+ export async function mintPreAuthKey(opts?: {
323
+ reusable?: boolean;
324
+ ephemeral?: boolean;
325
+ ttlMinutes?: number;
326
+ }): Promise<PreAuthKey> {
327
+ return post('/api/mesh/preauthkey', {
328
+ reusable: opts?.reusable === true,
329
+ ephemeral: opts?.ephemeral === true,
330
+ ttlMinutes: opts?.ttlMinutes,
331
+ });
332
+ }
@@ -77,6 +77,32 @@ export async function restartDaemon(): Promise<{ success: boolean; message: stri
77
77
  return res.json()
78
78
  }
79
79
 
80
+ export interface RestartResult {
81
+ success: boolean
82
+ scheduled: boolean
83
+ message: string
84
+ supervisor?: { supervised: boolean; kind: string | null; label: string | null; reason: string }
85
+ }
86
+
87
+ /**
88
+ * Restart the brain server itself. Answers 409 on a host where nothing would
89
+ * respawn the process — that is a real answer, not a failure, so the message is
90
+ * surfaced rather than thrown.
91
+ */
92
+ export async function restartServer(): Promise<RestartResult> {
93
+ const res = await apiFetch(`${API_BASE}/api/server/restart`, { method: 'POST' })
94
+ const data = await res.json().catch(() => ({}))
95
+ if (!res.ok && res.status !== 409) {
96
+ throw new Error(data.message || data.error || `Restart server API error: ${res.status}`)
97
+ }
98
+ return {
99
+ success: Boolean(data.success),
100
+ scheduled: Boolean(data.scheduled),
101
+ message: data.message || (data.scheduled ? 'Server restarting.' : 'Server did not restart.'),
102
+ supervisor: data.supervisor,
103
+ }
104
+ }
105
+
80
106
  export async function fetchBrains(): Promise<Record<string, unknown>[]> {
81
107
  const res = await apiFetch(`${API_BASE}/api/brains`)
82
108
  const data = await res.json()
@@ -22,9 +22,18 @@ export async function checkUpdate(): Promise<UpdateCheckResult> {
22
22
  }
23
23
  }
24
24
 
25
+ export interface UpdateRestartInfo {
26
+ /** true when the update replaced the code this server is running */
27
+ required: boolean
28
+ /** true when a restart into that code has been scheduled */
29
+ scheduled: boolean
30
+ reason: string
31
+ }
32
+
25
33
  export async function runUpdate(): Promise<{
26
34
  success: boolean
27
35
  message: string
36
+ restart?: UpdateRestartInfo
28
37
  summary?: {
29
38
  latest?: string
30
39
  updated?: number
@@ -49,6 +58,13 @@ export async function runUpdate(): Promise<{
49
58
  return {
50
59
  success,
51
60
  message: data.message || (success ? 'Package auto-update finished' : 'Package auto-update failed'),
61
+ restart: data.restart
62
+ ? {
63
+ required: Boolean(data.restart.required),
64
+ scheduled: Boolean(data.restart.scheduled),
65
+ reason: String(data.restart.reason ?? ''),
66
+ }
67
+ : undefined,
52
68
  summary: data.summary,
53
69
  }
54
70
  }
@@ -0,0 +1,118 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { render, screen, waitFor, fireEvent } from '@testing-library/react';
3
+ import { TailnetEnroll } from './TailnetEnroll';
4
+
5
+ vi.mock('../api/mesh', () => ({
6
+ fetchNodes: vi.fn(),
7
+ fetchEnrollmentStatus: vi.fn(),
8
+ mintPreAuthKey: vi.fn(),
9
+ }));
10
+ import { fetchNodes, fetchEnrollmentStatus, mintPreAuthKey } from '../api/mesh';
11
+
12
+ const node = (over = {}) => ({
13
+ hostname: 'laptop', ip: '100.64.0.3', online: true, self: false, os: 'macOS', ...over,
14
+ });
15
+
16
+ describe('TailnetEnroll', () => {
17
+ beforeEach(() => {
18
+ vi.mocked(fetchNodes).mockResolvedValue([node(), node({ hostname: 'macmini', ip: '100.64.0.2', self: true })] as never);
19
+ vi.mocked(fetchEnrollmentStatus).mockResolvedValue({ login_server: 'https://control.example.org' } as never);
20
+ vi.mocked(mintPreAuthKey).mockResolvedValue({
21
+ success: true,
22
+ key: 'hskey-auth-TESTKEY',
23
+ expiration: new Date(Date.now() + 60_000).toISOString(),
24
+ reusable: false,
25
+ ephemeral: true,
26
+ ttl_minutes: 15,
27
+ login_server: 'https://headscale.example.com',
28
+ } as never);
29
+ });
30
+
31
+ it('shows the control server and phone steps BEFORE any key is minted', async () => {
32
+ // The regression: instructions lived inside the `minted` branch behind a
33
+ // collapsed <details>, so step one -- the URL you must enter on the phone
34
+ // -- was invisible until a 15-minute countdown was already running.
35
+ render(<TailnetEnroll />);
36
+ expect(await screen.findByTestId('enroll-instructions')).toBeTruthy();
37
+ expect((await screen.findByTestId('enroll-login-server')).textContent).toContain(
38
+ 'https://control.example.org',
39
+ );
40
+ expect(screen.getByText(/On a phone/i)).toBeTruthy();
41
+ expect(mintPreAuthKey).not.toHaveBeenCalled();
42
+ });
43
+
44
+ it('takes the control server from config, not from a minted key', async () => {
45
+ render(<TailnetEnroll />);
46
+ await waitFor(() =>
47
+ expect(screen.getByTestId('enroll-login-server').textContent).toContain('control.example.org'),
48
+ );
49
+ expect(fetchEnrollmentStatus).toHaveBeenCalled();
50
+ });
51
+
52
+ it('says what to do when no control server is configured', async () => {
53
+ vi.mocked(fetchEnrollmentStatus).mockResolvedValue({ login_server: null } as never);
54
+ render(<TailnetEnroll />);
55
+ await waitFor(() =>
56
+ expect(screen.getByTestId('enroll-login-server').textContent).toMatch(/not configured/i),
57
+ );
58
+ });
59
+
60
+ it('keeps the Reset Keychain step visible — skipping it is why sign-in reverts to the public service', async () => {
61
+ render(<TailnetEnroll />);
62
+ expect(await screen.findByText(/Reset Keychain/i)).toBeTruthy();
63
+ });
64
+
65
+ it('lists mesh nodes', async () => {
66
+ render(<TailnetEnroll />);
67
+ await waitFor(() => expect(screen.getByTestId('enroll-node-list')).toHaveTextContent('laptop'));
68
+ expect(screen.getByTestId('enroll-node-list')).toHaveTextContent('macmini');
69
+ });
70
+
71
+ it('mints a key and shows a QR to scan', async () => {
72
+ render(<TailnetEnroll />);
73
+ fireEvent.click(screen.getByTestId('enroll-mint'));
74
+ await waitFor(() => expect(screen.getByTestId('enroll-qr').querySelector('svg')).toBeTruthy());
75
+ expect(screen.getByTestId('enroll-copy')).toBeTruthy();
76
+ });
77
+
78
+ it('config wins over the minted key for the control server URL', async () => {
79
+ // Deliberate precedence: the key echoes back whatever server issued it,
80
+ // but config is the source of truth and is available before any mint.
81
+ render(<TailnetEnroll />);
82
+ fireEvent.click(screen.getByTestId('enroll-mint'));
83
+ await waitFor(() => expect(screen.getByTestId('enroll-copy')).toBeTruthy());
84
+ expect(screen.getByTestId('enroll-login-server')).toHaveTextContent('control.example.org');
85
+ });
86
+
87
+ it('passes the reusable and ephemeral choices through to the API', async () => {
88
+ render(<TailnetEnroll />);
89
+ fireEvent.click(screen.getByTestId('enroll-reusable'));
90
+ fireEvent.click(screen.getByTestId('enroll-mint'));
91
+ await waitFor(() => expect(mintPreAuthKey).toHaveBeenCalledWith({ reusable: true, ephemeral: false }));
92
+ });
93
+
94
+ // A key is a bearer credential with a short TTL; a dead one must not sit on
95
+ // screen looking scannable.
96
+ it('marks the key expired once its TTL has elapsed', async () => {
97
+ vi.mocked(mintPreAuthKey).mockResolvedValue({
98
+ success: true, key: 'hskey-auth-OLD', expiration: new Date(Date.now() - 1000).toISOString(),
99
+ reusable: false, ephemeral: false, ttl_minutes: 15, login_server: 'https://headscale.example.com',
100
+ } as never);
101
+ render(<TailnetEnroll />);
102
+ fireEvent.click(screen.getByTestId('enroll-mint'));
103
+ await waitFor(() => expect(screen.getByTestId('enroll-ttl')).toHaveTextContent('expired'));
104
+ expect(screen.getByTestId('enroll-qr')).toHaveTextContent('Key expired');
105
+ });
106
+
107
+ it('surfaces a mint failure instead of silently doing nothing', async () => {
108
+ vi.mocked(mintPreAuthKey).mockRejectedValue(new Error('headscale unreachable'));
109
+ render(<TailnetEnroll />);
110
+ fireEvent.click(screen.getByTestId('enroll-mint'));
111
+ await waitFor(() => expect(screen.getByTestId('tailnet-enroll-error')).toHaveTextContent('headscale unreachable'));
112
+ });
113
+
114
+ it('warns about nodes with no recorded login account', async () => {
115
+ render(<TailnetEnroll />);
116
+ await waitFor(() => expect(screen.getByTestId('enroll-access-warning')).toHaveTextContent('no recorded login account'));
117
+ });
118
+ });
@@ -0,0 +1,237 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { QRCodeSVG } from 'qrcode.react';
3
+ import { fetchNodes, fetchEnrollmentStatus, mintPreAuthKey } from '../api/mesh';
4
+ import type { MeshNode, PreAuthKey } from '../api/mesh';
5
+
6
+ /**
7
+ * Add a device (phone, tablet, another machine) to the self-hosted tailnet.
8
+ *
9
+ * Enrolling a phone had no UI at all: `/api/mesh/enroll` only ever enrolls the
10
+ * host the server runs on, so the only way to add a device was
11
+ * `total-recall mesh preauthkey` — a CLI, which is exactly what you do not have
12
+ * on the device you are trying to enroll.
13
+ */
14
+ export function TailnetEnroll() {
15
+ const [nodes, setNodes] = useState<MeshNode[]>([]);
16
+ const [minted, setMinted] = useState<PreAuthKey | null>(null);
17
+ const [reusable, setReusable] = useState(false);
18
+ const [ephemeral, setEphemeral] = useState(false);
19
+ const [busy, setBusy] = useState(false);
20
+ const [error, setError] = useState<string | null>(null);
21
+ const [copied, setCopied] = useState(false);
22
+ const [serverCopied, setServerCopied] = useState(false);
23
+ const [secondsLeft, setSecondsLeft] = useState<number | null>(null);
24
+ // The control-server URL is the FIRST thing you need on the phone, and it is
25
+ // config, not a property of a key. Reading it only off a minted key meant the
26
+ // URL stayed invisible until a 15-minute countdown was already running.
27
+ const [controlServer, setControlServer] = useState<string | null>(null);
28
+
29
+ useEffect(() => {
30
+ fetchNodes()
31
+ .then(setNodes)
32
+ .catch((e) => setError(e?.message ?? 'could not load nodes'));
33
+ fetchEnrollmentStatus()
34
+ .then((st) => setControlServer(st.login_server))
35
+ .catch(() => setControlServer(null));
36
+ }, []);
37
+
38
+ // An enrollment key is a bearer credential with a short TTL. Show the time
39
+ // remaining rather than leaving a dead key on screen looking usable.
40
+ useEffect(() => {
41
+ if (!minted) return undefined;
42
+ const tick = () => {
43
+ const ms = new Date(minted.expiration).getTime() - Date.now();
44
+ setSecondsLeft(Math.max(0, Math.floor(ms / 1000)));
45
+ };
46
+ tick();
47
+ const id = setInterval(tick, 1000);
48
+ return () => clearInterval(id);
49
+ }, [minted]);
50
+
51
+ const mint = async () => {
52
+ setBusy(true);
53
+ setError(null);
54
+ setCopied(false);
55
+ try {
56
+ setMinted(await mintPreAuthKey({ reusable, ephemeral }));
57
+ } catch (e) {
58
+ setError(e instanceof Error ? e.message : 'could not mint an enrollment key');
59
+ } finally {
60
+ setBusy(false);
61
+ }
62
+ };
63
+
64
+ const expired = secondsLeft !== null && secondsLeft <= 0;
65
+ const loginServer = controlServer ?? minted?.login_server ?? null;
66
+ // `complete` is the resolver's own verdict: false when the login account is
67
+ // unknown and the node would refuse you. No resolved access at all counts as
68
+ // unknown too. (There is no `ssh_user` field — the account is `user`.)
69
+ const unknownAccess = nodes.filter((n) => !n.self && n.access_resolved?.complete !== true);
70
+
71
+ return (
72
+ <section data-testid="tailnet-enroll" style={{ marginTop: 24 }}>
73
+ <h3 style={{ marginBottom: 4 }}>Add a device to the tailnet</h3>
74
+ <p style={{ fontSize: 12, opacity: 0.75, marginTop: 0 }}>
75
+ Point the device at this control server, then authenticate it with a short-lived key.
76
+ </p>
77
+
78
+ {/*
79
+ Instructions come BEFORE the key and are open by default. They used to
80
+ live inside the `minted` branch behind a collapsed <details>, so the
81
+ control-server URL -- step one, and the thing you cannot proceed
82
+ without -- was invisible until a 15-minute countdown was already
83
+ running. Nothing here is hardcoded: the URL is whatever the brain's
84
+ config resolves to.
85
+ */}
86
+ <div
87
+ data-testid="enroll-instructions"
88
+ style={{ margin: '12px 0', padding: 14, borderRadius: 8, border: '1px solid var(--border)', background: 'var(--bg-secondary)' }}
89
+ >
90
+ <div style={{ fontSize: 12, marginBottom: 10 }}>
91
+ <strong>Control server</strong>
92
+ <div style={{ display: 'flex', gap: 8, alignItems: 'center', marginTop: 4 }}>
93
+ <code data-testid="enroll-login-server" style={{ wordBreak: 'break-all', flex: 1 }}>
94
+ {loginServer ?? 'not configured — set the Headscale URL in Secrets first'}
95
+ </code>
96
+ {loginServer && (
97
+ <button
98
+ type="button"
99
+ data-testid="enroll-copy-server"
100
+ onClick={() => { navigator.clipboard?.writeText(loginServer); setServerCopied(true); }}
101
+ >
102
+ {serverCopied ? 'Copied' : 'Copy'}
103
+ </button>
104
+ )}
105
+ </div>
106
+ </div>
107
+
108
+ <details open style={{ fontSize: 12 }}>
109
+ <summary style={{ cursor: 'pointer', fontWeight: 600 }}>On a phone (iOS)</summary>
110
+ <ol style={{ paddingLeft: 18, lineHeight: 1.7, marginBottom: 6 }}>
111
+ <li>Install <strong>Tailscale</strong> from the App Store — <strong>1.38.1 or newer</strong>, the first release that supports an alternate control server.</li>
112
+ <li>
113
+ <strong>Sign out of Tailscale first.</strong> The alternate-server setting is only read while
114
+ the app is logged out.
115
+ </li>
116
+ <li>
117
+ Open <strong>iOS Settings</strong>, scroll past Game Center and TV Provider to{' '}
118
+ <strong>Tailscale</strong>, and put the control server above into{' '}
119
+ <strong>Alternate Coordination Server URL</strong>.
120
+ </li>
121
+ <li>
122
+ <strong>If this device was ever signed into Tailscale's own service, turn on “Reset Keychain”.</strong>{' '}
123
+ Skipping this is the usual reason the app keeps going back to the public login.
124
+ </li>
125
+ <li>Force-quit Tailscale from the app switcher, then reopen it — the setting is read at launch.</li>
126
+ <li>Tap the plain <strong>Sign in</strong> option (not SSO). It should open this control server's page.</li>
127
+ <li>Authenticate with the key below.</li>
128
+ </ol>
129
+ <p style={{ opacity: 0.75, margin: 0 }}>
130
+ The in-app route — account icon → <strong>Log in…</strong> → options menu →{' '}
131
+ <strong>Use custom coordination server</strong> — reaches the same place when it works, but that
132
+ menu has been reported unresponsive on some builds. The Settings route above is the reliable one.
133
+ </p>
134
+ </details>
135
+
136
+ <details style={{ fontSize: 12, marginTop: 8 }}>
137
+ <summary style={{ cursor: 'pointer', fontWeight: 600 }}>On Android</summary>
138
+ <ol style={{ paddingLeft: 18, lineHeight: 1.7, margin: '4px 0 0' }}>
139
+ <li>Install Tailscale from Google Play and make sure it is signed out.</li>
140
+ <li>Open the app, tap the account icon, then <strong>Log in…</strong>.</li>
141
+ <li>Open the top-right options menu → <strong>Use custom coordination server</strong>.</li>
142
+ <li>Enter the control server above, then authenticate with the key below.</li>
143
+ </ol>
144
+ </details>
145
+ </div>
146
+
147
+ {error && (
148
+ <div
149
+ role="alert"
150
+ data-testid="tailnet-enroll-error"
151
+ style={{ margin: '10px 0', padding: 10, borderRadius: 8, border: '1px solid #ef4444', color: '#ef4444', fontSize: 12 }}
152
+ >
153
+ {error}
154
+ </div>
155
+ )}
156
+
157
+ <div style={{ display: 'flex', gap: 16, alignItems: 'center', flexWrap: 'wrap', margin: '12px 0' }}>
158
+ <label style={{ fontSize: 12, display: 'flex', gap: 6, alignItems: 'center' }}>
159
+ <input type="checkbox" checked={reusable} onChange={(e) => setReusable(e.target.checked)} data-testid="enroll-reusable" />
160
+ Reusable (enroll more than one device)
161
+ </label>
162
+ <label style={{ fontSize: 12, display: 'flex', gap: 6, alignItems: 'center' }}>
163
+ <input type="checkbox" checked={ephemeral} onChange={(e) => setEphemeral(e.target.checked)} data-testid="enroll-ephemeral" />
164
+ Ephemeral (drops off the list when offline)
165
+ </label>
166
+ <button type="button" onClick={mint} disabled={busy} data-testid="enroll-mint" style={{ cursor: busy ? 'wait' : 'pointer' }}>
167
+ {busy ? 'Minting…' : minted ? 'Mint another key' : 'Get enrollment key'}
168
+ </button>
169
+ </div>
170
+
171
+ {minted && (
172
+ <div style={{ display: 'flex', gap: 20, flexWrap: 'wrap', alignItems: 'flex-start' }}>
173
+ <div style={{ background: '#fff', padding: 16, borderRadius: 8 }} data-testid="enroll-qr">
174
+ {expired ? (
175
+ <div style={{ width: 150, height: 150, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#ef4444', fontSize: 12, textAlign: 'center' }}>
176
+ Key expired — mint another
177
+ </div>
178
+ ) : (
179
+ <QRCodeSVG value={minted.key} size={150} level="M" includeMargin />
180
+ )}
181
+ </div>
182
+
183
+ <div style={{ flex: 1, minWidth: 260 }}>
184
+ <div style={{ fontSize: 12, marginBottom: 6 }}>
185
+ <strong>Enrollment key</strong>{' '}
186
+ <span data-testid="enroll-ttl" style={{ opacity: 0.7 }}>
187
+ {expired ? '(expired)' : `(expires in ${secondsLeft}s, ${minted.reusable ? 'reusable' : 'single-use'})`}
188
+ </span>
189
+ <div style={{ display: 'flex', gap: 8, alignItems: 'center', marginTop: 4 }}>
190
+ <code style={{ wordBreak: 'break-all', flex: 1, opacity: expired ? 0.4 : 1 }}>{minted.key}</code>
191
+ <button
192
+ type="button"
193
+ disabled={expired}
194
+ onClick={() => { navigator.clipboard?.writeText(minted.key); setCopied(true); }}
195
+ data-testid="enroll-copy"
196
+ >
197
+ {copied ? 'Copied' : 'Copy'}
198
+ </button>
199
+ </div>
200
+ </div>
201
+
202
+ <details style={{ fontSize: 12, marginTop: 6 }}>
203
+ <summary style={{ cursor: 'pointer' }}>On a computer</summary>
204
+ <pre style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
205
+ {`tailscale up --login-server ${loginServer ?? '<control-server>'} --authkey ${expired ? '<expired — mint another>' : minted.key}`}
206
+ </pre>
207
+ <p style={{ opacity: 0.75 }}>
208
+ Always pass <code>--login-server</code> alongside any <code>--reset</code>, or the client
209
+ silently repoints at the public Tailscale control plane.
210
+ </p>
211
+ </details>
212
+ </div>
213
+ </div>
214
+ )}
215
+
216
+ <div style={{ marginTop: 20 }}>
217
+ <h4 style={{ marginBottom: 6 }}>Nodes ({nodes.length})</h4>
218
+ <ul data-testid="enroll-node-list" style={{ listStyle: 'none', padding: 0, fontSize: 12, margin: 0 }}>
219
+ {nodes.map((n) => (
220
+ <li key={`${n.hostname}-${n.ip ?? 'noip'}`} style={{ padding: '4px 0', display: 'flex', gap: 8 }}>
221
+ <span title={n.online ? 'online' : 'offline'}>{n.online ? '🟢' : '⚪️'}</span>
222
+ <strong>{n.hostname}</strong>
223
+ <code style={{ opacity: 0.75 }}>{n.ip ?? '—'}</code>
224
+ {n.self && <span style={{ opacity: 0.6 }}>(this host)</span>}
225
+ </li>
226
+ ))}
227
+ </ul>
228
+ {unknownAccess.length > 0 && (
229
+ <p data-testid="enroll-access-warning" style={{ fontSize: 12, color: '#f59e0b', marginBottom: 0 }}>
230
+ {unknownAccess.length} node(s) have no recorded login account — connecting to one fails as
231
+ though it were unreachable. Run <code>total-recall mesh access import</code>.
232
+ </p>
233
+ )}
234
+ </div>
235
+ </section>
236
+ );
237
+ }
@@ -1,4 +1,4 @@
1
- /** Official Total Recall brand marks from portfolio-site assets/logos. */
1
+ /** Official Total Recall brand marks. */
2
2
 
3
3
  export type BrandVariant = 'lockup' | 'icon' | 'badge' | 'mark'
4
4
 
@@ -1,6 +1,6 @@
1
1
  /* ═══════════════════════════════════════════════════════════════════════════════
2
2
  Total Recall — Design System (premium · brand blue vault)
3
- Brand mark: portfolio-site/assets/logos/total-recall.png (cube lockup)
3
+ Brand mark: the Total Recall cube lockup.
4
4
  ═══════════════════════════════════════════════════════════════════════════════ */
5
5
 
6
6
  :root {
@@ -54,4 +54,35 @@ describe('SettingsPage', () => {
54
54
  expect(await screen.findByTestId('settings-error')).toBeInTheDocument();
55
55
  expect(screen.getByText(/Config JSON API error: 500/i)).toBeInTheDocument();
56
56
  }, 10000);
57
+
58
+ it('restarts the server from the UI and reports the outcome', async () => {
59
+ vi.spyOn(console, 'error').mockImplementation(() => {});
60
+ vi.mocked(api.fetchConfigJson).mockResolvedValue({
61
+ security: { bind: {}, network: {}, rate_limits: {}, sandbox: {}, dashboard: {}, api: {} },
62
+ budget: { budget: {} },
63
+ brain: {},
64
+ secrets: {},
65
+ } as never);
66
+ vi.mocked(api.fetchHealth).mockResolvedValue({ status: 'healthy', version: '1.0.0' } as never);
67
+ vi.mocked(api.checkUpdate).mockResolvedValue({ updateAvailable: false } as never);
68
+ vi.mocked(api.restartServer).mockResolvedValue({
69
+ success: false,
70
+ scheduled: false,
71
+ message: 'refusing to exit: no supervisor would restart this process',
72
+ } as never);
73
+
74
+ await act(async () => {
75
+ render(<SettingsPage />);
76
+ });
77
+
78
+ const button = await screen.findByTestId('restart-server', {}, { timeout: 4000 });
79
+ await act(async () => {
80
+ button.click();
81
+ });
82
+
83
+ expect(api.restartServer).toHaveBeenCalled();
84
+ // An unsupervised host is a real answer, surfaced verbatim — not swallowed
85
+ // and not presented as a successful restart.
86
+ expect(screen.getByText(/refusing to exit/i)).toBeInTheDocument();
87
+ }, 10000);
57
88
  });
@@ -7,6 +7,7 @@ import {
7
7
  checkUpdate,
8
8
  runUpdate,
9
9
  restartDaemon,
10
+ restartServer,
10
11
  triggerRecompile,
11
12
  apiFetch,
12
13
  getApiBase,
@@ -14,6 +15,7 @@ import {
14
15
  import type { HealthData } from '../types';
15
16
  import type { ConfigJson } from '../types';
16
17
  import { MobilePairing } from '../components/MobilePairing';
18
+ import { TailnetEnroll } from '../components/TailnetEnroll';
17
19
 
18
20
  function normalizeConfigJson(data: ConfigJson): ConfigJson {
19
21
  const next = { ...data } as ConfigJson & Record<string, unknown>;
@@ -52,6 +54,40 @@ export default function SettingsPage({ activeBrainId }: { activeBrainId?: string
52
54
  const [updating, setUpdating] = useState(false);
53
55
  const [updateMessage, setUpdateMessage] = useState<string | null>(null);
54
56
  const [restartingDaemon, setRestartingDaemon] = useState(false);
57
+ const [restartingServer, setRestartingServer] = useState(false);
58
+
59
+ const handleRestartServer = async () => {
60
+ setRestartingServer(true);
61
+ setUpdateMessage(null);
62
+ try {
63
+ const res = await restartServer();
64
+ setUpdateMessage(res.message);
65
+ if (res.scheduled) {
66
+ // The server is about to drop this connection on purpose. Poll health
67
+ // until it answers again instead of leaving the page looking hung.
68
+ setTimeout(function poll(attempt = 0) {
69
+ fetchHealth()
70
+ .then((h) => {
71
+ setHealth(h);
72
+ setUpdateMessage('Server restarted — reconnected.');
73
+ setRestartingServer(false);
74
+ })
75
+ .catch(() => {
76
+ if (attempt >= 20) {
77
+ setUpdateMessage('Server restarted but has not answered yet — reload in a moment.');
78
+ setRestartingServer(false);
79
+ return;
80
+ }
81
+ setTimeout(() => poll(attempt + 1), 1500);
82
+ });
83
+ }, 2000);
84
+ return;
85
+ }
86
+ } catch (err: unknown) {
87
+ setUpdateMessage('Restart error: ' + (err as Error).message);
88
+ }
89
+ setRestartingServer(false);
90
+ };
55
91
 
56
92
  const handleRestartDaemon = async () => {
57
93
  setRestartingDaemon(true);
@@ -141,10 +177,17 @@ export default function SettingsPage({ activeBrainId }: { activeBrainId?: string
141
177
  s?.up_to_date != null ? `${s.up_to_date} current` : null,
142
178
  s?.latest ? `latest ${s.latest}` : null,
143
179
  ].filter(Boolean);
180
+ // Say what actually happened to this server, rather than telling the
181
+ // user to go work out whether a restart is needed.
182
+ const restartNote = res.restart?.scheduled
183
+ ? ' Server is restarting into the new code.'
184
+ : res.restart?.required
185
+ ? ` Server still runs the old code — ${res.restart.reason}`
186
+ : '';
144
187
  setUpdateMessage(
145
188
  bits.length
146
- ? `Update complete — ${bits.join(' · ')}. Restart daemon/server if this host still runs old code.`
147
- : res.message || 'Update complete.',
189
+ ? `Update complete — ${bits.join(' · ')}.${restartNote}`
190
+ : (res.message || 'Update complete.') + restartNote,
148
191
  );
149
192
  } else {
150
193
  const failDetail = (res.summary?.results || [])
@@ -559,6 +602,10 @@ export default function SettingsPage({ activeBrainId }: { activeBrainId?: string
559
602
  <MobilePairing />
560
603
  </div>
561
604
 
605
+ <div className="settings-card glow-on-hover">
606
+ <TailnetEnroll />
607
+ </div>
608
+
562
609
  {/* --- Global System Layer --- */}
563
610
  {/* Network & Binding */}
564
611
  <div className="settings-card">
@@ -1010,6 +1057,16 @@ export default function SettingsPage({ activeBrainId }: { activeBrainId?: string
1010
1057
  >
1011
1058
  {restartingDaemon ? 'Restarting…' : 'Restart Daemon'}
1012
1059
  </button>
1060
+ <button
1061
+ className="btn-secondary"
1062
+ onClick={handleRestartServer}
1063
+ disabled={restartingServer}
1064
+ data-testid="restart-server"
1065
+ title="Restart the brain server so newly installed code is the code that runs"
1066
+ style={{ fontSize: 13, padding: '8px 14px', borderRadius: 8, cursor: 'pointer' }}
1067
+ >
1068
+ {restartingServer ? 'Restarting…' : 'Restart Server'}
1069
+ </button>
1013
1070
  <button
1014
1071
  className="btn-primary"
1015
1072
  onClick={handleRunUpdate}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "total-recall-brain",
3
- "version": "3.24.2",
3
+ "version": "3.25.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": [
@@ -591,8 +591,9 @@ Current baseline: **288 spec files / 1493 tests, ~160 s** on the Mac Mini.
591
591
  > and OOM kills. Sync and run remotely:
592
592
  >
593
593
  > ```bash
594
- > rsync -a --delete --exclude=node_modules/ --exclude=.git/ ./ mac-mini:~/github/total-recall/
595
- > ssh mac-mini 'export PATH=/opt/homebrew/bin:$PATH; cd ~/github/total-recall && npx vitest run'
594
+ > REMOTE=build-host; REMOTE_DIR=code/total-recall # your own host and path
595
+ > rsync -a --delete --exclude=node_modules/ --exclude=.git/ ./ "$REMOTE:$REMOTE_DIR/"
596
+ > ssh "$REMOTE" "export PATH=/opt/homebrew/bin:\$PATH; cd $REMOTE_DIR && npx vitest run"
596
597
  > ```
597
598
  >
598
599
  > A non-login `ssh` shell has no Homebrew on PATH — export it or use `bash -lc`,
@@ -43,7 +43,7 @@ Returns the status of the vault, embeddings index, and LLM reachability.
43
43
  "vfs": {
44
44
  "exists": true,
45
45
  "skill_exists": true,
46
- "path": "/Users/greg/.agent"
46
+ "path": "<home>/.agent"
47
47
  }
48
48
  }
49
49
  ```
@@ -23,7 +23,7 @@ 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`, `antigravity`, `gemini`, `vscode`, `windsurf`, `aider`, `ultrachat`, `obsidian`.
26
+ * **Supported Clients**: `vscode`, `cursor`, `cline`, `codex`, `antigravity`, `gemini`, `aider`, `obsidian`, `pi`, `hermes`, `openclaw`, `generic`.
27
27
  * **Options**:
28
28
  * `--brain <url>`: Override default brain server URL.
29
29
  * `--token <PAT>`: Inject bearer PAT credentials for authenticating requests.