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.
Files changed (105) hide show
  1. package/bin/total-recall.mjs +9 -3
  2. package/frontend/dist/assets/{index-BG9NkVUy.css → index-BIQmqDKP.css} +1 -1
  3. package/frontend/dist/assets/{index-BtuiZqwP.js → index-DFenQQEQ.js} +74 -79
  4. package/frontend/dist/index.html +2 -2
  5. package/frontend/dist/sw.js +8 -5
  6. package/frontend/src/api/auth.ts +0 -3
  7. package/frontend/src/components/plugins/PluginPanel.tsx +7 -3
  8. package/frontend/src/components/plugins/card/CardEngine.tsx +1 -1
  9. package/frontend/src/pages/LoginPage.tsx +1 -41
  10. package/frontend/src/pages/PluginsPage.tsx +14 -7
  11. package/package.json +2 -2
  12. package/scaffold/.agent/skills/plugin/scripts/record.mjs +6 -3
  13. package/scaffold/.agent/skills/total-recall/plugins/auth/README.md +10 -0
  14. package/scaffold/.agent/skills/total-recall/plugins/auth/cli.mjs +197 -31
  15. package/scaffold/.agent/skills/total-recall/plugins/auth/package-lock.json +1456 -12
  16. package/scaffold/.agent/skills/total-recall/plugins/auth/package.json +4 -4
  17. package/scaffold/.agent/skills/total-recall/plugins/auth/plugin.json +8 -1
  18. package/scaffold/.agent/skills/total-recall/plugins/auth/src/cli-secret.mjs +210 -27
  19. package/scaffold/.agent/skills/total-recall/plugins/auth/src/provider-catalog.mjs +10 -1
  20. package/scaffold/.agent/skills/total-recall/plugins/auth/src/rotation-capability.mjs +15 -0
  21. package/scaffold/.agent/skills/total-recall/plugins/auth/src/secrets-env-export.mjs +78 -6
  22. package/scaffold/.agent/skills/total-recall/plugins/auth/src/secrets-merge.mjs +131 -0
  23. package/scaffold/.agent/skills/total-recall/plugins/auth/src/secrets-remote-deploy.mjs +111 -2
  24. package/scaffold/.agent/skills/total-recall/plugins/auth/src/secrets-store.mjs +26 -5
  25. package/scaffold/.agent/skills/total-recall/plugins/auth/ui/DESIGN.md +56 -8
  26. package/scaffold/.agent/skills/total-recall/plugins/auth/ui/card.js +1068 -204
  27. package/scaffold/.agent/skills/total-recall/plugins/auth/ui/panel.js +4 -2
  28. package/scaffold/.agent/skills/total-recall/plugins/cli-agents/ui/DESIGN.md +32 -0
  29. package/scaffold/.agent/skills/total-recall/plugins/cli-agents/ui/card.js +436 -274
  30. package/scaffold/.agent/skills/total-recall/plugins/code-quality/package-lock.json +4 -4
  31. package/scaffold/.agent/skills/total-recall/plugins/code-quality/package.json +1 -1
  32. package/scaffold/.agent/skills/total-recall/plugins/code-quality/ui/DESIGN.md +16 -2
  33. package/scaffold/.agent/skills/total-recall/plugins/code-quality/ui/card.js +461 -242
  34. package/scaffold/.agent/skills/total-recall/plugins/composable-cli/package-lock.json +4 -4
  35. package/scaffold/.agent/skills/total-recall/plugins/composable-cli/package.json +1 -1
  36. package/scaffold/.agent/skills/total-recall/plugins/composable-cli/plugin.json +1 -7
  37. package/scaffold/.agent/skills/total-recall/plugins/composable-cli/ui/DESIGN.md +17 -2
  38. package/scaffold/.agent/skills/total-recall/plugins/composable-cli/ui/card.js +426 -300
  39. package/scaffold/.agent/skills/total-recall/plugins/embeddings/package-lock.json +4 -4
  40. package/scaffold/.agent/skills/total-recall/plugins/embeddings/ui/DESIGN.md +32 -0
  41. package/scaffold/.agent/skills/total-recall/plugins/embeddings/ui/card.js +350 -283
  42. package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/package-lock.json +4 -4
  43. package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/ui/card.js +31 -19
  44. package/scaffold/.agent/skills/total-recall/plugins/mesh/README.md +10 -0
  45. package/scaffold/.agent/skills/total-recall/plugins/mesh/{src/core/headscale-server.mjs → capability/control-server.mjs} +5 -2
  46. package/scaffold/.agent/skills/total-recall/plugins/mesh/capability/headscale-api.mjs +192 -0
  47. package/scaffold/.agent/skills/total-recall/plugins/mesh/capability/index.mjs +46 -0
  48. package/scaffold/.agent/skills/total-recall/plugins/mesh/cli.mjs +9 -1
  49. package/scaffold/.agent/skills/total-recall/plugins/mesh/package-lock.json +23 -22
  50. package/scaffold/.agent/skills/total-recall/plugins/mesh/package.json +6 -3
  51. package/scaffold/.agent/skills/total-recall/plugins/mesh/plugin.json +242 -8
  52. package/scaffold/.agent/skills/total-recall/plugins/mesh/src/cli/mesh.mjs +14 -9
  53. package/scaffold/.agent/skills/total-recall/plugins/mesh/src/cli/server.mjs +1 -1
  54. package/scaffold/.agent/skills/total-recall/plugins/mesh/src/core/credentials.mjs +4 -2
  55. package/scaffold/.agent/skills/total-recall/plugins/mesh/src/core/headscale-client.mjs +34 -181
  56. package/scaffold/.agent/skills/total-recall/plugins/mesh/src/core/mesh-auth.mjs +2 -2
  57. package/scaffold/.agent/skills/total-recall/plugins/mesh/src/core/presence.mjs +5 -5
  58. package/scaffold/.agent/skills/total-recall/plugins/mesh/src/core/screen-view.mjs +78 -0
  59. package/scaffold/.agent/skills/total-recall/plugins/mesh/ui/DESIGN.md +211 -12
  60. package/scaffold/.agent/skills/total-recall/plugins/mesh/ui/card.js +323 -388
  61. package/scaffold/.agent/skills/total-recall/plugins/openwiki/README.md +12 -1
  62. package/scaffold/.agent/skills/total-recall/plugins/openwiki/cli.mjs +7 -4
  63. package/scaffold/.agent/skills/total-recall/plugins/openwiki/package-lock.json +6 -6
  64. package/scaffold/.agent/skills/total-recall/plugins/openwiki/package.json +3 -2
  65. package/scaffold/.agent/skills/total-recall/plugins/openwiki/plugin.json +34 -1
  66. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/bootstrap.mjs +99 -0
  67. package/scaffold/.agent/skills/total-recall/plugins/openwiki/templates/openwiki/README.md +10 -0
  68. package/scaffold/.agent/skills/total-recall/plugins/openwiki/templates/openwiki/architecture.md +5 -0
  69. package/scaffold/.agent/skills/total-recall/plugins/openwiki/templates/openwiki/ide.md +10 -0
  70. package/scaffold/.agent/skills/total-recall/plugins/openwiki/templates/openwiki/memory.md +11 -0
  71. package/scaffold/.agent/skills/total-recall/plugins/openwiki/templates/openwiki/secrets.md +7 -0
  72. package/scaffold/.agent/skills/total-recall/plugins/openwiki/templates/openwiki/skills.md +7 -0
  73. package/scaffold/.agent/skills/total-recall/plugins/openwiki/ui/DESIGN.md +16 -2
  74. package/scaffold/.agent/skills/total-recall/plugins/openwiki/ui/card.js +380 -298
  75. package/scaffold/.agent/skills/total-recall/plugins/security/cli.mjs +7 -1
  76. package/scaffold/.agent/skills/total-recall/plugins/security/package-lock.json +6 -6
  77. package/scaffold/.agent/skills/total-recall/plugins/security/package.json +1 -1
  78. package/scaffold/.agent/skills/total-recall/plugins/security/plugin.json +1 -1
  79. package/scaffold/.agent/skills/total-recall/plugins/security/src/scanner.mjs +17 -3
  80. package/scaffold/.agent/skills/total-recall/plugins/security/ui/DESIGN.md +38 -15
  81. package/scaffold/.agent/skills/total-recall/plugins/security/ui/card.js +60 -34
  82. package/scaffold/.agent/skills/total-recall/references/auth.md +2 -0
  83. package/src/cli/command.mjs +23 -18
  84. package/src/cli/daemon.mjs +23 -1
  85. package/src/cli/plugin/copies.mjs +8 -5
  86. package/src/cli/plugin/index.mjs +25 -2
  87. package/src/cli/rebuild.mjs +22 -0
  88. package/src/cli/reset-password.mjs +3 -2
  89. package/src/cli/skill.mjs +8 -2
  90. package/src/cli/status.mjs +25 -7
  91. package/src/core/automatic-brain-updates.mjs +9 -3
  92. package/src/core/brain-updates.mjs +63 -5
  93. package/src/core/global-brain-updates.mjs +21 -1
  94. package/src/core/mesh-brain-updates.mjs +5 -2
  95. package/src/core/meta-harness.mjs +16 -41
  96. package/src/core/plugin-runner.mjs +6 -1
  97. package/src/core/plugin-store.mjs +67 -1
  98. package/src/core/plugin-updates.mjs +1 -1
  99. package/src/core/portable-skills-maintenance.mjs +19 -0
  100. package/src/core/required-plugins.mjs +7 -0
  101. package/src/core/skills-registry.mjs +33 -3
  102. package/src/server/auth.mjs +41 -1
  103. package/src/server/memory-app.mjs +2 -1
  104. package/src/server/routes/auth.mjs +25 -1
  105. 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.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
- {"name":"operation","label":"Operation","type":"string","positional":true,"required":true},
94
- {"name":"node","label":"Node","type":"string","positional":true,"required":true},
95
- {"name":"source","label":"Source","type":"string","positional":true,"required":true},
96
- {"name":"destination","label":"Destination","type":"string","positional":true,"required":true}
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": "Inspect live mesh state",
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": "write"
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
- nodeInfo.harnesses = detectHarnesses().filter(h => h.available).map(h => h.id);
225
- } catch {}
226
- nodeInfo.runtimes = ['node', 'git'];
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', 'ollama'].includes(k)) {
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 '../core/headscale-server.mjs';
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, defaultBrainDir } = await __loadOwnedPlugin('auth', 'secrets-store');
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 = {}, brainDir = defaultBrainDir() }, dependencies = {}) {
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
- * Single implementation shared by the REST proxy (`routes/headscale.mjs`) and
7
- * automatic node enrollment (`mesh-enroll.mjs`). No control-server URL is ever
8
- * hardcoded here — it is resolved from the operator's own secret entry or
9
- * environment, so this works against any Headscale (or Tailscale SaaS) install.
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
- import path from 'node:path';
12
- const { getSecretsCatalog, getSecret, defaultBrainDir } = await __loadOwnedPlugin('auth', 'secrets-store');
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
- * Only the explicitly selected auth store is searched. Missing credentials
47
- * and unlock failures never select another brain or ambient token.
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 = brainDir || defaultBrainDir();
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
- * `brainDir` is required so the caller's brain scope selects the credential.
113
+ * Credentials always come from the auth owner's host-store selector.
118
114
  */
119
115
  export async function headscaleFetch(path, options = {}, brainDir) {
120
- const { url, token } = await resolveHeadscaleConfig(brainDir);
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
- const data = await headscaleFetch('/api/v1/user', {}, brainDir);
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 using the current numeric-user API without older-version retries. */
124
+ /** Mint a pre-auth key for a registered numeric control-server user. */
181
125
  export async function createHeadscalePreAuthKey(options = {}, brainDir) {
182
- const ttlMinutes = Number.isFinite(options.ttlMinutes) ? options.ttlMinutes : 10;
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
- try {
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
- const serialised = typeof policy === 'string' ? policy : JSON.stringify(policy, null, 2);
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(brainDir, 'TR_MESH_SYNC_TOKEN', {
23
+ const result = await getSecret(hostSecretsBrainDir(), 'TR_MESH_SYNC_TOKEN', {
24
24
  action: 'use',
25
25
  actor: 'mesh-sync-auth',
26
26
  });