dsh-mesh-node 0.0.0-stage → 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +341 -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/sakura-api.js +103 -0
- package/src/core/session-profile.js +146 -0
- package/src/core/tls.js +160 -0
- package/src/core/tunnels.js +212 -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 +317 -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/e2e-pair.js
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* e2e for pairing (one-time code -> per-peer token) and per-token ACL.
|
|
4
|
+
*
|
|
5
|
+
* Everything runs against a real dsh-mesh server on 127.0.0.1 with a throwaway
|
|
6
|
+
* DSH_MESH_HOME, so no real peer or model is involved. Bad samples are first-class:
|
|
7
|
+
* wrong code, reused code, exhausted attempts, expired code, disabled pairing,
|
|
8
|
+
* narrowed ACL, revoked token.
|
|
9
|
+
*
|
|
10
|
+
* Usage: node src/e2e-pair.js
|
|
11
|
+
*/
|
|
12
|
+
import { mkdtempSync, rmSync, writeFileSync, readFileSync, statSync, readdirSync } from 'node:fs'
|
|
13
|
+
import { tmpdir } from 'node:os'
|
|
14
|
+
import { join } from 'node:path'
|
|
15
|
+
import { Client } from '@modelcontextprotocol/client'
|
|
16
|
+
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/client'
|
|
17
|
+
import { startMeshServer } from './server.js'
|
|
18
|
+
import {
|
|
19
|
+
defaultConfig,
|
|
20
|
+
addInboundToken,
|
|
21
|
+
removeInboundToken,
|
|
22
|
+
loadSecrets,
|
|
23
|
+
pairingPath,
|
|
24
|
+
saveConfig
|
|
25
|
+
} from './core/config.js'
|
|
26
|
+
import { createPairingCode, readPairingState } from './core/pairing.js'
|
|
27
|
+
import { generateCert, describeCert } from './core/tls.js'
|
|
28
|
+
import { postJson } from './core/peer-http.js'
|
|
29
|
+
|
|
30
|
+
const quiet = { debug() {}, info() {}, warn() {}, error() {} }
|
|
31
|
+
const POSIX = process.platform !== 'win32'
|
|
32
|
+
const results = []
|
|
33
|
+
function assert(cond, message) {
|
|
34
|
+
if (!cond) throw new Error(message)
|
|
35
|
+
}
|
|
36
|
+
async function check(name, fn) {
|
|
37
|
+
try {
|
|
38
|
+
await fn()
|
|
39
|
+
results.push({ name, ok: true })
|
|
40
|
+
console.log(`PASS ${name}`)
|
|
41
|
+
} catch (error) {
|
|
42
|
+
results.push({ name, ok: false, error: error?.message || String(error) })
|
|
43
|
+
console.log(`FAIL ${name} — ${error?.message || error}`)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function freshConfig() {
|
|
48
|
+
const config = defaultConfig()
|
|
49
|
+
config.node.name = 'pair-test'
|
|
50
|
+
config.node.port = 0
|
|
51
|
+
config.node.tls = { enabled: false, cert: '', key: '' }
|
|
52
|
+
config.pairing = { enabled: true, codeTtlSec: 300, maxAttempts: 5 }
|
|
53
|
+
config.acl.fsRoots = [tmpdir()]
|
|
54
|
+
return config
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function pairRequest(port, body) {
|
|
58
|
+
const res = await fetch(`http://127.0.0.1:${port}/pair`, {
|
|
59
|
+
method: 'POST',
|
|
60
|
+
headers: { 'content-type': 'application/json' },
|
|
61
|
+
body: JSON.stringify(body)
|
|
62
|
+
})
|
|
63
|
+
let parsed
|
|
64
|
+
try {
|
|
65
|
+
parsed = await res.json()
|
|
66
|
+
} catch {
|
|
67
|
+
parsed = {}
|
|
68
|
+
}
|
|
69
|
+
return { status: res.status, body: parsed }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function connectMesh(port, token) {
|
|
73
|
+
const transport = new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${port}/mcp`), {
|
|
74
|
+
requestInit: { headers: { Authorization: `Bearer ${token}` } }
|
|
75
|
+
})
|
|
76
|
+
const client = new Client({ name: 'dsh-mesh-pair-e2e', version: '0.0.1' })
|
|
77
|
+
await client.connect(transport)
|
|
78
|
+
return client
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function parsedToolResult(result) {
|
|
82
|
+
const text = result?.content?.find?.((c) => c.type === 'text')?.text
|
|
83
|
+
return JSON.parse(text)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function runE2ePair() {
|
|
87
|
+
const home = mkdtempSync(join(tmpdir(), 'dsh-mesh-pair-'))
|
|
88
|
+
const tmpPeerB = mkdtempSync(join(tmpdir(), 'dsh-mesh-pair-tls-'))
|
|
89
|
+
const config = freshConfig()
|
|
90
|
+
saveConfig(config, home)
|
|
91
|
+
let srv
|
|
92
|
+
let srv2
|
|
93
|
+
const clients = []
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
srv = await startMeshServer({ home, log: quiet, port: 0 })
|
|
97
|
+
const port = srv.port
|
|
98
|
+
|
|
99
|
+
await check('health advertises pairing without exposing a code (bad sample: no code leak)', async () => {
|
|
100
|
+
const res = await fetch(`http://127.0.0.1:${port}/health`)
|
|
101
|
+
const body = await res.json()
|
|
102
|
+
assert(body.pairing?.enabled === true, 'pairing.enabled missing from /health')
|
|
103
|
+
assert(body.pairing?.codeActive === false, 'a code should not be active yet')
|
|
104
|
+
assert(!JSON.stringify(body).match(/[A-Z0-9]{4}-[A-Z0-9]{4}/), 'health leaked something code-shaped')
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
await check('pairing code has the human-friendly shape XXXX-XXXX', async () => {
|
|
108
|
+
const created = createPairingCode({ home, ttlSec: 300 })
|
|
109
|
+
assert(/^[A-Z0-9]{4}-[A-Z0-9]{4}$/.test(created.code), `unexpected code ${created.code}`)
|
|
110
|
+
assert(new Date(created.expiresAt).getTime() > Date.now(), 'code already expired')
|
|
111
|
+
globalThis.__pairCode = created.code
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
await check('wrong pairing code is rejected without saying why (bad sample)', async () => {
|
|
115
|
+
const res = await pairRequest(port, { code: 'ZZZZ-ZZZZ', peerName: 'intruder' })
|
|
116
|
+
assert(res.status === 401, `expected 401, got ${res.status}`)
|
|
117
|
+
assert(res.body.code === 'E_AUTH', `expected E_AUTH, got ${res.body.code}`)
|
|
118
|
+
assert(!('token' in res.body), 'a token leaked on a failed pairing')
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
await check('the right code is redeemed for a per-peer token', async () => {
|
|
122
|
+
const res = await pairRequest(port, { code: globalThis.__pairCode, peerName: 'company' })
|
|
123
|
+
assert(res.status === 200, `expected 200, got ${res.status} ${JSON.stringify(res.body)}`)
|
|
124
|
+
assert(res.body.ok === true, 'ok flag missing')
|
|
125
|
+
assert(typeof res.body.token === 'string' && res.body.token.length === 64, 'token is not 32 bytes hex')
|
|
126
|
+
assert(res.body.tokenId === 'company', `unexpected tokenId ${res.body.tokenId}`)
|
|
127
|
+
assert(res.body.url.endsWith('/mcp'), `unexpected url ${res.body.url}`)
|
|
128
|
+
assert(res.body.certFingerprint === null, 'plain http must not report a fingerprint')
|
|
129
|
+
globalThis.__pairedToken = res.body.token
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
await check('the paired token really connects over MCP', async () => {
|
|
133
|
+
const client = await connectMesh(port, globalThis.__pairedToken)
|
|
134
|
+
clients.push(client)
|
|
135
|
+
const listed = await client.listTools()
|
|
136
|
+
assert(Array.isArray(listed.tools) && listed.tools.length >= 20, `only ${listed.tools?.length} tools listed`)
|
|
137
|
+
globalThis.__pairedClient = client
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
await check('the same code cannot be redeemed twice (bad sample)', async () => {
|
|
141
|
+
const res = await pairRequest(port, { code: globalThis.__pairCode, peerName: 'copycat' })
|
|
142
|
+
assert(res.status === 401, `expected 401 on replay, got ${res.status}`)
|
|
143
|
+
assert(res.body.code === 'E_AUTH', `expected E_AUTH, got ${res.body.code}`)
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
await check('missing code is rejected (bad sample)', async () => {
|
|
147
|
+
const res = await pairRequest(port, { peerName: 'nocode' })
|
|
148
|
+
assert(res.status === 401, `expected 401, got ${res.status}`)
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
await check('guessing burns the code after maxAttempts (bad sample)', async () => {
|
|
152
|
+
const created = createPairingCode({ home, ttlSec: 300 })
|
|
153
|
+
let lastStatus = 0
|
|
154
|
+
let lastCode = ''
|
|
155
|
+
for (let i = 0; i < 5; i += 1) {
|
|
156
|
+
const bad = await pairRequest(port, { code: 'AAAA-AAAA', peerName: 'brute' })
|
|
157
|
+
lastStatus = bad.status
|
|
158
|
+
lastCode = bad.body.code
|
|
159
|
+
}
|
|
160
|
+
assert(lastStatus === 401, `expected 401 after guesses, got ${lastStatus}`)
|
|
161
|
+
assert(lastCode === 'E_MAX_ATTEMPTS', `expected E_MAX_ATTEMPTS, got ${lastCode}`)
|
|
162
|
+
const correct = await pairRequest(port, { code: created.code, peerName: 'brute' })
|
|
163
|
+
assert(correct.status === 401, 'a burnt code was still accepted')
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
await check('an expired code is rejected (bad sample)', async () => {
|
|
167
|
+
const created = createPairingCode({ home, ttlSec: 300 })
|
|
168
|
+
const file = pairingPath(home)
|
|
169
|
+
const record = JSON.parse(readFileSync(file, 'utf8'))
|
|
170
|
+
record.expiresAt = new Date(Date.now() - 1000).toISOString()
|
|
171
|
+
writeFileSync(file, JSON.stringify(record, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 })
|
|
172
|
+
const res = await pairRequest(port, { code: created.code, peerName: 'late' })
|
|
173
|
+
assert(res.status === 401, `expected 401 on expired code, got ${res.status}`)
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
await check('pairing.json stores only a hash, never the code (bad sample: leak)', async () => {
|
|
177
|
+
const created = createPairingCode({ home, ttlSec: 300 })
|
|
178
|
+
const text = readFileSync(pairingPath(home), 'utf8')
|
|
179
|
+
assert(!text.includes(created.code.replace('-', '')), 'the raw code is on disk')
|
|
180
|
+
assert(!text.includes(created.code), 'the formatted code is on disk')
|
|
181
|
+
// Node on Windows reports 0o666 for every file: chmod is advisory there, so the
|
|
182
|
+
// mode assertion only means something on POSIX.
|
|
183
|
+
if (POSIX) {
|
|
184
|
+
const mode = statSync(pairingPath(home)).mode & 0o777
|
|
185
|
+
assert((mode & 0o077) === 0, `pairing.json is too permissive: ${mode.toString(8)}`)
|
|
186
|
+
}
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
await check('pairing can be switched off (bad sample: disabled node)', async () => {
|
|
190
|
+
const offConfig = { ...config, pairing: { ...config.pairing, enabled: false } }
|
|
191
|
+
saveConfig(offConfig, home)
|
|
192
|
+
await new Promise((r) => setTimeout(r, 30)) // let the mtime-based reload notice
|
|
193
|
+
const created = createPairingCode({ home, ttlSec: 300 })
|
|
194
|
+
const res = await pairRequest(port, { code: created.code, peerName: 'nope' })
|
|
195
|
+
assert(res.status === 403, `expected 403 when pairing is off, got ${res.status}`)
|
|
196
|
+
saveConfig(config, home)
|
|
197
|
+
await new Promise((r) => setTimeout(r, 30))
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
await check('per-token ACL narrows what a peer can call', async () => {
|
|
201
|
+
const created = addInboundToken({ home, id: 'narrow', note: 'e2e', acl: { allow: ['mesh_self'] } })
|
|
202
|
+
const client = await connectMesh(port, created.token)
|
|
203
|
+
clients.push(client)
|
|
204
|
+
const denied = await client.callTool({ name: 'fs_read', arguments: { path: join(home, 'config.json') } })
|
|
205
|
+
const deniedBody = parsedToolResult(denied)
|
|
206
|
+
assert(denied.isError === true, 'fs_read should have been denied for the narrowed token')
|
|
207
|
+
assert(deniedBody.code === 'E_DENIED', `expected E_DENIED, got ${deniedBody.code}`)
|
|
208
|
+
const me = parsedToolResult(await client.callTool({ name: 'mesh_self', arguments: {} }))
|
|
209
|
+
assert(me.acl.you.tokenId === 'narrow', `mesh_self reported token ${me.acl.you.tokenId}`)
|
|
210
|
+
assert(me.acl.allow.length === 1 && me.acl.allow[0] === 'mesh_self', `narrowed allow leaked: ${me.acl.allow}`)
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
await check('a peer-specific deny wins even when the node allows the tool', async () => {
|
|
214
|
+
const created = addInboundToken({ home, id: 'denied-peer', note: 'e2e', acl: { deny: ['git_status'] } })
|
|
215
|
+
const client = await connectMesh(port, created.token)
|
|
216
|
+
clients.push(client)
|
|
217
|
+
const body = parsedToolResult(await client.callTool({ name: 'git_status', arguments: { path: home } }))
|
|
218
|
+
assert(body.code === 'E_DENIED', `expected E_DENIED, got ${body.code}`)
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
await check('revoking a token cuts access immediately (bad sample)', async () => {
|
|
222
|
+
const created = addInboundToken({ home, id: 'temp', note: 'e2e' })
|
|
223
|
+
const client = await connectMesh(port, created.token)
|
|
224
|
+
clients.push(client)
|
|
225
|
+
await client.listTools() // works before
|
|
226
|
+
removeInboundToken({ home, id: 'temp' })
|
|
227
|
+
let failed = false
|
|
228
|
+
try {
|
|
229
|
+
await client.listTools()
|
|
230
|
+
} catch {
|
|
231
|
+
failed = true
|
|
232
|
+
}
|
|
233
|
+
assert(failed, 'a revoked token still worked')
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
await check('TLS pairing: pinned fingerprint accepts a self-signed peer', async () => {
|
|
237
|
+
const config2 = defaultConfig()
|
|
238
|
+
config2.node.name = 'pair-tls'
|
|
239
|
+
config2.node.port = 0
|
|
240
|
+
config2.node.tls = { enabled: true, cert: '', key: '' }
|
|
241
|
+
config2.pairing = { enabled: true, codeTtlSec: 300, maxAttempts: 5 }
|
|
242
|
+
config2.acl.fsRoots = [tmpdir()]
|
|
243
|
+
await generateCert({ home: tmpPeerB, sans: ['127.0.0.1', 'localhost'], force: true })
|
|
244
|
+
saveConfig(config2, tmpPeerB)
|
|
245
|
+
srv2 = await startMeshServer({ home: tmpPeerB, log: quiet, port: 0 })
|
|
246
|
+
const fingerprint = describeCert(tmpPeerB).fingerprint256
|
|
247
|
+
const created = createPairingCode({ home: tmpPeerB, ttlSec: 120 })
|
|
248
|
+
const res = await postJson(
|
|
249
|
+
`https://127.0.0.1:${srv2.port}/pair`,
|
|
250
|
+
{ code: created.code, peerName: 'pinned' },
|
|
251
|
+
{ fingerprint }
|
|
252
|
+
)
|
|
253
|
+
assert(res.status === 200 && res.body.ok === true, `pinned pairing failed: ${res.status} ${JSON.stringify(res.body)}`)
|
|
254
|
+
assert(
|
|
255
|
+
res.fingerprint && res.fingerprint.replace(/:/g, '') === fingerprint.replace(/:/g, ''),
|
|
256
|
+
'the socket fingerprint does not match describeCert()'
|
|
257
|
+
)
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
await check('TLS pairing: a wrong pin aborts before the code is used (bad sample)', async () => {
|
|
261
|
+
const created = createPairingCode({ home: tmpPeerB, ttlSec: 120 })
|
|
262
|
+
let code = ''
|
|
263
|
+
try {
|
|
264
|
+
await postJson(
|
|
265
|
+
`https://127.0.0.1:${srv2.port}/pair`,
|
|
266
|
+
{ code: created.code, peerName: 'mitm' },
|
|
267
|
+
{ fingerprint: '11'.repeat(32) }
|
|
268
|
+
)
|
|
269
|
+
} catch (error) {
|
|
270
|
+
code = error?.code || error?.message || ''
|
|
271
|
+
}
|
|
272
|
+
assert(code === 'E_PIN_MISMATCH', `expected E_PIN_MISMATCH, got ${code}`)
|
|
273
|
+
const state = readPairingState(tmpPeerB)
|
|
274
|
+
assert(state && state.used === false && state.attempts === 0, 'the code was consumed even though the pin failed')
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
await check('advertising the peer token does not leak into audit values', async () => { const auditText = readFileSync(join(home, 'audit.jsonl'), 'utf8')
|
|
278
|
+
assert(auditText.includes('"tool":"pair"'), 'pairing was not audited')
|
|
279
|
+
assert(!auditText.includes(globalThis.__pairedToken), 'the token was written into the audit log')
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
await check('secrets.json holds the paired token with 0600', async () => {
|
|
283
|
+
const secrets = loadSecrets(home)
|
|
284
|
+
assert(secrets.inbound.some((e) => e.id === 'company'), 'paired token missing from secrets.json')
|
|
285
|
+
if (POSIX) {
|
|
286
|
+
const mode = statSync(join(home, 'secrets.json')).mode & 0o777
|
|
287
|
+
assert((mode & 0o077) === 0, `secrets.json is too permissive: ${mode.toString(8)}`)
|
|
288
|
+
}
|
|
289
|
+
const files = readdirSync(home)
|
|
290
|
+
assert(files.includes('pairing.json'), 'pairing.json missing')
|
|
291
|
+
})
|
|
292
|
+
} finally {
|
|
293
|
+
for (const client of clients) {
|
|
294
|
+
try {
|
|
295
|
+
await client.close?.()
|
|
296
|
+
} catch {
|
|
297
|
+
/* ignore */
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
if (srv) await srv.close()
|
|
301
|
+
if (srv2) await srv2.close()
|
|
302
|
+
rmSync(home, { recursive: true, force: true })
|
|
303
|
+
rmSync(tmpPeerB, { recursive: true, force: true })
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const failed = results.filter((r) => !r.ok)
|
|
307
|
+
console.log(`\ne2e-pair: ${results.length - failed.length}/${results.length} passed`)
|
|
308
|
+
if (failed.length > 0) {
|
|
309
|
+
for (const f of failed) console.log(` FAILED: ${f.name} — ${f.error}`)
|
|
310
|
+
return 1
|
|
311
|
+
}
|
|
312
|
+
return 0
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (process.argv[1]?.endsWith('e2e-pair.js')) {
|
|
316
|
+
runE2ePair().then((code) => process.exit(code))
|
|
317
|
+
}
|