@tiphareth/dsh-hardssh 0.1.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 (165) hide show
  1. package/README.md +50 -0
  2. package/cordis.patch.yml +26 -0
  3. package/lib/client.js +17780 -0
  4. package/lib/environment-BL1jddfB.js +449 -0
  5. package/lib/fs.js +478 -0
  6. package/lib/index.js +6758 -0
  7. package/lib/subprocess.js +600 -0
  8. package/lib/switch-fs-CAJpFY9C.js +193 -0
  9. package/lib/switch-fs-RrZtG2gv.js +210 -0
  10. package/lib/types/backend.d.ts +108 -0
  11. package/lib/types/base/capability.d.ts +107 -0
  12. package/lib/types/base/index.d.ts +18 -0
  13. package/lib/types/base/ledger-router.d.ts +48 -0
  14. package/lib/types/base/ledger.d.ts +82 -0
  15. package/lib/types/base/model.d.ts +108 -0
  16. package/lib/types/base/namespace.d.ts +57 -0
  17. package/lib/types/base/plugin.d.ts +102 -0
  18. package/lib/types/base/registry.d.ts +60 -0
  19. package/lib/types/base/router.d.ts +40 -0
  20. package/lib/types/client/api.d.ts +85 -0
  21. package/lib/types/client/directory-flow.d.ts +82 -0
  22. package/lib/types/client/icons.d.ts +19 -0
  23. package/lib/types/client/index.d.ts +34 -0
  24. package/lib/types/client/locales.d.ts +104 -0
  25. package/lib/types/client/manager-button.d.ts +32 -0
  26. package/lib/types/client/migrate.d.ts +20 -0
  27. package/lib/types/client/ssh/api.d.ts +89 -0
  28. package/lib/types/client/ssh/apply.d.ts +23 -0
  29. package/lib/types/client/ssh/locales.d.ts +156 -0
  30. package/lib/types/client/ssh/mount.d.ts +13 -0
  31. package/lib/types/client/ssh/panel/ClusterTab.d.ts +8 -0
  32. package/lib/types/client/ssh/panel/HostFingerprintDialog.d.ts +16 -0
  33. package/lib/types/client/ssh/panel/HostFormDialog.d.ts +13 -0
  34. package/lib/types/client/ssh/panel/HostsTab.d.ts +10 -0
  35. package/lib/types/client/ssh/panel/SessionSecretDialog.d.ts +16 -0
  36. package/lib/types/client/ssh/panel/SshPanel.d.ts +14 -0
  37. package/lib/types/client/ssh/panel/TerminalTab.d.ts +12 -0
  38. package/lib/types/client/ssh/panel/TransferTab.d.ts +8 -0
  39. package/lib/types/client/ssh/panel/TunnelsTab.d.ts +8 -0
  40. package/lib/types/client/ssh/panel/controller.d.ts +23 -0
  41. package/lib/types/client/ssh/panel/helpers.d.ts +15 -0
  42. package/lib/types/client/ssh/panel/xterm.css.d.ts +3 -0
  43. package/lib/types/client/ssh/sidebar-entry.d.ts +25 -0
  44. package/lib/types/client/state.d.ts +32 -0
  45. package/lib/types/client/text.d.ts +11 -0
  46. package/lib/types/client/workspace-badges.d.ts +38 -0
  47. package/lib/types/client/workspace-gate.d.ts +15 -0
  48. package/lib/types/client-http.d.ts +15 -0
  49. package/lib/types/core.d.ts +43 -0
  50. package/lib/types/fs.d.ts +36 -0
  51. package/lib/types/host-http.d.ts +25 -0
  52. package/lib/types/index.d.ts +53 -0
  53. package/lib/types/ledger.d.ts +132 -0
  54. package/lib/types/protocol.d.ts +101 -0
  55. package/lib/types/providers/index.d.ts +19 -0
  56. package/lib/types/providers/local/provider.d.ts +58 -0
  57. package/lib/types/providers/ssh/provider.d.ts +112 -0
  58. package/lib/types/remote/environment.d.ts +29 -0
  59. package/lib/types/remote/output.d.ts +38 -0
  60. package/lib/types/remote/remote-fs.d.ts +69 -0
  61. package/lib/types/remote/remote-process.d.ts +41 -0
  62. package/lib/types/remote/remote-subprocess.d.ts +31 -0
  63. package/lib/types/remote/remote-terminal.d.ts +41 -0
  64. package/lib/types/remote-runner.d.ts +83 -0
  65. package/lib/types/remote-search.d.ts +50 -0
  66. package/lib/types/routes.d.ts +24 -0
  67. package/lib/types/runtime/workspace-core.d.ts +59 -0
  68. package/lib/types/seam-state.d.ts +69 -0
  69. package/lib/types/shell.d.ts +8 -0
  70. package/lib/types/ssh/connection/lease.d.ts +16 -0
  71. package/lib/types/ssh/connection/pool.d.ts +57 -0
  72. package/lib/types/ssh/engine.d.ts +434 -0
  73. package/lib/types/ssh/exec/output.d.ts +31 -0
  74. package/lib/types/ssh/known-hosts.d.ts +89 -0
  75. package/lib/types/ssh/plugin.d.ts +57 -0
  76. package/lib/types/ssh/protocol.d.ts +236 -0
  77. package/lib/types/ssh/routes.d.ts +44 -0
  78. package/lib/types/ssh/store.d.ts +107 -0
  79. package/lib/types/ssh/tools.d.ts +35 -0
  80. package/lib/types/ssh/transfer/progress.d.ts +14 -0
  81. package/lib/types/ssh/vault.d.ts +110 -0
  82. package/lib/types/subprocess.d.ts +32 -0
  83. package/lib/types/switch/switch-fs.d.ts +88 -0
  84. package/lib/types/switch/switch-subprocess.d.ts +34 -0
  85. package/lib/types/tools.d.ts +12 -0
  86. package/package.json +134 -0
  87. package/src/backend.ts +624 -0
  88. package/src/base/capability.ts +86 -0
  89. package/src/base/index.ts +18 -0
  90. package/src/base/ledger-router.ts +128 -0
  91. package/src/base/ledger.ts +299 -0
  92. package/src/base/model.ts +118 -0
  93. package/src/base/namespace.ts +102 -0
  94. package/src/base/plugin.ts +170 -0
  95. package/src/base/registry.ts +109 -0
  96. package/src/base/router.ts +43 -0
  97. package/src/client/api.ts +196 -0
  98. package/src/client/css-modules.d.ts +5 -0
  99. package/src/client/directory-flow.tsx +482 -0
  100. package/src/client/icons.tsx +50 -0
  101. package/src/client/index.ts +210 -0
  102. package/src/client/locales.ts +105 -0
  103. package/src/client/manager-button.tsx +269 -0
  104. package/src/client/migrate.ts +109 -0
  105. package/src/client/ssh/api.ts +411 -0
  106. package/src/client/ssh/apply.ts +50 -0
  107. package/src/client/ssh/locales.ts +322 -0
  108. package/src/client/ssh/mount.tsx +83 -0
  109. package/src/client/ssh/panel/ClusterTab.tsx +123 -0
  110. package/src/client/ssh/panel/HostFingerprintDialog.tsx +82 -0
  111. package/src/client/ssh/panel/HostFormDialog.tsx +228 -0
  112. package/src/client/ssh/panel/HostsTab.tsx +236 -0
  113. package/src/client/ssh/panel/SessionSecretDialog.tsx +80 -0
  114. package/src/client/ssh/panel/SshPanel.tsx +77 -0
  115. package/src/client/ssh/panel/TerminalTab.tsx +176 -0
  116. package/src/client/ssh/panel/TransferTab.tsx +232 -0
  117. package/src/client/ssh/panel/TunnelsTab.tsx +177 -0
  118. package/src/client/ssh/panel/controller.ts +48 -0
  119. package/src/client/ssh/panel/helpers.ts +26 -0
  120. package/src/client/ssh/panel/panel.module.css +1006 -0
  121. package/src/client/ssh/panel/xterm.css.ts +2 -0
  122. package/src/client/ssh/sidebar-entry.ts +123 -0
  123. package/src/client/state.ts +99 -0
  124. package/src/client/text.ts +22 -0
  125. package/src/client/workspace-badges.ts +96 -0
  126. package/src/client/workspace-gate.ts +225 -0
  127. package/src/client/workspace.module.css +283 -0
  128. package/src/client-http.ts +45 -0
  129. package/src/core.ts +47 -0
  130. package/src/fs.ts +85 -0
  131. package/src/host-http.ts +74 -0
  132. package/src/index.ts +244 -0
  133. package/src/ledger.ts +416 -0
  134. package/src/protocol.ts +114 -0
  135. package/src/providers/index.ts +34 -0
  136. package/src/providers/local/provider.ts +179 -0
  137. package/src/providers/ssh/provider.ts +274 -0
  138. package/src/remote/environment.ts +123 -0
  139. package/src/remote/output.ts +142 -0
  140. package/src/remote/remote-fs.ts +532 -0
  141. package/src/remote/remote-process.ts +203 -0
  142. package/src/remote/remote-subprocess.ts +159 -0
  143. package/src/remote/remote-terminal.ts +141 -0
  144. package/src/remote-runner.ts +201 -0
  145. package/src/remote-search.ts +163 -0
  146. package/src/routes.ts +395 -0
  147. package/src/runtime/workspace-core.ts +154 -0
  148. package/src/seam-state.ts +185 -0
  149. package/src/shell.ts +10 -0
  150. package/src/ssh/connection/lease.ts +17 -0
  151. package/src/ssh/connection/pool.ts +275 -0
  152. package/src/ssh/engine.ts +1761 -0
  153. package/src/ssh/exec/output.ts +70 -0
  154. package/src/ssh/known-hosts.ts +214 -0
  155. package/src/ssh/plugin.ts +184 -0
  156. package/src/ssh/protocol.ts +227 -0
  157. package/src/ssh/routes.ts +892 -0
  158. package/src/ssh/store.ts +544 -0
  159. package/src/ssh/tools.ts +402 -0
  160. package/src/ssh/transfer/progress.ts +75 -0
  161. package/src/ssh/vault.ts +477 -0
  162. package/src/subprocess.ts +71 -0
  163. package/src/switch/switch-fs.ts +253 -0
  164. package/src/switch/switch-subprocess.ts +59 -0
  165. package/src/tools.ts +221 -0
package/src/index.ts ADDED
@@ -0,0 +1,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 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
+ }
package/src/ledger.ts ADDED
@@ -0,0 +1,416 @@
1
+ /**
2
+ * SSH-bound workspace ledger: the mapping from a LOCAL ANCHOR directory (the
3
+ * host-visible workspace path, which sessions use as their cwd) to a REMOTE
4
+ * directory on an SSH host. Persisted as JSON next to dsh-ssh.json so the
5
+ * fs/subprocess seams can route a session whose cwd is an anchor to the
6
+ * remote execution world, and everything else stays local.
7
+ *
8
+ * A ledger record is created through the GUI (pick a host, browse a remote
9
+ * dir, give it a title). The host-side anchor directory is auto-created
10
+ * under ~/.dsh/ssh-workspaces/<id>/; the host workspace registry owns it as
11
+ * a normal local workspace, so session creation/listing/persistence keep
12
+ * working untouched.
13
+ *
14
+ * Mutations are serialized through a per-instance queue and committed as:
15
+ * write temp file → atomic rename → swap memory/index → bump revision →
16
+ * notify subscribers. A persistence failure leaves observable state
17
+ * unchanged and never publishes an event.
18
+ *
19
+ * @module dsh-hardssh/ledger
20
+ */
21
+
22
+ import { randomUUID } from 'node:crypto'
23
+ import { realpathSync } from 'node:fs'
24
+ import {
25
+ mkdir,
26
+ readFile,
27
+ rename as renameFile,
28
+ rm,
29
+ writeFile,
30
+ } from 'node:fs/promises'
31
+ import { homedir } from 'node:os'
32
+ import { join, posix } from 'node:path'
33
+ import type { SshWorkspaceRecord } from './protocol.ts'
34
+
35
+ /** One committed snapshot of the ledger. */
36
+ export interface LedgerSnapshot {
37
+ revision: number
38
+ records: readonly SshWorkspaceRecord[]
39
+ }
40
+
41
+ /** One committed mutation, delivered to subscribers. */
42
+ export type LedgerChange =
43
+ | { type: 'created'; revision: number; record: SshWorkspaceRecord }
44
+ | { type: 'renamed'; revision: number; before: SshWorkspaceRecord; record: SshWorkspaceRecord }
45
+ | { type: 'removed'; revision: number; record: SshWorkspaceRecord }
46
+
47
+ export type LedgerListener = (change: LedgerChange) => void
48
+
49
+ /** The change payload before the committed revision is stamped on. */
50
+ type LedgerChangeWithoutRevision =
51
+ | { type: 'created'; record: SshWorkspaceRecord }
52
+ | { type: 'renamed'; before: SshWorkspaceRecord; record: SshWorkspaceRecord }
53
+ | { type: 'removed'; record: SshWorkspaceRecord }
54
+
55
+ /** Ledger file location: ~/.dsh/dsh-hardssh-workspaces.json. */
56
+ export function ledgerPath(): string {
57
+ return join(homedir(), '.dsh', 'dsh-hardssh-workspaces.json')
58
+ }
59
+
60
+ /**
61
+ * Normalize a remote POSIX root: keep '/' as '/', collapse repeated
62
+ * separators and dot segments, reject relative paths and NUL.
63
+ */
64
+ export function normalizeRemoteRoot(raw: string): string {
65
+ if (raw.includes('\0')) {
66
+ throw new Error(`remoteRoot must not contain NUL (got '${raw}')`)
67
+ }
68
+ const normalized = posix.normalize(raw.trim())
69
+ if (!normalized.startsWith('/')) {
70
+ throw new Error(`remoteRoot must be an absolute POSIX path (got '${raw}')`)
71
+ }
72
+ return normalized === '/' ? '/' : normalized.replace(/\/+$/, '')
73
+ }
74
+
75
+ /** Anchor roots: ~/.dsh/ssh-workspaces/<id>/ — visible in the sidebar as
76
+ * ordinary host workspaces (and thus selectable for sessions). */
77
+ export function anchorRoot(): string {
78
+ return join(homedir(), '.dsh', 'ssh-workspaces')
79
+ }
80
+
81
+ /** The anchor directory for one id. */
82
+ export function anchorPathFor(id: string): string {
83
+ return join(anchorRoot(), id)
84
+ }
85
+
86
+ /** Default record title when the user gives none. */
87
+ export function defaultTitle(remoteRoot: string, alias: string): string {
88
+ return `${alias}:${remoteRoot.split('/').filter(Boolean).pop() ?? remoteRoot}`
89
+ }
90
+
91
+ /**
92
+ * Normalize a local anchor path for comparison: Windows anchors are
93
+ * case-insensitive with mixed separators; POSIX anchors stay case-sensitive.
94
+ */
95
+ export function normalizeAnchorPath(path: string): string {
96
+ const windowsStyle = isWindowsAnchor(path)
97
+ if (windowsStyle) {
98
+ const normalized = path.replace(/\//g, '\\')
99
+ const rootLength = windowsRootLength(normalized)
100
+ return trimTrailingSeparators(normalized, rootLength).toLowerCase()
101
+ }
102
+ const rootLength = path.startsWith('/') ? 1 : 0
103
+ return trimTrailingSeparators(path, rootLength)
104
+ }
105
+
106
+ /** True when candidate equals anchor or is one of its descendants. Lexical
107
+ * comparison over already-resolved paths; deliberately no fs access. */
108
+ export function isPathUnderAnchor(anchor: string, candidate: string): boolean {
109
+ const windowsStyle = isWindowsAnchor(anchor)
110
+ const normalizedAnchor = normalizeAnchorPath(anchor)
111
+
112
+ let normalizedCandidate: string
113
+ if (windowsStyle) {
114
+ const withWindowsSeparators = candidate.replace(/\//g, '\\')
115
+ const rootLength = windowsRootLength(withWindowsSeparators)
116
+ normalizedCandidate = trimTrailingSeparators(withWindowsSeparators, rootLength).toLowerCase()
117
+ } else {
118
+ const rootLength = candidate.startsWith('/') ? 1 : 0
119
+ normalizedCandidate = trimTrailingSeparators(candidate, rootLength)
120
+ }
121
+
122
+ if (normalizedCandidate === normalizedAnchor) return true
123
+
124
+ const separator = windowsStyle ? '\\' : '/'
125
+ const descendantPrefix = normalizedAnchor.endsWith(separator)
126
+ ? normalizedAnchor
127
+ : `${normalizedAnchor}${separator}`
128
+ return normalizedCandidate.startsWith(descendantPrefix)
129
+ }
130
+
131
+ /**
132
+ * The in-memory ledger. Loaded lazily on first access; every mutation writes
133
+ * through durably (atomic rename) and is serialized through a per-instance
134
+ * queue. A synchronous anchor index is maintained from the latest committed
135
+ * records so host seams can resolve a cwd/root without awaiting I/O.
136
+ */
137
+ export class SshWorkspaceLedger {
138
+ private records: SshWorkspaceRecord[] | undefined
139
+ private loadPromise: Promise<void> | undefined
140
+ private anchorIndex: Array<{ anchor: string; record: SshWorkspaceRecord }> = []
141
+ private mutationTail: Promise<void> = Promise.resolve()
142
+ private currentRevision = 0
143
+ private readonly listeners = new Set<LedgerListener>()
144
+
145
+ /** Overridable file location (tests isolate per instance). */
146
+ constructor(private readonly fileOverride?: string, private readonly anchorOverride?: string) {}
147
+
148
+ private file(): string {
149
+ return this.fileOverride ?? ledgerPath()
150
+ }
151
+
152
+ private anchors(): string {
153
+ return this.anchorOverride ?? anchorRoot()
154
+ }
155
+
156
+ private anchorFor(id: string): string {
157
+ return join(this.anchors(), id)
158
+ }
159
+
160
+ /** Load the ledger once (missing/unreadable/malformed file -> empty ledger). */
161
+ private async ensureLoaded(): Promise<void> {
162
+ if (this.records !== undefined) return
163
+ if (this.loadPromise === undefined) {
164
+ this.loadPromise = this.readRecords()
165
+ }
166
+ await this.loadPromise
167
+ }
168
+
169
+ private async readRecords(): Promise<void> {
170
+ try {
171
+ const text = await readFile(this.file(), 'utf8')
172
+ const parsed = JSON.parse(text) as unknown
173
+ this.records = Array.isArray(parsed) ? parsed.filter(isRecord).map(cloneRecord) : []
174
+ } catch {
175
+ this.records = []
176
+ }
177
+ this.reindex()
178
+ }
179
+
180
+ /** Rebuild the synchronous anchor index from the current records. */
181
+ private reindex(): void {
182
+ this.anchorIndex = (this.records ?? [])
183
+ .map((record) => ({ anchor: normalizeAnchorPath(record.anchorPath), record }))
184
+ .sort((a, b) => b.anchor.length - a.anchor.length) // longest prefix first
185
+ }
186
+
187
+ /** Persist a proposed record array without touching observable state. */
188
+ private async save(nextRecords: readonly SshWorkspaceRecord[]): Promise<void> {
189
+ const target = this.file()
190
+ await mkdir(join(target, '..'), { recursive: true })
191
+ const temporary = `${target}.tmp-${process.pid}-${Date.now()}-${randomUUID()}`
192
+ try {
193
+ await writeFile(temporary, JSON.stringify(nextRecords, null, 2), 'utf8')
194
+ await renameFile(temporary, target)
195
+ } catch (error) {
196
+ await rm(temporary, { force: true }).catch(() => undefined)
197
+ throw error
198
+ }
199
+ }
200
+
201
+ /** Serialize one mutation; a rejected earlier mutation never poisons later ones. */
202
+ private enqueueMutation<T>(operation: () => Promise<T>): Promise<T> {
203
+ const result = this.mutationTail.then(operation)
204
+ this.mutationTail = result.then(() => undefined, () => undefined)
205
+ return result
206
+ }
207
+
208
+ /** Commit a computed next state: swap memory, bump revision, notify. */
209
+ private commit(nextRecords: SshWorkspaceRecord[], change: LedgerChangeWithoutRevision): void {
210
+ this.records = nextRecords
211
+ this.reindex()
212
+ this.currentRevision += 1
213
+ this.emit({ ...change, revision: this.currentRevision } as LedgerChange)
214
+ }
215
+
216
+ private emit(change: LedgerChange): void {
217
+ for (const listener of [...this.listeners]) {
218
+ try {
219
+ listener(cloneChange(change))
220
+ } catch (error) {
221
+ // Listener failures must never reject an already-committed mutation.
222
+ console.warn('[dsh-hardssh] ledger subscriber failed:', error instanceof Error ? error.message : String(error))
223
+ }
224
+ }
225
+ }
226
+
227
+ /** Load the ledger (compat; returns detached copies). */
228
+ async load(): Promise<SshWorkspaceRecord[]> {
229
+ await this.ensureLoaded()
230
+ return cloneRecords(this.records ?? [])
231
+ }
232
+
233
+ /** All records, in creation order (detached copies). */
234
+ async list(): Promise<SshWorkspaceRecord[]> {
235
+ await this.ensureLoaded()
236
+ return cloneRecords(this.records ?? [])
237
+ }
238
+
239
+ /** Current detached snapshot. */
240
+ async snapshot(): Promise<LedgerSnapshot> {
241
+ await this.ensureLoaded()
242
+ return { revision: this.currentRevision, records: cloneRecords(this.records ?? []) }
243
+ }
244
+
245
+ /** Current in-process snapshot, synchronously (empty before first load).
246
+ * The returned records are the live immutable objects — callers must not
247
+ * mutate them (commits always replace the array wholesale). */
248
+ snapshotSync(): LedgerSnapshot {
249
+ return { revision: this.currentRevision, records: this.records ?? [] }
250
+ }
251
+
252
+ /** Current in-process revision (restarts at 0 on process restart). */
253
+ revision(): number {
254
+ return this.currentRevision
255
+ }
256
+
257
+ /** Subscribe to committed mutations (no initial event). Returns a disposer. */
258
+ subscribe(listener: LedgerListener): () => void {
259
+ this.listeners.add(listener)
260
+ let disposed = false
261
+ return () => {
262
+ if (disposed) return
263
+ disposed = true
264
+ this.listeners.delete(listener)
265
+ }
266
+ }
267
+
268
+ /** Look up by id. */
269
+ async get(id: string): Promise<SshWorkspaceRecord | undefined> {
270
+ await this.ensureLoaded()
271
+ const record = this.records?.find((candidate) => candidate.id === id)
272
+ return record === undefined ? undefined : cloneRecord(record)
273
+ }
274
+
275
+ /** Look up by LOCAL anchor path (resolved). Returns the record whose
276
+ * anchor owns the path (the anchor itself or any descendant). */
277
+ async findByAnchor(path: string): Promise<SshWorkspaceRecord | undefined> {
278
+ await this.ensureLoaded()
279
+ return this.findByAnchorSync(path)
280
+ }
281
+
282
+ /** Synchronous anchor lookup (uses the in-memory index). */
283
+ findByAnchorSync(path: string): SshWorkspaceRecord | undefined {
284
+ const canonical = safeRealpathSync(path)
285
+ if (canonical === undefined) return undefined
286
+ for (const { anchor, record } of this.anchorIndex) {
287
+ if (isPathUnderAnchor(anchor, canonical)) return cloneRecord(record)
288
+ }
289
+ return undefined
290
+ }
291
+
292
+ /** Create a record: materialize the anchor, then persist+commit. */
293
+ async create(input: { title: string; alias: string; remoteRoot: string }): Promise<SshWorkspaceRecord> {
294
+ return this.enqueueMutation(async () => {
295
+ await this.ensureLoaded()
296
+ const id = randomUUID()
297
+ const remoteRoot = normalizeRemoteRoot(input.remoteRoot)
298
+ const record: SshWorkspaceRecord = {
299
+ id,
300
+ title: input.title.trim() === '' ? defaultTitle(remoteRoot, input.alias) : input.title.trim(),
301
+ alias: input.alias,
302
+ remoteRoot,
303
+ anchorPath: this.anchorFor(id),
304
+ createdAt: new Date().toISOString(),
305
+ }
306
+ // Materialize before publishing a binding that points at the anchor;
307
+ // a persistence failure leaves an unreferenced dir but no snapshot change.
308
+ await mkdir(record.anchorPath, { recursive: true })
309
+ const nextRecords = [...(this.records ?? []), record]
310
+ await this.save(nextRecords)
311
+ this.commit(nextRecords, { type: 'created', record })
312
+ return cloneRecord(record)
313
+ })
314
+ }
315
+
316
+ /** Rename a record (display title only). */
317
+ async rename(id: string, title: string): Promise<SshWorkspaceRecord | undefined> {
318
+ return this.enqueueMutation(async () => {
319
+ await this.ensureLoaded()
320
+ const records = this.records ?? []
321
+ const index = records.findIndex((record) => record.id === id)
322
+ if (index < 0) return undefined
323
+ const before = records[index]
324
+ const record: SshWorkspaceRecord = { ...before, title: title.trim() }
325
+ const nextRecords = [...records]
326
+ nextRecords[index] = record
327
+ await this.save(nextRecords)
328
+ this.commit(nextRecords, { type: 'renamed', before, record })
329
+ return cloneRecord(record)
330
+ })
331
+ }
332
+
333
+ /** Remove a record. The anchor directory is left in place (a host
334
+ * workspace may still reference it; the caller decides on deletion). */
335
+ async remove(id: string): Promise<boolean> {
336
+ return this.enqueueMutation(async () => {
337
+ await this.ensureLoaded()
338
+ const records = this.records ?? []
339
+ const index = records.findIndex((record) => record.id === id)
340
+ if (index < 0) return false
341
+ const record = records[index]
342
+ const nextRecords = [...records.slice(0, index), ...records.slice(index + 1)]
343
+ await this.save(nextRecords)
344
+ this.commit(nextRecords, { type: 'removed', record })
345
+ return true
346
+ })
347
+ }
348
+
349
+ /** The anchor root shared by every record (for UI hints). */
350
+ anchorsRoot(): string {
351
+ return this.anchors()
352
+ }
353
+ }
354
+
355
+ /** Guard: a parsed JSON value is a valid record. */
356
+ function isRecord(value: unknown): value is SshWorkspaceRecord {
357
+ if (typeof value !== 'object' || value === null) return false
358
+ const record = value as Record<string, unknown>
359
+ return typeof record.id === 'string'
360
+ && typeof record.title === 'string'
361
+ && typeof record.alias === 'string'
362
+ && typeof record.remoteRoot === 'string'
363
+ && typeof record.anchorPath === 'string'
364
+ }
365
+
366
+ /** Synchronous realpath, swallowing errors (a session cwd can vanish mid-flight). */
367
+ function safeRealpathSync(path: string): string | undefined {
368
+ try {
369
+ return realpathSync(path)
370
+ } catch {
371
+ return undefined
372
+ }
373
+ }
374
+
375
+ function isWindowsAnchor(path: string): boolean {
376
+ return /^[a-zA-Z]:[\\/]/.test(path) || path.startsWith('\\\\') || path.includes('\\')
377
+ }
378
+
379
+ function windowsRootLength(path: string): number {
380
+ if (/^[a-zA-Z]:\\/.test(path)) return 3
381
+ if (path.startsWith('\\\\')) {
382
+ const segments = path.slice(2).split('\\')
383
+ if (segments.length >= 2 && segments[0] !== '' && segments[1] !== '') {
384
+ return 2 + segments[0].length + 1 + segments[1].length
385
+ }
386
+ return 2
387
+ }
388
+ return 0
389
+ }
390
+
391
+ function trimTrailingSeparators(path: string, minimumLength: number): string {
392
+ let end = path.length
393
+ while (end > minimumLength && (path[end - 1] === '/' || path[end - 1] === '\\')) {
394
+ end -= 1
395
+ }
396
+ return path.slice(0, end)
397
+ }
398
+
399
+ function cloneRecord(record: SshWorkspaceRecord): SshWorkspaceRecord {
400
+ return { ...record }
401
+ }
402
+
403
+ function cloneRecords(records: readonly SshWorkspaceRecord[]): SshWorkspaceRecord[] {
404
+ return records.map(cloneRecord)
405
+ }
406
+
407
+ function cloneChange(change: LedgerChange): LedgerChange {
408
+ switch (change.type) {
409
+ case 'created':
410
+ return { ...change, record: cloneRecord(change.record) }
411
+ case 'renamed':
412
+ return { ...change, before: cloneRecord(change.before), record: cloneRecord(change.record) }
413
+ case 'removed':
414
+ return { ...change, record: cloneRecord(change.record) }
415
+ }
416
+ }