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
|
@@ -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
|
+
}
|