@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/backend.ts CHANGED
@@ -1,51 +1,25 @@
1
1
  /**
2
- * Gated filesystem backends and the production workspace file service.
2
+ * The generic record source the SSH workspace API projects.
3
3
  *
4
- * UI workspace routes resolve an exact ledger anchor and then create a
5
- * request-scoped RemoteBackend. There is no implicit local fallback and no
6
- * dependency on the legacy global SSH mode state.
4
+ * `GenericWorkspaceStore` is the single record source: it projects the generic
5
+ * `WorkspaceLedger` (via `WorkspaceCore`) into the client SSH DTO and performs
6
+ * workspace CRUD through the generic core. There is no implicit local fallback
7
+ * and no dependency on any retired global SSH mode state.
7
8
  */
8
9
 
9
- import type { Dirent, Stats } from 'node:fs'
10
- import {
11
- mkdir,
12
- readdir,
13
- readFile,
14
- realpath,
15
- stat,
16
- writeFile,
17
- } from 'node:fs/promises'
18
- import {
19
- dirname,
20
- isAbsolute,
21
- join,
22
- relative,
23
- resolve,
24
- sep,
25
- } from 'node:path'
10
+ import { realpathSync } from 'node:fs'
11
+ import { randomUUID } from 'node:crypto'
12
+ import { join } from 'node:path'
26
13
  import { posix } from 'node:path'
27
- import type { SshEngine } from './ssh/engine.ts'
28
- import { shellQuote } from './shell.ts'
29
- import { RemoteSearchService, type RemoteNameSearch } from './remote-search.ts'
30
- import type { SshWorkspaceLedger } from './ledger.ts'
14
+ import { defaultTitle, normalizeRemoteRoot as normalizeSshRemoteRoot } from './ledger.ts'
15
+ import { isPathUnderAnchor as baseIsPathUnderAnchor, normalizeAnchorPath as baseNormalizeAnchorPath, WORKSPACE_LEDGER_SCHEMA_VERSION } from './base/ledger.ts'
16
+ import type { WorkspaceRecord } from './base/model.ts'
17
+ import type { WorkspaceCore } from './runtime/workspace-core.ts'
31
18
  import type {
32
- DirListing,
33
- FileRead,
34
- FileWriteResult,
35
- SearchHit,
36
- SearchView,
37
19
  SshWorkspaceRecord,
38
20
  WorkspaceEntry,
39
21
  } from './protocol.ts'
40
22
 
41
- export const SEARCH_HIT_CAP = 200
42
- export const SEARCH_SCAN_CAP = 20_000
43
- export const SEARCH_MAX_DEPTH = 4
44
- export const REMOTE_SEARCH_TIMEOUT_MS = 20_000
45
-
46
- const SEARCH_SKIP_DIRS = new Set(['.git', 'node_modules'])
47
- const TREE_SKIP_DIRS = new Set(['.git'])
48
-
49
23
  export type BackendErrorCode =
50
24
  | 'binary'
51
25
  | 'too-large'
@@ -69,38 +43,51 @@ export class BackendError extends Error {
69
43
  }
70
44
  }
71
45
 
72
- export interface WorkspaceFileContext {
73
- workspaceId: string
74
- requestedRoot: string
75
- anchorPath: string
76
- alias: string
77
- remoteRoot: string
78
- }
79
-
80
- export interface WorkspaceFileService {
81
- resolveContext(root: string): Promise<WorkspaceFileContext>
82
- list(root: string, rel: string): Promise<DirListing>
83
- read(root: string, rel: string): Promise<FileRead>
84
- write(
85
- root: string,
86
- rel: string,
87
- content: string,
88
- expectedMtime?: number,
89
- ): Promise<FileWriteResult>
90
- search(root: string, query: string): Promise<SearchView>
46
+ /**
47
+ * The SSH-workspace record source the workspace routes, agent tools, and the
48
+ * SSH host-delete reference guard satisfy. Every consumer depends on this
49
+ * narrow view, so none of them knows which ledger implementation is
50
+ * authoritative.
51
+ */
52
+ export interface WorkspaceStoreView {
53
+ /** Every SSH-bound workspace as the client DTO. */
54
+ list(): Promise<SshWorkspaceRecord[]>
55
+ get(id: string): Promise<SshWorkspaceRecord | undefined>
56
+ findByAnchor(path: string): Promise<SshWorkspaceRecord | undefined>
57
+ /** Synchronous anchor lookup used by the session announcement. */
58
+ findByAnchorSync(path: string): SshWorkspaceRecord | undefined
59
+ /** Create an SSH workspace (title/alias/remoteRoot); the anchor is managed by the store. */
60
+ create(input: { title: string; alias: string; remoteRoot: string }): Promise<SshWorkspaceRecord>
61
+ rename(id: string, title: string): Promise<SshWorkspaceRecord | undefined>
62
+ remove(id: string): Promise<boolean>
63
+ /** The shared anchor root (fail-closed gating before the runtime is ready). */
64
+ anchorsRoot(): string
91
65
  }
92
66
 
93
- export interface WorkspaceBackend {
94
- assertRoot(root: string): void
95
- list(root: string, rel: string): Promise<DirListing>
96
- read(root: string, rel: string): Promise<FileRead>
97
- write(
98
- root: string,
99
- rel: string,
100
- content: string,
101
- expectedMtime?: number,
102
- ): Promise<FileWriteResult>
103
- search(root: string, query: string): Promise<SearchView>
67
+ /**
68
+ * Project one generic SSH `WorkspaceRecord` into the client `SshWorkspaceRecord`
69
+ * DTO (id/title/alias/remoteRoot/anchorPath/createdAt). The wire contract is
70
+ * preserved server-side; no client or protocol change accompanies the cutover.
71
+ * A generic ledger hands out provider-agnostic WorkspaceRecord values, so the
72
+ * projection cannot reuse any legacy accessor — it exists once for the generic
73
+ * record source.
74
+ */
75
+ function toSshWorkspaceDto(record: WorkspaceRecord): SshWorkspaceRecord {
76
+ // A legacy alias maps 1:1 to provider.connectionRef.{id,alias} (migration
77
+ // keeps both equal); prefer the display alias, falling back to the ref id.
78
+ const alias = record.provider.connectionRef?.alias ?? record.provider.connectionRef?.id ?? ''
79
+ // Every SSH record created through this plugin carries a managed anchor; a
80
+ // hand-made record without one projects an empty anchor (never throws, so
81
+ // stale sidebar lists keep rendering) and simply never binds a session.
82
+ const anchorPath = record.anchor?.path ?? ''
83
+ return {
84
+ id: record.id,
85
+ title: record.title,
86
+ alias,
87
+ remoteRoot: record.location.root,
88
+ anchorPath,
89
+ createdAt: record.createdAt,
90
+ }
104
91
  }
105
92
 
106
93
  /**
@@ -151,9 +138,7 @@ export function relToAbs(root: string, rel: string): string {
151
138
  return abs
152
139
  }
153
140
 
154
- export { shellQuote }
155
-
156
- /** Stable dir-first, case-insensitive ordering shared by every backend. */
141
+ /** Stable dir-first, case-insensitive ordering shared by every listing. */
157
142
  export function sortWorkspaceEntries(entries: WorkspaceEntry[]): WorkspaceEntry[] {
158
143
  return [...entries].sort((a, b) => {
159
144
  const aDir = a.type === 'dir'
@@ -164,60 +149,6 @@ export function sortWorkspaceEntries(entries: WorkspaceEntry[]): WorkspaceEntry[
164
149
  })
165
150
  }
166
151
 
167
- export type WorkspacePathFlavor = 'local' | 'remote'
168
-
169
- /** Return abs relative to root, rejecting prefix-collision/parent traversal. */
170
- export function relativeWorkspacePath(
171
- root: string,
172
- abs: string,
173
- flavor: WorkspacePathFlavor,
174
- ): string {
175
- if (flavor === 'remote') {
176
- const normalizedRoot = normalizeRemoteRoot(root)
177
- const normalizedAbs = posix.normalize(abs)
178
- if (!isInside(normalizedRoot, normalizedAbs)) {
179
- throw new BackendError('outside-root', `path '${abs}' is outside root '${root}'`)
180
- }
181
- const rel = posix.relative(normalizedRoot, normalizedAbs)
182
- return rel === '' ? '' : normalizeRel(rel)
183
- }
184
-
185
- const normalizedRoot = resolve(root)
186
- const normalizedAbs = resolve(abs)
187
- const rel = relative(normalizedRoot, normalizedAbs)
188
- if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
189
- throw new BackendError('outside-root', `path '${abs}' is outside root '${root}'`)
190
- }
191
- return rel === '' ? '' : rel.split(sep).join('/')
192
- }
193
-
194
- /** Normalize filesystem/SFTP errors to stable route-visible codes. */
195
- export function toBackendIoError(error: unknown, path: string): BackendError {
196
- if (error instanceof BackendError) return error
197
- const errno = error as NodeJS.ErrnoException
198
- const message = error instanceof Error ? error.message : String(error)
199
- const lower = message.toLowerCase()
200
-
201
- if (
202
- errno.code === 'ENOENT'
203
- || errno.code === 'ENOTDIR'
204
- || /\bno such file\b/.test(lower)
205
- || /\bnot found\b/.test(lower)
206
- ) {
207
- return new BackendError('not-found', `'${path}' was not found`, { cause: error })
208
- }
209
- if (
210
- errno.code === 'EACCES'
211
- || errno.code === 'EPERM'
212
- || /\bpermission denied\b/.test(lower)
213
- ) {
214
- return new BackendError('forbidden', `permission denied for '${path}'`, { cause: error })
215
- }
216
- if (/\bmtime conflict\b/.test(lower) || /\bconflict\b/.test(lower)) {
217
- return new BackendError('conflict', message, { cause: error })
218
- }
219
- return new BackendError('io', `'${path}': ${message}`, { cause: error })
220
- }
221
152
 
222
153
  /** Map a BackendError to its stable HTTP status (routes use this). */
223
154
  export function backendErrorStatus(error: BackendError, ioStatus: 500 | 502): number {
@@ -253,372 +184,113 @@ function normalizeRemoteRoot(root: string): string {
253
184
  return normalized === '/' ? '/' : normalized.replace(/\/+$/, '')
254
185
  }
255
186
 
256
- function decodeText(buffer: Buffer, path: string): string {
257
- const probe = buffer.subarray(0, Math.min(buffer.length, 8192))
258
- if (probe.includes(0)) {
259
- throw new BackendError('binary', `'${path}' is not a text file`)
260
- }
261
- return buffer.toString('utf8')
262
- }
263
-
264
- function canonicalLocalPath(path: string): string {
265
- const result = resolve(path).replace(/[\\/]+$/, '')
266
- return process.platform === 'win32' ? result.toLowerCase() : result
267
- }
268
-
269
- function sameLocalPath(a: string, b: string): boolean {
270
- return canonicalLocalPath(a) === canonicalLocalPath(b)
271
- }
272
-
273
- function sanitizeSearchQuery(query: string): string {
274
- return query.replace(/\0/g, '').replace(/[\r\n]/g, ' ').slice(0, 128)
275
- }
276
187
 
277
188
  /**
278
- * Local implementation retained for explicit local-only consumers. It is
279
- * deliberately NOT selected by LedgerWorkspaceFileService.
189
+ * The single record source: projects the generic `WorkspaceLedger` (via
190
+ * `WorkspaceCore`) into the client SSH DTO and performs workspace CRUD through
191
+ * the generic core. Routes, agent tools, and the SSH host-delete guard stay
192
+ * store-agnostic behind `WorkspaceStoreView`. New SSH workspaces keep the
193
+ * established anchor layout (~/.dsh/ssh-workspaces/<id>) so existing
194
+ * sidebar/session bindings and the host workspace registry stay unchanged.
280
195
  */
281
- export class LocalBackend implements WorkspaceBackend {
282
- assertRoot(root: string): void {
283
- if (!isAbsolute(root)) {
284
- throw new BackendError('outside-root', `root must be an absolute local path (got '${root}')`)
285
- }
286
- }
287
-
288
- async list(root: string, rel: string): Promise<DirListing> {
289
- const abs = await this.resolvePath(root, rel)
290
- let dirents: Dirent[]
291
- try {
292
- dirents = await readdir(abs, { withFileTypes: true })
293
- } catch (error) {
294
- throw toBackendIoError(error, abs)
295
- }
296
- const entries = dirents
297
- .filter(entry => !TREE_SKIP_DIRS.has(entry.name))
298
- .map((entry): WorkspaceEntry => ({
299
- name: entry.name,
300
- type: entry.isDirectory() ? 'dir' : entry.isFile() ? 'file' : 'other',
301
- size: 0,
302
- mtimeMs: 0,
303
- }))
304
- return { path: abs, entries: sortWorkspaceEntries(entries) }
305
- }
306
-
307
- async read(root: string, rel: string): Promise<FileRead> {
308
- const abs = await this.resolvePath(root, rel)
309
- let buffer: Buffer
310
- let stats: Stats
311
- try {
312
- ;[buffer, stats] = await Promise.all([readFile(abs), stat(abs)])
313
- } catch (error) {
314
- throw toBackendIoError(error, abs)
315
- }
316
- if (!stats.isFile()) {
317
- throw new BackendError('invalid', `'${abs}' is not a regular file`)
318
- }
319
- return { path: abs, content: decodeText(buffer, abs), size: stats.size, mtime: stats.mtimeMs }
320
- }
321
-
322
- async write(
323
- root: string,
324
- rel: string,
325
- content: string,
326
- expectedMtime?: number,
327
- ): Promise<FileWriteResult> {
328
- const abs = await this.resolvePath(root, rel)
329
- if (expectedMtime !== undefined) {
330
- let stats: Stats
331
- try {
332
- stats = await stat(abs)
333
- } catch (error) {
334
- throw toBackendIoError(error, abs)
335
- }
336
- if (Math.round(stats.mtimeMs) !== Math.round(expectedMtime)) {
337
- throw new BackendError(
338
- 'conflict',
339
- `mtime conflict: current ${Math.round(stats.mtimeMs)} != expected ${Math.round(expectedMtime)}`,
340
- )
341
- }
342
- }
343
- try {
344
- await mkdir(dirname(abs), { recursive: true })
345
- // Re-run the realpath-walk after mkdir: a concurrent actor may have
346
- // introduced a symlink while the parent chain was being created.
347
- await this.assertRealpathWalk(root, abs, rel)
348
- await writeFile(abs, content, 'utf8')
349
- const stats = await stat(abs)
350
- return { mtime: stats.mtimeMs }
351
- } catch (error) {
352
- throw toBackendIoError(error, abs)
353
- }
354
- }
355
-
356
- async search(root: string, query: string): Promise<SearchView> {
357
- const rootAbs = await this.resolvePath(root, '')
358
- const needle = query.toLocaleLowerCase()
359
- const hits: SearchHit[] = []
360
- let scanned = 0
361
- let truncated = false
362
-
363
- const walk = async (dir: string, depth: number): Promise<void> => {
364
- if (depth > SEARCH_MAX_DEPTH) return
365
- if (hits.length >= SEARCH_HIT_CAP || scanned >= SEARCH_SCAN_CAP) {
366
- truncated = true
367
- return
368
- }
369
- let dirents: Dirent[]
370
- try {
371
- dirents = await readdir(dir, { withFileTypes: true })
372
- } catch {
373
- return
374
- }
375
- for (const entry of dirents) {
376
- scanned += 1
377
- if (scanned > SEARCH_SCAN_CAP) {
378
- truncated = true
379
- return
380
- }
381
- if (entry.isDirectory() && SEARCH_SKIP_DIRS.has(entry.name)) continue
382
- const abs = join(dir, entry.name)
383
- // Directory symlinks are not followed (Dirent.isDirectory() is false
384
- // for symlinks).
385
- if (entry.isDirectory()) {
386
- if (entry.name.toLocaleLowerCase().includes(needle)) {
387
- hits.push({ path: abs, rel: relativeWorkspacePath(rootAbs, abs, 'local'), isDir: true })
388
- }
389
- await walk(abs, depth + 1)
390
- } else if (entry.isFile() && entry.name.toLocaleLowerCase().includes(needle)) {
391
- hits.push({ path: abs, rel: relativeWorkspacePath(rootAbs, abs, 'local'), isDir: false })
392
- }
393
- if (hits.length >= SEARCH_HIT_CAP) {
394
- truncated = true
395
- return
396
- }
397
- }
398
- }
399
-
400
- await walk(rootAbs, 0)
401
- return { query, hits: hits.slice(0, SEARCH_HIT_CAP), truncated }
402
- }
403
-
404
- private async resolvePath(root: string, rel: string): Promise<string> {
405
- this.assertRoot(root)
406
- const normalized = normalizeRel(rel)
407
- const normalizedRoot = resolve(root)
408
- const abs = normalized === '' ? normalizedRoot : resolve(normalizedRoot, ...normalized.split('/'))
409
- relativeWorkspacePath(normalizedRoot, abs, 'local')
410
- await this.assertRealpathWalk(normalizedRoot, abs, rel)
411
- return abs
412
- }
413
-
414
- /** Resolve the nearest existing ancestor; root and every existing target
415
- * ancestor must resolve below the real workspace root. */
416
- private async assertRealpathWalk(root: string, abs: string, rel: string): Promise<void> {
417
- let realRoot: string
418
- try {
419
- realRoot = await realpath(root)
420
- } catch (error) {
421
- throw toBackendIoError(error, root)
422
- }
423
- let probe = abs
424
- for (;;) {
425
- try {
426
- const realProbe = await realpath(probe)
427
- relativeWorkspacePath(realRoot, realProbe, 'local')
428
- return
429
- } catch (error) {
430
- if (error instanceof BackendError) throw error
431
- const code = (error as NodeJS.ErrnoException).code
432
- if (code !== 'ENOENT' && code !== 'ENOTDIR') {
433
- throw toBackendIoError(error, probe)
434
- }
435
- const parent = dirname(probe)
436
- if (parent === probe) {
437
- throw new BackendError('outside-root', `path cannot be resolved below workspace root: '${rel}'`)
438
- }
439
- probe = parent
440
- }
441
- }
442
- }
443
- }
444
-
445
- /** A request-scoped remote backend bound to one immutable ledger record. */
446
- export class RemoteBackend implements WorkspaceBackend {
447
- private readonly remoteRoot: string
448
-
196
+ export class GenericWorkspaceStore implements WorkspaceStoreView {
449
197
  constructor(
450
- private readonly engine: SshEngine,
451
- private readonly record: SshWorkspaceRecord,
452
- private readonly searchService: RemoteSearchService,
453
- ) {
454
- this.remoteRoot = normalizeRemoteRoot(record.remoteRoot)
455
- }
456
-
457
- assertRoot(root: string): void {
458
- if (normalizeRemoteRoot(root) !== this.remoteRoot) {
459
- throw new BackendError('root-mismatch', `root '${root}' does not match remote workspace root '${this.remoteRoot}'`)
460
- }
461
- }
462
-
463
- async list(root: string, rel: string): Promise<DirListing> {
464
- this.assertRoot(root)
465
- const abs = relToAbs(this.remoteRoot, rel)
466
- try {
467
- const entries = await this.engine.ls(this.record.alias, abs)
468
- return {
469
- path: abs,
470
- entries: sortWorkspaceEntries(
471
- entries
472
- .filter(entry => !TREE_SKIP_DIRS.has(entry.name))
473
- .map((entry): WorkspaceEntry => ({
474
- name: entry.name,
475
- type: entry.type,
476
- size: entry.size,
477
- mtimeMs: entry.mtimeMs,
478
- })),
479
- ),
480
- }
481
- } catch (error) {
482
- throw toBackendIoError(error, abs)
483
- }
484
- }
485
-
486
- async read(root: string, rel: string): Promise<FileRead> {
487
- this.assertRoot(root)
488
- const abs = relToAbs(this.remoteRoot, rel)
489
- try {
490
- const result = await this.engine.readFile(this.record.alias, abs)
491
- return {
492
- path: abs,
493
- content: decodeText(result.content, abs),
494
- size: result.size,
495
- mtime: result.mtime,
496
- }
497
- } catch (error) {
498
- throw toBackendIoError(error, abs)
499
- }
500
- }
501
-
502
- async write(
503
- root: string,
504
- rel: string,
505
- content: string,
506
- expectedMtime?: number,
507
- ): Promise<FileWriteResult> {
508
- this.assertRoot(root)
509
- const abs = relToAbs(this.remoteRoot, rel)
510
- try {
511
- const result = await this.engine.writeFile(
512
- this.record.alias,
513
- abs,
514
- Buffer.from(content, 'utf8'),
515
- expectedMtime,
516
- )
517
- return { mtime: result.mtime }
518
- } catch (error) {
519
- throw toBackendIoError(error, abs)
520
- }
521
- }
522
-
523
- async search(root: string, query: string): Promise<SearchView> {
524
- this.assertRoot(root)
525
- let found: RemoteNameSearch
526
- try {
527
- found = await this.searchService.searchNames({ alias: this.record.alias, root: this.remoteRoot }, query)
528
- } catch (error) {
529
- throw toBackendIoError(error, this.remoteRoot)
530
- }
531
- const hits: SearchHit[] = []
532
- for (const hit of found.hits) {
533
- // Never trust command output as authorization evidence.
534
- const rel = relativeWorkspacePath(this.remoteRoot, hit.path, 'remote')
535
- hits.push({ path: posix.normalize(hit.path), rel, isDir: hit.isDir })
536
- }
537
- return { query: found.query, hits, truncated: found.truncated }
538
- }
539
- }
540
-
541
- /** Production UI service: exact ledger anchor -> request-scoped backend. */
542
- export class LedgerWorkspaceFileService implements WorkspaceFileService {
543
- constructor(
544
- private readonly ledger: SshWorkspaceLedger,
545
- private readonly engine: SshEngine,
546
- private readonly searchService: RemoteSearchService,
198
+ private readonly core: WorkspaceCore,
199
+ /** Resolves when the generic runtime (migration + initialize) finished;
200
+ * rejects on any startup failure so every consumer fails closed instead
201
+ * of silently reading an un-migrated or corrupt ledger. */
202
+ private readonly ready: Promise<void>,
203
+ /** Anchor root for newly created SSH workspaces (~/.dsh/ssh-workspaces). */
204
+ private readonly anchorBase: string,
547
205
  ) {}
548
206
 
549
- async resolveContext(root: string): Promise<WorkspaceFileContext> {
550
- if (
551
- typeof root !== 'string'
552
- || root === ''
553
- || root.includes('\0')
554
- || !isAbsolute(root)
555
- ) {
556
- throw new BackendError('outside-root', 'root must be an absolute workspace anchor path')
557
- }
558
-
559
- const record = await this.ledger.findByAnchor(root)
560
- if (record === undefined) {
561
- throw new BackendError('not-remote', `root '${root}' is not bound to an SSH workspace`)
562
- }
563
-
564
- // findByAnchor intentionally accepts descendants for seam routing. The
565
- // HTTP workspace API is narrower and requires the exact anchor.
566
- if (!sameLocalPath(root, record.anchorPath)) {
567
- throw new BackendError('outside-root', `root '${root}' must exactly match workspace anchor '${record.anchorPath}'`)
568
- }
569
-
570
- return {
571
- workspaceId: record.id,
572
- requestedRoot: root,
573
- anchorPath: record.anchorPath,
574
- alias: record.alias,
575
- remoteRoot: normalizeRemoteRoot(record.remoteRoot),
207
+ /** WorkspaceCore.list() exposes every provider, but the SSH DTO surface is SSH-only, so this filters before projecting. */
208
+ async list(): Promise<SshWorkspaceRecord[]> {
209
+ await this.ready
210
+ const records = await this.core.list()
211
+ return records.filter(record => record.provider.id === 'ssh').map(toSshWorkspaceDto)
212
+ }
213
+
214
+ /** WorkspaceCore.get() returns generic records, so this narrows to SSH and projects the DTO. */
215
+ async get(id: string): Promise<SshWorkspaceRecord | undefined> {
216
+ await this.ready
217
+ const record = await this.core.get(id)
218
+ return record === undefined || record.provider.id !== 'ssh' ? undefined : toSshWorkspaceDto(record)
219
+ }
220
+
221
+ /** WorkspaceCore.findByAnchor() also resolves non-SSH workspaces, so this projects only SSH owners. */
222
+ async findByAnchor(path: string): Promise<SshWorkspaceRecord | undefined> {
223
+ await this.ready
224
+ const record = await this.core.findByAnchor(path)
225
+ return record === undefined || record.provider.id !== 'ssh' ? undefined : toSshWorkspaceDto(record)
226
+ }
227
+
228
+ /** WorkspaceCore has no synchronous anchor lookup on the public surface, so this resolves the detached ledger snapshot lexically. */
229
+ findByAnchorSync(path: string): SshWorkspaceRecord | undefined {
230
+ const canonical = safeRealpathSync(path)
231
+ if (canonical === undefined) return undefined
232
+ const anchors = this.core.ledger.snapshotSync().records
233
+ .filter(record => record.provider.id === 'ssh' && record.anchor !== undefined)
234
+ .map(record => ({ normalized: baseNormalizeAnchorPath(record.anchor!.path), record }))
235
+ .sort((a, b) => b.normalized.length - a.normalized.length) // longest prefix first
236
+ for (const { normalized, record } of anchors) {
237
+ if (baseIsPathUnderAnchor(normalized, canonical)) return toSshWorkspaceDto(record)
576
238
  }
239
+ return undefined
240
+ }
241
+
242
+ /** WorkspaceLedger.create() cannot generate SSH defaults or the SSH anchor layout, so this maps the SSH create input into a generic record first. */
243
+ async create(input: { title: string; alias: string; remoteRoot: string }): Promise<SshWorkspaceRecord> {
244
+ await this.ready
245
+ const root = normalizeSshRemoteRoot(input.remoteRoot)
246
+ // Anchor under the SSH anchor base (not the generic anchor root): ids and
247
+ // anchors must stay stable for sidebar/session binding.
248
+ const id = randomUUID()
249
+ const record = await this.core.create({
250
+ schemaVersion: WORKSPACE_LEDGER_SCHEMA_VERSION,
251
+ id,
252
+ title: input.title.trim() === '' ? defaultTitle(root, input.alias) : input.title.trim(),
253
+ provider: { id: 'ssh', connectionRef: { id: input.alias, alias: input.alias } },
254
+ location: { kind: 'posix', root },
255
+ anchor: { path: join(this.anchorBase, id), mode: 'managed' },
256
+ })
257
+ return toSshWorkspaceDto(record)
258
+ }
259
+
260
+ /** Title edits route through the generic update (the generic ledger has no SSH-specific rename). */
261
+ async rename(id: string, title: string): Promise<SshWorkspaceRecord | undefined> {
262
+ await this.ready
263
+ // This is an SSH-only projection over a provider-neutral core. Guard the
264
+ // provider before mutating so an arbitrary generic id cannot rename a local
265
+ // or third-party workspace through the SSH API.
266
+ const existing = await this.core.get(id)
267
+ if (existing === undefined || existing.provider.id !== 'ssh') return undefined
268
+ const record = await this.core.update(id, { title: title.trim() })
269
+ return record === undefined || record.provider.id !== 'ssh' ? undefined : toSshWorkspaceDto(record)
270
+ }
271
+
272
+ /** WorkspaceCore.remove() is the generic delete; the anchor directory stays in place (the host workspace may still reference it). */
273
+ async remove(id: string): Promise<boolean> {
274
+ await this.ready
275
+ // Match every other projection method: only SSH-owned records may cross
276
+ // this mutation boundary. No new Core CAS surface is introduced here.
277
+ const existing = await this.core.get(id)
278
+ if (existing === undefined || existing.provider.id !== 'ssh') return false
279
+ return this.core.remove(id)
280
+ }
281
+
282
+ /** The SSH anchor base this store creates its managed anchors under
283
+ * (~/.dsh/ssh-workspaces). */
284
+ anchorsRoot(): string {
285
+ return this.anchorBase
577
286
  }
287
+ }
578
288
 
579
- async list(root: string, rel: string): Promise<DirListing> {
580
- const { record, backend } = await this.resolveBackend(root)
581
- return backend.list(record.remoteRoot, rel)
582
- }
583
-
584
- async read(root: string, rel: string): Promise<FileRead> {
585
- const { record, backend } = await this.resolveBackend(root)
586
- return backend.read(record.remoteRoot, rel)
587
- }
588
-
589
- async write(
590
- root: string,
591
- rel: string,
592
- content: string,
593
- expectedMtime?: number,
594
- ): Promise<FileWriteResult> {
595
- const { record, backend } = await this.resolveBackend(root)
596
- return backend.write(record.remoteRoot, rel, content, expectedMtime)
597
- }
598
-
599
- async search(root: string, query: string): Promise<SearchView> {
600
- const { record, backend } = await this.resolveBackend(root)
601
- return backend.search(record.remoteRoot, query)
602
- }
603
-
604
- private async resolveBackend(root: string): Promise<{
605
- record: SshWorkspaceRecord
606
- backend: RemoteBackend
607
- }> {
608
- const context = await this.resolveContext(root)
609
- const record = await this.ledger.get(context.workspaceId)
610
-
611
- // A concurrent deletion between resolveContext and dispatch must fail
612
- // closed rather than silently selecting another backend.
613
- if (
614
- record === undefined
615
- || !sameLocalPath(record.anchorPath, context.anchorPath)
616
- || record.alias !== context.alias
617
- || normalizeRemoteRoot(record.remoteRoot) !== context.remoteRoot
618
- ) {
619
- throw new BackendError('not-remote', `workspace '${context.workspaceId}' is no longer available`)
620
- }
621
-
622
- return { record, backend: new RemoteBackend(this.engine, record, this.searchService) }
289
+ /** resolve() can throw for a vanished cwd; the sync lookup must stay total. */
290
+ function safeRealpathSync(path: string): string | undefined {
291
+ try {
292
+ return realpathSync(path)
293
+ } catch {
294
+ return undefined
623
295
  }
624
296
  }