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,294 @@
1
+ /**
2
+ * Diagnose one paired peer end to end: can this machine reach it, does a DSH
3
+ * profile mount it, and do the running DSH instances actually expose its tools?
4
+ *
5
+ * Every check corresponds to a failure mode observed in the field:
6
+ * - missing certificate pin / token -> refused handshake or 401;
7
+ * - https peer without NODE_EXTRA_CA_CERTS in the DSH process -> silent
8
+ * "mcp-client(home): server is disconnected" and zero tools;
9
+ * - overlay mounted but zero tools -> the in-DSH client failed to start
10
+ * (DSH hot-reloads cordis.patch.yml, so a restart is the last resort, not
11
+ * the first fix);
12
+ * - ACL/upgrade drift -> tools exist but calls return E_DENIED on the peer.
13
+ *
14
+ * The command is read-only: it never starts, stops, or reconfigures anything.
15
+ */
16
+ import { existsSync, readdirSync, readFileSync, realpathSync } from 'node:fs'
17
+ import { homedir } from 'node:os'
18
+ import { dirname, join, resolve } from 'node:path'
19
+ import { loadConfig, peersPath } from './config.js'
20
+ import { loadPeers } from './pairing.js'
21
+ import { createHostClient, hostInstances } from './host-agent.js'
22
+ import { connectPeer } from './peer-client.js'
23
+
24
+ const quiet = { debug() {}, info() {}, warn() {}, error() {} }
25
+
26
+ /** DSH state directories worth scanning, most specific first. */
27
+ export function dshHomeCandidates(env = process.env) {
28
+ const homes = []
29
+ const push = (value) => {
30
+ if (value && !homes.includes(value)) homes.push(value)
31
+ }
32
+ push(env.DSH_HOME)
33
+ for (const suffix of ['.dsh0', '.dsh1', '.dsh2', '.dsh3', '.dsh']) push(join(homedir(), suffix))
34
+ return homes
35
+ }
36
+
37
+ /** Which transport the entry around `lineIndex` declares (search a small window). */
38
+ function transportFor(lines, lineIndex) {
39
+ for (let index = lineIndex; index >= 0 && index >= lineIndex - 25; index -= 1) {
40
+ const match = lines[index].match(/transport:\s*['"]?([A-Za-z-]+)/)
41
+ if (match) return match[1]
42
+ if (index < lineIndex && /^\s*-\s+insert:/.test(lines[index])) break
43
+ }
44
+ return 'unknown'
45
+ }
46
+
47
+ /** Resolve a path for comparison even when it does not exist yet (junction-aware). */
48
+ function canonicalPath(value) {
49
+ try {
50
+ return realpathSync(value)
51
+ } catch {
52
+ return resolve(value)
53
+ }
54
+ }
55
+
56
+ /**
57
+ * True when an overlay's patch file lives in exactly this DSH profile directory.
58
+ * An overlay found through another instance's home (or through a `~/.dsh`
59
+ * junction pointing at `~/.dsh0`) must never be credited to another instance.
60
+ */
61
+ export function overlayMatchesProfile(overlay, profileDir) {
62
+ if (!overlay?.patchPath || !profileDir) return false
63
+ const overlayDir = canonicalPath(dirname(overlay.patchPath))
64
+ const wanted = canonicalPath(profileDir)
65
+ return process.platform === 'win32' ? overlayDir.toLowerCase() === wanted.toLowerCase() : overlayDir === wanted
66
+ }
67
+
68
+ /**
69
+ * Find DSH profile overlays that mount this peer (matched by serverName).
70
+ * Pure filesystem read; returns one row per matching overlay.
71
+ */
72
+ export function findPeerOverlays(peer, { env = process.env, dshHomes = null } = {}) {
73
+ const names = new Set([peer?.name, peer?.id, `${peer?.name}-mesh`, `${peer?.id}-mesh`].filter(Boolean))
74
+ const matches = []
75
+ const seen = new Set()
76
+ for (const dshHome of dshHomes ?? dshHomeCandidates(env)) {
77
+ const profilesDir = join(dshHome, 'profiles')
78
+ if (!existsSync(profilesDir)) continue
79
+ let profiles
80
+ try {
81
+ profiles = readdirSync(profilesDir, { withFileTypes: true })
82
+ } catch {
83
+ continue
84
+ }
85
+ for (const profile of profiles) {
86
+ if (!profile.isDirectory()) continue
87
+ const patchPath = join(profilesDir, profile.name, 'cordis.patch.yml')
88
+ if (!existsSync(patchPath)) continue
89
+ let text
90
+ try {
91
+ text = readFileSync(patchPath, 'utf8')
92
+ } catch {
93
+ continue
94
+ }
95
+ if (!text.includes('dsh-mcp-client')) continue
96
+ const lines = text.split(/\r?\n/)
97
+ for (let index = 0; index < lines.length; index += 1) {
98
+ const match = lines[index].match(/^\s*serverName:\s*['"]?([^'"\s]+)['"]?\s*$/)
99
+ if (!match || !names.has(match[1])) continue
100
+ const key = `${canonicalPath(patchPath)}::${match[1]}`
101
+ if (seen.has(key)) continue
102
+ seen.add(key)
103
+ matches.push({
104
+ dshHome,
105
+ profile: profile.name,
106
+ patchPath,
107
+ serverName: match[1],
108
+ transport: transportFor(lines, index)
109
+ })
110
+ }
111
+ }
112
+ }
113
+ return matches
114
+ }
115
+
116
+ function hintForConnectFailure(code) {
117
+ if (code === 'E_PEER_PIN_MISMATCH') {
118
+ return 'the peer certificate changed or the stored pin is wrong: re-pair with `peer add` and the fingerprint printed by `pair --code`'
119
+ }
120
+ if (code === 'E_PEER_NO_PIN') {
121
+ return 'https peer without a fingerprint: `peer import --file <name>.yml --fingerprint <sha256>`'
122
+ }
123
+ if (code === 'E_PEER_AUTH') {
124
+ return 'token rejected: re-pair (`peer add`), or the peer revoked the token (`revoke` on that node)'
125
+ }
126
+ if (code === 'E_PEER_TIMEOUT') {
127
+ return 'no answer in time: check the peer is up (`node src/cli.js cluster status`), the frp tunnel, and that `serve` runs there'
128
+ }
129
+ return 'check the peer node: `node src/cli.js cluster status` and the peer’s own mesh serve log'
130
+ }
131
+
132
+ /**
133
+ * Run all checks and return {verdict, checks, nextSteps}. `verdict` is `fail`
134
+ * only when a hard requirement (peer record, pin, handshake) fails; mount and
135
+ * environment findings are `warn` unless they would make in-session tools
136
+ * silently absent without any explanation.
137
+ */
138
+ export async function runPeerDoctor({ home, target = null, timeoutMs = 8000, env = process.env, dshHomes = null } = {}) {
139
+ const checks = []
140
+ const add = (id, status, detail, fix) => checks.push({ id, status, detail, ...(fix ? { fix } : {}) })
141
+ const peers = loadPeers(home)
142
+ const items = peers.items ?? []
143
+ add(
144
+ 'peers-file',
145
+ items.length > 0 ? 'pass' : 'fail',
146
+ `${peersPath(home)}: ${items.length} peer record(s)`,
147
+ items.length > 0 ? undefined : 'node src/cli.js peer import --file <name>.yml'
148
+ )
149
+
150
+ const peer = target
151
+ ? items.find((item) => item.name === target || item.id === target)
152
+ : items.length === 1
153
+ ? items[0]
154
+ : null
155
+ if (!peer) {
156
+ add(
157
+ 'peer-selection',
158
+ 'fail',
159
+ target
160
+ ? `peer "${target}" is not in ${peersPath(home)}`
161
+ : `several peers are paired (${items.map((item) => item.name).join(', ')}); pass --to <name>`,
162
+ 'node src/cli.js peer list'
163
+ )
164
+ return { verdict: 'fail', checks, nextSteps: ['pick a peer with --to <name>'] }
165
+ }
166
+ add('peer-selection', 'pass', `peer "${peer.name}" -> ${peer.url}`)
167
+
168
+ const secure = /^https:/i.test(peer.url)
169
+ if (secure) {
170
+ add(
171
+ 'certificate-pin',
172
+ peer.certFingerprint ? 'pass' : 'fail',
173
+ peer.certFingerprint
174
+ ? `pinned ${String(peer.certFingerprint).slice(0, 23)}…`
175
+ : 'https peer has no stored fingerprint; the pinned fetch refuses to connect',
176
+ peer.certFingerprint
177
+ ? undefined
178
+ : 'node src/cli.js peer import --file <name>.yml --fingerprint <sha256 from `pair --code`>'
179
+ )
180
+ } else {
181
+ add('certificate-pin', 'pass', 'plain http peer — no TLS trust needed (not recommended across machines)')
182
+ }
183
+
184
+ const overlays = findPeerOverlays(peer, { env, dshHomes })
185
+ const httpOverlay = overlays.find((entry) => entry.transport !== 'stdio')
186
+ const overlayDetail = overlays.map((entry) => `${entry.dshHome}/profiles/${entry.profile} (${entry.transport})`).join('; ')
187
+ const caEnv = env.NODE_EXTRA_CA_CERTS || ''
188
+ if (secure && httpOverlay && !caEnv) {
189
+ add(
190
+ 'dsh-ca-env',
191
+ 'warn',
192
+ 'NODE_EXTRA_CA_CERTS unset in this process and an overlay uses streamable-http: the in-DSH client cannot verify the self-signed peer and stays disconnected',
193
+ `switch it to the stdio bridge: node src/cli.js peer patch --transport stdio --to ${peer.name}`
194
+ )
195
+ } else if (secure) {
196
+ add(
197
+ 'dsh-ca-env',
198
+ 'pass',
199
+ caEnv ? `NODE_EXTRA_CA_CERTS=${caEnv}` : 'not needed: the stdio bridge pins the peer certificate itself'
200
+ )
201
+ }
202
+
203
+ if (overlays.length === 0) {
204
+ add(
205
+ 'dsh-overlay',
206
+ 'warn',
207
+ 'no DSH profile overlay mounts this peer, so only CLI calls work',
208
+ `node src/cli.js peer patch --transport stdio --to ${peer.name}`
209
+ )
210
+ } else {
211
+ add('dsh-overlay', 'pass', overlayDetail)
212
+ }
213
+
214
+ // Did the RUNNING instances actually register the peer tools? This is the only
215
+ // check that distinguishes "overlay is valid" from "overlay is live".
216
+ try {
217
+ const config = loadConfig(home)
218
+ const instances = hostInstances(config)
219
+ if (instances.length === 0) {
220
+ add('dsh-live-tools', 'warn', 'no host bridge configured, so loaded tools cannot be verified', 'node src/cli.js host install --profile <DSH profile>')
221
+ }
222
+ for (const entry of instances) {
223
+ const client = createHostClient({ config, home, log: quiet, instance: entry.id })
224
+ const alive = await client.ping().catch(() => false)
225
+ if (!alive) {
226
+ add(`dsh-${entry.id}`, 'warn', 'host bridge not reachable — its loaded tools cannot be verified')
227
+ continue
228
+ }
229
+ const { tools } = await client.tools().catch(() => ({ tools: [] }))
230
+ const names = (tools ?? []).map((tool) => tool.name)
231
+ const mounted = names.filter((name) => name.startsWith(`mcp__${peer.name}__`) || name.startsWith(`mcp__${peer.name}-mesh__`))
232
+ const ownOverlays = entry.profileDir
233
+ ? overlays.filter((overlay) => overlayMatchesProfile(overlay, entry.profileDir))
234
+ : overlays
235
+ if (mounted.length > 0) {
236
+ add(`dsh-${entry.id}`, 'pass', `${mounted.length} peer tool(s) registered (e.g. ${mounted[0]})`)
237
+ } else if (ownOverlays.length > 0) {
238
+ add(
239
+ `dsh-${entry.id}`,
240
+ 'warn',
241
+ `overlay is mounted in ${entry.profileDir} but this running instance exposes 0 "${peer.name}" tools (${names.length} total)`,
242
+ 'DSH hot-reloads cordis.patch.yml (HMR) within seconds; if the tools stay missing, check the mcp-client startup error in the DSH log — restart only as a last resort'
243
+ )
244
+ } else {
245
+ add(
246
+ `dsh-${entry.id}`,
247
+ 'warn',
248
+ `no overlay in this instance's profile (${entry.profileDir || 'unknown'}), so it can never expose peer tools (${names.length} tools total)`,
249
+ entry.profileDir
250
+ ? `add the peer insert block to ${join(entry.profileDir, 'cordis.patch.yml')} (HMR picks it up, no restart)`
251
+ : undefined
252
+ )
253
+ }
254
+ }
255
+ } catch (error) {
256
+ add('dsh-live-tools', 'warn', `could not inspect running instances: ${error?.message || error}`)
257
+ }
258
+
259
+ let connection
260
+ const started = Date.now()
261
+ try {
262
+ connection = await connectPeer(peer, { timeoutMs })
263
+ const listed = await connection.client.listTools()
264
+ add('mcp-handshake', 'pass', `initialized and listed ${(listed.tools ?? []).length} tools in ${Date.now() - started} ms`)
265
+ } catch (error) {
266
+ const code = typeof error?.code === 'string' ? error.code : null
267
+ add('mcp-handshake', 'fail', `${code ? `[${code}] ` : ''}${error?.message || error}`, hintForConnectFailure(code))
268
+ } finally {
269
+ if (connection) await connection.close()
270
+ }
271
+
272
+ const verdict = checks.some((check) => check.status === 'fail')
273
+ ? 'fail'
274
+ : checks.some((check) => check.status === 'warn')
275
+ ? 'warn'
276
+ : 'ok'
277
+ const nextSteps = []
278
+ const caMissing = secure && !caEnv
279
+ if (httpOverlay && caMissing) {
280
+ nextSteps.push(`switch the overlay to the stdio bridge: node src/cli.js peer patch --transport stdio --to ${peer.name}`)
281
+ }
282
+ if (overlays.length === 0) {
283
+ nextSteps.push(`mount the peer in DSH: node src/cli.js peer patch --transport stdio --to ${peer.name}`)
284
+ }
285
+ const staleLive = checks.filter(
286
+ (check) => check.id.startsWith('dsh-') && check.status === 'warn' && /overlay is mounted in/.test(check.detail)
287
+ )
288
+ if (staleLive.length > 0) {
289
+ nextSteps.push(
290
+ 'DSH hot-reloads cordis.patch.yml (HMR): re-run this doctor after ~10 s; restart the instance only if the tools stay missing (read its mcp-client startup error first)'
291
+ )
292
+ }
293
+ return { verdict, peer: { name: peer.name, url: peer.url, secure }, checks, nextSteps }
294
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Minimal HTTP(S) JSON POST used for peer pairing.
3
+ *
4
+ * 两个安全选项:
5
+ * - `caPath`:信任一张自签证书(标准信任链校验)。
6
+ * - `fingerprint`:TOFU 固定指纹 —— 先不判信任链(自签必然不过),抓 socket 上的
7
+ * 对端证书算 SHA-256,再和人工核对过的指纹比对;不一致直接 `E_PIN_MISMATCH`。
8
+ *
9
+ * 抽成独立文件是为了让 e2e 能直接测(cli.js 有 main 副作用,不适合被 import)。
10
+ */
11
+ import { fingerprintOfSocket, assertFingerprint } from './tls.js'
12
+
13
+ export async function postJson(urlString, body, { caPath, fingerprint } = {}) {
14
+ const isHttps = String(urlString).startsWith('https:')
15
+ const { request } = await import(isHttps ? 'node:https' : 'node:http')
16
+ const { readFileSync } = await import('node:fs')
17
+ const options = { method: 'POST', headers: { 'content-type': 'application/json' } }
18
+ if (isHttps && caPath) options.ca = readFileSync(caPath)
19
+ // 固定指纹时先不判信任链(自签必然不过),拿到 socket 证书后自己比对指纹。
20
+ // 这是 TOFU 的标准做法:信任来自「配对码那条可信信道」里带出来的指纹,而不是 CA。
21
+ if (isHttps && fingerprint) {
22
+ options.rejectUnauthorized = false
23
+ // 关键:不能复用 keep-alive 连接 —— 复用时会话已建立,secureConnect 不会再触发,
24
+ // 指纹校验就会被跳过。每次固定指纹请求都开新连接。
25
+ options.agent = false
26
+ }
27
+ return await new Promise((resolvePromise, rejectPromise) => {
28
+ const req = request(urlString, options, (res) => {
29
+ let text = ''
30
+ res.setEncoding('utf8')
31
+ res.on('data', (chunk) => {
32
+ text += chunk
33
+ })
34
+ res.on('end', () => {
35
+ let parsed
36
+ try {
37
+ parsed = JSON.parse(text)
38
+ } catch {
39
+ parsed = { raw: text }
40
+ }
41
+ resolvePromise({ status: res.statusCode, body: parsed, fingerprint: req.__meshFingerprint })
42
+ })
43
+ })
44
+ if (isHttps && fingerprint) {
45
+ req.on('socket', (socket) => {
46
+ socket.once('secureConnect', () => {
47
+ try {
48
+ const got = fingerprintOfSocket(socket)
49
+ assertFingerprint(fingerprint, got)
50
+ req.__meshFingerprint = got
51
+ } catch (error) {
52
+ req.destroy(error)
53
+ }
54
+ })
55
+ })
56
+ }
57
+ req.setTimeout(20000, () => req.destroy(new Error('timeout')))
58
+ req.on('error', rejectPromise)
59
+ req.end(JSON.stringify(body ?? {}))
60
+ })
61
+ }
@@ -0,0 +1,93 @@
1
+ import { isAbsolute, join, resolve, sep } from 'node:path'
2
+
3
+ /**
4
+ * Pure helpers for the repair toolkit (M7 step 3). Keeping the path/validation
5
+ * logic here means the MCP tool, the CLI and the selftest all agree on what a
6
+ * legal plugin payload is — writing into a DSH profile is the one operation that
7
+ * can brick an instance, so it gets validated in exactly one place.
8
+ */
9
+
10
+ export const PLUGIN_NAME_RE = /^(@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]{0,63}$/i
11
+
12
+ export function repairError(code, message, extra = {}) {
13
+ const error = new Error(message)
14
+ error.code = code
15
+ Object.assign(error, extra)
16
+ return error
17
+ }
18
+
19
+ /** Directory a plugin package occupies inside a profile's node_modules. */
20
+ export function pluginRoot(profileDir, name) {
21
+ return join(profileDir, 'node_modules', ...String(name).split('/'))
22
+ }
23
+
24
+ /**
25
+ * Validate and normalise a plugin file payload.
26
+ * `path` is always a forward-slash relative path inside the package directory.
27
+ */
28
+ export function normalizePluginFiles(files, { maxFiles = 20, maxFileBytes = 262144 } = {}) {
29
+ if (!Array.isArray(files) || files.length === 0) {
30
+ throw repairError('E_INVALID_ARG', 'files must be a non-empty array of {path, content}')
31
+ }
32
+ if (files.length > maxFiles) {
33
+ throw repairError('E_INVALID_ARG', `too many files: ${files.length} > ${maxFiles}`)
34
+ }
35
+ const seen = new Set()
36
+ return files.map((file, index) => {
37
+ const raw = typeof file?.path === 'string' ? file.path : ''
38
+ const rel = raw.replace(/\\/g, '/').replace(/^\.\//, '')
39
+ if (!rel) throw repairError('E_INVALID_ARG', `files[${index}].path is required`)
40
+ if (isAbsolute(rel) || /^[a-z]:/i.test(rel)) {
41
+ throw repairError('E_INVALID_ARG', `files[${index}].path must be relative: ${raw}`)
42
+ }
43
+ const parts = rel.split('/').filter((part) => part.length > 0)
44
+ if (parts.length === 0 || parts.some((part) => part === '.' || part === '..')) {
45
+ throw repairError('E_INVALID_ARG', `files[${index}].path escapes the package root: ${raw}`)
46
+ }
47
+ const content = typeof file?.content === 'string' ? file.content : null
48
+ if (content === null) throw repairError('E_INVALID_ARG', `files[${index}].content must be a string`)
49
+ const bytes = Buffer.byteLength(content, 'utf8')
50
+ if (bytes > maxFileBytes) {
51
+ throw repairError('E_INVALID_ARG', `files[${index}] is ${bytes} bytes > limit ${maxFileBytes}`)
52
+ }
53
+ const key = parts.join('/').toLowerCase()
54
+ if (seen.has(key)) throw repairError('E_INVALID_ARG', `duplicate file path: ${rel}`)
55
+ seen.add(key)
56
+ return { rel: parts.join('/'), abs: parts.join(sep), content, bytes }
57
+ })
58
+ }
59
+
60
+ /** True when the profile patch already wires a plugin by name or id. */
61
+ export function patchHasPlugin(patchText, name) {
62
+ const text = String(patchText || '')
63
+ if (!text) return false
64
+ const escaped = String(name).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
65
+ return new RegExp(`(^|[\\s-])(name|id):\\s*['"]?${escaped}['"]?\\s*$`, 'm').test(text)
66
+ }
67
+
68
+ export function buildPatchBlock(name) {
69
+ return `- insert:\n - id: ${name}\n name: ${name}\n`
70
+ }
71
+
72
+ /**
73
+ * Append a `- insert:` block unless the plugin is already wired.
74
+ * A non-empty, non-`[]` patch is preserved and flagged for backup by the caller.
75
+ */
76
+ export function applyPatchInsert(patchText, name) {
77
+ const current = String(patchText ?? '')
78
+ if (patchHasPlugin(current, name)) return { text: current, changed: false, backup: false }
79
+ const trimmed = current.trim()
80
+ const needsBackup = Boolean(trimmed && trimmed !== '[]')
81
+ const header = needsBackup ? `${current.replace(/\s+$/, '')}\n` : ''
82
+ return { text: header + buildPatchBlock(name), changed: true, backup: needsBackup }
83
+ }
84
+
85
+ /** Resolve `candidate` and assert it stays inside `root` (profile confinement). */
86
+ export function assertInside(root, candidate) {
87
+ const base = resolve(root)
88
+ const target = resolve(candidate)
89
+ if (target !== base && !target.startsWith(base + sep)) {
90
+ throw repairError('E_INVALID_ARG', `path escapes the allowed root: ${target}`)
91
+ }
92
+ return target
93
+ }
@@ -0,0 +1,21 @@
1
+ // @generated 2026-10-27 repair 重启命令的**运行期**白名单(不只是编辑期校验)
2
+ // [why] 2026-10-27 P2 安全收紧:`repair.restartScripts` 是「配置即白名单」,但配置可以被手改/被别的工具写。
3
+ // 如果只在写配置时校验,手改 config 仍能塞任意命令。所以 service_restart **执行前**也过同一套规则:
4
+ // 只允许调用 mesh 自己的重启脚本 / 看护脚本 / autostart 的 start-dshN.vbs|cmd,且禁止 shell 元字符。
5
+ // 归宿:reusable(dsh-mesh/src/core/)
6
+ export const RESTART_TARGET_RE = /\b(restart-dsh-instance\.mjs|instance-watchdog\.mjs|start-dsh\d*\.(vbs|cmd))\b/
7
+ export const MAX_RESTART_COMMAND_LEN = 600
8
+ /** Shell metacharacters that would let a "restart" command become arbitrary code. */
9
+ const FORBIDDEN_META_RE = /[|&;<>`$(){}]/
10
+
11
+ export function validateRestartCommand(command) {
12
+ const text = String(command || '').trim()
13
+ if (!text) return { ok: false, reason: 'command is empty' }
14
+ if (text.length > MAX_RESTART_COMMAND_LEN) return { ok: false, reason: `command longer than ${MAX_RESTART_COMMAND_LEN} chars` }
15
+ const meta = FORBIDDEN_META_RE.exec(text)
16
+ if (meta) return { ok: false, reason: `command contains shell metacharacter "${meta[0]}" — only plain quoted paths/args are allowed` }
17
+ if (!RESTART_TARGET_RE.test(text)) {
18
+ return { ok: false, reason: 'command must reference restart-dsh-instance.mjs / instance-watchdog.mjs / start-dsh<N>.vbs|cmd' }
19
+ }
20
+ return { ok: true }
21
+ }
@@ -0,0 +1,29 @@
1
+ /** Uniform tool result helpers: text content only (M1 keeps no outputSchema, so no structuredContent). */
2
+ export function ok(data) {
3
+ return { content: [{ type: 'text', text: typeof data === 'string' ? data : JSON.stringify(data, null, 2) }] }
4
+ }
5
+
6
+ export function fail(code, message, extra = {}) {
7
+ return {
8
+ isError: true,
9
+ content: [
10
+ {
11
+ type: 'text',
12
+ text: JSON.stringify({ code, message, retryable: false, ...extra }, null, 2)
13
+ }
14
+ ]
15
+ }
16
+ }
17
+
18
+ export function denied(message, extra) {
19
+ return fail('E_DENIED', message, extra)
20
+ }
21
+ export function invalidArg(message, extra) {
22
+ return fail('E_INVALID_ARG', message, extra)
23
+ }
24
+ export function notFound(message, extra) {
25
+ return fail('E_NOT_FOUND', message, extra)
26
+ }
27
+ export function internal(message, extra) {
28
+ return fail('E_INTERNAL', message, { retryable: true, ...extra })
29
+ }
@@ -0,0 +1,146 @@
1
+ import { existsSync, readFileSync, readdirSync } from 'node:fs'
2
+ import { dirname, join } from 'node:path'
3
+
4
+ /**
5
+ * Delegation safety profile (2026-10-25).
6
+ *
7
+ * A peer must never inject a task into a session that owns a scheduled job:
8
+ * that mixes two contexts and pollutes the scheduled conversation (real
9
+ * incident: a `channel_wake` landed inside a company `定时-*` session).
10
+ *
11
+ * The profile joins two local sources of truth:
12
+ * - `<dshHome>/storages/schedule.json` schedules -> sessionId
13
+ * - `<fsRoot>/.mesh/tasks/<taskId>/request.json` delegations -> sessionId
14
+ *
15
+ * A session is *protected* when it owns at least one schedule record.
16
+ * Delegations are the sessions this node created for a peer, so they are
17
+ * identified explicitly instead of being guessed from titles.
18
+ */
19
+
20
+ const TITLE_SCHEDULE_HINT = /定时|班次|schedule|nightly/i
21
+ const EMPTY = Object.freeze([])
22
+
23
+ function readJson(file, fallback = null) {
24
+ try {
25
+ return JSON.parse(readFileSync(file, 'utf8'))
26
+ } catch {
27
+ return fallback
28
+ }
29
+ }
30
+
31
+ /** `<home>/profiles/web` -> `<home>`; empty/unknown profileDir -> ''. */
32
+ export function dshHomeOf(hostEntry) {
33
+ const dir = String(hostEntry?.profileDir || '')
34
+ if (!dir) return ''
35
+ return dirname(dirname(dir))
36
+ }
37
+
38
+ /** sessionId -> [{id,title,kind,status,instance}] for one configured instance. */
39
+ export function readScheduleIndex(hostEntry) {
40
+ const home = dshHomeOf(hostEntry)
41
+ const file = home ? join(home, 'storages', 'schedule.json') : ''
42
+ const index = new Map()
43
+ const raw = file && existsSync(file) ? readJson(file) : null
44
+ const tasks = raw?.tables?.tasks || {}
45
+ for (const [id, entry] of Object.entries(tasks)) {
46
+ const sessionId = entry?.sessionId
47
+ if (!sessionId) continue
48
+ const record = entry.record || {}
49
+ const rows = index.get(sessionId) || []
50
+ rows.push({
51
+ id,
52
+ title: record.title || '',
53
+ kind: record.kind || '',
54
+ status: entry.status || '',
55
+ instance: hostEntry?.id || ''
56
+ })
57
+ index.set(sessionId, rows)
58
+ }
59
+ return index
60
+ }
61
+
62
+ /** sessionId -> delegation record from this node's `.mesh/tasks` mailbox. */
63
+ export function readDelegationIndex(root) {
64
+ const dir = root ? join(root, '.mesh', 'tasks') : ''
65
+ const index = new Map()
66
+ if (!dir || !existsSync(dir)) return index
67
+ let names = []
68
+ try {
69
+ names = readdirSync(dir)
70
+ } catch {
71
+ return index
72
+ }
73
+ for (const name of names) {
74
+ const request = readJson(join(dir, name, 'request.json'))
75
+ if (!request?.sessionId) continue
76
+ index.set(request.sessionId, {
77
+ taskId: request.taskId || name,
78
+ conversationId: request.conversationId || '',
79
+ from: request.from || '',
80
+ policy: request.sessionPolicy || '',
81
+ createdAt: request.requestedAt || ''
82
+ })
83
+ }
84
+ return index
85
+ }
86
+
87
+ /** Join every configured instance's schedules plus this node's delegations. */
88
+ export function buildSessionProfile(config) {
89
+ const schedules = new Map()
90
+ for (const entry of config?.hosts || []) {
91
+ if (entry?.enabled === false) continue
92
+ for (const [sessionId, rows] of readScheduleIndex(entry)) {
93
+ const merged = schedules.get(sessionId) || []
94
+ merged.push(...rows.map((row) => ({ ...row, instance: entry.id })))
95
+ schedules.set(sessionId, merged)
96
+ }
97
+ }
98
+ return { schedules, delegations: readDelegationIndex(config?.acl?.fsRoots?.[0] || '') }
99
+ }
100
+
101
+ /** Classify one host_sessions / delegate_candidates / listAgents row. */
102
+ export function classifySession(profile, session) {
103
+ const sessionId = session?.sessionId || session?.id || ''
104
+ const schedules = profile?.schedules?.get(sessionId) || EMPTY
105
+ const delegation = profile?.delegations?.get(sessionId) || null
106
+ const title = String(session?.title || '')
107
+ const alive = session?.agentAvailable === true && session?.state !== 'ended'
108
+ let kind = 'unknown'
109
+ let reason = 'no schedule record and no delegation record'
110
+ if (schedules.length > 0) {
111
+ kind = 'business-scheduled'
112
+ reason = `owns ${schedules.length} scheduled task(s) — injecting here would pollute the scheduled conversation`
113
+ } else if (delegation) {
114
+ kind = 'delegation'
115
+ reason = `created by delegation ${delegation.taskId}`
116
+ } else if (TITLE_SCHEDULE_HINT.test(title)) {
117
+ kind = 'business'
118
+ reason = 'title looks like a scheduled/business session (no schedule record found — still inject with care)'
119
+ }
120
+ return {
121
+ kind,
122
+ protected: schedules.length > 0,
123
+ delegatable: alive && schedules.length === 0,
124
+ recommended: alive && String(session?.state) === 'idle' && schedules.length === 0,
125
+ reason,
126
+ schedules,
127
+ delegation
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Guard for write-capable tools (host_session_prompt / channel_wake /
133
+ * host_call / delegate continuation). Returns null when the target is safe.
134
+ */
135
+ export function sessionBlock(profile, sessionId) {
136
+ const schedules = profile?.schedules?.get(String(sessionId || '')) || EMPTY
137
+ if (schedules.length === 0) return null
138
+ return {
139
+ kind: 'business-scheduled',
140
+ reason: `session ${sessionId} owns ${schedules.length} scheduled task(s): ${schedules
141
+ .map((row) => row.title || row.id)
142
+ .join(' / ')}`,
143
+ schedules,
144
+ hint: 'delegate to a FRESH conversation instead (omit sessionId), or pass force:true if you really mean to inject into this scheduled session'
145
+ }
146
+ }