total-recall-brain 3.41.1 → 3.41.3
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 +9 -3
- package/frontend/dist/assets/{index-BG9NkVUy.css → index-BIQmqDKP.css} +1 -1
- package/frontend/dist/assets/{index-BtuiZqwP.js → index-DFenQQEQ.js} +74 -79
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/sw.js +8 -5
- package/frontend/src/api/auth.ts +0 -3
- package/frontend/src/components/plugins/PluginPanel.tsx +7 -3
- package/frontend/src/components/plugins/card/CardEngine.tsx +1 -1
- package/frontend/src/pages/LoginPage.tsx +1 -41
- package/frontend/src/pages/PluginsPage.tsx +14 -7
- package/package.json +2 -2
- package/scaffold/.agent/skills/plugin/scripts/record.mjs +6 -3
- package/scaffold/.agent/skills/total-recall/plugins/auth/README.md +10 -0
- package/scaffold/.agent/skills/total-recall/plugins/auth/cli.mjs +197 -31
- package/scaffold/.agent/skills/total-recall/plugins/auth/package-lock.json +1456 -12
- package/scaffold/.agent/skills/total-recall/plugins/auth/package.json +4 -4
- package/scaffold/.agent/skills/total-recall/plugins/auth/plugin.json +8 -1
- package/scaffold/.agent/skills/total-recall/plugins/auth/src/cli-secret.mjs +210 -27
- package/scaffold/.agent/skills/total-recall/plugins/auth/src/provider-catalog.mjs +10 -1
- package/scaffold/.agent/skills/total-recall/plugins/auth/src/rotation-capability.mjs +15 -0
- package/scaffold/.agent/skills/total-recall/plugins/auth/src/secrets-env-export.mjs +78 -6
- package/scaffold/.agent/skills/total-recall/plugins/auth/src/secrets-merge.mjs +131 -0
- package/scaffold/.agent/skills/total-recall/plugins/auth/src/secrets-remote-deploy.mjs +111 -2
- package/scaffold/.agent/skills/total-recall/plugins/auth/src/secrets-store.mjs +26 -5
- package/scaffold/.agent/skills/total-recall/plugins/auth/ui/DESIGN.md +56 -8
- package/scaffold/.agent/skills/total-recall/plugins/auth/ui/card.js +1068 -204
- package/scaffold/.agent/skills/total-recall/plugins/auth/ui/panel.js +4 -2
- package/scaffold/.agent/skills/total-recall/plugins/cli-agents/ui/DESIGN.md +32 -0
- package/scaffold/.agent/skills/total-recall/plugins/cli-agents/ui/card.js +436 -274
- package/scaffold/.agent/skills/total-recall/plugins/code-quality/package-lock.json +4 -4
- package/scaffold/.agent/skills/total-recall/plugins/code-quality/package.json +1 -1
- package/scaffold/.agent/skills/total-recall/plugins/code-quality/ui/DESIGN.md +16 -2
- package/scaffold/.agent/skills/total-recall/plugins/code-quality/ui/card.js +461 -242
- package/scaffold/.agent/skills/total-recall/plugins/composable-cli/package-lock.json +4 -4
- package/scaffold/.agent/skills/total-recall/plugins/composable-cli/package.json +1 -1
- package/scaffold/.agent/skills/total-recall/plugins/composable-cli/plugin.json +1 -7
- package/scaffold/.agent/skills/total-recall/plugins/composable-cli/ui/DESIGN.md +17 -2
- package/scaffold/.agent/skills/total-recall/plugins/composable-cli/ui/card.js +426 -300
- package/scaffold/.agent/skills/total-recall/plugins/embeddings/package-lock.json +4 -4
- package/scaffold/.agent/skills/total-recall/plugins/embeddings/ui/DESIGN.md +32 -0
- package/scaffold/.agent/skills/total-recall/plugins/embeddings/ui/card.js +350 -283
- package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/package-lock.json +4 -4
- package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/ui/card.js +31 -19
- package/scaffold/.agent/skills/total-recall/plugins/mesh/README.md +10 -0
- package/scaffold/.agent/skills/total-recall/plugins/mesh/{src/core/headscale-server.mjs → capability/control-server.mjs} +5 -2
- package/scaffold/.agent/skills/total-recall/plugins/mesh/capability/headscale-api.mjs +192 -0
- package/scaffold/.agent/skills/total-recall/plugins/mesh/capability/index.mjs +46 -0
- package/scaffold/.agent/skills/total-recall/plugins/mesh/cli.mjs +9 -1
- package/scaffold/.agent/skills/total-recall/plugins/mesh/package-lock.json +23 -22
- package/scaffold/.agent/skills/total-recall/plugins/mesh/package.json +6 -3
- package/scaffold/.agent/skills/total-recall/plugins/mesh/plugin.json +242 -8
- package/scaffold/.agent/skills/total-recall/plugins/mesh/src/cli/mesh.mjs +14 -9
- package/scaffold/.agent/skills/total-recall/plugins/mesh/src/cli/server.mjs +1 -1
- package/scaffold/.agent/skills/total-recall/plugins/mesh/src/core/credentials.mjs +4 -2
- package/scaffold/.agent/skills/total-recall/plugins/mesh/src/core/headscale-client.mjs +34 -181
- package/scaffold/.agent/skills/total-recall/plugins/mesh/src/core/mesh-auth.mjs +2 -2
- package/scaffold/.agent/skills/total-recall/plugins/mesh/src/core/presence.mjs +5 -5
- package/scaffold/.agent/skills/total-recall/plugins/mesh/src/core/screen-view.mjs +78 -0
- package/scaffold/.agent/skills/total-recall/plugins/mesh/ui/DESIGN.md +211 -12
- package/scaffold/.agent/skills/total-recall/plugins/mesh/ui/card.js +323 -388
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/README.md +12 -1
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/cli.mjs +7 -4
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/package-lock.json +6 -6
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/package.json +3 -2
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/plugin.json +34 -1
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/bootstrap.mjs +99 -0
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/templates/openwiki/README.md +10 -0
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/templates/openwiki/architecture.md +5 -0
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/templates/openwiki/ide.md +10 -0
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/templates/openwiki/memory.md +11 -0
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/templates/openwiki/secrets.md +7 -0
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/templates/openwiki/skills.md +7 -0
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/ui/DESIGN.md +16 -2
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/ui/card.js +380 -298
- package/scaffold/.agent/skills/total-recall/plugins/security/cli.mjs +7 -1
- package/scaffold/.agent/skills/total-recall/plugins/security/package-lock.json +6 -6
- package/scaffold/.agent/skills/total-recall/plugins/security/package.json +1 -1
- package/scaffold/.agent/skills/total-recall/plugins/security/plugin.json +1 -1
- package/scaffold/.agent/skills/total-recall/plugins/security/src/scanner.mjs +17 -3
- package/scaffold/.agent/skills/total-recall/plugins/security/ui/DESIGN.md +38 -15
- package/scaffold/.agent/skills/total-recall/plugins/security/ui/card.js +60 -34
- package/scaffold/.agent/skills/total-recall/references/auth.md +2 -0
- package/src/cli/command.mjs +23 -18
- package/src/cli/daemon.mjs +23 -1
- package/src/cli/plugin/copies.mjs +8 -5
- package/src/cli/plugin/index.mjs +25 -2
- package/src/cli/rebuild.mjs +22 -0
- package/src/cli/reset-password.mjs +3 -2
- package/src/cli/skill.mjs +8 -2
- package/src/cli/status.mjs +25 -7
- package/src/core/automatic-brain-updates.mjs +9 -3
- package/src/core/brain-updates.mjs +63 -5
- package/src/core/global-brain-updates.mjs +21 -1
- package/src/core/mesh-brain-updates.mjs +5 -2
- package/src/core/meta-harness.mjs +16 -41
- package/src/core/plugin-runner.mjs +6 -1
- package/src/core/plugin-store.mjs +67 -1
- package/src/core/plugin-updates.mjs +1 -1
- package/src/core/portable-skills-maintenance.mjs +19 -0
- package/src/core/required-plugins.mjs +7 -0
- package/src/core/skills-registry.mjs +33 -3
- package/src/server/auth.mjs +41 -1
- package/src/server/memory-app.mjs +2 -1
- package/src/server/routes/auth.mjs +25 -1
- package/src/server/routes/plugins.mjs +17 -4
|
@@ -2,9 +2,15 @@
|
|
|
2
2
|
"$schema": "https://github.com/gregiteen/total-recall/blob/main/metadata.plugin.schema.json",
|
|
3
3
|
"id": "mesh",
|
|
4
4
|
"name": "Headscale",
|
|
5
|
-
"version": "0.2.
|
|
5
|
+
"version": "0.2.4",
|
|
6
6
|
"description": "Control-server mesh administration, Tailscale SSH, and peer node discovery.",
|
|
7
7
|
"license": "MIT",
|
|
8
|
+
"requires": [
|
|
9
|
+
{
|
|
10
|
+
"id": "auth",
|
|
11
|
+
"range": ">=1.2.1"
|
|
12
|
+
}
|
|
13
|
+
],
|
|
8
14
|
"branding": {
|
|
9
15
|
"name": "Headscale",
|
|
10
16
|
"color": "#818cf8",
|
|
@@ -90,10 +96,34 @@
|
|
|
90
96
|
"effect": "write",
|
|
91
97
|
"output": "json",
|
|
92
98
|
"args": [
|
|
93
|
-
{
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
99
|
+
{
|
|
100
|
+
"name": "operation",
|
|
101
|
+
"label": "Operation",
|
|
102
|
+
"type": "string",
|
|
103
|
+
"positional": true,
|
|
104
|
+
"required": true
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
"name": "node",
|
|
108
|
+
"label": "Node",
|
|
109
|
+
"type": "string",
|
|
110
|
+
"positional": true,
|
|
111
|
+
"required": true
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
"name": "source",
|
|
115
|
+
"label": "Source",
|
|
116
|
+
"type": "string",
|
|
117
|
+
"positional": true,
|
|
118
|
+
"required": true
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
"name": "destination",
|
|
122
|
+
"label": "Destination",
|
|
123
|
+
"type": "string",
|
|
124
|
+
"positional": true,
|
|
125
|
+
"required": true
|
|
126
|
+
}
|
|
97
127
|
]
|
|
98
128
|
},
|
|
99
129
|
{
|
|
@@ -103,8 +133,30 @@
|
|
|
103
133
|
},
|
|
104
134
|
{
|
|
105
135
|
"name": "view",
|
|
106
|
-
"description": "
|
|
107
|
-
"effect": "read"
|
|
136
|
+
"description": "Read independent owner observations for mesh screens",
|
|
137
|
+
"effect": "read",
|
|
138
|
+
"timeout_ms": 60000,
|
|
139
|
+
"args": [
|
|
140
|
+
{
|
|
141
|
+
"name": "screen",
|
|
142
|
+
"label": "Screen",
|
|
143
|
+
"type": "choice",
|
|
144
|
+
"positional": true,
|
|
145
|
+
"options": [
|
|
146
|
+
"machines",
|
|
147
|
+
"flows",
|
|
148
|
+
"placement",
|
|
149
|
+
"boundary",
|
|
150
|
+
"join"
|
|
151
|
+
]
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
"name": "json",
|
|
155
|
+
"label": "JSON output",
|
|
156
|
+
"type": "boolean",
|
|
157
|
+
"flag": "--json"
|
|
158
|
+
}
|
|
159
|
+
]
|
|
108
160
|
},
|
|
109
161
|
{
|
|
110
162
|
"name": "nodes",
|
|
@@ -145,7 +197,7 @@
|
|
|
145
197
|
{
|
|
146
198
|
"name": "enroll",
|
|
147
199
|
"description": "Mesh enroll operation",
|
|
148
|
-
"effect": "
|
|
200
|
+
"effect": "destructive"
|
|
149
201
|
},
|
|
150
202
|
{
|
|
151
203
|
"name": "preauthkey",
|
|
@@ -292,5 +344,187 @@
|
|
|
292
344
|
"distribution": {
|
|
293
345
|
"tier": "official",
|
|
294
346
|
"license": "open"
|
|
347
|
+
},
|
|
348
|
+
"mesh": {
|
|
349
|
+
"version": 1,
|
|
350
|
+
"description": "Declared support and observation contracts; declarations are not live readiness or primitive grants.",
|
|
351
|
+
"provides": [
|
|
352
|
+
{
|
|
353
|
+
"id": "node-inventory",
|
|
354
|
+
"operation": "status",
|
|
355
|
+
"runtime": "mesh",
|
|
356
|
+
"observation": "machines.status"
|
|
357
|
+
},
|
|
358
|
+
{
|
|
359
|
+
"id": "execution-probe",
|
|
360
|
+
"operation": "doctor",
|
|
361
|
+
"runtime": "mesh",
|
|
362
|
+
"observation": "doctor"
|
|
363
|
+
},
|
|
364
|
+
{
|
|
365
|
+
"id": "remote-execution",
|
|
366
|
+
"operation": "exec",
|
|
367
|
+
"runtime": "mesh",
|
|
368
|
+
"observation": "doctor"
|
|
369
|
+
},
|
|
370
|
+
{
|
|
371
|
+
"id": "path-probe",
|
|
372
|
+
"operation": "ping",
|
|
373
|
+
"runtime": "mesh",
|
|
374
|
+
"observation": "ping"
|
|
375
|
+
},
|
|
376
|
+
{
|
|
377
|
+
"id": "presence",
|
|
378
|
+
"operation": "presence",
|
|
379
|
+
"runtime": "presence",
|
|
380
|
+
"observation": "machines.presence"
|
|
381
|
+
},
|
|
382
|
+
{
|
|
383
|
+
"id": "file-push",
|
|
384
|
+
"operation": "file",
|
|
385
|
+
"runtime": "mesh",
|
|
386
|
+
"observation": null
|
|
387
|
+
},
|
|
388
|
+
{
|
|
389
|
+
"id": "secret-grants",
|
|
390
|
+
"operation": "grants",
|
|
391
|
+
"runtime": "mesh-identity",
|
|
392
|
+
"observation": "flows.grants"
|
|
393
|
+
},
|
|
394
|
+
{
|
|
395
|
+
"id": "control-policy",
|
|
396
|
+
"operation": "policy",
|
|
397
|
+
"runtime": "headscale-client",
|
|
398
|
+
"observation": "boundary.policy"
|
|
399
|
+
},
|
|
400
|
+
{
|
|
401
|
+
"id": "enrollment",
|
|
402
|
+
"operation": "enroll",
|
|
403
|
+
"runtime": "mesh-enroll",
|
|
404
|
+
"observation": "join.status"
|
|
405
|
+
}
|
|
406
|
+
],
|
|
407
|
+
"integrations": [
|
|
408
|
+
{
|
|
409
|
+
"id": "auth",
|
|
410
|
+
"owner": "auth",
|
|
411
|
+
"purpose": "One host credential store; no values in views",
|
|
412
|
+
"observation": "machines.status",
|
|
413
|
+
"evidence": "control.verified"
|
|
414
|
+
},
|
|
415
|
+
{
|
|
416
|
+
"id": "headscale",
|
|
417
|
+
"owner": "mesh",
|
|
418
|
+
"purpose": "Control server authentication and membership",
|
|
419
|
+
"observation": "machines.status",
|
|
420
|
+
"evidence": "control.verified"
|
|
421
|
+
},
|
|
422
|
+
{
|
|
423
|
+
"id": "tailscale",
|
|
424
|
+
"owner": "mesh",
|
|
425
|
+
"purpose": "Client enrollment and mesh network state",
|
|
426
|
+
"observation": "machines.status",
|
|
427
|
+
"evidence": "network_available"
|
|
428
|
+
},
|
|
429
|
+
{
|
|
430
|
+
"id": "ssh",
|
|
431
|
+
"owner": "mesh",
|
|
432
|
+
"purpose": "Per-machine execution probes",
|
|
433
|
+
"observation": "doctor",
|
|
434
|
+
"evidence": "reachable per machine"
|
|
435
|
+
},
|
|
436
|
+
{
|
|
437
|
+
"id": "presence",
|
|
438
|
+
"owner": "mesh",
|
|
439
|
+
"purpose": "Authenticated fresh activity observations",
|
|
440
|
+
"observation": "machines.presence",
|
|
441
|
+
"evidence": "peer states and timestamps"
|
|
442
|
+
},
|
|
443
|
+
{
|
|
444
|
+
"id": "plugin-sharing",
|
|
445
|
+
"owner": "mesh",
|
|
446
|
+
"purpose": "Peer plugin inventories",
|
|
447
|
+
"observation": "flows.plugins",
|
|
448
|
+
"evidence": "peer status"
|
|
449
|
+
},
|
|
450
|
+
{
|
|
451
|
+
"id": "updates",
|
|
452
|
+
"owner": "core",
|
|
453
|
+
"purpose": "Last automatic maintenance report",
|
|
454
|
+
"observation": "flows.updates",
|
|
455
|
+
"evidence": "recorded last result"
|
|
456
|
+
},
|
|
457
|
+
{
|
|
458
|
+
"id": "schedules",
|
|
459
|
+
"owner": "user-automations",
|
|
460
|
+
"purpose": "Task declarations and node placement",
|
|
461
|
+
"observation": "flows.tasks",
|
|
462
|
+
"evidence": "node inventory; no execution inferred"
|
|
463
|
+
},
|
|
464
|
+
{
|
|
465
|
+
"id": "agents",
|
|
466
|
+
"owner": "core",
|
|
467
|
+
"purpose": "Running and recent process inventory",
|
|
468
|
+
"observation": "flows.agents",
|
|
469
|
+
"evidence": "process records"
|
|
470
|
+
},
|
|
471
|
+
{
|
|
472
|
+
"id": "alerts",
|
|
473
|
+
"owner": "operator-alerts",
|
|
474
|
+
"purpose": "Recorded delivery attempts",
|
|
475
|
+
"observation": "flows.alerts",
|
|
476
|
+
"evidence": "ledger result; no recipient view inferred"
|
|
477
|
+
},
|
|
478
|
+
{
|
|
479
|
+
"id": "deployments",
|
|
480
|
+
"owner": "deploy",
|
|
481
|
+
"purpose": "Selected project release state",
|
|
482
|
+
"observation": "flows.deployments",
|
|
483
|
+
"evidence": "configured slot; no fleet-wide inference"
|
|
484
|
+
}
|
|
485
|
+
],
|
|
486
|
+
"endpoints": [
|
|
487
|
+
{
|
|
488
|
+
"path": "/api/mesh/presence/local",
|
|
489
|
+
"method": "GET",
|
|
490
|
+
"runtime": "route-mesh-presence",
|
|
491
|
+
"authentication": "mesh peer"
|
|
492
|
+
},
|
|
493
|
+
{
|
|
494
|
+
"path": "/api/mesh/plugins",
|
|
495
|
+
"method": "GET",
|
|
496
|
+
"runtime": "route-plugins-mesh",
|
|
497
|
+
"authentication": "mesh peer"
|
|
498
|
+
}
|
|
499
|
+
],
|
|
500
|
+
"task_placement": "Declared task placement is selected-node or per-node; actual execution requires a verified daemon report.",
|
|
501
|
+
"unavailable": [
|
|
502
|
+
"transfer-history",
|
|
503
|
+
"service-health-cli",
|
|
504
|
+
"exit-node-management",
|
|
505
|
+
"subnet-route-management",
|
|
506
|
+
"tunnel-inventory",
|
|
507
|
+
"public-exposure-inventory",
|
|
508
|
+
"speed-measurement",
|
|
509
|
+
"messages",
|
|
510
|
+
"shared-file-spaces"
|
|
511
|
+
]
|
|
512
|
+
},
|
|
513
|
+
"capability": {
|
|
514
|
+
"entry": "capability/index.mjs",
|
|
515
|
+
"dependencies": [],
|
|
516
|
+
"exports": [
|
|
517
|
+
"createControlServer",
|
|
518
|
+
"createHeadscaleApi",
|
|
519
|
+
"buildMeshSshPolicy",
|
|
520
|
+
"meshTargetOf",
|
|
521
|
+
"sshExec",
|
|
522
|
+
"assertSecureControlUrl",
|
|
523
|
+
"normalizeControlUrl",
|
|
524
|
+
"shellQuote",
|
|
525
|
+
"patchConfig",
|
|
526
|
+
"parseChecksum",
|
|
527
|
+
"consoleConfig"
|
|
528
|
+
]
|
|
295
529
|
}
|
|
296
530
|
}
|
|
@@ -46,6 +46,7 @@ function printHelp() {
|
|
|
46
46
|
Usage: total-recall mesh <command> [options]
|
|
47
47
|
|
|
48
48
|
status Control-server reachability and credential state
|
|
49
|
+
view <screen> Read Machines, Flows, Placement, Boundary or Join sources (--json)
|
|
49
50
|
doctor Probe reachability & compute capabilities across all nodes
|
|
50
51
|
nodes List mesh nodes and how to reach each one
|
|
51
52
|
ssh <node> [cmd…] Open a session using the node's recorded access
|
|
@@ -194,9 +195,9 @@ export default async function meshCli(argv = []) {
|
|
|
194
195
|
}
|
|
195
196
|
|
|
196
197
|
if (command === 'doctor' || command === 'diagnostics') {
|
|
197
|
-
console.log('\n🩺 Total Recall — Mesh Diagnostics & Compute Capability Audit\n');
|
|
198
198
|
const enriched = listEnrichedMeshNodes(vaultRoot);
|
|
199
199
|
const isJson = args.includes('--json');
|
|
200
|
+
if (!isJson) console.log('\n🩺 Total Recall — Mesh Diagnostics & Compute Capability Audit\n');
|
|
200
201
|
|
|
201
202
|
const stripAnsi = (s) => String(s || '').replace(/\x1b\[[0-9;]*m/g, '');
|
|
202
203
|
const pad = (s, l) => String(s || '') + ' '.repeat(Math.max(0, l - stripAnsi(s).length));
|
|
@@ -217,13 +218,17 @@ export default async function meshCli(argv = []) {
|
|
|
217
218
|
reachable: false,
|
|
218
219
|
error: null
|
|
219
220
|
};
|
|
221
|
+
nodeInfo.tested_runtimes = ['ollama', 'docker', 'node', 'git'];
|
|
222
|
+
nodeInfo.tested_harnesses = ['agy', 'claude', 'codex', 'gemini'];
|
|
220
223
|
|
|
221
224
|
if (node.self) {
|
|
222
225
|
try {
|
|
223
226
|
const { detectHarnesses } = await import('../core/meta-harness.mjs');
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
+
const detected = detectHarnesses();
|
|
228
|
+
nodeInfo.harnesses = detected.filter(h => h.available).map(h => h.id);
|
|
229
|
+
nodeInfo.tested_harnesses = detected.map(h => h.id);
|
|
230
|
+
} catch { nodeInfo.tested_harnesses = []; }
|
|
231
|
+
nodeInfo.runtimes = nodeInfo.tested_runtimes.filter(tool => spawnSync('/bin/sh', ['-c', 'command -v "$1"', 'mesh-probe', tool], { encoding: 'utf8', timeout: 3000 }).status === 0);
|
|
227
232
|
nodeInfo.reachable = true;
|
|
228
233
|
} else if (resolved.complete) {
|
|
229
234
|
try {
|
|
@@ -233,16 +238,12 @@ export default async function meshCli(argv = []) {
|
|
|
233
238
|
// reachable machines as down.
|
|
234
239
|
const res = await execMeshCommand(node.hostname, probeCmd, { vaultRoot, timeoutMs: 15000 });
|
|
235
240
|
nodeInfo.reachable = res.success;
|
|
236
|
-
if (res.success && !node.access?.verified_at) {
|
|
237
|
-
// A login that just worked is the only kind worth sharing with peers.
|
|
238
|
-
await setMeshNodeAccess(node.hostname, { verified_at: new Date().toISOString() }, { vaultRoot });
|
|
239
|
-
}
|
|
240
241
|
if (res.success) {
|
|
241
242
|
const parts = res.stdout.split(/\s+/);
|
|
242
243
|
for (const part of parts) {
|
|
243
244
|
const [k, v] = part.split('=');
|
|
244
245
|
if (v && v.trim()) {
|
|
245
|
-
if (['agy', 'claude', 'codex', 'gemini'
|
|
246
|
+
if (['agy', 'claude', 'codex', 'gemini'].includes(k)) {
|
|
246
247
|
nodeInfo.harnesses.push(k);
|
|
247
248
|
} else {
|
|
248
249
|
nodeInfo.runtimes.push(k);
|
|
@@ -770,6 +771,10 @@ export default async function meshCli(argv = []) {
|
|
|
770
771
|
if (reportPolicyModeError(err)) return;
|
|
771
772
|
throw err;
|
|
772
773
|
}
|
|
774
|
+
if (args.includes('--json')) {
|
|
775
|
+
console.log(JSON.stringify({ configured: current.configured, policy: current.policy, updatedAt: current.updatedAt || null }));
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
773
778
|
if (!current.configured) {
|
|
774
779
|
console.log('No policy is set on the control server.');
|
|
775
780
|
console.log('Run `total-recall mesh policy init-ssh` to enable Tailscale SSH.');
|
|
@@ -3,7 +3,7 @@ import fs from 'node:fs';
|
|
|
3
3
|
import os from 'node:os';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { spawnSync } from 'node:child_process';
|
|
6
|
-
import { meshTargetOf, provisionHeadscale, updateHeadscale, updateConsole, sshExec } from '
|
|
6
|
+
import { meshTargetOf, provisionHeadscale, updateHeadscale, updateConsole, sshExec } from '../../capability/control-server.mjs';
|
|
7
7
|
import { saveCredential } from '../core/credentials.mjs';
|
|
8
8
|
import { brainDir } from '../core/config.mjs';
|
|
9
9
|
import { PluginStateStore } from '../ssss-store.mjs';
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { importHostModule } from '../host-runtime.mjs';
|
|
2
2
|
const { loadPluginModule: __loadOwnedPlugin } = await importHostModule('src/core/plugin-runtime.mjs');
|
|
3
3
|
import { assertSecureControlUrl, normalizeControlUrl, headscaleUrlFromEnv, findHeadscaleKeyMeta } from './headscale-client.mjs';
|
|
4
|
-
const { setSecret
|
|
4
|
+
const { setSecret } = await __loadOwnedPlugin('auth', 'secrets-store');
|
|
5
|
+
const { hostSecretsBrainDir } = await __loadOwnedPlugin('auth', 'host-secrets-store');
|
|
5
6
|
|
|
6
7
|
export async function verifyCredential(value, url, fetchImpl = fetch) {
|
|
7
8
|
if (typeof value !== 'string' || !value.trim()) throw new Error('A credential is required');
|
|
@@ -18,8 +19,9 @@ export async function verifyCredential(value, url, fetchImpl = fetch) {
|
|
|
18
19
|
return { verified: true, url: normalizeControlUrl(url) };
|
|
19
20
|
}
|
|
20
21
|
|
|
21
|
-
export async function saveCredential({ key, value, configuration = {}
|
|
22
|
+
export async function saveCredential({ key, value, configuration = {} }, dependencies = {}) {
|
|
22
23
|
if (key !== 'HEADSCALE_API_KEY') throw new Error('Unsupported mesh credential');
|
|
24
|
+
const brainDir = hostSecretsBrainDir();
|
|
23
25
|
const meta = await findHeadscaleKeyMeta(brainDir);
|
|
24
26
|
const url = configuration.controlUrl || headscaleUrlFromEnv() || meta?.headscale_url;
|
|
25
27
|
if (!url) throw new Error('Set the control server URL before saving the credential');
|
|
@@ -3,14 +3,19 @@ const { loadPluginModule: __loadOwnedPlugin } = await importHostModule('src/core
|
|
|
3
3
|
/**
|
|
4
4
|
* Headscale control-server client.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
* automatic node enrollment (`mesh-enroll.mjs`).
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* Host side of the Headscale client, shared by the REST proxy
|
|
7
|
+
* (`routes/headscale.mjs`) and automatic node enrollment (`mesh-enroll.mjs`).
|
|
8
|
+
* This module only resolves the operator's credential from the auth plugin and
|
|
9
|
+
* the throttled transport; the API calls themselves live in the host-free
|
|
10
|
+
* brick (`capability/headscale-api.mjs`). No control-server URL is ever
|
|
11
|
+
* hardcoded — it comes from the operator's own secret entry or environment.
|
|
10
12
|
*/
|
|
11
|
-
|
|
12
|
-
const {
|
|
13
|
+
const { getSecretsCatalog, getSecret } = await __loadOwnedPlugin('auth', 'secrets-store');
|
|
14
|
+
const { hostSecretsBrainDir } = await __loadOwnedPlugin('auth', 'host-secrets-store');
|
|
13
15
|
import { throttledFetch } from './throttled-fetch.mjs';
|
|
16
|
+
import { createHeadscaleApi, assertSecureControlUrl, normalizeControlUrl, buildMeshSshPolicy } from '../../capability/headscale-api.mjs';
|
|
17
|
+
|
|
18
|
+
export { assertSecureControlUrl, normalizeControlUrl, buildMeshSshPolicy };
|
|
14
19
|
|
|
15
20
|
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
16
21
|
|
|
@@ -20,34 +25,15 @@ export function headscaleUrlFromEnv() {
|
|
|
20
25
|
return raw.trim() || null;
|
|
21
26
|
}
|
|
22
27
|
|
|
23
|
-
/**
|
|
24
|
-
* Reject non-HTTPS control servers (loopback excepted for local development).
|
|
25
|
-
* Bearer tokens must never travel in cleartext over a network hop.
|
|
26
|
-
*/
|
|
27
|
-
export function assertSecureControlUrl(url) {
|
|
28
|
-
const parsed = new URL(url);
|
|
29
|
-
const isLoopback = ['localhost', '127.0.0.1', '::1'].includes(parsed.hostname);
|
|
30
|
-
if (parsed.protocol !== 'https:' && !(isLoopback && parsed.protocol === 'http:')) {
|
|
31
|
-
throw new Error('Headscale URL must use HTTPS (HTTP is allowed only for loopback development)');
|
|
32
|
-
}
|
|
33
|
-
return parsed;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/** Strip a trailing slash so path joining stays predictable. */
|
|
37
|
-
export function normalizeControlUrl(url) {
|
|
38
|
-
const value = String(url || '').trim();
|
|
39
|
-
return value.endsWith('/') ? value.slice(0, -1) : value;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
28
|
/**
|
|
43
29
|
* Locate the operator's headscale credential without knowing its key name.
|
|
44
30
|
* Any secret whose provider is `headscale` is treated as the control-server token.
|
|
45
31
|
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
32
|
+
* Auth selects its one store for this host (or explicit TR_SECRETS_BRAIN).
|
|
33
|
+
* The caller's project brain never creates or selects a second credential store.
|
|
48
34
|
*/
|
|
49
35
|
async function locateHeadscaleKey(brainDir) {
|
|
50
|
-
const target =
|
|
36
|
+
const target = hostSecretsBrainDir();
|
|
51
37
|
|
|
52
38
|
const local = await getSecretsCatalog(target);
|
|
53
39
|
const hit = local.keys.find((k) => k.provider === 'headscale');
|
|
@@ -112,174 +98,41 @@ export async function describeHeadscaleAvailability(brainDir) {
|
|
|
112
98
|
}
|
|
113
99
|
}
|
|
114
100
|
|
|
101
|
+
/** A brick client bound to this brain's credential, over the host's throttled transport. */
|
|
102
|
+
async function hostApi(brainDir) {
|
|
103
|
+
const { url, token } = await resolveHeadscaleConfig(brainDir);
|
|
104
|
+
return createHeadscaleApi({
|
|
105
|
+
url,
|
|
106
|
+
token,
|
|
107
|
+
fetch: (target, { timeoutMs, ...options }) => throttledFetch(target, options, timeoutMs || DEFAULT_TIMEOUT_MS),
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
115
111
|
/**
|
|
116
112
|
* Authenticated request against the control server's API.
|
|
117
|
-
*
|
|
113
|
+
* Credentials always come from the auth owner's host-store selector.
|
|
118
114
|
*/
|
|
119
115
|
export async function headscaleFetch(path, options = {}, brainDir) {
|
|
120
|
-
|
|
121
|
-
assertSecureControlUrl(url);
|
|
122
|
-
|
|
123
|
-
const cleanPath = path.startsWith('/') ? path : `/${path}`;
|
|
124
|
-
const targetUrl = `${normalizeControlUrl(url)}${cleanPath}`;
|
|
125
|
-
|
|
126
|
-
const res = await throttledFetch(
|
|
127
|
-
targetUrl,
|
|
128
|
-
{
|
|
129
|
-
...options,
|
|
130
|
-
headers: {
|
|
131
|
-
...options.headers,
|
|
132
|
-
Authorization: `Bearer ${token}`,
|
|
133
|
-
Accept: 'application/json',
|
|
134
|
-
'Content-Type': 'application/json',
|
|
135
|
-
},
|
|
136
|
-
},
|
|
137
|
-
options.timeoutMs || DEFAULT_TIMEOUT_MS,
|
|
138
|
-
);
|
|
139
|
-
|
|
140
|
-
if (!res.ok) {
|
|
141
|
-
const errorText = await res.text().catch(() => '');
|
|
142
|
-
const error = new Error(`Headscale API error (${res.status} ${res.statusText})`);
|
|
143
|
-
error.status = res.status;
|
|
144
|
-
error.detail = errorText.slice(0, 500);
|
|
145
|
-
throw error;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
const contentType = res.headers.get('content-type') || '';
|
|
149
|
-
if (contentType.includes('application/json')) {
|
|
150
|
-
return res.json();
|
|
151
|
-
}
|
|
152
|
-
throw new Error('Control server returned a non-JSON response');
|
|
116
|
+
return (await hostApi(brainDir)).request(path, options);
|
|
153
117
|
}
|
|
154
118
|
|
|
155
|
-
/**
|
|
156
|
-
* Resolve a control-server user to both of its identifying forms.
|
|
157
|
-
*
|
|
158
|
-
* User-facing names resolve to the current API numeric ID.
|
|
159
|
-
*/
|
|
119
|
+
/** Resolve a control-server user to both of its identifying forms. */
|
|
160
120
|
export async function resolveHeadscaleUser(brainDir, requested) {
|
|
161
|
-
|
|
162
|
-
const users = data?.users || [];
|
|
163
|
-
|
|
164
|
-
if (requested) {
|
|
165
|
-
const match = users.find(
|
|
166
|
-
(u) =>
|
|
167
|
-
String(u.id) === String(requested) ||
|
|
168
|
-
u.name === requested ||
|
|
169
|
-
(u.email && u.email === requested),
|
|
170
|
-
);
|
|
171
|
-
if (match) return { id: String(match.id), name: match.name };
|
|
172
|
-
// Unknown to us — pass it through and let the control server decide.
|
|
173
|
-
return { id: String(requested), name: String(requested) };
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
if (!users.length) return null;
|
|
177
|
-
return { id: String(users[0].id), name: users[0].name };
|
|
121
|
+
return (await hostApi(brainDir)).resolveUser(requested);
|
|
178
122
|
}
|
|
179
123
|
|
|
180
|
-
/** Mint
|
|
124
|
+
/** Mint a pre-auth key for a registered numeric control-server user. */
|
|
181
125
|
export async function createHeadscalePreAuthKey(options = {}, brainDir) {
|
|
182
|
-
|
|
183
|
-
const expiration = options.expiration || new Date(Date.now() + ttlMinutes * 60_000).toISOString();
|
|
184
|
-
const ref = options.userRef || await resolveHeadscaleUser(brainDir, options.user);
|
|
185
|
-
if (!ref?.id || !/^[0-9]+$/.test(String(ref.id))) throw new Error('Select a registered numeric control-server user');
|
|
186
|
-
const user = String(ref.id);
|
|
187
|
-
const data = await headscaleFetch('/api/v1/preauthkey', {
|
|
188
|
-
method: 'POST', body: JSON.stringify({ user, reusable: options.reusable === true,
|
|
189
|
-
ephemeral: options.ephemeral === true, expiration, aclTags: options.aclTags || [] }),
|
|
190
|
-
}, brainDir);
|
|
191
|
-
const key = data?.preAuthKey?.key;
|
|
192
|
-
if (typeof key !== 'string' || !key) throw new Error('Headscale did not return a pre-auth key');
|
|
193
|
-
return { key, expiration, user, raw: data };
|
|
126
|
+
return (await hostApi(brainDir)).createPreAuthKey(options);
|
|
194
127
|
}
|
|
195
128
|
|
|
196
|
-
/**
|
|
197
|
-
* Read the control server's ACL policy.
|
|
198
|
-
*
|
|
199
|
-
* Requires the server to run with `policy.mode: database` — in `file` mode the
|
|
200
|
-
* policy lives on the control server's disk and the API cannot manage it.
|
|
201
|
-
*
|
|
202
|
-
* Headscale returns a 500 (not a 404) when database mode is on but the policy
|
|
203
|
-
* table is still empty, so an unset policy is normalised to `policy: null`
|
|
204
|
-
* rather than thrown. That distinction matters: "no policy yet" is the expected
|
|
205
|
-
* first-run state, whereas a real transport or auth failure must still surface.
|
|
206
|
-
*/
|
|
129
|
+
/** Read the control server's ACL policy; an empty policy table reads as `policy: null`. */
|
|
207
130
|
export async function getHeadscalePolicy(brainDir) {
|
|
208
|
-
|
|
209
|
-
const data = await headscaleFetch('/api/v1/policy', {}, brainDir);
|
|
210
|
-
return {
|
|
211
|
-
policy: data?.policy ?? null,
|
|
212
|
-
updatedAt: data?.updatedAt ?? null,
|
|
213
|
-
configured: Boolean(data?.policy),
|
|
214
|
-
raw: data,
|
|
215
|
-
};
|
|
216
|
-
} catch (err) {
|
|
217
|
-
const detail = String(err.detail || '');
|
|
218
|
-
const emptyPolicyTable =
|
|
219
|
-
err.status === 404 ||
|
|
220
|
-
(err.status === 500 && /policy|not found|no such/i.test(detail));
|
|
221
|
-
if (emptyPolicyTable) {
|
|
222
|
-
return { policy: null, updatedAt: null, configured: false, unset: true, raw: null };
|
|
223
|
-
}
|
|
224
|
-
if (err.status === 400 && /database/i.test(detail)) {
|
|
225
|
-
const modeError = new Error(
|
|
226
|
-
'Headscale is running in file policy mode; set `policy.mode: database` to manage the policy over the API',
|
|
227
|
-
);
|
|
228
|
-
modeError.status = err.status;
|
|
229
|
-
modeError.detail = err.detail;
|
|
230
|
-
modeError.code = 'POLICY_MODE_FILE';
|
|
231
|
-
throw modeError;
|
|
232
|
-
}
|
|
233
|
-
throw err;
|
|
234
|
-
}
|
|
131
|
+
return (await hostApi(brainDir)).getPolicy();
|
|
235
132
|
}
|
|
236
133
|
|
|
237
|
-
/**
|
|
238
|
-
* Replace the control server's ACL policy.
|
|
239
|
-
*
|
|
240
|
-
* `policy` may be an object (serialised here) or an already-formatted
|
|
241
|
-
* HuJSON/JSON string. Headscale validates server-side and rejects the write if
|
|
242
|
-
* the policy's own `tests` block fails, so a bad policy never lands.
|
|
243
|
-
*/
|
|
134
|
+
/** Replace the control server's ACL policy; Headscale validates it server-side. */
|
|
244
135
|
export async function setHeadscalePolicy(policy, brainDir) {
|
|
245
136
|
if (!policy) throw new Error('A policy is required');
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
const data = await headscaleFetch(
|
|
249
|
-
'/api/v1/policy',
|
|
250
|
-
{ method: 'PUT', body: JSON.stringify({ policy: serialised }) },
|
|
251
|
-
brainDir,
|
|
252
|
-
);
|
|
253
|
-
return { policy: data?.policy ?? serialised, updatedAt: data?.updatedAt ?? null, raw: data };
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
/**
|
|
257
|
-
* Build a policy that lets every mesh member reach every other member, and use
|
|
258
|
-
* Tailscale SSH between them as any non-root user.
|
|
259
|
-
*
|
|
260
|
-
* This is deliberately the simple single-operator shape: one person's own
|
|
261
|
-
* machines, all equally trusted. It is NOT appropriate for a multi-tenant
|
|
262
|
-
* tailnet — headscale membership is the trust boundary, so anything admitted to
|
|
263
|
-
* the mesh inherits this access. Narrow `src`/`dst` before adding a node you do
|
|
264
|
-
* not control.
|
|
265
|
-
*
|
|
266
|
-
* root is excluded via `autogroup:nonroot`: SSH-ing straight to root over the
|
|
267
|
-
* mesh removes the audit trail that makes per-user access worth having.
|
|
268
|
-
*/
|
|
269
|
-
export function buildMeshSshPolicy({ allowRoot = false } = {}) {
|
|
270
|
-
return {
|
|
271
|
-
acls: [{ action: 'accept', src: ['*'], dst: ['*:*'] }],
|
|
272
|
-
ssh: [
|
|
273
|
-
{
|
|
274
|
-
action: 'accept',
|
|
275
|
-
// autogroup:member, not autogroup:self. 'self' limits a member to SSH on their own
|
|
276
|
-
// devices, so no node on the mesh could reach any other one and every cross-machine
|
|
277
|
-
// operation fell back to key auth that had never been set up — which reads as the peer
|
|
278
|
-
// being unreachable. The point of this policy is that members reach each other.
|
|
279
|
-
src: ['autogroup:member'],
|
|
280
|
-
dst: ['autogroup:member'],
|
|
281
|
-
users: allowRoot ? ['autogroup:nonroot', 'root'] : ['autogroup:nonroot'],
|
|
282
|
-
},
|
|
283
|
-
],
|
|
284
|
-
};
|
|
137
|
+
return (await hostApi(brainDir)).setPolicy(policy);
|
|
285
138
|
}
|
|
@@ -2,8 +2,8 @@ import { importHostModule } from '../host-runtime.mjs';
|
|
|
2
2
|
const { loadPluginModule: __loadOwnedPlugin } = await importHostModule('src/core/plugin-runtime.mjs');
|
|
3
3
|
import crypto from 'node:crypto';
|
|
4
4
|
import net from 'node:net';
|
|
5
|
-
import { brainDir } from './config.mjs';
|
|
6
5
|
const { getSecret } = await __loadOwnedPlugin('auth', 'secrets-store');
|
|
6
|
+
const { hostSecretsBrainDir } = await __loadOwnedPlugin('auth', 'host-secrets-store');
|
|
7
7
|
|
|
8
8
|
export function normalizeRemoteAddress(value) {
|
|
9
9
|
const address = String(value || '').trim();
|
|
@@ -20,7 +20,7 @@ export function isMeshOrLoopbackAddress(value) {
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
export async function getMeshSyncToken() {
|
|
23
|
-
const result = await getSecret(
|
|
23
|
+
const result = await getSecret(hostSecretsBrainDir(), 'TR_MESH_SYNC_TOKEN', {
|
|
24
24
|
action: 'use',
|
|
25
25
|
actor: 'mesh-sync-auth',
|
|
26
26
|
});
|