@tiphareth/dsh-hardssh 0.1.2 → 0.2.3

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 +77 -50
  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 +333 -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 +42 -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 +59 -26
  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 +171 -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
@@ -30,6 +30,13 @@ import type { WorkspaceState } from '../protocol.ts'
30
30
  import { quoteShellArg } from './environment.ts'
31
31
 
32
32
  const BINARY_SAMPLE_BYTES = 8192
33
+ /** Hard cap on a whole-file `readText` (default reads are bounded so no
34
+ * caller can force an unbounded `Buffer.concat` of a huge remote file). */
35
+ const FULL_READ_MAX_BYTES = 32 * 1024 * 1024
36
+ /** Exclusive bound for the contextual-diff basis read; mirrors the local
37
+ * backend's `diffBasisMaxBytes` (an over-limit file skips the diff basis and
38
+ * falls back to a whole-file diff instead of failing the write). */
39
+ const DIFF_BASIS_MAX_BYTES = 10 * 1024 * 1024
33
40
  const BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/
34
41
 
35
42
  /** Shape of one remote stat the provider works with (engine-normalized). */
@@ -73,7 +80,7 @@ function decodeText(bytes: Uint8Array, displayPath: string): string {
73
80
  }
74
81
 
75
82
  /** Decode a base64-wrapped NUL-terminated canonical path from `realpath -mz`. */
76
- function decodeCanonicalPath(encoded: string): string {
83
+ export function decodeCanonicalPath(encoded: string): string {
77
84
  if (encoded.length === 0 || !BASE64.test(encoded)) {
78
85
  throw new Error('fs-ssh: canonical path transport returned invalid base64')
79
86
  }
@@ -151,13 +158,31 @@ function isNotFound(error: unknown): boolean {
151
158
  */
152
159
  export class SshFileSystem extends FileSystem {
153
160
  private readonly locks = new Map<string, Promise<unknown>>()
161
+ /** When set, every resolved target must stay under this root (the SSH
162
+ * workspace's `location.root`). Absent for the legacy/global SSH seam,
163
+ * which intentionally addresses the whole host. */
164
+ private readonly confineRoot: string | undefined
154
165
 
155
166
  constructor(
156
167
  ctx: Context,
157
168
  private readonly engine: SshEngine,
158
169
  private readonly getState: () => WorkspaceState,
170
+ confineRoot?: string,
159
171
  ) {
160
172
  super(ctx)
173
+ this.confineRoot = confineRoot === undefined ? undefined : posix.normalize(confineRoot)
174
+ }
175
+
176
+ /** Root-confinement gate for the workspace capability: reject any canonical
177
+ * path that escapes `confineRoot` (including via an in-root symlink whose
178
+ * realpath lands outside). Mirrors the local provider's fail-closed rule. */
179
+ private confine(path: string): string {
180
+ if (this.confineRoot === undefined) return path
181
+ const root = this.confineRoot.endsWith('/') ? this.confineRoot : `${this.confineRoot}/`
182
+ if (path !== this.confineRoot && !path.startsWith(root)) {
183
+ throw new Error(`workspace.ssh-outside-root: '${path}' is outside '${this.confineRoot}'`)
184
+ }
185
+ return path
161
186
  }
162
187
 
163
188
  /** The active remote execution world (throws when not in remote mode). */
@@ -189,6 +214,9 @@ export class SshFileSystem extends FileSystem {
189
214
  try {
190
215
  const targetKey = await this.canonicalPath(displayPath, opts?.signal)
191
216
  assertNotAborted(opts?.signal, 'resolve')
217
+ // Root confinement is checked on the CANONICAL target so a symlink that
218
+ // escapes the workspace root fails closed instead of being followed.
219
+ this.confine(targetKey)
192
220
  return { targetKey: FsTargetKey(targetKey), displayPath }
193
221
  } catch (error: unknown) {
194
222
  throw mapError(error, 'resolve', displayPath, opts?.signal)
@@ -196,7 +224,7 @@ export class SshFileSystem extends FileSystem {
196
224
  }
197
225
 
198
226
  override processPath(target: FsTarget): string {
199
- return String(target.targetKey)
227
+ return this.confine(String(target.targetKey))
200
228
  }
201
229
 
202
230
  override fileUrl(target: FsTarget): string {
@@ -212,10 +240,11 @@ export class SshFileSystem extends FileSystem {
212
240
 
213
241
  override async stat(target: FsTarget, signal?: AbortSignal): Promise<FsInfo | undefined> {
214
242
  assertNotAborted(signal, 'stat')
215
- const stats = await this.probe(String(target.targetKey), target.displayPath, signal)
243
+ const targetPath = String((await this.canonicalTarget(target, signal)).targetKey)
244
+ const stats = await this.probe(targetPath, target.displayPath, signal)
216
245
  if (stats === undefined) return undefined
217
246
  return {
218
- version: entryVersion(stats, String(target.targetKey)),
247
+ version: entryVersion(stats, targetPath),
219
248
  type: entryType(stats),
220
249
  ...(stats.isFile() ? { size: stats.size } : {}),
221
250
  }
@@ -227,7 +256,8 @@ export class SshFileSystem extends FileSystem {
227
256
  const displayPath = posix.resolve(this.resolveRemoteCwd(opts?.cwd), path)
228
257
  const { alias } = this.current()
229
258
  try {
230
- const info = await this.engine.lstat(alias, displayPath)
259
+ this.confine(displayPath)
260
+ const info = await this.engine.lstat(alias, displayPath, signal)
231
261
  assertNotAborted(signal, 'lstat')
232
262
  if (info === undefined) return undefined
233
263
  const type = info.type === 'symlink' ? 'symlink' as const : info.type === 'directory' ? 'directory' as const : info.type === 'file' ? 'file' as const : 'other' as const
@@ -243,8 +273,11 @@ export class SshFileSystem extends FileSystem {
243
273
  }
244
274
 
245
275
  override async readText(target: FsTarget, signal?: AbortSignal): Promise<string> {
246
- await this.requireRegular(target, signal)
247
- const bytes = await this.readBytesRaw(target, signal, Number.POSITIVE_INFINITY)
276
+ const info = await this.requireRegular(target, signal)
277
+ if (info.size !== undefined && info.size > FULL_READ_MAX_BYTES) {
278
+ throw new FsError(`cannot read "${target.displayPath}": ${info.size} bytes exceeds the ${FULL_READ_MAX_BYTES}-byte read limit`, 'FS_TOO_LARGE')
279
+ }
280
+ const bytes = await this.readBytesRaw(target, signal, FULL_READ_MAX_BYTES)
248
281
  assertNotAborted(signal, 'read')
249
282
  return decodeText(bytes, target.displayPath)
250
283
  }
@@ -260,15 +293,27 @@ export class SshFileSystem extends FileSystem {
260
293
  }
261
294
 
262
295
  override async streamText(target: FsTarget, signal?: AbortSignal): Promise<AsyncIterable<string>> {
263
- await this.requireRegular(target, signal)
264
- const { alias } = this.current()
296
+ const filesystem = this
265
297
  const displayPath = target.displayPath
266
- const stream = await this.engine.readStream(alias, String(target.targetKey))
267
298
  return {
268
299
  async *[Symbol.asyncIterator](): AsyncGenerator<string> {
300
+ let stream: import('node:stream').Readable | undefined
301
+ let onAbort: (() => void) | undefined
269
302
  const decoder = new TextDecoder('utf-8', { fatal: true })
270
303
  let sampledBytes = 0
271
304
  try {
305
+ // Acquire both the stat/read lease lazily: merely receiving the
306
+ // iterable owns no network resource. Every iterator owns and closes
307
+ // its own stream, including break/throw/abort paths.
308
+ const safeTarget = await filesystem.canonicalTarget(target, signal)
309
+ await filesystem.requireRegular(safeTarget, signal)
310
+ const { alias } = filesystem.current()
311
+ stream = await filesystem.engine.readStream(alias, String(safeTarget.targetKey), signal)
312
+ if (signal !== undefined) {
313
+ onAbort = () => { try { stream?.destroy(signal.reason instanceof Error ? signal.reason : undefined) } catch { /* already closed */ } }
314
+ signal.addEventListener('abort', onAbort, { once: true })
315
+ if (signal.aborted) onAbort()
316
+ }
272
317
  for await (const chunk of stream) {
273
318
  assertNotAborted(signal, 'read')
274
319
  const bytes = Buffer.from(chunk as Uint8Array)
@@ -284,6 +329,9 @@ export class SshFileSystem extends FileSystem {
284
329
  throw new FsError(`cannot read "${displayPath}": invalid UTF-8 text`, 'FS_NOT_TEXT', { cause: error })
285
330
  }
286
331
  }
332
+ // destroy() may end quietly when AbortSignal.reason is not an Error;
333
+ // classification must still come from the caller-owned signal.
334
+ assertNotAborted(signal, 'read')
287
335
  try {
288
336
  decoder.decode()
289
337
  } catch (error: unknown) {
@@ -291,28 +339,33 @@ export class SshFileSystem extends FileSystem {
291
339
  }
292
340
  } catch (error: unknown) {
293
341
  throw mapError(error, 'read', displayPath, signal)
342
+ } finally {
343
+ if (signal !== undefined && onAbort !== undefined) signal.removeEventListener('abort', onAbort)
344
+ if (stream !== undefined && !stream.destroyed) stream.destroy()
294
345
  }
295
346
  },
296
347
  }
297
348
  }
298
349
 
299
350
  override async listDir(target: FsTarget, signal?: AbortSignal): Promise<FsDirEntry[]> {
300
- const info = await this.stat(target, signal)
351
+ const safeTarget = await this.canonicalTarget(target, signal)
352
+ const info = await this.stat(safeTarget, signal)
301
353
  if (info === undefined) throw new FsError(`cannot list "${target.displayPath}": not found`, 'FS_NOT_FOUND')
302
354
  if (info.type !== 'directory') throw new FsError(`cannot list "${target.displayPath}": not a directory`, 'FS_NOT_DIRECTORY')
303
355
  const { alias } = this.current()
304
356
  try {
305
- const listed = await this.engine.ls(alias, String(target.targetKey))
357
+ const listed = await this.engine.ls(alias, String(safeTarget.targetKey), signal)
306
358
  assertNotAborted(signal, 'list')
307
359
  const displayPaths = listed.map(entry => posix.join(target.displayPath, entry.name))
308
360
  // One batch SFTP pass instead of one `realpath` exec per entry (P1-26);
309
361
  // symlink canonicalization is kept — target keys stay jail-safe.
310
- const canonicalPaths = await this.engine.realpaths(alias, displayPaths)
362
+ const canonicalPaths = await this.engine.realpaths(alias, displayPaths, signal)
311
363
  const entries: FsDirEntry[] = []
312
364
  for (let i = 0; i < listed.length; i += 1) {
313
365
  const entry = listed[i]!
314
366
  const displayPath = displayPaths[i]!
315
367
  const canonical = canonicalPaths[i]!
368
+ this.confine(canonical)
316
369
  const stats = this.asStats({
317
370
  type: entry.type === 'dir' ? 'directory' : entry.type === 'file' ? 'file' : 'other',
318
371
  size: entry.size,
@@ -339,7 +392,9 @@ export class SshFileSystem extends FileSystem {
339
392
  expected?: FsWriteIntent,
340
393
  signal?: AbortSignal,
341
394
  ): Promise<FsWriteOutcome> {
342
- return this.withLock(String(target.targetKey), async () => {
395
+ target = await this.canonicalTarget(target, signal)
396
+ const targetPath = this.processPath(target)
397
+ return this.withLock(targetPath, async () => {
343
398
  const existing = await this.probe(String(target.targetKey), target.displayPath, signal)
344
399
  if (existing !== undefined && !existing.isFile()) {
345
400
  throw new FsError(`cannot write "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE')
@@ -362,7 +417,9 @@ export class SshFileSystem extends FileSystem {
362
417
  expected?: { version: ReturnType<typeof FsVersion> },
363
418
  signal?: AbortSignal,
364
419
  ): Promise<FsEditOutcome> {
365
- return this.withLock(String(target.targetKey), async () => {
420
+ target = await this.canonicalTarget(target, signal)
421
+ const targetPath = this.processPath(target)
422
+ return this.withLock(targetPath, async () => {
366
423
  const existing = await this.probe(String(target.targetKey), target.displayPath, signal)
367
424
  if (existing === undefined) {
368
425
  throw new FsError(`cannot edit "${target.displayPath}": file changed since it was read`, 'FS_STALE_VERSION')
@@ -394,9 +451,15 @@ export class SshFileSystem extends FileSystem {
394
451
  }
395
452
  }
396
453
 
454
+ private async canonicalTarget(target: FsTarget, signal?: AbortSignal): Promise<FsTarget> {
455
+ const canonical = await this.canonicalPath(String(target.targetKey), signal)
456
+ this.confine(canonical)
457
+ return { targetKey: FsTargetKey(canonical), displayPath: target.displayPath }
458
+ }
459
+
397
460
  private async canonicalPath(path: string, signal?: AbortSignal): Promise<string> {
398
461
  const { alias } = this.current()
399
- const result = await this.engine.exec(alias, `set -o pipefail; realpath -mz -- ${quoteShellArg(path)} | base64 -w0`, 10_000)
462
+ const result = await this.engine.exec(alias, `set -o pipefail; realpath -mz -- ${quoteShellArg(path)} | base64 -w0`, { timeoutMs: 10_000, signal })
400
463
  signal?.throwIfAborted()
401
464
  if (!result.success || result.exitCode !== 0) throw new Error(result.stderr || `realpath failed for ${path}`)
402
465
  return decodeCanonicalPath(result.stdout.trim())
@@ -406,7 +469,7 @@ export class SshFileSystem extends FileSystem {
406
469
  assertNotAborted(signal, 'stat')
407
470
  const { alias } = this.current()
408
471
  try {
409
- const info = await this.engine.stat(alias, path)
472
+ const info = await this.engine.stat(alias, path, signal)
410
473
  assertNotAborted(signal, 'stat')
411
474
  return this.asStats({ type: info.type, size: info.size, mtimeMs: info.mtimeMs, mode: info.mode })
412
475
  } catch (error: unknown) {
@@ -425,7 +488,8 @@ export class SshFileSystem extends FileSystem {
425
488
  private async readBytesRaw(target: FsTarget, signal: AbortSignal | undefined, maxBytes: number): Promise<Uint8Array> {
426
489
  const { alias } = this.current()
427
490
  try {
428
- const data = await this.engine.readFile(alias, String(target.targetKey))
491
+ const safeTarget = await this.canonicalTarget(target, signal)
492
+ const data = await this.engine.readFile(alias, String(safeTarget.targetKey), maxBytes, signal)
429
493
  assertNotAborted(signal, 'read')
430
494
  if (data.content.length > maxBytes) {
431
495
  throw new FsError(`cannot read "${target.displayPath}": content exceeds the ${maxBytes}-byte limit`, 'FS_TOO_LARGE')
@@ -449,15 +513,17 @@ export class SshFileSystem extends FileSystem {
449
513
 
450
514
  private async readForDiff(target: FsTarget, signal?: AbortSignal): Promise<string | null> {
451
515
  try {
452
- return normalizeLineEndings(decodeText(await this.readBytesRaw(target, signal, Number.POSITIVE_INFINITY), target.displayPath))
516
+ return normalizeLineEndings(decodeText(await this.readBytesRaw(target, signal, DIFF_BASIS_MAX_BYTES), target.displayPath))
453
517
  } catch (error: unknown) {
454
- if (error instanceof FsError && error.code === 'FS_NOT_TEXT') return null
518
+ // A non-text or over-limit prior file yields no contextual diff basis but
519
+ // the write still succeeds (presentation falls back to a whole-file diff).
520
+ if (error instanceof FsError && (error.code === 'FS_NOT_TEXT' || error.code === 'FS_TOO_LARGE')) return null
455
521
  throw mapError(error, 'read', target.displayPath, signal)
456
522
  }
457
523
  }
458
524
 
459
525
  private async readForEdit(target: FsTarget, signal?: AbortSignal): Promise<string> {
460
- return decodeText(await this.readBytesRaw(target, signal, Number.POSITIVE_INFINITY), target.displayPath)
526
+ return decodeText(await this.readBytesRaw(target, signal, FULL_READ_MAX_BYTES), target.displayPath)
461
527
  }
462
528
 
463
529
  private async writeAtomic(
@@ -473,45 +539,68 @@ export class SshFileSystem extends FileSystem {
473
539
  const stagingDirectory = posix.join(posix.dirname(targetPath), `.dsh-${randomUUID()}.tmp`)
474
540
  const temporary = posix.join(stagingDirectory, 'content')
475
541
  let stagingCreated = false
542
+ let published = false
476
543
  try {
477
- await this.engine.mkdir(alias, stagingDirectory)
544
+ await this.engine.mkdir(alias, stagingDirectory, signal)
478
545
  stagingCreated = true
479
- await this.engine.writeFile(alias, temporary, Buffer.from(content, 'utf8'))
546
+ await this.engine.writeFile(alias, temporary, Buffer.from(content, 'utf8'), undefined, signal)
480
547
  assertNotAborted(signal, 'write')
481
548
  const mode = existing === undefined ? 0o600 : existing.mode & 0o777
482
- await this.engine.exec(alias, `chmod ${mode.toString(8)} -- ${quoteShellArg(temporary)}`, 10_000)
549
+ const chmod = await this.engine.exec(alias, `chmod ${mode.toString(8)} -- ${quoteShellArg(temporary)}`, { timeoutMs: 10_000, signal })
550
+ if (!chmod.success || chmod.exitCode !== 0) {
551
+ throw new Error(chmod.stderr || `chmod failed with exit code ${chmod.exitCode}`)
552
+ }
483
553
  assertNotAborted(signal, 'write')
484
554
  if (createIfAbsent) {
485
555
  const publication = await this.engine.exec(
486
556
  alias,
487
557
  `if ln -T -- ${quoteShellArg(temporary)} ${quoteShellArg(targetPath)}; then printf created; elif test -e ${quoteShellArg(targetPath)} || test -L ${quoteShellArg(targetPath)}; then printf exists; else exit 1; fi`,
488
- 10_000,
558
+ { timeoutMs: 10_000, signal },
489
559
  )
560
+ if (!publication.success || publication.exitCode !== 0) {
561
+ throw new Error(publication.stderr || `guarded create failed with exit code ${publication.exitCode}`)
562
+ }
490
563
  if (publication.stdout.trim() === 'exists') {
491
564
  throw new FsError(`cannot overwrite existing "${target.displayPath}" without reading it first`, 'FS_NOT_OBSERVED')
492
565
  }
493
566
  if (publication.stdout.trim() !== 'created') throw new Error('guarded create returned an invalid publication result')
567
+ published = true
494
568
  } else {
495
- await this.engine.rename(alias, temporary, targetPath)
569
+ await this.engine.rename(alias, temporary, targetPath, signal)
570
+ published = true
496
571
  }
497
- assertNotAborted(signal, 'write')
572
+ // Publication is the commit point. Cancellation after it must not report
573
+ // rollback while the target already contains the new content.
498
574
  await this.removeStaging(stagingDirectory)
499
- const committed = await this.probe(targetPath, target.displayPath, signal)
575
+ stagingCreated = false
576
+ const committed = await this.probe(targetPath, target.displayPath)
500
577
  if (committed === undefined) throw new FsError(`cannot write "${target.displayPath}": commit produced no file`, 'FS_IO_ERROR')
501
578
  return entryVersion(committed, targetPath)
502
579
  } catch (error: unknown) {
503
- if (stagingCreated) await this.removeStaging(stagingDirectory)
580
+ let cleanupError: unknown
581
+ if (stagingCreated) {
582
+ try { await this.removeStaging(stagingDirectory) } catch (cleanup) { cleanupError = cleanup }
583
+ }
584
+ if (cleanupError !== undefined) {
585
+ const state = published ? 'write was committed but staging cleanup failed' : 'write failed and staging cleanup also failed'
586
+ throw new FsError(`cannot write "${target.displayPath}": ${state}`, 'FS_IO_ERROR', {
587
+ cause: new AggregateError([error, cleanupError], state),
588
+ })
589
+ }
590
+ if (published) {
591
+ // Once publication succeeded, cancellation or a first transient cleanup
592
+ // error cannot truthfully turn the operation into an uncommitted failure.
593
+ const reconciled = await this.probe(targetPath, target.displayPath)
594
+ if (reconciled !== undefined) return entryVersion(reconciled, targetPath)
595
+ throw new FsError(`cannot write "${target.displayPath}": publication could not be reconciled`, 'FS_IO_ERROR', { cause: error })
596
+ }
504
597
  throw mapError(error, 'write', target.displayPath, signal)
505
598
  }
506
599
  }
507
600
 
508
601
  private async removeStaging(directory: string): Promise<void> {
509
602
  const { alias } = this.current()
510
- try {
511
- await this.engine.rm(alias, directory, true)
512
- } catch {
513
- // The target is already committed; an empty private directory cannot turn that write into a failure.
514
- }
603
+ await this.engine.rm(alias, directory, true)
515
604
  }
516
605
 
517
606
  /** Normalize an engine stat/ls shape into the RemoteStats the helpers expect. */