dsh-mesh-node 0.0.0-stage → 0.0.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 (55) 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 +341 -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/sakura-api.js +103 -0
  22. package/src/core/session-profile.js +146 -0
  23. package/src/core/tls.js +160 -0
  24. package/src/core/tunnels.js +212 -0
  25. package/src/core/util.js +68 -0
  26. package/src/e2e-autostart.js +58 -0
  27. package/src/e2e-channel.js +384 -0
  28. package/src/e2e-cluster.js +417 -0
  29. package/src/e2e-host.js +411 -0
  30. package/src/e2e-onboard.js +232 -0
  31. package/src/e2e-pair.js +317 -0
  32. package/src/e2e-plugin.js +395 -0
  33. package/src/e2e-repair.js +297 -0
  34. package/src/index.js +20 -0
  35. package/src/peer-bridge.js +124 -0
  36. package/src/selftest.js +872 -0
  37. package/src/server.js +317 -0
  38. package/src/tools/alerts.js +80 -0
  39. package/src/tools/channel.js +253 -0
  40. package/src/tools/cluster.js +235 -0
  41. package/src/tools/config-patch.js +182 -0
  42. package/src/tools/control-apps.js +469 -0
  43. package/src/tools/control-path.js +160 -0
  44. package/src/tools/control.js +232 -0
  45. package/src/tools/core.js +79 -0
  46. package/src/tools/delegate.js +653 -0
  47. package/src/tools/fs.js +436 -0
  48. package/src/tools/git.js +266 -0
  49. package/src/tools/host.js +305 -0
  50. package/src/tools/index.js +122 -0
  51. package/src/tools/repair-fs.js +130 -0
  52. package/src/tools/repair.js +317 -0
  53. package/src/tools/session-history.js +60 -0
  54. package/src/tools/shell.js +66 -0
  55. package/src/tools/tasks.js +117 -0
@@ -0,0 +1,235 @@
1
+ import { z } from 'zod'
2
+ import { existsSync, readFileSync, readdirSync } from 'node:fs'
3
+ import { execFileSync } from 'node:child_process'
4
+ import { join } from 'node:path'
5
+ import { loadPeers } from '../core/pairing.js'
6
+ import { clusterReport, probePeer } from '../core/cluster.js'
7
+ import { callPeerTool } from '../core/peer-client.js'
8
+ import { fail, ok } from '../core/result.js'
9
+
10
+ const TERMINAL = new Set(['done', 'failed', 'cancelled'])
11
+
12
+ function gitInfo(repo) {
13
+ if (!repo) return { path: '', error: 'no repo path' }
14
+ const run = (args) => execFileSync('git', ['-C', repo, ...args], { encoding: 'utf8', timeout: 5000, windowsHide: true }).trim()
15
+ try {
16
+ const head = run(['rev-parse', '--short', 'HEAD'])
17
+ const branch = run(['rev-parse', '--abbrev-ref', 'HEAD'])
18
+ const dirty = run(['status', '--porcelain']) !== ''
19
+ let ahead = null
20
+ let behind = null
21
+ try {
22
+ const counts = run(['rev-list', '--left-right', '--count', 'HEAD...@{upstream}']).split(/\s+/).map(Number)
23
+ ahead = counts[0] || 0
24
+ behind = counts[1] || 0
25
+ } catch {
26
+ /* no upstream */
27
+ }
28
+ return { path: repo, head, branch, dirty, ahead, behind }
29
+ } catch (error) {
30
+ return { path: repo, error: String(error?.message || error) }
31
+ }
32
+ }
33
+
34
+ function taskStats(root) {
35
+ const dir = join(root, '.mesh', 'tasks')
36
+ if (!existsSync(dir)) return { active: 0, stale: 0, activeStale: 0 }
37
+ let active = 0
38
+ let stale = 0
39
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
40
+ if (!entry.isDirectory()) continue
41
+ let status = null
42
+ try {
43
+ status = JSON.parse(readFileSync(join(dir, entry.name, 'status.json'), 'utf8'))
44
+ } catch {
45
+ status = null
46
+ }
47
+ const state = status?.state || 'submitted'
48
+ if (TERMINAL.has(state)) continue
49
+ active += 1
50
+ const match = /^T-(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})-/.exec(entry.name)
51
+ const idTime = match ? new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]), Number(match[4]), Number(match[5])).getTime() : 0
52
+ const age = Number(status?.staleSeconds || 0)
53
+ const statusAge = status?.updatedAt ? (Date.now() - Date.parse(status.updatedAt)) / 1000 : null
54
+ if (age > 3600 || (statusAge !== null && statusAge > 3600) || (idTime && Date.now() - idTime > 3600_000)) stale += 1
55
+ }
56
+ return { active, stale, activeStale: stale }
57
+ }
58
+ /**
59
+ * Cluster view (M7 step 4). One read-only call answers "is the whole cluster
60
+ * healthy, and if not which hop is broken": every local DSH instance behind a
61
+ * host bridge, plus every paired remote node's `/health` (version / tools /
62
+ * uptime / TLS pin). Used as the first step of the A-crashed-B-repairs-A drill.
63
+ */
64
+ export function buildCluster(ctx) {
65
+ return [
66
+ {
67
+ name: 'cluster_status',
68
+ title: 'Cluster health snapshot',
69
+ description:
70
+ 'Read-only snapshot of everything this node can reach: local DSH instances behind host bridges (pid, tool count, live agents, latency) and paired remote nodes probed over /health (version, tools, uptime, TLS fingerprint match). Also aggregates each peer\'s own host_instances into summary{local,remote,total} / cluster[] / remoteInstances[] so one call lists every machine\'s DSH instances (four-instance view); a peer that denies host_instances shows instancesError without failing the verdict. Returns a verdict (ok / degraded / down) plus an issues list. Start here before repairing anything: it tells you which hop is broken. Never mutates state.',
71
+ inputSchema: z.object({
72
+ includePeers: z.boolean().optional().describe('Probe paired remote nodes too (default true).'),
73
+ timeoutMs: z.number().int().min(1000).max(60000).optional().describe('Per-probe timeout in ms (default 4000).')
74
+ }),
75
+ handler: async ({ includePeers, timeoutMs }) => {
76
+ try {
77
+ const config = ctx.getConfig()
78
+ const peers = includePeers === false ? [] : loadPeers(ctx.home).items || []
79
+ const report = await clusterReport({
80
+ config,
81
+ home: ctx.home,
82
+ log: ctx.log,
83
+ peers: peers.map((peer) => ({
84
+ id: peer.id,
85
+ name: peer.name,
86
+ url: peer.url,
87
+ certFingerprint: peer.certFingerprint,
88
+ token: peer.token
89
+ })),
90
+ timeoutMs: timeoutMs ?? 4000
91
+ })
92
+ return ok(report)
93
+ } catch (error) {
94
+ return fail('E_CLUSTER_ERROR', error?.message || String(error))
95
+ }
96
+ }
97
+ },
98
+ {
99
+ name: 'mesh_overview',
100
+ title: 'One-screen mesh overview',
101
+ description:
102
+ 'Read-only overview for AI: node/mesh version, local DSH instances (up/plugin/pid/tools/live agents), peers (health/version), local active/stale delegation tasks, each peer active/stale task counts, and local/peer git head/dirty/ahead/behind. Use this first instead of chaining cluster_status + delegate_list.',
103
+ inputSchema: z.object({
104
+ includePeers: z.boolean().optional().describe('Probe and query paired peers too (default true).'),
105
+ timeoutMs: z.number().int().min(1000).max(60000).optional()
106
+ }),
107
+ handler: async ({ includePeers = true, timeoutMs = 4000 }) => {
108
+ try {
109
+ const config = ctx.getConfig()
110
+ const peers = includePeers === false ? [] : loadPeers(ctx.home).items || []
111
+ const report = await clusterReport({
112
+ config,
113
+ home: ctx.home,
114
+ log: ctx.log,
115
+ peers: peers.map((peer) => ({
116
+ id: peer.id,
117
+ name: peer.name,
118
+ url: peer.url,
119
+ certFingerprint: peer.certFingerprint,
120
+ token: peer.token
121
+ })),
122
+ timeoutMs
123
+ })
124
+ const localRoot = config.acl?.fsRoots?.[0]?.path || ''
125
+ const localTasks = localRoot ? taskStats(localRoot) : { active: 0, stale: 0, activeStale: 0 }
126
+ const peerTasks = []
127
+ for (const peer of peers) {
128
+ try {
129
+ const out = await callPeerTool(peer, { tool: 'delegate_list', args: { limit: 100 }, timeoutMs: Math.min(timeoutMs, 8000) })
130
+ const text = out.result?.content?.[0]?.text || '{}'
131
+ const listed = JSON.parse(text)
132
+ peerTasks.push({ peer: peer.name, activeCount: listed.activeCount || 0, staleActiveCount: listed.staleActiveCount || 0 })
133
+ } catch (error) {
134
+ peerTasks.push({ peer: peer.name, error: String(error?.message || error) })
135
+ }
136
+ }
137
+ const peerRepos = []
138
+ for (const peer of peers) {
139
+ try {
140
+ const selfOut = await callPeerTool(peer, { tool: 'mesh_self', args: {}, timeoutMs: Math.min(timeoutMs, 8000) })
141
+ const selfText = selfOut.result?.content?.[0]?.text || '{}'
142
+ const self = JSON.parse(selfText)
143
+ const repoPath = self.mesh?.repo || join(self.acl?.fsRoots?.[0]?.path || '', 'dsh-mesh')
144
+ const statusOut = await callPeerTool(peer, { tool: 'git_status', args: { path: repoPath }, timeoutMs: Math.min(timeoutMs, 8000) })
145
+ const statusText = statusOut.result?.content?.[0]?.text || '{}'
146
+ const status = JSON.parse(statusText)
147
+ peerRepos.push({
148
+ peer: peer.name,
149
+ path: repoPath,
150
+ head: status.head ? String(status.head).slice(0, 7) : '',
151
+ branch: status.branch || '',
152
+ dirty: (status.dirty?.modified || 0) + (status.dirty?.untracked || 0),
153
+ ahead: status.ahead ?? null,
154
+ behind: status.behind ?? null
155
+ })
156
+ } catch (error) {
157
+ peerRepos.push({ peer: peer.name, error: String(error?.message || error) })
158
+ }
159
+ }
160
+ return ok({
161
+ node: report.node,
162
+ summary: report.summary,
163
+ verdict: report.verdict,
164
+ issues: report.issues,
165
+ repos: { local: gitInfo(process.cwd()), peers: peerRepos },
166
+ instances: (report.instances || []).map((instance) => ({
167
+ id: instance.id,
168
+ label: instance.label,
169
+ up: instance.up,
170
+ plugin: instance.plugin,
171
+ pid: instance.pid,
172
+ toolCount: instance.toolCount,
173
+ liveAgents: (instance.agents || []).length
174
+ })),
175
+ peers: report.peers,
176
+ tasks: { local: localTasks, peers: peerTasks }
177
+ })
178
+ } catch (error) {
179
+ return fail('E_OVERVIEW_ERROR', error?.message || String(error))
180
+ }
181
+ }
182
+ },
183
+ {
184
+ name: 'cluster_exec',
185
+ title: 'Run one MCP tool on a paired peer',
186
+ description:
187
+ 'Calls exactly one tool on a paired remote node (the reverse direction: this node acts on the other machine). Looks the peer up by name/id in peers.json (token imported via `peer import` or obtained by `peer add`), re-checks its /health and TLS pin first, then makes one MCP tool call. Denied by default — add "cluster_exec" to acl.allow. Read-only tools are the intended use; write/repair tools on the peer are still governed by THAT node\'s own ACL.',
188
+ inputSchema: z.object({
189
+ peer: z.string().describe('Paired peer name or id, as listed by `node src/cli.js peer list`.'),
190
+ tool: z.string().describe('Tool name on the peer (e.g. mesh_self, cluster_status).'),
191
+ arguments: z.any().optional().describe('Arguments object for the peer tool (default {}).'),
192
+ timeoutMs: z.number().int().min(1000).max(600000).optional().describe('Per-call timeout in ms (default 30000).')
193
+ }),
194
+ handler: async ({ peer, tool, arguments: args, timeoutMs }) => {
195
+ const config = ctx.getConfig()
196
+ const target = String(peer || '').trim()
197
+ const entry = loadPeers(ctx.home).items.find((item) => item.name === target || item.id === target)
198
+ if (!entry) {
199
+ return fail('E_NOT_FOUND', `unknown peer "${target}" — run \`node src/cli.js peer list\` / \`peer import\``)
200
+ }
201
+ const limit = timeoutMs ?? 30000
202
+ const probe = await probePeer(
203
+ { id: entry.id, name: entry.name, url: entry.url, certFingerprint: entry.certFingerprint },
204
+ { timeoutMs: Math.min(limit, 8000) }
205
+ )
206
+ if (probe.up !== true) {
207
+ return fail(probe.error?.code || 'E_PEER_OFFLINE', `peer "${target}" is unreachable: ${probe.error?.message || 'no response'}`, {
208
+ retryable: true,
209
+ peer: target
210
+ })
211
+ }
212
+ if (probe.fingerprintMatch !== true) {
213
+ return fail('E_PEER_PIN_MISMATCH', `peer "${target}" certificate no longer matches the stored fingerprint`, {
214
+ retryable: false,
215
+ peer: target
216
+ })
217
+ }
218
+ try {
219
+ const out = await callPeerTool(entry, { tool, args: args ?? {}, timeoutMs: limit })
220
+ return ok({
221
+ peer: target,
222
+ node: probe.node,
223
+ version: probe.version,
224
+ tool,
225
+ ok: out.ok,
226
+ ms: out.ms,
227
+ result: out.result
228
+ })
229
+ } catch (error) {
230
+ return fail(error.code || 'E_PEER_CALL_FAILED', error.message, { retryable: false, peer: target })
231
+ }
232
+ }
233
+ }
234
+ ]
235
+ }
@@ -0,0 +1,182 @@
1
+ // @generated 2026-10-27 P1:mesh_config_patch(受限的「远程改本机 mesh 配置」)
2
+ // [why] 2026-10-27 事故暴露的鸡生蛋:实例全死时想远程救,必须先在本机开 repair/加 ACL,而那头没人。
3
+ // 本工具只允许**白名单字段**的**增量**修改(不删、不碰 token/shell/端口),带备份 + 原子写 + JSON 校验 + 默认 dry-run,
4
+ // 把「远程改配置」从「开放 fs_write」降级为「受限补丁」。
5
+ // 门禁:ACL 必须显式 allow `mesh_config_patch`(默认不给);写操作要 confirm:true。
6
+ // - op=repair.set 允许在 repair.enabled=false 时执行(用于远程开启修复通道,bootstrap)
7
+ // - 其它 op 要求 repair.enabled=true
8
+ import { copyFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs'
9
+ import { basename } from 'node:path'
10
+ import { z } from 'zod'
11
+ import { configPath, isAbsolutePath } from '../core/config.js'
12
+ import { validateRestartCommand } from '../core/restart-policy.js'
13
+ import { fail, ok } from '../core/result.js'
14
+
15
+ const TOOL_NAME_RE = /^[a-z][a-z0-9_]{2,40}$/
16
+ const SCRIPT_NAME_RE = /^[A-Za-z0-9._-]{1,40}$/
17
+ const NEVER_APPEND = new Set(['shell_run'])
18
+ const MAX_FSROOTS_PER_CALL = 10
19
+
20
+ const opSchema = z.enum(['acl.allow.append', 'acl.fsRoots.append', 'repair.set', 'restartScript.set'])
21
+
22
+ function readConfigFile(home) {
23
+ const file = configPath(home)
24
+ const text = readFileSync(file, 'utf8')
25
+ return { file, text, config: JSON.parse(text) }
26
+ }
27
+
28
+ function diffSummary(before, after) {
29
+ const flat = (value, prefix = '') => {
30
+ const out = {}
31
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
32
+ for (const [k, v] of Object.entries(value)) Object.assign(out, flat(v, prefix ? `${prefix}.${k}` : k))
33
+ } else {
34
+ out[prefix] = value
35
+ }
36
+ return out
37
+ }
38
+ const a = flat(before)
39
+ const b = flat(after)
40
+ const added = []
41
+ const changed = []
42
+ for (const key of Object.keys(b)) {
43
+ if (!(key in a)) added.push(key)
44
+ else if (JSON.stringify(a[key]) !== JSON.stringify(b[key])) changed.push(key)
45
+ }
46
+ const removed = Object.keys(a).filter((key) => !(key in b))
47
+ return { added, changed, removed }
48
+ }
49
+
50
+ /** Apply one whitelisted op in place on `config`; throws on invalid input. */
51
+ function applyOp(config, op, value) {
52
+ config.acl = config.acl || {}
53
+ config.repair = config.repair || {}
54
+ if (op === 'acl.allow.append') {
55
+ const list = Array.isArray(value) ? value : [value]
56
+ if (list.length === 0 || list.length > 40) throw new Error('value must be 1..40 tool names')
57
+ config.acl.allow = Array.isArray(config.acl.allow) ? config.acl.allow : []
58
+ let appended = 0
59
+ for (const raw of list) {
60
+ const name = String(raw || '').trim()
61
+ if (!TOOL_NAME_RE.test(name)) throw new Error(`invalid tool name: ${name}`)
62
+ if (NEVER_APPEND.has(name)) throw new Error(`refusing to auto-widen ${name} (enable it by hand on that machine)`)
63
+ if (!config.acl.allow.includes(name)) {
64
+ config.acl.allow.push(name)
65
+ appended += 1
66
+ }
67
+ }
68
+ return { appended, total: config.acl.allow.length }
69
+ }
70
+ if (op === 'acl.fsRoots.append') {
71
+ const list = Array.isArray(value) ? value : [value]
72
+ if (list.length === 0 || list.length > MAX_FSROOTS_PER_CALL) throw new Error(`value must be 1..${MAX_FSROOTS_PER_CALL} paths`)
73
+ config.acl.fsRoots = Array.isArray(config.acl.fsRoots) ? config.acl.fsRoots : []
74
+ let appended = 0
75
+ for (const raw of list) {
76
+ const path = String(raw || '').trim()
77
+ if (!isAbsolutePath(path)) throw new Error(`fsRoot must be an absolute path: ${path}`)
78
+ if (path.length > 260) throw new Error('fsRoot too long')
79
+ if (!config.acl.fsRoots.includes(path)) {
80
+ config.acl.fsRoots.push(path)
81
+ appended += 1
82
+ }
83
+ }
84
+ return { appended, total: config.acl.fsRoots.length }
85
+ }
86
+ if (op === 'repair.set') {
87
+ const patch = value && typeof value === 'object' ? value : {}
88
+ const keys = Object.keys(patch)
89
+ if (keys.length === 0 || keys.some((k) => !['enabled', 'timeoutMs'].includes(k))) throw new Error('repair.set only accepts {enabled?, timeoutMs?}')
90
+ if ('enabled' in patch) {
91
+ if (typeof patch.enabled !== 'boolean') throw new Error('enabled must be boolean')
92
+ config.repair.enabled = patch.enabled
93
+ }
94
+ if ('timeoutMs' in patch) {
95
+ const ms = Number(patch.timeoutMs)
96
+ if (!Number.isFinite(ms) || ms < 5000 || ms > 600000) throw new Error('timeoutMs must be 5000..600000')
97
+ config.repair.timeoutMs = ms
98
+ }
99
+ return { enabled: config.repair.enabled, timeoutMs: config.repair.timeoutMs }
100
+ }
101
+ if (op === 'restartScript.set') {
102
+ const patch = value && typeof value === 'object' ? value : {}
103
+ const name = String(patch.name || '').trim()
104
+ const command = String(patch.command || '').trim()
105
+ if (!SCRIPT_NAME_RE.test(name)) throw new Error('name must match [A-Za-z0-9._-]{1,40}')
106
+ if (!command || command.length > 500) throw new Error('command must be 1..500 chars')
107
+ const policy = validateRestartCommand(command)
108
+ if (!policy.ok) throw new Error(policy.reason)
109
+ config.repair.restartScripts = config.repair.restartScripts || {}
110
+ config.repair.restartScripts[name] = command
111
+ return { name, scripts: Object.keys(config.repair.restartScripts) }
112
+ }
113
+ throw new Error(`unknown op: ${op}`)
114
+ }
115
+
116
+ export function buildConfigPatch(ctx) {
117
+ return [
118
+ {
119
+ name: 'mesh_config_patch',
120
+ title: 'Patch this node mesh config (whitelisted, incremental only)',
121
+ description:
122
+ 'Remote-editable subset of THIS machine\'s mesh config.json: append ACL entries (acl.allow / acl.fsRoots), toggle repair.enabled/timeoutMs, set one repair.restartScripts entry. Incremental only — never removes entries, never touches tokens/shell/ports. Backup + atomic write + JSON re-parse + diff returned. Default is dry-run: pass confirm:true to write. ACL must explicitly allow mesh_config_patch; every op except repair.set also requires repair.enabled=true.',
123
+ inputSchema: z.object({
124
+ op: opSchema.describe(
125
+ 'acl.allow.append (value: string|string[]) | acl.fsRoots.append (value: string|string[]) | repair.set (value: {enabled?,timeoutMs?}) | restartScript.set (value: {name,command})'
126
+ ),
127
+ value: z.any().describe('Op-specific value (see op description).'),
128
+ confirm: z.boolean().optional().describe('true = actually write; otherwise dry-run.'),
129
+ note: z.string().max(200).optional().describe('Free-text reason recorded in the result/audit.')
130
+ }),
131
+ handler: async ({ op, value, confirm, note }) => {
132
+ const config = ctx.getConfig()
133
+ const enabled = Boolean(config.repair?.enabled)
134
+ if (op !== 'repair.set' && !enabled) {
135
+ return fail('E_REPAIR_DISABLED', 'repair.enabled=false on this node: only repair.set can run remotely', { op })
136
+ }
137
+ let before
138
+ try {
139
+ const loaded = readConfigFile(ctx.home)
140
+ before = loaded.config
141
+ } catch (error) {
142
+ return fail('E_CONFIG_READ', `cannot read config.json: ${error?.message || error}`)
143
+ }
144
+ const after = JSON.parse(JSON.stringify(before))
145
+ let summary
146
+ try {
147
+ summary = applyOp(after, op, value)
148
+ } catch (error) {
149
+ return fail('E_INVALID_ARG', error?.message || String(error), { op })
150
+ }
151
+ const diff = diffSummary(before, after)
152
+ const base = { op, note: note || '', dryRun: confirm !== true, summary, diff }
153
+ if (confirm !== true) return ok({ ...base, applied: false, hint: 'pass confirm:true to write' })
154
+ if (diff.removed.length > 0) return fail('E_GUARD', 'patch would remove keys (refused)', { diff })
155
+
156
+ const file = configPath(ctx.home)
157
+ const backup = `${file}.bak-${new Date().toISOString().replace(/[-:T]/g, '').slice(0, 14)}`
158
+ try {
159
+ copyFileSync(file, backup)
160
+ const next = JSON.stringify(after, null, 2) + '\n'
161
+ writeFileSync(file, next, 'utf8')
162
+ const verify = JSON.parse(readFileSync(file, 'utf8'))
163
+ if (JSON.stringify(verify) !== JSON.stringify(after)) throw new Error('re-read mismatch')
164
+ } catch (error) {
165
+ try {
166
+ copyFileSync(backup, file)
167
+ } catch {
168
+ /* keep the original error */
169
+ }
170
+ return fail('E_CONFIG_WRITE', `patch failed and was rolled back: ${error?.message || error}`, { backup })
171
+ }
172
+ return ok({
173
+ ...base,
174
+ applied: true,
175
+ backup: basename(backup),
176
+ configPath: file,
177
+ hotReload: 'mesh server reads config on every call (mtime) — ACL/repair changes take effect immediately; new TOOLS need `node scripts/mesh-restart.mjs`'
178
+ })
179
+ }
180
+ }
181
+ ]
182
+ }