@tiphareth/dsh-hardssh 0.1.2 → 0.2.3

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 (174) hide show
  1. package/LICENSE +30 -0
  2. package/README.md +77 -50
  3. package/SKILLS.md +140 -0
  4. package/lib/base/index.js +95 -0
  5. package/lib/client.js +1171 -1321
  6. package/lib/fs.js +333 -416
  7. package/lib/index.js +5428 -2991
  8. package/lib/ledger-B-LXlftp.js +43 -0
  9. package/lib/ledger-D2ezq1iW.js +376 -0
  10. package/lib/model-Cp7f70Mb.js +5 -0
  11. package/lib/registry-CViuzKYI.js +421 -0
  12. package/lib/subprocess.js +92 -549
  13. package/lib/types/backend.d.ts +73 -78
  14. package/lib/types/base/capability.d.ts +4 -0
  15. package/lib/types/base/ledger-router.d.ts +42 -26
  16. package/lib/types/base/ledger.d.ts +30 -19
  17. package/lib/types/base/model.d.ts +17 -8
  18. package/lib/types/base/plugin.d.ts +11 -0
  19. package/lib/types/base/registry.d.ts +28 -31
  20. package/lib/types/base/router.d.ts +0 -7
  21. package/lib/types/client/api.d.ts +8 -8
  22. package/lib/types/client/connect-host.d.ts +13 -0
  23. package/lib/types/client/index.d.ts +6 -4
  24. package/lib/types/client/locales.d.ts +15 -1
  25. package/lib/types/client/session-connect-gate.d.ts +71 -0
  26. package/lib/types/client/ssh/api.d.ts +11 -9
  27. package/lib/types/client/ssh/apply.d.ts +14 -8
  28. package/lib/types/client/ssh/locales.d.ts +8 -1
  29. package/lib/types/client/ssh/ops-tab.d.ts +34 -0
  30. package/lib/types/client/ssh/panel/ClusterTab.d.ts +3 -1
  31. package/lib/types/client/ssh/panel/ConnectionErrorDialog.d.ts +8 -0
  32. package/lib/types/client/ssh/panel/SessionSecretDialog.d.ts +4 -1
  33. package/lib/types/client/ssh/panel/SshPanel.d.ts +8 -8
  34. package/lib/types/client/ssh/panel/TerminalTab.d.ts +3 -5
  35. package/lib/types/client/ssh/panel/TransferTab.d.ts +5 -1
  36. package/lib/types/client/ssh/panel/TunnelsTab.d.ts +3 -1
  37. package/lib/types/client/ssh/session-target.d.ts +17 -0
  38. package/lib/types/client/state.d.ts +4 -1
  39. package/lib/types/client/workspace-panel-entry.d.ts +30 -0
  40. package/lib/types/client/workspace-panel.d.ts +24 -0
  41. package/lib/types/client-http.d.ts +77 -6
  42. package/lib/types/core.d.ts +5 -16
  43. package/lib/types/fs.d.ts +42 -25
  44. package/lib/types/index.d.ts +138 -9
  45. package/lib/types/ledger.d.ts +16 -111
  46. package/lib/types/protocol.d.ts +0 -5
  47. package/lib/types/providers/index.d.ts +6 -3
  48. package/lib/types/providers/local/provider.d.ts +83 -23
  49. package/lib/types/providers/ssh/provider.d.ts +24 -57
  50. package/lib/types/remote/environment.d.ts +2 -0
  51. package/lib/types/remote/remote-fs.d.ts +12 -1
  52. package/lib/types/remote/remote-process.d.ts +22 -1
  53. package/lib/types/remote/remote-subprocess.d.ts +13 -1
  54. package/lib/types/remote/remote-terminal.d.ts +4 -0
  55. package/lib/types/remote-search.d.ts +3 -3
  56. package/lib/types/routes.d.ts +36 -8
  57. package/lib/types/runtime/dsh-capabilities.d.ts +18 -0
  58. package/lib/types/runtime/workspace-core.d.ts +73 -38
  59. package/lib/types/runtime/workspace-migration.d.ts +113 -0
  60. package/lib/types/ssh/connection/lease.d.ts +23 -0
  61. package/lib/types/ssh/connection/manager.d.ts +359 -0
  62. package/lib/types/ssh/connection/pool.d.ts +8 -1
  63. package/lib/types/ssh/engine.d.ts +97 -267
  64. package/lib/types/ssh/known-hosts.d.ts +22 -5
  65. package/lib/types/ssh/local-transfer-policy.d.ts +20 -0
  66. package/lib/types/ssh/plugin.d.ts +10 -15
  67. package/lib/types/ssh/protocol.d.ts +18 -0
  68. package/lib/types/ssh/routes.d.ts +15 -7
  69. package/lib/types/ssh/sftp/service.d.ts +216 -0
  70. package/lib/types/ssh/store.d.ts +20 -8
  71. package/lib/types/ssh/terminal/service.d.ts +49 -0
  72. package/lib/types/ssh/tunnel/service.d.ts +26 -0
  73. package/lib/types/ssh/vault.d.ts +51 -4
  74. package/lib/types/subprocess.d.ts +13 -21
  75. package/lib/types/switch/switch-fs.d.ts +52 -2
  76. package/lib/types/switch/switch-subprocess.d.ts +32 -3
  77. package/lib/types/tools.d.ts +7 -6
  78. package/lib/types/workspace-tool-ops.d.ts +33 -0
  79. package/lib/types/workspace.d.ts +18 -0
  80. package/lib/vault-3gpWct2Q.js +559 -0
  81. package/lib/workspace.js +2 -0
  82. package/package.json +59 -26
  83. package/scripts/export-legacy-workspaces.mjs +136 -0
  84. package/src/backend.ts +155 -483
  85. package/src/base/capability.ts +4 -0
  86. package/src/base/ledger-router.ts +214 -65
  87. package/src/base/ledger.ts +175 -48
  88. package/src/base/model.ts +18 -8
  89. package/src/base/plugin.ts +15 -4
  90. package/src/base/registry.ts +49 -41
  91. package/src/base/router.ts +0 -8
  92. package/src/client/api.ts +8 -36
  93. package/src/client/connect-host.ts +199 -0
  94. package/src/client/directory-flow.tsx +91 -36
  95. package/src/client/index.ts +56 -53
  96. package/src/client/locales.ts +14 -0
  97. package/src/client/session-connect-gate.ts +163 -0
  98. package/src/client/ssh/api.ts +245 -108
  99. package/src/client/ssh/apply.ts +23 -24
  100. package/src/client/ssh/locales.ts +16 -2
  101. package/src/client/ssh/ops-tab.tsx +79 -0
  102. package/src/client/ssh/panel/ClusterTab.tsx +9 -20
  103. package/src/client/ssh/panel/ConnectionErrorDialog.tsx +33 -0
  104. package/src/client/ssh/panel/SessionSecretDialog.tsx +5 -1
  105. package/src/client/ssh/panel/SshPanel.tsx +81 -77
  106. package/src/client/ssh/panel/TerminalTab.tsx +18 -37
  107. package/src/client/ssh/panel/TransferTab.tsx +43 -33
  108. package/src/client/ssh/panel/TunnelsTab.tsx +9 -26
  109. package/src/client/ssh/panel/panel.module.css +90 -127
  110. package/src/client/ssh/session-target.ts +69 -0
  111. package/src/client/state.ts +9 -1
  112. package/src/client/workspace-badges.ts +99 -96
  113. package/src/client/workspace-panel-entry.tsx +91 -0
  114. package/src/client/workspace-panel.tsx +227 -0
  115. package/src/client/workspace.module.css +76 -2
  116. package/src/client-http.ts +118 -18
  117. package/src/core.ts +37 -47
  118. package/src/fs.ts +171 -85
  119. package/src/index.ts +535 -244
  120. package/src/ledger.ts +62 -416
  121. package/src/protocol.ts +0 -6
  122. package/src/providers/index.ts +8 -4
  123. package/src/providers/local/provider.ts +259 -87
  124. package/src/providers/ssh/provider.ts +158 -155
  125. package/src/remote/environment.ts +22 -1
  126. package/src/remote/remote-fs.ts +123 -34
  127. package/src/remote/remote-process.ts +265 -203
  128. package/src/remote/remote-subprocess.ts +61 -18
  129. package/src/remote/remote-terminal.ts +106 -21
  130. package/src/remote-search.ts +122 -26
  131. package/src/routes.ts +416 -395
  132. package/src/runtime/dsh-capabilities.ts +19 -0
  133. package/src/runtime/workspace-core.ts +171 -88
  134. package/src/runtime/workspace-migration.ts +472 -0
  135. package/src/ssh/connection/lease.ts +35 -0
  136. package/src/ssh/connection/manager.ts +1083 -0
  137. package/src/ssh/connection/pool.ts +341 -275
  138. package/src/ssh/engine.ts +269 -1477
  139. package/src/ssh/known-hosts.ts +42 -18
  140. package/src/ssh/local-transfer-policy.ts +83 -0
  141. package/src/ssh/plugin.ts +20 -19
  142. package/src/ssh/protocol.ts +22 -1
  143. package/src/ssh/routes.ts +419 -187
  144. package/src/ssh/sftp/service.ts +967 -0
  145. package/src/ssh/store.ts +169 -72
  146. package/src/ssh/terminal/service.ts +177 -0
  147. package/src/ssh/tools.ts +42 -20
  148. package/src/ssh/tunnel/service.ts +217 -0
  149. package/src/ssh/vault.ts +245 -91
  150. package/src/subprocess.ts +88 -71
  151. package/src/switch/switch-fs.ts +141 -10
  152. package/src/switch/switch-subprocess.ts +80 -6
  153. package/src/tools.ts +217 -221
  154. package/src/workspace-tool-ops.ts +101 -0
  155. package/src/workspace.ts +52 -0
  156. package/lib/environment-BL1jddfB.js +0 -449
  157. package/lib/switch-fs-CAJpFY9C.js +0 -193
  158. package/lib/switch-fs-RrZtG2gv.js +0 -210
  159. package/lib/types/client/manager-button.d.ts +0 -32
  160. package/lib/types/client/ssh/mount.d.ts +0 -13
  161. package/lib/types/client/ssh/panel/HostsTab.d.ts +0 -10
  162. package/lib/types/client/ssh/panel/controller.d.ts +0 -23
  163. package/lib/types/client/ssh/sidebar-entry.d.ts +0 -25
  164. package/lib/types/client/workspace-gate.d.ts +0 -15
  165. package/lib/types/remote-runner.d.ts +0 -83
  166. package/lib/types/seam-state.d.ts +0 -69
  167. package/src/client/manager-button.tsx +0 -269
  168. package/src/client/ssh/mount.tsx +0 -83
  169. package/src/client/ssh/panel/HostsTab.tsx +0 -236
  170. package/src/client/ssh/panel/controller.ts +0 -48
  171. package/src/client/ssh/sidebar-entry.ts +0 -123
  172. package/src/client/workspace-gate.ts +0 -225
  173. package/src/remote-runner.ts +0 -201
  174. package/src/seam-state.ts +0 -185
@@ -110,10 +110,18 @@ export class KnownHostsStore {
110
110
  return this.records.get(alias)
111
111
  }
112
112
 
113
- /** First encounter: record as pending (non-destructive re-observe keeps status). */
113
+ /** First encounter: record as pending. Re-observing keeps the recorded
114
+ * status/fingerprint but backfills a previously empty host/port. */
114
115
  observe(alias: string, meta: { host: string; port: number; keyType: string; fingerprintSha256: string }): void {
115
116
  const existing = this.records.get(alias)
116
- if (existing !== undefined) return // keep pending/trusted as-is
117
+ if (existing !== undefined) {
118
+ let changed = false
119
+ if (existing.host === '' && meta.host !== '') { existing.host = meta.host; changed = true }
120
+ if (existing.port === 0 && meta.port > 0) { existing.port = meta.port; changed = true }
121
+ if (existing.keyType === 'ssh-unknown' && meta.keyType !== 'ssh-unknown') { existing.keyType = meta.keyType; changed = true }
122
+ if (changed) this.save()
123
+ return
124
+ }
117
125
  this.records.set(alias, {
118
126
  alias,
119
127
  host: meta.host,
@@ -127,10 +135,13 @@ export class KnownHostsStore {
127
135
  this.save()
128
136
  }
129
137
 
130
- /** pending → trusted (must match the recorded fingerprint). */
131
- trust(alias: string): void {
138
+ /** pending → trusted (must match the recorded fingerprint). Optional
139
+ * host/port backfill completes a record whose first observe had no target. */
140
+ trust(alias: string, target?: { host?: string; port?: number }): void {
132
141
  const record = this.records.get(alias)
133
142
  if (record === undefined) throw new Error(`no host-key record for '${alias}'`)
143
+ if (record.host === '' && target?.host !== undefined && target.host !== '') record.host = target.host
144
+ if (record.port === 0 && target?.port !== undefined && target.port > 0) record.port = target.port
134
145
  record.status = 'trusted'
135
146
  record.confirmedAt = Date.now()
136
147
  this.save()
@@ -151,36 +162,49 @@ export class KnownHostsStore {
151
162
  export class HostKeyPolicy {
152
163
  constructor(private readonly knownHosts: KnownHostsStore) {}
153
164
 
154
- /** Evaluate one server key: trusted / unknown (records pending) / mismatch. */
155
- check(alias: string, rawKey: Buffer): HostKeyCheck {
165
+ /** Evaluate one server key: trusted / unknown (records pending) / mismatch.
166
+ * `target` supplies the host/port the engine is actually connecting to so
167
+ * the audit record is complete instead of `''`/0. */
168
+ check(alias: string, rawKey: Buffer, target?: { host?: string; port?: number }): HostKeyCheck {
156
169
  const recorded = this.knownHosts.lookup(alias)
157
170
  const actual = fingerprintOf(rawKey)
158
171
  if (recorded === undefined) {
159
172
  // First encounter: remember as pending, but NOT trusted — the caller
160
173
  // must refuse the connection until the operator confirms.
161
174
  this.knownHosts.observe(alias, {
162
- host: '', // host/port are filled by the engine's richer record on confirm
163
- port: 0,
175
+ host: target?.host ?? '',
176
+ port: target?.port ?? 0,
164
177
  keyType: keyTypeOf(rawKey),
165
178
  fingerprintSha256: actual,
166
179
  })
167
180
  return { kind: 'unknown', fingerprintSha256: actual }
168
181
  }
169
- if (recorded.status !== 'trusted') return { kind: 'unknown', fingerprintSha256: recorded.fingerprint }
182
+ if (recorded.status !== 'trusted') {
183
+ this.knownHosts.observe(alias, {
184
+ host: target?.host ?? '',
185
+ port: target?.port ?? 0,
186
+ keyType: keyTypeOf(rawKey),
187
+ fingerprintSha256: recorded.fingerprint,
188
+ })
189
+ return { kind: 'unknown', fingerprintSha256: recorded.fingerprint }
190
+ }
170
191
  if (fingerprintsEqual(recorded.fingerprint, actual)) return { kind: 'trusted' }
171
192
  return { kind: 'mismatch', expected: recorded.fingerprint, actual }
172
193
  }
173
194
  }
174
195
 
175
- /** Best-effort key type label from the raw blob's base64 header. */
176
- function keyTypeOf(rawKey: Buffer): string {
177
- try {
178
- const base64 = rawKey.toString('base64')
179
- const decoded = Buffer.from(base64, 'base64').toString('utf8')
180
- const end = decoded.indexOf(' ')
181
- if (end > 0) return decoded.slice(0, end)
182
- } catch { /* fall through */ }
183
- return 'ssh-unknown'
196
+ /**
197
+ * Parse the key type out of a real SSH wire public-key blob. The blob is
198
+ * `string algorithm, byte[] key` where every string is a 4-byte big-endian
199
+ * length followed by raw bytes — NOT the `ssh-ed25519 AAAA…` authorized_keys
200
+ * text form, so a text-oriented decode would always yield 'ssh-unknown'.
201
+ */
202
+ export function keyTypeOf(rawKey: Buffer): string {
203
+ if (rawKey.length < 4) return 'ssh-unknown'
204
+ const length = rawKey.readUInt32BE(0)
205
+ if (length === 0 || length > 64 || 4 + length > rawKey.length) return 'ssh-unknown'
206
+ const algorithm = rawKey.subarray(4, 4 + length).toString('latin1')
207
+ return /^[a-z0-9@.\-]+$/u.test(algorithm) ? algorithm : 'ssh-unknown'
184
208
  }
185
209
 
186
210
  function isRecord(value: unknown): value is KnownHostRecord {
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Host-side path policy for ssh_upload / ssh_download.
3
+ *
4
+ * These tools bridge remote I/O to the DSH host, so accepting an arbitrary
5
+ * absolute local path would bypass the normal FileSystem sandbox. The only
6
+ * trusted per-call root available to a tool body is the validated absolute cwd
7
+ * stored on the invoking Agent's immutable SessionHeader.
8
+ */
9
+
10
+ import { existsSync, realpathSync, statSync } from 'node:fs'
11
+ import { basename, dirname, isAbsolute, relative, resolve } from 'node:path'
12
+ import type { ToolRunContext } from '@deepseek-ai/dsh-tools'
13
+
14
+ export type LocalTransferDirection = 'upload-source' | 'download-destination'
15
+
16
+ function outside(root: string, candidate: string): boolean {
17
+ const rel = relative(root, candidate)
18
+ return rel === '..' || rel.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`) || isAbsolute(rel)
19
+ }
20
+
21
+ function fail(path: string, root: string): never {
22
+ throw new Error(`local transfer path '${path}' is outside the invoking session workspace '${root}'`)
23
+ }
24
+
25
+ /** Canonicalize a not-yet-existing destination through its nearest existing
26
+ * ancestor, preserving the suffix beneath that canonical ancestor. */
27
+ function canonicalFuturePath(path: string): string {
28
+ const suffix: string[] = [basename(path)]
29
+ let cursor = dirname(path)
30
+ for (;;) {
31
+ if (existsSync(cursor)) {
32
+ return resolve(realpathSync.native(cursor), ...suffix)
33
+ }
34
+ const parent = dirname(cursor)
35
+ if (parent === cursor) throw new Error(`cannot resolve an existing parent for local destination '${path}'`)
36
+ suffix.unshift(basename(cursor))
37
+ cursor = parent
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Validate and canonicalize one local transfer path.
43
+ *
44
+ * Uploads must name an existing regular file. Downloads may create the final
45
+ * file and missing parent directories, but their nearest existing ancestor is
46
+ * canonicalized so an in-workspace symlink/junction cannot redirect the write
47
+ * outside the session root.
48
+ */
49
+ export function secureLocalTransferPath(
50
+ exec: ToolRunContext,
51
+ localPath: string,
52
+ direction: LocalTransferDirection,
53
+ ): string {
54
+ const cwd = exec.agent?.session.header.cwd
55
+ if (cwd === undefined) {
56
+ throw new Error('local transfer denied: the tool call has no invoking session workspace')
57
+ }
58
+ if (!isAbsolute(cwd)) throw new Error('local transfer denied: invoking session cwd is not absolute')
59
+ if (!isAbsolute(localPath)) throw new Error('local transfer path must be absolute')
60
+
61
+ let root: string
62
+ try {
63
+ root = realpathSync.native(cwd)
64
+ } catch {
65
+ throw new Error(`local transfer denied: invoking session workspace '${cwd}' is unavailable`)
66
+ }
67
+
68
+ const requested = resolve(localPath)
69
+ let canonical: string
70
+ if (direction === 'upload-source') {
71
+ try {
72
+ canonical = realpathSync.native(requested)
73
+ } catch {
74
+ throw new Error(`local upload source '${localPath}' does not exist`)
75
+ }
76
+ if (!statSync(canonical).isFile()) throw new Error(`local upload source '${localPath}' is not a regular file`)
77
+ } else {
78
+ canonical = existsSync(requested) ? realpathSync.native(requested) : canonicalFuturePath(requested)
79
+ }
80
+
81
+ if (outside(root, canonical)) fail(localPath, root)
82
+ return canonical
83
+ }
package/src/ssh/plugin.ts CHANGED
@@ -25,29 +25,21 @@ import { sshClusterTool, sshDownloadTool, sshExecTool, sshListTool, sshTunnelToo
25
25
  * user settings survive the package merge unchanged. */
26
26
  export const SSH_SETTINGS_NAMESPACE = 'dsh-ssh'
27
27
 
28
- /** SSH-capability config, validated by the same-named schemastery schema. */
28
+ /** SSH-capability config, validated by the same-named schemastery schema.
29
+ * `secretStorage` is deliberately NOT a key here: the mode decides which
30
+ * objects are constructed at plugin load, so it is a single source in the
31
+ * top-level plugin config (`Config.secretStorage`). A value left in this
32
+ * namespace by hand is reported as drift by src/index.ts, never applied. */
29
33
  export interface SshConfig {
30
34
  /** When true (default), a system-prompt section announces the SSH capability. */
31
35
  announceToAgent?: boolean
32
36
  /** Master switch for the SSH surfaces (routes, tools, prompt section). */
33
37
  enabled?: boolean
34
- /**
35
- * Secret storage mode:
36
- * - 'none' (default): passwords/passphrases are NEVER persisted; each
37
- * session's first connection prompts for them (VSCode Remote-SSH style)
38
- * and they live in memory (session password table) for the connection
39
- * pool lifetime (idle 30 min auto-disconnect; new terminals reuse it).
40
- * - 'vault': store secrets encrypted at rest (AES-256-GCM) under a master
41
- * password / DSH_CREDENTIAL_PASSWORD; for headless agents that must run
42
- * password hosts unattended.
43
- */
44
- secretStorage?: 'none' | 'vault'
45
38
  }
46
39
 
47
40
  export const SshConfig: z<SshConfig> = z.object({
48
41
  announceToAgent: z.boolean().default(true),
49
42
  enabled: z.boolean().default(true),
50
- secretStorage: z.union([z.const('none'), z.const('vault')]).default('none'),
51
43
  })
52
44
 
53
45
  /** Schema default, re-read for hand-built test contexts. */
@@ -57,7 +49,7 @@ const DEFAULT_ANNOUNCE = true
57
49
  const SECTION_ORDER = 150
58
50
 
59
51
  /** Model-facing announcement: SSH operations capability, capabilities, limits. */
60
- export const SSH_GUIDANCE = '本机已安装 dsh-hardssh 插件(内建 DSH 远程 SSH 运维):侧边栏「SSH」入口;SSH 运维与 SSH 工作区在 dsh-hardssh 中统一维护。能力:主机配置存 ~/.dsh/dsh-ssh.json(可从 ~/.ssh/config 导入);持久连接池复用长连接(空闲 30 分钟自动断开);ssh_list 列出主机、ssh_exec 执行远程命令、ssh_upload/ssh_download 传输文件、ssh_tunnel 本地端口转发(访问远程数据库/内网服务)、ssh_cluster 集群并发执行;支持密钥/密码认证、passphrase 密钥与 ProxyJump 跳板机;Web 终端走 WebSocket。限制:主机操作由用户在 GUI 中配置后 agent 方可使用;密码以明文存在用户主目录私有文件(权限 0600);命令输出原样返回、可能含敏感信息;连接建立失败会自动重连,但命令或文件操作一旦提交就不会自动重试——结果不明确时应先核实远端状态,再决定是否人工重试;传输/执行消耗真实远程资源,先确认再操作。用户提到「SSH / 远程服务器 / 服务器操作 / 跳板机 / 隧道 / 部署 / 上传下载」时即指本插件,请据此协作。'
52
+ export const SSH_GUIDANCE = '本机已安装 dsh-hardssh 插件(内建 DSH 远程 SSH 运维):侧边栏「SSH」入口;SSH 运维与 SSH 工作区在 dsh-hardssh 中统一维护。能力:主机配置存 ~/.dsh/dsh-ssh.json(可从 ~/.ssh/config 导入);持久连接池复用长连接(空闲 30 分钟自动断开);ssh_list 列出主机、ssh_exec 执行远程命令、ssh_upload/ssh_download 传输文件、ssh_tunnel 本地端口转发(访问远程数据库/内网服务)、ssh_cluster 集群并发执行;支持密钥/密码认证、passphrase 密钥与 ProxyJump 跳板机;Web 终端走 WebSocket。限制:主机操作由用户在 GUI 中配置后 agent 方可使用;凭据默认不落盘(secretStorage=none 时仅存于本进程内存,按连接存活期复用、连接池回收即失效;secretStorage=vault 时以 AES-256-GCM 加密存于本机 vault 文件,`DSH_CREDENTIAL_PASSWORD` 自动解锁默认关闭,需 vaultAutoUnlock: env 显式开启);命令输出与错误经过凭据脱敏(已揭示/会话内的密码会被替换),但仍可能含其它敏感信息;连接建立失败会自动重连,但命令或文件操作一旦提交就不会自动重试——结果不明确时应先核实远端状态,再决定是否人工重试;传输/执行消耗真实远程资源,先确认再操作。用户提到「SSH / 远程服务器 / 服务器操作 / 跳板机 / 隧道 / 部署 / 上传下载」时即指本插件,请据此协作。'
61
53
 
62
54
  /** Shared instances the SSH surfaces run on. */
63
55
  export interface SshCapabilityDeps {
@@ -67,8 +59,10 @@ export interface SshCapabilityDeps {
67
59
  knownHosts?: import('./known-hosts.ts').KnownHostsStore
68
60
  /** Credential vault; absent → vault endpoints disabled. */
69
61
  vault?: import('./vault.ts').Vault
70
- /** SSH-bound workspace ledger (host-delete reference guard). */
71
- ledger?: import('../ledger.ts').SshWorkspaceLedger
62
+ /** SSH-bound workspace record source (host-delete reference guard). The
63
+ * legacy ledger in legacy mode, the generic WorkspaceCore-backed projection
64
+ * store in the default generic mode. */
65
+ ledger?: import('../backend.ts').WorkspaceStoreView
72
66
  }
73
67
 
74
68
  /**
@@ -92,7 +86,7 @@ export function mountSshCapability(ctx: Context, deps: SshCapabilityDeps): void
92
86
  // dynamic webServer inject (headless profiles lack the service — mirroring
93
87
  // the workspace routes in src/index.ts). The enabled switch is honored at
94
88
  // registration time; toggling it later takes effect on reload.
95
- const { routes, upgrade } = makeRoutes({
89
+ const { routes, upgrade, dispose: disposeRoutes } = makeRoutes({
96
90
  store,
97
91
  engine,
98
92
  knownHosts: deps.knownHosts,
@@ -103,12 +97,19 @@ export function mountSshCapability(ctx: Context, deps: SshCapabilityDeps): void
103
97
  onHostInvalidated: (alias) => invalidateRemoteEnvironment(engine, alias),
104
98
  })
105
99
  ctx.inject(['webServer'], (scoped) => {
106
- if (!resolve().enabled) return () => { /* disabled: nothing registered */ }
100
+ // Tear down the terminal server (live PTYs, SSH shells, WebSocketServer)
101
+ // on unload/HMR even when the switch is off or nothing was registered.
102
+ const dispose = (): void => { void disposeRoutes() }
103
+ if (!resolve().enabled) return dispose
107
104
  const disposers = routes.map(route => scoped.webServer.register(route))
108
105
  const upgradeDisposer = scoped.webServer.registerUpgrade(upgrade)
109
106
  return () => {
110
- for (const dispose of disposers) dispose()
107
+ for (const routeDisposer of disposers) routeDisposer()
108
+ // Unregistering the upgrade route does not release its terminals: close
109
+ // every live PTY/SSH shell and the instance's WebSocketServer so unload
110
+ // and HMR leave nothing running (A-06). Idempotent.
111
111
  upgradeDisposer()
112
+ dispose()
112
113
  }
113
114
  })
114
115
 
@@ -85,6 +85,16 @@ export interface ClusterResult {
85
85
  stderr?: string
86
86
  durationMs?: number
87
87
  error?: string
88
+ /** Stable machine code for a per-host failure (B-14) — lets callers retry
89
+ * on an auth/TOFU gate instead of parsing the human message. */
90
+ code?: 'NEEDS_PASSWORD' | 'HOST_KEY_UNKNOWN' | 'HOST_KEY_MISMATCH' | 'ALIAS_NOT_FOUND' | 'ABORTED' | 'TIMEOUT' | 'ERROR'
91
+ /** Which secret the host needs when `code` is NEEDS_PASSWORD. */
92
+ secret?: 'password' | 'passphrase'
93
+ /** Server-key fingerprint for HOST_KEY_UNKNOWN / HOST_KEY_MISMATCH. */
94
+ hostKeyFingerprint?: string
95
+ /** Expected/actual fingerprints for HOST_KEY_MISMATCH. */
96
+ expected?: string
97
+ actual?: string
88
98
  }
89
99
 
90
100
  /** SFTP transfer progress frame (upload stream). */
@@ -224,4 +234,15 @@ export type KnownHostAction = 'trust' | 'forget'
224
234
  /** NDJSON transfer stream line shapes (upload). */
225
235
  export type TransferStreamLine =
226
236
  | { type: 'progress'; progress: TransferProgress }
227
- | { type: 'result'; ok: boolean; transferredBytes?: number; error?: string }
237
+ /** The final remote rename is about to start; loss after this point means
238
+ * the target's result is unknown until inspected. */
239
+ | { type: 'commit' }
240
+ | {
241
+ type: 'result'
242
+ ok: boolean
243
+ transferredBytes?: number
244
+ error?: string
245
+ code?: string
246
+ secret?: 'password' | 'passphrase'
247
+ hostKeyFingerprint?: string
248
+ }