dsh-mesh-node 0.0.0-stage → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +8 -2
  3. package/package.json +43 -4
  4. package/src/cli.js +1246 -0
  5. package/src/core/acl.js +81 -0
  6. package/src/core/audit.js +75 -0
  7. package/src/core/autostart.js +119 -0
  8. package/src/core/cluster.js +341 -0
  9. package/src/core/config.js +622 -0
  10. package/src/core/desktop-proxy.js +85 -0
  11. package/src/core/ds-web.js +394 -0
  12. package/src/core/host-agent.js +263 -0
  13. package/src/core/jobs.js +259 -0
  14. package/src/core/pairing.js +141 -0
  15. package/src/core/peer-client.js +273 -0
  16. package/src/core/peer-doctor.js +294 -0
  17. package/src/core/peer-http.js +61 -0
  18. package/src/core/repair.js +93 -0
  19. package/src/core/restart-policy.js +21 -0
  20. package/src/core/result.js +29 -0
  21. package/src/core/sakura-api.js +103 -0
  22. package/src/core/session-profile.js +146 -0
  23. package/src/core/tls.js +160 -0
  24. package/src/core/tunnels.js +212 -0
  25. package/src/core/util.js +68 -0
  26. package/src/e2e-autostart.js +58 -0
  27. package/src/e2e-channel.js +384 -0
  28. package/src/e2e-cluster.js +417 -0
  29. package/src/e2e-host.js +411 -0
  30. package/src/e2e-onboard.js +232 -0
  31. package/src/e2e-pair.js +317 -0
  32. package/src/e2e-plugin.js +395 -0
  33. package/src/e2e-repair.js +297 -0
  34. package/src/index.js +20 -0
  35. package/src/peer-bridge.js +124 -0
  36. package/src/selftest.js +872 -0
  37. package/src/server.js +317 -0
  38. package/src/tools/alerts.js +80 -0
  39. package/src/tools/channel.js +253 -0
  40. package/src/tools/cluster.js +235 -0
  41. package/src/tools/config-patch.js +182 -0
  42. package/src/tools/control-apps.js +469 -0
  43. package/src/tools/control-path.js +160 -0
  44. package/src/tools/control.js +232 -0
  45. package/src/tools/core.js +79 -0
  46. package/src/tools/delegate.js +653 -0
  47. package/src/tools/fs.js +436 -0
  48. package/src/tools/git.js +266 -0
  49. package/src/tools/host.js +305 -0
  50. package/src/tools/index.js +122 -0
  51. package/src/tools/repair-fs.js +130 -0
  52. package/src/tools/repair.js +317 -0
  53. package/src/tools/session-history.js +60 -0
  54. package/src/tools/shell.js +66 -0
  55. package/src/tools/tasks.js +117 -0
@@ -0,0 +1,384 @@
1
+ import { createServer } from 'node:http'
2
+ import { mkdtempSync, rmSync, writeFileSync, utimesSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { WebSocketServer } from 'ws'
6
+ import { Client } from '@modelcontextprotocol/client'
7
+ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/client'
8
+ import { DshWebClient, DshError } from './core/ds-web.js'
9
+ import { defaultConfig, loadSecrets, configPath } from './core/config.js'
10
+ import { startMeshServer } from './server.js'
11
+
12
+ /**
13
+ * Protocol-level end-to-end test for the M3 channel (route A).
14
+ *
15
+ * A mock stand-in for `dsh web` speaks the exact wire shapes from
16
+ * apps/web/tests/smoke-real.e2e.ts (launch URL -> 303 + cookie, POST /api/<method>,
17
+ * session/list|create|prompt|page|cancel) so the client and the channel tools can be
18
+ * exercised without booting a real model instance.
19
+ *
20
+ * Usage: node src/e2e-channel.js
21
+ */
22
+
23
+ const quiet = { debug() {}, info() {}, warn() {}, error() {} }
24
+ const results = []
25
+ function assert(cond, message) {
26
+ if (!cond) throw new Error(message)
27
+ }
28
+ async function check(name, fn) {
29
+ try {
30
+ await fn()
31
+ results.push({ name, ok: true })
32
+ console.log(`PASS ${name}`)
33
+ } catch (error) {
34
+ results.push({ name, ok: false, error: error?.message || String(error) })
35
+ console.log(`FAIL ${name} — ${error?.message || error}`)
36
+ }
37
+ }
38
+
39
+ const LAUNCH_TOKEN = 'A'.repeat(43)
40
+ const COOKIE = 'dsh-auth-mock=v1.mock-signature'
41
+
42
+ /** Minimal DSH web host: one session, scripted turn emitted in three delayed steps. */
43
+ function startMockDsh() {
44
+ const events = []
45
+ let seq = 0
46
+ let sessions = []
47
+ const followers = new Set()
48
+ const push = (type, data = {}) => {
49
+ seq += 1
50
+ const event = { type, seq, data }
51
+ events.push(event)
52
+ for (const follower of followers) {
53
+ try {
54
+ follower.send(
55
+ JSON.stringify({ type: 'item', streamId: follower.streamId, value: { type: 'event', event } })
56
+ )
57
+ } catch {
58
+ /* follower already gone */
59
+ }
60
+ }
61
+ }
62
+ const server = createServer((req, res) => {
63
+ const url = new URL(req.url, 'http://127.0.0.1')
64
+ const send = (status, body, headers = {}) => {
65
+ res.writeHead(status, { 'content-type': 'application/json', ...headers })
66
+ res.end(JSON.stringify(body))
67
+ }
68
+ if (req.method === 'GET') {
69
+ if (url.pathname === '/') {
70
+ if (url.searchParams.get('token') === LAUNCH_TOKEN) {
71
+ res.writeHead(303, { location: '/', 'set-cookie': `${COOKIE}; HttpOnly; SameSite=Strict; Path=/` })
72
+ res.end()
73
+ return
74
+ }
75
+ res.writeHead(200, { 'content-type': 'text/plain' })
76
+ res.end('dsh web (mock)')
77
+ return
78
+ }
79
+ return send(404, { code: 'E_NOT_FOUND' })
80
+ }
81
+ if (req.method !== 'POST' || !url.pathname.startsWith('/api/')) return send(404, { code: 'E_NOT_FOUND' })
82
+ if (!String(req.headers.cookie || '').includes(COOKIE)) {
83
+ return send(401, { code: 'gateway/unauthorized', message: 'no session cookie' })
84
+ }
85
+ const chunks = []
86
+ req.on('data', (chunk) => chunks.push(chunk))
87
+ req.on('end', () => {
88
+ const envelope = JSON.parse(Buffer.concat(chunks).toString('utf8'))
89
+ const args = envelope?.payload?.args || {}
90
+ const value = (v) => send(200, { result: { ok: true, value: v } })
91
+ switch (envelope.method) {
92
+ case 'session/list':
93
+ return value({ items: sessions.map((s) => ({ sessionId: s, title: `title ${s}`, status: 'idle' })) })
94
+ case 'session/create': {
95
+ const sessionId = `mock-${sessions.length + 1}`
96
+ sessions = [...sessions, sessionId]
97
+ return value({ sessionId })
98
+ }
99
+ case 'session/prompt': {
100
+ const { sessionId, requestId, content } = args?.request || {}
101
+ if (!sessions.includes(sessionId)) return send(200, { result: { ok: false, error: { code: 'gateway/not-found', message: 'no such session' } } })
102
+ const text = content?.[0]?.text ?? ''
103
+ push('user/message', { message: { content: [{ type: 'text', text }] }, requestId })
104
+ setTimeout(() => push('turn/start', { turn: 1 }), 200)
105
+ setTimeout(() => push('assistant/message', { message: { content: [{ type: 'text', text: `echo: ${text}` }, { type: 'text', text: 'MESH-CHANNEL-OK' }] } }), 600)
106
+ setTimeout(() => push('turn/end', { reason: { kind: 'completed' } }), 900)
107
+ return value({ accepted: true })
108
+ }
109
+ case 'session/page': {
110
+ const request = args?.request || {}
111
+ const through = Number.isInteger(request.throughSeq) ? request.throughSeq : -1
112
+ const max = request.maxMessages ?? 40
113
+ const visible = events.filter((event) => through === -1 || event.seq <= through)
114
+ const records = visible.slice(-max).map((event) => ({ type: 'event', event }))
115
+ return value({ records, hasMore: visible.length > records.length })
116
+ }
117
+ case 'session/cancel':
118
+ return value({ cancelled: true })
119
+ default:
120
+ return send(200, { result: { ok: false, error: { code: 'gateway/bad-request', message: `unknown method ${envelope.method}` } } })
121
+ }
122
+ })
123
+ })
124
+ // ---- session/follow carrier (`/api/remote.mux`) ---------------------------
125
+ // Wire shapes mirror packages/api/gateway/src/stream-protocol.ts: {type:'open',
126
+ // streamId, endpoint, payload} in, `{type:'item', streamId, value}` out.
127
+ const wss = new WebSocketServer({ noServer: true })
128
+ server.on('upgrade', (req, socket, head) => {
129
+ const url = new URL(req.url, 'http://127.0.0.1')
130
+ if (url.pathname !== '/api/remote.mux' || !String(req.headers.cookie || '').includes(COOKIE)) {
131
+ socket.destroy()
132
+ return
133
+ }
134
+ wss.handleUpgrade(req, socket, head, (ws) => {
135
+ ws.on('message', (raw) => {
136
+ let frame
137
+ try {
138
+ frame = JSON.parse(raw.toString('utf8'))
139
+ } catch {
140
+ return
141
+ }
142
+ if (frame?.type === 'open' && frame.endpoint === 'session/follow') {
143
+ const maxMessages = frame.payload?.args?.request?.maxMessages ?? 40
144
+ ws.streamId = frame.streamId
145
+ followers.add(ws)
146
+ const records = events.slice(-maxMessages).map((event) => ({ type: 'event', event }))
147
+ ws.send(
148
+ JSON.stringify({
149
+ type: 'item',
150
+ streamId: frame.streamId,
151
+ value: { type: 'snapshot', cursor: seq, records, hasMore: false, header: {}, projections: {} }
152
+ })
153
+ )
154
+ return
155
+ }
156
+ if (frame?.type === 'cancel' || frame?.type === 'end') {
157
+ followers.delete(ws)
158
+ ws.close()
159
+ }
160
+ })
161
+ ws.on('close', () => followers.delete(ws))
162
+ })
163
+ })
164
+
165
+ return {
166
+ server,
167
+ dial() {
168
+ return new Promise((resolve) => {
169
+ server.listen(0, '127.0.0.1', () => resolve(`http://127.0.0.1:${server.address().port}`))
170
+ })
171
+ },
172
+ close() {
173
+ for (const ws of followers) {
174
+ try {
175
+ ws.close()
176
+ } catch {
177
+ /* already closed */
178
+ }
179
+ }
180
+ return new Promise((resolve) => {
181
+ wss.close(() => server.close(() => resolve()))
182
+ })
183
+ },
184
+ reset() {
185
+ events.length = 0
186
+ seq = 0
187
+ sessions = []
188
+ },
189
+ launchUrl(base) {
190
+ return `${base}/?token=${LAUNCH_TOKEN}`
191
+ }
192
+ }
193
+ }
194
+
195
+ async function connectMesh(url, token) {
196
+ const transport = new StreamableHTTPClientTransport(new URL(url), {
197
+ requestInit: { headers: { Authorization: `Bearer ${token}` } }
198
+ })
199
+ const client = new Client({ name: 'dsh-mesh-channel-e2e', version: '0.0.1' })
200
+ await client.connect(transport)
201
+ return client
202
+ }
203
+
204
+ function parsed(result) {
205
+ const text = result?.content?.find?.((c) => c.type === 'text')?.text
206
+ return JSON.parse(text)
207
+ }
208
+
209
+ export async function runE2eChannel() {
210
+ const home = mkdtempSync(join(tmpdir(), 'dsh-mesh-channel-'))
211
+ const mock = startMockDsh()
212
+ const mockBase = await mock.dial()
213
+ const launchUrl = mock.launchUrl(mockBase)
214
+ let meshClient
215
+ let srv
216
+
217
+ try {
218
+ // ---- client level -------------------------------------------------------
219
+ await check('client.login exchanges the launch URL for a cookie', async () => {
220
+ const client = new DshWebClient({ log: quiet })
221
+ const { baseUrl } = await client.login(launchUrl)
222
+ assert(baseUrl === mockBase, `unexpected origin ${baseUrl}`)
223
+ assert(client.cookie === COOKIE, `unexpected cookie ${client.cookie}`)
224
+ })
225
+
226
+ await check('bad launch token is rejected (bad sample)', async () => {
227
+ const client = new DshWebClient({ log: quiet })
228
+ let failed = false
229
+ try {
230
+ await client.login(`${mockBase}/?token=${'B'.repeat(43)}`)
231
+ } catch (error) {
232
+ failed = error instanceof DshError && error.code === 'E_DSH_AUTH'
233
+ }
234
+ assert(failed, 'invalid launch token was accepted')
235
+ })
236
+
237
+ await check('rpc without a cookie fails fast (bad sample)', async () => {
238
+ const client = new DshWebClient({ baseUrl: mockBase, log: quiet })
239
+ let code = ''
240
+ try {
241
+ await client.listSessions()
242
+ } catch (error) {
243
+ code = error.code
244
+ }
245
+ assert(code === 'E_DSH_NO_COOKIE', `expected E_DSH_NO_COOKIE, got ${code}`)
246
+ })
247
+
248
+ await check('session/create -> prompt -> page returns the assistant reply', async () => {
249
+ const client = new DshWebClient({ log: quiet })
250
+ await client.login(launchUrl)
251
+ const sessionId = await client.createSession()
252
+ const result = await client.sendAndWait({ sessionId, text: 'ping', waitMs: 20000, pollMs: 200 })
253
+ assert(result.turnEnd.kind === 'completed', `unexpected turn end ${JSON.stringify(result.turnEnd)}`)
254
+ assert(result.reply.includes('echo: ping'), `reply missing echo: ${result.reply}`)
255
+ assert(result.reply.includes('MESH-CHANNEL-OK'), `reply missing marker: ${result.reply}`)
256
+ })
257
+
258
+ await check('HTTP 401 from the host surfaces as E_DSH_HTTP (bad sample)', async () => {
259
+ const client = new DshWebClient({ baseUrl: mockBase, cookie: 'stale=1', log: quiet })
260
+ let code = ''
261
+ try {
262
+ await client.listSessions()
263
+ } catch (error) {
264
+ code = error.code
265
+ }
266
+ assert(code === 'E_DSH_HTTP', `expected E_DSH_HTTP, got ${code}`)
267
+ })
268
+
269
+ // ---- mesh tools level ---------------------------------------------------
270
+ mock.reset()
271
+ const config = defaultConfig()
272
+ config.node = { ...config.node, name: 'channel-e2e', bind: '127.0.0.1', port: 0, path: '/mcp' }
273
+ config.acl.allow = [...config.acl.allow, 'channel_login', 'channel_send', 'channel_cancel']
274
+ config.dsh.targets = [{ id: 'mock-dsh', baseUrl: mockBase }]
275
+ writeFileSync(configPath(home), JSON.stringify(config, null, 2) + '\n', 'utf8')
276
+ const stamp = new Date(Date.now() + 5000)
277
+ utimesSync(configPath(home), stamp, stamp)
278
+ const secrets = loadSecrets(home)
279
+ srv = await startMeshServer({ home, log: quiet })
280
+ meshClient = await connectMesh(srv.url, secrets.inbound[0].token)
281
+
282
+ await check('channel_targets lists the configured target without a cookie', async () => {
283
+ const data = parsed(await meshClient.callTool({ name: 'channel_targets', arguments: {} }))
284
+ const target = data.targets.find((t) => t.id === 'mock-dsh')
285
+ assert(target, 'configured target not listed')
286
+ assert(target.hasCookie === false, 'cookie should not exist yet')
287
+ })
288
+
289
+ await check('channel_send before login returns E_DSH_NO_COOKIE', async () => {
290
+ const data = parsed(
291
+ await meshClient.callTool({ name: 'channel_send', arguments: { target: 'mock-dsh', text: 'hi' } })
292
+ )
293
+ assert(data.code === 'E_DSH_NO_COOKIE', `expected E_DSH_NO_COOKIE, got ${data.code}`)
294
+ })
295
+
296
+ await check('channel_login stores the cookie in secrets.json (0600, gitignored)', async () => {
297
+ const data = parsed(
298
+ await meshClient.callTool({ name: 'channel_login', arguments: { launchUrl, target: 'mock-dsh' } })
299
+ )
300
+ assert(data.target === 'mock-dsh', `unexpected target ${data.target}`)
301
+ assert(data.baseUrl === mockBase, `unexpected baseUrl ${data.baseUrl}`)
302
+ const raw = JSON.parse(await import('node:fs').then((fs) => fs.readFileSync(join(home, 'secrets.json'), 'utf8')))
303
+ assert(raw.dshWeb?.['mock-dsh']?.cookie === COOKIE, 'cookie was not persisted')
304
+ })
305
+
306
+ await check('channel_login without a target derives dsh-<port>', async () => {
307
+ const data = parsed(await meshClient.callTool({ name: 'channel_login', arguments: { launchUrl } }))
308
+ const port = new URL(mockBase).port
309
+ assert(data.target === `dsh-${port}`, `unexpected derived target ${data.target}`)
310
+ })
311
+
312
+ await check('channel_sessions lists the mock session after create', async () => {
313
+ const created = parsed(
314
+ await meshClient.callTool({ name: 'channel_send', arguments: { target: 'mock-dsh', text: 'wake up' } })
315
+ )
316
+ assert(created.reply.includes('MESH-CHANNEL-OK'), `unexpected reply ${created.reply}`)
317
+ const data = parsed(await meshClient.callTool({ name: 'channel_sessions', arguments: { target: 'mock-dsh' } }))
318
+ assert(data.count >= 1, 'no sessions listed')
319
+ assert(data.sessions[0].sessionId.length > 0, 'session id missing')
320
+ })
321
+
322
+ await check('channel_page returns a compact event list', async () => {
323
+ const sessions = parsed(await meshClient.callTool({ name: 'channel_sessions', arguments: { target: 'mock-dsh' } }))
324
+ const sessionId = sessions.sessions[0].sessionId
325
+ const data = parsed(await meshClient.callTool({ name: 'channel_page', arguments: { target: 'mock-dsh', sessionId } }))
326
+ const types = data.events.map((e) => e.type)
327
+ assert(types.includes('user/message'), 'missing user/message')
328
+ assert(types.includes('assistant/message'), 'missing assistant/message')
329
+ assert(types.includes('turn/end'), 'missing turn/end')
330
+ })
331
+
332
+ await check('channel_send to an unknown target returns E_NOT_FOUND (bad sample)', async () => {
333
+ const data = parsed(
334
+ await meshClient.callTool({ name: 'channel_send', arguments: { target: 'nope', text: 'x' } })
335
+ )
336
+ assert(data.code === 'E_NOT_FOUND', `expected E_NOT_FOUND, got ${data.code}`)
337
+ })
338
+
339
+ await check('channel_send is denied by ACL until a human allows it (bad sample)', async () => {
340
+ const before = JSON.parse(await import('node:fs').then((fs) => fs.readFileSync(configPath(home), 'utf8')))
341
+ const next = {
342
+ ...before,
343
+ acl: { ...before.acl, allow: before.acl.allow.filter((n) => n !== 'channel_send') }
344
+ }
345
+ writeFileSync(configPath(home), JSON.stringify(next, null, 2) + '\n', 'utf8')
346
+ const bump = new Date(Date.now() + 8000)
347
+ utimesSync(configPath(home), bump, bump)
348
+ // wait for the mtime-based reload
349
+ await new Promise((r) => setTimeout(r, 300))
350
+ const data = parsed(
351
+ await meshClient.callTool({ name: 'channel_send', arguments: { target: 'mock-dsh', text: 'blocked' } })
352
+ )
353
+ assert(data.code === 'E_DENIED', `expected E_DENIED, got ${data.code}`)
354
+ })
355
+
356
+ await check('channel calls land in the audit log', async () => {
357
+ const data = parsed(
358
+ await meshClient.callTool({ name: 'mesh_audit_tail', arguments: { limit: 200, tool: 'channel_send' } })
359
+ )
360
+ assert(data.entries.some((e) => e.tool === 'channel_send' && e.code === 'E_DENIED'), 'denied channel call not audited')
361
+ })
362
+ } finally {
363
+ try {
364
+ await meshClient?.close?.()
365
+ } catch {
366
+ /* ignore */
367
+ }
368
+ if (srv) await srv.close()
369
+ await mock.close()
370
+ rmSync(home, { recursive: true, force: true })
371
+ }
372
+
373
+ const failed = results.filter((r) => !r.ok)
374
+ console.log(`\ne2e-channel: ${results.length - failed.length}/${results.length} passed`)
375
+ if (failed.length > 0) {
376
+ for (const f of failed) console.log(` FAILED: ${f.name} — ${f.error}`)
377
+ return 1
378
+ }
379
+ return 0
380
+ }
381
+
382
+ if (process.argv[1]?.endsWith('e2e-channel.js')) {
383
+ runE2eChannel().then((code) => process.exit(code))
384
+ }