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