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/core/tls.js
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TLS:自签证书生成与校验信息读取。
|
|
3
|
+
*
|
|
4
|
+
* 为什么需要:樱花穿透的 TCP 隧道默认不加密(只有「访问认证」,没有传输加密),
|
|
5
|
+
* Bearer token 与所有 MCP 报文都会明文过公网。跨网阶段必须自带 TLS。
|
|
6
|
+
*
|
|
7
|
+
* 设计取舍:
|
|
8
|
+
* - 自签「CA-and-leaf」证书(basicConstraints CA:true + serverAuth EKU),这样对端
|
|
9
|
+
* 把 cert.pem 当作信任锚(NODE_EXTRA_CA_CERTS)就能通过校验,不需要额外 CA 基础设施。
|
|
10
|
+
* - SAN 里默认包含 localhost / 127.0.0.1 / 本机 hostname;樱花隧道建好后用
|
|
11
|
+
* `--san <节点域名>` 重新生成(或首次生成时一次给全)。
|
|
12
|
+
* - 私钥 0600 落 `~/.dsh-mesh/tls/key.pem`,不进 git、不进任何快照。
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { X509Certificate } from 'node:crypto'
|
|
16
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
17
|
+
import { hostname as osHostname } from 'node:os'
|
|
18
|
+
import { join } from 'node:path'
|
|
19
|
+
import selfsigned from 'selfsigned'
|
|
20
|
+
|
|
21
|
+
/** TLS 文件目录。 */
|
|
22
|
+
export function tlsDir(home) {
|
|
23
|
+
return join(home, 'tls')
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** 证书路径(可在 config.tls.cert 覆盖)。 */
|
|
27
|
+
export function certPath(home) {
|
|
28
|
+
return join(tlsDir(home), 'cert.pem')
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** 私钥路径(可在 config.tls.key 覆盖)。 */
|
|
32
|
+
export function keyPath(home) {
|
|
33
|
+
return join(tlsDir(home), 'key.pem')
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 是否已生成证书。 */
|
|
37
|
+
export function hasCert(home) {
|
|
38
|
+
return existsSync(certPath(home)) && existsSync(keyPath(home))
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 判断字符串是不是 IPv4/IPv6 字面量。 */
|
|
42
|
+
function isIp(value) {
|
|
43
|
+
return /^(\d{1,3}\.){3}\d{1,3}$/.test(value) || /^[0-9a-f:]+$/i.test(value) && value.includes(':')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** 构造 SAN 列表:总是包含本机可用的默认项,再合并调用方给的。 */
|
|
47
|
+
export function buildAltNames(extra = []) {
|
|
48
|
+
const names = new Set(['localhost', '127.0.0.1', osHostname()])
|
|
49
|
+
for (const item of extra) {
|
|
50
|
+
const value = String(item).trim()
|
|
51
|
+
if (value.length > 0) names.add(value)
|
|
52
|
+
}
|
|
53
|
+
return [...names].map(value => (isIp(value) ? { type: 7, ip: value } : { type: 2, value }))
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 生成自签证书。
|
|
58
|
+
* @returns {{certPath: string, keyPath: string, info: object}}
|
|
59
|
+
*/
|
|
60
|
+
export async function generateCert({ home, sans = [], days = 825, keySize = 2048, commonName, force = false }) {
|
|
61
|
+
const dir = tlsDir(home)
|
|
62
|
+
mkdirSync(dir, { recursive: true })
|
|
63
|
+
if (!force && hasCert(home)) {
|
|
64
|
+
return { certPath: certPath(home), keyPath: keyPath(home), info: describeCert(home), reused: true }
|
|
65
|
+
}
|
|
66
|
+
const altNames = buildAltNames(sans)
|
|
67
|
+
const attrs = [{ name: 'commonName', value: commonName ?? `dsh-mesh-${osHostname()}` }]
|
|
68
|
+
const pems = await selfsigned.generate(attrs, {
|
|
69
|
+
days,
|
|
70
|
+
keySize,
|
|
71
|
+
algorithm: 'sha256',
|
|
72
|
+
extensions: [
|
|
73
|
+
{ name: 'basicConstraints', cA: true },
|
|
74
|
+
{ name: 'keyUsage', digitalSignature: true, keyEncipherment: true, keyCertSign: true },
|
|
75
|
+
{ name: 'extKeyUsage', serverAuth: true, clientAuth: true },
|
|
76
|
+
{ name: 'subjectAltName', altNames },
|
|
77
|
+
],
|
|
78
|
+
})
|
|
79
|
+
writeFileSync(certPath(home), pems.cert, { mode: 0o644 })
|
|
80
|
+
writeFileSync(keyPath(home), pems.private, { mode: 0o600 })
|
|
81
|
+
return { certPath: certPath(home), keyPath: keyPath(home), info: describeCert(home), reused: false, sans: altNames }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** 读取证书的人类可读信息(也用于 `tls info`)。 */
|
|
85
|
+
export function describeCert(home) {
|
|
86
|
+
if (!hasCert(home)) return { present: false }
|
|
87
|
+
const cert = new X509Certificate(readFileSync(certPath(home)))
|
|
88
|
+
return {
|
|
89
|
+
present: true,
|
|
90
|
+
certPath: certPath(home),
|
|
91
|
+
keyPath: keyPath(home),
|
|
92
|
+
subject: cert.subject.replace(/\n/g, ', '),
|
|
93
|
+
issuer: cert.issuer.replace(/\n/g, ', '),
|
|
94
|
+
validFrom: cert.validFrom,
|
|
95
|
+
validTo: cert.validTo,
|
|
96
|
+
validToDate: new Date(cert.validTo).toISOString(),
|
|
97
|
+
fingerprint256: cert.fingerprint256,
|
|
98
|
+
serialNumber: cert.serialNumber,
|
|
99
|
+
subjectAltName: cert.subjectAltName,
|
|
100
|
+
selfSigned: cert.subject === cert.issuer,
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** 供 https.createServer 使用的证书对。路径缺失时抛结构化错误。 */
|
|
105
|
+
export function loadTlsMaterial({ home, cert, key }) {
|
|
106
|
+
const certFile = cert || certPath(home)
|
|
107
|
+
const keyFile = key || keyPath(home)
|
|
108
|
+
if (!existsSync(certFile) || !existsSync(keyFile)) {
|
|
109
|
+
const error = new Error(`TLS 已启用但缺少证书:${certFile} / ${keyFile},先跑 \`node src/cli.js tls init\``)
|
|
110
|
+
error.code = 'E_TLS_MISSING'
|
|
111
|
+
throw error
|
|
112
|
+
}
|
|
113
|
+
return { cert: readFileSync(certFile), key: readFileSync(keyFile), certFile, keyFile }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* 证书指纹规范化:大小写无关、去掉冒号/空格,统一成 64 位大写 hex。
|
|
118
|
+
*
|
|
119
|
+
* 为什么需要它:`mesh_self` / `tls info` / `pair --code` 打印的是
|
|
120
|
+
* `AA:BB:...` 形式(X509Certificate.fingerprint256),而 socket 拿到的也是这种,
|
|
121
|
+
* 但人手工粘贴时常带上小写或漏冒号 —— 直接字符串比较会假失败。
|
|
122
|
+
*/
|
|
123
|
+
export function normalizeFingerprint(value) {
|
|
124
|
+
const compact = String(value || '')
|
|
125
|
+
.replace(/[^0-9a-fA-F]/g, '')
|
|
126
|
+
.toUpperCase()
|
|
127
|
+
if (compact.length !== 64) {
|
|
128
|
+
const error = new Error(`证书指纹应为 64 位 hex(SHA-256),收到 ${compact.length} 位`)
|
|
129
|
+
error.code = 'E_BAD_FINGERPRINT'
|
|
130
|
+
throw error
|
|
131
|
+
}
|
|
132
|
+
return compact
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** 从 TLS socket 上取对端证书指纹,返回规范化的 64 位 hex。 */
|
|
136
|
+
export function fingerprintOfSocket(socket) {
|
|
137
|
+
const cert = socket?.getPeerCertificate?.()
|
|
138
|
+
if (!cert || !cert.fingerprint256) {
|
|
139
|
+
const error = new Error('对端没有提供证书,无法核对指纹')
|
|
140
|
+
error.code = 'E_NO_PEER_CERT'
|
|
141
|
+
throw error
|
|
142
|
+
}
|
|
143
|
+
return normalizeFingerprint(cert.fingerprint256)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** 固定指纹校验:expected / actual 都规范化后比较,失败抛 E_PIN_MISMATCH。 */
|
|
147
|
+
export function assertFingerprint(expected, actual) {
|
|
148
|
+
const want = normalizeFingerprint(expected)
|
|
149
|
+
const got = normalizeFingerprint(actual)
|
|
150
|
+
if (want !== got) {
|
|
151
|
+
const error = new Error(
|
|
152
|
+
`证书指纹不匹配(可能被中间人替换或证书已轮换)\n 期望 ${want}\n 实际 ${got}`
|
|
153
|
+
)
|
|
154
|
+
error.code = 'E_PIN_MISMATCH'
|
|
155
|
+
error.expected = want
|
|
156
|
+
error.actual = got
|
|
157
|
+
throw error
|
|
158
|
+
}
|
|
159
|
+
return got
|
|
160
|
+
}
|
package/src/core/util.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process'
|
|
2
|
+
import { readFileSync } from 'node:fs'
|
|
3
|
+
import { createHash } from 'node:crypto'
|
|
4
|
+
|
|
5
|
+
/** Run a command without a shell; resolves (never rejects) with stdout/stderr/error. */
|
|
6
|
+
export function run(cmd, args, { timeout = 20000, cwd, signal, maxBuffer = 8 * 1024 * 1024 } = {}) {
|
|
7
|
+
return new Promise((resolvePromise) => {
|
|
8
|
+
execFile(
|
|
9
|
+
cmd,
|
|
10
|
+
args,
|
|
11
|
+
{ cwd, timeout, signal, encoding: 'utf8', windowsHide: true, maxBuffer },
|
|
12
|
+
(error, stdout, stderr) => {
|
|
13
|
+
resolvePromise({
|
|
14
|
+
error,
|
|
15
|
+
stdout: stdout || '',
|
|
16
|
+
stderr: stderr || '',
|
|
17
|
+
code: typeof error?.code === 'number' ? error.code : 0,
|
|
18
|
+
killed: Boolean(error?.killed),
|
|
19
|
+
timedOut: Boolean(error?.killed && error?.signal === 'SIGTERM')
|
|
20
|
+
})
|
|
21
|
+
}
|
|
22
|
+
)
|
|
23
|
+
})
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Trim a string to a byte budget without splitting a UTF-8 code point badly. */
|
|
27
|
+
export function clip(text, maxBytes) {
|
|
28
|
+
const buf = Buffer.from(text, 'utf8')
|
|
29
|
+
if (buf.length <= maxBytes) return { text, truncated: false, bytes: buf.length }
|
|
30
|
+
return { text: buf.subarray(0, maxBytes).toString('utf8'), truncated: true, bytes: buf.length }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function sha256(text) {
|
|
34
|
+
return createHash('sha256').update(text, 'utf8').digest('hex')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function short(text, n = 500) {
|
|
38
|
+
const s = String(text ?? '')
|
|
39
|
+
return s.length <= n ? s : s.slice(0, n) + '…'
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** ISO timestamp helper. */
|
|
43
|
+
export function nowIso() {
|
|
44
|
+
return new Date().toISOString()
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Strip a UTF-8 BOM. Windows editors (and PowerShell's Set-Content) love adding one,
|
|
49
|
+
* and `JSON.parse` chokes on it — a BOM'd config.json used to kill the node at boot.
|
|
50
|
+
*/
|
|
51
|
+
export function stripBom(text) {
|
|
52
|
+
return String(text).replace(/^\uFEFF/, '')
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Read a JSON file, tolerating a BOM. */
|
|
56
|
+
export function readJsonFile(path) {
|
|
57
|
+
return JSON.parse(stripBom(readFileSync(path, 'utf8')))
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Wrap a promise with a hard timeout so a stuck tool cannot hang a request forever. */
|
|
61
|
+
export function withTimeout(promise, ms, onTimeout) {
|
|
62
|
+
let timer
|
|
63
|
+
const timeout = new Promise((_resolve, reject) => {
|
|
64
|
+
timer = setTimeout(() => reject(onTimeout || new Error(`timed out after ${ms}ms`)), ms)
|
|
65
|
+
timer.unref?.()
|
|
66
|
+
})
|
|
67
|
+
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer))
|
|
68
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// e2e: autostart helper (Windows only). Read-only: never registers/removes a task.
|
|
2
|
+
// Proves (a) status is safe on a clean machine, (b) both mutating paths refuse without --yes,
|
|
3
|
+
// (c) the generated logon command points at this checkout's cli.js.
|
|
4
|
+
import assert from 'node:assert/strict'
|
|
5
|
+
import {
|
|
6
|
+
TASK_NAME,
|
|
7
|
+
autostartCommand,
|
|
8
|
+
autostartInstall,
|
|
9
|
+
autostartRemove,
|
|
10
|
+
autostartStatus
|
|
11
|
+
} from './core/autostart.js'
|
|
12
|
+
|
|
13
|
+
const checks = []
|
|
14
|
+
function check(name, fn) {
|
|
15
|
+
checks.push({ name, fn })
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
check('Windows guard / status shape', async () => {
|
|
19
|
+
if (process.platform !== 'win32') return 'skipped: not win32'
|
|
20
|
+
const st = await autostartStatus()
|
|
21
|
+
assert.equal(typeof st.installed, 'boolean')
|
|
22
|
+
assert.equal(st.name, TASK_NAME)
|
|
23
|
+
if (st.installed) {
|
|
24
|
+
assert.ok(st.taskToRun, 'installed task must report taskToRun')
|
|
25
|
+
} else {
|
|
26
|
+
assert.ok(typeof st.detail === 'string')
|
|
27
|
+
}
|
|
28
|
+
return `installed=${st.installed}`
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
check('command quotes node + cli.js + port', () => {
|
|
32
|
+
const cmd = autostartCommand(TASK_NAME, 8124)
|
|
33
|
+
assert.match(cmd, /^".*node(\.exe)?"/)
|
|
34
|
+
assert.ok(cmd.includes('cli.js'), 'command must point at the CLI')
|
|
35
|
+
assert.match(cmd, /serve --port 8124$/)
|
|
36
|
+
return cmd
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
check('install refuses without --yes', async () => {
|
|
40
|
+
await assert.rejects(() => autostartInstall({ yes: false }), /--yes/)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
check('remove refuses without --yes', async () => {
|
|
44
|
+
await assert.rejects(() => autostartRemove({ yes: false }), /--yes/)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
let failed = 0
|
|
48
|
+
for (const c of checks) {
|
|
49
|
+
try {
|
|
50
|
+
const detail = await c.fn()
|
|
51
|
+
console.log(`PASS ${c.name}${detail ? ` (${detail})` : ''}`)
|
|
52
|
+
} catch (err) {
|
|
53
|
+
failed += 1
|
|
54
|
+
console.error(`FAIL ${c.name}\n ${err.message}`)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
console.log(`\n${checks.length - failed}/${checks.length} passed`)
|
|
58
|
+
process.exit(failed === 0 ? 0 : 1)
|
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
import { createServer } from 'node:http'
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync, utimesSync } from 'node:fs'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { WebSocketServer } from 'ws'
|
|
6
|
+
import { Client } from '@modelcontextprotocol/client'
|
|
7
|
+
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/client'
|
|
8
|
+
import { DshWebClient, DshError } from './core/ds-web.js'
|
|
9
|
+
import { defaultConfig, loadSecrets, configPath } from './core/config.js'
|
|
10
|
+
import { startMeshServer } from './server.js'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Protocol-level end-to-end test for the M3 channel (route A).
|
|
14
|
+
*
|
|
15
|
+
* A mock stand-in for `dsh web` speaks the exact wire shapes from
|
|
16
|
+
* apps/web/tests/smoke-real.e2e.ts (launch URL -> 303 + cookie, POST /api/<method>,
|
|
17
|
+
* session/list|create|prompt|page|cancel) so the client and the channel tools can be
|
|
18
|
+
* exercised without booting a real model instance.
|
|
19
|
+
*
|
|
20
|
+
* Usage: node src/e2e-channel.js
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const quiet = { debug() {}, info() {}, warn() {}, error() {} }
|
|
24
|
+
const results = []
|
|
25
|
+
function assert(cond, message) {
|
|
26
|
+
if (!cond) throw new Error(message)
|
|
27
|
+
}
|
|
28
|
+
async function check(name, fn) {
|
|
29
|
+
try {
|
|
30
|
+
await fn()
|
|
31
|
+
results.push({ name, ok: true })
|
|
32
|
+
console.log(`PASS ${name}`)
|
|
33
|
+
} catch (error) {
|
|
34
|
+
results.push({ name, ok: false, error: error?.message || String(error) })
|
|
35
|
+
console.log(`FAIL ${name} — ${error?.message || error}`)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const LAUNCH_TOKEN = 'A'.repeat(43)
|
|
40
|
+
const COOKIE = 'dsh-auth-mock=v1.mock-signature'
|
|
41
|
+
|
|
42
|
+
/** Minimal DSH web host: one session, scripted turn emitted in three delayed steps. */
|
|
43
|
+
function startMockDsh() {
|
|
44
|
+
const events = []
|
|
45
|
+
let seq = 0
|
|
46
|
+
let sessions = []
|
|
47
|
+
const followers = new Set()
|
|
48
|
+
const push = (type, data = {}) => {
|
|
49
|
+
seq += 1
|
|
50
|
+
const event = { type, seq, data }
|
|
51
|
+
events.push(event)
|
|
52
|
+
for (const follower of followers) {
|
|
53
|
+
try {
|
|
54
|
+
follower.send(
|
|
55
|
+
JSON.stringify({ type: 'item', streamId: follower.streamId, value: { type: 'event', event } })
|
|
56
|
+
)
|
|
57
|
+
} catch {
|
|
58
|
+
/* follower already gone */
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const server = createServer((req, res) => {
|
|
63
|
+
const url = new URL(req.url, 'http://127.0.0.1')
|
|
64
|
+
const send = (status, body, headers = {}) => {
|
|
65
|
+
res.writeHead(status, { 'content-type': 'application/json', ...headers })
|
|
66
|
+
res.end(JSON.stringify(body))
|
|
67
|
+
}
|
|
68
|
+
if (req.method === 'GET') {
|
|
69
|
+
if (url.pathname === '/') {
|
|
70
|
+
if (url.searchParams.get('token') === LAUNCH_TOKEN) {
|
|
71
|
+
res.writeHead(303, { location: '/', 'set-cookie': `${COOKIE}; HttpOnly; SameSite=Strict; Path=/` })
|
|
72
|
+
res.end()
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
res.writeHead(200, { 'content-type': 'text/plain' })
|
|
76
|
+
res.end('dsh web (mock)')
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
return send(404, { code: 'E_NOT_FOUND' })
|
|
80
|
+
}
|
|
81
|
+
if (req.method !== 'POST' || !url.pathname.startsWith('/api/')) return send(404, { code: 'E_NOT_FOUND' })
|
|
82
|
+
if (!String(req.headers.cookie || '').includes(COOKIE)) {
|
|
83
|
+
return send(401, { code: 'gateway/unauthorized', message: 'no session cookie' })
|
|
84
|
+
}
|
|
85
|
+
const chunks = []
|
|
86
|
+
req.on('data', (chunk) => chunks.push(chunk))
|
|
87
|
+
req.on('end', () => {
|
|
88
|
+
const envelope = JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
89
|
+
const args = envelope?.payload?.args || {}
|
|
90
|
+
const value = (v) => send(200, { result: { ok: true, value: v } })
|
|
91
|
+
switch (envelope.method) {
|
|
92
|
+
case 'session/list':
|
|
93
|
+
return value({ items: sessions.map((s) => ({ sessionId: s, title: `title ${s}`, status: 'idle' })) })
|
|
94
|
+
case 'session/create': {
|
|
95
|
+
const sessionId = `mock-${sessions.length + 1}`
|
|
96
|
+
sessions = [...sessions, sessionId]
|
|
97
|
+
return value({ sessionId })
|
|
98
|
+
}
|
|
99
|
+
case 'session/prompt': {
|
|
100
|
+
const { sessionId, requestId, content } = args?.request || {}
|
|
101
|
+
if (!sessions.includes(sessionId)) return send(200, { result: { ok: false, error: { code: 'gateway/not-found', message: 'no such session' } } })
|
|
102
|
+
const text = content?.[0]?.text ?? ''
|
|
103
|
+
push('user/message', { message: { content: [{ type: 'text', text }] }, requestId })
|
|
104
|
+
setTimeout(() => push('turn/start', { turn: 1 }), 200)
|
|
105
|
+
setTimeout(() => push('assistant/message', { message: { content: [{ type: 'text', text: `echo: ${text}` }, { type: 'text', text: 'MESH-CHANNEL-OK' }] } }), 600)
|
|
106
|
+
setTimeout(() => push('turn/end', { reason: { kind: 'completed' } }), 900)
|
|
107
|
+
return value({ accepted: true })
|
|
108
|
+
}
|
|
109
|
+
case 'session/page': {
|
|
110
|
+
const request = args?.request || {}
|
|
111
|
+
const through = Number.isInteger(request.throughSeq) ? request.throughSeq : -1
|
|
112
|
+
const max = request.maxMessages ?? 40
|
|
113
|
+
const visible = events.filter((event) => through === -1 || event.seq <= through)
|
|
114
|
+
const records = visible.slice(-max).map((event) => ({ type: 'event', event }))
|
|
115
|
+
return value({ records, hasMore: visible.length > records.length })
|
|
116
|
+
}
|
|
117
|
+
case 'session/cancel':
|
|
118
|
+
return value({ cancelled: true })
|
|
119
|
+
default:
|
|
120
|
+
return send(200, { result: { ok: false, error: { code: 'gateway/bad-request', message: `unknown method ${envelope.method}` } } })
|
|
121
|
+
}
|
|
122
|
+
})
|
|
123
|
+
})
|
|
124
|
+
// ---- session/follow carrier (`/api/remote.mux`) ---------------------------
|
|
125
|
+
// Wire shapes mirror packages/api/gateway/src/stream-protocol.ts: {type:'open',
|
|
126
|
+
// streamId, endpoint, payload} in, `{type:'item', streamId, value}` out.
|
|
127
|
+
const wss = new WebSocketServer({ noServer: true })
|
|
128
|
+
server.on('upgrade', (req, socket, head) => {
|
|
129
|
+
const url = new URL(req.url, 'http://127.0.0.1')
|
|
130
|
+
if (url.pathname !== '/api/remote.mux' || !String(req.headers.cookie || '').includes(COOKIE)) {
|
|
131
|
+
socket.destroy()
|
|
132
|
+
return
|
|
133
|
+
}
|
|
134
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
135
|
+
ws.on('message', (raw) => {
|
|
136
|
+
let frame
|
|
137
|
+
try {
|
|
138
|
+
frame = JSON.parse(raw.toString('utf8'))
|
|
139
|
+
} catch {
|
|
140
|
+
return
|
|
141
|
+
}
|
|
142
|
+
if (frame?.type === 'open' && frame.endpoint === 'session/follow') {
|
|
143
|
+
const maxMessages = frame.payload?.args?.request?.maxMessages ?? 40
|
|
144
|
+
ws.streamId = frame.streamId
|
|
145
|
+
followers.add(ws)
|
|
146
|
+
const records = events.slice(-maxMessages).map((event) => ({ type: 'event', event }))
|
|
147
|
+
ws.send(
|
|
148
|
+
JSON.stringify({
|
|
149
|
+
type: 'item',
|
|
150
|
+
streamId: frame.streamId,
|
|
151
|
+
value: { type: 'snapshot', cursor: seq, records, hasMore: false, header: {}, projections: {} }
|
|
152
|
+
})
|
|
153
|
+
)
|
|
154
|
+
return
|
|
155
|
+
}
|
|
156
|
+
if (frame?.type === 'cancel' || frame?.type === 'end') {
|
|
157
|
+
followers.delete(ws)
|
|
158
|
+
ws.close()
|
|
159
|
+
}
|
|
160
|
+
})
|
|
161
|
+
ws.on('close', () => followers.delete(ws))
|
|
162
|
+
})
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
server,
|
|
167
|
+
dial() {
|
|
168
|
+
return new Promise((resolve) => {
|
|
169
|
+
server.listen(0, '127.0.0.1', () => resolve(`http://127.0.0.1:${server.address().port}`))
|
|
170
|
+
})
|
|
171
|
+
},
|
|
172
|
+
close() {
|
|
173
|
+
for (const ws of followers) {
|
|
174
|
+
try {
|
|
175
|
+
ws.close()
|
|
176
|
+
} catch {
|
|
177
|
+
/* already closed */
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return new Promise((resolve) => {
|
|
181
|
+
wss.close(() => server.close(() => resolve()))
|
|
182
|
+
})
|
|
183
|
+
},
|
|
184
|
+
reset() {
|
|
185
|
+
events.length = 0
|
|
186
|
+
seq = 0
|
|
187
|
+
sessions = []
|
|
188
|
+
},
|
|
189
|
+
launchUrl(base) {
|
|
190
|
+
return `${base}/?token=${LAUNCH_TOKEN}`
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function connectMesh(url, token) {
|
|
196
|
+
const transport = new StreamableHTTPClientTransport(new URL(url), {
|
|
197
|
+
requestInit: { headers: { Authorization: `Bearer ${token}` } }
|
|
198
|
+
})
|
|
199
|
+
const client = new Client({ name: 'dsh-mesh-channel-e2e', version: '0.0.1' })
|
|
200
|
+
await client.connect(transport)
|
|
201
|
+
return client
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function parsed(result) {
|
|
205
|
+
const text = result?.content?.find?.((c) => c.type === 'text')?.text
|
|
206
|
+
return JSON.parse(text)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export async function runE2eChannel() {
|
|
210
|
+
const home = mkdtempSync(join(tmpdir(), 'dsh-mesh-channel-'))
|
|
211
|
+
const mock = startMockDsh()
|
|
212
|
+
const mockBase = await mock.dial()
|
|
213
|
+
const launchUrl = mock.launchUrl(mockBase)
|
|
214
|
+
let meshClient
|
|
215
|
+
let srv
|
|
216
|
+
|
|
217
|
+
try {
|
|
218
|
+
// ---- client level -------------------------------------------------------
|
|
219
|
+
await check('client.login exchanges the launch URL for a cookie', async () => {
|
|
220
|
+
const client = new DshWebClient({ log: quiet })
|
|
221
|
+
const { baseUrl } = await client.login(launchUrl)
|
|
222
|
+
assert(baseUrl === mockBase, `unexpected origin ${baseUrl}`)
|
|
223
|
+
assert(client.cookie === COOKIE, `unexpected cookie ${client.cookie}`)
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
await check('bad launch token is rejected (bad sample)', async () => {
|
|
227
|
+
const client = new DshWebClient({ log: quiet })
|
|
228
|
+
let failed = false
|
|
229
|
+
try {
|
|
230
|
+
await client.login(`${mockBase}/?token=${'B'.repeat(43)}`)
|
|
231
|
+
} catch (error) {
|
|
232
|
+
failed = error instanceof DshError && error.code === 'E_DSH_AUTH'
|
|
233
|
+
}
|
|
234
|
+
assert(failed, 'invalid launch token was accepted')
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
await check('rpc without a cookie fails fast (bad sample)', async () => {
|
|
238
|
+
const client = new DshWebClient({ baseUrl: mockBase, log: quiet })
|
|
239
|
+
let code = ''
|
|
240
|
+
try {
|
|
241
|
+
await client.listSessions()
|
|
242
|
+
} catch (error) {
|
|
243
|
+
code = error.code
|
|
244
|
+
}
|
|
245
|
+
assert(code === 'E_DSH_NO_COOKIE', `expected E_DSH_NO_COOKIE, got ${code}`)
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
await check('session/create -> prompt -> page returns the assistant reply', async () => {
|
|
249
|
+
const client = new DshWebClient({ log: quiet })
|
|
250
|
+
await client.login(launchUrl)
|
|
251
|
+
const sessionId = await client.createSession()
|
|
252
|
+
const result = await client.sendAndWait({ sessionId, text: 'ping', waitMs: 20000, pollMs: 200 })
|
|
253
|
+
assert(result.turnEnd.kind === 'completed', `unexpected turn end ${JSON.stringify(result.turnEnd)}`)
|
|
254
|
+
assert(result.reply.includes('echo: ping'), `reply missing echo: ${result.reply}`)
|
|
255
|
+
assert(result.reply.includes('MESH-CHANNEL-OK'), `reply missing marker: ${result.reply}`)
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
await check('HTTP 401 from the host surfaces as E_DSH_HTTP (bad sample)', async () => {
|
|
259
|
+
const client = new DshWebClient({ baseUrl: mockBase, cookie: 'stale=1', log: quiet })
|
|
260
|
+
let code = ''
|
|
261
|
+
try {
|
|
262
|
+
await client.listSessions()
|
|
263
|
+
} catch (error) {
|
|
264
|
+
code = error.code
|
|
265
|
+
}
|
|
266
|
+
assert(code === 'E_DSH_HTTP', `expected E_DSH_HTTP, got ${code}`)
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
// ---- mesh tools level ---------------------------------------------------
|
|
270
|
+
mock.reset()
|
|
271
|
+
const config = defaultConfig()
|
|
272
|
+
config.node = { ...config.node, name: 'channel-e2e', bind: '127.0.0.1', port: 0, path: '/mcp' }
|
|
273
|
+
config.acl.allow = [...config.acl.allow, 'channel_login', 'channel_send', 'channel_cancel']
|
|
274
|
+
config.dsh.targets = [{ id: 'mock-dsh', baseUrl: mockBase }]
|
|
275
|
+
writeFileSync(configPath(home), JSON.stringify(config, null, 2) + '\n', 'utf8')
|
|
276
|
+
const stamp = new Date(Date.now() + 5000)
|
|
277
|
+
utimesSync(configPath(home), stamp, stamp)
|
|
278
|
+
const secrets = loadSecrets(home)
|
|
279
|
+
srv = await startMeshServer({ home, log: quiet })
|
|
280
|
+
meshClient = await connectMesh(srv.url, secrets.inbound[0].token)
|
|
281
|
+
|
|
282
|
+
await check('channel_targets lists the configured target without a cookie', async () => {
|
|
283
|
+
const data = parsed(await meshClient.callTool({ name: 'channel_targets', arguments: {} }))
|
|
284
|
+
const target = data.targets.find((t) => t.id === 'mock-dsh')
|
|
285
|
+
assert(target, 'configured target not listed')
|
|
286
|
+
assert(target.hasCookie === false, 'cookie should not exist yet')
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
await check('channel_send before login returns E_DSH_NO_COOKIE', async () => {
|
|
290
|
+
const data = parsed(
|
|
291
|
+
await meshClient.callTool({ name: 'channel_send', arguments: { target: 'mock-dsh', text: 'hi' } })
|
|
292
|
+
)
|
|
293
|
+
assert(data.code === 'E_DSH_NO_COOKIE', `expected E_DSH_NO_COOKIE, got ${data.code}`)
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
await check('channel_login stores the cookie in secrets.json (0600, gitignored)', async () => {
|
|
297
|
+
const data = parsed(
|
|
298
|
+
await meshClient.callTool({ name: 'channel_login', arguments: { launchUrl, target: 'mock-dsh' } })
|
|
299
|
+
)
|
|
300
|
+
assert(data.target === 'mock-dsh', `unexpected target ${data.target}`)
|
|
301
|
+
assert(data.baseUrl === mockBase, `unexpected baseUrl ${data.baseUrl}`)
|
|
302
|
+
const raw = JSON.parse(await import('node:fs').then((fs) => fs.readFileSync(join(home, 'secrets.json'), 'utf8')))
|
|
303
|
+
assert(raw.dshWeb?.['mock-dsh']?.cookie === COOKIE, 'cookie was not persisted')
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
await check('channel_login without a target derives dsh-<port>', async () => {
|
|
307
|
+
const data = parsed(await meshClient.callTool({ name: 'channel_login', arguments: { launchUrl } }))
|
|
308
|
+
const port = new URL(mockBase).port
|
|
309
|
+
assert(data.target === `dsh-${port}`, `unexpected derived target ${data.target}`)
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
await check('channel_sessions lists the mock session after create', async () => {
|
|
313
|
+
const created = parsed(
|
|
314
|
+
await meshClient.callTool({ name: 'channel_send', arguments: { target: 'mock-dsh', text: 'wake up' } })
|
|
315
|
+
)
|
|
316
|
+
assert(created.reply.includes('MESH-CHANNEL-OK'), `unexpected reply ${created.reply}`)
|
|
317
|
+
const data = parsed(await meshClient.callTool({ name: 'channel_sessions', arguments: { target: 'mock-dsh' } }))
|
|
318
|
+
assert(data.count >= 1, 'no sessions listed')
|
|
319
|
+
assert(data.sessions[0].sessionId.length > 0, 'session id missing')
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
await check('channel_page returns a compact event list', async () => {
|
|
323
|
+
const sessions = parsed(await meshClient.callTool({ name: 'channel_sessions', arguments: { target: 'mock-dsh' } }))
|
|
324
|
+
const sessionId = sessions.sessions[0].sessionId
|
|
325
|
+
const data = parsed(await meshClient.callTool({ name: 'channel_page', arguments: { target: 'mock-dsh', sessionId } }))
|
|
326
|
+
const types = data.events.map((e) => e.type)
|
|
327
|
+
assert(types.includes('user/message'), 'missing user/message')
|
|
328
|
+
assert(types.includes('assistant/message'), 'missing assistant/message')
|
|
329
|
+
assert(types.includes('turn/end'), 'missing turn/end')
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
await check('channel_send to an unknown target returns E_NOT_FOUND (bad sample)', async () => {
|
|
333
|
+
const data = parsed(
|
|
334
|
+
await meshClient.callTool({ name: 'channel_send', arguments: { target: 'nope', text: 'x' } })
|
|
335
|
+
)
|
|
336
|
+
assert(data.code === 'E_NOT_FOUND', `expected E_NOT_FOUND, got ${data.code}`)
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
await check('channel_send is denied by ACL until a human allows it (bad sample)', async () => {
|
|
340
|
+
const before = JSON.parse(await import('node:fs').then((fs) => fs.readFileSync(configPath(home), 'utf8')))
|
|
341
|
+
const next = {
|
|
342
|
+
...before,
|
|
343
|
+
acl: { ...before.acl, allow: before.acl.allow.filter((n) => n !== 'channel_send') }
|
|
344
|
+
}
|
|
345
|
+
writeFileSync(configPath(home), JSON.stringify(next, null, 2) + '\n', 'utf8')
|
|
346
|
+
const bump = new Date(Date.now() + 8000)
|
|
347
|
+
utimesSync(configPath(home), bump, bump)
|
|
348
|
+
// wait for the mtime-based reload
|
|
349
|
+
await new Promise((r) => setTimeout(r, 300))
|
|
350
|
+
const data = parsed(
|
|
351
|
+
await meshClient.callTool({ name: 'channel_send', arguments: { target: 'mock-dsh', text: 'blocked' } })
|
|
352
|
+
)
|
|
353
|
+
assert(data.code === 'E_DENIED', `expected E_DENIED, got ${data.code}`)
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
await check('channel calls land in the audit log', async () => {
|
|
357
|
+
const data = parsed(
|
|
358
|
+
await meshClient.callTool({ name: 'mesh_audit_tail', arguments: { limit: 200, tool: 'channel_send' } })
|
|
359
|
+
)
|
|
360
|
+
assert(data.entries.some((e) => e.tool === 'channel_send' && e.code === 'E_DENIED'), 'denied channel call not audited')
|
|
361
|
+
})
|
|
362
|
+
} finally {
|
|
363
|
+
try {
|
|
364
|
+
await meshClient?.close?.()
|
|
365
|
+
} catch {
|
|
366
|
+
/* ignore */
|
|
367
|
+
}
|
|
368
|
+
if (srv) await srv.close()
|
|
369
|
+
await mock.close()
|
|
370
|
+
rmSync(home, { recursive: true, force: true })
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const failed = results.filter((r) => !r.ok)
|
|
374
|
+
console.log(`\ne2e-channel: ${results.length - failed.length}/${results.length} passed`)
|
|
375
|
+
if (failed.length > 0) {
|
|
376
|
+
for (const f of failed) console.log(` FAILED: ${f.name} — ${f.error}`)
|
|
377
|
+
return 1
|
|
378
|
+
}
|
|
379
|
+
return 0
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
if (process.argv[1]?.endsWith('e2e-channel.js')) {
|
|
383
|
+
runE2eChannel().then((code) => process.exit(code))
|
|
384
|
+
}
|