total-recall-brain 3.22.0 → 3.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/bin/total-recall.mjs +2 -0
  2. package/frontend/src/api/headscale.ts +68 -1
  3. package/frontend/src/api/mesh.spec.ts +27 -0
  4. package/frontend/src/api/mesh.ts +85 -0
  5. package/frontend/src/components/EmbeddingProviderPanel.tsx +21 -3
  6. package/frontend/src/pages/MeshPage.spec.tsx +164 -0
  7. package/frontend/src/pages/MeshPage.tsx +294 -6
  8. package/frontend/src/pages/NetworkPage.spec.tsx +19 -4
  9. package/frontend/src/pages/OnboardingPage.spec.tsx +14 -2
  10. package/frontend/src/pages/SkillsPage.spec.tsx +1 -1
  11. package/package.json +2 -1
  12. package/scaffold/.agent/skills/total-recall/references/api-reference.md +1 -1
  13. package/scaffold/.agent/skills/total-recall/references/cli-reference.md +2 -1
  14. package/scaffold/.agent/skills/total-recall/skills-registry/index.yaml +6 -2574
  15. package/src/cli/mesh.mjs +416 -0
  16. package/src/cli/repo-expert-generate.mjs +5 -1
  17. package/src/cli/reset-password.mjs +75 -6
  18. package/src/cli/secret.mjs +250 -7
  19. package/src/core/browser-session.mjs +68 -13
  20. package/src/core/headscale-client.mjs +87 -0
  21. package/src/core/mesh-access.mjs +261 -0
  22. package/src/core/mesh-enroll.mjs +74 -32
  23. package/src/core/mesh.mjs +164 -12
  24. package/src/core/schema.mjs +36 -0
  25. package/src/core/secrets-keychain.mjs +83 -0
  26. package/src/core/secrets-rekey.mjs +0 -0
  27. package/src/core/secrets-store.mjs +26 -6
  28. package/src/core/tailscale-cli.mjs +69 -0
  29. package/src/server/index.mjs +23 -4
  30. package/src/server/pid-lock.mjs +64 -0
  31. package/src/server/route-inventory.mjs +62 -0
  32. package/src/server/route-manifest.json +20 -0
  33. package/src/server/routes/headscale.mjs +32 -0
  34. package/src/server/routes/mesh.mjs +118 -0
package/src/core/mesh.mjs CHANGED
@@ -6,19 +6,33 @@ import {
6
6
  findVfsDocumentByPath,
7
7
  listVfsDocumentsUnder,
8
8
  } from './vfs-documents.mjs';
9
+ import { logger } from './logger.mjs';
10
+ import { STATUS_TIMEOUT_MS, resolveTailscaleBinary } from './tailscale-cli.mjs';
9
11
 
10
12
  const CACHE_MS = 2_000;
13
+
11
14
  let cachedStatus = null;
12
15
  let cachedAt = 0;
13
16
 
14
17
  function readMeshStatus() {
15
18
  if (cachedStatus && Date.now() - cachedAt < CACHE_MS) return cachedStatus;
16
- const result = spawnSync('tailscale', ['status', '--json'], {
19
+ // The CLI is not on PATH for macOS GUI installs, where it lives inside the
20
+ // app bundle — so resolve it the same way enrollment does.
21
+ const result = spawnSync(resolveTailscaleBinary(), ['status', '--json'], {
17
22
  encoding: 'utf8',
18
- timeout: 2_000,
19
- stdio: ['ignore', 'pipe', 'ignore'],
23
+ timeout: STATUS_TIMEOUT_MS,
24
+ stdio: ['ignore', 'pipe', 'pipe'],
20
25
  });
21
- if (result.status !== 0 || !result.stdout) return null;
26
+ if (result.status !== 0 || !result.stdout) {
27
+ // Silence here is what made the failure indistinguishable from "no peers".
28
+ logger.info(
29
+ 'mesh',
30
+ `Could not read mesh status: ${
31
+ result.error?.message || (result.stderr || '').trim().slice(0, 200) || 'no output'
32
+ }`,
33
+ );
34
+ return null;
35
+ }
22
36
  try {
23
37
  cachedStatus = JSON.parse(result.stdout);
24
38
  cachedAt = Date.now();
@@ -50,6 +64,21 @@ export function meshNodeDocSlug(hostname) {
50
64
  return slug || 'unknown-node';
51
65
  }
52
66
 
67
+ /**
68
+ * Canonical identity for a node, independent of DNS suffix.
69
+ *
70
+ * A tailnet appends its own domain, so the same machine appears as `box`,
71
+ * `box.mesh`, and `box.mesh.example.org` depending on who is asking and when
72
+ * the tailnet was last renamed. Keying entities on the full name therefore
73
+ * mints a fresh duplicate document every time that suffix changes, which is
74
+ * how one host ended up with several entities describing it.
75
+ */
76
+ export function meshNodeKey(hostname) {
77
+ const base = normalizeHostname(hostname);
78
+ if (!base) return '';
79
+ return meshNodeDocSlug(String(base).split('.')[0] || base);
80
+ }
81
+
53
82
  function normalizeNode(node, self = false) {
54
83
  return {
55
84
  hostname: normalizeHostname(node?.DNSName) || node?.HostName || null,
@@ -105,24 +134,24 @@ export function mergeLivePeersWithEntities(peers, entities = []) {
105
134
  const byHost = new Map();
106
135
  const byIp = new Map();
107
136
  for (const ent of entities) {
108
- const host = normalizeHostname(ent.hostname);
137
+ const host = meshNodeKey(ent.hostname);
109
138
  if (host) byHost.set(host, ent);
110
139
  if (ent.ip) byIp.set(String(ent.ip), ent);
111
140
  }
112
141
 
113
142
  const merged = peers.map((peer) => {
114
- const host = normalizeHostname(peer.hostname);
143
+ const host = meshNodeKey(peer.hostname);
115
144
  const ent = (host && byHost.get(host)) || (peer.ip && byIp.get(String(peer.ip))) || null;
116
145
  return enrichPeerWithEntity(peer, ent);
117
146
  });
118
147
 
119
148
  const seen = new Set(
120
- merged.map((m) => normalizeHostname(m.hostname) || m.ip).filter(Boolean),
149
+ merged.map((m) => meshNodeKey(m.hostname) || m.ip).filter(Boolean),
121
150
  );
122
151
 
123
152
  // Vault-only entities (registered but not currently on the mesh).
124
153
  for (const ent of entities) {
125
- const key = normalizeHostname(ent.hostname) || ent.ip;
154
+ const key = meshNodeKey(ent.hostname) || ent.ip;
126
155
  if (!key || seen.has(key)) continue;
127
156
  seen.add(key);
128
157
  merged.push(
@@ -175,6 +204,9 @@ function enrichPeerWithEntity(peer, ent, extra = {}) {
175
204
  ? ent.interfaces
176
205
  : [],
177
206
  io: peer.io || ent?.io || null,
207
+ // Access is entity-only: the control server has no idea which account you
208
+ // log in as, so live discovery can never supply or override it.
209
+ access: ent?.access || null,
178
210
  };
179
211
  }
180
212
 
@@ -218,14 +250,111 @@ export function attachSelfInterfaces(nodes, interfacesSummary = null) {
218
250
 
219
251
  function findEntityForSelf(self, vaultRoot) {
220
252
  const entities = listMeshNodeEntities(vaultRoot);
221
- const host = normalizeHostname(self.hostname);
253
+ const host = meshNodeKey(self.hostname);
222
254
  return (
223
- entities.find((e) => normalizeHostname(e.hostname) === host) ||
255
+ entities.find((e) => meshNodeKey(e.hostname) === host) ||
224
256
  entities.find((e) => e.ip && self.ip && String(e.ip) === String(self.ip)) ||
225
257
  null
226
258
  );
227
259
  }
228
260
 
261
+ /** Find an enriched node (live + vault) by hostname or address. */
262
+ export function findMeshNode(nameOrAddress, vaultRoot = defaultVaultRoot()) {
263
+ const wanted = String(nameOrAddress || '').trim();
264
+ if (!wanted) return null;
265
+ const key = meshNodeKey(wanted);
266
+ const nodes = listEnrichedMeshNodes(vaultRoot);
267
+
268
+ return (
269
+ nodes.find((n) => String(n.ip) === wanted || String(n.lan_ip) === wanted) ||
270
+ nodes.find((n) => meshNodeKey(n.hostname) === key) ||
271
+ null
272
+ );
273
+ }
274
+
275
+ /**
276
+ * Record how to reach a node.
277
+ *
278
+ * Creates the entity when a peer has never had one — remote nodes only get a
279
+ * document when something writes to them, and access is usually the first
280
+ * thing anyone knows about a machine they did not install Total Recall on.
281
+ */
282
+ export async function setMeshNodeAccess(nameOrAddress, accessPatch, options = {}) {
283
+ const vaultRoot = options.vaultRoot || defaultVaultRoot();
284
+ const node = findMeshNode(nameOrAddress, vaultRoot);
285
+ if (!node) return { written: false, reason: 'node-not-found' };
286
+
287
+ const entities = listMeshNodeEntities(vaultRoot);
288
+ const existing =
289
+ entities.find((e) => meshNodeKey(e.hostname) === meshNodeKey(node.hostname)) ||
290
+ entities.find((e) => e.ip && node.ip && String(e.ip) === String(node.ip)) ||
291
+ null;
292
+
293
+ // Merge rather than replace: setting only a port must not erase the user.
294
+ const merged = { ...(existing?.access || {}), ...accessPatch };
295
+ for (const [key, value] of Object.entries(merged)) {
296
+ if (value === null || value === undefined) delete merged[key];
297
+ }
298
+
299
+ const now = new Date().toISOString();
300
+ const { processViaPackageKernel } = await import('./ssss-kernel-bridge.mjs');
301
+ const vfsPath = existing?.vfs_path || `system/mesh-nodes/${meshNodeKey(node.hostname)}.md`;
302
+
303
+ const envelope = existing
304
+ ? {
305
+ type: 'patch',
306
+ path: vfsPath,
307
+ patches: {
308
+ access: merged,
309
+ timestamp: now,
310
+ // A patch is validated against the whole resulting document, and the
311
+ // earliest node entities were written before title/description became
312
+ // required. Without backfilling them here, every write to one of
313
+ // those documents fails validation forever — the node can never be
314
+ // given an access record, which is exactly what it needs most.
315
+ ...(existing.title ? {} : { title: node.hostname }),
316
+ ...(existing.description
317
+ ? {}
318
+ : { description: `Mesh node entity for ${node.hostname}.` }),
319
+ },
320
+ }
321
+ : {
322
+ type: 'operation',
323
+ path: vfsPath,
324
+ content: [
325
+ '---',
326
+ 'type: mesh_node',
327
+ `title: ${JSON.stringify(node.hostname)}`,
328
+ `description: ${JSON.stringify(`Mesh node entity for ${node.hostname}.`)}`,
329
+ `timestamp: ${now}`,
330
+ `hostname: ${JSON.stringify(node.hostname)}`,
331
+ `ip: ${node.ip == null ? 'null' : JSON.stringify(node.ip)}`,
332
+ `status: ${node.online ? 'online' : 'offline'}`,
333
+ `os: ${node.os == null ? 'null' : JSON.stringify(node.os)}`,
334
+ `lan_ip: ${node.lan_ip == null ? 'null' : JSON.stringify(node.lan_ip)}`,
335
+ `access: ${JSON.stringify(merged)}`,
336
+ '---',
337
+ '',
338
+ '<!-- Access variables (login account, port, key path) live here so every',
339
+ ' machine and agent on the mesh resolves them the same way. -->',
340
+ '',
341
+ ].join('\n'),
342
+ };
343
+
344
+ const result = await processViaPackageKernel(
345
+ {
346
+ ...envelope,
347
+ idempotency_key: crypto.randomUUID(),
348
+ workspace_id: options.workspace_id || 'default',
349
+ actor: { role: 'system' },
350
+ },
351
+ vaultRoot,
352
+ { agentRole: 'system' },
353
+ );
354
+
355
+ return { written: !!result?.success, path: vfsPath, access: merged, result };
356
+ }
357
+
229
358
  /**
230
359
  * Upsert **this** node's mesh_node entity from live discovery.
231
360
  * Does not hardcode device names — slug and fields come from Tailscale self.
@@ -241,7 +370,7 @@ export async function patchOwnMeshNode(options = {}) {
241
370
  const existing = findEntityForSelf(self, vaultRoot);
242
371
  const slug = existing
243
372
  ? path.basename(existing.vfs_path, '.md')
244
- : meshNodeDocSlug(self.hostname);
373
+ : meshNodeKey(self.hostname);
245
374
  const vfsPath = existing?.vfs_path || `system/mesh-nodes/${slug}.md`;
246
375
  const now = new Date().toISOString();
247
376
  const status = self.online ? 'online' : 'offline';
@@ -280,6 +409,25 @@ export async function patchOwnMeshNode(options = {}) {
280
409
  const priorCaps = Array.isArray(existing?.capabilities) ? existing.capabilities : [];
281
410
  const capabilities = [...new Set([...priorCaps, ...ioChannels.map((c) => `io:${c}`)])];
282
411
 
412
+ // Which Tailscale build this machine runs decides whether the control server
413
+ // can authorise SSH to it, or whether callers still need their own keys.
414
+ // Recording it here makes it a mesh fact: every other node learns what this
415
+ // one can do without having to log in and look — which is the only way to
416
+ // find out otherwise, and needs the very access this may be missing.
417
+ // `getMeshSelf()` answered, so the client did run; only the build is in doubt.
418
+ const { classifyTailscaleVariant, meshSshFromVariant } = await import('./mesh-access.mjs');
419
+ const { hasTailscaleDaemon } = await import('./tailscale-cli.mjs');
420
+ const variant = classifyTailscaleVariant({
421
+ platform: process.platform,
422
+ hasClient: true,
423
+ hasDaemon: hasTailscaleDaemon(),
424
+ });
425
+ const access = {
426
+ ...(existing?.access || {}),
427
+ tailscale_variant: variant,
428
+ mesh_ssh: meshSshFromVariant(variant),
429
+ };
430
+
283
431
  const livePatches = {
284
432
  hostname: self.hostname,
285
433
  ip: self.ip,
@@ -291,6 +439,8 @@ export async function patchOwnMeshNode(options = {}) {
291
439
  transports: [...new Set(['mesh', ...(lanIp ? ['lan'] : [])])],
292
440
  io: ioProfile,
293
441
  capabilities,
442
+ // Merged, never replaced: probing must not erase a recorded login account.
443
+ access,
294
444
  ...requiredMeta,
295
445
  };
296
446
 
@@ -322,13 +472,15 @@ export async function patchOwnMeshNode(options = {}) {
322
472
  `os: ${self.os == null ? 'null' : JSON.stringify(self.os)}`,
323
473
  'role: null',
324
474
  'labels: []',
325
- 'capabilities: []',
326
475
  'notes: null',
327
476
  `lan_ip: ${lanIp == null ? 'null' : JSON.stringify(lanIp)}`,
328
477
  `transports: ${JSON.stringify(livePatches.transports)}`,
329
478
  `interfaces: ${JSON.stringify(interfacesSummary)}`,
330
479
  `io: ${JSON.stringify(ioProfile)}`,
480
+ // Emitted once. A duplicate YAML key is resolved by last-one-wins, so a
481
+ // stale earlier line survives review while contributing nothing.
331
482
  `capabilities: ${JSON.stringify(capabilities)}`,
483
+ `access: ${JSON.stringify(access)}`,
332
484
  '---',
333
485
  '',
334
486
  '<!-- Entity space: I/O (screen/touch/mic/speaker) + notes/labels via SSSS patch. Agents use io.channels / ui_hints. -->',
@@ -966,6 +966,40 @@ const DeviceIoProfileSchema = z.object({
966
966
  platform: z.string().optional(),
967
967
  }).passthrough();
968
968
 
969
+ /**
970
+ * How an operator (or agent) actually reaches this node.
971
+ *
972
+ * Identity and reachability come from the control server, but *operating* a
973
+ * node needs facts the control server never sees — which account to log in as
974
+ * above all. Without this, that knowledge lives in a local `~/.ssh/config` that
975
+ * no other machine and no agent can read, so every consumer rediscovers it by
976
+ * guessing. Recording it here makes access a mesh fact like any other.
977
+ *
978
+ * Secrets never belong on this document: `identity_file` is a path to a key,
979
+ * never key material.
980
+ */
981
+ const MeshNodeAccessSchema = z.object({
982
+ /** Login account. The single most common reason a reachable node looks dead. */
983
+ ssh_user: z.string().min(1).nullable().optional(),
984
+ ssh_port: z.number().int().positive().max(65535).nullable().optional(),
985
+ /** Preferred address when it is neither the mesh IP nor the LAN IP. */
986
+ ssh_host: z.string().min(1).nullable().optional(),
987
+ /** Path to a private key — never the key itself. */
988
+ identity_file: z.string().min(1).nullable().optional(),
989
+ /**
990
+ * Which Tailscale build runs here. Only the open-source `tailscaled` build
991
+ * can serve Tailscale SSH; the sandboxed GUI builds cannot, so this is what
992
+ * decides whether keyless mesh SSH is even possible for the node.
993
+ */
994
+ tailscale_variant: z.enum(['daemon', 'sandboxed', 'missing', 'unknown']).optional(),
995
+ /** Whether control-plane-authorised SSH works, derived from the variant. */
996
+ mesh_ssh: z.enum(['available', 'unsupported', 'unknown']).optional(),
997
+ /** Where these values came from, so a probe can outrank a guess. */
998
+ source: z.enum(['probe', 'ssh_config', 'manual', 'unknown']).optional(),
999
+ /** Last time access was proven by an actual successful connection. */
1000
+ verified_at: ssssDatetimeNullable().optional(),
1001
+ }).passthrough();
1002
+
969
1003
  export const MeshNodeSchema = z.object({
970
1004
  type: z.literal('mesh_node'),
971
1005
  title: z.string().min(1),
@@ -991,6 +1025,8 @@ export const MeshNodeSchema = z.object({
991
1025
  * (touchscreen, mic, speaker, screen, keyboard, …).
992
1026
  */
993
1027
  io: DeviceIoProfileSchema.optional(),
1028
+ /** Operational access — how to actually log in to this node. */
1029
+ access: MeshNodeAccessSchema.optional(),
994
1030
  }).passthrough();
995
1031
 
996
1032
  export const DaemonLeaderSchema = z.object({
@@ -0,0 +1,83 @@
1
+ /**
2
+ * macOS Keychain as a master-password carrier.
3
+ *
4
+ * `secrets-rekey` knows how to update file carriers — a LaunchAgent plist, an
5
+ * env file — because it can stage, back up and atomically rename them. The
6
+ * Keychain is not a file, so it was simply not a carrier at all. That is fine
7
+ * right up until a shell profile reads the password from the Keychain, which is
8
+ * the arrangement macOS installs steer people toward: a rekey then re-encrypts
9
+ * the store, updates the carriers it knows about, and leaves the Keychain
10
+ * holding the retired password. Every interactive shell breaks at once, with an
11
+ * error — "Unsupported state or unable to authenticate data" — that describes
12
+ * the symptom and not the cause.
13
+ *
14
+ * Portability: this module is macOS-only by nature and reports itself
15
+ * unavailable everywhere else, so callers can treat it as optional.
16
+ *
17
+ * The password is passed to `security` on stdin, never in argv. Anything in
18
+ * argv is visible to every process on the machine through `ps`.
19
+ */
20
+
21
+ import { spawnSync } from 'node:child_process';
22
+
23
+ const SECURITY_BIN = '/usr/bin/security';
24
+
25
+ /**
26
+ * Service name Total Recall stores its own master password under. A product
27
+ * constant like the `TR_SECRETS_PASSWORD` variable name — not a device or user
28
+ * detail — and overridable per invocation.
29
+ */
30
+ export const DEFAULT_KEYCHAIN_SERVICE = 'total-recall-secrets';
31
+
32
+ /** Is the Keychain usable on this host? */
33
+ export function keychainAvailable({ platform = process.platform } = {}) {
34
+ if (platform !== 'darwin') return false;
35
+ const probe = spawnSync(SECURITY_BIN, ['help'], { encoding: 'utf8', timeout: 5_000 });
36
+ return probe.status === 0 || probe.status === 1; // `help` exits non-zero on some releases
37
+ }
38
+
39
+ /**
40
+ * Current password held for a service/account, or null when there is no entry.
41
+ * The value is returned, never logged — callers compare it, they do not print it.
42
+ */
43
+ export function readKeychainPassword({ service, account = process.env.USER }) {
44
+ if (!service) throw new Error('Keychain carrier requires a service name');
45
+ const res = spawnSync(
46
+ SECURITY_BIN,
47
+ ['find-generic-password', '-a', String(account), '-s', String(service), '-w'],
48
+ { encoding: 'utf8', timeout: 10_000 },
49
+ );
50
+ if (res.status !== 0) return null;
51
+ // `security` terminates the value with a newline that is not part of it.
52
+ return (res.stdout || '').replace(/\n$/, '') || null;
53
+ }
54
+
55
+ /**
56
+ * Create or replace the entry. `-U` updates in place when one already exists,
57
+ * which keeps this idempotent and atomic for a single item.
58
+ *
59
+ * Omitting a value after `-w` makes `security` prompt for the password and read
60
+ * it from stdin — twice, to confirm — which is how the secret is kept out of
61
+ * argv.
62
+ */
63
+ export function writeKeychainPassword({ service, account = process.env.USER, password }) {
64
+ if (!service) throw new Error('Keychain carrier requires a service name');
65
+ if (!password) throw new Error('Refusing to write an empty Keychain password');
66
+ const res = spawnSync(
67
+ SECURITY_BIN,
68
+ ['add-generic-password', '-U', '-a', String(account), '-s', String(service), '-w'],
69
+ { input: `${password}\n${password}\n`, encoding: 'utf8', timeout: 10_000 },
70
+ );
71
+ if (res.status !== 0) {
72
+ // stderr may echo the prompt text but never the value itself.
73
+ throw new Error(
74
+ `Could not update Keychain entry ${service}: ${(res.stderr || '').trim().slice(0, 200) || `exit ${res.status}`}`,
75
+ );
76
+ }
77
+ return true;
78
+ }
79
+
80
+ /** `{ service, account }` → a stable label for logs and results. */
81
+ export function describeKeychainCarrier(carrier) {
82
+ return `keychain:${carrier.service}(${carrier.account || process.env.USER})`;
83
+ }
Binary file
@@ -43,6 +43,28 @@ function secretsPassword() {
43
43
  return process.env.TR_SECRETS_PASSWORD || process.env.TR_MASTER_PASSWORD || null;
44
44
  }
45
45
 
46
+ /**
47
+ * Is this buffer the plaintext JSON form of the store, rather than ciphertext?
48
+ *
49
+ * The encrypted form opens with a random 16-byte salt, so its first byte is
50
+ * `{` about once in every 256 stores — measured at 17 in 5000. Deciding the
51
+ * format from that byte alone therefore misreads roughly one store in every
52
+ * 256 as plain JSON. Reads survive it by falling back to decryption, but
53
+ * `migrateSecretsToEncryptedIfNeeded` does not: it reports perfectly good
54
+ * ciphertext as `not-json`, and its caller believes the store still needs
55
+ * migrating. The first byte is a cheap way to reject the common case; only
56
+ * parsing can settle the rest.
57
+ */
58
+ export function isPlainJsonStore(buf) {
59
+ if (!buf?.length || buf[0] !== 0x7b /* { */) return false;
60
+ try {
61
+ const parsed = JSON.parse(buf.toString('utf8'));
62
+ return Boolean(parsed) && typeof parsed === 'object' && !Array.isArray(parsed);
63
+ } catch {
64
+ return false;
65
+ }
66
+ }
67
+
46
68
  /**
47
69
  * Load raw secrets object synchronously
48
70
  */
@@ -53,7 +75,7 @@ export function loadSecretsSync(brainDir) {
53
75
  const buf = fs.readFileSync(filePath);
54
76
  const password = secretsPassword();
55
77
 
56
- if (password && buf.length > 44 && buf[0] !== 0x7b) {
78
+ if (password && buf.length > 44 && !isPlainJsonStore(buf)) {
57
79
  try {
58
80
  return decryptSecretsSync(buf, password);
59
81
  } catch (err) {
@@ -88,8 +110,7 @@ export async function loadSecrets(brainDir) {
88
110
  const buf = fs.readFileSync(filePath);
89
111
  const password = secretsPassword();
90
112
 
91
- // AES blob: starts with non-JSON (binary). Detect: first byte not `{`
92
- if (password && buf.length > 44 && buf[0] !== 0x7b /* { */) {
113
+ if (password && buf.length > 44 && !isPlainJsonStore(buf)) {
93
114
  try {
94
115
  return await decryptSecrets(buf, password);
95
116
  } catch (err) {
@@ -146,8 +167,7 @@ export async function migrateSecretsToEncryptedIfNeeded(brainDir) {
146
167
  return { migrated: false, path: filePath, reason: 'no-password' };
147
168
  }
148
169
  const buf = fs.readFileSync(filePath);
149
- // Already looks like AES blob (not starting with `{`)
150
- if (buf.length > 44 && buf[0] !== 0x7b) {
170
+ if (buf.length > 44 && !isPlainJsonStore(buf)) {
151
171
  return { migrated: false, path: filePath, reason: 'already-encrypted' };
152
172
  }
153
173
  let obj;
@@ -173,7 +193,7 @@ export async function migrateSecretsToEncryptedIfNeeded(brainDir) {
173
193
  export async function validateSecretsBuffer(buffer) {
174
194
  const buf = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer);
175
195
  const password = secretsPassword();
176
- if (password && buf.length > 44 && buf[0] !== 0x7b) {
196
+ if (password && buf.length > 44 && !isPlainJsonStore(buf)) {
177
197
  const parsed = await decryptSecrets(buf, password);
178
198
  if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
179
199
  throw new Error('Decrypted secrets payload must be an object');
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Locating the Tailscale client.
3
+ *
4
+ * Kept separate from both discovery and enrollment because each needs the
5
+ * binary but neither should depend on the other to find it. Folding this into
6
+ * the enrollment module made status reads fail wherever enrollment was stubbed
7
+ * — the lookup silently became `undefined`, and every mesh query returned an
8
+ * error that looked like an empty mesh.
9
+ */
10
+
11
+ import fs from 'node:fs';
12
+
13
+ /**
14
+ * macOS ships the CLI inside the app bundle rather than on PATH, so a
15
+ * PATH-only lookup fails on exactly the machines people install first.
16
+ */
17
+ export const FALLBACK_BINARIES = [
18
+ '/usr/local/bin/tailscale',
19
+ '/opt/homebrew/bin/tailscale',
20
+ '/Applications/Tailscale.app/Contents/MacOS/Tailscale',
21
+ '/usr/bin/tailscale',
22
+ ];
23
+
24
+ /**
25
+ * Presence of a `tailscaled` is what separates the open-source build, which
26
+ * can serve Tailscale SSH, from the sandboxed GUI builds, which cannot.
27
+ */
28
+ export const DARWIN_DAEMON_BINARIES = [
29
+ '/usr/local/bin/tailscaled',
30
+ '/opt/homebrew/bin/tailscaled',
31
+ '/usr/local/sbin/tailscaled',
32
+ '/opt/homebrew/sbin/tailscaled',
33
+ ];
34
+
35
+ /**
36
+ * How long to wait on a `tailscale status` read.
37
+ *
38
+ * Measured at 1.2–1.7s on an idle machine, and longer whenever peers are spread
39
+ * over a WAN or the host is busy. A tight timeout does not degrade gracefully:
40
+ * the call returns nothing, the mesh reads as completely empty, and every
41
+ * lookup reports a node as missing rather than unknown — indistinguishable from
42
+ * a machine that was never enrolled. Shared so discovery and enrollment cannot
43
+ * disagree about how patient to be.
44
+ */
45
+ export const STATUS_TIMEOUT_MS = 10_000;
46
+
47
+ function existsSafe(candidate) {
48
+ try {
49
+ return fs.existsSync(candidate);
50
+ } catch {
51
+ // Permission errors fall through to the next candidate, then to PATH.
52
+ return false;
53
+ }
54
+ }
55
+
56
+ /** Resolve the Tailscale CLI, honoring an explicit override first. */
57
+ export function resolveTailscaleBinary() {
58
+ const override = (process.env.TR_TAILSCALE_BIN || '').trim();
59
+ if (override) return override;
60
+ for (const candidate of FALLBACK_BINARIES) {
61
+ if (existsSafe(candidate)) return candidate;
62
+ }
63
+ return 'tailscale';
64
+ }
65
+
66
+ /** Is an open-source `tailscaled` present (the SSH-server-capable build)? */
67
+ export function hasTailscaleDaemon() {
68
+ return DARWIN_DAEMON_BINARIES.some(existsSafe);
69
+ }
@@ -28,6 +28,7 @@ import {
28
28
  requireScope
29
29
  } from './auth.mjs';
30
30
  import { logger, logEvents } from "../core/logger.mjs";
31
+ import { readProcessCommand, serverEntryHint, shouldHonorPidLock } from './pid-lock.mjs';
31
32
  import { drainActiveEmbeddings } from './routes/sessions.mjs';
32
33
  import { agentDir as configAgentDir, brainDir as configBrainDir, port as configPort, host as configHost, nodeEnv } from '../core/config.mjs';
33
34
  import { getDaemonStatus, ensureDaemonRunning } from '../core/daemon-control.mjs';
@@ -55,13 +56,31 @@ function acquireServerPidLock() {
55
56
  if (fs.existsSync(SERVER_PID_FILE)) {
56
57
  const existingPid = parseInt(fs.readFileSync(SERVER_PID_FILE, 'utf8').trim(), 10);
57
58
  if (existingPid && !isNaN(existingPid)) {
58
- try {
59
- process.kill(existingPid, 0); // signal 0 = liveness check only
59
+ // Liveness alone is not enough: PIDs are recycled, and a stale lock
60
+ // pointing at a number the OS has since reassigned would block startup
61
+ // on every boot, blaming an instance that does not exist.
62
+ const verdict = shouldHonorPidLock(existingPid, {
63
+ isAlive: (pid) => {
64
+ try {
65
+ process.kill(pid, 0); // signal 0 = liveness check only
66
+ return true;
67
+ } catch {
68
+ return false;
69
+ }
70
+ },
71
+ readCommand: readProcessCommand,
72
+ entryHint: serverEntryHint(fileURLToPath(import.meta.url)),
73
+ });
74
+ if (verdict.honor) {
60
75
  logger.error('server', `Another server instance is already running (PID: ${existingPid}). Exiting.`);
61
76
  process.exit(1);
62
- } catch {
63
- logger.info('server', `Stale server PID file found (PID: ${existingPid} is dead). Overwriting.`);
64
77
  }
78
+ logger.info(
79
+ 'server',
80
+ verdict.reason === 'pid-reused'
81
+ ? `Stale server PID file found (PID ${existingPid} now belongs to another program). Overwriting.`
82
+ : `Stale server PID file found (PID: ${existingPid} is dead). Overwriting.`,
83
+ );
65
84
  }
66
85
  }
67
86
  } catch {
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Deciding whether a PID lock is real.
3
+ *
4
+ * A lock file records the PID of the running server, and the obvious liveness
5
+ * test is `process.kill(pid, 0)` — it throws if nothing is there. What it
6
+ * cannot tell you is *what* is there. PIDs are recycled, aggressively so after
7
+ * a reboot, and the number in a stale lock file is as likely to belong to a
8
+ * system daemon as to anything of ours. Observed in the wild: a lock left
9
+ * pointing at PID 445, which the OS had since handed to `metrickitd`. The
10
+ * liveness check passed, the server exited with "another server instance is
11
+ * already running", and it did so on every boot from then on — an outage with
12
+ * an error message that sent you looking for a process that was never there.
13
+ *
14
+ * So liveness is necessary but not sufficient: the process has to look like
15
+ * this server before its lock is honored.
16
+ */
17
+
18
+ import { spawnSync } from 'node:child_process';
19
+ import path from 'node:path';
20
+
21
+ /**
22
+ * The tail of our own entry path (`server/index.mjs`), derived rather than
23
+ * written down so it stays correct wherever the package is installed and never
24
+ * hardcodes a checkout location.
25
+ */
26
+ export function serverEntryHint(entryPath) {
27
+ return path.join(path.basename(path.dirname(entryPath)), path.basename(entryPath));
28
+ }
29
+
30
+ /** Read a process's command line, or null when it cannot be determined. */
31
+ export function readProcessCommand(pid) {
32
+ try {
33
+ const res = spawnSync('ps', ['-o', 'command=', '-p', String(pid)], {
34
+ encoding: 'utf8',
35
+ timeout: 5_000,
36
+ });
37
+ if (res.status !== 0 || !res.stdout) return null;
38
+ return res.stdout.trim() || null;
39
+ } catch {
40
+ return null;
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Should the lock held by `pid` block startup?
46
+ *
47
+ * `isAlive` and `readCommand` are injected so the decision can be tested
48
+ * without spawning processes or guessing at PIDs.
49
+ */
50
+ export function shouldHonorPidLock(pid, { isAlive, readCommand, entryHint }) {
51
+ if (!pid || !Number.isInteger(pid) || pid <= 0) return { honor: false, reason: 'invalid-pid' };
52
+ if (!isAlive(pid)) return { honor: false, reason: 'dead' };
53
+
54
+ const command = readCommand(pid);
55
+ // Without a readable command line we cannot distinguish a real instance from
56
+ // a recycled number. Honor the lock: a refusal to start is recoverable by
57
+ // deleting the file, whereas two servers sharing one brain is not.
58
+ if (!command) return { honor: true, reason: 'alive-command-unknown' };
59
+
60
+ if (!command.includes(entryHint)) {
61
+ return { honor: false, reason: 'pid-reused', command };
62
+ }
63
+ return { honor: true, reason: 'alive-and-ours', command };
64
+ }