@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.
@@ -0,0 +1,314 @@
1
+ /**
2
+ * @hyzyn/dsh-tty — 测试专用内存 SSH 服务端(ssh2.Server)+ SFTP subsystem。
3
+ *
4
+ * password 认证(test/secret);SFTP 请求映射到 rootDir 下的真实文件系统
5
+ * (REALPATH/OPEN/READ/WRITE/CLOSE/OPENDIR/READDIR/STAT/LSTAT/MKDIR/RMDIR/
6
+ * REMOVE/RENAME,全部路径约束在 rootDir 内防越界;fs 错误按 ENOENT → NO_SUCH_FILE
7
+ * 映射)。供 ssh-smoke(S9 直测 lib/sftp.js)与 integration(B23 走插件 HTTP
8
+ * 路由)复用;不实现 shell/exec(SFTP 测试不需要)。
9
+ */
10
+ import ssh2 from 'ssh2'
11
+ import { generateKeyPairSync, randomBytes } from 'node:crypto'
12
+ import fsp from 'node:fs/promises'
13
+ import path from 'node:path'
14
+
15
+ export const TEST_USER = 'test'
16
+ export const TEST_PASSWORD = 'secret'
17
+
18
+ /* SFTPv3 状态码(协议常量,与 ssh2 内部实现一致) */
19
+ const STATUS = { OK: 0, EOF: 1, NO_SUCH_FILE: 2, PERMISSION_DENIED: 3, FAILURE: 4, OP_UNSUPPORTED: 8 }
20
+
21
+ function attrsOf(stats) {
22
+ return {
23
+ mode: stats.mode,
24
+ uid: Number(stats.uid ?? 0),
25
+ gid: Number(stats.gid ?? 0),
26
+ size: Number(stats.size ?? 0),
27
+ atime: Math.floor(Number(stats.atimeMs ?? 0) / 1000),
28
+ mtime: Math.floor(Number(stats.mtimeMs ?? 0) / 1000),
29
+ }
30
+ }
31
+
32
+ /** 解析客户端路径并约束在 rootDir 内(防越界写测试机文件)。 */
33
+ function resolveWithin(rootDir, raw) {
34
+ const input = String(raw ?? '').trim() || '.'
35
+ const abs = path.isAbsolute(input) ? input : path.resolve(rootDir, input)
36
+ const resolved = path.resolve(abs)
37
+ if (resolved !== rootDir && !resolved.startsWith(rootDir + path.sep)) {
38
+ const error = new Error('path escapes root: ' + input)
39
+ error.code = 'EPERM'
40
+ throw error
41
+ }
42
+ return resolved
43
+ }
44
+
45
+ /* SFTP OPEN 的 pflags 位标志(SSH_FXF_*,ssh2 服务端直接给 uint32) */
46
+ const FXF = { READ: 0x01, WRITE: 0x02, APPEND: 0x04, CREAT: 0x08, TRUNC: 0x10, EXCL: 0x20 }
47
+
48
+ /** SFTP OPEN pflags(uint32)→ fs open flags(测试只用到 r/w/a 组合)。 */
49
+ function openFlagsOf(pflags) {
50
+ const flags = Number(pflags ?? 0)
51
+ const read = (flags & FXF.READ) !== 0
52
+ const write = (flags & FXF.WRITE) !== 0
53
+ if (read && !write) return 'r'
54
+ if ((flags & FXF.APPEND) !== 0) return 'a'
55
+ if (write && (flags & FXF.CREAT) !== 0 && (flags & FXF.TRUNC) !== 0) return 'w'
56
+ if (write && (flags & FXF.CREAT) !== 0) return 'a'
57
+ if (write) return 'r+'
58
+ return 'r'
59
+ }
60
+
61
+ /** 把 SFTP 请求事件接到 rootDir 文件系统上。 */
62
+ function attachSftp(sftpStream, rootDir) {
63
+ const handles = new Map() // handleBuf.toString('hex') → {type:'file', fd} | {type:'dir', entries, sent}
64
+ const newHandle = () => {
65
+ const buf = randomBytes(4)
66
+ return { buf, key: buf.toString('hex') }
67
+ }
68
+ const fail = (reqId, error, fallbackCode = STATUS.FAILURE) => {
69
+ const code = error?.code === 'ENOENT' ? STATUS.NO_SUCH_FILE : error?.code === 'EPERM' ? STATUS.PERMISSION_DENIED : fallbackCode
70
+ sftpStream.status(reqId, code, error instanceof Error ? error.message : String(error ?? 'failure'))
71
+ }
72
+ const statusOk = (reqId) => sftpStream.status(reqId, STATUS.OK)
73
+ const withHandle = (reqId, handleBuf, expectedType, run) => {
74
+ const entry = handles.get(handleBuf.toString('hex'))
75
+ if (entry === undefined || entry.type !== expectedType) {
76
+ sftpStream.status(reqId, STATUS.FAILURE, 'bad handle')
77
+ return
78
+ }
79
+ run(entry)
80
+ }
81
+
82
+ sftpStream.on('OPEN', (reqId, filename, pflags) => {
83
+ let target
84
+ try {
85
+ target = resolveWithin(rootDir, filename)
86
+ } catch (error) {
87
+ fail(reqId, error)
88
+ return
89
+ }
90
+ const flags = openFlagsOf(pflags)
91
+ fsp.open(target, flags).then((fd) => {
92
+ const { buf, key } = newHandle()
93
+ handles.set(key, { type: 'file', fd })
94
+ sftpStream.handle(reqId, buf)
95
+ }).catch((error) => fail(reqId, error))
96
+ })
97
+ sftpStream.on('READ', (reqId, handleBuf, offset, len) => {
98
+ withHandle(reqId, handleBuf, 'file', (entry) => {
99
+ const buf = Buffer.alloc(Math.min(Number(len ?? 32768), 32768))
100
+ entry.fd.read(buf, 0, buf.length, Number(offset ?? 0)).then(({ bytesRead }) => {
101
+ if (bytesRead <= 0) sftpStream.status(reqId, STATUS.EOF)
102
+ else sftpStream.data(reqId, buf.subarray(0, bytesRead))
103
+ }).catch((error) => fail(reqId, error))
104
+ })
105
+ })
106
+ sftpStream.on('WRITE', (reqId, handleBuf, offset, data) => {
107
+ withHandle(reqId, handleBuf, 'file', (entry) => {
108
+ entry.fd.write(data, 0, data.length, Number(offset ?? 0)).then(() => statusOk(reqId)).catch((error) => fail(reqId, error))
109
+ })
110
+ })
111
+ sftpStream.on('CLOSE', (reqId, handleBuf) => {
112
+ const key = handleBuf.toString('hex')
113
+ const entry = handles.get(key)
114
+ if (entry === undefined) {
115
+ sftpStream.status(reqId, STATUS.FAILURE, 'bad handle')
116
+ return
117
+ }
118
+ handles.delete(key)
119
+ if (entry.type === 'file') {
120
+ entry.fd.close().then(() => statusOk(reqId)).catch((error) => fail(reqId, error))
121
+ } else {
122
+ statusOk(reqId)
123
+ }
124
+ })
125
+ sftpStream.on('OPENDIR', (reqId, dirPath) => {
126
+ let target
127
+ try {
128
+ target = resolveWithin(rootDir, dirPath)
129
+ } catch (error) {
130
+ fail(reqId, error)
131
+ return
132
+ }
133
+ Promise.all([
134
+ fsp.readdir(target, { withFileTypes: true }),
135
+ fsp.stat(target),
136
+ ]).then(async ([dirents, dirStats]) => {
137
+ const entries = await Promise.all(dirents.map(async (dirent) => {
138
+ let stats = null
139
+ try {
140
+ stats = await fsp.lstat(path.join(target, dirent.name))
141
+ } catch {
142
+ stats = null
143
+ }
144
+ return { dirent, stats }
145
+ }))
146
+ const { buf, key } = newHandle()
147
+ handles.set(key, { type: 'dir', path: target, entries, dirStats, sent: false })
148
+ sftpStream.handle(reqId, buf)
149
+ }).catch((error) => fail(reqId, error))
150
+ })
151
+ sftpStream.on('READDIR', (reqId, handleBuf) => {
152
+ withHandle(reqId, handleBuf, 'dir', (entry) => {
153
+ if (entry.sent) {
154
+ sftpStream.status(reqId, STATUS.EOF)
155
+ return
156
+ }
157
+ entry.sent = true
158
+ sftpStream.name(reqId, entry.entries.map(({ dirent, stats }) => ({
159
+ filename: dirent.name,
160
+ longname: (dirent.isDirectory() ? 'd' : dirent.isSymbolicLink() ? 'l' : '-') + 'rw-r--r--',
161
+ attrs: stats !== null ? attrsOf(stats) : { mode: 0, uid: 0, gid: 0, size: 0, atime: 0, mtime: 0 },
162
+ })))
163
+ })
164
+ })
165
+ sftpStream.on('REALPATH', (reqId, requestPath) => {
166
+ try {
167
+ const resolved = resolveWithin(rootDir, requestPath)
168
+ sftpStream.name(reqId, [{ filename: resolved, longname: resolved }])
169
+ } catch (error) {
170
+ fail(reqId, error, STATUS.NO_SUCH_FILE)
171
+ }
172
+ })
173
+ // 以下单路径操作的 resolveWithin 守卫可能同步 throw(路径越出 rootDir),
174
+ // 必须 try/catch 后回 status 包——否则响应丢失,客户端会永远挂起。
175
+ sftpStream.on('STAT', (reqId, requestPath) => {
176
+ let target
177
+ try {
178
+ target = resolveWithin(rootDir, requestPath)
179
+ } catch (error) {
180
+ fail(reqId, error)
181
+ return
182
+ }
183
+ fsp.stat(target).then((stats) => sftpStream.attrs(reqId, attrsOf(stats))).catch((error) => fail(reqId, error))
184
+ })
185
+ sftpStream.on('LSTAT', (reqId, requestPath) => {
186
+ let target
187
+ try {
188
+ target = resolveWithin(rootDir, requestPath)
189
+ } catch (error) {
190
+ fail(reqId, error)
191
+ return
192
+ }
193
+ fsp.lstat(target).then((stats) => sftpStream.attrs(reqId, attrsOf(stats))).catch((error) => fail(reqId, error))
194
+ })
195
+ sftpStream.on('MKDIR', (reqId, requestPath) => {
196
+ let target
197
+ try {
198
+ target = resolveWithin(rootDir, requestPath)
199
+ } catch (error) {
200
+ fail(reqId, error)
201
+ return
202
+ }
203
+ fsp.mkdir(target).then(() => statusOk(reqId)).catch((error) => fail(reqId, error))
204
+ })
205
+ sftpStream.on('RMDIR', (reqId, requestPath) => {
206
+ let target
207
+ try {
208
+ target = resolveWithin(rootDir, requestPath)
209
+ } catch (error) {
210
+ fail(reqId, error)
211
+ return
212
+ }
213
+ fsp.rmdir(target).then(() => statusOk(reqId)).catch((error) => fail(reqId, error))
214
+ })
215
+ sftpStream.on('REMOVE', (reqId, requestPath) => {
216
+ let target
217
+ try {
218
+ target = resolveWithin(rootDir, requestPath)
219
+ } catch (error) {
220
+ fail(reqId, error)
221
+ return
222
+ }
223
+ fsp.unlink(target).then(() => statusOk(reqId)).catch((error) => fail(reqId, error))
224
+ })
225
+ sftpStream.on('RENAME', (reqId, from, to) => {
226
+ try {
227
+ const src = resolveWithin(rootDir, from)
228
+ const dest = resolveWithin(rootDir, to)
229
+ fsp.rename(src, dest).then(() => statusOk(reqId)).catch((error) => fail(reqId, error))
230
+ } catch (error) {
231
+ fail(reqId, error)
232
+ }
233
+ })
234
+ // 客户端 WriteStream 未传 mode 时不会发;万一发了按成功忽略
235
+ sftpStream.on('SETSTAT', (reqId) => statusOk(reqId))
236
+ sftpStream.on('FSETSTAT', (reqId) => statusOk(reqId))
237
+ sftpStream.on('EXTENDED', (reqId) => sftpStream.status(reqId, STATUS.OP_UNSUPPORTED, 'extended ops unsupported'))
238
+ }
239
+
240
+ async function listenOnce(server) {
241
+ let lastError = null
242
+ for (let attempt = 0; attempt < 10; attempt++) {
243
+ try {
244
+ await new Promise((resolve, reject) => {
245
+ server.once('error', reject)
246
+ server.listen(0, '127.0.0.1', () => {
247
+ server.removeListener('error', reject)
248
+ resolve()
249
+ })
250
+ })
251
+ server.on('error', (error) => {
252
+ console.error('[test-sshd] server 错误: ' + error.message)
253
+ })
254
+ return
255
+ } catch (error) {
256
+ lastError = error
257
+ await new Promise((resolve) => setTimeout(resolve, 300))
258
+ }
259
+ }
260
+ throw lastError ?? new Error('test-sshd listen 重试耗尽')
261
+ }
262
+
263
+ /**
264
+ * 起一个带 SFTP 的内存 sshd(127.0.0.1 随机端口)。
265
+ * @param {object} options
266
+ * @param {string} options.rootDir SFTP 可见根目录(须为绝对路径)
267
+ * @returns {Promise<{server: ssh2.Server, port: number, rootDir: string, close: () => Promise<void>}>}
268
+ */
269
+ export async function startSftpSshd({ rootDir }) {
270
+ if (!path.isAbsolute(rootDir)) throw new Error('rootDir 必须是绝对路径')
271
+ const { privateKey } = generateKeyPairSync('rsa', {
272
+ modulusLength: 2048,
273
+ publicKeyEncoding: { type: 'spki', format: 'pem' },
274
+ privateKeyEncoding: { type: 'pkcs1', format: 'pem' },
275
+ })
276
+ const server = new ssh2.Server({ hostKeys: [privateKey] }, (client) => {
277
+ client.on('authentication', (ctx) => {
278
+ if (ctx.method === 'password' && ctx.username === TEST_USER && ctx.password === TEST_PASSWORD) {
279
+ ctx.accept()
280
+ return
281
+ }
282
+ ctx.reject()
283
+ })
284
+ client.on('ready', () => {
285
+ client.on('session', (accept) => {
286
+ const session = accept()
287
+ session.on('sftp', (acceptSftp) => {
288
+ attachSftp(acceptSftp(), rootDir)
289
+ })
290
+ // 无 shell/exec 监听:ssh2 服务端对未监听的请求自动拒绝
291
+ })
292
+ })
293
+ client.on('error', () => {
294
+ /* 认证失败/客户端断开等,测试忽略 */
295
+ })
296
+ })
297
+ await listenOnce(server)
298
+ const port = /** @type {import('node:net').AddressInfo} */ (server.address()).port
299
+ return {
300
+ server,
301
+ port,
302
+ rootDir,
303
+ close: async () => {
304
+ await new Promise((resolve) => {
305
+ const timer = setTimeout(resolve, 3000).unref()
306
+ server.close(() => {
307
+ clearTimeout(timer)
308
+ resolve()
309
+ })
310
+ })
311
+ server.closeAllConnections?.()
312
+ },
313
+ }
314
+ }
@@ -0,0 +1,68 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * @hyzyn/dsh-tty — 对「正在运行的 dsh web」做存活探测与端到端冒烟。
4
+ * 用法:node scripts/live.mjs [wsUrl] (默认 ws://127.0.0.1:3080/api/dsh-tty/ws,可用 DSH_TTY_WS_URL 覆盖)
5
+ * 用途:确认用户重启 dsh web 后插件宿主半体已加载;未重启时路由不存在,连接即失败。
6
+ * 退出码:0 = 路由存在且 spawn→echo→kill→exit 全链路通过;1 = 失败;2 = 路由未注册。
7
+ */
8
+ import WebSocket from 'ws'
9
+
10
+ // 默认地址可用环境变量 DSH_TTY_WS_URL 覆盖(不同 profile 的实例端口不同)
11
+ const url = process.argv[2] ?? process.env.DSH_TTY_WS_URL ?? 'ws://127.0.0.1:3080/api/dsh-tty/ws'
12
+ const client = new WebSocket(url)
13
+ client.setMaxListeners(0)
14
+
15
+ const timer = setTimeout(() => {
16
+ console.error('[live] 连接超时(路由可能未注册或服务未启动)')
17
+ process.exit(2)
18
+ }, 8000)
19
+
20
+ let text = ''
21
+ let sawReady = false
22
+ let sawExit = false
23
+ let killed = false
24
+
25
+ client.on('open', () => {
26
+ clearTimeout(timer)
27
+ console.log('[live] 路由存在,WS 已连接(插件宿主半体已加载)')
28
+ client.send(JSON.stringify({ t: 'spawn', cols: 80, rows: 24 }))
29
+ })
30
+
31
+ client.on('message', (raw) => {
32
+ const msg = JSON.parse(raw.toString())
33
+ if (msg.t === 'ready') {
34
+ sawReady = true
35
+ console.log('[live] ready pid=' + msg.pid)
36
+ client.send(JSON.stringify({ t: 'input', d: 'printf "LIVE_OK_%s\\n" "$TERM"\n' }))
37
+ } else if (msg.t === 'data') {
38
+ text += String(msg.d ?? '')
39
+ if (!killed && /LIVE_OK_xterm-256color/.test(text)) {
40
+ killed = true
41
+ console.log('[live] TERM 注入 + 数据通道 OK(xterm-256color)')
42
+ client.send(JSON.stringify({ t: 'kill' }))
43
+ }
44
+ } else if (msg.t === 'exit') {
45
+ sawExit = true
46
+ console.log('[live] exit code=' + msg.code + ' signal=' + msg.signal)
47
+ client.close()
48
+ console.log('[live] 端到端冒烟通过')
49
+ process.exit(0)
50
+ } else if (msg.t === 'error') {
51
+ console.error('[live] 服务端错误:' + String(msg.m ?? ''))
52
+ process.exit(1)
53
+ }
54
+ })
55
+
56
+ client.on('error', (error) => {
57
+ clearTimeout(timer)
58
+ console.error('[live] 连接失败:' + error.message + '(dsh web 可能尚未重启,插件未加载)')
59
+ process.exit(2)
60
+ })
61
+
62
+ client.on('close', () => {
63
+ if (!sawExit) {
64
+ clearTimeout(timer)
65
+ console.error('[live] 连接意外关闭(sawReady=' + sawReady + ')')
66
+ process.exit(1)
67
+ }
68
+ })
@@ -0,0 +1,75 @@
1
+ <!doctype html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <title>dsh-tty preview</title>
6
+ <link rel="stylesheet" href="./skin.css" />
7
+ <style>
8
+ html, body { margin: 0; height: 100%; }
9
+ body {
10
+ display: flex;
11
+ background: var(--dsw-alias-bg-base);
12
+ color: var(--dsw-alias-label-primary);
13
+ font: 14px/1.5 system-ui, -apple-system, "PingFang SC", "Helvetica Neue", sans-serif;
14
+ }
15
+ [data-pane="sidebar"] {
16
+ flex: none;
17
+ width: 236px;
18
+ padding: 10px 10px 14px;
19
+ box-sizing: border-box;
20
+ background: var(--dsw-specific-sidebar-fill);
21
+ border-right: 1px solid var(--dsw-alias-border-l1);
22
+ }
23
+ .sidebarInner { display: flex; flex-direction: column; gap: 4px; height: 100%; }
24
+ .logoRow { display: flex; align-items: center; gap: 8px; padding: 6px 12px 10px; font-weight: 600; color: var(--dsw-alias-label-primary); }
25
+ .logoMark { width: 22px; height: 22px; border-radius: 7px; background: var(--dsw-alias-brand-primary); color: var(--dsw-alias-label-primary-inverted); display: grid; place-items: center; font-size: 12px; font-weight: 700; }
26
+ .newSession {
27
+ display: flex; align-items: center; gap: 8px; width: 100%; height: 34px; padding: 0 12px;
28
+ border: 1px dashed var(--dsw-alias-border-l2); border-radius: 10px; background: 0 0;
29
+ color: var(--dsw-alias-label-secondary); font: inherit; font-size: 13px; cursor: pointer; margin-bottom: 6px;
30
+ }
31
+ .fakeEntry { display: flex; align-items: center; gap: 8px; height: 32px; padding: 0 12px; border-radius: 8px; color: var(--dsw-alias-label-secondary); font-size: 13px; }
32
+ .spacer { flex: 1; }
33
+ .main { flex: 1; display: grid; place-items: center; color: var(--dsw-alias-label-tertiary); font-size: 13px; }
34
+ /* 设置卡片预览容器:滚动条跟随主题,避免截图里出现浅色默认滚动条 */
35
+ #preview-settings { scrollbar-width: thin; scrollbar-color: var(--dsw-alias-scrollbar-bg-l1, rgba(127,127,127,.45)) transparent; }
36
+ #preview-settings::-webkit-scrollbar { width: 10px; }
37
+ #preview-settings::-webkit-scrollbar-track { background: transparent; }
38
+ #preview-settings::-webkit-scrollbar-thumb { border: 3px solid transparent; border-radius: 999px; background: var(--dsw-alias-scrollbar-bg-l1, rgba(127,127,127,.4)); background-clip: content-box; }
39
+ .mainCard { padding: 18px 26px; border: 1px dashed var(--dsw-alias-border-l2); border-radius: 14px; }
40
+ </style>
41
+ </head>
42
+ <body>
43
+ <div data-pane="sidebar" class="sidebarCol">
44
+ <div class="sidebarInner">
45
+ <div class="logoRow"><span class="logoMark">D</span><span>DeepSeek Harness</span></div>
46
+ <button class="newSession">+ 新会话</button>
47
+ <div class="fakeEntry" data-dsh-global-search-entry>全局搜索</div>
48
+ <div class="spacer"></div>
49
+ </div>
50
+ </div>
51
+ <div class="main"><div class="mainCard">DSH 主区域(预览占位)</div></div>
52
+
53
+ <script>
54
+ // 场景开关(必须在 mock-host.js 之前执行)
55
+ const _q = new URLSearchParams(location.search)
56
+ if (_q.get('sftp')) window.__PREVIEW_SFTP_STYLE = _q.get('sftp')
57
+ if (_q.get('persist')) window.__PREVIEW_PERSISTENCE = _q.get('persist')
58
+ // 样例路径(终端 cwd / SFTP 本机栏 / 家目录)由 preview.mjs 运行时注入,
59
+ // 源码里不留任何开发者机器路径;脱离 preview.mjs 打开时用中性占位
60
+ window.__PREVIEW_CWD = _q.get('cwd') || '/home/user/project'
61
+ window.__PREVIEW_HOME = _q.get('home') || '/home/user'
62
+ if (_q.get('theme') !== 'light') document.documentElement.dataset.previewDark = ''
63
+ document.addEventListener('DOMContentLoaded', () => {
64
+ if (_q.get('theme') !== 'light') document.body.setAttribute('data-ds-dark-theme', '')
65
+ })
66
+ </script>
67
+ <script src="./vendor/react.js"></script>
68
+ <script src="./vendor/react-dom.js"></script>
69
+ <script src="./mock-host.js"></script>
70
+ <script src="./client.js"></script>
71
+ <!-- dsh-docker 的浏览器半体:用于「ttyTerminal.mount 就地嵌入」的端到端联调 -->
72
+ <script src="./docker-client.js"></script>
73
+ <script src="./harness.js"></script>
74
+ </body>
75
+ </html>