total-recall-brain 3.21.2 → 3.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/frontend/dist/assets/index-C7_Xpk27.css +1 -0
- package/frontend/dist/assets/{index-D0XeeXhG.js → index-DJjuZ3t3.js} +106 -106
- package/frontend/dist/index.html +2 -2
- package/frontend/src/App.spec.tsx +1 -1
- package/frontend/src/api/_base.ts +3 -3
- package/frontend/src/api/embeddings.ts +46 -0
- package/frontend/src/api/mesh.ts +47 -0
- package/frontend/src/api/network.spec.ts +9 -9
- package/frontend/src/api/network.ts +4 -4
- package/frontend/src/api/notifications.ts +5 -4
- package/frontend/src/api/webhooks.ts +1 -1
- package/frontend/src/components/BrainSelector.spec.tsx +4 -4
- package/frontend/src/components/BrainSelector.tsx +1 -1
- package/frontend/src/components/ContextualHelp.spec.tsx +14 -14
- package/frontend/src/components/ContextualHelp.tsx +3 -1
- package/frontend/src/components/DocumentTable.spec.tsx +1 -1
- package/frontend/src/components/EmbeddingProviderPanel.spec.tsx +127 -0
- package/frontend/src/components/EmbeddingProviderPanel.tsx +177 -0
- package/frontend/src/components/Graph3D.spec.tsx +4 -4
- package/frontend/src/components/Graph3D.tsx +3 -3
- package/frontend/src/components/MobilePairing.spec.tsx +1 -1
- package/frontend/src/components/MobilePairing.tsx +1 -1
- package/frontend/src/components/UsageChart.spec.tsx +4 -4
- package/frontend/src/components/UsageChart.tsx +1 -1
- package/frontend/src/components/VoiceInput.spec.tsx +6 -6
- package/frontend/src/components/VoiceInput.tsx +19 -4
- package/frontend/src/components/brand/BrandMark.spec.tsx +3 -4
- package/frontend/src/index.css +6 -0
- package/frontend/src/pages/AutomationsPage.spec.tsx +4 -4
- package/frontend/src/pages/ChatPage.spec.tsx +3 -3
- package/frontend/src/pages/CollabPage.spec.tsx +1 -2
- package/frontend/src/pages/DesignDocsPage.spec.tsx +1 -1
- package/frontend/src/pages/FilesPage.spec.tsx +2 -2
- package/frontend/src/pages/GraphPage.spec.tsx +1 -1
- package/frontend/src/pages/HealthPage.spec.tsx +2 -2
- package/frontend/src/pages/HelpPage.spec.tsx +5 -5
- package/frontend/src/pages/InboxPage.spec.tsx +4 -4
- package/frontend/src/pages/InstructionsPage.spec.tsx +5 -5
- package/frontend/src/pages/IntegrationsPage.spec.tsx +7 -7
- package/frontend/src/pages/MemoryPage.spec.tsx +2 -2
- package/frontend/src/pages/MeshPage.spec.tsx +111 -2
- package/frontend/src/pages/MeshPage.tsx +95 -11
- package/frontend/src/pages/NetworkPage.spec.tsx +7 -8
- package/frontend/src/pages/NetworkPage.tsx +12 -12
- package/frontend/src/pages/NotificationsPage.tsx +9 -9
- package/frontend/src/pages/OnboardingPage.spec.tsx +7 -7
- package/frontend/src/pages/OpenWikiPage.tsx +3 -3
- package/frontend/src/pages/RulesPage.tsx +3 -3
- package/frontend/src/pages/SecretsPage.spec.tsx +5 -5
- package/frontend/src/pages/SecretsPage.tsx +13 -14
- package/frontend/src/pages/SettingsPage.spec.tsx +6 -6
- package/frontend/src/pages/SettingsPage.tsx +2 -2
- package/frontend/src/pages/SkillsPage.spec.tsx +1 -2
- package/frontend/src/pages/SkillsPage.tsx +3 -3
- package/frontend/src/pages/TasksPage.spec.tsx +3 -3
- package/frontend/src/pages/UsagePage.spec.tsx +2 -2
- package/frontend/src/pages/UsagePage.tsx +1 -1
- package/frontend/src/pages/WebhooksPage.spec.tsx +1 -1
- package/frontend/src/pages/WebhooksPage.tsx +19 -19
- package/frontend/src/setupTests.ts +4 -4
- package/package.json +1 -1
- package/scaffold/.agent/skills/total-recall/SKILL.md +92 -0
- package/scaffold/.agent/skills/total-recall/modules/agents/agents.yml +15 -13
- package/scaffold/.agent/skills/total-recall/research-queue.jsonl +0 -1
- package/src/cli/secret.mjs +93 -12
- package/src/core/browser-session.mjs +185 -0
- package/src/core/daemon-loop.mjs +28 -1
- package/src/core/embeddings.mjs +52 -10
- package/src/core/headscale-client.mjs +227 -0
- package/src/core/mesh-enroll.mjs +439 -0
- package/src/core/ollama-embeddings.mjs +336 -0
- package/src/core/provider-catalog.mjs +175 -6
- package/src/core/provider-rotation-recipes.mjs +299 -0
- package/src/core/rotation-capability.mjs +189 -0
- package/src/core/secrets-rotate.mjs +192 -0
- package/src/server/rest.mjs +2 -0
- package/src/server/route-manifest.json +20 -0
- package/src/server/routes/embeddings.mjs +111 -0
- package/src/server/routes/headscale.mjs +21 -81
- package/src/server/routes/mesh.mjs +41 -0
- package/frontend/dist/assets/index-B4RASWbV.css +0 -1
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authenticated browser sessions for TR.
|
|
3
|
+
*
|
|
4
|
+
* TR's existing Playwright use (source-adapters.playwrightScrape) is stateless
|
|
5
|
+
* scraping: `chromium.launch()` with a throwaway context. Provider consoles
|
|
6
|
+
* require a logged-in session, so rotation needs a *persistent* profile that
|
|
7
|
+
* survives across runs — you log in once per provider, and the daemon can drive
|
|
8
|
+
* rotations later without re-auth.
|
|
9
|
+
*
|
|
10
|
+
* SECURITY: the profile directory holds live session cookies for Stripe,
|
|
11
|
+
* GitHub, etc. It is at least as sensitive as secrets.enc and is created 0700.
|
|
12
|
+
* Never copy it, never sync it, never log its contents.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
|
|
18
|
+
const PROFILE_DIRNAME = 'browser-profile';
|
|
19
|
+
|
|
20
|
+
const PROFILE_README = `# TR browser profile — DO NOT SYNC, DO NOT COPY
|
|
21
|
+
|
|
22
|
+
This directory holds live authenticated sessions (Stripe, GitHub, cloud consoles)
|
|
23
|
+
used by \`total-recall secret rotate-auto\`.
|
|
24
|
+
|
|
25
|
+
Treat it as equivalent to secrets.enc:
|
|
26
|
+
- never commit it
|
|
27
|
+
- never include it in backups that leave this machine
|
|
28
|
+
- delete it with \`total-recall secret browser-logout\` when no longer needed
|
|
29
|
+
`;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Load chromium if playwright is installed. Mirrors source-adapters.getPlaywright
|
|
33
|
+
* so browser support stays an optional dependency.
|
|
34
|
+
* @returns {Promise<any|null>}
|
|
35
|
+
*/
|
|
36
|
+
export async function getChromium() {
|
|
37
|
+
try {
|
|
38
|
+
const pw = await import('playwright');
|
|
39
|
+
return pw.chromium || pw.default?.chromium || null;
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @param {string} brainDir
|
|
47
|
+
* @returns {string}
|
|
48
|
+
*/
|
|
49
|
+
export function resolveProfileDir(brainDir) {
|
|
50
|
+
return path.join(brainDir, PROFILE_DIRNAME);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Create the profile dir with restrictive permissions if absent.
|
|
55
|
+
* @param {string} brainDir
|
|
56
|
+
*/
|
|
57
|
+
export function ensureProfileDir(brainDir) {
|
|
58
|
+
const dir = resolveProfileDir(brainDir);
|
|
59
|
+
if (!fs.existsSync(dir)) {
|
|
60
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
61
|
+
fs.writeFileSync(path.join(dir, 'README.md'), PROFILE_README, { mode: 0o600 });
|
|
62
|
+
}
|
|
63
|
+
try {
|
|
64
|
+
fs.chmodSync(dir, 0o700);
|
|
65
|
+
} catch {
|
|
66
|
+
/* best effort on exotic filesystems */
|
|
67
|
+
}
|
|
68
|
+
return dir;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Launch a persistent, authenticated browser context.
|
|
73
|
+
*
|
|
74
|
+
* @param {string} brainDir
|
|
75
|
+
* @param {{ headless?: boolean, timeoutMs?: number }} [opts]
|
|
76
|
+
* @returns {Promise<{ ok: boolean, context?: any, error?: string, profileDir?: string }>}
|
|
77
|
+
*/
|
|
78
|
+
export async function launchRotationContext(brainDir, opts = {}) {
|
|
79
|
+
const chromium = await getChromium();
|
|
80
|
+
if (!chromium) {
|
|
81
|
+
return {
|
|
82
|
+
ok: false,
|
|
83
|
+
error:
|
|
84
|
+
'playwright is not installed. Run: npm install playwright && npx playwright install chromium',
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const profileDir = ensureProfileDir(brainDir);
|
|
89
|
+
// Headed by default: provider logins routinely require human 2FA, and a
|
|
90
|
+
// headless first-run would simply hang on an invisible challenge.
|
|
91
|
+
const headless = opts.headless === true;
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
const context = await chromium.launchPersistentContext(profileDir, {
|
|
95
|
+
headless,
|
|
96
|
+
viewport: { width: 1440, height: 900 },
|
|
97
|
+
args: ['--disable-blink-features=AutomationControlled', '--no-first-run'],
|
|
98
|
+
});
|
|
99
|
+
context.setDefaultTimeout(opts.timeoutMs ?? 30_000);
|
|
100
|
+
return { ok: true, context, profileDir };
|
|
101
|
+
} catch (err) {
|
|
102
|
+
return { ok: false, error: `failed to launch browser profile: ${err.message}`, profileDir };
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Navigate to a console and report whether the session is authenticated.
|
|
108
|
+
*
|
|
109
|
+
* Recipes describe authentication by what is visible, not by URL alone, because
|
|
110
|
+
* most consoles redirect to a login page rather than returning an error status.
|
|
111
|
+
*
|
|
112
|
+
* @param {any} context
|
|
113
|
+
* @param {{ console_url: string, signed_in?: string, signed_out?: string }} recipe
|
|
114
|
+
* @returns {Promise<{ page: any, authenticated: boolean, url: string }>}
|
|
115
|
+
*/
|
|
116
|
+
export async function openConsole(context, recipe) {
|
|
117
|
+
const page = context.pages()[0] || (await context.newPage());
|
|
118
|
+
await page.goto(recipe.console_url, { waitUntil: 'domcontentloaded' });
|
|
119
|
+
// Consoles are SPA-heavy; give client-side auth redirects a beat to settle.
|
|
120
|
+
await page.waitForTimeout(1500);
|
|
121
|
+
|
|
122
|
+
const authenticated = await isAuthenticated(page, recipe);
|
|
123
|
+
return { page, authenticated, url: page.url() };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* @param {any} page
|
|
128
|
+
* @param {{ signed_in?: string, signed_out?: string }} recipe
|
|
129
|
+
* @returns {Promise<boolean>}
|
|
130
|
+
*/
|
|
131
|
+
export async function isAuthenticated(page, recipe) {
|
|
132
|
+
// A visible signed-out marker (login form, "Sign in" CTA) is decisive.
|
|
133
|
+
if (recipe.signed_out) {
|
|
134
|
+
const out = await page.locator(recipe.signed_out).first().isVisible().catch(() => false);
|
|
135
|
+
if (out) return false;
|
|
136
|
+
}
|
|
137
|
+
if (recipe.signed_in) {
|
|
138
|
+
return page.locator(recipe.signed_in).first().isVisible().catch(() => false);
|
|
139
|
+
}
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Wait for a human to complete login/2FA in the headed window.
|
|
145
|
+
*
|
|
146
|
+
* @param {any} page
|
|
147
|
+
* @param {object} recipe
|
|
148
|
+
* @param {{ timeoutMs?: number, pollMs?: number, onWait?: (s:number)=>void }} [opts]
|
|
149
|
+
* @returns {Promise<boolean>}
|
|
150
|
+
*/
|
|
151
|
+
export async function waitForLogin(page, recipe, opts = {}) {
|
|
152
|
+
const timeoutMs = opts.timeoutMs ?? 180_000;
|
|
153
|
+
const pollMs = opts.pollMs ?? 2000;
|
|
154
|
+
const started = Date.now();
|
|
155
|
+
|
|
156
|
+
while (Date.now() - started < timeoutMs) {
|
|
157
|
+
if (await isAuthenticated(page, recipe)) return true;
|
|
158
|
+
opts.onWait?.(Math.round((Date.now() - started) / 1000));
|
|
159
|
+
await page.waitForTimeout(pollMs);
|
|
160
|
+
}
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Close a context without throwing.
|
|
166
|
+
* @param {any} context
|
|
167
|
+
*/
|
|
168
|
+
export async function closeContext(context) {
|
|
169
|
+
try {
|
|
170
|
+
await context?.close();
|
|
171
|
+
} catch {
|
|
172
|
+
/* already closed */
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Delete the persistent profile (logout everywhere).
|
|
178
|
+
* @param {string} brainDir
|
|
179
|
+
*/
|
|
180
|
+
export function clearProfile(brainDir) {
|
|
181
|
+
const dir = resolveProfileDir(brainDir);
|
|
182
|
+
if (!fs.existsSync(dir)) return { removed: false, path: dir };
|
|
183
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
184
|
+
return { removed: true, path: dir };
|
|
185
|
+
}
|
package/src/core/daemon-loop.mjs
CHANGED
|
@@ -232,9 +232,25 @@ async function main() {
|
|
|
232
232
|
logger.info({ subsystem: 'daemon-loop', message: `Boot repo-sync failed: ${err.message}` });
|
|
233
233
|
}
|
|
234
234
|
|
|
235
|
+
// Automatic mesh enrollment — a node that never authenticated to the control
|
|
236
|
+
// server has no mesh IP, so this must run before the first mesh patch.
|
|
237
|
+
try {
|
|
238
|
+
const { ensureEnrolled } = await import('./mesh-enroll.mjs');
|
|
239
|
+
const enrollment = await ensureEnrolled({ brainDir: BRAIN_DIR });
|
|
240
|
+
if (!enrollment.skipped) {
|
|
241
|
+
logger.info({
|
|
242
|
+
subsystem: 'daemon-loop',
|
|
243
|
+
message: `Boot mesh enrollment: ok=${enrollment.ok} state=${enrollment.state} method=${enrollment.method || 'n/a'}`,
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
} catch (err) {
|
|
247
|
+
logger.info({ subsystem: 'daemon-loop', message: `Boot mesh enrollment failed: ${err.message}` });
|
|
248
|
+
}
|
|
249
|
+
|
|
235
250
|
// Initial mesh patch
|
|
236
251
|
try {
|
|
237
|
-
const { patchOwnMeshNode } = await import('./mesh.mjs');
|
|
252
|
+
const { clearMeshStatusCache, patchOwnMeshNode } = await import('./mesh.mjs');
|
|
253
|
+
clearMeshStatusCache();
|
|
238
254
|
await patchOwnMeshNode();
|
|
239
255
|
logger.info({ subsystem: 'daemon-loop', message: 'Mesh node patched on boot' });
|
|
240
256
|
} catch (err) {
|
|
@@ -306,6 +322,17 @@ async function main() {
|
|
|
306
322
|
// Periodically patch own mesh node (heartbeat)
|
|
307
323
|
if (taskCount > 0 && taskCount % (60000 / TASK_SLEEP_MS) === 0) {
|
|
308
324
|
try {
|
|
325
|
+
// Self-heal enrollment: a node that dropped off the control server
|
|
326
|
+
// (logged out, expired node key) rejoins without user intervention.
|
|
327
|
+
// Internally throttled, so this is cheap on every heartbeat.
|
|
328
|
+
const { ensureEnrolled } = await import('./mesh-enroll.mjs');
|
|
329
|
+
const enrollment = await ensureEnrolled({ brainDir: BRAIN_DIR });
|
|
330
|
+
if (enrollment.changed) {
|
|
331
|
+
logger.info({
|
|
332
|
+
subsystem: 'daemon-loop',
|
|
333
|
+
message: `Mesh re-enrolled via ${enrollment.method || 'unknown'}`,
|
|
334
|
+
});
|
|
335
|
+
}
|
|
309
336
|
const { patchOwnMeshNode } = await import('./mesh.mjs');
|
|
310
337
|
await patchOwnMeshNode();
|
|
311
338
|
// renewLease is a no-op shim (re-checks isLeader); kept for API stability.
|
package/src/core/embeddings.mjs
CHANGED
|
@@ -6,8 +6,13 @@
|
|
|
6
6
|
*
|
|
7
7
|
* Index file: .agent/memory-derived/embeddings.db
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
9
|
+
* Providers, in order: Ollama (local/mesh) → OpenRouter → Google → OpenAI.
|
|
10
|
+
* Ollama leads because it needs no key, costs nothing, and keeps working when
|
|
11
|
+
* a hosted provider is out of credit — the cloud providers are the fallback,
|
|
12
|
+
* not the default.
|
|
13
|
+
*
|
|
14
|
+
* Every provider must return EMBEDDING_DIMS-wide vectors; the vss0 index is
|
|
15
|
+
* declared at that width and a mismatch corrupts search silently.
|
|
11
16
|
*/
|
|
12
17
|
|
|
13
18
|
import fs from 'node:fs';
|
|
@@ -22,9 +27,21 @@ import {
|
|
|
22
27
|
|
|
23
28
|
import { googleApiKey, openRouterApiKey, embedModel, brainDir as defaultBrainDir } from './config.mjs';
|
|
24
29
|
import { logger } from './logger.mjs';
|
|
30
|
+
import {
|
|
31
|
+
resolveOllamaEndpoint,
|
|
32
|
+
selectEmbeddingModel,
|
|
33
|
+
getOllamaEmbedding,
|
|
34
|
+
} from './ollama-embeddings.mjs';
|
|
25
35
|
|
|
26
36
|
const DEFAULT_EMBED_MODEL = embedModel;
|
|
27
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Vector width of the vault index. The vss0 virtual tables are declared at this
|
|
40
|
+
* width, so it is a storage constraint rather than a tuning knob — changing it
|
|
41
|
+
* requires rebuilding every stored embedding.
|
|
42
|
+
*/
|
|
43
|
+
export const EMBEDDING_DIMS = 768;
|
|
44
|
+
|
|
28
45
|
/** Resolve API keys at call time so tests can set env after import. */
|
|
29
46
|
function resolveGoogleApiKey() {
|
|
30
47
|
return googleApiKey || process.env.GOOGLE_API_KEY || process.env.GEMINI_API_KEY || null;
|
|
@@ -111,10 +128,10 @@ function getDb(derivedDir) {
|
|
|
111
128
|
if (vssOk) {
|
|
112
129
|
db.exec(`
|
|
113
130
|
CREATE VIRTUAL TABLE IF NOT EXISTS vss_vault_embeddings USING vss0(
|
|
114
|
-
embedding(
|
|
131
|
+
embedding(${EMBEDDING_DIMS})
|
|
115
132
|
);
|
|
116
133
|
CREATE VIRTUAL TABLE IF NOT EXISTS vss_session_embeddings USING vss0(
|
|
117
|
-
embedding(
|
|
134
|
+
embedding(${EMBEDDING_DIMS})
|
|
118
135
|
);
|
|
119
136
|
`);
|
|
120
137
|
}
|
|
@@ -242,9 +259,9 @@ async function getOpenRouterEmbedding(text, model = 'openai/text-embedding-3-sma
|
|
|
242
259
|
'Content-Type': 'application/json',
|
|
243
260
|
'Authorization': `Bearer ${openRouterKey}`,
|
|
244
261
|
},
|
|
245
|
-
//
|
|
246
|
-
//
|
|
247
|
-
body: JSON.stringify({ model, input: text, dimensions:
|
|
262
|
+
// Match the vault's declared vector width so results stay comparable
|
|
263
|
+
// against previously-stored embeddings.
|
|
264
|
+
body: JSON.stringify({ model, input: text, dimensions: EMBEDDING_DIMS }),
|
|
248
265
|
}, 20000);
|
|
249
266
|
|
|
250
267
|
if (!res.ok) {
|
|
@@ -296,10 +313,32 @@ export async function getEmbedding(text, _unused, model = DEFAULT_EMBED_MODEL) {
|
|
|
296
313
|
|
|
297
314
|
let embedding;
|
|
298
315
|
|
|
299
|
-
//
|
|
316
|
+
// Local/mesh Ollama first: no key, no spend, no rate limit, and unaffected by
|
|
317
|
+
// a hosted provider running out of credit. The model is discovered from the
|
|
318
|
+
// server at call time rather than named here.
|
|
319
|
+
try {
|
|
320
|
+
const baseUrl = await resolveOllamaEndpoint();
|
|
321
|
+
if (baseUrl) {
|
|
322
|
+
const ollamaModel = await selectEmbeddingModel(baseUrl, {
|
|
323
|
+
dims: EMBEDDING_DIMS,
|
|
324
|
+
preferred: process.env.TR_OLLAMA_EMBED_MODEL || null,
|
|
325
|
+
});
|
|
326
|
+
if (ollamaModel) {
|
|
327
|
+
embedding = await getOllamaEmbedding(input, {
|
|
328
|
+
baseUrl,
|
|
329
|
+
model: ollamaModel,
|
|
330
|
+
dims: EMBEDDING_DIMS,
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
} catch (err) {
|
|
335
|
+
logger.debug('embeddings: Ollama unavailable, trying hosted providers', { err: err.message });
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// OpenRouter next: standing preference (Gemini's budget is exhausted, so
|
|
300
339
|
// Google is a near-guaranteed slow failure right now — trying it first just
|
|
301
340
|
// taxes every call with a ~15-20s timeout before falling through).
|
|
302
|
-
if (openRouterKey) {
|
|
341
|
+
if (!embedding && openRouterKey) {
|
|
303
342
|
try {
|
|
304
343
|
embedding = await getOpenRouterEmbedding(input);
|
|
305
344
|
} catch (err) {
|
|
@@ -325,7 +364,10 @@ export async function getEmbedding(text, _unused, model = DEFAULT_EMBED_MODEL) {
|
|
|
325
364
|
}
|
|
326
365
|
|
|
327
366
|
if (!embedding) {
|
|
328
|
-
throw new Error(
|
|
367
|
+
throw new Error(
|
|
368
|
+
'No embedding provider available. Run Ollama with an embedding model (optionally set TR_OLLAMA_URL / TR_OLLAMA_EMBED_MODEL), ' +
|
|
369
|
+
'or set OPENROUTER_API_KEY, GOOGLE_API_KEY, or OPENAI_API_KEY.',
|
|
370
|
+
);
|
|
329
371
|
}
|
|
330
372
|
|
|
331
373
|
return embedding;
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Headscale control-server client.
|
|
3
|
+
*
|
|
4
|
+
* Single implementation shared by the REST proxy (`routes/headscale.mjs`) and
|
|
5
|
+
* automatic node enrollment (`mesh-enroll.mjs`). No control-server URL is ever
|
|
6
|
+
* hardcoded here — it is resolved from the operator's own secret entry or
|
|
7
|
+
* environment, so this works against any Headscale (or Tailscale SaaS) install.
|
|
8
|
+
*/
|
|
9
|
+
import { getSecretsCatalog, getSecret } from './secrets-store.mjs';
|
|
10
|
+
import { throttledFetch } from './throttled-fetch.mjs';
|
|
11
|
+
|
|
12
|
+
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
13
|
+
|
|
14
|
+
/** Env override so headless/CI installs can point at a control server without a secret. */
|
|
15
|
+
export function headscaleUrlFromEnv() {
|
|
16
|
+
const raw = process.env.TR_HEADSCALE_URL || process.env.TR_LOGIN_SERVER || '';
|
|
17
|
+
return raw.trim() || null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Reject non-HTTPS control servers (loopback excepted for local development).
|
|
22
|
+
* Bearer tokens must never travel in cleartext over a network hop.
|
|
23
|
+
*/
|
|
24
|
+
export function assertSecureControlUrl(url) {
|
|
25
|
+
const parsed = new URL(url);
|
|
26
|
+
const isLoopback = ['localhost', '127.0.0.1', '::1'].includes(parsed.hostname);
|
|
27
|
+
if (parsed.protocol !== 'https:' && !(isLoopback && parsed.protocol === 'http:')) {
|
|
28
|
+
throw new Error('Headscale URL must use HTTPS (HTTP is allowed only for loopback development)');
|
|
29
|
+
}
|
|
30
|
+
return parsed;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Strip a trailing slash so path joining stays predictable. */
|
|
34
|
+
export function normalizeControlUrl(url) {
|
|
35
|
+
const value = String(url || '').trim();
|
|
36
|
+
return value.endsWith('/') ? value.slice(0, -1) : value;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Locate the operator's headscale credential without knowing its key name.
|
|
41
|
+
* Any secret whose provider is `headscale` is treated as the control-server token.
|
|
42
|
+
*/
|
|
43
|
+
export async function findHeadscaleKeyMeta(brainDir) {
|
|
44
|
+
const catalog = await getSecretsCatalog(brainDir);
|
|
45
|
+
return catalog.keys.find((k) => k.provider === 'headscale') || null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Resolve `{ url, token }` for the control server.
|
|
50
|
+
* Throws when no credential is configured — callers that must degrade
|
|
51
|
+
* gracefully should use `describeHeadscaleAvailability()` instead.
|
|
52
|
+
*/
|
|
53
|
+
export async function resolveHeadscaleConfig(brainDir) {
|
|
54
|
+
const meta = await findHeadscaleKeyMeta(brainDir);
|
|
55
|
+
if (!meta) {
|
|
56
|
+
throw new Error('Headscale API Key not configured in ApiKeysPage');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const got = await getSecret(brainDir, meta.key, {
|
|
60
|
+
action: 'use',
|
|
61
|
+
actor: 'headscale-client',
|
|
62
|
+
});
|
|
63
|
+
if (!got.found || !got.value) {
|
|
64
|
+
throw new Error('Headscale API Key token value is empty or not set');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const url = normalizeControlUrl(meta.headscale_url || headscaleUrlFromEnv() || '');
|
|
68
|
+
if (!url) {
|
|
69
|
+
throw new Error('Headscale control server URL is not set on the headscale API key');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return { url, token: got.value, keyName: meta.key };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Non-throwing capability probe used by enrollment and status endpoints:
|
|
77
|
+
* reports whether an automated (pre-auth key) enrollment path is possible.
|
|
78
|
+
*/
|
|
79
|
+
export async function describeHeadscaleAvailability(brainDir) {
|
|
80
|
+
try {
|
|
81
|
+
const meta = await findHeadscaleKeyMeta(brainDir);
|
|
82
|
+
if (!meta) {
|
|
83
|
+
return { configured: false, reason: 'no-headscale-api-key', url: headscaleUrlFromEnv() };
|
|
84
|
+
}
|
|
85
|
+
const url = normalizeControlUrl(meta.headscale_url || headscaleUrlFromEnv() || '');
|
|
86
|
+
if (!url) {
|
|
87
|
+
return { configured: false, reason: 'no-control-url', url: null, keyName: meta.key };
|
|
88
|
+
}
|
|
89
|
+
if (!meta.set) {
|
|
90
|
+
return { configured: false, reason: 'api-key-value-empty', url, keyName: meta.key };
|
|
91
|
+
}
|
|
92
|
+
return { configured: true, reason: null, url, keyName: meta.key };
|
|
93
|
+
} catch (err) {
|
|
94
|
+
return { configured: false, reason: err.message || 'headscale-probe-failed', url: null };
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Authenticated request against the control server's API.
|
|
100
|
+
* `brainDir` is required so the caller's brain scope selects the credential.
|
|
101
|
+
*/
|
|
102
|
+
export async function headscaleFetch(path, options = {}, brainDir) {
|
|
103
|
+
const { url, token } = await resolveHeadscaleConfig(brainDir);
|
|
104
|
+
assertSecureControlUrl(url);
|
|
105
|
+
|
|
106
|
+
const cleanPath = path.startsWith('/') ? path : `/${path}`;
|
|
107
|
+
const targetUrl = `${normalizeControlUrl(url)}${cleanPath}`;
|
|
108
|
+
|
|
109
|
+
const res = await throttledFetch(
|
|
110
|
+
targetUrl,
|
|
111
|
+
{
|
|
112
|
+
...options,
|
|
113
|
+
headers: {
|
|
114
|
+
...options.headers,
|
|
115
|
+
Authorization: `Bearer ${token}`,
|
|
116
|
+
Accept: 'application/json',
|
|
117
|
+
'Content-Type': 'application/json',
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
options.timeoutMs || DEFAULT_TIMEOUT_MS,
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
if (!res.ok) {
|
|
124
|
+
const errorText = await res.text().catch(() => '');
|
|
125
|
+
const error = new Error(`Headscale API error (${res.status} ${res.statusText})`);
|
|
126
|
+
error.status = res.status;
|
|
127
|
+
error.detail = errorText.slice(0, 500);
|
|
128
|
+
throw error;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const contentType = res.headers.get('content-type') || '';
|
|
132
|
+
if (contentType.includes('application/json')) {
|
|
133
|
+
return res.json();
|
|
134
|
+
}
|
|
135
|
+
return { success: true };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Headscale renamed `/machine` to `/node`; retry the legacy path on 404 so one
|
|
140
|
+
* client works across control-server versions.
|
|
141
|
+
*/
|
|
142
|
+
export async function headscaleFetchWithLegacyFallback(currentPath, legacyPath, options, brainDir) {
|
|
143
|
+
try {
|
|
144
|
+
return await headscaleFetch(currentPath, options, brainDir);
|
|
145
|
+
} catch (err) {
|
|
146
|
+
if (err.status !== 404 || !legacyPath) throw err;
|
|
147
|
+
return headscaleFetch(legacyPath, options, brainDir);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Resolve a control-server user to both of its identifying forms.
|
|
153
|
+
*
|
|
154
|
+
* Headscale 0.26 made `/api/v1/preauthkey` take a numeric user **id** where
|
|
155
|
+
* older releases took the user **name**, so callers need both to stay
|
|
156
|
+
* version-agnostic. With no `requested` user, the first user is used.
|
|
157
|
+
*/
|
|
158
|
+
export async function resolveHeadscaleUser(brainDir, requested) {
|
|
159
|
+
const data = await headscaleFetch('/api/v1/user', {}, brainDir);
|
|
160
|
+
const users = data?.users || [];
|
|
161
|
+
|
|
162
|
+
if (requested) {
|
|
163
|
+
const match = users.find(
|
|
164
|
+
(u) =>
|
|
165
|
+
String(u.id) === String(requested) ||
|
|
166
|
+
u.name === requested ||
|
|
167
|
+
(u.email && u.email === requested),
|
|
168
|
+
);
|
|
169
|
+
if (match) return { id: String(match.id), name: match.name };
|
|
170
|
+
// Unknown to us — pass it through and let the control server decide.
|
|
171
|
+
return { id: String(requested), name: String(requested) };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (!users.length) return null;
|
|
175
|
+
return { id: String(users[0].id), name: users[0].name };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Mint a pre-auth key for unattended enrollment.
|
|
180
|
+
*
|
|
181
|
+
* Defaults are deliberately narrow: single-use, non-ephemeral, short-lived —
|
|
182
|
+
* an enrollment key that leaks should expire before it is useful.
|
|
183
|
+
*
|
|
184
|
+
* The user field is tried as id first, then name: Headscale >=0.26 rejects the
|
|
185
|
+
* name with 400, and older releases reject the id the same way, so one client
|
|
186
|
+
* works against both without the operator pinning a version.
|
|
187
|
+
*/
|
|
188
|
+
export async function createHeadscalePreAuthKey(options = {}, brainDir) {
|
|
189
|
+
const ttlMinutes = Number.isFinite(options.ttlMinutes) ? options.ttlMinutes : 10;
|
|
190
|
+
const expiration =
|
|
191
|
+
options.expiration || new Date(Date.now() + ttlMinutes * 60_000).toISOString();
|
|
192
|
+
|
|
193
|
+
const ref = options.userRef || (await resolveHeadscaleUser(brainDir, options.user));
|
|
194
|
+
const attempts = [];
|
|
195
|
+
if (ref?.id) attempts.push(String(ref.id));
|
|
196
|
+
if (ref?.name && String(ref.name) !== String(ref.id)) attempts.push(String(ref.name));
|
|
197
|
+
if (!attempts.length) attempts.push(String(options.user || 'default'));
|
|
198
|
+
|
|
199
|
+
let lastError = null;
|
|
200
|
+
for (const user of attempts) {
|
|
201
|
+
try {
|
|
202
|
+
const data = await headscaleFetch(
|
|
203
|
+
'/api/v1/preauthkey',
|
|
204
|
+
{
|
|
205
|
+
method: 'POST',
|
|
206
|
+
body: JSON.stringify({
|
|
207
|
+
user,
|
|
208
|
+
reusable: options.reusable === true,
|
|
209
|
+
ephemeral: options.ephemeral === true,
|
|
210
|
+
expiration,
|
|
211
|
+
aclTags: options.aclTags || [],
|
|
212
|
+
}),
|
|
213
|
+
},
|
|
214
|
+
brainDir,
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
const key = data?.preAuthKey?.key || data?.preauthkey?.key || data?.key || null;
|
|
218
|
+
if (!key) throw new Error('Headscale did not return a pre-auth key');
|
|
219
|
+
return { key, expiration, user, raw: data };
|
|
220
|
+
} catch (err) {
|
|
221
|
+
lastError = err;
|
|
222
|
+
// Only the id-vs-name mismatch is worth retrying; anything else is real.
|
|
223
|
+
if (err.status !== 400) throw err;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
throw lastError || new Error('Headscale did not return a pre-auth key');
|
|
227
|
+
}
|