total-recall-brain 3.25.0 → 3.25.2
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/frontend/dist/assets/{index-wTl6WfEH.js → index-wFSsoOKK.js} +92 -92
- package/frontend/dist/index.html +1 -1
- package/frontend/src/api/mesh.ts +26 -1
- package/frontend/src/components/TailnetEnroll.spec.tsx +49 -4
- package/frontend/src/components/TailnetEnroll.tsx +155 -23
- package/package.json +1 -1
- package/scaffold/.agent/skills/total-recall/scripts/sync-repo.mjs +18 -4
- package/src/server/route-manifest.json +4 -0
- package/src/server/routes/mesh.mjs +66 -1
- package/scaffold/.agent/skills/total-recall/research-queue.jsonl +0 -7
package/frontend/dist/index.html
CHANGED
|
@@ -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-
|
|
20
|
+
<script type="module" crossorigin src="/assets/index-wFSsoOKK.js"></script>
|
|
21
21
|
<link rel="stylesheet" crossorigin href="/assets/index-C7_Xpk27.css">
|
|
22
22
|
</head>
|
|
23
23
|
<body>
|
package/frontend/src/api/mesh.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { get, post } from './_base';
|
|
1
|
+
import { get, post, apiFetch, API_BASE } from './_base';
|
|
2
2
|
|
|
3
3
|
export interface MeshInterfaceSummary {
|
|
4
4
|
name: string;
|
|
@@ -278,6 +278,31 @@ export interface EnrollResult {
|
|
|
278
278
|
}
|
|
279
279
|
|
|
280
280
|
/** Enrollment state of this node on the mesh control server. */
|
|
281
|
+
export interface RegisteredNode {
|
|
282
|
+
success: boolean;
|
|
283
|
+
user?: string;
|
|
284
|
+
message: string;
|
|
285
|
+
node?: { id: string | null; name: string | null; ip_addresses: string[]; online: boolean } | null;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Approve a device that registered interactively.
|
|
290
|
+
*
|
|
291
|
+
* iOS has no other option: the upstream Tailscale app refuses pre-auth keys
|
|
292
|
+
* against a custom control server, so a phone always ends up on headscale's
|
|
293
|
+
* "run this on the server" page waiting for approval.
|
|
294
|
+
*/
|
|
295
|
+
export async function registerNode(authId: string, user?: string): Promise<RegisteredNode> {
|
|
296
|
+
const res = await apiFetch(`${API_BASE}/api/mesh/register-node`, {
|
|
297
|
+
method: 'POST',
|
|
298
|
+
headers: { 'Content-Type': 'application/json' },
|
|
299
|
+
body: JSON.stringify({ auth_id: authId, ...(user ? { user } : {}) }),
|
|
300
|
+
});
|
|
301
|
+
const data = await res.json().catch(() => ({}));
|
|
302
|
+
if (!res.ok) throw new Error(data.message || `register failed: ${res.status}`);
|
|
303
|
+
return data as RegisteredNode;
|
|
304
|
+
}
|
|
305
|
+
|
|
281
306
|
export async function fetchEnrollmentStatus(): Promise<EnrollmentStatus> {
|
|
282
307
|
return get('/api/mesh/enrollment');
|
|
283
308
|
}
|
|
@@ -4,9 +4,10 @@ import { TailnetEnroll } from './TailnetEnroll';
|
|
|
4
4
|
|
|
5
5
|
vi.mock('../api/mesh', () => ({
|
|
6
6
|
fetchNodes: vi.fn(),
|
|
7
|
+
fetchEnrollmentStatus: vi.fn(),
|
|
7
8
|
mintPreAuthKey: vi.fn(),
|
|
8
9
|
}));
|
|
9
|
-
import { fetchNodes, mintPreAuthKey } from '../api/mesh';
|
|
10
|
+
import { fetchNodes, fetchEnrollmentStatus, mintPreAuthKey } from '../api/mesh';
|
|
10
11
|
|
|
11
12
|
const node = (over = {}) => ({
|
|
12
13
|
hostname: 'laptop', ip: '100.64.0.3', online: true, self: false, os: 'macOS', ...over,
|
|
@@ -15,6 +16,7 @@ const node = (over = {}) => ({
|
|
|
15
16
|
describe('TailnetEnroll', () => {
|
|
16
17
|
beforeEach(() => {
|
|
17
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);
|
|
18
20
|
vi.mocked(mintPreAuthKey).mockResolvedValue({
|
|
19
21
|
success: true,
|
|
20
22
|
key: 'hskey-auth-TESTKEY',
|
|
@@ -26,17 +28,60 @@ describe('TailnetEnroll', () => {
|
|
|
26
28
|
} as never);
|
|
27
29
|
});
|
|
28
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 (iOS)')).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
|
+
|
|
29
65
|
it('lists mesh nodes', async () => {
|
|
30
66
|
render(<TailnetEnroll />);
|
|
31
67
|
await waitFor(() => expect(screen.getByTestId('enroll-node-list')).toHaveTextContent('laptop'));
|
|
32
68
|
expect(screen.getByTestId('enroll-node-list')).toHaveTextContent('macmini');
|
|
33
69
|
});
|
|
34
70
|
|
|
35
|
-
it('mints a key and shows
|
|
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.
|
|
36
81
|
render(<TailnetEnroll />);
|
|
37
82
|
fireEvent.click(screen.getByTestId('enroll-mint'));
|
|
38
|
-
await waitFor(() => expect(screen.getByTestId('enroll-
|
|
39
|
-
expect(screen.getByTestId('enroll-
|
|
83
|
+
await waitFor(() => expect(screen.getByTestId('enroll-copy')).toBeTruthy());
|
|
84
|
+
expect(screen.getByTestId('enroll-login-server')).toHaveTextContent('control.example.org');
|
|
40
85
|
});
|
|
41
86
|
|
|
42
87
|
it('passes the reusable and ephemeral choices through to the API', async () => {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useEffect, useState } from 'react';
|
|
2
2
|
import { QRCodeSVG } from 'qrcode.react';
|
|
3
|
-
import { fetchNodes, mintPreAuthKey } from '../api/mesh';
|
|
3
|
+
import { fetchNodes, fetchEnrollmentStatus, mintPreAuthKey, registerNode } from '../api/mesh';
|
|
4
4
|
import type { MeshNode, PreAuthKey } from '../api/mesh';
|
|
5
5
|
|
|
6
6
|
/**
|
|
@@ -19,12 +19,23 @@ export function TailnetEnroll() {
|
|
|
19
19
|
const [busy, setBusy] = useState(false);
|
|
20
20
|
const [error, setError] = useState<string | null>(null);
|
|
21
21
|
const [copied, setCopied] = useState(false);
|
|
22
|
+
const [serverCopied, setServerCopied] = useState(false);
|
|
22
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
|
+
const [authId, setAuthId] = useState('');
|
|
29
|
+
const [approving, setApproving] = useState(false);
|
|
30
|
+
const [approved, setApproved] = useState<string | null>(null);
|
|
23
31
|
|
|
24
32
|
useEffect(() => {
|
|
25
33
|
fetchNodes()
|
|
26
34
|
.then(setNodes)
|
|
27
35
|
.catch((e) => setError(e?.message ?? 'could not load nodes'));
|
|
36
|
+
fetchEnrollmentStatus()
|
|
37
|
+
.then((st) => setControlServer(st.login_server))
|
|
38
|
+
.catch(() => setControlServer(null));
|
|
28
39
|
}, []);
|
|
29
40
|
|
|
30
41
|
// An enrollment key is a bearer credential with a short TTL. Show the time
|
|
@@ -53,8 +64,28 @@ export function TailnetEnroll() {
|
|
|
53
64
|
}
|
|
54
65
|
};
|
|
55
66
|
|
|
67
|
+
const approve = async () => {
|
|
68
|
+
setApproving(true);
|
|
69
|
+
setError(null);
|
|
70
|
+
setApproved(null);
|
|
71
|
+
try {
|
|
72
|
+
// Accept a pasted whole command as well as a bare id — the device shows
|
|
73
|
+
// the id inside `headscale nodes register --user X --key <id>`, and
|
|
74
|
+
// making someone extract a substring by hand is a needless failure.
|
|
75
|
+
const match = authId.match(/hskey-authreq-[A-Za-z0-9._-]+/);
|
|
76
|
+
const res = await registerNode(match ? match[0] : authId.trim());
|
|
77
|
+
setApproved(res.message);
|
|
78
|
+
setAuthId('');
|
|
79
|
+
setNodes(await fetchNodes().catch(() => nodes));
|
|
80
|
+
} catch (e) {
|
|
81
|
+
setError(e instanceof Error ? e.message : 'could not approve device');
|
|
82
|
+
} finally {
|
|
83
|
+
setApproving(false);
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
|
|
56
87
|
const expired = secondsLeft !== null && secondsLeft <= 0;
|
|
57
|
-
const loginServer = minted?.login_server ?? null;
|
|
88
|
+
const loginServer = controlServer ?? minted?.login_server ?? null;
|
|
58
89
|
// `complete` is the resolver's own verdict: false when the login account is
|
|
59
90
|
// unknown and the node would refuse you. No resolved access at all counts as
|
|
60
91
|
// unknown too. (There is no `ssh_user` field — the account is `user`.)
|
|
@@ -64,9 +95,129 @@ export function TailnetEnroll() {
|
|
|
64
95
|
<section data-testid="tailnet-enroll" style={{ marginTop: 24 }}>
|
|
65
96
|
<h3 style={{ marginBottom: 4 }}>Add a device to the tailnet</h3>
|
|
66
97
|
<p style={{ fontSize: 12, opacity: 0.75, marginTop: 0 }}>
|
|
67
|
-
|
|
98
|
+
Point the device at this control server, then authenticate it with a short-lived key.
|
|
68
99
|
</p>
|
|
69
100
|
|
|
101
|
+
{/*
|
|
102
|
+
Instructions come BEFORE the key and are open by default. They used to
|
|
103
|
+
live inside the `minted` branch behind a collapsed <details>, so the
|
|
104
|
+
control-server URL -- step one, and the thing you cannot proceed
|
|
105
|
+
without -- was invisible until a 15-minute countdown was already
|
|
106
|
+
running. Nothing here is hardcoded: the URL is whatever the brain's
|
|
107
|
+
config resolves to.
|
|
108
|
+
*/}
|
|
109
|
+
<div
|
|
110
|
+
data-testid="enroll-instructions"
|
|
111
|
+
style={{ margin: '12px 0', padding: 14, borderRadius: 8, border: '1px solid var(--border)', background: 'var(--bg-secondary)' }}
|
|
112
|
+
>
|
|
113
|
+
<div style={{ fontSize: 12, marginBottom: 10 }}>
|
|
114
|
+
<strong>Control server</strong>
|
|
115
|
+
<div style={{ display: 'flex', gap: 8, alignItems: 'center', marginTop: 4 }}>
|
|
116
|
+
<code data-testid="enroll-login-server" style={{ wordBreak: 'break-all', flex: 1 }}>
|
|
117
|
+
{loginServer ?? 'not configured — set the Headscale URL in Secrets first'}
|
|
118
|
+
</code>
|
|
119
|
+
{loginServer && (
|
|
120
|
+
<button
|
|
121
|
+
type="button"
|
|
122
|
+
data-testid="enroll-copy-server"
|
|
123
|
+
onClick={() => { navigator.clipboard?.writeText(loginServer); setServerCopied(true); }}
|
|
124
|
+
>
|
|
125
|
+
{serverCopied ? 'Copied' : 'Copy'}
|
|
126
|
+
</button>
|
|
127
|
+
)}
|
|
128
|
+
</div>
|
|
129
|
+
</div>
|
|
130
|
+
|
|
131
|
+
{/*
|
|
132
|
+
Approving an interactively-registered device is the ONLY route that
|
|
133
|
+
works for iOS: the upstream Tailscale app refuses a pre-auth key when
|
|
134
|
+
pointed at a custom control server, so a phone always lands on
|
|
135
|
+
headscale's "run this command on the server" page. Until this panel
|
|
136
|
+
existed, finishing that required SSH and a CLI -- on the machine you
|
|
137
|
+
are not holding.
|
|
138
|
+
*/}
|
|
139
|
+
<div
|
|
140
|
+
data-testid="enroll-approve"
|
|
141
|
+
style={{ margin: '0 0 12px', padding: 12, borderRadius: 8, border: '1px solid var(--border)' }}
|
|
142
|
+
>
|
|
143
|
+
<strong style={{ fontSize: 12 }}>Device waiting for approval?</strong>
|
|
144
|
+
<p style={{ fontSize: 12, opacity: 0.75, margin: '4px 0 8px' }}>
|
|
145
|
+
If a device shows “run the command below in the headscale server”, paste that whole line
|
|
146
|
+
(or just the <code>hskey-authreq-…</code> part) here.
|
|
147
|
+
</p>
|
|
148
|
+
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
|
149
|
+
<input
|
|
150
|
+
type="text"
|
|
151
|
+
value={authId}
|
|
152
|
+
onChange={(e) => setAuthId(e.target.value)}
|
|
153
|
+
placeholder="hskey-authreq-…"
|
|
154
|
+
data-testid="enroll-authid"
|
|
155
|
+
style={{ flex: 1, minWidth: 220, fontFamily: 'var(--font-mono)', fontSize: 12 }}
|
|
156
|
+
/>
|
|
157
|
+
<button
|
|
158
|
+
type="button"
|
|
159
|
+
onClick={approve}
|
|
160
|
+
disabled={approving || !authId.trim()}
|
|
161
|
+
data-testid="enroll-approve-btn"
|
|
162
|
+
>
|
|
163
|
+
{approving ? 'Approving…' : 'Approve device'}
|
|
164
|
+
</button>
|
|
165
|
+
</div>
|
|
166
|
+
{approved && (
|
|
167
|
+
<p data-testid="enroll-approved" style={{ fontSize: 12, color: '#10b981', margin: '8px 0 0' }}>
|
|
168
|
+
{approved}
|
|
169
|
+
</p>
|
|
170
|
+
)}
|
|
171
|
+
</div>
|
|
172
|
+
|
|
173
|
+
<details open style={{ fontSize: 12 }}>
|
|
174
|
+
<summary style={{ cursor: 'pointer', fontWeight: 600 }}>On a phone (iOS)</summary>
|
|
175
|
+
<ol style={{ paddingLeft: 18, lineHeight: 1.7, marginBottom: 6 }}>
|
|
176
|
+
<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>
|
|
177
|
+
<li>
|
|
178
|
+
<strong>Sign out of Tailscale first.</strong> The alternate-server setting is only read while
|
|
179
|
+
the app is logged out.
|
|
180
|
+
</li>
|
|
181
|
+
<li>
|
|
182
|
+
Open <strong>iOS Settings</strong>, scroll past Game Center and TV Provider to{' '}
|
|
183
|
+
<strong>Tailscale</strong>, and put the control server above into{' '}
|
|
184
|
+
<strong>Alternate Coordination Server URL</strong>.
|
|
185
|
+
</li>
|
|
186
|
+
<li>
|
|
187
|
+
<strong>Turn on “Reset Keychain”.</strong> Treat this as required, not optional. The app keeps
|
|
188
|
+
its node key in the iOS keychain and will otherwise replay an old registration id forever —
|
|
189
|
+
force-quitting does not clear it, and the server rejects the stale id every time while the
|
|
190
|
+
phone keeps showing a page that looks perfectly valid.
|
|
191
|
+
</li>
|
|
192
|
+
<li>Force-quit Tailscale from the app switcher, then reopen it — the setting is read at launch.</li>
|
|
193
|
+
<li>Tap the plain <strong>Sign in</strong> option (not SSO). It should open this control server's page.</li>
|
|
194
|
+
<li>
|
|
195
|
+
The page will say <em>“run the command below in the headscale server”</em>. That is expected —
|
|
196
|
+
copy the line it shows and paste it into <strong>Device waiting for approval?</strong> above.
|
|
197
|
+
<strong> Do not use the enrollment key below on a phone:</strong> the Tailscale iOS app does not
|
|
198
|
+
accept pre-auth keys against a custom control server, so interactive sign-in plus approval here
|
|
199
|
+
is the only route that works.
|
|
200
|
+
</li>
|
|
201
|
+
</ol>
|
|
202
|
+
<p style={{ opacity: 0.75, margin: 0 }}>
|
|
203
|
+
The in-app route — account icon → <strong>Log in…</strong> → options menu →{' '}
|
|
204
|
+
<strong>Use custom coordination server</strong> — reaches the same place when it works, but that
|
|
205
|
+
menu has been reported unresponsive on some builds. The Settings route above is the reliable one.
|
|
206
|
+
</p>
|
|
207
|
+
</details>
|
|
208
|
+
|
|
209
|
+
<details style={{ fontSize: 12, marginTop: 8 }}>
|
|
210
|
+
<summary style={{ cursor: 'pointer', fontWeight: 600 }}>On Android</summary>
|
|
211
|
+
<ol style={{ paddingLeft: 18, lineHeight: 1.7, margin: '4px 0 0' }}>
|
|
212
|
+
<li>Install Tailscale from Google Play and make sure it is signed out.</li>
|
|
213
|
+
<li>Open the app, tap the account icon, then <strong>Log in…</strong>.</li>
|
|
214
|
+
<li>Open the top-right options menu → <strong>Use custom coordination server</strong>.</li>
|
|
215
|
+
<li>Enter the control server above. If it accepts the key below, use it; if it shows a
|
|
216
|
+
“run this command” page instead, approve it above like iOS.</li>
|
|
217
|
+
</ol>
|
|
218
|
+
</details>
|
|
219
|
+
</div>
|
|
220
|
+
|
|
70
221
|
{error && (
|
|
71
222
|
<div
|
|
72
223
|
role="alert"
|
|
@@ -87,7 +238,7 @@ export function TailnetEnroll() {
|
|
|
87
238
|
Ephemeral (drops off the list when offline)
|
|
88
239
|
</label>
|
|
89
240
|
<button type="button" onClick={mint} disabled={busy} data-testid="enroll-mint" style={{ cursor: busy ? 'wait' : 'pointer' }}>
|
|
90
|
-
{busy ? 'Minting…' : minted ? 'Mint another key' : 'Get enrollment key'}
|
|
241
|
+
{busy ? 'Minting…' : minted ? 'Mint another key' : 'Get enrollment key (computers)'}
|
|
91
242
|
</button>
|
|
92
243
|
</div>
|
|
93
244
|
|
|
@@ -104,11 +255,6 @@ export function TailnetEnroll() {
|
|
|
104
255
|
</div>
|
|
105
256
|
|
|
106
257
|
<div style={{ flex: 1, minWidth: 260 }}>
|
|
107
|
-
<div style={{ fontSize: 12, marginBottom: 6 }}>
|
|
108
|
-
<strong>Control server</strong>
|
|
109
|
-
<div><code data-testid="enroll-login-server">{loginServer ?? 'not configured'}</code></div>
|
|
110
|
-
</div>
|
|
111
|
-
|
|
112
258
|
<div style={{ fontSize: 12, marginBottom: 6 }}>
|
|
113
259
|
<strong>Enrollment key</strong>{' '}
|
|
114
260
|
<span data-testid="enroll-ttl" style={{ opacity: 0.7 }}>
|
|
@@ -127,20 +273,6 @@ export function TailnetEnroll() {
|
|
|
127
273
|
</div>
|
|
128
274
|
</div>
|
|
129
275
|
|
|
130
|
-
<details style={{ fontSize: 12, marginTop: 10 }}>
|
|
131
|
-
<summary style={{ cursor: 'pointer' }}>On a phone (iOS / Android)</summary>
|
|
132
|
-
<ol style={{ paddingLeft: 18, lineHeight: 1.6 }}>
|
|
133
|
-
<li>Install Tailscale (iOS needs 1.38.1 or newer).</li>
|
|
134
|
-
<li>Account icon → <strong>Log in</strong> → options menu → <strong>Use custom coordination server</strong>.</li>
|
|
135
|
-
<li>Enter <code>{loginServer ?? 'your control server URL'}</code>.</li>
|
|
136
|
-
<li>Scan the QR or paste the key above when asked to authenticate.</li>
|
|
137
|
-
</ol>
|
|
138
|
-
<p style={{ opacity: 0.75 }}>
|
|
139
|
-
If that menu does not respond (reported on some 2026 iOS builds), set the same URL under
|
|
140
|
-
iOS Settings → Tailscale → <em>Alternate coordination server URL</em>.
|
|
141
|
-
</p>
|
|
142
|
-
</details>
|
|
143
|
-
|
|
144
276
|
<details style={{ fontSize: 12, marginTop: 6 }}>
|
|
145
277
|
<summary style={{ cursor: 'pointer' }}>On a computer</summary>
|
|
146
278
|
<pre style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
|
package/package.json
CHANGED
|
@@ -135,16 +135,30 @@ function writePreservingInjectedBlock(srcFile, dstFile) {
|
|
|
135
135
|
return 'updated';
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
+
// Per-brain state a template must NEVER overwrite. memory-vault was always
|
|
139
|
+
// here; the rest were not, and a sync across repos would have destroyed one
|
|
140
|
+
// repo's 7 queued research topics and its 68-line skills catalog. If a file
|
|
141
|
+
// records what THIS brain has done, it is state, not a template.
|
|
142
|
+
const STATE_DIRS = new Set([
|
|
143
|
+
'memory-vault', 'memory-derived', 'memory-inbox', 'sessions', 'logs',
|
|
144
|
+
'scheduler', 'config', 'backups', 'browser-profile', 'skills-registry',
|
|
145
|
+
'.snapshots', '.git', 'node_modules',
|
|
146
|
+
]);
|
|
147
|
+
const STATE_FILES = new Set([
|
|
148
|
+
'research-queue.jsonl', 'daemon.pid', '.extension-connected', 'graph.canvas',
|
|
149
|
+
]);
|
|
150
|
+
|
|
138
151
|
function walk(dir) {
|
|
139
152
|
const out = [];
|
|
140
153
|
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
141
154
|
const p = path.join(dir, e.name);
|
|
142
155
|
if (e.isDirectory()) {
|
|
143
|
-
|
|
144
|
-
if (e.name === 'memory-vault' || e.name === 'memory-derived' || e.name === 'sessions') continue;
|
|
145
|
-
if (e.name === '.git' || e.name === 'node_modules') continue;
|
|
156
|
+
if (STATE_DIRS.has(e.name)) continue;
|
|
146
157
|
out.push(...walk(p));
|
|
147
|
-
} else
|
|
158
|
+
} else {
|
|
159
|
+
if (STATE_FILES.has(e.name) || e.name.endsWith('.enc')) continue;
|
|
160
|
+
out.push(p);
|
|
161
|
+
}
|
|
148
162
|
}
|
|
149
163
|
return out;
|
|
150
164
|
}
|
|
@@ -16,7 +16,15 @@ import {
|
|
|
16
16
|
readSshConfig,
|
|
17
17
|
resolveNodeAccess,
|
|
18
18
|
} from '../../core/mesh-access.mjs';
|
|
19
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
createHeadscalePreAuthKey,
|
|
21
|
+
resolveHeadscaleConfig,
|
|
22
|
+
resolveHeadscaleUser,
|
|
23
|
+
headscaleFetch,
|
|
24
|
+
} from '../../core/headscale-client.mjs';
|
|
25
|
+
|
|
26
|
+
// headscale rejects anything else with a 500; validate up front instead.
|
|
27
|
+
const AUTH_REQUEST_PREFIX = 'hskey-authreq-';
|
|
20
28
|
import { throttledFetch } from '../../core/throttled-fetch.mjs';
|
|
21
29
|
import { defaultVaultRoot } from '../../core/vfs-documents.mjs';
|
|
22
30
|
import {
|
|
@@ -304,6 +312,63 @@ router.post('/api/mesh/preauthkey', requireAuth, requireScope('config:write'), a
|
|
|
304
312
|
}
|
|
305
313
|
});
|
|
306
314
|
|
|
315
|
+
/**
|
|
316
|
+
* POST /api/mesh/register-node
|
|
317
|
+
* Approve a device that registered interactively.
|
|
318
|
+
*
|
|
319
|
+
* This is the ONLY path that works for iOS. The upstream Tailscale iOS app
|
|
320
|
+
* does not accept a pre-auth key when pointed at a custom control server, so a
|
|
321
|
+
* phone always lands on headscale's "run this command on the server" page and
|
|
322
|
+
* waits for server-side approval. Without this endpoint that approval was only
|
|
323
|
+
* reachable over SSH + CLI, which is exactly the machine the user is not
|
|
324
|
+
* holding.
|
|
325
|
+
*
|
|
326
|
+
* Body: { auth_id, user? }
|
|
327
|
+
*/
|
|
328
|
+
router.post('/api/mesh/register-node', requireAuth, requireScope('config:write'), async (req, res) => {
|
|
329
|
+
try {
|
|
330
|
+
const authId = String(req.body?.auth_id || '').trim();
|
|
331
|
+
if (!authId) {
|
|
332
|
+
return res.status(400).json({ success: false, message: 'auth_id is required (the hskey-authreq-… value shown on the device)' });
|
|
333
|
+
}
|
|
334
|
+
// Fail with the fix rather than passing a malformed id through to a 500.
|
|
335
|
+
if (!authId.startsWith(AUTH_REQUEST_PREFIX)) {
|
|
336
|
+
return res.status(400).json({
|
|
337
|
+
success: false,
|
|
338
|
+
message: `that does not look like a registration id — expected it to start with "${AUTH_REQUEST_PREFIX}". `
|
|
339
|
+
+ 'Copy the whole value from the device screen, not the surrounding command.',
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const user = await resolveHeadscaleUser(BRAIN_DIR, req.body?.user);
|
|
344
|
+
const query = `?user=${encodeURIComponent(user)}&key=${encodeURIComponent(authId)}`;
|
|
345
|
+
const result = await headscaleFetch(`/api/v1/node/register${query}`, { method: 'POST' }, BRAIN_DIR);
|
|
346
|
+
const node = (typeof result === 'string' ? JSON.parse(result) : result)?.node ?? null;
|
|
347
|
+
|
|
348
|
+
res.json({
|
|
349
|
+
success: true,
|
|
350
|
+
user,
|
|
351
|
+
node: node && {
|
|
352
|
+
id: node.id ?? null,
|
|
353
|
+
name: node.name ?? node.givenName ?? null,
|
|
354
|
+
ip_addresses: node.ipAddresses ?? [],
|
|
355
|
+
online: node.online ?? false,
|
|
356
|
+
},
|
|
357
|
+
message: node?.name ? `${node.name} joined the tailnet.` : 'Device registered.',
|
|
358
|
+
});
|
|
359
|
+
} catch (err) {
|
|
360
|
+
const msg = String(err?.message || '');
|
|
361
|
+
// headscale answers 500 for an expired/unknown id; say so in those terms.
|
|
362
|
+
const stale = /not found|expired|invalid/i.test(msg);
|
|
363
|
+
res.status(stale ? 409 : 500).json({
|
|
364
|
+
success: false,
|
|
365
|
+
message: stale
|
|
366
|
+
? 'That registration id is no longer valid — it expires quickly. Reopen sign-in on the device to get a fresh one.'
|
|
367
|
+
: msg || 'could not register device',
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
});
|
|
371
|
+
|
|
307
372
|
/**
|
|
308
373
|
* This host's I/O capability profile for agent UI generation
|
|
309
374
|
* (screen, touch, mic, speaker, keyboard, camera, headless, …).
|
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
{"id":"7478a515-ea8e-4360-93b8-a10cf0642d88","topic":"npm Registry API documentation and developer endpoints","status":"pending","priority":"medium","notes":"Consult official npm documentation for registry APIs, authentication, and package metadata without using raw key strings.","node_slug":null,"research_phase":"acquisition","created_at":"2026-08-18T20:58:08.631Z","updated_at":"2026-08-18T20:58:08.631Z","completed_at":null,"summary":{"id":"7478a515-ea8e-4360-93b8-a10cf0642d88","topic":"npm Registry API documentation and developer endpoints","status":"pending","phase":"acquisition","last_updated":"2026-08-18T20:58:08.631Z","conclusions":[],"ongoing_directions":["Research project is enqueued / in progress in phase: acquisition"],"sources_count":0}}
|
|
2
|
-
{"id":"f9757282-dad8-4c3b-b44d-c796555468c5","topic":"Supabase Client, Auth, and REST APIs","status":"pending","priority":"medium","notes":"Use official Supabase documentation to understand authentication, database, and storage API surfaces.","node_slug":null,"research_phase":"acquisition","created_at":"2026-08-18T20:57:04.280Z","updated_at":"2026-08-18T20:57:04.280Z","completed_at":null,"summary":{"id":"f9757282-dad8-4c3b-b44d-c796555468c5","topic":"Supabase Client, Auth, and REST APIs","status":"pending","phase":"acquisition","last_updated":"2026-08-18T20:57:04.280Z","conclusions":[],"ongoing_directions":["Research project is enqueued / in progress in phase: acquisition"],"sources_count":0}}
|
|
3
|
-
{"id":"e196b27d-c799-4e60-88d0-585c479b6053","topic":"Supabase Database, Auth, Storage, and Management APIs","status":"pending","priority":"medium","notes":"Consult official Supabase documentation for REST API, SDK, and platform integration; do not use raw secret key string.","node_slug":null,"research_phase":"acquisition","created_at":"2026-08-18T20:56:34.347Z","updated_at":"2026-08-18T20:56:34.347Z","completed_at":null,"summary":{"id":"e196b27d-c799-4e60-88d0-585c479b6053","topic":"Supabase Database, Auth, Storage, and Management APIs","status":"pending","phase":"acquisition","last_updated":"2026-08-18T20:56:34.347Z","conclusions":[],"ongoing_directions":["Research project is enqueued / in progress in phase: acquisition"],"sources_count":0}}
|
|
4
|
-
{"id":"f6c787c9-212c-4468-a276-5af7d7d78dbc","topic":"Supabase REST API and Client SDKs","status":"pending","priority":"medium","notes":"Use official Supabase documentation for client SDKs, REST API, Database, and Auth endpoints. Do not search using raw key values.","node_slug":null,"research_phase":"acquisition","created_at":"2026-08-18T20:56:21.400Z","updated_at":"2026-08-18T20:56:21.400Z","completed_at":null,"summary":{"id":"f6c787c9-212c-4468-a276-5af7d7d78dbc","topic":"Supabase REST API and Client SDKs","status":"pending","phase":"acquisition","last_updated":"2026-08-18T20:56:21.400Z","conclusions":[],"ongoing_directions":["Research project is enqueued / in progress in phase: acquisition"],"sources_count":0}}
|
|
5
|
-
{"id":"c95b76a3-9bd3-42fd-8c37-0765cabef6a8","topic":"Documenso document signing and template REST API","status":"pending","priority":"medium","notes":"Consult official Documenso documentation for REST API endpoints, document signing workflows, and API key authentication. Do not query using the raw key string.","node_slug":null,"research_phase":"acquisition","created_at":"2026-08-18T20:55:37.372Z","updated_at":"2026-08-18T20:55:37.372Z","completed_at":null,"summary":{"id":"c95b76a3-9bd3-42fd-8c37-0765cabef6a8","topic":"Documenso document signing and template REST API","status":"pending","phase":"acquisition","last_updated":"2026-08-18T20:55:37.372Z","conclusions":[],"ongoing_directions":["Research project is enqueued / in progress in phase: acquisition"],"sources_count":0}}
|
|
6
|
-
{"id":"9b1f9703-76c3-4743-916a-a3340c2b74f4","topic":"Claude Code messaging API and CLI integration","status":"pending","priority":"medium","notes":"Consult official Anthropic Claude Code documentation for API and integration details; do not use the raw key string.","node_slug":null,"research_phase":"acquisition","created_at":"2026-08-18T20:55:25.048Z","updated_at":"2026-08-18T20:55:25.048Z","completed_at":null,"summary":{"id":"9b1f9703-76c3-4743-916a-a3340c2b74f4","topic":"Claude Code messaging API and CLI integration","status":"pending","phase":"acquisition","last_updated":"2026-08-18T20:55:25.048Z","conclusions":[],"ongoing_directions":["Research project is enqueued / in progress in phase: acquisition"],"sources_count":0}}
|
|
7
|
-
{"id":"0efa65c2-0231-4267-a304-e0285e50011e","topic":"Total Recall repository improvement research: SSSS validated write pipeline, filesystem-native memory engines, local-first semantic indexing, Express 5 API hardening, 2026 agent memory architecture patterns","status":"pending","priority":"high","notes":"Queued from repo-wide analysis request on 2026-06-01. Focus on current best practices and meta-patterns for filesystem-native memory OS, validated operation envelopes, append-only logs, cache invalidation, research queue architecture, and secure local-first AI tools.","node_slug":null,"research_phase":"acquisition","created_at":"2026-06-01T06:07:40.102Z","updated_at":"2026-06-01T06:07:40.126Z","completed_at":null,"summary":{"id":"0efa65c2-0231-4267-a304-e0285e50011e","topic":"Total Recall repository improvement research: SSSS validated write pipeline, filesystem-native memory engines, local-first semantic indexing, Express 5 API hardening, 2026 agent memory architecture patterns","status":"pending","phase":"acquisition","last_updated":"2026-06-01T06:07:40.126Z","conclusions":[],"ongoing_directions":["Research project is enqueued / in progress in phase: acquisition"],"sources_count":0}}
|