@hyzyn/dsh-tty 0.21.0 → 0.22.0-rc.2
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 +113 -10
- package/README.md +117 -10
- package/client.js +150 -36
- package/lib/index.d.ts +195 -25
- package/lib/index.js +930 -149
- package/lib/index.js.map +1 -1
- package/lib/probe.d.ts +29 -1
- package/lib/probe.js +98 -19
- package/lib/probe.js.map +1 -1
- package/lib/sftp.js +37 -9
- package/lib/sftp.js.map +1 -1
- package/lib/shell-integration.js +25 -1
- package/lib/shell-integration.js.map +1 -1
- package/lib/ssh-config.d.ts +34 -2
- package/lib/ssh-config.js +150 -32
- package/lib/ssh-config.js.map +1 -1
- package/lib/ssh.d.ts +256 -2
- package/lib/ssh.js +637 -6
- package/lib/ssh.js.map +1 -1
- package/lib/tunnels.js +47 -5
- package/lib/tunnels.js.map +1 -1
- package/package.json +4 -2
- package/scripts/integration.mjs +183 -20
- package/scripts/jump-smoke.mjs +175 -0
- package/scripts/lib/proxy-bridge.mjs +30 -0
- package/scripts/preview/harness.js +74 -26
- package/scripts/probe-route-smoke.mjs +10 -1
- package/scripts/proxycommand-smoke.mjs +267 -0
- package/scripts/windows-smoke.mjs +123 -1
package/scripts/integration.mjs
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* B14. tty_screen 虚拟屏 + tty_capture ANSI 清洗
|
|
20
20
|
* B15. shell 集成(OSC 133 命令捕获/退出码 + OSC 7 cwd 跟随)
|
|
21
21
|
* B16. ~/.ssh/config 解析器
|
|
22
|
-
* B17. tty_expect
|
|
22
|
+
* B17. tty_expect 匹配与超时(含 D72:命令已结束后的回溯命中、回显不算命中)
|
|
23
23
|
* B18. data 帧合并后帧序不变量(exit 在最后一帧 data 之后)
|
|
24
24
|
* B19. known_hosts 解析器
|
|
25
25
|
* B20. 端口转发隧道(forwardOut 往返 + forwardIn 就绪 + 状态与工具)
|
|
@@ -38,6 +38,9 @@
|
|
|
38
38
|
* (不新建 PTY / 不占名额)、输出扇出、kill 广播 exit
|
|
39
39
|
* B29. 禁用热生效(enabled 开关):工具/公告撤下与恢复、数据路由 403、
|
|
40
40
|
* /config 保持可读写、存量 WS 被关闭与新升级被拒
|
|
41
|
+
* B34. 退出后的只读保留(D77):命令跑完仍可读、写被拒、tty_close 释放
|
|
42
|
+
* B35. `tty_open command=` 执行整段 shell 代码(D78):`a; b` / `cd x && cmd` /
|
|
43
|
+
* `for` 不被 exec 截断,退出码取自最后一条
|
|
41
44
|
*
|
|
42
45
|
* 用法:pnpm --filter @hyzyn/dsh-tty integration
|
|
43
46
|
* 退出码:0 = 全部 PASS,1 = 任一 FAIL。
|
|
@@ -56,6 +59,15 @@ import { parseKnownHosts } from '../lib/known-hosts.js'
|
|
|
56
59
|
import { assertSupportedJsonSchema, validateJsonSchemaValue } from '@deepseek-ai/dsh-tools'
|
|
57
60
|
import { startSftpSshd, TEST_USER, TEST_PASSWORD } from './lib/test-sshd.mjs'
|
|
58
61
|
|
|
62
|
+
/*
|
|
63
|
+
* 变更端点要求「同源证明」(docker D32 口径,见 docs/architecture.md § 7 与
|
|
64
|
+
* packages/tty/src/index.ts 的 gateRoute):浏览器同源 fetch 必带
|
|
65
|
+
* `sec-fetch-site: same-origin`——本脚本模拟的正是**面板**这条调用路径,所以照带。
|
|
66
|
+
* 少了它,/probe 与 /sftp 的写动作会被 403「缺少同源证明」(本脚本此前就踩在这里)。
|
|
67
|
+
*/
|
|
68
|
+
const SAME_ORIGIN = { 'sec-fetch-site': 'same-origin' }
|
|
69
|
+
|
|
70
|
+
|
|
59
71
|
|
|
60
72
|
/* 全局看门狗:任何环节卡死时留痕退出(正常路径会先 process.exit)。 */
|
|
61
73
|
setTimeout(() => {
|
|
@@ -432,11 +444,11 @@ async function run() {
|
|
|
432
444
|
const d1 = await res1.json().catch(() => ({}))
|
|
433
445
|
if (res1.status === 200 && d1.ok === true && typeof d1.config?.shell === 'string') pass('B10a GET 当前配置')
|
|
434
446
|
else fail('B10a GET 当前配置', res1.status + ' ' + JSON.stringify(d1))
|
|
435
|
-
const res2 = await fetch(base, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ maxSessions: 3, term: 'xterm-256color' }) })
|
|
447
|
+
const res2 = await fetch(base, { method: 'POST', headers: { ...SAME_ORIGIN, 'content-type': 'application/json' }, body: JSON.stringify({ maxSessions: 3, term: 'xterm-256color' }) })
|
|
436
448
|
const d2 = await res2.json().catch(() => ({}))
|
|
437
449
|
if (res2.status === 200 && d2.ok === true && d2.config?.maxSessions === 3) pass('B10b POST 配置生效(maxSessions=3)')
|
|
438
450
|
else fail('B10b POST 配置生效(maxSessions=3)', res2.status + ' ' + JSON.stringify(d2))
|
|
439
|
-
const res3 = await fetch(base, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ maxSessions: 99 }) })
|
|
451
|
+
const res3 = await fetch(base, { method: 'POST', headers: { ...SAME_ORIGIN, 'content-type': 'application/json' }, body: JSON.stringify({ maxSessions: 99 }) })
|
|
440
452
|
const d3 = await res3.json().catch(() => ({}))
|
|
441
453
|
if (res3.status === 400 && /maxSessions/.test(String(d3.error ?? ''))) pass('B10c 非法 maxSessions 被拒')
|
|
442
454
|
else fail('B10c 非法 maxSessions 被拒', res3.status + ' ' + JSON.stringify(d3))
|
|
@@ -446,7 +458,7 @@ async function run() {
|
|
|
446
458
|
console.log('\n[10] 超限恢复')
|
|
447
459
|
{
|
|
448
460
|
const base = `http://127.0.0.1:${port}/api/dsh-tty/config`
|
|
449
|
-
await fetch(base, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ maxSessions: 2 }) })
|
|
461
|
+
await fetch(base, { method: 'POST', headers: { ...SAME_ORIGIN, 'content-type': 'application/json' }, body: JSON.stringify({ maxSessions: 2 }) })
|
|
450
462
|
await sleep(200)
|
|
451
463
|
const s1 = openSession(port)
|
|
452
464
|
await s1.open()
|
|
@@ -474,7 +486,7 @@ async function run() {
|
|
|
474
486
|
s1.client.close()
|
|
475
487
|
s2.client.close()
|
|
476
488
|
s3.client.close()
|
|
477
|
-
await fetch(base, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ maxSessions: 4 }) })
|
|
489
|
+
await fetch(base, { method: 'POST', headers: { ...SAME_ORIGIN, 'content-type': 'application/json' }, body: JSON.stringify({ maxSessions: 4 }) })
|
|
478
490
|
}
|
|
479
491
|
|
|
480
492
|
// B12: agent 工具集(tty_list / tty_capture / tty_send)
|
|
@@ -732,6 +744,29 @@ async function run() {
|
|
|
732
744
|
const missResult = await expect.execute({ sid: 'ex-1', pattern: 'NEVER_APPEARS_XYZ', timeoutSec: 1 })
|
|
733
745
|
if (missResult.matched === false && missResult.timedOut === true && typeof missResult.text === 'string') pass('B17b tty_expect 超时不抛错(matched=false, timedOut=true)')
|
|
734
746
|
else fail('B17b tty_expect 超时不抛错(matched=false, timedOut=true)', JSON.stringify(missResult).slice(0, 160))
|
|
747
|
+
|
|
748
|
+
// B17c(D72):命令**早已跑完**(输出到达发生在注册之前)时也要回溯命中。
|
|
749
|
+
// 真实场景就是 AI 无法预测命令耗时:`tty_send` 与 `tty_expect` 之间隔着一次
|
|
750
|
+
// 模型推理,命令若在这个窗口里跑完,旧实现只能白等满超时并返回空文本。
|
|
751
|
+
// 这里刻意「先等输出落地、再注册」,把那个窗口显式做出来。
|
|
752
|
+
s.client.send(JSON.stringify({ t: 'input', sid: 'ex-1', d: 'printf "BACKFILL_%s\\n" MARK\n' }))
|
|
753
|
+
await s.waitFor(() => /BACKFILL_MARK/.test(s.state.text), 10000, 'BACKFILL_MARK 输出')
|
|
754
|
+
await sleep(500) // 等 D 标记与 prompt 落定:注册这一刻命令已经结束
|
|
755
|
+
const backStarted = Date.now()
|
|
756
|
+
const backResult = await expect.execute({ sid: 'ex-1', pattern: 'BACKFILL_MARK', timeoutSec: 8 })
|
|
757
|
+
const backElapsed = Date.now() - backStarted
|
|
758
|
+
// matchedFrom 必须是回溯(last = 上一条命令的输出 / buffered = 未读缓冲):
|
|
759
|
+
// 若回成 live,说明它是注册之后才到的,这条用例就没有意义了
|
|
760
|
+
if (backResult.matched === true && backResult.matchedFrom !== undefined && backResult.matchedFrom !== 'live' && backElapsed < 2000) pass(`B17c 命令已结束后注册仍回溯命中(matchedFrom=${backResult.matchedFrom},${backElapsed}ms)`)
|
|
761
|
+
else fail('B17c 命令已结束后注册仍回溯命中', JSON.stringify(backResult).slice(0, 160) + ` elapsed=${backElapsed}ms`)
|
|
762
|
+
|
|
763
|
+
// B17d(D72):回显**不算**命中。上面那条命令的回显里带着 BACKFILL_%s(输出是
|
|
764
|
+
// BACKFILL_MARK),它落在 shell 集成的 A..B 之间——真实 zsh / bash 是否真的如此,
|
|
765
|
+
// 是「用 A..B 边界去回显」这个设计唯一没法在单测里自证的前提(单测的标记是
|
|
766
|
+
// 自己造的,属循环论证),所以在这里拿真 shell 钉一次。
|
|
767
|
+
const echoResult = await expect.execute({ sid: 'ex-1', pattern: 'BACKFILL_%s', timeoutSec: 1 })
|
|
768
|
+
if (echoResult.matched === false && echoResult.timedOut === true) pass('B17d 回显里的字样不算命中(A..B 边界在真 shell 上成立)')
|
|
769
|
+
else fail('B17d 回显里的字样不算命中(A..B 边界在真 shell 上成立)', JSON.stringify(echoResult).slice(0, 160) + '(若本机 shell 未启用集成,这是降级语义而非回归)')
|
|
735
770
|
}
|
|
736
771
|
s.client.send(JSON.stringify({ t: 'kill', sid: 'ex-1' }))
|
|
737
772
|
await s.waitFor(() => s.state.exited !== null, 10000, 'ex-1 exit')
|
|
@@ -858,7 +893,7 @@ async function run() {
|
|
|
858
893
|
})
|
|
859
894
|
const sshPort = sshServer.address().port
|
|
860
895
|
const post = async (body) => {
|
|
861
|
-
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) })
|
|
896
|
+
const res = await fetch(`http://127.0.0.1:${port}/api/dsh-tty/config`, { method: 'POST', headers: { ...SAME_ORIGIN, 'content-type': 'application/json' }, body: JSON.stringify(body) })
|
|
862
897
|
return res.json()
|
|
863
898
|
}
|
|
864
899
|
const put = await post({
|
|
@@ -944,7 +979,7 @@ async function run() {
|
|
|
944
979
|
} else {
|
|
945
980
|
await fetch(`http://127.0.0.1:${port}/api/dsh-tty/config`, {
|
|
946
981
|
method: 'POST',
|
|
947
|
-
headers: { 'content-type': 'application/json' },
|
|
982
|
+
headers: { ...SAME_ORIGIN, 'content-type': 'application/json' },
|
|
948
983
|
body: JSON.stringify({ shell: '/bin/bash' }),
|
|
949
984
|
})
|
|
950
985
|
const s = openSession(port)
|
|
@@ -995,7 +1030,7 @@ async function run() {
|
|
|
995
1030
|
const rootDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'dsh-tty-b23-'))
|
|
996
1031
|
const sftpd = await startSftpSshd({ rootDir })
|
|
997
1032
|
const post = async (body) => {
|
|
998
|
-
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) })
|
|
1033
|
+
const res = await fetch(`http://127.0.0.1:${port}/api/dsh-tty/config`, { method: 'POST', headers: { ...SAME_ORIGIN, 'content-type': 'application/json' }, body: JSON.stringify(body) })
|
|
999
1034
|
return res.json()
|
|
1000
1035
|
}
|
|
1001
1036
|
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 }] })
|
|
@@ -1006,7 +1041,7 @@ async function run() {
|
|
|
1006
1041
|
const sftpApi = async (action, payload) => {
|
|
1007
1042
|
const res = await fetch(`http://127.0.0.1:${port}/api/dsh-tty/sftp/${action}`, {
|
|
1008
1043
|
method: 'POST',
|
|
1009
|
-
headers: { 'content-type': 'application/json' },
|
|
1044
|
+
headers: { ...SAME_ORIGIN, 'content-type': 'application/json' },
|
|
1010
1045
|
body: JSON.stringify({ name: 'b23-ssh', ...payload }),
|
|
1011
1046
|
})
|
|
1012
1047
|
const data = await res.json().catch(() => ({}))
|
|
@@ -1027,7 +1062,7 @@ async function run() {
|
|
|
1027
1062
|
const payload = Buffer.alloc(4096, 0x62)
|
|
1028
1063
|
const up = await fetch(`http://127.0.0.1:${port}/api/dsh-tty/sftp/upload`, {
|
|
1029
1064
|
method: 'POST',
|
|
1030
|
-
headers: { 'x-dsh-sftp-meta': Buffer.from(JSON.stringify({ name: 'b23-ssh', path: rootDir + '/b23dir/up.bin' })).toString('base64url') },
|
|
1065
|
+
headers: { ...SAME_ORIGIN, 'x-dsh-sftp-meta': Buffer.from(JSON.stringify({ name: 'b23-ssh', path: rootDir + '/b23dir/up.bin' })).toString('base64url') },
|
|
1031
1066
|
body: payload,
|
|
1032
1067
|
})
|
|
1033
1068
|
const upData = await up.json().catch(() => ({}))
|
|
@@ -1046,7 +1081,7 @@ async function run() {
|
|
|
1046
1081
|
// d) download:流式响应 + content-disposition + 字节一致
|
|
1047
1082
|
const dl = await fetch(`http://127.0.0.1:${port}/api/dsh-tty/sftp/download`, {
|
|
1048
1083
|
method: 'POST',
|
|
1049
|
-
headers: { 'content-type': 'application/json' },
|
|
1084
|
+
headers: { ...SAME_ORIGIN, 'content-type': 'application/json' },
|
|
1050
1085
|
body: JSON.stringify({ name: 'b23-ssh', path: rootDir + '/b23dir/up.bin' }),
|
|
1051
1086
|
})
|
|
1052
1087
|
const dlBuf = Buffer.from(await dl.arrayBuffer())
|
|
@@ -1109,7 +1144,7 @@ async function run() {
|
|
|
1109
1144
|
const rootDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'dsh-tty-b24-'))
|
|
1110
1145
|
const sftpd = await startSftpSshd({ rootDir })
|
|
1111
1146
|
const post = async (body) => {
|
|
1112
|
-
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) })
|
|
1147
|
+
const res = await fetch(`http://127.0.0.1:${port}/api/dsh-tty/config`, { method: 'POST', headers: { ...SAME_ORIGIN, 'content-type': 'application/json' }, body: JSON.stringify(body) })
|
|
1113
1148
|
return res.json()
|
|
1114
1149
|
}
|
|
1115
1150
|
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 }] })
|
|
@@ -1221,7 +1256,7 @@ async function run() {
|
|
|
1221
1256
|
return tmuxListError !== null ? [] : out.stdout.trim().split('\n').filter(Boolean)
|
|
1222
1257
|
}
|
|
1223
1258
|
const post = async (body) => {
|
|
1224
|
-
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) })
|
|
1259
|
+
const res = await fetch(`http://127.0.0.1:${port}/api/dsh-tty/config`, { method: 'POST', headers: { ...SAME_ORIGIN, 'content-type': 'application/json' }, body: JSON.stringify(body) })
|
|
1225
1260
|
return res.json()
|
|
1226
1261
|
}
|
|
1227
1262
|
const probe = await execFileP('tmux', ['-V'])
|
|
@@ -1397,7 +1432,7 @@ async function run() {
|
|
|
1397
1432
|
console.log('\n[24] 跨窗口共享持久会话')
|
|
1398
1433
|
{
|
|
1399
1434
|
const post = async (body) => {
|
|
1400
|
-
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) })
|
|
1435
|
+
const res = await fetch(`http://127.0.0.1:${port}/api/dsh-tty/config`, { method: 'POST', headers: { ...SAME_ORIGIN, 'content-type': 'application/json' }, body: JSON.stringify(body) })
|
|
1401
1436
|
return res.json()
|
|
1402
1437
|
}
|
|
1403
1438
|
await post({ persistence: 'tmux' })
|
|
@@ -1437,7 +1472,7 @@ async function run() {
|
|
|
1437
1472
|
const probe = async (body) => {
|
|
1438
1473
|
const res = await fetch(`http://127.0.0.1:${port}/api/dsh-tty/probe`, {
|
|
1439
1474
|
method: 'POST',
|
|
1440
|
-
headers: { 'content-type': 'application/json' },
|
|
1475
|
+
headers: { ...SAME_ORIGIN, 'content-type': 'application/json' },
|
|
1441
1476
|
body: JSON.stringify(body),
|
|
1442
1477
|
})
|
|
1443
1478
|
return res.json()
|
|
@@ -1500,7 +1535,7 @@ async function run() {
|
|
|
1500
1535
|
console.log('\n[29] 禁用热生效与恢复(enabled 开关)')
|
|
1501
1536
|
{
|
|
1502
1537
|
const configPost = async (body) => {
|
|
1503
|
-
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) })
|
|
1538
|
+
const res = await fetch(`http://127.0.0.1:${port}/api/dsh-tty/config`, { method: 'POST', headers: { ...SAME_ORIGIN, 'content-type': 'application/json' }, body: JSON.stringify(body) })
|
|
1504
1539
|
return { status: res.status, body: await res.json() }
|
|
1505
1540
|
}
|
|
1506
1541
|
|
|
@@ -1516,7 +1551,7 @@ async function run() {
|
|
|
1516
1551
|
else fail('B29b 禁用后 agent 工具全部撤下', `残留: ${toolDefs.map((d) => d.name).join(',')}`)
|
|
1517
1552
|
if (promptParts.length === 0) pass('B29c 禁用后公告与动态快照撤下')
|
|
1518
1553
|
else fail('B29c 禁用后公告与动态快照撤下', `残留 ${String(promptParts.length)}`)
|
|
1519
|
-
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' }) })
|
|
1554
|
+
const probeRes = await fetch(`http://127.0.0.1:${port}/api/dsh-tty/probe`, { method: 'POST', headers: { ...SAME_ORIGIN, 'content-type': 'application/json' }, body: JSON.stringify({ host: '127.0.0.1', username: 'u' }) })
|
|
1520
1555
|
if (probeRes.status === 403) pass('B29d 禁用后数据路由 403(/probe)')
|
|
1521
1556
|
else fail('B29d 禁用后数据路由 403(/probe)', `status=${String(probeRes.status)}`)
|
|
1522
1557
|
const cfgGet = await fetch(`http://127.0.0.1:${port}/api/dsh-tty/config`)
|
|
@@ -1616,7 +1651,7 @@ async function run() {
|
|
|
1616
1651
|
await new Promise((resolve) => remote.listen(0, '127.0.0.1', resolve))
|
|
1617
1652
|
const remotePort = remote.address().port
|
|
1618
1653
|
const postConfig = async (body) => {
|
|
1619
|
-
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) })
|
|
1654
|
+
const res = await fetch('http://127.0.0.1:' + String(port) + '/api/dsh-tty/config', { method: 'POST', headers: { ...SAME_ORIGIN, 'content-type': 'application/json' }, body: JSON.stringify(body) })
|
|
1620
1655
|
return { status: res.status, body: await res.json() }
|
|
1621
1656
|
}
|
|
1622
1657
|
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 }] })
|
|
@@ -1741,7 +1776,7 @@ async function run() {
|
|
|
1741
1776
|
await new Promise((resolve) => remote.listen(0, '127.0.0.1', resolve))
|
|
1742
1777
|
const remotePort = remote.address().port
|
|
1743
1778
|
const postConfig = async (body) => {
|
|
1744
|
-
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) })
|
|
1779
|
+
const res = await fetch('http://127.0.0.1:' + String(port) + '/api/dsh-tty/config', { method: 'POST', headers: { ...SAME_ORIGIN, 'content-type': 'application/json' }, body: JSON.stringify(body) })
|
|
1745
1780
|
return { status: res.status, body: await res.json() }
|
|
1746
1781
|
}
|
|
1747
1782
|
await postConfig({ sshHosts: [{ name: 'b31-ssh', host: '127.0.0.1', port: remotePort, username: 'test', auth: 'password', password: 'secret', keyPath: '', passphrase: '', agentForward: false }] })
|
|
@@ -1864,12 +1899,140 @@ async function run() {
|
|
|
1864
1899
|
}
|
|
1865
1900
|
}
|
|
1866
1901
|
|
|
1902
|
+
// B34: 退出后的只读保留(D77,用户上报 issue #4 的正题)
|
|
1903
|
+
//
|
|
1904
|
+
// 现场:`tty_open` 跑一条**会结束**的命令,跑完会话就退役——输出还在终端上,
|
|
1905
|
+
// AI 一个字符都取不回来(用户被迫先开 `/bin/sh` 再往里塞命令)。这一条用真 PTY
|
|
1906
|
+
// 走一遍修好之后的链路:命令跑完 → 清单里仍有它(exited)→ tty_capture 读得到
|
|
1907
|
+
// 输出 → tty_send 拒写 → tty_close 释放。
|
|
1908
|
+
console.log('\n[31] 退出后的只读保留(D77)')
|
|
1909
|
+
{
|
|
1910
|
+
const open77 = toolDefs.find((d) => d.name === 'tty_open')
|
|
1911
|
+
const close77 = toolDefs.find((d) => d.name === 'tty_close')
|
|
1912
|
+
const list77 = toolDefs.find((d) => d.name === 'tty_list')
|
|
1913
|
+
const capture77 = toolDefs.find((d) => d.name === 'tty_capture')
|
|
1914
|
+
const send77 = toolDefs.find((d) => d.name === 'tty_send')
|
|
1915
|
+
if (open77 === undefined || close77 === undefined || list77 === undefined || capture77 === undefined || send77 === undefined) {
|
|
1916
|
+
fail('B34 D77 工具集', '缺工具: ' + toolDefs.map((d) => d.name).join(','))
|
|
1917
|
+
} else {
|
|
1918
|
+
const opened = await open77.execute({ cwd: '/tmp', command: 'printf "D77_%s\\n" RETAINED' })
|
|
1919
|
+
const sid = opened.sid
|
|
1920
|
+
// 命令瞬间跑完:等它转成只读保留态(清单里带 exited:true)
|
|
1921
|
+
let entry = null
|
|
1922
|
+
for (let i = 0; i < 40; i++) {
|
|
1923
|
+
await sleep(150)
|
|
1924
|
+
entry = ((await list77.execute({})).sessions ?? []).find((x) => x.sid === sid)
|
|
1925
|
+
if (entry !== undefined && entry.exited === true) break
|
|
1926
|
+
}
|
|
1927
|
+
if (entry !== undefined && entry.exited === true && entry.retainMs === undefined) {
|
|
1928
|
+
pass('B34a 命令退出后会话仍在清单里(exited:true,默认不按时间释放)')
|
|
1929
|
+
} else {
|
|
1930
|
+
fail('B34a 命令退出后会话仍在清单里', JSON.stringify(entry))
|
|
1931
|
+
}
|
|
1932
|
+
|
|
1933
|
+
const read = await capture77.execute({ sid, lines: 100 })
|
|
1934
|
+
if (read.exited === true && String(read.tail).includes('D77_RETAINED')) {
|
|
1935
|
+
pass('B34b tty_capture 读得到退出前的输出(带 exited 标记)')
|
|
1936
|
+
} else {
|
|
1937
|
+
fail('B34b tty_capture 读得到退出前的输出', JSON.stringify(read).slice(0, 160))
|
|
1938
|
+
}
|
|
1939
|
+
|
|
1940
|
+
let refused = false
|
|
1941
|
+
try {
|
|
1942
|
+
await send77.execute({ sid, data: 'echo nope\n' })
|
|
1943
|
+
} catch {
|
|
1944
|
+
refused = true
|
|
1945
|
+
}
|
|
1946
|
+
if (refused) pass('B34c tty_send 对只读保留态明确拒写')
|
|
1947
|
+
else fail('B34c tty_send 对只读保留态明确拒写', '居然写进去了')
|
|
1948
|
+
|
|
1949
|
+
const closed = await close77.execute({ sid })
|
|
1950
|
+
const gone = !((await list77.execute({})).sessions ?? []).some((x) => x.sid === sid)
|
|
1951
|
+
if (closed.ok === true && gone) pass('B34d tty_close 释放保留态(关掉后从清单消失)')
|
|
1952
|
+
else fail('B34d tty_close 释放保留态', `ok=${String(closed.ok)} gone=${String(gone)}`)
|
|
1953
|
+
}
|
|
1954
|
+
}
|
|
1955
|
+
|
|
1956
|
+
// B35: `tty_open command=` 执行的是**整段 shell 代码**(D78)
|
|
1957
|
+
//
|
|
1958
|
+
// 旧实现把用户命令直接缀在 `exec` 后面——`exec` 只作用于简单命令,于是
|
|
1959
|
+
// `echo A; echo B` 只跑 A、`cd /tmp && pwd` 空输出、`echo X; exit 3` 报 0、
|
|
1960
|
+
// `for …` 直接 parse error(2026-09-27 报告)。这里用真 PTY 钉住五条形态,
|
|
1961
|
+
// 判据取「后半段的输出真的出现了」+「退出码取自最后一条」。
|
|
1962
|
+
console.log('\n[32] `command` 按 shell 语法整体执行(D78)')
|
|
1963
|
+
{
|
|
1964
|
+
const open = toolDefs.find((d) => d.name === 'tty_open')
|
|
1965
|
+
const close = toolDefs.find((d) => d.name === 'tty_close')
|
|
1966
|
+
const list = toolDefs.find((d) => d.name === 'tty_list')
|
|
1967
|
+
const capture = toolDefs.find((d) => d.name === 'tty_capture')
|
|
1968
|
+
if (open === undefined || close === undefined || list === undefined || capture === undefined) {
|
|
1969
|
+
fail('B35 D78 工具集', '缺工具: ' + toolDefs.map((d) => d.name).join(','))
|
|
1970
|
+
} else {
|
|
1971
|
+
/** 开一条命令会话并等它转成只读保留态(命令跑完),返回 { sid, entry }。 */
|
|
1972
|
+
const openAndSettle = async (command) => {
|
|
1973
|
+
const opened = await open.execute({ cwd: '/tmp', command })
|
|
1974
|
+
for (let i = 0; i < 80; i++) {
|
|
1975
|
+
await sleep(150)
|
|
1976
|
+
const entry = ((await list.execute({})).sessions ?? []).find((x) => x.sid === opened.sid) ?? null
|
|
1977
|
+
if (entry !== null && entry.exited === true) return { sid: opened.sid, entry }
|
|
1978
|
+
}
|
|
1979
|
+
return { sid: opened.sid, entry: null }
|
|
1980
|
+
}
|
|
1981
|
+
const readTail = async (sid) => String((await capture.execute({ sid, lines: 80 })).tail ?? '')
|
|
1982
|
+
const opened = []
|
|
1983
|
+
try {
|
|
1984
|
+
// ① `a; b`:旧实现只跑第一条
|
|
1985
|
+
const semi = await openAndSettle('echo A; echo B; echo C; echo D')
|
|
1986
|
+
opened.push(semi.sid)
|
|
1987
|
+
const semiTail = await readTail(semi.sid)
|
|
1988
|
+
if (['A', 'B', 'C', 'D'].every((m) => semiTail.includes(m))) pass('B35a 分号列表整段执行(A/B/C/D 都在)')
|
|
1989
|
+
else fail('B35a 分号列表整段执行', JSON.stringify(semiTail).slice(0, 160))
|
|
1990
|
+
|
|
1991
|
+
// ② `cd x && cmd`:agent 最惯用的写法,旧实现空输出
|
|
1992
|
+
const and = await openAndSettle('cd /tmp && pwd')
|
|
1993
|
+
opened.push(and.sid)
|
|
1994
|
+
const andTail = await readTail(and.sid)
|
|
1995
|
+
if (andTail.includes('/tmp')) pass('B35b `cd && cmd` 完整执行(拿到 /tmp)')
|
|
1996
|
+
else fail('B35b `cd && cmd` 完整执行', JSON.stringify(andTail).slice(0, 160))
|
|
1997
|
+
|
|
1998
|
+
// ③ 退出码取自命令列表最后一条(旧实现取被 exec 的第一条)
|
|
1999
|
+
const rc = await openAndSettle('echo X; exit 3')
|
|
2000
|
+
opened.push(rc.sid)
|
|
2001
|
+
if (rc.entry !== null && rc.entry.exitCode === 3) pass('B35c 退出码取自最后一条(echo X; exit 3 → exitCode=3)')
|
|
2002
|
+
else fail('B35c 退出码取自最后一条', JSON.stringify(rc.entry))
|
|
2003
|
+
|
|
2004
|
+
// ④ 复合语句(for)不再 parse error
|
|
2005
|
+
const loop = await openAndSettle('for i in 1 2 3; do echo L$i; done')
|
|
2006
|
+
opened.push(loop.sid)
|
|
2007
|
+
const loopTail = await readTail(loop.sid)
|
|
2008
|
+
if (['L1', 'L2', 'L3'].every((m) => loopTail.includes(m))) pass('B35d `for …; do …; done` 整段执行(L1/L2/L3 都在)')
|
|
2009
|
+
else fail('B35d `for …` 整段执行', JSON.stringify(loopTail).slice(0, 200))
|
|
2010
|
+
|
|
2011
|
+
// ⑤ 命令里的单引号安全穿过包装层
|
|
2012
|
+
const quoted = await openAndSettle("echo 'QUOTED-OK'")
|
|
2013
|
+
opened.push(quoted.sid)
|
|
2014
|
+
const quotedTail = await readTail(quoted.sid)
|
|
2015
|
+
if (quotedTail.includes('QUOTED-OK')) pass("B35e 命令内的单引号不被包装层吃掉(echo 'QUOTED-OK')")
|
|
2016
|
+
else fail('B35e 命令内的单引号', JSON.stringify(quotedTail).slice(0, 160))
|
|
2017
|
+
} catch (error) {
|
|
2018
|
+
fail('B35 D78 `command` 按 shell 语法整体执行', error.message)
|
|
2019
|
+
}
|
|
2020
|
+
for (const sid of opened) {
|
|
2021
|
+
try {
|
|
2022
|
+
await close.execute({ sid })
|
|
2023
|
+
} catch {
|
|
2024
|
+
/* 已释放 */
|
|
2025
|
+
}
|
|
2026
|
+
}
|
|
2027
|
+
}
|
|
2028
|
+
}
|
|
2029
|
+
|
|
1867
2030
|
// B33: 输出契约(所有已调用工具的真实返回值都符合各自声明的 output.schema)
|
|
1868
2031
|
//
|
|
1869
2032
|
// 这一条是 §B12e 的补充:B12e 只证明 schema 形状受支持,证明不了返回值合法。
|
|
1870
2033
|
// 真实宿主对输出做 additionalProperties:false 校验,违约会直接把工具调用变成
|
|
1871
2034
|
// Error(tty_list 加 owner 漏改 schema 即此)。
|
|
1872
|
-
console.log('\n[
|
|
2035
|
+
console.log('\n[33] 输出契约')
|
|
1873
2036
|
{
|
|
1874
2037
|
if (outputViolations.length === 0) pass('B33 工具返回值符合声明的 output.schema')
|
|
1875
2038
|
else fail('B33 工具返回值符合声明的 output.schema', outputViolations.slice(0, 3).join(' | '))
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* @hyzyn/dsh-tty — 跳板机(ProxyJump 单跳)的真机冒烟。
|
|
4
|
+
*
|
|
5
|
+
* 为什么必须真机跑一遍:`forwardOut → ConnectConfig.sock` 这条链路的正确性**不可能**靠单测
|
|
6
|
+
* 证明——假 ssh2 只能验「谁先连、config 长什么样」,而「通道真的把字节送到了另一台 sshd、
|
|
7
|
+
* 目标在通道上完成了握手并开了 SFTP 通道」只有真实现才能给。本脚本起**两个真的 ssh2 服务端**:
|
|
8
|
+
*
|
|
9
|
+
* - bastion:接受密码认证,并处理 `direct-tcpip`(把通道接到目标端口);
|
|
10
|
+
* - target:`scripts/lib/test-sshd.mjs` 的内存 SFTP 服务端。
|
|
11
|
+
*
|
|
12
|
+
* **两者的凭据刻意不同**(bastion: jump/jump-secret,target: test/secret):这样「跳板机凭据
|
|
13
|
+
* 没被目标凭据顶掉」也能一并验到——那正是 `jumpSpecOf` 的继承规则最容易写错的地方。
|
|
14
|
+
*
|
|
15
|
+
* 三个用例:
|
|
16
|
+
* J1 经跳板机列 SFTP 目录(正向:通道 + 目标握手 + 通道建立)
|
|
17
|
+
* J2 跳板机密码错 → 文案点名**跳板机**(而不是笼统的目标超时)
|
|
18
|
+
* J3 通道通了但目标不可达 → 文案同时点名目标与**经跳板机 X**(两跳归属不能混)
|
|
19
|
+
*
|
|
20
|
+
* 退出码:全部 PASS → 0;任一 FAIL → 1(CI 与发布闸都跑它)。
|
|
21
|
+
*/
|
|
22
|
+
import net from 'node:net'
|
|
23
|
+
import os from 'node:os'
|
|
24
|
+
import path from 'node:path'
|
|
25
|
+
import fsp from 'node:fs/promises'
|
|
26
|
+
import ssh2 from 'ssh2'
|
|
27
|
+
import { generateKeyPairSync } from 'node:crypto'
|
|
28
|
+
import { SftpManager } from '../lib/sftp.js'
|
|
29
|
+
import { startSftpSshd } from './lib/test-sshd.mjs'
|
|
30
|
+
|
|
31
|
+
const BASTION_USER = 'jump'
|
|
32
|
+
const BASTION_PASSWORD = 'jump-secret'
|
|
33
|
+
|
|
34
|
+
let failed = 0
|
|
35
|
+
function pass(name) {
|
|
36
|
+
console.log(` ✔ PASS ${name}`)
|
|
37
|
+
}
|
|
38
|
+
function fail(name, detail) {
|
|
39
|
+
failed += 1
|
|
40
|
+
console.log(` ✘ FAIL ${name}${detail === undefined ? '' : ` — ${detail}`}`)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** 起一个「能当跳板机」的 sshd:密码认证 + `direct-tcpip` 转发到目标端口。 */
|
|
44
|
+
async function startBastion() {
|
|
45
|
+
const { privateKey } = generateKeyPairSync('rsa', {
|
|
46
|
+
modulusLength: 2048,
|
|
47
|
+
publicKeyEncoding: { type: 'spki', format: 'pem' },
|
|
48
|
+
privateKeyEncoding: { type: 'pkcs1', format: 'pem' },
|
|
49
|
+
})
|
|
50
|
+
/** 观测量:建了几条连接、开了几条转发通道(用例里断言「确实过了跳板机」)。 */
|
|
51
|
+
const seen = { connections: 0, forwards: 0, closed: 0 }
|
|
52
|
+
const server = new ssh2.Server({ hostKeys: [privateKey] }, (client) => {
|
|
53
|
+
seen.connections += 1
|
|
54
|
+
client.on('authentication', (ctx) => {
|
|
55
|
+
if (ctx.method === 'password' && ctx.username === BASTION_USER && ctx.password === BASTION_PASSWORD) {
|
|
56
|
+
ctx.accept()
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
ctx.reject()
|
|
60
|
+
})
|
|
61
|
+
client.on('ready', () => {
|
|
62
|
+
// direct-tcpip:这就是 forwardOut 落在服务端的那一半
|
|
63
|
+
client.on('tcpip', (accept, reject, info) => {
|
|
64
|
+
seen.forwards += 1
|
|
65
|
+
const channel = accept()
|
|
66
|
+
const socket = net.connect(info.destPort, info.destIP)
|
|
67
|
+
socket.on('connect', () => {
|
|
68
|
+
channel.pipe(socket).pipe(channel)
|
|
69
|
+
})
|
|
70
|
+
socket.on('error', () => {
|
|
71
|
+
try {
|
|
72
|
+
channel.close()
|
|
73
|
+
} catch {
|
|
74
|
+
/* 已关闭 */
|
|
75
|
+
}
|
|
76
|
+
})
|
|
77
|
+
channel.on('close', () => socket.destroy())
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
client.on('close', () => {
|
|
81
|
+
seen.closed += 1
|
|
82
|
+
})
|
|
83
|
+
client.on('error', () => {
|
|
84
|
+
/* 认证失败 / 断开:用例自己断言 */
|
|
85
|
+
})
|
|
86
|
+
})
|
|
87
|
+
await new Promise((resolve, reject) => {
|
|
88
|
+
server.once('error', reject)
|
|
89
|
+
server.listen(0, '127.0.0.1', () => resolve())
|
|
90
|
+
})
|
|
91
|
+
const port = server.address().port
|
|
92
|
+
return {
|
|
93
|
+
port,
|
|
94
|
+
seen,
|
|
95
|
+
close: () => new Promise((resolve) => server.close(() => resolve())),
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const root = await fsp.mkdtemp(path.join(os.tmpdir(), 'dsh-tty-jump-'))
|
|
100
|
+
await fsp.writeFile(path.join(root, 'hello.txt'), 'via bastion\n')
|
|
101
|
+
|
|
102
|
+
const bastion = await startBastion()
|
|
103
|
+
const target = await startSftpSshd({ rootDir: root })
|
|
104
|
+
|
|
105
|
+
/** 经跳板机连目标的规格(凭据刻意不同:bastion 用 jump/jump-secret)。 */
|
|
106
|
+
const specWithJump = (jumpOverride = {}) => ({
|
|
107
|
+
host: '127.0.0.1',
|
|
108
|
+
port: target.port,
|
|
109
|
+
username: 'test',
|
|
110
|
+
auth: 'password',
|
|
111
|
+
password: 'secret',
|
|
112
|
+
jump: { host: '127.0.0.1', port: bastion.port, username: BASTION_USER, auth: 'password', password: BASTION_PASSWORD, ...jumpOverride },
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
const sftp = new SftpManager({ info: () => {}, warn: () => {} })
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
// ---- J1:正向。SFTP 的 list 要经跳板机通道到达目标 ----
|
|
119
|
+
try {
|
|
120
|
+
const listed = await sftp.list(specWithJump(), root)
|
|
121
|
+
const names = listed.entries.map((entry) => entry.name)
|
|
122
|
+
if (names.includes('hello.txt') && bastion.seen.forwards >= 1) {
|
|
123
|
+
pass(`J1 经跳板机列 SFTP 目录(跳板机转发 ${bastion.seen.forwards} 次,目标 ${names.length} 项)`)
|
|
124
|
+
} else {
|
|
125
|
+
fail('J1 经跳板机列 SFTP 目录', `files=${JSON.stringify(names)} forwards=${bastion.seen.forwards}`)
|
|
126
|
+
}
|
|
127
|
+
} catch (error) {
|
|
128
|
+
fail('J1 经跳板机列 SFTP 目录', error instanceof Error ? error.message : String(error))
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ---- J2:跳板机密码错 → 必须点名跳板机 ----
|
|
132
|
+
try {
|
|
133
|
+
await sftp.list(specWithJump({ password: 'wrong' }), root)
|
|
134
|
+
fail('J2 跳板机密码错', '竟然没报错')
|
|
135
|
+
} catch (error) {
|
|
136
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
137
|
+
if (message.includes('跳板机') && message.includes(`${BASTION_USER}@127.0.0.1:${String(bastion.port)}`)) {
|
|
138
|
+
pass('J2 跳板机密码错 → 文案点名跳板机')
|
|
139
|
+
} else {
|
|
140
|
+
fail('J2 跳板机密码错 → 文案点名跳板机', message)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ---- J3:通道通了但目标不可达 → 目标与「经跳板机 X」都要出现 ----
|
|
145
|
+
try {
|
|
146
|
+
const deadPort = target.port + 1
|
|
147
|
+
// 指向一个没人监听的端口:bastion 的 net.connect 会成功(本机 127.0.0.1),
|
|
148
|
+
// 但通道上的目标握手会失败
|
|
149
|
+
await sftp.list({ ...specWithJump(), port: deadPort }, root)
|
|
150
|
+
fail('J3 通道通了但目标不可达', '竟然没报错')
|
|
151
|
+
} catch (error) {
|
|
152
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
153
|
+
if (message.includes('经跳板机') && message.includes('127.0.0.1')) {
|
|
154
|
+
pass('J3 目标不可达 → 文案同时点名目标与跳板机')
|
|
155
|
+
} else {
|
|
156
|
+
fail('J3 目标不可达 → 文案同时点名目标与跳板机', message)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ---- 清理:跳板机连接必须被关掉(不留 keepalive 养着的连接) ----
|
|
161
|
+
await sftp.disposeAll()
|
|
162
|
+
await new Promise((resolve) => setTimeout(resolve, 200))
|
|
163
|
+
if (bastion.seen.closed >= bastion.seen.connections) {
|
|
164
|
+
pass(`J4 收尾后跳板机连接全部关闭(${bastion.seen.closed}/${bastion.seen.connections})`)
|
|
165
|
+
} else {
|
|
166
|
+
fail('J4 收尾后跳板机连接全部关闭', `closed=${bastion.seen.closed} opened=${bastion.seen.connections}`)
|
|
167
|
+
}
|
|
168
|
+
} finally {
|
|
169
|
+
await bastion.close().catch(() => {})
|
|
170
|
+
await target.close().catch(() => {})
|
|
171
|
+
await fsp.rm(root, { recursive: true, force: true }).catch(() => {})
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
console.log(failed === 0 ? '\njump smoke: 全部 PASS' : `\njump smoke: ${failed} 个 FAIL`)
|
|
175
|
+
process.exit(failed === 0 ? 0 : 1)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 最小 `ProxyCommand` 桥:把 stdio 接到 `host port`。
|
|
4
|
+
*
|
|
5
|
+
* 这就是 `ssh -W %h:%p bastion` / `nc host port` 的原语,写成脚本是为了**不依赖系统工具**
|
|
6
|
+
* (CI 上未必有 ssh / nc,参数还各不相同)。真机冒烟 `scripts/proxycommand-smoke.mjs` 用它
|
|
7
|
+
* 当代理命令,从而把「子进程 stdio ↔ SSH 传输」这条链路整条跑通。
|
|
8
|
+
*
|
|
9
|
+
* 用法:node proxy-bridge.mjs <host> <port>
|
|
10
|
+
*/
|
|
11
|
+
import net from 'node:net'
|
|
12
|
+
|
|
13
|
+
const [host, port] = process.argv.slice(2)
|
|
14
|
+
if (host === undefined || port === undefined) {
|
|
15
|
+
process.stderr.write('usage: proxy-bridge.mjs <host> <port>\n')
|
|
16
|
+
process.exit(2)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const socket = net.connect(Number(port), host)
|
|
20
|
+
socket.on('connect', () => {
|
|
21
|
+
process.stdin.pipe(socket)
|
|
22
|
+
socket.pipe(process.stdout)
|
|
23
|
+
})
|
|
24
|
+
socket.on('error', (error) => {
|
|
25
|
+
process.stderr.write(`${error.message}\n`)
|
|
26
|
+
process.exit(1)
|
|
27
|
+
})
|
|
28
|
+
socket.on('close', () => {
|
|
29
|
+
process.exit(0)
|
|
30
|
+
})
|