@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
@@ -80,6 +80,16 @@ export interface ClusterResult {
80
80
  stderr?: string;
81
81
  durationMs?: number;
82
82
  error?: string;
83
+ /** Stable machine code for a per-host failure (B-14) — lets callers retry
84
+ * on an auth/TOFU gate instead of parsing the human message. */
85
+ code?: 'NEEDS_PASSWORD' | 'HOST_KEY_UNKNOWN' | 'HOST_KEY_MISMATCH' | 'ALIAS_NOT_FOUND' | 'ABORTED' | 'TIMEOUT' | 'ERROR';
86
+ /** Which secret the host needs when `code` is NEEDS_PASSWORD. */
87
+ secret?: 'password' | 'passphrase';
88
+ /** Server-key fingerprint for HOST_KEY_UNKNOWN / HOST_KEY_MISMATCH. */
89
+ hostKeyFingerprint?: string;
90
+ /** Expected/actual fingerprints for HOST_KEY_MISMATCH. */
91
+ expected?: string;
92
+ actual?: string;
83
93
  }
84
94
  /** SFTP transfer progress frame (upload stream). */
85
95
  export interface TransferProgress {
@@ -227,10 +237,18 @@ export type KnownHostAction = 'trust' | 'forget';
227
237
  export type TransferStreamLine = {
228
238
  type: 'progress';
229
239
  progress: TransferProgress;
240
+ }
241
+ /** The final remote rename is about to start; loss after this point means
242
+ * the target's result is unknown until inspected. */
243
+ | {
244
+ type: 'commit';
230
245
  } | {
231
246
  type: 'result';
232
247
  ok: boolean;
233
248
  transferredBytes?: number;
234
249
  error?: string;
250
+ code?: string;
251
+ secret?: 'password' | 'passphrase';
252
+ hostKeyFingerprint?: string;
235
253
  };
236
254
  //# sourceMappingURL=protocol.d.ts.map
@@ -21,8 +21,8 @@ export interface SshRoutesDeps {
21
21
  knownHosts?: KnownHostsStore;
22
22
  /** Credential vault (optional; enables /api/dsh-ssh/vault endpoints). */
23
23
  vault?: import('./vault.ts').Vault;
24
- /** SSH-bound workspace ledger (host-delete reference check). */
25
- ledger?: import('../ledger.ts').SshWorkspaceLedger;
24
+ /** SSH-bound workspace record source (host-delete reference check). */
25
+ ledger?: import('../backend.ts').WorkspaceStoreView;
26
26
  /** Temp dir for upload/download staging (tests inject a sandbox). */
27
27
  stagingDir?: string;
28
28
  /** Hard cap on ACTUAL upload bytes accepted (chunked requests included).
@@ -32,13 +32,21 @@ export interface SshRoutesDeps {
32
32
  * remote environment cache) can drop that alias. */
33
33
  onHostInvalidated?: (alias: string) => void;
34
34
  }
35
+ /** Every /api/dsh-ssh route plus the terminal upgrade, owned by one call. */
36
+ export interface SshRoutes {
37
+ routes: WebRoute[];
38
+ upgrade: WebUpgradeRoute;
39
+ /**
40
+ * Release this instance's terminal resources: refuse new upgrades, close
41
+ * every live terminal session (and its SSH shell), close the WebSocketServer.
42
+ * Idempotent — a second call resolves without touching anything.
43
+ */
44
+ dispose: () => Promise<void>;
45
+ }
35
46
  /**
36
47
  * Build every /api/dsh-ssh route (exact paths) plus the terminal upgrade.
37
48
  * @param deps - store, engine, staging dir.
38
- * @returns routes and the upgrade route.
49
+ * @returns routes, the upgrade route, and this instance's disposer.
39
50
  */
40
- export declare function makeRoutes(deps: SshRoutesDeps): {
41
- routes: WebRoute[];
42
- upgrade: WebUpgradeRoute;
43
- };
51
+ export declare function makeRoutes(deps: SshRoutesDeps): SshRoutes;
44
52
  //# sourceMappingURL=routes.d.ts.map
@@ -0,0 +1,216 @@
1
+ import { Client } from 'ssh2';
2
+ import type { ClientLease } from '../connection/lease.ts';
3
+ import type { TransferProgress } from '../protocol.ts';
4
+ /** Lets an SFTP operation declare the point after which replay is unsafe. */
5
+ export interface SftpOperationControl {
6
+ markCommitted(): void;
7
+ }
8
+ /** Options for one access.withClient() call. */
9
+ export interface SftpClientOptions {
10
+ /** Total acquire+operation attempt budget (default 3). */
11
+ attempts?: number;
12
+ retryPolicy?: 'never' | 'connect-only' | 'idempotent';
13
+ signal?: AbortSignal;
14
+ }
15
+ /**
16
+ * Narrow connection dependency of the SFTP component: the pooled lease
17
+ * acquisition plus the engine's retry/replay policy. The component owns the
18
+ * lease for the whole operation (including the lifetime of a returned read
19
+ * stream) and never reaches into the pool's bookkeeping.
20
+ */
21
+ export interface SftpClientAccess {
22
+ withClient<T>(alias: string, fn: (client: Client, control: SftpOperationControl) => Promise<T>, options?: SftpClientOptions): Promise<T>;
23
+ acquire(alias: string, options: {
24
+ kind: 'operation' | 'stream';
25
+ signal?: AbortSignal;
26
+ }): Promise<ClientLease>;
27
+ }
28
+ /**
29
+ * The engine knobs the SFTP component reads. Keyed exactly like the engine's
30
+ * resolved options, so every SFTP deadline/concurrency keeps one source of
31
+ * truth.
32
+ */
33
+ export interface SftpOptions {
34
+ sftpConcurrency: number;
35
+ sftpOpenTimeoutMs: number;
36
+ sftpOperationTimeoutMs: number;
37
+ sftpReadTimeoutMs: number;
38
+ maxReadFileBytes: number;
39
+ sftpTransferIdleTimeoutMs: number;
40
+ sftpRecursiveRmTimeoutMs: number;
41
+ }
42
+ /**
43
+ * Owns every SFTP operation for the engine: one cached subsystem channel per
44
+ * live pooled client (plus its cancellation), the per-request deadlines, and
45
+ * the upload/download/ls/stat/read/write/mkdir/rm/rename surface.
46
+ */
47
+ export declare class SftpService {
48
+ private readonly access;
49
+ private readonly sftpOpts;
50
+ /**
51
+ * One cached SFTP subsystem channel per live client. `Client.sftp()` opens a
52
+ * NEW subsystem channel on every call and OpenSSH caps open sessions per
53
+ * connection (MaxSessions, default 10) —reopening SFTP per operation lets
54
+ * channels pile up on the pooled long-lived connection until listing/reading
55
+ * fails intermittently. Caching one channel per client fixes that; the pool
56
+ * drops the cache via onClientDisposed when a connection is torn down.
57
+ */
58
+ private readonly cache;
59
+ constructor(access: SftpClientAccess, sftpOpts: SftpOptions);
60
+ /** Upload one local file (or directory tree) to a remote path. */
61
+ upload(alias: string, localPath: string, remotePath: string, recursive: boolean, onProgress?: (progress: TransferProgress) => void, signal?: AbortSignal): Promise<{
62
+ bytes: number;
63
+ files: number;
64
+ }>;
65
+ /** Download one remote file to a local path. */
66
+ download(alias: string, remotePath: string, localPath: string, onProgress?: (progress: TransferProgress) => void, signal?: AbortSignal): Promise<{
67
+ bytes: number;
68
+ }>;
69
+ /** List a remote directory (file browser). Bounded by a timeout so a
70
+ * stalled SFTP request fails instead of leaving the file tree spinning. */
71
+ ls(alias: string, path: string, signal?: AbortSignal): Promise<import('../protocol.ts').RemoteDirEntry[]>;
72
+ /**
73
+ * Resolve many remote paths to their canonical form in one SFTP pass
74
+ * (P1-26): one lease + one batch of `sftp.realpath` calls instead of N
75
+ * `realpath` execs. A path that cannot resolve (dangling symlink, vanished
76
+ * entry) fails the whole batch — callers treat an unresolvable listing as
77
+ * an error rather than silently using an uncanonical path.
78
+ */
79
+ realpaths(alias: string, remotePaths: readonly string[], signal?: AbortSignal): Promise<string[]>;
80
+ /**
81
+ * Classify readdir entries, following symlinks so a link to a directory
82
+ * (e.g. AutoDL's /root/autodl-tmp) lists as a directory instead of 'other'.
83
+ * Symlinks are stat'd in PARALLEL batches: serializing them turns a conda /
84
+ * venv bin full of links into N round-trips (seconds to tens of seconds on a
85
+ * slow link) — batching keeps it to a handful of round-trips. The whole pass
86
+ * is bounded by ls()'s timeout.
87
+ */
88
+ classifyEntries(sftp: import('ssh2').SFTPWrapper, dirPath: string, list: Array<{
89
+ filename: string;
90
+ attrs: import('ssh2').Stats;
91
+ }>): Promise<import('../protocol.ts').RemoteDirEntry[]>;
92
+ /** Stat one remote path (file browser / conflict checks). Bounded by a timeout. */
93
+ stat(alias: string, remotePath: string, signal?: AbortSignal): Promise<{
94
+ type: 'dir' | 'file' | 'other';
95
+ size: number;
96
+ mtimeMs: number;
97
+ mode: number;
98
+ }>;
99
+ /**
100
+ * Lstat one remote path without following the final symlink. Returns
101
+ * undefined when the path is absent (the fs seam's lstat contract).
102
+ */
103
+ lstat(alias: string, remotePath: string, signal?: AbortSignal): Promise<{
104
+ type: 'file' | 'directory' | 'symlink' | 'other';
105
+ size: number;
106
+ mtimeMs: number;
107
+ mode: number;
108
+ } | undefined>;
109
+ /**
110
+ * Open a remote file read stream (the fs seam's streamText). The returned
111
+ * stream must be consumed or destroyed; the pooled connection stays busy
112
+ * for the stream's lifetime.
113
+ */
114
+ /**
115
+ * Open a remote file read stream (the fs seam's streamText). The returned
116
+ * stream must be consumed or destroyed; the pooled connection stays busy
117
+ * for the stream's lifetime (P0-10: a 'stream' lease, released on
118
+ * end/close/error/destroy — not when this function returns).
119
+ */
120
+ readStream(alias: string, remotePath: string, signal?: AbortSignal): Promise<import('node:stream').Readable>;
121
+ /**
122
+ * Read one remote file fully into memory (text or binary) with its mtime.
123
+ * The workspace plugin's text gate (UTF-8 + size caps) lives on its caller.
124
+ */
125
+ readFile(alias: string, remotePath: string, maxBytes?: number, signal?: AbortSignal): Promise<{
126
+ content: Buffer;
127
+ mtime: number;
128
+ size: number;
129
+ }>;
130
+ /**
131
+ * Write one remote file from memory (parents are created). When
132
+ * `expectedMtime` is given, a stat-then-write conflict check throws before
133
+ * any byte is written (the GUI and the workspace tools use it for
134
+ * overwrite protection).
135
+ */
136
+ writeFile(alias: string, remotePath: string, content: Buffer, expectedMtime?: number, signal?: AbortSignal): Promise<{
137
+ mtime: number;
138
+ }>;
139
+ /** Create a remote directory chain (mkdir -p semantics). */
140
+ mkdir(alias: string, remotePath: string, signal?: AbortSignal): Promise<void>;
141
+ /**
142
+ * Remove a remote file or directory. Directories require `recursive: true`
143
+ * and are walked depth-first (children first, then the directory itself).
144
+ *
145
+ * Deletion never follows symlinks: every node is classified with lstat, so
146
+ * a symlink pointing at a directory is unlinked (only the link), never
147
+ * recursed into — the old stat/readdir-attr check could delete the link
148
+ * target's contents.
149
+ */
150
+ rm(alias: string, remotePath: string, recursive?: boolean, signal?: AbortSignal): Promise<void>;
151
+ /** Rename / move a remote path (mv semantics, same filesystem). */
152
+ rename(alias: string, fromPath: string, toPath: string, signal?: AbortSignal): Promise<void>;
153
+ /** Reject a promise after `ms` (unref'd so it never keeps the process alive).
154
+ * `onTimeout` (when given) runs right before the rejection: ssh2 SFTP
155
+ * requests have no cancel API, so callers that hold an abort handle (e.g.
156
+ * a read stream) destroy it here — otherwise the underlying transfer would
157
+ * keep running (and, for reads, keep buffering) after the caller was told
158
+ * it timed out. */
159
+ withTimeout<T>(promise: Promise<T>, ms: number, message: string, onTimeout?: () => void): Promise<T>;
160
+ /** Bound one SFTP request and retire the subsystem only when it is safe.
161
+ *
162
+ * Every request rides ONE cached subsystem channel per pooled client. Closing
163
+ * that channel on a single request's timeout used to destroy every concurrent
164
+ * SFTP operation on the same connection. A timeout therefore only MARKS the
165
+ * channel suspect; the decision to close it belongs to the drain path, which
166
+ * ends it exactly once the last in-flight request has settled — so a stalled
167
+ * callback cannot linger behind another lease, and a healthy concurrent request
168
+ * is never destroyed. */
169
+ withSftpTimeout<T>(sftp: import('ssh2').SFTPWrapper, promise: Promise<T>, ms: number, message: string, onTimeout?: () => void): Promise<T>;
170
+ /** Count one operation against a cached subsystem's in-flight set. */
171
+ private enter;
172
+ /** End one operation's count; rotate a suspect channel only once it drains. */
173
+ private leave;
174
+ /**
175
+ * Retire a subsystem after a timeout without destroying concurrent SFTP work.
176
+ *
177
+ * A cached channel is SHARED by every operation on the pooled client, so
178
+ * closing it here would fail whatever else is in flight. Instead it is marked
179
+ * suspect and rotated by `leave()` once the last operation finishes. Only a
180
+ * wrapper this service does not track (a direct caller) is ended immediately.
181
+ *
182
+ * @param sftp - the subsystem that timed out.
183
+ */
184
+ private retireSharedSubsystem;
185
+ /** The cache entry owning one resolved subsystem channel, if still cached. */
186
+ private entryFor;
187
+ /** Drop a suspect channel once it has drained, so the next call reopens. */
188
+ private rotateSuspect;
189
+ /** Stat wrapper (one SFTP stat call). */
190
+ sftpStat(sftp: import('ssh2').SFTPWrapper, remotePath: string): Promise<import('ssh2').Stats>;
191
+ /** Lstat wrapper (does NOT follow symlinks — the deletion safety gate). */
192
+ sftpLstat(sftp: import('ssh2').SFTPWrapper, remotePath: string, timeoutMs?: number): Promise<import('ssh2').Stats>;
193
+ /** Unlink wrapper with the same per-operation deadline as every other SFTP callback. */
194
+ sftpUnlink(sftp: import('ssh2').SFTPWrapper, remotePath: string, timeoutMs?: number): Promise<void>;
195
+ /**
196
+ * The (cached) SFTP channel for a pooled client. `Client.sftp()` opens a new
197
+ * subsystem channel per call, so this memoizes one channel per live client;
198
+ * when the channel closes the cache entry is dropped so the next call opens
199
+ * SFTP on the replacement connection. Failed opens are also evicted so a
200
+ * transient channel failure can be retried.
201
+ */
202
+ sftpFor(client: Client): Promise<import('ssh2').SFTPWrapper>;
203
+ /** Create a remote directory chain (stat-then-mkdir per segment). */
204
+ ensureRemoteDir(sftp: import('ssh2').SFTPWrapper, remote: string): Promise<void>;
205
+ fastPut(sftp: import('ssh2').SFTPWrapper, src: string, dst: string, onProgress?: (progress: TransferProgress) => void): Promise<void>;
206
+ fastGet(sftp: import('ssh2').SFTPWrapper, src: string, dst: string, initialTotal: number, onProgress?: (progress: TransferProgress) => void): Promise<void>;
207
+ /**
208
+ * Drop one client's cached channel. Wired as the connection pool's
209
+ * onDispose hook, so a torn-down client never keeps a half-open SFTP
210
+ * subsystem behind it.
211
+ */
212
+ onClientDisposed(client: Client, error: Error): void;
213
+ /** Drop every cached channel (engine dispose). */
214
+ dispose(error: Error): void;
215
+ }
216
+ //# sourceMappingURL=service.d.ts.map
@@ -35,6 +35,8 @@ export declare class HostStore {
35
35
  constructor(path?: string, sshConfigOverride?: string);
36
36
  /** Load all entries (empty store when the file is absent). */
37
37
  list(): SshHostEntry[];
38
+ /** The ssh-config path this store reads (override or the default). */
39
+ get sshConfigOverridePath(): string | undefined;
38
40
  /** Find one entry by alias. */
39
41
  find(alias: string): SshHostEntry | undefined;
40
42
  /** Secret-free projection for the browser and agent surfaces. */
@@ -48,7 +50,8 @@ export declare class HostStore {
48
50
  /**
49
51
  * Import hosts from `~/.ssh/config`: Host blocks with a single non-wildcard
50
52
  * pattern and a HostName become entries (key auth via IdentityFile, jump
51
- * hosts via ProxyJump). Existing aliases are skipped.
53
+ * hosts via ProxyJump). Existing aliases are skipped. Each entry is created
54
+ * through this store's own `create`, which keeps plaintext secrets inline.
52
55
  * @returns import statistics.
53
56
  */
54
57
  importFromSshConfig(): ImportResult;
@@ -77,8 +80,9 @@ export interface ResolvedAuth {
77
80
  *
78
81
  * - **'none' (default, VSCode Remote-SSH style)**: secrets are NEVER
79
82
  * persisted. create/update strip the password/passphrase from the entry
80
- * (kind + keyPath stay); the engine prompts once per session and holds the
81
- * credential in-memory (session password table). The plaintext HostStore
83
+ * (kind + keyPath stay); the engine prompts once and holds the credential
84
+ * in-memory for that CONNECTION's lifetime (the pool's retirement drops it).
85
+ * The plaintext HostStore
82
86
  * remains untouched, so existing v1 tests and dual-format reads keep
83
87
  * working.
84
88
  */
@@ -86,19 +90,27 @@ export declare class SecureHostStore {
86
90
  private readonly vault;
87
91
  private readonly mode;
88
92
  private readonly inner;
93
+ private readonly pendingSecretCleanup;
89
94
  constructor(vault: import('./vault.ts').Vault | undefined, path?: string, sshConfigOverride?: string, mode?: 'none' | 'vault');
90
95
  list(): SshHostEntry[];
91
96
  find(alias: string): SshHostEntry | undefined;
92
97
  summarize(entry: SshHostEntry): SshHostSummary;
93
98
  get path(): string;
94
- /** Legacy ssh-config import (delegates; passwords imported stay inline
95
- * a follow-up may route them through the vault). */
96
- importFromSshConfig(): ImportResult;
97
- /** Create: secrets are stashed per mode; entry only keeps kind (+ keyPath). */
99
+ /** The ssh-config path the underlying store reads (override or default). */
100
+ get sshConfigOverridePath(): string | undefined;
101
+ /** ssh-config import routed through THIS store's `create`, so every imported
102
+ * credential is handled per `secretStorage` (never persisted in 'none' mode,
103
+ * vault-encrypted in 'vault' mode) instead of being stored inline. */
104
+ importFromSshConfig(): Promise<ImportResult>;
105
+ /** Deferred best-effort cleanup queue (diagnostics/tests; contains no plaintext). */
106
+ pendingSecretCleanupRefs(): string[];
107
+ private removeSecret;
108
+ private retryPendingCleanup;
109
+ /** Create: secrets are staged, then rolled back if host persistence fails. */
98
110
  create(payload: HostPayload): Promise<SshHostEntry>;
99
111
  /** Update: auth present -> re-stash (or strip in none mode); absent → keep. */
100
112
  update(alias: string, patch: Partial<HostPayload>): Promise<SshHostEntry>;
101
- /** Delete: drop the host (and its vault secrets in vault mode, best-effort). */
113
+ /** Delete the host first; vault cleanup can then only leave an orphan. */
102
114
  delete(alias: string): Promise<void>;
103
115
  /** Resolve the authentication for one entry (vault reveal when needed). */
104
116
  resolveAuth(entry: SshHostEntry): Promise<ResolvedAuth>;
@@ -0,0 +1,49 @@
1
+ import type { Client } from 'ssh2';
2
+ import type { ExecSession, ShellSession } from '../engine.ts';
3
+ /**
4
+ * Narrow connection dependency used by the standalone terminal transports.
5
+ * `connectStandalone` must reach the store itself (the not-found wording is
6
+ * this component's own contract) and must always return a PRIVATE connection
7
+ * — never a pooled one.
8
+ */
9
+ export interface TerminalConnectionAccess {
10
+ connectStandalone(alias: string): Promise<{
11
+ client: Client;
12
+ hops: Client[];
13
+ }>;
14
+ }
15
+ /**
16
+ * Owns the standalone PTY-shell / streaming-exec transports: one exclusive
17
+ * connection per session (closing a shell can therefore never tear down a
18
+ * pooled exec or tunnel sharing the alias), the channel-open deadline,
19
+ * idempotent teardown and session assembly.
20
+ */
21
+ export declare class TerminalService {
22
+ private readonly connection;
23
+ /** Teardowns of live standalone transports, so dispose() can close sessions
24
+ * that the caller never closed explicitly (openShell/openExec own their
25
+ * whole connection and would otherwise outlive the engine). */
26
+ private readonly activeTeardowns;
27
+ constructor(connection: TerminalConnectionAccess);
28
+ /**
29
+ * Open one standalone channel on its own connection (never a pooled one).
30
+ * Shared by openShell/openExec: alias lookup, jump chain, channel-open
31
+ * timeout, idempotent teardown, and late-callback cleanup live here, so the
32
+ * two public methods keep only their session-specific assembly.
33
+ */
34
+ private openStandaloneChannel;
35
+ /** Open a PTY shell session for the web terminal (standalone connection). */
36
+ openShell(alias: string, size: {
37
+ cols: number;
38
+ rows: number;
39
+ }): Promise<ShellSession>;
40
+ /**
41
+ * Open a streaming exec channel (no PTY) for the remote subprocess seam.
42
+ * Like the PTY shell, the channel rides its own connection so closing it
43
+ * can never tear down a pooled exec/tunnel sharing the alias.
44
+ */
45
+ openExec(alias: string, command: string): Promise<ExecSession>;
46
+ /** Close every standalone transport this service still owns. */
47
+ dispose(): void;
48
+ }
49
+ //# sourceMappingURL=service.d.ts.map
@@ -0,0 +1,26 @@
1
+ import type { SshConnectionService } from '../connection/pool.ts';
2
+ import type { SshHostEntry, TunnelInfo } from '../protocol.ts';
3
+ /** Narrow connection dependency used by the tunnel component. */
4
+ export interface TunnelConnectionAccess {
5
+ readonly connections: SshConnectionService;
6
+ findEntry(alias: string): SshHostEntry | undefined;
7
+ }
8
+ /** Owns local listeners, forwarded sockets, and tunnel leases. */
9
+ export declare class TunnelService {
10
+ private readonly connection;
11
+ private readonly tunnels;
12
+ private nextTunnelId;
13
+ constructor(connection: TunnelConnectionAccess);
14
+ private removeClientFailureListener;
15
+ private markFailedForClient;
16
+ startTunnel(alias: string, options: {
17
+ remotePort: number;
18
+ remoteHost?: string;
19
+ localPort?: number;
20
+ }): Promise<TunnelInfo>;
21
+ listTunnels(): TunnelInfo[];
22
+ stopTunnel(id: string): boolean;
23
+ stopAllTunnels(alias?: string): number;
24
+ dispose(): void;
25
+ }
26
+ //# sourceMappingURL=service.d.ts.map
@@ -13,6 +13,8 @@
13
13
  *
14
14
  * @module dsh-hardssh/vault
15
15
  */
16
+ /** Minimum non-whitespace master-password length. */
17
+ export declare const MIN_MASTER_PASSWORD_LENGTH = 8;
16
18
  /** Types of secrets the vault stores. */
17
19
  export type VaultPurpose = 'host.password' | 'host.passphrase';
18
20
  /** KDF parameters persisted in the file header. */
@@ -29,8 +31,37 @@ export interface VaultStatus {
29
31
  mode: 'env' | 'password';
30
32
  entries: number;
31
33
  }
32
- /** Default vault file location: <home>/.dsh/dsh-ssh-vault.json. */
34
+ /**
35
+ * Directory holding the credential vault: `<home>/.dsh/ssh-secrets`.
36
+ *
37
+ * It IS inside `~/.dsh` (which the fs seam declares a local-infrastructure root
38
+ * so skills and plugin config stay readable), so its protection does NOT come
39
+ * from location — it comes from `SwitchFileSystem.deniedRoots` (fs.ts), which
40
+ * refuses this directory on every dispatch path, resolve to write, before any
41
+ * backend sees it.
42
+ *
43
+ * Residual risk, stated plainly: a command that runs on THIS machine as the
44
+ * same user (e.g. the client-side `pwsh` tool) can still read the file. What
45
+ * keeps the credential safe is that the file is encrypted (AES-256-GCM +
46
+ * scrypt) AND that environment auto-unlock is off by default — so a stolen
47
+ * ciphertext is only an offline scrypt target, not a usable credential.
48
+ */
49
+ export declare function vaultDirectory(): string;
50
+ /** Vault file location: <home>/.dsh/ssh-secrets/dsh-ssh-vault.json. */
33
51
  export declare function vaultPath(): string;
52
+ /** Pre-relocation vault location (<home>/.dsh/dsh-ssh-vault.json). */
53
+ export declare function legacyVaultPath(): string;
54
+ /**
55
+ * Move a pre-relocation vault into {@link vaultDirectory} on first use.
56
+ *
57
+ * Never destructive: the new location wins when both exist, and a failed move
58
+ * falls back to copying so the source file is left in place.
59
+ *
60
+ * @param target - destination path (defaults to {@link vaultPath}).
61
+ * @param legacy - source path (defaults to {@link legacyVaultPath}).
62
+ * @returns true when a move/copy happened.
63
+ */
64
+ export declare function migrateLegacyVault(target?: string, legacy?: string): boolean;
34
65
  /** SHA3-256 hex of a UTF-8 string. */
35
66
  export declare function sha3_256Hex(text: string): string;
36
67
  /** Errors raised by the vault. */
@@ -64,19 +95,31 @@ export interface RevealedSecret {
64
95
  */
65
96
  export declare class Vault {
66
97
  private readonly path;
67
- private readonly passwordProvider;
68
98
  private document;
69
99
  private unlockedKey;
70
100
  private lockTimer;
101
+ private readonly idleLockMs;
71
102
  private readonly leaked;
103
+ /** Whether `DSH_CREDENTIAL_PASSWORD` may unlock this instance at load. */
104
+ private readonly allowEnvUnlock;
105
+ /** Single-instance mutation queue. Every operation that can change the
106
+ * in-memory document or persist it runs in invocation order. The tail is
107
+ * always recovered so one rejected mutation never poisons later work. */
108
+ private mutationTail;
72
109
  constructor(filePath?: string, options?: {
73
- /** Session password source (loopback unlock route passes a password). */
74
- passwordProvider?: () => Promise<string | undefined>;
75
110
  /** Vault idle auto-lock (ms); 0 disables (default 30 min). */
76
111
  idleLockMs?: number;
112
+ /**
113
+ * Allow unlocking from `DSH_CREDENTIAL_PASSWORD` at construction.
114
+ * OFF by default: that variable is visible to anything running as the
115
+ * same user — including an agent session — so auto-unlock is an explicit
116
+ * opt-in (`vaultAutoUnlock: 'env'` in the plugin config), not a default.
117
+ */
118
+ allowEnvUnlock?: boolean;
77
119
  });
78
120
  private load;
79
121
  private save;
122
+ private saveDocument;
80
123
  private deriveKey;
81
124
  private lockoutRetryAfter;
82
125
  private checkLockout;
@@ -84,6 +127,10 @@ export declare class Vault {
84
127
  private encryptEntryBuffer;
85
128
  private decryptEntryBuffer;
86
129
  private requireKey;
130
+ private validateMasterPassword;
131
+ /** Successful credential activity extends the idle lock deadline. */
132
+ private touch;
133
+ private enqueueMutation;
87
134
  private tryEnvUnlock;
88
135
  /** Current status (locked / mode / entry count) — safe for UIs. */
89
136
  status(): VaultStatus;
@@ -1,32 +1,24 @@
1
1
  /**
2
- * The `ctx.subprocess` switch row: provides the workspace-routing subprocess
3
- * facade in the host scope. The local runtime is mounted in an isolated
4
- * child scope; every SSH-bound workspace record gets its OWN remote runtime
5
- * bound to that record's alias + remote root; the facade routes each spawn by
6
- * its cwd. The facade auto-provides `subprocess` here because the plain
7
- * subprocess row is disabled by the profile patch.
2
+ * The `ctx.subprocess` switch row: provides the generic WorkspaceCore-routing
3
+ * subprocess facade in the host scope. The local runtime is mounted in an
4
+ * isolated child scope; each spawn resolves by cwd through the same workspace
5
+ * router used by the filesystem seam.
8
6
  *
9
- * Routing and per-record remote runtimes are owned by the SHARED seam state
10
- * (hardsshCore.seams): the row binds its per-record runtime factory and reads
11
- * the state on every spawn. The seam state re-applies the ledger snapshot
12
- * synchronously on every commit, so creating or removing an SSH workspace
13
- * takes effect without a plugin restart and the fs/subprocess seams can never
14
- * disagree. Before the initial ledger load finishes, routing degrades to
15
- * local with a one-time warning for anchor-root paths.
7
+ * A bound connection supplies `workspace.process`. Before the core is ready,
8
+ * or for an unowned cwd beneath the managed SSH anchor root, spawning fails
9
+ * closed rather than executing on the client machine.
16
10
  *
17
11
  * @module dsh-hardssh/subprocess
18
12
  */
19
13
  import type { Context } from '@deepseek-ai/cordis';
20
- import type { HardsshCore } from './core.ts';
21
- declare module '@deepseek-ai/cordis' {
22
- interface Context {
23
- hardsshCore: HardsshCore;
24
- }
25
- }
14
+ import type { SubprocessRuntime } from '@deepseek-ai/dsh-subprocess';
15
+ import type { WorkspaceCore } from './runtime/workspace-core.ts';
26
16
  /** Stable cordis plugin name. */
27
17
  export declare const name = "hardssh-subprocess";
28
- /** Services required: the shared workspace core (mode store + engine). */
18
+ /** Workspace routing is exclusively provided by the generic core. */
29
19
  export declare const inject: string[];
30
- /** Mount the switching subprocess facade. */
20
+ /** Resolve the subprocess runtime owning a cwd, or undefined when local. */
21
+ export declare function genericSubprocessFor(core: WorkspaceCore, cwd: string | undefined, reservedAnchorRoots: readonly string[]): SubprocessRuntime | undefined;
22
+ /** Mount the generic switching subprocess facade. */
31
23
  export declare function apply(ctx: Context): void;
32
24
  //# sourceMappingURL=subprocess.d.ts.map
@@ -46,20 +46,70 @@ export interface SwitchFsDeps {
46
46
  /** The world owning one namespaced target key (never undefined: every
47
47
  * key this facade issued maps back). */
48
48
  worldForNamespace(namespace: string): WorkspaceWorld | undefined;
49
+ /** Client-side roots that must stay LOCAL even in a bound workspace (dsh's
50
+ * own `~/.dsh` with skills & harness state, plugin configs, …). Default
51
+ * routing in a remote world is REMOTE for everything else. */
52
+ localRoots?: ReadonlyArray<string>;
53
+ /** Windows inside `localRoots` that must NEVER be treated as local
54
+ * infrastructure — the managed workspace ANCHOR ROOT. Anchors physically
55
+ * live under `~/.dsh`, so without this carve-out a path inside the anchors
56
+ * window (including a stale anchor dir with no ledger record) would fall
57
+ * into the local exception and a remote session would silently write to
58
+ * the client machine. Semantics: local exception = localRoots MINUS
59
+ * exclusions. */
60
+ localRootExclusions?: ReadonlyArray<string>;
61
+ /** Roots whose contents are NEVER accessible through this seam — the
62
+ * encrypted credential store. Enforced on every dispatch path (see
63
+ * `assertNotDenied`), so a bound session's agent cannot read the vault
64
+ * ciphertext through the routed filesystem and attack it offline. It does
65
+ * NOT constrain a command that runs natively on this machine as the same
66
+ * user (e.g. the client-side `pwsh` tool). */
67
+ deniedRoots?: ReadonlyArray<string>;
68
+ /** The world owning an ABSOLUTE workspace anchor path (the caller's own
69
+ * workspace or a SIBLING workspace's anchor), or undefined when no
70
+ * workspace claims that path. Consulted BEFORE the `localRoots`
71
+ * carve-out: managed SSH workspace anchors live under `~/.dsh` (a
72
+ * declared local root), so routing a sibling anchor to local would
73
+ * silently run a remote-workspace file operation on this machine. */
74
+ worldForAnchorPath?(path: string): WorkspaceWorld | undefined;
49
75
  }
50
76
  /** The routing filesystem facade. */
51
77
  export declare class SwitchFileSystem extends FileSystem {
52
78
  private readonly deps;
53
79
  private readonly local;
54
80
  constructor(ctx: Context, deps: SwitchFsDeps);
55
- /** Sandbox default from the local backend (remote worlds are unconfined
56
- * the docs/doc of the tool layer reads this once at mount). */
81
+ /** The deployment's local backend is still a sandboxed filesystem, and
82
+ * `dsh-tool-fs` reads this capability fact ONCE at apply() to decide whether
83
+ * to advertise `sandbox_permissions` / `justification` at all. Returning
84
+ * undefined here disabled that escalation entry for EVERY session,
85
+ * including purely local ones, so the local backend's mode is the honest
86
+ * answer. Remote worlds are not confined by the local sandbox, so a wider
87
+ * per-call policy is meaningless for them and is dropped at the call site
88
+ * instead of being silently swallowed by a 4-parameter remote backend. */
57
89
  get sandboxMode(): SandboxMode | undefined;
58
90
  /** Encode a raw key into the world's namespace. */
59
91
  private encode;
60
92
  /** Decode a namespaced key back to (rawKey, world). Supports both the
61
93
  * current `wfs://<id>/…` form and the legacy `ssh:<recordId>:…` form. */
62
94
  private decode;
95
+ /**
96
+ * Refuse a protected client directory before any backend sees it.
97
+ *
98
+ * Only a LOCAL world is checked: for a remote world the path is a path on the
99
+ * SERVER, which may legitimately look like a client path (both can be
100
+ * `/home/me/.dsh/…`).
101
+ *
102
+ * @param path - the path (or canonical target key) about to be dispatched.
103
+ * @param namespace - the resolved world's namespace ('' = local).
104
+ */
105
+ private assertNotDenied;
106
+ /**
107
+ * The world owning one call: an explicit anchor path (this workspace's or a
108
+ * SIBLING's) wins over the session cwd, because anchors are physically under
109
+ * the local-infrastructure root and would otherwise be carved out as local.
110
+ * Falls back to the session cwd's world, then to the caller's default.
111
+ */
112
+ private worldForPath;
63
113
  resolve(path: string, opts?: {
64
114
  cwd?: string;
65
115
  signal?: AbortSignal;