@tiphareth/dsh-hardssh 0.1.2 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (174) hide show
  1. package/LICENSE +30 -0
  2. package/README.md +49 -25
  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 +307 -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 +18 -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 +58 -25
  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 +137 -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
package/src/index.ts CHANGED
@@ -1,244 +1,535 @@
1
- /**
2
- * dsh-hardssh — host half. Owns the local⇄remote mode store, the
3
- * /api/dsh-hardssh route family (loopback-only), the remote_* agent
4
- * tools, a model-facing announcement section, the shared workspace core
5
- * (`ctx.hardsshCore`, including the shared seam state the fs/subprocess
6
- * switch rows consume), and since the legacy dsh-ssh package was merged
7
- * in the SSH operations capability (host manager, ssh_* tools, /api/dsh-ssh,
8
- * web terminal). In SSH mode the model's ordinary read/write/edit/bash tools
9
- * run transparently on the remote host through those switch rows. File
10
- * operations and SSH operations ride ONE shared SshEngine/HostStore over
11
- * ~/.dsh/dsh-ssh.json (a single connection pool; SSH ops and workspaces
12
- * invalidate together on config change). The browser half (./client) renders
13
- * the header buttons, the SSH config dialog, the left workspace panel, and
14
- * the SSH host-manager surfaces.
15
- *
16
- * The announcement section is rendered PER SESSION from the ledger facts
17
- * (which workspace this session's cwd binds to), never from path-string
18
- * heuristics; and a global tool guard blocks glob/grep/pwsh calls in
19
- * SSH-bound sessions with a clear error instead of letting them fail with
20
- * "No such file or directory" on the remote host.
21
- */
22
- import type { Context } from '@deepseek-ai/cordis'
23
- import type {} from '@deepseek-ai/dsh-host-webserver'
24
- import type {} from '@deepseek-ai/dsh-system-prompt'
25
- import type {} from '@deepseek-ai/dsh-tools'
26
- import z from '@deepseek-ai/schemastery'
27
- import type { HardsshCore } from './core.ts'
28
- import { SshWorkspaceLedger } from './ledger.ts'
29
- import type { SshWorkspaceRecord } from './protocol.ts'
30
- import { LedgerWorkspaceFileService } from './backend.ts'
31
- import { RemoteWorkspaceRunner } from './remote-runner.ts'
32
- import { makeRoutes } from './routes.ts'
33
- import { makeWorkspaceTools } from './tools.ts'
34
- import { WorkspaceSeamState } from './seam-state.ts'
35
- import { SshEngine } from './ssh/engine.ts'
36
- import { mountSshCapability } from './ssh/plugin.ts'
37
- import { HostStore } from './ssh/store.ts'
38
- import { SecureHostStore } from './ssh/store.ts'
39
- import { KnownHostsStore } from './ssh/known-hosts.ts'
40
- import { Vault } from './ssh/vault.ts'
41
- import { RemoteSearchService } from './remote-search.ts'
42
- import { mountWorkspaceCore } from './runtime/workspace-core.ts'
43
-
44
- /** Stable cordis plugin name. */
45
- export const name = 'hardssh'
46
-
47
- /**
48
- * Services required before the workspace surfaces can mount. `webServer` is
49
- * deliberately NOT here: headless profiles lack it, and a hard inject would
50
- * block the whole load tree routes register through the dynamic
51
- * ctx.inject(['webServer'], …) below (DSH 插件规范 §4.2).
52
- */
53
- export const inject = ['tools', 'systemPrompt']
54
-
55
- /** Plugin config (schemastery; optional fields use .default, never .optional). */
56
- export interface Config {
57
- /** Master switch (default on). Disabling requires reverting the profile seam patch. */
58
- enabled: boolean
59
- /** Whether the model-facing announcement section is mounted. */
60
- announceToAgent: boolean
61
- /**
62
- * Secret storage mode: 'none' (default, VSCode Remote-SSH style: passwords
63
- * never persisted, prompted once per session) or 'vault' (encrypted at
64
- * rest, for unattended agents). Mirrors the dsh-ssh settings namespace.
65
- */
66
- secretStorage: 'none' | 'vault'
67
- }
68
-
69
- export const Config: z<Config> = z.object({
70
- enabled: z.boolean().default(true),
71
- announceToAgent: z.boolean().default(true),
72
- secretStorage: z.union([z.const('none'), z.const('vault')]).default('none'),
73
- })
74
-
75
- /** Order of the announcement section (right after the dsh-ssh section at 150). */
76
- const SECTION_ORDER = 160
77
-
78
- /** Tools that execute a WINDOWS-format binary locally and therefore cannot
79
- * work when the spawn routes to a remote host (glob/grep spawn the packaged
80
- * ripgrep, pwsh spawns powershell). Blocked with a clear error in SSH-bound
81
- * sessions instead of failing with "No such file or directory". */
82
- const REMOTE_INCOMPATIBLE_TOOLS = new Set(['glob', 'grep', 'pwsh'])
83
-
84
- /** Minimal shape of the assembly context the guidance section reads. The DSH
85
- * type (`AssembleContext`) only declares scope/signal, but the agent loop
86
- * passes `{ agent, scope, signal }` (see dsh-agent assembleContextFor). */
87
- interface GuidanceAssemblyContext {
88
- agent?: { session?: { header?: { cwd?: string } } }
89
- }
90
-
91
- /** Render the workspace guidance from the LEDGER facts for this session:
92
- * a cwd the ledger binds renders the remote branch (with the real alias /
93
- * remote root), everything else the local branch. No path-string matching. */
94
- function renderWorkspaceGuidance(ledger: SshWorkspaceLedger, context: unknown): string {
95
- const agent = (context as GuidanceAssemblyContext).agent
96
- const cwd = agent?.session?.header?.cwd
97
- const record = cwd === undefined || cwd === '' ? undefined : ledger.findByAnchorSync(cwd)
98
- if (record === undefined) return localGuidance()
99
- return remoteGuidance(record)
100
- }
101
-
102
- /** Local-session branch of the announcement. */
103
- function localGuidance(): string {
104
- return '本机已安装 dsh-hardssh 插件(SSH 工作区)。当前会话是本机工作区:正常使用全部工具(pwsh / glob / grep / read / write / edit),文件操作在本机。remote_ls / remote_search / remote_status 与 ssh_exec / ssh_upload / ssh_download / ssh_tunnel / ssh_cluster 用于一次性远程运维(消耗真实远程资源,先确认再执行)。用户提到「SSH 工作区 / 远程工作区 / 远程文件 / 远程项目 / 远程服务器上改代码」时即指本插件。'
105
- }
106
-
107
- /** SSH-bound-session branch of the announcement. */
108
- function remoteGuidance(record: SshWorkspaceRecord): string {
109
- return `本机已安装 dsh-hardssh 插件(SSH 工作区)。当前会话绑定到远程工作区「${record.title}」(${record.alias} @ ${record.remoteRoot}):
110
- - read / write / edit 自动路由到远程(SFTP);路径用远程绝对路径(如 ${record.remoteRoot}/src/main.ts),相对路径以远程根目录为基准。
111
- - glob / grep / pwsh 在本工作区不可用(已被自动拦截并返回明确错误),请改用 remote_search(mode="glob" 按文件名、mode="grep" 按固定字符串搜内容)、remote_ls、remote_status、ssh_exec。
112
- - 远程操作消耗真实远程资源,先确认再执行;remote_search 有限深与条数上限。`
113
- }
114
-
115
- /**
116
- * Mount the mode store, routes, tools, announcement, guard, and the shared core.
117
- * @param ctx - host plugin context carrying tools/systemPrompt (webServer optional).
118
- * @param config - resolved plugin config (schema defaults applied by the loader).
119
- */
120
- export function apply(ctx: Context, config?: Config): void {
121
- const resolved = {
122
- enabled: config?.enabled ?? true,
123
- announceToAgent: config?.announceToAgent ?? true,
124
- // Secret storage: 'none' (VSCode Remote-SSH style; default) or 'vault'.
125
- secretStorage: config?.secretStorage ?? 'none' as const,
126
- }
127
-
128
- // Host-key TOFU: connections are refused until the operator confirms the
129
- // server fingerprint (see known-hosts.ts). Default-enabled for new installs;
130
- // the engine falls back to pre-security behavior when no store is passed.
131
- const knownHosts = new KnownHostsStore()
132
- // Credential handling per secretStorage:
133
- // - 'vault': encrypt at rest (AES-256-GCM + scrypt) via SecureHostStore;
134
- // for unattended agents running password hosts.
135
- // - 'none' (default): passwords are NEVER persisted they are prompted
136
- // once per session (VSCode Remote-SSH style) and held in the engine's
137
- // in-memory session table for the connection pool lifetime.
138
- const vault = resolved.secretStorage === 'vault' ? new Vault() : undefined
139
- const secureHosts = new SecureHostStore(vault, undefined, undefined, resolved.secretStorage)
140
- const engine = new SshEngine(secureHosts, undefined, {
141
- knownHosts,
142
- resolveSecrets: (entry) => secureHosts.resolveAuth(entry),
143
- })
144
- const search = new RemoteSearchService(engine)
145
- const ledger = new SshWorkspaceLedger()
146
- const seams = new WorkspaceSeamState(ledger)
147
- ctx.effect(() => () => {
148
- engine.dispose()
149
- vault?.dispose()
150
- }, 'dsh-hardssh: engine')
151
-
152
- // The shared core is ALWAYS provided: the seam-switch rows (./fs,
153
- // ./subprocess) inject it, and they replace the deployment's fs/subprocess
154
- // providers starving them would break the model's file tools.
155
- const runner = new RemoteWorkspaceRunner(engine, ledger)
156
- const core: HardsshCore = { hosts: secureHosts, engine, ledger, seams, resolveRemote: (root) => runner.resolveRemote(root) }
157
- ctx.provide('hardsshCore', core)
158
- // Workspace clients (e.g. dsh-workbench-tiphareth) may read the SSH
159
- // workspace core under the legacy `sshWorkspaceCore` service name —
160
- // provide the same instance under both names so SSH-mode fs/git
161
- // delegation actually engages.
162
- ctx.provide('sshWorkspaceCore', core)
163
-
164
- // Generic workspace base: canonical `workspaceCore` service carrying the
165
- // provider-agnostic ledger/registry/router + builtin providers (local/ssh).
166
- // Third-party plugins consume this; the legacy aliases above keep existing
167
- // consumers working unchanged.
168
- mountWorkspaceCore(ctx, { engine, hosts: secureHosts })
169
-
170
- // Keep the shared seam state in sync with the ledger (synchronous re-apply
171
- // on every commit + initial load). Registered before the enabled switch so
172
- // routing stays consistent even when the agent-facing surfaces are off.
173
- ctx.effect(() => seams.attach(), 'dsh-hardssh: seam state')
174
-
175
- // SSH operations capability (host manager, ssh_* tools, /api/dsh-ssh,
176
- // terminal, settings, prompt) mounted against the SAME engine/store and
177
- // kept independent of the workspace `enabled` switch below (its own
178
- // `dsh-ssh` settings namespace toggles it).
179
- mountSshCapability(ctx, { store: secureHosts, engine, knownHosts, vault, ledger })
180
-
181
- if (!resolved.enabled) return
182
-
183
- // Host workspace registration hooks (make the anchor a real sidebar
184
- // workspace). workspaceRegistry is optional headless profiles lack it.
185
- const registerHostWorkspace = async (anchorPath: string, title: string): Promise<void> => {
186
- const registry = ctx.get('workspaceRegistry') as { resolveByPath?: (path: string) => Promise<{ id: string } | undefined>; create?: (path: string, title?: string) => Promise<{ id: string }> } | undefined
187
- if (registry?.create === undefined) return
188
- const existing = registry.resolveByPath !== undefined ? await registry.resolveByPath(anchorPath) : undefined
189
- if (existing !== undefined) return
190
- await registry.create(anchorPath, title)
191
- }
192
- const unregisterHostWorkspace = async (anchorPath: string): Promise<void> => {
193
- const registry = ctx.get('workspaceRegistry') as { resolveByPath?: (path: string) => Promise<{ id: string } | undefined>; delete?: (id: string) => Promise<boolean> } | undefined
194
- if (registry?.resolveByPath === undefined || registry.delete === undefined) return
195
- const existing = await registry.resolveByPath(anchorPath)
196
- if (existing !== undefined) await registry.delete(existing.id)
197
- }
198
-
199
- const routes = makeRoutes({
200
- hosts: secureHosts,
201
- engine,
202
- ledger,
203
- files: new LedgerWorkspaceFileService(ledger, engine, search),
204
- registerHostWorkspace,
205
- unregisterHostWorkspace,
206
- })
207
- // webServer is optional (headless profiles lack it): dynamic inject keeps
208
- // this plugin loadable everywhere, mounting routes only when it appears.
209
- // The inject callback receives a scoped Context with the service available.
210
- ctx.inject(['webServer'], (scoped) => {
211
- const disposers = routes.map(route => scoped.webServer.register(route))
212
- return () => {
213
- for (const dispose of disposers) dispose()
214
- }
215
- })
216
-
217
- const tools = makeWorkspaceTools({ engine, ledger, search })
218
- ctx.effect(() => {
219
- const disposers = tools.map(tool => ctx.tools.register(tool))
220
- return () => {
221
- for (const dispose of disposers) dispose()
222
- }
223
- }, 'dsh-hardssh: tools')
224
-
225
- // Global guard: deny glob/grep/pwsh in SSH-bound sessions with a clear
226
- // reason (their executables are Windows-format and cannot run on the
227
- // remote host); every other session is untouched.
228
- ctx.effect(() => ctx.tools.guard((execution) => {
229
- if (execution.agent === undefined || !REMOTE_INCOMPATIBLE_TOOLS.has(execution.name)) return undefined
230
- const cwd = execution.agent.session?.header?.cwd
231
- if (cwd === undefined || cwd === '') return undefined
232
- const record = ledger.findByAnchorSync(cwd)
233
- if (record === undefined) return undefined
234
- return `当前会话绑定 SSH 远程工作区「${record.title}」(${record.alias} @ ${record.remoteRoot}),${execution.name} 的可执行文件是 Windows 格式、在远程主机上不存在,已被拦截。请改用 remote_search / remote_ls / ssh_exec 操作远程文件与命令,或使用 read / write / edit(自动路由到远程)。`
235
- }), 'dsh-hardssh: remote-incompatible tool guard')
236
-
237
- if (resolved.announceToAgent) {
238
- ctx.systemPrompt.section({
239
- name: 'plugin:dsh-hardssh',
240
- order: SECTION_ORDER,
241
- text: (context) => renderWorkspaceGuidance(ledger, context),
242
- })
243
- }
244
- }
1
+ /**
2
+ * dsh-hardssh — host half. Owns the local⇄remote mode store, the
3
+ * /api/dsh-hardssh route family (loopback-only), the remote_* agent
4
+ * tools, a model-facing announcement section, the generic workspace core,
5
+ * and since the legacy dsh-ssh package was merged
6
+ * in the SSH operations capability (host manager, ssh_* tools, /api/dsh-ssh,
7
+ * web terminal). In SSH mode the model's ordinary read/write/edit/bash tools
8
+ * run transparently on the remote host through those switch rows. File
9
+ * operations and SSH operations ride ONE shared SshEngine/HostStore over
10
+ * ~/.dsh/dsh-ssh.json (a single connection pool; SSH ops and workspaces
11
+ * invalidate together on config change). The browser half (./client) renders
12
+ * the header buttons, the SSH config dialog, the left workspace panel, and
13
+ * the SSH host-manager surfaces.
14
+ *
15
+ * The announcement section is rendered PER SESSION from the workspace facts
16
+ * (which workspace this session's cwd binds to), never from path-string
17
+ * heuristics. There is no tool guard layer: routing lives in the fs/subprocess
18
+ * seams, and the subprocess facade refuses the one combination that cannot be
19
+ * routed correctly a client-side search helper (glob/grep's bundled ripgrep)
20
+ * inside a remote-bound session, which would otherwise return confidently wrong
21
+ * "no matches" from the local anchor.
22
+ *
23
+ * The generic WorkspaceCore is the only production workspace runtime. On the
24
+ * first upgraded boot, the frozen legacy SSH ledger is imported once into
25
+ * `~/.dsh/workspaces/index.v1.json`; the atomic migration report is the durable
26
+ * cutover marker. Emergency generic-to-legacy conversion is an offline export,
27
+ * never an alternate in-process routing mode.
28
+ */
29
+ import type { Context } from '@deepseek-ai/cordis'
30
+ import type {} from '@deepseek-ai/dsh-host-webserver'
31
+ import type {} from '@deepseek-ai/dsh-settings'
32
+ import type {} from '@deepseek-ai/dsh-system-prompt'
33
+ import type {} from '@deepseek-ai/dsh-tools'
34
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs'
35
+ import { join } from 'node:path'
36
+ import z from '@deepseek-ai/schemastery'
37
+ import type { HardsshCore } from './core.ts'
38
+ import { ledgerPath, anchorRoot } from './ledger.ts'
39
+ import { GenericWorkspaceStore, type WorkspaceStoreView } from './backend.ts'
40
+ import { makeRoutes, reconcileHostWorkspaces, type HostWorkspaceReconcileDeps, type HostWorkspaceReconcileReport } from './routes.ts'
41
+ import { makeWorkspaceTools } from './tools.ts'
42
+ import { capabilityToolOpsResolver } from './workspace-tool-ops.ts'
43
+ import { SshEngine } from './ssh/engine.ts'
44
+ import { mountSshCapability, SSH_SETTINGS_NAMESPACE } from './ssh/plugin.ts'
45
+ import { HostStore } from './ssh/store.ts'
46
+ import { SecureHostStore } from './ssh/store.ts'
47
+ import { KnownHostsStore } from './ssh/known-hosts.ts'
48
+ import { Vault } from './ssh/vault.ts'
49
+ import { mountWorkspaceCore, genericLedgerPath, type WorkspaceCore } from './runtime/workspace-core.ts'
50
+ import { inspectGenericLedger, migrateLegacySshLedger, recoverGenericLedger, type WorkspaceMigrationReport } from './runtime/workspace-migration.ts'
51
+ import type { SshWorkspaceRecord } from './protocol.ts'
52
+
53
+ /** Stable cordis plugin name. */
54
+ export const name = 'hardssh'
55
+
56
+ /**
57
+ * Services required before the workspace surfaces can mount. `webServer` is
58
+ * deliberately NOT here: headless profiles lack it, and a hard inject would
59
+ * block the whole load tree routes register through the dynamic
60
+ * ctx.inject(['webServer'], …) below (DSH 插件规范 §4.2).
61
+ */
62
+ export const inject = ['tools', 'systemPrompt']
63
+
64
+ /** Plugin config (schemastery; optional fields use .default, never .optional). */
65
+ export interface Config {
66
+ /**
67
+ * Workspace-surface switch (default on). It gates ONLY the SSH-workspace
68
+ * surfaces this row mounts after the check below: the /api/dsh-hardssh
69
+ * workspace CRUD routes, the remote_* workspace agent tools, and the
70
+ * workspace announcement section. It does NOT gate the SSH operations
71
+ * capability (host manager, ssh_* tools, /api/dsh-ssh, web terminal — its
72
+ * own `dsh-ssh` settings namespace `enabled`), the shared
73
+ * engine/host-store/vault, the fs/subprocess routing rows, or the seam
74
+ * replacement itself: those mount before this switch and keep running.
75
+ */
76
+ enabled: boolean
77
+ /** Whether the model-facing announcement section is mounted. */
78
+ announceToAgent: boolean
79
+ /**
80
+ * Secret storage mode: 'none' (default, VSCode Remote-SSH style: passwords
81
+ * never persisted, prompted once per session) or 'vault' (encrypted at
82
+ * rest, for unattended agents).
83
+ *
84
+ * This is the SINGLE SOURCE for the mode: the Vault and SecureHostStore are
85
+ * constructed once from it when the plugin loads, so changing it requires a
86
+ * plugin reload. The `dsh-ssh` settings namespace also exposes
87
+ * `secretStorage` for the settings UI, but that value cannot switch storage
88
+ * mode at runtime (see reportSecretStorageDrift below).
89
+ */
90
+ secretStorage: 'none' | 'vault'
91
+ /**
92
+ * Whether the credential vault may auto-unlock from the
93
+ * `DSH_CREDENTIAL_PASSWORD` environment variable at plugin load.
94
+ *
95
+ * `'off'` (default): the vault stays locked until a password is entered, so a
96
+ * leaked ciphertext is not enough to recover credentials. `'env'` is the
97
+ * explicit opt-in for unattended/headless agents.
98
+ */
99
+ vaultAutoUnlock: 'off' | 'env'
100
+ }
101
+
102
+ export const Config: z<Config> = z.object({
103
+ enabled: z.boolean().default(true),
104
+ announceToAgent: z.boolean().default(true),
105
+ secretStorage: z.union([z.const('none'), z.const('vault')]).default('none'),
106
+ vaultAutoUnlock: z.union([z.const('off'), z.const('env')]).default('off'),
107
+ })
108
+
109
+ /**
110
+ * The ONE effective secretStorage mode: the plugin config value, defaulting to
111
+ * 'none'. The Vault and SecureHostStore are constructed from this value once
112
+ * (plugin load), which is what makes it authoritative.
113
+ */
114
+ export function resolveSecretStorageMode(config?: { secretStorage?: unknown }): 'none' | 'vault' {
115
+ return config?.secretStorage === 'vault' ? 'vault' : 'none'
116
+ }
117
+
118
+ /** Read the `secretStorage` field out of a resolved settings section. */
119
+ function secretStorageOf(section: unknown): 'none' | 'vault' | undefined {
120
+ if (typeof section !== 'object' || section === null) return undefined
121
+ const value = (section as { secretStorage?: unknown }).secretStorage
122
+ return value === 'vault' ? 'vault' : value === 'none' ? 'none' : undefined
123
+ }
124
+
125
+ /**
126
+ * Report a secretStorage disagreement between the RUNNING mode (fixed when the
127
+ * Vault/SecureHostStore were constructed at plugin load) and the value the
128
+ * `dsh-ssh` settings namespace currently resolves to. The mode cannot change
129
+ * at runtime, so staying silent would let the settings UI claim a storage mode
130
+ * this process is not using.
131
+ * @param running - the construction-time mode actually in effect.
132
+ * @param section - the resolved `dsh-ssh` settings section (any shape).
133
+ * @returns the warning text, or undefined when both agree / the value is unusable.
134
+ */
135
+ export function secretStorageDriftMessage(running: 'none' | 'vault', section: unknown): string | undefined {
136
+ const requested = secretStorageOf(section)
137
+ if (requested === undefined || requested === running) return undefined
138
+ return `[dsh-hardssh] secretStorage: the dsh-ssh settings namespace resolves to '${requested}' but this process is running '${running}'. The mode is fixed by the plugin config (secretStorage) when the plugin loads — the vault and host store are constructed once — so the namespace value is ignored; set secretStorage: '${requested}' in the plugin config and reload the plugin (restart dsh web) to switch.`
139
+ }
140
+
141
+ /**
142
+ * Report secretStorage drift once at mount and on every committed settings
143
+ * change (deduplicated, so a repeated change does not spam the log). The
144
+ * settings service is a sibling context and cordis events travel UP from the
145
+ * emitting ctx, so the listener sits on the root context; `ctx.effect` keeps
146
+ * the subscription tied to this plugin's lifetime.
147
+ * @param ctx - the plugin context (provides `settings` when present).
148
+ * @param running - the construction-time mode actually in effect.
149
+ */
150
+ export function watchSecretStorageDrift(ctx: Context, running: 'none' | 'vault'): void {
151
+ let last: string | undefined
152
+ const report = (section: unknown): void => {
153
+ const message = secretStorageDriftMessage(running, section)
154
+ if (message === undefined || message === last) return
155
+ last = message
156
+ console.warn(message)
157
+ }
158
+ const settings = ctx.get('settings') as { get?: (ns: string) => unknown } | undefined
159
+ report(settings?.get?.(SSH_SETTINGS_NAMESPACE))
160
+ ctx.effect(() => ctx.root.on('settings/updated', (ns, next) => {
161
+ if (ns === SSH_SETTINGS_NAMESPACE) report(next)
162
+ }), 'dsh-hardssh: secretStorage drift watch')
163
+ }
164
+
165
+ /** Isolated-path options for one generic startup (tests inject temp dirs). */
166
+ export interface GenericBootOptions {
167
+ /** The frozen legacy SSH ledger file (~/.dsh/dsh-hardssh-workspaces.json). */
168
+ legacyPath: string
169
+ /** The generic ledger target (~/.dsh/workspaces/index.v1.json). */
170
+ genericPath: string
171
+ /** Migration report path. The report is also the durable cutover marker. */
172
+ reportPath?: string
173
+ }
174
+
175
+ /**
176
+ * Run the Phase-7 cutover sequence against one generic core. A structurally
177
+ * valid atomic migration report is the durable cutover marker: once it exists,
178
+ * the generic ledger is permanently authoritative and startup never compares
179
+ * it with (or even reads) the frozen legacy source again. Without a marker the
180
+ * one-time migration runs and atomically publishes that report only after the
181
+ * generic snapshot has committed and verified.
182
+ *
183
+ * A valid marker is also a proof of existence: it records that N workspaces
184
+ * were committed, so a MISSING or UNREADABLE ledger underneath it is data loss,
185
+ * not a fresh deployment. `WorkspaceLedger` treats ENOENT as an empty array,
186
+ * which would silently drop every workspace forever (the marker suppresses the
187
+ * one-time import); this gate therefore recovers the ledger — `.last-good`,
188
+ * then the newest `.backup-*`, then a legacy re-import — and, failing that,
189
+ * refuses to start so the surfaces fail closed instead of showing nothing.
190
+ *
191
+ * The recovery is verified against the marker's own id list, so a source that
192
+ * would restore FEWER workspaces than were recorded is rejected instead of
193
+ * accepted as success. A ledger that exists but is corrupt is treated like a
194
+ * missing one; a ledger that exists and is valid is authoritative even when it
195
+ * is empty (deleting every workspace is a legitimate, recorded intent).
196
+ *
197
+ * On any failure the promise rejects and the core stays NOT ready, so workspace
198
+ * consumers fail closed instead of silently operating local files.
199
+ */
200
+ export async function bootstrapGenericWorkspaceCore(core: WorkspaceCore, options: GenericBootOptions): Promise<void> {
201
+ const reportPath = options.reportPath ?? join(options.genericPath, '..', 'migration-report.json')
202
+ const marker = readGenericCutoverMarker(reportPath)
203
+ if (marker === undefined) {
204
+ await migrateLegacySshLedger({
205
+ mode: 'generic',
206
+ legacyPath: options.legacyPath,
207
+ genericPath: options.genericPath,
208
+ reportPath,
209
+ })
210
+ } else if (marker.recordCount > 0) {
211
+ const state = await inspectGenericLedger(options.genericPath)
212
+ if (state !== 'readable') {
213
+ const recovery = await recoverGenericLedger({
214
+ genericPath: options.genericPath,
215
+ legacyPath: options.legacyPath,
216
+ expectedIds: marker.ids,
217
+ })
218
+ if (!recovery.recovered) {
219
+ throw new Error(
220
+ `generic workspace ledger '${options.genericPath}' is ${state} although the cutover marker '${reportPath}' reports ${marker.recordCount} workspace(s) `
221
+ + `(recovery failed: ${recovery.reason ?? 'unknown reason'}); refusing to start with a reduced workspace set `
222
+ + '— restore the ledger from a backup, or delete the marker to re-run the one-time import',
223
+ )
224
+ }
225
+ // Leave durable, user-inspectable evidence: a console line alone is easy
226
+ // to miss, and this is the only signal that workspaces came back from a
227
+ // recovery source rather than normal startup.
228
+ writeRecoveryReport(options.genericPath, marker, recovery)
229
+ }
230
+ }
231
+ await core.initialize()
232
+ }
233
+
234
+ /** Best-effort sidecar record of one automatic ledger recovery. */
235
+ function writeRecoveryReport(
236
+ genericPath: string,
237
+ marker: WorkspaceMigrationReport,
238
+ recovery: { source?: string; path?: string; recordCount?: number },
239
+ ): void {
240
+ try {
241
+ const target = join(genericPath, '..', 'recovery-report.json')
242
+ writeFileSync(target, `${JSON.stringify({
243
+ schemaVersion: 1,
244
+ recoveredAt: new Date().toISOString(),
245
+ ledger: genericPath,
246
+ source: recovery.source,
247
+ sourcePath: recovery.path,
248
+ restoredRecordCount: recovery.recordCount,
249
+ markerRecordCount: marker.recordCount,
250
+ markerIds: marker.ids,
251
+ }, null, 2)}\n`, 'utf8')
252
+ } catch (error) {
253
+ console.warn(`[dsh-hardssh] workspace ledger was restored but the recovery report could not be written: ${error instanceof Error ? error.message : String(error)}`)
254
+ }
255
+ }
256
+
257
+ /**
258
+ * Validate only the persisted report schema, never current ledger/source
259
+ * content. The marker records that the migration committed and verified; its
260
+ * presence is what makes later generic CRUD (including rename and deleting the
261
+ * last SSH record) authoritative across restarts.
262
+ *
263
+ * The parsed marker is returned (not just a boolean) because its `recordCount`
264
+ * is what distinguishes "the ledger is legitimately empty" from "the ledger
265
+ * file went missing" at boot.
266
+ *
267
+ * @param path - the migration report path.
268
+ * @returns the validated marker, or undefined when absent/malformed.
269
+ */
270
+ function readGenericCutoverMarker(path: string): WorkspaceMigrationReport | undefined {
271
+ if (!existsSync(path)) return undefined
272
+ let value: unknown
273
+ try {
274
+ value = JSON.parse(readFileSync(path, 'utf8')) as unknown
275
+ } catch {
276
+ return undefined
277
+ }
278
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined
279
+ const marker = value as Record<string, unknown>
280
+ if (marker.schemaVersion !== 1 || marker.mode !== 'generic') return undefined
281
+ if (marker.status !== 'migrated' && marker.status !== 'unchanged') return undefined
282
+ if (typeof marker.createdAt !== 'string' || Number.isNaN(Date.parse(marker.createdAt))) return undefined
283
+ if (typeof marker.sourceDigest !== 'string' || !/^[0-9a-f]{64}$/u.test(marker.sourceDigest)) return undefined
284
+ if (typeof marker.targetDigest !== 'string' || !/^[0-9a-f]{64}$/u.test(marker.targetDigest)) return undefined
285
+ if (typeof marker.recordCount !== 'number' || !Number.isSafeInteger(marker.recordCount) || marker.recordCount < 0) return undefined
286
+ if (!Array.isArray(marker.ids) || marker.ids.length !== marker.recordCount || marker.ids.some(id => typeof id !== 'string' || id === '')) return undefined
287
+ if (new Set(marker.ids).size !== marker.ids.length) return undefined
288
+ if (!Array.isArray(marker.differences) || marker.differences.some(difference => typeof difference !== 'string') || marker.differences.length !== 0) return undefined
289
+ if (marker.backupPath !== undefined && typeof marker.backupPath !== 'string') return undefined
290
+ return value as WorkspaceMigrationReport
291
+ }
292
+
293
+ /**
294
+ * Migration report path next to the generic ledger (~/.dsh/workspaces/
295
+ * migration-report.json). The migration module cannot know the deployment
296
+ * directory (only the caller passes it through GenericBootOptions), so the
297
+ * production assembly computes the path here once.
298
+ */
299
+ function genericMigrationReportPath(): string {
300
+ return join(genericLedgerPath(), '..', 'migration-report.json')
301
+ }
302
+
303
+ /** Order of the announcement section (right after the dsh-ssh section at 150). */
304
+ const SECTION_ORDER = 160
305
+
306
+ /** Minimal shape of the assembly context the guidance section reads. The DSH
307
+ * type (`AssembleContext`) only declares scope/signal, but the agent loop
308
+ * passes `{ agent, scope, signal }` (see dsh-agent assembleContextFor). */
309
+ interface GuidanceAssemblyContext {
310
+ agent?: { session?: { header?: { cwd?: string } } }
311
+ }
312
+
313
+ /** Render the workspace guidance from the RECORD SOURCE for this session:
314
+ * a cwd the store binds renders the remote branch (with the real alias /
315
+ * remote root), everything else the local branch. No path-string matching. */
316
+ function renderWorkspaceGuidance(workspaces: WorkspaceStoreView, context: unknown): string {
317
+ const agent = (context as GuidanceAssemblyContext).agent
318
+ const cwd = agent?.session?.header?.cwd
319
+ const record = cwd === undefined || cwd === '' ? undefined : workspaces.findByAnchorSync(cwd)
320
+ if (record === undefined) return localGuidance()
321
+ return remoteGuidance(record)
322
+ }
323
+
324
+ /** Local-session branch of the announcement. Exported for the guidance test. */
325
+ export function localGuidance(): string {
326
+ return '本机已安装 dsh-hardssh 插件(SSH 工作区)。当前会话是本机工作区:正常使用全部工具(pwsh / glob / grep / read / write / edit),文件操作在本机。remote_ls / remote_search / remote_status 与 ssh_exec / ssh_upload / ssh_download / ssh_tunnel / ssh_cluster 用于一次性远程运维(消耗真实远程资源,先确认再执行)。用户提到「SSH 工作区 / 远程工作区 / 远程文件 / 远程项目 / 远程服务器上改代码」时即指本插件。'
327
+ }
328
+
329
+ /**
330
+ * SSH-bound-session branch of the announcement.
331
+ *
332
+ * The tool facts here must match the seams. read/write/edit route through
333
+ * `SwitchFileSystem` (server data remote, client-declared local roots local).
334
+ * glob/grep do NOT: they run the CLIENT's bundled ripgrep through
335
+ * `ctx.subprocess`, which cannot see the remote workspace, so the subprocess
336
+ * facade refuses that combination and points here to the remote tools. `pwsh`
337
+ * is a client-native binary and runs on this machine.
338
+ *
339
+ * Exported for the guidance test.
340
+ */
341
+ export function remoteGuidance(record: SshWorkspaceRecord): string {
342
+ return `本机已安装 dsh-hardssh 插件(SSH 工作区)。当前会话绑定到远程工作区「${record.title}」(${record.alias} @ ${record.remoteRoot}):
343
+ - read / write / edit 自动路由到远程(SFTP);路径用远程绝对路径(如 ${record.remoteRoot}/src/main.ts),相对路径以远程根目录为基准。
344
+ - glob / grep 用的是**本机**打包的 ripgrep,看不到服务器内容;在 SSH 工作区里调用会被明确拒绝(不会静默返回空结果)。请在远端检索时用 remote_search(mode="glob" 按文件名、mode="grep" 按固定字符串搜内容,有限深与条数上限)、remote_ls、remote_status。
345
+ - pwsh / powershell / cmd 是客户端原生二进制,在本机执行;远端为 POSIX 主机时请用 bash 语义命令或 ssh_exec。
346
+ - 远程操作消耗真实远程资源,先确认再执行。`
347
+ }
348
+
349
+ /**
350
+ * A-04 startup half: replay host-workspace registration for every stored
351
+ * record once the workspace store is ready, so a process restart (or a create
352
+ * whose registration failed before the compensating rollback existed) cannot
353
+ * leave a binding whose sidebar entry is missing. `registerHostWorkspace` is
354
+ * create-if-missing, so replay is idempotent.
355
+ *
356
+ * Thin delegation to the shared `reconcileHostWorkspaces` (src/routes.ts) that
357
+ * the loopback-guarded `/reconcile` route also calls, so the two halves cannot
358
+ * drift. This wrapper owns the startup-only rules: never reject (a broken host
359
+ * registry or an unreadable ledger must not break plugin load) and log what
360
+ * happened instead of surfacing it to the loader.
361
+ * @param deps - the record source and the host-registry registration hook.
362
+ * @returns the shared reconcile report (empty when the record list failed).
363
+ */
364
+ export async function reconcileHostWorkspacesOnStartup(deps: HostWorkspaceReconcileDeps): Promise<HostWorkspaceReconcileReport> {
365
+ try {
366
+ const report = await reconcileHostWorkspaces(deps)
367
+ // The shared helper reports an unreadable record source instead of
368
+ // throwing; the startup path must still surface it in the log.
369
+ if (report.listError !== undefined) {
370
+ console.warn(`[dsh-hardssh] host-workspace startup reconciliation could not list workspaces: ${report.listError}`)
371
+ }
372
+ for (const failure of report.failures) {
373
+ console.warn(`[dsh-hardssh] host-workspace startup reconciliation failed for workspace '${failure.id}': ${failure.error}`)
374
+ }
375
+ return report
376
+ } catch (error) {
377
+ console.warn(`[dsh-hardssh] host-workspace startup reconciliation failed: ${error instanceof Error ? error.message : String(error)}`)
378
+ return { registered: 0, failures: [] }
379
+ }
380
+ }
381
+
382
+ /**
383
+ * Mount the mode store, routes, tools, announcement, guard, and the shared core.
384
+ * @param ctx - host plugin context carrying tools/systemPrompt (webServer optional).
385
+ * @param config - resolved plugin config (schema defaults applied by the loader).
386
+ */
387
+ export function apply(ctx: Context, config?: Config): void {
388
+ const resolved = {
389
+ enabled: config?.enabled ?? true,
390
+ announceToAgent: config?.announceToAgent ?? true,
391
+ // Secret storage: 'none' (VSCode Remote-SSH style; default) or 'vault'.
392
+ // Single-sourced: the Vault/SecureHostStore below are built from THIS
393
+ // value, and any disagreement with the dsh-ssh settings namespace is
394
+ // reported through secretStorageDriftMessage (never silently accepted).
395
+ secretStorage: resolveSecretStorageMode(config),
396
+ // Env-based vault auto-unlock is opt-in (see the Config docs): the variable
397
+ // is visible to anything running as this user, so it is not a default.
398
+ vaultAutoUnlock: config?.vaultAutoUnlock === 'env' ? 'env' as const : 'off' as const,
399
+ }
400
+
401
+ // Host-key TOFU: connections are refused until the operator confirms the
402
+ // server fingerprint (see known-hosts.ts). Default-enabled for new installs;
403
+ // the engine falls back to pre-security behavior when no store is passed.
404
+ const knownHosts = new KnownHostsStore()
405
+ // Credential handling per secretStorage:
406
+ // - 'vault': encrypt at rest (AES-256-GCM + scrypt) via SecureHostStore;
407
+ // for unattended agents running password hosts.
408
+ // - 'none' (default): passwords are NEVER persisted — they are prompted
409
+ // once (VSCode Remote-SSH style) and held in the engine's in-memory table
410
+ // for that CONNECTION's lifetime (the pool's retirement drops them).
411
+ const vault = resolved.secretStorage === 'vault'
412
+ ? new Vault(undefined, { allowEnvUnlock: resolved.vaultAutoUnlock === 'env' })
413
+ : undefined
414
+ const secureHosts = new SecureHostStore(vault, undefined, undefined, resolved.secretStorage)
415
+ const engine = new SshEngine(secureHosts, undefined, {
416
+ knownHosts,
417
+ resolveSecrets: (entry) => secureHosts.resolveAuth(entry),
418
+ redactOutput: vault === undefined ? undefined : (text) => vault.redact(text),
419
+ })
420
+ ctx.effect(() => () => {
421
+ engine.dispose()
422
+ vault?.dispose()
423
+ }, 'dsh-hardssh: engine')
424
+
425
+ // Canonical provider-neutral workspace runtime. It is the only in-process
426
+ // ledger/router used by fs, subprocess, routes, tools and host-delete guards.
427
+ const genericCore = mountWorkspaceCore(ctx, { engine, hosts: secureHosts })
428
+ const boot = bootstrapGenericWorkspaceCore(genericCore, {
429
+ legacyPath: ledgerPath(),
430
+ genericPath: genericLedgerPath(),
431
+ // The atomically published report is also the permanent cutover marker.
432
+ reportPath: genericMigrationReportPath(),
433
+ })
434
+ // A failed startup must not become an unhandled rejection: consumers await
435
+ // this same promise and fail closed; there is no in-process legacy fallback.
436
+ void boot.catch((error: unknown) => {
437
+ console.error('[dsh-hardssh] generic workspace runtime failed to initialize — workspace consumers will fail closed (no silent local fallback):', error instanceof Error ? error.message : String(error))
438
+ })
439
+ // Preserve the established SSH anchor layout across the one-time import.
440
+ const workspaces: WorkspaceStoreView = new GenericWorkspaceStore(genericCore, boot, anchorRoot())
441
+
442
+ // SSH-specific integrations retain only the shared host store and engine.
443
+ // dsh-workbench consumes `workspaceCore` directly; no sshWorkspaceCore or
444
+ // synchronous compatibility runner alias remains.
445
+ const core: HardsshCore = { hosts: secureHosts, engine }
446
+ ctx.provide('hardsshCore', core)
447
+
448
+ // SSH operations capability (host manager, ssh_* tools, /api/dsh-ssh,
449
+ // terminal, settings, prompt) — mounted against the SAME engine/store and
450
+ // kept independent of the workspace `enabled` switch below (its own
451
+ // `dsh-ssh` settings namespace toggles it). The host-delete reference guard
452
+ // reads the same record source as the workspace surfaces.
453
+ mountSshCapability(ctx, { store: secureHosts, engine, knownHosts, vault, ledger: workspaces })
454
+
455
+ // C-04: secretStorage is a construction-time decision (vault + store above).
456
+ // The dsh-ssh settings namespace exposes the same key for the settings UI,
457
+ // but its resolve() only reads enabled/announceToAgent, so editing it cannot
458
+ // switch storage mode. Report the disagreement instead of pretending it
459
+ // applied: once at mount, then on every committed settings change. The
460
+ // namespace is registered by mountSshCapability above, so the first read
461
+ // already sees the resolved value.
462
+ watchSecretStorageDrift(ctx, resolved.secretStorage)
463
+
464
+ if (!resolved.enabled) return
465
+
466
+ // Host workspace registration hooks (make the anchor a real sidebar
467
+ // workspace). workspaceRegistry is optional — headless profiles lack it.
468
+ const registerHostWorkspace = async (anchorPath: string, title: string): Promise<void> => {
469
+ const registry = ctx.get('workspaceRegistry') as { resolveByPath?: (path: string) => Promise<{ id: string } | undefined>; create?: (path: string, title?: string) => Promise<{ id: string }> } | undefined
470
+ if (registry?.create === undefined) return
471
+ const existing = registry.resolveByPath !== undefined ? await registry.resolveByPath(anchorPath) : undefined
472
+ if (existing !== undefined) return
473
+ await registry.create(anchorPath, title)
474
+ }
475
+ const unregisterHostWorkspace = async (anchorPath: string): Promise<void> => {
476
+ const registry = ctx.get('workspaceRegistry') as { resolveByPath?: (path: string) => Promise<{ id: string } | undefined>; delete?: (id: string) => Promise<boolean> } | undefined
477
+ if (registry?.resolveByPath === undefined || registry.delete === undefined) return
478
+ const existing = await registry.resolveByPath(anchorPath)
479
+ if (existing !== undefined) {
480
+ const removed = await registry.delete(existing.id)
481
+ if (!removed) throw new Error(`host workspace registry refused to delete '${existing.id}'`)
482
+ }
483
+ }
484
+
485
+ const routes = makeRoutes({
486
+ hosts: secureHosts,
487
+ engine,
488
+ workspaces,
489
+ registerHostWorkspace,
490
+ unregisterHostWorkspace,
491
+ })
492
+ // webServer is optional (headless profiles lack it): dynamic inject keeps
493
+ // this plugin loadable everywhere, mounting routes only when it appears.
494
+ // The inject callback receives a scoped Context with the service available.
495
+ ctx.inject(['webServer'], (scoped) => {
496
+ const disposers = routes.map(route => scoped.webServer.register(route))
497
+ return () => {
498
+ for (const dispose of disposers) dispose()
499
+ }
500
+ })
501
+
502
+ // A-04 startup half: a restart does not replay sidebar registration, and a
503
+ // create whose registration failed before compensation existed leaves a
504
+ // binding with no host workspace. Reconcile once, after the record source is
505
+ // wired (the generic store gates on its boot). The helper never rejects; the
506
+ // catch is belt-and-braces so a future change cannot break plugin load.
507
+ void reconcileHostWorkspacesOnStartup({ workspaces, registerHostWorkspace }).catch((error: unknown) => {
508
+ console.warn('[dsh-hardssh] host-workspace startup reconciliation failed:', error instanceof Error ? error.message : String(error))
509
+ })
510
+
511
+ // Bound-workspace ops always open the generic logical connection and use its
512
+ // workspace.fs/workspace.search capabilities.
513
+ const toolOps = capabilityToolOpsResolver(workspaces, genericCore)
514
+ const tools = makeWorkspaceTools({ workspaces, ops: toolOps })
515
+ ctx.effect(() => {
516
+ const disposers = tools.map(tool => ctx.tools.register(tool))
517
+ return () => {
518
+ for (const dispose of disposers) dispose()
519
+ }
520
+ }, 'dsh-hardssh: tools')
521
+
522
+ // Tool routing for SSH-bound sessions is handled by the fs/subprocess
523
+ // seams, not by tool guards: glob/grep operate through the routed fs
524
+ // (server data remote, declared client roots local — skills readable),
525
+ // and client-native binaries (pwsh.exe etc.) spawn locally via the
526
+ // subprocess switch. See switch-fs.ts / switch-subprocess.ts.
527
+
528
+ if (resolved.announceToAgent) {
529
+ ctx.systemPrompt.section({
530
+ name: 'plugin:dsh-hardssh',
531
+ order: SECTION_ORDER,
532
+ text: (context) => renderWorkspaceGuidance(workspaces, context),
533
+ })
534
+ }
535
+ }