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.
- package/LICENSE +21 -0
- package/README.md +8 -2
- package/package.json +43 -4
- package/src/cli.js +1246 -0
- package/src/core/acl.js +81 -0
- package/src/core/audit.js +75 -0
- package/src/core/autostart.js +119 -0
- package/src/core/cluster.js +340 -0
- package/src/core/config.js +622 -0
- package/src/core/desktop-proxy.js +85 -0
- package/src/core/ds-web.js +394 -0
- package/src/core/host-agent.js +263 -0
- package/src/core/jobs.js +259 -0
- package/src/core/pairing.js +141 -0
- package/src/core/peer-client.js +273 -0
- package/src/core/peer-doctor.js +294 -0
- package/src/core/peer-http.js +61 -0
- package/src/core/repair.js +93 -0
- package/src/core/restart-policy.js +21 -0
- package/src/core/result.js +29 -0
- package/src/core/session-profile.js +146 -0
- package/src/core/tls.js +160 -0
- package/src/core/util.js +68 -0
- package/src/e2e-autostart.js +58 -0
- package/src/e2e-channel.js +384 -0
- package/src/e2e-cluster.js +417 -0
- package/src/e2e-host.js +411 -0
- package/src/e2e-onboard.js +232 -0
- package/src/e2e-pair.js +317 -0
- package/src/e2e-plugin.js +395 -0
- package/src/e2e-repair.js +297 -0
- package/src/index.js +20 -0
- package/src/peer-bridge.js +124 -0
- package/src/selftest.js +872 -0
- package/src/server.js +313 -0
- package/src/tools/alerts.js +80 -0
- package/src/tools/channel.js +253 -0
- package/src/tools/cluster.js +235 -0
- package/src/tools/config-patch.js +182 -0
- package/src/tools/control-apps.js +469 -0
- package/src/tools/control-path.js +160 -0
- package/src/tools/control.js +232 -0
- package/src/tools/core.js +79 -0
- package/src/tools/delegate.js +653 -0
- package/src/tools/fs.js +436 -0
- package/src/tools/git.js +266 -0
- package/src/tools/host.js +305 -0
- package/src/tools/index.js +122 -0
- package/src/tools/repair-fs.js +130 -0
- package/src/tools/repair.js +317 -0
- package/src/tools/session-history.js +60 -0
- package/src/tools/shell.js +66 -0
- package/src/tools/tasks.js +117 -0
package/src/selftest.js
ADDED
|
@@ -0,0 +1,872 @@
|
|
|
1
|
+
import { mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { get } from 'node:https'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { Client } from '@modelcontextprotocol/client'
|
|
6
|
+
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/client'
|
|
7
|
+
import { loadSecrets, defaultConfig, configPath, loadConfig, DEFAULT_ALLOW, aclDrift } from './core/config.js'
|
|
8
|
+
import { describeAcl, resolveAclUpdate } from './core/acl.js'
|
|
9
|
+
import { applyPatchInsert, assertInside, normalizePluginFiles, patchHasPlugin, pluginRoot } from './core/repair.js'
|
|
10
|
+
import { hostTokenPath, resolveHostInstance } from './core/host-agent.js'
|
|
11
|
+
import { buildAll, isAllowed } from './tools/index.js'
|
|
12
|
+
import { run } from './core/util.js'
|
|
13
|
+
import { startMeshServer } from './server.js'
|
|
14
|
+
|
|
15
|
+
const results = []
|
|
16
|
+
function check(name, fn) {
|
|
17
|
+
return (async () => {
|
|
18
|
+
try {
|
|
19
|
+
await fn()
|
|
20
|
+
results.push({ name, ok: true })
|
|
21
|
+
console.log(`PASS ${name}`)
|
|
22
|
+
} catch (error) {
|
|
23
|
+
results.push({ name, ok: false, error: error?.message || String(error) })
|
|
24
|
+
console.log(`FAIL ${name} — ${error?.message || error}`)
|
|
25
|
+
}
|
|
26
|
+
})()
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function assert(cond, message) {
|
|
30
|
+
if (!cond) throw new Error(message)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Silent logger for servers started during the suite. */
|
|
34
|
+
const quiet = { debug() {}, info() {}, warn() {}, error() {} }
|
|
35
|
+
|
|
36
|
+
/** JSON GET over https, with an optional trust anchor. */
|
|
37
|
+
function httpsGetJson(port, path, { ca } = {}) {
|
|
38
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
39
|
+
const req = get({ host: '127.0.0.1', port, path, ca, timeout: 3000 }, (res) => {
|
|
40
|
+
let text = ''
|
|
41
|
+
res.setEncoding('utf8')
|
|
42
|
+
res.on('data', (chunk) => { text += chunk })
|
|
43
|
+
res.on('end', () => {
|
|
44
|
+
try {
|
|
45
|
+
resolvePromise(JSON.parse(text))
|
|
46
|
+
} catch {
|
|
47
|
+
rejectPromise(new Error(`non-JSON response (${res.statusCode}): ${text.slice(0, 80)}`))
|
|
48
|
+
}
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
req.on('timeout', () => req.destroy(new Error('timeout')))
|
|
52
|
+
req.on('error', rejectPromise)
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function parseToolResult(result) {
|
|
57
|
+
const text = result?.content?.find?.((c) => c.type === 'text')?.text
|
|
58
|
+
assert(typeof text === 'string', 'tool result has no text content')
|
|
59
|
+
return JSON.parse(text)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function connect(url, token, name) {
|
|
63
|
+
const transport = new StreamableHTTPClientTransport(new URL(url), {
|
|
64
|
+
requestInit: { headers: { Authorization: `Bearer ${token}` } }
|
|
65
|
+
})
|
|
66
|
+
const client = new Client({ name, version: '0.0.1' })
|
|
67
|
+
await client.connect(transport)
|
|
68
|
+
return { client, transport }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function writeConfigFile(home, config) {
|
|
72
|
+
const p = configPath(home)
|
|
73
|
+
writeFileSync(p, JSON.stringify(config, null, 2) + '\n', 'utf8')
|
|
74
|
+
// Force a distinct mtime so the hot-reload watcher definitely notices the change.
|
|
75
|
+
const stamp = new Date(Date.now() + 5000)
|
|
76
|
+
utimesSync(p, stamp, stamp)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function runSelftest({ log = console } = {}) {
|
|
80
|
+
const repoRoot = process.cwd()
|
|
81
|
+
const home = mkdtempSync(join(tmpdir(), 'dsh-mesh-selftest-'))
|
|
82
|
+
mkdirSync(join(home, 'greppable'), { recursive: true })
|
|
83
|
+
writeFileSync(join(home, 'greppable', 'a.txt'), 'alpha\nTODO: fix me\ngamma\n')
|
|
84
|
+
writeFileSync(join(home, 'greppable', 'b.txt'), 'nothing here\n')
|
|
85
|
+
const gitRepo = join(home, 'tmp-repo')
|
|
86
|
+
mkdirSync(gitRepo, { recursive: true })
|
|
87
|
+
writeFileSync(join(gitRepo, 'file.txt'), 'v1\n')
|
|
88
|
+
await run('git', ['init', '-q', '-b', 'main', gitRepo])
|
|
89
|
+
await run('git', ['-C', gitRepo, 'add', '.'])
|
|
90
|
+
await run('git', ['-C', gitRepo, '-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-qm', 'first commit'])
|
|
91
|
+
writeFileSync(join(gitRepo, 'file.txt'), 'v2 changed\n')
|
|
92
|
+
writeFileSync(join(gitRepo, 'new.txt'), 'untracked\n')
|
|
93
|
+
|
|
94
|
+
const config = defaultConfig()
|
|
95
|
+
config.node = { ...config.node, name: 'selftest', bind: '127.0.0.1', port: 0, path: '/mcp' }
|
|
96
|
+
config.acl.fsRoots = [repoRoot, home]
|
|
97
|
+
writeConfigFile(home, config)
|
|
98
|
+
|
|
99
|
+
const secrets = loadSecrets(home)
|
|
100
|
+
const token = secrets.inbound[0].token
|
|
101
|
+
// Earlier checks rotate the inbound token, so peer checks must re-read it instead of reusing `token`.
|
|
102
|
+
const liveToken = () => loadSecrets(home).inbound[0].token
|
|
103
|
+
const srv = await startMeshServer({ home, log: quiet })
|
|
104
|
+
const health = await fetch(`http://127.0.0.1:${srv.port}/health`).then((r) => r.json())
|
|
105
|
+
|
|
106
|
+
let client
|
|
107
|
+
try {
|
|
108
|
+
await check('health endpoint answers without auth', () => {
|
|
109
|
+
assert(health?.ok === true, 'health not ok')
|
|
110
|
+
assert(health?.service === 'dsh-mesh', 'wrong service name')
|
|
111
|
+
assert(health?.tools >= 14, `expected >=14 tools, got ${health?.tools}`)
|
|
112
|
+
assert(health?.node === 'selftest', `unexpected node ${health?.node}`)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
await check('unauthenticated /mcp is rejected with 401', async () => {
|
|
116
|
+
const res = await fetch(`http://127.0.0.1:${srv.port}/mcp`, {
|
|
117
|
+
method: 'POST',
|
|
118
|
+
headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' },
|
|
119
|
+
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} })
|
|
120
|
+
})
|
|
121
|
+
assert(res.status === 401, `expected 401, got ${res.status}`)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
await check('wrong bearer token cannot connect (bad sample)', async () => {
|
|
125
|
+
let failed = false
|
|
126
|
+
try {
|
|
127
|
+
const bad = await connect(srv.url, 'wrong-token', 'dsh-mesh-wrong-token')
|
|
128
|
+
await bad.client.listTools()
|
|
129
|
+
} catch {
|
|
130
|
+
failed = true
|
|
131
|
+
}
|
|
132
|
+
assert(failed, 'wrong token was accepted')
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
const conn = await connect(srv.url, token, 'dsh-mesh-selftest')
|
|
136
|
+
client = conn.client
|
|
137
|
+
|
|
138
|
+
await check('tools/list exposes the M2 tool set', async () => {
|
|
139
|
+
const list = await client.listTools()
|
|
140
|
+
const names = list.tools.map((t) => t.name).sort()
|
|
141
|
+
for (const expected of [
|
|
142
|
+
'mesh_self',
|
|
143
|
+
'fs_read',
|
|
144
|
+
'fs_list',
|
|
145
|
+
'fs_stat',
|
|
146
|
+
'fs_grep',
|
|
147
|
+
'fs_stats',
|
|
148
|
+
'git_status',
|
|
149
|
+
'git_log',
|
|
150
|
+
'git_diff',
|
|
151
|
+
'repo_scan',
|
|
152
|
+
'shell_run',
|
|
153
|
+
'task_submit',
|
|
154
|
+
'task_status',
|
|
155
|
+
'task_list',
|
|
156
|
+
'task_cancel',
|
|
157
|
+
'mesh_audit_tail'
|
|
158
|
+
]) {
|
|
159
|
+
assert(names.includes(expected), `missing ${expected}`)
|
|
160
|
+
}
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
await check('mesh_self returns host facts and tool ACL flags', async () => {
|
|
164
|
+
const data = parseToolResult(await client.callTool({ name: 'mesh_self', arguments: {} }))
|
|
165
|
+
assert(data.mesh?.name === 'selftest', `unexpected node name ${data.mesh?.name}`)
|
|
166
|
+
assert(typeof data.host?.hostname === 'string' && data.host.hostname.length > 0, 'missing hostname')
|
|
167
|
+
assert(Number.isInteger(data.host?.ppid) && data.host.ppid > 0, 'missing parent pid')
|
|
168
|
+
assert(Number.isInteger(data.host?.processUptimeSec) && data.host.processUptimeSec >= 0, 'missing process uptime')
|
|
169
|
+
assert(Array.isArray(data.tools) && data.tools.length >= 14, 'unexpected tool list')
|
|
170
|
+
const shell = data.tools.find((t) => t.name === 'shell_run')
|
|
171
|
+
assert(shell && shell.allowed === false, 'shell_run should be listed but denied')
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
await check('fs_read reads a file inside fsRoots', async () => {
|
|
175
|
+
const data = parseToolResult(
|
|
176
|
+
await client.callTool({ name: 'fs_read', arguments: { path: join(repoRoot, 'README.md'), maxBytes: 65536 } })
|
|
177
|
+
)
|
|
178
|
+
assert(data.path.endsWith('README.md'), 'wrong path')
|
|
179
|
+
assert(typeof data.sha256 === 'string' && data.sha256.length === 64, 'missing sha256')
|
|
180
|
+
assert(data.content.includes('dsh-mesh'), 'README content not returned')
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
await check('fs_read honours a line range', async () => {
|
|
184
|
+
const data = parseToolResult(
|
|
185
|
+
await client.callTool({
|
|
186
|
+
name: 'fs_read',
|
|
187
|
+
arguments: { path: join(repoRoot, 'README.md'), startLine: 1, endLine: 1 }
|
|
188
|
+
})
|
|
189
|
+
)
|
|
190
|
+
assert(data.range?.startLine === 1 && data.range?.endLine === 1, 'range not applied')
|
|
191
|
+
assert(!data.content.includes('\n'), 'range returned more than one line')
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
await check('fs_read outside fsRoots is denied (bad sample)', async () => {
|
|
195
|
+
const data = parseToolResult(await client.callTool({ name: 'fs_read', arguments: { path: 'C:\\Windows\\win.ini' } }))
|
|
196
|
+
assert(data.code === 'E_DENIED', `expected E_DENIED, got ${data.code}`)
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
await check('fs_read on a missing file returns E_NOT_FOUND', async () => {
|
|
200
|
+
const data = parseToolResult(
|
|
201
|
+
await client.callTool({ name: 'fs_read', arguments: { path: join(repoRoot, '__does_not_exist__.md') } })
|
|
202
|
+
)
|
|
203
|
+
assert(data.code === 'E_NOT_FOUND', `expected E_NOT_FOUND, got ${data.code}`)
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
await check('fs_list lists a directory dirs-first', async () => {
|
|
207
|
+
const data = parseToolResult(await client.callTool({ name: 'fs_list', arguments: { path: repoRoot } }))
|
|
208
|
+
assert(Array.isArray(data.entries) && data.entries.length > 0, 'no entries')
|
|
209
|
+
const firstFile = data.entries.findIndex((e) => e.type !== 'dir')
|
|
210
|
+
const lastDir = data.entries.reduce((acc, e, i) => (e.type === 'dir' ? i : acc), -1)
|
|
211
|
+
assert(firstFile === -1 || lastDir < firstFile, 'entries are not directories-first')
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
await check('fs_list outside fsRoots is denied (bad sample)', async () => {
|
|
215
|
+
const data = parseToolResult(await client.callTool({ name: 'fs_list', arguments: { path: 'C:\\Windows' } }))
|
|
216
|
+
assert(data.code === 'E_DENIED', `expected E_DENIED, got ${data.code}`)
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
await check('fs_stat returns sha256 for a small file', async () => {
|
|
220
|
+
const data = parseToolResult(
|
|
221
|
+
await client.callTool({ name: 'fs_stat', arguments: { path: join(home, 'greppable', 'a.txt') } })
|
|
222
|
+
)
|
|
223
|
+
assert(data.type === 'file', `unexpected type ${data.type}`)
|
|
224
|
+
assert(data.sizeBytes > 0, 'missing size')
|
|
225
|
+
assert(typeof data.sha256 === 'string' && data.sha256.length === 64, 'missing sha256')
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
await check('fs_grep finds a regex match', async () => {
|
|
229
|
+
const data = parseToolResult(
|
|
230
|
+
await client.callTool({
|
|
231
|
+
name: 'fs_grep',
|
|
232
|
+
arguments: { root: join(home, 'greppable'), pattern: 'TODO' }
|
|
233
|
+
})
|
|
234
|
+
)
|
|
235
|
+
assert(data.matches === 1, `expected 1 match, got ${data.matches}`)
|
|
236
|
+
assert(data.results[0].line === 2, `wrong line ${data.results[0].line}`)
|
|
237
|
+
assert(data.results[0].path.endsWith('a.txt'), 'wrong file')
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
await check('fs_grep rejects an invalid regex', async () => {
|
|
241
|
+
const data = parseToolResult(
|
|
242
|
+
await client.callTool({ name: 'fs_grep', arguments: { root: join(home, 'greppable'), pattern: '([' } })
|
|
243
|
+
)
|
|
244
|
+
assert(data.code === 'E_INVALID_ARG', `expected E_INVALID_ARG, got ${data.code}`)
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
await check('fs_stats aggregates a tree without streaming entries', async () => {
|
|
248
|
+
const dir = join(home, 'stats')
|
|
249
|
+
mkdirSync(join(dir, 'sub'), { recursive: true })
|
|
250
|
+
writeFileSync(join(dir, 'a.txt'), '12345')
|
|
251
|
+
writeFileSync(join(dir, 'sub', 'b.log'), '1234567890')
|
|
252
|
+
const data = parseToolResult(await client.callTool({ name: 'fs_stats', arguments: { path: dir } }))
|
|
253
|
+
assert(data.totals?.files === 2, `expected 2 files, got ${data.totals?.files}`)
|
|
254
|
+
assert(data.totals?.bytes === 15, `expected 15 bytes, got ${data.totals?.bytes}`)
|
|
255
|
+
assert(data.topLevel?.length === 2, `expected 2 top-level entries, got ${data.topLevel?.length}`)
|
|
256
|
+
const sub = data.topLevel.find((e) => e.name === 'sub')
|
|
257
|
+
assert(sub && sub.sizeBytes === 10 && sub.fileCount === 1, 'subdirectory recursive size is wrong')
|
|
258
|
+
assert(data.topFiles[0]?.sizeBytes === 10, 'largest file is wrong')
|
|
259
|
+
const exts = data.extStats.top.map((e) => e.ext)
|
|
260
|
+
assert(exts.includes('.txt') && exts.includes('.log'), 'extension histogram is missing entries')
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
await check('fs_stats outside fsRoots is denied (bad sample)', async () => {
|
|
264
|
+
const data = parseToolResult(await client.callTool({ name: 'fs_stats', arguments: { path: 'C:\\Windows' } }))
|
|
265
|
+
assert(data.code === 'E_DENIED', `expected E_DENIED, got ${data.code}`)
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
await check('git_status inspects this repository', async () => {
|
|
269
|
+
const data = parseToolResult(await client.callTool({ name: 'git_status', arguments: { path: repoRoot } }))
|
|
270
|
+
assert(typeof data.root === 'string' && data.root.length > 0, 'missing root')
|
|
271
|
+
assert(typeof data.head === 'string' && /^[0-9a-f]{40}$/.test(data.head), `bad head ${data.head}`)
|
|
272
|
+
assert(data.dirty && typeof data.dirty === 'object', 'missing dirty counts')
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
await check('git_status on a non-repo path returns E_INVALID_ARG', async () => {
|
|
276
|
+
const nonRepo = join(home, 'greppable')
|
|
277
|
+
const data = parseToolResult(await client.callTool({ name: 'git_status', arguments: { path: nonRepo } }))
|
|
278
|
+
assert(data.code === 'E_INVALID_ARG', `expected E_INVALID_ARG, got ${data.code}`)
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
await check('git_log returns commits of the temp repo', async () => {
|
|
282
|
+
const data = parseToolResult(
|
|
283
|
+
await client.callTool({ name: 'git_log', arguments: { path: gitRepo, limit: 5, format: 'detail' } })
|
|
284
|
+
)
|
|
285
|
+
assert(data.count >= 1, `expected commits, got ${data.count}`)
|
|
286
|
+
assert(data.commits[0].subject === 'first commit', `unexpected subject ${data.commits[0].subject}`)
|
|
287
|
+
assert(/^[0-9a-f]{40}$/.test(data.commits[0].sha), 'missing full sha')
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
await check('git_diff shows the working tree change', async () => {
|
|
291
|
+
const data = parseToolResult(
|
|
292
|
+
await client.callTool({ name: 'git_diff', arguments: { path: gitRepo, file: 'file.txt' } })
|
|
293
|
+
)
|
|
294
|
+
assert(data.diff.includes('+v2 changed'), 'diff does not contain the change')
|
|
295
|
+
assert(data.stat.includes('file.txt'), 'stat summary missing file')
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
await check('repo_scan finds the temp repo and its dirty state', async () => {
|
|
299
|
+
const data = parseToolResult(
|
|
300
|
+
await client.callTool({ name: 'repo_scan', arguments: { roots: [home], depth: 2 } })
|
|
301
|
+
)
|
|
302
|
+
assert(data.repos >= 1, `expected >=1 repo, got ${data.repos}`)
|
|
303
|
+
const item = data.items.find((r) => r.root.endsWith('tmp-repo'))
|
|
304
|
+
assert(item, 'tmp-repo not found')
|
|
305
|
+
assert(item.dirty.modified >= 1 && item.dirty.untracked >= 1, `unexpected dirty ${JSON.stringify(item.dirty)}`)
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
await check('shell_run is denied while disabled (bad sample)', async () => {
|
|
309
|
+
const data = parseToolResult(
|
|
310
|
+
await client.callTool({ name: 'shell_run', arguments: { command: 'node', args: ['--version'] } })
|
|
311
|
+
)
|
|
312
|
+
assert(data.code === 'E_DENIED', `expected E_DENIED, got ${data.code}`)
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
await check('task_submit runs a job and task_status reports success', async () => {
|
|
316
|
+
const sub = parseToolResult(
|
|
317
|
+
await client.callTool({
|
|
318
|
+
name: 'task_submit',
|
|
319
|
+
arguments: { tool: 'git_log', arguments: { path: gitRepo, limit: 1 }, idem: 'selftest-job-1' }
|
|
320
|
+
})
|
|
321
|
+
)
|
|
322
|
+
assert(typeof sub.jobId === 'string' && sub.jobId.length > 0, 'missing jobId')
|
|
323
|
+
const final = await waitForJob(client, sub.jobId)
|
|
324
|
+
assert(final.state === 'succeeded', `expected succeeded, got ${final.state} (${JSON.stringify(final.error)})`)
|
|
325
|
+
const inner = JSON.parse(final.result.content[0].text)
|
|
326
|
+
assert(inner.count === 1, `unexpected inner result count ${inner.count}`)
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
await check('task_submit with the same idem key reuses the job', async () => {
|
|
330
|
+
const again = parseToolResult(
|
|
331
|
+
await client.callTool({
|
|
332
|
+
name: 'task_submit',
|
|
333
|
+
arguments: { tool: 'git_log', arguments: { path: gitRepo, limit: 1 }, idem: 'selftest-job-1' }
|
|
334
|
+
})
|
|
335
|
+
)
|
|
336
|
+
assert(again.idempotent === true, 'second submit was not deduplicated')
|
|
337
|
+
assert(again.jobId.length > 0, 'missing jobId')
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
await check('task_cancel on a finished job returns E_INVALID_ARG', async () => {
|
|
341
|
+
const list = parseToolResult(await client.callTool({ name: 'task_list', arguments: { limit: 5 } }))
|
|
342
|
+
const done = list.jobs.find((j) => j.state === 'succeeded')
|
|
343
|
+
assert(done, 'no finished job to cancel')
|
|
344
|
+
const data = parseToolResult(await client.callTool({ name: 'task_cancel', arguments: { jobId: done.id } }))
|
|
345
|
+
assert(data.code === 'E_INVALID_ARG', `expected E_INVALID_ARG, got ${data.code}`)
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
await check('task_submit refuses to queue task tools', async () => {
|
|
349
|
+
const data = parseToolResult(
|
|
350
|
+
await client.callTool({ name: 'task_submit', arguments: { tool: 'task_submit', arguments: {} } })
|
|
351
|
+
)
|
|
352
|
+
assert(data.code === 'E_INVALID_ARG', `expected E_INVALID_ARG, got ${data.code}`)
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
await check('mesh_audit_tail returns the calls made so far', async () => {
|
|
356
|
+
const data = parseToolResult(await client.callTool({ name: 'mesh_audit_tail', arguments: { limit: 100 } }))
|
|
357
|
+
assert(Array.isArray(data.entries) && data.entries.length > 5, 'expected audit entries')
|
|
358
|
+
const tools = new Set(data.entries.map((e) => e.tool))
|
|
359
|
+
assert(tools.has('fs_read') && tools.has('git_status'), 'expected tools missing from audit')
|
|
360
|
+
const deniedEntry = data.entries.find((e) => e.code === 'E_DENIED')
|
|
361
|
+
assert(deniedEntry, 'denied call was not audited')
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
await check('ACL hot-reloads from config.json (revoke fs_read)', async () => {
|
|
365
|
+
const before = loadConfig(home)
|
|
366
|
+
const next = { ...before, acl: { ...before.acl, allow: before.acl.allow.filter((n) => n !== 'fs_read') } }
|
|
367
|
+
await writeConfigFile(home, next)
|
|
368
|
+
const data = parseToolResult(
|
|
369
|
+
await client.callTool({ name: 'fs_read', arguments: { path: join(repoRoot, 'README.md') } })
|
|
370
|
+
)
|
|
371
|
+
assert(data.code === 'E_DENIED', `expected E_DENIED after revoke, got ${data.code}`)
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
await check('shell_run works once a human enables it (hot-reload)', async () => {
|
|
375
|
+
const before = loadConfig(home)
|
|
376
|
+
const next = {
|
|
377
|
+
...before,
|
|
378
|
+
acl: { ...before.acl, allow: [...before.acl.allow, 'shell_run'] },
|
|
379
|
+
shell: { ...before.shell, enabled: true, allowCommands: ['node'] }
|
|
380
|
+
}
|
|
381
|
+
await writeConfigFile(home, next)
|
|
382
|
+
const data = parseToolResult(
|
|
383
|
+
await client.callTool({ name: 'shell_run', arguments: { command: 'node', args: ['--version'], timeoutMs: 30000 } })
|
|
384
|
+
)
|
|
385
|
+
assert(data.exitCode === 0, `expected exit 0, got ${data.exitCode} (${data.stderr})`)
|
|
386
|
+
assert(/^v\d+\./.test(data.stdout.trim()), `unexpected node version output: ${data.stdout}`)
|
|
387
|
+
})
|
|
388
|
+
|
|
389
|
+
await check('shell_run refuses a command outside allowCommands (bad sample)', async () => {
|
|
390
|
+
const data = parseToolResult(
|
|
391
|
+
await client.callTool({ name: 'shell_run', arguments: { command: 'git', args: ['--version'] } })
|
|
392
|
+
)
|
|
393
|
+
assert(data.code === 'E_DENIED', `expected E_DENIED, got ${data.code}`)
|
|
394
|
+
})
|
|
395
|
+
await check('token rotation takes effect without a restart (hot-reload)', async () => {
|
|
396
|
+
const { rotateToken } = await import('./core/config.js')
|
|
397
|
+
const rotated = rotateToken({ home, note: 'selftest rotation' })
|
|
398
|
+
let oldRejected = false
|
|
399
|
+
try {
|
|
400
|
+
const stale = await connect(srv.url, token, 'dsh-mesh-stale-token')
|
|
401
|
+
await stale.client.listTools()
|
|
402
|
+
} catch {
|
|
403
|
+
oldRejected = true
|
|
404
|
+
}
|
|
405
|
+
assert(oldRejected, 'old token was still accepted after rotation')
|
|
406
|
+
const fresh = await connect(srv.url, rotated.token, 'dsh-mesh-fresh-token')
|
|
407
|
+
const list = await fresh.client.listTools()
|
|
408
|
+
assert(list.tools.length >= 14, 'rotated token could not list tools')
|
|
409
|
+
await fresh.client.close()
|
|
410
|
+
})
|
|
411
|
+
|
|
412
|
+
await check('TLS: https /health works with the generated cert and fails without trust (bad sample)', async () => {
|
|
413
|
+
const { generateCert, certPath } = await import('./core/tls.js')
|
|
414
|
+
const { readFileSync } = await import('node:fs')
|
|
415
|
+
await generateCert({ home })
|
|
416
|
+
const before = loadConfig(home)
|
|
417
|
+
await writeConfigFile(home, { ...before, node: { ...before.node, tls: { enabled: true } } })
|
|
418
|
+
const tlsSrv = await startMeshServer({ home, secrets, log: quiet })
|
|
419
|
+
try {
|
|
420
|
+
const ca = readFileSync(certPath(home))
|
|
421
|
+
const health = await httpsGetJson(tlsSrv.port, '/health', { ca })
|
|
422
|
+
assert(health.ok === true, 'https health not ok')
|
|
423
|
+
assert(health.tls === true, 'https health did not report tls:true')
|
|
424
|
+
assert(String(tlsSrv.url).startsWith('https://'), `unexpected url ${tlsSrv.url}`)
|
|
425
|
+
let refused = false
|
|
426
|
+
try {
|
|
427
|
+
await httpsGetJson(tlsSrv.port, '/health', {})
|
|
428
|
+
} catch {
|
|
429
|
+
refused = true
|
|
430
|
+
}
|
|
431
|
+
assert(refused, 'self-signed cert was accepted without an explicit trust anchor')
|
|
432
|
+
} finally {
|
|
433
|
+
await tlsSrv.close()
|
|
434
|
+
await writeConfigFile(home, { ...before, node: { ...before.node, tls: { enabled: false } } })
|
|
435
|
+
}
|
|
436
|
+
})
|
|
437
|
+
await check('peer client: tools/list + tools/call over the pinned transport', async () => {
|
|
438
|
+
const { listPeerTools, callPeerTool } = await import('./core/peer-client.js')
|
|
439
|
+
const peer = { name: 'selftest-peer', url: srv.url, token: liveToken(), certFingerprint: null }
|
|
440
|
+
const listed = await listPeerTools(peer)
|
|
441
|
+
assert(listed.count >= 14, `expected >=14 peer tools, got ${listed.count}`)
|
|
442
|
+
assert(listed.tools.some((tool) => tool.name === 'mesh_self'), 'mesh_self missing from the peer tool list')
|
|
443
|
+
const called = await callPeerTool(peer, { tool: 'mesh_self', args: {} })
|
|
444
|
+
assert(called.ok === true, 'callPeerTool reported isError for mesh_self')
|
|
445
|
+
const data = parseToolResult(called.result)
|
|
446
|
+
assert(data.mesh?.name === 'selftest', `unexpected peer node name ${data.mesh?.name}`)
|
|
447
|
+
})
|
|
448
|
+
|
|
449
|
+
await check('peer client: peer-side denial keeps E_DENIED and a wrong token fails (bad sample)', async () => {
|
|
450
|
+
const { listPeerTools, callPeerTool } = await import('./core/peer-client.js')
|
|
451
|
+
const peer = { name: 'selftest-peer', url: srv.url, token: liveToken(), certFingerprint: null }
|
|
452
|
+
const denied = await callPeerTool(peer, { tool: 'shell_run', args: { command: 'not-in-any-allowlist' } })
|
|
453
|
+
assert(denied.ok === false, 'shell_run should be denied by the peer ACL')
|
|
454
|
+
assert(parseToolResult(denied.result).code === 'E_DENIED', 'denied result should carry code E_DENIED')
|
|
455
|
+
let failed = false
|
|
456
|
+
try {
|
|
457
|
+
await listPeerTools({ ...peer, token: 'wrong-token' })
|
|
458
|
+
} catch {
|
|
459
|
+
failed = true
|
|
460
|
+
}
|
|
461
|
+
assert(failed, 'wrong token was accepted by the peer client')
|
|
462
|
+
})
|
|
463
|
+
|
|
464
|
+
await check('peer bridge serves the peer tool surface over stdio', async () => {
|
|
465
|
+
const { savePeers } = await import('./core/pairing.js')
|
|
466
|
+
const { StdioClientTransport } = await import('@modelcontextprotocol/client/stdio')
|
|
467
|
+
const bridgeHome = mkdtempSync(join(tmpdir(), 'dsh-mesh-bridge-'))
|
|
468
|
+
const bridgeClient = new Client({ name: 'dsh-mesh-bridge-selftest', version: '0.0.1' })
|
|
469
|
+
try {
|
|
470
|
+
savePeers(
|
|
471
|
+
{
|
|
472
|
+
version: 1,
|
|
473
|
+
items: [{ id: 'selftest-peer', name: 'selftest-peer', url: srv.url, token: liveToken(), certFingerprint: null }]
|
|
474
|
+
},
|
|
475
|
+
bridgeHome
|
|
476
|
+
)
|
|
477
|
+
const cleanEnv = Object.fromEntries(Object.entries(process.env).filter(([, value]) => value !== undefined))
|
|
478
|
+
await bridgeClient.connect(
|
|
479
|
+
new StdioClientTransport({
|
|
480
|
+
command: process.execPath,
|
|
481
|
+
args: [join(repoRoot, 'src', 'cli.js'), 'peer', 'bridge', '--to', 'selftest-peer'],
|
|
482
|
+
cwd: repoRoot,
|
|
483
|
+
env: { ...cleanEnv, DSH_MESH_HOME: bridgeHome },
|
|
484
|
+
stderr: 'ignore'
|
|
485
|
+
})
|
|
486
|
+
)
|
|
487
|
+
const listed = await bridgeClient.listTools()
|
|
488
|
+
assert(listed.tools.length >= 14, `bridge exposed only ${listed.tools.length} tools`)
|
|
489
|
+
const result = await bridgeClient.callTool({ name: 'mesh_self', arguments: {} })
|
|
490
|
+
assert(result.isError !== true, 'call through the bridge failed')
|
|
491
|
+
assert(parseToolResult(result).mesh?.name === 'selftest', 'bridge proxied the wrong peer')
|
|
492
|
+
} finally {
|
|
493
|
+
await bridgeClient.close().catch(() => {})
|
|
494
|
+
rmSync(bridgeHome, { recursive: true, force: true })
|
|
495
|
+
}
|
|
496
|
+
})
|
|
497
|
+
|
|
498
|
+
await check('peer doctor: fails without a peer, warns on a reachable one (bad sample)', async () => {
|
|
499
|
+
const { runPeerDoctor } = await import('./core/peer-doctor.js')
|
|
500
|
+
const { savePeers } = await import('./core/pairing.js')
|
|
501
|
+
const doctorHome = mkdtempSync(join(tmpdir(), 'dsh-mesh-doctor-'))
|
|
502
|
+
try {
|
|
503
|
+
writeConfigFile(doctorHome, { ...defaultConfig(), hosts: [] })
|
|
504
|
+
const empty = await runPeerDoctor({ home: doctorHome, dshHomes: [], timeoutMs: 5000 })
|
|
505
|
+
assert(empty.verdict === 'fail', 'doctor must fail when no peer is paired')
|
|
506
|
+
assert(
|
|
507
|
+
empty.checks.some((check) => check.id === 'peers-file' && check.status === 'fail'),
|
|
508
|
+
'expected a peers-file failure'
|
|
509
|
+
)
|
|
510
|
+
savePeers(
|
|
511
|
+
{
|
|
512
|
+
version: 1,
|
|
513
|
+
items: [{ id: 'selftest-peer', name: 'selftest-peer', url: srv.url, token: liveToken(), certFingerprint: null }]
|
|
514
|
+
},
|
|
515
|
+
doctorHome
|
|
516
|
+
)
|
|
517
|
+
const report = await runPeerDoctor({ home: doctorHome, dshHomes: [], timeoutMs: 5000 })
|
|
518
|
+
assert(report.verdict === 'warn', `expected warn without overlay/hosts, got ${report.verdict}`)
|
|
519
|
+
assert(report.checks.find((check) => check.id === 'mcp-handshake')?.status === 'pass', 'handshake check should pass')
|
|
520
|
+
assert(report.checks.find((check) => check.id === 'dsh-overlay')?.status === 'warn', 'missing overlay should warn')
|
|
521
|
+
assert(report.nextSteps.length > 0, 'doctor should suggest next steps')
|
|
522
|
+
} finally {
|
|
523
|
+
rmSync(doctorHome, { recursive: true, force: true })
|
|
524
|
+
}
|
|
525
|
+
})
|
|
526
|
+
|
|
527
|
+
await check('peer doctor: an overlay is attributed only to the profile that mounts it (bad sample)', async () => {
|
|
528
|
+
const { findPeerOverlays, overlayMatchesProfile } = await import('./core/peer-doctor.js')
|
|
529
|
+
const fakeDsh = mkdtempSync(join(tmpdir(), 'dsh-mesh-dshhome-'))
|
|
530
|
+
try {
|
|
531
|
+
const profileDir = join(fakeDsh, 'profiles', 'web')
|
|
532
|
+
mkdirSync(profileDir, { recursive: true })
|
|
533
|
+
writeFileSync(
|
|
534
|
+
join(profileDir, 'cordis.patch.yml'),
|
|
535
|
+
[
|
|
536
|
+
'- insert:',
|
|
537
|
+
' - id: home',
|
|
538
|
+
' name: "@deepseek-ai/dsh-mcp-client"',
|
|
539
|
+
' config:',
|
|
540
|
+
' transport: stdio',
|
|
541
|
+
' serverName: home',
|
|
542
|
+
' command: node.exe',
|
|
543
|
+
' args: [src/cli.js, peer, bridge, --to, home]',
|
|
544
|
+
''
|
|
545
|
+
].join('\n')
|
|
546
|
+
)
|
|
547
|
+
const peer = { id: 'peer-home', name: 'home', url: 'https://peer.invalid:1' }
|
|
548
|
+
// The same directory twice simulates `~/.dsh` being a junction onto `~/.dsh0`.
|
|
549
|
+
const overlays = findPeerOverlays(peer, { dshHomes: [fakeDsh, fakeDsh] })
|
|
550
|
+
assert(overlays.length === 1, `expected one deduped overlay, got ${overlays.length}`)
|
|
551
|
+
assert(overlays[0].transport === 'stdio', `expected stdio transport, got ${overlays[0].transport}`)
|
|
552
|
+
assert(overlayMatchesProfile(overlays[0], profileDir), 'overlay should match its own profile')
|
|
553
|
+
assert(
|
|
554
|
+
!overlayMatchesProfile(overlays[0], join(fakeDsh, 'profiles', 'web2')),
|
|
555
|
+
'overlay must not be credited to another profile'
|
|
556
|
+
)
|
|
557
|
+
} finally {
|
|
558
|
+
rmSync(fakeDsh, { recursive: true, force: true })
|
|
559
|
+
}
|
|
560
|
+
})
|
|
561
|
+
|
|
562
|
+
await check('delegate: fresh session by default, explicit sessionId continues it (bad sample: no idle-session hijack)', async () => {
|
|
563
|
+
const { createServer } = await import('node:http')
|
|
564
|
+
const { buildDelegate } = await import('./tools/delegate.js')
|
|
565
|
+
const delegateHome = mkdtempSync(join(tmpdir(), 'dsh-mesh-delegate-'))
|
|
566
|
+
const workdir = join(delegateHome, 'workspace')
|
|
567
|
+
mkdirSync(workdir, { recursive: true })
|
|
568
|
+
const token = 'selftest-host-token'
|
|
569
|
+
const calls = []
|
|
570
|
+
const sessions = [
|
|
571
|
+
{ sessionId: 'session-existing', state: 'idle', agentAvailable: true, live: true, cwd: workdir, updatedAt: 1, title: 'existing' }
|
|
572
|
+
]
|
|
573
|
+
const fake = createServer((req, res) => {
|
|
574
|
+
const chunks = []
|
|
575
|
+
req.on('data', (chunk) => chunks.push(chunk))
|
|
576
|
+
req.on('end', () => {
|
|
577
|
+
let body = {}
|
|
578
|
+
try {
|
|
579
|
+
body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}')
|
|
580
|
+
} catch {
|
|
581
|
+
/* bad JSON → empty body */
|
|
582
|
+
}
|
|
583
|
+
const send = (payload) => {
|
|
584
|
+
res.writeHead(200, { 'content-type': 'application/json' })
|
|
585
|
+
res.end(JSON.stringify(payload))
|
|
586
|
+
}
|
|
587
|
+
const pathname = req.url.split('?')[0]
|
|
588
|
+
if (pathname === '/host/ping') return send({ ok: true, plugin: '0.2.0-selftest' })
|
|
589
|
+
if (pathname === '/host/agents') {
|
|
590
|
+
return send({ ok: true, agents: [{ id: 'session-existing', status: 'idle', title: 'existing', cwd: workdir }] })
|
|
591
|
+
}
|
|
592
|
+
if (pathname === '/host/sessions') return send({ ok: true, source: 'agents', count: sessions.length, sessions })
|
|
593
|
+
if (pathname === '/host/sessions/create') {
|
|
594
|
+
calls.push({ path: pathname, body })
|
|
595
|
+
const sessionId = 'session-created-1'
|
|
596
|
+
sessions.push({
|
|
597
|
+
sessionId,
|
|
598
|
+
state: 'idle',
|
|
599
|
+
agentAvailable: true,
|
|
600
|
+
live: true,
|
|
601
|
+
cwd: body.cwd || '',
|
|
602
|
+
updatedAt: Date.now(),
|
|
603
|
+
title: 'fresh'
|
|
604
|
+
})
|
|
605
|
+
return send({ ok: true, sessionId, plugin: '0.2.0-selftest' })
|
|
606
|
+
}
|
|
607
|
+
if (pathname === '/host/prompt') {
|
|
608
|
+
calls.push({ path: pathname, body })
|
|
609
|
+
return send({ ok: true, sessionId: body.sessionId, mode: body.mode || 'queue', accepted: true, plugin: '0.2.1-selftest' })
|
|
610
|
+
}
|
|
611
|
+
if (pathname === '/host/wake') {
|
|
612
|
+
calls.push({ path: pathname, body })
|
|
613
|
+
return send({ ok: true, sessionId: body.sessionId, delivered: true, wokeUp: true })
|
|
614
|
+
}
|
|
615
|
+
res.writeHead(404, { 'content-type': 'application/json' })
|
|
616
|
+
res.end(JSON.stringify({ ok: false, code: 'E_NOT_FOUND' }))
|
|
617
|
+
})
|
|
618
|
+
})
|
|
619
|
+
await new Promise((resolvePromise) => fake.listen(0, '127.0.0.1', resolvePromise))
|
|
620
|
+
const port = fake.address().port
|
|
621
|
+
try {
|
|
622
|
+
writeFileSync(join(delegateHome, 'host-token'), `${token}\n`)
|
|
623
|
+
const base = defaultConfig()
|
|
624
|
+
const cfg = {
|
|
625
|
+
...base,
|
|
626
|
+
hosts: [{ id: 'dsh0', url: `http://127.0.0.1:${port}`, enabled: true }],
|
|
627
|
+
acl: { ...base.acl, fsRoots: [delegateHome] }
|
|
628
|
+
}
|
|
629
|
+
const ctx = { getConfig: () => cfg, home: delegateHome, peerId: 'selftest', log: quiet }
|
|
630
|
+
const tool = buildDelegate(ctx).find((entry) => entry.name === 'delegate')
|
|
631
|
+
const first = await tool.handler({ goal: 'do a thing', workspace: workdir })
|
|
632
|
+
assert(first.isError !== true, `fresh delegate failed: ${first.content?.[0]?.text}`)
|
|
633
|
+
const firstData = parseToolResult(first)
|
|
634
|
+
assert(firstData.createdSession === true, 'delegate must create a fresh session by default')
|
|
635
|
+
assert(firstData.sessionId === 'session-created-1', `unexpected created session ${firstData.sessionId}`)
|
|
636
|
+
assert(
|
|
637
|
+
calls.some((call) => call.path === '/host/sessions/create' && call.body.cwd === workdir),
|
|
638
|
+
'session creation must pass the requested workspace cwd'
|
|
639
|
+
)
|
|
640
|
+
assert(
|
|
641
|
+
calls.some((call) => call.path === '/host/prompt' && call.body.sessionId === 'session-created-1'),
|
|
642
|
+
'a fresh delegate must drive the first turn with host_session_prompt (wake would sit in a dormant inbox)'
|
|
643
|
+
)
|
|
644
|
+
assert(
|
|
645
|
+
!calls.some((call) => call.path === '/host/wake' && call.body.sessionId === 'session-existing'),
|
|
646
|
+
'a fresh delegate must not wake an unrelated idle session'
|
|
647
|
+
)
|
|
648
|
+
const second = await tool.handler({ goal: 'keep going', sessionId: 'session-created-1' })
|
|
649
|
+
assert(second.isError !== true, `continuation failed: ${second.content?.[0]?.text}`)
|
|
650
|
+
const secondData = parseToolResult(second)
|
|
651
|
+
assert(secondData.createdSession === false, 'continuation must not create another session')
|
|
652
|
+
assert(secondData.sessionId === 'session-created-1', 'continuation must use the requested session')
|
|
653
|
+
assert(
|
|
654
|
+
calls.some((call) => call.path === '/host/wake' && call.body.sessionId === 'session-created-1'),
|
|
655
|
+
'a continuation must wake the live session, not prompt a fresh one'
|
|
656
|
+
)
|
|
657
|
+
const dead = await tool.handler({ goal: 'nope', sessionId: 'session-gone' })
|
|
658
|
+
assert(dead.isError === true, 'continuing a dead session must fail loud')
|
|
659
|
+
assert(
|
|
660
|
+
String(dead.content[0].text).includes('session not alive'),
|
|
661
|
+
'dead-session failure must explain that the session is gone'
|
|
662
|
+
)
|
|
663
|
+
} finally {
|
|
664
|
+
await new Promise((resolvePromise) => fake.close(resolvePromise))
|
|
665
|
+
rmSync(delegateHome, { recursive: true, force: true })
|
|
666
|
+
}
|
|
667
|
+
})
|
|
668
|
+
} finally {
|
|
669
|
+
try {
|
|
670
|
+
await client?.close?.()
|
|
671
|
+
} catch {
|
|
672
|
+
/* ignore */
|
|
673
|
+
}
|
|
674
|
+
await srv.close()
|
|
675
|
+
rmSync(home, { recursive: true, force: true })
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
await check('acl reset restores DEFAULT_ALLOW', () => {
|
|
679
|
+
const next = resolveAclUpdate({ allow: ['mesh_self', 'shell_run'], deny: ['fs_read'] }, { reset: true })
|
|
680
|
+
assert(next.changed, 'reset should report a change')
|
|
681
|
+
assert(next.allow.length === DEFAULT_ALLOW.length, `expected ${DEFAULT_ALLOW.length} allowed, got ${next.allow.length}`)
|
|
682
|
+
assert(next.deny.length === 0, 'reset should clear deny list')
|
|
683
|
+
})
|
|
684
|
+
|
|
685
|
+
await check('acl allow/deny keep allow and deny disjoint', () => {
|
|
686
|
+
const added = resolveAclUpdate({ allow: ['mesh_self'], deny: [] }, { allow: ['fs_read'] })
|
|
687
|
+
assert(added.allow.includes('fs_read'), 'allow did not add fs_read')
|
|
688
|
+
const fromDeny = resolveAclUpdate({ allow: [], deny: ['fs_read'] }, { allow: ['fs_read'] })
|
|
689
|
+
assert(!fromDeny.deny.includes('fs_read'), 'allow did not clear the deny entry')
|
|
690
|
+
const denied = resolveAclUpdate({ allow: ['fs_read'], deny: [] }, { deny: ['fs_read'] })
|
|
691
|
+
assert(!denied.allow.includes('fs_read'), 'deny did not remove from allow')
|
|
692
|
+
assert(denied.deny.includes('fs_read'), 'deny did not add fs_read')
|
|
693
|
+
})
|
|
694
|
+
|
|
695
|
+
await check('acl rejects unknown tool names (bad sample)', () => {
|
|
696
|
+
let error
|
|
697
|
+
try {
|
|
698
|
+
resolveAclUpdate({ allow: [], deny: [] }, { allow: ['nope_tool'], known: ['mesh_self'] })
|
|
699
|
+
} catch (err) {
|
|
700
|
+
error = err
|
|
701
|
+
}
|
|
702
|
+
assert(error?.code === 'E_INVALID_ARG', 'unknown tool should be rejected with E_INVALID_ARG')
|
|
703
|
+
})
|
|
704
|
+
|
|
705
|
+
await check('acl no-op reports changed:false', () => {
|
|
706
|
+
const same = resolveAclUpdate({ allow: ['mesh_self'], deny: [] }, { allow: ['mesh_self'] })
|
|
707
|
+
assert(same.changed === false, 'no-op update should not report a change')
|
|
708
|
+
})
|
|
709
|
+
|
|
710
|
+
await check('describeAcl: deny wins over allow', () => {
|
|
711
|
+
const view = describeAcl({ acl: { allow: ['a', 'b'], deny: ['b'] } }, ['a', 'b', 'c'])
|
|
712
|
+
assert(view.tools.find((t) => t.name === 'a').allowed === true, 'a should be allowed')
|
|
713
|
+
assert(view.tools.find((t) => t.name === 'b').allowed === false, 'deny must win over allow')
|
|
714
|
+
assert(view.tools.find((t) => t.name === 'c').allowed === false, 'unlisted tool should be denied')
|
|
715
|
+
})
|
|
716
|
+
|
|
717
|
+
await check('acl drift reports missing and extra tools', () => {
|
|
718
|
+
const drift = aclDrift({ acl: { allow: ['mesh_self', 'channel_send'] } })
|
|
719
|
+
assert(drift.missing.includes('fs_read'), 'fs_read should be reported missing')
|
|
720
|
+
assert(drift.missing.includes('host_self'), 'host_self should be reported missing')
|
|
721
|
+
assert(drift.extra.includes('channel_send'), 'channel_send should be reported extra')
|
|
722
|
+
assert(!drift.extra.includes('mesh_self'), 'default tool should not be reported extra')
|
|
723
|
+
})
|
|
724
|
+
|
|
725
|
+
await check('config v5 -> v6 folds the single host bridge into hosts[] and adds dsh1', () => {
|
|
726
|
+
const tmpPeerB = mkdtempSync(join(tmpdir(), 'mesh-cfg-v6-'))
|
|
727
|
+
try {
|
|
728
|
+
writeFileSync(
|
|
729
|
+
configPath(tmpPeerB),
|
|
730
|
+
JSON.stringify(
|
|
731
|
+
{
|
|
732
|
+
version: 5,
|
|
733
|
+
host: { enabled: true, url: 'http://127.0.0.1:9111', tokenFile: '', timeoutMs: 4321 }
|
|
734
|
+
},
|
|
735
|
+
null,
|
|
736
|
+
2
|
|
737
|
+
),
|
|
738
|
+
'utf8'
|
|
739
|
+
)
|
|
740
|
+
const cfg = loadConfig(tmpPeerB, { log: quiet })
|
|
741
|
+
assert(cfg.version === 7, `expected version 7 after full migration, got ${cfg.version}`)
|
|
742
|
+
assert(Array.isArray(cfg.hosts) && cfg.hosts.length === 2, 'expected two host entries')
|
|
743
|
+
assert(
|
|
744
|
+
cfg.hosts[0].id === 'dsh0' && cfg.hosts[0].url === 'http://127.0.0.1:9111',
|
|
745
|
+
'the custom bridge should survive as dsh0'
|
|
746
|
+
)
|
|
747
|
+
assert(cfg.hosts[0].timeoutMs === 4321, 'custom timeout should survive')
|
|
748
|
+
assert(cfg.hosts[1].id === 'dsh1', 'dsh1 default should be appended')
|
|
749
|
+
assert(cfg.host === undefined, 'legacy host key should be gone from the merged config')
|
|
750
|
+
} finally {
|
|
751
|
+
rmSync(tmpPeerB, { recursive: true, force: true })
|
|
752
|
+
}
|
|
753
|
+
})
|
|
754
|
+
|
|
755
|
+
await check('host instances resolve by id and derive per-instance token files', () => {
|
|
756
|
+
const tmpPeerB = mkdtempSync(join(tmpdir(), 'mesh-host-resolve-'))
|
|
757
|
+
try {
|
|
758
|
+
const cfg = defaultConfig()
|
|
759
|
+
assert(hostTokenPath(cfg, tmpPeerB, 'dsh0') === join(tmpPeerB, 'host-token'), 'dsh0 keeps the historical token file')
|
|
760
|
+
assert(
|
|
761
|
+
hostTokenPath(cfg, tmpPeerB, 'dsh1') === join(tmpPeerB, 'host-token-dsh1'),
|
|
762
|
+
'other ids get their own token file'
|
|
763
|
+
)
|
|
764
|
+
assert(resolveHostInstance(cfg, 'dsh1').id === 'dsh1', 'resolve dsh1 by id')
|
|
765
|
+
assert(resolveHostInstance(cfg, undefined).id === 'dsh0', 'default instance is the first enabled one')
|
|
766
|
+
let threw = null
|
|
767
|
+
try {
|
|
768
|
+
resolveHostInstance(cfg, 'dsh9')
|
|
769
|
+
} catch (error) {
|
|
770
|
+
threw = error
|
|
771
|
+
}
|
|
772
|
+
assert(threw && threw.code === 'E_HOST_UNKNOWN', `expected E_HOST_UNKNOWN, got ${threw?.code}`)
|
|
773
|
+
} finally {
|
|
774
|
+
rmSync(tmpPeerB, { recursive: true, force: true })
|
|
775
|
+
}
|
|
776
|
+
})
|
|
777
|
+
|
|
778
|
+
await check('config v6 -> v7 adds repair defaults and derives hosts[].profileDir', () => {
|
|
779
|
+
const tmpPeerB = mkdtempSync(join(tmpdir(), 'mesh-cfg-v7-'))
|
|
780
|
+
try {
|
|
781
|
+
writeFileSync(
|
|
782
|
+
configPath(tmpPeerB),
|
|
783
|
+
JSON.stringify(
|
|
784
|
+
{
|
|
785
|
+
version: 6,
|
|
786
|
+
hosts: [{ id: 'dsh0', label: 'custom', enabled: true, url: 'http://127.0.0.1:9112', tokenFile: '', timeoutMs: 5000 }]
|
|
787
|
+
},
|
|
788
|
+
null,
|
|
789
|
+
2
|
|
790
|
+
),
|
|
791
|
+
'utf8'
|
|
792
|
+
)
|
|
793
|
+
const cfg = loadConfig(tmpPeerB, { log: quiet })
|
|
794
|
+
assert(cfg.version === 7, `expected version 7, got ${cfg.version}`)
|
|
795
|
+
assert(cfg.repair && cfg.repair.enabled === false, 'repair must default to disabled')
|
|
796
|
+
assert(Array.isArray(cfg.repair.restartScripts) === false && typeof cfg.repair.restartScripts === 'object', 'restartScripts map')
|
|
797
|
+
assert(cfg.hosts[0].url === 'http://127.0.0.1:9112', 'custom bridge should survive')
|
|
798
|
+
assert(
|
|
799
|
+
String(cfg.hosts[0].profileDir).endsWith(join('profiles', 'web')) && String(cfg.hosts[0].profileDir).includes('.dsh0'),
|
|
800
|
+
`profileDir should be derived for dsh0, got ${cfg.hosts[0].profileDir}`
|
|
801
|
+
)
|
|
802
|
+
} finally {
|
|
803
|
+
rmSync(tmpPeerB, { recursive: true, force: true })
|
|
804
|
+
}
|
|
805
|
+
})
|
|
806
|
+
|
|
807
|
+
await check('repair payload validation + patch wiring are strict and idempotent', () => {
|
|
808
|
+
const files = normalizePluginFiles([
|
|
809
|
+
{ path: 'package.json', content: '{}' },
|
|
810
|
+
{ path: 'lib/index.js', content: 'export function apply() {}' }
|
|
811
|
+
])
|
|
812
|
+
assert(files.length === 2 && files[1].rel === 'lib/index.js', 'files normalise to relative paths')
|
|
813
|
+
for (const bad of [
|
|
814
|
+
[{ path: '../evil.js', content: 'x' }],
|
|
815
|
+
[{ path: 'C:/evil.js', content: 'x' }],
|
|
816
|
+
[{ path: 'a/b.txt', content: 'x' }, { path: 'A/B.txt', content: 'y' }],
|
|
817
|
+
[{ path: 'big.js', content: 'x'.repeat(2048) }]
|
|
818
|
+
]) {
|
|
819
|
+
let threw = null
|
|
820
|
+
try {
|
|
821
|
+
normalizePluginFiles(bad, { maxFileBytes: 1024 })
|
|
822
|
+
} catch (error) {
|
|
823
|
+
threw = error
|
|
824
|
+
}
|
|
825
|
+
assert(threw && threw.code === 'E_INVALID_ARG', `expected E_INVALID_ARG for ${JSON.stringify(bad[0])}`)
|
|
826
|
+
}
|
|
827
|
+
const first = applyPatchInsert('', 'dsh-mesh-host')
|
|
828
|
+
assert(first.changed === true && patchHasPlugin(first.text, 'dsh-mesh-host'), 'empty patch gets wired')
|
|
829
|
+
const second = applyPatchInsert(first.text, 'dsh-mesh-host')
|
|
830
|
+
assert(second.changed === false, 'wiring twice is a no-op')
|
|
831
|
+
const kept = applyPatchInsert('- insert:\n - id: other\n name: other-plugin\n', 'dsh-mesh-host')
|
|
832
|
+
assert(kept.changed === true && kept.backup === true, 'existing entries are preserved and flagged for backup')
|
|
833
|
+
assert(kept.text.includes('other-plugin') && kept.text.includes('dsh-mesh-host'), 'both plugins present')
|
|
834
|
+
let escaped = null
|
|
835
|
+
try {
|
|
836
|
+
assertInside(join(tmpdir(), 'root'), pluginRoot(join(tmpdir(), 'root'), 'x').replace('root', 'escape'))
|
|
837
|
+
} catch (error) {
|
|
838
|
+
escaped = error
|
|
839
|
+
}
|
|
840
|
+
assert(escaped && escaped.code === 'E_INVALID_ARG', 'assertInside must reject paths outside the root')
|
|
841
|
+
})
|
|
842
|
+
|
|
843
|
+
await check('tools: host_session_archive is exposed but denied by default', async () => {
|
|
844
|
+
const config = defaultConfig()
|
|
845
|
+
const tools = buildAll({ getConfig: () => config, home: join(tmpdir(), 'selftest-tools'), peerId: 'selftest', log: quiet })
|
|
846
|
+
const archive = tools.find((tool) => tool.name === 'host_session_archive')
|
|
847
|
+
assert(archive, 'host_session_archive missing from the tool manifest')
|
|
848
|
+
assert(archive.handler instanceof Function, 'host_session_archive has no handler')
|
|
849
|
+
assert(!isAllowed(config, 'host_session_archive'), 'host_session_archive must be denied by default')
|
|
850
|
+
})
|
|
851
|
+
|
|
852
|
+
const failed = results.filter((r) => !r.ok)
|
|
853
|
+
console.log(`\nselftest: ${results.length - failed.length}/${results.length} passed`)
|
|
854
|
+
if (failed.length > 0) {
|
|
855
|
+
for (const f of failed) console.log(` FAILED: ${f.name} — ${f.error}`)
|
|
856
|
+
return 1
|
|
857
|
+
}
|
|
858
|
+
return 0
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
async function waitForJob(client, jobId, { attempts = 40 } = {}) {
|
|
862
|
+
for (let i = 0; i < attempts; i += 1) {
|
|
863
|
+
const job = parseToolResult(await client.callTool({ name: 'task_status', arguments: { jobId } }))
|
|
864
|
+
if (job.done) return job
|
|
865
|
+
await new Promise((r) => setTimeout(r, 100))
|
|
866
|
+
}
|
|
867
|
+
throw new Error(`job ${jobId} did not finish in time`)
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
if (import.meta.url === `file://${process.argv[1]?.replace(/\\/g, '/')}` || process.argv[1]?.endsWith('selftest.js')) {
|
|
871
|
+
runSelftest().then((code) => process.exit(code))
|
|
872
|
+
}
|