@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.
@@ -24,6 +24,29 @@
24
24
  const q = (sel) => document.querySelector(sel)
25
25
  const qa = (sel) => [...document.querySelectorAll(sel)]
26
26
 
27
+ /*
28
+ * 预览语言:`?locale=en`(或 CDP 注入 `window.__previewLocale = 'en'`)预览英文界面,
29
+ * 默认 zh。与 search 的 mock-host / codegraph 的 preview-card 同一个开关形状
30
+ * (见 docs/i18n.md § 预览与 smoke 夹具)。夹具**提供**了 locale 服务,就必须能选语言,
31
+ * 否则它验的是一条不存在的路(「老宿主没有 locale 服务」那条路由中文兜底覆盖)。
32
+ */
33
+ try {
34
+ const wanted = new URLSearchParams(location.search).get('locale')
35
+ if (wanted === 'en' || wanted === 'zh') window.__previewLocale = wanted
36
+ } catch {
37
+ /* file:// 下 location.search 可能不可用 */
38
+ }
39
+
40
+ /*
41
+ * 按**界面文案**找元素:文案已经进目录(i18n),所以定位串必须**双语共存**——只认中文
42
+ * 会把英文界面下的走查挡在门外(同 search 的 `titles: ['通用设置', 'General']`)。
43
+ * 三个谓词都收「任一语言」,断言的失败信息仍是中文(开发者看的)。
44
+ */
45
+ const says = (el, ...names) => names.some((name) => (el.textContent || '').includes(name))
46
+ const saysExact = (el, ...names) => names.some((name) => (el.textContent || '').trim() === name)
47
+ const attributeSays = (el, attr, ...names) => names.some((name) => (el.getAttribute(attr) || '') === name)
48
+ const findByAria = (root, ...names) => [...root.querySelectorAll('[aria-label]')].find((el) => attributeSays(el, 'aria-label', ...names))
49
+
27
50
  /* ---------- 挂载插件 ---------- */
28
51
  // 场景之间共用同一个页面 target,必须先清掉上一个场景的标签持久化
29
52
  try {
@@ -99,6 +122,30 @@
99
122
  // 宿主会话服务也进服务表:docker 那一半是经 ctx.inject(['sessions']) 取的(夹具里
100
123
  // ctx.sessions 是直接属性、不走服务表,这里补一份,免得那条 inject 永远等不到依赖)
101
124
  services.set('sessions', ctx.sessions)
125
+
126
+ /*
127
+ * 假 locale 服务(`@deepseek-ai/dsh-client-locale`):插件经 `ctx.inject(['locale'], cb)`
128
+ * **动态**取它,所以服务表里没有它时 callback 不触发、`t` 保持中文兜底(= 老宿主那条路)。
129
+ * 桩的形状与 search 的 mock-host 一致:`register(ns, locale, dict)` + `bind(ns)`,
130
+ * 词条按 `window.__previewLocale` 选语言。
131
+ */
132
+ const localeDicts = new Map()
133
+ const localeStub = {
134
+ register: (ns, locale, dict) => {
135
+ const entry = localeDicts.get(ns) || {}
136
+ entry[locale] = dict
137
+ localeDicts.set(ns, entry)
138
+ return () => {}
139
+ },
140
+ bind: (ns) => (key, params) => {
141
+ const active = window.__previewLocale === 'en' ? 'en' : 'zh'
142
+ const dict = localeDicts.get(ns) || {}
143
+ const text = (dict[active] || {})[key] || key
144
+ if (params === undefined) return text
145
+ return String(text).replace(/\{(\w+)\}/g, (_match, name) => (params[name] === undefined ? '' : String(params[name])))
146
+ },
147
+ }
148
+ services.set('locale', localeStub)
102
149
  exports.apply(ctx)
103
150
 
104
151
  /**
@@ -136,8 +183,9 @@
136
183
  await waitFor(() => q('.tt_addMenu'))
137
184
  await sleep(80)
138
185
  }
139
- const clickMenuItem = async (label) => {
140
- const item = await waitFor(() => qa('.tt_addMenuItem').find((el) => el.textContent.includes(label)))
186
+ // 菜单项按**双语**匹配(文案已进目录,见上面的 says)
187
+ const clickMenuItem = async (...labels) => {
188
+ const item = await waitFor(() => qa('.tt_addMenuItem').find((el) => says(el, ...labels)))
141
189
  item.click()
142
190
  await sleep(200)
143
191
  return item
@@ -210,7 +258,7 @@
210
258
  if (bar === null) return '状态条 DOM 缺失'
211
259
  if (bar.hidden === true) return '状态条未显示(stats 订阅链路可能断开)'
212
260
  const text = bar.textContent
213
- if (!/CPU/.test(text) || !/内存/.test(text) || !/网络/.test(text)) return '状态条内容不完整:' + text
261
+ if (!/CPU/.test(text) || !/(内存|Memory)/.test(text) || !/(网络|Network)/.test(text)) return '状态条内容不完整:' + text
214
262
  /*
215
263
  * 订阅时序:客户端**故意**在 spawn 之前就发一次 statsOn(switchTab 早于 spawnTab),
216
264
  * 宿主此时还没登记这个 sid,会按「未知 sid」忽略——这是设计里的正常现象,所以
@@ -309,7 +357,7 @@
309
357
  await clickMenuItem('staging-db')
310
358
  await waitFor(() => tabs().length === 3)
311
359
  await clickAdd()
312
- await clickMenuItem('本地终端')
360
+ await clickMenuItem('本地终端', 'Local terminal')
313
361
  await waitFor(() => tabs().length === 4)
314
362
  tabs()[1].click()
315
363
  await sleep(250)
@@ -338,7 +386,7 @@
338
386
  async ssh() {
339
387
  await openPanel()
340
388
  await clickAdd()
341
- await clickMenuItem('SSH 连接…')
389
+ await clickMenuItem('SSH 连接…', 'SSH connection…')
342
390
  await waitFor(() => q('.tt_sshCard'))
343
391
  await sleep(150)
344
392
  },
@@ -346,16 +394,16 @@
346
394
  async 'ssh-probe'() {
347
395
  await openPanel()
348
396
  await clickAdd()
349
- await clickMenuItem('SSH 连接…')
397
+ await clickMenuItem('SSH 连接…', 'SSH connection…')
350
398
  await waitFor(() => q('.tt_sshCard'))
351
399
  const inputs = qa('.tt_sshCard input')
352
400
  inputs[0].value = '192.0.2.10'
353
401
  inputs[1].value = '22'
354
402
  inputs[2].value = 'root'
355
- const probeBtn = qa('.tt_sshActions .tt_toolBtn').find((b) => b.textContent.includes('试连'))
403
+ const probeBtn = qa('.tt_sshActions .tt_toolBtn').find((b) => says(b, '试连', 'Test'))
356
404
  probeBtn.click()
357
405
  const resultEl = () => q('.tt_sshProbeResult')
358
- await waitFor(() => resultEl() && resultEl().textContent !== '' && !resultEl().textContent.includes('测试中'))
406
+ await waitFor(() => resultEl() && resultEl().textContent !== '' && !says(resultEl(), '测试中', 'Testing'))
359
407
  await sleep(250)
360
408
  },
361
409
  /* SSH 连接对话框(编辑连接簿条目,含 env 选择器) */
@@ -366,7 +414,7 @@
366
414
  const r = qa('.tt_addMenuRow')
367
415
  return r.length > 0 ? r : null
368
416
  })
369
- const editBtn = rows[1].querySelector('.tt_addMenuEdit[title="编辑连接"]')
417
+ const editBtn = [...rows[1].querySelectorAll('.tt_addMenuEdit')].find((b) => attributeSays(b, 'title', '编辑连接', 'Edit connection'))
370
418
  editBtn.click()
371
419
  await waitFor(() => q('.tt_sshCard'))
372
420
  await sleep(200)
@@ -407,7 +455,7 @@
407
455
  // 「编辑」按钮是本次新增的能力:先在第一条隧道行上找到它
408
456
  const firstRow = rowOf('staging-pg')
409
457
  if (firstRow === undefined) throw new Error('找不到 staging-pg 行(fixture 应有该隧道)')
410
- const editBtn = buttonsIn(firstRow).find((b) => b.textContent === '编辑')
458
+ const editBtn = buttonsIn(firstRow).find((b) => saysExact(b, '编辑', 'Edit'))
411
459
  if (editBtn === undefined) throw new Error('隧道行没有「编辑」按钮')
412
460
  editBtn.click()
413
461
  await sleep(150)
@@ -421,7 +469,7 @@
421
469
  await sleep(120)
422
470
 
423
471
  // 回填:本地转发那边「127.0.0.1:<端口>」成对呈现,端口输入带 aria-label 标识
424
- const localPortInput = document.querySelector('#preview-settings [aria-label="本机监听端口"]')
472
+ const localPortInput = findByAria(document.querySelector('#preview-settings') || document, '本机监听端口', 'Local listen port')
425
473
  if (localPortInput === null) throw new Error('本地转发表单没渲染(找不到本机监听端口输入)')
426
474
  if (localPortInput.value !== '15432') throw new Error('编辑未回填本地端口:' + String(localPortInput.value))
427
475
 
@@ -444,7 +492,7 @@
444
492
 
445
493
  // 切到「远程 -R」:固定端应换到**右侧**(本机拨号),左侧变成可填的服务器侧监听地址
446
494
  // ——两个方向的固定端不同,这正是不能照搬单一形状的原因
447
- segBtns.find((b) => (b.textContent ?? '').includes('远程')).click()
495
+ segBtns.find((b) => says(b, '远程 -R', 'Remote -R')).click()
448
496
  await sleep(200)
449
497
  scrollTunnelIntoView()
450
498
  const remoteEndpoints = [...document.querySelectorAll('#preview-settings .tt_tunnelEndpoint')]
@@ -460,7 +508,7 @@
460
508
  throw new Error('远程方向的箭头没有翻转标记')
461
509
  }
462
510
  // 切回本地 -L,后续断言按本地方向的期望继续
463
- segBtns.find((b) => (b.textContent ?? '').includes('本地')).click()
511
+ segBtns.find((b) => says(b, '本地 -L', 'Local -L')).click()
464
512
  await sleep(200)
465
513
  scrollTunnelIntoView()
466
514
  await sleep(80)
@@ -499,13 +547,13 @@
499
547
  }
500
548
 
501
549
  // 方向切换会重渲表单:DOM 节点可能被替换,重新取一次(别用切换前的引用)
502
- const localPortInput2 = document.querySelector('#preview-settings [aria-label="本机监听端口"]')
550
+ const localPortInput2 = findByAria(document.querySelector('#preview-settings') || document, '本机监听端口', 'Local listen port')
503
551
  if (localPortInput2 === null) throw new Error('切回本地后表单没渲染')
504
552
 
505
553
  const body = q('#preview-settings .tt_cardBody')
506
- if (buttonsIn(body).find((b) => b.textContent === '保存修改') === undefined) throw new Error('编辑态没有「保存修改」')
507
- if (buttonsIn(body).find((b) => b.textContent === '取消') === undefined) throw new Error('编辑态没有「取消」')
508
- if (buttonsIn(body).find((b) => b.textContent === '添加隧道') !== undefined) {
554
+ if (buttonsIn(body).find((b) => saysExact(b, '保存修改', 'Save changes')) === undefined) throw new Error('编辑态没有「保存修改」')
555
+ if (buttonsIn(body).find((b) => saysExact(b, '取消', 'Cancel')) === undefined) throw new Error('编辑态没有「取消」')
556
+ if (buttonsIn(body).find((b) => saysExact(b, '添加隧道', 'Add tunnel')) !== undefined) {
509
557
  throw new Error('编辑态仍显示「添加隧道」(会误加一条而不是改这条)')
510
558
  }
511
559
  if (document.querySelectorAll('#preview-settings .tt_sshHostRow[data-editing]').length !== 1) {
@@ -530,7 +578,7 @@
530
578
  }
531
579
  setReactInput(localPortInput2, '15433')
532
580
  await sleep(150)
533
- buttonsIn(body).find((b) => b.textContent === '保存修改').click()
581
+ buttonsIn(body).find((b) => saysExact(b, '保存修改', 'Save changes')).click()
534
582
  await sleep(400)
535
583
 
536
584
  window.__previewAssert = async () => {
@@ -546,7 +594,7 @@
546
594
  if (!names.some((n) => n.startsWith('prod-redis'))) {
547
595
  problems.push('编辑波及了别的隧道')
548
596
  }
549
- if (buttonsIn(document.querySelector('#preview-settings .tt_cardBody')).find((b) => b.textContent === '添加隧道') === undefined) {
597
+ if (buttonsIn(document.querySelector('#preview-settings .tt_cardBody')).find((b) => saysExact(b, '添加隧道', 'Add tunnel')) === undefined) {
550
598
  problems.push('保存后没有退出编辑态')
551
599
  }
552
600
  return problems.length > 0 ? problems.join(';') : null
@@ -839,7 +887,7 @@
839
887
  await clickMenuItem('staging-db')
840
888
  await waitFor(() => tabs().length === 2)
841
889
  await sleep(200)
842
- const btn = qa('.tt_connAct').find((b) => b.textContent.includes('隧道'))
890
+ const btn = qa('.tt_connAct').find((b) => says(b, '隧道', 'Tunnel'))
843
891
  if (btn) btn.click()
844
892
  await waitFor(() => q('.tt_tunnelPop'))
845
893
  await sleep(300)
@@ -861,17 +909,17 @@
861
909
 
862
910
  const acts = qa('.tt_connAct')
863
911
  // 没有隧道 ⇒ 不该出现常驻「隧道 N」按钮
864
- if (acts.some((b) => b.textContent.includes('隧道'))) {
912
+ if (acts.some((b) => says(b, '隧道', 'Tunnel'))) {
865
913
  throw new Error('没有隧道的连接却出现了常驻「隧道」按钮')
866
914
  }
867
- const more = acts.find((b) => b.textContent.includes('更多'))
915
+ const more = acts.find((b) => says(b, '更多', 'More'))
868
916
  if (more === undefined) throw new Error('没有隧道的连接缺少「⋯ 更多」入口')
869
917
  more.click()
870
918
  await waitFor(() => q('.tt_connMore'))
871
919
  await sleep(250)
872
920
 
873
921
  // 闭环:菜单项必须真的可点、并给出可执行的信息(该连接没有启用隧道 + 去哪配)
874
- const item = qa('.tt_connMore .tt_addMenuItem').find((el) => el.textContent.includes('端口转发'))
922
+ const item = qa('.tt_connMore .tt_addMenuItem').find((el) => says(el, '端口转发', 'Port forwarding'))
875
923
  if (item === undefined) throw new Error('「⋯」菜单里没有端口转发项')
876
924
  item.click()
877
925
  await waitFor(() => q('.tt_tunnelPop'))
@@ -880,8 +928,8 @@
880
928
  const pop = q('.tt_tunnelPop')
881
929
  if (pop === null) return '点击菜单项后没有打开隧道弹层'
882
930
  const text = pop.textContent ?? ''
883
- if (!text.includes('暂无启用')) return '弹层没有说明「该连接暂无启用的隧道」:' + text
884
- if (!text.includes('插件配置') && !text.includes('设置')) return '弹层没有告诉用户去哪配置'
931
+ if (!text.includes('暂无启用') && !text.includes('No enabled tunnels')) return '弹层没有说明「该连接暂无启用的隧道」:' + text
932
+ if (!/插件配置|设置|Settings/.test(text)) return '弹层没有告诉用户去哪配置'
885
933
  return null
886
934
  }
887
935
  },
@@ -1081,7 +1129,7 @@
1081
1129
  await openPanel()
1082
1130
  window.__PREVIEW_AT_LIMIT = true
1083
1131
  await clickAdd()
1084
- await clickMenuItem('本地终端')
1132
+ await clickMenuItem('本地终端', 'Local terminal')
1085
1133
  await waitFor(() => q('.tt_toast'))
1086
1134
  await sleep(200)
1087
1135
  },
@@ -13,6 +13,15 @@ import net from 'node:net'
13
13
  import { name, inject, apply } from '../lib/index.js'
14
14
  import { startSftpSshd, TEST_USER, TEST_PASSWORD } from './lib/test-sshd.mjs'
15
15
 
16
+ /*
17
+ * 变更端点要求「同源证明」(docker D32 口径,见 docs/architecture.md § 7 与
18
+ * packages/tty/src/index.ts 的 gateRoute):浏览器同源 fetch 必带
19
+ * `sec-fetch-site: same-origin`——本脚本模拟的正是**面板**这条调用路径,所以照带。
20
+ * 少了它,/probe 与 /sftp 的写动作会被 403「缺少同源证明」(本脚本此前就踩在这里)。
21
+ */
22
+ const SAME_ORIGIN = { 'sec-fetch-site': 'same-origin' }
23
+
24
+
16
25
  const RESULTS = []
17
26
  function pass(name) { RESULTS.push(['PASS', name]); console.log(' ✔ PASS ' + name) }
18
27
  function fail(name, detail) { RESULTS.push(['FAIL', name, detail]); console.error(' ✘ FAIL ' + name + (detail ? ' — ' + detail : '')) }
@@ -44,7 +53,7 @@ async function run() {
44
53
  const probe = async (body) => {
45
54
  const res = await fetch(`http://127.0.0.1:${port}/api/dsh-tty/probe`, {
46
55
  method: 'POST',
47
- headers: { 'content-type': 'application/json' },
56
+ headers: { ...SAME_ORIGIN, 'content-type': 'application/json' },
48
57
  body: JSON.stringify(body),
49
58
  })
50
59
  return res.json()
@@ -0,0 +1,267 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * @hyzyn/dsh-tty — 代理命令(ProxyCommand)的真机冒烟。
4
+ *
5
+ * 为什么必须真机跑一遍:`child_process.spawn → Duplex.from({readable, writable}) → ConnectConfig.sock`
6
+ * 这条链路的正确性单测证明不了——测试里只能断言「sock 是个 Duplex」;「stdin/stdout 真的把字节
7
+ * 送到了对端 sshd、目标真的在这条流上完成了握手、会话与 SFTP 都通、收尾真的没留下子进程」
8
+ * 只有真实现能给。这里刻意**不用系统 ssh / nc**(CI 上未必有、参数还各不相同),而是自己写一个
9
+ * 十几行的桥(`scripts/lib/proxy-bridge.mjs`):把 stdio 接到 `host port`——这正是
10
+ * `ssh -W %h:%p bastion` 的原语,于是 `%h` / `%p` 的展开也被一并验到。
11
+ *
12
+ * 用例:
13
+ * P1 终端会话经代理命令连上(spawnSsh:握手 + shell channel + 命令往返)
14
+ * P2 SFTP 经代理命令列目录(同一份传输被 sftp.ts 那条路径复用)
15
+ * P3 未获宿主授权 → 明确失败(文案点名环境变量),**且没有起进程**(即便「开关想开着」)
16
+ * P4 命令本身失败(桥不存在)→ 文案带上代理命令的失败事实(stderr 摘要),而不是笼统的目标超时
17
+ * P5 含 shell 特殊字符的主机名 → 拒绝代入(不执行任何命令)
18
+ * P6 收尾后没有残留的桥进程(子进程不许变常驻孤儿)
19
+ *
20
+ * 退出码:全部 PASS → 0;任一 FAIL → 1(已进 CI 的 dsh-tty 端到端那一档)。
21
+ */
22
+ import { execFileSync, spawn } from 'node:child_process'
23
+ import fsp from 'node:fs/promises'
24
+ import os from 'node:os'
25
+ import path from 'node:path'
26
+ import { fileURLToPath } from 'node:url'
27
+ import { generateKeyPairSync } from 'node:crypto'
28
+ import ssh2 from 'ssh2'
29
+ import { SftpManager } from '../lib/sftp.js'
30
+ import { bindCapabilitySources, capabilityGranted } from '@hyzyn/dsh-kit'
31
+ import { setProxyCommandPolicy, spawnSsh } from '../lib/ssh.js'
32
+ import { startSftpSshd, TEST_PASSWORD, TEST_USER } from './lib/test-sshd.mjs'
33
+
34
+ const here = path.dirname(fileURLToPath(import.meta.url))
35
+ const BRIDGE = path.join(here, 'lib', 'proxy-bridge.mjs')
36
+
37
+ let failed = 0
38
+ function pass(name) {
39
+ console.log(` ✔ PASS ${name}`)
40
+ }
41
+ function fail(name, detail) {
42
+ failed += 1
43
+ console.log(` ✘ FAIL ${name}${detail === undefined ? '' : ` — ${detail}`}`)
44
+ }
45
+
46
+ /* 全局看门狗:任何环节卡死时留痕退出(正常路径会先 process.exit)。 */
47
+ setTimeout(() => {
48
+ console.error('[watchdog] 60s 看门狗触发:proxycommand-smoke 卡死')
49
+ process.exit(1)
50
+ }, 60_000).unref?.()
51
+
52
+ /**
53
+ * 数一数现在还活着几个桥进程(按命令行里的脚本路径匹配)。
54
+ *
55
+ * 返回 **-1 = 这个平台数不了**(Windows 没有 `ps`)。调用方必须把 -1 当「无法判定」而不是
56
+ * 0——否则「没有残留进程」在 Windows 上会变成一个**假通过**(第一次写这个脚本时就是这么比的)。
57
+ */
58
+ function bridgeProcessCount() {
59
+ try {
60
+ const out = execFileSync('ps', ['-A', '-o', 'command'], { encoding: 'utf8' })
61
+ return out.split('\n').filter((line) => line.includes('proxy-bridge.mjs')).length
62
+ } catch {
63
+ return -1
64
+ }
65
+ }
66
+
67
+ const root = await fsp.mkdtemp(path.join(os.tmpdir(), 'dsh-tty-proxycmd-'))
68
+ await fsp.writeFile(path.join(root, 'hello.txt'), 'via proxy command\n')
69
+
70
+ const target = await startSftpSshd({ rootDir: root })
71
+
72
+ /**
73
+ * 最小「能开 shell」的内存 sshd(只做密码认证 + pty + 回一句标记)。
74
+ *
75
+ * 为什么要另一个服务端:`test-sshd.mjs` 是 SFTP 专用(不接 pty 请求),而终端那条路
76
+ * `spawnSsh` 一定要 pty(`shell channel 打开失败: Unable to request a pseudo-terminal`)。
77
+ * 这里只需要能证明「终端真的跑在代理命令的 stdio 上」与「收尾后子进程没了」。
78
+ */
79
+ async function startShellSshd() {
80
+ const { privateKey } = generateKeyPairSync('rsa', {
81
+ modulusLength: 2048,
82
+ publicKeyEncoding: { type: 'spki', format: 'pem' },
83
+ privateKeyEncoding: { type: 'pkcs1', format: 'pem' },
84
+ })
85
+ const server = new ssh2.Server({ hostKeys: [privateKey] }, (client) => {
86
+ client.on('authentication', (ctx) => {
87
+ if (ctx.method === 'password' && ctx.username === TEST_USER && ctx.password === TEST_PASSWORD) {
88
+ ctx.accept()
89
+ return
90
+ }
91
+ ctx.reject()
92
+ })
93
+ client.on('ready', () => {
94
+ client.on('session', (accept) => {
95
+ const session = accept()
96
+ session.on('pty', (acceptPty) => acceptPty())
97
+ session.on('shell', (acceptShell) => {
98
+ const stream = acceptShell()
99
+ stream.on('data', (chunk) => {
100
+ // 极简伪 shell:收到 printf 就回一句可匹配标记(传输通了就够,不解释命令)
101
+ if (chunk.toString('utf8').includes('printf')) stream.write('PROXYCMD:OK\r\n')
102
+ })
103
+ })
104
+ })
105
+ })
106
+ client.on('error', () => {
107
+ /* 断开:用例自己断言 */
108
+ })
109
+ })
110
+ await new Promise((resolve, reject) => {
111
+ server.once('error', reject)
112
+ server.listen(0, '127.0.0.1', () => resolve())
113
+ })
114
+ return {
115
+ port: server.address().port,
116
+ close: () => new Promise((resolve) => server.close(() => resolve())),
117
+ }
118
+ }
119
+
120
+ const shellTarget = await startShellSshd()
121
+
122
+ /**
123
+ * 代理命令串:`"<node>" "<bridge>" %h %p`。
124
+ *
125
+ * 用 JSON.stringify 包引号是为了两平台都对:POSIX 的 sh 与 Windows 的 cmd 都认双引号,
126
+ * 而 execPath 在 Windows 上常含空格(`C:\Program Files\nodejs\node.exe`)。
127
+ */
128
+ const proxyCommand = `${JSON.stringify(process.execPath)} ${JSON.stringify(BRIDGE)} %h %p`
129
+
130
+ /**
131
+ * 闸门的两个维度:`granted` 来自宿主侧环境变量(进程启动时采样一次,见 kit 的 capability.js),
132
+ * `enabled` 是界面上那个开关。这里**照插件宿主半体的做法**拼出同样的组合,而不是直接传 true
133
+ * ——否则冒烟就绕过了这一轮新加的那一层授权。
134
+ */
135
+ const GRANT_ENV = 'DSH_TTY_ALLOW_PROXY_COMMAND'
136
+ const applyPolicy = (enabled) => setProxyCommandPolicy({ granted: capabilityGranted(GRANT_ENV), enabled })
137
+ const specOf = (extra = {}) => ({
138
+ host: '127.0.0.1',
139
+ port: target.port,
140
+ username: TEST_USER,
141
+ auth: 'password',
142
+ password: TEST_PASSWORD,
143
+ proxyCommand,
144
+ ...extra,
145
+ })
146
+
147
+ /** 读一条 SSH 会话的输出直到匹配(或超时)。 */
148
+ async function readUntil(handle, pattern, timeoutMs = 8000) {
149
+ let text = ''
150
+ return await new Promise((resolve) => {
151
+ const timer = setTimeout(() => resolve({ text, matched: pattern.test(text) }), timeoutMs)
152
+ handle.output.on('data', (chunk) => {
153
+ text += chunk.toString('utf8')
154
+ if (pattern.test(text)) {
155
+ clearTimeout(timer)
156
+ resolve({ text, matched: true })
157
+ }
158
+ })
159
+ })
160
+ }
161
+
162
+ try {
163
+ // ---- P3 放在最前:**未获宿主授权**时不该起任何进程(先测这条,后面的用例才有干净的计数) ----
164
+ const before = bridgeProcessCount()
165
+ delete process.env[GRANT_ENV]
166
+ // 绑定 = 「这次宿主启动时带了什么环境」(见 kit 的 bindCapabilitySources):先改变量再绑定,顺序不能反
167
+ bindCapabilitySources(undefined)
168
+ applyPolicy(true) // 即便「开关想开着」,没有宿主授权也必须拒绝
169
+ try {
170
+ await new SftpManager({ info: () => {}, warn: () => {} }).list(specOf(), root)
171
+ fail('P3 闸门关着 → 明确失败', '竟然连上了')
172
+ } catch (error) {
173
+ const message = error instanceof Error ? error.message : String(error)
174
+ await new Promise((resolve) => setTimeout(resolve, 150))
175
+ const after = bridgeProcessCount()
176
+ if (message.includes('未获宿主授权') && message.includes(GRANT_ENV) && (after === -1 || after <= before)) {
177
+ const count = after === -1 ? '本平台数不了进程(无 ps)' : `桥进程 ${String(before)} → ${String(after)}`
178
+ pass(`P3 未授权 → 明确失败${after === -1 ? '(未起进程这一条跳过:' + count + ')' : `且没起进程(${count})`}`)
179
+ } else {
180
+ fail('P3 未授权 → 明确失败且没起进程', `msg=${message} before=${String(before)} after=${String(after)}`)
181
+ }
182
+ }
183
+
184
+ // ---- P5:代入值含 shell 特殊字符 → 拒绝执行(连进程都不该起) ----
185
+ // 从这里开始模拟「宿主启动时就带了授权环境变量」:先设变量再绑定(顺序不能反)
186
+ process.env[GRANT_ENV] = '1'
187
+ bindCapabilitySources(undefined)
188
+ applyPolicy(true)
189
+ if (!capabilityGranted(GRANT_ENV)) fail('P3b 宿主侧授权生效', '设了环境变量却没被采到')
190
+ else pass('P3b 宿主侧授权生效(DSH_TTY_ALLOW_PROXY_COMMAND=1)')
191
+ try {
192
+ await new SftpManager({ info: () => {}, warn: () => {} }).list(specOf({ host: '127.0.0.1; touch /tmp/dsh-pwned' }), root)
193
+ fail('P5 主机名含 shell 特殊字符 → 拒绝代入', '竟然没报错')
194
+ } catch (error) {
195
+ const message = error instanceof Error ? error.message : String(error)
196
+ if (message.includes('无法代入')) pass('P5 主机名含 shell 特殊字符 → 拒绝代入')
197
+ else fail('P5 主机名含 shell 特殊字符 → 拒绝代入', message)
198
+ }
199
+ const pwned = await fsp.access('/tmp/dsh-pwned').then(() => true).catch(() => false)
200
+ if (pwned) {
201
+ await fsp.rm('/tmp/dsh-pwned', { force: true })
202
+ fail('P5b 注入没有落地', '竟然真的创建了 /tmp/dsh-pwned')
203
+ } else {
204
+ pass('P5b 注入没有落地(/tmp/dsh-pwned 不存在)')
205
+ }
206
+
207
+ // ---- P1:终端会话经代理命令连上,并在上面跑一条命令 ----
208
+ let session = null
209
+ try {
210
+ session = await spawnSsh(specOf({ port: shellTarget.port }), { term: 'xterm-256color', cols: 80, rows: 24, logger: { info: () => {}, warn: () => {} } })
211
+ session.write('printf "PROXYCMD:%s\\n" OK\n')
212
+ const { text } = await readUntil(session, /PROXYCMD:OK/)
213
+ if (text.includes('PROXYCMD:OK')) pass('P1 终端会话经代理命令连上并完成命令往返')
214
+ else fail('P1 终端会话经代理命令连上并完成命令往返', JSON.stringify(text.slice(-200)))
215
+ } catch (error) {
216
+ fail('P1 终端会话经代理命令连上并完成命令往返', error instanceof Error ? error.message : String(error))
217
+ } finally {
218
+ if (session !== null) await session.terminate().catch(() => {})
219
+ session?.forceKill?.()
220
+ }
221
+
222
+ // ---- P2:SFTP 走同一条准备路径(prepareSshConnect) ----
223
+ const sftp = new SftpManager({ info: () => {}, warn: () => {} })
224
+ try {
225
+ const listed = await sftp.list(specOf(), root)
226
+ const names = listed.entries.map((entry) => entry.name)
227
+ if (names.includes('hello.txt')) pass(`P2 SFTP 经代理命令列目录(${String(names.length)} 项)`)
228
+ else fail('P2 SFTP 经代理命令列目录', JSON.stringify(names))
229
+ } catch (error) {
230
+ fail('P2 SFTP 经代理命令列目录', error instanceof Error ? error.message : String(error))
231
+ } finally {
232
+ sftp.disposeAll()
233
+ }
234
+
235
+ // ---- P4:命令自己失败 → 文案点名「代理命令已退出」+ 退出码 ----
236
+ try {
237
+ const dial = new SftpManager({ info: () => {}, warn: () => {} })
238
+ await dial.list(specOf({ proxyCommand: `${JSON.stringify(process.execPath)} ${JSON.stringify(path.join(os.tmpdir(), 'definitely-missing-bridge.mjs'))} %h %p` }), root)
239
+ fail('P4 命令失败 → 文案点名代理命令', '竟然连上了')
240
+ } catch (error) {
241
+ const message = error instanceof Error ? error.message : String(error)
242
+ /*
243
+ * 断言刻意宽松:子进程「结束输出」与「exit」相差 1~2ms,而 ssh2 一看流断了就报错——
244
+ * 谁先到不确定。两种时序都必须**至少**把 stderr 摘要交出来(那才是可排查的信息):
245
+ * - exit 先到 → 「代理命令已退出(退出码 1)…」;
246
+ * - 传输先关 → 「代理命令传输已关闭;stderr: …」。
247
+ */
248
+ const named = message.includes('代理命令')
249
+ const explained = message.includes('Cannot find module') || message.includes('退出码')
250
+ if (named && explained) pass('P4 命令失败 → 文案带上代理命令的失败事实(stderr 摘要可见)')
251
+ else fail('P4 命令失败 → 文案带上代理命令的失败事实(stderr 摘要可见)', message)
252
+ }
253
+
254
+ // ---- P6:收尾后没有残留的桥进程 ----
255
+ await new Promise((resolve) => setTimeout(resolve, 400))
256
+ const leaked = bridgeProcessCount()
257
+ if (leaked === 0) pass('P6 收尾后没有残留的代理命令进程')
258
+ else if (leaked === -1) console.log(' ⊘ SKIP P6 收尾后没有残留的代理命令进程 — 本平台没有 ps,数不了')
259
+ else fail('P6 收尾后没有残留的代理命令进程', `ps 里还有 ${String(leaked)} 个 proxy-bridge.mjs`)
260
+ } finally {
261
+ await target.close().catch(() => {})
262
+ await shellTarget.close().catch(() => {})
263
+ await fsp.rm(root, { recursive: true, force: true }).catch(() => {})
264
+ }
265
+
266
+ console.log(failed === 0 ? '\nproxycommand smoke: 全部 PASS' : `\nproxycommand smoke: ${String(failed)} 个 FAIL`)
267
+ process.exit(failed === 0 ? 0 : 1)