dsh-remote 0.8.42 → 0.8.44

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 CHANGED
@@ -57,6 +57,7 @@ The image above is the overview. What follows is only what the image does not ma
57
57
  The rest:
58
58
 
59
59
  - **22 model tools** (listed so they can be copied or searched): `rw_info`, `rw_connect`, `rw_machines`, `rw_pick_workspace`, `rw_list_dir`, `rw_stat`, `rw_read_file`, `rw_write_file`, `rw_edit`, `rw_append`, `rw_mkdir`, `rw_remove`, `rw_move`, `rw_exec`, `rw_search`, `rw_download`, `rw_upload`, `rw_sync`, `rw_push`, `rw_forward`, `rw_disconnect`, `rw_deploy_probe`.
60
+ - **Tell local and remote Sessions apart in the sidebar** (`0.8.43+`) — a remote Session row carries a **green dot** and its hover card names `user@host:port` plus the remote path; opening it also shows a host chip in the conversation header (a local Session shows neither). A Session counts as remote only when its cwd sits inside a `$DSH_HOME/remote-workspaces/…` mirror, so a machine that is merely saved never labels a local Session.
60
61
  - **Open a remote machine's DSH Web UI locally** (`0.8.36+`) — nothing has to be exposed on the remote: the plugin SSHes out, starts a `dsh web` there that binds **`127.0.0.1` only**, and tunnels its port back to a loopback port on this machine. The URL carries a one-time login token, consumed once by a redirect.
61
62
  - **One-click health check & deploy** (`0.8.36+`) — **a failed connection is usually the remote dsh version or environment, not the plugin.** The read-only check reports platform / node / npm / dsh version / **whether the native module can load** / proxy and npm registry, with a suggested fix; "Deploy & verify" installs into a remote **private directory** (no system directory, no PATH change, no replacing the version you use — deleting it is a complete rollback) and verifies step by step. The button **no longer requires running the check first** (`0.8.40+`) — the deploy re-checks internally. If that still fails, the built-in `dsh-remote-deploy` skill takes over.
62
63
  - **Cross-platform remotes** — all file access is SFTP-protocol-level (no POSIX shell), so Linux/macOS/Windows remotes all work.
@@ -242,6 +243,8 @@ Deploying to a product profile is a separate, explicit action (`./sync.sh`) for
242
243
  | `hostKeyMode` | string | `accept-new` | host-key policy: `accept-new` (TOFU), `verify` (reject unknown hosts), `off` (skip) |
243
244
  | `useAgent` | bool | `false` | authenticate via the OpenSSH agent (`SSH_AUTH_SOCK`) |
244
245
  | `keyboardInteractive` | bool | `false` | allow keyboard-interactive auth (OTP/MFA) with the configured password |
246
+ | `backend` | string | `native` | SSH transport backend (issue #51). `native` = the built-in ssh2 client (password / private key / agent / OTP); `openssh` = **drive the local `ssh` command**, which is the only way to reach hosts whose sole authentication method is GSSAPI/Kerberos (log in once with `kinit`), and which also honours `ProxyCommand`, `PKCS11Provider`, `Match` blocks and the rest of `~/.ssh/config`. **Settable per machine.** The cost is a fresh connection per command (slower than native); SFTP still reuses one session. The host name is handed to ssh **verbatim** (so `~/.ssh/config` stays the single source of truth) and host keys are checked by ssh against your own `known_hosts` |
247
+ | `sshBinary` | string | `''` | the `ssh` executable the `openssh` backend drives (empty = the `ssh` on PATH) |
245
248
  | `proxy` | object | — | jump host: `{ host, port?, username?, password?, privateKeyPath? }` |
246
249
  | `autoPush` | bool | `false` | auto-push edited mirror files back to the remote (watcher, debounced) |
247
250
  | `auditLog` | bool | `true` | append executed commands to `$DSH_HOME/remote-workspaces/audit.log` |
@@ -276,6 +279,8 @@ Deploying to a product profile is a separate, explicit action (`./sync.sh`) for
276
279
 
277
280
  **Cannot reach an internal machine** — set a jump host (or add the bastion as its own machine first).
278
281
 
282
+ **The host only allows GSSAPI/Kerberos (`ssh` works after `kinit`, the plugin does not)** — in Settings → 远程工作区 → Advanced, switch **Backend** to `openssh` (drive the local `ssh`) and tick "use a ~/.ssh/config alias", with Host set to the **alias** in `~/.ssh/config` (not the IP). The login is then performed by your local ssh with your Kerberos ticket, and `ProxyCommand`, `GSSAPIAuthentication` etc. all apply per `~/.ssh/config` — **one source of truth for the configuration** (issues #50/#51). Command-line equivalent: `rw_connect(host="<alias>", useSshConfig=true, backend="openssh")`.
283
+
279
284
  **`rw_sync`/`rw_push` reports conflicts** — files changed on both sides are skipped and listed (never silently overwritten); merge manually and retry, or pass `force=true`.
280
285
 
281
286
  **Windows remotes** — everything goes over SFTP, no POSIX shell needed; read Chinese files with `encoding=gbk`.
package/README.md CHANGED
@@ -46,6 +46,7 @@ DSH 的 Web 界面刻意只监听 `127.0.0.1`(CLI 为安全拒绝 `--host 0.0.
46
46
  其余要点:
47
47
 
48
48
  - **22 个模型工具**(便于复制/检索):`rw_info`、`rw_connect`、`rw_machines`、`rw_pick_workspace`、`rw_list_dir`、`rw_stat`、`rw_read_file`、`rw_write_file`、`rw_edit`、`rw_append`、`rw_mkdir`、`rw_remove`、`rw_move`、`rw_exec`、`rw_search`、`rw_download`、`rw_upload`、`rw_sync`、`rw_push`、`rw_forward`、`rw_disconnect`、`rw_deploy_probe`。
49
+ - **会话列表里区分本地/远程**(`0.8.43+`)—— 远程会话的行首有一个**绿点**,悬停显示 `user@host:port` 与远端路径;打开该会话后,标题栏还会常显一个主机标签(本地会话两者都没有)。判定只看会话 cwd 是否落在 `$DSH_HOME/remote-workspaces/…` 镜像里,所以「保存过但没在用」的机器不会给本地会话打标。
49
50
  - **把远端机器的 DSH 界面挂到本机**(`0.8.36+`)—— 不用在远端开任何端口:插件主动 SSH 连过去,在远端**只监听 `127.0.0.1`** 地起一个 `dsh web`,再把端口经隧道搬回本机的一个 loopback 端口。地址带一次性登录令牌,只在本机这次跳转里用一次。
50
51
  - **连不上时一键体检与部署**(`0.8.36+`)—— **连不上大多不是插件的问题,而是远端 dsh 的版本或环境不对**。体检(只读)会报出平台/node/npm/dsh 版本、**原生模块能否启动**、代理与 npm 源,并给出修复建议;「部署并验证」把它装到远端**私有目录**(不写系统目录、不改 PATH、不覆盖你在用的版本,删目录即回滚)并逐步校验。按钮**不再需要先体检**——直接点即可(`0.8.40+`),部署过程自己会重新体检。失败还能交给内置的 `dsh-remote-deploy` 技能排查。
51
52
  - **远端跨平台** —— 文件访问走 SFTP 协议层(不依赖 POSIX shell),Linux/macOS/Windows 远端都能列/读/写/搜索/同步。
@@ -224,6 +225,8 @@ scripts/dev-run.sh --status # 是否在运行
224
225
  | `hostKeyMode` | string | `accept-new` | 主机指纹策略:`accept-new`(首次信任)、`verify`(拒绝未知主机)、`off`(跳过校验) |
225
226
  | `useAgent` | bool | `false` | 用 OpenSSH agent(`SSH_AUTH_SOCK`)认证 |
226
227
  | `keyboardInteractive` | bool | `false` | 允许 keyboard-interactive 认证(OTP/MFA)并复用配置的密码 |
228
+ | `backend` | string | `native` | SSH 传输后端(issue #51)。`native`=内置 ssh2(密码/私钥/agent/动态码);`openssh`=**调用本机 `ssh` 命令**,因此支持 GSSAPI/Kerberos(`kinit` 后免密)以及 `ProxyCommand`、`PKCS11Provider`、`Match` 等 OpenSSH 专有特性。**可按机器单独设置**。代价:每条命令新建一次连接(比 native 慢);SFTP 仍复用单条长连接。主机名会被**原样**交给 ssh(`~/.ssh/config` 是配置唯一来源),主机密钥也由 ssh 用你自己的 `known_hosts` 校验 |
229
+ | `sshBinary` | string | `''` | `openssh` 后端调用的 `ssh` 可执行文件(留空=用 PATH 里的 `ssh`) |
227
230
  | `proxy` | object | — | 跳板机:`{ host, port?, username?, password?, privateKeyPath? }` |
228
231
  | `autoPush` | bool | `false` | 镜像内文件被编辑后自动推回远端(watcher,带防抖) |
229
232
  | `auditLog` | bool | `true` | 把执行的命令追加到 `$DSH_HOME/remote-workspaces/audit.log` |
@@ -258,6 +261,8 @@ scripts/dev-run.sh --status # 是否在运行
258
261
 
259
262
  **连不上内网机器** —— 填「跳板机」主机(也可先把跳板机本身配成一台机器)。
260
263
 
264
+ **企业机器只允许 GSSAPI/Kerberos(`kinit` 后 `ssh` 能进,插件进不去)** —— 在设置 → 远程工作区 → 高级配置里把「连接后端」改成 **`openssh`**(调用本机 `ssh`),并勾选「使用 ssh_config 别名」,Host 填 `~/.ssh/config` 里那个**别名**(不是 IP)。这样登录由本机 ssh 用你的 Kerberos 票据完成,且 `ProxyCommand`、`GSSAPIAuthentication` 等全部照 `~/.ssh/config` 生效 —— **配置只有 `~/.ssh/config` 一个来源**(issue #50/#51)。命令行等价写法:`rw_connect(host="<别名>", useSshConfig=true, backend="openssh")`。
265
+
261
266
  **`rw_sync`/`rw_push` 报冲突** —— 两边都改过的文件会被跳过并列出(绝不静默覆盖);手动合并后重试,或用 `force=true` 以一边为准。
262
267
 
263
268
  **Windows 远程** —— 全部走 SFTP,不依赖 POSIX shell;中文文件用 `encoding=gbk`。
package/lib/client.js CHANGED
@@ -49,6 +49,15 @@ window.__ModuleLoader__.load({
49
49
  'settings.addMachine': '添加机器',
50
50
  'settings.editMachine': '编辑机器',
51
51
  'settings.badgeSshConfig': 'ssh cfg',
52
+ // Issue #51 — transport backend (GSSAPI/Kerberos via system OpenSSH).
53
+ 'settings.badgeOpenssh': 'openssh',
54
+ 'settings.backend': '连接后端',
55
+ 'settings.backendNative': 'native(内置 ssh2:密码 / 私钥 / agent / 动态码)',
56
+ 'settings.backendOpenssh': 'openssh(调用本机 ssh 命令)',
57
+ 'settings.backendOpensshHint': '调用本机 ssh,因此完全遵循 ~/.ssh/config:支持 GSSAPI/Kerberos(kinit 后免密登录)、ProxyCommand、PKCS11Provider、Match 等 OpenSSH 专有特性;主机密钥由 ssh 用你自己的 known_hosts 校验。代价是每条命令都要新建一次连接(比 native 慢),文件浏览(SFTP)仍保持长连接。',
58
+ 'settings.backendOpensshAliasOk': '已勾选「使用 ssh_config 别名」:主机名原样交给 ssh,端口/用户/私钥/跳板机一律由 ~/.ssh/config 的 Host 块决定,对应的输入框在 openssh 后端下填了也不生效(这就是「配置唯一来源」,issue #50)。密码/Passphrase 仍会通过 askpass 交给 ssh,仅当 Host 块里没有可用认证时才用得上。',
59
+ 'settings.sshBinary': 'ssh 路径',
60
+ 'settings.sshBinaryPlaceholder': '留空 = 用 PATH 里的 ssh',
52
61
  'settings.sshImport': '从 ~/.ssh/config 导入',
53
62
  'settings.sshCollapse': '收起',
54
63
  'settings.sshNoEntries': '~/.ssh/config 里没有可导入的 Host 条目',
@@ -257,6 +266,9 @@ window.__ModuleLoader__.load({
257
266
  'explorer.noRemoteWs': '未设置远程工作区',
258
267
  'explorer.sessionLocal': '当前会话未使用远程工作区',
259
268
  'explorer.localSession': '当前会话是本地工作区,未使用远程文件。\n\n如需访问远程,可在设置页将某台机器「设为当前」,再用上方「…」选择远程工作区。',
269
+ // issue #49:会话列表里标识「这是远程会话,连的是哪台机」
270
+ 'session.remoteBadge': '远程会话:{target}',
271
+ 'session.remoteHover': '远程主机:{target}',
260
272
  'explorer.loading': '加载中…',
261
273
  'explorer.dirMissing': '目录不存在或已被删除',
262
274
  'explorer.notConnected': '未连接远程主机(重启后需在设置中将机器「设为当前」,或刷新右侧列)',
@@ -315,6 +327,15 @@ window.__ModuleLoader__.load({
315
327
  'settings.addMachine': 'Add machine',
316
328
  'settings.editMachine': 'Edit machine',
317
329
  'settings.badgeSshConfig': 'ssh cfg',
330
+ // Issue #51 — transport backend (GSSAPI/Kerberos via system OpenSSH).
331
+ 'settings.badgeOpenssh': 'openssh',
332
+ 'settings.backend': 'Backend',
333
+ 'settings.backendNative': 'native (built-in ssh2: password / key / agent / OTP)',
334
+ 'settings.backendOpenssh': 'openssh (drive the local ssh command)',
335
+ 'settings.backendOpensshHint': 'Delegates the transport to the local `ssh`, so the whole of ~/.ssh/config applies: GSSAPI/Kerberos (log in once with kinit and no secret is needed), ProxyCommand, PKCS11Provider, Match blocks… Host keys are checked by ssh against your own known_hosts. The cost is a fresh connection per command (slower than native); file browsing over SFTP still reuses one session.',
336
+ 'settings.backendOpensshAliasOk': '"Use a ~/.ssh/config alias" is on: the name is handed to ssh verbatim, and Port/User/IdentityFile/ProxyJump come from that Host block — the matching fields below have no effect (this is the "one source of truth" case, issue #50). A password/passphrase is still offered to ssh through askpass, and is only used if the Host block has no usable authentication.',
337
+ 'settings.sshBinary': 'ssh path',
338
+ 'settings.sshBinaryPlaceholder': 'Empty = the ssh on PATH',
318
339
  'settings.sshImport': 'Import from ~/.ssh/config',
319
340
  'settings.sshCollapse': 'Collapse',
320
341
  'settings.sshNoEntries': 'No importable Host entries in ~/.ssh/config',
@@ -523,6 +544,9 @@ window.__ModuleLoader__.load({
523
544
  'explorer.noRemoteWs': 'No remote workspace set',
524
545
  'explorer.sessionLocal': 'This session does not use a remote workspace',
525
546
  'explorer.localSession': 'This session is a local workspace and does not use remote files.\n\nTo access remote, set a machine as "current" in Settings, then pick a remote workspace with the "…" button above.',
547
+ // issue #49: tell a remote Session apart in the Session list, and name its host.
548
+ 'session.remoteBadge': 'Remote session: {target}',
549
+ 'session.remoteHover': 'Remote host: {target}',
526
550
  'explorer.loading': 'Loading…',
527
551
  'explorer.dirMissing': 'Directory does not exist or was deleted',
528
552
  'explorer.notConnected': 'Not connected to the remote host (after a restart, set a machine as "current" in Settings, or refresh the right column)',
@@ -763,8 +787,40 @@ window.__ModuleLoader__.load({
763
787
  document.head.appendChild(el)
764
788
  } catch { /* vm tests / no DOM */ }
765
789
  }
790
+ /**
791
+ * Issue #52 — a backdrop may close its dialog only when the PRESS began on
792
+ * the backdrop itself.
793
+ *
794
+ * `click` is dispatched on the nearest common ancestor of the mousedown and
795
+ * mouseup targets. A gesture that pressed an element INSIDE the panel and
796
+ * released on the backdrop — because the panel re-laid-out mid-gesture (the
797
+ * remote autocomplete list used to sit in document flow, so expanding it
798
+ * pushed a centred panel's edges out from under the pointer) — is therefore
799
+ * reported as a click ON THE BACKDROP, and a bare `onClick` closed the whole
800
+ * dialog. Selecting a suggestion kicked the user out of the picker.
801
+ *
802
+ * Guarding on the press origin keeps "click outside to dismiss" while a
803
+ * drag that merely ENDS outside can no longer dismiss anything. The panel's
804
+ * own `stopPropagation` stays as a second line of defence.
805
+ *
806
+ * @returns {{onMouseDown: Function, onClick: (e: object) => boolean}} props to
807
+ * spread on the backdrop; `onClick` reports whether the press started there.
808
+ */
809
+ function useBackdropGuard() {
810
+ const pressedOnBackdrop = React.useRef(false)
811
+ return {
812
+ onMouseDown: (e) => { pressedOnBackdrop.current = e.target === e.currentTarget },
813
+ onClick: (e) => {
814
+ const startedOnBackdrop = pressedOnBackdrop.current
815
+ pressedOnBackdrop.current = false
816
+ return startedOnBackdrop && e.target === e.currentTarget
817
+ },
818
+ }
819
+ }
820
+
766
821
  function PromptDialog(props) {
767
822
  const { title, initial, placeholder, onSubmit, onCancel } = props
823
+ const backdrop = useBackdropGuard()
768
824
  const [val, setVal] = React.useState(initial || '')
769
825
  React.useEffect(() => {
770
826
  try {
@@ -779,7 +835,10 @@ window.__ModuleLoader__.load({
779
835
  }, [val, onSubmit, onCancel])
780
836
  return React.createElement('div', {
781
837
  style: { position: 'fixed', inset: 0, zIndex: 1000000, background: 'rgba(0,0,0,0.45)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 },
782
- onClick: onCancel,
838
+ onMouseDown: backdrop.onMouseDown,
839
+ // Issue #52: only a press that STARTED here may dismiss (a gesture that
840
+ // began inside the panel and ended on the backdrop must not).
841
+ onClick: (e) => { if (backdrop.onClick(e)) onCancel() },
783
842
  },
784
843
  React.createElement('div', {
785
844
  className: 'dsh-rw-modal-panel',
@@ -890,6 +949,9 @@ window.__ModuleLoader__.load({
890
949
  host: form.host, port: Number(form.port) || 22, username: form.username || 'root',
891
950
  password: form.password, privateKeyPath: form.privateKeyPath, passphrase: form.passphrase,
892
951
  useSshConfig: !!form.useSshConfig,
952
+ // Issue #51: the probe must exercise the SAME backend the machine will
953
+ // use — a host reachable only via GSSAPI fails the native probe.
954
+ backend: form.backend || 'native',
893
955
  proxy: form.proxyHost ? { host: form.proxyHost, port: Number(form.proxyPort) || 22, username: form.proxyUser || '', password: form.proxyPassword || '', privateKeyPath: form.proxyKey || '' } : undefined,
894
956
  })
895
957
  .then((r) => {
@@ -1075,15 +1137,17 @@ window.__ModuleLoader__.load({
1075
1137
  encryptPassword: !!(m.credentialBackend && m.credentialBackend !== 'plain'),
1076
1138
  proxyHost: (m.proxy && m.proxy.host) || '', proxyPort: String((m.proxy && m.proxy.port) || 22),
1077
1139
  proxyUser: (m.proxy && m.proxy.username) || '', proxyPassword: '', proxyKey: (m.proxy && m.proxy.privateKeyPath) || '',
1140
+ // Issue #51: the transport backend is per machine.
1141
+ backend: m.backend === 'openssh' ? 'openssh' : 'native', sshBinary: m.sshBinary || '',
1078
1142
  id: m.id,
1079
1143
  useSshConfig: !!m.useSshConfig,
1080
1144
  })
1081
- // 编辑时若该机器用了高级配置(私钥/agent/跳板/钥匙串等),自动展开折叠区
1082
- if (m.privateKeyPath || m.useAgent || m.keyboardInteractive || m.workspace || (m.proxy && m.proxy.host) || (m.credentialBackend && m.credentialBackend !== 'plain')) {
1145
+ // 编辑时若该机器用了高级配置(私钥/agent/跳板/钥匙串/系统 OpenSSH 后端),自动展开折叠区
1146
+ if (m.privateKeyPath || m.useAgent || m.keyboardInteractive || m.workspace || (m.proxy && m.proxy.host) || m.backend === 'openssh' || (m.credentialBackend && m.credentialBackend !== 'plain')) {
1083
1147
  setAdvOpen(true)
1084
1148
  }
1085
1149
  }
1086
- const clearForm = () => setForm({ name: '', host: '', port: '22', username: 'root', password: '', privateKeyPath: '', passphrase: '', workspace: '', hostKeyMode: '', useAgent: false, keyboardInteractive: false, encryptPassword: false, proxyHost: '', proxyPort: '22', proxyUser: '', proxyPassword: '', proxyKey: '', id: '', useSshConfig: false })
1150
+ const clearForm = () => setForm({ name: '', host: '', port: '22', username: 'root', password: '', privateKeyPath: '', passphrase: '', workspace: '', hostKeyMode: '', useAgent: false, keyboardInteractive: false, encryptPassword: false, proxyHost: '', proxyPort: '22', proxyUser: '', proxyPassword: '', proxyKey: '', backend: 'native', sshBinary: '', id: '', useSshConfig: false })
1087
1151
  const save = (action) => {
1088
1152
  setBusy(true); setErr(''); setMsg('')
1089
1153
  if (action === 'delete') {
@@ -1095,6 +1159,8 @@ window.__ModuleLoader__.load({
1095
1159
  username: form.username, password: form.password, privateKeyPath: form.privateKeyPath, passphrase: form.passphrase,
1096
1160
  workspace: form.workspace, hostKeyMode: form.hostKeyMode, useAgent: form.useAgent, keyboardInteractive: form.keyboardInteractive,
1097
1161
  encryptPassword: form.encryptPassword, useSshConfig: !!form.useSshConfig,
1162
+ // Issue #51: the transport backend (and, for `openssh`, the executable).
1163
+ backend: form.backend || 'native', sshBinary: form.sshBinary || '',
1098
1164
  proxy: form.proxyHost ? { host: form.proxyHost, port: Number(form.proxyPort) || 22, username: form.proxyUser || '', password: form.proxyPassword || '', privateKeyPath: form.proxyKey || '' } : undefined,
1099
1165
  }).then((r) => {
1100
1166
  refresh(); clearForm()
@@ -1185,6 +1251,10 @@ window.__ModuleLoader__.load({
1185
1251
  m.useSshConfig ? (m.host + (m.sshConfigResolved && m.sshConfigResolved.matched ? ' ' + tr('settings.sshResolvedTo', { user: m.sshConfigResolved.username || '?', host: m.sshConfigResolved.host, port: m.sshConfigResolved.port }) : ''))
1186
1252
  : (m.username + '@' + m.host + ':' + m.port)),
1187
1253
  m.useSshConfig ? pill('', tr('settings.badgeSshConfig')) : null,
1254
+ // Issue #51: a machine on the system-OpenSSH backend behaves
1255
+ // differently enough (auth from ~/.ssh/config, GSSAPI) that
1256
+ // the row must say so.
1257
+ m.backend === 'openssh' ? pill('', tr('settings.badgeOpenssh')) : null,
1188
1258
  m.id === currentId ? pill('is-ok', tr('settings.current')) : null,
1189
1259
  m.passwordSet ? pill('', tr('settings.badgePwd')) : null,
1190
1260
  (m.credentialBackend && m.credentialBackend !== 'plain') ? pill('', tr('settings.badgeKeychain')) : null,
@@ -1246,6 +1316,23 @@ window.__ModuleLoader__.load({
1246
1316
  style: { ...buttonS, width: '100%', marginBottom: advOpen ? 10 : 0, color: advOpen ? T.primary : 'inherit', fontWeight: advOpen ? 600 : 400, borderStyle: 'dashed' },
1247
1317
  }, advOpen ? tr('settings.advCollapse') : tr('settings.advExpand')),
1248
1318
  advOpen ? React.createElement('div', { key: 'adv', style: { paddingTop: 2 } },
1319
+ // Issue #51 — transport backend. Placed FIRST in the advanced block
1320
+ // because it changes what the fields below even mean: with the
1321
+ // system-OpenSSH backend, host/user/port/key all come from
1322
+ // ~/.ssh/config and only the machine name (an alias) matters.
1323
+ row(tr('settings.backend'), React.createElement('select', { value: form.backend || 'native', onChange: setF('backend'), style: { ...inputS, maxWidth: 260 } },
1324
+ React.createElement('option', { value: 'native' }, tr('settings.backendNative')),
1325
+ React.createElement('option', { value: 'openssh' }, tr('settings.backendOpenssh'))), 'be'),
1326
+ (form.backend === 'openssh')
1327
+ ? React.createElement('div', { style: { fontSize: 11, opacity: 0.85, marginBottom: 8, marginLeft: LABEL_W + 8, lineHeight: 1.55 } },
1328
+ React.createElement('div', null, tr('settings.backendOpensshHint')),
1329
+ form.useSshConfig
1330
+ ? React.createElement('div', { style: { color: T.ok, marginTop: 2 } }, tr('settings.backendOpensshAliasOk'))
1331
+ : null)
1332
+ : null,
1333
+ (form.backend === 'openssh')
1334
+ ? row(tr('settings.sshBinary'), React.createElement('input', { value: form.sshBinary || '', onChange: setF('sshBinary'), placeholder: tr('settings.sshBinaryPlaceholder'), style: inputS }), 'sb')
1335
+ : null,
1249
1336
  row(tr('settings.privateKey'), React.createElement('input', { value: form.privateKeyPath, onChange: setF('privateKeyPath'), placeholder: 'Empty = password or agent', style: inputS }), 'k'),
1250
1337
  row(tr('settings.passphrase'), React.createElement('input', { type: 'password', value: form.passphrase, onChange: setF('passphrase'), placeholder: 'Encrypted private key passphrase', style: inputS }), 'pp'),
1251
1338
  row(tr('settings.workspace'), React.createElement('input', { value: form.workspace, onChange: setF('workspace'), placeholder: 'Optional: default remote dir for this machine', style: inputS }), 'ws'),
@@ -1608,6 +1695,13 @@ window.__ModuleLoader__.load({
1608
1695
  const [mkdirPrompt, setMkdirPrompt] = React.useState(false)
1609
1696
  const suggestTimer = React.useRef(null)
1610
1697
  const suggestRef = React.useRef(null)
1698
+ // Issue #52 defect 1: the modal backdrop must dismiss only when the press
1699
+ // STARTED on it (a suggestion click whose mouseup landed on the backdrop
1700
+ // used to close the whole picker). The local-browse popup below is its own
1701
+ // full-screen overlay, so it gets its own guard ref — sharing one ref
1702
+ // would make a press on either backdrop arm the other.
1703
+ const backdrop = useBackdropGuard()
1704
+ const localBackdrop = useBackdropGuard()
1611
1705
  // Autocomplete performance (slow per-keystroke remote scans):
1612
1706
  // - postedCurrentRef/currentPromiseRef: POST /current once per machine
1613
1707
  // switch; concurrent callers share ONE in-flight promise and a failed
@@ -2037,7 +2131,8 @@ window.__ModuleLoader__.load({
2037
2131
  const parent = crumbs.length >= 2 ? crumbs[crumbs.length - 2].path : null
2038
2132
  return React.createElement('div', {
2039
2133
  style: { position: 'fixed', inset: 0, zIndex: 9999, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 12 },
2040
- onClick: () => setLocalPop(null),
2134
+ onMouseDown: localBackdrop.onMouseDown,
2135
+ onClick: (e) => { if (localBackdrop.onClick(e)) setLocalPop(null) },
2041
2136
  },
2042
2137
  React.createElement('div', { style: { background: v('--dsw-alias-bg-overlay', '#1e1e1e'), border: '1px solid ' + T.borderStrong, borderRadius: 10, boxShadow: '0 10px 40px rgba(0,0,0,0.5)', width: 'min(560px, 94vw)', minWidth: 320, display: 'flex', flexDirection: 'column', maxHeight: 'min(460px, 84vh)', overflow: 'hidden' }, onClick: (e) => e.stopPropagation() },
2043
2138
  React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center', padding: '8px 12px', borderBottom: '1px solid ' + T.border } },
@@ -2148,7 +2243,15 @@ window.__ModuleLoader__.load({
2148
2243
  onClick: () => switchTab(t),
2149
2244
  style: { ...buttonS, fontWeight: tab === t ? 700 : 400, padding: '5px 14px' },
2150
2245
  }, lbl)
2151
- return React.createElement('div', { style: { position: 'fixed', inset: 0, zIndex: 9999, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }, onClick: () => { if (!busy && !mkdirPrompt) onCancel() } },
2246
+ return React.createElement('div', {
2247
+ style: { position: 'fixed', inset: 0, zIndex: 9999, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 },
2248
+ onMouseDown: backdrop.onMouseDown,
2249
+ // Issue #52 defect 1: this was a bare onClick, so picking an autocomplete
2250
+ // suggestion (mousedown inside the panel, panel re-laid-out, mouseup on
2251
+ // the backdrop) closed the whole dialog. Only a press that began on the
2252
+ // backdrop dismisses now.
2253
+ onClick: (e) => { if (backdrop.onClick(e) && !busy && !mkdirPrompt) onCancel() },
2254
+ },
2152
2255
  React.createElement('div', { className: 'dsh-rw-modal-panel', style: { background: v('--dsw-alias-bg-layer-1', '#18181b'), color: T.label, border: '1px solid ' + T.borderStrong, borderRadius: 12, boxShadow: '0 12px 48px rgba(0,0,0,0.5)', width: 'min(600px, 94vw)', maxHeight: 'min(680px, 92vh)', display: 'flex', flexDirection: 'column', padding: 16, boxSizing: 'border-box' }, onClick: (e) => e.stopPropagation() },
2153
2256
  React.createElement('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 10 } },
2154
2257
  React.createElement('div', { style: { fontSize: 15, fontWeight: 650 } }, tr('picker.title')),
@@ -2169,7 +2272,7 @@ window.__ModuleLoader__.load({
2169
2272
  err ? React.createElement('div', { style: { color: T.danger, fontSize: 12 } }, err) : null,
2170
2273
  localPop ? renderLocalPopup() : null,
2171
2274
  )
2172
- : React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 8, minHeight: 0, overflowY: 'auto' } },
2275
+ : React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 8, minHeight: 0, overflowY: suggestOpen ? 'visible' : 'auto' } },
2173
2276
  React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center' } },
2174
2277
  React.createElement('label', { style: { fontSize: 12, opacity: 0.8, whiteSpace: 'nowrap' } }, tr('picker.remoteMachine')),
2175
2278
  React.createElement('div', { ref: ddRef, style: { position: 'relative', display: 'inline-block' } },
@@ -2209,14 +2312,29 @@ window.__ModuleLoader__.load({
2209
2312
  React.createElement('button', { type: 'button', className: 'dsh-rw-btn', style: { ...buttonS, whiteSpace: 'nowrap' }, onClick: pickHome, disabled: !machineId }, tr('picker.home')),
2210
2313
  ),
2211
2314
  (suggestOpen && suggest.length)
2212
- ? React.createElement('div', { style: { marginTop: 6, background: v('--dsw-alias-bg-overlay', '#1e1e1e'), color: T.label, border: '1px solid ' + T.borderStrong, borderRadius: 8, maxHeight: 200, overflowY: 'auto', boxShadow: '0 6px 24px rgba(0,0,0,0.25)' } },
2213
- suggest.map((s, i) => React.createElement('div', { key: s + i, className: 'dsh-rw-list-item', onMouseDown: () => selectSuggestion(s), style: { padding: '6px 10px', cursor: 'pointer', fontSize: 12, fontFamily: 'monospace', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' } }, s)),
2315
+ // Issue #52 defect 2: this used to be an in-flow block, so
2316
+ // expanding it pushed the 「设为远程工作区」 button down and the
2317
+ // user could never hit a button that was moving. It is now an
2318
+ // absolutely-positioned overlay hanging off `suggestRef`
2319
+ // (position: relative), which also keeps the modal's height —
2320
+ // and therefore its centred edges — stable while open (that
2321
+ // stability is what makes the press-origin guard above exact).
2322
+ // `paddingBottom` lets the last entries scroll clear of the
2323
+ // button row, which is lifted over the dropdown by zIndex.
2324
+ ? React.createElement('div', { style: { position: 'absolute', top: '100%', left: 0, right: 0, zIndex: 60, marginTop: 6, paddingBottom: 36, background: v('--dsw-alias-bg-overlay', '#1e1e1e'), color: T.label, border: '1px solid ' + T.borderStrong, borderRadius: 8, maxHeight: 200, overflowY: 'auto', boxShadow: '0 6px 24px rgba(0,0,0,0.25)' } },
2325
+ // Issue #52 defect 1, local belt-and-braces: swallow the
2326
+ // gesture so no ancestor (least of all the backdrop) can
2327
+ // observe a click that began on a suggestion.
2328
+ suggest.map((s, i) => React.createElement('div', { key: s + i, className: 'dsh-rw-list-item', onMouseDown: (e) => { e.preventDefault(); e.stopPropagation(); selectSuggestion(s) }, style: { padding: '6px 10px', cursor: 'pointer', fontSize: 12, fontFamily: 'monospace', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' } }, s)),
2214
2329
  )
2215
2330
  : null,
2216
2331
  ),
2217
2332
  popOpen ? renderDirPopup() : null,
2218
2333
  err ? React.createElement('div', { style: { color: T.danger, fontSize: 12 } }, err) : null,
2219
- React.createElement('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end', alignItems: 'center' } },
2334
+ // The action row rides ABOVE the dropdown (zIndex 70) on its own
2335
+ // opaque layer, so the list slides out from behind it instead of
2336
+ // covering 「设为远程工作区」.
2337
+ React.createElement('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end', alignItems: 'center', position: 'relative', zIndex: 70, background: v('--dsw-alias-bg-layer-1', '#18181b') } },
2220
2338
  React.createElement('span', { style: { fontSize: 11, opacity: 0.75, fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 } }, path ? tr('picker.selected', { path }) : ''),
2221
2339
  React.createElement('button', { type: 'button', className: 'dsh-rw-btn dsh-rw-primary', style: primaryBtnS, onClick: () => commitPath(path), disabled: busy || !machineId || !path.trim() }, busy ? tr('picker.mirroring') : tr('picker.setRemoteWs')),
2222
2340
  ),
@@ -2275,6 +2393,199 @@ window.__ModuleLoader__.load({
2275
2393
  .catch(() => '')
2276
2394
  }
2277
2395
 
2396
+ // ── which sessions are remote (issue #49) ────────────────────────────────
2397
+ //
2398
+ // The sidebar must say, at a glance, whether a Session row is a LOCAL
2399
+ // session or one working on a remote host — and which host. The truth is
2400
+ // already on the client: every Session summary carries its own cwd, and a
2401
+ // cwd that lives inside a `$DSH_HOME/remote-workspaces/<host-user-port>/…`
2402
+ // mirror IS a remote session bound to that mirror's recorded origin (the
2403
+ // same invariant lib/binding.js uses host-side; see issue #13).
2404
+ //
2405
+ // So one GET /dsh-remote/bindings (the whole mirror registry, with each
2406
+ // mirror's recorded host/port/user/path) answers every row, instead of one
2407
+ // request per row. The active machine is deliberately NOT consulted: a saved
2408
+ // machine that happens to be "current" must never label a local session.
2409
+ //
2410
+ // Failure is not fatal and not retried in a loop: no registry means no
2411
+ // badge, which is exactly what a user with zero mirrors sees.
2412
+
2413
+ const MIRRORS = { list: [], loaded: false, error: '' }
2414
+ const mirrorWatchers = new Set()
2415
+ let mirrorLoad = null
2416
+ let mirrorTimer = null
2417
+
2418
+ const notifyMirrors = () => {
2419
+ for (const fn of mirrorWatchers) { try { fn() } catch { /* one watcher must not stop the rest */ } }
2420
+ }
2421
+ /** Load the mirror registry once (concurrent callers share the request). */
2422
+ function ensureMirrors() {
2423
+ if (MIRRORS.loaded) return Promise.resolve(MIRRORS)
2424
+ if (mirrorLoad) return mirrorLoad
2425
+ mirrorLoad = api('GET', '/dsh-remote/bindings')
2426
+ .then((r) => {
2427
+ MIRRORS.list = (r && Array.isArray(r.mirrors)) ? r.mirrors : []
2428
+ MIRRORS.error = ''
2429
+ })
2430
+ .catch((e) => { MIRRORS.error = String((e && e.message) || e) })
2431
+ .then(() => {
2432
+ MIRRORS.loaded = true
2433
+ mirrorLoad = null
2434
+ notifyMirrors()
2435
+ return MIRRORS
2436
+ })
2437
+ return mirrorLoad
2438
+ }
2439
+ /** Force a refresh (mirrors appear when a workspace is picked). */
2440
+ function refreshMirrors() {
2441
+ MIRRORS.loaded = false
2442
+ return ensureMirrors()
2443
+ }
2444
+ /**
2445
+ * The mirror containing a local path, or null.
2446
+ *
2447
+ * Separator-aware on purpose: `/a/mirror-2` must NOT be treated as living
2448
+ * inside `/a/mirror` (the same trap lib/binding.js documents). The deepest
2449
+ * match wins, so a future nested layout stays stable. Windows paths compare
2450
+ * case-insensitively because the platform does.
2451
+ */
2452
+ function mirrorForCwd(cwd, mirrors) {
2453
+ const raw = String(cwd || '')
2454
+ if (!raw) return null
2455
+ const list = Array.isArray(mirrors) ? mirrors : []
2456
+ const isWin = raw.includes('\\')
2457
+ const sep = isWin ? '\\' : '/'
2458
+ const fold = (s) => (isWin ? s.toLowerCase() : s)
2459
+ const base = fold(raw.replace(/[\\/]+$/, ''))
2460
+ let best = null
2461
+ for (const m of list) {
2462
+ const dirRaw = String((m && m.dir) || '').replace(/[\\/]+$/, '')
2463
+ if (!dirRaw) continue
2464
+ const dir = fold(dirRaw)
2465
+ if (base !== dir && !base.startsWith(dir + sep) && !base.startsWith(dir + '/')) continue
2466
+ if (!best || dir.length > fold(String(best.dir).replace(/[\\/]+$/, '')).length) best = m
2467
+ }
2468
+ return best
2469
+ }
2470
+ /** `user@host:port` for a mirror record (port omitted when it is 22). */
2471
+ function mirrorTarget(m) {
2472
+ if (!m) return ''
2473
+ const user = m.username ? m.username + '@' : ''
2474
+ const port = Number(m.port) && Number(m.port) !== 22 ? ':' + m.port : ''
2475
+ return user + String(m.host || '') + port
2476
+ }
2477
+ /** Host as the reader asked for it: the address, plus the saved machine name. */
2478
+ function mirrorLabel(m) {
2479
+ if (!m) return ''
2480
+ const target = mirrorTarget(m)
2481
+ return m.name && m.name !== m.host ? target + ' · ' + m.name : target
2482
+ }
2483
+ /**
2484
+ * The remote binding of one Session row, read from that row's own cwd.
2485
+ *
2486
+ * A row with no cwd (a blank New Session) is not remote — a blank session
2487
+ * has no workspace yet, and must never inherit the badge from a neighbor.
2488
+ *
2489
+ * Hook shape is CONSTANT: when the framework does not hand the entry a
2490
+ * `useSessions` (older sidebar), a stable no-op hook is selected instead of
2491
+ * skipping the call, so the hook count cannot change across renders (a
2492
+ * conditional hook is a crash, not a degraded badge).
2493
+ */
2494
+ const NO_SELECT = () => undefined
2495
+ function useSessionMirror(sessionId, useSessions) {
2496
+ // `mirrorVersion` is only a re-render trigger: the registry arrives
2497
+ // asynchronously, and every mounted mark must repaint when it does.
2498
+ const [, bumpMirrorVersion] = React.useState(0)
2499
+ const select = typeof useSessions === 'function' ? useSessions : NO_SELECT
2500
+ const cwd = select((s) => (sessionId && s && s.byId && s.byId[sessionId] ? s.byId[sessionId].cwd : undefined))
2501
+ React.useEffect(() => {
2502
+ const fn = () => bumpMirrorVersion((n) => n + 1)
2503
+ mirrorWatchers.add(fn)
2504
+ ensureMirrors()
2505
+ // Mirrors change when a remote workspace is picked in THIS tab; a slow
2506
+ // poll covers a pick made in another tab without a per-render request.
2507
+ if (!mirrorTimer && typeof setInterval === 'function') {
2508
+ mirrorTimer = setInterval(refreshMirrors, 60000)
2509
+ }
2510
+ return () => {
2511
+ mirrorWatchers.delete(fn)
2512
+ if (!mirrorWatchers.size && mirrorTimer) { clearInterval(mirrorTimer); mirrorTimer = null }
2513
+ }
2514
+ }, [])
2515
+ return mirrorForCwd(cwd, MIRRORS.list)
2516
+ }
2517
+
2518
+ // Leading seat of a Session row: the 16px cell before the title. A green dot
2519
+ // means "this session works on a remote host"; a local session renders
2520
+ // nothing at all, so the cell stays blank exactly as before. The owner only
2521
+ // mounts this seat while the row is idle (a running/pending row keeps its
2522
+ // own status dot there) — the hover card below therefore carries the host
2523
+ // for every row, running or not.
2524
+ function RemoteSessionMark(props) {
2525
+ const m = useSessionMirror(props.sessionId, props.useSessions)
2526
+ if (!m) return null
2527
+ const target = mirrorTarget(m)
2528
+ return React.createElement('span', {
2529
+ 'data-dsh-remote-mark': '',
2530
+ role: 'img',
2531
+ 'aria-label': tr('session.remoteBadge', { target }),
2532
+ title: tr('session.remoteBadge', { target }) + (m.remotePath ? '\n' + m.remotePath : ''),
2533
+ style: { width: 16, height: 20, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 },
2534
+ }, React.createElement('span', {
2535
+ 'aria-hidden': 'true',
2536
+ style: {
2537
+ width: 8, height: 8, borderRadius: '50%',
2538
+ background: 'var(--dsw-alias-state-success-primary, #4caf7d)',
2539
+ boxShadow: '0 0 0 2px color-mix(in srgb, var(--dsw-alias-state-success-primary, #4caf7d) 22%, transparent)',
2540
+ },
2541
+ }))
2542
+ }
2543
+
2544
+ // Hover-card section of a Session row, between its relative time and its
2545
+ // trailing status line: names the remote host (and the remote path it
2546
+ // works on) for a remote row. A local row renders nothing.
2547
+ function RemoteSessionHover(props) {
2548
+ const m = useSessionMirror(props.sessionId, props.useSessions)
2549
+ if (!m) return null
2550
+ return React.createElement('div', { 'data-dsh-remote-hover': '', style: { display: 'flex', flexDirection: 'column', gap: 2, fontSize: 12, lineHeight: '16px' } },
2551
+ React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 6 } },
2552
+ React.createElement('span', {
2553
+ 'aria-hidden': 'true',
2554
+ style: { width: 7, height: 7, borderRadius: '50%', flexShrink: 0, background: 'var(--dsw-alias-state-success-primary, #4caf7d)' },
2555
+ }),
2556
+ React.createElement('span', { style: { color: '#cfd3d6', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, mirrorLabel(m)),
2557
+ ),
2558
+ m.remotePath
2559
+ ? React.createElement('div', { style: { color: '#adb2b8', wordBreak: 'break-all', paddingLeft: 13 } }, m.remotePath)
2560
+ : null,
2561
+ )
2562
+ }
2563
+
2564
+ // Conversation header chip for the CURRENT session: the one place the host
2565
+ // can stay visible without hovering anything (the row's 16px cell is too
2566
+ // narrow for an address). Renders nothing for a local session.
2567
+ //
2568
+ // This seat is session-scoped, so the owner hands the entry its `sessionId`
2569
+ // (exactly like the shipped "open in app" utility) and the chip needs
2570
+ // nothing from the sidebar.
2571
+ function RemoteSessionChip(props) {
2572
+ const m = useSessionMirror(props.sessionId, props.useSessions)
2573
+ if (!m) return null
2574
+ const target = mirrorTarget(m)
2575
+ return React.createElement('span', {
2576
+ 'data-dsh-remote-chip': '',
2577
+ className: 'dsh-rw-pill is-ok',
2578
+ title: tr('session.remoteHover', { target }) + (m.remotePath ? '\n' + m.remotePath : ''),
2579
+ style: { maxWidth: 220, overflow: 'hidden', textOverflow: 'ellipsis', display: 'inline-flex', alignItems: 'center', gap: 5 },
2580
+ },
2581
+ React.createElement('span', {
2582
+ 'aria-hidden': 'true',
2583
+ style: { width: 7, height: 7, borderRadius: '50%', flexShrink: 0, background: 'var(--dsw-alias-state-success-primary, #4caf7d)' },
2584
+ }),
2585
+ React.createElement('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, target),
2586
+ )
2587
+ }
2588
+
2278
2589
  function readRemoteFile(path, maxBytes, sessionId) {
2279
2590
  return api('POST', '/dsh-remote/read', withSessionBody({ path, maxBytes }, sessionId)).catch((e) => {
2280
2591
  throw new Error(tr('file.readFail', { msg: ((e && e.message) || e) }))
@@ -3083,6 +3394,19 @@ window.__ModuleLoader__.load({
3083
3394
  inner.effect(() => registerSidebarIntegration(inner), 'dsh-remote.betterSidebar')
3084
3395
  })
3085
3396
  ctx.inject(['sidebarRightTabs'], registerNativeSidebar)
3397
+ // Issue #49: mark the remote Sessions in the sidebar's own Session list.
3398
+ // These three seats belong to the SHIPPED ui-workspace browser, so they
3399
+ // are injected (not assumed): a composition with a replacement sidebar
3400
+ // simply never declares them, and the mark is absent rather than broken.
3401
+ slots.inject('sidebar.session.row.leading', () =>
3402
+ slots.register({ name: 'sidebar.session.row.leading', id: 'dsh-remote-remote-mark', order: 5 }, RemoteSessionMark),
3403
+ )
3404
+ slots.inject('sidebar.session.row.hover', () =>
3405
+ slots.register({ name: 'sidebar.session.row.hover', id: 'dsh-remote-remote-host', order: 5 }, RemoteSessionHover),
3406
+ )
3407
+ slots.inject('conversation.session.header.utilities', () =>
3408
+ slots.register({ name: 'conversation.session.header.utilities', id: 'dsh-remote-remote-chip', order: 15 }, RemoteSessionChip),
3409
+ )
3086
3410
  }
3087
3411
 
3088
3412
  exports.name = name
package/lib/forwards.js CHANGED
@@ -155,6 +155,22 @@ export class ForwardManager {
155
155
  }
156
156
 
157
157
  _startReverse(d, client) {
158
+ // The system-OpenSSH backend (issue #51) cannot do ssh2's two-phase
159
+ // `forwardIn` + `tcpip`: the `ssh -R` CLI needs the final target as an
160
+ // argument, so it exposes `reverseForward()` instead. Prefer it when
161
+ // present — otherwise every reverse tunnel would fail on that backend.
162
+ if (typeof client.reverseForward === 'function') {
163
+ return client.reverseForward({
164
+ bindHost: '127.0.0.1',
165
+ listenPort: d.listenPort,
166
+ targetHost: d.targetHost,
167
+ targetPort: d.targetPort,
168
+ }).then((handle) => {
169
+ this.servers.set(d.id, { kind: 'reverse-cli', handle, client })
170
+ }).catch((err) => {
171
+ throw new Error(`远端监听 ${d.listenPort} 失败: ${(err && err.message) || err}`)
172
+ })
173
+ }
158
174
  return new Promise((resolve, reject) => {
159
175
  // ssh2 calls the remote TCP listen API `forwardIn` (NOT openssh_forwardIn,
160
176
  // which in ssh2 >= 1.16 is stream-local only).
@@ -170,6 +186,9 @@ export class ForwardManager {
170
186
  }
171
187
 
172
188
  _handleTcpip(info, accept, reject) {
189
+ // Only the ssh2 backend reaches here: `ssh -R` handles the remote listener
190
+ // and its targets entirely inside the CLI, so a 'reverse-cli' entry never
191
+ // produces a 'tcpip' event.
173
192
  for (const entry of this.servers.values()) {
174
193
  if (entry.kind === 'reverse' && Number(info.destPort) === Number(this._defFor(entry).listenPort)) {
175
194
  const channel = accept()
@@ -205,6 +224,9 @@ export class ForwardManager {
205
224
  if (entry.kind === 'local') {
206
225
  entry.server.close()
207
226
  for (const s of entry.sockets) { try { s.destroy() } catch {} }
227
+ } else if (entry.kind === 'reverse-cli') {
228
+ // The `ssh -R` child IS the tunnel: killing it closes the remote listener.
229
+ if (entry.handle && typeof entry.handle.close === 'function') entry.handle.close()
208
230
  } else if (entry.client && typeof entry.client.unforwardIn === 'function') {
209
231
  const d = this._defFor(entry)
210
232
  entry.client.unforwardIn('127.0.0.1', d.listenPort, () => {})