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.
Files changed (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +8 -2
  3. package/package.json +43 -4
  4. package/src/cli.js +1246 -0
  5. package/src/core/acl.js +81 -0
  6. package/src/core/audit.js +75 -0
  7. package/src/core/autostart.js +119 -0
  8. package/src/core/cluster.js +340 -0
  9. package/src/core/config.js +622 -0
  10. package/src/core/desktop-proxy.js +85 -0
  11. package/src/core/ds-web.js +394 -0
  12. package/src/core/host-agent.js +263 -0
  13. package/src/core/jobs.js +259 -0
  14. package/src/core/pairing.js +141 -0
  15. package/src/core/peer-client.js +273 -0
  16. package/src/core/peer-doctor.js +294 -0
  17. package/src/core/peer-http.js +61 -0
  18. package/src/core/repair.js +93 -0
  19. package/src/core/restart-policy.js +21 -0
  20. package/src/core/result.js +29 -0
  21. package/src/core/session-profile.js +146 -0
  22. package/src/core/tls.js +160 -0
  23. package/src/core/util.js +68 -0
  24. package/src/e2e-autostart.js +58 -0
  25. package/src/e2e-channel.js +384 -0
  26. package/src/e2e-cluster.js +417 -0
  27. package/src/e2e-host.js +411 -0
  28. package/src/e2e-onboard.js +232 -0
  29. package/src/e2e-pair.js +317 -0
  30. package/src/e2e-plugin.js +395 -0
  31. package/src/e2e-repair.js +297 -0
  32. package/src/index.js +20 -0
  33. package/src/peer-bridge.js +124 -0
  34. package/src/selftest.js +872 -0
  35. package/src/server.js +313 -0
  36. package/src/tools/alerts.js +80 -0
  37. package/src/tools/channel.js +253 -0
  38. package/src/tools/cluster.js +235 -0
  39. package/src/tools/config-patch.js +182 -0
  40. package/src/tools/control-apps.js +469 -0
  41. package/src/tools/control-path.js +160 -0
  42. package/src/tools/control.js +232 -0
  43. package/src/tools/core.js +79 -0
  44. package/src/tools/delegate.js +653 -0
  45. package/src/tools/fs.js +436 -0
  46. package/src/tools/git.js +266 -0
  47. package/src/tools/host.js +305 -0
  48. package/src/tools/index.js +122 -0
  49. package/src/tools/repair-fs.js +130 -0
  50. package/src/tools/repair.js +317 -0
  51. package/src/tools/session-history.js +60 -0
  52. package/src/tools/shell.js +66 -0
  53. package/src/tools/tasks.js +117 -0
@@ -0,0 +1,259 @@
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ readFileSync,
5
+ readdirSync,
6
+ rmSync,
7
+ statSync,
8
+ writeFileSync
9
+ } from 'node:fs'
10
+ import { randomBytes } from 'node:crypto'
11
+ import { join } from 'node:path'
12
+ import { nowIso, readJsonFile, short } from './util.js'
13
+
14
+ export const TERMINAL_STATES = ['succeeded', 'failed', 'cancelled']
15
+
16
+ export function jobsDir(home) {
17
+ const dir = join(home, 'jobs')
18
+ mkdirSync(dir, { recursive: true, mode: 0o700 })
19
+ return dir
20
+ }
21
+
22
+ function jobPath(home, id) {
23
+ return join(jobsDir(home), `${id}.json`)
24
+ }
25
+
26
+ export function newJobId() {
27
+ return `${Date.now().toString(36)}-${randomBytes(4).toString('hex')}`
28
+ }
29
+
30
+ function writeJob(home, job) {
31
+ writeFileSync(jobPath(home, job.id), JSON.stringify(job, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 })
32
+ }
33
+
34
+ function readJob(home, id) {
35
+ const p = jobPath(home, id)
36
+ if (!existsSync(p)) return null
37
+ try {
38
+ return readJsonFile(p)
39
+ } catch {
40
+ return null
41
+ }
42
+ }
43
+
44
+ function allJobs(home) {
45
+ const dir = jobsDir(home)
46
+ const out = []
47
+ for (const name of readdirSync(dir)) {
48
+ if (!name.endsWith('.json')) continue
49
+ try {
50
+ out.push(readJsonFile(join(dir, name)))
51
+ } catch {
52
+ /* skip corrupt job file */
53
+ }
54
+ }
55
+ return out.sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)))
56
+ }
57
+
58
+ /** Trim old jobs so the jobs dir cannot grow without bound. */
59
+ export function pruneJobs(home, { ttlSec = 7 * 24 * 3600, maxJobs = 500 } = {}) {
60
+ const jobs = allJobs(home)
61
+ const cutoff = Date.now() - ttlSec * 1000
62
+ const keep = []
63
+ for (const job of jobs) {
64
+ const created = Date.parse(job.createdAt || 0)
65
+ const terminal = TERMINAL_STATES.includes(job.state)
66
+ if (terminal && Number.isFinite(created) && created < cutoff) {
67
+ try {
68
+ rmSync(jobPath(home, job.id), { force: true })
69
+ } catch {
70
+ /* ignore */
71
+ }
72
+ continue
73
+ }
74
+ keep.push(job)
75
+ }
76
+ for (const job of keep.slice(maxJobs)) {
77
+ if (!TERMINAL_STATES.includes(job.state)) continue
78
+ try {
79
+ rmSync(jobPath(home, job.id), { force: true })
80
+ } catch {
81
+ /* ignore */
82
+ }
83
+ }
84
+ return { scanned: jobs.length, kept: Math.min(keep.length, maxJobs) }
85
+ }
86
+
87
+ /**
88
+ * Async job runner: `task_submit` persists a job, runs it in-process and lets the caller
89
+ * poll `task_status`. Jobs survive a server restart as records (running ones are marked
90
+ * interrupted on load, so a client never waits forever).
91
+ *
92
+ * Idempotency: `task_submit` with the same `idem` key returns the original job instead of
93
+ * creating a second one — that is what makes retries after a dropped connection safe.
94
+ */
95
+ export class JobRunner {
96
+ constructor({ home, exec, audit, log = console, limits = {} }) {
97
+ this.home = home
98
+ this.exec = exec
99
+ this.audit = audit
100
+ this.log = log
101
+ this.limits = limits
102
+ this.running = new Map()
103
+ this.recoverInterrupted()
104
+ }
105
+
106
+ recoverInterrupted() {
107
+ let recovered = 0
108
+ for (const job of allJobs(this.home)) {
109
+ if (job.state === 'running' || job.state === 'queued') {
110
+ job.state = 'failed'
111
+ job.error = { code: 'E_INTERRUPTED', message: 'node restarted while the job was running', retryable: true }
112
+ job.finishedAt = nowIso()
113
+ try {
114
+ writeJob(this.home, job)
115
+ recovered += 1
116
+ } catch {
117
+ /* ignore */
118
+ }
119
+ }
120
+ }
121
+ if (recovered > 0) this.log?.info?.(`[dsh-mesh] marked ${recovered} interrupted job(s) as failed`)
122
+ return recovered
123
+ }
124
+
125
+ findByIdem(idem) {
126
+ if (!idem) return null
127
+ return allJobs(this.home).find((j) => j.idem === idem) || null
128
+ }
129
+
130
+ submit({ tool, arguments: args = {}, idem, peer, timeoutMs }) {
131
+ if (idem) {
132
+ const existing = this.findByIdem(idem)
133
+ if (existing) return { job: existing, idempotent: true }
134
+ }
135
+ const job = {
136
+ id: newJobId(),
137
+ idem: idem || null,
138
+ tool,
139
+ arguments: args,
140
+ state: 'queued',
141
+ createdAt: nowIso(),
142
+ startedAt: null,
143
+ finishedAt: null,
144
+ peer: peer || null,
145
+ result: null,
146
+ error: null
147
+ }
148
+ writeJob(this.home, job)
149
+ this.#start(job, timeoutMs)
150
+ return { job: this.#public(job), idempotent: false }
151
+ }
152
+
153
+ #start(job, timeoutMs) {
154
+ const controller = new AbortController()
155
+ const timer = timeoutMs
156
+ ? setTimeout(() => controller.abort(new Error(`job timed out after ${timeoutMs}ms`)), timeoutMs)
157
+ : null
158
+ timer?.unref?.()
159
+ this.running.set(job.id, { controller, job })
160
+
161
+ setImmediate(async () => {
162
+ job.state = 'running'
163
+ job.startedAt = nowIso()
164
+ try {
165
+ writeJob(this.home, job)
166
+ } catch {
167
+ /* ignore */
168
+ }
169
+ try {
170
+ const result = await this.exec(job.tool, job.arguments, { signal: controller.signal, jobId: job.id })
171
+ job.result = result
172
+ if (result?.isError) {
173
+ job.state = controller.signal.aborted ? 'cancelled' : 'failed'
174
+ try {
175
+ job.error = JSON.parse(result.content?.[0]?.text || '{}')
176
+ } catch {
177
+ job.error = { code: 'E_INTERNAL', message: 'tool returned an error' }
178
+ }
179
+ } else {
180
+ job.state = controller.signal.aborted ? 'cancelled' : 'succeeded'
181
+ }
182
+ } catch (error) {
183
+ job.state = controller.signal.aborted ? 'cancelled' : 'failed'
184
+ job.error = { code: controller.signal.aborted ? 'E_CANCELLED' : 'E_INTERNAL', message: short(error?.message || error) }
185
+ } finally {
186
+ if (timer) clearTimeout(timer)
187
+ job.finishedAt = nowIso()
188
+ this.running.delete(job.id)
189
+ try {
190
+ writeJob(this.home, job)
191
+ } catch {
192
+ /* ignore */
193
+ }
194
+ this.audit?.({
195
+ peer: job.peer,
196
+ tool: job.tool,
197
+ jobId: job.id,
198
+ ok: job.state === 'succeeded',
199
+ state: job.state,
200
+ args: Object.keys(job.arguments || {})
201
+ })
202
+ }
203
+ })
204
+ }
205
+
206
+ get(id) {
207
+ const job = readJob(this.home, id)
208
+ return job ? this.#public(job) : null
209
+ }
210
+
211
+ list({ state, limit = 20 } = {}) {
212
+ let jobs = allJobs(this.home)
213
+ if (state) jobs = jobs.filter((j) => j.state === state)
214
+ return jobs.slice(0, Math.min(Math.max(1, limit), 100)).map((j) => this.#public(j))
215
+ }
216
+
217
+ cancel(id) {
218
+ const job = readJob(this.home, id)
219
+ if (!job) return { ok: false, code: 'E_NOT_FOUND', message: `no such job: ${id}` }
220
+ if (TERMINAL_STATES.includes(job.state)) {
221
+ return { ok: false, code: 'E_INVALID_ARG', message: `job already ${job.state}` }
222
+ }
223
+ const live = this.running.get(id)
224
+ if (live) {
225
+ live.controller.abort(new Error('cancelled by client'))
226
+ return { ok: true, job: this.#public({ ...job, cancelRequested: true }) }
227
+ }
228
+ job.state = 'cancelled'
229
+ job.finishedAt = nowIso()
230
+ writeJob(this.home, job)
231
+ return { ok: true, job: this.#public(job) }
232
+ }
233
+
234
+ stats() {
235
+ const jobs = allJobs(this.home)
236
+ const byState = {}
237
+ for (const job of jobs) byState[job.state] = (byState[job.state] || 0) + 1
238
+ return { total: jobs.length, byState, running: this.running.size, dir: jobsDir(this.home) }
239
+ }
240
+
241
+ #public(job) {
242
+ return {
243
+ id: job.id,
244
+ idem: job.idem || null,
245
+ tool: job.tool,
246
+ arguments: job.arguments,
247
+ state: job.state,
248
+ createdAt: job.createdAt,
249
+ startedAt: job.startedAt,
250
+ finishedAt: job.finishedAt,
251
+ durationMs:
252
+ job.startedAt && job.finishedAt ? Date.parse(job.finishedAt) - Date.parse(job.startedAt) : null,
253
+ cancelRequested: Boolean(job.cancelRequested),
254
+ error: job.error,
255
+ result: job.result,
256
+ resultBytes: job.result ? Buffer.byteLength(JSON.stringify(job.result), 'utf8') : 0
257
+ }
258
+ }
259
+ }
@@ -0,0 +1,141 @@
1
+ import { createHash, randomInt, timingSafeEqual } from 'node:crypto'
2
+ import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
3
+ import { meshHome, pairingPath, peersPath, ensureHome } from './config.js'
4
+
5
+ /**
6
+ * One-time pairing codes.
7
+ *
8
+ * Flow:
9
+ * 1. On the node being reached: `dsh-mesh pair --code` → "7KQ2-M9XD" (TTL 5 min, one use).
10
+ * 2. The human tells the code to the peer machine.
11
+ * 3. The peer runs `dsh-mesh peer add --url <url> --code 7KQ2-M9XD` → POST /pair.
12
+ * 4. The node verifies the code and hands back a *unique* bearer token for that peer.
13
+ *
14
+ * Only the sha256 of the code is stored, so a leaked pairing.json cannot be replayed.
15
+ */
16
+
17
+ /** Crockford-ish alphabet: no 0/O/1/I/L to keep it readable when typed by a human. */
18
+ const ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789'
19
+ const CODE_LEN = 8
20
+
21
+ export function normalizeCode(raw) {
22
+ const compact = String(raw || '')
23
+ .toUpperCase()
24
+ .replace(/[^A-Z0-9]/g, '')
25
+ return compact
26
+ }
27
+
28
+ export function hashCode(code) {
29
+ return createHash('sha256').update(`dsh-mesh-pair:${normalizeCode(code)}`).digest('hex')
30
+ }
31
+
32
+ export function formatCode(compact) {
33
+ const s = normalizeCode(compact)
34
+ return `${s.slice(0, 4)}-${s.slice(4)}`
35
+ }
36
+
37
+ function randomCode() {
38
+ let out = ''
39
+ for (let i = 0; i < CODE_LEN; i += 1) out += ALPHABET[randomInt(ALPHABET.length)]
40
+ return out
41
+ }
42
+
43
+ function writeJson0600(file, value) {
44
+ const tmp = `${file}.tmp-${process.pid}`
45
+ writeFileSync(tmp, JSON.stringify(value, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 })
46
+ renameSync(tmp, file)
47
+ }
48
+
49
+ function readJsonSafe(file) {
50
+ try {
51
+ if (!existsSync(file)) return null
52
+ const text = readFileSync(file, 'utf8').replace(/^\uFEFF/, '')
53
+ return JSON.parse(text)
54
+ } catch {
55
+ return null
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Create (and persist) a one-time code. Only one code is valid at a time:
61
+ * creating a new one invalidates the previous one.
62
+ */
63
+ export function createPairingCode({ home = meshHome(), ttlSec = 300, note } = {}) {
64
+ ensureHome(home)
65
+ const compact = randomCode()
66
+ const now = Date.now()
67
+ const record = {
68
+ version: 1,
69
+ codeHash: hashCode(compact),
70
+ createdAt: new Date(now).toISOString(),
71
+ expiresAt: new Date(now + Math.max(30, ttlSec) * 1000).toISOString(),
72
+ attempts: 0,
73
+ used: false,
74
+ note: note || undefined
75
+ }
76
+ writeJson0600(pairingPath(home), record)
77
+ return { code: formatCode(compact), expiresAt: record.expiresAt, ttlSec }
78
+ }
79
+
80
+ export function readPairingState(home = meshHome()) {
81
+ const record = readJsonSafe(pairingPath(home))
82
+ if (!record) return null
83
+ return {
84
+ createdAt: record.createdAt,
85
+ expiresAt: record.expiresAt,
86
+ attempts: record.attempts || 0,
87
+ used: record.used === true,
88
+ expired: new Date(record.expiresAt).getTime() <= Date.now(),
89
+ note: record.note
90
+ }
91
+ }
92
+
93
+ function sameHash(a, b) {
94
+ const ab = Buffer.from(String(a), 'hex')
95
+ const bb = Buffer.from(String(b), 'hex')
96
+ if (ab.length !== bb.length || ab.length === 0) return false
97
+ return timingSafeEqual(ab, bb)
98
+ }
99
+
100
+ /**
101
+ * Verify a presented code and consume it.
102
+ * Returns { ok:true } once; every failure carries a stable code so the server can
103
+ * answer 401 without leaking whether the code exists (all failures look the same
104
+ * except E_MAX_ATTEMPTS, which is only reachable by someone who keeps guessing).
105
+ */
106
+ export function claimPairingCode({ home = meshHome(), code, maxAttempts = 5 } = {}) {
107
+ const record = readJsonSafe(pairingPath(home))
108
+ if (!record) return { ok: false, reason: 'E_NO_CODE' }
109
+ if (record.used) return { ok: false, reason: 'E_CODE_USED' }
110
+ if (new Date(record.expiresAt).getTime() <= Date.now()) return { ok: false, reason: 'E_CODE_EXPIRED' }
111
+ if ((record.attempts || 0) >= maxAttempts) return { ok: false, reason: 'E_MAX_ATTEMPTS' }
112
+
113
+ if (!sameHash(hashCode(code), record.codeHash)) {
114
+ record.attempts = (record.attempts || 0) + 1
115
+ if (record.attempts >= maxAttempts) {
116
+ record.used = true // burn it: no more guesses
117
+ writeJson0600(pairingPath(home), record)
118
+ return { ok: false, reason: 'E_MAX_ATTEMPTS', attemptsLeft: 0 }
119
+ }
120
+ writeJson0600(pairingPath(home), record)
121
+ return { ok: false, reason: 'E_BAD_CODE', attemptsLeft: Math.max(0, maxAttempts - record.attempts) }
122
+ }
123
+
124
+ record.used = true
125
+ record.usedAt = new Date().toISOString()
126
+ writeJson0600(pairingPath(home), record)
127
+ return { ok: true }
128
+ }
129
+
130
+ /** Peer bookkeeping on the *client* side: who we can talk to, and with which token. */
131
+ export function loadPeers(home = meshHome()) {
132
+ const raw = readJsonSafe(peersPath(home))
133
+ return raw && Array.isArray(raw.items) ? raw : { version: 1, items: [] }
134
+ }
135
+
136
+ export function savePeers(peers, home = meshHome()) {
137
+ ensureHome(home)
138
+ const file = peersPath(home)
139
+ writeJson0600(file, peers)
140
+ return file
141
+ }
@@ -0,0 +1,273 @@
1
+ /**
2
+ * Outbound peer client (M7 step 4: cluster_exec).
3
+ *
4
+ * `cluster_status` only probes peers over /health; this module is what lets a
5
+ * node ACT on a paired peer — call one MCP tool on the other machine. The trust
6
+ * model stays TOFU: the peer's stored certificate pin is checked on EVERY https
7
+ * connection (we cannot rely on NODE_EXTRA_CA_CERTS at runtime), so a changed
8
+ * certificate fails loudly instead of silently accepting a new one.
9
+ *
10
+ * The MCP SDK's transport takes a `fetch` option; we supply a small node:https
11
+ * based implementation because Node's global fetch cannot talk to a self-signed
12
+ * peer without adding its CA to the process trust store.
13
+ */
14
+ import { request as httpRequest } from 'node:http'
15
+ import { Agent as HttpsAgent, request as httpsRequest } from 'node:https'
16
+ import { Client } from '@modelcontextprotocol/client'
17
+ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/client'
18
+ import { MESH_VERSION } from './config.js'
19
+ import { assertFingerprint, fingerprintOfSocket, normalizeFingerprint } from './tls.js'
20
+
21
+ const DEFAULT_TIMEOUT_MS = 30000
22
+
23
+ export function peerError(code, message, extra = {}) {
24
+ const error = new Error(message)
25
+ error.code = code
26
+ Object.assign(error, extra)
27
+ return error
28
+ }
29
+
30
+ /** `https://host:port/mcp` -> `https://host:port` (peer records store the base URL). */
31
+ export function baseUrlOf(url) {
32
+ return String(url || '')
33
+ .trim()
34
+ .replace(/\/+$/, '')
35
+ .replace(/\/mcp$/, '')
36
+ }
37
+
38
+ /**
39
+ * Read the piece of a `mesh-peer pair` output file that mesh needs to call the
40
+ * peer: the bearer token and base URL live in the generated DSH overlay (the
41
+ * `<name>.json` metadata intentionally has no token). Also accepts the metadata
42
+ * file for URL/fingerprint hints.
43
+ */
44
+ export function parsePeerOverlay(text, { meta = null } = {}) {
45
+ const source = String(text || '')
46
+ const tokenMatch = source.match(/Authorization:\s*['"]?Bearer\s+([A-Za-z0-9._~+/=-]+)['"]?/)
47
+ const token = tokenMatch ? tokenMatch[1] : ''
48
+ if (!token) {
49
+ throw peerError('E_INVALID_ARG', 'no Bearer token found — pass the <name>.yml overlay written by `mesh-peer pair`')
50
+ }
51
+ const urlMatch = source.match(/^\s*url:\s*['"]?([^'"\s]+)['"]?\s*$/m)
52
+ const nameMatch = source.match(/^\s*serverName:\s*['"]?([^'"\s]+)['"]?\s*$/m)
53
+ const rawUrl = meta?.url || (urlMatch ? urlMatch[1] : '')
54
+ if (!rawUrl) throw peerError('E_INVALID_ARG', 'no peer url found — pass --url or the <name>.json metadata')
55
+ const fallbackName = meta?.node || ''
56
+ const serverName = nameMatch ? nameMatch[1] : ''
57
+ const suggestedName = String(fallbackName || serverName || 'peer')
58
+ .replace(/-mesh$/, '')
59
+ .replace(/[^A-Za-z0-9._-]/g, '-')
60
+ .slice(0, 40)
61
+ const certFingerprint = meta?.certFingerprint || null
62
+ return {
63
+ token,
64
+ url: baseUrlOf(rawUrl),
65
+ tokenId: meta?.tokenId || suggestedName,
66
+ certFingerprint,
67
+ suggestedName
68
+ }
69
+ }
70
+
71
+ /**
72
+ * node:https implementation of the WHATWG fetch subset the MCP transport needs.
73
+ * TLS verification is replaced by the stored pin: `rejectUnauthorized:false` is
74
+ * only acceptable because the socket certificate is compared to the pin before
75
+ * a single byte of the response is handed to the SDK.
76
+ */
77
+ export function makePinnedFetch({ certFingerprint, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
78
+ const expected = certFingerprint ? normalizeFingerprint(certFingerprint) : null
79
+ // No TLS session caching: a resumed session would skip the certificate check.
80
+ const pinnedAgent = new HttpsAgent({ keepAlive: false, maxCachedSessions: 0, rejectUnauthorized: false })
81
+
82
+ return function pinnedFetch(input, init = {}) {
83
+ return new Promise((resolvePromise, rejectPromise) => {
84
+ let url
85
+ try {
86
+ url = new URL(String(input))
87
+ } catch {
88
+ rejectPromise(peerError('E_INVALID_ARG', `invalid peer url: ${input}`))
89
+ return
90
+ }
91
+ const secure = url.protocol === 'https:'
92
+ if (secure && !expected) {
93
+ rejectPromise(peerError('E_PEER_NO_PIN', 'refusing to talk to an https peer without a stored certificate fingerprint'))
94
+ return
95
+ }
96
+ const body = init.body
97
+ // The SDK passes a Headers instance; spreading it would silently drop every
98
+ // header (including Authorization) because Headers is not enumerable.
99
+ const rawHeaders = init.headers
100
+ const headers =
101
+ typeof Headers !== 'undefined' && rawHeaders instanceof Headers
102
+ ? Object.fromEntries(rawHeaders.entries())
103
+ : Array.isArray(rawHeaders)
104
+ ? Object.fromEntries(rawHeaders)
105
+ : { ...(rawHeaders || {}) }
106
+ const request = (secure ? httpsRequest : httpRequest)(
107
+ {
108
+ protocol: url.protocol,
109
+ hostname: url.hostname,
110
+ port: url.port || (secure ? 443 : 80),
111
+ path: `${url.pathname}${url.search}`,
112
+ method: init.method || 'GET',
113
+ headers,
114
+ ...(secure ? { rejectUnauthorized: false, agent: pinnedAgent } : {}),
115
+ timeout: timeoutMs
116
+ },
117
+ (res) => {
118
+ const status = res.statusCode || 0
119
+ const stream = new ReadableStream({
120
+ start(controller) {
121
+ res.on('data', (chunk) => controller.enqueue(new Uint8Array(chunk)))
122
+ res.on('end', () => controller.close())
123
+ res.on('error', (error) => controller.error(error))
124
+ }
125
+ })
126
+ if (status === 204 || status === 304) {
127
+ res.resume()
128
+ resolvePromise(new Response(null, { status, statusText: res.statusMessage || '' }))
129
+ return
130
+ }
131
+ resolvePromise(new Response(stream, { status, statusText: res.statusMessage || '', headers: res.headers }))
132
+ }
133
+ )
134
+ if (secure) {
135
+ // Verify the pin at handshake time. Checking it on the response is too late
136
+ // and unreliable: by then the socket may already be destroyed/kept alive.
137
+ request.on('socket', (socket) => {
138
+ const verify = () => {
139
+ try {
140
+ assertFingerprint(expected, fingerprintOfSocket(socket))
141
+ } catch (error) {
142
+ request.destroy(
143
+ peerError('E_PEER_PIN_MISMATCH', `peer certificate does not match the stored fingerprint (${error?.code || 'unknown'})`, {
144
+ cause: error
145
+ })
146
+ )
147
+ }
148
+ }
149
+ if (socket.connecting || socket.secureConnecting) socket.once('secureConnect', verify)
150
+ else verify()
151
+ })
152
+ }
153
+ request.on('timeout', () => request.destroy(peerError('E_PEER_TIMEOUT', `peer did not answer within ${timeoutMs} ms`)))
154
+ request.on('error', (error) => rejectPromise(error))
155
+ if (init.signal) {
156
+ if (init.signal.aborted) {
157
+ request.destroy(peerError('E_PEER_ABORTED', 'request aborted'))
158
+ return
159
+ }
160
+ init.signal.addEventListener('abort', () => request.destroy(peerError('E_PEER_ABORTED', 'request aborted')), { once: true })
161
+ }
162
+ if (body !== undefined && body !== null) {
163
+ if (typeof body === 'string' || body instanceof Uint8Array || body instanceof URLSearchParams) request.write(body)
164
+ else if (body instanceof ArrayBuffer) request.write(new Uint8Array(body))
165
+ else request.write(String(body))
166
+ }
167
+ request.end()
168
+ })
169
+ }
170
+ }
171
+
172
+ /** Close a client + transport pair; the connection is already unusable if this fails. */
173
+ async function closeConnection(client, transport) {
174
+ try {
175
+ await client.close()
176
+ } catch {
177
+ /* already closed */
178
+ }
179
+ try {
180
+ await transport.close?.()
181
+ } catch {
182
+ /* already closed */
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Keep the peer's own error code (E_PEER_PIN_MISMATCH and friends) so callers can
188
+ * react per failure mode; only map the transport's HTTP 401 to E_PEER_AUTH.
189
+ */
190
+ function peerFailureCode(error, fallback) {
191
+ const message = error?.message || String(error)
192
+ const ownCode = typeof error?.code === 'string' && /^E_[A-Z0-9_]+$/.test(error.code) ? error.code : null
193
+ return ownCode || (/401|unauthori[sz]ed/i.test(message) ? 'E_PEER_AUTH' : fallback)
194
+ }
195
+
196
+ /**
197
+ * Open a pinned MCP connection to a paired peer and keep it for several calls
198
+ * (tools/list followed by tools/call). TLS trust comes from the stored pin, not
199
+ * from NODE_EXTRA_CA_CERTS, so this works in any process — including a stdio
200
+ * bridge child that DSH spawned with a scrubbed environment. The caller owns
201
+ * `close()`.
202
+ */
203
+ export async function connectPeer(peer, { timeoutMs = DEFAULT_TIMEOUT_MS, clientName = 'dsh-mesh-peer' } = {}) {
204
+ const name = String(peer?.name || peer?.id || 'peer')
205
+ if (!peer?.url) throw peerError('E_INVALID_ARG', `peer "${name}" has no url`)
206
+ if (!peer?.token) throw peerError('E_PEER_NO_TOKEN', `peer "${name}" has no outbound token — run \`node src/cli.js peer import --file <name>.yml\``)
207
+ const endpoint = `${baseUrlOf(peer.url)}/mcp`
208
+ const client = new Client({ name: clientName, version: MESH_VERSION })
209
+ const transport = new StreamableHTTPClientTransport(new URL(endpoint), {
210
+ fetch: makePinnedFetch({ certFingerprint: peer.certFingerprint, timeoutMs }),
211
+ requestInit: { headers: { Authorization: `Bearer ${peer.token}` } }
212
+ })
213
+ try {
214
+ await client.connect(transport)
215
+ } catch (error) {
216
+ await closeConnection(client, transport)
217
+ throw error
218
+ }
219
+ return { peer: name, client, transport, close: () => closeConnection(client, transport) }
220
+ }
221
+
222
+ /**
223
+ * Call one tool on a paired peer. `peer` = {name, url, token, certFingerprint}
224
+ * (as stored by `peer import`). Returns a compact result — never the token.
225
+ */
226
+ export async function callPeerTool(peer, { tool, args = {}, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
227
+ const name = String(peer?.name || peer?.id || 'peer')
228
+ const started = Date.now()
229
+ let connection
230
+ try {
231
+ connection = await connectPeer(peer, { timeoutMs, clientName: 'dsh-mesh-cluster-exec' })
232
+ const result = await connection.client.callTool({ name: tool, arguments: args })
233
+ return {
234
+ peer: name,
235
+ tool,
236
+ ok: result?.isError !== true,
237
+ ms: Date.now() - started,
238
+ result
239
+ }
240
+ } catch (error) {
241
+ const message = error?.message || String(error)
242
+ throw peerError(peerFailureCode(error, 'E_PEER_CALL_FAILED'), `peer "${name}" tool "${tool}" failed: ${message}`)
243
+ } finally {
244
+ if (connection) await connection.close()
245
+ }
246
+ }
247
+
248
+ /**
249
+ * List the tools a paired peer exposes (same pinned transport as callPeerTool).
250
+ * Used by `peer tools`/`peer call` and by the stdio bridge, which republishes
251
+ * this list locally so the tool surface stays in sync with the peer.
252
+ */
253
+ export async function listPeerTools(peer, { timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
254
+ const name = String(peer?.name || peer?.id || 'peer')
255
+ const started = Date.now()
256
+ let connection
257
+ try {
258
+ connection = await connectPeer(peer, { timeoutMs, clientName: 'dsh-mesh-peer-tools' })
259
+ const listed = await connection.client.listTools()
260
+ const tools = listed?.tools ?? []
261
+ return {
262
+ peer: name,
263
+ ok: true,
264
+ ms: Date.now() - started,
265
+ count: tools.length,
266
+ tools: tools.map((tool) => ({ name: tool.name, description: tool.description || '' }))
267
+ }
268
+ } catch (error) {
269
+ throw peerError(peerFailureCode(error, 'E_PEER_CALL_FAILED'), `peer "${name}" tools/list failed: ${error?.message || error}`)
270
+ } finally {
271
+ if (connection) await connection.close()
272
+ }
273
+ }