@hyzyn/dsh-tty 0.18.2 → 0.19.0
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 +85 -34
- package/README.md +65 -29
- package/client.js +51 -37
- package/lib/index.d.ts +367 -1
- package/lib/index.js +420 -138
- 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 +8 -36
- 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 +33 -13
- package/lib/shell-integration.js.map +1 -1
- package/lib/ssh.d.ts +21 -7
- package/lib/ssh.js +114 -19
- package/lib/ssh.js.map +1 -1
- package/lib/tunnels.js +67 -13
- package/lib/tunnels.js.map +1 -1
- package/package.json +7 -2
- 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,1691 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* @hyzyn/dsh-tty — M1 集成测试:把构建后的真实插件(lib/index.js)加载进
|
|
4
|
+
* 真实 DSH 服务组合(dsh-host-webserver + dsh-subprocess-local),模拟浏览器
|
|
5
|
+
* 半体用 ws 客户端完整走一遍协议,并验证边界:
|
|
6
|
+
* B1. spawn → ready → data(输入回显)→ kill → exit 全链路
|
|
7
|
+
* B2. resize 帧生效(stty size 校验)
|
|
8
|
+
* B3. 单会话约束:同连接二次 spawn 报「会话已存在」
|
|
9
|
+
* B4. maxSessions 上限:第二连接 spawn 报「会话数已达上限」
|
|
10
|
+
* B5. loopback 围栏:伪造 Host 的 upgrade 请求被拒
|
|
11
|
+
* B6. TERM 注入(-c 包装层)生效
|
|
12
|
+
* B7. 单连接多会话:共存、数据隔离、单独 kill
|
|
13
|
+
* B8. cwd 跟随与校验
|
|
14
|
+
* B9. 配置热生效(settings/updated)
|
|
15
|
+
* B10. /api/dsh-tty/config 读写 API
|
|
16
|
+
* B11. 超限被拒后的恢复
|
|
17
|
+
* B12. agent 工具集(tty_list / tty_capture / tty_screen / tty_expect / tty_send)
|
|
18
|
+
* B13. 断线保活与重连(sessions/attach 帧,协议 v3)
|
|
19
|
+
* B14. tty_screen 虚拟屏 + tty_capture ANSI 清洗
|
|
20
|
+
* B15. shell 集成(OSC 133 命令捕获/退出码 + OSC 7 cwd 跟随)
|
|
21
|
+
* B16. ~/.ssh/config 解析器
|
|
22
|
+
* B17. tty_expect 匹配与超时
|
|
23
|
+
* B18. data 帧合并后帧序不变量(exit 在最后一帧 data 之后)
|
|
24
|
+
* B19. known_hosts 解析器
|
|
25
|
+
* B20. 端口转发隧道(forwardOut 往返 + forwardIn 就绪 + 状态与工具)
|
|
26
|
+
* B21. /api/dsh-tty/shells 候选列表(Shell 路径可选可输入的数据源)
|
|
27
|
+
* B22. bash 3.2(无 PS0)shell 集成:DEBUG trap 兜底 B 标记(capture{last} + expect 早停)
|
|
28
|
+
* B23. SFTP 文件传输(/api/dsh-tty/sftp/* 路由:list/mkdir/upload/download/remove
|
|
29
|
+
* + agent 工具 sftp_list/sftp_read/sftp_write,test-sshd 内存 sshd)
|
|
30
|
+
* B24. SFTP 管理闭环(agent sftp_mkdir/sftp_rename/sftp_remove/sftp_tree:
|
|
31
|
+
* mkdir -p、tree 限深截断、跨目录 rename、非空删除拒绝与递归删除)
|
|
32
|
+
* B25. 会话持久化(tmux,0.10.0):配置门控、tmux 会话托管、kill-session
|
|
33
|
+
* 收尾、shell 集成 DCS passthrough(capture{last});无 tmux 时降级
|
|
34
|
+
* 普通会话 + 灰字提示(本机无 tmux 则跳过 tmux 断言)
|
|
35
|
+
* B26. 持久恢复(tmux):保活回收只杀 PTY 不杀 tmux 会话;同 persistName
|
|
36
|
+
* 重新 spawn 按 `tmux -A` 接回原现场(屏幕重画含此前输出)
|
|
37
|
+
* B27. 跨窗口共享(0.10.1):同 persistName 二次 spawn 重绑定现有宿主会话
|
|
38
|
+
* (不新建 PTY / 不占名额)、输出扇出、kill 广播 exit
|
|
39
|
+
* B29. 禁用热生效(enabled 开关):工具/公告撤下与恢复、数据路由 403、
|
|
40
|
+
* /config 保持可读写、存量 WS 被关闭与新升级被拒
|
|
41
|
+
*
|
|
42
|
+
* 用法:pnpm --filter @hyzyn/dsh-tty integration
|
|
43
|
+
* 退出码:0 = 全部 PASS,1 = 任一 FAIL。
|
|
44
|
+
*/
|
|
45
|
+
import { Context } from '@deepseek-ai/cordis'
|
|
46
|
+
import WebServerRuntime from '@deepseek-ai/dsh-host-webserver'
|
|
47
|
+
import { LocalSubprocessRuntime } from '@deepseek-ai/dsh-subprocess-local'
|
|
48
|
+
import WebSocket from 'ws'
|
|
49
|
+
import fsp from 'node:fs/promises'
|
|
50
|
+
import net from 'node:net'
|
|
51
|
+
import os from 'node:os'
|
|
52
|
+
import path from 'node:path'
|
|
53
|
+
import { name, inject, apply } from '../lib/index.js'
|
|
54
|
+
import { parseSshConfig } from '../lib/ssh-config.js'
|
|
55
|
+
import { parseKnownHosts } from '../lib/known-hosts.js'
|
|
56
|
+
import { assertSupportedJsonSchema } from '@deepseek-ai/dsh-tools'
|
|
57
|
+
import { startSftpSshd, TEST_USER, TEST_PASSWORD } from './lib/test-sshd.mjs'
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
/* 全局看门狗:任何环节卡死时留痕退出(正常路径会先 process.exit)。 */
|
|
61
|
+
setTimeout(() => {
|
|
62
|
+
console.error('[watchdog] 120s 看门狗触发:集成测试卡死')
|
|
63
|
+
process.exit(2)
|
|
64
|
+
}, 120000).unref()
|
|
65
|
+
process.on('unhandledRejection', (reason) => {
|
|
66
|
+
console.error('[unhandledRejection]', reason)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
const RESULTS = []
|
|
70
|
+
function pass(name) { RESULTS.push(['PASS', name]); console.log(' ✔ PASS ' + name) }
|
|
71
|
+
function fail(name, detail) { RESULTS.push(['FAIL', name, detail]); console.error(' ✘ FAIL ' + name + (detail ? ' — ' + detail : '')) }
|
|
72
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
73
|
+
|
|
74
|
+
/** 简易 ws 会话客户端:收集 data 帧文本,提供 send 与等待匹配。 */
|
|
75
|
+
function openSession(port, headers) {
|
|
76
|
+
const client = new WebSocket(`ws://127.0.0.1:${port}/api/dsh-tty/ws`, { headers })
|
|
77
|
+
client.setMaxListeners(0) // 测试里多次重连会反复挂 once 监听,避免告警
|
|
78
|
+
const state = {
|
|
79
|
+
frames: [],
|
|
80
|
+
text: '',
|
|
81
|
+
ready: false,
|
|
82
|
+
exited: null,
|
|
83
|
+
errors: [],
|
|
84
|
+
closed: false,
|
|
85
|
+
waiters: [],
|
|
86
|
+
}
|
|
87
|
+
client.on('message', (raw) => {
|
|
88
|
+
const msg = JSON.parse(raw.toString())
|
|
89
|
+
state.frames.push(msg)
|
|
90
|
+
// 收集器剥掉 shell 集成标记(133;B 与命令输出同行,会粘住 ^…$ 行匹配)
|
|
91
|
+
if (msg.t === 'data') state.text += String(msg.d ?? '').replace(/\x1b\]133;[ABDC](?:;\d+)?(?:\x07|\x1b\\)|\x1b\]7;[^\x07\x1b]*(?:\x07|\x1b\\)/g, '')
|
|
92
|
+
if (msg.t === 'ready') state.ready = true
|
|
93
|
+
if (msg.t === 'exit') state.exited = { sid: msg.sid, code: msg.code, signal: msg.signal }
|
|
94
|
+
if (msg.t === 'error') state.errors.push(String(msg.m ?? ''))
|
|
95
|
+
for (const w of [...state.waiters]) w()
|
|
96
|
+
})
|
|
97
|
+
client.on('close', () => {
|
|
98
|
+
state.closed = true
|
|
99
|
+
for (const w of [...state.waiters]) w()
|
|
100
|
+
})
|
|
101
|
+
const waitFor = async (pred, timeoutMs = 12000, label = '条件') => {
|
|
102
|
+
const start = Date.now()
|
|
103
|
+
while (!pred()) {
|
|
104
|
+
if (Date.now() - start > timeoutMs) {
|
|
105
|
+
// 超时信息带上下文(0.19.0):受限环境里 PTY spawn 被拒时此前只显示
|
|
106
|
+
// 「已收文本: ""」,排查者会误判成插件 bug——error 帧 / 退出事实一并打出
|
|
107
|
+
const details = [`已收文本: ${JSON.stringify(state.text.slice(-200))}`]
|
|
108
|
+
if (state.errors.length > 0) details.push(`error 帧: ${JSON.stringify(state.errors.slice(-3))}`)
|
|
109
|
+
if (state.exited !== null && state.exited !== undefined) details.push(`进程已退出: ${JSON.stringify(state.exited)}`)
|
|
110
|
+
if (state.closed) details.push('ws 已关闭')
|
|
111
|
+
throw new Error(`等待${label}超时(${details.join(';')})`)
|
|
112
|
+
}
|
|
113
|
+
await Promise.race([
|
|
114
|
+
new Promise((resolve) => state.waiters.push(resolve)),
|
|
115
|
+
sleep(200),
|
|
116
|
+
])
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const open = async () => {
|
|
120
|
+
const start = Date.now()
|
|
121
|
+
let lastError = ''
|
|
122
|
+
while (client.readyState !== WebSocket.OPEN) {
|
|
123
|
+
if (Date.now() - start > 10000) throw new Error('ws 连接超时:' + (lastError || '无错误事件') + '(可能 upgrade 路由未就绪)')
|
|
124
|
+
await new Promise((resolve) => {
|
|
125
|
+
const timer = setTimeout(resolve, 100)
|
|
126
|
+
client.once('open', () => { clearTimeout(timer); resolve() })
|
|
127
|
+
client.once('error', (error) => { lastError = error.message })
|
|
128
|
+
client.once('close', () => { clearTimeout(timer); resolve() })
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return { client, state, waitFor, open }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function run() {
|
|
136
|
+
const app = new Context()
|
|
137
|
+
const wsFiber = app.plugin(WebServerRuntime, { host: '127.0.0.1', port: 0 })
|
|
138
|
+
const subFiber = app.plugin(LocalSubprocessRuntime)
|
|
139
|
+
// 最小 settings 服务 stub:让插件的 settings 注入回调触发,并可手动派发
|
|
140
|
+
// settings/updated 事件(与 dsh-settings 的 dispatch 方式一致)来测配置热生效。
|
|
141
|
+
const toolDefs = []
|
|
142
|
+
const promptParts = []
|
|
143
|
+
const stubFiber = app.plugin({
|
|
144
|
+
name: 'settings-stub',
|
|
145
|
+
apply: (ctx) => {
|
|
146
|
+
ctx.provide('settings', { register: () => ({ get: () => ({}), update: async () => {} }) })
|
|
147
|
+
ctx.provide('tools', {
|
|
148
|
+
register: (definition) => {
|
|
149
|
+
toolDefs.push(definition)
|
|
150
|
+
// dispose 必须真摘除:B29 禁用场景要断言「工具全部撤下」
|
|
151
|
+
return () => {
|
|
152
|
+
const index = toolDefs.indexOf(definition)
|
|
153
|
+
if (index >= 0) toolDefs.splice(index, 1)
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
})
|
|
157
|
+
// systemPrompt 桩:记录 section/context 注册与撤下(B29 公告断言用)
|
|
158
|
+
ctx.provide('systemPrompt', {
|
|
159
|
+
section: (options) => {
|
|
160
|
+
promptParts.push(options)
|
|
161
|
+
return () => {
|
|
162
|
+
const index = promptParts.indexOf(options)
|
|
163
|
+
if (index >= 0) promptParts.splice(index, 1)
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
context: (options) => {
|
|
167
|
+
promptParts.push(options)
|
|
168
|
+
return () => {
|
|
169
|
+
const index = promptParts.indexOf(options)
|
|
170
|
+
if (index >= 0) promptParts.splice(index, 1)
|
|
171
|
+
}
|
|
172
|
+
},
|
|
173
|
+
})
|
|
174
|
+
},
|
|
175
|
+
})
|
|
176
|
+
await stubFiber.await()
|
|
177
|
+
const emitSettingsUpdated = (ns, next) => {
|
|
178
|
+
const args = ['settings/updated', ns, next, {}, 'test']
|
|
179
|
+
for (const cb of app.events.dispatch('emit', args)) cb(...args)
|
|
180
|
+
}
|
|
181
|
+
const pluginFiber = app.plugin({ name, inject, apply }, { maxSessions: 2, term: 'xterm-256color', colorTerm: 'truecolor' })
|
|
182
|
+
await wsFiber.await()
|
|
183
|
+
await subFiber.await()
|
|
184
|
+
await pluginFiber.await()
|
|
185
|
+
const port = app.webServer.port
|
|
186
|
+
console.log(`webServer on 127.0.0.1:${port},插件已挂载`)
|
|
187
|
+
await sleep(400) // 等 ctx.inject(['webServer']) 回调完成路由注册
|
|
188
|
+
|
|
189
|
+
// B1 + B6: 全链路 + TERM
|
|
190
|
+
console.log('\n[1] 全链路(spawn→data→kill→exit)与 TERM 注入')
|
|
191
|
+
{
|
|
192
|
+
const s = openSession(port)
|
|
193
|
+
await s.open()
|
|
194
|
+
s.client.send(JSON.stringify({ t: 'spawn', cols: 80, rows: 24 }))
|
|
195
|
+
await s.waitFor(() => s.state.ready, 10000, 'ready')
|
|
196
|
+
s.client.send(JSON.stringify({ t: 'input', d: 'printf "IT_TERM_%s\\n" "$TERM"\n' }))
|
|
197
|
+
await s.waitFor(() => /IT_TERM_xterm-256color/.test(s.state.text), 10000, 'TERM 输出')
|
|
198
|
+
pass('B6 TERM 注入生效(xterm-256color)')
|
|
199
|
+
s.client.send(JSON.stringify({ t: 'input', d: 'printf "IT_READY_%s\\n" OK\n' }))
|
|
200
|
+
await s.waitFor(() => /IT_READY_OK/.test(s.state.text), 10000, '回显')
|
|
201
|
+
pass('B1a spawn→data 全链路(输入回显可见)')
|
|
202
|
+
s.client.send(JSON.stringify({ t: 'kill' }))
|
|
203
|
+
await s.waitFor(() => s.state.exited !== null, 10000, 'exit 帧')
|
|
204
|
+
pass('B1b kill→exit 帧(code=' + s.state.exited.code + ' signal=' + s.state.exited.signal + ')')
|
|
205
|
+
s.client.close()
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// B2: resize
|
|
209
|
+
console.log('\n[2] resize 帧')
|
|
210
|
+
{
|
|
211
|
+
const s = openSession(port)
|
|
212
|
+
await s.open()
|
|
213
|
+
s.client.send(JSON.stringify({ t: 'spawn', cols: 80, rows: 24 }))
|
|
214
|
+
await s.waitFor(() => s.state.ready, 10000, 'ready')
|
|
215
|
+
s.client.send(JSON.stringify({ t: 'input', d: 'stty size\n' }))
|
|
216
|
+
await s.waitFor(() => /^\d+ \d+$/m.test(s.state.text), 10000, '初始尺寸')
|
|
217
|
+
const before = s.state.text.match(/^(\d+) (\d+)$/m)
|
|
218
|
+
s.client.send(JSON.stringify({ t: 'resize', cols: 110, rows: 33 }))
|
|
219
|
+
await sleep(400)
|
|
220
|
+
s.client.send(JSON.stringify({ t: 'input', d: 'stty size\n' }))
|
|
221
|
+
const after = await new Promise((resolve, reject) => {
|
|
222
|
+
const start = Date.now()
|
|
223
|
+
const check = () => {
|
|
224
|
+
const matches = [...s.state.text.matchAll(/^(\d+) (\d+)$/gm)]
|
|
225
|
+
const last = matches[matches.length - 1]
|
|
226
|
+
if (last !== undefined && last.index !== before.index) return resolve(last)
|
|
227
|
+
if (Date.now() - start > 10000) return reject(new Error('resize 后 stty size 超时'))
|
|
228
|
+
setTimeout(check, 50)
|
|
229
|
+
}
|
|
230
|
+
check()
|
|
231
|
+
})
|
|
232
|
+
const ok = Number(after[1]) === 33 && Number(after[2]) === 110
|
|
233
|
+
console.log(` before=${before[1]}x${before[2]} after=${after[1]}x${after[2]}(期望 33x110)`)
|
|
234
|
+
if (ok) pass('B2 resize 帧生效')
|
|
235
|
+
else fail('B2 resize 帧生效', `${after[1]}x${after[2]}`)
|
|
236
|
+
s.client.send(JSON.stringify({ t: 'kill' }))
|
|
237
|
+
await s.waitFor(() => s.state.exited !== null, 10000, 'exit')
|
|
238
|
+
s.client.close()
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// B3: sid 冲突约束(v2:同连接多会话,同 sid 才拒绝)
|
|
242
|
+
console.log('\n[3] sid 冲突约束')
|
|
243
|
+
{
|
|
244
|
+
const s = openSession(port)
|
|
245
|
+
await s.open()
|
|
246
|
+
s.client.send(JSON.stringify({ t: 'spawn', sid: 'tab-a' }))
|
|
247
|
+
await s.waitFor(() => s.state.ready, 10000, 'ready')
|
|
248
|
+
s.client.send(JSON.stringify({ t: 'spawn', sid: 'tab-a' }))
|
|
249
|
+
await s.waitFor(() => s.state.errors.length > 0, 10000, '重复 sid 错误')
|
|
250
|
+
if (/sid 已存在/.test(s.state.errors[0])) pass('B3 同 sid 二次 spawn 被拒')
|
|
251
|
+
else fail('B3 同 sid 二次 spawn 被拒', s.state.errors[0])
|
|
252
|
+
s.client.send(JSON.stringify({ t: 'kill', sid: 'tab-a' }))
|
|
253
|
+
await s.waitFor(() => s.state.exited !== null, 10000, 'exit')
|
|
254
|
+
s.client.close()
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// B4: maxSessions 上限(跨连接)
|
|
258
|
+
console.log('\n[4] maxSessions 上限')
|
|
259
|
+
{
|
|
260
|
+
const s1 = openSession(port)
|
|
261
|
+
await s1.open()
|
|
262
|
+
s1.client.send(JSON.stringify({ t: 'spawn', sid: 's1' }))
|
|
263
|
+
await s1.waitFor(() => s1.state.ready, 10000, 's1 ready')
|
|
264
|
+
const s2 = openSession(port)
|
|
265
|
+
await s2.open()
|
|
266
|
+
s2.client.send(JSON.stringify({ t: 'spawn', sid: 's2' }))
|
|
267
|
+
await s2.waitFor(() => s2.state.ready, 10000, 's2 ready')
|
|
268
|
+
const s3 = openSession(port)
|
|
269
|
+
await s3.open()
|
|
270
|
+
s3.client.send(JSON.stringify({ t: 'spawn', sid: 's3' }))
|
|
271
|
+
await s3.waitFor(() => s3.state.errors.length > 0, 10000, 's3 上限错误')
|
|
272
|
+
if (/会话数已达上限/.test(s3.state.errors[0])) pass('B4 maxSessions=2 生效(第三连接被拒)')
|
|
273
|
+
else fail('B4 maxSessions=2 生效(第三连接被拒)', s3.state.errors[0])
|
|
274
|
+
s3.client.close()
|
|
275
|
+
s2.client.send(JSON.stringify({ t: 'kill', sid: 's2' }))
|
|
276
|
+
await s2.waitFor(() => s2.state.exited !== null, 10000, 's2 exit')
|
|
277
|
+
s2.client.close()
|
|
278
|
+
s1.client.send(JSON.stringify({ t: 'kill', sid: 's1' }))
|
|
279
|
+
await s1.waitFor(() => s1.state.exited !== null, 10000, 's1 exit')
|
|
280
|
+
s1.client.close()
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// B5: loopback 围栏
|
|
284
|
+
console.log('\n[5] loopback 围栏')
|
|
285
|
+
{
|
|
286
|
+
const s = openSession(port, { Host: 'attacker.example.com' })
|
|
287
|
+
let outcome
|
|
288
|
+
try {
|
|
289
|
+
await s.open()
|
|
290
|
+
outcome = 'open'
|
|
291
|
+
} catch {
|
|
292
|
+
outcome = s.client.readyState === WebSocket.CLOSED ? 'rejected' : 'other'
|
|
293
|
+
}
|
|
294
|
+
if (outcome === 'rejected') pass('B5 伪造 Host 的 upgrade 被拒')
|
|
295
|
+
else fail('B5 伪造 Host 的 upgrade 被拒', '连接意外建立(' + outcome + ')')
|
|
296
|
+
try { s.client.terminate() } catch { /* 忽略 */ }
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// B7: 单连接多会话(共存、数据隔离、单独 kill)
|
|
300
|
+
console.log('\n[6] 单连接多会话')
|
|
301
|
+
{
|
|
302
|
+
const s = openSession(port)
|
|
303
|
+
await s.open()
|
|
304
|
+
s.client.send(JSON.stringify({ t: 'spawn', sid: 'tab-1' }))
|
|
305
|
+
await s.waitFor(() => s.state.ready, 10000, 'tab-1 ready')
|
|
306
|
+
s.state.ready = false
|
|
307
|
+
s.client.send(JSON.stringify({ t: 'spawn', sid: 'tab-2' }))
|
|
308
|
+
await s.waitFor(() => s.state.ready, 10000, 'tab-2 ready')
|
|
309
|
+
s.client.send(JSON.stringify({ t: 'input', sid: 'tab-1', d: 'printf "TAB1_%s\\n" OK\n' }))
|
|
310
|
+
await s.waitFor(() => /TAB1_OK/.test(s.state.text), 10000, 'tab-1 回显')
|
|
311
|
+
s.client.send(JSON.stringify({ t: 'input', sid: 'tab-2', d: 'printf "TAB2_%s\\n" OK\n' }))
|
|
312
|
+
await s.waitFor(() => /TAB2_OK/.test(s.state.text), 10000, 'tab-2 回显')
|
|
313
|
+
s.client.send(JSON.stringify({ t: 'kill', sid: 'tab-1' }))
|
|
314
|
+
await s.waitFor(() => s.state.exited !== null && s.state.exited.sid === 'tab-1', 10000, 'tab-1 exit(带 sid)')
|
|
315
|
+
s.client.send(JSON.stringify({ t: 'input', sid: 'tab-2', d: 'printf "TAB2_%s\\n" STILL\n' }))
|
|
316
|
+
await s.waitFor(() => /TAB2_STILL/.test(s.state.text), 10000, 'tab-2 仍可用')
|
|
317
|
+
// 竞态回归:对已删除 sid 发 input/resize 应静默忽略(无错误帧)
|
|
318
|
+
const errorsBefore = s.state.errors.length
|
|
319
|
+
s.client.send(JSON.stringify({ t: 'input', sid: 'tab-1', d: 'echo ghost\n' }))
|
|
320
|
+
s.client.send(JSON.stringify({ t: 'resize', sid: 'tab-1', cols: 90, rows: 20 }))
|
|
321
|
+
await sleep(600)
|
|
322
|
+
if (s.state.errors.length === errorsBefore) pass('B7b 已删除 sid 的 input/resize 静默忽略(无错误帧)')
|
|
323
|
+
else fail('B7b 已删除 sid 的 input/resize 静默忽略(无错误帧)', JSON.stringify(s.state.errors.slice(errorsBefore)))
|
|
324
|
+
pass('B7 单连接多会话:双 tab 共存、数据隔离、单独 kill 后另一 tab 存活')
|
|
325
|
+
s.client.send(JSON.stringify({ t: 'kill', sid: 'tab-2' }))
|
|
326
|
+
await s.waitFor(() => s.state.exited !== null && s.state.exited.sid === 'tab-2', 10000, 'tab-2 exit')
|
|
327
|
+
s.client.close()
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// B8: cwd 跟随与校验
|
|
331
|
+
console.log('\n[7] cwd 跟随与校验')
|
|
332
|
+
{
|
|
333
|
+
const s = openSession(port)
|
|
334
|
+
await s.open()
|
|
335
|
+
s.client.send(JSON.stringify({ t: 'spawn', sid: 'cwd-tab', cwd: '/tmp' }))
|
|
336
|
+
await s.waitFor(() => s.state.ready, 10000, 'ready')
|
|
337
|
+
s.client.send(JSON.stringify({ t: 'input', sid: 'cwd-tab', d: 'pwd\n' }))
|
|
338
|
+
await s.waitFor(() => s.state.text.includes('/tmp'), 10000, 'pwd 输出')
|
|
339
|
+
pass('B8 spawn 携带 cwd 生效(pwd=/tmp)')
|
|
340
|
+
s.client.send(JSON.stringify({ t: 'spawn', sid: 'bad-cwd', cwd: '/nonexistent-dir-xyz' }))
|
|
341
|
+
await s.waitFor(() => s.state.errors.some((m) => /cwd 不存在/.test(m)), 10000, 'cwd 错误')
|
|
342
|
+
pass('B8b 不存在的 cwd 被拒')
|
|
343
|
+
s.client.send(JSON.stringify({ t: 'kill', sid: 'cwd-tab' }))
|
|
344
|
+
await s.waitFor(() => s.state.exited !== null, 10000, 'exit')
|
|
345
|
+
s.client.close()
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// B9: 配置热生效(settings/updated → LiveConfig / maxSessions)
|
|
349
|
+
console.log('\n[8] 配置热生效')
|
|
350
|
+
{
|
|
351
|
+
const s = openSession(port)
|
|
352
|
+
await s.open()
|
|
353
|
+
emitSettingsUpdated('tty', { shell: '', term: 'xterm-256color', colorTerm: 'truecolor', cwd: '/tmp', maxSessions: 1 })
|
|
354
|
+
await sleep(300)
|
|
355
|
+
s.client.send(JSON.stringify({ t: 'spawn', sid: 'hot' })) // 不带 cwd → 应落到热改后的 /tmp
|
|
356
|
+
await s.waitFor(() => s.state.ready, 10000, 'ready')
|
|
357
|
+
s.client.send(JSON.stringify({ t: 'input', sid: 'hot', d: 'pwd\n' }))
|
|
358
|
+
await s.waitFor(() => s.state.text.includes('/tmp'), 10000, 'pwd=/tmp')
|
|
359
|
+
pass('B9 配置热生效:cwd 动态更新后新会话生效(pwd=/tmp)')
|
|
360
|
+
const s2 = openSession(port)
|
|
361
|
+
await s2.open()
|
|
362
|
+
s2.client.send(JSON.stringify({ t: 'spawn', sid: 's2' }))
|
|
363
|
+
await s2.waitFor(() => s2.state.errors.length > 0, 10000, 's2 上限错误')
|
|
364
|
+
if (/会话数已达上限/.test(s2.state.errors[0])) pass('B9b maxSessions 热改生效(上限降为 1)')
|
|
365
|
+
else fail('B9b maxSessions 热改生效(上限降为 1)', s2.state.errors[0])
|
|
366
|
+
s2.client.close()
|
|
367
|
+
s.client.send(JSON.stringify({ t: 'kill', sid: 'hot' }))
|
|
368
|
+
await s.waitFor(() => s.state.exited !== null, 10000, 'exit')
|
|
369
|
+
s.client.close()
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// B10: /api/dsh-tty/config 读写 API
|
|
373
|
+
console.log('\n[9] 配置 API')
|
|
374
|
+
{
|
|
375
|
+
const base = `http://127.0.0.1:${port}/api/dsh-tty/config`
|
|
376
|
+
const res1 = await fetch(base)
|
|
377
|
+
const d1 = await res1.json().catch(() => ({}))
|
|
378
|
+
if (res1.status === 200 && d1.ok === true && typeof d1.config?.shell === 'string') pass('B10a GET 当前配置')
|
|
379
|
+
else fail('B10a GET 当前配置', res1.status + ' ' + JSON.stringify(d1))
|
|
380
|
+
const res2 = await fetch(base, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ maxSessions: 3, term: 'xterm-256color' }) })
|
|
381
|
+
const d2 = await res2.json().catch(() => ({}))
|
|
382
|
+
if (res2.status === 200 && d2.ok === true && d2.config?.maxSessions === 3) pass('B10b POST 配置生效(maxSessions=3)')
|
|
383
|
+
else fail('B10b POST 配置生效(maxSessions=3)', res2.status + ' ' + JSON.stringify(d2))
|
|
384
|
+
const res3 = await fetch(base, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ maxSessions: 99 }) })
|
|
385
|
+
const d3 = await res3.json().catch(() => ({}))
|
|
386
|
+
if (res3.status === 400 && /maxSessions/.test(String(d3.error ?? ''))) pass('B10c 非法 maxSessions 被拒')
|
|
387
|
+
else fail('B10c 非法 maxSessions 被拒', res3.status + ' ' + JSON.stringify(d3))
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// B11: 超限被拒后的恢复(用户场景:kill 一个会话后新 spawn 必须成功)
|
|
391
|
+
console.log('\n[10] 超限恢复')
|
|
392
|
+
{
|
|
393
|
+
const base = `http://127.0.0.1:${port}/api/dsh-tty/config`
|
|
394
|
+
await fetch(base, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ maxSessions: 2 }) })
|
|
395
|
+
await sleep(200)
|
|
396
|
+
const s1 = openSession(port)
|
|
397
|
+
await s1.open()
|
|
398
|
+
s1.client.send(JSON.stringify({ t: 'spawn', sid: 'x1' }))
|
|
399
|
+
await s1.waitFor(() => s1.state.ready, 10000, 'x1 ready')
|
|
400
|
+
const s2 = openSession(port)
|
|
401
|
+
await s2.open()
|
|
402
|
+
s2.client.send(JSON.stringify({ t: 'spawn', sid: 'x2' }))
|
|
403
|
+
await s2.waitFor(() => s2.state.ready, 10000, 'x2 ready')
|
|
404
|
+
const s3 = openSession(port)
|
|
405
|
+
await s3.open()
|
|
406
|
+
s3.client.send(JSON.stringify({ t: 'spawn', sid: 'x3' }))
|
|
407
|
+
await s3.waitFor(() => s3.state.errors.length > 0, 10000, 'x3 被拒')
|
|
408
|
+
if (/会话数已达上限/.test(s3.state.errors[0])) pass('B11a 超限拒绝(cap=2 第三个被拒)')
|
|
409
|
+
else fail('B11a 超限拒绝(cap=2 第三个被拒)', s3.state.errors[0])
|
|
410
|
+
s1.client.send(JSON.stringify({ t: 'kill', sid: 'x1' }))
|
|
411
|
+
await s1.waitFor(() => s1.state.exited !== null, 10000, 'x1 exit')
|
|
412
|
+
s3.client.send(JSON.stringify({ t: 'spawn', sid: 'x3b' }))
|
|
413
|
+
await s3.waitFor(() => s3.state.ready, 10000, 'x3b ready')
|
|
414
|
+
pass('B11b 释放名额后新 spawn 成功')
|
|
415
|
+
s2.client.send(JSON.stringify({ t: 'kill', sid: 'x2' }))
|
|
416
|
+
await s2.waitFor(() => s2.state.exited !== null, 10000, 'x2 exit')
|
|
417
|
+
s3.client.send(JSON.stringify({ t: 'kill', sid: 'x3b' }))
|
|
418
|
+
await s3.waitFor(() => s3.state.exited !== null, 10000, 'x3b exit')
|
|
419
|
+
s1.client.close()
|
|
420
|
+
s2.client.close()
|
|
421
|
+
s3.client.close()
|
|
422
|
+
await fetch(base, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ maxSessions: 4 }) })
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// B12: agent 工具集(tty_list / tty_capture / tty_send)
|
|
426
|
+
console.log('\n[11] agent 工具集')
|
|
427
|
+
{
|
|
428
|
+
const s = openSession(port)
|
|
429
|
+
await s.open()
|
|
430
|
+
s.client.send(JSON.stringify({ t: 'spawn', sid: 't12' }))
|
|
431
|
+
await s.waitFor(() => s.state.ready, 10000, 't12 ready')
|
|
432
|
+
const list = toolDefs.find((d) => d.name === 'tty_list')
|
|
433
|
+
const capture = toolDefs.find((d) => d.name === 'tty_capture')
|
|
434
|
+
const send = toolDefs.find((d) => d.name === 'tty_send')
|
|
435
|
+
if (list === undefined || capture === undefined || send === undefined) {
|
|
436
|
+
fail('B12 agent 工具集', '工具未注册: ' + toolDefs.map((d) => d.name).join(','))
|
|
437
|
+
} else {
|
|
438
|
+
// 真实宿主同样的校验(注册时 assertSupportedJsonSchema)
|
|
439
|
+
let schemaOk = true
|
|
440
|
+
for (const d of toolDefs.filter((x) => x.name.startsWith('tty_'))) {
|
|
441
|
+
try {
|
|
442
|
+
assertSupportedJsonSchema(d.parameters)
|
|
443
|
+
assertSupportedJsonSchema(d.output?.schema ?? {})
|
|
444
|
+
} catch (e) {
|
|
445
|
+
schemaOk = false
|
|
446
|
+
fail('B12e 工具 schema 通过 DSH 校验', d.name + ': ' + String(e && e.message ? e.message : e))
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
if (schemaOk) pass('B12e 工具 schema 通过 DSH 校验(assertSupportedJsonSchema)')
|
|
450
|
+
const listed = await list.execute({})
|
|
451
|
+
if (Array.isArray(listed.sessions) && listed.sessions.some((x) => x.sid === 't12')) pass('B12a tty_list 列出活跃会话')
|
|
452
|
+
else fail('B12a tty_list 列出活跃会话', JSON.stringify(listed))
|
|
453
|
+
const sent = await send.execute({ sid: 't12', data: 'printf "CAPTURE_%s\\n" OK\n' })
|
|
454
|
+
if (sent.ok === true && sent.sent > 0) pass('B12b tty_send 发送输入')
|
|
455
|
+
else fail('B12b tty_send 发送输入', JSON.stringify(sent))
|
|
456
|
+
await sleep(600)
|
|
457
|
+
// shell 执行有延迟(.zshrc 启动),轮询等待标记出现在输出缓冲
|
|
458
|
+
let captured = null
|
|
459
|
+
for (let i = 0; i < 16; i++) {
|
|
460
|
+
await sleep(250)
|
|
461
|
+
captured = await capture.execute({ sid: 't12', lines: 20 })
|
|
462
|
+
if (typeof captured.tail === 'string' && captured.tail.includes('CAPTURE_OK')) break
|
|
463
|
+
}
|
|
464
|
+
if (captured !== null && typeof captured.tail === 'string' && captured.tail.includes('CAPTURE_OK')) pass('B12c tty_capture 读到会话输出')
|
|
465
|
+
else fail('B12c tty_capture 读到会话输出', JSON.stringify(captured === null ? null : captured.tail).slice(0, 120))
|
|
466
|
+
let rejected = false
|
|
467
|
+
try {
|
|
468
|
+
await capture.execute({ sid: 'ghost-session' })
|
|
469
|
+
} catch {
|
|
470
|
+
rejected = true
|
|
471
|
+
}
|
|
472
|
+
if (rejected) pass('B12d 不存在的 sid 调用工具被拒')
|
|
473
|
+
else fail('B12d 不存在的 sid 调用工具被拒', '未抛错')
|
|
474
|
+
}
|
|
475
|
+
s.client.send(JSON.stringify({ t: 'kill', sid: 't12' }))
|
|
476
|
+
await s.waitFor(() => s.state.exited !== null, 10000, 't12 exit')
|
|
477
|
+
s.client.close()
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// B13: 断线保活与重连(sessions/attach 帧,协议 v3)
|
|
481
|
+
console.log('\n[12] 断线保活与重连')
|
|
482
|
+
{
|
|
483
|
+
const s1 = openSession(port)
|
|
484
|
+
await s1.open()
|
|
485
|
+
s1.client.send(JSON.stringify({ t: 'spawn', sid: 're-1' }))
|
|
486
|
+
await s1.waitFor(() => s1.state.ready, 10000, 're-1 ready')
|
|
487
|
+
s1.client.send(JSON.stringify({ t: 'input', sid: 're-1', d: 'printf "REPLAY_%s\\n" MARK\n' }))
|
|
488
|
+
await s1.waitFor(() => /REPLAY_MARK/.test(s1.state.text), 10000, '回放标记输出')
|
|
489
|
+
// 不 kill 直接断开 = 异常断开:会话应转孤儿保活(默认 reconnectGraceSec=120)
|
|
490
|
+
s1.client.close()
|
|
491
|
+
await sleep(400)
|
|
492
|
+
const s2 = openSession(port)
|
|
493
|
+
await s2.open()
|
|
494
|
+
s2.client.send(JSON.stringify({ t: 'sessions' }))
|
|
495
|
+
await s2.waitFor(() => s2.state.frames.some((f) => f.t === 'sessions'), 10000, 'sessions 帧')
|
|
496
|
+
const sessionsFrame = s2.state.frames.find((f) => f.t === 'sessions')
|
|
497
|
+
const entry = (sessionsFrame?.list ?? []).find((x) => x.sid === 're-1')
|
|
498
|
+
if (entry !== undefined && entry.attachable === true) pass('B13a 异常断开后会话保活且 attachable')
|
|
499
|
+
else fail('B13a 异常断开后会话保活且 attachable', JSON.stringify(sessionsFrame))
|
|
500
|
+
// attach → ready(reattached:true)→ data 帧回放断线前输出
|
|
501
|
+
s2.client.send(JSON.stringify({ t: 'attach', sid: 're-1' }))
|
|
502
|
+
await s2.waitFor(() => s2.state.frames.some((f) => f.t === 'ready' && f.sid === 're-1'), 10000, 'attach ready')
|
|
503
|
+
if (s2.state.frames.some((f) => f.t === 'ready' && f.sid === 're-1' && f.reattached === true)) pass('B13b attach 成功(ready.reattached)')
|
|
504
|
+
else fail('B13b attach 成功(ready.reattached)', '缺 reattached 标记')
|
|
505
|
+
await s2.waitFor(() => /REPLAY_MARK/.test(s2.state.text), 10000, '缓冲回放')
|
|
506
|
+
pass('B13c attach 后回放断线前输出(REPLAY_MARK)')
|
|
507
|
+
s2.client.send(JSON.stringify({ t: 'input', sid: 're-1', d: 'printf "AFTER_%s\\n" ATTACH\n' }))
|
|
508
|
+
await s2.waitFor(() => /AFTER_ATTACH/.test(s2.state.text), 10000, 'attach 后输入')
|
|
509
|
+
pass('B13d attach 后会话继续交互')
|
|
510
|
+
// 已连接会话的重复 attach 被拒(防多窗口抢绑)
|
|
511
|
+
const errorsBefore = s2.state.errors.length
|
|
512
|
+
s2.client.send(JSON.stringify({ t: 'attach', sid: 're-1' }))
|
|
513
|
+
await s2.waitFor(() => s2.state.errors.length > errorsBefore, 10000, '重复 attach 错误')
|
|
514
|
+
if (/已连接/.test(s2.state.errors[s2.state.errors.length - 1])) pass('B13e 已连接会话的重复 attach 被拒')
|
|
515
|
+
else fail('B13e 已连接会话的重复 attach 被拒', s2.state.errors[s2.state.errors.length - 1])
|
|
516
|
+
s2.client.send(JSON.stringify({ t: 'kill', sid: 're-1' }))
|
|
517
|
+
await s2.waitFor(() => s2.state.exited !== null && s2.state.exited.sid === 're-1', 10000, 're-1 exit')
|
|
518
|
+
s2.client.close()
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// B14: tty_screen 虚拟屏 + tty_capture ANSI 清洗
|
|
522
|
+
console.log('\n[13] tty_screen 与 tty_capture 清洗')
|
|
523
|
+
{
|
|
524
|
+
const s = openSession(port)
|
|
525
|
+
await s.open()
|
|
526
|
+
s.client.send(JSON.stringify({ t: 'spawn', sid: 'screen-1' }))
|
|
527
|
+
await s.waitFor(() => s.state.ready, 10000, 'screen-1 ready')
|
|
528
|
+
const screenTool = toolDefs.find((d) => d.name === 'tty_screen')
|
|
529
|
+
const capture = toolDefs.find((d) => d.name === 'tty_capture')
|
|
530
|
+
if (screenTool === undefined || capture === undefined) {
|
|
531
|
+
fail('B14 工具注册', '缺工具: ' + toolDefs.map((d) => d.name).join(','))
|
|
532
|
+
} else {
|
|
533
|
+
s.client.send(JSON.stringify({ t: 'input', sid: 'screen-1', d: 'printf "SCREEN_%s\\n" OK\n' }))
|
|
534
|
+
await s.waitFor(() => /SCREEN_OK/.test(s.state.text), 10000, '屏幕标记输出')
|
|
535
|
+
// tty_screen:虚拟屏渲染的可见屏幕应包含标记(等价用户此刻看到的画面)
|
|
536
|
+
let shot = null
|
|
537
|
+
for (let i = 0; i < 16; i++) {
|
|
538
|
+
await sleep(250)
|
|
539
|
+
shot = await screenTool.execute({ sid: 'screen-1' })
|
|
540
|
+
if (typeof shot?.text === 'string' && shot.text.includes('SCREEN_OK')) break
|
|
541
|
+
}
|
|
542
|
+
if (shot !== null && typeof shot.text === 'string' && shot.text.includes('SCREEN_OK')) pass('B14a tty_screen 虚拟屏渲染可见屏幕(SCREEN_OK)')
|
|
543
|
+
else fail('B14a tty_screen 虚拟屏渲染可见屏幕(SCREEN_OK)', JSON.stringify(shot === null ? null : shot.text).slice(0, 120))
|
|
544
|
+
// tty_capture:默认清洗后尾部应为纯文本(无 ESC 转义)且包含标记
|
|
545
|
+
let cap = null
|
|
546
|
+
for (let i = 0; i < 16; i++) {
|
|
547
|
+
await sleep(250)
|
|
548
|
+
cap = await capture.execute({ sid: 'screen-1', lines: 80 })
|
|
549
|
+
if (typeof cap.tail === 'string' && cap.tail.includes('SCREEN_OK')) break
|
|
550
|
+
}
|
|
551
|
+
const cleanOk = cap !== null && typeof cap.tail === 'string' && cap.tail.includes('SCREEN_OK') && !/\x1b/.test(cap.tail)
|
|
552
|
+
if (cleanOk) pass('B14b tty_capture 默认清洗 ANSI(尾部为纯文本且含标记)')
|
|
553
|
+
else fail('B14b tty_capture 默认清洗 ANSI(尾部为纯文本且含标记)', JSON.stringify(cap === null ? null : cap.tail).slice(0, 120))
|
|
554
|
+
}
|
|
555
|
+
s.client.send(JSON.stringify({ t: 'kill', sid: 'screen-1' }))
|
|
556
|
+
await s.waitFor(() => s.state.exited !== null, 10000, 'screen-1 exit')
|
|
557
|
+
s.client.close()
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// B15: shell 集成(OSC 133/7)—— 命令捕获 + 退出码 + cwd 跟随(真实 zsh)
|
|
561
|
+
console.log('\n[14] shell 集成(OSC 133/7)')
|
|
562
|
+
{
|
|
563
|
+
const s = openSession(port)
|
|
564
|
+
await s.open()
|
|
565
|
+
s.client.send(JSON.stringify({ t: 'spawn', sid: 'si-1' }))
|
|
566
|
+
await s.waitFor(() => s.state.ready, 10000, 'si-1 ready')
|
|
567
|
+
const capture = toolDefs.find((d) => d.name === 'tty_capture')
|
|
568
|
+
const list = toolDefs.find((d) => d.name === 'tty_list')
|
|
569
|
+
const expect = toolDefs.find((d) => d.name === 'tty_expect')
|
|
570
|
+
if (capture === undefined || list === undefined || expect === undefined) {
|
|
571
|
+
fail('B15 工具注册', '缺工具: ' + toolDefs.map((d) => d.name).join(','))
|
|
572
|
+
} else {
|
|
573
|
+
s.client.send(JSON.stringify({ t: 'input', sid: 'si-1', d: 'printf "SI_%s\\n" OK\n' }))
|
|
574
|
+
await s.waitFor(() => /SI_OK/.test(s.state.text), 10000, 'SI_OK 输出')
|
|
575
|
+
await sleep(600) // 等 D 标记(precmd 在下一 prompt 前发出)
|
|
576
|
+
let last = null
|
|
577
|
+
for (let i = 0; i < 12; i++) {
|
|
578
|
+
await sleep(250)
|
|
579
|
+
try {
|
|
580
|
+
last = await capture.execute({ sid: 'si-1', last: true })
|
|
581
|
+
} catch (error) {
|
|
582
|
+
last = { error: error.message }
|
|
583
|
+
}
|
|
584
|
+
if (last !== null && last.source === 'last') break
|
|
585
|
+
}
|
|
586
|
+
if (last !== null && last.source === 'last' && String(last.tail).includes('SI_OK') && last.exitCode === 0) pass('B15a tty_capture{last:true} 拿到命令输出 + exitCode=0')
|
|
587
|
+
else fail('B15a tty_capture{last:true} 拿到命令输出 + exitCode=0', JSON.stringify(last).slice(0, 160))
|
|
588
|
+
// OSC 7:cd 之后 tty_list 的 cwd 跟随
|
|
589
|
+
s.client.send(JSON.stringify({ t: 'input', sid: 'si-1', d: 'cd /tmp\n' }))
|
|
590
|
+
let cwdOk = false
|
|
591
|
+
for (let i = 0; i < 16 && !cwdOk; i++) {
|
|
592
|
+
await sleep(300)
|
|
593
|
+
const listed = await list.execute({})
|
|
594
|
+
const entry = listed.sessions.find((x) => x.sid === 'si-1')
|
|
595
|
+
cwdOk = entry !== undefined && entry.cwd === '/tmp'
|
|
596
|
+
}
|
|
597
|
+
if (cwdOk) pass('B15b OSC 7 cwd 上报生效(tty_list 跟随 cd /tmp)')
|
|
598
|
+
else fail('B15b OSC 7 cwd 上报生效(tty_list 跟随 cd /tmp)', 'cwd 未更新')
|
|
599
|
+
}
|
|
600
|
+
s.client.send(JSON.stringify({ t: 'kill', sid: 'si-1' }))
|
|
601
|
+
await s.waitFor(() => s.state.exited !== null, 10000, 'si-1 exit')
|
|
602
|
+
s.client.close()
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// B16: ~/.ssh/config 解析器(纯函数,不读文件系统)
|
|
606
|
+
console.log('\n[15] ssh-config 解析器')
|
|
607
|
+
{
|
|
608
|
+
const sample = [
|
|
609
|
+
'# comment',
|
|
610
|
+
'Host web1',
|
|
611
|
+
' HostName web1.example.com',
|
|
612
|
+
' User alice',
|
|
613
|
+
' Port 2222',
|
|
614
|
+
' IdentityFile ~/.ssh/id_ed25519',
|
|
615
|
+
'',
|
|
616
|
+
'Host = db1',
|
|
617
|
+
' hostname = db1.internal # inline comment',
|
|
618
|
+
' user = bob',
|
|
619
|
+
'',
|
|
620
|
+
'Host *',
|
|
621
|
+
' Compression yes',
|
|
622
|
+
'',
|
|
623
|
+
'Host nouser',
|
|
624
|
+
' HostName nowhere',
|
|
625
|
+
].join('\n')
|
|
626
|
+
const entries = parseSshConfig(sample)
|
|
627
|
+
const web1 = entries.find((e) => e.name === 'web1')
|
|
628
|
+
const db1 = entries.find((e) => e.name === 'db1')
|
|
629
|
+
const ok = entries.length === 2
|
|
630
|
+
&& web1 !== undefined && web1.host === 'web1.example.com' && web1.username === 'alice'
|
|
631
|
+
&& web1.port === 2222 && web1.auth === 'key' && web1.keyPath === '~/.ssh/id_ed25519'
|
|
632
|
+
&& db1 !== undefined && db1.host === 'db1.internal' && db1.username === 'bob' && db1.auth === 'agent'
|
|
633
|
+
&& db1.port === 22
|
|
634
|
+
if (ok) pass('B16 ssh-config 解析(别名/=/行内注释/IdentityFile→key/通配与无User跳过)')
|
|
635
|
+
else fail('B16 ssh-config 解析(别名/=/行内注释/IdentityFile→key/通配与无User跳过)', JSON.stringify(entries))
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// B17: tty_expect —— 匹配与超时两分支
|
|
639
|
+
console.log('\n[16] tty_expect')
|
|
640
|
+
{
|
|
641
|
+
const s = openSession(port)
|
|
642
|
+
await s.open()
|
|
643
|
+
s.client.send(JSON.stringify({ t: 'spawn', sid: 'ex-1' }))
|
|
644
|
+
await s.waitFor(() => s.state.ready, 10000, 'ex-1 ready')
|
|
645
|
+
const expect = toolDefs.find((d) => d.name === 'tty_expect')
|
|
646
|
+
if (expect === undefined) {
|
|
647
|
+
fail('B17 tty_expect 注册', '未找到 tty_expect')
|
|
648
|
+
} else {
|
|
649
|
+
// 先注册等待再发命令(工具内部同步挂 output 监听,无竞态窗口)
|
|
650
|
+
const pendingOk = expect.execute({ sid: 'ex-1', pattern: 'EXPECT_HIT', timeoutSec: 15 })
|
|
651
|
+
await sleep(300)
|
|
652
|
+
s.client.send(JSON.stringify({ t: 'input', sid: 'ex-1', d: 'sleep 1; printf "EXPECT_%s\\n" HIT\n' }))
|
|
653
|
+
const okResult = await pendingOk
|
|
654
|
+
if (okResult.matched === true && okResult.timedOut === false && /EXPECT_HIT/.test(okResult.text)) pass('B17a tty_expect 等到就绪标记(matched=true)')
|
|
655
|
+
else fail('B17a tty_expect 等到就绪标记(matched=true)', JSON.stringify(okResult).slice(0, 160))
|
|
656
|
+
await sleep(800) // 等上一条命令的 D 标记与 prompt 落定,走纯超时分支
|
|
657
|
+
const missResult = await expect.execute({ sid: 'ex-1', pattern: 'NEVER_APPEARS_XYZ', timeoutSec: 1 })
|
|
658
|
+
if (missResult.matched === false && missResult.timedOut === true && typeof missResult.text === 'string') pass('B17b tty_expect 超时不抛错(matched=false, timedOut=true)')
|
|
659
|
+
else fail('B17b tty_expect 超时不抛错(matched=false, timedOut=true)', JSON.stringify(missResult).slice(0, 160))
|
|
660
|
+
}
|
|
661
|
+
s.client.send(JSON.stringify({ t: 'kill', sid: 'ex-1' }))
|
|
662
|
+
await s.waitFor(() => s.state.exited !== null, 10000, 'ex-1 exit')
|
|
663
|
+
s.client.close()
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// B18: data 帧合并与帧序不变量(exit 永远在最后一帧 data 之后)
|
|
667
|
+
console.log('\n[17] data 帧合并与帧序')
|
|
668
|
+
{
|
|
669
|
+
const s = openSession(port)
|
|
670
|
+
await s.open()
|
|
671
|
+
console.log(' [B18] spawning…')
|
|
672
|
+
s.client.send(JSON.stringify({ t: 'spawn', sid: 'co-1' }))
|
|
673
|
+
await s.waitFor(() => s.state.ready, 10000, 'co-1 ready')
|
|
674
|
+
console.log(' [B18] ready ✓')
|
|
675
|
+
s.client.send(JSON.stringify({ t: 'input', sid: 'co-1', d: 'printf "COARSE_%s\\n" OK\n' }))
|
|
676
|
+
console.log(' [B18] input sent')
|
|
677
|
+
await s.waitFor(() => /COARSE_OK/.test(s.state.text), 10000, 'COARSE_OK 输出')
|
|
678
|
+
console.log(' [B18] COARSE_OK ✓')
|
|
679
|
+
await sleep(300)
|
|
680
|
+
console.log(' [B18] killing…')
|
|
681
|
+
s.client.send(JSON.stringify({ t: 'kill', sid: 'co-1' }))
|
|
682
|
+
await s.waitFor(() => s.state.exited !== null && s.state.exited.sid === 'co-1', 10000, 'co-1 exit')
|
|
683
|
+
console.log(' [B18] exit ✓')
|
|
684
|
+
const exitIndex = s.state.frames.findIndex((f) => f.t === 'exit' && f.sid === 'co-1')
|
|
685
|
+
const lastDataIndex = s.state.frames.reduce((acc, f, i) => (f.t === 'data' && f.sid === 'co-1' ? i : acc), -1)
|
|
686
|
+
if (exitIndex > lastDataIndex && /COARSE_OK/.test(s.state.text)) pass('B18 帧合并后帧序不变量(exit 在最后一帧 data 之后,输出完整)')
|
|
687
|
+
else fail('B18 帧合并后帧序不变量(exit 在最后一帧 data 之后,输出完整)', `exitIndex=${exitIndex} lastDataIndex=${lastDataIndex}`)
|
|
688
|
+
s.client.close()
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// B19: known_hosts 解析器(非 hashed / [host]:port / 通配跳过 / @marker 跳过 / hashed 候选还原)
|
|
692
|
+
console.log('\n[18] known_hosts 解析器')
|
|
693
|
+
{
|
|
694
|
+
const { createHash, createHmac, randomBytes } = await import('node:crypto')
|
|
695
|
+
const blob1 = Buffer.from('web1-host-key-bytes', 'utf8').toString('base64')
|
|
696
|
+
const blob2 = Buffer.from('db1-host-key-bytes', 'utf8').toString('base64')
|
|
697
|
+
const blob3 = Buffer.from('hashed-host-key-bytes', 'utf8').toString('base64')
|
|
698
|
+
const salt = randomBytes(20)
|
|
699
|
+
const hmac = createHmac('sha1', salt).update('web1.example.com').digest('base64')
|
|
700
|
+
const sample = [
|
|
701
|
+
'# comment',
|
|
702
|
+
'@cert-authority *.example.com ssh-ed25519 ' + blob1,
|
|
703
|
+
'web1.example.com,alias ssh-ed25519 ' + blob1,
|
|
704
|
+
'[db1.internal]:2222 ssh-rsa ' + blob2,
|
|
705
|
+
'*.wildcard ssh-ed25519 ' + blob1,
|
|
706
|
+
'|1|' + salt.toString('base64') + '|' + hmac + ' ssh-ed25519 ' + blob3,
|
|
707
|
+
].join('\n')
|
|
708
|
+
const entries = parseKnownHosts(sample, ['web1.example.com'])
|
|
709
|
+
const fp1 = createHash('sha256').update(Buffer.from('web1-host-key-bytes')).digest('hex')
|
|
710
|
+
const fp2 = createHash('sha256').update(Buffer.from('db1-host-key-bytes')).digest('hex')
|
|
711
|
+
const web1 = entries.find((e) => e.host === 'web1.example.com')
|
|
712
|
+
const alias = entries.find((e) => e.host === 'alias')
|
|
713
|
+
const db1 = entries.find((e) => e.host === 'db1.internal')
|
|
714
|
+
const hashedReplayed = entries.filter((e) => e.host === 'web1.example.com').length
|
|
715
|
+
// 0.19.0 起解析结果按 host:port 聚合、指纹进 fingerprints 集合(一机多把钥匙)
|
|
716
|
+
// 0.19.0 起解析结果按 host:port 聚合、指纹进 fingerprints 集合(一机多把钥匙):
|
|
717
|
+
// hashed 条目还原出的 web1 指纹并入 web1 记录(不再「只留首条」被丢弃)
|
|
718
|
+
const fp3 = createHash('sha256').update(Buffer.from('hashed-host-key-bytes')).digest('hex')
|
|
719
|
+
const ok = entries.length === 3
|
|
720
|
+
&& web1 !== undefined && web1.port === 22 && Array.isArray(web1.fingerprints) && web1.fingerprints.length === 2 && web1.fingerprints[0] === fp1 && web1.fingerprints[1] === fp3
|
|
721
|
+
&& alias !== undefined && alias.port === 22 && Array.isArray(alias.fingerprints) && alias.fingerprints.length === 1 && alias.fingerprints[0] === fp1
|
|
722
|
+
&& db1 !== undefined && db1.port === 2222 && Array.isArray(db1.fingerprints) && db1.fingerprints.length === 1 && db1.fingerprints[0] === fp2
|
|
723
|
+
&& hashedReplayed === 1
|
|
724
|
+
if (ok) pass('B19 known_hosts 解析(别名拆分/[host]:port/@marker与通配跳过/hashed 候选还原+去重)')
|
|
725
|
+
else fail('B19 known_hosts 解析(别名拆分/[host]:port/@marker与通配跳过/hashed 候选还原+去重)', JSON.stringify(entries))
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// B20: 端口转发隧道(内存 SSH 服务桥接 + 双隧道 + 本地转发往返)
|
|
729
|
+
console.log('\n[19] 端口转发隧道')
|
|
730
|
+
{
|
|
731
|
+
const ssh2mod = await import('ssh2')
|
|
732
|
+
const ssh2 = ssh2mod.default ?? ssh2mod
|
|
733
|
+
const netMod = await import('node:net')
|
|
734
|
+
const net = netMod.default ?? netMod
|
|
735
|
+
const { generateKeyPairSync } = await import('node:crypto')
|
|
736
|
+
const mkEcho = () => new Promise((resolve) => {
|
|
737
|
+
const server = net.createServer((socket) => socket.on('data', (d) => socket.write(d)))
|
|
738
|
+
server.listen(0, '127.0.0.1', () => resolve({ server, port: server.address().port }))
|
|
739
|
+
})
|
|
740
|
+
const targetA = await mkEcho() // local 方向的「远程服务」
|
|
741
|
+
const targetB = await mkEcho() // remote 方向的「本地服务」
|
|
742
|
+
const freePort = () => new Promise((resolve, reject) => {
|
|
743
|
+
const probe = net.createServer()
|
|
744
|
+
probe.on('error', reject)
|
|
745
|
+
probe.listen(0, '127.0.0.1', () => {
|
|
746
|
+
const p = probe.address().port
|
|
747
|
+
probe.close(() => resolve(p))
|
|
748
|
+
})
|
|
749
|
+
})
|
|
750
|
+
const tunnelLocalPort = await freePort()
|
|
751
|
+
const remoteBindPort = await freePort()
|
|
752
|
+
const { privateKey } = generateKeyPairSync('rsa', {
|
|
753
|
+
modulusLength: 2048,
|
|
754
|
+
publicKeyEncoding: { type: 'spki', format: 'pem' },
|
|
755
|
+
privateKeyEncoding: { type: 'pkcs1', format: 'pem' },
|
|
756
|
+
})
|
|
757
|
+
const sshServer = new ssh2.Server({ hostKeys: [privateKey] }, (client) => {
|
|
758
|
+
client.on('authentication', (ctx) => {
|
|
759
|
+
if (ctx.method === 'password' && ctx.username === 'test' && ctx.password === 'secret') ctx.accept()
|
|
760
|
+
else ctx.reject()
|
|
761
|
+
})
|
|
762
|
+
client.on('request', (accept, reject, name) => {
|
|
763
|
+
if (name === 'tcpip-forward' || name === 'cancel-tcpip-forward') accept()
|
|
764
|
+
else reject()
|
|
765
|
+
})
|
|
766
|
+
client.on('tcpip', (accept, reject, info) => {
|
|
767
|
+
const stream = accept()
|
|
768
|
+
const upstream = net.connect(info.destPort, info.destIP, () => {
|
|
769
|
+
stream.pipe(upstream)
|
|
770
|
+
upstream.pipe(stream)
|
|
771
|
+
})
|
|
772
|
+
upstream.on('error', () => stream.end())
|
|
773
|
+
stream.on('error', () => upstream.end())
|
|
774
|
+
})
|
|
775
|
+
client.on('error', () => {})
|
|
776
|
+
})
|
|
777
|
+
await new Promise((resolve, reject) => {
|
|
778
|
+
sshServer.once('error', reject)
|
|
779
|
+
sshServer.listen(0, '127.0.0.1', () => {
|
|
780
|
+
sshServer.removeListener('error', reject)
|
|
781
|
+
resolve()
|
|
782
|
+
})
|
|
783
|
+
})
|
|
784
|
+
const sshPort = sshServer.address().port
|
|
785
|
+
const post = async (body) => {
|
|
786
|
+
const res = await fetch(`http://127.0.0.1:${port}/api/dsh-tty/config`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
|
|
787
|
+
return res.json()
|
|
788
|
+
}
|
|
789
|
+
const put = await post({
|
|
790
|
+
sshHosts: [{ name: 'it-ssh', host: '127.0.0.1', port: sshPort, username: 'test', auth: 'password', password: 'secret', keyPath: '', passphrase: '', agentForward: false }],
|
|
791
|
+
tunnels: [
|
|
792
|
+
{ name: 't-local', bookName: 'it-ssh', direction: 'local', localPort: tunnelLocalPort, remoteHost: '127.0.0.1', remotePort: targetA.port, enabled: true },
|
|
793
|
+
{ name: 't-remote', bookName: 'it-ssh', direction: 'remote', remotePort: remoteBindPort, localTargetPort: targetB.port, enabled: true },
|
|
794
|
+
],
|
|
795
|
+
})
|
|
796
|
+
if (put.ok === true && Array.isArray(put.config?.tunnels) && put.config.tunnels.length === 2) pass('B20a tunnels 配置热生效(POST → reconcile)')
|
|
797
|
+
else fail('B20a tunnels 配置热生效(POST → reconcile)', JSON.stringify(put).slice(0, 160))
|
|
798
|
+
let statuses = []
|
|
799
|
+
for (let i = 0; i < 30; i++) {
|
|
800
|
+
await sleep(400)
|
|
801
|
+
const r = await (await fetch(`http://127.0.0.1:${port}/api/dsh-tty/tunnels`)).json()
|
|
802
|
+
statuses = r.tunnels ?? []
|
|
803
|
+
if (statuses.length === 2 && statuses.every((t) => t.state === 'active')) break
|
|
804
|
+
}
|
|
805
|
+
if (statuses.length === 2 && statuses.every((t) => t.state === 'active')) pass('B20b 双隧道到达 active(forwardOut + forwardIn)')
|
|
806
|
+
else fail('B20b 双隧道到达 active(forwardOut + forwardIn)', JSON.stringify(statuses))
|
|
807
|
+
const roundtrip = await new Promise((resolve) => {
|
|
808
|
+
const socket = net.connect(tunnelLocalPort, '127.0.0.1', () => socket.write('PING_B20'))
|
|
809
|
+
let buf = ''
|
|
810
|
+
socket.on('data', (d) => {
|
|
811
|
+
buf += d.toString()
|
|
812
|
+
if (buf.includes('PING_B20')) {
|
|
813
|
+
socket.end()
|
|
814
|
+
resolve(buf)
|
|
815
|
+
}
|
|
816
|
+
})
|
|
817
|
+
socket.on('error', (e) => resolve('ERR:' + e.message))
|
|
818
|
+
setTimeout(() => resolve('TIMEOUT:' + buf), 6000)
|
|
819
|
+
})
|
|
820
|
+
if (typeof roundtrip === 'string' && roundtrip.includes('PING_B20')) pass('B20c 本地转发往返(TCP → forwardOut → 服务端桥 → echo 目标)')
|
|
821
|
+
else fail('B20c 本地转发往返(TCP → forwardOut → 服务端桥 → echo 目标)', String(roundtrip).slice(0, 120))
|
|
822
|
+
const tunnelList = toolDefs.find((d) => d.name === 'tunnel_list')
|
|
823
|
+
if (tunnelList === undefined) {
|
|
824
|
+
fail('B20d tunnel_list 注册', '未找到 tunnel_list')
|
|
825
|
+
} else {
|
|
826
|
+
const listed = await tunnelList.execute({})
|
|
827
|
+
const names = (listed.tunnels ?? []).map((t) => `${t.name}:${t.state}`).join(',')
|
|
828
|
+
if (Array.isArray(listed.tunnels) && listed.tunnels.some((t) => t.name === 't-local' && t.state === 'active')) pass('B20d tunnel_list 工具可见隧道状态(' + names + ')')
|
|
829
|
+
else fail('B20d tunnel_list 工具可见隧道状态', names)
|
|
830
|
+
}
|
|
831
|
+
await post({ tunnels: [] })
|
|
832
|
+
await sleep(400)
|
|
833
|
+
const after = await (await fetch(`http://127.0.0.1:${port}/api/dsh-tty/tunnels`)).json()
|
|
834
|
+
if ((after.tunnels ?? []).length === 0) pass('B20e tunnels 清空即停止(reconcile 移除)')
|
|
835
|
+
else fail('B20e tunnels 清空即停止(reconcile 移除)', JSON.stringify(after.tunnels))
|
|
836
|
+
targetA.server.close()
|
|
837
|
+
targetB.server.close()
|
|
838
|
+
await new Promise((resolve) => {
|
|
839
|
+
const timer = setTimeout(resolve, 1000).unref()
|
|
840
|
+
sshServer.close(() => {
|
|
841
|
+
clearTimeout(timer)
|
|
842
|
+
resolve()
|
|
843
|
+
})
|
|
844
|
+
})
|
|
845
|
+
sshServer.closeAllConnections?.()
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
// B21: /api/dsh-tty/shells 候选列表(设置卡片「Shell 路径」数据源)
|
|
849
|
+
console.log('\n[19] shells 候选路由')
|
|
850
|
+
{
|
|
851
|
+
const { existsSync } = await import('node:fs')
|
|
852
|
+
const data = await (await fetch(`http://127.0.0.1:${port}/api/dsh-tty/shells`)).json()
|
|
853
|
+
const shells = Array.isArray(data.shells) ? data.shells : []
|
|
854
|
+
const allAbsolute = shells.every((p) => typeof p === 'string' && p.startsWith('/'))
|
|
855
|
+
const hasCurrent = typeof process.env.SHELL !== 'string' || process.env.SHELL.trim() === '' || shells.includes(process.env.SHELL)
|
|
856
|
+
const hasBash = !existsSync('/bin/bash') || shells.includes('/bin/bash')
|
|
857
|
+
if (data.ok === true && shells.length > 0 && allAbsolute && hasCurrent && hasBash) pass('B21 shells 候选路由(/etc/shells + $SHELL 去重、存在且可执行、$SHELL 优先)')
|
|
858
|
+
else fail('B21 shells 候选路由(/etc/shells + $SHELL 去重、存在且可执行、$SHELL 优先)', JSON.stringify(data).slice(0, 200))
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
// B22: bash 3.2(macOS 自带,无 PS0)shell 集成 —— DEBUG trap 兜底 B 标记
|
|
862
|
+
// 注意:本用例把 config.shell 热切成 /bin/bash(内存 settings stub 不持久),
|
|
863
|
+
// 必须放在所有依赖默认 shell 的用例之后。
|
|
864
|
+
console.log('\n[20] bash 3.2 shell 集成(DEBUG trap 兜底)')
|
|
865
|
+
{
|
|
866
|
+
const { existsSync } = await import('node:fs')
|
|
867
|
+
if (!existsSync('/bin/bash')) {
|
|
868
|
+
console.log(' ⊘ SKIP B22(本机无 /bin/bash)')
|
|
869
|
+
} else {
|
|
870
|
+
await fetch(`http://127.0.0.1:${port}/api/dsh-tty/config`, {
|
|
871
|
+
method: 'POST',
|
|
872
|
+
headers: { 'content-type': 'application/json' },
|
|
873
|
+
body: JSON.stringify({ shell: '/bin/bash' }),
|
|
874
|
+
})
|
|
875
|
+
const s = openSession(port)
|
|
876
|
+
await s.open()
|
|
877
|
+
s.client.send(JSON.stringify({ t: 'spawn', sid: 'b32-1' }))
|
|
878
|
+
await s.waitFor(() => s.state.ready, 10000, 'b32-1 ready')
|
|
879
|
+
const capture = toolDefs.find((d) => d.name === 'tty_capture')
|
|
880
|
+
const expect = toolDefs.find((d) => d.name === 'tty_expect')
|
|
881
|
+
if (capture === undefined || expect === undefined) {
|
|
882
|
+
fail('B22 工具注册', '缺工具: ' + toolDefs.map((d) => d.name).join(','))
|
|
883
|
+
} else {
|
|
884
|
+
// a) DEBUG trap 补出 B 标记 → capture{last:true} 可用
|
|
885
|
+
s.client.send(JSON.stringify({ t: 'input', sid: 'b32-1', d: 'printf "B32_%s\\n" OK\n' }))
|
|
886
|
+
await s.waitFor(() => /B32_OK/.test(s.state.text), 10000, 'B32_OK 输出')
|
|
887
|
+
await sleep(600) // 等 D 标记(PROMPT_COMMAND 在下一 prompt 前发出)
|
|
888
|
+
let last = null
|
|
889
|
+
for (let i = 0; i < 12; i++) {
|
|
890
|
+
await sleep(250)
|
|
891
|
+
try {
|
|
892
|
+
last = await capture.execute({ sid: 'b32-1', last: true })
|
|
893
|
+
} catch (error) {
|
|
894
|
+
last = { error: error.message }
|
|
895
|
+
}
|
|
896
|
+
if (last !== null && last.source === 'last') break
|
|
897
|
+
}
|
|
898
|
+
if (last !== null && last.source === 'last' && String(last.tail).includes('B32_OK') && last.exitCode === 0) pass('B22a bash 3.2 tty_capture{last:true} 拿到命令输出 + exitCode=0(DEBUG trap 补 B 标记)')
|
|
899
|
+
else fail('B22a bash 3.2 tty_capture{last:true} 拿到命令输出 + exitCode=0(DEBUG trap 补 B 标记)', JSON.stringify(last).slice(0, 160))
|
|
900
|
+
// b) 命令在飞时注册 expect → 命令结束(D 标记)早停并带退出码
|
|
901
|
+
await sleep(600) // 等 B22a 的 prompt 落定,避免吃到上一条的 D
|
|
902
|
+
const startedAt = Date.now()
|
|
903
|
+
s.client.send(JSON.stringify({ t: 'input', sid: 'b32-1', d: 'sleep 2\n' }))
|
|
904
|
+
await sleep(500) // B 标记已到(inCommand=true)后再注册
|
|
905
|
+
const early = await expect.execute({ sid: 'b32-1', pattern: 'NEVER_APPEARS_B32', timeoutSec: 20 })
|
|
906
|
+
const elapsed = Date.now() - startedAt
|
|
907
|
+
if (early.matched === false && early.timedOut === false && early.exitCode === 0 && elapsed < 10000) pass(`B22b bash 3.2 tty_expect 命令结束早停(exitCode=0,${elapsed}ms << 20s 超时)`)
|
|
908
|
+
else fail('B22b bash 3.2 tty_expect 命令结束早停(exitCode=0)', JSON.stringify(early).slice(0, 160) + ` elapsed=${elapsed}ms`)
|
|
909
|
+
}
|
|
910
|
+
s.client.send(JSON.stringify({ t: 'kill', sid: 'b32-1' }))
|
|
911
|
+
await s.waitFor(() => s.state.exited !== null, 10000, 'b32-1 exit')
|
|
912
|
+
s.client.close()
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
// B23: SFTP 文件传输(0.7.0)—— /api/dsh-tty/sftp/* 路由 + sftp_* agent 工具
|
|
917
|
+
// 数据源是 scripts/lib/test-sshd.mjs 的内存 sshd(sftp subsystem 映射临时目录)。
|
|
918
|
+
console.log('\n[21] SFTP 文件传输(HTTP 路由 + agent 工具)')
|
|
919
|
+
{
|
|
920
|
+
const rootDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'dsh-tty-b23-'))
|
|
921
|
+
const sftpd = await startSftpSshd({ rootDir })
|
|
922
|
+
const post = async (body) => {
|
|
923
|
+
const res = await fetch(`http://127.0.0.1:${port}/api/dsh-tty/config`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
|
|
924
|
+
return res.json()
|
|
925
|
+
}
|
|
926
|
+
const put = await post({ sshHosts: [{ name: 'b23-ssh', host: '127.0.0.1', port: sftpd.port, username: TEST_USER, auth: 'password', password: TEST_PASSWORD, keyPath: '', passphrase: '', agentForward: false }] })
|
|
927
|
+
if (put.ok === true) pass('B23a 连接簿写入(POST config sshHosts → SFTP 走连接簿凭证)')
|
|
928
|
+
else fail('B23a 连接簿写入(POST config sshHosts → SFTP 走连接簿凭证)', JSON.stringify(put).slice(0, 160))
|
|
929
|
+
await fsp.writeFile(path.join(rootDir, 'seed.txt'), 'SEED_CONTENT_B23')
|
|
930
|
+
|
|
931
|
+
const sftpApi = async (action, payload) => {
|
|
932
|
+
const res = await fetch(`http://127.0.0.1:${port}/api/dsh-tty/sftp/${action}`, {
|
|
933
|
+
method: 'POST',
|
|
934
|
+
headers: { 'content-type': 'application/json' },
|
|
935
|
+
body: JSON.stringify({ name: 'b23-ssh', ...payload }),
|
|
936
|
+
})
|
|
937
|
+
const data = await res.json().catch(() => ({}))
|
|
938
|
+
return { status: res.status, data }
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
// b) list:path 缺省 → realpath home(rootDir)+ 预置文件可见
|
|
942
|
+
const listed = await sftpApi('list', { path: '' })
|
|
943
|
+
const seed = (listed.data.entries ?? []).find((e) => e.name === 'seed.txt')
|
|
944
|
+
if (listed.data.ok === true && listed.data.path === rootDir && seed?.isFile === true && seed?.size === 16) {
|
|
945
|
+
pass('B23b list 路由(path 缺省 realpath 到 rootDir,seed.txt 文件属性正确)')
|
|
946
|
+
} else {
|
|
947
|
+
fail('B23b list 路由(path 缺省 realpath 到 rootDir,seed.txt 文件属性正确)', JSON.stringify(listed).slice(0, 200))
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
// c) mkdir + upload(原始字节 body,x-dsh-sftp-meta 头带 spec+path)
|
|
951
|
+
const mk = await sftpApi('mkdir', { path: rootDir + '/b23dir' })
|
|
952
|
+
const payload = Buffer.alloc(4096, 0x62)
|
|
953
|
+
const up = await fetch(`http://127.0.0.1:${port}/api/dsh-tty/sftp/upload`, {
|
|
954
|
+
method: 'POST',
|
|
955
|
+
headers: { 'x-dsh-sftp-meta': Buffer.from(JSON.stringify({ name: 'b23-ssh', path: rootDir + '/b23dir/up.bin' })).toString('base64url') },
|
|
956
|
+
body: payload,
|
|
957
|
+
})
|
|
958
|
+
const upData = await up.json().catch(() => ({}))
|
|
959
|
+
let stored = null
|
|
960
|
+
try {
|
|
961
|
+
stored = await fsp.readFile(path.join(rootDir, 'b23dir', 'up.bin'))
|
|
962
|
+
} catch {
|
|
963
|
+
stored = null
|
|
964
|
+
}
|
|
965
|
+
if (mk.data.ok === true && up.status === 200 && upData.ok === true && upData.bytes === 4096 && stored !== null && stored.equals(payload)) {
|
|
966
|
+
pass('B23c mkdir + upload 路由(4096 字节流式落盘逐一一致)')
|
|
967
|
+
} else {
|
|
968
|
+
fail('B23c mkdir + upload 路由(4096 字节流式落盘逐一一致)', `mk=${JSON.stringify(mk.data).slice(0, 80)} up=${up.status}/${JSON.stringify(upData).slice(0, 80)} stored=${stored === null ? '缺失' : String(stored.length) + 'B'}`)
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
// d) download:流式响应 + content-disposition + 字节一致
|
|
972
|
+
const dl = await fetch(`http://127.0.0.1:${port}/api/dsh-tty/sftp/download`, {
|
|
973
|
+
method: 'POST',
|
|
974
|
+
headers: { 'content-type': 'application/json' },
|
|
975
|
+
body: JSON.stringify({ name: 'b23-ssh', path: rootDir + '/b23dir/up.bin' }),
|
|
976
|
+
})
|
|
977
|
+
const dlBuf = Buffer.from(await dl.arrayBuffer())
|
|
978
|
+
const disposition = dl.headers.get('content-disposition') ?? ''
|
|
979
|
+
if (dl.status === 200 && dlBuf.equals(payload) && disposition.includes('up.bin')) {
|
|
980
|
+
pass('B23d download 路由(octet-stream + content-disposition + 字节一致)')
|
|
981
|
+
} else {
|
|
982
|
+
fail('B23d download 路由(octet-stream + content-disposition + 字节一致)', `status=${String(dl.status)} bytes=${String(dlBuf.length)} disposition=${JSON.stringify(disposition)}`)
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
// e) agent 工具:schema 校验 + sftp_list / sftp_write(追加)/ sftp_read
|
|
986
|
+
const sftpList = toolDefs.find((d) => d.name === 'sftp_list')
|
|
987
|
+
const sftpRead = toolDefs.find((d) => d.name === 'sftp_read')
|
|
988
|
+
const sftpWrite = toolDefs.find((d) => d.name === 'sftp_write')
|
|
989
|
+
if (sftpList === undefined || sftpRead === undefined || sftpWrite === undefined) {
|
|
990
|
+
fail('B23e sftp_* agent 工具(schema + list/read/write)', '缺工具: ' + toolDefs.map((d) => d.name).filter((n) => n.startsWith('sftp')).join(',') || '无')
|
|
991
|
+
} else {
|
|
992
|
+
let schemaOk = true
|
|
993
|
+
for (const d of [sftpList, sftpRead, sftpWrite]) {
|
|
994
|
+
try {
|
|
995
|
+
assertSupportedJsonSchema(d.parameters)
|
|
996
|
+
assertSupportedJsonSchema(d.output?.schema ?? {})
|
|
997
|
+
} catch (e) {
|
|
998
|
+
schemaOk = false
|
|
999
|
+
fail('B23e sftp_* agent 工具(schema + list/read/write)', d.name + ' schema: ' + String(e && e.message ? e.message : e))
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
const toolListed = schemaOk ? await sftpList.execute({ book: 'b23-ssh', path: rootDir + '/b23dir' }) : null
|
|
1003
|
+
const saw = toolListed !== null && (toolListed.entries ?? []).some((e) => e.name === 'up.bin' && e.isDir === false && e.size === 4096)
|
|
1004
|
+
let noteOk = false
|
|
1005
|
+
let readBack = null
|
|
1006
|
+
try {
|
|
1007
|
+
await sftpWrite.execute({ book: 'b23-ssh', path: rootDir + '/b23dir/note.txt', content: 'hello-b23', append: true })
|
|
1008
|
+
await sftpWrite.execute({ book: 'b23-ssh', path: rootDir + '/b23dir/note.txt', content: '-again', append: true })
|
|
1009
|
+
readBack = await sftpRead.execute({ book: 'b23-ssh', path: rootDir + '/b23dir/note.txt' })
|
|
1010
|
+
noteOk = readBack.content === 'hello-b23-again' && readBack.truncated === false
|
|
1011
|
+
} catch (error) {
|
|
1012
|
+
readBack = { error: error.message }
|
|
1013
|
+
}
|
|
1014
|
+
if (schemaOk && saw === true && noteOk) pass('B23e sftp_* agent 工具(schema 通过 + list 可见上传文件 + write 追加后 read 一致)')
|
|
1015
|
+
else fail('B23e sftp_* agent 工具(schema 通过 + list 可见上传文件 + write 追加后 read 一致)', `saw=${String(saw)} note=${JSON.stringify(readBack).slice(0, 120)}`)
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
// f) remove 递归 + 连接簿清空
|
|
1019
|
+
const rm = await sftpApi('remove', { path: rootDir + '/b23dir', recursive: true })
|
|
1020
|
+
const gone = await fsp.access(path.join(rootDir, 'b23dir')).then(() => false, () => true)
|
|
1021
|
+
if (rm.data.ok === true && gone) pass('B23f remove 路由(目录递归删除)')
|
|
1022
|
+
else fail('B23f remove 路由(目录递归删除)', `status=${String(rm.status)} data=${JSON.stringify(rm.data).slice(0, 120)} gone=${String(gone)}`)
|
|
1023
|
+
await post({ sshHosts: [] })
|
|
1024
|
+
await sftpd.close()
|
|
1025
|
+
await fsp.rm(rootDir, { recursive: true, force: true })
|
|
1026
|
+
console.log(' sftp sshd 已关闭,临时目录已清理')
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
// B24: SFTP 管理闭环(0.8.0)—— agent sftp_mkdir/sftp_rename/sftp_remove/
|
|
1030
|
+
// sftp_tree(test-sshd 内存 sshd,覆盖 parents 逐级补齐、tree 限深截断、
|
|
1031
|
+
// 跨目录 rename = mv、非空目录非递归删除拒绝、递归删除与不存在路径报错)。
|
|
1032
|
+
console.log('\n[22] SFTP 管理闭环(agent sftp_mkdir/rename/remove/tree)')
|
|
1033
|
+
{
|
|
1034
|
+
const rootDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'dsh-tty-b24-'))
|
|
1035
|
+
const sftpd = await startSftpSshd({ rootDir })
|
|
1036
|
+
const post = async (body) => {
|
|
1037
|
+
const res = await fetch(`http://127.0.0.1:${port}/api/dsh-tty/config`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
|
|
1038
|
+
return res.json()
|
|
1039
|
+
}
|
|
1040
|
+
const put = await post({ sshHosts: [{ name: 'b24-ssh', host: '127.0.0.1', port: sftpd.port, username: TEST_USER, auth: 'password', password: TEST_PASSWORD, keyPath: '', passphrase: '', agentForward: false }] })
|
|
1041
|
+
if (put.ok === true) pass('B24a 连接簿写入(b24-ssh)')
|
|
1042
|
+
else fail('B24a 连接簿写入(b24-ssh)', JSON.stringify(put).slice(0, 160))
|
|
1043
|
+
|
|
1044
|
+
const sftpMkdir = toolDefs.find((d) => d.name === 'sftp_mkdir')
|
|
1045
|
+
const sftpRename = toolDefs.find((d) => d.name === 'sftp_rename')
|
|
1046
|
+
const sftpRemove = toolDefs.find((d) => d.name === 'sftp_remove')
|
|
1047
|
+
const sftpTree = toolDefs.find((d) => d.name === 'sftp_tree')
|
|
1048
|
+
const sftpWrite = toolDefs.find((d) => d.name === 'sftp_write')
|
|
1049
|
+
const sftpRead = toolDefs.find((d) => d.name === 'sftp_read')
|
|
1050
|
+
if (sftpMkdir === undefined || sftpRename === undefined || sftpRemove === undefined || sftpTree === undefined || sftpWrite === undefined || sftpRead === undefined) {
|
|
1051
|
+
fail('B24 工具注册', '缺工具: ' + toolDefs.map((d) => d.name).filter((n) => n.startsWith('sftp')).join(',') || '无')
|
|
1052
|
+
} else {
|
|
1053
|
+
let schemaOk = true
|
|
1054
|
+
for (const d of [sftpMkdir, sftpRename, sftpRemove, sftpTree]) {
|
|
1055
|
+
try {
|
|
1056
|
+
assertSupportedJsonSchema(d.parameters)
|
|
1057
|
+
assertSupportedJsonSchema(d.output?.schema ?? {})
|
|
1058
|
+
} catch (e) {
|
|
1059
|
+
schemaOk = false
|
|
1060
|
+
fail('B24 工具注册(schema 校验)', d.name + ' schema: ' + String(e && e.message ? e.message : e))
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
if (schemaOk) pass('B24a sftp_mkdir/rename/remove/tree schema 校验')
|
|
1064
|
+
|
|
1065
|
+
// b) mkdir parents 逐级补齐(等效 mkdir -p)
|
|
1066
|
+
const libDir = rootDir + '/b24/proj/src/lib'
|
|
1067
|
+
await sftpMkdir.execute({ book: 'b24-ssh', path: libDir, parents: true })
|
|
1068
|
+
const libStat = await fsp.stat(libDir).then((s) => s.isDirectory(), () => false)
|
|
1069
|
+
let noParentsRejected = false
|
|
1070
|
+
try {
|
|
1071
|
+
await sftpMkdir.execute({ book: 'b24-ssh', path: rootDir + '/b24/no/such/dir' })
|
|
1072
|
+
} catch {
|
|
1073
|
+
noParentsRejected = true
|
|
1074
|
+
}
|
|
1075
|
+
if (libStat && noParentsRejected) pass('B24b sftp_mkdir(parents 逐级补齐 + 缺父目录被拒)')
|
|
1076
|
+
else fail('B24b sftp_mkdir(parents 逐级补齐 + 缺父目录被拒)', `libDir=${String(libStat)} noParentsRejected=${String(noParentsRejected)}`)
|
|
1077
|
+
|
|
1078
|
+
// seed 一个文件(sftp_write),供 tree / rename / 移动断言
|
|
1079
|
+
await sftpWrite.execute({ book: 'b24-ssh', path: libDir + '/util.ts', content: 'export const b24 = 1\n' })
|
|
1080
|
+
|
|
1081
|
+
// c) tree:深度优先目录优先 + maxDepth 限深截断
|
|
1082
|
+
const treeShallow = await sftpTree.execute({ book: 'b24-ssh', path: rootDir + '/b24', maxDepth: 1 })
|
|
1083
|
+
const shallowOk = treeShallow.entries.length === 1 && treeShallow.entries[0].path === rootDir + '/b24/proj' && treeShallow.entries[0].depth === 1 && treeShallow.entries[0].isDir === true && treeShallow.truncated === true
|
|
1084
|
+
const treeFull = await sftpTree.execute({ book: 'b24-ssh', path: rootDir + '/b24', maxDepth: 4 })
|
|
1085
|
+
const names = treeFull.entries.map((e) => e.path)
|
|
1086
|
+
const fullOk = names.join('|') === [rootDir + '/b24/proj', rootDir + '/b24/proj/src', rootDir + '/b24/proj/src/lib', rootDir + '/b24/proj/src/lib/util.ts'].join('|')
|
|
1087
|
+
&& treeFull.truncated === false
|
|
1088
|
+
&& treeFull.entries[3].size === 'export const b24 = 1\n'.length
|
|
1089
|
+
if (shallowOk && fullOk) pass('B24c sftp_tree(深度优先目录优先 + maxDepth 截断标记)')
|
|
1090
|
+
else fail('B24c sftp_tree(深度优先目录优先 + maxDepth 截断标记)', `shallow=${JSON.stringify(treeShallow).slice(0, 160)} full=${names.join('|')} truncated=${String(treeFull.truncated)}`)
|
|
1091
|
+
|
|
1092
|
+
// d) rename 文件 + 内容不变
|
|
1093
|
+
await sftpRename.execute({ book: 'b24-ssh', from: libDir + '/util.ts', to: libDir + '/helper.ts' })
|
|
1094
|
+
const renamed = await fsp.access(libDir + '/util.ts').then(() => false, () => true)
|
|
1095
|
+
const readBack = await sftpRead.execute({ book: 'b24-ssh', path: libDir + '/helper.ts' })
|
|
1096
|
+
if (renamed && readBack.content === 'export const b24 = 1\n') pass('B24d sftp_rename(文件重命名 + 内容不变)')
|
|
1097
|
+
else fail('B24d sftp_rename(文件重命名 + 内容不变)', `renamed=${String(renamed)} content=${JSON.stringify(readBack.content ?? readBack).slice(0, 80)}`)
|
|
1098
|
+
|
|
1099
|
+
// e) rename 跨目录 = 移动
|
|
1100
|
+
await sftpMkdir.execute({ book: 'b24-ssh', path: rootDir + '/b24/dest' })
|
|
1101
|
+
await sftpRename.execute({ book: 'b24-ssh', from: libDir + '/helper.ts', to: rootDir + '/b24/dest/helper.ts' })
|
|
1102
|
+
const movedAway = await fsp.access(libDir + '/helper.ts').then(() => false, () => true)
|
|
1103
|
+
const movedContent = await fsp.readFile(rootDir + '/b24/dest/helper.ts', 'utf8').catch(() => null)
|
|
1104
|
+
if (movedAway && movedContent === 'export const b24 = 1\n') pass('B24e sftp_rename(跨目录移动 = mv)')
|
|
1105
|
+
else fail('B24e sftp_rename(跨目录移动 = mv)', `movedAway=${String(movedAway)} content=${JSON.stringify(movedContent).slice(0, 80)}`)
|
|
1106
|
+
|
|
1107
|
+
// f) remove:非空目录不带 recursive 被拒;recursive 整树删除;已删路径再操作报错
|
|
1108
|
+
let nonEmptyRejected = false
|
|
1109
|
+
try {
|
|
1110
|
+
await sftpRemove.execute({ book: 'b24-ssh', path: rootDir + '/b24/proj' })
|
|
1111
|
+
} catch {
|
|
1112
|
+
nonEmptyRejected = true
|
|
1113
|
+
}
|
|
1114
|
+
await sftpRemove.execute({ book: 'b24-ssh', path: rootDir + '/b24', recursive: true })
|
|
1115
|
+
const treeGone = await fsp.access(rootDir + '/b24').then(() => false, () => true)
|
|
1116
|
+
let missingRejected = false
|
|
1117
|
+
try {
|
|
1118
|
+
await sftpTree.execute({ book: 'b24-ssh', path: rootDir + '/b24' })
|
|
1119
|
+
} catch {
|
|
1120
|
+
missingRejected = true
|
|
1121
|
+
}
|
|
1122
|
+
if (nonEmptyRejected && treeGone && missingRejected) pass('B24f sftp_remove(非空目录需 recursive + 递归删除 + 复查报错)')
|
|
1123
|
+
else fail('B24f sftp_remove(非空目录需 recursive + 递归删除 + 复查报错)', `nonEmptyRejected=${String(nonEmptyRejected)} treeGone=${String(treeGone)} missingRejected=${String(missingRejected)}`)
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
await post({ sshHosts: [] })
|
|
1127
|
+
await sftpd.close()
|
|
1128
|
+
await fsp.rm(rootDir, { recursive: true, force: true })
|
|
1129
|
+
console.log(' sftp sshd 已关闭,临时目录已清理')
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
// B25/B26: 会话持久化(tmux,0.10.0)—— persistence 门控 / tmux 托管 /
|
|
1133
|
+
// kill-session 收尾 / shell 集成 DCS passthrough / 保活回收不杀 tmux 会话 /
|
|
1134
|
+
// 同 persistName 接回现场。本机无 tmux 时只验证降级提示路径。
|
|
1135
|
+
console.log('\n[23] 会话持久化(tmux)')
|
|
1136
|
+
{
|
|
1137
|
+
const execFileP = (file, args) => new Promise((resolve) => {
|
|
1138
|
+
import('node:child_process').then(({ execFile }) => {
|
|
1139
|
+
execFile(file, args, { timeout: 5000 }, (error, stdout, stderr) => resolve({ error, stdout: String(stdout), stderr: String(stderr) }))
|
|
1140
|
+
})
|
|
1141
|
+
})
|
|
1142
|
+
const tmuxList = async () => {
|
|
1143
|
+
const out = await execFileP('tmux', ['-L', 'dsh-tty', 'list-sessions', '-F', '#{session_name}'])
|
|
1144
|
+
return out.error !== null && out.error !== undefined ? [] : out.stdout.trim().split('\n').filter(Boolean)
|
|
1145
|
+
}
|
|
1146
|
+
const post = async (body) => {
|
|
1147
|
+
const res = await fetch(`http://127.0.0.1:${port}/api/dsh-tty/config`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
|
|
1148
|
+
return res.json()
|
|
1149
|
+
}
|
|
1150
|
+
const probe = await execFileP('tmux', ['-V'])
|
|
1151
|
+
const hasTmux = probe.error === null || probe.error === undefined
|
|
1152
|
+
const versionMatch = /tmux\s+(\d+)\.(\d+)/.exec(probe.stdout)
|
|
1153
|
+
const hasPassthrough = hasTmux && versionMatch !== null && (Number(versionMatch[1]) > 3 || (Number(versionMatch[1]) === 3 && Number(versionMatch[2]) >= 3))
|
|
1154
|
+
|
|
1155
|
+
// B25a: persistence=off 时 persist 帧被门控 —— 普通会话、ready 无 persist、无 tmux 会话
|
|
1156
|
+
await post({ persistence: 'off' })
|
|
1157
|
+
{
|
|
1158
|
+
const s = openSession(port)
|
|
1159
|
+
await s.open()
|
|
1160
|
+
s.client.send(JSON.stringify({ t: 'spawn', sid: 'b25off', persist: true, persistName: 'b25gate' }))
|
|
1161
|
+
await s.waitFor(() => s.state.ready, 10000, 'ready')
|
|
1162
|
+
const ready = s.state.frames.find((f) => f.t === 'ready' && f.sid === 'b25off')
|
|
1163
|
+
const listed = hasTmux ? await tmuxList() : []
|
|
1164
|
+
if (ready !== undefined && ready.persist !== true && !listed.includes('dsh-b25gate')) pass('B25a persistence=off 门控 persist 帧(普通会话)')
|
|
1165
|
+
else fail('B25a persistence=off 门控 persist 帧(普通会话)', `persist=${String(ready?.persist)} listed=${JSON.stringify(listed)}`)
|
|
1166
|
+
s.client.send(JSON.stringify({ t: 'kill', sid: 'b25off' }))
|
|
1167
|
+
await s.waitFor(() => s.state.exited !== null, 10000, 'exit')
|
|
1168
|
+
s.client.close()
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
if (!hasTmux) {
|
|
1172
|
+
// 降级路径:persistence=tmux + persist 帧 + 无 tmux → 普通会话 + 灰字提示
|
|
1173
|
+
await post({ persistence: 'tmux' })
|
|
1174
|
+
const s = openSession(port)
|
|
1175
|
+
await s.open()
|
|
1176
|
+
s.client.send(JSON.stringify({ t: 'spawn', sid: 'b25fb', persist: true, persistName: 'b25fb' }))
|
|
1177
|
+
await s.waitFor(() => s.state.ready, 10000, 'ready')
|
|
1178
|
+
await s.waitFor(() => /未检测到 tmux/.test(s.state.text), 10000, '降级提示')
|
|
1179
|
+
const ready = s.state.frames.find((f) => f.t === 'ready' && f.sid === 'b25fb')
|
|
1180
|
+
if (ready !== undefined && ready.persist !== true) pass('B25b 无 tmux 降级为普通会话 + 灰字提示')
|
|
1181
|
+
else fail('B25b 无 tmux 降级为普通会话 + 灰字提示', `persist=${String(ready?.persist)}`)
|
|
1182
|
+
s.client.send(JSON.stringify({ t: 'kill', sid: 'b25fb' }))
|
|
1183
|
+
await s.waitFor(() => s.state.exited !== null, 10000, 'exit')
|
|
1184
|
+
s.client.close()
|
|
1185
|
+
await post({ persistence: 'off' })
|
|
1186
|
+
console.log(' 本机无 tmux:B25c/B26 跳过')
|
|
1187
|
+
} else {
|
|
1188
|
+
await post({ persistence: 'tmux' })
|
|
1189
|
+
const s = openSession(port)
|
|
1190
|
+
await s.open()
|
|
1191
|
+
s.client.send(JSON.stringify({ t: 'spawn', sid: 'b25tmux', cols: 80, rows: 24, persist: true, persistName: 'b25persist' }))
|
|
1192
|
+
await s.waitFor(() => s.state.ready, 10000, 'ready')
|
|
1193
|
+
const ready = s.state.frames.find((f) => f.t === 'ready' && f.sid === 'b25tmux')
|
|
1194
|
+
// ready 只代表 channel 打开;tmux server 是 fork 出来的,会话出现要晚一点。
|
|
1195
|
+
// 本地(快)一次就中,CI runner 上会输给这个竞态——轮询到出现为止(最多 ~6s)。
|
|
1196
|
+
let listed = await tmuxList()
|
|
1197
|
+
for (let i = 0; i < 20 && !listed.includes('dsh-b25persist'); i++) {
|
|
1198
|
+
await sleep(300)
|
|
1199
|
+
listed = await tmuxList()
|
|
1200
|
+
}
|
|
1201
|
+
if (ready !== undefined && ready.persist === true && listed.includes('dsh-b25persist')) pass('B25b persistence=tmux 持久 spawn(tmux 会话托管 + ready.persist)')
|
|
1202
|
+
else fail('B25b persistence=tmux 持久 spawn(tmux 会话托管 + ready.persist)', `persist=${String(ready?.persist)} listed=${JSON.stringify(listed)}`)
|
|
1203
|
+
|
|
1204
|
+
// shell 集成经 DCS passthrough 送达宿主:capture{last} 拿到命令输出与退出码
|
|
1205
|
+
if (hasPassthrough) {
|
|
1206
|
+
s.client.send(JSON.stringify({ t: 'input', sid: 'b25tmux', d: 'printf "B25LAST-%s\\n" 42\n' }))
|
|
1207
|
+
await s.waitFor(() => /B25LAST-42/.test(s.state.text), 10000, '命令输出')
|
|
1208
|
+
await sleep(600) // 等 D 标记(precmd)到达
|
|
1209
|
+
const ttyCapture = toolDefs.find((d) => d.name === 'tty_capture')
|
|
1210
|
+
const last = await ttyCapture.execute({ sid: 'b25tmux', last: true })
|
|
1211
|
+
if (last.source === 'last' && /B25LAST-42/.test(last.tail ?? '') && last.exitCode === 0) pass('B25c tmux passthrough shell 集成(capture{last} + 退出码)')
|
|
1212
|
+
else fail('B25c tmux passthrough shell 集成(capture{last} + 退出码)', JSON.stringify({ source: last.source, exitCode: last.exitCode, tail: (last.tail ?? '').slice(-80) }))
|
|
1213
|
+
} else {
|
|
1214
|
+
console.log(' tmux <3.3(无 allow-passthrough):B25c 跳过')
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
// B25d: kill 帧 → tmux 会话真正结束(kill-session 而非 detach)
|
|
1218
|
+
s.client.send(JSON.stringify({ t: 'kill', sid: 'b25tmux' }))
|
|
1219
|
+
await s.waitFor(() => s.state.exited !== null, 10000, 'exit')
|
|
1220
|
+
await sleep(500)
|
|
1221
|
+
const listedAfterKill = await tmuxList()
|
|
1222
|
+
if (!listedAfterKill.includes('dsh-b25persist')) pass('B25d kill 帧收尾 tmux 会话(kill-session)')
|
|
1223
|
+
else fail('B25d kill 帧收尾 tmux 会话(kill-session)', JSON.stringify(listedAfterKill))
|
|
1224
|
+
|
|
1225
|
+
// B26: 恢复现场 —— 保活回收只杀 PTY(tmux 会话存活);同 persistName 接回
|
|
1226
|
+
await post({ reconnectGraceSec: 1 })
|
|
1227
|
+
const w1 = openSession(port)
|
|
1228
|
+
await w1.open()
|
|
1229
|
+
w1.client.send(JSON.stringify({ t: 'spawn', sid: 'b26a', cols: 80, rows: 24, persist: true, persistName: 'b26resume' }))
|
|
1230
|
+
await w1.waitFor(() => w1.state.ready, 10000, 'b26a ready')
|
|
1231
|
+
w1.client.send(JSON.stringify({ t: 'input', sid: 'b26a', d: 'printf "B26MARK-%s\\n" resume\n' }))
|
|
1232
|
+
await w1.waitFor(() => /B26MARK-resume/.test(w1.state.text), 10000, '标记输出')
|
|
1233
|
+
// 异常断开 → 孤儿 → 保活 1s + 回收器扫描(≤10s)只杀 PTY;tmux 会话应存活
|
|
1234
|
+
w1.client.close()
|
|
1235
|
+
await sleep(12500)
|
|
1236
|
+
const listedAfterReap = await tmuxList()
|
|
1237
|
+
if (listedAfterReap.includes('dsh-b26resume')) pass('B26a 保活回收只杀 PTY(tmux 会话存活可恢复)')
|
|
1238
|
+
else fail('B26a 保活回收只杀 PTY(tmux 会话存活可恢复)', JSON.stringify(listedAfterReap))
|
|
1239
|
+
// sessions 帧带 tmux 会话名清单(新窗口恢复持久标签的存活确认数据源)
|
|
1240
|
+
const wq = openSession(port)
|
|
1241
|
+
await wq.open()
|
|
1242
|
+
wq.client.send(JSON.stringify({ t: 'sessions' }))
|
|
1243
|
+
await wq.waitFor(() => wq.state.frames.some((f) => f.t === 'sessions'), 5000, 'sessions 帧')
|
|
1244
|
+
const sframe = wq.state.frames.find((f) => f.t === 'sessions')
|
|
1245
|
+
if (sframe !== undefined && Array.isArray(sframe.tmux) && sframe.tmux.includes('dsh-b26resume')) pass('B26a2 sessions 帧带 tmux 会话名清单')
|
|
1246
|
+
else fail('B26a2 sessions 帧带 tmux 会话名清单', JSON.stringify(sframe?.tmux))
|
|
1247
|
+
wq.client.close()
|
|
1248
|
+
const w2 = openSession(port)
|
|
1249
|
+
await w2.open()
|
|
1250
|
+
w2.client.send(JSON.stringify({ t: 'spawn', sid: 'b26b', cols: 80, rows: 24, persist: true, persistName: 'b26resume' }))
|
|
1251
|
+
await w2.waitFor(() => w2.state.ready, 10000, 'b26b ready')
|
|
1252
|
+
await w2.waitFor(() => /B26MARK-resume/.test(w2.state.text), 10000, '接回重画')
|
|
1253
|
+
pass('B26b 同 persistName 重新 spawn 接回原现场(tmux -A 重画)')
|
|
1254
|
+
w2.client.send(JSON.stringify({ t: 'kill', sid: 'b26b' }))
|
|
1255
|
+
await w2.waitFor(() => w2.state.exited !== null, 10000, 'b26b exit')
|
|
1256
|
+
await sleep(500)
|
|
1257
|
+
const listedFinal = await tmuxList()
|
|
1258
|
+
if (!listedFinal.includes('dsh-b26resume')) pass('B26c 恢复会话 kill 后 tmux 会话清理')
|
|
1259
|
+
else fail('B26c 恢复会话 kill 后 tmux 会话清理', JSON.stringify(listedFinal))
|
|
1260
|
+
w2.client.close()
|
|
1261
|
+
|
|
1262
|
+
// B26d: attach 不回放缓冲 —— 重连后现场由 tmux refresh-client 重画恰好一次
|
|
1263
|
+
//(旧行为:回放缓冲 + tmux 重画 = 内容重影 + 幽灵滚动条)
|
|
1264
|
+
const w3 = openSession(port)
|
|
1265
|
+
await w3.open()
|
|
1266
|
+
w3.client.send(JSON.stringify({ t: 'spawn', sid: 'b26d', cols: 80, rows: 24, persist: true, persistName: 'b26refresh' }))
|
|
1267
|
+
await w3.waitFor(() => w3.state.ready, 10000, 'b26d ready')
|
|
1268
|
+
w3.client.send(JSON.stringify({ t: 'input', sid: 'b26d', d: 'printf "B26REFRESH-%s\\n" ok\n' }))
|
|
1269
|
+
await w3.waitFor(() => /B26REFRESH-ok/.test(w3.state.text), 10000, 'b26d 输出')
|
|
1270
|
+
await sleep(600) // 等 D 标记与重画尘埃落定
|
|
1271
|
+
w3.client.close() // 异常断开 → 孤儿(grace=1s,需在回收前尽快 attach)
|
|
1272
|
+
await sleep(200)
|
|
1273
|
+
const w4 = openSession(port)
|
|
1274
|
+
await w4.open()
|
|
1275
|
+
w4.client.send(JSON.stringify({ t: 'attach', sid: 'b26d' }))
|
|
1276
|
+
await w4.waitFor(() => w4.state.ready, 10000, 'b26d attach ready')
|
|
1277
|
+
await w4.waitFor(() => /B26REFRESH-ok/.test(w4.state.text), 10000, 'refresh-client 重画')
|
|
1278
|
+
const occurrences = (w4.state.text.match(/B26REFRESH-ok/g) ?? []).length
|
|
1279
|
+
if (occurrences === 1) pass('B26d attach 跳过缓冲回放(tmux 重画一次,无重影/幽灵滚动条)')
|
|
1280
|
+
else fail('B26d attach 跳过缓冲回放(tmux 重画一次,无重影/幽灵滚动条)', `marker 出现 ${occurrences} 次`)
|
|
1281
|
+
// refresh 帧:reset 后请宿主 refresh-client 重画(不碰尺寸)
|
|
1282
|
+
w4.client.send(JSON.stringify({ t: 'refresh', sid: 'b26d' }))
|
|
1283
|
+
await sleep(600)
|
|
1284
|
+
const refreshedOk = (w4.state.text.match(/B26REFRESH-ok/g) ?? []).length >= 1 && w4.state.errors.length === 0
|
|
1285
|
+
if (refreshedOk) pass('B26e refresh 帧(refresh-client 重画,无错误)')
|
|
1286
|
+
else fail('B26e refresh 帧(refresh-client 重画,无错误)', `errors=${JSON.stringify(w4.state.errors)}`)
|
|
1287
|
+
w4.client.send(JSON.stringify({ t: 'kill', sid: 'b26d' }))
|
|
1288
|
+
await w4.waitFor(() => w4.state.exited !== null, 10000, 'b26d exit')
|
|
1289
|
+
w4.client.close()
|
|
1290
|
+
await post({ reconnectGraceSec: 120, persistence: 'off' })
|
|
1291
|
+
console.log(' 持久化配置已还原(persistence=off,grace=120)')
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
// B27: 跨窗口共享持久会话(0.10.1)——同 persistName 的第二次 spawn 重绑定
|
|
1296
|
+
// 到现有宿主会话(不新建 PTY、不占名额),输出扇出两侧可见;kill 广播 exit
|
|
1297
|
+
console.log('\n[24] 跨窗口共享持久会话')
|
|
1298
|
+
{
|
|
1299
|
+
const post = async (body) => {
|
|
1300
|
+
const res = await fetch(`http://127.0.0.1:${port}/api/dsh-tty/config`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
|
|
1301
|
+
return res.json()
|
|
1302
|
+
}
|
|
1303
|
+
await post({ persistence: 'tmux' })
|
|
1304
|
+
const wA = openSession(port)
|
|
1305
|
+
await wA.open()
|
|
1306
|
+
wA.client.send(JSON.stringify({ t: 'spawn', sid: 'b27a', cols: 80, rows: 24, persist: true, persistName: 'b27share' }))
|
|
1307
|
+
await wA.waitFor(() => wA.state.ready, 10000, 'b27a ready')
|
|
1308
|
+
wA.client.send(JSON.stringify({ t: 'input', sid: 'b27a', d: 'printf "B27MARK-%s\\n" shared\n' }))
|
|
1309
|
+
await wA.waitFor(() => /B27MARK-shared/.test(wA.state.text), 10000, 'b27a 标记输出')
|
|
1310
|
+
const wB = openSession(port)
|
|
1311
|
+
await wB.open()
|
|
1312
|
+
wB.client.send(JSON.stringify({ t: 'spawn', sid: 'b27b', cols: 80, rows: 24, persist: true, persistName: 'b27share' }))
|
|
1313
|
+
await wB.waitFor(() => wB.state.ready, 10000, 'b27b ready(重绑定)')
|
|
1314
|
+
await wB.waitFor(() => /B27MARK-shared/.test(wB.state.text), 10000, 'B 侧可见共享输出')
|
|
1315
|
+
const ttyList = toolDefs.find((d) => d.name === 'tty_list')
|
|
1316
|
+
const listed = await ttyList.execute({})
|
|
1317
|
+
const persistCount = listed.sessions.filter((s) => s.persist === true).length
|
|
1318
|
+
const bothAlive = wA.state.exited === null && wB.state.exited === null
|
|
1319
|
+
if (persistCount === 1 && bothAlive) pass('B27a 同 persistName 二次 spawn 重绑定(不新建 PTY / 不占名额)')
|
|
1320
|
+
else fail('B27a 同 persistName 二次 spawn 重绑定(不新建 PTY / 不占名额)', `persist 会话数=${String(persistCount)}`)
|
|
1321
|
+
// kill A → exit 广播到 B(共享会话随 kill 结束)
|
|
1322
|
+
wA.client.send(JSON.stringify({ t: 'kill', sid: 'b27a' }))
|
|
1323
|
+
await wB.waitFor(() => wB.state.exited !== null, 10000, 'B 侧 exit 广播')
|
|
1324
|
+
if (wB.state.exited !== null) pass('B27b kill 广播 exit 到共享连接')
|
|
1325
|
+
else fail('B27b kill 广播 exit 到共享连接', JSON.stringify(wB.state.exited))
|
|
1326
|
+
wA.client.close()
|
|
1327
|
+
wB.client.close()
|
|
1328
|
+
await post({ persistence: 'off' })
|
|
1329
|
+
console.log(' 持久化配置已还原')
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
// B28: SSH 连接测试路由 /api/dsh-tty/probe(0.11.0)
|
|
1333
|
+
// 连接簿行「测试」(bookRecord=true 完整 TOFU)与对话框「试连」(无 store
|
|
1334
|
+
// 只比对)共用;验证:成功分类、host key 记录/匹配、坏密码、端口不通
|
|
1335
|
+
console.log('\n[25] SSH 连接测试路由')
|
|
1336
|
+
{
|
|
1337
|
+
const probe = async (body) => {
|
|
1338
|
+
const res = await fetch(`http://127.0.0.1:${port}/api/dsh-tty/probe`, {
|
|
1339
|
+
method: 'POST',
|
|
1340
|
+
headers: { 'content-type': 'application/json' },
|
|
1341
|
+
body: JSON.stringify(body),
|
|
1342
|
+
})
|
|
1343
|
+
return res.json()
|
|
1344
|
+
}
|
|
1345
|
+
// 起一个内存 sshd(password test/secret,无 shell——probe 只握手不开 channel)
|
|
1346
|
+
const sftpd = await startSftpSshd({ rootDir: os.tmpdir() })
|
|
1347
|
+
const spec = { host: '127.0.0.1', port: sftpd.port, username: TEST_USER, auth: 'password', password: TEST_PASSWORD }
|
|
1348
|
+
// 试连(bookRecord=false):成功且不落盘 hostKeys
|
|
1349
|
+
const cfgBefore = await (await fetch(`http://127.0.0.1:${port}/api/dsh-tty/config`)).json()
|
|
1350
|
+
const hostsBefore = Array.isArray(cfgBefore.config?.hostKeys) ? cfgBefore.config.hostKeys.length : 0
|
|
1351
|
+
const r1 = await probe({ ...spec, bookRecord: false })
|
|
1352
|
+
if (r1.ok === true && r1.result?.auth?.ok === true && r1.result?.tcp?.ok === true) pass('B28a 试连成功(auth.ok,hostkey=' + String(r1.result?.hostkey?.state) + ')')
|
|
1353
|
+
else fail('B28a 试连成功', JSON.stringify(r1))
|
|
1354
|
+
const cfgAfter1 = await (await fetch(`http://127.0.0.1:${port}/api/dsh-tty/config`)).json()
|
|
1355
|
+
const hostsAfter1 = Array.isArray(cfgAfter1.config?.hostKeys) ? cfgAfter1.config.hostKeys.length : 0
|
|
1356
|
+
if (hostsAfter1 === hostsBefore) pass('B28b 试连不落盘 hostKeys(对话框试连语义)')
|
|
1357
|
+
else fail('B28b 试连不落盘 hostKeys(对话框试连语义)', `before=${hostsBefore} after=${hostsAfter1}`)
|
|
1358
|
+
|
|
1359
|
+
// 连接簿测试(bookRecord=true):新指纹当场 TOFU 记录
|
|
1360
|
+
const r2 = await probe({ ...spec, bookRecord: true })
|
|
1361
|
+
if (r2.ok === true && r2.result?.auth?.ok === true && r2.result?.hostkey?.state === 'recorded') pass('B28c 连接簿测试:新指纹 TOFU 记录(' + String(r2.result?.hostkey?.fingerprint?.slice(0, 12)) + '…)')
|
|
1362
|
+
else fail('B28c 连接簿测试:新指纹 TOFU 记录', JSON.stringify(r2))
|
|
1363
|
+
const cfgAfter2 = await (await fetch(`http://127.0.0.1:${port}/api/dsh-tty/config`)).json()
|
|
1364
|
+
const hostsAfter2 = Array.isArray(cfgAfter2.config?.hostKeys) ? cfgAfter2.config.hostKeys : []
|
|
1365
|
+
// 0.19.0:记录形态为 {host, port, fingerprints[]}(旧单 fingerprint 字段兼容读取)
|
|
1366
|
+
const recordedKey = hostsAfter2.some((hk) => hk.host === '127.0.0.1' && Number(hk.port) === sftpd.port && ((Array.isArray(hk.fingerprints) && hk.fingerprints.length > 0) || typeof hk.fingerprint === 'string'))
|
|
1367
|
+
if (recordedKey) pass('B28d 指纹已持久化到 hostKeys(可被后续连接校验)')
|
|
1368
|
+
else fail('B28d 指纹已持久化到 hostKeys', JSON.stringify(hostsAfter2))
|
|
1369
|
+
// 再连同主机 → matched(与记录一致放行)
|
|
1370
|
+
const r3 = await probe({ ...spec, bookRecord: true })
|
|
1371
|
+
if (r3.ok === true && r3.result?.auth?.ok === true && r3.result?.hostkey?.state === 'matched') pass('B28e 二次测试:指纹匹配放行')
|
|
1372
|
+
else fail('B28e 二次测试:指纹匹配放行', JSON.stringify(r3))
|
|
1373
|
+
|
|
1374
|
+
// 坏密码 → 认证被拒(分类文案)
|
|
1375
|
+
const r4 = await probe({ ...spec, password: 'wrong', bookRecord: true })
|
|
1376
|
+
if (r4.ok === false && r4.result?.auth?.ok === false && /认证被拒绝/.test(r4.result?.auth?.error || '')) pass('B28f 坏密码 → 认证被拒绝')
|
|
1377
|
+
else fail('B28f 坏密码 → 认证被拒绝', JSON.stringify(r4))
|
|
1378
|
+
|
|
1379
|
+
// 端口不通 → TCP 拒绝(不进入握手)
|
|
1380
|
+
const dead = net.createServer()
|
|
1381
|
+
await new Promise((resolve) => dead.listen(0, '127.0.0.1', resolve))
|
|
1382
|
+
const deadPort = dead.address().port
|
|
1383
|
+
await new Promise((resolve) => dead.close(resolve))
|
|
1384
|
+
const r5 = await probe({ ...spec, port: deadPort, bookRecord: false })
|
|
1385
|
+
if (r5.ok === false && r5.result?.tcp?.ok === false && r5.result?.auth?.ok === false) pass('B28g 端口不通 → tcp 拒绝')
|
|
1386
|
+
else fail('B28g 端口不通 → tcp 拒绝', JSON.stringify(r5))
|
|
1387
|
+
|
|
1388
|
+
// 字段校验:缺 username / 非法端口 → 友好错误(不触发网络)
|
|
1389
|
+
const r6 = await probe({ host: '127.0.0.1', port: 22, username: '', auth: 'password', password: 'x' })
|
|
1390
|
+
if (r6.ok === false && /用户名|主机/.test(r6.error || '')) pass('B28h 缺字段 → 友好错误')
|
|
1391
|
+
else fail('B28h 缺字段 → 友好错误', JSON.stringify(r6))
|
|
1392
|
+
const r7 = await probe({ host: '127.0.0.1', port: 70000, username: 'u', auth: 'password', password: 'x' })
|
|
1393
|
+
if (r7.ok === false && /端口/.test(r7.error || '')) pass('B28i 非法端口 → 友好错误')
|
|
1394
|
+
else fail('B28i 非法端口 → 友好错误', JSON.stringify(r7))
|
|
1395
|
+
|
|
1396
|
+
await sftpd.close()
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
// B29: 禁用热生效(enabled 开关):工具/公告撤下、数据路由 403、WS 关闭与拒绝、/config 保持可用
|
|
1400
|
+
console.log('\n[29] 禁用热生效与恢复(enabled 开关)')
|
|
1401
|
+
{
|
|
1402
|
+
const configPost = async (body) => {
|
|
1403
|
+
const res = await fetch(`http://127.0.0.1:${port}/api/dsh-tty/config`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
|
|
1404
|
+
return { status: res.status, body: await res.json() }
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
// 存量 WS 先连上(插件仍启用),禁用后应被服务端正常关闭
|
|
1408
|
+
const alive = openSession(port)
|
|
1409
|
+
await alive.open()
|
|
1410
|
+
|
|
1411
|
+
const off = await configPost({ enabled: false })
|
|
1412
|
+
if (off.status === 200 && off.body.config?.enabled === false) pass('B29a POST /config enabled=false 生效')
|
|
1413
|
+
else fail('B29a POST /config enabled=false 生效', `status=${String(off.status)}`)
|
|
1414
|
+
await sleep(100)
|
|
1415
|
+
if (toolDefs.length === 0) pass('B29b 禁用后 agent 工具全部撤下')
|
|
1416
|
+
else fail('B29b 禁用后 agent 工具全部撤下', `残留: ${toolDefs.map((d) => d.name).join(',')}`)
|
|
1417
|
+
if (promptParts.length === 0) pass('B29c 禁用后公告与动态快照撤下')
|
|
1418
|
+
else fail('B29c 禁用后公告与动态快照撤下', `残留 ${String(promptParts.length)}`)
|
|
1419
|
+
const probeRes = await fetch(`http://127.0.0.1:${port}/api/dsh-tty/probe`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ host: '127.0.0.1', username: 'u' }) })
|
|
1420
|
+
if (probeRes.status === 403) pass('B29d 禁用后数据路由 403(/probe)')
|
|
1421
|
+
else fail('B29d 禁用后数据路由 403(/probe)', `status=${String(probeRes.status)}`)
|
|
1422
|
+
const cfgGet = await fetch(`http://127.0.0.1:${port}/api/dsh-tty/config`)
|
|
1423
|
+
if (cfgGet.status === 200 && (await cfgGet.json()).config?.enabled === false) pass('B29e 禁用后 /config 仍可读(卡片依赖)')
|
|
1424
|
+
else fail('B29e 禁用后 /config 仍可读(卡片依赖)', `status=${String(cfgGet.status)}`)
|
|
1425
|
+
|
|
1426
|
+
// 存量 WS 已在禁用前连上:应被服务端正常关闭(会话转孤儿保活,PTY 进程不动)
|
|
1427
|
+
try {
|
|
1428
|
+
await alive.waitFor(() => alive.state.closed, 5000, '禁用后存量 WS 关闭')
|
|
1429
|
+
pass('B29f 禁用后存量 WS 被服务端关闭')
|
|
1430
|
+
} catch (error) {
|
|
1431
|
+
fail('B29f 禁用后存量 WS 被服务端关闭', String(error?.message ?? error).slice(0, 140))
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
// 新升级被拒(socket 直接 destroy:ws 客户端应快速 error/close 而非建连成功)
|
|
1435
|
+
const rejected = await new Promise((resolve) => {
|
|
1436
|
+
const ws = new WebSocket(`ws://127.0.0.1:${port}/api/dsh-tty/ws`)
|
|
1437
|
+
const timer = setTimeout(() => { ws.terminate(); resolve(false) }, 3000)
|
|
1438
|
+
ws.once('open', () => { clearTimeout(timer); ws.terminate(); resolve(false) })
|
|
1439
|
+
ws.once('error', () => { clearTimeout(timer); resolve(true) })
|
|
1440
|
+
ws.once('close', () => { clearTimeout(timer); resolve(true) })
|
|
1441
|
+
})
|
|
1442
|
+
if (rejected) pass('B29g 禁用后新 WS 升级被拒')
|
|
1443
|
+
else fail('B29g 禁用后新 WS 升级被拒', '3s 内未收到 error/close 或握手成功')
|
|
1444
|
+
|
|
1445
|
+
// 重新启用:工具、公告、WS 全部恢复
|
|
1446
|
+
const on = await configPost({ enabled: true })
|
|
1447
|
+
if (on.status === 200 && on.body.config?.enabled === true) pass('B29h 重新启用生效')
|
|
1448
|
+
else fail('B29h 重新启用生效', `status=${String(on.status)}`)
|
|
1449
|
+
await sleep(100)
|
|
1450
|
+
if (toolDefs.length === 13) pass('B29i 重新启用后 13 个 agent 工具回归')
|
|
1451
|
+
else fail('B29i 重新启用后 13 个 agent 工具回归', `当前 ${String(toolDefs.length)}`)
|
|
1452
|
+
if (promptParts.length === 2) pass('B29j 重新启用后公告与动态快照恢复')
|
|
1453
|
+
else fail('B29j 重新启用后公告与动态快照恢复', `当前 ${String(promptParts.length)}`)
|
|
1454
|
+
const again = openSession(port)
|
|
1455
|
+
try {
|
|
1456
|
+
await again.open()
|
|
1457
|
+
pass('B29k 重新启用后 WS 可连接')
|
|
1458
|
+
} catch (error) {
|
|
1459
|
+
fail('B29k 重新启用后 WS 可连接', String(error?.message ?? error).slice(0, 140))
|
|
1460
|
+
}
|
|
1461
|
+
again.client.close()
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
// B30: 服务器状态条(0.17.0):SSH 会话的远端采集(非 PTY exec channel)
|
|
1465
|
+
// + 订阅驱动 + 开关热生效。刻意不用本地会话:本用例不依赖本机 PTY
|
|
1466
|
+
// (无 ptmx 的环境照样能跑),且远端采集正是该特性的主路径。
|
|
1467
|
+
console.log('\n[30] 服务器状态条(SSH 远端采集 / 订阅 / 开关热生效)')
|
|
1468
|
+
{
|
|
1469
|
+
const ssh2mod = await import('ssh2')
|
|
1470
|
+
const ssh2 = ssh2mod.default ?? ssh2mod
|
|
1471
|
+
const { generateKeyPairSync } = await import('node:crypto')
|
|
1472
|
+
const { privateKey } = generateKeyPairSync('rsa', {
|
|
1473
|
+
modulusLength: 2048,
|
|
1474
|
+
publicKeyEncoding: { type: 'spki', format: 'pem' },
|
|
1475
|
+
privateKeyEncoding: { type: 'pkcs1', format: 'pem' },
|
|
1476
|
+
})
|
|
1477
|
+
/** 伪远端:只实现 shell + exec;exec 按真实采集脚本的输出形状回吐 JSON 行。 */
|
|
1478
|
+
const execLog = []
|
|
1479
|
+
const remote = new ssh2.Server({ hostKeys: [privateKey] }, (client) => {
|
|
1480
|
+
client.on('authentication', (ctx) => {
|
|
1481
|
+
if (ctx.method === 'password' && ctx.username === 'test' && ctx.password === 'secret') ctx.accept()
|
|
1482
|
+
else ctx.reject()
|
|
1483
|
+
})
|
|
1484
|
+
client.on('ready', () => {
|
|
1485
|
+
client.on('session', (accept) => {
|
|
1486
|
+
const session = accept()
|
|
1487
|
+
// 客户端的 conn.shell 会先发 pty-req:不接住的话 ssh2 服务端会自动拒绝,
|
|
1488
|
+
// shell 根本开不起来(ssh-smoke 同一处坑)
|
|
1489
|
+
session.on('pty', (acceptPty) => { acceptPty() })
|
|
1490
|
+
session.on('window-change', () => {})
|
|
1491
|
+
session.on('shell', (acceptShell) => {
|
|
1492
|
+
const stream = acceptShell()
|
|
1493
|
+
stream.write('dsh-integ:~$ ')
|
|
1494
|
+
stream.on('data', () => {})
|
|
1495
|
+
})
|
|
1496
|
+
session.on('exec', (acceptExec, _rejectExec, info) => {
|
|
1497
|
+
const stream = acceptExec()
|
|
1498
|
+
execLog.push(typeof info?.command === 'string' ? info.command : '')
|
|
1499
|
+
let tick = 0
|
|
1500
|
+
const timer = setInterval(() => {
|
|
1501
|
+
tick += 1
|
|
1502
|
+
stream.write(JSON.stringify({
|
|
1503
|
+
cpuPct: 30 + tick, cores: 4, memUsed: 1e9 * tick, memTotal: 4e9, memPct: 25 + tick,
|
|
1504
|
+
diskTotal: 1e11, diskUsed: 5e10, diskPct: 50, uptimeSec: 1000 + tick,
|
|
1505
|
+
tcpConns: 10 + tick, rxRate: 2048 * tick, txRate: 4096 * tick, tempC: 41.5,
|
|
1506
|
+
}) + '\n')
|
|
1507
|
+
}, 120)
|
|
1508
|
+
const stop = () => clearInterval(timer)
|
|
1509
|
+
stream.on('close', stop)
|
|
1510
|
+
stream.on('end', stop)
|
|
1511
|
+
})
|
|
1512
|
+
})
|
|
1513
|
+
})
|
|
1514
|
+
client.on('error', () => {})
|
|
1515
|
+
})
|
|
1516
|
+
await new Promise((resolve) => remote.listen(0, '127.0.0.1', resolve))
|
|
1517
|
+
const remotePort = remote.address().port
|
|
1518
|
+
const postConfig = async (body) => {
|
|
1519
|
+
const res = await fetch('http://127.0.0.1:' + String(port) + '/api/dsh-tty/config', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
|
|
1520
|
+
return { status: res.status, body: await res.json() }
|
|
1521
|
+
}
|
|
1522
|
+
const put = await postConfig({ sshHosts: [{ name: 'b30-ssh', host: '127.0.0.1', port: remotePort, username: 'test', auth: 'password', password: 'secret', keyPath: '', passphrase: '', agentForward: false }] })
|
|
1523
|
+
if (put.status === 200) pass('B30a 内存 sshd + 连接簿就绪')
|
|
1524
|
+
else fail('B30a 内存 sshd + 连接簿就绪', 'status=' + String(put.status))
|
|
1525
|
+
|
|
1526
|
+
const s = openSession(port)
|
|
1527
|
+
const other = openSession(port)
|
|
1528
|
+
await s.open()
|
|
1529
|
+
await other.open()
|
|
1530
|
+
s.client.send(JSON.stringify({ t: 'ssh', sid: 'b30', cols: 80, rows: 24, name: 'b30-ssh' }))
|
|
1531
|
+
await s.waitFor(() => s.state.ready, 12000, 'ssh ready')
|
|
1532
|
+
const statsFrames = () => s.state.frames.filter((f) => f.t === 'stats')
|
|
1533
|
+
const otherStats = () => other.state.frames.filter((f) => f.t === 'stats')
|
|
1534
|
+
|
|
1535
|
+
// 懒启动:没有 statsOn 就不该开 exec channel
|
|
1536
|
+
await sleep(900)
|
|
1537
|
+
if (statsFrames().length === 0 && execLog.length === 0) pass('B30b 未订阅时不采集(懒启动,无 exec channel)')
|
|
1538
|
+
else fail('B30b 未订阅时不采集(懒启动)', 'frames=' + String(statsFrames().length) + ' exec=' + String(execLog.length))
|
|
1539
|
+
|
|
1540
|
+
s.client.send(JSON.stringify({ t: 'statsOn', sid: 'b30' }))
|
|
1541
|
+
await s.waitFor(() => statsFrames().length >= 2, 10000, 'stats 帧')
|
|
1542
|
+
const frame = statsFrames()[statsFrames().length - 1]
|
|
1543
|
+
const stats = frame.stats
|
|
1544
|
+
const shapeOk = stats !== null && typeof stats === 'object' && stats.cores === 4 && Number.isFinite(stats.cpuPct) && stats.cpuPct >= 30 && stats.cpuPct <= 100 && Number.isFinite(stats.uptimeSec) && stats.tempC === 41.5
|
|
1545
|
+
if (frame.sid === 'b30' && shapeOk) pass('B30c statsOn → 远端 exec 帧(逐行 JSON,cpuPct=' + String(stats.cpuPct) + ' cores=4 tempC=41.5)')
|
|
1546
|
+
else fail('B30c statsOn → 远端 exec 帧', 'sid=' + String(frame.sid) + ' stats=' + JSON.stringify(stats).slice(0, 160))
|
|
1547
|
+
|
|
1548
|
+
const command = execLog[0] ?? ''
|
|
1549
|
+
if (command.startsWith('sh -c ') && command.includes('/proc/stat')) pass('B30d 采集命令经 sh -c 单引号包裹(' + String(command.length) + ' 字符)')
|
|
1550
|
+
else fail('B30d 采集命令形状', command.slice(0, 80))
|
|
1551
|
+
|
|
1552
|
+
await sleep(1200)
|
|
1553
|
+
if (otherStats().length === 0) pass('B30e stats 帧只发给订阅者')
|
|
1554
|
+
else fail('B30e stats 帧只发给订阅者', '旁路连接收到 ' + String(otherStats().length) + ' 帧')
|
|
1555
|
+
|
|
1556
|
+
const off = await postConfig({ statsEnabled: false })
|
|
1557
|
+
if (off.status === 200 && off.body.config && off.body.config.statsEnabled === false) pass('B30f POST statsEnabled=false 生效')
|
|
1558
|
+
else fail('B30f POST statsEnabled=false 生效', 'status=' + String(off.status))
|
|
1559
|
+
const mark = statsFrames().length
|
|
1560
|
+
await sleep(2000)
|
|
1561
|
+
if (statsFrames().length === mark) pass('B30g 关闭后停表(远端 channel 关闭、不再发帧)')
|
|
1562
|
+
else fail('B30g 关闭后停表', '多出 ' + String(statsFrames().length - mark) + ' 帧')
|
|
1563
|
+
|
|
1564
|
+
await postConfig({ statsEnabled: true })
|
|
1565
|
+
await s.waitFor(() => statsFrames().length > mark, 10000, '重开后的 stats 帧')
|
|
1566
|
+
pass('B30h 重开后按存留订阅自动恢复采集')
|
|
1567
|
+
|
|
1568
|
+
s.client.send(JSON.stringify({ t: 'statsOff', sid: 'b30' }))
|
|
1569
|
+
await sleep(400)
|
|
1570
|
+
const mark2 = statsFrames().length
|
|
1571
|
+
await sleep(1600)
|
|
1572
|
+
if (statsFrames().length === mark2) pass('B30i statsOff 后停表(无帧)')
|
|
1573
|
+
else fail('B30i statsOff 后停表(无帧)', '多出 ' + String(statsFrames().length - mark2) + ' 帧')
|
|
1574
|
+
|
|
1575
|
+
s.client.send(JSON.stringify({ t: 'kill', sid: 'b30' }))
|
|
1576
|
+
await s.waitFor(() => s.state.exited !== null, 12000, 'exit 帧')
|
|
1577
|
+
s.client.close()
|
|
1578
|
+
other.client.close()
|
|
1579
|
+
await new Promise((resolve) => remote.close(resolve))
|
|
1580
|
+
remote.closeAllConnections?.()
|
|
1581
|
+
await postConfig({ sshHosts: [] })
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
// B31: 服务器状态条——Windows 远端的自动回退(POSIX 脚本被拒 → PowerShell 分支)
|
|
1585
|
+
// 伪远端模拟 cmd.exe:sh -c 脚本立即非零退出且 stdout 一个字节都不吐,
|
|
1586
|
+
// 只有 -EncodedCommand 才回吐帧。断言:回退生效、只回退一次、退订即停表。
|
|
1587
|
+
console.log('\n[31] 服务器状态条:Windows 远端回退(POSIX → PowerShell)')
|
|
1588
|
+
{
|
|
1589
|
+
const ssh2mod = await import('ssh2')
|
|
1590
|
+
const ssh2 = ssh2mod.default ?? ssh2mod
|
|
1591
|
+
const { generateKeyPairSync } = await import('node:crypto')
|
|
1592
|
+
const { privateKey } = generateKeyPairSync('rsa', {
|
|
1593
|
+
modulusLength: 2048,
|
|
1594
|
+
publicKeyEncoding: { type: 'spki', format: 'pem' },
|
|
1595
|
+
privateKeyEncoding: { type: 'pkcs1', format: 'pem' },
|
|
1596
|
+
})
|
|
1597
|
+
const execLog = []
|
|
1598
|
+
const remote = new ssh2.Server({ hostKeys: [privateKey] }, (client) => {
|
|
1599
|
+
client.on('authentication', (ctx) => {
|
|
1600
|
+
if (ctx.method === 'password' && ctx.username === 'test' && ctx.password === 'secret') ctx.accept()
|
|
1601
|
+
else ctx.reject()
|
|
1602
|
+
})
|
|
1603
|
+
client.on('ready', () => {
|
|
1604
|
+
client.on('session', (accept) => {
|
|
1605
|
+
const session = accept()
|
|
1606
|
+
session.on('pty', (acceptPty) => { acceptPty() })
|
|
1607
|
+
session.on('window-change', () => {})
|
|
1608
|
+
session.on('shell', (acceptShell) => {
|
|
1609
|
+
const stream = acceptShell()
|
|
1610
|
+
stream.write('PS> ')
|
|
1611
|
+
stream.on('data', () => {})
|
|
1612
|
+
})
|
|
1613
|
+
session.on('exec', (acceptExec, _rejectExec, info) => {
|
|
1614
|
+
const command = typeof info?.command === 'string' ? info.command : ''
|
|
1615
|
+
execLog.push(command)
|
|
1616
|
+
const stream = acceptExec()
|
|
1617
|
+
if (command.includes('-EncodedCommand')) {
|
|
1618
|
+
// Windows 分支:用可辨认的值(cores=12 / cpuPct>=70)与 POSIX 分支区分
|
|
1619
|
+
let tick = 0
|
|
1620
|
+
const timer = setInterval(() => {
|
|
1621
|
+
tick += 1
|
|
1622
|
+
stream.write(JSON.stringify({
|
|
1623
|
+
cpuPct: 70 + tick, cores: 12, memTotal: 17179869184, memUsed: 9663676416, memPct: 56,
|
|
1624
|
+
diskTotal: 511000000000, diskUsed: 302000000000, diskPct: 59, uptimeSec: 1000 + tick,
|
|
1625
|
+
tcpConns: 142 + tick, rxRate: 4096 * tick, txRate: 8192 * tick,
|
|
1626
|
+
}) + '\n')
|
|
1627
|
+
}, 120)
|
|
1628
|
+
const stop = () => clearInterval(timer)
|
|
1629
|
+
stream.on('close', stop)
|
|
1630
|
+
stream.on('end', stop)
|
|
1631
|
+
return
|
|
1632
|
+
}
|
|
1633
|
+
// POSIX 分支:cmd.exe 语义——非零退出、stdout 无输出
|
|
1634
|
+
stream.exit(1)
|
|
1635
|
+
stream.end()
|
|
1636
|
+
})
|
|
1637
|
+
})
|
|
1638
|
+
})
|
|
1639
|
+
client.on('error', () => {})
|
|
1640
|
+
})
|
|
1641
|
+
await new Promise((resolve) => remote.listen(0, '127.0.0.1', resolve))
|
|
1642
|
+
const remotePort = remote.address().port
|
|
1643
|
+
const postConfig = async (body) => {
|
|
1644
|
+
const res = await fetch('http://127.0.0.1:' + String(port) + '/api/dsh-tty/config', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
|
|
1645
|
+
return { status: res.status, body: await res.json() }
|
|
1646
|
+
}
|
|
1647
|
+
await postConfig({ sshHosts: [{ name: 'b31-ssh', host: '127.0.0.1', port: remotePort, username: 'test', auth: 'password', password: 'secret', keyPath: '', passphrase: '', agentForward: false }] })
|
|
1648
|
+
const s = openSession(port)
|
|
1649
|
+
await s.open()
|
|
1650
|
+
s.client.send(JSON.stringify({ t: 'ssh', sid: 'b31', cols: 80, rows: 24, name: 'b31-ssh' }))
|
|
1651
|
+
await s.waitFor(() => s.state.ready, 12000, 'ssh ready')
|
|
1652
|
+
const statsFrames = () => s.state.frames.filter((f) => f.t === 'stats')
|
|
1653
|
+
|
|
1654
|
+
s.client.send(JSON.stringify({ t: 'statsOn', sid: 'b31' }))
|
|
1655
|
+
await s.waitFor(() => statsFrames().length >= 2, 12000, '回退后的 stats 帧')
|
|
1656
|
+
const stats = statsFrames()[statsFrames().length - 1].stats
|
|
1657
|
+
if (stats !== null && stats.cores === 12 && Number.isFinite(stats.cpuPct) && stats.cpuPct >= 70) pass('B31a POSIX 被拒后自动回退 PowerShell 分支并出帧(cores=12)')
|
|
1658
|
+
else fail('B31a 回退后出帧', JSON.stringify(stats))
|
|
1659
|
+
|
|
1660
|
+
const orderOk = execLog.length >= 2 && execLog[0].startsWith('sh -c ') && execLog[0].includes('/proc/stat') && execLog[1].includes('-EncodedCommand')
|
|
1661
|
+
if (orderOk) pass('B31b 两跳顺序正确:先 POSIX(sh -c),后 -EncodedCommand')
|
|
1662
|
+
else fail('B31b 两跳顺序', execLog.map((c) => c.slice(0, 40)).join(' | '))
|
|
1663
|
+
await sleep(1200)
|
|
1664
|
+
if (execLog.length === 2) pass('B31c 只回退一次(不无限重试)')
|
|
1665
|
+
else fail('B31c 只回退一次', 'exec 次数=' + String(execLog.length))
|
|
1666
|
+
|
|
1667
|
+
s.client.send(JSON.stringify({ t: 'statsOff', sid: 'b31' }))
|
|
1668
|
+
await sleep(400)
|
|
1669
|
+
const mark = statsFrames().length
|
|
1670
|
+
await sleep(1600)
|
|
1671
|
+
if (statsFrames().length === mark) pass('B31d statsOff 后停表(无帧)')
|
|
1672
|
+
else fail('B31d statsOff 后停表', '多出 ' + String(statsFrames().length - mark) + ' 帧')
|
|
1673
|
+
|
|
1674
|
+
s.client.send(JSON.stringify({ t: 'kill', sid: 'b31' }))
|
|
1675
|
+
await s.waitFor(() => s.state.exited !== null, 12000, 'exit 帧')
|
|
1676
|
+
s.client.close()
|
|
1677
|
+
await new Promise((resolve) => remote.close(resolve))
|
|
1678
|
+
remote.closeAllConnections?.()
|
|
1679
|
+
await postConfig({ sshHosts: [] })
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
const failed = RESULTS.filter(([kind]) => kind === 'FAIL')
|
|
1683
|
+
console.log(`\n==== 集成测试:${RESULTS.length - failed.length}/${RESULTS.length} PASS ====`)
|
|
1684
|
+
for (const [kind, name, detail] of RESULTS) console.log(` ${kind === 'PASS' ? '✔' : '✘'} ${name}${detail ? ' — ' + detail : ''}`)
|
|
1685
|
+
process.exit(failed.length > 0 ? 1 : 0)
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
run().catch((error) => {
|
|
1689
|
+
console.error('集成测试崩溃:', error)
|
|
1690
|
+
process.exit(1)
|
|
1691
|
+
})
|