dsh-remote 0.8.46 → 0.8.48

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.
Files changed (391) hide show
  1. package/LICENSE +21 -21
  2. package/README.en.md +361 -361
  3. package/README.md +368 -368
  4. package/cordis.patch.yml +9 -9
  5. package/lib/binding.js +172 -172
  6. package/lib/client.js +3637 -3637
  7. package/lib/credential.js +177 -177
  8. package/lib/deploy-tasks.js +142 -142
  9. package/lib/errors.js +87 -87
  10. package/lib/file-reference.js +457 -457
  11. package/lib/forwards.js +258 -258
  12. package/lib/hostkey.js +108 -108
  13. package/lib/http-transport.js +196 -196
  14. package/lib/ignore.js +202 -156
  15. package/lib/index.js +3779 -3747
  16. package/lib/openssh.js +838 -838
  17. package/lib/paths.js +225 -225
  18. package/lib/pool.js +757 -757
  19. package/lib/registry.js +95 -95
  20. package/lib/remote-fs.js +148 -143
  21. package/lib/routes-fs.js +233 -233
  22. package/lib/search.js +235 -230
  23. package/lib/sftp-surface.js +76 -76
  24. package/lib/sshconfig.js +454 -454
  25. package/lib/sync.js +275 -275
  26. package/lib/tasks.js +95 -95
  27. package/lib/telemetry.js +213 -213
  28. package/lib/update.js +472 -472
  29. package/lib/web-attach.js +850 -850
  30. package/lib/web-deploy-skill.js +205 -205
  31. package/lib/web-deploy.js +694 -694
  32. package/node_modules/asn1/Jenkinsfile +65 -0
  33. package/node_modules/asn1/LICENSE +19 -0
  34. package/node_modules/asn1/README.md +50 -0
  35. package/node_modules/asn1/lib/ber/errors.js +13 -0
  36. package/node_modules/asn1/lib/ber/index.js +27 -0
  37. package/node_modules/asn1/lib/ber/reader.js +262 -0
  38. package/node_modules/asn1/lib/ber/types.js +36 -0
  39. package/node_modules/asn1/lib/ber/writer.js +317 -0
  40. package/node_modules/asn1/lib/index.js +20 -0
  41. package/node_modules/asn1/package.json +31 -0
  42. package/node_modules/bcrypt-pbkdf/CONTRIBUTING.md +13 -0
  43. package/node_modules/bcrypt-pbkdf/LICENSE +66 -0
  44. package/node_modules/bcrypt-pbkdf/README.md +45 -0
  45. package/node_modules/bcrypt-pbkdf/index.js +556 -0
  46. package/node_modules/bcrypt-pbkdf/package.json +15 -0
  47. package/node_modules/buildcheck/.eslintrc.js +5 -0
  48. package/node_modules/buildcheck/.github/workflows/ci.yml +88 -0
  49. package/node_modules/buildcheck/.github/workflows/lint.yml +27 -0
  50. package/node_modules/buildcheck/.npmignore +3 -0
  51. package/node_modules/buildcheck/LICENSE +19 -0
  52. package/node_modules/buildcheck/README.md +106 -0
  53. package/node_modules/buildcheck/deps/Find-VisualStudio.cs +250 -0
  54. package/node_modules/buildcheck/lib/findvs.js +361 -0
  55. package/node_modules/buildcheck/lib/index.js +738 -0
  56. package/node_modules/buildcheck/package.json +36 -0
  57. package/node_modules/buildcheck/test/test.js +8 -0
  58. package/node_modules/cpu-features/.eslintrc.js +5 -0
  59. package/node_modules/cpu-features/.github/workflows/ci.yml +74 -0
  60. package/node_modules/cpu-features/.github/workflows/lint.yml +27 -0
  61. package/node_modules/cpu-features/LICENSE +19 -0
  62. package/node_modules/cpu-features/README.md +59 -0
  63. package/node_modules/cpu-features/binding.gyp +16 -0
  64. package/node_modules/cpu-features/buildcheck.js +32 -0
  65. package/node_modules/cpu-features/deps/cpu_features/.clang-format +4 -0
  66. package/node_modules/cpu-features/deps/cpu_features/.dockerignore +31 -0
  67. package/node_modules/cpu-features/deps/cpu_features/.github/workflows/Dockerfile +5 -0
  68. package/node_modules/cpu-features/deps/cpu_features/.github/workflows/aarch64_linux_cmake.yml +30 -0
  69. package/node_modules/cpu-features/deps/cpu_features/.github/workflows/amd64_freebsd_cmake.yml +22 -0
  70. package/node_modules/cpu-features/deps/cpu_features/.github/workflows/amd64_linux_bazel.yml +26 -0
  71. package/node_modules/cpu-features/deps/cpu_features/.github/workflows/amd64_linux_cmake.yml +31 -0
  72. package/node_modules/cpu-features/deps/cpu_features/.github/workflows/amd64_macos_cmake.yml +43 -0
  73. package/node_modules/cpu-features/deps/cpu_features/.github/workflows/amd64_windows_cmake.yml +25 -0
  74. package/node_modules/cpu-features/deps/cpu_features/.github/workflows/arm_linux_cmake.yml +31 -0
  75. package/node_modules/cpu-features/deps/cpu_features/.github/workflows/clang_format.yml +24 -0
  76. package/node_modules/cpu-features/deps/cpu_features/.github/workflows/mips_linux_cmake.yml +30 -0
  77. package/node_modules/cpu-features/deps/cpu_features/.github/workflows/power_linux_cmake.yml +29 -0
  78. package/node_modules/cpu-features/deps/cpu_features/.github/workflows/riscv_linux_cmake.yml +28 -0
  79. package/node_modules/cpu-features/deps/cpu_features/.github/workflows/s390x_linux_cmake.yml +27 -0
  80. package/node_modules/cpu-features/deps/cpu_features/.grenrc.yml +21 -0
  81. package/node_modules/cpu-features/deps/cpu_features/BUILD.bazel +329 -0
  82. package/node_modules/cpu-features/deps/cpu_features/CMakeLists.txt +261 -0
  83. package/node_modules/cpu-features/deps/cpu_features/CONTRIBUTING.md +23 -0
  84. package/node_modules/cpu-features/deps/cpu_features/LICENSE +230 -0
  85. package/node_modules/cpu-features/deps/cpu_features/README.md +272 -0
  86. package/node_modules/cpu-features/deps/cpu_features/WORKSPACE +19 -0
  87. package/node_modules/cpu-features/deps/cpu_features/bazel/ci/README.md +5 -0
  88. package/node_modules/cpu-features/deps/cpu_features/bazel/platforms.bzl +11 -0
  89. package/node_modules/cpu-features/deps/cpu_features/cmake/CpuFeaturesConfig.cmake.in +3 -0
  90. package/node_modules/cpu-features/deps/cpu_features/cmake/CpuFeaturesNdkCompatConfig.cmake.in +3 -0
  91. package/node_modules/cpu-features/deps/cpu_features/cmake/README.md +30 -0
  92. package/node_modules/cpu-features/deps/cpu_features/cmake/ci/Makefile +252 -0
  93. package/node_modules/cpu-features/deps/cpu_features/cmake/ci/README.md +40 -0
  94. package/node_modules/cpu-features/deps/cpu_features/cmake/ci/doc/docker.dot +64 -0
  95. package/node_modules/cpu-features/deps/cpu_features/cmake/ci/doc/docker.svg +312 -0
  96. package/node_modules/cpu-features/deps/cpu_features/cmake/ci/doc/generate_image.sh +7 -0
  97. package/node_modules/cpu-features/deps/cpu_features/cmake/ci/docker/amd64/Dockerfile +48 -0
  98. package/node_modules/cpu-features/deps/cpu_features/cmake/ci/docker/toolchain/Dockerfile +34 -0
  99. package/node_modules/cpu-features/deps/cpu_features/cmake/ci/sample/CMakeLists.txt +22 -0
  100. package/node_modules/cpu-features/deps/cpu_features/cmake/ci/sample/main.cpp +11 -0
  101. package/node_modules/cpu-features/deps/cpu_features/cmake/ci/vagrant/freebsd/Vagrantfile +107 -0
  102. package/node_modules/cpu-features/deps/cpu_features/cmake/googletest.CMakeLists.txt.in +15 -0
  103. package/node_modules/cpu-features/deps/cpu_features/cpu_features.gyp +105 -0
  104. package/node_modules/cpu-features/deps/cpu_features/include/cpu_features_cache_info.h +54 -0
  105. package/node_modules/cpu-features/deps/cpu_features/include/cpu_features_macros.h +384 -0
  106. package/node_modules/cpu-features/deps/cpu_features/include/cpuinfo_aarch64.h +259 -0
  107. package/node_modules/cpu-features/deps/cpu_features/include/cpuinfo_arm.h +121 -0
  108. package/node_modules/cpu-features/deps/cpu_features/include/cpuinfo_mips.h +74 -0
  109. package/node_modules/cpu-features/deps/cpu_features/include/cpuinfo_ppc.h +149 -0
  110. package/node_modules/cpu-features/deps/cpu_features/include/cpuinfo_riscv.h +72 -0
  111. package/node_modules/cpu-features/deps/cpu_features/include/cpuinfo_s390x.h +108 -0
  112. package/node_modules/cpu-features/deps/cpu_features/include/cpuinfo_x86.h +288 -0
  113. package/node_modules/cpu-features/deps/cpu_features/include/internal/bit_utils.h +40 -0
  114. package/node_modules/cpu-features/deps/cpu_features/include/internal/cpuid_x86.h +37 -0
  115. package/node_modules/cpu-features/deps/cpu_features/include/internal/filesystem.h +39 -0
  116. package/node_modules/cpu-features/deps/cpu_features/include/internal/hwcaps.h +240 -0
  117. package/node_modules/cpu-features/deps/cpu_features/include/internal/stack_line_reader.h +49 -0
  118. package/node_modules/cpu-features/deps/cpu_features/include/internal/string_view.h +110 -0
  119. package/node_modules/cpu-features/deps/cpu_features/include/internal/windows_utils.h +70 -0
  120. package/node_modules/cpu-features/deps/cpu_features/ndk_compat/CMakeLists.txt +60 -0
  121. package/node_modules/cpu-features/deps/cpu_features/ndk_compat/README.md +4 -0
  122. package/node_modules/cpu-features/deps/cpu_features/ndk_compat/cpu-features.c +205 -0
  123. package/node_modules/cpu-features/deps/cpu_features/ndk_compat/cpu-features.h +320 -0
  124. package/node_modules/cpu-features/deps/cpu_features/ndk_compat/ndk-compat-test.c +12 -0
  125. package/node_modules/cpu-features/deps/cpu_features/patches/0001-Add-Apple-Silicon-Support.patch +456 -0
  126. package/node_modules/cpu-features/deps/cpu_features/scripts/generate_badges.d +165 -0
  127. package/node_modules/cpu-features/deps/cpu_features/scripts/make_release.sh +75 -0
  128. package/node_modules/cpu-features/deps/cpu_features/scripts/run_integration.sh +490 -0
  129. package/node_modules/cpu-features/deps/cpu_features/scripts/test_integration.sh +79 -0
  130. package/node_modules/cpu-features/deps/cpu_features/src/copy.inl +19 -0
  131. package/node_modules/cpu-features/deps/cpu_features/src/define_introspection.inl +86 -0
  132. package/node_modules/cpu-features/deps/cpu_features/src/define_introspection_and_hwcaps.inl +26 -0
  133. package/node_modules/cpu-features/deps/cpu_features/src/equals.inl +22 -0
  134. package/node_modules/cpu-features/deps/cpu_features/src/filesystem.c +62 -0
  135. package/node_modules/cpu-features/deps/cpu_features/src/hwcaps.c +169 -0
  136. package/node_modules/cpu-features/deps/cpu_features/src/impl_aarch64__base_implementation.inl +88 -0
  137. package/node_modules/cpu-features/deps/cpu_features/src/impl_aarch64_linux_or_android.c +79 -0
  138. package/node_modules/cpu-features/deps/cpu_features/src/impl_aarch64_macos_or_iphone.c +82 -0
  139. package/node_modules/cpu-features/deps/cpu_features/src/impl_aarch64_windows.c +138 -0
  140. package/node_modules/cpu-features/deps/cpu_features/src/impl_arm_linux_or_android.c +212 -0
  141. package/node_modules/cpu-features/deps/cpu_features/src/impl_mips_linux_or_android.c +93 -0
  142. package/node_modules/cpu-features/deps/cpu_features/src/impl_ppc_linux.c +163 -0
  143. package/node_modules/cpu-features/deps/cpu_features/src/impl_riscv_linux.c +111 -0
  144. package/node_modules/cpu-features/deps/cpu_features/src/impl_s390x_linux.c +120 -0
  145. package/node_modules/cpu-features/deps/cpu_features/src/impl_x86__base_implementation.inl +2055 -0
  146. package/node_modules/cpu-features/deps/cpu_features/src/impl_x86_freebsd.c +68 -0
  147. package/node_modules/cpu-features/deps/cpu_features/src/impl_x86_linux_or_android.c +58 -0
  148. package/node_modules/cpu-features/deps/cpu_features/src/impl_x86_macos.c +57 -0
  149. package/node_modules/cpu-features/deps/cpu_features/src/impl_x86_windows.c +58 -0
  150. package/node_modules/cpu-features/deps/cpu_features/src/stack_line_reader.c +132 -0
  151. package/node_modules/cpu-features/deps/cpu_features/src/string_view.c +192 -0
  152. package/node_modules/cpu-features/deps/cpu_features/src/utils/list_cpu_features.c +461 -0
  153. package/node_modules/cpu-features/deps/cpu_features/test/CMakeLists.txt +114 -0
  154. package/node_modules/cpu-features/deps/cpu_features/test/bit_utils_test.cc +53 -0
  155. package/node_modules/cpu-features/deps/cpu_features/test/cpuinfo_aarch64_test.cc +396 -0
  156. package/node_modules/cpu-features/deps/cpu_features/test/cpuinfo_arm_test.cc +384 -0
  157. package/node_modules/cpu-features/deps/cpu_features/test/cpuinfo_mips_test.cc +176 -0
  158. package/node_modules/cpu-features/deps/cpu_features/test/cpuinfo_ppc_test.cc +131 -0
  159. package/node_modules/cpu-features/deps/cpu_features/test/cpuinfo_riscv_test.cc +180 -0
  160. package/node_modules/cpu-features/deps/cpu_features/test/cpuinfo_s390x_test.cc +82 -0
  161. package/node_modules/cpu-features/deps/cpu_features/test/cpuinfo_x86_test.cc +1729 -0
  162. package/node_modules/cpu-features/deps/cpu_features/test/filesystem_for_testing.cc +103 -0
  163. package/node_modules/cpu-features/deps/cpu_features/test/filesystem_for_testing.h +61 -0
  164. package/node_modules/cpu-features/deps/cpu_features/test/hwcaps_for_testing.cc +52 -0
  165. package/node_modules/cpu-features/deps/cpu_features/test/hwcaps_for_testing.h +31 -0
  166. package/node_modules/cpu-features/deps/cpu_features/test/stack_line_reader_test.cc +132 -0
  167. package/node_modules/cpu-features/deps/cpu_features/test/string_view_test.cc +202 -0
  168. package/node_modules/cpu-features/lib/index.js +5 -0
  169. package/node_modules/cpu-features/package.json +40 -0
  170. package/node_modules/cpu-features/src/binding.cc +151 -0
  171. package/node_modules/cpu-features/test/test.js +12 -0
  172. package/node_modules/nan/.github/workflows/ci.yml +46 -0
  173. package/node_modules/nan/.pre-commit-config.yaml +8 -0
  174. package/node_modules/nan/CHANGELOG.md +605 -0
  175. package/node_modules/nan/CMakeLists.txt +138 -0
  176. package/node_modules/nan/LICENSE.md +9 -0
  177. package/node_modules/nan/README.md +457 -0
  178. package/node_modules/nan/doc/asyncworker.md +146 -0
  179. package/node_modules/nan/doc/buffers.md +54 -0
  180. package/node_modules/nan/doc/callback.md +76 -0
  181. package/node_modules/nan/doc/converters.md +41 -0
  182. package/node_modules/nan/doc/errors.md +226 -0
  183. package/node_modules/nan/doc/json.md +62 -0
  184. package/node_modules/nan/doc/maybe_types.md +583 -0
  185. package/node_modules/nan/doc/methods.md +689 -0
  186. package/node_modules/nan/doc/new.md +147 -0
  187. package/node_modules/nan/doc/node_misc.md +123 -0
  188. package/node_modules/nan/doc/object_wrappers.md +263 -0
  189. package/node_modules/nan/doc/persistent.md +296 -0
  190. package/node_modules/nan/doc/scopes.md +73 -0
  191. package/node_modules/nan/doc/script.md +58 -0
  192. package/node_modules/nan/doc/string_bytes.md +81 -0
  193. package/node_modules/nan/doc/v8_internals.md +211 -0
  194. package/node_modules/nan/doc/v8_misc.md +85 -0
  195. package/node_modules/nan/include_dirs.js +1 -0
  196. package/node_modules/nan/nan.h +3202 -0
  197. package/node_modules/nan/nan_callbacks.h +141 -0
  198. package/node_modules/nan/nan_callbacks_12_inl.h +730 -0
  199. package/node_modules/nan/nan_callbacks_pre_12_inl.h +528 -0
  200. package/node_modules/nan/nan_converters.h +72 -0
  201. package/node_modules/nan/nan_converters_43_inl.h +68 -0
  202. package/node_modules/nan/nan_converters_pre_43_inl.h +42 -0
  203. package/node_modules/nan/nan_define_own_property_helper.h +29 -0
  204. package/node_modules/nan/nan_implementation_12_inl.h +446 -0
  205. package/node_modules/nan/nan_implementation_pre_12_inl.h +263 -0
  206. package/node_modules/nan/nan_json.h +166 -0
  207. package/node_modules/nan/nan_maybe_43_inl.h +360 -0
  208. package/node_modules/nan/nan_maybe_pre_43_inl.h +268 -0
  209. package/node_modules/nan/nan_new.h +340 -0
  210. package/node_modules/nan/nan_object_wrap.h +156 -0
  211. package/node_modules/nan/nan_persistent_12_inl.h +132 -0
  212. package/node_modules/nan/nan_persistent_pre_12_inl.h +242 -0
  213. package/node_modules/nan/nan_private.h +73 -0
  214. package/node_modules/nan/nan_scriptorigin.h +97 -0
  215. package/node_modules/nan/nan_string_bytes.h +305 -0
  216. package/node_modules/nan/nan_typedarray_contents.h +96 -0
  217. package/node_modules/nan/nan_weak.h +453 -0
  218. package/node_modules/nan/package.json +38 -0
  219. package/node_modules/nan/tools/1to2.js +412 -0
  220. package/node_modules/nan/tools/README.md +14 -0
  221. package/node_modules/nan/tools/package.json +19 -0
  222. package/node_modules/safer-buffer/LICENSE +21 -0
  223. package/node_modules/safer-buffer/Porting-Buffer.md +268 -0
  224. package/node_modules/safer-buffer/Readme.md +156 -0
  225. package/node_modules/safer-buffer/dangerous.js +58 -0
  226. package/node_modules/safer-buffer/package.json +34 -0
  227. package/node_modules/safer-buffer/safer.js +77 -0
  228. package/node_modules/safer-buffer/tests.js +406 -0
  229. package/node_modules/ssh2/.eslintignore +4 -0
  230. package/node_modules/ssh2/.eslintrc.js +5 -0
  231. package/node_modules/ssh2/.github/workflows/ci.yml +110 -0
  232. package/node_modules/ssh2/.github/workflows/lint.yml +27 -0
  233. package/node_modules/ssh2/LICENSE +19 -0
  234. package/node_modules/ssh2/README.md +1529 -0
  235. package/node_modules/ssh2/SFTP.md +413 -0
  236. package/node_modules/ssh2/examples/server-chat.js +238 -0
  237. package/node_modules/ssh2/examples/sftp-server-download-only.js +134 -0
  238. package/node_modules/ssh2/install.js +27 -0
  239. package/node_modules/ssh2/lib/Channel.js +295 -0
  240. package/node_modules/ssh2/lib/agent.js +1123 -0
  241. package/node_modules/ssh2/lib/client.js +2176 -0
  242. package/node_modules/ssh2/lib/http-agents.js +84 -0
  243. package/node_modules/ssh2/lib/index.js +44 -0
  244. package/node_modules/ssh2/lib/keygen.js +582 -0
  245. package/node_modules/ssh2/lib/protocol/Protocol.js +2136 -0
  246. package/node_modules/ssh2/lib/protocol/SFTP.js +4052 -0
  247. package/node_modules/ssh2/lib/protocol/constants.js +356 -0
  248. package/node_modules/ssh2/lib/protocol/crypto/binding.gyp +23 -0
  249. package/node_modules/ssh2/lib/protocol/crypto/poly1305.js +43 -0
  250. package/node_modules/ssh2/lib/protocol/crypto/src/binding.cc +2300 -0
  251. package/node_modules/ssh2/lib/protocol/crypto.js +1602 -0
  252. package/node_modules/ssh2/lib/protocol/handlers.js +16 -0
  253. package/node_modules/ssh2/lib/protocol/handlers.misc.js +1285 -0
  254. package/node_modules/ssh2/lib/protocol/kex.js +1908 -0
  255. package/node_modules/ssh2/lib/protocol/keyParser.js +1484 -0
  256. package/node_modules/ssh2/lib/protocol/node-fs-compat.js +115 -0
  257. package/node_modules/ssh2/lib/protocol/utils.js +356 -0
  258. package/node_modules/ssh2/lib/protocol/zlib.js +255 -0
  259. package/node_modules/ssh2/lib/server.js +1380 -0
  260. package/node_modules/ssh2/lib/utils.js +336 -0
  261. package/node_modules/ssh2/package.json +49 -0
  262. package/node_modules/ssh2/test/common.js +316 -0
  263. package/node_modules/ssh2/test/fixtures/bad_rsa_private_key +26 -0
  264. package/node_modules/ssh2/test/fixtures/https_cert.pem +33 -0
  265. package/node_modules/ssh2/test/fixtures/https_key.pem +52 -0
  266. package/node_modules/ssh2/test/fixtures/id_dsa +12 -0
  267. package/node_modules/ssh2/test/fixtures/id_ecdsa +5 -0
  268. package/node_modules/ssh2/test/fixtures/id_rsa +15 -0
  269. package/node_modules/ssh2/test/fixtures/id_rsa.ppk +26 -0
  270. package/node_modules/ssh2/test/fixtures/id_rsa_enc +30 -0
  271. package/node_modules/ssh2/test/fixtures/keyParser/openssh_new_dsa +21 -0
  272. package/node_modules/ssh2/test/fixtures/keyParser/openssh_new_dsa.pub +1 -0
  273. package/node_modules/ssh2/test/fixtures/keyParser/openssh_new_dsa.pub.result +7 -0
  274. package/node_modules/ssh2/test/fixtures/keyParser/openssh_new_dsa.result +7 -0
  275. package/node_modules/ssh2/test/fixtures/keyParser/openssh_new_dsa_enc +22 -0
  276. package/node_modules/ssh2/test/fixtures/keyParser/openssh_new_dsa_enc.pub +1 -0
  277. package/node_modules/ssh2/test/fixtures/keyParser/openssh_new_dsa_enc.pub.result +7 -0
  278. package/node_modules/ssh2/test/fixtures/keyParser/openssh_new_dsa_enc.result +7 -0
  279. package/node_modules/ssh2/test/fixtures/keyParser/openssh_new_dsa_enc_gcm +23 -0
  280. package/node_modules/ssh2/test/fixtures/keyParser/openssh_new_dsa_enc_gcm.pub +1 -0
  281. package/node_modules/ssh2/test/fixtures/keyParser/openssh_new_dsa_enc_gcm.pub.result +7 -0
  282. package/node_modules/ssh2/test/fixtures/keyParser/openssh_new_dsa_enc_gcm.result +7 -0
  283. package/node_modules/ssh2/test/fixtures/keyParser/openssh_new_ecdsa +9 -0
  284. package/node_modules/ssh2/test/fixtures/keyParser/openssh_new_ecdsa.pub +1 -0
  285. package/node_modules/ssh2/test/fixtures/keyParser/openssh_new_ecdsa.pub.result +7 -0
  286. package/node_modules/ssh2/test/fixtures/keyParser/openssh_new_ecdsa.result +7 -0
  287. package/node_modules/ssh2/test/fixtures/keyParser/openssh_new_ecdsa_enc +10 -0
  288. package/node_modules/ssh2/test/fixtures/keyParser/openssh_new_ecdsa_enc.pub +1 -0
  289. package/node_modules/ssh2/test/fixtures/keyParser/openssh_new_ecdsa_enc.pub.result +8 -0
  290. package/node_modules/ssh2/test/fixtures/keyParser/openssh_new_ecdsa_enc.result +7 -0
  291. package/node_modules/ssh2/test/fixtures/keyParser/openssh_new_ecdsa_enc_gcm +10 -0
  292. package/node_modules/ssh2/test/fixtures/keyParser/openssh_new_ecdsa_enc_gcm.pub +1 -0
  293. package/node_modules/ssh2/test/fixtures/keyParser/openssh_new_ecdsa_enc_gcm.pub.result +8 -0
  294. package/node_modules/ssh2/test/fixtures/keyParser/openssh_new_ecdsa_enc_gcm.result +7 -0
  295. package/node_modules/ssh2/test/fixtures/keyParser/openssh_new_ed25519 +7 -0
  296. package/node_modules/ssh2/test/fixtures/keyParser/openssh_new_ed25519.pub +1 -0
  297. package/node_modules/ssh2/test/fixtures/keyParser/openssh_new_ed25519.pub.result +7 -0
  298. package/node_modules/ssh2/test/fixtures/keyParser/openssh_new_ed25519.result +7 -0
  299. package/node_modules/ssh2/test/fixtures/keyParser/openssh_new_rsa +27 -0
  300. package/node_modules/ssh2/test/fixtures/keyParser/openssh_new_rsa.pub +1 -0
  301. package/node_modules/ssh2/test/fixtures/keyParser/openssh_new_rsa.pub.result +7 -0
  302. package/node_modules/ssh2/test/fixtures/keyParser/openssh_new_rsa.result +7 -0
  303. package/node_modules/ssh2/test/fixtures/keyParser/openssh_new_rsa_enc +28 -0
  304. package/node_modules/ssh2/test/fixtures/keyParser/openssh_new_rsa_enc.pub +1 -0
  305. package/node_modules/ssh2/test/fixtures/keyParser/openssh_new_rsa_enc.pub.result +8 -0
  306. package/node_modules/ssh2/test/fixtures/keyParser/openssh_new_rsa_enc.result +7 -0
  307. package/node_modules/ssh2/test/fixtures/keyParser/openssh_new_rsa_enc_gcm +29 -0
  308. package/node_modules/ssh2/test/fixtures/keyParser/openssh_new_rsa_enc_gcm.pub +1 -0
  309. package/node_modules/ssh2/test/fixtures/keyParser/openssh_new_rsa_enc_gcm.pub.result +8 -0
  310. package/node_modules/ssh2/test/fixtures/keyParser/openssh_new_rsa_enc_gcm.result +7 -0
  311. package/node_modules/ssh2/test/fixtures/keyParser/openssh_old_dsa +12 -0
  312. package/node_modules/ssh2/test/fixtures/keyParser/openssh_old_dsa.pub +1 -0
  313. package/node_modules/ssh2/test/fixtures/keyParser/openssh_old_dsa.pub.result +7 -0
  314. package/node_modules/ssh2/test/fixtures/keyParser/openssh_old_dsa.result +7 -0
  315. package/node_modules/ssh2/test/fixtures/keyParser/openssh_old_dsa_enc +15 -0
  316. package/node_modules/ssh2/test/fixtures/keyParser/openssh_old_dsa_enc.pub +1 -0
  317. package/node_modules/ssh2/test/fixtures/keyParser/openssh_old_dsa_enc.pub.result +8 -0
  318. package/node_modules/ssh2/test/fixtures/keyParser/openssh_old_dsa_enc.result +7 -0
  319. package/node_modules/ssh2/test/fixtures/keyParser/openssh_old_ecdsa +5 -0
  320. package/node_modules/ssh2/test/fixtures/keyParser/openssh_old_ecdsa.pub +1 -0
  321. package/node_modules/ssh2/test/fixtures/keyParser/openssh_old_ecdsa.pub.result +7 -0
  322. package/node_modules/ssh2/test/fixtures/keyParser/openssh_old_ecdsa.result +7 -0
  323. package/node_modules/ssh2/test/fixtures/keyParser/openssh_old_ecdsa_enc +8 -0
  324. package/node_modules/ssh2/test/fixtures/keyParser/openssh_old_ecdsa_enc.pub +1 -0
  325. package/node_modules/ssh2/test/fixtures/keyParser/openssh_old_ecdsa_enc.pub.result +8 -0
  326. package/node_modules/ssh2/test/fixtures/keyParser/openssh_old_ecdsa_enc.result +7 -0
  327. package/node_modules/ssh2/test/fixtures/keyParser/openssh_old_rsa +27 -0
  328. package/node_modules/ssh2/test/fixtures/keyParser/openssh_old_rsa.pub +1 -0
  329. package/node_modules/ssh2/test/fixtures/keyParser/openssh_old_rsa.pub.result +7 -0
  330. package/node_modules/ssh2/test/fixtures/keyParser/openssh_old_rsa.result +7 -0
  331. package/node_modules/ssh2/test/fixtures/keyParser/openssh_old_rsa_enc +30 -0
  332. package/node_modules/ssh2/test/fixtures/keyParser/openssh_old_rsa_enc.pub +1 -0
  333. package/node_modules/ssh2/test/fixtures/keyParser/openssh_old_rsa_enc.pub.result +8 -0
  334. package/node_modules/ssh2/test/fixtures/keyParser/openssh_old_rsa_enc.result +7 -0
  335. package/node_modules/ssh2/test/fixtures/keyParser/openssh_old_rsa_enc_aes256 +54 -0
  336. package/node_modules/ssh2/test/fixtures/keyParser/openssh_old_rsa_enc_aes256.pub +1 -0
  337. package/node_modules/ssh2/test/fixtures/keyParser/openssh_old_rsa_enc_aes256.pub.result +6 -0
  338. package/node_modules/ssh2/test/fixtures/keyParser/openssh_old_rsa_enc_aes256.result +6 -0
  339. package/node_modules/ssh2/test/fixtures/keyParser/ppk_dsa_enc +17 -0
  340. package/node_modules/ssh2/test/fixtures/keyParser/ppk_dsa_enc.result +7 -0
  341. package/node_modules/ssh2/test/fixtures/keyParser/ppk_rsa +26 -0
  342. package/node_modules/ssh2/test/fixtures/keyParser/ppk_rsa.result +7 -0
  343. package/node_modules/ssh2/test/fixtures/keyParser/ppk_rsa_enc +18 -0
  344. package/node_modules/ssh2/test/fixtures/keyParser/ppk_rsa_enc.result +7 -0
  345. package/node_modules/ssh2/test/fixtures/keyParser/rfc4716_rsa.pub +9 -0
  346. package/node_modules/ssh2/test/fixtures/keyParser/rfc4716_rsa.pub.result +7 -0
  347. package/node_modules/ssh2/test/fixtures/keyParser/rfc4716_rsa2.pub +10 -0
  348. package/node_modules/ssh2/test/fixtures/keyParser/rfc4716_rsa2.pub.result +7 -0
  349. package/node_modules/ssh2/test/fixtures/keyParser/rfc4716_rsa3.pub +11 -0
  350. package/node_modules/ssh2/test/fixtures/keyParser/rfc4716_rsa3.pub.result +7 -0
  351. package/node_modules/ssh2/test/fixtures/keyParser/rfc4716_rsa4.pub +11 -0
  352. package/node_modules/ssh2/test/fixtures/keyParser/rfc4716_rsa4.pub.result +7 -0
  353. package/node_modules/ssh2/test/fixtures/keyParser/rfc4716_rsa5.pub +8 -0
  354. package/node_modules/ssh2/test/fixtures/keyParser/rfc4716_rsa5.pub.result +7 -0
  355. package/node_modules/ssh2/test/fixtures/keyParser/rfc4716_rsa6.pub +13 -0
  356. package/node_modules/ssh2/test/fixtures/keyParser/rfc4716_rsa6.pub.result +7 -0
  357. package/node_modules/ssh2/test/fixtures/openssh_new_rsa +27 -0
  358. package/node_modules/ssh2/test/fixtures/ssh_host_dsa_key +12 -0
  359. package/node_modules/ssh2/test/fixtures/ssh_host_ecdsa_key +5 -0
  360. package/node_modules/ssh2/test/fixtures/ssh_host_rsa_key +15 -0
  361. package/node_modules/ssh2/test/test-exec.js +578 -0
  362. package/node_modules/ssh2/test/test-integration-openssh.js +486 -0
  363. package/node_modules/ssh2/test/test-keygen.js +60 -0
  364. package/node_modules/ssh2/test/test-misc-client-server.js +1460 -0
  365. package/node_modules/ssh2/test/test-openssh.js +261 -0
  366. package/node_modules/ssh2/test/test-protocol-crypto.js +631 -0
  367. package/node_modules/ssh2/test/test-protocol-keyparser.js +177 -0
  368. package/node_modules/ssh2/test/test-server-hostkeys.js +138 -0
  369. package/node_modules/ssh2/test/test-sftp.js +842 -0
  370. package/node_modules/ssh2/test/test-shell.js +109 -0
  371. package/node_modules/ssh2/test/test-userauth-agent-openssh.js +110 -0
  372. package/node_modules/ssh2/test/test-userauth-agent.js +171 -0
  373. package/node_modules/ssh2/test/test-userauth.js +611 -0
  374. package/node_modules/ssh2/test/test-worker-imports.js +25 -0
  375. package/node_modules/ssh2/test/test.js +20 -0
  376. package/node_modules/ssh2/util/build_pagent.bat +2 -0
  377. package/node_modules/ssh2/util/pagent.c +88 -0
  378. package/node_modules/ssh2/util/pagent.exe +0 -0
  379. package/node_modules/tweetnacl/.npmignore +4 -0
  380. package/node_modules/tweetnacl/AUTHORS.md +28 -0
  381. package/node_modules/tweetnacl/CHANGELOG.md +221 -0
  382. package/node_modules/tweetnacl/LICENSE +24 -0
  383. package/node_modules/tweetnacl/PULL_REQUEST_TEMPLATE.md +20 -0
  384. package/node_modules/tweetnacl/README.md +459 -0
  385. package/node_modules/tweetnacl/nacl-fast.js +2388 -0
  386. package/node_modules/tweetnacl/nacl-fast.min.js +2 -0
  387. package/node_modules/tweetnacl/nacl.d.ts +98 -0
  388. package/node_modules/tweetnacl/nacl.js +1175 -0
  389. package/node_modules/tweetnacl/nacl.min.js +1 -0
  390. package/node_modules/tweetnacl/package.json +58 -0
  391. package/package.json +96 -89
package/lib/pool.js CHANGED
@@ -1,757 +1,757 @@
1
- // Persistent SSH/SFTP connection pool (one identity → one live client).
2
- import ssh2 from 'ssh2'
3
- import { readFileSync } from 'node:fs'
4
- import { homedir } from 'node:os'
5
- import path from 'node:path'
6
- import { truncate } from './paths.js'
7
- import { createHostKeyGuard } from './hostkey.js'
8
- import { buildSftpSurface } from './sftp-surface.js'
9
- import { SystemOpenSshPool, isSystemOpenSshBackend } from './openssh.js'
10
-
11
- const { Client } = ssh2
12
-
13
- /**
14
- * Build the pool for a config.
15
- *
16
- * Two backends exist (issue #51): the native one below, which speaks SSH itself
17
- * via ssh2, and the "system OpenSSH" one (lib/openssh.js), which delegates to
18
- * the local `ssh` executable so GSSAPI/Kerberos and the rest of the user's
19
- * `~/.ssh/config` are honoured. Both expose one identical surface, so every
20
- * caller can keep using `new SshPool(...)`-shaped code — this factory only has
21
- * to decide which class to instantiate.
22
- */
23
- export function createPool(config, opts) {
24
- return isSystemOpenSshBackend(config)
25
- ? new SystemOpenSshPool(config, opts)
26
- : new SshPool(config, opts)
27
- }
28
-
29
- /**
30
- * Run one `forwardOut` and resolve with the resulting channel.
31
- *
32
- * ssh2 reports a dead connection by THROWING synchronously —
33
- * `if (!this._sock || !isWritable(this._sock)) throw new Error('Not connected')`
34
- * (ssh2 >= 1.16, `Client.forwardOut`) — and never calls the callback, so a
35
- * callback-only error path cannot guard this call (issue #54). Two further
36
- * consequences justify a shared helper rather than an inline try/catch:
37
- *
38
- * • inside a `new Promise(executor)` a bare throw rejects nothing: it becomes
39
- * an unhandled REJECTION and leaves the promise pending forever;
40
- * • called from a `net.Server` connection handler the throw escapes to the
41
- * process top level and kills the host.
42
- *
43
- * This guards the CALLER's side of the contract rather than assuming one
44
- * backend's behaviour: the system-OpenSSH backend (lib/openssh.js `forwardOut`)
45
- * already converts its own spawn failure into a callback error, so for it the
46
- * try/catch below is dead code — but a backend is free to throw, and the one
47
- * that does must not be able to take the process down.
48
- *
49
- * @param {object} client - an ssh2 Client or OpenSshConnection.
50
- * @param {object} spec - `srcHost`/`srcPort`/`dstHost`/`dstPort`.
51
- * @returns {Promise<object>} the forwarded channel / duplex stream.
52
- */
53
- export function forwardOutChannel(client, { srcHost = '127.0.0.1', srcPort = 0, dstHost, dstPort }) {
54
- return new Promise((resolve, reject) => {
55
- // `reject` is called at most once: a callback that fires after a successful
56
- // throw-path rejection would be a no-op, which Promise guarantees.
57
- try {
58
- client.forwardOut(srcHost, srcPort, dstHost, dstPort, (err, channel) => (err ? reject(err) : resolve(channel)))
59
- } catch (err) {
60
- reject(err)
61
- }
62
- })
63
- }
64
-
65
- /**
66
- * The ACTIVE pool: one long-lived object whose concrete implementation can be
67
- * swapped when the current machine's transport backend changes.
68
- *
69
- * Why a delegating facade instead of just constructing the right class once:
70
- * `backend` is a PER-MACHINE field (an enterprise Kerberos host needs
71
- * `openssh` while every other machine stays on `native`), but the plugin keeps
72
- * exactly one active pool for its whole lifetime and hangs `passwordResolver` /
73
- * `onReady` / `onCloseHook` off it once at startup. Replacing the object
74
- * wholesale on every machine switch would silently drop those hooks — the port
75
- * forwards and keychain passwords would stop working after the first switch.
76
- *
77
- * So the facade owns the hooks and the shared `config` object, and forwards
78
- * them to whichever concrete pool is right for the current config. Everything
79
- * else is a straight delegation, which is why callers cannot tell the
80
- * difference (and why the class is deliberately not part of the public story).
81
- */
82
- export class ActivePool {
83
- constructor(config, opts = {}) {
84
- this.config = config
85
- this.opts = opts
86
- // The hooks are assigned by the plugin AFTER construction, so they are
87
- // stored here and re-applied to every delegate this facade ever creates.
88
- this._passwordResolver = null
89
- this._onReady = null
90
- this._onCloseHook = null
91
- this._delegate = null
92
- this._sync()
93
- }
94
-
95
- /** Which concrete class the current config asks for. */
96
- _wantsSystemOpenSsh() {
97
- return isSystemOpenSshBackend(this.config)
98
- }
99
-
100
- _delegateIsCurrent() {
101
- if (!this._delegate) return false
102
- const isOpenssh = this._delegate instanceof SystemOpenSshPool
103
- return isOpenssh === this._wantsSystemOpenSsh()
104
- }
105
-
106
- /** Create (or replace) the delegate so it matches the current config. */
107
- _sync() {
108
- if (this._delegateIsCurrent()) return this._delegate
109
- const previous = this._delegate
110
- const next = createPool(this.config, this.opts)
111
- next.passwordResolver = this._passwordResolver
112
- next.onReady = this._onReady
113
- next.onCloseHook = this._onCloseHook
114
- this._delegate = next
115
- // Tear the old backend down LAST, and never let a teardown error mask the
116
- // new pool: the connection it was holding belongs to the old host anyway.
117
- if (previous) { try { previous.close() } catch { /* already gone */ } }
118
- return next
119
- }
120
-
121
- get delegate() { return this._sync() }
122
-
123
- // ── hooks: stored here, mirrored onto every delegate ──────────────────────
124
- get passwordResolver() { return this._passwordResolver }
125
- set passwordResolver(fn) {
126
- this._passwordResolver = fn
127
- if (this._delegate) this._delegate.passwordResolver = fn
128
- }
129
- get onReady() { return this._onReady }
130
- set onReady(fn) {
131
- this._onReady = fn
132
- if (this._delegate) this._delegate.onReady = fn
133
- }
134
- get onCloseHook() { return this._onCloseHook }
135
- set onCloseHook(fn) {
136
- this._onCloseHook = fn
137
- if (this._delegate) this._delegate.onCloseHook = fn
138
- }
139
-
140
- // ── pass-through state the plugin reads directly ──────────────────────────
141
- get client() { return this._delegate ? this._delegate.client : null }
142
- // Assignable because the plugin's own tests arm a fake live connection on the
143
- // active pool; a getter-only facade would make that assignment a silent
144
- // no-op in sloppy mode and a TypeError in the strict-mode ESM tests.
145
- set client(v) { if (this._sync()) this._delegate.client = v }
146
- get connecting() { return this._delegate ? this._delegate.connecting : null }
147
- get proxyPool() { return this._delegate ? this._delegate.proxyPool : null }
148
- get epoch() { return this._delegate ? this._delegate.epoch : 0 }
149
- get platform() { return this._delegate ? this._delegate.platform : 'unknown' }
150
- set platform(v) { if (this._sync()) this._delegate.platform = v }
151
- get gitBashPath() { return this._delegate ? this._delegate.gitBashPath : '' }
152
- set gitBashPath(v) { if (this._sync()) this._delegate.gitBashPath = v }
153
- get shellMode() { return this._delegate ? this._delegate.shellMode : 'native' }
154
- set shellMode(v) { if (this._sync()) this._delegate.shellMode = v }
155
-
156
- // ── delegated operations ──────────────────────────────────────────────────
157
- /** Re-point at a new target, switching backend first if the machine asks. */
158
- setTarget(fields, previous) {
159
- // The backend may have changed with the machine, so apply that part of the
160
- // request to `config` BEFORE picking the delegate: _sync() chooses the class
161
- // from config.backend, and the old backend must not be handed a host it can
162
- // no longer reach. The delegate then receives the whole request as usual.
163
- if (fields && fields.backend !== undefined) this.config.backend = String(fields.backend)
164
- if (fields && fields.sshBinary !== undefined) this.config.sshBinary = String(fields.sshBinary)
165
- const d = this._sync()
166
- return d.setTarget(fields, previous)
167
- }
168
- connect() { return this.delegate.connect() }
169
- exec(cmd, opts) { return this.delegate.exec(cmd, opts) }
170
- sftp() { return this.delegate.sftp() }
171
- detect() { return this.delegate.detect() }
172
- invalidate() { return this.delegate.invalidate() }
173
- close() { return this.delegate.close() }
174
- }
175
-
176
- export class SshPool {
177
- constructor(config, opts = {}) {
178
- this.config = config
179
- this._knownHostsFile = opts.knownHostsFile
180
- this.client = null
181
- this.connecting = null
182
- this.proxyPool = null
183
- // Generational token: bumped on every target change / close so a stale
184
- // in-flight connect can never hand this pool a connection to an old host.
185
- this.epoch = 0
186
- /** Auto-detected remote platform: unknown | windows | posix (per target). */
187
- this.platform = 'unknown'
188
- /** Resolved Git Bash bash.exe path on Windows remotes ('' when none). */
189
- this.gitBashPath = ''
190
- /** Resolved terminal strategy: native | git-bash. */
191
- this.shellMode = 'native'
192
- /** In-flight platform detection promise (cached). */
193
- this._detecting = null
194
- /** Optional async resolver for a machine-stored (keychain) password. */
195
- this.passwordResolver = null
196
- /** Optional hook called with the live client after a successful connect. */
197
- this.onReady = null
198
- /** Optional hook called when the pool closes. */
199
- this.onCloseHook = null
200
- }
201
-
202
- resolveKeyPath() {
203
- const p = this.config.privateKeyPath
204
- if (!p) return ''
205
- if (p.startsWith('~/') || p === '~') return path.join(homedir(), p.slice(1))
206
- return p
207
- }
208
-
209
- setTarget({ host, port, username, password, privateKeyPath, passphrase, workspace, useAgent, keyboardInteractive, proxy, hostKeyMode, backend, sshBinary }, previous) {
210
- // Collect the requested changes first: an UNCHANGED target is a no-op. The
211
- // old behavior closed the live connection and re-ran platform detection
212
- // even for an identical target, so every picker keystroke
213
- // (POST /current → applyActiveMachine → setTarget) paid a full SSH
214
- // reconnect + re-detect — the "slow remote path autocomplete" bug.
215
- //
216
- // `previous` is the diff BASELINE. Callers whose shared config was already
217
- // mutated BEFORE this call (applyActiveMachine: applyMachine rewrites the
218
- // live config IN PLACE, and pool.config IS that config object) pass a
219
- // pre-mutation snapshot — otherwise the comparison runs the new values
220
- // against themselves, is always equal and never closes even for a machine
221
- // switch (the wrong-host hazard of issue #25). Defaults to the current
222
- // config for callers that pass fresh values read independently of it.
223
- const base = previous || this.config
224
- const wanted = []
225
- if (host !== undefined) wanted.push(['host', String(host)])
226
- if (port !== undefined && Number(port)) wanted.push(['port', Number(port)])
227
- if (username !== undefined) wanted.push(['username', String(username)])
228
- if (password !== undefined && password !== null) wanted.push(['password', String(password)])
229
- if (privateKeyPath !== undefined) wanted.push(['privateKeyPath', String(privateKeyPath)])
230
- if (passphrase !== undefined) wanted.push(['passphrase', String(passphrase)])
231
- if (workspace !== undefined) wanted.push(['workspace', String(workspace)])
232
- if (useAgent !== undefined) wanted.push(['useAgent', !!useAgent])
233
- if (keyboardInteractive !== undefined) wanted.push(['keyboardInteractive', !!keyboardInteractive])
234
- if (proxy !== undefined) wanted.push(['proxy', proxy])
235
- if (hostKeyMode !== undefined) wanted.push(['hostKeyMode', String(hostKeyMode)])
236
- // Issue #51: the transport backend is part of the target identity — changing
237
- // it must close the live connection exactly like changing the host does.
238
- if (backend !== undefined) wanted.push(['backend', String(backend)])
239
- if (sshBinary !== undefined) wanted.push(['sshBinary', String(sshBinary)])
240
- const differs = ([k, v]) => (k === 'proxy'
241
- ? JSON.stringify(base[k] ?? null) !== JSON.stringify(v ?? null)
242
- : base[k] !== v)
243
- // Something actually changed → apply it and force a fresh connection.
244
- // (A caller that WANTS a reconnect for an identical target uses
245
- // invalidate(); setTarget semantics stay "re-point when moved".)
246
- if (wanted.some(differs)) {
247
- for (const [k, v] of wanted) this.config[k] = v
248
- // the new target may be a different OS — re-detect on the next command
249
- this.platform = 'unknown'
250
- this.gitBashPath = ''
251
- this.shellMode = 'native'
252
- this._detecting = null
253
- this.close()
254
- }
255
- return this
256
- }
257
-
258
- connect() {
259
- if (this.client) return Promise.resolve(this.client)
260
- if (this.connecting) return this.connecting
261
- const epoch = this.epoch
262
- const pending = this._doConnect(epoch)
263
- this.connecting = pending
264
- const clear = () => {
265
- if (this.epoch === epoch && this.connecting === pending) this.connecting = null
266
- }
267
- pending.then(clear, clear)
268
- return pending
269
- }
270
-
271
- async _doConnect(epoch) {
272
- const isCurrent = () => this.epoch === epoch
273
- const knownHostsFile = typeof this._knownHostsFile === 'function' ? this._knownHostsFile() : this._knownHostsFile
274
- const guard = createHostKeyGuard(this.config, knownHostsFile)
275
- const client = new Client()
276
- let settled = false
277
- const fail = (err) => {
278
- if (settled) return
279
- settled = true
280
- if (isCurrent() && this.client === client) this.client = null
281
- throw guard.lastError ? new Error(guard.lastError) : err
282
- }
283
-
284
- // Proxy jump: SSH to the bastion first, then tunnel to the target through it.
285
- let sock = null
286
- const proxyCfg = this.config.proxy
287
- if (proxyCfg && proxyCfg.host) {
288
- try {
289
- this.proxyPool = new SshPool({
290
- ...this.config,
291
- host: proxyCfg.host,
292
- port: Number(proxyCfg.port) || 22,
293
- username: proxyCfg.username || this.config.username || 'root',
294
- password: proxyCfg.password || '',
295
- privateKeyPath: proxyCfg.privateKeyPath || '',
296
- passphrase: proxyCfg.passphrase || '',
297
- proxy: undefined,
298
- }, { knownHostsFile: this._knownHostsFile })
299
- const pclient = await this.proxyPool.connect()
300
- if (!isCurrent()) throw new Error('ssh target changed during proxy connect')
301
- try {
302
- sock = await forwardOutChannel(pclient, { dstHost: this.config.host, dstPort: this.config.port })
303
- } catch (e) {
304
- throw new Error('proxy forward to target failed: ' + ((e && e.message) || e))
305
- }
306
- } catch (err) {
307
- return fail(err)
308
- }
309
- }
310
-
311
- return new Promise((resolve, reject) => {
312
- const rejectOnce = (err) => {
313
- if (settled) return
314
- settled = true
315
- if (isCurrent() && this.client === client) this.client = null
316
- reject(guard.lastError ? new Error(guard.lastError) : err)
317
- }
318
- client.on('ready', () => {
319
- if (settled) return
320
- settled = true
321
- if (!isCurrent()) {
322
- try { client.end() } catch {}
323
- reject(new Error('ssh target changed during connect'))
324
- return
325
- }
326
- this.client = client
327
- resolve(client)
328
- if (this.onReady) { try { this.onReady(client) } catch {} }
329
- })
330
- client.on('error', (e) => rejectOnce(e))
331
- client.on('close', () => {
332
- if (isCurrent() && this.client === client) this.client = null
333
- rejectOnce(new Error('ssh connection closed'))
334
- })
335
-
336
- const buildOpts = async () => {
337
- const opts = {
338
- host: this.config.host,
339
- port: this.config.port,
340
- username: this.config.username,
341
- readyTimeout: this.config.connectTimeoutMs,
342
- keepaliveInterval: 15000,
343
- keepaliveCountMax: 3,
344
- hostVerifier: (key) => guard.verifier(key),
345
- }
346
- if (sock) opts.sock = sock
347
- if (this.config.useAgent) {
348
- const sockPath = process.env.SSH_AUTH_SOCK
349
- if (sockPath) opts.agent = sockPath
350
- }
351
- let password = this.config.password || ''
352
- if (!password && this.passwordResolver) {
353
- try { password = (await this.passwordResolver()) || '' } catch {}
354
- }
355
- if (password) {
356
- opts.password = password
357
- opts.tryKeyboard = true
358
- } else if (this.config.keyboardInteractive && !this.config.privateKeyPath) {
359
- opts.tryKeyboard = true
360
- }
361
- if (this.config.privateKeyPath) {
362
- const keyPath = this.resolveKeyPath()
363
- if (!keyPath) {
364
- throw new Error('no credentials: set a password or a privateKeyPath to connect')
365
- }
366
- let key
367
- try {
368
- key = readFileSync(keyPath)
369
- } catch (err) {
370
- throw new Error(`cannot read private key "${keyPath}": ${err && err.message}`)
371
- }
372
- opts.privateKey = key
373
- opts.passphrase = this.config.passphrase || undefined
374
- } else if (!password && !opts.agent) {
375
- throw new Error('no credentials: set a password, a privateKeyPath, or enable useAgent to connect')
376
- }
377
- return opts
378
- }
379
-
380
- buildOpts().then(
381
- (opts) => {
382
- if (opts.tryKeyboard) {
383
- client.on('keyboard-interactive', (name, instructions, lang, prompts, finish) => {
384
- finish(prompts.map(() => this.config.password || ''))
385
- })
386
- }
387
- // ssh2 parses the private key SYNCHRONOUSLY inside connect() and
388
- // throws on an unparseable key (the classic case: an encrypted
389
- // OpenSSH key without a passphrase). Left uncaught, that throw
390
- // rejects this orphaned .then chain — an unhandled rejection that
391
- // crashes the whole host process (issue #48: "dsh: fatal load
392
- // failure" on every boot, because boot-restore re-dials the bad
393
- // machine). It also leaves the outer promise pending forever.
394
- // Convert it into a normal rejection so every caller's try/catch —
395
- // boot restore, test-connect, rw_connect — sees a regular failure.
396
- try {
397
- client.connect(opts)
398
- } catch (err) {
399
- rejectOnce(err)
400
- }
401
- },
402
- (err) => rejectOnce(err),
403
- )
404
- })
405
- }
406
-
407
- /** Detect the remote platform + locate Git Bash once; cached per target. */
408
- detect() {
409
- if (this.platform !== 'unknown') return Promise.resolve()
410
- if (this._detecting) return this._detecting
411
- this._detecting = this._detect().finally(() => {
412
- this._detecting = null
413
- })
414
- return this._detecting
415
- }
416
-
417
- async _detect() {
418
- let res
419
- try {
420
- // `cmd /c ver` works under cmd.exe, PowerShell AND Git Bash (all print
421
- // "Microsoft Windows …"); on POSIX hosts `cmd` simply doesn't exist.
422
- res = await this._execRaw('cmd /c ver', { timeoutMs: Math.min(this.config.commandTimeoutMs, 8000) })
423
- } catch (err) {
424
- this.platform = 'unknown'
425
- this.shellMode = 'native'
426
- return
427
- }
428
- const out = String(res.stdout || '') + '\n' + String(res.stderr || '')
429
- if (res.code === 0 && /microsoft windows/i.test(out)) {
430
- this.platform = 'windows'
431
- await this._resolveGitBash()
432
- return
433
- }
434
- // inconclusive — probe for a Git-Bash/MSYS remote (uname prints MINGW64_NT…)
435
- try {
436
- const u = await this._execRaw('uname -s', { timeoutMs: Math.min(this.config.commandTimeoutMs, 8000) })
437
- if (/mingw|msys|cygwin/i.test(String(u.stdout || ''))) {
438
- this.platform = 'windows'
439
- await this._resolveGitBash()
440
- return
441
- }
442
- } catch {}
443
- this.platform = 'posix'
444
- this.shellMode = 'native'
445
- this.gitBashPath = ''
446
- }
447
-
448
- /** On a Windows remote, locate Git Bash (config path → PATH → common installs). */
449
- async _resolveGitBash() {
450
- const cfg = String(this.config.shell || '').trim()
451
- if (cfg && cfg !== 'git-bash' && cfg !== 'native') {
452
- if (await this._cmdExists(cfg)) {
453
- this.gitBashPath = cfg
454
- this.shellMode = 'git-bash'
455
- return
456
- }
457
- }
458
- if (cfg === 'native') {
459
- this.shellMode = 'native'
460
- this.gitBashPath = ''
461
- return
462
- }
463
- try {
464
- const r = await this._execRaw('cmd /c where bash', { timeoutMs: 8000 })
465
- const first = String(r.stdout || '').trim().split(/\r?\n/)[0].trim()
466
- if (first) {
467
- this.gitBashPath = first
468
- this.shellMode = 'git-bash'
469
- return
470
- }
471
- } catch {}
472
- const candidates = [
473
- 'C:\\Program Files\\Git\\bin\\bash.exe',
474
- 'C:\\Program Files (x86)\\Git\\bin\\bash.exe',
475
- ...(process.env.LOCALAPPDATA ? [process.env.LOCALAPPDATA + '\\Programs\\Git\\bin\\bash.exe'] : []),
476
- '%LOCALAPPDATA%\\Programs\\Git\\bin\\bash.exe',
477
- ]
478
- for (const c of candidates) {
479
- if (await this._cmdExists(c)) {
480
- this.gitBashPath = c
481
- this.shellMode = 'git-bash'
482
- return
483
- }
484
- }
485
- this.shellMode = 'git-bash' // wanted but not found — commands fall back to raw
486
- this.gitBashPath = ''
487
- }
488
-
489
- async _cmdExists(p) {
490
- try {
491
- const r = await this._execRaw(`cmd /c if exist "${p}" (echo Y) else (echo N)`, { timeoutMs: 8000 })
492
- return /Y/.test(String(r.stdout || ''))
493
- } catch {
494
- return false
495
- }
496
- }
497
-
498
- /** Run one remote command; resolves { code, signal, stdout, stderr }.
499
- * On Windows remotes with Git Bash the script is piped to `bash -s` over the
500
- * exec-channel stdin — no shell quoting round-trip, so any content (quotes,
501
- * backslashes, newlines) survives verbatim. */
502
- exec(command, timeoutMsOrOpts) {
503
- const opts = timeoutMsOrOpts && typeof timeoutMsOrOpts === 'object' ? timeoutMsOrOpts : { timeoutMs: timeoutMsOrOpts }
504
- return this.detect().then(() => {
505
- if (this.platform === 'windows' && this.gitBashPath) {
506
- const script = String(command)
507
- return this._execRaw(`"${this.gitBashPath}" -s`, { ...opts, timeoutMs: opts.timeoutMs || this.config.commandTimeoutMs }, (stream) => {
508
- try { stream.end(script) } catch {}
509
- })
510
- }
511
- return this._execRaw(command, opts)
512
- })
513
- }
514
-
515
- /** Raw exec (no detection / no wrapper). Optional stdinWriter(stream) feeds
516
- * the remote process stdin (used by the Git Bash `-s` mode). */
517
- _execRaw(command, opts, stdinWriter) {
518
- const timeoutMs = (opts && opts.timeoutMs) || this.config.commandTimeoutMs
519
- const signal = opts && opts.signal
520
- return this.connect().then(
521
- (client) =>
522
- new Promise((resolve, reject) => {
523
- let retried = false
524
- // Caller cancellation (issue #44): a tool that hands us exec.signal
525
- // expects the remote command to stop when the turn is cancelled.
526
- // Resolve the same shape as a timeout so callers need no new branch.
527
- let onAbort = null
528
- const cleanupAbort = () => { if (signal && onAbort) signal.removeEventListener('abort', onAbort) }
529
- const runOn = (c) => {
530
- const execOpts = {}
531
- if (opts && opts.pty) execOpts.pty = true
532
- if (opts && opts.env && typeof opts.env === 'object') execOpts.env = opts.env
533
- c.exec(command, execOpts, (err, stream) => {
534
- if (err) {
535
- // Channel-open failure — or a session termination — usually
536
- // means the pooled connection died server-side (idle timeout /
537
- // network reset) while keepalive hadn't noticed. Drop it and
538
- // retry ONCE on a fresh connection.
539
- if (!retried && /channel open failure|open failed|unexpected .* session termination|session termination|disconnect/i.test(String((err && err.message) || err))) {
540
- retried = true
541
- this.invalidate()
542
- return this.connect().then(
543
- (fresh) => runOn(fresh),
544
- (e2) => reject(new Error('ssh exec failed (reconnect): ' + ((e2 && e2.message) || e2))),
545
- )
546
- }
547
- return reject(new Error('ssh exec failed: ' + ((err && err.message) || err)))
548
- }
549
- let stdout = ''
550
- let stderr = ''
551
- let settled = false
552
- let exitCode = null
553
- let exitSignal = null
554
- const hardCap = Math.max(this.config.maxOutputChars * 4, 1024 * 1024)
555
- const settle = () => {
556
- if (settled) return
557
- settled = true
558
- clearTimeout(timer)
559
- cleanupAbort()
560
- resolve({
561
- code: exitCode,
562
- signal: exitSignal,
563
- stdout: truncate(stdout, this.config.maxOutputChars),
564
- stderr: truncate(stderr, this.config.maxOutputChars),
565
- })
566
- }
567
- const timer = setTimeout(() => {
568
- if (settled) return
569
- exitCode = -1
570
- exitSignal = 'TIMEOUT'
571
- // Kill the remote command (SIGTERM) rather than just dropping the
572
- // channel, so a runaway process cannot keep running and holding
573
- // the SSH connection after we've given up on its output.
574
- try {
575
- if (typeof stream.signal === 'function') stream.signal('SIGTERM')
576
- } catch {}
577
- const hardClose = setTimeout(() => {
578
- try { stream.close() } catch {}
579
- }, 800)
580
- if (typeof hardClose.unref === 'function') hardClose.unref()
581
- settle()
582
- }, timeoutMs)
583
- if (signal) {
584
- if (signal.aborted) {
585
- try { if (typeof stream.signal === 'function') stream.signal('SIGTERM') } catch {}
586
- try { stream.close() } catch {}
587
- exitCode = -1
588
- exitSignal = 'ABORTED'
589
- return settle()
590
- }
591
- onAbort = () => {
592
- if (settled) return
593
- exitCode = -1
594
- exitSignal = 'ABORTED'
595
- try { if (typeof stream.signal === 'function') stream.signal('SIGTERM') } catch {}
596
- const hardClose = setTimeout(() => { try { stream.close() } catch {} }, 500)
597
- if (typeof hardClose.unref === 'function') hardClose.unref()
598
- settle()
599
- }
600
- signal.addEventListener('abort', onAbort, { once: true })
601
- }
602
- stream.on('close', (code, sigName) => {
603
- if (settled) return
604
- exitCode = code
605
- // `sigName` is the signal NAME that killed the remote process
606
- // (e.g. 'SIGTERM'), not the caller's AbortSignal. The rename
607
- // matters: `signal` in this scope is the AbortSignal, and
608
- // assigning it here would leak an object into the result.
609
- exitSignal = sigName
610
- settle()
611
- })
612
- stream.on('data', (d) => {
613
- if (stdout.length < hardCap) stdout += d
614
- })
615
- stream.stderr.on('data', (d) => {
616
- if (stderr.length < hardCap) stderr += d
617
- })
618
- stream.on('error', (e) => {
619
- if (settled) return
620
- settled = true
621
- clearTimeout(timer)
622
- cleanupAbort()
623
- reject(new Error('ssh stream error: ' + ((e && e.message) || e)))
624
- })
625
- if (stdinWriter) {
626
- try { stdinWriter(stream) } catch {}
627
- }
628
- })
629
- }
630
- runOn(client)
631
- }),
632
- )
633
- }
634
-
635
- /** Resolve a promisified SFTP client. All paths normalized via toSftpPath(). */
636
- sftp() {
637
- return this.connect().then(
638
- (client) =>
639
- new Promise((resolve, reject) => {
640
- let retried = false
641
- const runOn = (c) => {
642
- // Bound the subchannel open itself, per attempt. Without this, a
643
- // remote whose sftp subsystem is unresponsive (overloaded sshd,
644
- // exceeded MaxSessions, half-open TCP) leaves `c.sftp(cb)` parked
645
- // forever with no callback — issue #44 point 3. ssh2 only calls back
646
- // on success or on a channel error, and a wedged peer produces
647
- // neither.
648
- //
649
- // The timer must be created per attempt, not once outside: the
650
- // callback clears it, so a single outer timer would leave the retry
651
- // (below) with no timeout at all.
652
- let timedOut = false
653
- const attemptTimer = setTimeout(() => {
654
- timedOut = true
655
- reject(new Error(`ssh sftp open timed out after ${this.config.commandTimeoutMs}ms`))
656
- }, this.config.commandTimeoutMs)
657
- // ★ 这里**不能 unref**。
658
- //
659
- // 这个定时器是"本次 await 的 sftp 打开"的**唯一结算者**:要么它超时 reject,
660
- // 要么 ssh2 回调 resolve。若把它 unref,而进程此刻没有别的 pending 句柄,
661
- // Node 会认为无事可做而**直接退出**,调用方永远等不到结果。
662
- // 实测症状:CI 上该用例之后的 4 个用例被标记 `cancelledByParent`
663
- // (同文件子进程提前退出),而本地因运行器恰好持着句柄而侥幸通过。
664
- // 注意与上面 exec() 的超时定时器保持一致 —— 那个本来就没有 unref。
665
- // 下面的 hardClose 仍然 unref,因为它只做清理、不负责结算。
666
- const clearAttempt = () => { clearTimeout(attemptTimer) }
667
- try {
668
- c.sftp((err, sftp) => {
669
- clearAttempt()
670
- if (timedOut) return
671
- if (err) {
672
- // Same dead-connection recovery as exec(): a channel open
673
- // failure — or a session termination (remote closed the
674
- // SFTP subchannel, e.g. transient network blip / sshd idle
675
- // drop) — means the pooled connection is stale: drop it and
676
- // retry ONCE on a fresh connection.
677
- if (!retried && /channel open failure|open failed|unexpected sftp session termination|session termination|disconnect/i.test(String((err && err.message) || err))) {
678
- retried = true
679
- this.invalidate()
680
- return this.connect().then(
681
- (fresh) => runOn(fresh),
682
- (e2) => reject(new Error('ssh sftp failed (reconnect): ' + ((e2 && e2.message) || e2))),
683
- )
684
- }
685
- return reject(new Error('ssh sftp failed: ' + ((err && err.message) || err)))
686
- }
687
- // The promisified surface (timeouts, dead-channel fail-fast,
688
- // range reads) is shared verbatim with the system-OpenSSH
689
- // backend — see lib/sftp-surface.js for the rules it encodes.
690
- resolve(buildSftpSurface(sftp, { commandTimeoutMs: this.config.commandTimeoutMs }))
691
- })
692
- } catch (e) {
693
- clearAttempt()
694
- reject(new Error('ssh sftp failed: ' + ((e && e.message) || e)))
695
- }
696
- }
697
- runOn(client)
698
- }),
699
- )
700
- }
701
-
702
- /**
703
- * Drop the cached client and force a fresh connection on the next call.
704
- * Called when a channel open fails (e.g. "Channel open failure: open
705
- * failed") — the pooled SSH connection is usually dead server-side while
706
- * keepalive has not yet noticed, and reusing it keeps failing. The epoch
707
- * bump orphans any in-flight connect; the client is ended so ssh2 frees
708
- * its sockets.
709
- */
710
- invalidate() {
711
- this.epoch++
712
- const client = this.client
713
- this.client = null
714
- const pending = this.connecting
715
- this.connecting = null
716
- if (pending && typeof pending.catch === 'function') {
717
- try { pending.catch(() => {}) } catch {}
718
- }
719
- if (this.proxyPool) {
720
- try { this.proxyPool.close() } catch {}
721
- this.proxyPool = null
722
- }
723
- if (client) {
724
- try { client.end() } catch {}
725
- }
726
- // Same teardown as close(): the forwards manager must detach from the
727
- // dying client, or its stale `close -> stopAll()` listener later tears
728
- // down forwards that were re-attached to the NEW client (onReady does
729
- // the re-attach on the next successful connect).
730
- if (this.onCloseHook) {
731
- try { this.onCloseHook() } catch {}
732
- }
733
- }
734
-
735
- close() {
736
- this.epoch++
737
- const client = this.client
738
- this.client = null
739
- const pending = this.connecting
740
- this.connecting = null
741
- if (pending && typeof pending.catch === 'function') {
742
- try { pending.catch(() => {}) } catch {}
743
- }
744
- if (this.proxyPool) {
745
- try { this.proxyPool.close() } catch {}
746
- this.proxyPool = null
747
- }
748
- if (client) {
749
- try {
750
- client.end()
751
- } catch {}
752
- }
753
- if (this.onCloseHook) {
754
- try { this.onCloseHook() } catch {}
755
- }
756
- }
757
- }
1
+ // Persistent SSH/SFTP connection pool (one identity → one live client).
2
+ import ssh2 from 'ssh2'
3
+ import { readFileSync } from 'node:fs'
4
+ import { homedir } from 'node:os'
5
+ import path from 'node:path'
6
+ import { truncate } from './paths.js'
7
+ import { createHostKeyGuard } from './hostkey.js'
8
+ import { buildSftpSurface } from './sftp-surface.js'
9
+ import { SystemOpenSshPool, isSystemOpenSshBackend } from './openssh.js'
10
+
11
+ const { Client } = ssh2
12
+
13
+ /**
14
+ * Build the pool for a config.
15
+ *
16
+ * Two backends exist (issue #51): the native one below, which speaks SSH itself
17
+ * via ssh2, and the "system OpenSSH" one (lib/openssh.js), which delegates to
18
+ * the local `ssh` executable so GSSAPI/Kerberos and the rest of the user's
19
+ * `~/.ssh/config` are honoured. Both expose one identical surface, so every
20
+ * caller can keep using `new SshPool(...)`-shaped code — this factory only has
21
+ * to decide which class to instantiate.
22
+ */
23
+ export function createPool(config, opts) {
24
+ return isSystemOpenSshBackend(config)
25
+ ? new SystemOpenSshPool(config, opts)
26
+ : new SshPool(config, opts)
27
+ }
28
+
29
+ /**
30
+ * Run one `forwardOut` and resolve with the resulting channel.
31
+ *
32
+ * ssh2 reports a dead connection by THROWING synchronously —
33
+ * `if (!this._sock || !isWritable(this._sock)) throw new Error('Not connected')`
34
+ * (ssh2 >= 1.16, `Client.forwardOut`) — and never calls the callback, so a
35
+ * callback-only error path cannot guard this call (issue #54). Two further
36
+ * consequences justify a shared helper rather than an inline try/catch:
37
+ *
38
+ * • inside a `new Promise(executor)` a bare throw rejects nothing: it becomes
39
+ * an unhandled REJECTION and leaves the promise pending forever;
40
+ * • called from a `net.Server` connection handler the throw escapes to the
41
+ * process top level and kills the host.
42
+ *
43
+ * This guards the CALLER's side of the contract rather than assuming one
44
+ * backend's behaviour: the system-OpenSSH backend (lib/openssh.js `forwardOut`)
45
+ * already converts its own spawn failure into a callback error, so for it the
46
+ * try/catch below is dead code — but a backend is free to throw, and the one
47
+ * that does must not be able to take the process down.
48
+ *
49
+ * @param {object} client - an ssh2 Client or OpenSshConnection.
50
+ * @param {object} spec - `srcHost`/`srcPort`/`dstHost`/`dstPort`.
51
+ * @returns {Promise<object>} the forwarded channel / duplex stream.
52
+ */
53
+ export function forwardOutChannel(client, { srcHost = '127.0.0.1', srcPort = 0, dstHost, dstPort }) {
54
+ return new Promise((resolve, reject) => {
55
+ // `reject` is called at most once: a callback that fires after a successful
56
+ // throw-path rejection would be a no-op, which Promise guarantees.
57
+ try {
58
+ client.forwardOut(srcHost, srcPort, dstHost, dstPort, (err, channel) => (err ? reject(err) : resolve(channel)))
59
+ } catch (err) {
60
+ reject(err)
61
+ }
62
+ })
63
+ }
64
+
65
+ /**
66
+ * The ACTIVE pool: one long-lived object whose concrete implementation can be
67
+ * swapped when the current machine's transport backend changes.
68
+ *
69
+ * Why a delegating facade instead of just constructing the right class once:
70
+ * `backend` is a PER-MACHINE field (an enterprise Kerberos host needs
71
+ * `openssh` while every other machine stays on `native`), but the plugin keeps
72
+ * exactly one active pool for its whole lifetime and hangs `passwordResolver` /
73
+ * `onReady` / `onCloseHook` off it once at startup. Replacing the object
74
+ * wholesale on every machine switch would silently drop those hooks — the port
75
+ * forwards and keychain passwords would stop working after the first switch.
76
+ *
77
+ * So the facade owns the hooks and the shared `config` object, and forwards
78
+ * them to whichever concrete pool is right for the current config. Everything
79
+ * else is a straight delegation, which is why callers cannot tell the
80
+ * difference (and why the class is deliberately not part of the public story).
81
+ */
82
+ export class ActivePool {
83
+ constructor(config, opts = {}) {
84
+ this.config = config
85
+ this.opts = opts
86
+ // The hooks are assigned by the plugin AFTER construction, so they are
87
+ // stored here and re-applied to every delegate this facade ever creates.
88
+ this._passwordResolver = null
89
+ this._onReady = null
90
+ this._onCloseHook = null
91
+ this._delegate = null
92
+ this._sync()
93
+ }
94
+
95
+ /** Which concrete class the current config asks for. */
96
+ _wantsSystemOpenSsh() {
97
+ return isSystemOpenSshBackend(this.config)
98
+ }
99
+
100
+ _delegateIsCurrent() {
101
+ if (!this._delegate) return false
102
+ const isOpenssh = this._delegate instanceof SystemOpenSshPool
103
+ return isOpenssh === this._wantsSystemOpenSsh()
104
+ }
105
+
106
+ /** Create (or replace) the delegate so it matches the current config. */
107
+ _sync() {
108
+ if (this._delegateIsCurrent()) return this._delegate
109
+ const previous = this._delegate
110
+ const next = createPool(this.config, this.opts)
111
+ next.passwordResolver = this._passwordResolver
112
+ next.onReady = this._onReady
113
+ next.onCloseHook = this._onCloseHook
114
+ this._delegate = next
115
+ // Tear the old backend down LAST, and never let a teardown error mask the
116
+ // new pool: the connection it was holding belongs to the old host anyway.
117
+ if (previous) { try { previous.close() } catch { /* already gone */ } }
118
+ return next
119
+ }
120
+
121
+ get delegate() { return this._sync() }
122
+
123
+ // ── hooks: stored here, mirrored onto every delegate ──────────────────────
124
+ get passwordResolver() { return this._passwordResolver }
125
+ set passwordResolver(fn) {
126
+ this._passwordResolver = fn
127
+ if (this._delegate) this._delegate.passwordResolver = fn
128
+ }
129
+ get onReady() { return this._onReady }
130
+ set onReady(fn) {
131
+ this._onReady = fn
132
+ if (this._delegate) this._delegate.onReady = fn
133
+ }
134
+ get onCloseHook() { return this._onCloseHook }
135
+ set onCloseHook(fn) {
136
+ this._onCloseHook = fn
137
+ if (this._delegate) this._delegate.onCloseHook = fn
138
+ }
139
+
140
+ // ── pass-through state the plugin reads directly ──────────────────────────
141
+ get client() { return this._delegate ? this._delegate.client : null }
142
+ // Assignable because the plugin's own tests arm a fake live connection on the
143
+ // active pool; a getter-only facade would make that assignment a silent
144
+ // no-op in sloppy mode and a TypeError in the strict-mode ESM tests.
145
+ set client(v) { if (this._sync()) this._delegate.client = v }
146
+ get connecting() { return this._delegate ? this._delegate.connecting : null }
147
+ get proxyPool() { return this._delegate ? this._delegate.proxyPool : null }
148
+ get epoch() { return this._delegate ? this._delegate.epoch : 0 }
149
+ get platform() { return this._delegate ? this._delegate.platform : 'unknown' }
150
+ set platform(v) { if (this._sync()) this._delegate.platform = v }
151
+ get gitBashPath() { return this._delegate ? this._delegate.gitBashPath : '' }
152
+ set gitBashPath(v) { if (this._sync()) this._delegate.gitBashPath = v }
153
+ get shellMode() { return this._delegate ? this._delegate.shellMode : 'native' }
154
+ set shellMode(v) { if (this._sync()) this._delegate.shellMode = v }
155
+
156
+ // ── delegated operations ──────────────────────────────────────────────────
157
+ /** Re-point at a new target, switching backend first if the machine asks. */
158
+ setTarget(fields, previous) {
159
+ // The backend may have changed with the machine, so apply that part of the
160
+ // request to `config` BEFORE picking the delegate: _sync() chooses the class
161
+ // from config.backend, and the old backend must not be handed a host it can
162
+ // no longer reach. The delegate then receives the whole request as usual.
163
+ if (fields && fields.backend !== undefined) this.config.backend = String(fields.backend)
164
+ if (fields && fields.sshBinary !== undefined) this.config.sshBinary = String(fields.sshBinary)
165
+ const d = this._sync()
166
+ return d.setTarget(fields, previous)
167
+ }
168
+ connect() { return this.delegate.connect() }
169
+ exec(cmd, opts) { return this.delegate.exec(cmd, opts) }
170
+ sftp() { return this.delegate.sftp() }
171
+ detect() { return this.delegate.detect() }
172
+ invalidate() { return this.delegate.invalidate() }
173
+ close() { return this.delegate.close() }
174
+ }
175
+
176
+ export class SshPool {
177
+ constructor(config, opts = {}) {
178
+ this.config = config
179
+ this._knownHostsFile = opts.knownHostsFile
180
+ this.client = null
181
+ this.connecting = null
182
+ this.proxyPool = null
183
+ // Generational token: bumped on every target change / close so a stale
184
+ // in-flight connect can never hand this pool a connection to an old host.
185
+ this.epoch = 0
186
+ /** Auto-detected remote platform: unknown | windows | posix (per target). */
187
+ this.platform = 'unknown'
188
+ /** Resolved Git Bash bash.exe path on Windows remotes ('' when none). */
189
+ this.gitBashPath = ''
190
+ /** Resolved terminal strategy: native | git-bash. */
191
+ this.shellMode = 'native'
192
+ /** In-flight platform detection promise (cached). */
193
+ this._detecting = null
194
+ /** Optional async resolver for a machine-stored (keychain) password. */
195
+ this.passwordResolver = null
196
+ /** Optional hook called with the live client after a successful connect. */
197
+ this.onReady = null
198
+ /** Optional hook called when the pool closes. */
199
+ this.onCloseHook = null
200
+ }
201
+
202
+ resolveKeyPath() {
203
+ const p = this.config.privateKeyPath
204
+ if (!p) return ''
205
+ if (p.startsWith('~/') || p === '~') return path.join(homedir(), p.slice(1))
206
+ return p
207
+ }
208
+
209
+ setTarget({ host, port, username, password, privateKeyPath, passphrase, workspace, useAgent, keyboardInteractive, proxy, hostKeyMode, backend, sshBinary }, previous) {
210
+ // Collect the requested changes first: an UNCHANGED target is a no-op. The
211
+ // old behavior closed the live connection and re-ran platform detection
212
+ // even for an identical target, so every picker keystroke
213
+ // (POST /current → applyActiveMachine → setTarget) paid a full SSH
214
+ // reconnect + re-detect — the "slow remote path autocomplete" bug.
215
+ //
216
+ // `previous` is the diff BASELINE. Callers whose shared config was already
217
+ // mutated BEFORE this call (applyActiveMachine: applyMachine rewrites the
218
+ // live config IN PLACE, and pool.config IS that config object) pass a
219
+ // pre-mutation snapshot — otherwise the comparison runs the new values
220
+ // against themselves, is always equal and never closes even for a machine
221
+ // switch (the wrong-host hazard of issue #25). Defaults to the current
222
+ // config for callers that pass fresh values read independently of it.
223
+ const base = previous || this.config
224
+ const wanted = []
225
+ if (host !== undefined) wanted.push(['host', String(host)])
226
+ if (port !== undefined && Number(port)) wanted.push(['port', Number(port)])
227
+ if (username !== undefined) wanted.push(['username', String(username)])
228
+ if (password !== undefined && password !== null) wanted.push(['password', String(password)])
229
+ if (privateKeyPath !== undefined) wanted.push(['privateKeyPath', String(privateKeyPath)])
230
+ if (passphrase !== undefined) wanted.push(['passphrase', String(passphrase)])
231
+ if (workspace !== undefined) wanted.push(['workspace', String(workspace)])
232
+ if (useAgent !== undefined) wanted.push(['useAgent', !!useAgent])
233
+ if (keyboardInteractive !== undefined) wanted.push(['keyboardInteractive', !!keyboardInteractive])
234
+ if (proxy !== undefined) wanted.push(['proxy', proxy])
235
+ if (hostKeyMode !== undefined) wanted.push(['hostKeyMode', String(hostKeyMode)])
236
+ // Issue #51: the transport backend is part of the target identity — changing
237
+ // it must close the live connection exactly like changing the host does.
238
+ if (backend !== undefined) wanted.push(['backend', String(backend)])
239
+ if (sshBinary !== undefined) wanted.push(['sshBinary', String(sshBinary)])
240
+ const differs = ([k, v]) => (k === 'proxy'
241
+ ? JSON.stringify(base[k] ?? null) !== JSON.stringify(v ?? null)
242
+ : base[k] !== v)
243
+ // Something actually changed → apply it and force a fresh connection.
244
+ // (A caller that WANTS a reconnect for an identical target uses
245
+ // invalidate(); setTarget semantics stay "re-point when moved".)
246
+ if (wanted.some(differs)) {
247
+ for (const [k, v] of wanted) this.config[k] = v
248
+ // the new target may be a different OS — re-detect on the next command
249
+ this.platform = 'unknown'
250
+ this.gitBashPath = ''
251
+ this.shellMode = 'native'
252
+ this._detecting = null
253
+ this.close()
254
+ }
255
+ return this
256
+ }
257
+
258
+ connect() {
259
+ if (this.client) return Promise.resolve(this.client)
260
+ if (this.connecting) return this.connecting
261
+ const epoch = this.epoch
262
+ const pending = this._doConnect(epoch)
263
+ this.connecting = pending
264
+ const clear = () => {
265
+ if (this.epoch === epoch && this.connecting === pending) this.connecting = null
266
+ }
267
+ pending.then(clear, clear)
268
+ return pending
269
+ }
270
+
271
+ async _doConnect(epoch) {
272
+ const isCurrent = () => this.epoch === epoch
273
+ const knownHostsFile = typeof this._knownHostsFile === 'function' ? this._knownHostsFile() : this._knownHostsFile
274
+ const guard = createHostKeyGuard(this.config, knownHostsFile)
275
+ const client = new Client()
276
+ let settled = false
277
+ const fail = (err) => {
278
+ if (settled) return
279
+ settled = true
280
+ if (isCurrent() && this.client === client) this.client = null
281
+ throw guard.lastError ? new Error(guard.lastError) : err
282
+ }
283
+
284
+ // Proxy jump: SSH to the bastion first, then tunnel to the target through it.
285
+ let sock = null
286
+ const proxyCfg = this.config.proxy
287
+ if (proxyCfg && proxyCfg.host) {
288
+ try {
289
+ this.proxyPool = new SshPool({
290
+ ...this.config,
291
+ host: proxyCfg.host,
292
+ port: Number(proxyCfg.port) || 22,
293
+ username: proxyCfg.username || this.config.username || 'root',
294
+ password: proxyCfg.password || '',
295
+ privateKeyPath: proxyCfg.privateKeyPath || '',
296
+ passphrase: proxyCfg.passphrase || '',
297
+ proxy: undefined,
298
+ }, { knownHostsFile: this._knownHostsFile })
299
+ const pclient = await this.proxyPool.connect()
300
+ if (!isCurrent()) throw new Error('ssh target changed during proxy connect')
301
+ try {
302
+ sock = await forwardOutChannel(pclient, { dstHost: this.config.host, dstPort: this.config.port })
303
+ } catch (e) {
304
+ throw new Error('proxy forward to target failed: ' + ((e && e.message) || e))
305
+ }
306
+ } catch (err) {
307
+ return fail(err)
308
+ }
309
+ }
310
+
311
+ return new Promise((resolve, reject) => {
312
+ const rejectOnce = (err) => {
313
+ if (settled) return
314
+ settled = true
315
+ if (isCurrent() && this.client === client) this.client = null
316
+ reject(guard.lastError ? new Error(guard.lastError) : err)
317
+ }
318
+ client.on('ready', () => {
319
+ if (settled) return
320
+ settled = true
321
+ if (!isCurrent()) {
322
+ try { client.end() } catch {}
323
+ reject(new Error('ssh target changed during connect'))
324
+ return
325
+ }
326
+ this.client = client
327
+ resolve(client)
328
+ if (this.onReady) { try { this.onReady(client) } catch {} }
329
+ })
330
+ client.on('error', (e) => rejectOnce(e))
331
+ client.on('close', () => {
332
+ if (isCurrent() && this.client === client) this.client = null
333
+ rejectOnce(new Error('ssh connection closed'))
334
+ })
335
+
336
+ const buildOpts = async () => {
337
+ const opts = {
338
+ host: this.config.host,
339
+ port: this.config.port,
340
+ username: this.config.username,
341
+ readyTimeout: this.config.connectTimeoutMs,
342
+ keepaliveInterval: 15000,
343
+ keepaliveCountMax: 3,
344
+ hostVerifier: (key) => guard.verifier(key),
345
+ }
346
+ if (sock) opts.sock = sock
347
+ if (this.config.useAgent) {
348
+ const sockPath = process.env.SSH_AUTH_SOCK
349
+ if (sockPath) opts.agent = sockPath
350
+ }
351
+ let password = this.config.password || ''
352
+ if (!password && this.passwordResolver) {
353
+ try { password = (await this.passwordResolver()) || '' } catch {}
354
+ }
355
+ if (password) {
356
+ opts.password = password
357
+ opts.tryKeyboard = true
358
+ } else if (this.config.keyboardInteractive && !this.config.privateKeyPath) {
359
+ opts.tryKeyboard = true
360
+ }
361
+ if (this.config.privateKeyPath) {
362
+ const keyPath = this.resolveKeyPath()
363
+ if (!keyPath) {
364
+ throw new Error('no credentials: set a password or a privateKeyPath to connect')
365
+ }
366
+ let key
367
+ try {
368
+ key = readFileSync(keyPath)
369
+ } catch (err) {
370
+ throw new Error(`cannot read private key "${keyPath}": ${err && err.message}`)
371
+ }
372
+ opts.privateKey = key
373
+ opts.passphrase = this.config.passphrase || undefined
374
+ } else if (!password && !opts.agent) {
375
+ throw new Error('no credentials: set a password, a privateKeyPath, or enable useAgent to connect')
376
+ }
377
+ return opts
378
+ }
379
+
380
+ buildOpts().then(
381
+ (opts) => {
382
+ if (opts.tryKeyboard) {
383
+ client.on('keyboard-interactive', (name, instructions, lang, prompts, finish) => {
384
+ finish(prompts.map(() => this.config.password || ''))
385
+ })
386
+ }
387
+ // ssh2 parses the private key SYNCHRONOUSLY inside connect() and
388
+ // throws on an unparseable key (the classic case: an encrypted
389
+ // OpenSSH key without a passphrase). Left uncaught, that throw
390
+ // rejects this orphaned .then chain — an unhandled rejection that
391
+ // crashes the whole host process (issue #48: "dsh: fatal load
392
+ // failure" on every boot, because boot-restore re-dials the bad
393
+ // machine). It also leaves the outer promise pending forever.
394
+ // Convert it into a normal rejection so every caller's try/catch —
395
+ // boot restore, test-connect, rw_connect — sees a regular failure.
396
+ try {
397
+ client.connect(opts)
398
+ } catch (err) {
399
+ rejectOnce(err)
400
+ }
401
+ },
402
+ (err) => rejectOnce(err),
403
+ )
404
+ })
405
+ }
406
+
407
+ /** Detect the remote platform + locate Git Bash once; cached per target. */
408
+ detect() {
409
+ if (this.platform !== 'unknown') return Promise.resolve()
410
+ if (this._detecting) return this._detecting
411
+ this._detecting = this._detect().finally(() => {
412
+ this._detecting = null
413
+ })
414
+ return this._detecting
415
+ }
416
+
417
+ async _detect() {
418
+ let res
419
+ try {
420
+ // `cmd /c ver` works under cmd.exe, PowerShell AND Git Bash (all print
421
+ // "Microsoft Windows …"); on POSIX hosts `cmd` simply doesn't exist.
422
+ res = await this._execRaw('cmd /c ver', { timeoutMs: Math.min(this.config.commandTimeoutMs, 8000) })
423
+ } catch (err) {
424
+ this.platform = 'unknown'
425
+ this.shellMode = 'native'
426
+ return
427
+ }
428
+ const out = String(res.stdout || '') + '\n' + String(res.stderr || '')
429
+ if (res.code === 0 && /microsoft windows/i.test(out)) {
430
+ this.platform = 'windows'
431
+ await this._resolveGitBash()
432
+ return
433
+ }
434
+ // inconclusive — probe for a Git-Bash/MSYS remote (uname prints MINGW64_NT…)
435
+ try {
436
+ const u = await this._execRaw('uname -s', { timeoutMs: Math.min(this.config.commandTimeoutMs, 8000) })
437
+ if (/mingw|msys|cygwin/i.test(String(u.stdout || ''))) {
438
+ this.platform = 'windows'
439
+ await this._resolveGitBash()
440
+ return
441
+ }
442
+ } catch {}
443
+ this.platform = 'posix'
444
+ this.shellMode = 'native'
445
+ this.gitBashPath = ''
446
+ }
447
+
448
+ /** On a Windows remote, locate Git Bash (config path → PATH → common installs). */
449
+ async _resolveGitBash() {
450
+ const cfg = String(this.config.shell || '').trim()
451
+ if (cfg && cfg !== 'git-bash' && cfg !== 'native') {
452
+ if (await this._cmdExists(cfg)) {
453
+ this.gitBashPath = cfg
454
+ this.shellMode = 'git-bash'
455
+ return
456
+ }
457
+ }
458
+ if (cfg === 'native') {
459
+ this.shellMode = 'native'
460
+ this.gitBashPath = ''
461
+ return
462
+ }
463
+ try {
464
+ const r = await this._execRaw('cmd /c where bash', { timeoutMs: 8000 })
465
+ const first = String(r.stdout || '').trim().split(/\r?\n/)[0].trim()
466
+ if (first) {
467
+ this.gitBashPath = first
468
+ this.shellMode = 'git-bash'
469
+ return
470
+ }
471
+ } catch {}
472
+ const candidates = [
473
+ 'C:\\Program Files\\Git\\bin\\bash.exe',
474
+ 'C:\\Program Files (x86)\\Git\\bin\\bash.exe',
475
+ ...(process.env.LOCALAPPDATA ? [process.env.LOCALAPPDATA + '\\Programs\\Git\\bin\\bash.exe'] : []),
476
+ '%LOCALAPPDATA%\\Programs\\Git\\bin\\bash.exe',
477
+ ]
478
+ for (const c of candidates) {
479
+ if (await this._cmdExists(c)) {
480
+ this.gitBashPath = c
481
+ this.shellMode = 'git-bash'
482
+ return
483
+ }
484
+ }
485
+ this.shellMode = 'git-bash' // wanted but not found — commands fall back to raw
486
+ this.gitBashPath = ''
487
+ }
488
+
489
+ async _cmdExists(p) {
490
+ try {
491
+ const r = await this._execRaw(`cmd /c if exist "${p}" (echo Y) else (echo N)`, { timeoutMs: 8000 })
492
+ return /Y/.test(String(r.stdout || ''))
493
+ } catch {
494
+ return false
495
+ }
496
+ }
497
+
498
+ /** Run one remote command; resolves { code, signal, stdout, stderr }.
499
+ * On Windows remotes with Git Bash the script is piped to `bash -s` over the
500
+ * exec-channel stdin — no shell quoting round-trip, so any content (quotes,
501
+ * backslashes, newlines) survives verbatim. */
502
+ exec(command, timeoutMsOrOpts) {
503
+ const opts = timeoutMsOrOpts && typeof timeoutMsOrOpts === 'object' ? timeoutMsOrOpts : { timeoutMs: timeoutMsOrOpts }
504
+ return this.detect().then(() => {
505
+ if (this.platform === 'windows' && this.gitBashPath) {
506
+ const script = String(command)
507
+ return this._execRaw(`"${this.gitBashPath}" -s`, { ...opts, timeoutMs: opts.timeoutMs || this.config.commandTimeoutMs }, (stream) => {
508
+ try { stream.end(script) } catch {}
509
+ })
510
+ }
511
+ return this._execRaw(command, opts)
512
+ })
513
+ }
514
+
515
+ /** Raw exec (no detection / no wrapper). Optional stdinWriter(stream) feeds
516
+ * the remote process stdin (used by the Git Bash `-s` mode). */
517
+ _execRaw(command, opts, stdinWriter) {
518
+ const timeoutMs = (opts && opts.timeoutMs) || this.config.commandTimeoutMs
519
+ const signal = opts && opts.signal
520
+ return this.connect().then(
521
+ (client) =>
522
+ new Promise((resolve, reject) => {
523
+ let retried = false
524
+ // Caller cancellation (issue #44): a tool that hands us exec.signal
525
+ // expects the remote command to stop when the turn is cancelled.
526
+ // Resolve the same shape as a timeout so callers need no new branch.
527
+ let onAbort = null
528
+ const cleanupAbort = () => { if (signal && onAbort) signal.removeEventListener('abort', onAbort) }
529
+ const runOn = (c) => {
530
+ const execOpts = {}
531
+ if (opts && opts.pty) execOpts.pty = true
532
+ if (opts && opts.env && typeof opts.env === 'object') execOpts.env = opts.env
533
+ c.exec(command, execOpts, (err, stream) => {
534
+ if (err) {
535
+ // Channel-open failure — or a session termination — usually
536
+ // means the pooled connection died server-side (idle timeout /
537
+ // network reset) while keepalive hadn't noticed. Drop it and
538
+ // retry ONCE on a fresh connection.
539
+ if (!retried && /channel open failure|open failed|unexpected .* session termination|session termination|disconnect/i.test(String((err && err.message) || err))) {
540
+ retried = true
541
+ this.invalidate()
542
+ return this.connect().then(
543
+ (fresh) => runOn(fresh),
544
+ (e2) => reject(new Error('ssh exec failed (reconnect): ' + ((e2 && e2.message) || e2))),
545
+ )
546
+ }
547
+ return reject(new Error('ssh exec failed: ' + ((err && err.message) || err)))
548
+ }
549
+ let stdout = ''
550
+ let stderr = ''
551
+ let settled = false
552
+ let exitCode = null
553
+ let exitSignal = null
554
+ const hardCap = Math.max(this.config.maxOutputChars * 4, 1024 * 1024)
555
+ const settle = () => {
556
+ if (settled) return
557
+ settled = true
558
+ clearTimeout(timer)
559
+ cleanupAbort()
560
+ resolve({
561
+ code: exitCode,
562
+ signal: exitSignal,
563
+ stdout: truncate(stdout, this.config.maxOutputChars),
564
+ stderr: truncate(stderr, this.config.maxOutputChars),
565
+ })
566
+ }
567
+ const timer = setTimeout(() => {
568
+ if (settled) return
569
+ exitCode = -1
570
+ exitSignal = 'TIMEOUT'
571
+ // Kill the remote command (SIGTERM) rather than just dropping the
572
+ // channel, so a runaway process cannot keep running and holding
573
+ // the SSH connection after we've given up on its output.
574
+ try {
575
+ if (typeof stream.signal === 'function') stream.signal('SIGTERM')
576
+ } catch {}
577
+ const hardClose = setTimeout(() => {
578
+ try { stream.close() } catch {}
579
+ }, 800)
580
+ if (typeof hardClose.unref === 'function') hardClose.unref()
581
+ settle()
582
+ }, timeoutMs)
583
+ if (signal) {
584
+ if (signal.aborted) {
585
+ try { if (typeof stream.signal === 'function') stream.signal('SIGTERM') } catch {}
586
+ try { stream.close() } catch {}
587
+ exitCode = -1
588
+ exitSignal = 'ABORTED'
589
+ return settle()
590
+ }
591
+ onAbort = () => {
592
+ if (settled) return
593
+ exitCode = -1
594
+ exitSignal = 'ABORTED'
595
+ try { if (typeof stream.signal === 'function') stream.signal('SIGTERM') } catch {}
596
+ const hardClose = setTimeout(() => { try { stream.close() } catch {} }, 500)
597
+ if (typeof hardClose.unref === 'function') hardClose.unref()
598
+ settle()
599
+ }
600
+ signal.addEventListener('abort', onAbort, { once: true })
601
+ }
602
+ stream.on('close', (code, sigName) => {
603
+ if (settled) return
604
+ exitCode = code
605
+ // `sigName` is the signal NAME that killed the remote process
606
+ // (e.g. 'SIGTERM'), not the caller's AbortSignal. The rename
607
+ // matters: `signal` in this scope is the AbortSignal, and
608
+ // assigning it here would leak an object into the result.
609
+ exitSignal = sigName
610
+ settle()
611
+ })
612
+ stream.on('data', (d) => {
613
+ if (stdout.length < hardCap) stdout += d
614
+ })
615
+ stream.stderr.on('data', (d) => {
616
+ if (stderr.length < hardCap) stderr += d
617
+ })
618
+ stream.on('error', (e) => {
619
+ if (settled) return
620
+ settled = true
621
+ clearTimeout(timer)
622
+ cleanupAbort()
623
+ reject(new Error('ssh stream error: ' + ((e && e.message) || e)))
624
+ })
625
+ if (stdinWriter) {
626
+ try { stdinWriter(stream) } catch {}
627
+ }
628
+ })
629
+ }
630
+ runOn(client)
631
+ }),
632
+ )
633
+ }
634
+
635
+ /** Resolve a promisified SFTP client. All paths normalized via toSftpPath(). */
636
+ sftp() {
637
+ return this.connect().then(
638
+ (client) =>
639
+ new Promise((resolve, reject) => {
640
+ let retried = false
641
+ const runOn = (c) => {
642
+ // Bound the subchannel open itself, per attempt. Without this, a
643
+ // remote whose sftp subsystem is unresponsive (overloaded sshd,
644
+ // exceeded MaxSessions, half-open TCP) leaves `c.sftp(cb)` parked
645
+ // forever with no callback — issue #44 point 3. ssh2 only calls back
646
+ // on success or on a channel error, and a wedged peer produces
647
+ // neither.
648
+ //
649
+ // The timer must be created per attempt, not once outside: the
650
+ // callback clears it, so a single outer timer would leave the retry
651
+ // (below) with no timeout at all.
652
+ let timedOut = false
653
+ const attemptTimer = setTimeout(() => {
654
+ timedOut = true
655
+ reject(new Error(`ssh sftp open timed out after ${this.config.commandTimeoutMs}ms`))
656
+ }, this.config.commandTimeoutMs)
657
+ // ★ 这里**不能 unref**。
658
+ //
659
+ // 这个定时器是"本次 await 的 sftp 打开"的**唯一结算者**:要么它超时 reject,
660
+ // 要么 ssh2 回调 resolve。若把它 unref,而进程此刻没有别的 pending 句柄,
661
+ // Node 会认为无事可做而**直接退出**,调用方永远等不到结果。
662
+ // 实测症状:CI 上该用例之后的 4 个用例被标记 `cancelledByParent`
663
+ // (同文件子进程提前退出),而本地因运行器恰好持着句柄而侥幸通过。
664
+ // 注意与上面 exec() 的超时定时器保持一致 —— 那个本来就没有 unref。
665
+ // 下面的 hardClose 仍然 unref,因为它只做清理、不负责结算。
666
+ const clearAttempt = () => { clearTimeout(attemptTimer) }
667
+ try {
668
+ c.sftp((err, sftp) => {
669
+ clearAttempt()
670
+ if (timedOut) return
671
+ if (err) {
672
+ // Same dead-connection recovery as exec(): a channel open
673
+ // failure — or a session termination (remote closed the
674
+ // SFTP subchannel, e.g. transient network blip / sshd idle
675
+ // drop) — means the pooled connection is stale: drop it and
676
+ // retry ONCE on a fresh connection.
677
+ if (!retried && /channel open failure|open failed|unexpected sftp session termination|session termination|disconnect/i.test(String((err && err.message) || err))) {
678
+ retried = true
679
+ this.invalidate()
680
+ return this.connect().then(
681
+ (fresh) => runOn(fresh),
682
+ (e2) => reject(new Error('ssh sftp failed (reconnect): ' + ((e2 && e2.message) || e2))),
683
+ )
684
+ }
685
+ return reject(new Error('ssh sftp failed: ' + ((err && err.message) || err)))
686
+ }
687
+ // The promisified surface (timeouts, dead-channel fail-fast,
688
+ // range reads) is shared verbatim with the system-OpenSSH
689
+ // backend — see lib/sftp-surface.js for the rules it encodes.
690
+ resolve(buildSftpSurface(sftp, { commandTimeoutMs: this.config.commandTimeoutMs }))
691
+ })
692
+ } catch (e) {
693
+ clearAttempt()
694
+ reject(new Error('ssh sftp failed: ' + ((e && e.message) || e)))
695
+ }
696
+ }
697
+ runOn(client)
698
+ }),
699
+ )
700
+ }
701
+
702
+ /**
703
+ * Drop the cached client and force a fresh connection on the next call.
704
+ * Called when a channel open fails (e.g. "Channel open failure: open
705
+ * failed") — the pooled SSH connection is usually dead server-side while
706
+ * keepalive has not yet noticed, and reusing it keeps failing. The epoch
707
+ * bump orphans any in-flight connect; the client is ended so ssh2 frees
708
+ * its sockets.
709
+ */
710
+ invalidate() {
711
+ this.epoch++
712
+ const client = this.client
713
+ this.client = null
714
+ const pending = this.connecting
715
+ this.connecting = null
716
+ if (pending && typeof pending.catch === 'function') {
717
+ try { pending.catch(() => {}) } catch {}
718
+ }
719
+ if (this.proxyPool) {
720
+ try { this.proxyPool.close() } catch {}
721
+ this.proxyPool = null
722
+ }
723
+ if (client) {
724
+ try { client.end() } catch {}
725
+ }
726
+ // Same teardown as close(): the forwards manager must detach from the
727
+ // dying client, or its stale `close -> stopAll()` listener later tears
728
+ // down forwards that were re-attached to the NEW client (onReady does
729
+ // the re-attach on the next successful connect).
730
+ if (this.onCloseHook) {
731
+ try { this.onCloseHook() } catch {}
732
+ }
733
+ }
734
+
735
+ close() {
736
+ this.epoch++
737
+ const client = this.client
738
+ this.client = null
739
+ const pending = this.connecting
740
+ this.connecting = null
741
+ if (pending && typeof pending.catch === 'function') {
742
+ try { pending.catch(() => {}) } catch {}
743
+ }
744
+ if (this.proxyPool) {
745
+ try { this.proxyPool.close() } catch {}
746
+ this.proxyPool = null
747
+ }
748
+ if (client) {
749
+ try {
750
+ client.end()
751
+ } catch {}
752
+ }
753
+ if (this.onCloseHook) {
754
+ try { this.onCloseHook() } catch {}
755
+ }
756
+ }
757
+ }