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-host.js
ADDED
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
import { createServer } from 'node:http'
|
|
2
|
+
import { mkdtempSync, mkdirSync, renameSync, rmSync, utimesSync, writeFileSync } from 'node:fs'
|
|
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 { defaultConfig, loadSecrets, configPath } from './core/config.js'
|
|
8
|
+
import { startMeshServer } from './server.js'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* End-to-end test for the host bridge (M3.2 + M4, route B).
|
|
12
|
+
*
|
|
13
|
+
* A mock stand-in for the `dsh-mesh-host` plugin speaks the exact loopback
|
|
14
|
+
* endpoints the real plugin exposes (/host/ping|self|tools|agents|tools/call|wake),
|
|
15
|
+
* so the mesh-side client, the ACL, the E2E wiring and the failure modes can all be
|
|
16
|
+
* exercised without booting DSH.
|
|
17
|
+
*
|
|
18
|
+
* Usage: node src/e2e-host.js
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const quiet = { debug() {}, info() {}, warn() {}, error() {} }
|
|
22
|
+
const TOKEN = 'h'.repeat(64)
|
|
23
|
+
const results = []
|
|
24
|
+
function assert(cond, message) {
|
|
25
|
+
if (!cond) throw new Error(message)
|
|
26
|
+
}
|
|
27
|
+
async function check(name, fn) {
|
|
28
|
+
try {
|
|
29
|
+
await fn()
|
|
30
|
+
results.push({ name, ok: true })
|
|
31
|
+
console.log(`PASS ${name}`)
|
|
32
|
+
} catch (error) {
|
|
33
|
+
results.push({ name, ok: false, error: error?.message || String(error) })
|
|
34
|
+
console.log(`FAIL ${name} — ${error?.message || error}`)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Mock of the in-DSH plugin. Auth, routing and error shapes mirror the real one. */
|
|
39
|
+
function startMockHost({ token = TOKEN } = {}) {
|
|
40
|
+
const calls = []
|
|
41
|
+
const server = createServer((req, res) => {
|
|
42
|
+
const url = new URL(req.url, 'http://127.0.0.1')
|
|
43
|
+
const send = (status, body) => {
|
|
44
|
+
res.writeHead(status, { 'content-type': 'application/json' })
|
|
45
|
+
res.end(JSON.stringify(body))
|
|
46
|
+
}
|
|
47
|
+
if (req.method === 'GET' && url.pathname === '/host/ping') return send(200, { ok: true, plugin: 'mock-0.1.0' })
|
|
48
|
+
const auth = String(req.headers.authorization || '')
|
|
49
|
+
if (auth !== `Bearer ${token}`) return send(401, { ok: false, code: 'E_HOST_AUTH', message: 'bad token' })
|
|
50
|
+
if (req.method === 'GET' && url.pathname === '/host/self') {
|
|
51
|
+
return send(200, {
|
|
52
|
+
ok: true,
|
|
53
|
+
plugin: 'mock-0.1.0',
|
|
54
|
+
node: { hostname: 'mock-host', platform: 'win32', pid: 4242 },
|
|
55
|
+
tools: ['read', 'write', 'shell'],
|
|
56
|
+
agents: [{ id: 'sess-1', status: 'idle', title: 'mock session' }]
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
if (req.method === 'GET' && url.pathname === '/host/tools') {
|
|
60
|
+
return send(200, {
|
|
61
|
+
ok: true,
|
|
62
|
+
tools: [
|
|
63
|
+
{ name: 'read', description: 'Read a file', parameters: { type: 'object' } },
|
|
64
|
+
{ name: 'write', description: 'Write a file', parameters: { type: 'object' } },
|
|
65
|
+
{ name: 'shell', description: 'Run a shell command', parameters: { type: 'object' } }
|
|
66
|
+
]
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
if (req.method === 'GET' && url.pathname === '/host/agents') {
|
|
70
|
+
return send(200, {
|
|
71
|
+
ok: true,
|
|
72
|
+
agents: [
|
|
73
|
+
{ id: 'sess-1', status: 'idle', title: 'mock session' },
|
|
74
|
+
{ id: 'sess-2', status: 'running', title: 'busy session' }
|
|
75
|
+
]
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
if (req.method === 'GET' && url.pathname === '/host/sessions') {
|
|
79
|
+
return send(200, {
|
|
80
|
+
ok: true,
|
|
81
|
+
sessions: [
|
|
82
|
+
{ sessionId: 'sess-1', title: 'mock session', state: 'idle', agentAvailable: true, cwd: '/tmp/mock' },
|
|
83
|
+
{ sessionId: 'sess-sched', title: '定时-库存同步', state: 'idle', agentAvailable: true, cwd: '/tmp/mock' },
|
|
84
|
+
{ sessionId: 'sess-deleg', title: 'delegated task', state: 'idle', agentAvailable: true, cwd: '/tmp/mock' }
|
|
85
|
+
]
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
let body = ''
|
|
89
|
+
req.on('data', chunk => { body += chunk })
|
|
90
|
+
req.on('end', () => {
|
|
91
|
+
let payload = {}
|
|
92
|
+
try {
|
|
93
|
+
payload = JSON.parse(body || '{}')
|
|
94
|
+
} catch {
|
|
95
|
+
return send(400, { ok: false, code: 'E_INVALID_ARG', message: 'body must be JSON' })
|
|
96
|
+
}
|
|
97
|
+
calls.push({ path: url.pathname, payload })
|
|
98
|
+
if (url.pathname === '/host/tools/call') {
|
|
99
|
+
if (payload.tool === 'read') return send(200, { ok: true, value: { path: payload.arguments?.path, text: 'mock file body' }, content: [] })
|
|
100
|
+
if (payload.tool === 'shell') return send(200, { ok: false, code: 'E_DENIED', message: 'host policy denied shell' })
|
|
101
|
+
return send(404, { ok: false, code: 'E_NOT_FOUND', message: `no such tool in this host: ${payload.tool}` })
|
|
102
|
+
}
|
|
103
|
+
if (url.pathname === '/host/wake') {
|
|
104
|
+
if (payload.sessionId !== 'sess-1') return send(404, { ok: false, code: 'E_NOT_FOUND', message: `no such session: ${payload.sessionId}` })
|
|
105
|
+
return send(200, {
|
|
106
|
+
ok: true,
|
|
107
|
+
sessionId: payload.sessionId,
|
|
108
|
+
target: payload.target || 'next-turn',
|
|
109
|
+
messageId: 'mock-message-id',
|
|
110
|
+
statusBefore: 'idle',
|
|
111
|
+
wokeUp: true,
|
|
112
|
+
delivered: true
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
return send(404, { ok: false, code: 'E_NOT_FOUND', message: 'unknown endpoint' })
|
|
116
|
+
})
|
|
117
|
+
})
|
|
118
|
+
return new Promise(resolve => {
|
|
119
|
+
server.listen(0, '127.0.0.1', () => {
|
|
120
|
+
const port = server.address().port
|
|
121
|
+
resolve({
|
|
122
|
+
url: `http://127.0.0.1:${port}`,
|
|
123
|
+
calls,
|
|
124
|
+
reset: () => { calls.length = 0 },
|
|
125
|
+
close: () => new Promise(r => server.close(r))
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
})
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function parsed(result) {
|
|
132
|
+
const block = result?.content?.[0]
|
|
133
|
+
const text = block?.type === 'text' ? block.text : '{}'
|
|
134
|
+
try {
|
|
135
|
+
return JSON.parse(text)
|
|
136
|
+
} catch {
|
|
137
|
+
return { raw: text }
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function connectMesh(url, token) {
|
|
142
|
+
const transport = new StreamableHTTPClientTransport(new URL(url), {
|
|
143
|
+
requestInit: { headers: { Authorization: `Bearer ${token}` } }
|
|
144
|
+
})
|
|
145
|
+
const client = new Client({ name: 'mesh-host-e2e', version: '0.0.0' })
|
|
146
|
+
await client.connect(transport)
|
|
147
|
+
return client
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Rewrite config.json and force a fresh mtime so the hot reloader picks it up. */
|
|
151
|
+
function patchConfig(home, mutate) {
|
|
152
|
+
const config = JSON.parse(await_read(configPath(home)))
|
|
153
|
+
mutate(config)
|
|
154
|
+
writeFileSync(configPath(home), JSON.stringify(config, null, 2) + '\n', 'utf8')
|
|
155
|
+
const stamp = new Date(Date.now() + Math.floor(Math.random() * 1000) + 500)
|
|
156
|
+
utimesSync(configPath(home), stamp, stamp)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// tiny sync reader helper (kept local so the file has one import style)
|
|
160
|
+
import { readFileSync } from 'node:fs'
|
|
161
|
+
function await_read(path) {
|
|
162
|
+
return readFileSync(path, 'utf8')
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function main() {
|
|
166
|
+
const home = mkdtempSync(join(tmpdir(), 'mesh-host-e2e-'))
|
|
167
|
+
let mock = null
|
|
168
|
+
let srv = null
|
|
169
|
+
let client = null
|
|
170
|
+
try {
|
|
171
|
+
mock = await startMockHost()
|
|
172
|
+
writeFileSync(join(home, 'host-token'), `${TOKEN}\n`, 'utf8')
|
|
173
|
+
|
|
174
|
+
const config = defaultConfig()
|
|
175
|
+
config.node = { ...config.node, name: 'host-e2e', bind: '127.0.0.1', port: 0, path: '/mcp' }
|
|
176
|
+
// Delegation-safety fixtures: a fake DSH home with one scheduled session and
|
|
177
|
+
// one session created by an earlier delegation.
|
|
178
|
+
const dshHome = join(home, 'dsh0home')
|
|
179
|
+
mkdirSync(join(dshHome, 'storages'), { recursive: true })
|
|
180
|
+
writeFileSync(
|
|
181
|
+
join(dshHome, 'storages', 'schedule.json'),
|
|
182
|
+
JSON.stringify(
|
|
183
|
+
{
|
|
184
|
+
unit: { name: 'schedule', version: 1 },
|
|
185
|
+
tables: {
|
|
186
|
+
tasks: {
|
|
187
|
+
'schedule-mock': {
|
|
188
|
+
sessionId: 'sess-sched',
|
|
189
|
+
status: 'active',
|
|
190
|
+
record: { id: 'schedule-mock', kind: 'every', title: '定时-库存同步' }
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
},
|
|
195
|
+
null,
|
|
196
|
+
2
|
|
197
|
+
),
|
|
198
|
+
'utf8'
|
|
199
|
+
)
|
|
200
|
+
mkdirSync(join(home, '.mesh', 'tasks', 'T-MOCK'), { recursive: true })
|
|
201
|
+
writeFileSync(
|
|
202
|
+
join(home, '.mesh', 'tasks', 'T-MOCK', 'request.json'),
|
|
203
|
+
JSON.stringify(
|
|
204
|
+
{
|
|
205
|
+
taskId: 'T-MOCK',
|
|
206
|
+
conversationId: 'C-MOCK',
|
|
207
|
+
from: 'peer',
|
|
208
|
+
sessionId: 'sess-deleg',
|
|
209
|
+
instance: 'dsh0',
|
|
210
|
+
sessionPolicy: 'new',
|
|
211
|
+
requestedAt: new Date().toISOString()
|
|
212
|
+
},
|
|
213
|
+
null,
|
|
214
|
+
2
|
|
215
|
+
),
|
|
216
|
+
'utf8'
|
|
217
|
+
)
|
|
218
|
+
config.hosts = [
|
|
219
|
+
{
|
|
220
|
+
id: 'dsh0',
|
|
221
|
+
label: 'mock host',
|
|
222
|
+
enabled: true,
|
|
223
|
+
url: mock.url,
|
|
224
|
+
tokenFile: join(home, 'host-token'),
|
|
225
|
+
timeoutMs: 10000,
|
|
226
|
+
profileDir: join(dshHome, 'profiles', 'web')
|
|
227
|
+
}
|
|
228
|
+
]
|
|
229
|
+
config.acl = { ...config.acl, fsRoots: [home] }
|
|
230
|
+
writeFileSync(configPath(home), JSON.stringify(config, null, 2) + '\n', 'utf8')
|
|
231
|
+
const secrets = loadSecrets(home)
|
|
232
|
+
srv = await startMeshServer({ home, log: quiet })
|
|
233
|
+
client = await connectMesh(srv.url, secrets.inbound[0].token)
|
|
234
|
+
|
|
235
|
+
await check('host_instances lists configured bridges and pings each', async () => {
|
|
236
|
+
const data = parsed(await client.callTool({ name: 'host_instances', arguments: {} }))
|
|
237
|
+
assert(data.count === 1, `expected 1 instance, got ${data.count}`)
|
|
238
|
+
const row = data.instances[0]
|
|
239
|
+
assert(row.id === 'dsh0', `unexpected id ${row.id}`)
|
|
240
|
+
assert(row.alive === true, 'mock bridge should be alive')
|
|
241
|
+
assert(String(row.tokenFile).endsWith('host-token'), `unexpected tokenFile ${row.tokenFile}`)
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
await check('unknown host instance fails loud with E_HOST_UNKNOWN', async () => {
|
|
245
|
+
const data = parsed(await client.callTool({ name: 'host_self', arguments: { instance: 'dsh9' } }))
|
|
246
|
+
assert(data.code === 'E_HOST_UNKNOWN', `expected E_HOST_UNKNOWN, got ${data.code}`)
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
await check('host_call is denied by default ACL (write-capable tool)', async () => {
|
|
250
|
+
const data = parsed(await client.callTool({ name: 'host_call', arguments: { tool: 'read', arguments: {} } }))
|
|
251
|
+
assert(data.code === 'E_DENIED', `expected E_DENIED, got ${data.code}`)
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
await check('channel_wake is denied by default ACL (injects into a live session)', async () => {
|
|
255
|
+
const data = parsed(
|
|
256
|
+
await client.callTool({ name: 'channel_wake', arguments: { sessionId: 'sess-1', text: 'hi' } })
|
|
257
|
+
)
|
|
258
|
+
assert(data.code === 'E_DENIED', `expected E_DENIED, got ${data.code}`)
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
await check('host_self reports the bridge instance, tool count and agents', async () => {
|
|
262
|
+
const data = parsed(await client.callTool({ name: 'host_self', arguments: {} }))
|
|
263
|
+
assert(data.ok === true, 'host_self failed')
|
|
264
|
+
assert(data.node.hostname === 'mock-host', `unexpected hostname ${data.node?.hostname}`)
|
|
265
|
+
assert(data.tools.length === 3, `expected 3 tools, got ${data.tools.length}`)
|
|
266
|
+
assert(data.agents[0].status === 'idle', 'agent status missing')
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
await check('host_tools lists name/description/parameters and filters by search', async () => {
|
|
270
|
+
const all = parsed(await client.callTool({ name: 'host_tools', arguments: {} }))
|
|
271
|
+
assert(all.count === 3, `expected 3 tools, got ${all.count}`)
|
|
272
|
+
assert(all.tools.every(t => 'name' in t && 'description' in t), 'tool fields missing')
|
|
273
|
+
const filtered = parsed(await client.callTool({ name: 'host_tools', arguments: { search: 'sh' } }))
|
|
274
|
+
assert(filtered.count === 1 && filtered.tools[0].name === 'shell', `unexpected filter result ${JSON.stringify(filtered)}`)
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
await check('channel_agents lists live sessions and filters by sessionId', async () => {
|
|
278
|
+
const all = parsed(await client.callTool({ name: 'channel_agents', arguments: {} }))
|
|
279
|
+
assert(all.count === 2, `expected 2 agents, got ${all.count}`)
|
|
280
|
+
const one = parsed(await client.callTool({ name: 'channel_agents', arguments: { sessionId: 'sess-2' } }))
|
|
281
|
+
assert(one.count === 1 && one.agents[0].status === 'running', 'session filter failed')
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
// opt in to the write-capable tools, exactly as a human would in config.json
|
|
285
|
+
patchConfig(home, c => {
|
|
286
|
+
c.acl.allow = [...c.acl.allow, 'host_call', 'channel_wake']
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
await check('host_call runs a host tool after explicit ACL opt-in', async () => {
|
|
290
|
+
const data = parsed(
|
|
291
|
+
await client.callTool({ name: 'host_call', arguments: { tool: 'read', arguments: { path: 'C:/tmp/a.txt' } } })
|
|
292
|
+
)
|
|
293
|
+
assert(data.ok === true, `host_call failed: ${JSON.stringify(data)}`)
|
|
294
|
+
assert(data.value.text === 'mock file body', `unexpected value ${JSON.stringify(data.value)}`)
|
|
295
|
+
assert(mock.calls.some(c => c.path === '/host/tools/call'), 'bridge was not called')
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
await check('host_call surfaces a host-side failure code (bad sample)', async () => {
|
|
299
|
+
const data = parsed(await client.callTool({ name: 'host_call', arguments: { tool: 'shell', arguments: {} } }))
|
|
300
|
+
assert(data.code === 'E_DENIED', `expected host E_DENIED, got ${data.code}`)
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
await check('host_call unknown tool returns E_NOT_FOUND without running it', async () => {
|
|
304
|
+
const data = parsed(await client.callTool({ name: 'host_call', arguments: { tool: 'nope', arguments: {} } }))
|
|
305
|
+
assert(data.code === 'E_NOT_FOUND', `expected E_NOT_FOUND, got ${data.code}`)
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
await check('channel_wake injects into an idle session and reports wokeUp', async () => {
|
|
309
|
+
const data = parsed(
|
|
310
|
+
await client.callTool({ name: 'channel_wake', arguments: { sessionId: 'sess-1', text: 'MESH-WAKE-OK' } })
|
|
311
|
+
)
|
|
312
|
+
assert(data.ok === true, `channel_wake failed: ${JSON.stringify(data)}`)
|
|
313
|
+
assert(data.wokeUp === true, 'wake should report the idle session as woken')
|
|
314
|
+
assert(data.messageId === 'mock-message-id', 'messageId missing')
|
|
315
|
+
const sent = mock.calls.find(c => c.path === '/host/wake')
|
|
316
|
+
assert(sent.payload.text === 'MESH-WAKE-OK', 'text was not forwarded')
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
await check('channel_wake on an unknown session returns E_NOT_FOUND (bad sample)', async () => {
|
|
320
|
+
const data = parsed(await client.callTool({ name: 'channel_wake', arguments: { sessionId: 'ghost', text: 'x' } }))
|
|
321
|
+
assert(data.code === 'E_NOT_FOUND', `expected E_NOT_FOUND, got ${data.code}`)
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
await check('host_sessions marks a scheduled session protected (business-scheduled)', async () => {
|
|
325
|
+
const data = parsed(await client.callTool({ name: 'host_sessions', arguments: { includeEnded: true } }))
|
|
326
|
+
const rows = data.sessions || []
|
|
327
|
+
const sched = rows.find((row) => row.sessionId === 'sess-sched')
|
|
328
|
+
const deleg = rows.find((row) => row.sessionId === 'sess-deleg')
|
|
329
|
+
assert(sched?.protected === true, `expected protected sess-sched, got ${JSON.stringify(sched)}`)
|
|
330
|
+
assert(sched.kind === 'business-scheduled', `expected business-scheduled, got ${sched.kind}`)
|
|
331
|
+
assert(String(sched.reason).includes('scheduled'), 'reason should explain the schedule')
|
|
332
|
+
assert(deleg?.kind === 'delegation', `expected delegation kind, got ${deleg?.kind}`)
|
|
333
|
+
assert(deleg.protected === false, 'a delegation session is not protected')
|
|
334
|
+
assert(data.protectedCount === 1, `expected protectedCount=1, got ${data.protectedCount}`)
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
await check('channel_wake refuses a session that owns scheduled tasks (bad sample)', async () => {
|
|
338
|
+
const data = parsed(await client.callTool({ name: 'channel_wake', arguments: { sessionId: 'sess-sched', text: 'x' } }))
|
|
339
|
+
assert(data.code === 'E_SESSION_PROTECTED', `expected E_SESSION_PROTECTED, got ${data.code}`)
|
|
340
|
+
assert(Array.isArray(data.schedules) && data.schedules.length === 1, 'refusal must list schedule evidence')
|
|
341
|
+
assert(String(data.hint || '').includes('FRESH conversation'), 'refusal should suggest a fresh conversation')
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
await check('channel_wake force:true bypasses the scheduled-session guard', async () => {
|
|
345
|
+
mock.reset()
|
|
346
|
+
const data = parsed(await client.callTool({ name: 'channel_wake', arguments: { sessionId: 'sess-sched', text: 'x', force: true } }))
|
|
347
|
+
assert(data.code !== 'E_SESSION_PROTECTED', `force should bypass the guard, got ${data.code}`)
|
|
348
|
+
assert(
|
|
349
|
+
mock.calls.some((call) => call.path === '/host/wake' && call.payload.sessionId === 'sess-sched'),
|
|
350
|
+
'force should reach the bridge'
|
|
351
|
+
)
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
await check('delegate refuses to continue a scheduled session (bad sample)', async () => {
|
|
355
|
+
const data = parsed(await client.callTool({ name: 'delegate', arguments: { goal: 'check state', sessionId: 'sess-sched' } }))
|
|
356
|
+
assert(data.code === 'E_SESSION_PROTECTED', `expected E_SESSION_PROTECTED, got ${data.code}`)
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
await check('delegate_candidates exposes delegation vs business-scheduled kinds', async () => {
|
|
360
|
+
const data = parsed(await client.callTool({ name: 'delegate_candidates', arguments: { includeEnded: true, limit: 50 } }))
|
|
361
|
+
const rows = data.sessions || []
|
|
362
|
+
const sched = rows.find((row) => row.sessionId === 'sess-sched')
|
|
363
|
+
const deleg = rows.find((row) => row.sessionId === 'sess-deleg')
|
|
364
|
+
assert(sched?.protected === true, 'candidates must mark protected sessions')
|
|
365
|
+
assert(sched.delegatable === false, 'a protected session must not be delegatable')
|
|
366
|
+
assert(deleg?.kind === 'delegation', `expected delegation kind, got ${deleg?.kind}`)
|
|
367
|
+
assert(deleg.recommended === true, 'an idle safe session should be recommended')
|
|
368
|
+
})
|
|
369
|
+
|
|
370
|
+
await check('missing host token surfaces E_HOST_NO_TOKEN with a fix hint', async () => {
|
|
371
|
+
renameSync(join(home, 'host-token'), join(home, 'host-token.off'))
|
|
372
|
+
const data = parsed(await client.callTool({ name: 'host_self', arguments: {} }))
|
|
373
|
+
assert(data.code === 'E_HOST_NO_TOKEN', `expected E_HOST_NO_TOKEN, got ${data.code}`)
|
|
374
|
+
assert(String(data.message || '').includes('host-token'), 'hint should name the token file')
|
|
375
|
+
renameSync(join(home, 'host-token.off'), join(home, 'host-token'))
|
|
376
|
+
})
|
|
377
|
+
|
|
378
|
+
await check('bridge down surfaces E_HOST_OFFLINE and marks it retryable', async () => {
|
|
379
|
+
await mock.close()
|
|
380
|
+
mock = null
|
|
381
|
+
const data = parsed(await client.callTool({ name: 'host_self', arguments: {} }))
|
|
382
|
+
assert(data.code === 'E_HOST_OFFLINE', `expected E_HOST_OFFLINE, got ${data.code}`)
|
|
383
|
+
assert(data.retryable === true, 'offline should be retryable')
|
|
384
|
+
})
|
|
385
|
+
} finally {
|
|
386
|
+
try {
|
|
387
|
+
await client?.close?.()
|
|
388
|
+
} catch {
|
|
389
|
+
/* ignore */
|
|
390
|
+
}
|
|
391
|
+
try {
|
|
392
|
+
await srv?.close?.()
|
|
393
|
+
} catch {
|
|
394
|
+
/* ignore */
|
|
395
|
+
}
|
|
396
|
+
if (mock) await mock.close()
|
|
397
|
+
rmSync(home, { recursive: true, force: true })
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const failed = results.filter(r => !r.ok)
|
|
401
|
+
console.log(`\n${results.length - failed.length}/${results.length} PASS`)
|
|
402
|
+
if (failed.length > 0) {
|
|
403
|
+
for (const f of failed) console.log(`FAILED: ${f.name} — ${f.error}`)
|
|
404
|
+
process.exitCode = 1
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
main().catch(error => {
|
|
409
|
+
console.error(error)
|
|
410
|
+
process.exitCode = 1
|
|
411
|
+
})
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* e2e-onboard:模拟「一台全新的对端机器」走完公司机接入手册,全程真实网络/真实 DSH。
|
|
4
|
+
*
|
|
5
|
+
* 它验证的是**给公司机的那份手册本身**(不是 mock):
|
|
6
|
+
* 1) mesh-peer check —— 公网链路 + TLS 指纹固定 + 无 token 必须 401
|
|
7
|
+
* 2) mesh-peer pair —— 一次性配对码换 peer 专属 token,落盘 overlay
|
|
8
|
+
* 3) 真 DSH 挂该 overlay —— headless 实例经公网隧道调 mcp__<name>-mesh__mesh_self
|
|
9
|
+
* 4) 再调一个数据工具 —— mcp__<name>-mesh__git_status(证明 ACL + 真实数据面)
|
|
10
|
+
* 5) 清理 —— revoke 刚发的 token(必须从 secrets 里消失)
|
|
11
|
+
*
|
|
12
|
+
* 用法:
|
|
13
|
+
* node src/e2e-onboard.js [--url https://relay.example.com:35724] [--keep] [--skip-dsh]
|
|
14
|
+
*
|
|
15
|
+
* 退出码:0 全通过 / 1 有失败
|
|
16
|
+
*/
|
|
17
|
+
import { spawn } from 'node:child_process'
|
|
18
|
+
import { copyFileSync, existsSync, mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs'
|
|
19
|
+
import { tmpdir } from 'node:os'
|
|
20
|
+
import { join } from 'node:path'
|
|
21
|
+
import { fileURLToPath } from 'node:url'
|
|
22
|
+
|
|
23
|
+
import { meshHome, loadConfig, loadSecrets, removeInboundToken } from './core/config.js'
|
|
24
|
+
import { createPairingCode } from './core/pairing.js'
|
|
25
|
+
import { certPath, describeCert } from './core/tls.js'
|
|
26
|
+
|
|
27
|
+
const ROOT = fileURLToPath(new URL('..', import.meta.url))
|
|
28
|
+
const PEER_CLI = join(ROOT, 'client', 'mesh-peer.mjs')
|
|
29
|
+
|
|
30
|
+
const argv = process.argv.slice(2)
|
|
31
|
+
function opt(name, fallback = undefined) {
|
|
32
|
+
const i = argv.indexOf(`--${name}`)
|
|
33
|
+
return i >= 0 && argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[i + 1] : fallback
|
|
34
|
+
}
|
|
35
|
+
const flag = (name) => argv.includes(`--${name}`)
|
|
36
|
+
|
|
37
|
+
const HOME = meshHome()
|
|
38
|
+
const URL_BASE = opt('url', process.env.MESH_PUBLIC_URL || 'https://relay.example.com:35724')
|
|
39
|
+
const NAME = `drill${Date.now().toString(36).slice(-5)}`
|
|
40
|
+
const KEEP = flag('keep')
|
|
41
|
+
const SKIP_DSH = flag('skip-dsh')
|
|
42
|
+
|
|
43
|
+
let pass = 0
|
|
44
|
+
let fail = 0
|
|
45
|
+
const results = []
|
|
46
|
+
|
|
47
|
+
function check(ok, label, detail = '') {
|
|
48
|
+
if (ok) {
|
|
49
|
+
pass += 1
|
|
50
|
+
results.push(`PASS ${label}${detail ? ` — ${detail}` : ''}`)
|
|
51
|
+
} else {
|
|
52
|
+
fail += 1
|
|
53
|
+
results.push(`FAIL ${label}${detail ? ` — ${detail}` : ''}`)
|
|
54
|
+
}
|
|
55
|
+
return ok
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function run(cmd, args, { env = {}, timeoutMs = 120000 } = {}) {
|
|
59
|
+
return new Promise((resolve) => {
|
|
60
|
+
const started = Date.now()
|
|
61
|
+
const child = spawn(cmd, args, {
|
|
62
|
+
cwd: ROOT,
|
|
63
|
+
env: { ...process.env, ...env },
|
|
64
|
+
windowsHide: true,
|
|
65
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
66
|
+
})
|
|
67
|
+
let stdout = ''
|
|
68
|
+
let stderr = ''
|
|
69
|
+
let timedOut = false
|
|
70
|
+
const timer = setTimeout(() => {
|
|
71
|
+
timedOut = true
|
|
72
|
+
child.kill('SIGKILL')
|
|
73
|
+
}, timeoutMs)
|
|
74
|
+
child.stdout.on('data', (d) => {
|
|
75
|
+
stdout += d
|
|
76
|
+
})
|
|
77
|
+
child.stderr.on('data', (d) => {
|
|
78
|
+
stderr += d
|
|
79
|
+
})
|
|
80
|
+
child.on('error', (err) => {
|
|
81
|
+
clearTimeout(timer)
|
|
82
|
+
resolve({ code: -1, stdout, stderr: stderr + String(err), ms: Date.now() - started, timedOut })
|
|
83
|
+
})
|
|
84
|
+
child.on('close', (code) => {
|
|
85
|
+
clearTimeout(timer)
|
|
86
|
+
resolve({ code, stdout, stderr, ms: Date.now() - started, timedOut })
|
|
87
|
+
})
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function firstValue(text, key) {
|
|
92
|
+
const m = new RegExp(`${key}\\s*[=:]\\s*\\S+`, 'i').exec(text)
|
|
93
|
+
return m ? m[0] : ''
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function main() {
|
|
97
|
+
console.log(`[onboard] home=${HOME}`)
|
|
98
|
+
console.log(`[onboard] public url=${URL_BASE}`)
|
|
99
|
+
|
|
100
|
+
const config = loadConfig(HOME, { log: () => {} })
|
|
101
|
+
if (!check(config.node?.tls?.enabled === true, 'mesh 配置里 TLS 已开启(否则不该走公网)')) {
|
|
102
|
+
return finish()
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const tls = describeCert(HOME)
|
|
106
|
+
if (!check(!!tls?.fingerprint256, '读到本机证书指纹(mesh_self / tls init 的产物)')) return finish()
|
|
107
|
+
const fingerprint = tls.fingerprint256
|
|
108
|
+
console.log(`[onboard] cert fingerprint=${fingerprint.slice(0, 23)}…`)
|
|
109
|
+
|
|
110
|
+
const stage = mkdtempSync(join(tmpdir(), 'mesh-onboard-'))
|
|
111
|
+
const certCopy = join(stage, 'cert.pem')
|
|
112
|
+
copyFileSync(certPath(HOME), certCopy)
|
|
113
|
+
console.log(`[onboard] stage=${stage}`)
|
|
114
|
+
|
|
115
|
+
// ── 1) check ───────────────────────────────────────────────────────────────
|
|
116
|
+
const checkRun = await run(process.execPath, [PEER_CLI, 'check', '--url', URL_BASE, '--fingerprint', fingerprint], {
|
|
117
|
+
timeoutMs: 60000,
|
|
118
|
+
})
|
|
119
|
+
check(checkRun.code === 0, 'mesh-peer check 退出码 0', `code=${checkRun.code} ms=${checkRun.ms}`)
|
|
120
|
+
check(/5\/5\s+passed/i.test(checkRun.stdout + checkRun.stderr), 'check 5/5 全通过', firstValue(checkRun.stdout, 'server'))
|
|
121
|
+
check(/401/.test(checkRun.stdout + checkRun.stderr), 'check 覆盖了「无 token 必须 401」这条坏样本')
|
|
122
|
+
|
|
123
|
+
// ── 2) pair ────────────────────────────────────────────────────────────────
|
|
124
|
+
const pairing = createPairingCode({ home: HOME, ttlSec: 300, note: `e2e-onboard ${NAME}` })
|
|
125
|
+
console.log(`[onboard] pairing code=${pairing.code} expiresAt=${pairing.expiresAt}`)
|
|
126
|
+
|
|
127
|
+
const pairRun = await run(
|
|
128
|
+
process.execPath,
|
|
129
|
+
[PEER_CLI, 'pair', '--url', URL_BASE, '--code', pairing.code, '--name', NAME, '--out-dir', stage, '--fingerprint', fingerprint],
|
|
130
|
+
{ timeoutMs: 60000 },
|
|
131
|
+
)
|
|
132
|
+
check(pairRun.code === 0, 'mesh-peer pair 退出码 0', `code=${pairRun.code} ms=${pairRun.ms}`)
|
|
133
|
+
const overlayPath = join(stage, `${NAME}.yml`)
|
|
134
|
+
const metaPath = join(stage, `${NAME}.json`)
|
|
135
|
+
const overlayExists = existsSync(overlayPath)
|
|
136
|
+
check(overlayExists, '生成了 DSH overlay(<name>.yml)')
|
|
137
|
+
check(existsSync(metaPath), '生成了 peer 元信息(<name>.json)')
|
|
138
|
+
|
|
139
|
+
let tokenId = null
|
|
140
|
+
let overlayText = ''
|
|
141
|
+
let overlayToken = ''
|
|
142
|
+
if (overlayExists) {
|
|
143
|
+
overlayText = readFileSync(overlayPath, 'utf8')
|
|
144
|
+
const tokenMatch = /Bearer\s+(['"]?)([A-Fa-f0-9]{32,})\1/.exec(overlayText)
|
|
145
|
+
overlayToken = tokenMatch ? tokenMatch[2] : ''
|
|
146
|
+
check(
|
|
147
|
+
/streamable-http/.test(overlayText) && !!overlayToken,
|
|
148
|
+
'overlay 是 streamable-http + Bearer token',
|
|
149
|
+
)
|
|
150
|
+
const mode = statSync(overlayPath).mode & 0o777
|
|
151
|
+
check(process.platform === 'win32' || mode === 0o600, 'overlay 权限 0600(仅属主可读)', `mode=${mode.toString(8)}`)
|
|
152
|
+
const metaText = existsSync(metaPath) ? readFileSync(metaPath, 'utf8') : ''
|
|
153
|
+
check(!!overlayToken && !metaText.includes(overlayToken), '元信息 json 不含 token 明文(只放 tokenId)')
|
|
154
|
+
}
|
|
155
|
+
if (existsSync(metaPath)) {
|
|
156
|
+
try {
|
|
157
|
+
tokenId = JSON.parse(readFileSync(metaPath, 'utf8')).tokenId || null
|
|
158
|
+
} catch {
|
|
159
|
+
tokenId = null
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
check(!!tokenId, 'pair 返回了 tokenId(可用于精确 revoke)', tokenId || '')
|
|
163
|
+
const minted = loadSecrets(HOME).inbound.find((t) => t.id === tokenId)
|
|
164
|
+
check(!!minted, 'mesh 侧 secrets 里确实多了这个 token')
|
|
165
|
+
check(minted?.acl == null, '未申请收窄时 peer token 继承服务端默认 ACL(acl=null,不是全放行)')
|
|
166
|
+
|
|
167
|
+
// ── 3) + 4) 真 DSH 挂 overlay 跑两次工具调用 ───────────────────────────────
|
|
168
|
+
if (!SKIP_DSH && overlayExists) {
|
|
169
|
+
const env = { NODE_EXTRA_CA_CERTS: certCopy }
|
|
170
|
+
const comspec = process.env.ComSpec || 'cmd.exe'
|
|
171
|
+
|
|
172
|
+
const selfPrompt = `调用 MCP 工具 mcp__${NAME}-mesh__mesh_self(参数为 {}),然后严格只输出一行:VER=<mesh.version> NAME=<mesh.name>`
|
|
173
|
+
const selfRun = await run(comspec, ['/c', 'dsh', '--profile', 'headless', '--patch', overlayPath, selfPrompt], {
|
|
174
|
+
env,
|
|
175
|
+
timeoutMs: 240000,
|
|
176
|
+
})
|
|
177
|
+
const selfOut = selfRun.stdout + selfRun.stderr
|
|
178
|
+
check(selfRun.code === 0, '真 DSH 挂 overlay 启动并完成(exit 0)', `code=${selfRun.code} ms=${selfRun.ms}`)
|
|
179
|
+
check(/VER=\d+\.\d+\.\d+/.test(selfOut), '经公网隧道读到 mesh_self 的版本号', firstValue(selfOut, 'VER'))
|
|
180
|
+
check(/NAME=home/.test(selfOut), '读到节点名 home', firstValue(selfOut, 'NAME'))
|
|
181
|
+
|
|
182
|
+
const repoPath = ROOT.replace(/[\\/]+$/, '')
|
|
183
|
+
const gitPrompt = `调用 MCP 工具 mcp__${NAME}-mesh__git_status(参数 {"path":"${repoPath.replace(/\\/g, '\\\\')}"}),然后严格只输出一行:BRANCH=<branch>`
|
|
184
|
+
const gitRun = await run(comspec, ['/c', 'dsh', '--profile', 'headless', '--patch', overlayPath, gitPrompt], {
|
|
185
|
+
env,
|
|
186
|
+
timeoutMs: 240000,
|
|
187
|
+
})
|
|
188
|
+
const gitOut = gitRun.stdout + gitRun.stderr
|
|
189
|
+
check(gitRun.code === 0, '第二次调用(数据工具 git_status)完成', `code=${gitRun.code} ms=${gitRun.ms}`)
|
|
190
|
+
check(/BRANCH=main/.test(gitOut), '经公网读到本仓真实 git 分支', firstValue(gitOut, 'BRANCH'))
|
|
191
|
+
|
|
192
|
+
// 坏样本:默认 deny 的工具必须仍然是 deny(配对不会把 ACL 放宽)
|
|
193
|
+
const denyPrompt = `调用 MCP 工具 mcp__${NAME}-mesh__host_call(参数 {"tool":"read","arguments":{"file_path":"${repoPath.replace(/\\/g, '\\\\')}\\\\package.json"}}),然后严格只输出一行:ERR=<错误码>`
|
|
194
|
+
const denyRun = await run(comspec, ['/c', 'dsh', '--profile', 'headless', '--patch', overlayPath, denyPrompt], {
|
|
195
|
+
env,
|
|
196
|
+
timeoutMs: 240000,
|
|
197
|
+
})
|
|
198
|
+
const denyOut = denyRun.stdout + denyRun.stderr
|
|
199
|
+
check(denyRun.code === 0, '第三次调用(被默认拒绝的 host_call)完成', `code=${denyRun.code} ms=${denyRun.ms}`)
|
|
200
|
+
check(/E_DENIED/.test(denyOut), 'host_call 对配对 token 仍然是 E_DENIED(默认 deny 未被绕过)', firstValue(denyOut, 'ERR'))
|
|
201
|
+
} else if (SKIP_DSH) {
|
|
202
|
+
console.log('[onboard] --skip-dsh:跳过真 DSH 两步')
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ── 5) 清理 ────────────────────────────────────────────────────────────────
|
|
206
|
+
if (tokenId) {
|
|
207
|
+
removeInboundToken({ home: HOME, id: tokenId })
|
|
208
|
+
const after = loadSecrets(HOME).inbound.find((t) => t.id === tokenId)
|
|
209
|
+
check(!after, 'revoke 后该 token 从 secrets 消失')
|
|
210
|
+
}
|
|
211
|
+
if (!KEEP) {
|
|
212
|
+
rmSync(stage, { recursive: true, force: true })
|
|
213
|
+
check(!existsSync(stage), '清理临时目录')
|
|
214
|
+
} else {
|
|
215
|
+
console.log(`[onboard] --keep:保留 ${stage}`)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return finish()
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function finish() {
|
|
222
|
+
console.log('')
|
|
223
|
+
for (const line of results) console.log(line)
|
|
224
|
+
console.log(`\ne2e-onboard: ${pass}/${pass + fail} passed`)
|
|
225
|
+
process.exitCode = fail === 0 ? 0 : 1
|
|
226
|
+
return fail === 0
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
main().catch((err) => {
|
|
230
|
+
console.error('[onboard] fatal:', err)
|
|
231
|
+
process.exitCode = 1
|
|
232
|
+
})
|