@tiphareth/dsh-hardssh 0.1.2-alpha

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 +17794 -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 +142 -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 +99 -0
  126. package/src/client/workspace-gate.ts +232 -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/routes.ts ADDED
@@ -0,0 +1,395 @@
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
+ }
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Runtime bridge: exposes the generic workspace base (ledger, provider
3
+ * registry, router, plugin host) as a cordis service under the canonical
4
+ * `workspaceCore` name, while keeping the legacy `hardsshCore` /
5
+ * `sshWorkspaceCore` aliases pointing at the same instances so existing
6
+ * consumers keep working.
7
+ *
8
+ * This module is the DSH boundary: it imports the base (provider-agnostic)
9
+ * and the legacy SSH modules, but the base itself never imports this
10
+ * module — direction of dependency stays base ← runtime.
11
+ *
12
+ * @module @tiphareth/dsh-hardssh/runtime/workspace-core
13
+ */
14
+
15
+ import type { Context } from '@deepseek-ai/cordis'
16
+ import { homedir } from 'node:os'
17
+ import { join } from 'node:path'
18
+ import { WorkspaceProviderRegistry, type WorkspaceRegistry } from '../base/registry.ts'
19
+ import type { WorkspaceRecord } from '../base/model.ts'
20
+ import { WorkspaceLedger } from '../base/ledger.ts'
21
+ import { registerBuiltinProviders } from '../providers/index.ts'
22
+ import type { SshEngine } from '../ssh/engine.ts'
23
+ import type { HostStoreView } from '../core.ts'
24
+ import type { SshWorkspaceLedger } from '../ledger.ts'
25
+ import type { SshWorkspaceRecord } from '../protocol.ts'
26
+ import { LedgerWorkspaceRouter } from '../base/ledger-router.ts'
27
+ import type { WorkspaceProvider } from '../base/model.ts'
28
+
29
+ /** The canonical workspace-core service shape. */
30
+ export interface WorkspaceCore {
31
+ /** Generic ledger (WorkspaceRecord CRUD + anchor index). */
32
+ ledger: WorkspaceLedger
33
+ /** Provider registry (local/ssh/builtin + third-party). */
34
+ providers: WorkspaceProviderRegistry
35
+ /** Ledger+provider router for the switch facades. */
36
+ router: LedgerWorkspaceRouter
37
+ /** Registry surface exposed to plugins. */
38
+ registry: WorkspaceRegistry
39
+ /** Map a legacy SSH record into a generic WorkspaceRecord. */
40
+ fromSshRecord(record: SshWorkspaceRecord): WorkspaceRecord
41
+ }
42
+
43
+ /**
44
+ * A WorkspaceRegistry backed by the generic ledger + provider registry.
45
+ */
46
+ class LedgerBasedWorkspaceRegistry implements WorkspaceRegistry {
47
+ constructor(
48
+ private readonly ledger: WorkspaceLedger,
49
+ private readonly providerRegistry: WorkspaceProviderRegistry,
50
+ ) {}
51
+
52
+ async listWorkspaces(): Promise<WorkspaceRecord[]> {
53
+ return this.ledger.list()
54
+ }
55
+
56
+ subscribe(listener: (workspace: WorkspaceRecord) => void): () => void {
57
+ return this.ledger.subscribe((change) => listener(change.record))
58
+ }
59
+
60
+ register(provider: WorkspaceProvider): void {
61
+ this.providerRegistry.register(provider)
62
+ }
63
+
64
+ unregister(providerId: string): void {
65
+ this.providerRegistry.unregister(providerId)
66
+ }
67
+
68
+ provider(id: string): WorkspaceProvider | undefined {
69
+ return this.providerRegistry.get(id)
70
+ }
71
+
72
+ providers(): WorkspaceProvider[] {
73
+ return this.providerRegistry.list()
74
+ }
75
+ }
76
+
77
+ /** Convert a legacy SSH-bound workspace record into the generic model. */
78
+ export function sshRecordToWorkspaceRecord(record: SshWorkspaceRecord): WorkspaceRecord {
79
+ return {
80
+ schemaVersion: 1,
81
+ id: record.id,
82
+ title: record.title,
83
+ provider: {
84
+ id: 'ssh',
85
+ connectionRef: { id: record.alias, alias: record.alias },
86
+ },
87
+ location: {
88
+ kind: 'posix',
89
+ root: record.remoteRoot,
90
+ },
91
+ anchor: {
92
+ path: record.anchorPath,
93
+ mode: 'managed',
94
+ },
95
+ createdAt: record.createdAt,
96
+ updatedAt: record.createdAt,
97
+ extensions: {},
98
+ }
99
+ }
100
+
101
+ /** Persistence path for the generic ledger (~/.dsh/workspaces/index.v1.json). */
102
+ export function genericLedgerPath(): string {
103
+ return join(homedir(), '.dsh', 'workspaces', 'index.v1.json')
104
+ }
105
+
106
+ /** Anchor root for managed generic workspaces (~/.dsh/workspaces/anchors). */
107
+ export function genericAnchorRoot(): string {
108
+ return join(homedir(), '.dsh', 'workspaces', 'anchors')
109
+ }
110
+
111
+ /**
112
+ * Build and provide the workspace core on the cordis context: registers the
113
+ * builtin providers, instantiates the generic ledger (persisting under
114
+ * ~/.dsh/workspaces/index.v1.json), constructs the ledger router, and
115
+ * provides `workspaceCore`.
116
+ */
117
+ export function mountWorkspaceCore(ctx: Context, deps: { engine?: SshEngine; hosts?: HostStoreView }): WorkspaceCore {
118
+ const providers = new WorkspaceProviderRegistry()
119
+ const ledger = new WorkspaceLedger(genericLedgerPath(), genericAnchorRoot())
120
+
121
+ // Register builtin providers against the shared registry.
122
+ registerBuiltinProviders(providers, deps)
123
+
124
+ const registry: WorkspaceRegistry = new LedgerBasedWorkspaceRegistry(ledger, providers)
125
+ const router = new LedgerWorkspaceRouter(ledger, registry, {
126
+ open: async (record) => {
127
+ const provider = providers.get(record.provider.id)
128
+ if (provider === undefined) return undefined
129
+ const connection = await provider.open(record)
130
+ return connection
131
+ },
132
+ onRemoved: () => { /* connection cleanup handled by closeAll */ },
133
+ })
134
+
135
+ const core: WorkspaceCore = {
136
+ ledger,
137
+ providers,
138
+ router,
139
+ registry,
140
+ fromSshRecord: sshRecordToWorkspaceRecord,
141
+ }
142
+
143
+ ctx.provide('workspaceCore', core)
144
+ return core
145
+ }
146
+
147
+ /** The legacy SSH ledger held by the hardssh core (type bridge). */
148
+ export type { SshWorkspaceLedger }
149
+
150
+ declare module '@deepseek-ai/cordis' {
151
+ interface Context {
152
+ workspaceCore: WorkspaceCore
153
+ }
154
+ }