dsh-mesh-node 0.0.0-stage → 0.0.1

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 (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +8 -2
  3. package/package.json +43 -4
  4. package/src/cli.js +1246 -0
  5. package/src/core/acl.js +81 -0
  6. package/src/core/audit.js +75 -0
  7. package/src/core/autostart.js +119 -0
  8. package/src/core/cluster.js +340 -0
  9. package/src/core/config.js +622 -0
  10. package/src/core/desktop-proxy.js +85 -0
  11. package/src/core/ds-web.js +394 -0
  12. package/src/core/host-agent.js +263 -0
  13. package/src/core/jobs.js +259 -0
  14. package/src/core/pairing.js +141 -0
  15. package/src/core/peer-client.js +273 -0
  16. package/src/core/peer-doctor.js +294 -0
  17. package/src/core/peer-http.js +61 -0
  18. package/src/core/repair.js +93 -0
  19. package/src/core/restart-policy.js +21 -0
  20. package/src/core/result.js +29 -0
  21. package/src/core/session-profile.js +146 -0
  22. package/src/core/tls.js +160 -0
  23. package/src/core/util.js +68 -0
  24. package/src/e2e-autostart.js +58 -0
  25. package/src/e2e-channel.js +384 -0
  26. package/src/e2e-cluster.js +417 -0
  27. package/src/e2e-host.js +411 -0
  28. package/src/e2e-onboard.js +232 -0
  29. package/src/e2e-pair.js +317 -0
  30. package/src/e2e-plugin.js +395 -0
  31. package/src/e2e-repair.js +297 -0
  32. package/src/index.js +20 -0
  33. package/src/peer-bridge.js +124 -0
  34. package/src/selftest.js +872 -0
  35. package/src/server.js +313 -0
  36. package/src/tools/alerts.js +80 -0
  37. package/src/tools/channel.js +253 -0
  38. package/src/tools/cluster.js +235 -0
  39. package/src/tools/config-patch.js +182 -0
  40. package/src/tools/control-apps.js +469 -0
  41. package/src/tools/control-path.js +160 -0
  42. package/src/tools/control.js +232 -0
  43. package/src/tools/core.js +79 -0
  44. package/src/tools/delegate.js +653 -0
  45. package/src/tools/fs.js +436 -0
  46. package/src/tools/git.js +266 -0
  47. package/src/tools/host.js +305 -0
  48. package/src/tools/index.js +122 -0
  49. package/src/tools/repair-fs.js +130 -0
  50. package/src/tools/repair.js +317 -0
  51. package/src/tools/session-history.js +60 -0
  52. package/src/tools/shell.js +66 -0
  53. package/src/tools/tasks.js +117 -0
@@ -0,0 +1,81 @@
1
+ // ACL editing helpers. Pure functions so the CLI and the self-test share one truth:
2
+ // an upgraded config keeps its (possibly customised) allow list — new default allows
3
+ // are NOT merged in automatically, which is why `acl reset|allow|deny` exists.
4
+ import { DEFAULT_ALLOW } from './config.js'
5
+
6
+ function unique(list) {
7
+ return [...new Set(list.filter(name => typeof name === 'string' && name.trim()).map(name => name.trim()))]
8
+ }
9
+
10
+ function validate(names, known) {
11
+ if (!known) return names
12
+ const catalog = new Set(known)
13
+ const unknown = names.filter(name => !catalog.has(name))
14
+ if (unknown.length > 0) {
15
+ throw Object.assign(new Error(`unknown tool(s): ${unknown.join(', ')}`), { code: 'E_INVALID_ARG' })
16
+ }
17
+ return names
18
+ }
19
+
20
+ /**
21
+ * Compute the next ACL from the current one.
22
+ * @param {{allow?: string[], deny?: string[]}} current
23
+ * @param {{allow?: string[], deny?: string[], reset?: boolean, known?: string[]}} change
24
+ * @returns {{allow: string[], deny: string[], changed: boolean, notes: string[]}}
25
+ */
26
+ export function resolveAclUpdate(current, change = {}) {
27
+ const notes = []
28
+ let allow = [...(current?.allow || [])]
29
+ let deny = [...(current?.deny || [])]
30
+
31
+ if (change.reset) {
32
+ allow = [...DEFAULT_ALLOW]
33
+ deny = []
34
+ notes.push('reset to DEFAULT_ALLOW')
35
+ }
36
+
37
+ const toAllow = validate(unique(change.allow || []), change.known)
38
+ const toDeny = validate(unique(change.deny || []), change.known)
39
+
40
+ for (const name of toAllow) {
41
+ if (!allow.includes(name)) {
42
+ allow.push(name)
43
+ notes.push(`allow ${name}`)
44
+ }
45
+ if (deny.includes(name)) {
46
+ deny = deny.filter(n => n !== name)
47
+ notes.push(`un-deny ${name}`)
48
+ }
49
+ }
50
+ for (const name of toDeny) {
51
+ if (!deny.includes(name)) {
52
+ deny.push(name)
53
+ notes.push(`deny ${name}`)
54
+ }
55
+ if (allow.includes(name)) {
56
+ allow = allow.filter(n => n !== name)
57
+ notes.push(`un-allow ${name}`)
58
+ }
59
+ }
60
+
61
+ const changed =
62
+ allow.length !== (current?.allow || []).length ||
63
+ deny.length !== (current?.deny || []).length ||
64
+ allow.some(n => !(current?.allow || []).includes(n))
65
+
66
+ return { allow, deny, changed, notes }
67
+ }
68
+
69
+ /** Effective view: every tool with the flag that `mesh_self` reports. */
70
+ export function describeAcl(config, names = []) {
71
+ const allow = config?.acl?.allow || []
72
+ const deny = config?.acl?.deny || []
73
+ return {
74
+ allow,
75
+ deny,
76
+ tools: names.map(name => {
77
+ const denied = deny.includes(name)
78
+ return { name, allowed: denied ? false : allow.includes(name) }
79
+ })
80
+ }
81
+ }
@@ -0,0 +1,75 @@
1
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, statSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { nowIso, short, stripBom } from './util.js'
4
+
5
+ const MAX_AUDIT_BYTES = 5 * 1024 * 1024
6
+ const KEEP_TAIL_LINES = 400
7
+
8
+ export function auditPath(home) {
9
+ mkdirSync(home, { recursive: true, mode: 0o700 })
10
+ return join(home, 'audit.jsonl')
11
+ }
12
+
13
+ function rotateIfNeeded(file) {
14
+ try {
15
+ if (!existsSync(file)) return
16
+ if (statSync(file).size < MAX_AUDIT_BYTES) return
17
+ renameSync(file, `${file}.1`)
18
+ } catch {
19
+ /* rotation is best-effort: never break a tool call because of it */
20
+ }
21
+ }
22
+
23
+ /**
24
+ * Audit writer. One JSON line per tool call: who (token id), what, result, duration.
25
+ * Never contains argument values (they may hold secrets); only argument key names.
26
+ */
27
+ export function makeAudit({ home, log = console }) {
28
+ const file = auditPath(home)
29
+ return async function audit(event) {
30
+ const line = {
31
+ ts: nowIso(),
32
+ ...event,
33
+ args: Array.isArray(event?.args) ? event.args : undefined
34
+ }
35
+ try {
36
+ rotateIfNeeded(file)
37
+ appendFileSync(file, JSON.stringify(line) + '\n', { encoding: 'utf8' })
38
+ } catch (error) {
39
+ log?.error?.(`[dsh-mesh] audit write failed: ${error?.message || error}`)
40
+ }
41
+ }
42
+ }
43
+
44
+ /** Read the last `limit` audit lines (newest last). */
45
+ export function tailAudit(home, { limit = 50, tool, ok } = {}) {
46
+ const file = auditPath(home)
47
+ if (!existsSync(file)) return { path: file, entries: [], total: 0 }
48
+ const text = stripBom(readFileSync(file, 'utf8'))
49
+ const lines = text.split(/\r?\n/).filter(Boolean)
50
+ let entries = []
51
+ for (const line of lines) {
52
+ try {
53
+ entries.push(JSON.parse(stripBom(line)))
54
+ } catch {
55
+ /* skip corrupt line */
56
+ }
57
+ }
58
+ const total = entries.length
59
+ if (tool) entries = entries.filter((e) => e.tool === tool)
60
+ if (typeof ok === 'boolean') entries = entries.filter((e) => e.ok === ok)
61
+ const slice = entries.slice(-Math.min(Math.max(1, limit), KEEP_TAIL_LINES))
62
+ return { path: file, total, matched: entries.length, entries: slice }
63
+ }
64
+
65
+ export function auditSummary(home) {
66
+ const file = auditPath(home)
67
+ if (!existsSync(file)) return { path: file, exists: false, sizeBytes: 0 }
68
+ let sizeBytes = 0
69
+ try {
70
+ sizeBytes = statSync(file).size
71
+ } catch {
72
+ /* ignore */
73
+ }
74
+ return { path: file, exists: true, sizeBytes, preview: short('', 0) }
75
+ }
@@ -0,0 +1,119 @@
1
+ // Autostart helper: keep the mesh endpoint alive across user logins (Windows Task Scheduler).
2
+ // Read-only by default: `status` only queries schtasks; `install`/`remove` need an explicit
3
+ // confirmation flag because creating a per-user scheduled task is a user-level config change.
4
+ import { execFile } from 'node:child_process'
5
+ import { fileURLToPath } from 'node:url'
6
+ import { dirname as dirnameOf, join as joinPath } from 'node:path'
7
+
8
+ export const TASK_NAME = 'dsh-mesh-endpoint'
9
+
10
+ const CLI_PATH = joinPath(dirnameOf(fileURLToPath(import.meta.url)), '..', 'cli.js')
11
+
12
+ function exec(args) {
13
+ return new Promise((resolvePromise) => {
14
+ // schtasks writes the console code page (GBK on zh-CN Windows), so decode bytes ourselves.
15
+ execFile(
16
+ 'schtasks',
17
+ args,
18
+ { windowsHide: true, timeout: 15000, encoding: 'buffer' },
19
+ (error, stdout, stderr) => {
20
+ const code = error ? (typeof error.code === 'number' ? error.code : 1) : 0
21
+ resolvePromise({
22
+ code,
23
+ stdout: decodeText(stdout),
24
+ stderr: decodeText(stderr),
25
+ message: error ? String(error.message) : ''
26
+ })
27
+ }
28
+ )
29
+ })
30
+ }
31
+
32
+ function decodeText(buf) {
33
+ if (!Buffer.isBuffer(buf)) return buf || ''
34
+ try {
35
+ return new TextDecoder('gbk').decode(buf)
36
+ } catch {
37
+ return buf.toString('utf8')
38
+ }
39
+ }
40
+
41
+ function assertWindows() {
42
+ if (process.platform !== 'win32') throw new Error('autostart is Windows-only (schtasks)')
43
+ }
44
+
45
+ /** Parse `schtasks /Query /V /FO LIST` into a flat object (localized labels are matched by suffix). */
46
+ function parseVerboseList(text) {
47
+ const out = {}
48
+ for (const line of text.split(/\r?\n/)) {
49
+ const i = line.indexOf(':')
50
+ if (i === -1) continue
51
+ const key = line.slice(0, i).trim()
52
+ const value = line.slice(i + 1).trim()
53
+ if (key && value) out[key] = value
54
+ }
55
+ return out
56
+ }
57
+
58
+ /** Inspect the task. Never throws for "not installed" — that is the normal state. */
59
+ export async function autostartStatus(name = TASK_NAME) {
60
+ assertWindows()
61
+ const res = await exec(['/Query', '/TN', name, '/V', '/FO', 'LIST'])
62
+ if (res.code !== 0) {
63
+ return { installed: false, name, detail: (res.stderr || res.message || '').trim() }
64
+ }
65
+ const fields = parseVerboseList(res.stdout)
66
+ // zh-CN Windows localizes these labels ("状态", "要运行的任务", …), so a suffix match on the
67
+ // English name alone silently returns undefined — match localized aliases too (2026-10-19).
68
+ const ALIASES = {
69
+ status: ['Status', '状态'],
70
+ state: ['ScheduledTaskState', '计划任务状态', '已计划的任务状态'],
71
+ taskToRun: ['TaskToRun', '要运行的任务'],
72
+ lastRun: ['LastRunTime', '上次运行时间'],
73
+ lastResult: ['LastResult', '上次结果'],
74
+ runAsUser: ['RunAsUser', '运行身份', '运行方式', '作为用户运行']
75
+ }
76
+ const pick = (aliases) => {
77
+ const squash = (value) => value.replace(/[\s.]/g, '')
78
+ const key = Object.keys(fields).find((candidate) => aliases.some((alias) => squash(candidate).endsWith(squash(alias)) && squash(candidate).length <= squash(alias).length + 2))
79
+ || Object.keys(fields).find((candidate) => aliases.includes(candidate.trim()))
80
+ return key ? fields[key] : undefined
81
+ }
82
+ return {
83
+ installed: true,
84
+ name,
85
+ status: pick(ALIASES.status),
86
+ state: pick(ALIASES.state),
87
+ taskToRun: pick(ALIASES.taskToRun),
88
+ lastRun: pick(ALIASES.lastRun),
89
+ lastResult: pick(ALIASES.lastResult),
90
+ runAsUser: pick(ALIASES.runAsUser)
91
+ }
92
+ }
93
+
94
+ /** The exact command a login-time task would run. */
95
+ export function autostartCommand(name = TASK_NAME, port) {
96
+ const portArg = port ? ` --port ${port}` : ''
97
+ return `"${process.execPath}" "${CLI_PATH}" serve${portArg}`
98
+ }
99
+
100
+ /**
101
+ * Create/replace a logon-triggered task that runs `serve`.
102
+ * Callers must pass `yes: true`; the CLI only forwards it after an explicit `--yes`.
103
+ */
104
+ export async function autostartInstall({ name = TASK_NAME, command = autostartCommand(name), yes = false } = {}) {
105
+ assertWindows()
106
+ if (!yes) throw new Error('refusing to install without --yes (creates a per-user scheduled task)')
107
+ // /RL LIMITED: run at the user's normal integrity level, never elevated.
108
+ const res = await exec(['/Create', '/TN', name, '/SC', 'ONLOGON', '/RL', 'LIMITED', '/TR', command, '/F'])
109
+ if (res.code !== 0) throw new Error(`schtasks /Create failed (${res.code}): ${(res.stderr || res.message || '').trim()}`)
110
+ return { ok: true, name, command, stdout: res.stdout.trim() }
111
+ }
112
+
113
+ export async function autostartRemove({ name = TASK_NAME, yes = false } = {}) {
114
+ assertWindows()
115
+ if (!yes) throw new Error('refusing to remove without --yes')
116
+ const res = await exec(['/Delete', '/TN', name, '/F'])
117
+ if (res.code !== 0) throw new Error(`schtasks /Delete failed (${res.code}): ${(res.stderr || res.message || '').trim()}`)
118
+ return { ok: true, name }
119
+ }
@@ -0,0 +1,340 @@
1
+ /**
2
+ * Cluster view (M7 step 4).
3
+ *
4
+ * A single read-only snapshot of everything this node can currently reach:
5
+ * - local: every DSH instance behind a host bridge (id / pid / tool count / live agents)
6
+ * - remote: every paired peer's `/health` (version / tools / uptime / TLS pin)
7
+ *
8
+ * The point is to answer "is the cluster healthy, and if not, which hop is broken"
9
+ * from one call, instead of hand-running `host status` plus curl on each peer.
10
+ * Nothing here mutates state; all probes are bounded by `timeoutMs`.
11
+ */
12
+ import { request as httpRequest } from 'node:http'
13
+ import { request as httpsRequest } from 'node:https'
14
+ import { createHostClient, hostInstances } from './host-agent.js'
15
+ import { MESH_VERSION } from './config.js'
16
+ import { callPeerTool } from './peer-client.js'
17
+ import { fingerprintOfSocket, normalizeFingerprint } from './tls.js'
18
+
19
+ const DEFAULT_TIMEOUT_MS = 4000
20
+
21
+ function normalizeUrl(url) {
22
+ return String(url || '').replace(/\/+$/, '')
23
+ }
24
+
25
+ function normalizePeerHealth(body) {
26
+ if (!body || body.service !== 'dsh-mesh') {
27
+ return { error: { code: 'E_PEER_BAD_RESPONSE', message: 'peer /health did not identify as dsh-mesh' } }
28
+ }
29
+ return {
30
+ version: body.version,
31
+ node: body.node,
32
+ tools: body.tools,
33
+ pid: body.pid ?? null,
34
+ uptimeSec: body.uptimeSec,
35
+ tls: body.tls === true,
36
+ pairing: body.pairing ? { enabled: body.pairing.enabled === true, codeActive: body.pairing.codeActive === true } : null
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Probe one paired peer over HTTPS. Uses TOFU pinning (peers store the cert
42
+ * fingerprint captured at pairing time): the trust chain is not judged, the
43
+ * certificate is read off the socket and compared against the stored pin.
44
+ */
45
+ export function probePeer(peer, { timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
46
+ const base = normalizeUrl(peer?.url)
47
+ const expected = peer?.certFingerprint ? normalizeFingerprint(peer.certFingerprint) : null
48
+ return new Promise((resolve) => {
49
+ let settled = false
50
+ const startedAt = Date.now()
51
+ const finish = (value) => {
52
+ if (settled) return
53
+ settled = true
54
+ clearTimeout(timer)
55
+ resolve({ id: peer?.id || null, name: peer?.name || null, url: base, ...value })
56
+ }
57
+ if (!base) {
58
+ return finish({ up: false, latencyMs: 0, error: { code: 'E_PEER_BAD_RESPONSE', message: 'peer has no url' } })
59
+ }
60
+
61
+ const isHttps = base.startsWith('https:')
62
+ const request = isHttps ? httpsRequest : httpRequest
63
+ const options = { method: 'GET', agent: false, headers: { accept: 'application/json' } }
64
+ if (isHttps) options.rejectUnauthorized = false
65
+
66
+ const req = request(`${base}/health`, options, (res) => {
67
+ // Grab the certificate *inside* the response callback: by the time `end`
68
+ // fires the socket may already be gone (same trap as company-check.mjs).
69
+ let got = null
70
+ if (isHttps) {
71
+ try {
72
+ got = fingerprintOfSocket(res.socket)
73
+ } catch {
74
+ got = null
75
+ }
76
+ }
77
+ const chunks = []
78
+ res.on('data', (chunk) => chunks.push(chunk))
79
+ res.on('end', () => {
80
+ const latencyMs = Date.now() - startedAt
81
+ const text = Buffer.concat(chunks).toString('utf8')
82
+ let body
83
+ try {
84
+ body = JSON.parse(text)
85
+ } catch {
86
+ return finish({
87
+ up: false,
88
+ latencyMs,
89
+ fingerprint: got,
90
+ fingerprintMatch: got && expected ? normalizeFingerprint(got) === expected : null,
91
+ error: { code: 'E_PEER_BAD_RESPONSE', message: `peer /health returned non-JSON (${res.statusCode})` }
92
+ })
93
+ }
94
+ const match = got && expected ? normalizeFingerprint(got) === expected : null
95
+ if (res.statusCode !== 200) {
96
+ return finish({
97
+ up: false,
98
+ latencyMs,
99
+ status: res.statusCode,
100
+ fingerprint: got,
101
+ fingerprintMatch: match,
102
+ error: { code: 'E_PEER_BAD_RESPONSE', message: `peer /health returned ${res.statusCode}` }
103
+ })
104
+ }
105
+ if (match === false) {
106
+ const health = normalizePeerHealth(body)
107
+ return finish({
108
+ up: true,
109
+ latencyMs,
110
+ fingerprint: got,
111
+ fingerprintMatch: false,
112
+ ...health,
113
+ error: {
114
+ code: 'E_PEER_PIN_MISMATCH',
115
+ message: 'peer certificate does not match the fingerprint stored at pairing time — refuse to trust it'
116
+ }
117
+ })
118
+ }
119
+ const health = normalizePeerHealth(body)
120
+ return finish({ up: !health.error, latencyMs, fingerprint: got, fingerprintMatch: match, ...health })
121
+ })
122
+ })
123
+ const timer = setTimeout(() => {
124
+ try {
125
+ req.destroy(new Error('timeout'))
126
+ } catch {
127
+ /* ignore */
128
+ }
129
+ finish({
130
+ up: false,
131
+ latencyMs: Date.now() - startedAt,
132
+ error: { code: 'E_PEER_TIMEOUT', message: `peer did not answer within ${timeoutMs} ms` }
133
+ })
134
+ }, timeoutMs)
135
+ req.on('error', (error) => {
136
+ finish({
137
+ up: false,
138
+ latencyMs: Date.now() - startedAt,
139
+ error: {
140
+ code: error?.code === 'ECONNRESET' || error?.code === 'ECONNREFUSED' ? 'E_PEER_OFFLINE' : 'E_PEER_TLS',
141
+ message: `${base} unreachable (${error?.code || error?.message || error})`,
142
+ detail: error?.code || null
143
+ }
144
+ })
145
+ })
146
+ req.setTimeout(timeoutMs, () => req.destroy(new Error('timeout')))
147
+ req.end()
148
+ })
149
+ }
150
+
151
+ /** Probe one local DSH instance through its host bridge. */
152
+ export async function probeInstance({ config, home, log, entry, timeoutMs = DEFAULT_TIMEOUT_MS }) {
153
+ const startedAt = Date.now()
154
+ const base = { id: entry.id, label: entry.label || entry.id, url: entry.url, profileDir: entry.profileDir || '' }
155
+ try {
156
+ const client = createHostClient({ config, home, log, instance: entry.id })
157
+ await client.ping()
158
+ const [self, tools, agents] = await Promise.all([client.self(), client.tools(), client.agents()])
159
+ const toolList = tools?.tools || []
160
+ const agentList = agents?.agents || []
161
+ return {
162
+ ...base,
163
+ up: true,
164
+ latencyMs: Date.now() - startedAt,
165
+ plugin: self?.plugin || null,
166
+ hostname: self?.node?.hostname || null,
167
+ platform: self?.node?.platform || null,
168
+ pid: self?.node?.pid || null,
169
+ toolCount: toolList.length,
170
+ agents: agentList.map((agent) => ({ id: agent.id, status: agent.status, title: agent.title || '' })),
171
+ runningAgents: agentList.filter((agent) => agent.status === 'running').length
172
+ }
173
+ } catch (error) {
174
+ return {
175
+ ...base,
176
+ up: false,
177
+ latencyMs: Date.now() - startedAt,
178
+ error: { code: error?.code || 'E_HOST_OFFLINE', message: error?.message || String(error) }
179
+ }
180
+ }
181
+ }
182
+
183
+ /**
184
+ * Probe this node's OWN serve endpoint (`/health`) — the hop every peer needs.
185
+ * The host-bridge probes below only prove the DSH instances are up; when the
186
+ * local mesh server dies they all still look fine and only the peers look
187
+ * offline. Reported 2026-10-23: the company endpoint had been down ~3 days
188
+ * while `self-update status --deep` read clean.
189
+ */
190
+ export async function probeSelfEndpoint(config, { timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
191
+ const node = config?.node || {}
192
+ const scheme = node.tls?.enabled ? 'https' : 'http'
193
+ const url = `${scheme}://${node.bind || '127.0.0.1'}:${node.port || 8124}`
194
+ const probe = await probePeer({ id: 'self', name: 'self', url }, { timeoutMs })
195
+ return {
196
+ url,
197
+ up: probe.up === true,
198
+ version: probe.version || null,
199
+ pid: probe.pid ?? null,
200
+ tools: probe.tools ?? null,
201
+ uptimeSec: probe.uptimeSec ?? null,
202
+ error: probe.error || null
203
+ }
204
+ }
205
+
206
+ /** Parse the JSON payload of an MCP tool result (remote tools return text content). */
207
+ function parsePeerToolPayload(result) {
208
+ const block = result?.content?.[0]
209
+ if (!block || block.type !== 'text') return null
210
+ try {
211
+ return JSON.parse(block.text)
212
+ } catch {
213
+ return null
214
+ }
215
+ }
216
+
217
+ /**
218
+ * Ask a paired peer for its OWN host instances through the MCP `host_instances`
219
+ * tool, so the cluster view can show all machines' DSH instances in one place.
220
+ * This is best-effort: a peer that denies the tool (or has no token) still gets
221
+ * its /health row — we just record `instancesError` instead of failing the view.
222
+ */
223
+ async function probePeerInstances(peer, timeoutMs) {
224
+ if (!peer?.token) {
225
+ return { error: { code: 'E_PEER_NO_TOKEN', message: 'peer has no outbound token in peers.json — cannot list its host instances' } }
226
+ }
227
+ try {
228
+ const result = await callPeerTool(peer, { tool: 'host_instances', args: {}, timeoutMs })
229
+ const body = parsePeerToolPayload(result.result)
230
+ const rows = Array.isArray(body?.instances) ? body.instances : []
231
+ return {
232
+ instances: rows.map((row) => ({
233
+ id: row.id,
234
+ label: row.label || row.id,
235
+ url: row.url,
236
+ alive: row.alive === true
237
+ }))
238
+ }
239
+ } catch (error) {
240
+ return { error: { code: error?.code || 'E_PEER_CALL_FAILED', message: error?.message || String(error) } }
241
+ }
242
+ }
243
+
244
+ /**
245
+ * @param {{ probeSelf?: boolean }} options `probeSelf: true` additionally probes
246
+ * this node's own serve endpoint, so a CLI running *outside* the server can tell
247
+ * "my host bridges are fine" apart from "my node is actually serving peers".
248
+ * Off by default: callers inside the server would only measure themselves, and
249
+ * tests use `node.port = 0`.
250
+ */
251
+ export async function clusterReport({ config, home, log, peers = [], timeoutMs = DEFAULT_TIMEOUT_MS, probeSelf = false } = {}) {
252
+ const endpoint = probeSelf ? await probeSelfEndpoint(config, { timeoutMs }) : null
253
+ const instances = []
254
+ for (const entry of hostInstances(config)) {
255
+ if (entry.enabled === false) continue
256
+ instances.push(await probeInstance({ config, home, log, entry, timeoutMs }))
257
+ }
258
+ const peerReports = await Promise.all(
259
+ peers.map(async (peer) => {
260
+ const report = await probePeer(peer, { timeoutMs })
261
+ if (report.up === true && !report.error) {
262
+ const remote = await probePeerInstances(peer, timeoutMs)
263
+ report.instances = remote.instances || []
264
+ if (remote.error) report.instancesError = remote.error
265
+ }
266
+ return report
267
+ })
268
+ )
269
+
270
+ const issues = []
271
+ if (endpoint && !endpoint.up) {
272
+ issues.push(`local endpoint ${endpoint.url} down: ${endpoint.error?.code || 'E_ENDPOINT_OFFLINE'}`)
273
+ }
274
+ for (const instance of instances) {
275
+ if (!instance.up) issues.push(`instance ${instance.id} down: ${instance.error?.code || 'E_HOST_OFFLINE'}`)
276
+ else if (instance.runningAgents > 0) issues.push(`instance ${instance.id} has ${instance.runningAgents} running agent(s)`)
277
+ }
278
+ for (const peer of peerReports) {
279
+ if (!peer.up) issues.push(`peer ${peer.name || peer.id || peer.url} down: ${peer.error?.code || 'E_PEER_OFFLINE'}`)
280
+ else if (peer.fingerprintMatch === false) issues.push(`peer ${peer.name || peer.id} fingerprint mismatch`)
281
+ else if (peer.instancesError) {
282
+ issues.push(`peer ${peer.name || peer.id} host instances unavailable: ${peer.instancesError.code}`)
283
+ }
284
+ }
285
+
286
+ // Flatten remote instances next to local ones so one call shows the whole
287
+ // cluster (four DSH instances on two machines). `scope` says where a row lives.
288
+ const localRows = instances.map((instance) => ({
289
+ scope: 'local',
290
+ id: instance.id,
291
+ label: instance.label,
292
+ up: instance.up === true,
293
+ toolCount: instance.toolCount ?? null,
294
+ pid: instance.pid ?? null
295
+ }))
296
+ const remoteRows = []
297
+ for (const peer of peerReports) {
298
+ for (const row of peer.instances || []) {
299
+ remoteRows.push({
300
+ scope: peer.name || peer.id,
301
+ id: row.id,
302
+ label: row.label,
303
+ url: row.url,
304
+ up: row.alive === true,
305
+ remote: true
306
+ })
307
+ if (row.alive !== true) issues.push(`peer ${peer.name || peer.id} instance ${row.id} down`)
308
+ }
309
+ }
310
+
311
+ const anyInstanceUp = instances.some((instance) => instance.up)
312
+ const remoteAllAlive = remoteRows.every((row) => row.up === true)
313
+ const allUp =
314
+ (!endpoint || endpoint.up) &&
315
+ instances.every((instance) => instance.up) &&
316
+ peerReports.every((peer) => peer.up && peer.fingerprintMatch !== false) &&
317
+ remoteAllAlive
318
+
319
+ return {
320
+ node: { version: MESH_VERSION, home, instances: instances.length, peers: peerReports.length },
321
+ summary: { local: localRows.length, remote: remoteRows.length, total: localRows.length + remoteRows.length },
322
+ endpoint,
323
+ instances,
324
+ remoteInstances: remoteRows,
325
+ cluster: [...localRows, ...remoteRows],
326
+ peers: peerReports,
327
+ verdict:
328
+ endpoint && !endpoint.up
329
+ ? 'down'
330
+ : instances.length === 0
331
+ ? 'unknown'
332
+ : allUp
333
+ ? 'ok'
334
+ : anyInstanceUp
335
+ ? 'degraded'
336
+ : 'down',
337
+ issues
338
+ }
339
+ }
340
+