dsh-mesh-node 0.0.0-stage → 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +8 -2
- package/package.json +43 -4
- package/src/cli.js +1246 -0
- package/src/core/acl.js +81 -0
- package/src/core/audit.js +75 -0
- package/src/core/autostart.js +119 -0
- package/src/core/cluster.js +340 -0
- package/src/core/config.js +622 -0
- package/src/core/desktop-proxy.js +85 -0
- package/src/core/ds-web.js +394 -0
- package/src/core/host-agent.js +263 -0
- package/src/core/jobs.js +259 -0
- package/src/core/pairing.js +141 -0
- package/src/core/peer-client.js +273 -0
- package/src/core/peer-doctor.js +294 -0
- package/src/core/peer-http.js +61 -0
- package/src/core/repair.js +93 -0
- package/src/core/restart-policy.js +21 -0
- package/src/core/result.js +29 -0
- package/src/core/session-profile.js +146 -0
- package/src/core/tls.js +160 -0
- package/src/core/util.js +68 -0
- package/src/e2e-autostart.js +58 -0
- package/src/e2e-channel.js +384 -0
- package/src/e2e-cluster.js +417 -0
- package/src/e2e-host.js +411 -0
- package/src/e2e-onboard.js +232 -0
- package/src/e2e-pair.js +317 -0
- package/src/e2e-plugin.js +395 -0
- package/src/e2e-repair.js +297 -0
- package/src/index.js +20 -0
- package/src/peer-bridge.js +124 -0
- package/src/selftest.js +872 -0
- package/src/server.js +313 -0
- package/src/tools/alerts.js +80 -0
- package/src/tools/channel.js +253 -0
- package/src/tools/cluster.js +235 -0
- package/src/tools/config-patch.js +182 -0
- package/src/tools/control-apps.js +469 -0
- package/src/tools/control-path.js +160 -0
- package/src/tools/control.js +232 -0
- package/src/tools/core.js +79 -0
- package/src/tools/delegate.js +653 -0
- package/src/tools/fs.js +436 -0
- package/src/tools/git.js +266 -0
- package/src/tools/host.js +305 -0
- package/src/tools/index.js +122 -0
- package/src/tools/repair-fs.js +130 -0
- package/src/tools/repair.js +317 -0
- package/src/tools/session-history.js +60 -0
- package/src/tools/shell.js +66 -0
- package/src/tools/tasks.js +117 -0
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
// e2e: the in-process host bridge plugin (plugin/lib/index.js), exercised with a mock ctx.
|
|
2
|
+
// The plugin normally runs inside DSH; here we inject fake `tools` + `agents` services so every
|
|
3
|
+
// endpoint, auth path and error mapping is covered without a real DSH process.
|
|
4
|
+
import assert from 'node:assert/strict'
|
|
5
|
+
import { mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs'
|
|
6
|
+
import { tmpdir } from 'node:os'
|
|
7
|
+
import { join } from 'node:path'
|
|
8
|
+
import { apply, PLUGIN_VERSION } from '../plugin/lib/index.js'
|
|
9
|
+
|
|
10
|
+
const home = mkdtempSync(join(tmpdir(), 'dsh-mesh-plugin-'))
|
|
11
|
+
const port = 19000 + Math.floor(Math.random() * 800)
|
|
12
|
+
const base = `http://127.0.0.1:${port}`
|
|
13
|
+
process.env.DSH_MESH_HOME = home
|
|
14
|
+
process.env.DSH_MESH_HOST_PORT = String(port)
|
|
15
|
+
process.env.DSH_MESH_HOST_TOKEN_FILE = join(home, 'host-token')
|
|
16
|
+
|
|
17
|
+
// ---------- mock host services ----------
|
|
18
|
+
const executed = []
|
|
19
|
+
const sent = []
|
|
20
|
+
const toolMap = new Map([
|
|
21
|
+
['echo', { name: 'echo' }],
|
|
22
|
+
['deny', { name: 'deny' }],
|
|
23
|
+
['boom', { name: 'boom' }],
|
|
24
|
+
['slow', { name: 'slow' }]
|
|
25
|
+
])
|
|
26
|
+
const tools = {
|
|
27
|
+
schemas: () => [...toolMap.values()].map(t => ({ name: t.name, description: `${t.name} tool`, parameters: null })),
|
|
28
|
+
get: name => toolMap.get(name),
|
|
29
|
+
execute: async input => {
|
|
30
|
+
executed.push(input)
|
|
31
|
+
if (input.name === 'echo') return { isError: false, value: { echoed: input.arguments }, content: [] }
|
|
32
|
+
if (input.name === 'deny') return { isError: true, error: { code: 'E_DENIED', message: 'denied by ACL' }, content: [] }
|
|
33
|
+
if (input.name === 'slow') return { isError: false, value: { waited: true }, content: [] }
|
|
34
|
+
throw Object.assign(new Error('exploded'), { code: 'E_BOOM' })
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function makeAgent(id, status, opts = {}) {
|
|
39
|
+
const agent = {
|
|
40
|
+
id,
|
|
41
|
+
status,
|
|
42
|
+
session: { title: opts.title ?? `title-${id}`, seq: opts.seq ?? 1 }
|
|
43
|
+
}
|
|
44
|
+
if (opts.sendless !== true) {
|
|
45
|
+
agent.send = (message, target, wakeup) => sent.push({ kind: 'send', id, message, target, wakeup })
|
|
46
|
+
} else {
|
|
47
|
+
agent.followup = message => sent.push({ kind: 'followup', id, message })
|
|
48
|
+
}
|
|
49
|
+
return agent
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const agentMap = new Map([
|
|
53
|
+
['sess-running', makeAgent('sess-running', 'running', { seq: 7 })],
|
|
54
|
+
['sess-idle', makeAgent('sess-idle', 'idle')],
|
|
55
|
+
['sess-followup', makeAgent('sess-followup', 'running', { sendless: true })]
|
|
56
|
+
])
|
|
57
|
+
const agents = { get: id => agentMap.get(id), list: () => [...agentMap.values()] }
|
|
58
|
+
|
|
59
|
+
// sessionController is the path the web UI's "new chat" uses; the plugin only
|
|
60
|
+
// reaches it through ctx.get (optional service, so old profiles still load).
|
|
61
|
+
const sessionCalls = []
|
|
62
|
+
const sessionController = {
|
|
63
|
+
create: async request => {
|
|
64
|
+
sessionCalls.push({ kind: 'create', request })
|
|
65
|
+
return { sessionId: request?.sessionId || 'sess-created', agentPreset: request?.agentPreset || 'default' }
|
|
66
|
+
},
|
|
67
|
+
prompt: async (request, signal) => {
|
|
68
|
+
sessionCalls.push({ kind: 'prompt', request, signal })
|
|
69
|
+
return { accepted: true }
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let dispose
|
|
74
|
+
const ctx = {
|
|
75
|
+
tools,
|
|
76
|
+
agents,
|
|
77
|
+
get: name => (name === 'sessionController' ? sessionController : undefined),
|
|
78
|
+
effect: factory => {
|
|
79
|
+
dispose = factory()
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
apply(ctx)
|
|
83
|
+
|
|
84
|
+
// ---------- helpers ----------
|
|
85
|
+
const auth = () => ({ authorization: `Bearer ${readFileSync(join(home, 'host-token'), 'utf8').trim()}` })
|
|
86
|
+
|
|
87
|
+
async function call(method, path, { body, headers = {}, raw } = {}) {
|
|
88
|
+
const res = await fetch(`${base}${path}`, {
|
|
89
|
+
method,
|
|
90
|
+
headers: { 'content-type': 'application/json', ...headers },
|
|
91
|
+
body: raw ?? (body === undefined ? undefined : JSON.stringify(body))
|
|
92
|
+
})
|
|
93
|
+
const text = await res.text()
|
|
94
|
+
let json
|
|
95
|
+
try {
|
|
96
|
+
json = JSON.parse(text)
|
|
97
|
+
} catch {
|
|
98
|
+
json = { raw: text }
|
|
99
|
+
}
|
|
100
|
+
return { status: res.status, body: json }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function waitReady() {
|
|
104
|
+
for (let i = 0; i < 100; i += 1) {
|
|
105
|
+
try {
|
|
106
|
+
const res = await fetch(`${base}/host/ping`)
|
|
107
|
+
if (res.ok) return
|
|
108
|
+
} catch {
|
|
109
|
+
/* not listening yet */
|
|
110
|
+
}
|
|
111
|
+
await new Promise(r => setTimeout(r, 50))
|
|
112
|
+
}
|
|
113
|
+
throw new Error('plugin listener never came up')
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ---------- checks ----------
|
|
117
|
+
const checks = []
|
|
118
|
+
const check = (name, fn) => checks.push({ name, fn })
|
|
119
|
+
|
|
120
|
+
check('ping needs no token', async () => {
|
|
121
|
+
await waitReady()
|
|
122
|
+
const { status, body } = await call('GET', '/host/ping')
|
|
123
|
+
assert.equal(status, 200)
|
|
124
|
+
assert.equal(body.ok, true)
|
|
125
|
+
assert.equal(body.plugin, PLUGIN_VERSION)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
check('token file auto-created 0600', () => {
|
|
129
|
+
const path = join(home, 'host-token')
|
|
130
|
+
const token = readFileSync(path, 'utf8').trim()
|
|
131
|
+
assert.match(token, /^[a-f0-9]{64}$/)
|
|
132
|
+
if (process.platform !== 'win32') assert.equal(statSync(path).mode & 0o777, 0o600)
|
|
133
|
+
return `${token.length} hex chars`
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
check('missing token → 401 E_AUTH', async () => {
|
|
137
|
+
const { status, body } = await call('GET', '/host/self')
|
|
138
|
+
assert.equal(status, 401)
|
|
139
|
+
assert.equal(body.code, 'E_AUTH')
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
check('wrong token → 401', async () => {
|
|
143
|
+
const { status } = await call('GET', '/host/self', { headers: { authorization: 'Bearer nope' } })
|
|
144
|
+
assert.equal(status, 401)
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
check('self reports tools + agents + node', async () => {
|
|
148
|
+
const { status, body } = await call('GET', '/host/self', { headers: auth() })
|
|
149
|
+
assert.equal(status, 200)
|
|
150
|
+
assert.deepEqual(body.tools.sort(), ['boom', 'deny', 'echo', 'slow'])
|
|
151
|
+
assert.equal(body.agents.length, 3)
|
|
152
|
+
assert.equal(body.node.pid, process.pid)
|
|
153
|
+
assert.ok(body.now)
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
check('host/tools returns schemas', async () => {
|
|
157
|
+
const { body } = await call('GET', '/host/tools', { headers: auth() })
|
|
158
|
+
assert.equal(body.tools.length, 4)
|
|
159
|
+
assert.deepEqual(Object.keys(body.tools[0]).sort(), ['description', 'name', 'parameters'])
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
check('host/agents exposes status/title/seq', async () => {
|
|
163
|
+
const { body } = await call('GET', '/host/agents', { headers: auth() })
|
|
164
|
+
const running = body.agents.find(a => a.id === 'sess-running')
|
|
165
|
+
assert.equal(running.status, 'running')
|
|
166
|
+
assert.equal(running.title, 'title-sess-running')
|
|
167
|
+
assert.equal(running.seq, 7)
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
check('tools/call happy path → value', async () => {
|
|
171
|
+
const { status, body } = await call('POST', '/host/tools/call', {
|
|
172
|
+
headers: auth(),
|
|
173
|
+
body: { tool: 'echo', arguments: { hi: 1 } }
|
|
174
|
+
})
|
|
175
|
+
assert.equal(status, 200)
|
|
176
|
+
assert.deepEqual(body, { ok: true, value: { echoed: { hi: 1 } }, content: [] })
|
|
177
|
+
const last = executed.at(-1)
|
|
178
|
+
assert.equal(last.name, 'echo')
|
|
179
|
+
assert.match(last.callId, /^[0-9a-f-]{36}$/)
|
|
180
|
+
assert.ok(last.signal instanceof AbortSignal)
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
check('tools/call unknown tool → 404 E_NOT_FOUND', async () => {
|
|
184
|
+
const { status, body } = await call('POST', '/host/tools/call', { headers: auth(), body: { tool: 'nope' } })
|
|
185
|
+
assert.equal(status, 404)
|
|
186
|
+
assert.equal(body.code, 'E_NOT_FOUND')
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
check('tools/call maps isError → ok:false + code', async () => {
|
|
190
|
+
const { status, body } = await call('POST', '/host/tools/call', { headers: auth(), body: { tool: 'deny' } })
|
|
191
|
+
assert.equal(status, 200)
|
|
192
|
+
assert.equal(body.ok, false)
|
|
193
|
+
assert.equal(body.code, 'E_DENIED')
|
|
194
|
+
assert.equal(body.message, 'denied by ACL')
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
check('tools/call thrown error → ok:false E_BOOM', async () => {
|
|
198
|
+
const { body } = await call('POST', '/host/tools/call', { headers: auth(), body: { tool: 'boom' } })
|
|
199
|
+
assert.equal(body.ok, false)
|
|
200
|
+
assert.equal(body.code, 'E_BOOM')
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
check('tools/call bad JSON → 400 E_INVALID_ARG', async () => {
|
|
204
|
+
const { status, body } = await call('POST', '/host/tools/call', { headers: auth(), raw: '{oops' })
|
|
205
|
+
assert.equal(status, 400)
|
|
206
|
+
assert.equal(body.code, 'E_INVALID_ARG')
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
check('tools/call missing tool → 400', async () => {
|
|
210
|
+
const { status, body } = await call('POST', '/host/tools/call', { headers: auth(), body: {} })
|
|
211
|
+
assert.equal(status, 400)
|
|
212
|
+
assert.equal(body.code, 'E_INVALID_ARG')
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
check('tools/call unknown sessionId → 404', async () => {
|
|
216
|
+
const { status, body } = await call('POST', '/host/tools/call', {
|
|
217
|
+
headers: auth(),
|
|
218
|
+
body: { tool: 'echo', sessionId: 'ghost' }
|
|
219
|
+
})
|
|
220
|
+
assert.equal(status, 404)
|
|
221
|
+
assert.equal(body.code, 'E_NOT_FOUND')
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
check('tools/call passes the live agent through', async () => {
|
|
225
|
+
const { body } = await call('POST', '/host/tools/call', {
|
|
226
|
+
headers: auth(),
|
|
227
|
+
body: { tool: 'slow', sessionId: 'sess-running' }
|
|
228
|
+
})
|
|
229
|
+
assert.equal(body.ok, true)
|
|
230
|
+
assert.equal(executed.at(-1).agent.id, 'sess-running')
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
check('sessions/create calls sessionController.create with cwd + preset', async () => {
|
|
234
|
+
const { status, body } = await call('POST', '/host/sessions/create', {
|
|
235
|
+
headers: auth(),
|
|
236
|
+
body: { cwd: 'C:/work/repo', agentPreset: 'code' }
|
|
237
|
+
})
|
|
238
|
+
assert.equal(status, 200)
|
|
239
|
+
assert.equal(body.ok, true)
|
|
240
|
+
assert.equal(body.sessionId, 'sess-created')
|
|
241
|
+
assert.equal(body.agentPreset, 'code')
|
|
242
|
+
const last = sessionCalls.at(-1)
|
|
243
|
+
assert.equal(last.kind, 'create')
|
|
244
|
+
assert.deepEqual(last.request, { cwd: 'C:/work/repo', agentPreset: 'code' })
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
check('sessions/create requires the host token', async () => {
|
|
248
|
+
const { status, body } = await call('POST', '/host/sessions/create', { body: {} })
|
|
249
|
+
assert.equal(status, 401)
|
|
250
|
+
assert.equal(body.code, 'E_AUTH')
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
check('prompt calls sessionController.prompt with mode + text content', async () => {
|
|
254
|
+
const { status, body } = await call('POST', '/host/prompt', {
|
|
255
|
+
headers: auth(),
|
|
256
|
+
body: { sessionId: 'sess-idle', text: 'do the thing', mode: 'steer', requestId: 'req-fixed-1' }
|
|
257
|
+
})
|
|
258
|
+
assert.equal(status, 200)
|
|
259
|
+
assert.equal(body.ok, true)
|
|
260
|
+
assert.equal(body.sessionId, 'sess-idle')
|
|
261
|
+
assert.equal(body.requestId, 'req-fixed-1')
|
|
262
|
+
assert.equal(body.mode, 'steer')
|
|
263
|
+
assert.equal(body.accepted, true)
|
|
264
|
+
const last = sessionCalls.at(-1)
|
|
265
|
+
assert.equal(last.kind, 'prompt')
|
|
266
|
+
assert.deepEqual(last.request, {
|
|
267
|
+
requestId: 'req-fixed-1',
|
|
268
|
+
sessionId: 'sess-idle',
|
|
269
|
+
mode: 'steer',
|
|
270
|
+
content: [{ type: 'text', text: 'do the thing' }]
|
|
271
|
+
})
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
check('prompt rejects empty text / missing session (bad sample)', async () => {
|
|
275
|
+
const noText = await call('POST', '/host/prompt', { headers: auth(), body: { sessionId: 'sess-idle', text: ' ' } })
|
|
276
|
+
assert.equal(noText.status, 400)
|
|
277
|
+
const noSession = await call('POST', '/host/prompt', { headers: auth(), body: { text: 'hi' } })
|
|
278
|
+
assert.equal(noSession.body.code, 'E_INVALID_ARG')
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
check('sessions/create + prompt fail loud without a sessionController (bad sample)', async () => {
|
|
282
|
+
const saved = {
|
|
283
|
+
home: process.env.DSH_MESH_HOME,
|
|
284
|
+
port: process.env.DSH_MESH_HOST_PORT,
|
|
285
|
+
token: process.env.DSH_MESH_HOST_TOKEN_FILE
|
|
286
|
+
}
|
|
287
|
+
const tmpPeerB = mkdtempSync(join(tmpdir(), 'dsh-mesh-plugin-noc-'))
|
|
288
|
+
const port2 = port + 1
|
|
289
|
+
const base2 = `http://127.0.0.1:${port2}`
|
|
290
|
+
process.env.DSH_MESH_HOME = tmpPeerB
|
|
291
|
+
process.env.DSH_MESH_HOST_PORT = String(port2)
|
|
292
|
+
process.env.DSH_MESH_HOST_TOKEN_FILE = join(tmpPeerB, 'host-token')
|
|
293
|
+
let dispose2
|
|
294
|
+
apply({ tools, agents, effect: factory => { dispose2 = factory() } })
|
|
295
|
+
try {
|
|
296
|
+
for (let i = 0; i < 100; i += 1) {
|
|
297
|
+
try {
|
|
298
|
+
if ((await fetch(`${base2}/host/ping`)).ok) break
|
|
299
|
+
} catch {
|
|
300
|
+
/* not listening yet */
|
|
301
|
+
}
|
|
302
|
+
await new Promise(r => setTimeout(r, 50))
|
|
303
|
+
}
|
|
304
|
+
const token = readFileSync(join(tmpPeerB, 'host-token'), 'utf8').trim()
|
|
305
|
+
const headers = { 'content-type': 'application/json', authorization: `Bearer ${token}` }
|
|
306
|
+
const created = await fetch(`${base2}/host/sessions/create`, { method: 'POST', headers, body: '{}' })
|
|
307
|
+
const createdBody = await created.json()
|
|
308
|
+
assert.equal(created.status, 501)
|
|
309
|
+
assert.equal(createdBody.code, 'E_HOST_NO_CONTROLLER')
|
|
310
|
+
const prompted = await fetch(`${base2}/host/prompt`, { method: 'POST', headers, body: JSON.stringify({ sessionId: 's', text: 'x' }) })
|
|
311
|
+
const promptedBody = await prompted.json()
|
|
312
|
+
assert.equal(prompted.status, 501)
|
|
313
|
+
assert.equal(promptedBody.code, 'E_HOST_NO_CONTROLLER')
|
|
314
|
+
} finally {
|
|
315
|
+
dispose2()
|
|
316
|
+
process.env.DSH_MESH_HOME = saved.home
|
|
317
|
+
process.env.DSH_MESH_HOST_PORT = saved.port
|
|
318
|
+
process.env.DSH_MESH_HOST_TOKEN_FILE = saved.token
|
|
319
|
+
rmSync(tmpPeerB, { recursive: true, force: true })
|
|
320
|
+
}
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
check('wake running session: delivered, not wokeUp', async () => {
|
|
324
|
+
const { body } = await call('POST', '/host/wake', {
|
|
325
|
+
headers: auth(),
|
|
326
|
+
body: { sessionId: 'sess-running', text: 'hello', target: 'next-step' }
|
|
327
|
+
})
|
|
328
|
+
assert.equal(body.ok, true)
|
|
329
|
+
assert.equal(body.delivered, true)
|
|
330
|
+
assert.equal(body.wokeUp, false)
|
|
331
|
+
assert.equal(body.statusBefore, 'running')
|
|
332
|
+
assert.equal(body.target, 'next-step')
|
|
333
|
+
const evt = sent.at(-1)
|
|
334
|
+
assert.equal(evt.kind, 'send')
|
|
335
|
+
assert.equal(evt.wakeup, true)
|
|
336
|
+
assert.equal(evt.target, 'next-step')
|
|
337
|
+
assert.equal(evt.message.role, 'user')
|
|
338
|
+
assert.deepEqual(evt.message.content, [{ type: 'text', text: 'hello' }])
|
|
339
|
+
assert.ok(Object.isFrozen(evt.message), 'message must be deep-frozen like createUserMessage')
|
|
340
|
+
assert.equal(evt.message.id, body.messageId)
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
check('wake idle session: wokeUp true + default target next-turn', async () => {
|
|
344
|
+
const { body } = await call('POST', '/host/wake', {
|
|
345
|
+
headers: auth(),
|
|
346
|
+
body: { sessionId: 'sess-idle', text: 'wake up' }
|
|
347
|
+
})
|
|
348
|
+
assert.equal(body.wokeUp, true)
|
|
349
|
+
assert.equal(body.target, 'next-turn')
|
|
350
|
+
assert.equal(sent.at(-1).target, 'next-turn')
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
check('wake falls back to followup when no send()', async () => {
|
|
354
|
+
const { body } = await call('POST', '/host/wake', {
|
|
355
|
+
headers: auth(),
|
|
356
|
+
body: { sessionId: 'sess-followup', text: 'fallback' }
|
|
357
|
+
})
|
|
358
|
+
assert.equal(body.ok, true)
|
|
359
|
+
assert.equal(sent.at(-1).kind, 'followup')
|
|
360
|
+
})
|
|
361
|
+
|
|
362
|
+
check('wake missing text / unknown session → 4xx', async () => {
|
|
363
|
+
const missing = await call('POST', '/host/wake', { headers: auth(), body: { sessionId: 'sess-idle' } })
|
|
364
|
+
assert.equal(missing.status, 400)
|
|
365
|
+
const ghost = await call('POST', '/host/wake', { headers: auth(), body: { sessionId: 'ghost', text: 'x' } })
|
|
366
|
+
assert.equal(ghost.status, 404)
|
|
367
|
+
})
|
|
368
|
+
|
|
369
|
+
check('unknown endpoint → 404', async () => {
|
|
370
|
+
const { status, body } = await call('GET', '/host/nope', { headers: auth() })
|
|
371
|
+
assert.equal(status, 404)
|
|
372
|
+
assert.equal(body.code, 'E_NOT_FOUND')
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
check('dispose closes the listener', async () => {
|
|
376
|
+
assert.equal(typeof dispose, 'function')
|
|
377
|
+
dispose()
|
|
378
|
+
await new Promise(r => setTimeout(r, 100))
|
|
379
|
+
await assert.rejects(() => fetch(`${base}/host/ping`))
|
|
380
|
+
})
|
|
381
|
+
|
|
382
|
+
// ---------- run ----------
|
|
383
|
+
let failed = 0
|
|
384
|
+
for (const c of checks) {
|
|
385
|
+
try {
|
|
386
|
+
const detail = await c.fn()
|
|
387
|
+
console.log(`PASS ${c.name}${detail ? ` (${detail})` : ''}`)
|
|
388
|
+
} catch (err) {
|
|
389
|
+
failed += 1
|
|
390
|
+
console.error(`FAIL ${c.name}\n ${err.message}`)
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
rmSync(home, { recursive: true, force: true })
|
|
394
|
+
console.log(`\n${checks.length - failed}/${checks.length} passed`)
|
|
395
|
+
process.exit(failed === 0 ? 0 : 1)
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { defaultConfig } from './core/config.js'
|
|
5
|
+
import { dispatch } from './tools/index.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* End-to-end test for the repair toolkit (M7 step 3): plugin_install /
|
|
9
|
+
* service_restart / repair_status. Everything runs against a throwaway mesh home
|
|
10
|
+
* and a throwaway "DSH profile", so no real DSH is touched.
|
|
11
|
+
*
|
|
12
|
+
* Usage: node src/e2e-repair.js
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const quiet = { debug() {}, info() {}, warn() {}, error() {} }
|
|
16
|
+
const results = []
|
|
17
|
+
function assert(cond, message) {
|
|
18
|
+
if (!cond) throw new Error(message)
|
|
19
|
+
}
|
|
20
|
+
async function check(name, fn) {
|
|
21
|
+
try {
|
|
22
|
+
await fn()
|
|
23
|
+
results.push({ name, ok: true })
|
|
24
|
+
console.log(`PASS ${name}`)
|
|
25
|
+
} catch (error) {
|
|
26
|
+
results.push({ name, ok: false, error: error?.message || String(error) })
|
|
27
|
+
console.log(`FAIL ${name} — ${error?.message || error}`)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function body(result) {
|
|
32
|
+
const text = result?.content?.[0]?.text || ''
|
|
33
|
+
try {
|
|
34
|
+
return JSON.parse(text)
|
|
35
|
+
} catch {
|
|
36
|
+
return { raw: text }
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const home = mkdtempSync(join(tmpdir(), 'dsh-mesh-repair-'))
|
|
41
|
+
const profileDir = join(home, 'profiles', 'web')
|
|
42
|
+
mkdirSync(profileDir, { recursive: true })
|
|
43
|
+
writeFileSync(join(profileDir, 'package.json'), JSON.stringify({ name: 'dsh-profile-mock' }), 'utf8')
|
|
44
|
+
|
|
45
|
+
const config = defaultConfig()
|
|
46
|
+
config.repair = {
|
|
47
|
+
...config.repair,
|
|
48
|
+
enabled: true,
|
|
49
|
+
maxFiles: 5,
|
|
50
|
+
maxFileBytes: 4096,
|
|
51
|
+
timeoutMs: 30000,
|
|
52
|
+
backupDir: join(home, 'backups'),
|
|
53
|
+
restartScripts: {
|
|
54
|
+
ok: 'node -e "console.log(\'RESTART-OK\')"',
|
|
55
|
+
fail: 'node -e "process.exit(3)"'
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
config.hosts = [
|
|
59
|
+
{
|
|
60
|
+
id: 'dsh0',
|
|
61
|
+
label: 'mock instance',
|
|
62
|
+
enabled: true,
|
|
63
|
+
url: 'http://127.0.0.1:1',
|
|
64
|
+
tokenFile: '',
|
|
65
|
+
timeoutMs: 1000,
|
|
66
|
+
profileDir
|
|
67
|
+
}
|
|
68
|
+
]
|
|
69
|
+
config.acl.allow = [...new Set([...config.acl.allow, 'repair_status', 'plugin_install', 'service_restart', 'fs_write'])]
|
|
70
|
+
|
|
71
|
+
const ctx = {
|
|
72
|
+
home,
|
|
73
|
+
getConfig: () => config,
|
|
74
|
+
log: quiet,
|
|
75
|
+
audit: async () => {},
|
|
76
|
+
peerId: 'e2e-repair'
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const pluginFiles = [
|
|
80
|
+
{ path: 'package.json', content: JSON.stringify({ name: 'dsh-demo-plugin', version: '1.0.0', main: 'lib/index.js' }) },
|
|
81
|
+
{ path: 'lib/index.js', content: 'export const name = "dsh-demo-plugin"\nexport function apply() {}\n' }
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
async function run() {
|
|
85
|
+
await check('repair_status: enabled + instance profileDir visible', async () => {
|
|
86
|
+
const data = body(await dispatch(ctx, 'repair_status', {}))
|
|
87
|
+
assert(data.enabled === true, 'enabled should be true')
|
|
88
|
+
assert(data.instances?.[0]?.profileDir === profileDir, `profileDir mismatch: ${data.instances?.[0]?.profileDir}`)
|
|
89
|
+
assert(Array.isArray(data.restartScripts) && data.restartScripts.includes('ok'), 'restart scripts listed')
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
await check('plugin_install: confirm is required', async () => {
|
|
93
|
+
const data = body(await dispatch(ctx, 'plugin_install', { name: 'dsh-demo-plugin', files: pluginFiles }))
|
|
94
|
+
assert(data.code === 'E_INVALID_ARG', `expected E_INVALID_ARG, got ${data.code}`)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
await check('plugin_install: writes files and wires cordis.patch.yml', async () => {
|
|
98
|
+
const data = body(await dispatch(ctx, 'plugin_install', { name: 'dsh-demo-plugin', files: pluginFiles, confirm: true }))
|
|
99
|
+
assert(data.packageDir, `missing packageDir: ${JSON.stringify(data)}`)
|
|
100
|
+
assert(existsSync(join(profileDir, 'node_modules', 'dsh-demo-plugin', 'lib', 'index.js')), 'lib/index.js not written')
|
|
101
|
+
assert(readFileSync(join(profileDir, 'cordis.patch.yml'), 'utf8').includes('dsh-demo-plugin'), 'patch not wired')
|
|
102
|
+
assert(data.patch?.changed === true, 'patch.changed should be true on first install')
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
await check('plugin_install: idempotent patch + backups an existing copy', async () => {
|
|
106
|
+
const data = body(await dispatch(ctx, 'plugin_install', { name: 'dsh-demo-plugin', files: pluginFiles, confirm: true }))
|
|
107
|
+
assert(data.patch?.changed === false, 'patch.changed should be false on second install')
|
|
108
|
+
assert(Array.isArray(data.backups) && data.backups.length > 0, 'existing plugin dir should be backed up')
|
|
109
|
+
assert(existsSync(data.backups[0]), `backup missing: ${data.backups[0]}`)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
await check('plugin_install: rejects path traversal', async () => {
|
|
113
|
+
const data = body(
|
|
114
|
+
await dispatch(ctx, 'plugin_install', {
|
|
115
|
+
name: 'dsh-evil',
|
|
116
|
+
files: [{ path: '../escape.js', content: 'x' }],
|
|
117
|
+
confirm: true
|
|
118
|
+
})
|
|
119
|
+
)
|
|
120
|
+
assert(data.code === 'E_INVALID_ARG', `expected E_INVALID_ARG, got ${data.code}`)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
await check('plugin_install: rejects absolute path', async () => {
|
|
124
|
+
const data = body(
|
|
125
|
+
await dispatch(ctx, 'plugin_install', {
|
|
126
|
+
name: 'dsh-evil',
|
|
127
|
+
files: [{ path: 'C:/Windows/evil.js', content: 'x' }],
|
|
128
|
+
confirm: true
|
|
129
|
+
})
|
|
130
|
+
)
|
|
131
|
+
assert(data.code === 'E_INVALID_ARG', `expected E_INVALID_ARG, got ${data.code}`)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
await check('plugin_install: rejects oversized payload', async () => {
|
|
135
|
+
const data = body(
|
|
136
|
+
await dispatch(ctx, 'plugin_install', {
|
|
137
|
+
name: 'dsh-big',
|
|
138
|
+
files: [{ path: 'index.js', content: 'x'.repeat(5000) }],
|
|
139
|
+
confirm: true
|
|
140
|
+
})
|
|
141
|
+
)
|
|
142
|
+
assert(data.code === 'E_INVALID_ARG', `expected E_INVALID_ARG, got ${data.code}`)
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
await check('plugin_install: rejects unknown instance', async () => {
|
|
146
|
+
const data = body(
|
|
147
|
+
await dispatch(ctx, 'plugin_install', { name: 'dsh-demo-plugin', files: pluginFiles, instance: 'nope', confirm: true })
|
|
148
|
+
)
|
|
149
|
+
assert(data.code === 'E_HOST_UNKNOWN', `expected E_HOST_UNKNOWN, got ${data.code}`)
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
await check('plugin_install: refuses profileDir outside config', async () => {
|
|
153
|
+
const data = body(
|
|
154
|
+
await dispatch(ctx, 'plugin_install', {
|
|
155
|
+
name: 'dsh-demo-plugin',
|
|
156
|
+
files: pluginFiles,
|
|
157
|
+
profileDir: join(home, 'elsewhere'),
|
|
158
|
+
confirm: true
|
|
159
|
+
})
|
|
160
|
+
)
|
|
161
|
+
assert(data.code === 'E_INVALID_ARG', `expected E_INVALID_ARG, got ${data.code}`)
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
await check('plugin_install: refuses a non-profile directory', async () => {
|
|
165
|
+
const notProfile = join(home, 'not-a-profile')
|
|
166
|
+
mkdirSync(notProfile, { recursive: true })
|
|
167
|
+
config.hosts.push({ ...config.hosts[0], id: 'dsh9', profileDir: notProfile })
|
|
168
|
+
const data = body(
|
|
169
|
+
await dispatch(ctx, 'plugin_install', { name: 'dsh-demo-plugin', files: pluginFiles, instance: 'dsh9', confirm: true })
|
|
170
|
+
)
|
|
171
|
+
config.hosts.pop()
|
|
172
|
+
assert(data.code === 'E_PROFILE_NOT_FOUND', `expected E_PROFILE_NOT_FOUND, got ${data.code}`)
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
await check('fs_write: confirm is required', async () => {
|
|
176
|
+
const data = body(await dispatch(ctx, 'fs_write', { path: 'AGENTS.md', content: 'x' }))
|
|
177
|
+
assert(data.code === 'E_INVALID_ARG', `expected E_INVALID_ARG, got ${data.code}`)
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
await check('fs_write: creates a new file and verifies sha256', async () => {
|
|
181
|
+
const data = body(await dispatch(ctx, 'fs_write', { path: 'config/extra.json', content: '{"ok":true}\n', confirm: true }))
|
|
182
|
+
assert(data.created === true, `created should be true: ${JSON.stringify(data)}`)
|
|
183
|
+
assert(data.verified === true, 'verified should be true')
|
|
184
|
+
assert(existsSync(join(profileDir, 'config', 'extra.json')), 'file not written')
|
|
185
|
+
assert(readFileSync(join(profileDir, 'config', 'extra.json'), 'utf8') === '{"ok":true}\n', 'content mismatch')
|
|
186
|
+
assert(data.sha256 && data.sha256.length === 64, `sha256 missing: ${data.sha256}`)
|
|
187
|
+
assert(data.backup === null, `no backup expected for a new file, got ${data.backup}`)
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
await check('fs_write: backups the old content before overwriting', async () => {
|
|
191
|
+
const data = body(await dispatch(ctx, 'fs_write', { path: 'config/extra.json', content: '{"ok":false}\n', confirm: true }))
|
|
192
|
+
assert(data.created === false, 'created should be false on overwrite')
|
|
193
|
+
assert(data.backup && existsSync(data.backup), `backup missing: ${data.backup}`)
|
|
194
|
+
assert(readFileSync(data.backup, 'utf8') === '{"ok":true}\n', 'backup should hold the previous content')
|
|
195
|
+
assert(readFileSync(join(profileDir, 'config', 'extra.json'), 'utf8') === '{"ok":false}\n', 'new content not written')
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
await check('fs_write: accepts an absolute path inside the profile', async () => {
|
|
199
|
+
const target = join(profileDir, 'patch-view.yml')
|
|
200
|
+
const data = body(await dispatch(ctx, 'fs_write', { path: target, content: '- insert: []\n', confirm: true }))
|
|
201
|
+
assert(data.path === target, `path mismatch: ${data.path}`)
|
|
202
|
+
assert(readFileSync(target, 'utf8') === '- insert: []\n', 'absolute write failed')
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
await check('fs_write: rejects path traversal', async () => {
|
|
206
|
+
const data = body(await dispatch(ctx, 'fs_write', { path: '../escape.txt', content: 'x', confirm: true }))
|
|
207
|
+
assert(data.code === 'E_INVALID_ARG', `expected E_INVALID_ARG, got ${data.code}`)
|
|
208
|
+
assert(!existsSync(join(tmpdir(), 'escape.txt')) && !existsSync(join(home, 'escape.txt')), 'escape file must not exist')
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
await check('fs_write: rejects an absolute path outside the profile', async () => {
|
|
212
|
+
const data = body(await dispatch(ctx, 'fs_write', { path: join(home, 'outside.txt'), content: 'x', confirm: true }))
|
|
213
|
+
assert(data.code === 'E_INVALID_ARG', `expected E_INVALID_ARG, got ${data.code}`)
|
|
214
|
+
assert(!existsSync(join(home, 'outside.txt')), 'outside file must not exist')
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
await check('fs_write: rejects oversized content', async () => {
|
|
218
|
+
const data = body(await dispatch(ctx, 'fs_write', { path: 'big.txt', content: 'x'.repeat(5000), confirm: true }))
|
|
219
|
+
assert(data.code === 'E_INVALID_ARG', `expected E_INVALID_ARG, got ${data.code}`)
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
await check('fs_write: ACL deny after removing the allow entry', async () => {
|
|
223
|
+
config.acl.allow = config.acl.allow.filter((name) => name !== 'fs_write')
|
|
224
|
+
const result = await dispatch(ctx, 'fs_write', { path: 'nope.txt', content: 'x', confirm: true })
|
|
225
|
+
assert(result.isError === true, 'expected an error result')
|
|
226
|
+
assert(body(result).code === 'E_DENIED', `expected E_DENIED, got ${body(result).code}`)
|
|
227
|
+
config.acl.allow = [...config.acl.allow, 'fs_write']
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
await check('plugin_install: rolls the package dir back when the patch write fails', async () => {
|
|
231
|
+
const patchPath = join(profileDir, 'cordis.patch.yml')
|
|
232
|
+
const savedPatch = existsSync(patchPath) ? readFileSync(patchPath, 'utf8') : null
|
|
233
|
+
rmSync(patchPath, { force: true })
|
|
234
|
+
mkdirSync(patchPath, { recursive: true }) // a directory where the patch file must go -> write fails
|
|
235
|
+
try {
|
|
236
|
+
const data = body(
|
|
237
|
+
await dispatch(ctx, 'plugin_install', {
|
|
238
|
+
name: 'dsh-rollback-demo',
|
|
239
|
+
files: [{ path: 'index.js', content: 'export const name = "dsh-rollback-demo"\n' }],
|
|
240
|
+
confirm: true
|
|
241
|
+
})
|
|
242
|
+
)
|
|
243
|
+
assert(data.code === 'E_WRITE_FAILED', `expected E_WRITE_FAILED, got ${data.code}`)
|
|
244
|
+
assert(!existsSync(join(profileDir, 'node_modules', 'dsh-rollback-demo')), 'package dir must be rolled back')
|
|
245
|
+
} finally {
|
|
246
|
+
rmSync(patchPath, { recursive: true, force: true })
|
|
247
|
+
if (savedPatch !== null) writeFileSync(patchPath, savedPatch, 'utf8')
|
|
248
|
+
}
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
await check('service_restart: unknown script refused', async () => {
|
|
252
|
+
const data = body(await dispatch(ctx, 'service_restart', { script: 'nope', confirm: true }))
|
|
253
|
+
assert(data.code === 'E_NOT_FOUND', `expected E_NOT_FOUND, got ${data.code}`)
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
await check('service_restart: allowlisted script runs and returns output', async () => {
|
|
257
|
+
const data = body(await dispatch(ctx, 'service_restart', { script: 'ok', confirm: true }))
|
|
258
|
+
assert(data.code === 0, `expected exit 0, got ${data.code}`)
|
|
259
|
+
assert(String(data.stdout).includes('RESTART-OK'), `stdout missing marker: ${data.stdout}`)
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
await check('service_restart: failing script surfaces exit code', async () => {
|
|
263
|
+
const data = body(await dispatch(ctx, 'service_restart', { script: 'fail', confirm: true }))
|
|
264
|
+
assert(data.code === 'E_SCRIPT_FAILED', `expected E_SCRIPT_FAILED, got ${data.code}`)
|
|
265
|
+
assert(data.exitCode === 3, `expected exitCode 3, got ${data.exitCode}`)
|
|
266
|
+
assert(String(data.message).includes('exited with'), `unexpected message: ${data.message}`)
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
await check('ACL: repair tools are denied after removing the allow entry', async () => {
|
|
270
|
+
config.acl.allow = config.acl.allow.filter((name) => name !== 'plugin_install')
|
|
271
|
+
const result = await dispatch(ctx, 'plugin_install', { name: 'dsh-demo-plugin', files: pluginFiles, confirm: true })
|
|
272
|
+
assert(result.isError === true, 'expected an error result')
|
|
273
|
+
assert(body(result).code === 'E_DENIED', `expected E_DENIED, got ${body(result).code}`)
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
await check('repair disabled: gate rejects even when ACL allows', async () => {
|
|
277
|
+
config.acl.allow = [...config.acl.allow, 'plugin_install']
|
|
278
|
+
config.repair.enabled = false
|
|
279
|
+
const data = body(await dispatch(ctx, 'plugin_install', { name: 'dsh-demo-plugin', files: pluginFiles, confirm: true }))
|
|
280
|
+
assert(data.code === 'E_DENIED', `expected E_DENIED, got ${data.code}`)
|
|
281
|
+
config.repair.enabled = true
|
|
282
|
+
})
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
try {
|
|
286
|
+
await run()
|
|
287
|
+
} finally {
|
|
288
|
+
rmSync(home, { recursive: true, force: true })
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const passed = results.filter((row) => row.ok).length
|
|
292
|
+
const failed = results.length - passed
|
|
293
|
+
console.log(`\ne2e-repair: ${passed}/${results.length} passed`)
|
|
294
|
+
if (failed > 0) {
|
|
295
|
+
for (const row of results.filter((item) => !item.ok)) console.log(` FAILED ${row.name}: ${row.error}`)
|
|
296
|
+
process.exitCode = 1
|
|
297
|
+
}
|