@hyzyn/dsh-tty 0.18.3 → 0.19.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/README.en.md +61 -30
- package/README.md +49 -27
- package/client.js +39 -32
- package/lib/index.d.ts +367 -1
- package/lib/index.js +417 -135
- package/lib/index.js.map +1 -1
- package/lib/known-hosts.d.ts +7 -1
- package/lib/known-hosts.js +26 -12
- package/lib/known-hosts.js.map +1 -1
- package/lib/probe.d.ts +2 -2
- package/lib/probe.js +7 -35
- package/lib/probe.js.map +1 -1
- package/lib/sftp.d.ts +63 -10
- package/lib/sftp.js +244 -44
- package/lib/sftp.js.map +1 -1
- package/lib/shell-integration.js +35 -15
- package/lib/shell-integration.js.map +1 -1
- package/lib/ssh.d.ts +21 -7
- package/lib/ssh.js +113 -18
- package/lib/ssh.js.map +1 -1
- package/lib/tunnels.js +67 -13
- package/lib/tunnels.js.map +1 -1
- package/package.json +8 -3
- package/scripts/build-client.mjs +30 -0
- package/scripts/integration.mjs +1691 -0
- package/scripts/lib/test-sshd.mjs +314 -0
- package/scripts/live.mjs +68 -0
- package/scripts/preview/harness.html +75 -0
- package/scripts/preview/harness.js +875 -0
- package/scripts/preview/mock-host.js +365 -0
- package/scripts/preview.mjs +502 -0
- package/scripts/probe-route-smoke.mjs +111 -0
- package/scripts/probe-smoke.mjs +143 -0
- package/scripts/probe.mjs +240 -0
- package/scripts/sftplimits-smoke.mjs +92 -0
- package/scripts/ssh-smoke.mjs +593 -0
- package/scripts/tui-smoke.mjs +112 -0
- package/scripts/windows-smoke.mjs +179 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* @hyzyn/dsh-tty — probe 冒烟:内存 SSH server(ssh2.Server password 认证
|
|
4
|
+
* test/secret)× 真实 lib/probe.js 端到端。验证 probe 分类:
|
|
5
|
+
* P1 正确密码 + 无 store → auth.ok=true,hostkey.state='matched'(只比对不落盘)
|
|
6
|
+
* P2 正确密码 + store → hostkey.state='recorded'(TOFU 记录新指纹)
|
|
7
|
+
* P3 错误密码 → auth.ok=false,error 含「认证被拒绝」
|
|
8
|
+
* P4 端口未监听(TCP 层)→ tcp.ok=false,error 含「连接被拒绝」
|
|
9
|
+
* P5 DNS 解析失败 → tcp.ok=false,error 含「DNS」
|
|
10
|
+
* P6 TOFU mismatch:同 host:port 换服务器 host key → hostkey.state='mismatch'
|
|
11
|
+
* (含指引文案与 known/current 指纹),auth.ok=false
|
|
12
|
+
* P7 TCP 通但无 SSH banner(裸 TCP 服务)→ auth.ok=false(tcp.ok=true)
|
|
13
|
+
*
|
|
14
|
+
* 用法:pnpm --filter @hyzyn/dsh-tty build && node scripts/probe-smoke.mjs
|
|
15
|
+
* 退出码:0 = 全部 PASS,1 = 任一 FAIL。
|
|
16
|
+
*/
|
|
17
|
+
import { generateKeyPairSync } from 'node:crypto'
|
|
18
|
+
import net from 'node:net'
|
|
19
|
+
import ssh2 from 'ssh2'
|
|
20
|
+
import { probeSsh } from '../lib/probe.js'
|
|
21
|
+
import { TEST_PASSWORD, TEST_USER } from './lib/test-sshd.mjs'
|
|
22
|
+
|
|
23
|
+
const RESULTS = []
|
|
24
|
+
function pass(name) { RESULTS.push(['PASS', name]); console.log(' ✔ PASS ' + name) }
|
|
25
|
+
function fail(name, detail) { RESULTS.push(['FAIL', name, detail]); console.error(' ✘ FAIL ' + name + (detail ? ' — ' + detail : '')) }
|
|
26
|
+
|
|
27
|
+
/* 一次性内存 sshd(password 认证);只认证不开 shell(probe 不需要) */
|
|
28
|
+
function startSshd(hostKey, listenPort) {
|
|
29
|
+
return new Promise((resolve, reject) => {
|
|
30
|
+
const server = new ssh2.Server({ hostKeys: [hostKey] }, (client) => {
|
|
31
|
+
client.on('authentication', (ctx) => {
|
|
32
|
+
if (ctx.method === 'password' && ctx.username === TEST_USER && ctx.password === TEST_PASSWORD) ctx.accept()
|
|
33
|
+
else ctx.reject()
|
|
34
|
+
})
|
|
35
|
+
client.on('ready', () => { /* probe 不开 channel */ })
|
|
36
|
+
client.on('error', () => { /* 忽略 */ })
|
|
37
|
+
})
|
|
38
|
+
server.once('error', reject)
|
|
39
|
+
const onListen = () => resolve({ server, port: server.address().port })
|
|
40
|
+
if (listenPort !== undefined) server.listen(listenPort, '127.0.0.1', onListen)
|
|
41
|
+
else server.listen(0, '127.0.0.1', onListen)
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
/** 彻底关停:closeAllConnections 释放残留连接(probe 的 client 已 end,但
|
|
45
|
+
* ssh2 Server 侧 socket 可能仍占用端口,必须强关才可同端口复用)。 */
|
|
46
|
+
function closeSshd(s) {
|
|
47
|
+
return new Promise((resolve) => {
|
|
48
|
+
const timer = setTimeout(resolve, 3000).unref()
|
|
49
|
+
s.server.close(() => {
|
|
50
|
+
clearTimeout(timer)
|
|
51
|
+
resolve()
|
|
52
|
+
})
|
|
53
|
+
s.server.closeAllConnections?.()
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const keyA = generateKeyPairSync('rsa', { modulusLength: 2048, privateKeyEncoding: { type: 'pkcs1', format: 'pem' } }).privateKey
|
|
58
|
+
const keyB = generateKeyPairSync('rsa', { modulusLength: 2048, privateKeyEncoding: { type: 'pkcs1', format: 'pem' } }).privateKey
|
|
59
|
+
|
|
60
|
+
/* 内存 hostKeyStore */
|
|
61
|
+
// 0.19.0 起 HostKeyStore.get 返回该 host:port 的指纹集合(一机多把钥匙)
|
|
62
|
+
function makeStore() {
|
|
63
|
+
const map = new Map()
|
|
64
|
+
return {
|
|
65
|
+
get: (host, port) => {
|
|
66
|
+
const fps = map.get(host + ':' + port)
|
|
67
|
+
return fps === undefined ? undefined : [fps]
|
|
68
|
+
},
|
|
69
|
+
record: (host, port, fingerprint) => { map.set(host + ':' + port, fingerprint) },
|
|
70
|
+
_dump: () => map,
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function main() {
|
|
75
|
+
const host = '127.0.0.1'
|
|
76
|
+
const sshd = await startSshd(keyA)
|
|
77
|
+
const port = sshd.port
|
|
78
|
+
const spec = { host, port, username: TEST_USER, auth: 'password', password: TEST_PASSWORD }
|
|
79
|
+
const store = makeStore()
|
|
80
|
+
|
|
81
|
+
// P1 成功无 store
|
|
82
|
+
const p1 = await probeSsh(spec, undefined)
|
|
83
|
+
if (p1.auth.ok === true && p1.tcp.ok === true && p1.hostkey.state === 'matched') pass('P1 正确密码(无 store)→ auth.ok / hostkey.matched')
|
|
84
|
+
else fail('P1 正确密码(无 store)', JSON.stringify(p1))
|
|
85
|
+
|
|
86
|
+
// P2 成功 + store 记录
|
|
87
|
+
const p2 = await probeSsh(spec, store)
|
|
88
|
+
const recorded = store._dump().get(host + ':' + port)
|
|
89
|
+
if (p2.auth.ok === true && p2.hostkey.state === 'recorded' && typeof recorded === 'string' && recorded.length === 64) pass('P2 正确密码 + store → hostkey.recorded(指纹已记录)')
|
|
90
|
+
else fail('P2 正确密码 + store', JSON.stringify(p2) + ' / store=' + String(recorded))
|
|
91
|
+
|
|
92
|
+
// P3 错误密码
|
|
93
|
+
const p3 = await probeSsh({ ...spec, password: 'wrong' }, undefined)
|
|
94
|
+
if (p3.auth.ok === false && /认证被拒绝/.test(p3.auth.error || '')) pass('P3 错误密码 → 认证被拒绝')
|
|
95
|
+
else fail('P3 错误密码', JSON.stringify(p3))
|
|
96
|
+
|
|
97
|
+
// P6 TOFU mismatch:关掉 keyA 服务端,同端口换 keyB
|
|
98
|
+
const p6port = port
|
|
99
|
+
await closeSshd(sshd)
|
|
100
|
+
const sshdB = await startSshd(keyB, p6port)
|
|
101
|
+
const store6 = makeStore()
|
|
102
|
+
const first6 = await probeSsh({ ...spec, port: p6port }, store6)
|
|
103
|
+
if (first6.hostkey.state !== 'recorded') fail('P6 预记录', JSON.stringify(first6))
|
|
104
|
+
await closeSshd(sshdB)
|
|
105
|
+
const sshdA2 = await startSshd(keyA, p6port) // 同 host:port 换回 keyA → 指纹与 store6 不一致
|
|
106
|
+
const p6 = await probeSsh({ ...spec, port: p6port }, store6)
|
|
107
|
+
const known = store6._dump().get(host + ':' + p6port)
|
|
108
|
+
if (p6.auth.ok === false && p6.hostkey.state === 'mismatch' && typeof p6.hostkey.error === 'string' && p6.hostkey.error.includes('MITM') && Array.isArray(p6.hostkey.known) && p6.hostkey.known.length === 1 && p6.hostkey.known[0] === known) pass('P6 host key 变更 → mismatch + 指引')
|
|
109
|
+
else fail('P6 host key 变更', JSON.stringify(p6) + ' / known=' + String(known))
|
|
110
|
+
await closeSshd(sshdA2)
|
|
111
|
+
|
|
112
|
+
// P4 TCP 拒绝(未监听端口)
|
|
113
|
+
const closed = net.createServer()
|
|
114
|
+
await new Promise((resolve) => closed.listen(0, '127.0.0.1', resolve))
|
|
115
|
+
const deadPort = closed.address().port
|
|
116
|
+
await new Promise((resolve) => closed.close(resolve))
|
|
117
|
+
const p4 = await probeSsh({ ...spec, port: deadPort }, undefined)
|
|
118
|
+
if (p4.tcp.ok === false && p4.auth.ok === false && /拒绝|ECONNREFUSED/.test((p4.tcp.error || '') + (p4.auth.error || ''))) pass('P4 端口未监听 → tcp 拒绝')
|
|
119
|
+
else fail('P4 端口未监听', JSON.stringify(p4))
|
|
120
|
+
|
|
121
|
+
// P5 DNS 失败
|
|
122
|
+
const p5 = await probeSsh({ ...spec, host: 'no-such-host.invalid' }, undefined)
|
|
123
|
+
if (p5.tcp.ok === false && /DNS/.test(p5.tcp.error || '')) pass('P5 DNS 解析失败 → tcp DNS')
|
|
124
|
+
else fail('P5 DNS 解析失败', JSON.stringify(p5))
|
|
125
|
+
|
|
126
|
+
// P7 TCP 通但无 SSH banner(裸 TCP 服务)
|
|
127
|
+
const plain = net.createServer((sock) => { sock.on('error', () => {}) })
|
|
128
|
+
await new Promise((resolve) => plain.listen(0, '127.0.0.1', resolve))
|
|
129
|
+
const plainPort = plain.address().port
|
|
130
|
+
const p7 = await probeSsh({ ...spec, port: plainPort }, undefined)
|
|
131
|
+
if (p7.tcp.ok === true && p7.auth.ok === false) pass('P7 裸 TCP(无 SSH)→ tcp 通 + auth 失败')
|
|
132
|
+
else fail('P7 裸 TCP', JSON.stringify(p7))
|
|
133
|
+
await new Promise((resolve) => plain.close(resolve))
|
|
134
|
+
|
|
135
|
+
const failedCount = RESULTS.filter(([kind]) => kind === 'FAIL').length
|
|
136
|
+
console.log(failedCount === 0 ? '\nprobe smoke: 全部 PASS' : `\nprobe smoke: ${failedCount} 个 FAIL`)
|
|
137
|
+
process.exit(failedCount === 0 ? 0 : 1)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
main().catch((error) => {
|
|
141
|
+
console.error('[probe-smoke] 未捕获错误: ' + (error && error.stack ? error.stack : error))
|
|
142
|
+
process.exit(2)
|
|
143
|
+
})
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* @hyzyn/dsh-tty — M0 验证探针(standalone,不依赖 dsh web 宿主)。
|
|
4
|
+
*
|
|
5
|
+
* 验证四个关键假设:
|
|
6
|
+
* A1. ctx.subprocess.spawnTerminal 能用 $SHELL 起真实 PTY,output 流能收到输出;
|
|
7
|
+
* A2. spec.env 里覆盖 TERM=xterm-256color 能压过 node-pty 的 name:"dumb"(TUI 必需);
|
|
8
|
+
* A3. (handle).terminal.resize(cols, rows) 透传 node-pty 原生 resize 生效(DSH 未暴露该 API);
|
|
9
|
+
* A4. webServer.registerUpgrade + ws 的 WebSocketServer({noServer:true}) 握手端到端可用。
|
|
10
|
+
*
|
|
11
|
+
* 用法:pnpm --filter @hyzyn/dsh-tty probe
|
|
12
|
+
* 退出码:0 = 全部 PASS,1 = 任一 FAIL。
|
|
13
|
+
*/
|
|
14
|
+
import { Context } from '@deepseek-ai/cordis'
|
|
15
|
+
import WebServerRuntime from '@deepseek-ai/dsh-host-webserver'
|
|
16
|
+
import { LocalSubprocessRuntime } from '@deepseek-ai/dsh-subprocess-local'
|
|
17
|
+
import WebSocket, { WebSocketServer } from 'ws'
|
|
18
|
+
|
|
19
|
+
const RESULTS = []
|
|
20
|
+
function pass(name) { RESULTS.push(['PASS', name]); console.log(' ✔ PASS ' + name) }
|
|
21
|
+
function fail(name, detail) { RESULTS.push(['FAIL', name, detail]); console.error(' ✘ FAIL ' + name + (detail ? ' — ' + detail : '')) }
|
|
22
|
+
|
|
23
|
+
/** 累积终端输出直到匹配正则,超时 reject。 */
|
|
24
|
+
function expectOutput(handle, re, timeoutMs = 8000) {
|
|
25
|
+
return new Promise((resolve, reject) => {
|
|
26
|
+
let buffer = ''
|
|
27
|
+
const timer = setTimeout(() => {
|
|
28
|
+
handle.output.off('data', onData)
|
|
29
|
+
reject(new Error(`timeout waiting for ${re}; got: ${JSON.stringify(buffer.slice(-300))}`))
|
|
30
|
+
}, timeoutMs)
|
|
31
|
+
const onData = (chunk) => {
|
|
32
|
+
buffer += chunk.toString('utf8')
|
|
33
|
+
const match = buffer.match(re)
|
|
34
|
+
if (match) {
|
|
35
|
+
clearTimeout(timer)
|
|
36
|
+
handle.output.off('data', onData)
|
|
37
|
+
resolve(match)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
handle.output.on('data', onData)
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 构造 shell argv。DSH 的 spawnTerminal 硬编码 node-pty name:"dumb",且 node-pty
|
|
48
|
+
* 里 name 优先于 env.TERM——必须在 exec 真正的 shell 之前 export,才能让
|
|
49
|
+
* vim/htop 等 TUI 拿到 xterm-256color(M0 A2 实测结论)。
|
|
50
|
+
*/
|
|
51
|
+
function shellArgv(shell, { term = 'xterm-256color', colorTerm = 'truecolor' } = {}) {
|
|
52
|
+
return [shell, '-c', `export TERM='${term}'; export COLORTERM='${colorTerm}'; exec "${shell}"`]
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function testPty(app) {
|
|
56
|
+
console.log('\n[1/2] PTY 基础能力(spawnTerminal + TERM + resize)')
|
|
57
|
+
const shell = process.env.SHELL || '/bin/zsh'
|
|
58
|
+
const handle = await app.subprocess.spawnTerminal({
|
|
59
|
+
argv: shellArgv(shell),
|
|
60
|
+
rows: 24,
|
|
61
|
+
cols: 80,
|
|
62
|
+
cwd: process.cwd(),
|
|
63
|
+
env: { TERM: 'xterm-256color', COLORTERM: 'truecolor' },
|
|
64
|
+
graceMs: 3000,
|
|
65
|
+
})
|
|
66
|
+
console.log(` spawned ${shell} pid=${handle.pid}`)
|
|
67
|
+
|
|
68
|
+
// 关掉终端回显:避免「输入行本身」被 expectOutput 误匹配(真实输出才可靠)。
|
|
69
|
+
await sleep(600) // 等 shell 就绪
|
|
70
|
+
handle.write('stty -echo\n').catch(() => {})
|
|
71
|
+
await sleep(200)
|
|
72
|
+
|
|
73
|
+
// A1: 输出流(printf 构造唯一标记,回显已关,匹配到的必是真实输出)
|
|
74
|
+
handle.write('printf "__PTY_READY_%s\\n" OK\n').catch(() => {})
|
|
75
|
+
try {
|
|
76
|
+
await expectOutput(handle, /__PTY_READY_[A-Z]+/)
|
|
77
|
+
pass('A1 spawnTerminal 输出流可达')
|
|
78
|
+
} catch (error) {
|
|
79
|
+
fail('A1 spawnTerminal 输出流可达', error.message)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// A2: TERM 覆盖(zsh 的 ZLE 会渲染输入行,回显行含 "%s\n";用排除 % 与引号的正则只匹配真实输出)
|
|
83
|
+
handle.write('printf "TERMIS:%s\\n" "$TERM"\n').catch(() => {})
|
|
84
|
+
try {
|
|
85
|
+
const match = await expectOutput(handle, /TERMIS:([^\s"%']+)/)
|
|
86
|
+
if (match[1] === 'xterm-256color') pass('A2 TERM 覆盖生效(' + match[1] + ')')
|
|
87
|
+
else fail('A2 TERM 覆盖生效', '得到 ' + match[1] + ',期望 xterm-256color')
|
|
88
|
+
} catch (error) {
|
|
89
|
+
fail('A2 TERM 覆盖生效', error.message)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// A3: resize 透传(stty size 输出 "rows cols")
|
|
93
|
+
handle.write('stty size\n').catch(() => {})
|
|
94
|
+
const before = await expectOutput(handle, /^(\d+) (\d+)$/m).catch(() => null)
|
|
95
|
+
try {
|
|
96
|
+
;(handle).terminal.resize(100, 30)
|
|
97
|
+
await sleep(300)
|
|
98
|
+
handle.write('stty size\n').catch(() => {})
|
|
99
|
+
const resized = await expectOutput(handle, /^(\d+) (\d+)$/m)
|
|
100
|
+
const ok = Number(resized[1]) === 30 && Number(resized[2]) === 100
|
|
101
|
+
console.log(` before=${before ? before[1] + 'x' + before[2] : 'n/a'} after-resize=${resized[1]}x${resized[2]}(期望 30x100)`)
|
|
102
|
+
if (ok) pass('A3 resize 透传生效')
|
|
103
|
+
else fail('A3 resize 透传生效', `stty 报告 ${resized[1]}x${resized[2]}`)
|
|
104
|
+
} catch (error) {
|
|
105
|
+
fail('A3 resize 透传生效', error.message)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
handle.write('stty echo\n').catch(() => {})
|
|
109
|
+
|
|
110
|
+
// A1b: terminate 树级清理(best-effort:幸存者竞态时降级为直接 SIGKILL)
|
|
111
|
+
try {
|
|
112
|
+
const donePromise = handle.done
|
|
113
|
+
await handle.terminate()
|
|
114
|
+
const outcome = await donePromise
|
|
115
|
+
console.log(` terminate → exitCode=${outcome.exitCode} signal=${outcome.signal}`)
|
|
116
|
+
pass('A1b terminate 树级清理并给出退出事实')
|
|
117
|
+
} catch (error) {
|
|
118
|
+
fail('A1b terminate 树级清理并给出退出事实', error.message)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** best-effort 终止:terminate() 抛「幸存者」竞态时降级为对顶层 shell 直接 SIGKILL。 */
|
|
123
|
+
async function forceKill(handle) {
|
|
124
|
+
try {
|
|
125
|
+
await handle.terminate()
|
|
126
|
+
} catch (error) {
|
|
127
|
+
console.warn(' terminate() 报幸存者竞态,降级 SIGKILL:' + error.message)
|
|
128
|
+
try {
|
|
129
|
+
await handle.terminal.kill('SIGKILL')
|
|
130
|
+
} catch {
|
|
131
|
+
/* 已退出 */
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function testWs(app) {
|
|
137
|
+
console.log('\n[2/2] WebSocket upgrade 路由(registerUpgrade + ws 握手)')
|
|
138
|
+
const wss = new WebSocketServer({ noServer: true })
|
|
139
|
+
|
|
140
|
+
/** 宿主侧会话逻辑(M1 会照此骨架落进 src/index.ts) */
|
|
141
|
+
const onConnection = (ws) => {
|
|
142
|
+
let handle = null
|
|
143
|
+
const send = (msg) => { if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg)) }
|
|
144
|
+
ws.on('message', async (raw) => {
|
|
145
|
+
let msg
|
|
146
|
+
try { msg = JSON.parse(raw.toString()) } catch { return }
|
|
147
|
+
if (msg.t === 'spawn') {
|
|
148
|
+
try {
|
|
149
|
+
handle = await app.subprocess.spawnTerminal({
|
|
150
|
+
argv: shellArgv(process.env.SHELL || '/bin/zsh'),
|
|
151
|
+
rows: msg.rows ?? 24,
|
|
152
|
+
cols: msg.cols ?? 80,
|
|
153
|
+
cwd: process.cwd(),
|
|
154
|
+
env: { TERM: 'xterm-256color', COLORTERM: 'truecolor' },
|
|
155
|
+
graceMs: 3000,
|
|
156
|
+
})
|
|
157
|
+
send({ t: 'ready', pid: handle.pid })
|
|
158
|
+
handle.output.on('data', (chunk) => send({ t: 'data', d: chunk.toString('utf8') }))
|
|
159
|
+
handle.done.then((outcome) => {
|
|
160
|
+
send({ t: 'exit', code: outcome.exitCode, signal: outcome.signal })
|
|
161
|
+
ws.close()
|
|
162
|
+
})
|
|
163
|
+
} catch (error) {
|
|
164
|
+
send({ t: 'error', m: error.message })
|
|
165
|
+
}
|
|
166
|
+
} else if (msg.t === 'input' && handle) {
|
|
167
|
+
handle.write(String(msg.d ?? '')).catch(() => {})
|
|
168
|
+
} else if (msg.t === 'resize' && handle) {
|
|
169
|
+
try { handle.terminal.resize(Number(msg.cols), Number(msg.rows)) } catch { /* 透传失败不致命 */ }
|
|
170
|
+
} else if (msg.t === 'kill' && handle) {
|
|
171
|
+
await forceKill(handle)
|
|
172
|
+
}
|
|
173
|
+
})
|
|
174
|
+
ws.on('close', () => { if (handle) forceKill(handle) })
|
|
175
|
+
}
|
|
176
|
+
wss.on('connection', onConnection)
|
|
177
|
+
app.webServer.registerUpgrade({
|
|
178
|
+
path: '/api/dsh-tty/ws',
|
|
179
|
+
handler: (req, socket, head) => {
|
|
180
|
+
wss.handleUpgrade(req, socket, head, (ws) => wss.emit('connection', ws, req))
|
|
181
|
+
},
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
const url = `ws://127.0.0.1:${app.webServer.port}/api/dsh-tty/ws`
|
|
185
|
+
const client = new WebSocket(url)
|
|
186
|
+
const received = []
|
|
187
|
+
let sawExit = false
|
|
188
|
+
let sawResizeAck = false
|
|
189
|
+
|
|
190
|
+
const result = await new Promise((resolve) => {
|
|
191
|
+
const timer = setTimeout(() => resolve({ ok: false, error: 'WS 端到端超时', received }), 15000)
|
|
192
|
+
client.on('open', () => {
|
|
193
|
+
client.send(JSON.stringify({ t: 'spawn', cols: 80, rows: 24 }))
|
|
194
|
+
})
|
|
195
|
+
client.on('message', (raw) => {
|
|
196
|
+
const msg = JSON.parse(raw.toString())
|
|
197
|
+
received.push(msg.t)
|
|
198
|
+
if (msg.t === 'ready') {
|
|
199
|
+
client.send(JSON.stringify({ t: 'resize', cols: 100, rows: 30 }))
|
|
200
|
+
client.send(JSON.stringify({ t: 'input', d: 'echo __WS_PROBE_OK__\n' }))
|
|
201
|
+
} else if (msg.t === 'data' && String(msg.d).includes('__WS_PROBE_OK__')) {
|
|
202
|
+
client.send(JSON.stringify({ t: 'kill' }))
|
|
203
|
+
} else if (msg.t === 'exit') {
|
|
204
|
+
sawExit = true
|
|
205
|
+
clearTimeout(timer)
|
|
206
|
+
client.close()
|
|
207
|
+
resolve({ ok: true, received })
|
|
208
|
+
}
|
|
209
|
+
})
|
|
210
|
+
client.on('error', (error) => {
|
|
211
|
+
clearTimeout(timer)
|
|
212
|
+
resolve({ ok: false, error: error.message, received })
|
|
213
|
+
})
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
if (result.ok && sawExit) pass('A4 registerUpgrade + ws 握手端到端(spawn→resize→input→data→kill→exit)')
|
|
217
|
+
else fail('A4 registerUpgrade + ws 握手端到端(spawn→resize→input→data→kill→exit)', result.error || '未见 exit 帧')
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function main() {
|
|
221
|
+
const app = new Context()
|
|
222
|
+
const wsFiber = app.plugin(WebServerRuntime, { host: '127.0.0.1', port: 0 })
|
|
223
|
+
const subFiber = app.plugin(LocalSubprocessRuntime)
|
|
224
|
+
await wsFiber.await()
|
|
225
|
+
await subFiber.await()
|
|
226
|
+
console.log(`webServer listening on 127.0.0.1:${app.webServer.port}`)
|
|
227
|
+
|
|
228
|
+
await testPty(app)
|
|
229
|
+
await testWs(app)
|
|
230
|
+
|
|
231
|
+
const failed = RESULTS.filter(([kind]) => kind === 'FAIL')
|
|
232
|
+
console.log(`\n==== M0 结果:${RESULTS.length - failed.length}/${RESULTS.length} PASS ====`)
|
|
233
|
+
for (const [kind, name, detail] of RESULTS) console.log(` ${kind === 'PASS' ? '✔' : '✘'} ${name}${detail ? ' — ' + detail : ''}`)
|
|
234
|
+
process.exit(failed.length > 0 ? 1 : 0)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
main().catch((error) => {
|
|
238
|
+
console.error('probe 崩溃:', error)
|
|
239
|
+
process.exit(1)
|
|
240
|
+
})
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* @hyzyn/dsh-tty — sftpLimits 配置贯通冒烟:POST /api/dsh-tty/config 带
|
|
4
|
+
* sftpLimits → GET 回读正确;缺省回落默认;非法值拒绝;normalizePatch 拦截。
|
|
5
|
+
* 用法:node scripts/sftplimits-smoke.mjs
|
|
6
|
+
*/
|
|
7
|
+
import { Context } from '@deepseek-ai/cordis'
|
|
8
|
+
import WebServerRuntime from '@deepseek-ai/dsh-host-webserver'
|
|
9
|
+
import { name, inject, apply } from '../lib/index.js'
|
|
10
|
+
|
|
11
|
+
const RESULTS = []
|
|
12
|
+
function pass(name) { RESULTS.push(['PASS', name]); console.log(' ✔ PASS ' + name) }
|
|
13
|
+
function fail(name, detail) { RESULTS.push(['FAIL', name, detail]); console.error(' ✘ FAIL ' + name + (detail ? ' — ' + detail : '')) }
|
|
14
|
+
|
|
15
|
+
async function run() {
|
|
16
|
+
const app = new Context()
|
|
17
|
+
const wsFiber = app.plugin(WebServerRuntime, { host: '127.0.0.1', port: 0 })
|
|
18
|
+
const stubFiber = app.plugin({
|
|
19
|
+
name: 'settings-stub',
|
|
20
|
+
apply: (ctx) => {
|
|
21
|
+
ctx.provide('settings', { register: () => ({ get: () => ({}), update: async () => {} }) })
|
|
22
|
+
ctx.provide('tools', { register: () => () => {} })
|
|
23
|
+
// 与真实契约同形(section/context 返回注销器):插件热应用时会调用重建
|
|
24
|
+
ctx.provide('systemPrompt', {
|
|
25
|
+
section: () => () => {},
|
|
26
|
+
context: () => () => {},
|
|
27
|
+
})
|
|
28
|
+
},
|
|
29
|
+
})
|
|
30
|
+
await stubFiber.await()
|
|
31
|
+
const pluginFiber = app.plugin({ name, inject, apply }, { maxSessions: 4 })
|
|
32
|
+
await wsFiber.await()
|
|
33
|
+
await pluginFiber.await()
|
|
34
|
+
const port = app.webServer.port
|
|
35
|
+
await new Promise((resolve) => setTimeout(resolve, 300))
|
|
36
|
+
|
|
37
|
+
const getConfig = async () => (await (await fetch(`http://127.0.0.1:${port}/api/dsh-tty/config`)).json()).config
|
|
38
|
+
const post = async (body) => {
|
|
39
|
+
const res = await fetch(`http://127.0.0.1:${port}/api/dsh-tty/config`, {
|
|
40
|
+
method: 'POST',
|
|
41
|
+
headers: { 'content-type': 'application/json' },
|
|
42
|
+
body: JSON.stringify(body),
|
|
43
|
+
})
|
|
44
|
+
return { status: res.status, data: await res.json().catch(() => ({})) }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// 1. 缺省值:配置未提供 → 默认 1024/2048/1000
|
|
48
|
+
const c0 = await getConfig()
|
|
49
|
+
if (c0.sftpLimits?.maxDownloadMb === 1024 && c0.sftpLimits?.maxUploadMb === 2048 && c0.sftpLimits?.maxUploadFiles === 1000) pass('S1 缺省 sftpLimits(1024/2048/1000)')
|
|
50
|
+
else fail('S1 缺省 sftpLimits', JSON.stringify(c0.sftpLimits))
|
|
51
|
+
|
|
52
|
+
// 2. 提交合法值 → 回读生效
|
|
53
|
+
const r2 = await post({ sftpLimits: { maxDownloadMb: 512, maxUploadMb: 0, maxUploadFiles: 200 } })
|
|
54
|
+
if (r2.status === 200 && r2.data.ok === true) {
|
|
55
|
+
const c2 = await getConfig()
|
|
56
|
+
if (c2.sftpLimits.maxDownloadMb === 512 && c2.sftpLimits.maxUploadMb === 0 && c2.sftpLimits.maxUploadFiles === 200) pass('S2 提交 sftpLimits 热生效(512/0/200,0=不限)')
|
|
57
|
+
else fail('S2 提交 sftpLimits 热生效', JSON.stringify(c2.sftpLimits))
|
|
58
|
+
} else fail('S2 提交 sftpLimits 热生效', JSON.stringify(r2))
|
|
59
|
+
|
|
60
|
+
// 3. 部分提交(只改一项)→ 其余保持
|
|
61
|
+
const r3 = await post({ sftpLimits: { maxDownloadMb: 256 } })
|
|
62
|
+
if (r3.status === 200 && r3.data.ok === true) {
|
|
63
|
+
const c3 = await getConfig()
|
|
64
|
+
if (c3.sftpLimits.maxDownloadMb === 256 && c3.sftpLimits.maxUploadMb === 0 && c3.sftpLimits.maxUploadFiles === 200) pass('S3 部分提交只改对应项')
|
|
65
|
+
else fail('S3 部分提交只改对应项', JSON.stringify(c3.sftpLimits))
|
|
66
|
+
} else fail('S3 部分提交只改对应项', JSON.stringify(r3))
|
|
67
|
+
|
|
68
|
+
// 4. 非法值拒绝
|
|
69
|
+
const r4 = await post({ sftpLimits: { maxDownloadMb: -5 } })
|
|
70
|
+
if (r4.status === 400 && /整数/.test(r4.data.error || '')) pass('S4 负值拒绝')
|
|
71
|
+
else fail('S4 负值拒绝', JSON.stringify(r4))
|
|
72
|
+
const r5 = await post({ sftpLimits: { maxUploadFiles: 99999999 } })
|
|
73
|
+
if (r5.status === 400 && /整数/.test(r5.data.error || '')) pass('S5 超上限拒绝')
|
|
74
|
+
else fail('S5 超上限拒绝', JSON.stringify(r5))
|
|
75
|
+
const r6 = await post({ sftpLimits: 'not-an-object' })
|
|
76
|
+
if (r6.status === 400 && /对象/.test(r6.data.error || '')) pass('S6 非对象拒绝')
|
|
77
|
+
else fail('S6 非对象拒绝', JSON.stringify(r6))
|
|
78
|
+
|
|
79
|
+
// 7. 未知键仍拦截(原行为保持)
|
|
80
|
+
const r7 = await post({ bogus: 1 })
|
|
81
|
+
if (r7.status === 400 && /未知配置项/.test(r7.data.error || '')) pass('S7 未知配置项仍拒绝')
|
|
82
|
+
else fail('S7 未知配置项仍拒绝', JSON.stringify(r7))
|
|
83
|
+
|
|
84
|
+
const failedCount = RESULTS.filter(([kind]) => kind === 'FAIL').length
|
|
85
|
+
console.log(failedCount === 0 ? '\nsftpLimits smoke: 全部 PASS' : `\nsftpLimits smoke: ${failedCount} 个 FAIL`)
|
|
86
|
+
process.exit(failedCount === 0 ? 0 : 1)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
run().catch((error) => {
|
|
90
|
+
console.error('[sftplimits-smoke] 崩溃: ' + (error && error.stack ? error.stack : error))
|
|
91
|
+
process.exit(2)
|
|
92
|
+
})
|