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
package/src/server.js
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
2
|
+
import { createServer as createHttpServer } from 'node:http'
|
|
3
|
+
import { createServer as createHttpsServer } from 'node:https'
|
|
4
|
+
import { McpServer, createMcpHandler } from '@modelcontextprotocol/server'
|
|
5
|
+
import { toNodeHandler } from '@modelcontextprotocol/node'
|
|
6
|
+
import { MESH_VERSION, verifyBearer, createConfigLoader, createSecretsLoader, meshHome, addInboundToken, peerAclForToken } from './core/config.js'
|
|
7
|
+
import { makeAudit, auditPath } from './core/audit.js'
|
|
8
|
+
import { JobRunner } from './core/jobs.js'
|
|
9
|
+
import { loadTlsMaterial, describeCert } from './core/tls.js'
|
|
10
|
+
import { claimPairingCode, readPairingState } from './core/pairing.js'
|
|
11
|
+
import { dispatch, isAllowed, applyPeerAcl, registerTools, toolNames } from './tools/index.js'
|
|
12
|
+
|
|
13
|
+
const NODE_STARTED_AT = new Date().toISOString()
|
|
14
|
+
|
|
15
|
+
const INSTRUCTIONS = `This endpoint is a dsh-mesh node: another developer machine exposed over the Model Context Protocol.
|
|
16
|
+
Call mesh_self first to learn what the peer can do and which tools your token may use.
|
|
17
|
+
All paths are paths on the PEER machine, not on yours.
|
|
18
|
+
Long operations: use task_submit (with an idem key) then poll task_status, instead of risking a call timeout.
|
|
19
|
+
shell_run is opt-in and normally refused.`
|
|
20
|
+
|
|
21
|
+
/** Carries the authenticated token id down into the per-request MCP server factory. */
|
|
22
|
+
const requestScope = new AsyncLocalStorage()
|
|
23
|
+
|
|
24
|
+
export function buildMcpServer({ ctx, log = console }) {
|
|
25
|
+
const config = ctx.getConfig()
|
|
26
|
+
const server = new McpServer(
|
|
27
|
+
{ name: `dsh-mesh/${config.node?.name || 'local'}`, version: MESH_VERSION },
|
|
28
|
+
{ instructions: INSTRUCTIONS }
|
|
29
|
+
)
|
|
30
|
+
registerTools(server, ctx)
|
|
31
|
+
return server
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function sendJson(res, status, body, extraHeaders = {}) {
|
|
35
|
+
const text = JSON.stringify(body)
|
|
36
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', ...extraHeaders })
|
|
37
|
+
res.end(text)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Read a small JSON body (pairing requests are tiny; anything huge is a mistake). */
|
|
41
|
+
function readJsonBody(req, limitBytes = 8192) {
|
|
42
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
43
|
+
let size = 0
|
|
44
|
+
const chunks = []
|
|
45
|
+
req.on('data', (chunk) => {
|
|
46
|
+
size += chunk.length
|
|
47
|
+
if (size > limitBytes) {
|
|
48
|
+
rejectPromise(new Error('body too large'))
|
|
49
|
+
req.destroy()
|
|
50
|
+
return
|
|
51
|
+
}
|
|
52
|
+
chunks.push(chunk)
|
|
53
|
+
})
|
|
54
|
+
req.on('end', () => {
|
|
55
|
+
const text = Buffer.concat(chunks).toString('utf8').replace(/^\uFEFF/, '').trim()
|
|
56
|
+
if (text === '') return resolvePromise({})
|
|
57
|
+
try {
|
|
58
|
+
resolvePromise(JSON.parse(text))
|
|
59
|
+
} catch {
|
|
60
|
+
rejectPromise(new Error('body is not valid JSON'))
|
|
61
|
+
}
|
|
62
|
+
})
|
|
63
|
+
req.on('error', rejectPromise)
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Peer ids double as token ids; keep them filename/log friendly. */
|
|
68
|
+
function sanitizePeerId(raw) {
|
|
69
|
+
const id = String(raw || '')
|
|
70
|
+
.trim()
|
|
71
|
+
.replace(/[^A-Za-z0-9._-]/g, '-')
|
|
72
|
+
.replace(/-+/g, '-')
|
|
73
|
+
.slice(0, 48)
|
|
74
|
+
return id === '' ? null : id
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Start the mesh HTTP endpoint.
|
|
79
|
+
* @param {object} opts
|
|
80
|
+
* @param {object} [opts.config] Fixed config (tests). Omit to load and hot-reload from disk.
|
|
81
|
+
* @param {object} opts.secrets Inbound tokens.
|
|
82
|
+
* @param {string} [opts.home] Mesh home directory (defaults to ~/.dsh-mesh).
|
|
83
|
+
*/
|
|
84
|
+
export async function startMeshServer({ config, secrets, log = console, port, bind, tls, home } = {}) {
|
|
85
|
+
const meshHomeDir = home || meshHome()
|
|
86
|
+
const getConfig = config ? () => config : createConfigLoader({ home: meshHomeDir, log })
|
|
87
|
+
// Secrets hot-reload too: rotating a token takes effect without a restart.
|
|
88
|
+
const getSecrets = secrets ? () => secrets : createSecretsLoader({ home: meshHomeDir, log })
|
|
89
|
+
const audit = makeAudit({ home: meshHomeDir, log })
|
|
90
|
+
|
|
91
|
+
const initial = getConfig()
|
|
92
|
+
const jobs = new JobRunner({
|
|
93
|
+
home: meshHomeDir,
|
|
94
|
+
audit,
|
|
95
|
+
log,
|
|
96
|
+
limits: initial.limits || {},
|
|
97
|
+
exec: (tool, args, opts) => dispatch(ctx, tool, args, opts)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
// Shared context. `peerId` is filled per request from the verified bearer token.
|
|
101
|
+
const ctx = {
|
|
102
|
+
home: meshHomeDir,
|
|
103
|
+
secrets,
|
|
104
|
+
getSecrets,
|
|
105
|
+
log,
|
|
106
|
+
audit,
|
|
107
|
+
jobs,
|
|
108
|
+
getConfig,
|
|
109
|
+
isAllowed,
|
|
110
|
+
toolNames: () => toolNames(ctx),
|
|
111
|
+
dispatch: (name, args, opts) => dispatch(ctx, name, args, opts)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const nodeCfg = initial.node || {}
|
|
115
|
+
const listenPort = port ?? nodeCfg.port ?? 8124
|
|
116
|
+
const listenBind = bind ?? nodeCfg.bind ?? '127.0.0.1'
|
|
117
|
+
const mcpPath = nodeCfg.path || '/mcp'
|
|
118
|
+
const tlsCfg = tls ?? nodeCfg.tls ?? { enabled: false }
|
|
119
|
+
|
|
120
|
+
let calls = 0
|
|
121
|
+
const mcpHandler = createMcpHandler(
|
|
122
|
+
() => {
|
|
123
|
+
const scope = requestScope.getStore()
|
|
124
|
+
const peerAcl = scope?.peerAcl || null
|
|
125
|
+
return buildMcpServer({
|
|
126
|
+
ctx: {
|
|
127
|
+
...ctx,
|
|
128
|
+
peerId: scope?.tokenId,
|
|
129
|
+
peerAcl,
|
|
130
|
+
getConfig: () => applyPeerAcl(getConfig(), peerAcl)
|
|
131
|
+
},
|
|
132
|
+
log
|
|
133
|
+
})
|
|
134
|
+
},
|
|
135
|
+
{ onerror: (error) => log?.error?.(`[dsh-mesh] handler error: ${error?.message || error}`) }
|
|
136
|
+
)
|
|
137
|
+
const nodeHandler = toNodeHandler(mcpHandler)
|
|
138
|
+
|
|
139
|
+
const requestListener = async (req, res) => {
|
|
140
|
+
let pathname = '/'
|
|
141
|
+
try {
|
|
142
|
+
pathname = new URL(req.url, 'http://mesh.local').pathname
|
|
143
|
+
} catch {
|
|
144
|
+
/* malformed URL falls through to 404 */
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (pathname === '/health') {
|
|
148
|
+
const liveConfig = getConfig()
|
|
149
|
+
const pairingState = readPairingState(meshHomeDir)
|
|
150
|
+
sendJson(res, 200, {
|
|
151
|
+
ok: true,
|
|
152
|
+
service: 'dsh-mesh',
|
|
153
|
+
version: MESH_VERSION,
|
|
154
|
+
node: liveConfig.node?.name || 'local',
|
|
155
|
+
/** Local DSH instances this node can bridge to (cluster addressing: <node>:<instance>). */
|
|
156
|
+
hosts: (liveConfig.hosts || []).map((entry) => ({
|
|
157
|
+
id: entry.id,
|
|
158
|
+
url: entry.url,
|
|
159
|
+
enabled: entry.enabled !== false
|
|
160
|
+
})),
|
|
161
|
+
uptimeSec: Math.round((Date.now() - Date.parse(NODE_STARTED_AT)) / 1000),
|
|
162
|
+
pid: process.pid,
|
|
163
|
+
startedAt: NODE_STARTED_AT,
|
|
164
|
+
tools: toolNames(ctx).length,
|
|
165
|
+
jobs: jobs.stats(),
|
|
166
|
+
tls: tlsCfg?.enabled === true,
|
|
167
|
+
pairing: {
|
|
168
|
+
enabled: liveConfig.pairing?.enabled === true,
|
|
169
|
+
codeActive: Boolean(pairingState && !pairingState.used && !pairingState.expired),
|
|
170
|
+
expiresAt: pairingState?.expiresAt ?? null
|
|
171
|
+
},
|
|
172
|
+
now: new Date().toISOString()
|
|
173
|
+
})
|
|
174
|
+
return
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Unauthenticated by design: a peer has no token yet, it redeems a one-time code instead.
|
|
178
|
+
if (pathname === '/pair') {
|
|
179
|
+
if (req.method !== 'POST') {
|
|
180
|
+
sendJson(res, 405, { code: 'E_INVALID_ARG', message: 'use POST /pair' }, { allow: 'POST' })
|
|
181
|
+
return
|
|
182
|
+
}
|
|
183
|
+
const liveConfig = getConfig()
|
|
184
|
+
if (liveConfig.pairing?.enabled !== true) {
|
|
185
|
+
sendJson(res, 403, { code: 'E_DENIED', message: 'pairing is disabled on this node' })
|
|
186
|
+
return
|
|
187
|
+
}
|
|
188
|
+
let body
|
|
189
|
+
try {
|
|
190
|
+
body = await readJsonBody(req)
|
|
191
|
+
} catch (error) {
|
|
192
|
+
sendJson(res, 400, { code: 'E_INVALID_ARG', message: error?.message || 'bad body' })
|
|
193
|
+
return
|
|
194
|
+
}
|
|
195
|
+
const claim = claimPairingCode({
|
|
196
|
+
home: meshHomeDir,
|
|
197
|
+
code: body?.code,
|
|
198
|
+
maxAttempts: Number(liveConfig.pairing?.maxAttempts) || 5
|
|
199
|
+
})
|
|
200
|
+
if (!claim.ok) {
|
|
201
|
+
// Small constant delay so the answer time does not reveal much; never say why.
|
|
202
|
+
await new Promise((r) => setTimeout(r, 200))
|
|
203
|
+
await audit({ peer: 'pair', tool: 'pair', ok: false, code: claim.reason, args: ['code'] })
|
|
204
|
+
sendJson(
|
|
205
|
+
res,
|
|
206
|
+
401,
|
|
207
|
+
{
|
|
208
|
+
code: claim.reason === 'E_MAX_ATTEMPTS' ? 'E_MAX_ATTEMPTS' : 'E_AUTH',
|
|
209
|
+
message: 'pairing code rejected',
|
|
210
|
+
retryable: claim.reason !== 'E_MAX_ATTEMPTS' && claim.reason !== 'E_CODE_USED'
|
|
211
|
+
},
|
|
212
|
+
{ 'www-authenticate': 'Bearer realm="dsh-mesh"' }
|
|
213
|
+
)
|
|
214
|
+
return
|
|
215
|
+
}
|
|
216
|
+
const baseId = sanitizePeerId(body?.peerName) || `peer-${Date.now().toString(36)}`
|
|
217
|
+
let created
|
|
218
|
+
try {
|
|
219
|
+
created = addInboundToken({
|
|
220
|
+
home: meshHomeDir,
|
|
221
|
+
id: baseId,
|
|
222
|
+
note: `paired ${new Date().toISOString()}`,
|
|
223
|
+
acl: body?.acl
|
|
224
|
+
})
|
|
225
|
+
} catch {
|
|
226
|
+
created = addInboundToken({
|
|
227
|
+
home: meshHomeDir,
|
|
228
|
+
id: `${baseId}-${Math.random().toString(36).slice(2, 6)}`,
|
|
229
|
+
note: `paired ${new Date().toISOString()}`,
|
|
230
|
+
acl: body?.acl
|
|
231
|
+
})
|
|
232
|
+
}
|
|
233
|
+
await audit({ peer: created.id, tool: 'pair', ok: true, args: ['code', 'peerName'] })
|
|
234
|
+
const scheme = tlsCfg?.enabled === true ? 'https' : 'http'
|
|
235
|
+
const address = httpServer.address()
|
|
236
|
+
const actualPort = typeof address === 'object' && address ? address.port : listenPort
|
|
237
|
+
sendJson(res, 200, {
|
|
238
|
+
ok: true,
|
|
239
|
+
token: created.token,
|
|
240
|
+
tokenId: created.id,
|
|
241
|
+
node: liveConfig.node?.name || 'local',
|
|
242
|
+
version: MESH_VERSION,
|
|
243
|
+
url: `${scheme}://${listenBind}:${actualPort}${mcpPath}`,
|
|
244
|
+
certFingerprint: tlsCfg?.enabled === true ? describeCert(meshHomeDir)?.fingerprint256 ?? null : null,
|
|
245
|
+
tools: toolNames(ctx).length
|
|
246
|
+
})
|
|
247
|
+
return
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (pathname !== mcpPath) {
|
|
251
|
+
sendJson(res, 404, { code: 'E_NOT_FOUND', message: `no route for ${pathname}`, mcpEndpoint: mcpPath })
|
|
252
|
+
return
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const secretsNow = getSecrets()
|
|
256
|
+
const tokenId = verifyBearer(req, secretsNow)
|
|
257
|
+
if (!tokenId) {
|
|
258
|
+
sendJson(
|
|
259
|
+
res,
|
|
260
|
+
401,
|
|
261
|
+
{ code: 'E_AUTH', message: 'missing or invalid bearer token' },
|
|
262
|
+
{ 'www-authenticate': 'Bearer realm="dsh-mesh"' }
|
|
263
|
+
)
|
|
264
|
+
return
|
|
265
|
+
}
|
|
266
|
+
const peerAcl = peerAclForToken(secretsNow, tokenId)
|
|
267
|
+
|
|
268
|
+
calls += 1
|
|
269
|
+
const started = Date.now()
|
|
270
|
+
res.on('close', () => {
|
|
271
|
+
log?.debug?.(`[dsh-mesh] ${req.method} ${pathname} token=${tokenId} ${res.statusCode} ${Date.now() - started}ms`)
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
try {
|
|
275
|
+
await requestScope.run({ tokenId, peerAcl }, () => nodeHandler(req, res))
|
|
276
|
+
} catch (error) {
|
|
277
|
+
log?.error?.(`[dsh-mesh] transport failure: ${error?.message || error}`)
|
|
278
|
+
if (!res.headersSent) sendJson(res, 500, { code: 'E_INTERNAL', message: 'transport failure' })
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const httpServer =
|
|
283
|
+
tlsCfg?.enabled === true
|
|
284
|
+
? createHttpsServer(loadTlsMaterial({ home: meshHomeDir, cert: tlsCfg.cert, key: tlsCfg.key }), requestListener)
|
|
285
|
+
: createHttpServer(requestListener)
|
|
286
|
+
|
|
287
|
+
await new Promise((resolvePromise, rejectPromise) => {
|
|
288
|
+
httpServer.once('error', rejectPromise)
|
|
289
|
+
httpServer.listen(listenPort, listenBind, () => {
|
|
290
|
+
httpServer.off('error', rejectPromise)
|
|
291
|
+
resolvePromise()
|
|
292
|
+
})
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
const address = httpServer.address()
|
|
296
|
+
const actualPort = typeof address === 'object' && address ? address.port : listenPort
|
|
297
|
+
const scheme = tlsCfg?.enabled === true ? 'https' : 'http'
|
|
298
|
+
const url = `${scheme}://${listenBind}:${actualPort}${mcpPath}`
|
|
299
|
+
|
|
300
|
+
return {
|
|
301
|
+
port: actualPort,
|
|
302
|
+
url,
|
|
303
|
+
httpServer,
|
|
304
|
+
ctx,
|
|
305
|
+
auditPath: auditPath(meshHomeDir),
|
|
306
|
+
get requestCount() {
|
|
307
|
+
return calls
|
|
308
|
+
},
|
|
309
|
+
close() {
|
|
310
|
+
return new Promise((resolvePromise) => httpServer.close(() => resolvePromise()))
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// @generated 2026-10-27 P2:mesh_alert(查看/清除本机「当前告警」指针)
|
|
2
|
+
// [why] 告警写进 <mesh home>/ALERT.json 后,只能本机跑 `mesh-health --clear-alert` 清;
|
|
3
|
+
// 加一个 mesh 工具就能远程清(例如家里/peer-x 上残留的旧告警),历史 alerts.jsonl 默认保留。
|
|
4
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import { z } from 'zod'
|
|
7
|
+
import { fail, ok } from '../core/result.js'
|
|
8
|
+
|
|
9
|
+
function alertPaths(home) {
|
|
10
|
+
return { state: join(home, 'ALERT.json'), history: join(home, 'alerts.jsonl') }
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function readLatest(state) {
|
|
14
|
+
try {
|
|
15
|
+
const parsed = JSON.parse(readFileSync(state, 'utf8'))
|
|
16
|
+
return parsed && parsed.at ? parsed : null
|
|
17
|
+
} catch {
|
|
18
|
+
return null
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function buildAlerts(ctx) {
|
|
23
|
+
return [
|
|
24
|
+
{
|
|
25
|
+
name: 'mesh_alert',
|
|
26
|
+
title: 'Inspect or clear this node active alert',
|
|
27
|
+
description:
|
|
28
|
+
'Read the latest alert (ALERT.json) plus the last 10 entries of alerts.jsonl, or clear the "active alert" pointer so mesh-health stops showing it (history is kept unless keepHistory:false). status is read-only; clear requires confirm:true. Use it after verifying a recovered incident.',
|
|
29
|
+
inputSchema: z.object({
|
|
30
|
+
op: z.enum(['status', 'clear']).describe('status = read; clear = reset the active-alert pointer'),
|
|
31
|
+
confirm: z.boolean().optional().describe('clear requires true'),
|
|
32
|
+
keepHistory: z.boolean().optional().describe('clear: keep alerts.jsonl history (default true; false truncates it)')
|
|
33
|
+
}),
|
|
34
|
+
handler: async ({ op, confirm, keepHistory }) => {
|
|
35
|
+
const { state, history } = alertPaths(ctx.home)
|
|
36
|
+
if (op === 'status') {
|
|
37
|
+
let recent = []
|
|
38
|
+
try {
|
|
39
|
+
recent = readFileSync(history, 'utf8')
|
|
40
|
+
.trim()
|
|
41
|
+
.split('\n')
|
|
42
|
+
.filter(Boolean)
|
|
43
|
+
.slice(-10)
|
|
44
|
+
.map((line) => {
|
|
45
|
+
try {
|
|
46
|
+
return JSON.parse(line)
|
|
47
|
+
} catch {
|
|
48
|
+
return { raw: line.slice(0, 200) }
|
|
49
|
+
}
|
|
50
|
+
})
|
|
51
|
+
} catch {
|
|
52
|
+
/* no history yet */
|
|
53
|
+
}
|
|
54
|
+
return ok({ latest: readLatest(state), recentCount: recent.length, recent, paths: { state, history } })
|
|
55
|
+
}
|
|
56
|
+
if (op === 'clear') {
|
|
57
|
+
if (confirm !== true) return fail('E_INVALID_ARG', 'clear requires confirm:true')
|
|
58
|
+
const previous = readLatest(state)
|
|
59
|
+
try {
|
|
60
|
+
mkdirSync(ctx.home, { recursive: true })
|
|
61
|
+
writeFileSync(state, '{}\n', 'utf8')
|
|
62
|
+
} catch (error) {
|
|
63
|
+
return fail('E_WRITE', `cannot clear active alert: ${error?.message || error}`)
|
|
64
|
+
}
|
|
65
|
+
let historyTruncated = false
|
|
66
|
+
if (keepHistory === false) {
|
|
67
|
+
try {
|
|
68
|
+
writeFileSync(history, '', 'utf8')
|
|
69
|
+
historyTruncated = true
|
|
70
|
+
} catch {
|
|
71
|
+
/* keep history on failure */
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return ok({ cleared: Boolean(previous), previous, keptHistory: !historyTruncated, historyTruncated })
|
|
75
|
+
}
|
|
76
|
+
return fail('E_INVALID_ARG', 'op must be status|clear')
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
]
|
|
80
|
+
}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import { DshWebClient, DshError, assistantText, summarizeRecord } from '../core/ds-web.js'
|
|
3
|
+
import { saveSecrets } from '../core/config.js'
|
|
4
|
+
import { fail, invalidArg, notFound, ok } from '../core/result.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* M3 channel (route A): talk to a *local* DSH web instance's Remote API so a peer AI
|
|
8
|
+
* can put a message into a running session and read the reply back.
|
|
9
|
+
*
|
|
10
|
+
* Route B (a DSH plugin calling `inbox.append`) needs an installed plugin and is not
|
|
11
|
+
* implemented yet; when it lands these tools keep the same surface.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
function meshHome(ctx) {
|
|
15
|
+
return ctx.home
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function targets(ctx) {
|
|
19
|
+
const config = ctx.getConfig()
|
|
20
|
+
const configured = (config.dsh?.targets || []).filter((t) => t && t.id && t.baseUrl)
|
|
21
|
+
return configured.map((t) => ({ id: t.id, baseUrl: t.baseUrl, note: t.note }))
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function secretsOf(ctx) {
|
|
25
|
+
try {
|
|
26
|
+
return ctx.getSecrets() || {}
|
|
27
|
+
} catch {
|
|
28
|
+
return {}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Persist the cookie so a restart of the mesh node does not need a new launch URL. */
|
|
33
|
+
function rememberCookie(ctx, { targetId, baseUrl, cookie }) {
|
|
34
|
+
const secrets = secretsOf(ctx)
|
|
35
|
+
secrets.dshWeb = { ...(secrets.dshWeb || {}) }
|
|
36
|
+
secrets.dshWeb[targetId] = { baseUrl, cookie, savedAt: new Date().toISOString() }
|
|
37
|
+
saveSecrets(secrets, meshHome(ctx))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function targetInfo(ctx) {
|
|
41
|
+
const secrets = secretsOf(ctx)
|
|
42
|
+
const stored = secrets.dshWeb || {}
|
|
43
|
+
return targets(ctx).map((t) => {
|
|
44
|
+
const saved = stored[t.id]
|
|
45
|
+
return {
|
|
46
|
+
id: t.id,
|
|
47
|
+
baseUrl: t.baseUrl,
|
|
48
|
+
note: t.note,
|
|
49
|
+
hasCookie: Boolean(saved?.cookie),
|
|
50
|
+
savedAt: saved?.savedAt
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Build a client for one configured target, reusing the persisted cookie. */
|
|
56
|
+
function clientFor(ctx, targetId) {
|
|
57
|
+
const secrets = secretsOf(ctx)
|
|
58
|
+
const saved = (secrets.dshWeb || {})[targetId]
|
|
59
|
+
const configured = targets(ctx).find((t) => t.id === targetId)
|
|
60
|
+
if (!configured && !saved) {
|
|
61
|
+
return {
|
|
62
|
+
error: notFound(`unknown channel target: ${targetId}`, {
|
|
63
|
+
targets: targetInfo(ctx).map((t) => t.id),
|
|
64
|
+
hint: 'add dsh.targets[] to config.json or call channel_login with a launch URL'
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const baseUrl = configured?.baseUrl || saved?.baseUrl
|
|
69
|
+
const client = new DshWebClient({ baseUrl, cookie: saved?.cookie, log: ctx.log })
|
|
70
|
+
return { client, baseUrl, targetId }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function describeError(error) {
|
|
74
|
+
if (error instanceof DshError) {
|
|
75
|
+
return fail(error.code, error.message.replace(/^[A-Z_]+: /, ''), {
|
|
76
|
+
retryable: error.code === 'E_DSH_UNREACHABLE' || error.code === 'E_TIMEOUT',
|
|
77
|
+
...error.extra
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
return fail('E_DSH_UNEXPECTED', String(error?.message || error))
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function autoTargetId(baseUrl) {
|
|
84
|
+
try {
|
|
85
|
+
const url = new URL(baseUrl)
|
|
86
|
+
const port = url.port || (url.protocol === 'https:' ? '443' : '80')
|
|
87
|
+
return `dsh-${port}`
|
|
88
|
+
} catch {
|
|
89
|
+
return 'dsh-local'
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function buildChannel(ctx) {
|
|
94
|
+
const limits = ctx.getConfig().limits || {}
|
|
95
|
+
const defaultWaitMs = limits.channelWaitMs ?? 180000
|
|
96
|
+
const defaultPollMs = limits.channelPollMs ?? 800
|
|
97
|
+
const defaultMessages = limits.channelMaxMessages ?? 60
|
|
98
|
+
|
|
99
|
+
return [
|
|
100
|
+
{
|
|
101
|
+
name: 'channel_targets',
|
|
102
|
+
title: 'List DSH channel targets',
|
|
103
|
+
description:
|
|
104
|
+
'List the local DSH instances this mesh node can talk to (config.dsh.targets plus targets saved by channel_login), and whether a session cookie is already stored. Read-only.',
|
|
105
|
+
inputSchema: z.object({}),
|
|
106
|
+
handler: async () => ok({ targets: targetInfo(ctx), hint: 'channel_login <launchUrl> to (re)authenticate' })
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
name: 'channel_login',
|
|
110
|
+
title: 'Log in to a local DSH instance',
|
|
111
|
+
description:
|
|
112
|
+
'Exchange a `dsh web` launch URL (the one printed on startup, containing ?token=) for a session cookie, then persist it in secrets.json. The launch token is single-use; the cookie survives DSH restarts. Returns the target id and how many sessions exist.',
|
|
113
|
+
inputSchema: z.object({
|
|
114
|
+
launchUrl: z.string().describe('Full launch URL, e.g. http://127.0.0.1:3080/?token=...'),
|
|
115
|
+
target: z.string().optional().describe('Target id to store under (defaults to dsh-<port>)')
|
|
116
|
+
}),
|
|
117
|
+
handler: async (args) => {
|
|
118
|
+
try {
|
|
119
|
+
const client = new DshWebClient({ log: ctx.log })
|
|
120
|
+
const { baseUrl } = await client.login(args.launchUrl)
|
|
121
|
+
const targetId = args.target || autoTargetId(baseUrl)
|
|
122
|
+
const list = await client.listSessions()
|
|
123
|
+
rememberCookie(ctx, { targetId, baseUrl, cookie: client.cookie })
|
|
124
|
+
return ok({
|
|
125
|
+
target: targetId,
|
|
126
|
+
baseUrl,
|
|
127
|
+
sessions: (list?.items || []).length,
|
|
128
|
+
stored: '~/.dsh-mesh/secrets.json (0600, never in git)',
|
|
129
|
+
next: `channel_send({ target: "${targetId}", text: "..." })`
|
|
130
|
+
})
|
|
131
|
+
} catch (error) {
|
|
132
|
+
return describeError(error)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
name: 'channel_sessions',
|
|
138
|
+
title: 'List sessions on a DSH target',
|
|
139
|
+
description:
|
|
140
|
+
'List sessions of one local DSH instance: sessionId, title, state (working|idle|ended), running, agentAvailable, cwd (workspace) and updatedAt. Requires a channel_login cookie for that target; host_sessions is the cookie-free alternative. Use this before channel_send to find the session you want to talk to, or omit sessionId there to open a new one.',
|
|
141
|
+
inputSchema: z.object({
|
|
142
|
+
target: z.string().describe('Target id from channel_targets'),
|
|
143
|
+
limit: z.number().int().min(1).max(200).optional()
|
|
144
|
+
}),
|
|
145
|
+
handler: async (args) => {
|
|
146
|
+
const built = clientFor(ctx, args.target)
|
|
147
|
+
if (built.error) return built.error
|
|
148
|
+
try {
|
|
149
|
+
const value = await built.client.listSessions()
|
|
150
|
+
const items = (value?.items || []).slice(0, args.limit ?? 50).map((item) => ({
|
|
151
|
+
sessionId: item.sessionId || item.id,
|
|
152
|
+
title: item.title || item.projections?.values?.title || '',
|
|
153
|
+
state: item.running ? 'working' : item.agentAvailable ? 'idle' : 'ended',
|
|
154
|
+
status: item.status,
|
|
155
|
+
running: item.running === true,
|
|
156
|
+
agentAvailable: item.agentAvailable === true,
|
|
157
|
+
cwd: item.cwd || '',
|
|
158
|
+
updatedAt: item.updatedAt || item.modifiedAt || item.createdAt
|
|
159
|
+
}))
|
|
160
|
+
return ok({ target: args.target, baseUrl: built.baseUrl, count: items.length, sessions: items })
|
|
161
|
+
} catch (error) {
|
|
162
|
+
return describeError(error)
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
name: 'channel_page',
|
|
168
|
+
title: 'Read recent events of a DSH session',
|
|
169
|
+
description:
|
|
170
|
+
'Read the last N events of one session (user messages, assistant messages, turn boundaries) as a compact list. Useful to catch up on a conversation without waiting for a turn.',
|
|
171
|
+
inputSchema: z.object({
|
|
172
|
+
target: z.string().describe('Target id from channel_targets'),
|
|
173
|
+
sessionId: z.string(),
|
|
174
|
+
maxMessages: z.number().int().min(1).max(200).optional()
|
|
175
|
+
}),
|
|
176
|
+
handler: async (args) => {
|
|
177
|
+
const built = clientFor(ctx, args.target)
|
|
178
|
+
if (built.error) return built.error
|
|
179
|
+
try {
|
|
180
|
+
const page = await built.client.page({
|
|
181
|
+
sessionId: args.sessionId,
|
|
182
|
+
maxMessages: args.maxMessages ?? defaultMessages
|
|
183
|
+
})
|
|
184
|
+
return ok({
|
|
185
|
+
target: args.target,
|
|
186
|
+
sessionId: args.sessionId,
|
|
187
|
+
hasMore: page.hasMore,
|
|
188
|
+
events: page.records.map(summarizeRecord),
|
|
189
|
+
assistantText: assistantText(page.records).slice(-4000)
|
|
190
|
+
})
|
|
191
|
+
} catch (error) {
|
|
192
|
+
return describeError(error)
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
name: 'channel_send',
|
|
198
|
+
title: 'Send a message to a DSH session and wait for the reply',
|
|
199
|
+
description:
|
|
200
|
+
'Put a text message into a local DSH session (creating a fresh session when sessionId is omitted) and wait until that turn ends, then return the assistant reply. This is how a peer AI wakes the other side up instead of only reading files. Opt-in tool: enable it in acl.allow.',
|
|
201
|
+
inputSchema: z.object({
|
|
202
|
+
target: z.string().describe('Target id from channel_targets'),
|
|
203
|
+
text: z.string().min(1).describe('Message to send, as if typed by the user'),
|
|
204
|
+
sessionId: z.string().optional().describe('Existing session; omit to open a new one'),
|
|
205
|
+
mode: z.enum(['queue', 'steer']).optional().describe('queue = after current turn, steer = interrupt'),
|
|
206
|
+
waitMs: z.number().int().min(1000).max(1800000).optional()
|
|
207
|
+
}),
|
|
208
|
+
handler: async (args, { signal } = {}) => {
|
|
209
|
+
const built = clientFor(ctx, args.target)
|
|
210
|
+
if (built.error) return built.error
|
|
211
|
+
try {
|
|
212
|
+
const sessionId = args.sessionId || (await built.client.createSession())
|
|
213
|
+
if (signal?.aborted) throw new DshError('E_ABORTED', 'caller aborted before prompt')
|
|
214
|
+
const result = await built.client.sendAndWait({
|
|
215
|
+
sessionId,
|
|
216
|
+
text: args.text,
|
|
217
|
+
mode: args.mode || 'queue',
|
|
218
|
+
waitMs: args.waitMs ?? defaultWaitMs,
|
|
219
|
+
pollMs: defaultPollMs
|
|
220
|
+
})
|
|
221
|
+
return ok({
|
|
222
|
+
target: args.target,
|
|
223
|
+
created: !args.sessionId,
|
|
224
|
+
sessionId: result.sessionId,
|
|
225
|
+
requestId: result.requestId,
|
|
226
|
+
turnEnd: result.turnEnd,
|
|
227
|
+
reply: result.reply,
|
|
228
|
+
events: result.events,
|
|
229
|
+
ms: result.ms
|
|
230
|
+
})
|
|
231
|
+
} catch (error) {
|
|
232
|
+
return describeError(error)
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
},
|
|
236
|
+
{
|
|
237
|
+
name: 'channel_cancel',
|
|
238
|
+
title: 'Cancel the running turn of a DSH session',
|
|
239
|
+
description: 'Ask one session to abort its current turn. Opt-in tool: enable it in acl.allow.',
|
|
240
|
+
inputSchema: z.object({ target: z.string(), sessionId: z.string() }),
|
|
241
|
+
handler: async (args) => {
|
|
242
|
+
const built = clientFor(ctx, args.target)
|
|
243
|
+
if (built.error) return built.error
|
|
244
|
+
try {
|
|
245
|
+
await built.client.cancel({ sessionId: args.sessionId })
|
|
246
|
+
return ok({ target: args.target, sessionId: args.sessionId, cancelled: true })
|
|
247
|
+
} catch (error) {
|
|
248
|
+
return describeError(error)
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
]
|
|
253
|
+
}
|