@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.
- package/LICENSE +30 -0
- package/README.md +77 -50
- package/SKILLS.md +140 -0
- package/lib/base/index.js +95 -0
- package/lib/client.js +1171 -1321
- package/lib/fs.js +333 -416
- package/lib/index.js +5428 -2991
- package/lib/ledger-B-LXlftp.js +43 -0
- package/lib/ledger-D2ezq1iW.js +376 -0
- package/lib/model-Cp7f70Mb.js +5 -0
- package/lib/registry-CViuzKYI.js +421 -0
- package/lib/subprocess.js +92 -549
- package/lib/types/backend.d.ts +73 -78
- package/lib/types/base/capability.d.ts +4 -0
- package/lib/types/base/ledger-router.d.ts +42 -26
- package/lib/types/base/ledger.d.ts +30 -19
- package/lib/types/base/model.d.ts +17 -8
- package/lib/types/base/plugin.d.ts +11 -0
- package/lib/types/base/registry.d.ts +28 -31
- package/lib/types/base/router.d.ts +0 -7
- package/lib/types/client/api.d.ts +8 -8
- package/lib/types/client/connect-host.d.ts +13 -0
- package/lib/types/client/index.d.ts +6 -4
- package/lib/types/client/locales.d.ts +15 -1
- package/lib/types/client/session-connect-gate.d.ts +71 -0
- package/lib/types/client/ssh/api.d.ts +11 -9
- package/lib/types/client/ssh/apply.d.ts +14 -8
- package/lib/types/client/ssh/locales.d.ts +8 -1
- package/lib/types/client/ssh/ops-tab.d.ts +34 -0
- package/lib/types/client/ssh/panel/ClusterTab.d.ts +3 -1
- package/lib/types/client/ssh/panel/ConnectionErrorDialog.d.ts +8 -0
- package/lib/types/client/ssh/panel/SessionSecretDialog.d.ts +4 -1
- package/lib/types/client/ssh/panel/SshPanel.d.ts +8 -8
- package/lib/types/client/ssh/panel/TerminalTab.d.ts +3 -5
- package/lib/types/client/ssh/panel/TransferTab.d.ts +5 -1
- package/lib/types/client/ssh/panel/TunnelsTab.d.ts +3 -1
- package/lib/types/client/ssh/session-target.d.ts +17 -0
- package/lib/types/client/state.d.ts +4 -1
- package/lib/types/client/workspace-panel-entry.d.ts +30 -0
- package/lib/types/client/workspace-panel.d.ts +24 -0
- package/lib/types/client-http.d.ts +77 -6
- package/lib/types/core.d.ts +5 -16
- package/lib/types/fs.d.ts +42 -25
- package/lib/types/index.d.ts +138 -9
- package/lib/types/ledger.d.ts +16 -111
- package/lib/types/protocol.d.ts +0 -5
- package/lib/types/providers/index.d.ts +6 -3
- package/lib/types/providers/local/provider.d.ts +83 -23
- package/lib/types/providers/ssh/provider.d.ts +24 -57
- package/lib/types/remote/environment.d.ts +2 -0
- package/lib/types/remote/remote-fs.d.ts +12 -1
- package/lib/types/remote/remote-process.d.ts +22 -1
- package/lib/types/remote/remote-subprocess.d.ts +13 -1
- package/lib/types/remote/remote-terminal.d.ts +4 -0
- package/lib/types/remote-search.d.ts +3 -3
- package/lib/types/routes.d.ts +36 -8
- package/lib/types/runtime/dsh-capabilities.d.ts +18 -0
- package/lib/types/runtime/workspace-core.d.ts +73 -38
- package/lib/types/runtime/workspace-migration.d.ts +113 -0
- package/lib/types/ssh/connection/lease.d.ts +23 -0
- package/lib/types/ssh/connection/manager.d.ts +359 -0
- package/lib/types/ssh/connection/pool.d.ts +8 -1
- package/lib/types/ssh/engine.d.ts +97 -267
- package/lib/types/ssh/known-hosts.d.ts +22 -5
- package/lib/types/ssh/local-transfer-policy.d.ts +20 -0
- package/lib/types/ssh/plugin.d.ts +10 -15
- package/lib/types/ssh/protocol.d.ts +18 -0
- package/lib/types/ssh/routes.d.ts +15 -7
- package/lib/types/ssh/sftp/service.d.ts +216 -0
- package/lib/types/ssh/store.d.ts +20 -8
- package/lib/types/ssh/terminal/service.d.ts +49 -0
- package/lib/types/ssh/tunnel/service.d.ts +26 -0
- package/lib/types/ssh/vault.d.ts +51 -4
- package/lib/types/subprocess.d.ts +13 -21
- package/lib/types/switch/switch-fs.d.ts +52 -2
- package/lib/types/switch/switch-subprocess.d.ts +32 -3
- package/lib/types/tools.d.ts +7 -6
- package/lib/types/workspace-tool-ops.d.ts +33 -0
- package/lib/types/workspace.d.ts +18 -0
- package/lib/vault-3gpWct2Q.js +559 -0
- package/lib/workspace.js +2 -0
- package/package.json +59 -26
- package/scripts/export-legacy-workspaces.mjs +136 -0
- package/src/backend.ts +155 -483
- package/src/base/capability.ts +4 -0
- package/src/base/ledger-router.ts +214 -65
- package/src/base/ledger.ts +175 -48
- package/src/base/model.ts +18 -8
- package/src/base/plugin.ts +15 -4
- package/src/base/registry.ts +49 -41
- package/src/base/router.ts +0 -8
- package/src/client/api.ts +8 -36
- package/src/client/connect-host.ts +199 -0
- package/src/client/directory-flow.tsx +91 -36
- package/src/client/index.ts +56 -53
- package/src/client/locales.ts +14 -0
- package/src/client/session-connect-gate.ts +163 -0
- package/src/client/ssh/api.ts +245 -108
- package/src/client/ssh/apply.ts +23 -24
- package/src/client/ssh/locales.ts +16 -2
- package/src/client/ssh/ops-tab.tsx +79 -0
- package/src/client/ssh/panel/ClusterTab.tsx +9 -20
- package/src/client/ssh/panel/ConnectionErrorDialog.tsx +33 -0
- package/src/client/ssh/panel/SessionSecretDialog.tsx +5 -1
- package/src/client/ssh/panel/SshPanel.tsx +81 -77
- package/src/client/ssh/panel/TerminalTab.tsx +18 -37
- package/src/client/ssh/panel/TransferTab.tsx +43 -33
- package/src/client/ssh/panel/TunnelsTab.tsx +9 -26
- package/src/client/ssh/panel/panel.module.css +90 -127
- package/src/client/ssh/session-target.ts +69 -0
- package/src/client/state.ts +9 -1
- package/src/client/workspace-badges.ts +99 -96
- package/src/client/workspace-panel-entry.tsx +91 -0
- package/src/client/workspace-panel.tsx +227 -0
- package/src/client/workspace.module.css +76 -2
- package/src/client-http.ts +118 -18
- package/src/core.ts +37 -47
- package/src/fs.ts +171 -85
- package/src/index.ts +535 -244
- package/src/ledger.ts +62 -416
- package/src/protocol.ts +0 -6
- package/src/providers/index.ts +8 -4
- package/src/providers/local/provider.ts +259 -87
- package/src/providers/ssh/provider.ts +158 -155
- package/src/remote/environment.ts +22 -1
- package/src/remote/remote-fs.ts +123 -34
- package/src/remote/remote-process.ts +265 -203
- package/src/remote/remote-subprocess.ts +61 -18
- package/src/remote/remote-terminal.ts +106 -21
- package/src/remote-search.ts +122 -26
- package/src/routes.ts +416 -395
- package/src/runtime/dsh-capabilities.ts +19 -0
- package/src/runtime/workspace-core.ts +171 -88
- package/src/runtime/workspace-migration.ts +472 -0
- package/src/ssh/connection/lease.ts +35 -0
- package/src/ssh/connection/manager.ts +1083 -0
- package/src/ssh/connection/pool.ts +341 -275
- package/src/ssh/engine.ts +269 -1477
- package/src/ssh/known-hosts.ts +42 -18
- package/src/ssh/local-transfer-policy.ts +83 -0
- package/src/ssh/plugin.ts +20 -19
- package/src/ssh/protocol.ts +22 -1
- package/src/ssh/routes.ts +419 -187
- package/src/ssh/sftp/service.ts +967 -0
- package/src/ssh/store.ts +169 -72
- package/src/ssh/terminal/service.ts +177 -0
- package/src/ssh/tools.ts +42 -20
- package/src/ssh/tunnel/service.ts +217 -0
- package/src/ssh/vault.ts +245 -91
- package/src/subprocess.ts +88 -71
- package/src/switch/switch-fs.ts +141 -10
- package/src/switch/switch-subprocess.ts +80 -6
- package/src/tools.ts +217 -221
- package/src/workspace-tool-ops.ts +101 -0
- package/src/workspace.ts +52 -0
- package/lib/environment-BL1jddfB.js +0 -449
- package/lib/switch-fs-CAJpFY9C.js +0 -193
- package/lib/switch-fs-RrZtG2gv.js +0 -210
- package/lib/types/client/manager-button.d.ts +0 -32
- package/lib/types/client/ssh/mount.d.ts +0 -13
- package/lib/types/client/ssh/panel/HostsTab.d.ts +0 -10
- package/lib/types/client/ssh/panel/controller.d.ts +0 -23
- package/lib/types/client/ssh/sidebar-entry.d.ts +0 -25
- package/lib/types/client/workspace-gate.d.ts +0 -15
- package/lib/types/remote-runner.d.ts +0 -83
- package/lib/types/seam-state.d.ts +0 -69
- package/src/client/manager-button.tsx +0 -269
- package/src/client/ssh/mount.tsx +0 -83
- package/src/client/ssh/panel/HostsTab.tsx +0 -236
- package/src/client/ssh/panel/controller.ts +0 -48
- package/src/client/ssh/sidebar-entry.ts +0 -123
- package/src/client/workspace-gate.ts +0 -225
- package/src/remote-runner.ts +0 -201
- package/src/seam-state.ts +0 -185
package/src/routes.ts
CHANGED
|
@@ -1,395 +1,416 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The /api/dsh-hardssh route family: SSH-bound workspace CRUD
|
|
3
|
-
* directory browsing for the
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
import type {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
import type
|
|
18
|
-
import {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
if (
|
|
268
|
-
writeJson(res,
|
|
269
|
-
return
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
const
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
//
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
}
|
|
336
|
-
return
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
await
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
1
|
+
/**
|
|
2
|
+
* The /api/dsh-hardssh route family: SSH-bound workspace CRUD and remote
|
|
3
|
+
* directory browsing for the workspace-creation picker. Every route carries
|
|
4
|
+
* the same loopback-only trust fence as /api/dsh-ssh — these endpoints can
|
|
5
|
+
* read and write files on remote servers, so LAN-exposed dsh web deployments
|
|
6
|
+
* must not serve them.
|
|
7
|
+
*
|
|
8
|
+
* The per-workspace file tree / file content / search HTTP surface that used
|
|
9
|
+
* to live here was superseded by the provider-neutral file browsing in the
|
|
10
|
+
* dsh-workbench plugin (which consumes `workspace.fs`/`workspace.search`
|
|
11
|
+
* capabilities directly) and had no remaining UI consumer, so it was removed.
|
|
12
|
+
*/
|
|
13
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
14
|
+
import { posix } from 'node:path'
|
|
15
|
+
import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
|
|
16
|
+
import type { HostStore } from './ssh/store.ts'
|
|
17
|
+
import { NeedsPasswordError, type SshEngine } from './ssh/engine.ts'
|
|
18
|
+
import { HostKeyMismatchError, HostKeyUnknownError } from './ssh/known-hosts.ts'
|
|
19
|
+
import { isLoopbackRequest, queryParam, readJsonBody as readJsonBodyShared, writeJson } from './host-http.ts'
|
|
20
|
+
import type { WorkspaceStoreView } from './backend.ts'
|
|
21
|
+
import { BackendError, backendErrorStatus, sortWorkspaceEntries } from './backend.ts'
|
|
22
|
+
import { WORKSPACE_API } from './protocol.ts'
|
|
23
|
+
|
|
24
|
+
/** Cap on JSON request bodies (file writes carry content). */
|
|
25
|
+
const MAX_JSON_BODY_BYTES = 4 * 1024 * 1024
|
|
26
|
+
|
|
27
|
+
/** A request-body failure with a distinct HTTP status (413 vs 400). */
|
|
28
|
+
class RequestBodyError extends Error {
|
|
29
|
+
constructor(
|
|
30
|
+
public readonly status: 400 | 413,
|
|
31
|
+
message: string,
|
|
32
|
+
) {
|
|
33
|
+
super(message)
|
|
34
|
+
this.name = 'RequestBodyError'
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Read a JSON request body (throws RequestBodyError when too large/malformed). */
|
|
39
|
+
async function readJsonBody(req: IncomingMessage): Promise<Record<string, unknown>> {
|
|
40
|
+
const result = await readJsonBodyShared(req, MAX_JSON_BODY_BYTES)
|
|
41
|
+
if (result.ok) return result.body
|
|
42
|
+
if (result.reason === 'too-large') {
|
|
43
|
+
throw new RequestBodyError(413, `request body exceeds ${MAX_JSON_BODY_BYTES} bytes`)
|
|
44
|
+
}
|
|
45
|
+
throw new RequestBodyError(400, result.reason === 'malformed' ? 'malformed JSON body' : 'JSON body must be an object')
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Map any thrown error to a stable JSON error response. Interactive gates
|
|
49
|
+
* (session password, host-key TOFU) keep their machine codes so the client
|
|
50
|
+
* can prompt and retry instead of showing a raw message. */
|
|
51
|
+
function writeRouteError(res: ServerResponse, error: unknown, ioStatus: 500 | 502 = 502): void {
|
|
52
|
+
if (error instanceof RequestBodyError) {
|
|
53
|
+
writeJson(res, error.status, { error: error.message, code: error.status === 413 ? 'too-large' : 'invalid' })
|
|
54
|
+
return
|
|
55
|
+
}
|
|
56
|
+
if (error instanceof BackendError) {
|
|
57
|
+
writeJson(res, backendErrorStatus(error, ioStatus), { error: error.message, code: error.code })
|
|
58
|
+
return
|
|
59
|
+
}
|
|
60
|
+
if (error instanceof NeedsPasswordError) {
|
|
61
|
+
// NOT 200 here: readJson on a 2xx body would treat the structured error
|
|
62
|
+
// as a successful listing. 500 makes the client throw HttpApiError(code).
|
|
63
|
+
writeJson(res, 500, { error: error.message, code: 'NEEDS_PASSWORD', secret: error.secret })
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
if (error instanceof HostKeyUnknownError) {
|
|
67
|
+
writeJson(res, 500, { error: error.message, code: 'HOST_KEY_UNKNOWN', hostKeyFingerprint: error.fingerprintSha256 })
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
if (error instanceof HostKeyMismatchError) {
|
|
71
|
+
writeJson(res, 500, { error: error.message, code: 'HOST_KEY_MISMATCH', hostKeyFingerprint: error.actual })
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
writeJson(res, ioStatus, { error: error instanceof Error ? error.message : String(error), code: 'io' })
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** One record whose host-workspace (re)registration failed. */
|
|
78
|
+
export interface HostWorkspaceReconcileFailure {
|
|
79
|
+
id: string
|
|
80
|
+
error: string
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Result of replaying host-workspace registration for every stored record. */
|
|
84
|
+
export interface HostWorkspaceReconcileReport {
|
|
85
|
+
registered: number
|
|
86
|
+
failures: HostWorkspaceReconcileFailure[]
|
|
87
|
+
/** Set when the record source itself could not be listed (startup path
|
|
88
|
+
* tolerates it; the route surfaces it as an I/O failure). */
|
|
89
|
+
listError?: string
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Registration replay dependencies (the route and the startup path share it). */
|
|
93
|
+
export interface HostWorkspaceReconcileDeps {
|
|
94
|
+
workspaces: WorkspaceStoreView
|
|
95
|
+
registerHostWorkspace?: (anchorPath: string, title: string) => Promise<void>
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Replay host-workspace registration for every stored record. Registration
|
|
100
|
+
* happens only on create/delete, so a process restart (or a registration that
|
|
101
|
+
* failed before compensation existed) leaves records whose sidebar entry is
|
|
102
|
+
* missing; `registerHostWorkspace` is create-if-missing, so this is the
|
|
103
|
+
* idempotent startup/retry compensation. Per-record failures are reported and
|
|
104
|
+
* never thrown: a broken host registry must not break plugin startup.
|
|
105
|
+
*
|
|
106
|
+
* Shared by the `/reconcile` route and the boot path so the two halves cannot
|
|
107
|
+
* drift.
|
|
108
|
+
*/
|
|
109
|
+
export async function reconcileHostWorkspaces(deps: HostWorkspaceReconcileDeps): Promise<HostWorkspaceReconcileReport> {
|
|
110
|
+
let records: Awaited<ReturnType<WorkspaceStoreView['list']>>
|
|
111
|
+
try {
|
|
112
|
+
records = await deps.workspaces.list()
|
|
113
|
+
} catch (error) {
|
|
114
|
+
// A corrupt/unavailable record source must not break plugin startup.
|
|
115
|
+
return { registered: 0, failures: [], listError: error instanceof Error ? error.message : String(error) }
|
|
116
|
+
}
|
|
117
|
+
const failures: HostWorkspaceReconcileFailure[] = []
|
|
118
|
+
let registered = 0
|
|
119
|
+
for (const record of records) {
|
|
120
|
+
try {
|
|
121
|
+
// No host registry (headless profile): there is no sidebar entry to
|
|
122
|
+
// create, so the desired state already holds for every record.
|
|
123
|
+
if (deps.registerHostWorkspace !== undefined) {
|
|
124
|
+
await deps.registerHostWorkspace(record.anchorPath, record.title)
|
|
125
|
+
}
|
|
126
|
+
registered += 1
|
|
127
|
+
} catch (error) {
|
|
128
|
+
failures.push({ id: record.id, error: error instanceof Error ? error.message : String(error) })
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return { registered, failures }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Route family dependencies. */
|
|
135
|
+
export interface WorkspaceRoutesDeps {
|
|
136
|
+
/** Read-only host surface (list only; the SSH routes own the write path). */
|
|
137
|
+
hosts: import('./core.ts').HostStoreView
|
|
138
|
+
engine: SshEngine
|
|
139
|
+
/** SSH-workspace record source: the generic WorkspaceCore-backed
|
|
140
|
+
* projection store (the only runtime). */
|
|
141
|
+
workspaces: WorkspaceStoreView
|
|
142
|
+
/** Register the anchor dir as a HOST workspace (sidebar visibility). */
|
|
143
|
+
registerHostWorkspace?: (anchorPath: string, title: string) => Promise<void>
|
|
144
|
+
/** Drop the host workspace registration for an anchor dir. */
|
|
145
|
+
unregisterHostWorkspace?: (anchorPath: string) => Promise<void>
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Build every /api/dsh-hardssh route (workspace CRUD + remote directory
|
|
150
|
+
* browsing for the picker; the per-workspace file surface was removed).
|
|
151
|
+
*/
|
|
152
|
+
export function makeRoutes(deps: WorkspaceRoutesDeps): WebRoute[] {
|
|
153
|
+
const { hosts, engine, workspaces } = deps
|
|
154
|
+
|
|
155
|
+
const guard = (req: IncomingMessage, res: ServerResponse, method: string): boolean => {
|
|
156
|
+
if (!isLoopbackRequest(req)) {
|
|
157
|
+
writeJson(res, 403, { error: 'forbidden: loopback-only' })
|
|
158
|
+
return false
|
|
159
|
+
}
|
|
160
|
+
if (req.method !== method) {
|
|
161
|
+
writeJson(res, 405, { error: `method not allowed: ${req.method}` })
|
|
162
|
+
return false
|
|
163
|
+
}
|
|
164
|
+
return true
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** List the available SSH hosts (for the picker). */
|
|
168
|
+
const hostsRoute: WebRoute = {
|
|
169
|
+
kind: 'exact',
|
|
170
|
+
path: WORKSPACE_API.sshWorkspaces + '/hosts',
|
|
171
|
+
handler: async (req, res) => {
|
|
172
|
+
if (!guard(req, res, 'GET')) return
|
|
173
|
+
writeJson(res, 200, { hosts: hosts.list().map((host) => ({
|
|
174
|
+
alias: host.alias,
|
|
175
|
+
host: host.host,
|
|
176
|
+
port: host.port,
|
|
177
|
+
user: host.user,
|
|
178
|
+
auth: host.auth,
|
|
179
|
+
description: host.description,
|
|
180
|
+
})) })
|
|
181
|
+
},
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** List (GET) / create (POST) SSH-bound workspaces. Body for POST:
|
|
185
|
+
* { title, alias, remoteRoot }. */
|
|
186
|
+
const wsRoute: WebRoute = {
|
|
187
|
+
kind: 'exact',
|
|
188
|
+
path: WORKSPACE_API.sshWorkspaces,
|
|
189
|
+
handler: async (req, res) => {
|
|
190
|
+
if (!isLoopbackRequest(req)) {
|
|
191
|
+
writeJson(res, 403, { error: 'forbidden: loopback-only' })
|
|
192
|
+
return
|
|
193
|
+
}
|
|
194
|
+
if (req.method === 'GET') {
|
|
195
|
+
writeJson(res, 200, { workspaces: await workspaces.list() })
|
|
196
|
+
return
|
|
197
|
+
}
|
|
198
|
+
if (req.method === 'POST') {
|
|
199
|
+
let body: Record<string, unknown>
|
|
200
|
+
try {
|
|
201
|
+
body = await readJsonBody(req)
|
|
202
|
+
} catch (error) {
|
|
203
|
+
writeRouteError(res, error, 500)
|
|
204
|
+
return
|
|
205
|
+
}
|
|
206
|
+
const title = typeof body.title === 'string' ? body.title.trim() : ''
|
|
207
|
+
const alias = typeof body.alias === 'string' ? body.alias.trim() : ''
|
|
208
|
+
const remoteRoot = typeof body.remoteRoot === 'string' ? body.remoteRoot.trim() : ''
|
|
209
|
+
if (alias === '' || remoteRoot === '') {
|
|
210
|
+
writeJson(res, 400, { error: 'title, alias and remoteRoot are required' })
|
|
211
|
+
return
|
|
212
|
+
}
|
|
213
|
+
if (hosts.find(alias) === undefined) {
|
|
214
|
+
writeJson(res, 404, { error: `alias '${alias}' not found — configure it in the SSH dialog first` })
|
|
215
|
+
return
|
|
216
|
+
}
|
|
217
|
+
if (!remoteRoot.startsWith('/')) {
|
|
218
|
+
writeJson(res, 400, { error: `remoteRoot must be an absolute remote path (got '${remoteRoot}')` })
|
|
219
|
+
return
|
|
220
|
+
}
|
|
221
|
+
try {
|
|
222
|
+
const record = await workspaces.create({ title, alias, remoteRoot })
|
|
223
|
+
// Make the anchor a REAL host workspace so it appears in the
|
|
224
|
+
// sidebar and can host sessions (the seams route it remote by its
|
|
225
|
+
// anchor path). The two stores have no shared transaction, so a
|
|
226
|
+
// registration failure is COMPENSATED by deleting the record just
|
|
227
|
+
// created: returning 200 here would leave a binding the sidebar
|
|
228
|
+
// cannot reach, and returning 5xx while keeping the record would
|
|
229
|
+
// make a retry create a duplicate.
|
|
230
|
+
if (deps.registerHostWorkspace !== undefined) {
|
|
231
|
+
try {
|
|
232
|
+
await deps.registerHostWorkspace(record.anchorPath, record.title)
|
|
233
|
+
} catch (error) {
|
|
234
|
+
const registration = error instanceof Error ? error.message : String(error)
|
|
235
|
+
try {
|
|
236
|
+
await workspaces.remove(record.id)
|
|
237
|
+
} catch (rollbackError) {
|
|
238
|
+
const rollback = rollbackError instanceof Error ? rollbackError.message : String(rollbackError)
|
|
239
|
+
writeJson(res, 500, {
|
|
240
|
+
error: `host workspace registration failed for '${record.title}' (${registration}); rollback also failed (${rollback}) — workspace '${record.id}' still exists`,
|
|
241
|
+
code: 'HOST_REGISTRATION_ROLLBACK_FAILED',
|
|
242
|
+
})
|
|
243
|
+
return
|
|
244
|
+
}
|
|
245
|
+
writeJson(res, 502, {
|
|
246
|
+
error: `host workspace registration failed for '${record.title}': ${registration}`,
|
|
247
|
+
code: 'HOST_REGISTRATION_FAILED',
|
|
248
|
+
})
|
|
249
|
+
return
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
writeJson(res, 200, { workspace: record })
|
|
253
|
+
} catch (error) {
|
|
254
|
+
writeRouteError(res, error, 500)
|
|
255
|
+
}
|
|
256
|
+
return
|
|
257
|
+
}
|
|
258
|
+
writeJson(res, 405, { error: `method not allowed: ${req.method}` })
|
|
259
|
+
},
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Rename / delete one SSH-bound workspace. */
|
|
263
|
+
const itemRoute: WebRoute = {
|
|
264
|
+
kind: 'exact',
|
|
265
|
+
path: WORKSPACE_API.sshWorkspaces + '/item',
|
|
266
|
+
handler: async (req, res) => {
|
|
267
|
+
if (!isLoopbackRequest(req)) {
|
|
268
|
+
writeJson(res, 403, { error: 'forbidden: loopback-only' })
|
|
269
|
+
return
|
|
270
|
+
}
|
|
271
|
+
const url = new URL(req.url ?? '/', 'http://localhost')
|
|
272
|
+
const id = queryParam(url, 'id')
|
|
273
|
+
if (id === undefined || id === '') {
|
|
274
|
+
writeJson(res, 400, { error: 'id query parameter is required' })
|
|
275
|
+
return
|
|
276
|
+
}
|
|
277
|
+
if (req.method === 'PATCH') {
|
|
278
|
+
let body: Record<string, unknown>
|
|
279
|
+
try {
|
|
280
|
+
body = await readJsonBody(req)
|
|
281
|
+
} catch (error) {
|
|
282
|
+
writeRouteError(res, error, 500)
|
|
283
|
+
return
|
|
284
|
+
}
|
|
285
|
+
const title = typeof body.title === 'string' ? body.title.trim() : ''
|
|
286
|
+
if (title === '') {
|
|
287
|
+
writeJson(res, 400, { error: 'title is required' })
|
|
288
|
+
return
|
|
289
|
+
}
|
|
290
|
+
const record = await workspaces.rename(id, title)
|
|
291
|
+
if (record === undefined) {
|
|
292
|
+
writeJson(res, 404, { error: `workspace '${id}' not found` })
|
|
293
|
+
return
|
|
294
|
+
}
|
|
295
|
+
writeJson(res, 200, { workspace: record })
|
|
296
|
+
return
|
|
297
|
+
}
|
|
298
|
+
if (req.method === 'DELETE') {
|
|
299
|
+
const record = await workspaces.get(id)
|
|
300
|
+
if (record === undefined) {
|
|
301
|
+
writeJson(res, 404, { error: `workspace '${id}' not found` })
|
|
302
|
+
return
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Unregister first. If this fails the authoritative ledger record —
|
|
306
|
+
// including its original id and anchor — remains completely untouched.
|
|
307
|
+
if (deps.unregisterHostWorkspace !== undefined) {
|
|
308
|
+
try {
|
|
309
|
+
await deps.unregisterHostWorkspace(record.anchorPath)
|
|
310
|
+
} catch (error) {
|
|
311
|
+
const unregistration = error instanceof Error ? error.message : String(error)
|
|
312
|
+
writeJson(res, 502, {
|
|
313
|
+
error: `host workspace unregistration failed for '${record.title}': ${unregistration}`,
|
|
314
|
+
code: 'HOST_UNREGISTRATION_FAILED',
|
|
315
|
+
})
|
|
316
|
+
return
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// The cross-store order deliberately leaves this recovery asymmetric:
|
|
321
|
+
// if persistence fails after sidebar removal, the original ledger
|
|
322
|
+
// record remains and the existing startup reconcile re-registers its
|
|
323
|
+
// exact id/anchor. Creating a compensating record here would invent a
|
|
324
|
+
// new identity and orphan the old sidebar binding.
|
|
325
|
+
try {
|
|
326
|
+
const removed = await workspaces.remove(id)
|
|
327
|
+
if (!removed) {
|
|
328
|
+
writeJson(res, 404, { error: `workspace '${id}' not found` })
|
|
329
|
+
return
|
|
330
|
+
}
|
|
331
|
+
} catch (error) {
|
|
332
|
+
writeRouteError(res, error, 500)
|
|
333
|
+
return
|
|
334
|
+
}
|
|
335
|
+
writeJson(res, 200, { ok: true })
|
|
336
|
+
return
|
|
337
|
+
}
|
|
338
|
+
writeJson(res, 405, { error: `method not allowed: ${req.method}` })
|
|
339
|
+
},
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** Browse a remote directory tree (SFTP readdir via the engine) for the
|
|
343
|
+
* workspace picker. Query: alias + path (default home when omitted). */
|
|
344
|
+
const dirRoute: WebRoute = {
|
|
345
|
+
kind: 'exact',
|
|
346
|
+
path: WORKSPACE_API.sshWorkspaceDir,
|
|
347
|
+
handler: async (req, res) => {
|
|
348
|
+
if (!guard(req, res, 'GET')) return
|
|
349
|
+
const url = new URL(req.url ?? '/', 'http://localhost')
|
|
350
|
+
const alias = queryParam(url, 'alias')
|
|
351
|
+
let requestedPath = queryParam(url, 'path')
|
|
352
|
+
if (alias === undefined || alias === '') {
|
|
353
|
+
writeJson(res, 400, { error: 'alias query parameter is required' })
|
|
354
|
+
return
|
|
355
|
+
}
|
|
356
|
+
if (hosts.find(alias) === undefined) {
|
|
357
|
+
writeJson(res, 404, { error: `alias '${alias}' not found` })
|
|
358
|
+
return
|
|
359
|
+
}
|
|
360
|
+
try {
|
|
361
|
+
if (requestedPath === undefined || requestedPath === '') {
|
|
362
|
+
const home = await engine.exec(alias, 'printf %s "$HOME"', 10_000)
|
|
363
|
+
if (!home.success || home.stdout.trim() === '') {
|
|
364
|
+
writeJson(res, 502, { error: `could not resolve remote home: ${home.stderr.trim() || 'empty $HOME'}` })
|
|
365
|
+
return
|
|
366
|
+
}
|
|
367
|
+
requestedPath = home.stdout.trim()
|
|
368
|
+
}
|
|
369
|
+
if (requestedPath.includes('\0') || requestedPath.includes('\\') || !requestedPath.startsWith('/')) {
|
|
370
|
+
writeJson(res, 400, { error: `path must be an absolute POSIX path (got '${requestedPath}')` })
|
|
371
|
+
return
|
|
372
|
+
}
|
|
373
|
+
const abs = posix.normalize(requestedPath)
|
|
374
|
+
const entries = sortWorkspaceEntries(
|
|
375
|
+
(await engine.ls(alias, abs)).map((entry) => ({ name: entry.name, type: entry.type, size: entry.size, mtimeMs: entry.mtimeMs })),
|
|
376
|
+
)
|
|
377
|
+
writeJson(res, 200, { path: abs, entries })
|
|
378
|
+
} catch (error) {
|
|
379
|
+
writeRouteError(res, error, 502)
|
|
380
|
+
}
|
|
381
|
+
},
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Replay host-workspace registration for every stored record. Registration
|
|
386
|
+
* happens only on create/delete, so a process restart (or a registration
|
|
387
|
+
* that failed before compensation existed) leaves records whose sidebar
|
|
388
|
+
* entry is missing; `registerHostWorkspace` is create-if-missing, so this is
|
|
389
|
+
* the idempotent startup/retry compensation. Per-record failures are
|
|
390
|
+
* reported, never fatal.
|
|
391
|
+
*/
|
|
392
|
+
const reconcileRoute: WebRoute = {
|
|
393
|
+
kind: 'exact',
|
|
394
|
+
path: WORKSPACE_API.sshWorkspaces + '/reconcile',
|
|
395
|
+
handler: async (req, res) => {
|
|
396
|
+
if (!guard(req, res, 'POST')) return
|
|
397
|
+
try {
|
|
398
|
+
const report = await reconcileHostWorkspaces({
|
|
399
|
+
workspaces,
|
|
400
|
+
registerHostWorkspace: deps.registerHostWorkspace,
|
|
401
|
+
})
|
|
402
|
+
// The helper tolerates a failing record source for the startup path;
|
|
403
|
+
// an explicit HTTP request must still see the I/O failure.
|
|
404
|
+
if (report.listError !== undefined) {
|
|
405
|
+
writeJson(res, 502, { error: report.listError, code: 'io' })
|
|
406
|
+
return
|
|
407
|
+
}
|
|
408
|
+
writeJson(res, 200, { ok: true, registered: report.registered, failed: report.failures.length, failures: report.failures })
|
|
409
|
+
} catch (error) {
|
|
410
|
+
writeRouteError(res, error, 502)
|
|
411
|
+
}
|
|
412
|
+
},
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
return [hostsRoute, wsRoute, itemRoute, dirRoute, reconcileRoute]
|
|
416
|
+
}
|