@tiphareth/dsh-hardssh 0.1.2-alpha → 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 +1165 -1329
  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 +36 -11
  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 -99
  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 -232
  173. package/src/remote-runner.ts +0 -201
  174. package/src/seam-state.ts +0 -185
package/src/routes.ts CHANGED
@@ -1,395 +1,416 @@
1
- /**
2
- * The /api/dsh-hardssh route family: SSH-bound workspace CRUD, remote
3
- * directory browsing for the picker, plus the workspace file ops (tree /
4
- * file / search) served through the gated WorkspaceFileService every file
5
- * op is bound to an EXACT SSH workspace anchor; there is no implicit local
6
- * fallback. Every route carries the same loopback-only trust fence as
7
- * /api/dsh-ssh — these endpoints can read and write files on remote servers,
8
- * so LAN-exposed dsh web deployments must not serve them.
9
- */
10
- import type { IncomingMessage, ServerResponse } from 'node:http'
11
- import { posix } from 'node:path'
12
- import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
13
- import type { HostStore } from './ssh/store.ts'
14
- import { NeedsPasswordError, type SshEngine } from './ssh/engine.ts'
15
- import { HostKeyMismatchError, HostKeyUnknownError } from './ssh/known-hosts.ts'
16
- import { isLoopbackRequest, queryParam, readJsonBody as readJsonBodyShared, writeJson } from './host-http.ts'
17
- import type { SshWorkspaceLedger } from './ledger.ts'
18
- import {
19
- BackendError,
20
- backendErrorStatus,
21
- normalizeRel,
22
- sortWorkspaceEntries,
23
- type WorkspaceFileService,
24
- } from './backend.ts'
25
- import { WORKSPACE_API } from './protocol.ts'
26
-
27
- /** Cap on JSON request bodies (file writes carry content). */
28
- const MAX_JSON_BODY_BYTES = 4 * 1024 * 1024
29
-
30
- /** A request-body failure with a distinct HTTP status (413 vs 400). */
31
- class RequestBodyError extends Error {
32
- constructor(
33
- public readonly status: 400 | 413,
34
- message: string,
35
- ) {
36
- super(message)
37
- this.name = 'RequestBodyError'
38
- }
39
- }
40
-
41
- /** Read a JSON request body (throws RequestBodyError when too large/malformed). */
42
- async function readJsonBody(req: IncomingMessage): Promise<Record<string, unknown>> {
43
- const result = await readJsonBodyShared(req, MAX_JSON_BODY_BYTES)
44
- if (result.ok) return result.body
45
- if (result.reason === 'too-large') {
46
- throw new RequestBodyError(413, `request body exceeds ${MAX_JSON_BODY_BYTES} bytes`)
47
- }
48
- throw new RequestBodyError(400, result.reason === 'malformed' ? 'malformed JSON body' : 'JSON body must be an object')
49
- }
50
-
51
- /** Map any thrown error to a stable JSON error response. Interactive gates
52
- * (session password, host-key TOFU) keep their machine codes so the client
53
- * can prompt and retry instead of showing a raw message. */
54
- function writeRouteError(res: ServerResponse, error: unknown, ioStatus: 500 | 502 = 502): void {
55
- if (error instanceof RequestBodyError) {
56
- writeJson(res, error.status, { error: error.message, code: error.status === 413 ? 'too-large' : 'invalid' })
57
- return
58
- }
59
- if (error instanceof BackendError) {
60
- writeJson(res, backendErrorStatus(error, ioStatus), { error: error.message, code: error.code })
61
- return
62
- }
63
- if (error instanceof NeedsPasswordError) {
64
- // NOT 200 here: readJson on a 2xx body would treat the structured error
65
- // as a successful listing. 500 makes the client throw HttpApiError(code).
66
- writeJson(res, 500, { error: error.message, code: 'NEEDS_PASSWORD', secret: error.secret })
67
- return
68
- }
69
- if (error instanceof HostKeyUnknownError) {
70
- writeJson(res, 500, { error: error.message, code: 'HOST_KEY_UNKNOWN', hostKeyFingerprint: error.fingerprintSha256 })
71
- return
72
- }
73
- if (error instanceof HostKeyMismatchError) {
74
- writeJson(res, 500, { error: error.message, code: 'HOST_KEY_MISMATCH', hostKeyFingerprint: error.actual })
75
- return
76
- }
77
- writeJson(res, ioStatus, { error: error instanceof Error ? error.message : String(error), code: 'io' })
78
- }
79
-
80
- /** Required query parameter (missing/empty -> invalid BackendError). */
81
- function requiredQuery(url: URL, name: string): string {
82
- const value = queryParam(url, name)
83
- if (value === undefined || value === '') {
84
- throw new BackendError('invalid', `${name} query parameter is required`)
85
- }
86
- return value
87
- }
88
-
89
- /** Route family dependencies. */
90
- export interface WorkspaceRoutesDeps {
91
- /** Read-only host surface (list only; the SSH routes own the write path). */
92
- hosts: import('./core.ts').HostStoreView
93
- engine: SshEngine
94
- ledger: SshWorkspaceLedger
95
- /** Gated workspace file service (tree / file / search). */
96
- files: WorkspaceFileService
97
- /** Register the anchor dir as a HOST workspace (sidebar visibility). */
98
- registerHostWorkspace?: (anchorPath: string, title: string) => Promise<void>
99
- /** Drop the host workspace registration for an anchor dir. */
100
- unregisterHostWorkspace?: (anchorPath: string) => Promise<void>
101
- }
102
-
103
- /**
104
- * Build every /api/dsh-hardssh route.
105
- * @param deps - host store (alias listing), ssh engine, workspace ledger, file service.
106
- * @returns the routes to register.
107
- */
108
- export function makeRoutes(deps: WorkspaceRoutesDeps): WebRoute[] {
109
- const { hosts, engine, ledger, files } = deps
110
-
111
- const guard = (req: IncomingMessage, res: ServerResponse, method: string): boolean => {
112
- if (!isLoopbackRequest(req)) {
113
- writeJson(res, 403, { error: 'forbidden: loopback-only' })
114
- return false
115
- }
116
- if (req.method !== method) {
117
- writeJson(res, 405, { error: `method not allowed: ${req.method}` })
118
- return false
119
- }
120
- return true
121
- }
122
-
123
- /** List the available SSH hosts (for the picker). */
124
- const hostsRoute: WebRoute = {
125
- kind: 'exact',
126
- path: WORKSPACE_API.sshWorkspaces + '/hosts',
127
- handler: async (req, res) => {
128
- if (!guard(req, res, 'GET')) return
129
- writeJson(res, 200, { hosts: hosts.list().map((host) => ({
130
- alias: host.alias,
131
- host: host.host,
132
- port: host.port,
133
- user: host.user,
134
- auth: host.auth,
135
- description: host.description,
136
- })) })
137
- },
138
- }
139
-
140
- /** List (GET) / create (POST) SSH-bound workspaces. Body for POST:
141
- * { title, alias, remoteRoot }. */
142
- const wsRoute: WebRoute = {
143
- kind: 'exact',
144
- path: WORKSPACE_API.sshWorkspaces,
145
- handler: async (req, res) => {
146
- if (!isLoopbackRequest(req)) {
147
- writeJson(res, 403, { error: 'forbidden: loopback-only' })
148
- return
149
- }
150
- if (req.method === 'GET') {
151
- writeJson(res, 200, { workspaces: await ledger.list() })
152
- return
153
- }
154
- if (req.method === 'POST') {
155
- let body: Record<string, unknown>
156
- try {
157
- body = await readJsonBody(req)
158
- } catch (error) {
159
- writeRouteError(res, error, 500)
160
- return
161
- }
162
- const title = typeof body.title === 'string' ? body.title.trim() : ''
163
- const alias = typeof body.alias === 'string' ? body.alias.trim() : ''
164
- const remoteRoot = typeof body.remoteRoot === 'string' ? body.remoteRoot.trim() : ''
165
- if (alias === '' || remoteRoot === '') {
166
- writeJson(res, 400, { error: 'title, alias and remoteRoot are required' })
167
- return
168
- }
169
- if (hosts.find(alias) === undefined) {
170
- writeJson(res, 404, { error: `alias '${alias}' not found — configure it in the SSH dialog first` })
171
- return
172
- }
173
- if (!remoteRoot.startsWith('/')) {
174
- writeJson(res, 400, { error: `remoteRoot must be an absolute remote path (got '${remoteRoot}')` })
175
- return
176
- }
177
- try {
178
- const record = await ledger.create({ title, alias, remoteRoot })
179
- // Make the anchor a REAL host workspace so it appears in the
180
- // sidebar and can host sessions (the seams route it remote by its
181
- // anchor path). Registration failure does not roll back the ledger
182
- // record — the workspace still binds for tool routing.
183
- try {
184
- if (deps.registerHostWorkspace !== undefined) {
185
- await deps.registerHostWorkspace(record.anchorPath, record.title)
186
- }
187
- } catch (error) {
188
- console.warn(`[dsh-hardssh] host workspace registration failed for ${record.title}: ${error instanceof Error ? error.message : String(error)}`)
189
- }
190
- writeJson(res, 200, { workspace: record })
191
- } catch (error) {
192
- writeRouteError(res, error, 500)
193
- }
194
- return
195
- }
196
- writeJson(res, 405, { error: `method not allowed: ${req.method}` })
197
- },
198
- }
199
-
200
- /** Rename / delete one SSH-bound workspace. */
201
- const itemRoute: WebRoute = {
202
- kind: 'exact',
203
- path: WORKSPACE_API.sshWorkspaces + '/item',
204
- handler: async (req, res) => {
205
- if (!isLoopbackRequest(req)) {
206
- writeJson(res, 403, { error: 'forbidden: loopback-only' })
207
- return
208
- }
209
- const url = new URL(req.url ?? '/', 'http://localhost')
210
- const id = queryParam(url, 'id')
211
- if (id === undefined || id === '') {
212
- writeJson(res, 400, { error: 'id query parameter is required' })
213
- return
214
- }
215
- if (req.method === 'PATCH') {
216
- let body: Record<string, unknown>
217
- try {
218
- body = await readJsonBody(req)
219
- } catch (error) {
220
- writeRouteError(res, error, 500)
221
- return
222
- }
223
- const title = typeof body.title === 'string' ? body.title.trim() : ''
224
- if (title === '') {
225
- writeJson(res, 400, { error: 'title is required' })
226
- return
227
- }
228
- const record = await ledger.rename(id, title)
229
- if (record === undefined) {
230
- writeJson(res, 404, { error: `workspace '${id}' not found` })
231
- return
232
- }
233
- writeJson(res, 200, { workspace: record })
234
- return
235
- }
236
- if (req.method === 'DELETE') {
237
- const record = await ledger.get(id)
238
- const removed = await ledger.remove(id)
239
- if (removed && record !== undefined && deps.unregisterHostWorkspace !== undefined) {
240
- try {
241
- await deps.unregisterHostWorkspace(record.anchorPath)
242
- } catch (error) {
243
- console.warn(`[dsh-hardssh] host workspace unregistration failed for ${record.title}: ${error instanceof Error ? error.message : String(error)}`)
244
- }
245
- }
246
- writeJson(res, removed ? 200 : 404, removed ? { ok: true } : { error: `workspace '${id}' not found` })
247
- return
248
- }
249
- writeJson(res, 405, { error: `method not allowed: ${req.method}` })
250
- },
251
- }
252
-
253
- /** Browse a remote directory tree (SFTP readdir via the engine) for the
254
- * workspace picker. Query: alias + path (default home when omitted). */
255
- const dirRoute: WebRoute = {
256
- kind: 'exact',
257
- path: WORKSPACE_API.sshWorkspaceDir,
258
- handler: async (req, res) => {
259
- if (!guard(req, res, 'GET')) return
260
- const url = new URL(req.url ?? '/', 'http://localhost')
261
- const alias = queryParam(url, 'alias')
262
- let requestedPath = queryParam(url, 'path')
263
- if (alias === undefined || alias === '') {
264
- writeJson(res, 400, { error: 'alias query parameter is required' })
265
- return
266
- }
267
- if (hosts.find(alias) === undefined) {
268
- writeJson(res, 404, { error: `alias '${alias}' not found` })
269
- return
270
- }
271
- try {
272
- if (requestedPath === undefined || requestedPath === '') {
273
- const home = await engine.exec(alias, 'printf %s "$HOME"', 10_000)
274
- if (!home.success || home.stdout.trim() === '') {
275
- writeJson(res, 502, { error: `could not resolve remote home: ${home.stderr.trim() || 'empty $HOME'}` })
276
- return
277
- }
278
- requestedPath = home.stdout.trim()
279
- }
280
- if (requestedPath.includes('\0') || requestedPath.includes('\\') || !requestedPath.startsWith('/')) {
281
- writeJson(res, 400, { error: `path must be an absolute POSIX path (got '${requestedPath}')` })
282
- return
283
- }
284
- const abs = posix.normalize(requestedPath)
285
- const entries = sortWorkspaceEntries(
286
- (await engine.ls(alias, abs)).map((entry) => ({ name: entry.name, type: entry.type, size: entry.size, mtimeMs: entry.mtimeMs })),
287
- )
288
- writeJson(res, 200, { path: abs, entries })
289
- } catch (error) {
290
- writeRouteError(res, error, 502)
291
- }
292
- },
293
- }
294
-
295
- /** List a directory below an exact SSH workspace anchor. */
296
- const treeRoute: WebRoute = {
297
- kind: 'exact',
298
- path: WORKSPACE_API.tree,
299
- handler: async (req, res) => {
300
- if (!guard(req, res, 'GET')) return
301
- try {
302
- const url = new URL(req.url ?? '/', 'http://localhost')
303
- const root = requiredQuery(url, 'root')
304
- const rel = queryParam(url, 'path') ?? ''
305
- // Explicit resolution keeps authorization visible at the route seam.
306
- await files.resolveContext(root)
307
- const listing = await files.list(root, normalizeRel(rel))
308
- writeJson(res, 200, { listing })
309
- } catch (error) {
310
- writeRouteError(res, error, 502)
311
- }
312
- },
313
- }
314
-
315
- /** Read (GET) or write (PUT) a file below an exact SSH workspace anchor. */
316
- const fileRoute: WebRoute = {
317
- kind: 'exact',
318
- path: WORKSPACE_API.file,
319
- handler: async (req, res) => {
320
- if (!isLoopbackRequest(req)) {
321
- writeJson(res, 403, { error: 'forbidden: loopback-only', code: 'forbidden' })
322
- return
323
- }
324
-
325
- if (req.method === 'GET') {
326
- try {
327
- const url = new URL(req.url ?? '/', 'http://localhost')
328
- const root = requiredQuery(url, 'root')
329
- const rel = requiredQuery(url, 'path')
330
- await files.resolveContext(root)
331
- const file = await files.read(root, normalizeRel(rel))
332
- writeJson(res, 200, { file })
333
- } catch (error) {
334
- writeRouteError(res, error, 502)
335
- }
336
- return
337
- }
338
-
339
- if (req.method === 'PUT') {
340
- try {
341
- const body = await readJsonBody(req)
342
- const root = typeof body.root === 'string' ? body.root : ''
343
- const rel = typeof body.path === 'string' ? body.path : ''
344
- const content = body.content
345
- if (root === '' || rel === '') {
346
- throw new BackendError('invalid', 'root and path are required')
347
- }
348
- if (typeof content !== 'string') {
349
- throw new BackendError('invalid', 'content must be a string')
350
- }
351
- let expectedMtime: number | undefined
352
- if (body.expectedMtime !== undefined) {
353
- if (
354
- typeof body.expectedMtime !== 'number'
355
- || !Number.isFinite(body.expectedMtime)
356
- || body.expectedMtime < 0
357
- ) {
358
- throw new BackendError('invalid', 'expectedMtime must be a finite non-negative number')
359
- }
360
- expectedMtime = body.expectedMtime
361
- }
362
- await files.resolveContext(root)
363
- const result = await files.write(root, normalizeRel(rel), content, expectedMtime)
364
- writeJson(res, 200, { result })
365
- } catch (error) {
366
- writeRouteError(res, error, 502)
367
- }
368
- return
369
- }
370
-
371
- writeJson(res, 405, { error: `method not allowed: ${req.method}`, code: 'invalid' })
372
- },
373
- }
374
-
375
- /** Filename search below an exact SSH workspace anchor. */
376
- const searchRoute: WebRoute = {
377
- kind: 'exact',
378
- path: WORKSPACE_API.search,
379
- handler: async (req, res) => {
380
- if (!guard(req, res, 'GET')) return
381
- try {
382
- const url = new URL(req.url ?? '/', 'http://localhost')
383
- const root = requiredQuery(url, 'root')
384
- const query = queryParam(url, 'query') ?? ''
385
- await files.resolveContext(root)
386
- const search = await files.search(root, query)
387
- writeJson(res, 200, { search })
388
- } catch (error) {
389
- writeRouteError(res, error, 502)
390
- }
391
- },
392
- }
393
-
394
- return [hostsRoute, wsRoute, itemRoute, dirRoute, treeRoute, fileRoute, searchRoute]
395
- }
1
+ /**
2
+ * The /api/dsh-hardssh route family: SSH-bound workspace CRUD and remote
3
+ * directory browsing for the workspace-creation picker. Every route carries
4
+ * the same loopback-only trust fence as /api/dsh-sshthese endpoints can
5
+ * read and write files on remote servers, so LAN-exposed dsh web deployments
6
+ * must not serve them.
7
+ *
8
+ * The per-workspace file tree / file content / search HTTP surface that used
9
+ * to live here was superseded by the provider-neutral file browsing in the
10
+ * dsh-workbench plugin (which consumes `workspace.fs`/`workspace.search`
11
+ * capabilities directly) and had no remaining UI consumer, so it was removed.
12
+ */
13
+ import type { IncomingMessage, ServerResponse } from 'node:http'
14
+ import { posix } from 'node:path'
15
+ import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
16
+ import type { HostStore } from './ssh/store.ts'
17
+ import { NeedsPasswordError, type SshEngine } from './ssh/engine.ts'
18
+ import { HostKeyMismatchError, HostKeyUnknownError } from './ssh/known-hosts.ts'
19
+ import { isLoopbackRequest, queryParam, readJsonBody as readJsonBodyShared, writeJson } from './host-http.ts'
20
+ import type { WorkspaceStoreView } from './backend.ts'
21
+ import { BackendError, backendErrorStatus, sortWorkspaceEntries } from './backend.ts'
22
+ import { WORKSPACE_API } from './protocol.ts'
23
+
24
+ /** Cap on JSON request bodies (file writes carry content). */
25
+ const MAX_JSON_BODY_BYTES = 4 * 1024 * 1024
26
+
27
+ /** A request-body failure with a distinct HTTP status (413 vs 400). */
28
+ class RequestBodyError extends Error {
29
+ constructor(
30
+ public readonly status: 400 | 413,
31
+ message: string,
32
+ ) {
33
+ super(message)
34
+ this.name = 'RequestBodyError'
35
+ }
36
+ }
37
+
38
+ /** Read a JSON request body (throws RequestBodyError when too large/malformed). */
39
+ async function readJsonBody(req: IncomingMessage): Promise<Record<string, unknown>> {
40
+ const result = await readJsonBodyShared(req, MAX_JSON_BODY_BYTES)
41
+ if (result.ok) return result.body
42
+ if (result.reason === 'too-large') {
43
+ throw new RequestBodyError(413, `request body exceeds ${MAX_JSON_BODY_BYTES} bytes`)
44
+ }
45
+ throw new RequestBodyError(400, result.reason === 'malformed' ? 'malformed JSON body' : 'JSON body must be an object')
46
+ }
47
+
48
+ /** Map any thrown error to a stable JSON error response. Interactive gates
49
+ * (session password, host-key TOFU) keep their machine codes so the client
50
+ * can prompt and retry instead of showing a raw message. */
51
+ function writeRouteError(res: ServerResponse, error: unknown, ioStatus: 500 | 502 = 502): void {
52
+ if (error instanceof RequestBodyError) {
53
+ writeJson(res, error.status, { error: error.message, code: error.status === 413 ? 'too-large' : 'invalid' })
54
+ return
55
+ }
56
+ if (error instanceof BackendError) {
57
+ writeJson(res, backendErrorStatus(error, ioStatus), { error: error.message, code: error.code })
58
+ return
59
+ }
60
+ if (error instanceof NeedsPasswordError) {
61
+ // NOT 200 here: readJson on a 2xx body would treat the structured error
62
+ // as a successful listing. 500 makes the client throw HttpApiError(code).
63
+ writeJson(res, 500, { error: error.message, code: 'NEEDS_PASSWORD', secret: error.secret })
64
+ return
65
+ }
66
+ if (error instanceof HostKeyUnknownError) {
67
+ writeJson(res, 500, { error: error.message, code: 'HOST_KEY_UNKNOWN', hostKeyFingerprint: error.fingerprintSha256 })
68
+ return
69
+ }
70
+ if (error instanceof HostKeyMismatchError) {
71
+ writeJson(res, 500, { error: error.message, code: 'HOST_KEY_MISMATCH', hostKeyFingerprint: error.actual })
72
+ return
73
+ }
74
+ writeJson(res, ioStatus, { error: error instanceof Error ? error.message : String(error), code: 'io' })
75
+ }
76
+
77
+ /** One record whose host-workspace (re)registration failed. */
78
+ export interface HostWorkspaceReconcileFailure {
79
+ id: string
80
+ error: string
81
+ }
82
+
83
+ /** Result of replaying host-workspace registration for every stored record. */
84
+ export interface HostWorkspaceReconcileReport {
85
+ registered: number
86
+ failures: HostWorkspaceReconcileFailure[]
87
+ /** Set when the record source itself could not be listed (startup path
88
+ * tolerates it; the route surfaces it as an I/O failure). */
89
+ listError?: string
90
+ }
91
+
92
+ /** Registration replay dependencies (the route and the startup path share it). */
93
+ export interface HostWorkspaceReconcileDeps {
94
+ workspaces: WorkspaceStoreView
95
+ registerHostWorkspace?: (anchorPath: string, title: string) => Promise<void>
96
+ }
97
+
98
+ /**
99
+ * Replay host-workspace registration for every stored record. Registration
100
+ * happens only on create/delete, so a process restart (or a registration that
101
+ * failed before compensation existed) leaves records whose sidebar entry is
102
+ * missing; `registerHostWorkspace` is create-if-missing, so this is the
103
+ * idempotent startup/retry compensation. Per-record failures are reported and
104
+ * never thrown: a broken host registry must not break plugin startup.
105
+ *
106
+ * Shared by the `/reconcile` route and the boot path so the two halves cannot
107
+ * drift.
108
+ */
109
+ export async function reconcileHostWorkspaces(deps: HostWorkspaceReconcileDeps): Promise<HostWorkspaceReconcileReport> {
110
+ let records: Awaited<ReturnType<WorkspaceStoreView['list']>>
111
+ try {
112
+ records = await deps.workspaces.list()
113
+ } catch (error) {
114
+ // A corrupt/unavailable record source must not break plugin startup.
115
+ return { registered: 0, failures: [], listError: error instanceof Error ? error.message : String(error) }
116
+ }
117
+ const failures: HostWorkspaceReconcileFailure[] = []
118
+ let registered = 0
119
+ for (const record of records) {
120
+ try {
121
+ // No host registry (headless profile): there is no sidebar entry to
122
+ // create, so the desired state already holds for every record.
123
+ if (deps.registerHostWorkspace !== undefined) {
124
+ await deps.registerHostWorkspace(record.anchorPath, record.title)
125
+ }
126
+ registered += 1
127
+ } catch (error) {
128
+ failures.push({ id: record.id, error: error instanceof Error ? error.message : String(error) })
129
+ }
130
+ }
131
+ return { registered, failures }
132
+ }
133
+
134
+ /** Route family dependencies. */
135
+ export interface WorkspaceRoutesDeps {
136
+ /** Read-only host surface (list only; the SSH routes own the write path). */
137
+ hosts: import('./core.ts').HostStoreView
138
+ engine: SshEngine
139
+ /** SSH-workspace record source: the generic WorkspaceCore-backed
140
+ * projection store (the only runtime). */
141
+ workspaces: WorkspaceStoreView
142
+ /** Register the anchor dir as a HOST workspace (sidebar visibility). */
143
+ registerHostWorkspace?: (anchorPath: string, title: string) => Promise<void>
144
+ /** Drop the host workspace registration for an anchor dir. */
145
+ unregisterHostWorkspace?: (anchorPath: string) => Promise<void>
146
+ }
147
+
148
+ /**
149
+ * Build every /api/dsh-hardssh route (workspace CRUD + remote directory
150
+ * browsing for the picker; the per-workspace file surface was removed).
151
+ */
152
+ export function makeRoutes(deps: WorkspaceRoutesDeps): WebRoute[] {
153
+ const { hosts, engine, workspaces } = deps
154
+
155
+ const guard = (req: IncomingMessage, res: ServerResponse, method: string): boolean => {
156
+ if (!isLoopbackRequest(req)) {
157
+ writeJson(res, 403, { error: 'forbidden: loopback-only' })
158
+ return false
159
+ }
160
+ if (req.method !== method) {
161
+ writeJson(res, 405, { error: `method not allowed: ${req.method}` })
162
+ return false
163
+ }
164
+ return true
165
+ }
166
+
167
+ /** List the available SSH hosts (for the picker). */
168
+ const hostsRoute: WebRoute = {
169
+ kind: 'exact',
170
+ path: WORKSPACE_API.sshWorkspaces + '/hosts',
171
+ handler: async (req, res) => {
172
+ if (!guard(req, res, 'GET')) return
173
+ writeJson(res, 200, { hosts: hosts.list().map((host) => ({
174
+ alias: host.alias,
175
+ host: host.host,
176
+ port: host.port,
177
+ user: host.user,
178
+ auth: host.auth,
179
+ description: host.description,
180
+ })) })
181
+ },
182
+ }
183
+
184
+ /** List (GET) / create (POST) SSH-bound workspaces. Body for POST:
185
+ * { title, alias, remoteRoot }. */
186
+ const wsRoute: WebRoute = {
187
+ kind: 'exact',
188
+ path: WORKSPACE_API.sshWorkspaces,
189
+ handler: async (req, res) => {
190
+ if (!isLoopbackRequest(req)) {
191
+ writeJson(res, 403, { error: 'forbidden: loopback-only' })
192
+ return
193
+ }
194
+ if (req.method === 'GET') {
195
+ writeJson(res, 200, { workspaces: await workspaces.list() })
196
+ return
197
+ }
198
+ if (req.method === 'POST') {
199
+ let body: Record<string, unknown>
200
+ try {
201
+ body = await readJsonBody(req)
202
+ } catch (error) {
203
+ writeRouteError(res, error, 500)
204
+ return
205
+ }
206
+ const title = typeof body.title === 'string' ? body.title.trim() : ''
207
+ const alias = typeof body.alias === 'string' ? body.alias.trim() : ''
208
+ const remoteRoot = typeof body.remoteRoot === 'string' ? body.remoteRoot.trim() : ''
209
+ if (alias === '' || remoteRoot === '') {
210
+ writeJson(res, 400, { error: 'title, alias and remoteRoot are required' })
211
+ return
212
+ }
213
+ if (hosts.find(alias) === undefined) {
214
+ writeJson(res, 404, { error: `alias '${alias}' not found — configure it in the SSH dialog first` })
215
+ return
216
+ }
217
+ if (!remoteRoot.startsWith('/')) {
218
+ writeJson(res, 400, { error: `remoteRoot must be an absolute remote path (got '${remoteRoot}')` })
219
+ return
220
+ }
221
+ try {
222
+ const record = await workspaces.create({ title, alias, remoteRoot })
223
+ // Make the anchor a REAL host workspace so it appears in the
224
+ // sidebar and can host sessions (the seams route it remote by its
225
+ // anchor path). The two stores have no shared transaction, so a
226
+ // registration failure is COMPENSATED by deleting the record just
227
+ // created: returning 200 here would leave a binding the sidebar
228
+ // cannot reach, and returning 5xx while keeping the record would
229
+ // make a retry create a duplicate.
230
+ if (deps.registerHostWorkspace !== undefined) {
231
+ try {
232
+ await deps.registerHostWorkspace(record.anchorPath, record.title)
233
+ } catch (error) {
234
+ const registration = error instanceof Error ? error.message : String(error)
235
+ try {
236
+ await workspaces.remove(record.id)
237
+ } catch (rollbackError) {
238
+ const rollback = rollbackError instanceof Error ? rollbackError.message : String(rollbackError)
239
+ writeJson(res, 500, {
240
+ error: `host workspace registration failed for '${record.title}' (${registration}); rollback also failed (${rollback}) — workspace '${record.id}' still exists`,
241
+ code: 'HOST_REGISTRATION_ROLLBACK_FAILED',
242
+ })
243
+ return
244
+ }
245
+ writeJson(res, 502, {
246
+ error: `host workspace registration failed for '${record.title}': ${registration}`,
247
+ code: 'HOST_REGISTRATION_FAILED',
248
+ })
249
+ return
250
+ }
251
+ }
252
+ writeJson(res, 200, { workspace: record })
253
+ } catch (error) {
254
+ writeRouteError(res, error, 500)
255
+ }
256
+ return
257
+ }
258
+ writeJson(res, 405, { error: `method not allowed: ${req.method}` })
259
+ },
260
+ }
261
+
262
+ /** Rename / delete one SSH-bound workspace. */
263
+ const itemRoute: WebRoute = {
264
+ kind: 'exact',
265
+ path: WORKSPACE_API.sshWorkspaces + '/item',
266
+ handler: async (req, res) => {
267
+ if (!isLoopbackRequest(req)) {
268
+ writeJson(res, 403, { error: 'forbidden: loopback-only' })
269
+ return
270
+ }
271
+ const url = new URL(req.url ?? '/', 'http://localhost')
272
+ const id = queryParam(url, 'id')
273
+ if (id === undefined || id === '') {
274
+ writeJson(res, 400, { error: 'id query parameter is required' })
275
+ return
276
+ }
277
+ if (req.method === 'PATCH') {
278
+ let body: Record<string, unknown>
279
+ try {
280
+ body = await readJsonBody(req)
281
+ } catch (error) {
282
+ writeRouteError(res, error, 500)
283
+ return
284
+ }
285
+ const title = typeof body.title === 'string' ? body.title.trim() : ''
286
+ if (title === '') {
287
+ writeJson(res, 400, { error: 'title is required' })
288
+ return
289
+ }
290
+ const record = await workspaces.rename(id, title)
291
+ if (record === undefined) {
292
+ writeJson(res, 404, { error: `workspace '${id}' not found` })
293
+ return
294
+ }
295
+ writeJson(res, 200, { workspace: record })
296
+ return
297
+ }
298
+ if (req.method === 'DELETE') {
299
+ const record = await workspaces.get(id)
300
+ if (record === undefined) {
301
+ writeJson(res, 404, { error: `workspace '${id}' not found` })
302
+ return
303
+ }
304
+
305
+ // Unregister first. If this fails the authoritative ledger record —
306
+ // including its original id and anchor — remains completely untouched.
307
+ if (deps.unregisterHostWorkspace !== undefined) {
308
+ try {
309
+ await deps.unregisterHostWorkspace(record.anchorPath)
310
+ } catch (error) {
311
+ const unregistration = error instanceof Error ? error.message : String(error)
312
+ writeJson(res, 502, {
313
+ error: `host workspace unregistration failed for '${record.title}': ${unregistration}`,
314
+ code: 'HOST_UNREGISTRATION_FAILED',
315
+ })
316
+ return
317
+ }
318
+ }
319
+
320
+ // The cross-store order deliberately leaves this recovery asymmetric:
321
+ // if persistence fails after sidebar removal, the original ledger
322
+ // record remains and the existing startup reconcile re-registers its
323
+ // exact id/anchor. Creating a compensating record here would invent a
324
+ // new identity and orphan the old sidebar binding.
325
+ try {
326
+ const removed = await workspaces.remove(id)
327
+ if (!removed) {
328
+ writeJson(res, 404, { error: `workspace '${id}' not found` })
329
+ return
330
+ }
331
+ } catch (error) {
332
+ writeRouteError(res, error, 500)
333
+ return
334
+ }
335
+ writeJson(res, 200, { ok: true })
336
+ return
337
+ }
338
+ writeJson(res, 405, { error: `method not allowed: ${req.method}` })
339
+ },
340
+ }
341
+
342
+ /** Browse a remote directory tree (SFTP readdir via the engine) for the
343
+ * workspace picker. Query: alias + path (default home when omitted). */
344
+ const dirRoute: WebRoute = {
345
+ kind: 'exact',
346
+ path: WORKSPACE_API.sshWorkspaceDir,
347
+ handler: async (req, res) => {
348
+ if (!guard(req, res, 'GET')) return
349
+ const url = new URL(req.url ?? '/', 'http://localhost')
350
+ const alias = queryParam(url, 'alias')
351
+ let requestedPath = queryParam(url, 'path')
352
+ if (alias === undefined || alias === '') {
353
+ writeJson(res, 400, { error: 'alias query parameter is required' })
354
+ return
355
+ }
356
+ if (hosts.find(alias) === undefined) {
357
+ writeJson(res, 404, { error: `alias '${alias}' not found` })
358
+ return
359
+ }
360
+ try {
361
+ if (requestedPath === undefined || requestedPath === '') {
362
+ const home = await engine.exec(alias, 'printf %s "$HOME"', 10_000)
363
+ if (!home.success || home.stdout.trim() === '') {
364
+ writeJson(res, 502, { error: `could not resolve remote home: ${home.stderr.trim() || 'empty $HOME'}` })
365
+ return
366
+ }
367
+ requestedPath = home.stdout.trim()
368
+ }
369
+ if (requestedPath.includes('\0') || requestedPath.includes('\\') || !requestedPath.startsWith('/')) {
370
+ writeJson(res, 400, { error: `path must be an absolute POSIX path (got '${requestedPath}')` })
371
+ return
372
+ }
373
+ const abs = posix.normalize(requestedPath)
374
+ const entries = sortWorkspaceEntries(
375
+ (await engine.ls(alias, abs)).map((entry) => ({ name: entry.name, type: entry.type, size: entry.size, mtimeMs: entry.mtimeMs })),
376
+ )
377
+ writeJson(res, 200, { path: abs, entries })
378
+ } catch (error) {
379
+ writeRouteError(res, error, 502)
380
+ }
381
+ },
382
+ }
383
+
384
+ /**
385
+ * Replay host-workspace registration for every stored record. Registration
386
+ * happens only on create/delete, so a process restart (or a registration
387
+ * that failed before compensation existed) leaves records whose sidebar
388
+ * entry is missing; `registerHostWorkspace` is create-if-missing, so this is
389
+ * the idempotent startup/retry compensation. Per-record failures are
390
+ * reported, never fatal.
391
+ */
392
+ const reconcileRoute: WebRoute = {
393
+ kind: 'exact',
394
+ path: WORKSPACE_API.sshWorkspaces + '/reconcile',
395
+ handler: async (req, res) => {
396
+ if (!guard(req, res, 'POST')) return
397
+ try {
398
+ const report = await reconcileHostWorkspaces({
399
+ workspaces,
400
+ registerHostWorkspace: deps.registerHostWorkspace,
401
+ })
402
+ // The helper tolerates a failing record source for the startup path;
403
+ // an explicit HTTP request must still see the I/O failure.
404
+ if (report.listError !== undefined) {
405
+ writeJson(res, 502, { error: report.listError, code: 'io' })
406
+ return
407
+ }
408
+ writeJson(res, 200, { ok: true, registered: report.registered, failed: report.failures.length, failures: report.failures })
409
+ } catch (error) {
410
+ writeRouteError(res, error, 502)
411
+ }
412
+ },
413
+ }
414
+
415
+ return [hostsRoute, wsRoute, itemRoute, dirRoute, reconcileRoute]
416
+ }