total-recall-brain 3.41.0 → 3.41.2

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 (51) hide show
  1. package/package.json +1 -1
  2. package/scaffold/.agent/skills/meta-harness/references/optimized/original.md +1 -1
  3. package/scaffold/.agent/skills/total-recall/plugins/auth/README.md +10 -0
  4. package/scaffold/.agent/skills/total-recall/plugins/auth/package-lock.json +1456 -12
  5. package/scaffold/.agent/skills/total-recall/plugins/auth/package.json +4 -4
  6. package/scaffold/.agent/skills/total-recall/plugins/auth/plugin.json +1 -1
  7. package/scaffold/.agent/skills/total-recall/plugins/auth/src/cli-secret.mjs +210 -27
  8. package/scaffold/.agent/skills/total-recall/plugins/auth/src/provider-catalog.mjs +10 -1
  9. package/scaffold/.agent/skills/total-recall/plugins/auth/src/rotation-capability.mjs +15 -0
  10. package/scaffold/.agent/skills/total-recall/plugins/auth/src/secrets-env-export.mjs +78 -6
  11. package/scaffold/.agent/skills/total-recall/plugins/auth/src/secrets-merge.mjs +131 -0
  12. package/scaffold/.agent/skills/total-recall/plugins/auth/src/secrets-remote-deploy.mjs +111 -2
  13. package/scaffold/.agent/skills/total-recall/plugins/auth/src/secrets-store.mjs +26 -5
  14. package/scaffold/.agent/skills/total-recall/plugins/code-quality/package-lock.json +4 -4
  15. package/scaffold/.agent/skills/total-recall/plugins/composable-cli/package-lock.json +4 -4
  16. package/scaffold/.agent/skills/total-recall/plugins/embeddings/package-lock.json +4 -4
  17. package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/package-lock.json +4 -4
  18. package/scaffold/.agent/skills/total-recall/plugins/mesh/README.md +10 -0
  19. package/scaffold/.agent/skills/total-recall/plugins/mesh/{src/core/headscale-server.mjs → capability/control-server.mjs} +5 -2
  20. package/scaffold/.agent/skills/total-recall/plugins/mesh/capability/headscale-api.mjs +192 -0
  21. package/scaffold/.agent/skills/total-recall/plugins/mesh/capability/index.mjs +46 -0
  22. package/scaffold/.agent/skills/total-recall/plugins/mesh/cli.mjs +9 -1
  23. package/scaffold/.agent/skills/total-recall/plugins/mesh/package-lock.json +23 -22
  24. package/scaffold/.agent/skills/total-recall/plugins/mesh/package.json +6 -3
  25. package/scaffold/.agent/skills/total-recall/plugins/mesh/plugin.json +242 -8
  26. package/scaffold/.agent/skills/total-recall/plugins/mesh/src/cli/mesh.mjs +14 -9
  27. package/scaffold/.agent/skills/total-recall/plugins/mesh/src/cli/server.mjs +1 -1
  28. package/scaffold/.agent/skills/total-recall/plugins/mesh/src/core/credentials.mjs +4 -2
  29. package/scaffold/.agent/skills/total-recall/plugins/mesh/src/core/headscale-client.mjs +34 -181
  30. package/scaffold/.agent/skills/total-recall/plugins/mesh/src/core/mesh-auth.mjs +2 -2
  31. package/scaffold/.agent/skills/total-recall/plugins/mesh/src/core/presence.mjs +5 -5
  32. package/scaffold/.agent/skills/total-recall/plugins/mesh/src/core/screen-view.mjs +78 -0
  33. package/scaffold/.agent/skills/total-recall/plugins/mesh/ui/DESIGN.md +211 -12
  34. package/scaffold/.agent/skills/total-recall/plugins/mesh/ui/card.js +323 -388
  35. package/scaffold/.agent/skills/total-recall/plugins/openwiki/README.md +12 -1
  36. package/scaffold/.agent/skills/total-recall/plugins/openwiki/cli.mjs +7 -4
  37. package/scaffold/.agent/skills/total-recall/plugins/openwiki/package-lock.json +6 -6
  38. package/scaffold/.agent/skills/total-recall/plugins/openwiki/package.json +3 -2
  39. package/scaffold/.agent/skills/total-recall/plugins/openwiki/plugin.json +34 -1
  40. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/bootstrap.mjs +99 -0
  41. package/scaffold/.agent/skills/total-recall/plugins/openwiki/templates/openwiki/README.md +10 -0
  42. package/scaffold/.agent/skills/total-recall/plugins/openwiki/templates/openwiki/architecture.md +5 -0
  43. package/scaffold/.agent/skills/total-recall/plugins/openwiki/templates/openwiki/ide.md +10 -0
  44. package/scaffold/.agent/skills/total-recall/plugins/openwiki/templates/openwiki/memory.md +11 -0
  45. package/scaffold/.agent/skills/total-recall/plugins/openwiki/templates/openwiki/secrets.md +7 -0
  46. package/scaffold/.agent/skills/total-recall/plugins/openwiki/templates/openwiki/skills.md +7 -0
  47. package/scaffold/.agent/skills/total-recall/plugins/security/package-lock.json +4 -4
  48. package/scaffold/.agent/skills/total-recall/references/auth.md +2 -0
  49. package/src/cli/brain.mjs +68 -0
  50. package/src/cli/connect.mjs +10 -39
  51. package/src/core/surface.mjs +2 -2
@@ -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
  });
@@ -1,6 +1,6 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { getLocalPresence, resolveActiveDevice } from './mesh-activity.mjs';
3
- import { getMeshPeers, execMeshCommand } from './mesh.mjs';
3
+ import { getMeshPeers, listEnrichedMeshNodes, meshVaultRoot, execMeshCommand } from './mesh.mjs';
4
4
  import { getMeshSyncAuthorization } from './mesh-auth.mjs';
5
5
 
6
6
  function validRecord(record, peer) {
@@ -11,12 +11,12 @@ export async function getMeshPresence({ fetchImpl = fetch, includePeers = true }
11
11
  const local = getLocalPresence();
12
12
  const peers = [];
13
13
  if (includePeers) {
14
- const candidates = getMeshPeers({ includeSelf: false }).filter(peer => peer.online && peer.ip);
15
- const authorization = candidates.length ? await getMeshSyncAuthorization() : null;
14
+ const candidates = listEnrichedMeshNodes(meshVaultRoot()).filter(peer => !peer.self && !peer.vault_only && peer.online && peer.ip);
15
+ const authorization = candidates.some(peer => Number.isInteger(Number(peer.brain_port)) && Number(peer.brain_port) > 0 && Number(peer.brain_port) <= 65535) ? await getMeshSyncAuthorization() : null;
16
16
  await Promise.all(candidates.map(async peer => {
17
17
  try {
18
- const port = Number(peer.brain_port || process.env.TR_SERVER_PORT || 3000);
19
- if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('Invalid peer service port');
18
+ const port = Number(peer.brain_port);
19
+ if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('No valid presence service port is recorded for this peer');
20
20
  const response = await fetchImpl(`http://${peer.ip}:${port}/api/mesh/presence/local`, { headers: { Authorization: authorization }, redirect: 'error', signal: AbortSignal.timeout(10000) });
21
21
  if (!response.ok) throw new Error(`Peer HTTP ${response.status}`);
22
22
  const record = await response.json();
@@ -0,0 +1,78 @@
1
+ import path from 'node:path';
2
+ import fs from 'node:fs';
3
+ import { execFile } from 'node:child_process';
4
+ import { promisify } from 'node:util';
5
+ const execute = promisify(execFile);
6
+
7
+ // Fixed read operations only. No command or argument comes from the caller.
8
+ const SOURCES = {
9
+ machines: { status: ['mesh', 'status'], presence: ['mesh', 'presence'] },
10
+ flows: {
11
+ grants: ['mesh', 'grants', 'list'], plugins: ['plugin', 'peers'], updates: ['update', '--status'],
12
+ tasks: ['user-automations', 'list'], agents: ['agent', 'list'], alerts: ['alerts', 'log'], deployments: ['deploy', 'status'],
13
+ },
14
+ placement: { tasks: ['user-automations', 'list'], agents: ['agent', 'list'] },
15
+ boundary: { policy: ['mesh', 'policy', 'get'], grants: ['mesh', 'grants', 'list'] },
16
+ join: { status: ['mesh', 'status'] },
17
+ };
18
+
19
+ export function meshDeclarations() {
20
+ const manifest = JSON.parse(fs.readFileSync(new URL('../../plugin.json', import.meta.url), 'utf8'));
21
+ return { ...manifest.mesh, provides: manifest.mesh.provides.map(capability => ({ ...capability, effect: manifest.cli.subcommands.find(operation => operation.name === capability.operation)?.effect })) };
22
+ }
23
+
24
+ export function parseCliObject(text) {
25
+ const value = String(text).trim();
26
+ try { const parsed = JSON.parse(value); if (parsed && typeof parsed === 'object') return parsed; } catch {}
27
+ const lines = value.split('\n').filter(line => /^\s*[{[]/.test(line));
28
+ if (lines.length > 1) {
29
+ try { const rows = lines.map(line => JSON.parse(line)); if (rows.every(row => row && typeof row === 'object')) return rows; } catch {}
30
+ }
31
+ // Ignore prose banners; retain arrays and multiline JSON as one payload.
32
+ for (let start = 0; start < value.length; start++) {
33
+ if (!'{['.includes(value[start])) continue;
34
+ let depth = 0, quoted = false;
35
+ for (let end = start; end < value.length; end++) {
36
+ const ch = value[end];
37
+ if (quoted) { if (ch === '\\') end++; else if (ch === '"') quoted = false; }
38
+ else if (ch === '"') quoted = true;
39
+ else if ('{['.includes(ch)) depth++;
40
+ else if ('}]'.includes(ch) && --depth === 0) {
41
+ try { return JSON.parse(value.slice(start, end + 1)); } catch { break; }
42
+ }
43
+ }
44
+ }
45
+ throw new Error('Owner command did not return JSON');
46
+ }
47
+
48
+ async function readSource(args) {
49
+ const root = process.env.TR_PACKAGE_ROOT;
50
+ if (!root || !path.isAbsolute(root)) throw new Error('Explicit valid Total Recall host runtime required');
51
+ const cli = path.join(root, 'bin', 'total-recall.mjs');
52
+ if (!fs.statSync(cli).isFile()) throw new Error('Selected host CLI is unavailable');
53
+ let result;
54
+ try {
55
+ result = await execute(process.execPath, [cli, ...args, '--json'], {
56
+ cwd: process.cwd(), timeout: 20000, maxBuffer: 262144, encoding: 'utf8',
57
+ env: { ...process.env, TR_EMBEDDINGS_DISABLED: '1' },
58
+ });
59
+ } catch (error) {
60
+ // Do not forward arbitrary owner output or command diagnostics into a read view.
61
+ throw new Error(error.killed ? 'Owner read timed out' : `Owner read failed (exit ${error.code ?? 'unknown'})`);
62
+ }
63
+ return parseCliObject(result.stdout);
64
+ }
65
+
66
+ export async function readMeshView(screen = 'machines', dependencies = {}) {
67
+ if (!Object.hasOwn(SOURCES, screen)) throw new Error(`Unknown mesh screen: ${screen}`);
68
+ const read = dependencies.readSource || readSource;
69
+ const entries = await Promise.all(Object.entries(SOURCES[screen]).map(async ([name, args]) => {
70
+ const checked_at = new Date().toISOString();
71
+ try {
72
+ const data = await read([...args]);
73
+ if (!data || typeof data !== 'object' || data.ok === false || data.success === false) throw new Error('Owner reported a failed observation');
74
+ return [name, { ok: true, command: args.join(' '), checked_at, data }];
75
+ } catch (error) { return [name, { ok: false, command: args.join(' '), checked_at, error: error.message }]; }
76
+ }));
77
+ return { screen, observed_at: new Date().toISOString(), declarations: meshDeclarations(), sources: Object.fromEntries(entries) };
78
+ }
@@ -1,18 +1,217 @@
1
- # DESIGN.md — Ghost Net
1
+ # DESIGN.md — Headscale Mesh
2
2
 
3
3
  ```yaml
4
4
  tokens:
5
- brand-name: "Ghost Net"
6
- wordmark: "[GHOST//NET]"
7
- color-primary: "#22c55e"
8
- color-background: "#090d16"
9
- color-accent: "#38bdf8"
10
- badge: "Control Mesh"
11
- font-mono: "'JetBrains Mono', 'Fira Code', monospace"
5
+ brand-name: "Headscale"
6
+ wordmark: "HEADSCALE"
7
+ color-primary: "#818cf8"
8
+ color-background: "#0b1020"
9
+ color-accent: "#3b82f6"
10
+ color-ok: "#34d399"
11
+ color-warn: "#fbbf24"
12
+ color-danger: "#f87171"
13
+ badge: "Your machines"
14
+ font-body: "'Space Grotesk', system-ui, sans-serif"
15
+ font-mono: "'JetBrains Mono', ui-monospace, monospace"
12
16
  border-radius: "8px"
17
+ density: "compact"
18
+ motion: "none"
13
19
  ```
14
20
 
15
- ## Brand & Typographic Wordmark
16
- - **Name**: Ghost Net
17
- - **Wordmark Logo**: `[GHOST//NET]` (custom typographic badge, no generic icons)
18
- - **Palette**: Primary `#22c55e`, Surface `#090d16`, Accent `#38bdf8`.
21
+ This document is the design. The component follows it. Where the component and this file disagree, the component is wrong.
22
+
23
+ ## What a mesh is, for this operator
24
+
25
+ A mesh here is the operator's own machines (a Mac mini, a laptop, a cloud server, a phone) joined by one control server so that people and agents can reach any of them from any other. The operator is one person, working from whichever machine is in front of them, and agents work across the rest.
26
+
27
+ A mesh is also a platform. Very little of what matters about it is configuration; most of it is what the mesh lets everything else do: run commands anywhere, tell where the person is, release a secret to a machine that has a grant, share plugins without a catalog, update a fleet, place scheduled work and agents. `docs/MESH_PLATFORM.md` maps those layers, says what is built and what is not, and is the source for this card's scope. This card is the top layer: the operator's view of the platform. It is not a network settings page.
28
+
29
+ That framing decides everything below. Four facts about this mesh came from reading its real output, not from the manifest, and each one changed the design:
30
+
31
+ 1. **Reachability is a ladder, not a state.** A machine is reachable only if the control server sees it, the network path answers, a login is recorded, and a command actually runs. These are four separate checks that fail for four separate reasons with four separate fixes. A single "Healthy / Offline" label throws away the only thing the operator needs: which rung broke.
32
+ 2. **The operator is a participant.** `presence` reports which machine the person is active on and for how long it has been idle. An operator working from the laptop to drive the Mac mini needs the card to know the laptop is where they are.
33
+ 3. **Recorded is not live.** A node's entity file carries `last_heartbeat` and hardware figures written once by something that later stopped writing. The laptop's heartbeat is thirteen days old while it is online, pingable, answering commands and in use. A recorded date must never be read as liveness. Every screen separates what was measured now from what was recorded earlier, and states the date of the recording.
34
+ 4. **Membership is the trust boundary.** The policy in force lets every member reach every port of every member and SSH as root. The CLI's own help says to audit nodes before widening that. A phone and a Chromebook are inside the same boundary as the cloud server. The card has to make that visible, because it is the one fact about the mesh with security weight.
35
+
36
+ ## Who uses it and what they are in the middle of
37
+
38
+ One operator, mid-task, who suspects a machine cannot be reached, wants to know whether the things that depend on the mesh are working, or wants to know where something can run. Six jobs, in the order of how often they come up:
39
+
40
+ | # | Job | How often | What answers it |
41
+ | --- | --- | --- | --- |
42
+ | 1 | "I can't reach X" or "is everything up?" | Constant | Machines |
43
+ | 2 | "Is what rides on the mesh actually working?" (secrets, updates, plugins, scheduled work) | Often | Flows |
44
+ | 3 | "Where can this run?" (needs Docker, Claude, a camera, a display) | Often | Placement |
45
+ | 4 | "What is inside, what is exposed, and what may they do?" | Occasionally, and after any change | Boundary |
46
+ | 5 | "Add a machine." | Rarely | Join |
47
+ | 6 | "Send something to a machine or a person; see what was said." | Will be constant | Messages and Files, **when those capabilities exist** |
48
+
49
+ Job 6 has no screen yet and gets none until its operations exist. A screen that shows a feature with nothing behind it is a seeded value in another costume.
50
+
51
+ The control server, the leader and this machine's enrollment are not a job. They are a precondition for all four: if the control server cannot be verified, every other figure on the card is unreliable. So they are a strip that stays on screen, not a screen.
52
+
53
+ ## The persistent strip
54
+
55
+ One row above the screens, always present:
56
+
57
+ - **Control server**: the host name and whether the API key was verified. If it is not verified, the strip turns to a danger state and the verdict line says the rest of the card cannot be trusted.
58
+ - **Leader**: which machine leads and by what strategy. It states whether this machine leads.
59
+ - **This machine**: its name, its enrollment state and the backend state.
60
+
61
+ Then the **verdict**, one sentence computed only from returned values. Its subject is the machines the operator can log in to, because that is what "reachable" can mean. A phone cannot host a login, so it is counted apart and never makes the sentence worse.
62
+
63
+ ## Screen 1 — Machines
64
+
65
+ Master and detail. The roster is on the left; the selected machine is on the right. On a narrow width they stack. **A machine does not get its own screen.** One machine is a selection inside this screen, which keeps the comparison across machines visible, and that comparison is the point of the job.
66
+
67
+ ### The ladder
68
+
69
+ Four rungs, always in this order, each in one of four states: passed, failed, not yet checked, not applicable.
70
+
71
+ | Rung | Question | Source | Measured |
72
+ | --- | --- | --- | --- |
73
+ | Control | Does the control server see it online? | `status` | now |
74
+ | Path | Does the network path answer? | `ping`, and direct or relayed once an operation exposes it | now, from this machine |
75
+ | Login | Is an SSH login recorded for it? | `status` access | recorded, with its verification date |
76
+ | Run | Did a command execute on it? | `doctor` | now, from this machine |
77
+
78
+ The headline for a machine is the first rung that failed, named in words. `Stops at Login: no SSH login recorded` and `Stops at Run: connection timed out` are different problems and read differently at a glance.
79
+
80
+ **Severity is decided by contradiction, not by a fixed list of expectations.** The card cannot know which machines are meant to be always on, and it does not guess. So:
81
+
82
+ - Control says online, but Path or Run fails: **danger**. The mesh claims the machine is there and it does not answer. This is a real fault.
83
+ - Control says offline: **neutral, stated plainly as offline.** Laptops sleep and phones leave. That is a fact, not an alarm.
84
+ - Online and answering, but no login is recorded: **warning**, with the fix on the next line.
85
+ - A rung not yet checked stays not-yet-checked, never passed.
86
+
87
+ **Not applicable is real.** A phone cannot host an SSH login. Its Login and Run rungs are not applicable, derived from the operating system the node reports, and it is not reported as missing a login.
88
+
89
+ ### Progressive reading
90
+
91
+ `status` is fast and paints the roster at once. `ping`, `doctor` and `presence` take seconds because they run over SSH or wait on peers. They fill in as they arrive. Until then their rungs read as not yet checked with the word "checking", and a failed source shows its own error without blanking the rest. A failed read clears its figures; it never leaves the previous value standing as if current.
92
+
93
+ ### Presence
94
+
95
+ The row for the machine the operator is active on says so, with idle time. This card is usually opened from a different machine than the one being fixed, and this is how it shows which is which. A peer whose presence endpoint did not answer says "presence unavailable" with the reason, which is a different fact from "idle".
96
+
97
+ ### Roster order
98
+
99
+ Danger first, then warning, then not-yet-checked, then reachable, then machines that cannot host a login, with recorded-only records collapsed at the bottom. Within a group, the machine the operator is on comes first.
100
+
101
+ ### The detail pane
102
+
103
+ Per machine, in this order:
104
+
105
+ 1. The ladder, one line per rung: the state in words, the evidence, and how old the evidence is.
106
+ 2. **Fix.** The action that addresses the failing rung, placed directly under it:
107
+ - Path failed or not checked: **Ping now** (read).
108
+ - Login failed: **Find a login** (`access discover`, write) and **Record a login** (`access <node> --user …`, write) with user, host, port and key path fields. A key path is a path, never a key.
109
+ - Run failed or not checked: **Verify execution** (`access verify`, which runs a probe command on each configured machine).
110
+ - Reachable: **Copy SSH command**. SSH is interactive and cannot run inside a sandboxed frame, so the card hands the operator the exact command.
111
+ 3. Recorded facts, each dated: login source and user, verification date, hardware figures and their measurement date. A heading says these were recorded earlier.
112
+ 4. Addresses: the mesh address and the LAN address. The overlay and wireless-direct interfaces that the system reports (`utun*`, `awdl0`, `llw0`) are noise for this operator and are not shown.
113
+
114
+ ## Screen 2 — Flows
115
+
116
+ Job 2 is the one the first version missed entirely. The mesh's value is what it carries, so the card shows each thing that moves and whether it is moving. One row per flow, each answering the same three questions: what moves, when did it last demonstrably work, and which machines is it failing on.
117
+
118
+ | Flow | What the row shows | Read from |
119
+ | --- | --- | --- |
120
+ | Secrets | machines holding grants, and the keys each may read; an empty list stated as "no machine may read a secret through the mesh" | `grants list` |
121
+ | Plugins | what each peer shares, with each peer's own status: reachable, offline, not configured, unsupported, error | `plugin peers` |
122
+ | Updates | the version and the verified report of the last fleet update; failures counted, with the first reasons | `update --status` |
123
+ | Scheduled work | which tasks exist, on which node they run, and whether placement is per node or a selected node | `user-automations list` |
124
+ | Agents | running and recent agent processes, and the node each runs on | `agent list` |
125
+ | Alerts | recent mesh events from the alert ledger | `alerts log` |
126
+ | Deployments | the active slot and release of each registered deploy host; an empty list stated as "no host is registered with deploy", which is the case today | `deploy status` |
127
+
128
+ Three more flows belong here and are **not drawn until an operation exists**: service health per node, exit nodes and routes, and speed. The "Operations the card depends on" table in `docs/MESH_PLATFORM.md` lists them. Deployments are read from the deploy plugin and never duplicated: the mesh owns machines, deploy owns releases.
129
+
130
+ The row's headline is computed from the returned data. The fleet-update row would today read as a failure with its reason, because the last run failed on most consumers. A flow whose read operation fails says so, with the error and the time, and never reads as healthy.
131
+
132
+ A flow with no read operation is not drawn. File push has none (no transfer log), and appears in the "not built" note below until it does.
133
+
134
+ ## Screen 3 — Placement
135
+
136
+ The question is "where can this run?", and the native shape of that answer is a matrix: machines down the side, capabilities across the top.
137
+
138
+ - **Runtimes** and **agent harnesses**: measured now by `doctor`. Columns are the union of what any machine returned, so the card carries no hard-coded list of tools.
139
+ - **Hardware** (screen, microphone, camera, speaker, headless): recorded, shown with the measurement date, because it comes from the entity file and not from a live probe.
140
+
141
+ A cell is **present**, **known absent**, or **unknown**. Absent is claimed only for a machine that answered `doctor`; for a machine that did not, every probed cell is unknown and the row says which rung stopped it. Without that distinction the matrix would say "no Docker" about a machine it never reached.
142
+
143
+ Below the matrix, **where work runs**: scheduled tasks and agent processes grouped by node, so "where can this run" and "where does it run" sit together.
144
+
145
+ **Choosing columns filters rows.** Selecting Docker and Claude shows only the machines that have both. That is the actual task, so it is the interaction.
146
+
147
+ ## Screen 4 — Boundary
148
+
149
+ Two halves of one boundary, side by side on a wide card.
150
+
151
+ - **Inside**: who is a member, what the policy lets members do, what each may read, and any exit node or subnet route.
152
+ - **Outside**: what is reachable from the internet. For each public hostname, the origin machine, the path (a public address, the proxy or a tunnel), the protection and the last probe. The most important line on the whole card is any service meant to be mesh-only that is bound to a public address.
153
+
154
+ The outside half draws only what can be read today: DNS records from the domains plugin matched to the public addresses of machines, deploy health, and the firewall findings of the security audit. It says plainly that tunnels, their hostnames and the machines behind them are **not readable**, because no operation reads them (see `docs/MESH_PLATFORM.md`, "The edge"). It does not say there are none.
155
+
156
+ Exit nodes and subnet routes belong on the inside half, because each one widens the boundary: an exit node sees all the traffic of every client that uses it. The section appears when the operations in `docs/MESH_PLATFORM.md` (P5) exist. Until then the card does not claim there are none, because it cannot read them; it says exit nodes are not readable.
157
+
158
+ The boundary: who is inside, what the policy lets them do, and what they may read.
159
+
160
+ **Inside half.**
161
+
162
+ - **Members.** Count of machines on the control server, including those that cannot host a login. A recorded-only record is not a member.
163
+ - **Policy in plain words.** The ACL and SSH rules are translated into sentences from the returned policy, and any selector the card does not recognise is shown exactly as written, never guessed at. The policy in force today renders as: any member may reach any port on any member; any member may SSH to any member as a non-root user or as root. Root SSH is marked, because the CLI itself calls it discouraged. Policy editing stays on the command line, behind a dry run, and the card shows the command.
164
+ - **Secret grants.** Which machines may read which secret keys through the mesh. The returned list is empty, and the owner's contract is that a missing policy grants nothing, so the empty state says exactly that: no machine except the one holding the store may read any secret through the mesh. Adding and removing a grant are write actions; removing requires a confirmation that names the machine and the keys. Grants are key names, never values.
165
+ - **Records that look wrong**, computed from returned data only. Two records that share an ethernet hardware address are probably one machine: the mesh holds a live record and a recorded-only record for the Mac mini with the same address. No mesh command retires a record, and the card says so rather than offering a button that does not exist.
166
+
167
+ ## Screen 5 — Join
168
+
169
+ Admitting a machine widens the boundary, so this screen restates the consequence before it offers the recipe.
170
+
171
+ - This machine's enrollment: state, backend state, addresses, whether auto-enroll is on or why it is blocked.
172
+ - A two-step recipe: mint an enrollment key on an enrolled machine, then run `tailscale up` with the control server's login address on the new one. The options are explicit: a **reusable** key can admit more than one machine, and an **ephemeral** node disappears when it goes offline. Each toggle states what it does to the boundary.
173
+ - **The card never renders the key.** An enrollment key is a credential. The recipe is shown as commands to run, with the key left to the shell that mints it.
174
+ - **Re-enroll** this machine is a destructive action: it names the target and requires confirmation.
175
+ - When no control server is configured at all, this screen is the whole card: a first-run state that shows the one command that provisions a server, because a new developer project starts here with its own control server and never points at anyone else's.
176
+
177
+ ## What the card does not do, and why
178
+
179
+ | Operation | Decision |
180
+ | --- | --- |
181
+ | `ssh` | Interactive. Hand-off: the exact command, copyable. |
182
+ | `exec`, `dispatch`, `file put` | **Not on the card for now.** Running a command or sending a file to a machine from a dashboard click, including as root on the cloud server, is a decision that belongs to the operator (open decision 5 in `docs/MESH_PLATFORM.md`). Agents use these through the CLI. |
183
+ | Messages, boards, file spaces | Do not exist. See `docs/MESH_PLATFORM.md`, proposals P2 and P3. |
184
+ | `policy set`, `policy init-ssh` | Hand-off with the dry-run command. A policy edit can lock every machine out of every other. |
185
+ | `server init`, `server update`, `server console` | Hand-off. Long, staged and host-specific. Shown on the first-run state. |
186
+ | `enroll` | Status is read. Re-enroll is on Join, with confirmation. |
187
+ | `preauthkey` | Hand-off. The key is a credential. |
188
+ | `token` | Not on the card. Credentials are entered through the drawer panel, never here. |
189
+
190
+ Every operation not on the card is listed here on purpose, so that an omission can be told apart from an oversight.
191
+
192
+ ## Failure states, all first-class
193
+
194
+ Control server unverified; mesh disconnected; a source unreadable (named, with its error and time); a peer's presence unavailable; a machine not on the control server; a login recorded but never verified; a rung not yet checked. None of these renders as a zero, a blank or a reassuring word.
195
+
196
+ ## Identity of a machine
197
+
198
+ A machine is keyed by its mesh address everywhere on the card. The client reports names that differ from the node records (for example a cloud server reported as `web-01`, a phone as `localhost`), so a name is a label and never a key.
199
+
200
+ ## Layout and accessibility
201
+
202
+ - Wide first. Machines is two panes at container width 720 px and above, stacked below it.
203
+ - Every control is at least 44 px high, reachable by keyboard, with a visible focus ring.
204
+ - Severity is never colour alone. Each rung state has its own shape (solid, hatched, dashed, dotted) and its own word.
205
+ - No timers. Reading is on open and on Refresh. Nothing animates; the one rule for reduced motion is therefore satisfied by having no motion.
206
+ - Colours come from the host's tokens with a fallback only for the state hues.
207
+ - No emoji, no decorative icons. The mark is a three-node graph drawn in SVG.
208
+
209
+ ## What was wrong with the first version
210
+
211
+ It joined `status` and `doctor` into one severity label, so the ladder was lost. It treated a phone's missing login as a defect. It printed raw payload states as three adjacent words. It had no notion of where the operator is, no capability view, no trust view and no way to fix what it reported. It never rendered against real data. It was a list of commands with a verdict on top.
212
+
213
+ ## Implemented host contract — 2026-10-25
214
+
215
+ The five views now follow this design. The card reads the host's frozen permitted-operation list. The dashboard currently mounts the face read-only, so login administration and re-enrollment appear as explicit CLI handoffs there; a writable host can expose the forms and target-specific confirmation. This preserves host enforcement instead of drawing mutation buttons that cannot execute.
216
+
217
+ The Placement view also renders the plugin-owned manifest integration matrix. It labels declarations separately from timestamped observations and explains what each read proves. Unknown activity, stale presence, untested capabilities, failed reads and missing configuration are distinct. Native guard contention is handled by the generic SSSS owner without dropping auth audit records. Unsupported primitive authorization, exposure/tunnel inventories and route management are not represented as implemented capabilities.