total-recall-brain 3.24.2 → 3.25.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/src/cli/mesh.mjs CHANGED
@@ -108,6 +108,15 @@ export default async function meshCli(argv = []) {
108
108
  const vaultRoot = path.join(brainDir, 'memory-vault');
109
109
  const command = args.shift();
110
110
 
111
+ // `--help` after a subcommand must never EXECUTE that subcommand. Only the
112
+ // bare `mesh --help` was handled, so `mesh preauthkey --help` fell straight
113
+ // into the preauthkey branch and MINTED A LIVE ENROLLMENT KEY for someone who
114
+ // had asked for usage text — a credential issued by a help flag.
115
+ if (args.includes('--help') || args.includes('-h')) {
116
+ printHelp();
117
+ return;
118
+ }
119
+
111
120
  try {
112
121
  if (command === 'status') {
113
122
  const info = await describeHeadscaleAvailability(brainDir);
@@ -0,0 +1,130 @@
1
+ /**
2
+ * server-restart — restart the brain server so freshly installed code is the
3
+ * code that is actually running.
4
+ *
5
+ * Updating did not restart anything. `POST /api/update/run` installed
6
+ * total-recall-brain@latest and returned a summary; the server kept serving the
7
+ * modules Node had already loaded, and the update banner cleared — so a finished
8
+ * update looked identical to a running one. Only the daemon had a restart
9
+ * control; the server had none.
10
+ *
11
+ * The hard rule here: NEVER exit unless a supervisor will bring us back.
12
+ * Exiting an unsupervised server is not a restart, it is an outage, so
13
+ * supervision is proven (the job's reported PID is ours) rather than assumed.
14
+ */
15
+ import { execFileSync } from 'node:child_process';
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+
19
+ const LAUNCHD_LABELS = ['com.totalrecall.brain', 'com.totalrecall.server'];
20
+ const SYSTEMD_UNITS = ['total-recall-server', 'total-recall-server.service'];
21
+
22
+ function launchdPidFor(label) {
23
+ try {
24
+ const out = execFileSync('launchctl', ['list', label], { encoding: 'utf8', stdio: 'pipe' });
25
+ const m = out.match(/"PID"\s*=\s*(\d+)/);
26
+ return m ? Number(m[1]) : null;
27
+ } catch {
28
+ return null;
29
+ }
30
+ }
31
+
32
+ function systemdPidFor(unit) {
33
+ for (const scope of [['--user'], []]) {
34
+ try {
35
+ const out = execFileSync('systemctl', [...scope, 'show', '-p', 'MainPID', '--value', unit], {
36
+ encoding: 'utf8',
37
+ stdio: 'pipe',
38
+ });
39
+ const pid = Number(String(out).trim());
40
+ if (pid > 0) return { pid, scope: scope.length ? 'user' : 'system' };
41
+ } catch {
42
+ /* unit not present in this scope */
43
+ }
44
+ }
45
+ return null;
46
+ }
47
+
48
+ /**
49
+ * Prove that a supervisor owns THIS process and will restart it.
50
+ * @returns {{supervised: boolean, kind: string|null, label: string|null, reason: string}}
51
+ */
52
+ export function detectSupervisor(pid = process.pid) {
53
+ // An explicit opt-in for hosts running under a supervisor we cannot query
54
+ // (docker restart policies, pm2, runit). The operator asserts respawn.
55
+ if (process.env.TR_SUPERVISED === '1') {
56
+ return { supervised: true, kind: 'declared', label: 'TR_SUPERVISED=1', reason: 'declared by operator' };
57
+ }
58
+ if (process.platform === 'darwin') {
59
+ for (const label of LAUNCHD_LABELS) {
60
+ const jobPid = launchdPidFor(label);
61
+ if (jobPid === pid) {
62
+ return { supervised: true, kind: 'launchd', label, reason: `launchd job ${label} owns pid ${pid}` };
63
+ }
64
+ }
65
+ return { supervised: false, kind: null, label: null, reason: 'no launchd job reports this pid' };
66
+ }
67
+ for (const unit of SYSTEMD_UNITS) {
68
+ const found = systemdPidFor(unit);
69
+ if (found && found.pid === pid) {
70
+ return { supervised: true, kind: 'systemd', label: unit, reason: `systemd ${found.scope} unit ${unit} owns pid ${pid}` };
71
+ }
72
+ }
73
+ return { supervised: false, kind: null, label: null, reason: 'no systemd unit reports this pid' };
74
+ }
75
+
76
+ /**
77
+ * Schedule a graceful self-restart.
78
+ *
79
+ * Sends SIGTERM to ourselves rather than calling process.exit, so the existing
80
+ * shutdown path runs: the HTTP server drains, background work finishes, and the
81
+ * server PID lock is RELEASED. Skipping that lock release would leave the
82
+ * respawned instance hitting the singleton guard and exiting immediately —
83
+ * turning a restart into a permanent stop.
84
+ *
85
+ * The delay lets the HTTP response flush before the socket goes away.
86
+ *
87
+ * @returns {{scheduled: boolean, supervisor: object, delayMs?: number, reason?: string}}
88
+ */
89
+ export function requestSelfRestart({ delayMs = 750, force = false } = {}) {
90
+ const supervisor = detectSupervisor();
91
+ if (!supervisor.supervised && !force) {
92
+ return {
93
+ scheduled: false,
94
+ supervisor,
95
+ reason:
96
+ 'refusing to exit: no supervisor would restart this process, so exiting would take the server down permanently. '
97
+ + 'Run it under launchd/systemd, or set TR_SUPERVISED=1 if another supervisor guarantees respawn.',
98
+ };
99
+ }
100
+ const timer = setTimeout(() => {
101
+ process.kill(process.pid, 'SIGTERM');
102
+ }, delayMs);
103
+ timer.unref?.();
104
+ return { scheduled: true, supervisor, delayMs };
105
+ }
106
+
107
+ /**
108
+ * The version recorded in a package root's manifest, read from DISK rather than
109
+ * from the ESM import cache.
110
+ *
111
+ * This is how we decide whether an update actually replaced the code backing
112
+ * this process. `summary.updated > 0` cannot answer that: it counts registered
113
+ * projects, and updating some other project's node_modules is no reason to
114
+ * bounce this server. Comparing the manifest on disk against the version this
115
+ * process booted with is directly observable and right in every install shape —
116
+ * source tree (never changes from an npm install, so no restart), or a package
117
+ * installed under a consumer repo (npm replaces the directory we are running
118
+ * from, so restart).
119
+ *
120
+ * @param {string} rootDir package root to read
121
+ * @returns {string|null}
122
+ */
123
+ export function packageVersionOnDisk(rootDir) {
124
+ try {
125
+ const raw = fs.readFileSync(path.join(rootDir, 'package.json'), 'utf8');
126
+ return JSON.parse(raw).version || null;
127
+ } catch {
128
+ return null;
129
+ }
130
+ }
@@ -347,6 +347,10 @@
347
347
  "method": "GET",
348
348
  "path": "/api/mesh/nodes"
349
349
  },
350
+ {
351
+ "method": "POST",
352
+ "path": "/api/mesh/preauthkey"
353
+ },
350
354
  {
351
355
  "method": "GET",
352
356
  "path": "/api/network/audit"
@@ -575,6 +579,10 @@
575
579
  "method": "POST",
576
580
  "path": "/api/secrets/usage"
577
581
  },
582
+ {
583
+ "method": "POST",
584
+ "path": "/api/server/restart"
585
+ },
578
586
  {
579
587
  "method": "GET",
580
588
  "path": "/api/sessions"
@@ -16,6 +16,7 @@ import {
16
16
  readSshConfig,
17
17
  resolveNodeAccess,
18
18
  } from '../../core/mesh-access.mjs';
19
+ import { createHeadscalePreAuthKey, resolveHeadscaleConfig } from '../../core/headscale-client.mjs';
19
20
  import { throttledFetch } from '../../core/throttled-fetch.mjs';
20
21
  import { defaultVaultRoot } from '../../core/vfs-documents.mjs';
21
22
  import {
@@ -264,6 +265,45 @@ router.post('/api/mesh/enroll', requireAuth, requireScope('config:write'), async
264
265
  }
265
266
  });
266
267
 
268
+ /**
269
+ * POST /api/mesh/preauthkey
270
+ * Mint an enrollment key for a NEW device (a phone, a tablet, another laptop).
271
+ *
272
+ * /api/mesh/enroll only ever enrolls THIS host, so adding a phone had no API at
273
+ * all and the only route in was the CLI — which a phone user does not have.
274
+ *
275
+ * Body: { reusable?: boolean, ephemeral?: boolean, ttlMinutes?: number }
276
+ */
277
+ router.post('/api/mesh/preauthkey', requireAuth, requireScope('config:write'), async (req, res) => {
278
+ try {
279
+ const reusable = req.body?.reusable === true;
280
+ const ephemeral = req.body?.ephemeral === true;
281
+ // Short by default: an enrollment key is a bearer credential, and the window
282
+ // only has to cover walking to the device. Capped so a UI cannot mint a
283
+ // long-lived key by accident.
284
+ const requested = Number(req.body?.ttlMinutes);
285
+ const ttlMinutes = Number.isFinite(requested) ? Math.min(Math.max(requested, 1), 60) : 15;
286
+
287
+ const { key, expiration } = await createHeadscalePreAuthKey(
288
+ { reusable, ephemeral, ttlMinutes },
289
+ BRAIN_DIR,
290
+ );
291
+ res.json({
292
+ success: true,
293
+ key,
294
+ expiration,
295
+ reusable,
296
+ ephemeral,
297
+ ttl_minutes: ttlMinutes,
298
+ // Surfaced so the UI can show the exact control-server URL to type into a
299
+ // phone; never include the token, only the URL.
300
+ login_server: await resolveHeadscaleConfig(BRAIN_DIR).then((c) => c.url).catch(() => null),
301
+ });
302
+ } catch (err) {
303
+ res.status(500).json({ success: false, message: err.message || 'could not mint enrollment key' });
304
+ }
305
+ });
306
+
267
307
  /**
268
308
  * This host's I/O capability profile for agent UI generation
269
309
  * (screen, touch, mic, speaker, keyboard, camera, headless, …).
@@ -3,10 +3,13 @@
3
3
  *
4
4
  * GET /api/logs/:type — Read last 200 lines of server or daemon log
5
5
  * POST /api/diagnostics/agents — Run upgrade --agents diagnostics
6
- * POST /api/diagnostics/agents — Run upgrade --agents diagnostics
6
+ * GET /api/pairing — Reachable URLs for the mobile pairing QR
7
7
  * GET /api/tasks/failed — List failed tasks from the DLQ
8
8
  * POST /api/tasks/:id/retry — Re-queue a failed task
9
9
  * GET /api/usage — Usage ledger + cost summary
10
+ * GET /api/usage/providers — Spend as the providers report it
11
+ * POST /api/daemon/restart — Restart the Active Intelligence daemon
12
+ * POST /api/server/restart — Restart this server (supervised hosts only)
10
13
  */
11
14
 
12
15
  import { Router } from 'express';
@@ -217,5 +220,39 @@ router.post('/api/daemon/restart', requireAuth, requireScope('config:write'), as
217
220
  }
218
221
  });
219
222
 
223
+ /**
224
+ * POST /api/server/restart
225
+ * Restarts the brain server itself, so freshly installed code is the code that
226
+ * is actually running.
227
+ *
228
+ * `force` is deliberately NOT accepted from HTTP. Forcing an exit on a host
229
+ * where nothing would respawn us is not a restart, it is an outage, and an API
230
+ * caller must not be able to cause one. Hosts under a supervisor we cannot
231
+ * query (docker restart policies, pm2, runit) opt in with TR_SUPERVISED=1.
232
+ */
233
+ router.post('/api/server/restart', requireAuth, requireScope('config:write'), async (_req, res) => {
234
+ try {
235
+ const { requestSelfRestart } = await import('../../core/server-restart.mjs');
236
+ const result = requestSelfRestart();
237
+ if (!result.scheduled) {
238
+ return res.status(409).json({
239
+ success: false,
240
+ scheduled: false,
241
+ supervisor: result.supervisor,
242
+ message: result.reason,
243
+ });
244
+ }
245
+ logger.info('server', `Restart requested via API — ${result.supervisor.reason}`);
246
+ res.json({
247
+ success: true,
248
+ scheduled: true,
249
+ supervisor: result.supervisor,
250
+ message: `Server restarting (${result.supervisor.label}). It should be back in a few seconds.`,
251
+ });
252
+ } catch (err) {
253
+ serverError(res, err);
254
+ }
255
+ });
256
+
220
257
  export default router;
221
258
  export { router as systemRouter };
@@ -13,9 +13,35 @@ import {
13
13
  needsUpdate,
14
14
  isPackageAutoUpdateEnabled,
15
15
  } from '../../core/package-auto-update.mjs';
16
+ import { packageVersionOnDisk, requestSelfRestart } from '../../core/server-restart.mjs';
16
17
 
17
18
  const router = Router();
18
19
 
20
+ /**
21
+ * Install into the host root and WAIT for it.
22
+ *
23
+ * This used to be `detached: true` + `unref()`, which meant the endpoint
24
+ * answered "update complete" while npm was still writing files — the same
25
+ * class of lie as reporting an update finished while the old code kept
26
+ * running. We need the exit anyway: the restart decision below reads the
27
+ * manifest on disk, and reading it before npm has replaced it would see the
28
+ * old version and skip the restart.
29
+ *
30
+ * @returns {Promise<{ok: boolean, code?: number, error?: string}>}
31
+ */
32
+ function installIntoHostRoot(latest) {
33
+ return new Promise((resolve) => {
34
+ const proc = spawn('npm', ['install', `${PACKAGE_NAME}@${latest}`, '--save'], {
35
+ cwd: ROOT,
36
+ stdio: 'ignore',
37
+ });
38
+ // spawn reports a missing binary or bad cwd as an 'error' EVENT, not a
39
+ // throw, and an unhandled 'error' on an EventEmitter kills the server.
40
+ proc.on('error', (err) => resolve({ ok: false, error: err.message }));
41
+ proc.on('close', (code) => resolve({ ok: code === 0, code }));
42
+ });
43
+ }
44
+
19
45
  router.get('/api/update/check', requireAuth, async (_req, res) => {
20
46
  try {
21
47
  // Host package (this install)
@@ -75,6 +101,11 @@ router.post('/api/update/run', requireAuth, requireScope('config:write'), async
75
101
  const dryRun = Boolean(req.body?.dryRun);
76
102
  const force = req.body?.force !== false;
77
103
  const roots = Array.isArray(req.body?.roots) ? req.body.roots : undefined;
104
+ const restartWanted = req.body?.restart !== false;
105
+
106
+ // The version this process is actually RUNNING, captured before anything is
107
+ // installed over it.
108
+ const versionBefore = packageVersionOnDisk(ROOT);
78
109
 
79
110
  // Fire multi-project updater (awaits — install can take minutes; client should timeout high)
80
111
  const summary = await runPackageAutoUpdate({
@@ -86,17 +117,13 @@ router.post('/api/update/run', requireAuth, requireScope('config:write'), async
86
117
  save: true,
87
118
  });
88
119
 
89
- // Also kick a local install in ROOT when this host is a consumer (non-source)
120
+ // Also install into ROOT when this host is a consumer (non-source)
90
121
  const hostInfo = inspectProjectPackage(ROOT);
122
+ let hostInstall = null;
91
123
  if (!hostInfo.isSourceTree && (hostInfo.declared || hostInfo.installed) && !dryRun) {
92
124
  const latest = summary.latest || (await fetchLatestNpmVersionAsync());
93
125
  if (latest && needsUpdate(hostInfo.installed, latest)) {
94
- const proc = spawn('npm', ['install', `${PACKAGE_NAME}@${latest}`, '--save'], {
95
- cwd: ROOT,
96
- detached: true,
97
- stdio: 'ignore',
98
- });
99
- proc.unref();
126
+ hostInstall = await installIntoHostRoot(latest);
100
127
  }
101
128
  }
102
129
 
@@ -114,17 +141,49 @@ router.post('/api/update/run', requireAuth, requireScope('config:write'), async
114
141
  if (failed) parts.push(`${failed} failed`);
115
142
  const detail = parts.length ? parts.join(', ') : 'no projects checked';
116
143
 
144
+ // Restart only when the update replaced the code backing THIS process.
145
+ // Some other project's node_modules changing is no reason to bounce the
146
+ // server, and a source tree is never touched by an npm install at all.
147
+ const versionAfter = packageVersionOnDisk(ROOT);
148
+ const selfReplaced = Boolean(versionBefore && versionAfter && versionBefore !== versionAfter);
149
+ let restart = {
150
+ scheduled: false,
151
+ required: selfReplaced,
152
+ reason: selfReplaced
153
+ ? 'restart not requested'
154
+ : 'this server is already running the installed code',
155
+ };
156
+ if (selfReplaced && !dryRun && restartWanted) {
157
+ const outcome = requestSelfRestart();
158
+ restart = {
159
+ scheduled: outcome.scheduled,
160
+ required: true,
161
+ supervisor: outcome.supervisor,
162
+ reason: outcome.scheduled
163
+ ? `restarting into v${versionAfter} (${outcome.supervisor.label})`
164
+ : outcome.reason,
165
+ };
166
+ }
167
+
168
+ const restartNote = restart.scheduled
169
+ ? ` Restarting into v${versionAfter} now.`
170
+ : restart.required
171
+ ? ` This server is still running v${versionBefore} — restart it to pick up v${versionAfter}.`
172
+ : '';
173
+
117
174
  res.json({
118
175
  success: ok,
119
176
  updating: !dryRun && ok,
120
177
  message: dryRun
121
178
  ? `Dry-run complete — ${detail}`
122
179
  : ok
123
- ? `Package auto-update finished for registered projects (${detail}). Latest: ${summary?.latest || 'n/a'}`
180
+ ? `Package auto-update finished for registered projects (${detail}). Latest: ${summary?.latest || 'n/a'}.${restartNote}`
124
181
  : summary?.reason === 'npm-view-failed'
125
182
  ? 'Could not resolve latest version from npm (network or registry). Try again.'
126
183
  : `Package auto-update finished with errors (${detail}). Latest: ${summary?.latest || 'n/a'}`,
127
184
  summary,
185
+ host_install: hostInstall,
186
+ restart,
128
187
  });
129
188
  } catch (err) {
130
189
  serverError(res, err);