@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/backend.ts ADDED
@@ -0,0 +1,624 @@
1
+ /**
2
+ * Gated filesystem backends and the production workspace file service.
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.
7
+ */
8
+
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'
26
+ 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'
31
+ import type {
32
+ DirListing,
33
+ FileRead,
34
+ FileWriteResult,
35
+ SearchHit,
36
+ SearchView,
37
+ SshWorkspaceRecord,
38
+ WorkspaceEntry,
39
+ } from './protocol.ts'
40
+
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
+ export type BackendErrorCode =
50
+ | 'binary'
51
+ | 'too-large'
52
+ | 'conflict'
53
+ | 'outside-root'
54
+ | 'not-remote'
55
+ | 'root-mismatch'
56
+ | 'not-found'
57
+ | 'forbidden'
58
+ | 'invalid'
59
+ | 'io'
60
+
61
+ export class BackendError extends Error {
62
+ constructor(
63
+ public readonly code: BackendErrorCode,
64
+ message: string,
65
+ options?: { cause?: unknown },
66
+ ) {
67
+ super(message, options)
68
+ this.name = 'BackendError'
69
+ }
70
+ }
71
+
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>
91
+ }
92
+
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>
104
+ }
105
+
106
+ /**
107
+ * Normalize a workspace-relative path.
108
+ *
109
+ * Backslashes are rejected instead of treated as separators: the same input
110
+ * would mean a separator on Windows and a filename character on POSIX.
111
+ * Absolute paths, drive-qualified paths, NUL and ".." are rejected.
112
+ */
113
+ export function normalizeRel(raw: string): string {
114
+ if (typeof raw !== 'string') {
115
+ throw new BackendError('invalid', 'path must be a string')
116
+ }
117
+ if (raw.includes('\0')) {
118
+ throw new BackendError('invalid', 'path contains a NUL byte')
119
+ }
120
+ if (raw.includes('\\')) {
121
+ throw new BackendError('invalid', 'workspace-relative paths must use forward slashes')
122
+ }
123
+ if (raw.startsWith('/') || /^[A-Za-z]:/.test(raw) || raw.startsWith('//')) {
124
+ throw new BackendError('outside-root', 'path must be workspace-relative')
125
+ }
126
+
127
+ const parts = raw.split('/').filter(part => part !== '' && part !== '.')
128
+ if (parts.some(part => part === '..')) {
129
+ throw new BackendError('outside-root', 'path escapes root: ".." is not allowed')
130
+ }
131
+ return parts.join('/')
132
+ }
133
+
134
+ /** POSIX prefix gate. Handles "/" without producing an empty root. */
135
+ export function isInside(root: string, abs: string): boolean {
136
+ const normalizedRoot = normalizeRemoteRoot(root)
137
+ const normalizedAbs = posix.normalize(abs)
138
+ if (!posix.isAbsolute(normalizedAbs)) return false
139
+ if (normalizedRoot === '/') return normalizedAbs.startsWith('/')
140
+ return normalizedAbs === normalizedRoot || normalizedAbs.startsWith(`${normalizedRoot}/`)
141
+ }
142
+
143
+ /** Resolve a normalized relative path below a POSIX root. */
144
+ export function relToAbs(root: string, rel: string): string {
145
+ const normalizedRoot = normalizeRemoteRoot(root)
146
+ const normalizedRel = normalizeRel(rel)
147
+ const abs = normalizedRel === '' ? normalizedRoot : posix.resolve(normalizedRoot, normalizedRel)
148
+ if (!isInside(normalizedRoot, abs)) {
149
+ throw new BackendError('outside-root', `path '${rel}' escapes remote root '${normalizedRoot}'`)
150
+ }
151
+ return abs
152
+ }
153
+
154
+ export { shellQuote }
155
+
156
+ /** Stable dir-first, case-insensitive ordering shared by every backend. */
157
+ export function sortWorkspaceEntries(entries: WorkspaceEntry[]): WorkspaceEntry[] {
158
+ return [...entries].sort((a, b) => {
159
+ const aDir = a.type === 'dir'
160
+ const bDir = b.type === 'dir'
161
+ if (aDir !== bDir) return aDir ? -1 : 1
162
+ const insensitive = a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })
163
+ return insensitive !== 0 ? insensitive : a.name.localeCompare(b.name)
164
+ })
165
+ }
166
+
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
+
222
+ /** Map a BackendError to its stable HTTP status (routes use this). */
223
+ export function backendErrorStatus(error: BackendError, ioStatus: 500 | 502): number {
224
+ switch (error.code) {
225
+ case 'outside-root':
226
+ case 'root-mismatch':
227
+ case 'forbidden':
228
+ return 403
229
+ case 'not-found':
230
+ return 404
231
+ case 'conflict':
232
+ return 409
233
+ case 'binary':
234
+ case 'invalid':
235
+ return 400
236
+ case 'too-large':
237
+ return 413
238
+ case 'not-remote':
239
+ return 503
240
+ case 'io':
241
+ return ioStatus
242
+ }
243
+ }
244
+
245
+ function normalizeRemoteRoot(root: string): string {
246
+ if (typeof root !== 'string' || root.includes('\0')) {
247
+ throw new BackendError('invalid', 'remote root is invalid')
248
+ }
249
+ if (root.includes('\\') || !posix.isAbsolute(root)) {
250
+ throw new BackendError('root-mismatch', `remote root must be an absolute POSIX path (got '${root}')`)
251
+ }
252
+ const normalized = posix.normalize(root)
253
+ return normalized === '/' ? '/' : normalized.replace(/\/+$/, '')
254
+ }
255
+
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
+
277
+ /**
278
+ * Local implementation retained for explicit local-only consumers. It is
279
+ * deliberately NOT selected by LedgerWorkspaceFileService.
280
+ */
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
+
449
+ 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,
547
+ ) {}
548
+
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),
576
+ }
577
+ }
578
+
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) }
623
+ }
624
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Provider capability interfaces — the resource-access contracts a workspace
3
+ * provider may offer. Providers implement only what they support; consumers
4
+ * look up capabilities through `WorkspaceConnection.get(...)` and degrade
5
+ * gracefully when undefined.
6
+ *
7
+ * All paths in these interfaces are workspace-relative POSIX-like paths
8
+ * resolved against the record's `location.root` by the provider. The provider
9
+ * is responsible for root confinement (and any extra semantics, e.g. SFTP
10
+ * mode bits), so a consumer can treat every workspace uniformly.
11
+ *
12
+ * @module @tiphareth/dsh-hardssh/base/capability
13
+ */
14
+
15
+ /** Uniform stat shape (both backends normalize to this). */
16
+ export interface WorkspaceStat {
17
+ type: 'dir' | 'file' | 'other'
18
+ size: number
19
+ mtimeMs: number
20
+ mode?: number
21
+ }
22
+
23
+ /** One directory entry. */
24
+ export interface WorkspaceDirEntry {
25
+ name: string
26
+ type: 'dir' | 'file' | 'other'
27
+ size: number
28
+ mtimeMs: number
29
+ }
30
+
31
+ /** File-system capability. */
32
+ export interface WorkspaceFileSystem {
33
+ stat(path: string, signal?: AbortSignal): Promise<WorkspaceStat | undefined>
34
+ list(path: string, signal?: AbortSignal): Promise<WorkspaceDirEntry[]>
35
+ readFile(path: string, signal?: AbortSignal): Promise<Uint8Array>
36
+ writeFile(path: string, data: Uint8Array, signal?: AbortSignal): Promise<void>
37
+ mkdir(path: string, options?: { recursive?: boolean; signal?: AbortSignal }): Promise<void>
38
+ rm(path: string, options?: { recursive?: boolean; signal?: AbortSignal }): Promise<void>
39
+ rename(from: string, to: string, signal?: AbortSignal): Promise<void>
40
+ }
41
+
42
+ /** One process result. */
43
+ export interface WorkspaceProcessResult {
44
+ stdout: string
45
+ stderr: string
46
+ exitCode: number
47
+ timedOut?: boolean
48
+ durationMs?: number
49
+ }
50
+
51
+ /** Subprocess capability. */
52
+ export interface WorkspaceProcessRuntime {
53
+ exec(command: string, options?: { timeoutMs?: number; cwd?: string; signal?: AbortSignal }): Promise<WorkspaceProcessResult>
54
+ }
55
+
56
+ /** Interactive terminal capability. */
57
+ export interface WorkspaceTerminalService {
58
+ openTerminal(cols: number, rows: number): Promise<WorkspaceTerminalHandle>
59
+ }
60
+
61
+ /** Minimal PTY handle (mirrors the ssh provider's duck-typed handle). */
62
+ export interface WorkspaceTerminalHandle {
63
+ shell: string
64
+ write(data: string): void
65
+ resize(cols: number, rows: number): void
66
+ kill(): void
67
+ onData(handler: (data: string) => void): { dispose(): void }
68
+ onExit(handler: (event: { exitCode: number }) => void): { dispose(): void }
69
+ }
70
+
71
+ /** One filename-search hit. */
72
+ export interface WorkspaceSearchHit {
73
+ /** Absolute path on the provider. */
74
+ path: string
75
+ /** Path relative to the search root. */
76
+ rel: string
77
+ isDir: boolean
78
+ }
79
+
80
+ /** Filename / content search capability. */
81
+ export interface WorkspaceSearchService {
82
+ /** glob-style filename search. */
83
+ glob(pattern: string, options?: { root?: string; maxDepth?: number; signal?: AbortSignal }): Promise<{ hits: WorkspaceSearchHit[]; truncated: boolean }>
84
+ /** fixed-string content search (skips .git / node_modules). */
85
+ grep(fixedPhrase: string, options?: { root?: string; signal?: AbortSignal }): Promise<{ hits: WorkspaceSearchHit[]; truncated: boolean }>
86
+ }