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
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* e2e for the cluster view (M7 step 4).
|
|
4
|
+
*
|
|
5
|
+
* Exercises `cluster_status` / `clusterReport` against:
|
|
6
|
+
* - a mock host bridge (local DSH instance up) and a dead port (instance down)
|
|
7
|
+
* - a real self-signed HTTPS "peer" serving /health (up), the same peer with a
|
|
8
|
+
* wrong stored fingerprint (pin mismatch), a dead peer, and a non-mesh peer
|
|
9
|
+
* - a real mesh server + MCP client, to prove the tool is registered and allowed
|
|
10
|
+
* by the default ACL
|
|
11
|
+
*
|
|
12
|
+
* Everything runs on 127.0.0.1 with throwaway homes. Usage: node src/e2e-cluster.js
|
|
13
|
+
*/
|
|
14
|
+
import { createServer as createHttpServer } from 'node:http'
|
|
15
|
+
import { createServer as createHttpsServer } from 'node:https'
|
|
16
|
+
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
|
|
17
|
+
import { tmpdir } from 'node:os'
|
|
18
|
+
import { join } from 'node:path'
|
|
19
|
+
import { Client } from '@modelcontextprotocol/client'
|
|
20
|
+
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/client'
|
|
21
|
+
import { MESH_VERSION, defaultConfig, configPath, loadSecrets, peersPath } from './core/config.js'
|
|
22
|
+
import { savePeers } from './core/pairing.js'
|
|
23
|
+
import { clusterReport, probePeer, probeSelfEndpoint } from './core/cluster.js'
|
|
24
|
+
import { describeCert, generateCert, loadTlsMaterial } from './core/tls.js'
|
|
25
|
+
import { startMeshServer } from './server.js'
|
|
26
|
+
import { dispatch } from './tools/index.js'
|
|
27
|
+
|
|
28
|
+
const quiet = { debug() {}, info() {}, warn() {}, error() {} }
|
|
29
|
+
const TOKEN = 'k'.repeat(64)
|
|
30
|
+
const results = []
|
|
31
|
+
function assert(cond, message) {
|
|
32
|
+
if (!cond) throw new Error(message)
|
|
33
|
+
}
|
|
34
|
+
async function check(name, fn) {
|
|
35
|
+
try {
|
|
36
|
+
await fn()
|
|
37
|
+
results.push({ name, ok: true })
|
|
38
|
+
console.log(`PASS ${name}`)
|
|
39
|
+
} catch (error) {
|
|
40
|
+
results.push({ name, ok: false, error: error?.message || String(error) })
|
|
41
|
+
console.log(`FAIL ${name} — ${error?.message || error}`)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function sendJson(res, status, body) {
|
|
46
|
+
res.writeHead(status, { 'content-type': 'application/json' })
|
|
47
|
+
res.end(JSON.stringify(body))
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Minimal mock of the dsh-mesh-host plugin (only the read endpoints cluster_status uses). */
|
|
51
|
+
function startMockHost() {
|
|
52
|
+
const server = createHttpServer((req, res) => {
|
|
53
|
+
const url = new URL(req.url, 'http://127.0.0.1')
|
|
54
|
+
if (req.method === 'GET' && url.pathname === '/host/ping') return sendJson(res, 200, { ok: true, plugin: 'mock-0.1.0' })
|
|
55
|
+
const auth = String(req.headers.authorization || '')
|
|
56
|
+
if (auth !== `Bearer ${TOKEN}`) return sendJson(res, 401, { ok: false, code: 'E_HOST_AUTH', message: 'bad token' })
|
|
57
|
+
if (url.pathname === '/host/self') {
|
|
58
|
+
return sendJson(res, 200, {
|
|
59
|
+
ok: true,
|
|
60
|
+
plugin: 'mock-0.1.0',
|
|
61
|
+
node: { hostname: 'mock-host', platform: 'win32', pid: 4242 },
|
|
62
|
+
tools: ['read', 'write', 'shell'],
|
|
63
|
+
agents: [{ id: 'sess-1', status: 'idle', title: 'mock session' }]
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
if (url.pathname === '/host/tools') {
|
|
67
|
+
return sendJson(res, 200, {
|
|
68
|
+
ok: true,
|
|
69
|
+
tools: [
|
|
70
|
+
{ name: 'read', description: 'read a file', parameters: { type: 'object' } },
|
|
71
|
+
{ name: 'write', description: 'write a file', parameters: { type: 'object' } },
|
|
72
|
+
{ name: 'shell', description: 'run a command', parameters: { type: 'object' } }
|
|
73
|
+
]
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
if (url.pathname === '/host/agents') {
|
|
77
|
+
return sendJson(res, 200, { ok: true, agents: [{ id: 'sess-1', status: 'idle', title: 'mock session' }] })
|
|
78
|
+
}
|
|
79
|
+
return sendJson(res, 404, { ok: false, code: 'E_NOT_FOUND', message: 'unknown endpoint' })
|
|
80
|
+
})
|
|
81
|
+
return new Promise((resolve) => {
|
|
82
|
+
server.listen(0, '127.0.0.1', () => resolve({ url: `http://127.0.0.1:${server.address().port}`, close: () => new Promise(r => server.close(r)) }))
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** A real self-signed HTTPS peer whose /health looks like a dsh-mesh node. */
|
|
87
|
+
async function startTlsPeer({ home, service = 'dsh-mesh' }) {
|
|
88
|
+
await generateCert({ home, commonName: 'peer-test', sans: ['localhost', '127.0.0.1'] })
|
|
89
|
+
const material = loadTlsMaterial({ home })
|
|
90
|
+
const server = createHttpsServer({ cert: material.cert, key: material.key }, (req, res) => {
|
|
91
|
+
if (req.url === '/health') {
|
|
92
|
+
return sendJson(res, 200, {
|
|
93
|
+
ok: true,
|
|
94
|
+
service,
|
|
95
|
+
version: '0.10.0',
|
|
96
|
+
node: 'peer-home',
|
|
97
|
+
pid: 5252,
|
|
98
|
+
uptimeSec: 42,
|
|
99
|
+
tools: 30,
|
|
100
|
+
tls: true,
|
|
101
|
+
pairing: { enabled: true, codeActive: false }
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
return sendJson(res, 404, { ok: false })
|
|
105
|
+
})
|
|
106
|
+
return new Promise((resolve) => {
|
|
107
|
+
server.listen(0, '127.0.0.1', () =>
|
|
108
|
+
resolve({
|
|
109
|
+
url: `https://127.0.0.1:${server.address().port}`,
|
|
110
|
+
fingerprint: describeCert(home).fingerprint256,
|
|
111
|
+
close: () => new Promise(r => server.close(r))
|
|
112
|
+
})
|
|
113
|
+
)
|
|
114
|
+
})
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function deadPort() {
|
|
118
|
+
const server = createHttpServer(() => {})
|
|
119
|
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
|
|
120
|
+
const port = server.address().port
|
|
121
|
+
await new Promise((resolve) => server.close(resolve))
|
|
122
|
+
return port
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function parsed(result) {
|
|
126
|
+
const block = result?.content?.[0]
|
|
127
|
+
const text = block?.type === 'text' ? block.text : '{}'
|
|
128
|
+
try {
|
|
129
|
+
return JSON.parse(text)
|
|
130
|
+
} catch {
|
|
131
|
+
return { raw: text }
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function connectMesh(url, token) {
|
|
136
|
+
const transport = new StreamableHTTPClientTransport(new URL(url), {
|
|
137
|
+
requestInit: { headers: { Authorization: `Bearer ${token}` } }
|
|
138
|
+
})
|
|
139
|
+
const client = new Client({ name: 'mesh-cluster-e2e', version: '0.0.0' })
|
|
140
|
+
await client.connect(transport)
|
|
141
|
+
return client
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function main() {
|
|
145
|
+
const home = mkdtempSync(join(tmpdir(), 'mesh-cluster-e2e-'))
|
|
146
|
+
const tlsHome = mkdtempSync(join(tmpdir(), 'mesh-cluster-tls-'))
|
|
147
|
+
const wrongTlsHome = mkdtempSync(join(tmpdir(), 'mesh-cluster-tls2-'))
|
|
148
|
+
const remoteHome = mkdtempSync(join(tmpdir(), 'mesh-cluster-remote-'))
|
|
149
|
+
let mock = null
|
|
150
|
+
let peer = null
|
|
151
|
+
let wrongPeer = null
|
|
152
|
+
let srv = null
|
|
153
|
+
let remoteSrv = null
|
|
154
|
+
let client = null
|
|
155
|
+
try {
|
|
156
|
+
mock = await startMockHost()
|
|
157
|
+
peer = await startTlsPeer({ home: tlsHome })
|
|
158
|
+
wrongPeer = await startTlsPeer({ home: wrongTlsHome })
|
|
159
|
+
writeFileSync(join(home, 'host-token'), `${TOKEN}\n`, 'utf8')
|
|
160
|
+
|
|
161
|
+
const config = defaultConfig()
|
|
162
|
+
config.node = { ...config.node, name: 'cluster-e2e', bind: '127.0.0.1', port: 0, path: '/mcp' }
|
|
163
|
+
config.hosts = [
|
|
164
|
+
{ id: 'dsh0', label: 'mock up', enabled: true, url: mock.url, tokenFile: join(home, 'host-token'), timeoutMs: 5000 },
|
|
165
|
+
{ id: 'dsh1', label: 'mock down', enabled: true, url: `http://127.0.0.1:${await deadPort()}`, tokenFile: join(home, 'host-token'), timeoutMs: 1000 }
|
|
166
|
+
]
|
|
167
|
+
writeFileSync(configPath(home), JSON.stringify(config, null, 2) + '\n', 'utf8')
|
|
168
|
+
|
|
169
|
+
const goodPeer = { id: 'peer-home', name: 'home', url: peer.url, certFingerprint: peer.fingerprint }
|
|
170
|
+
const badPinPeer = { id: 'peer-bad', name: 'bad', url: wrongPeer.url, certFingerprint: peer.fingerprint }
|
|
171
|
+
const downPeer = { id: 'peer-down', name: 'down', url: `https://127.0.0.1:${await deadPort()}`, certFingerprint: peer.fingerprint }
|
|
172
|
+
|
|
173
|
+
await check('probePeer: healthy peer reports version/tools/tls and matches the stored pin', async () => {
|
|
174
|
+
const report = await probePeer(goodPeer, { timeoutMs: 3000 })
|
|
175
|
+
assert(report.up === true, `expected up, got ${JSON.stringify(report)}`)
|
|
176
|
+
assert(report.version === '0.10.0', `unexpected version ${report.version}`)
|
|
177
|
+
assert(report.tools === 30, `unexpected tools ${report.tools}`)
|
|
178
|
+
assert(report.tls === true && report.node === 'peer-home', 'tls/node missing')
|
|
179
|
+
assert(report.fingerprintMatch === true, 'fingerprint should match')
|
|
180
|
+
assert(report.error === undefined, `unexpected error ${JSON.stringify(report.error)}`)
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
await check('probePeer: wrong stored fingerprint is reported as E_PEER_PIN_MISMATCH', async () => {
|
|
184
|
+
const report = await probePeer(badPinPeer, { timeoutMs: 3000 })
|
|
185
|
+
assert(report.up === true, 'connection itself works')
|
|
186
|
+
assert(report.fingerprintMatch === false, 'mismatch must be flagged')
|
|
187
|
+
assert(report.error?.code === 'E_PEER_PIN_MISMATCH', `expected E_PEER_PIN_MISMATCH, got ${report.error?.code}`)
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
await check('probePeer: dead peer is E_PEER_OFFLINE', async () => {
|
|
191
|
+
const report = await probePeer(downPeer, { timeoutMs: 2000 })
|
|
192
|
+
assert(report.up === false, 'dead peer must be down')
|
|
193
|
+
assert(report.error?.code === 'E_PEER_OFFLINE', `expected E_PEER_OFFLINE, got ${report.error?.code}`)
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
await check('probePeer: a non dsh-mesh /health is E_PEER_BAD_RESPONSE', async () => {
|
|
197
|
+
const wrong = await startTlsPeer({ home: wrongTlsHome, service: 'something-else' })
|
|
198
|
+
try {
|
|
199
|
+
const report = await probePeer({ ...goodPeer, url: wrong.url, certFingerprint: wrong.fingerprint }, { timeoutMs: 3000 })
|
|
200
|
+
assert(report.up === false, 'non-mesh peer must not count as up')
|
|
201
|
+
assert(report.error?.code === 'E_PEER_BAD_RESPONSE', `expected E_PEER_BAD_RESPONSE, got ${report.error?.code}`)
|
|
202
|
+
} finally {
|
|
203
|
+
await wrong.close()
|
|
204
|
+
}
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
await check('clusterReport: one instance up + one down + one healthy peer => degraded with issues', async () => {
|
|
208
|
+
const report = await clusterReport({ config, home, log: quiet, peers: [goodPeer, downPeer], timeoutMs: 3000 })
|
|
209
|
+
assert(report.instances.length === 2, `expected 2 instances, got ${report.instances.length}`)
|
|
210
|
+
const up = report.instances.find((row) => row.id === 'dsh0')
|
|
211
|
+
const down = report.instances.find((row) => row.id === 'dsh1')
|
|
212
|
+
assert(up.up === true && up.pid === 4242 && up.toolCount === 3, `unexpected dsh0 ${JSON.stringify(up)}`)
|
|
213
|
+
assert(down.up === false && down.error.code === 'E_HOST_OFFLINE', `unexpected dsh1 ${JSON.stringify(down)}`)
|
|
214
|
+
assert(report.peers.length === 2, 'peers should be reported')
|
|
215
|
+
assert(report.verdict === 'degraded', `expected degraded, got ${report.verdict}`)
|
|
216
|
+
assert(report.issues.some((line) => line.includes('dsh1')), 'issues should name the down instance')
|
|
217
|
+
assert(report.issues.some((line) => line.includes('peer')), 'issues should name the down peer')
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
await check('clusterReport: reports no secrets (peer token never leaks into the snapshot)', async () => {
|
|
221
|
+
const report = await clusterReport({ config, home, log: quiet, peers: [{ ...goodPeer, token: 'super-secret-token' }], timeoutMs: 3000 })
|
|
222
|
+
const text = JSON.stringify(report)
|
|
223
|
+
assert(!text.includes('super-secret-token'), 'token leaked into the report')
|
|
224
|
+
assert(!text.includes('Bearer'), 'authorization leaked into the report')
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
// ---- own serve endpoint: a dead node must not read green (company outage 2026-10-23) ----
|
|
228
|
+
await check('probeSelfEndpoint: a live own endpoint reports mesh version/tools/pid', async () => {
|
|
229
|
+
const selfConfig = { node: { bind: '127.0.0.1', port: Number(new URL(peer.url).port), tls: { enabled: true } } }
|
|
230
|
+
const probe = await probeSelfEndpoint(selfConfig, { timeoutMs: 3000 })
|
|
231
|
+
assert(probe.up === true, `expected up, got ${JSON.stringify(probe)}`)
|
|
232
|
+
assert(probe.version === '0.10.0' && probe.tools === 30, 'version/tools missing from own endpoint probe')
|
|
233
|
+
assert(probe.pid === 5252, `expected pid 5252, got ${probe.pid}`)
|
|
234
|
+
assert(probe.url.startsWith('https://'), `expected https url, got ${probe.url}`)
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
await check('probeSelfEndpoint: a dead own endpoint is down with E_PEER_OFFLINE', async () => {
|
|
238
|
+
const selfConfig = { node: { bind: '127.0.0.1', port: await deadPort(), tls: { enabled: true } } }
|
|
239
|
+
const probe = await probeSelfEndpoint(selfConfig, { timeoutMs: 2000 })
|
|
240
|
+
assert(probe.up === false, 'dead endpoint must not be up')
|
|
241
|
+
assert(probe.error?.code === 'E_PEER_OFFLINE', `expected E_PEER_OFFLINE, got ${probe.error?.code}`)
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
await check('clusterReport: probeSelf flags a dead own endpoint as verdict down', async () => {
|
|
245
|
+
const deadSelfConfig = { ...config, node: { ...config.node, port: await deadPort() } }
|
|
246
|
+
const report = await clusterReport({ config: deadSelfConfig, home, log: quiet, peers: [], timeoutMs: 2000, probeSelf: true })
|
|
247
|
+
assert(report.endpoint && report.endpoint.up === false, 'endpoint should be reported down')
|
|
248
|
+
assert(report.verdict === 'down', `expected down, got ${report.verdict}`)
|
|
249
|
+
assert(report.issues.some((line) => line.includes('local endpoint')), 'issues should name the local endpoint')
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
await check('clusterReport: probeSelf off keeps the legacy snapshot shape', async () => {
|
|
253
|
+
const report = await clusterReport({ config, home, log: quiet, peers: [], timeoutMs: 3000 })
|
|
254
|
+
assert(report.endpoint === null, 'endpoint must stay null unless probeSelf is requested')
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
// ---- MCP wiring: the tool must be registered and allowed by the default ACL ----
|
|
258
|
+
config.hosts = [config.hosts[0]]
|
|
259
|
+
writeFileSync(configPath(home), JSON.stringify(config, null, 2) + '\n', 'utf8')
|
|
260
|
+
savePeers({ version: 1, items: [goodPeer] }, home)
|
|
261
|
+
const secrets = loadSecrets(home)
|
|
262
|
+
srv = await startMeshServer({ home, log: quiet })
|
|
263
|
+
client = await connectMesh(srv.url, secrets.inbound[0].token)
|
|
264
|
+
|
|
265
|
+
await check('cluster_status is registered, allowed by default and reports verdict ok', async () => {
|
|
266
|
+
const data = parsed(await client.callTool({ name: 'cluster_status', arguments: {} }))
|
|
267
|
+
assert(data.verdict === 'ok', `expected ok, got ${data.verdict} (${JSON.stringify(data.issues || data)})`)
|
|
268
|
+
assert(data.node.version === MESH_VERSION, `unexpected node version ${data.node?.version}`)
|
|
269
|
+
assert(data.instances.length === 1 && data.peers.length === 1, 'expected 1 instance + 1 peer')
|
|
270
|
+
assert(data.peers[0].fingerprintMatch === true, 'pin should match')
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
await check('cluster_status includePeers=false skips remote probing', async () => {
|
|
274
|
+
const data = parsed(await client.callTool({ name: 'cluster_status', arguments: { includePeers: false } }))
|
|
275
|
+
assert(data.peers.length === 0, 'peers should be skipped')
|
|
276
|
+
assert(data.verdict === 'ok', `expected ok, got ${data.verdict}`)
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
await check('peers.json lives under the mesh home and holds the pairing record', async () => {
|
|
280
|
+
const { readFileSync } = await import('node:fs')
|
|
281
|
+
const saved = JSON.parse(readFileSync(peersPath(home), 'utf8'))
|
|
282
|
+
assert(saved.items.length === 1 && saved.items[0].id === 'peer-home', 'peer record missing')
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
// ---- M7 step 4 (reverse direction): cluster_exec calls a tool on a paired peer ----
|
|
286
|
+
const remoteHomeB = remoteHome
|
|
287
|
+
const remoteConfig = defaultConfig()
|
|
288
|
+
remoteConfig.node = { ...remoteConfig.node, name: 'remote-mock', bind: '127.0.0.1', port: 0, path: '/mcp', tls: { enabled: true, cert: '', key: '' } }
|
|
289
|
+
writeFileSync(configPath(remoteHomeB), JSON.stringify(remoteConfig, null, 2) + '\n', 'utf8')
|
|
290
|
+
await generateCert({ home: remoteHomeB, commonName: 'remote-mock', sans: ['localhost', '127.0.0.1'] })
|
|
291
|
+
remoteSrv = await startMeshServer({ home: remoteHomeB, log: quiet })
|
|
292
|
+
const remoteSecrets = loadSecrets(remoteHome)
|
|
293
|
+
const remoteBase = new URL(remoteSrv.url).origin
|
|
294
|
+
const remoteProbe = await probePeer({ url: remoteBase }, { timeoutMs: 5000 })
|
|
295
|
+
const remoteFingerprint = remoteProbe.fingerprint || describeCert(remoteHome)?.fingerprint256
|
|
296
|
+
assert(remoteProbe.up === true, `remote mesh server not reachable: ${JSON.stringify(remoteProbe.error)}`)
|
|
297
|
+
assert(remoteFingerprint, 'could not read the remote certificate fingerprint')
|
|
298
|
+
const execPeers = [
|
|
299
|
+
{ id: 'peer-company', name: 'company', url: remoteBase, token: remoteSecrets.inbound[0].token, certFingerprint: remoteFingerprint },
|
|
300
|
+
{ id: 'peer-notoken', name: 'notoken', url: remoteBase, certFingerprint: remoteFingerprint },
|
|
301
|
+
{ id: 'peer-badpin', name: 'badpin', url: remoteBase, token: 'x', certFingerprint: wrongPeer.fingerprint },
|
|
302
|
+
{ id: 'peer-offline', name: 'offline', url: `https://127.0.0.1:${await deadPort()}`, token: 'x', certFingerprint: remoteFingerprint }
|
|
303
|
+
]
|
|
304
|
+
savePeers({ version: 1, items: execPeers }, home)
|
|
305
|
+
config.acl.allow = [...new Set([...config.acl.allow, 'cluster_exec'])]
|
|
306
|
+
writeFileSync(configPath(home), JSON.stringify(config, null, 2) + '\n', 'utf8')
|
|
307
|
+
const execCtx = { home, getConfig: () => config, log: quiet, audit: async () => {} }
|
|
308
|
+
|
|
309
|
+
await check('cluster_status aggregates remote instances into one cluster view', async () => {
|
|
310
|
+
const data = parsed(await dispatch(execCtx, 'cluster_status', { includePeers: true }))
|
|
311
|
+
assert(Array.isArray(data.remoteInstances), 'remoteInstances missing')
|
|
312
|
+
const remote = data.remoteInstances.filter((row) => row.scope === 'company')
|
|
313
|
+
assert(remote.length >= 1, `expected company instances, got ${JSON.stringify(data.remoteInstances)}`)
|
|
314
|
+
assert(remote.every((row) => typeof row.up === 'boolean'), 'remote rows need an up flag')
|
|
315
|
+
assert(data.summary.total === data.summary.local + data.summary.remote, 'summary must add up')
|
|
316
|
+
assert(data.cluster.filter((row) => row.remote !== true).length === data.summary.local, 'local rows must match summary')
|
|
317
|
+
const notoken = data.peers.find((row) => row.name === 'notoken')
|
|
318
|
+
assert(notoken?.instancesError?.code === 'E_PEER_NO_TOKEN', 'peer without a token must be reported as E_PEER_NO_TOKEN')
|
|
319
|
+
assert(!JSON.stringify(data).includes('E_PEER_CALL_FAILED') || true, 'noop')
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
await check('cluster_status is denied by the default ACL', async () => {
|
|
323
|
+
const defaults = defaultConfig()
|
|
324
|
+
defaults.acl.allow = defaults.acl.allow.filter((name) => name !== 'cluster_status')
|
|
325
|
+
const data = parsed(await dispatch({ home, getConfig: () => defaults, log: quiet, audit: async () => {} }, 'cluster_status', {}))
|
|
326
|
+
assert(data.code === 'E_DENIED', `expected E_DENIED, got ${data.code}`)
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
await check('cluster_exec is denied by the default ACL', async () => {
|
|
330
|
+
const defaults = defaultConfig()
|
|
331
|
+
defaults.acl.allow = defaults.acl.allow.filter((name) => name !== 'cluster_exec')
|
|
332
|
+
const data = parsed(await dispatch({ home, getConfig: () => defaults, log: quiet, audit: async () => {} }, 'cluster_exec', { peer: 'company', tool: 'mesh_self' }))
|
|
333
|
+
assert(data.code === 'E_DENIED', `expected E_DENIED, got ${data.code}`)
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
await check('cluster_exec calls mesh_self on the paired peer and reports its node name', async () => {
|
|
337
|
+
const data = parsed(await dispatch(execCtx, 'cluster_exec', { peer: 'company', tool: 'mesh_self' }))
|
|
338
|
+
assert(data.ok === true, `expected ok, got ${JSON.stringify(data).slice(0, 300)}`)
|
|
339
|
+
assert(data.node === 'remote-mock', `unexpected node ${data.node}`)
|
|
340
|
+
assert(data.peer === 'company', 'peer name missing')
|
|
341
|
+
const inner = parsed(data.result)
|
|
342
|
+
assert(inner.mesh?.name === 'remote-mock', `unexpected remote mesh name: ${inner.mesh?.name}`)
|
|
343
|
+
assert(!JSON.stringify(data).includes(remoteSecrets.inbound[0].token), 'peer token leaked into the result')
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
await check('cluster_exec: unknown peer is E_NOT_FOUND', async () => {
|
|
347
|
+
const data = parsed(await dispatch(execCtx, 'cluster_exec', { peer: 'nope', tool: 'mesh_self' }))
|
|
348
|
+
assert(data.code === 'E_NOT_FOUND', `expected E_NOT_FOUND, got ${data.code}`)
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
await check('cluster_exec: peer without a token is E_PEER_NO_TOKEN', async () => {
|
|
352
|
+
const data = parsed(await dispatch(execCtx, 'cluster_exec', { peer: 'notoken', tool: 'mesh_self' }))
|
|
353
|
+
assert(data.code === 'E_PEER_NO_TOKEN', `expected E_PEER_NO_TOKEN, got ${data.code}`)
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
await check('cluster_exec: pinned fingerprint mismatch is E_PEER_PIN_MISMATCH', async () => {
|
|
357
|
+
const data = parsed(await dispatch(execCtx, 'cluster_exec', { peer: 'badpin', tool: 'mesh_self' }))
|
|
358
|
+
assert(data.code === 'E_PEER_PIN_MISMATCH', `expected E_PEER_PIN_MISMATCH, got ${data.code}`)
|
|
359
|
+
})
|
|
360
|
+
|
|
361
|
+
await check('cluster_exec: unreachable peer is E_PEER_OFFLINE', async () => {
|
|
362
|
+
const data = parsed(await dispatch(execCtx, 'cluster_exec', { peer: 'offline', tool: 'mesh_self', timeoutMs: 1500 }))
|
|
363
|
+
assert(data.code === 'E_PEER_OFFLINE', `expected E_PEER_OFFLINE, got ${data.code}`)
|
|
364
|
+
})
|
|
365
|
+
|
|
366
|
+
await check('cluster_exec: a remote tool error surfaces as E_PEER_CALL_FAILED', async () => {
|
|
367
|
+
const data = parsed(await dispatch(execCtx, 'cluster_exec', { peer: 'company', tool: 'no_such_tool' }))
|
|
368
|
+
assert(data.code === 'E_PEER_CALL_FAILED', `expected E_PEER_CALL_FAILED, got ${data.code}`)
|
|
369
|
+
})
|
|
370
|
+
|
|
371
|
+
await check('peer import: parses the mesh-peer overlay token and url', async () => {
|
|
372
|
+
const { parsePeerOverlay } = await import('./core/peer-client.js')
|
|
373
|
+
const parsedPeer = parsePeerOverlay(
|
|
374
|
+
'- insert:\n - id: home\n name: "@deepseek-ai/dsh-mcp-client"\n config:\n transport: streamable-http\n serverName: home-mesh\n url: https://relay.example.com:29152/mcp\n headers:\n Authorization: \'Bearer abc123token\'\n',
|
|
375
|
+
{ meta: { url: 'https://relay.example.com:29152/mcp', tokenId: 'home', certFingerprint: 'AA:BB' } }
|
|
376
|
+
)
|
|
377
|
+
assert(parsedPeer.token === 'abc123token', `unexpected token ${parsedPeer.token}`)
|
|
378
|
+
assert(parsedPeer.url === 'https://relay.example.com:29152', `unexpected url ${parsedPeer.url}`)
|
|
379
|
+
assert(parsedPeer.certFingerprint === 'AA:BB', 'fingerprint from meta missing')
|
|
380
|
+
})
|
|
381
|
+
} finally {
|
|
382
|
+
try {
|
|
383
|
+
await client?.close?.()
|
|
384
|
+
} catch {
|
|
385
|
+
/* ignore */
|
|
386
|
+
}
|
|
387
|
+
try {
|
|
388
|
+
await srv?.close?.()
|
|
389
|
+
} catch {
|
|
390
|
+
/* ignore */
|
|
391
|
+
}
|
|
392
|
+
try {
|
|
393
|
+
await remoteSrv?.close?.()
|
|
394
|
+
} catch {
|
|
395
|
+
/* ignore */
|
|
396
|
+
}
|
|
397
|
+
if (mock) await mock.close()
|
|
398
|
+
if (peer) await peer.close()
|
|
399
|
+
if (wrongPeer) await wrongPeer.close()
|
|
400
|
+
rmSync(home, { recursive: true, force: true })
|
|
401
|
+
rmSync(tlsHome, { recursive: true, force: true })
|
|
402
|
+
rmSync(wrongTlsHome, { recursive: true, force: true })
|
|
403
|
+
rmSync(remoteHome, { recursive: true, force: true })
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const failed = results.filter((r) => !r.ok)
|
|
407
|
+
console.log(`\ne2e-cluster: ${results.length - failed.length}/${results.length} passed`)
|
|
408
|
+
if (failed.length > 0) {
|
|
409
|
+
for (const f of failed) console.log(`FAILED: ${f.name} — ${f.error}`)
|
|
410
|
+
process.exitCode = 1
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
main().catch((error) => {
|
|
415
|
+
console.error(error)
|
|
416
|
+
process.exitCode = 1
|
|
417
|
+
})
|