total-recall-brain 3.22.0 → 3.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/bin/total-recall.mjs +2 -0
  2. package/frontend/src/api/headscale.ts +68 -1
  3. package/frontend/src/api/mesh.spec.ts +27 -0
  4. package/frontend/src/api/mesh.ts +85 -0
  5. package/frontend/src/components/EmbeddingProviderPanel.tsx +21 -3
  6. package/frontend/src/pages/MeshPage.spec.tsx +164 -0
  7. package/frontend/src/pages/MeshPage.tsx +294 -6
  8. package/frontend/src/pages/NetworkPage.spec.tsx +19 -4
  9. package/frontend/src/pages/OnboardingPage.spec.tsx +14 -2
  10. package/frontend/src/pages/SkillsPage.spec.tsx +1 -1
  11. package/package.json +2 -1
  12. package/scaffold/.agent/skills/total-recall/references/api-reference.md +1 -1
  13. package/scaffold/.agent/skills/total-recall/references/cli-reference.md +2 -1
  14. package/scaffold/.agent/skills/total-recall/skills-registry/index.yaml +6 -2574
  15. package/src/cli/mesh.mjs +416 -0
  16. package/src/cli/repo-expert-generate.mjs +5 -1
  17. package/src/cli/reset-password.mjs +75 -6
  18. package/src/cli/secret.mjs +250 -7
  19. package/src/core/browser-session.mjs +68 -13
  20. package/src/core/headscale-client.mjs +87 -0
  21. package/src/core/mesh-access.mjs +261 -0
  22. package/src/core/mesh-enroll.mjs +74 -32
  23. package/src/core/mesh.mjs +164 -12
  24. package/src/core/schema.mjs +36 -0
  25. package/src/core/secrets-keychain.mjs +83 -0
  26. package/src/core/secrets-rekey.mjs +0 -0
  27. package/src/core/secrets-store.mjs +26 -6
  28. package/src/core/tailscale-cli.mjs +69 -0
  29. package/src/server/index.mjs +23 -4
  30. package/src/server/pid-lock.mjs +64 -0
  31. package/src/server/route-inventory.mjs +62 -0
  32. package/src/server/route-manifest.json +20 -0
  33. package/src/server/routes/headscale.mjs +32 -0
  34. package/src/server/routes/mesh.mjs +118 -0
@@ -5,7 +5,7 @@
5
5
  * npx total-recall secret set <key> <value> [--provider name] [--scope global|project]
6
6
  * npx total-recall secret get <key> # prints value (use carefully)
7
7
  * npx total-recall secret list
8
- * npx total-recall secret rotate <key> <new-value>
8
+ * npx total-recall secret rotate <key> # prompts for the value
9
9
  * npx total-recall secret delete <key>
10
10
  * npx total-recall secret audit [--limit N]
11
11
  * npx total-recall secret usage [--days N]
@@ -60,6 +60,19 @@ import { listProviders, getProvider } from '../core/provider-catalog.mjs';
60
60
  import { resolveBrainDir, parseLayerFlag } from './agent-dir.mjs';
61
61
  import fs from 'node:fs';
62
62
  import path from 'node:path';
63
+ import { execFileSync } from 'node:child_process';
64
+ import os from 'node:os';
65
+ import {
66
+ generateSecretsMasterPassword,
67
+ readMasterPasswordFromCarrier,
68
+ rekeySecretsTransaction,
69
+ } from '../core/secrets-rekey.mjs';
70
+ import {
71
+ DEFAULT_KEYCHAIN_SERVICE,
72
+ keychainAvailable,
73
+ readKeychainPassword,
74
+ } from '../core/secrets-keychain.mjs';
75
+ import { agentDir as configuredAgentDir, getActiveBrains } from '../core/config.mjs';
63
76
 
64
77
  // Reads a secret value from stdin (fd 0) so it never has to appear as a CLI
65
78
  // argument — CLI args land in shell history, `ps aux`, and any transcript
@@ -71,6 +84,75 @@ function readStdinValue() {
71
84
  return raw.endsWith('\n') ? raw.slice(0, -1) : raw;
72
85
  }
73
86
 
87
+ /**
88
+ * Read the OS clipboard. Keeps a pasted credential inside this process rather
89
+ * than routing it through argv, shell history, or an agent transcript.
90
+ *
91
+ * @returns {string}
92
+ */
93
+ function readSystemClipboard() {
94
+ const cmds =
95
+ process.platform === 'darwin'
96
+ ? [['pbpaste', []]]
97
+ : process.platform === 'win32'
98
+ ? [['powershell', ['-NoProfile', '-Command', 'Get-Clipboard']]]
99
+ : [
100
+ ['wl-paste', ['--no-newline']],
101
+ ['xclip', ['-selection', 'clipboard', '-o']],
102
+ ['xsel', ['--clipboard', '--output']],
103
+ ];
104
+ for (const [cmd, args] of cmds) {
105
+ try {
106
+ return execFileSync(cmd, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
107
+ } catch {
108
+ // try the next clipboard tool
109
+ }
110
+ }
111
+ return '';
112
+ }
113
+
114
+ /**
115
+ * Read a credential without echoing it and without it ever becoming an argv
116
+ * element. `argv` is world-readable via `ps`, lands in shell history, and is
117
+ * captured verbatim by any agent driving this CLI. Falls back to reading one
118
+ * line from stdin when the input is piped rather than a terminal.
119
+ *
120
+ * @param {string} label
121
+ * @returns {Promise<string>}
122
+ */
123
+ function readSecretValue(label) {
124
+ return new Promise((resolve, reject) => {
125
+ const input = process.stdin;
126
+ if (!input.isTTY) {
127
+ resolve(readStdinValue());
128
+ return;
129
+ }
130
+ process.stdout.write(label);
131
+ input.setRawMode(true);
132
+ input.resume();
133
+ input.setEncoding('utf8');
134
+ let buf = '';
135
+ const onData = (ch) => {
136
+ if (ch === '\n' || ch === '\r' || ch === '\u0004') {
137
+ input.setRawMode(false);
138
+ input.pause();
139
+ input.removeListener('data', onData);
140
+ process.stdout.write('\n');
141
+ resolve(buf.trim());
142
+ } else if (ch === '\u0003') {
143
+ input.setRawMode(false);
144
+ process.stdout.write('\n');
145
+ process.exit(130);
146
+ } else if (ch === '\u007f' || ch === '\b') {
147
+ buf = buf.slice(0, -1);
148
+ } else {
149
+ buf += ch;
150
+ }
151
+ };
152
+ input.on('data', onData);
153
+ });
154
+ }
155
+
74
156
  function printHelp() {
75
157
  console.log(`
76
158
  total-recall secret — Secrets store (separate from memory vault)
@@ -84,7 +166,18 @@ function printHelp() {
84
166
  generate <key> Generate a random secret for outside integrations
85
167
  get <key> Print secret value (audited)
86
168
  list List keys + metadata only (no values)
87
- rotate <key> [value] Replace value and mark rotated (also supports --stdin)
169
+ rotate <key> Replace value and mark rotated. Prompts for the value;
170
+ --stdin reads fd0, --from-clipboard reads the OS
171
+ clipboard (use the browser you are already signed
172
+ into). The value never appears in argv or output.
173
+ rekey Rotate the secrets-store master password transactionally.
174
+ The replacement is generated in memory by default;
175
+ --stdin accepts a coordinated replacement without argv.
176
+ --launch-agent/--env-file/--keychain select persistent
177
+ carriers. The macOS Keychain entry is included
178
+ automatically when present, because a shell profile
179
+ that reads it would otherwise be left on the retired
180
+ password; --no-keychain opts out.
88
181
  delete <key> Remove a secret
89
182
  audit [--limit N] Recent set/get/rotate events (no values)
90
183
  usage [--days N] Sum usage.jsonl for last N days (default 1)
@@ -232,6 +325,11 @@ function parseArgs(args) {
232
325
  remotePort: 22,
233
326
  restartCmd: null,
234
327
  stdin: false,
328
+ launchAgents: [],
329
+ envFiles: [],
330
+ keychainServices: [],
331
+ keychain: true,
332
+ storePaths: [],
235
333
  };
236
334
  if (!args.length || args[0] === '--help' || args[0] === '-h') {
237
335
  out.help = true;
@@ -382,6 +480,9 @@ function parseArgs(args) {
382
480
  case '--replace-all':
383
481
  out.replaceAll = true;
384
482
  break;
483
+ case '--from-clipboard':
484
+ out.fromClipboard = true;
485
+ break;
385
486
  case '--export-env':
386
487
  out.exportEnv = true;
387
488
  break;
@@ -434,6 +535,21 @@ function parseArgs(args) {
434
535
  case '--stdin':
435
536
  out.stdin = true;
436
537
  break;
538
+ case '--launch-agent':
539
+ out.launchAgents.push(args[++i]);
540
+ break;
541
+ case '--env-file':
542
+ out.envFiles.push(args[++i]);
543
+ break;
544
+ case '--keychain':
545
+ out.keychainServices.push(args[++i]);
546
+ break;
547
+ case '--no-keychain':
548
+ out.keychain = false;
549
+ break;
550
+ case '--store':
551
+ out.storePaths.push(args[++i]);
552
+ break;
437
553
  case '--help':
438
554
  case '-h':
439
555
  out.help = true;
@@ -466,11 +582,11 @@ export default async function secretCli(argv) {
466
582
  }
467
583
 
468
584
  if (opts.stdin) {
469
- if (!['set', 'rotate'].includes(opts.command)) {
470
- console.error('❌ --stdin is only valid with `set` or `rotate`');
585
+ if (!['set', 'rotate', 'rekey'].includes(opts.command)) {
586
+ console.error('❌ --stdin is only valid with `set`, `rotate`, or `rekey`');
471
587
  process.exit(1);
472
588
  }
473
- if (!opts.key) {
589
+ if (opts.command !== 'rekey' && !opts.key) {
474
590
  console.error(`Usage: total-recall secret ${opts.command} <key> --stdin`);
475
591
  process.exit(1);
476
592
  }
@@ -482,6 +598,102 @@ export default async function secretCli(argv) {
482
598
  }
483
599
 
484
600
  try {
601
+ if (opts.command === 'rekey') {
602
+ const carriers = [
603
+ ...opts.launchAgents.filter(Boolean).map((carrierPath) => ({
604
+ path: path.resolve(carrierPath),
605
+ type: 'launchd-plist',
606
+ allowInsert: true,
607
+ })),
608
+ ...opts.envFiles.filter(Boolean).map((carrierPath) => ({
609
+ path: path.resolve(carrierPath),
610
+ type: 'env',
611
+ allowInsert: true,
612
+ })),
613
+ ];
614
+
615
+ if (!carriers.length && process.platform === 'darwin') {
616
+ const launchAgentsDir = path.join(os.homedir(), 'Library', 'LaunchAgents');
617
+ if (fs.existsSync(launchAgentsDir)) {
618
+ for (const entry of fs.readdirSync(launchAgentsDir)) {
619
+ if (!entry.endsWith('.plist')) continue;
620
+ const carrierPath = path.join(launchAgentsDir, entry);
621
+ const source = fs.readFileSync(carrierPath, 'utf8');
622
+ if (source.includes('<key>TR_SECRETS_PASSWORD</key>')) {
623
+ carriers.push({ path: carrierPath, type: 'launchd-plist' });
624
+ }
625
+ }
626
+ }
627
+ }
628
+
629
+ // A shell profile that reads the password from the Keychain is the most
630
+ // common macOS arrangement, and it is invisible to a file-carrier scan.
631
+ // Left out, a rotation succeeds and every interactive shell breaks at
632
+ // once — so the entry is picked up by default when it exists.
633
+ const keychainCarriers = opts.keychainServices.filter(Boolean).map((service) => ({ service }));
634
+ if (!keychainCarriers.length && opts.keychain !== false && keychainAvailable()) {
635
+ if (readKeychainPassword({ service: DEFAULT_KEYCHAIN_SERVICE })) {
636
+ keychainCarriers.push({ service: DEFAULT_KEYCHAIN_SERVICE });
637
+ }
638
+ }
639
+
640
+ if (!carriers.length && !keychainCarriers.length) {
641
+ console.error(
642
+ '❌ No persistent master-password carrier found. Use --launch-agent <path>, --env-file <path> or --keychain <service>.',
643
+ );
644
+ process.exit(1);
645
+ }
646
+
647
+ const currentPasswords = [
648
+ ...carriers.map((carrier) => readMasterPasswordFromCarrier(carrier)),
649
+ ...keychainCarriers.map((carrier) => readKeychainPassword(carrier)),
650
+ ].filter(Boolean);
651
+ const oldPassword = currentPasswords[0] || process.env.TR_SECRETS_PASSWORD;
652
+ if (!oldPassword) {
653
+ console.error('❌ The current master password is unavailable from the selected carriers.');
654
+ process.exit(1);
655
+ }
656
+ if (currentPasswords.some((value) => value !== oldPassword)) {
657
+ console.error('❌ Selected credential carriers do not contain the same current master password.');
658
+ console.error(' Every carrier must hold the same password before one can replace it.');
659
+ process.exit(1);
660
+ }
661
+
662
+ const defaultBrainDirs = [
663
+ configuredAgentDir,
664
+ brainDir,
665
+ ...Object.values(getActiveBrains()).map((brain) => brain?.brainDir),
666
+ ].filter(Boolean);
667
+ const legacyAgentDirs = defaultBrainDirs
668
+ .filter(
669
+ (dir) =>
670
+ path.basename(dir) === 'total-recall' && path.basename(path.dirname(dir)) === 'skills',
671
+ )
672
+ .map((dir) => path.dirname(path.dirname(dir)));
673
+ const storePaths = opts.storePaths.length
674
+ ? opts.storePaths.map((storePath) => path.resolve(storePath))
675
+ : [...new Set([...defaultBrainDirs, ...legacyAgentDirs].map((dir) => resolveSecretsPath(dir)))].filter(
676
+ (storePath) => fs.existsSync(storePath),
677
+ );
678
+ const newPassword = opts.value || generateSecretsMasterPassword();
679
+ const result = await rekeySecretsTransaction({
680
+ storePaths,
681
+ carriers,
682
+ keychainCarriers,
683
+ oldPassword,
684
+ newPassword,
685
+ });
686
+
687
+ console.log(`\n ✅ Secrets master password rotated transactionally`);
688
+ console.log(` stores re-encrypted and verified: ${result.stores.length}`);
689
+ console.log(` persistent carriers updated: ${result.carriers.length}`);
690
+ console.log(` keychain entries updated: ${result.keychain.length}`);
691
+ console.log(` retired password rejected: yes`);
692
+ console.log(` replacement value was not printed`);
693
+ console.log(` restart affected services so they load the new credential\n`);
694
+ return;
695
+ }
696
+
485
697
  if (opts.command === 'set') {
486
698
  if (!opts.key || opts.value == null) {
487
699
  console.error('Usage: total-recall secret set <key> <value>');
@@ -918,8 +1130,39 @@ export default async function secretCli(argv) {
918
1130
  }
919
1131
 
920
1132
  if (opts.command === 'rotate') {
921
- if (!opts.key || !opts.value) {
922
- console.error('Usage: total-recall secret rotate <key> <new-value> [--export-env|--export-all|--remote <name>]');
1133
+ if (!opts.key) {
1134
+ console.error('Usage: total-recall secret rotate <key> [--export-env|--export-all|--remote <name>]');
1135
+ console.error(' The new value is read from a prompt or stdin — never from argv.');
1136
+ process.exit(1);
1137
+ }
1138
+ // A credential passed as an argument is disclosed the moment it is typed:
1139
+ // argv is readable by any user via `ps`, is saved to shell history, and is
1140
+ // captured verbatim by any agent driving this CLI. Read it from a hidden
1141
+ // prompt (or stdin when piped) so it stays in this process's memory.
1142
+ if (!opts.value && opts.fromClipboard) {
1143
+ // Read the OS clipboard inside this process. This lets the operator use
1144
+ // the browser they are already signed into — copy the new credential
1145
+ // there, and the value travels clipboard -> Node -> secrets.enc without
1146
+ // ever passing through argv, a terminal echo, or an agent transcript.
1147
+ opts.value = readSystemClipboard();
1148
+ if (!opts.value) {
1149
+ console.error('Error: clipboard is empty — copy the new credential first.');
1150
+ process.exit(1);
1151
+ }
1152
+ console.log(` 📋 Read ${opts.value.length} chars from the clipboard (value not shown).`);
1153
+ }
1154
+ if (!opts.value) {
1155
+ opts.value = await readSecretValue(`New value for ${opts.key}: `);
1156
+ } else if (!opts.fromClipboard) {
1157
+ console.error(
1158
+ `\n ⚠️ WARNING: the value was passed as a command-line argument.\n` +
1159
+ ` It is now in your shell history and was visible to any user\n` +
1160
+ ` running \`ps\` on this machine. Treat it as already disclosed\n` +
1161
+ ` and rotate again using the prompt: total-recall secret rotate ${opts.key}\n`,
1162
+ );
1163
+ }
1164
+ if (!opts.value) {
1165
+ console.error('Error: empty value — aborting rotation.');
923
1166
  process.exit(1);
924
1167
  }
925
1168
  if (opts.remote) {
@@ -90,17 +90,42 @@ export async function launchRotationContext(brainDir, opts = {}) {
90
90
  // headless first-run would simply hang on an invisible challenge.
91
91
  const headless = opts.headless === true;
92
92
 
93
- try {
94
- const context = await chromium.launchPersistentContext(profileDir, {
95
- headless,
96
- viewport: { width: 1440, height: 900 },
97
- args: ['--disable-blink-features=AutomationControlled', '--no-first-run'],
98
- });
99
- context.setDefaultTimeout(opts.timeoutMs ?? 30_000);
100
- return { ok: true, context, profileDir };
101
- } catch (err) {
102
- return { ok: false, error: `failed to launch browser profile: ${err.message}`, profileDir };
93
+ const base = {
94
+ headless,
95
+ viewport: { width: 1440, height: 900 },
96
+ args: ['--disable-blink-features=AutomationControlled', '--no-first-run'],
97
+ };
98
+
99
+ // Playwright's bundled Chromium is not available on every host — it has
100
+ // dropped support for older macOS versions, and the pinned build is often
101
+ // simply not downloaded. A rotation that dies here strands the operator with
102
+ // live, burned credentials, so fall back to any locally installed Chrome or
103
+ // Edge before giving up.
104
+ const attempts = [
105
+ { label: 'bundled chromium', options: base },
106
+ { label: 'system chrome', options: { ...base, channel: 'chrome' } },
107
+ { label: 'system edge', options: { ...base, channel: 'msedge' } },
108
+ ];
109
+
110
+ const failures = [];
111
+ for (const attempt of attempts) {
112
+ try {
113
+ const context = await chromium.launchPersistentContext(profileDir, attempt.options);
114
+ context.setDefaultTimeout(opts.timeoutMs ?? 30_000);
115
+ return { ok: true, context, profileDir, browser: attempt.label };
116
+ } catch (err) {
117
+ failures.push(`${attempt.label}: ${err.message.split('\n')[0]}`);
118
+ }
103
119
  }
120
+
121
+ return {
122
+ ok: false,
123
+ error:
124
+ `failed to launch a browser profile. Tried ${attempts.length} browsers —\n ` +
125
+ failures.join('\n ') +
126
+ '\nInstall one with: npx playwright install chromium (or install Google Chrome).',
127
+ profileDir,
128
+ };
104
129
  }
105
130
 
106
131
  /**
@@ -128,16 +153,46 @@ export async function openConsole(context, recipe) {
128
153
  * @param {{ signed_in?: string, signed_out?: string }} recipe
129
154
  * @returns {Promise<boolean>}
130
155
  */
131
- export async function isAuthenticated(page, recipe) {
156
+ export async function isAuthenticated(page, recipe, opts = {}) {
132
157
  // A visible signed-out marker (login form, "Sign in" CTA) is decisive.
133
158
  if (recipe.signed_out) {
134
159
  const out = await page.locator(recipe.signed_out).first().isVisible().catch(() => false);
135
160
  if (out) return false;
136
161
  }
162
+
137
163
  if (recipe.signed_in) {
138
- return page.locator(recipe.signed_in).first().isVisible().catch(() => false);
164
+ // Consoles are SPA-heavy: the positive marker frequently renders a second
165
+ // or two after DOMContentLoaded. Give it a real wait instead of a single
166
+ // instantaneous probe.
167
+ const appeared = await page
168
+ .locator(recipe.signed_in)
169
+ .first()
170
+ .waitFor({ state: 'visible', timeout: opts.markerTimeoutMs ?? 8000 })
171
+ .then(() => true)
172
+ .catch(() => false);
173
+ if (appeared) return true;
174
+
175
+ // The positive marker never showed and no login form matched. Selectors go
176
+ // stale constantly, so fall back to the one signal every console agrees on:
177
+ // an unauthenticated request gets redirected to a login URL.
178
+ return !looksLikeLoginUrl(page.url());
139
179
  }
140
- return true;
180
+
181
+ return !looksLikeLoginUrl(page.url());
182
+ }
183
+
184
+ /**
185
+ * Provider-agnostic sign-in detection. Consoles redirect anonymous visitors to
186
+ * a login route rather than returning an error status, so the landed URL is a
187
+ * far more durable signal than any per-provider CSS selector.
188
+ *
189
+ * @param {string} url
190
+ * @returns {boolean}
191
+ */
192
+ export function looksLikeLoginUrl(url) {
193
+ return /\/(login|log-in|sign[_-]?in|signin|auth\/login|sessions\/new|u\/login)(\/|\?|#|$)/i.test(
194
+ String(url || ''),
195
+ );
141
196
  }
142
197
 
143
198
  /**
@@ -225,3 +225,90 @@ export async function createHeadscalePreAuthKey(options = {}, brainDir) {
225
225
  }
226
226
  throw lastError || new Error('Headscale did not return a pre-auth key');
227
227
  }
228
+
229
+ /**
230
+ * Read the control server's ACL policy.
231
+ *
232
+ * Requires the server to run with `policy.mode: database` — in `file` mode the
233
+ * policy lives on the control server's disk and the API cannot manage it.
234
+ *
235
+ * Headscale returns a 500 (not a 404) when database mode is on but the policy
236
+ * table is still empty, so an unset policy is normalised to `policy: null`
237
+ * rather than thrown. That distinction matters: "no policy yet" is the expected
238
+ * first-run state, whereas a real transport or auth failure must still surface.
239
+ */
240
+ export async function getHeadscalePolicy(brainDir) {
241
+ try {
242
+ const data = await headscaleFetch('/api/v1/policy', {}, brainDir);
243
+ return {
244
+ policy: data?.policy ?? null,
245
+ updatedAt: data?.updatedAt ?? null,
246
+ configured: Boolean(data?.policy),
247
+ raw: data,
248
+ };
249
+ } catch (err) {
250
+ const detail = String(err.detail || '');
251
+ const emptyPolicyTable =
252
+ err.status === 404 ||
253
+ (err.status === 500 && /policy|not found|no such/i.test(detail));
254
+ if (emptyPolicyTable) {
255
+ return { policy: null, updatedAt: null, configured: false, unset: true, raw: null };
256
+ }
257
+ if (err.status === 400 && /database/i.test(detail)) {
258
+ const modeError = new Error(
259
+ 'Headscale is running in file policy mode; set `policy.mode: database` to manage the policy over the API',
260
+ );
261
+ modeError.status = err.status;
262
+ modeError.detail = err.detail;
263
+ modeError.code = 'POLICY_MODE_FILE';
264
+ throw modeError;
265
+ }
266
+ throw err;
267
+ }
268
+ }
269
+
270
+ /**
271
+ * Replace the control server's ACL policy.
272
+ *
273
+ * `policy` may be an object (serialised here) or an already-formatted
274
+ * HuJSON/JSON string. Headscale validates server-side and rejects the write if
275
+ * the policy's own `tests` block fails, so a bad policy never lands.
276
+ */
277
+ export async function setHeadscalePolicy(policy, brainDir) {
278
+ if (!policy) throw new Error('A policy is required');
279
+ const serialised = typeof policy === 'string' ? policy : JSON.stringify(policy, null, 2);
280
+
281
+ const data = await headscaleFetch(
282
+ '/api/v1/policy',
283
+ { method: 'PUT', body: JSON.stringify({ policy: serialised }) },
284
+ brainDir,
285
+ );
286
+ return { policy: data?.policy ?? serialised, updatedAt: data?.updatedAt ?? null, raw: data };
287
+ }
288
+
289
+ /**
290
+ * Build a policy that lets every mesh member reach every other member, and use
291
+ * Tailscale SSH between them as any non-root user.
292
+ *
293
+ * This is deliberately the simple single-operator shape: one person's own
294
+ * machines, all equally trusted. It is NOT appropriate for a multi-tenant
295
+ * tailnet — headscale membership is the trust boundary, so anything admitted to
296
+ * the mesh inherits this access. Narrow `src`/`dst` before adding a node you do
297
+ * not control.
298
+ *
299
+ * root is excluded via `autogroup:nonroot`: SSH-ing straight to root over the
300
+ * mesh removes the audit trail that makes per-user access worth having.
301
+ */
302
+ export function buildMeshSshPolicy({ allowRoot = false } = {}) {
303
+ return {
304
+ acls: [{ action: 'accept', src: ['*'], dst: ['*:*'] }],
305
+ ssh: [
306
+ {
307
+ action: 'accept',
308
+ src: ['autogroup:member'],
309
+ dst: ['autogroup:self'],
310
+ users: allowRoot ? ['autogroup:nonroot', 'root'] : ['autogroup:nonroot'],
311
+ },
312
+ ],
313
+ };
314
+ }