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.
- package/bin/total-recall.mjs +2 -0
- package/frontend/src/api/headscale.ts +68 -1
- package/frontend/src/api/mesh.spec.ts +27 -0
- package/frontend/src/api/mesh.ts +85 -0
- package/frontend/src/components/EmbeddingProviderPanel.tsx +21 -3
- package/frontend/src/pages/MeshPage.spec.tsx +164 -0
- package/frontend/src/pages/MeshPage.tsx +294 -6
- package/frontend/src/pages/NetworkPage.spec.tsx +19 -4
- package/frontend/src/pages/OnboardingPage.spec.tsx +14 -2
- package/frontend/src/pages/SkillsPage.spec.tsx +1 -1
- package/package.json +2 -1
- package/scaffold/.agent/skills/total-recall/references/api-reference.md +1 -1
- package/scaffold/.agent/skills/total-recall/references/cli-reference.md +2 -1
- package/scaffold/.agent/skills/total-recall/skills-registry/index.yaml +6 -2574
- package/src/cli/mesh.mjs +416 -0
- package/src/cli/repo-expert-generate.mjs +5 -1
- package/src/cli/reset-password.mjs +75 -6
- package/src/cli/secret.mjs +250 -7
- package/src/core/browser-session.mjs +68 -13
- package/src/core/headscale-client.mjs +87 -0
- package/src/core/mesh-access.mjs +261 -0
- package/src/core/mesh-enroll.mjs +74 -32
- package/src/core/mesh.mjs +164 -12
- package/src/core/schema.mjs +36 -0
- package/src/core/secrets-keychain.mjs +83 -0
- package/src/core/secrets-rekey.mjs +0 -0
- package/src/core/secrets-store.mjs +26 -6
- package/src/core/tailscale-cli.mjs +69 -0
- package/src/server/index.mjs +23 -4
- package/src/server/pid-lock.mjs +64 -0
- package/src/server/route-inventory.mjs +62 -0
- package/src/server/route-manifest.json +20 -0
- package/src/server/routes/headscale.mjs +32 -0
- 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
|
-
|
|
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:
|
|
19
|
-
stdio: ['ignore', 'pipe', '
|
|
23
|
+
timeout: STATUS_TIMEOUT_MS,
|
|
24
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
20
25
|
});
|
|
21
|
-
if (result.status !== 0 || !result.stdout)
|
|
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 =
|
|
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 =
|
|
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) =>
|
|
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 =
|
|
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 =
|
|
253
|
+
const host = meshNodeKey(self.hostname);
|
|
222
254
|
return (
|
|
223
|
-
entities.find((e) =>
|
|
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
|
-
:
|
|
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. -->',
|
package/src/core/schema.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|
package/src/server/index.mjs
CHANGED
|
@@ -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
|
-
|
|
59
|
-
|
|
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
|
+
}
|