dsh-ssh-tunnel 1.0.0 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +68 -0
- package/README.md +80 -38
- package/README_en.md +80 -38
- package/cordis.patch.yml +4 -4
- package/icon.svg +26 -0
- package/lib/client.js +1643 -1472
- package/lib/index.js +1739 -1611
- package/lib/session.js +616 -525
- package/lib/shared/args.js +23 -0
- package/lib/shared/host-key.js +32 -32
- package/lib/shared/host-summary.js +75 -75
- package/lib/shared/http-trust.js +59 -59
- package/lib/shared/path.js +21 -22
- package/lib/shared/persist.js +41 -41
- package/lib/shared/session-auth.js +47 -39
- package/lib/shared/session-policy.js +151 -125
- package/lib/shared/shell-buffer.js +10 -10
- package/lib/shared/vendor.js +49 -49
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +91 -84
- package/scripts/install.ps1 +191 -93
- package/scripts/install.sh +278 -226
- package/scripts/lib/bundle-check.cjs +108 -0
- package/scripts/lib/strip-mount.cjs +114 -0
- package/scripts/lib/ws-exclude.cjs +143 -0
- package/scripts/smoke-test.mjs +899 -511
- package/scripts/sync-to-dsh.sh +87 -5
package/lib/index.js
CHANGED
|
@@ -1,1611 +1,1739 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* dsh-ssh-tunnel — host half
|
|
3
|
-
* LiveAgent-style multi-host SSH + project-scoped grants + SSHManager tool.
|
|
4
|
-
*/
|
|
5
|
-
import { createRequire } from 'node:module'
|
|
6
|
-
import { randomUUID } from 'node:crypto'
|
|
7
|
-
import {
|
|
8
|
-
chmodSync,
|
|
9
|
-
existsSync,
|
|
10
|
-
mkdirSync,
|
|
11
|
-
readFileSync,
|
|
12
|
-
readdirSync,
|
|
13
|
-
lstatSync,
|
|
14
|
-
statSync,
|
|
15
|
-
writeFileSync,
|
|
16
|
-
renameSync,
|
|
17
|
-
unlinkSync,
|
|
18
|
-
rmdirSync,
|
|
19
|
-
} from 'node:fs'
|
|
20
|
-
import { homedir } from 'node:os'
|
|
21
|
-
import { dirname, join } from 'node:path'
|
|
22
|
-
import { Client } from 'ssh2'
|
|
23
|
-
import {
|
|
24
|
-
normalizeProjectKey,
|
|
25
|
-
constrainToWorkspace,
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
SSH_KEEPALIVE_COUNT_MAX,
|
|
42
|
-
SSH_KEEPALIVE_INTERVAL_MS,
|
|
43
|
-
SSH_READY_TIMEOUT_MS,
|
|
44
|
-
SSH_RECONNECT_ATTEMPT_TIMEOUT_MS,
|
|
45
|
-
SSH_STATUS_DISCONNECTED,
|
|
46
|
-
SSH_STATUS_RECONNECTING,
|
|
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
|
-
const
|
|
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
|
-
function
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
return
|
|
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
|
-
const
|
|
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
|
-
const
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
if (
|
|
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
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
const
|
|
356
|
-
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
}
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
}
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
}
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
}
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
}
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
}
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
}
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
const
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
const
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
}
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
}
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
if (
|
|
1035
|
-
|
|
1036
|
-
}
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
const
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
const
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
const
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
return { ok: true,
|
|
1194
|
-
}
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
const
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
)
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
name
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
}
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
const
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
)
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
if (
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
}
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* dsh-ssh-tunnel — host half
|
|
3
|
+
* LiveAgent-style multi-host SSH + project-scoped grants + SSHManager tool.
|
|
4
|
+
*/
|
|
5
|
+
import { createRequire } from 'node:module'
|
|
6
|
+
import { randomUUID } from 'node:crypto'
|
|
7
|
+
import {
|
|
8
|
+
chmodSync,
|
|
9
|
+
existsSync,
|
|
10
|
+
mkdirSync,
|
|
11
|
+
readFileSync,
|
|
12
|
+
readdirSync,
|
|
13
|
+
lstatSync,
|
|
14
|
+
statSync,
|
|
15
|
+
writeFileSync,
|
|
16
|
+
renameSync,
|
|
17
|
+
unlinkSync,
|
|
18
|
+
rmdirSync,
|
|
19
|
+
} from 'node:fs'
|
|
20
|
+
import { homedir } from 'node:os'
|
|
21
|
+
import { dirname, join } from 'node:path'
|
|
22
|
+
import { Client } from 'ssh2'
|
|
23
|
+
import {
|
|
24
|
+
normalizeProjectKey,
|
|
25
|
+
constrainToWorkspace,
|
|
26
|
+
} from './shared/path.js'
|
|
27
|
+
import {
|
|
28
|
+
hostCredentialConfigured,
|
|
29
|
+
publicHost,
|
|
30
|
+
modelHostSummary,
|
|
31
|
+
} from './shared/host-summary.js'
|
|
32
|
+
import { requireString, stringOrDefault } from './shared/args.js'
|
|
33
|
+
import {
|
|
34
|
+
fingerprintFromHostKey,
|
|
35
|
+
formatHostFingerprint,
|
|
36
|
+
isUsableHostFingerprint,
|
|
37
|
+
} from './shared/host-key.js'
|
|
38
|
+
import {
|
|
39
|
+
API_ERROR_STATUS,
|
|
40
|
+
MAX_BYTES_SFTP_READ_DEFAULT,
|
|
41
|
+
SSH_KEEPALIVE_COUNT_MAX,
|
|
42
|
+
SSH_KEEPALIVE_INTERVAL_MS,
|
|
43
|
+
SSH_READY_TIMEOUT_MS,
|
|
44
|
+
SSH_RECONNECT_ATTEMPT_TIMEOUT_MS,
|
|
45
|
+
SSH_STATUS_DISCONNECTED,
|
|
46
|
+
SSH_STATUS_RECONNECTING,
|
|
47
|
+
clampMaxBytes,
|
|
48
|
+
disconnectedSessionError,
|
|
49
|
+
hostKeyPromptMessage,
|
|
50
|
+
isLiveStatus,
|
|
51
|
+
kbiReconnectError,
|
|
52
|
+
kbiUnsupportedError,
|
|
53
|
+
pickOldestTombstone,
|
|
54
|
+
defaultTimeoutMsForAction,
|
|
55
|
+
SSH_TOOL_TIMEOUT_MS,
|
|
56
|
+
sshError,
|
|
57
|
+
withTimeout,
|
|
58
|
+
} from './shared/session-policy.js'
|
|
59
|
+
import { createSessionRegistry } from './session.js'
|
|
60
|
+
import {
|
|
61
|
+
assertSessionStillAuthorized,
|
|
62
|
+
discardRevokedLocalFile,
|
|
63
|
+
} from './shared/session-auth.js'
|
|
64
|
+
import { isTrustedRequest, MAX_JSON_BODY_BYTES } from './shared/http-trust.js'
|
|
65
|
+
import {
|
|
66
|
+
findExistingHostByEndpoint,
|
|
67
|
+
migrateMarkerPath,
|
|
68
|
+
readJsonFile,
|
|
69
|
+
writeJsonAtomic,
|
|
70
|
+
} from './shared/persist.js'
|
|
71
|
+
import { resolveVendorAsset } from './shared/vendor.js'
|
|
72
|
+
|
|
73
|
+
// Out-of-tree plugins cannot reliably resolve @deepseek-ai/dsh-tools (see modlens).
|
|
74
|
+
// Register a raw JSON-Schema tool definition via ctx.tools.register instead of defineTool.
|
|
75
|
+
const requireSelf = createRequire(import.meta.url)
|
|
76
|
+
|
|
77
|
+
let PLUGIN_VERSION = '0.0.0'
|
|
78
|
+
try {
|
|
79
|
+
PLUGIN_VERSION = String(requireSelf('../package.json').version || PLUGIN_VERSION)
|
|
80
|
+
} catch {}
|
|
81
|
+
|
|
82
|
+
export const name = 'dsh-ssh-tunnel'
|
|
83
|
+
// webRuntime is deliberately NOT injected: it belongs to the web-app bundle row
|
|
84
|
+
// and is optional here. Reading it without inject requires ctx.get(name, true) —
|
|
85
|
+
// a bare ctx.webRuntime throws when the service is not injected.
|
|
86
|
+
export const inject = ['webServer', 'sessions', 'tools']
|
|
87
|
+
|
|
88
|
+
/** Set true after SSHManager successfully enters the tools registry. */
|
|
89
|
+
let sshManagerRegistered = false
|
|
90
|
+
|
|
91
|
+
const DATA_DIR = process.env.DSH_HOME
|
|
92
|
+
? join(process.env.DSH_HOME, 'ssh-tunnel')
|
|
93
|
+
: join(homedir(), '.dsh/ssh-tunnel')
|
|
94
|
+
const HOSTS_PATH = join(DATA_DIR, 'hosts.json')
|
|
95
|
+
const SECRETS_PATH = join(DATA_DIR, 'secrets.json')
|
|
96
|
+
const GRANTS_PATH = join(DATA_DIR, 'grants.json')
|
|
97
|
+
const KNOWN_PATH = join(DATA_DIR, 'known_hosts.json')
|
|
98
|
+
|
|
99
|
+
function rmLocalRecursive(abs) {
|
|
100
|
+
const st = lstatSync(abs)
|
|
101
|
+
if (st.isSymbolicLink() || st.isFile()) {
|
|
102
|
+
unlinkSync(abs)
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
if (st.isDirectory()) {
|
|
106
|
+
for (const name of readdirSync(abs)) {
|
|
107
|
+
rmLocalRecursive(join(abs, name))
|
|
108
|
+
}
|
|
109
|
+
rmdirSync(abs)
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
unlinkSync(abs)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function ensureDataDir() {
|
|
116
|
+
mkdirSync(DATA_DIR, { recursive: true })
|
|
117
|
+
try { chmodSync(DATA_DIR, 0o700) } catch {}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function readJson(path, fallback) {
|
|
121
|
+
return readJsonFile(path, fallback)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function writeJsonFile(path, data, mode = 0o600) {
|
|
125
|
+
ensureDataDir()
|
|
126
|
+
writeJsonAtomic(path, data, mode)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
function sessionCwdOf(ctx, sessionId, clientCwd) {
|
|
131
|
+
try {
|
|
132
|
+
const headerCwd = ctx.sessions?.get?.(sessionId)?.header?.cwd
|
|
133
|
+
if (headerCwd) return normalizeProjectKey(headerCwd)
|
|
134
|
+
} catch {}
|
|
135
|
+
if (clientCwd) return normalizeProjectKey(clientCwd)
|
|
136
|
+
return ''
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/* ---------------- Host store ---------------- */
|
|
140
|
+
|
|
141
|
+
function emptyHosts() {
|
|
142
|
+
return { version: 1, hosts: [] }
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function loadHosts() {
|
|
146
|
+
const data = readJson(HOSTS_PATH, emptyHosts())
|
|
147
|
+
if (!Array.isArray(data.hosts)) data.hosts = []
|
|
148
|
+
data.version = 1
|
|
149
|
+
return data
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function saveHosts(data) {
|
|
153
|
+
writeJsonFile(HOSTS_PATH, { version: 1, hosts: data.hosts || [] }, 0o600)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function loadSecrets() {
|
|
157
|
+
return readJson(SECRETS_PATH, { version: 1, byHostId: {} })
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function saveSecrets(data) {
|
|
161
|
+
writeJsonFile(SECRETS_PATH, { version: 1, byHostId: data.byHostId || {} }, 0o600)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function loadGrants() {
|
|
165
|
+
return readJson(GRANTS_PATH, { version: 1, projects: {} })
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function saveGrants(data) {
|
|
169
|
+
writeJsonFile(GRANTS_PATH, { version: 1, projects: data.projects || {} }, 0o600)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function loadKnown() {
|
|
173
|
+
return readJson(KNOWN_PATH, { version: 1, keys: {} })
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function saveKnown(data) {
|
|
177
|
+
writeJsonFile(KNOWN_PATH, { version: 1, keys: data.keys || {} }, 0o600)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
function upsertHostRecord(input) {
|
|
184
|
+
const hostsDoc = loadHosts()
|
|
185
|
+
const secrets = loadSecrets()
|
|
186
|
+
const id = (input.id && String(input.id).trim()) || randomUUID()
|
|
187
|
+
const existing = hostsDoc.hosts.find((h) => h.id === id)
|
|
188
|
+
const authType = input.authType || existing?.authType || 'privateKey'
|
|
189
|
+
if (authType === 'keyboardInteractive') {
|
|
190
|
+
throw new Error(kbiUnsupportedError())
|
|
191
|
+
}
|
|
192
|
+
const record = {
|
|
193
|
+
id,
|
|
194
|
+
name: String(input.name || existing?.name || input.host || 'host').trim() || 'host',
|
|
195
|
+
host: String(input.host || existing?.host || '').trim(),
|
|
196
|
+
port: Number(input.port != null ? input.port : existing?.port) || 22,
|
|
197
|
+
username: String(input.username || existing?.username || '').trim(),
|
|
198
|
+
authType,
|
|
199
|
+
privateKeyPath:
|
|
200
|
+
input.privateKeyPath !== undefined
|
|
201
|
+
? String(input.privateKeyPath || '')
|
|
202
|
+
: existing?.privateKeyPath || '',
|
|
203
|
+
source: input.source || existing?.source || 'manual',
|
|
204
|
+
updatedAt: Date.now(),
|
|
205
|
+
}
|
|
206
|
+
if (!record.host || !record.username) {
|
|
207
|
+
throw new Error('host and username are required')
|
|
208
|
+
}
|
|
209
|
+
const sec = Object.assign({}, secrets.byHostId[id] || {})
|
|
210
|
+
if (input.password !== undefined) {
|
|
211
|
+
if (input.password === '' || input.password == null) delete sec.password
|
|
212
|
+
else sec.password = String(input.password)
|
|
213
|
+
}
|
|
214
|
+
if (input.privateKeyPem !== undefined) {
|
|
215
|
+
if (input.privateKeyPem === '' || input.privateKeyPem == null) delete sec.privateKeyPem
|
|
216
|
+
else sec.privateKeyPem = String(input.privateKeyPem)
|
|
217
|
+
}
|
|
218
|
+
if (input.passphrase !== undefined) {
|
|
219
|
+
if (input.passphrase === '' || input.passphrase == null) delete sec.passphrase
|
|
220
|
+
else sec.passphrase = String(input.passphrase)
|
|
221
|
+
}
|
|
222
|
+
if (Object.keys(sec).length) secrets.byHostId[id] = sec
|
|
223
|
+
else delete secrets.byHostId[id]
|
|
224
|
+
|
|
225
|
+
if (existing) {
|
|
226
|
+
hostsDoc.hosts = hostsDoc.hosts.map((h) => (h.id === id ? record : h))
|
|
227
|
+
} else {
|
|
228
|
+
hostsDoc.hosts.push(record)
|
|
229
|
+
}
|
|
230
|
+
saveHosts(hostsDoc)
|
|
231
|
+
saveSecrets(secrets)
|
|
232
|
+
return publicHost(record, secrets)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function deleteHostRecord(id) {
|
|
236
|
+
const hostsDoc = loadHosts()
|
|
237
|
+
hostsDoc.hosts = hostsDoc.hosts.filter((h) => h.id !== id)
|
|
238
|
+
saveHosts(hostsDoc)
|
|
239
|
+
const secrets = loadSecrets()
|
|
240
|
+
delete secrets.byHostId[id]
|
|
241
|
+
saveSecrets(secrets)
|
|
242
|
+
// Prompts pointing at a deleted host can never be answered usefully.
|
|
243
|
+
for (const [promptId, p] of pendingPrompts) {
|
|
244
|
+
if (String(p.hostId) === String(id)) pendingPrompts.delete(promptId)
|
|
245
|
+
}
|
|
246
|
+
const grants = loadGrants()
|
|
247
|
+
for (const key of Object.keys(grants.projects || {})) {
|
|
248
|
+
const g = grants.projects[key]
|
|
249
|
+
if (g && Array.isArray(g.hostIds)) {
|
|
250
|
+
const had = new Set(g.hostIds).has(id)
|
|
251
|
+
g.hostIds = g.hostIds.filter((x) => x !== id)
|
|
252
|
+
g.updatedAt = Date.now()
|
|
253
|
+
if (had) registry.closeSessionsForHost(key, id)
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
saveGrants(grants)
|
|
257
|
+
return { ok: true }
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function scanOpenSsh() {
|
|
261
|
+
// Only the user's real ~/.ssh: a fixed /workspace path assumed a mount that
|
|
262
|
+
// other hosts do not have.
|
|
263
|
+
const dirs = [join(homedir(), '.ssh')]
|
|
264
|
+
const found = []
|
|
265
|
+
const configPaths = dirs.map((d) => join(d, 'config'))
|
|
266
|
+
for (const cfgPath of configPaths) {
|
|
267
|
+
if (!existsSync(cfgPath)) continue
|
|
268
|
+
let text = ''
|
|
269
|
+
try { text = readFileSync(cfgPath, 'utf8') } catch { continue }
|
|
270
|
+
let current = null
|
|
271
|
+
const flush = () => {
|
|
272
|
+
if (current && current.patterns.length) found.push(current)
|
|
273
|
+
current = null
|
|
274
|
+
}
|
|
275
|
+
for (const line of text.split(/\r?\n/)) {
|
|
276
|
+
const t = line.trim()
|
|
277
|
+
if (!t || t.startsWith('#')) continue
|
|
278
|
+
const m = /^(Host|HostName|User|Port|IdentityFile)\s+(.+)$/i.exec(t)
|
|
279
|
+
if (!m) continue
|
|
280
|
+
const key = m[1].toLowerCase()
|
|
281
|
+
const val = m[2].trim().replace(/^"|"$/g, '')
|
|
282
|
+
if (key === 'host') {
|
|
283
|
+
flush()
|
|
284
|
+
const patterns = val.split(/\s+/).filter((p) => p && p !== '*')
|
|
285
|
+
current = { patterns, hostName: '', user: '', port: 22, identityFile: '' }
|
|
286
|
+
} else if (current) {
|
|
287
|
+
if (key === 'hostname') current.hostName = val
|
|
288
|
+
if (key === 'user') current.user = val
|
|
289
|
+
if (key === 'port') current.port = Number(val) || 22
|
|
290
|
+
if (key === 'identityfile') current.identityFile = val.replace(/^~(?=\/)/, homedir())
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
flush()
|
|
294
|
+
}
|
|
295
|
+
// Only files that actually carry a private key header: without this the scan
|
|
296
|
+
// reported config backups, known_hosts.old, allowed_signers etc. as keys.
|
|
297
|
+
const keyHeader = /^-----BEGIN [A-Z ]*PRIVATE KEY-----/
|
|
298
|
+
const keyFiles = []
|
|
299
|
+
for (const dir of dirs) {
|
|
300
|
+
if (!existsSync(dir)) continue
|
|
301
|
+
try {
|
|
302
|
+
for (const name of readdirSync(dir)) {
|
|
303
|
+
const p = join(dir, name)
|
|
304
|
+
let head = ''
|
|
305
|
+
try {
|
|
306
|
+
if (!statSync(p).isFile()) continue
|
|
307
|
+
const fd = readFileSync(p, { encoding: 'utf8', flag: 'r' })
|
|
308
|
+
head = fd.split(/\r?\n/, 1)[0] || ''
|
|
309
|
+
} catch { continue }
|
|
310
|
+
if (keyHeader.test(head.trim())) keyFiles.push(p)
|
|
311
|
+
}
|
|
312
|
+
} catch {}
|
|
313
|
+
}
|
|
314
|
+
return {
|
|
315
|
+
configs: found.map((c) => ({
|
|
316
|
+
name: c.patterns[0],
|
|
317
|
+
patterns: c.patterns,
|
|
318
|
+
host: c.hostName || c.patterns[0],
|
|
319
|
+
username: c.user || '',
|
|
320
|
+
port: c.port || 22,
|
|
321
|
+
privateKeyPath: c.identityFile || '',
|
|
322
|
+
authType: 'privateKey',
|
|
323
|
+
})),
|
|
324
|
+
privateKeys: keyFiles,
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function importScanEntry(entry) {
|
|
329
|
+
// No implicit "root": a scan entry without User must be completed by the
|
|
330
|
+
// user, not silently saved as root (upsert rejects the empty username).
|
|
331
|
+
return upsertHostRecord({
|
|
332
|
+
name: entry.name || entry.host,
|
|
333
|
+
host: entry.host,
|
|
334
|
+
port: entry.port || 22,
|
|
335
|
+
username: String(entry.username || '').trim(),
|
|
336
|
+
authType: 'privateKey',
|
|
337
|
+
privateKeyPath: entry.privateKeyPath || '',
|
|
338
|
+
source: 'openssh-scan',
|
|
339
|
+
})
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/* ---------------- Grants ---------------- */
|
|
343
|
+
|
|
344
|
+
function getProjectGrants(projectPathKey) {
|
|
345
|
+
const key = normalizeProjectKey(projectPathKey)
|
|
346
|
+
const grants = loadGrants()
|
|
347
|
+
const g = grants.projects[key] || { hostIds: [], updatedAt: 0 }
|
|
348
|
+
return { projectPathKey: key, hostIds: [...(g.hostIds || [])], updatedAt: g.updatedAt || 0 }
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function setProjectGrants(projectPathKey, hostIds) {
|
|
352
|
+
const key = normalizeProjectKey(projectPathKey)
|
|
353
|
+
if (!key) throw sshError('bad_request', 'projectPathKey required')
|
|
354
|
+
const grants = loadGrants()
|
|
355
|
+
const ids = [...new Set((hostIds || []).map((x) => String(x).trim()).filter(Boolean))]
|
|
356
|
+
const prev = new Set((grants.projects[key]?.hostIds) || [])
|
|
357
|
+
grants.projects[key] = { hostIds: ids, updatedAt: Date.now() }
|
|
358
|
+
saveGrants(grants)
|
|
359
|
+
// Security: removing a host from a project grant must terminate its live
|
|
360
|
+
// sessions in this project, not just block new connections.
|
|
361
|
+
for (const removed of prev) {
|
|
362
|
+
if (!ids.includes(String(removed))) registry.closeSessionsForHost(key, String(removed))
|
|
363
|
+
}
|
|
364
|
+
return getProjectGrants(key)
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Authorization predicate for using an SSH session: the host must still exist
|
|
369
|
+
* and be granted to the session's own project at use time. Re-evaluated on
|
|
370
|
+
* every session use / reconnect so a grant revocation takes effect immediately.
|
|
371
|
+
*/
|
|
372
|
+
function isHostAuthorized(projectPathKey, hostId) {
|
|
373
|
+
const key = normalizeProjectKey(projectPathKey)
|
|
374
|
+
if (!key || !hostId) return false
|
|
375
|
+
const { hostIds } = getProjectGrants(key)
|
|
376
|
+
return hostIds.includes(String(hostId)) && !!getHostById(String(hostId))
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/* ---------------- Sessions ---------------- */
|
|
380
|
+
|
|
381
|
+
/** @type {Map<string, any>} */
|
|
382
|
+
const pendingPrompts = new Map()
|
|
383
|
+
|
|
384
|
+
// Prompts are one-shot UI state, not durable data: untrusted prompts must not
|
|
385
|
+
// accumulate forever (a client that never answers would otherwise pin them).
|
|
386
|
+
const PROMPT_TTL_MS = 5 * 60_000
|
|
387
|
+
const PROMPT_MAX = 50
|
|
388
|
+
|
|
389
|
+
function setPendingPrompt(prompt) {
|
|
390
|
+
const now = Date.now()
|
|
391
|
+
for (const [id, p] of pendingPrompts) {
|
|
392
|
+
if (p.expiresAt <= now) pendingPrompts.delete(id)
|
|
393
|
+
}
|
|
394
|
+
while (pendingPrompts.size >= PROMPT_MAX) {
|
|
395
|
+
let oldestId = ''
|
|
396
|
+
let oldestAt = Infinity
|
|
397
|
+
for (const [id, p] of pendingPrompts) {
|
|
398
|
+
if ((p.createdAt || 0) < oldestAt) {
|
|
399
|
+
oldestAt = p.createdAt || 0
|
|
400
|
+
oldestId = id
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
if (!oldestId) break
|
|
404
|
+
pendingPrompts.delete(oldestId)
|
|
405
|
+
}
|
|
406
|
+
const promptId = randomUUID()
|
|
407
|
+
pendingPrompts.set(promptId, { ...prompt, createdAt: now, expiresAt: now + PROMPT_TTL_MS })
|
|
408
|
+
return promptId
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// A mutating action that already reached the remote host before the grant was
|
|
412
|
+
// revoked must not be reported as if nothing happened.
|
|
413
|
+
const REVOKED_AFTER_RUN =
|
|
414
|
+
'The operation already ran on the remote host, but the host lost project access before the result could be returned; the change stands remotely and the result is withheld.'
|
|
415
|
+
const DOWNLOAD_DISCARDED =
|
|
416
|
+
"The host's project access was revoked during the transfer; the partially downloaded file was discarded and nothing was delivered."
|
|
417
|
+
|
|
418
|
+
function loadPrivateKeyMaterial(host, secrets) {
|
|
419
|
+
const sec = secrets.byHostId[host.id] || {}
|
|
420
|
+
if (sec.privateKeyPem) return Buffer.from(sec.privateKeyPem)
|
|
421
|
+
if (host.privateKeyPath) {
|
|
422
|
+
const p = String(host.privateKeyPath).replace(/^~(?=\/)/, homedir())
|
|
423
|
+
if (!existsSync(p)) throw new Error('Cannot read private key: ' + p)
|
|
424
|
+
return readFileSync(p)
|
|
425
|
+
}
|
|
426
|
+
throw new Error('No private key configured')
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function buildConnectOpts(host, secrets) {
|
|
430
|
+
const opts = {
|
|
431
|
+
host: host.host,
|
|
432
|
+
port: host.port || 22,
|
|
433
|
+
username: host.username,
|
|
434
|
+
readyTimeout: SSH_READY_TIMEOUT_MS,
|
|
435
|
+
keepaliveInterval: SSH_KEEPALIVE_INTERVAL_MS,
|
|
436
|
+
keepaliveCountMax: SSH_KEEPALIVE_COUNT_MAX,
|
|
437
|
+
// Pass a hex digest into hostVerifier (not a raw key Buffer).
|
|
438
|
+
hostHash: 'sha256',
|
|
439
|
+
}
|
|
440
|
+
const sec = secrets.byHostId[host.id] || {}
|
|
441
|
+
if (host.authType === 'password') {
|
|
442
|
+
if (!sec.password) throw new Error('password not configured')
|
|
443
|
+
opts.password = sec.password
|
|
444
|
+
} else if (host.authType === 'keyboardInteractive') {
|
|
445
|
+
throw new Error(kbiUnsupportedError())
|
|
446
|
+
} else {
|
|
447
|
+
opts.privateKey = loadPrivateKeyMaterial(host, secrets)
|
|
448
|
+
if (sec.passphrase) opts.passphrase = sec.passphrase
|
|
449
|
+
}
|
|
450
|
+
return opts
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function knownKeyId(host, port) {
|
|
454
|
+
return `${host}:${port || 22}`
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function connectSsh(host, secrets, options = {}) {
|
|
458
|
+
return new Promise((resolve, reject) => {
|
|
459
|
+
const client = new Client()
|
|
460
|
+
const opts = buildConnectOpts(host, secrets)
|
|
461
|
+
const known = loadKnown()
|
|
462
|
+
const kid = knownKeyId(host.host, host.port)
|
|
463
|
+
let settled = false
|
|
464
|
+
|
|
465
|
+
const finishErr = (e) => {
|
|
466
|
+
if (settled) return
|
|
467
|
+
settled = true
|
|
468
|
+
try { client.end() } catch {}
|
|
469
|
+
reject(e instanceof Error ? e : new Error(String(e)))
|
|
470
|
+
}
|
|
471
|
+
const finishOk = (value) => {
|
|
472
|
+
if (settled) return
|
|
473
|
+
settled = true
|
|
474
|
+
resolve(value)
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// ssh2 hostVerifier: with hostHash, key is sha256 hex; otherwise Buffer (we still normalize).
|
|
478
|
+
opts.hostVerifier = (hashedKey, callback) => {
|
|
479
|
+
const fp = fingerprintFromHostKey(hashedKey)
|
|
480
|
+
const stored = known.keys[kid]
|
|
481
|
+
const storedFp = stored && isUsableHostFingerprint(stored.fingerprint)
|
|
482
|
+
? String(stored.fingerprint).trim().toLowerCase()
|
|
483
|
+
: ''
|
|
484
|
+
const decide = (ok) => {
|
|
485
|
+
if (typeof callback === 'function') {
|
|
486
|
+
try { callback(ok) } catch {}
|
|
487
|
+
return
|
|
488
|
+
}
|
|
489
|
+
return ok
|
|
490
|
+
}
|
|
491
|
+
const remember = () => {
|
|
492
|
+
known.keys[kid] = {
|
|
493
|
+
fingerprint: fp,
|
|
494
|
+
algo: 'sha256',
|
|
495
|
+
trustedAt: Date.now(),
|
|
496
|
+
}
|
|
497
|
+
saveKnown(known)
|
|
498
|
+
}
|
|
499
|
+
// Missing, legacy binary String(Buffer), or unusable entries → re-prompt (safe migration).
|
|
500
|
+
if (!stored || !storedFp) {
|
|
501
|
+
if (options.trustHostKey) {
|
|
502
|
+
remember()
|
|
503
|
+
return decide(true)
|
|
504
|
+
}
|
|
505
|
+
const promptId = setPendingPrompt({
|
|
506
|
+
kind: 'host_key',
|
|
507
|
+
hostId: host.id,
|
|
508
|
+
host: host.host,
|
|
509
|
+
port: host.port || 22,
|
|
510
|
+
fingerprint: fp,
|
|
511
|
+
fingerprintAlgo: 'SHA256',
|
|
512
|
+
projectPathKey: normalizeProjectKey(options.projectPathKey || ''),
|
|
513
|
+
message: `Unknown host key for ${host.host}:${host.port || 22} (${formatHostFingerprint(fp)})`,
|
|
514
|
+
})
|
|
515
|
+
finishErr(Object.assign(new Error('SSH_PROMPT'), { promptId, kind: 'host_key' }))
|
|
516
|
+
return decide(false)
|
|
517
|
+
}
|
|
518
|
+
if (storedFp !== fp) {
|
|
519
|
+
const promptId = setPendingPrompt({
|
|
520
|
+
kind: 'host_key_changed',
|
|
521
|
+
hostId: host.id,
|
|
522
|
+
host: host.host,
|
|
523
|
+
port: host.port || 22,
|
|
524
|
+
fingerprint: fp,
|
|
525
|
+
fingerprintAlgo: 'SHA256',
|
|
526
|
+
previous: storedFp,
|
|
527
|
+
projectPathKey: normalizeProjectKey(options.projectPathKey || ''),
|
|
528
|
+
message: `Host key changed for ${host.host}:${host.port || 22} (new ${formatHostFingerprint(fp)})`,
|
|
529
|
+
})
|
|
530
|
+
finishErr(Object.assign(new Error('SSH_PROMPT'), { promptId, kind: 'host_key_changed' }))
|
|
531
|
+
return decide(false)
|
|
532
|
+
}
|
|
533
|
+
return decide(true)
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
client.on('ready', () => finishOk(client))
|
|
537
|
+
client.on('error', finishErr)
|
|
538
|
+
try {
|
|
539
|
+
client.connect(opts)
|
|
540
|
+
} catch (e) {
|
|
541
|
+
finishErr(e)
|
|
542
|
+
}
|
|
543
|
+
})
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
const registry = createSessionRegistry({
|
|
547
|
+
connectClient: (host, secrets, options) => connectSsh(host, secrets, options),
|
|
548
|
+
loadSecrets,
|
|
549
|
+
getPrompt: (promptId) => pendingPrompts.get(promptId),
|
|
550
|
+
getHostById,
|
|
551
|
+
isHostAuthorized,
|
|
552
|
+
})
|
|
553
|
+
|
|
554
|
+
/* ---------------- SFTP helpers ---------------- */
|
|
555
|
+
|
|
556
|
+
function sftpTimed(action, work, opts = {}, onTimeout) {
|
|
557
|
+
return withTimeout(typeof work === 'function' ? work() : work, opts.timeoutMs ?? defaultTimeoutMsForAction(action), {
|
|
558
|
+
signal: opts.signal,
|
|
559
|
+
onTimeout,
|
|
560
|
+
})
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function sftpStat(sftp, remotePath, opts = {}) {
|
|
564
|
+
return sftpTimed(
|
|
565
|
+
'sftp_stat',
|
|
566
|
+
() => new Promise((resolve, reject) => {
|
|
567
|
+
sftp.stat(remotePath, (err, st) => (err ? reject(err) : resolve(st)))
|
|
568
|
+
}),
|
|
569
|
+
opts,
|
|
570
|
+
)
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function sftpReaddir(sftp, remotePath, opts = {}) {
|
|
574
|
+
return sftpTimed(
|
|
575
|
+
'sftp_list',
|
|
576
|
+
() => new Promise((resolve, reject) => {
|
|
577
|
+
sftp.readdir(remotePath, (err, list) => (err ? reject(err) : resolve(list || [])))
|
|
578
|
+
}),
|
|
579
|
+
opts,
|
|
580
|
+
)
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function sftpReadFile(sftp, remotePath, maxBytes = 512 * 1024, opts = {}) {
|
|
584
|
+
let rs
|
|
585
|
+
return sftpTimed(
|
|
586
|
+
'sftp_read_text',
|
|
587
|
+
() => new Promise((resolve, reject) => {
|
|
588
|
+
const chunks = []
|
|
589
|
+
let total = 0
|
|
590
|
+
rs = sftp.createReadStream(remotePath)
|
|
591
|
+
rs.on('data', (c) => {
|
|
592
|
+
total += c.length
|
|
593
|
+
if (total <= maxBytes) chunks.push(c)
|
|
594
|
+
})
|
|
595
|
+
rs.on('error', reject)
|
|
596
|
+
rs.on('end', () => {
|
|
597
|
+
const buf = Buffer.concat(chunks)
|
|
598
|
+
resolve({ content: buf.toString('utf8'), truncated: total > maxBytes, size: total })
|
|
599
|
+
})
|
|
600
|
+
}),
|
|
601
|
+
opts,
|
|
602
|
+
() => {
|
|
603
|
+
try { rs?.destroy?.() } catch {}
|
|
604
|
+
},
|
|
605
|
+
)
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function sftpWriteFile(sftp, remotePath, content, opts = {}) {
|
|
609
|
+
let ws
|
|
610
|
+
return sftpTimed(
|
|
611
|
+
'sftp_write_text',
|
|
612
|
+
() => new Promise((resolve, reject) => {
|
|
613
|
+
ws = sftp.createWriteStream(remotePath)
|
|
614
|
+
ws.on('error', reject)
|
|
615
|
+
ws.on('close', () => resolve({ ok: true }))
|
|
616
|
+
ws.end(content)
|
|
617
|
+
}),
|
|
618
|
+
opts,
|
|
619
|
+
() => {
|
|
620
|
+
try { ws?.destroy?.() } catch {}
|
|
621
|
+
},
|
|
622
|
+
)
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function sftpMkdir(sftp, remotePath, opts = {}) {
|
|
626
|
+
return sftpTimed(
|
|
627
|
+
'sftp_mkdir',
|
|
628
|
+
() => new Promise((resolve, reject) => {
|
|
629
|
+
sftp.mkdir(remotePath, (err) => (err ? reject(err) : resolve({ ok: true })))
|
|
630
|
+
}),
|
|
631
|
+
opts,
|
|
632
|
+
)
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function sftpRename(sftp, from, to, opts = {}) {
|
|
636
|
+
return sftpTimed(
|
|
637
|
+
'sftp_rename',
|
|
638
|
+
() => new Promise((resolve, reject) => {
|
|
639
|
+
sftp.rename(from, to, (err) => (err ? reject(err) : resolve({ ok: true })))
|
|
640
|
+
}),
|
|
641
|
+
opts,
|
|
642
|
+
)
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
function sftpUnlink(sftp, remotePath, opts = {}) {
|
|
646
|
+
return sftpTimed(
|
|
647
|
+
'sftp_delete',
|
|
648
|
+
() => new Promise((resolve, reject) => {
|
|
649
|
+
sftp.unlink(remotePath, (err) => (err ? reject(err) : resolve({ ok: true })))
|
|
650
|
+
}),
|
|
651
|
+
opts,
|
|
652
|
+
)
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function sftpRmdir(sftp, remotePath, opts = {}) {
|
|
656
|
+
return sftpTimed(
|
|
657
|
+
'sftp_delete',
|
|
658
|
+
() => new Promise((resolve, reject) => {
|
|
659
|
+
sftp.rmdir(remotePath, (err) => (err ? reject(err) : resolve({ ok: true })))
|
|
660
|
+
}),
|
|
661
|
+
opts,
|
|
662
|
+
)
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function sftpFastGet(sftp, remote, local, opts = {}) {
|
|
666
|
+
return sftpTimed(
|
|
667
|
+
'sftp_download',
|
|
668
|
+
() => new Promise((resolve, reject) => {
|
|
669
|
+
sftp.fastGet(remote, local, (err) => (err ? reject(err) : resolve({ ok: true })))
|
|
670
|
+
}),
|
|
671
|
+
opts,
|
|
672
|
+
)
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
function sftpFastPut(sftp, local, remote, opts = {}) {
|
|
676
|
+
return sftpTimed(
|
|
677
|
+
'sftp_upload',
|
|
678
|
+
() => new Promise((resolve, reject) => {
|
|
679
|
+
sftp.fastPut(local, remote, (err) => (err ? reject(err) : resolve({ ok: true })))
|
|
680
|
+
}),
|
|
681
|
+
opts,
|
|
682
|
+
)
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* Local root for sidebar file operations: the project workspace it displays.
|
|
687
|
+
* The client already sends this key, so the guard can use the real workspace
|
|
688
|
+
* instead of a hard-coded `/workspace`.
|
|
689
|
+
*/
|
|
690
|
+
function localProjectRoot(body) {
|
|
691
|
+
const src = body || {}
|
|
692
|
+
return normalizeProjectKey(src.projectPathKey || src.cwd || '')
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/**
|
|
696
|
+
* Guard a local path against the project workspace the user opened rather than
|
|
697
|
+
* a fixed `/workspace`: on hosts whose workspace is a Windows drive path the
|
|
698
|
+
* fixed root rejects every real local path, which breaks SFTP upload/download
|
|
699
|
+
* and local file browsing.
|
|
700
|
+
*/
|
|
701
|
+
function assertWorkspaceLocal(localPath, projectPathKey = '') {
|
|
702
|
+
return constrainToWorkspace(localPath, { root: projectPathKey })
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/* ---------------- Resolve session for tools ---------------- */
|
|
706
|
+
|
|
707
|
+
function getHostById(id) {
|
|
708
|
+
return loadHosts().hosts.find((h) => h.id === id)
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
function allowedHostMap(projectPathKey) {
|
|
712
|
+
const { hostIds } = getProjectGrants(projectPathKey)
|
|
713
|
+
const secrets = loadSecrets()
|
|
714
|
+
const map = new Map()
|
|
715
|
+
for (const id of hostIds) {
|
|
716
|
+
const h = getHostById(id)
|
|
717
|
+
if (h) map.set(id, h)
|
|
718
|
+
}
|
|
719
|
+
return { map, secrets, hostIds }
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
async function resolveSession(args, projectPathKey, needsSftp, signal) {
|
|
723
|
+
const strategy = args.session_strategy || 'reuse_or_create'
|
|
724
|
+
if (!['reuse_or_create', 'new', 'require_existing'].includes(strategy)) {
|
|
725
|
+
throw new Error('SSHManager.session_strategy is invalid.')
|
|
726
|
+
}
|
|
727
|
+
const sessionId = args.session_id ? String(args.session_id).trim() : ''
|
|
728
|
+
const { map: allowed, hostIds } = allowedHostMap(projectPathKey)
|
|
729
|
+
if (!hostIds.length) throw new Error('No SSH hosts are associated with the current project.')
|
|
730
|
+
|
|
731
|
+
if (sessionId) {
|
|
732
|
+
if (strategy === 'new') throw new Error('SSHManager.session_strategy=new cannot be combined with session_id.')
|
|
733
|
+
const s = registry.get(sessionId)
|
|
734
|
+
if (!s || s.projectPathKey !== normalizeProjectKey(projectPathKey)) {
|
|
735
|
+
throw new Error('SSH session not found in the current project.')
|
|
736
|
+
}
|
|
737
|
+
if (!allowed.has(s.hostId)) throw new Error('SSH session host is not authorized for the current project.')
|
|
738
|
+
if (needsSftp && !s.sftpEnabled) {
|
|
739
|
+
throw new Error('SSH session does not have SFTP enabled. Use host_id to reuse or create an SFTP-enabled session.')
|
|
740
|
+
}
|
|
741
|
+
if (s.status === SSH_STATUS_DISCONNECTED) {
|
|
742
|
+
throw new Error(disconnectedSessionError())
|
|
743
|
+
}
|
|
744
|
+
if (s.status === SSH_STATUS_RECONNECTING) {
|
|
745
|
+
const live = await registry.waitForLive(sessionId, SSH_RECONNECT_ATTEMPT_TIMEOUT_MS)
|
|
746
|
+
if (needsSftp && !live.sftpEnabled) {
|
|
747
|
+
throw new Error('SSH session does not have SFTP enabled. Use host_id to reuse or create an SFTP-enabled session.')
|
|
748
|
+
}
|
|
749
|
+
return { session: live, reused: true, created: false, strategy: 'session_id' }
|
|
750
|
+
}
|
|
751
|
+
if (!isLiveStatus(s.status, s.running) || !s.client) {
|
|
752
|
+
throw new Error('session not running')
|
|
753
|
+
}
|
|
754
|
+
return { session: s, reused: true, created: false, strategy: 'session_id' }
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
const hostId = String(args.host_id || '').trim()
|
|
758
|
+
if (!hostId) throw new Error('SSHManager.host_id is required.')
|
|
759
|
+
const host = allowed.get(hostId)
|
|
760
|
+
if (!host) throw new Error('SSH host is not associated with the current project.')
|
|
761
|
+
|
|
762
|
+
if (strategy !== 'new') {
|
|
763
|
+
const reusable = registry.findReusableSession(projectPathKey, hostId, !!needsSftp)
|
|
764
|
+
if (reusable) {
|
|
765
|
+
return { session: reusable, reused: true, created: false, strategy }
|
|
766
|
+
}
|
|
767
|
+
if (strategy === 'reuse_or_create') {
|
|
768
|
+
const tomb = pickOldestTombstone([...registry.sessions.values()], {
|
|
769
|
+
projectPathKey: normalizeProjectKey(projectPathKey),
|
|
770
|
+
hostId,
|
|
771
|
+
needsSftp: !!needsSftp,
|
|
772
|
+
})
|
|
773
|
+
if (tomb) {
|
|
774
|
+
if (host.authType === 'keyboardInteractive') throw new Error(kbiUnsupportedError())
|
|
775
|
+
await registry.reconnectSession(tomb.id, {
|
|
776
|
+
manual: true,
|
|
777
|
+
signal,
|
|
778
|
+
projectPathKey,
|
|
779
|
+
})
|
|
780
|
+
const revived = registry.get(tomb.id)
|
|
781
|
+
if (!revived || !isLiveStatus(revived.status, revived.running)) {
|
|
782
|
+
throw new Error('SSH reconnect failed; session left disconnected.')
|
|
783
|
+
}
|
|
784
|
+
return { session: revived, reused: true, created: false, revived: true, strategy }
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
if (strategy === 'require_existing') {
|
|
788
|
+
throw new Error('No reusable SSH session exists for this host in the current project.')
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
if (host.authType === 'keyboardInteractive') {
|
|
793
|
+
throw new Error(kbiUnsupportedError())
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
const created = await registry.createLiveSession({
|
|
797
|
+
host,
|
|
798
|
+
projectPathKey,
|
|
799
|
+
title: args.title || `SSHManager: ${host.name || host.host}`,
|
|
800
|
+
sftpEnabled: true,
|
|
801
|
+
trustHostKey: false,
|
|
802
|
+
})
|
|
803
|
+
if (!created.ok) {
|
|
804
|
+
throw new Error(created.message || hostKeyPromptMessage())
|
|
805
|
+
}
|
|
806
|
+
const s = registry.get(created.session.session_id)
|
|
807
|
+
return { session: s, reused: false, created: true, strategy }
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
/* ---------------- HTTP helpers ---------------- */
|
|
811
|
+
|
|
812
|
+
function writeJson(res, status, body) {
|
|
813
|
+
res.statusCode = status
|
|
814
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
815
|
+
res.setHeader('cache-control', 'no-store')
|
|
816
|
+
res.end(JSON.stringify(body))
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
/** Serve an allowlisted vendor asset (browser terminal UMD/CSS) same-origin. */
|
|
820
|
+
async function serveVendor(req, res) {
|
|
821
|
+
try {
|
|
822
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
823
|
+
res.writeHead(405)
|
|
824
|
+
res.end()
|
|
825
|
+
return
|
|
826
|
+
}
|
|
827
|
+
const pathname = new URL(req.url || '/', 'http://dsh.internal').pathname
|
|
828
|
+
const prefix = '/dsh-ssh-tunnel/vendor/'
|
|
829
|
+
if (!pathname.startsWith(prefix)) {
|
|
830
|
+
res.writeHead(404)
|
|
831
|
+
res.end()
|
|
832
|
+
return
|
|
833
|
+
}
|
|
834
|
+
const name = pathname.slice(prefix.length)
|
|
835
|
+
// allowlist only: the resolver throws for anything else (incl. traversal)
|
|
836
|
+
const { path: filePath, type } = resolveVendorAsset(name)
|
|
837
|
+
const body = readFileSync(filePath)
|
|
838
|
+
res.statusCode = 200
|
|
839
|
+
res.setHeader('content-type', type)
|
|
840
|
+
res.setHeader('cache-control', 'public, max-age=31536000, immutable')
|
|
841
|
+
res.setHeader('x-content-type-options', 'nosniff')
|
|
842
|
+
res.end(req.method === 'HEAD' ? undefined : body)
|
|
843
|
+
} catch (error) {
|
|
844
|
+
res.writeHead(404)
|
|
845
|
+
res.end()
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
async function readJsonBody(req) {
|
|
850
|
+
const chunks = []
|
|
851
|
+
let total = 0
|
|
852
|
+
for await (const chunk of req) {
|
|
853
|
+
total += chunk.length
|
|
854
|
+
if (total > MAX_JSON_BODY_BYTES) {
|
|
855
|
+
throw sshError('payload_too_large', 'request body too large')
|
|
856
|
+
}
|
|
857
|
+
chunks.push(chunk)
|
|
858
|
+
}
|
|
859
|
+
if (!chunks.length) return {}
|
|
860
|
+
const raw = Buffer.concat(chunks).toString('utf8')
|
|
861
|
+
if (!raw.trim()) return {}
|
|
862
|
+
return JSON.parse(raw)
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
/* ---------------- API + Tool ---------------- */
|
|
866
|
+
|
|
867
|
+
async function handleApi(method, body, ctx) {
|
|
868
|
+
if (method === 'health') {
|
|
869
|
+
return {
|
|
870
|
+
ok: true,
|
|
871
|
+
version: PLUGIN_VERSION,
|
|
872
|
+
name: 'dsh-ssh-tunnel',
|
|
873
|
+
sshManagerRegistered,
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
if (method === 'getProjectContext') {
|
|
878
|
+
const sessionId = body.sessionId || ''
|
|
879
|
+
const projectPathKey = sessionCwdOf(ctx, sessionId, body.cwd)
|
|
880
|
+
return { sessionId, projectPathKey, hasProject: !!projectPathKey }
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
if (method === 'listHosts') {
|
|
884
|
+
const secrets = loadSecrets()
|
|
885
|
+
return { hosts: loadHosts().hosts.map((h) => publicHost(h, secrets)) }
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
if (method === 'saveHost') {
|
|
889
|
+
return { ok: true, host: upsertHostRecord(body.host || body) }
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
if (method === 'deleteHost') {
|
|
893
|
+
return deleteHostRecord(String(body.id || ''))
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
if (method === 'scanOpenSsh') {
|
|
897
|
+
return scanOpenSsh()
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
if (method === 'importScan') {
|
|
901
|
+
const host = importScanEntry(body.entry || body)
|
|
902
|
+
return { ok: true, host }
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
if (method === 'getGrants') {
|
|
906
|
+
const projectPathKey = normalizeProjectKey(body.projectPathKey || body.cwd || '')
|
|
907
|
+
return getProjectGrants(projectPathKey)
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
if (method === 'setGrants') {
|
|
911
|
+
return setProjectGrants(body.projectPathKey || body.cwd, body.hostIds || [])
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
if (method === 'listSessions') {
|
|
915
|
+
const projectPathKey = normalizeProjectKey(body.projectPathKey || body.cwd || '')
|
|
916
|
+
return { sessions: registry.listSessionsForProject(projectPathKey) }
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
if (method === 'connect') {
|
|
920
|
+
const host = getHostById(body.hostId || body.host_id)
|
|
921
|
+
if (!host) throw sshError('not_found', 'host not found')
|
|
922
|
+
const projectPathKey = normalizeProjectKey(body.projectPathKey || body.cwd || '')
|
|
923
|
+
if (!projectPathKey) throw sshError('bad_request', 'projectPathKey required')
|
|
924
|
+
// Project access is explicit (Grants tab / setGrants). Connect must not silently expand it.
|
|
925
|
+
const grants = getProjectGrants(projectPathKey)
|
|
926
|
+
if (!grants.hostIds.includes(host.id)) {
|
|
927
|
+
throw sshError(
|
|
928
|
+
'forbidden',
|
|
929
|
+
'host is not authorized for this project; grant access under Project access before connecting',
|
|
930
|
+
)
|
|
931
|
+
}
|
|
932
|
+
const result = await registry.createLiveSession({
|
|
933
|
+
host,
|
|
934
|
+
projectPathKey,
|
|
935
|
+
title: body.title,
|
|
936
|
+
sftpEnabled: body.sftpEnabled !== false,
|
|
937
|
+
trustHostKey: !!body.trustHostKey,
|
|
938
|
+
})
|
|
939
|
+
return result
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
if (method === 'disconnect') {
|
|
943
|
+
return registry.closeSession(String(body.sessionId || body.session_id || ''), body.projectPathKey)
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
if (method === 'reconnect') {
|
|
947
|
+
try {
|
|
948
|
+
return await registry.reconnectSession(String(body.sessionId || body.session_id || ''), {
|
|
949
|
+
manual: true,
|
|
950
|
+
projectPathKey: body.projectPathKey,
|
|
951
|
+
})
|
|
952
|
+
} catch (e) {
|
|
953
|
+
// Same shape as connect: a host-key prompt must reach the tab UI with
|
|
954
|
+
// its promptId, not die as a generic error.
|
|
955
|
+
if (e && e.isPromptError) {
|
|
956
|
+
return {
|
|
957
|
+
ok: false,
|
|
958
|
+
sshPrompt: pendingPrompts.get(e.promptId),
|
|
959
|
+
promptId: e.promptId,
|
|
960
|
+
message: hostKeyPromptMessage(),
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
throw e
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
if (method === 'answerPrompt') {
|
|
968
|
+
const promptId = String(body.promptId || '')
|
|
969
|
+
const p = pendingPrompts.get(promptId)
|
|
970
|
+
if (!p) throw sshError('not_found', 'prompt not found')
|
|
971
|
+
if (p.expiresAt <= Date.now()) {
|
|
972
|
+
pendingPrompts.delete(promptId)
|
|
973
|
+
throw sshError('gone', 'prompt expired; reconnect to raise a fresh prompt')
|
|
974
|
+
}
|
|
975
|
+
if (body.trustHostKey && (p.kind === 'host_key' || p.kind === 'host_key_changed')) {
|
|
976
|
+
// Trusting a key for a host that no longer exists is pointless and hides
|
|
977
|
+
// the failure from the tab UI.
|
|
978
|
+
if (!getHostById(p.hostId)) {
|
|
979
|
+
pendingPrompts.delete(promptId)
|
|
980
|
+
throw sshError('not_found', 'host no longer exists')
|
|
981
|
+
}
|
|
982
|
+
const fp = fingerprintFromHostKey(p.fingerprint)
|
|
983
|
+
if (!isUsableHostFingerprint(fp)) {
|
|
984
|
+
throw sshError('bad_request', 'invalid host key fingerprint in prompt')
|
|
985
|
+
}
|
|
986
|
+
const known = loadKnown()
|
|
987
|
+
known.keys[knownKeyId(p.host, p.port)] = {
|
|
988
|
+
fingerprint: fp,
|
|
989
|
+
algo: 'sha256',
|
|
990
|
+
trustedAt: Date.now(),
|
|
991
|
+
}
|
|
992
|
+
saveKnown(known)
|
|
993
|
+
pendingPrompts.delete(promptId)
|
|
994
|
+
if (body.connectAfter) {
|
|
995
|
+
const host = getHostById(p.hostId)
|
|
996
|
+
if (!host) return { ok: true, trusted: true }
|
|
997
|
+
const projectPathKey = normalizeProjectKey(body.projectPathKey || body.cwd || '')
|
|
998
|
+
if (!projectPathKey) return { ok: true, trusted: true, connected: false, error: 'projectPathKey required' }
|
|
999
|
+
const grants = getProjectGrants(projectPathKey)
|
|
1000
|
+
if (!grants.hostIds.includes(host.id)) {
|
|
1001
|
+
return {
|
|
1002
|
+
ok: true,
|
|
1003
|
+
trusted: true,
|
|
1004
|
+
connected: false,
|
|
1005
|
+
error:
|
|
1006
|
+
'host is not authorized for this project; grant access under Project access before connecting',
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
return registry.createLiveSession({
|
|
1010
|
+
host,
|
|
1011
|
+
projectPathKey,
|
|
1012
|
+
title: body.title,
|
|
1013
|
+
sftpEnabled: body.sftpEnabled !== false,
|
|
1014
|
+
trustHostKey: true,
|
|
1015
|
+
})
|
|
1016
|
+
}
|
|
1017
|
+
return { ok: true, trusted: true }
|
|
1018
|
+
}
|
|
1019
|
+
pendingPrompts.delete(promptId)
|
|
1020
|
+
return { ok: true }
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
if (method === 'listPrompts') {
|
|
1024
|
+
const now = Date.now()
|
|
1025
|
+
const projectPathKey = normalizeProjectKey(body.projectPathKey || body.cwd || '')
|
|
1026
|
+
const prompts = []
|
|
1027
|
+
for (const [id, p] of pendingPrompts) {
|
|
1028
|
+
if (p.expiresAt <= now) {
|
|
1029
|
+
pendingPrompts.delete(id)
|
|
1030
|
+
continue
|
|
1031
|
+
}
|
|
1032
|
+
// Optional filter: the tab UI passes its project and only sees its own
|
|
1033
|
+
// prompts; unfiltered callers keep seeing everything.
|
|
1034
|
+
if (projectPathKey && p.projectPathKey && p.projectPathKey !== projectPathKey) continue
|
|
1035
|
+
prompts.push(Object.assign({ promptId: id }, p))
|
|
1036
|
+
}
|
|
1037
|
+
return { prompts }
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
if (method === 'shellOpen') {
|
|
1041
|
+
const s = registry.requireLiveSession(body.sessionId || body.session_id, body.projectPathKey)
|
|
1042
|
+
await registry.ensureShell(s)
|
|
1043
|
+
assertSessionStillAuthorized(s, isHostAuthorized)
|
|
1044
|
+
return {
|
|
1045
|
+
ok: true,
|
|
1046
|
+
session_id: s.id,
|
|
1047
|
+
output: s.getOutputText(),
|
|
1048
|
+
seq: s.outputSeq || 0,
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
if (method === 'shellRead') {
|
|
1053
|
+
const s = registry.requireLiveSession(body.sessionId || body.session_id, body.projectPathKey)
|
|
1054
|
+
if (!s.shellStream) await registry.ensureShell(s)
|
|
1055
|
+
// since = last seq the reader has seen; baseSeq/dropped report ring-buffer
|
|
1056
|
+
// eviction so the client can refetch instead of silently skipping output.
|
|
1057
|
+
const sliced = s.chunkFrom(Number(body.since) || 0)
|
|
1058
|
+
assertSessionStillAuthorized(s, isHostAuthorized)
|
|
1059
|
+
return {
|
|
1060
|
+
ok: true,
|
|
1061
|
+
session_id: s.id,
|
|
1062
|
+
chunk: sliced.chunk,
|
|
1063
|
+
length: sliced.length,
|
|
1064
|
+
since: sliced.since,
|
|
1065
|
+
seq: sliced.seq,
|
|
1066
|
+
baseSeq: sliced.baseSeq,
|
|
1067
|
+
dropped: sliced.dropped,
|
|
1068
|
+
chunkTruncated: sliced.chunkTruncated,
|
|
1069
|
+
running: !!s.running,
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
if (method === 'shellWrite') {
|
|
1074
|
+
const s = registry.requireLiveSession(body.sessionId || body.session_id, body.projectPathKey)
|
|
1075
|
+
const stream = await registry.ensureShell(s)
|
|
1076
|
+
const data = body.data != null ? String(body.data) : ''
|
|
1077
|
+
if (!data) throw sshError('bad_request', 'data required')
|
|
1078
|
+
stream.write(data)
|
|
1079
|
+
s.updatedAt = Date.now()
|
|
1080
|
+
assertSessionStillAuthorized(s, isHostAuthorized, REVOKED_AFTER_RUN)
|
|
1081
|
+
return { ok: true }
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
if (method === 'shellResize') {
|
|
1085
|
+
const s = registry.requireLiveSession(body.sessionId || body.session_id, body.projectPathKey)
|
|
1086
|
+
const stream = await registry.ensureShell(s)
|
|
1087
|
+
const cols = Number(body.cols) || 80
|
|
1088
|
+
const rows = Number(body.rows) || 24
|
|
1089
|
+
if (typeof stream.setWindow === 'function') stream.setWindow(rows, cols, 0, 0)
|
|
1090
|
+
assertSessionStillAuthorized(s, isHostAuthorized)
|
|
1091
|
+
return { ok: true, cols, rows }
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
if (method === 'sftpList') {
|
|
1095
|
+
const s = registry.requireLiveSession(body.sessionId || body.session_id, body.projectPathKey)
|
|
1096
|
+
if (!s.sftp) throw sshError('conflict', 'SFTP not enabled on this session')
|
|
1097
|
+
const remotePath = String(body.path || '.')
|
|
1098
|
+
const list = await sftpReaddir(s.sftp, remotePath)
|
|
1099
|
+
const entries = []
|
|
1100
|
+
for (const e of list) {
|
|
1101
|
+
const name = e.filename
|
|
1102
|
+
if (!name || name === '.') continue
|
|
1103
|
+
const attrs = e.attrs || {}
|
|
1104
|
+
let isDir = false
|
|
1105
|
+
try {
|
|
1106
|
+
if (typeof attrs.isDirectory === 'function') isDir = attrs.isDirectory()
|
|
1107
|
+
else if (attrs.mode != null) isDir = (attrs.mode & 0o170000) === 0o040000
|
|
1108
|
+
} catch {}
|
|
1109
|
+
entries.push({
|
|
1110
|
+
name,
|
|
1111
|
+
path: remotePath === '/' ? '/' + name : (remotePath.replace(/\/$/, '') + '/' + name),
|
|
1112
|
+
isDirectory: isDir,
|
|
1113
|
+
size: attrs.size || 0,
|
|
1114
|
+
mtime: attrs.mtime || 0,
|
|
1115
|
+
longname: e.longname || name,
|
|
1116
|
+
})
|
|
1117
|
+
}
|
|
1118
|
+
entries.sort((a, b) => {
|
|
1119
|
+
if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1
|
|
1120
|
+
return a.name.localeCompare(b.name)
|
|
1121
|
+
})
|
|
1122
|
+
assertSessionStillAuthorized(s, isHostAuthorized)
|
|
1123
|
+
return { ok: true, path: remotePath, entries }
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
if (method === 'sftpReadText') {
|
|
1127
|
+
const s = registry.requireLiveSession(body.sessionId || body.session_id, body.projectPathKey)
|
|
1128
|
+
if (!s.sftp) throw sshError('conflict', 'SFTP not enabled on this session')
|
|
1129
|
+
const remotePath = requireString(body.path, 'path')
|
|
1130
|
+
const r = await sftpReadFile(s.sftp, remotePath, clampMaxBytes(body.maxBytes, MAX_BYTES_SFTP_READ_DEFAULT))
|
|
1131
|
+
assertSessionStillAuthorized(s, isHostAuthorized)
|
|
1132
|
+
return { ok: true, path: remotePath, ...r }
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
if (method === 'sftpWriteText') {
|
|
1136
|
+
const s = registry.requireLiveSession(body.sessionId || body.session_id, body.projectPathKey)
|
|
1137
|
+
if (!s.sftp) throw sshError('conflict', 'SFTP not enabled on this session')
|
|
1138
|
+
const remotePath = requireString(body.path, 'path')
|
|
1139
|
+
await sftpWriteFile(s.sftp, remotePath, String(body.content ?? ''))
|
|
1140
|
+
assertSessionStillAuthorized(s, isHostAuthorized, REVOKED_AFTER_RUN)
|
|
1141
|
+
return { ok: true, path: remotePath }
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
if (method === 'sftpMkdir') {
|
|
1145
|
+
const s = registry.requireLiveSession(body.sessionId || body.session_id, body.projectPathKey)
|
|
1146
|
+
if (!s.sftp) throw sshError('conflict', 'SFTP not enabled on this session')
|
|
1147
|
+
const remotePath = requireString(body.path, 'path')
|
|
1148
|
+
await sftpMkdir(s.sftp, remotePath)
|
|
1149
|
+
assertSessionStillAuthorized(s, isHostAuthorized, REVOKED_AFTER_RUN)
|
|
1150
|
+
return { ok: true, path: remotePath }
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
if (method === 'sftpDelete') {
|
|
1154
|
+
const s = registry.requireLiveSession(body.sessionId || body.session_id, body.projectPathKey)
|
|
1155
|
+
if (!s.sftp) throw sshError('conflict', 'SFTP not enabled on this session')
|
|
1156
|
+
const remotePath = requireString(body.path, 'path')
|
|
1157
|
+
try {
|
|
1158
|
+
await sftpUnlink(s.sftp, remotePath)
|
|
1159
|
+
} catch {
|
|
1160
|
+
await sftpRmdir(s.sftp, remotePath)
|
|
1161
|
+
}
|
|
1162
|
+
assertSessionStillAuthorized(s, isHostAuthorized, REVOKED_AFTER_RUN)
|
|
1163
|
+
return { ok: true, path: remotePath }
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
if (method === 'sftpRename') {
|
|
1167
|
+
const s = registry.requireLiveSession(body.sessionId || body.session_id, body.projectPathKey)
|
|
1168
|
+
if (!s.sftp) throw sshError('conflict', 'SFTP not enabled on this session')
|
|
1169
|
+
const from = stringOrDefault(body.from_path, body.from, 'from_path')
|
|
1170
|
+
const to = stringOrDefault(body.to_path, body.to, 'to_path')
|
|
1171
|
+
await sftpRename(s.sftp, from, to)
|
|
1172
|
+
assertSessionStillAuthorized(s, isHostAuthorized, REVOKED_AFTER_RUN)
|
|
1173
|
+
return { ok: true }
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
if (method === 'sftpDownload') {
|
|
1177
|
+
const s = registry.requireLiveSession(body.sessionId || body.session_id, body.projectPathKey)
|
|
1178
|
+
if (!s.sftp) throw sshError('conflict', 'SFTP not enabled on this session')
|
|
1179
|
+
const remote = requireString(body.remote_path ?? body.path, 'remote_path')
|
|
1180
|
+
const local = assertWorkspaceLocal(stringOrDefault(body.local_path, body.to_path, 'local_path'), localProjectRoot(body))
|
|
1181
|
+
mkdirSync(dirname(local), { recursive: true })
|
|
1182
|
+
try {
|
|
1183
|
+
await sftpFastGet(s.sftp, remote, local)
|
|
1184
|
+
} catch (e) {
|
|
1185
|
+
if (discardRevokedLocalFile(s, local, isHostAuthorized)) {
|
|
1186
|
+
throw sshError('forbidden', DOWNLOAD_DISCARDED)
|
|
1187
|
+
}
|
|
1188
|
+
throw e
|
|
1189
|
+
}
|
|
1190
|
+
if (discardRevokedLocalFile(s, local, isHostAuthorized)) {
|
|
1191
|
+
throw sshError('forbidden', DOWNLOAD_DISCARDED)
|
|
1192
|
+
}
|
|
1193
|
+
return { ok: true, remote, local }
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
if (method === 'sftpUpload') {
|
|
1197
|
+
const s = registry.requireLiveSession(body.sessionId || body.session_id, body.projectPathKey)
|
|
1198
|
+
if (!s.sftp) throw sshError('conflict', 'SFTP not enabled on this session')
|
|
1199
|
+
const local = assertWorkspaceLocal(stringOrDefault(body.local_path, body.path, 'local_path'), localProjectRoot(body))
|
|
1200
|
+
const remote = requireString(body.remote_path ?? body.to_path, 'remote_path')
|
|
1201
|
+
await sftpFastPut(s.sftp, local, remote)
|
|
1202
|
+
assertSessionStillAuthorized(s, isHostAuthorized, REVOKED_AFTER_RUN)
|
|
1203
|
+
return { ok: true, local, remote }
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
|
|
1207
|
+
if (method === 'localList') {
|
|
1208
|
+
const localRoot = localProjectRoot(body)
|
|
1209
|
+
const abs = constrainToWorkspace(body.path || localRoot, { root: localRoot })
|
|
1210
|
+
let names
|
|
1211
|
+
try {
|
|
1212
|
+
names = readdirSync(abs)
|
|
1213
|
+
} catch (e) {
|
|
1214
|
+
throw sshError('bad_request', 'cannot list: ' + (e && e.message ? e.message : e))
|
|
1215
|
+
}
|
|
1216
|
+
const entries = []
|
|
1217
|
+
for (const name of names) {
|
|
1218
|
+
if (name === '.' || name === '..') continue
|
|
1219
|
+
const full = join(abs, name)
|
|
1220
|
+
try {
|
|
1221
|
+
constrainToWorkspace(full, { root: localRoot })
|
|
1222
|
+
} catch {
|
|
1223
|
+
continue
|
|
1224
|
+
}
|
|
1225
|
+
let st
|
|
1226
|
+
try { st = lstatSync(full) } catch { continue }
|
|
1227
|
+
const isDir = st.isDirectory()
|
|
1228
|
+
entries.push({
|
|
1229
|
+
name,
|
|
1230
|
+
path: full,
|
|
1231
|
+
isDirectory: isDir,
|
|
1232
|
+
size: st.size || 0,
|
|
1233
|
+
mtime: Math.floor((st.mtimeMs || 0) / 1000),
|
|
1234
|
+
})
|
|
1235
|
+
}
|
|
1236
|
+
entries.sort((a, b) => {
|
|
1237
|
+
if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1
|
|
1238
|
+
return a.name.localeCompare(b.name)
|
|
1239
|
+
})
|
|
1240
|
+
return { ok: true, path: abs, root: localRoot, entries }
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
if (method === 'localMkdir') {
|
|
1244
|
+
const localRoot = localProjectRoot(body)
|
|
1245
|
+
const abs = constrainToWorkspace(body.path || localRoot, { root: localRoot })
|
|
1246
|
+
mkdirSync(abs, { recursive: true })
|
|
1247
|
+
return { ok: true, path: abs }
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
if (method === 'localDelete') {
|
|
1251
|
+
const abs = constrainToWorkspace(requireString(body.path, 'path'), { forUnlink: true, root: localProjectRoot(body) })
|
|
1252
|
+
rmLocalRecursive(abs)
|
|
1253
|
+
return { ok: true, path: abs }
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
if (method === 'localRename') {
|
|
1257
|
+
const localRoot = localProjectRoot(body)
|
|
1258
|
+
const from = constrainToWorkspace(stringOrDefault(body.from_path, body.from, 'from_path'), { forUnlink: true, root: localRoot })
|
|
1259
|
+
const to = constrainToWorkspace(stringOrDefault(body.to_path, body.to, 'to_path'), { root: localRoot })
|
|
1260
|
+
renameSync(from, to)
|
|
1261
|
+
return { ok: true, from, to }
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
if (method === 'migrateDraft') {
|
|
1265
|
+
return migrateOldDraft()
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
throw sshError('not_found', 'unknown method: ' + method)
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
function migrateOldDraft() {
|
|
1272
|
+
const marker = migrateMarkerPath(DATA_DIR)
|
|
1273
|
+
if (existsSync(marker)) return { ok: true, migrated: 0, skipped: true }
|
|
1274
|
+
// Old drafts may live under any profile name; scanning beats hard-coding web.
|
|
1275
|
+
const profilesDir = join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'profiles')
|
|
1276
|
+
let draftPaths = []
|
|
1277
|
+
try {
|
|
1278
|
+
draftPaths = readdirSync(profilesDir)
|
|
1279
|
+
.filter((n) => n && !n.startsWith('.'))
|
|
1280
|
+
.map((n) => join(profilesDir, n, 'dsh-ssh-ui.draft.json'))
|
|
1281
|
+
.filter((p) => existsSync(p))
|
|
1282
|
+
} catch {
|
|
1283
|
+
draftPaths = []
|
|
1284
|
+
}
|
|
1285
|
+
if (!draftPaths.length) {
|
|
1286
|
+
writeFileSync(marker, String(Date.now()) + '\n', { mode: 0o600 })
|
|
1287
|
+
return { ok: true, migrated: 0 }
|
|
1288
|
+
}
|
|
1289
|
+
let n = 0
|
|
1290
|
+
for (const draftPath of draftPaths) {
|
|
1291
|
+
let raw
|
|
1292
|
+
try {
|
|
1293
|
+
raw = JSON.parse(readFileSync(draftPath, 'utf8'))
|
|
1294
|
+
} catch {
|
|
1295
|
+
continue
|
|
1296
|
+
}
|
|
1297
|
+
const profiles =
|
|
1298
|
+
raw.version === 2 && Array.isArray(raw.profiles)
|
|
1299
|
+
? raw.profiles
|
|
1300
|
+
: [raw]
|
|
1301
|
+
for (const p of profiles) {
|
|
1302
|
+
if (!p || !p.host) continue
|
|
1303
|
+
const existing = findExistingHostByEndpoint(
|
|
1304
|
+
loadHosts().hosts,
|
|
1305
|
+
p.host,
|
|
1306
|
+
p.port || 22,
|
|
1307
|
+
p.username || 'root',
|
|
1308
|
+
)
|
|
1309
|
+
if (existing) continue
|
|
1310
|
+
const migratedAuth = p.authMode || p.authType || 'privateKey'
|
|
1311
|
+
if (migratedAuth === 'keyboardInteractive') continue
|
|
1312
|
+
upsertHostRecord({
|
|
1313
|
+
name: p.alias || `${p.username || 'user'}@${p.host}`,
|
|
1314
|
+
host: p.host,
|
|
1315
|
+
port: p.port || 22,
|
|
1316
|
+
username: p.username || 'root',
|
|
1317
|
+
authType: migratedAuth,
|
|
1318
|
+
privateKeyPath: p.privateKey && !String(p.privateKey).includes('BEGIN') ? p.privateKey : '',
|
|
1319
|
+
privateKeyPem: p.privateKey && String(p.privateKey).includes('BEGIN') ? p.privateKey : '',
|
|
1320
|
+
password: p.password || '',
|
|
1321
|
+
passphrase: p.passphrase || '',
|
|
1322
|
+
source: 'migrated-draft',
|
|
1323
|
+
})
|
|
1324
|
+
n++
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
writeFileSync(marker, String(Date.now()) + '\n', { mode: 0o600 })
|
|
1328
|
+
return { ok: true, migrated: n }
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
function registerSshManagerTool(ctx) {
|
|
1332
|
+
if (!ctx.tools || typeof ctx.tools.register !== 'function') {
|
|
1333
|
+
console.error('[dsh-ssh-tunnel] tools service unavailable; SSHManager tool not registered')
|
|
1334
|
+
return () => {}
|
|
1335
|
+
}
|
|
1336
|
+
// Read-only actions can run in parallel (e.g. next to a 300s exec); anything
|
|
1337
|
+
// that mutates sessions, the remote host, or the PTY stays exclusive.
|
|
1338
|
+
const READ_ONLY_ACTIONS = new Set([
|
|
1339
|
+
'list_hosts',
|
|
1340
|
+
'list_sessions',
|
|
1341
|
+
'read_session',
|
|
1342
|
+
'sftp_list',
|
|
1343
|
+
'sftp_stat',
|
|
1344
|
+
'sftp_read_text',
|
|
1345
|
+
])
|
|
1346
|
+
// Raw tool definition (JSON Schema parameters) — same pattern as @liustack/modlens.
|
|
1347
|
+
// Do not import defineTool from @deepseek-ai/dsh-tools; out-of-tree resolution is unreliable.
|
|
1348
|
+
const tool = {
|
|
1349
|
+
name: 'SSHManager',
|
|
1350
|
+
timeoutMs: SSH_TOOL_TIMEOUT_MS,
|
|
1351
|
+
isConcurrencySafe: (args) => READ_ONLY_ACTIONS.has(String(args?.action || '').trim()),
|
|
1352
|
+
description:
|
|
1353
|
+
'Manage SSH sessions and remote SFTP for hosts associated with the current project. Use host_id from list_hosts. credential=saved means secrets are stored—do not ask user to paste them. credential=unsupported means the host uses retired keyboard-interactive auth: edit it to password or private key; never dial. Default session_strategy is reuse_or_create (revives a disconnected session for that host instead of opening another). Host-key prompts must be completed in the SSH Tunnel tab. exec defaults to 30s; SFTP metadata 30s; SFTP transfer 120s. Pass timeout_ms (1s–300s) to override. Stop cancels in-flight SSH/SFTP.',
|
|
1354
|
+
parameters: {
|
|
1355
|
+
type: 'object',
|
|
1356
|
+
properties: {
|
|
1357
|
+
action: {
|
|
1358
|
+
type: 'string',
|
|
1359
|
+
description:
|
|
1360
|
+
'list_hosts|list_sessions|create_session|close_session|exec|sftp_list|sftp_stat|sftp_read_text|sftp_write_text|sftp_mkdir|sftp_rename|sftp_delete|sftp_upload|sftp_download|read_session|send_input|resize_session',
|
|
1361
|
+
},
|
|
1362
|
+
host_id: { type: 'string', description: 'Authorized host id from list_hosts' },
|
|
1363
|
+
session_id: { type: 'string', description: 'Existing session id' },
|
|
1364
|
+
session_strategy: {
|
|
1365
|
+
type: 'string',
|
|
1366
|
+
description: 'reuse_or_create|new|require_existing',
|
|
1367
|
+
},
|
|
1368
|
+
title: { type: 'string' },
|
|
1369
|
+
command: { type: 'string', description: 'Remote command for exec' },
|
|
1370
|
+
cwd: { type: 'string', description: 'Remote cwd for exec' },
|
|
1371
|
+
path: { type: 'string' },
|
|
1372
|
+
from_path: { type: 'string' },
|
|
1373
|
+
to_path: { type: 'string' },
|
|
1374
|
+
content: { type: 'string' },
|
|
1375
|
+
local_path: { type: 'string' },
|
|
1376
|
+
remote_path: { type: 'string' },
|
|
1377
|
+
data: { type: 'string', description: 'PTY input' },
|
|
1378
|
+
cols: { type: 'number' },
|
|
1379
|
+
rows: { type: 'number' },
|
|
1380
|
+
timeout_ms: {
|
|
1381
|
+
type: 'number',
|
|
1382
|
+
description:
|
|
1383
|
+
'Operation timeout in milliseconds for exec, SFTP, and shell actions. Default 30000 for exec/SFTP metadata, 120000 for SFTP transfer. Clamped to 1000–300000. The whole SSHManager call also has a 300s host ceiling.',
|
|
1384
|
+
},
|
|
1385
|
+
max_bytes: {
|
|
1386
|
+
type: 'number',
|
|
1387
|
+
description:
|
|
1388
|
+
'Maximum output size in bytes for exec and sftp_read_text. Clamped to 1024–1048576; defaults 262144 (exec) and 524288 (sftp_read_text). When cut, the result sets truncated: true and contains only the first bytes.',
|
|
1389
|
+
},
|
|
1390
|
+
},
|
|
1391
|
+
required: ['action'],
|
|
1392
|
+
additionalProperties: false,
|
|
1393
|
+
},
|
|
1394
|
+
output: {
|
|
1395
|
+
schema: {
|
|
1396
|
+
type: 'object',
|
|
1397
|
+
additionalProperties: false,
|
|
1398
|
+
properties: {
|
|
1399
|
+
text: { type: 'string' },
|
|
1400
|
+
isError: { type: 'boolean' },
|
|
1401
|
+
},
|
|
1402
|
+
required: ['text'],
|
|
1403
|
+
},
|
|
1404
|
+
render: (_args, value) => [{ type: 'text', text: value.text }],
|
|
1405
|
+
},
|
|
1406
|
+
async execute(args, exec) {
|
|
1407
|
+
const action = String(args.action || '').trim()
|
|
1408
|
+
const agent = exec.agent
|
|
1409
|
+
if (!agent) throw new Error('SSHManager requires an initiating agent')
|
|
1410
|
+
const sessionId = agent.session.id
|
|
1411
|
+
const projectPathKey = sessionCwdOf(ctx, sessionId)
|
|
1412
|
+
if (!projectPathKey) throw new Error('Select/open a project workspace first.')
|
|
1413
|
+
exec.signal?.throwIfAborted?.()
|
|
1414
|
+
const opOpts = { timeoutMs: args.timeout_ms, signal: exec.signal }
|
|
1415
|
+
|
|
1416
|
+
const ok = (t) => ({ text: t, isError: false })
|
|
1417
|
+
const fail = (t) => ({ text: `SSHManager failed: ${t}`, isError: true })
|
|
1418
|
+
const textify = (obj) => (typeof obj === 'string' ? obj : JSON.stringify(obj, null, 2))
|
|
1419
|
+
|
|
1420
|
+
try {
|
|
1421
|
+
if (action === 'list_hosts') {
|
|
1422
|
+
const { map, secrets } = allowedHostMap(projectPathKey)
|
|
1423
|
+
const lines = [...map.values()].map((h) => {
|
|
1424
|
+
const s = modelHostSummary(h, secrets)
|
|
1425
|
+
return `- ${s.host_id} · ${s.name || s.endpoint} · ${s.endpoint} · auth=${s.authType} · credential=${s.credentialStatus}`
|
|
1426
|
+
})
|
|
1427
|
+
return ok(
|
|
1428
|
+
lines.length > 0
|
|
1429
|
+
? `project: ${projectPathKey}\n` + lines.join('\n')
|
|
1430
|
+
: `project: ${projectPathKey}\nNo authorized SSH hosts.`,
|
|
1431
|
+
)
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
if (action === 'list_sessions') {
|
|
1435
|
+
const { hostIds } = getProjectGrants(projectPathKey)
|
|
1436
|
+
const list = registry.listSessionsForProject(projectPathKey).filter((s) => hostIds.includes(s.host_id))
|
|
1437
|
+
const lines = list.map(
|
|
1438
|
+
(s) =>
|
|
1439
|
+
`- ${s.session_id} · host=${s.host_id} · ${s.status} · sftp=${s.sftpEnabled} · running=${s.running} · ${s.title}`,
|
|
1440
|
+
)
|
|
1441
|
+
return ok(lines.length ? lines.join('\n') : 'No SSH sessions in this project.')
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
if (action === 'create_session') {
|
|
1445
|
+
const resolved = await resolveSession(
|
|
1446
|
+
{ host_id: args.host_id, session_strategy: 'new', title: args.title },
|
|
1447
|
+
projectPathKey,
|
|
1448
|
+
true,
|
|
1449
|
+
exec.signal,
|
|
1450
|
+
)
|
|
1451
|
+
return ok(
|
|
1452
|
+
textify({
|
|
1453
|
+
session_id: resolved.session.id,
|
|
1454
|
+
host_id: resolved.session.hostId,
|
|
1455
|
+
created: resolved.created,
|
|
1456
|
+
reused: resolved.reused,
|
|
1457
|
+
sftpEnabled: resolved.session.sftpEnabled,
|
|
1458
|
+
}),
|
|
1459
|
+
)
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
if (action === 'close_session') {
|
|
1463
|
+
const id = String(args.session_id || '')
|
|
1464
|
+
const s = registry.get(id)
|
|
1465
|
+
if (!s || s.projectPathKey !== normalizeProjectKey(projectPathKey)) {
|
|
1466
|
+
throw new Error('SSH session not found in the current project.')
|
|
1467
|
+
}
|
|
1468
|
+
registry.closeSession(id, projectPathKey)
|
|
1469
|
+
return ok(`closed ${id}`)
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
if (action === 'exec') {
|
|
1473
|
+
const command = requireString(args.command, 'command')
|
|
1474
|
+
const resolved = await resolveSession(args, projectPathKey, false, exec.signal)
|
|
1475
|
+
const result = await registry.execOnSession(resolved.session, command, {
|
|
1476
|
+
cwd: args.cwd,
|
|
1477
|
+
timeoutMs: args.timeout_ms,
|
|
1478
|
+
maxBytes: args.max_bytes,
|
|
1479
|
+
signal: exec.signal,
|
|
1480
|
+
})
|
|
1481
|
+
resolved.session.updatedAt = Date.now()
|
|
1482
|
+
// Result gate: the grant may have been revoked while the command
|
|
1483
|
+
// ran; never deliver remote output past the boundary.
|
|
1484
|
+
assertSessionStillAuthorized(resolved.session, isHostAuthorized)
|
|
1485
|
+
// ssh2 delivers exit-status on normal exit and exit-signal on signal
|
|
1486
|
+
// death; neither arrives when the connection itself was cut — that
|
|
1487
|
+
// must not read as a clean exit 0.
|
|
1488
|
+
const exitLine = result.exitKnown
|
|
1489
|
+
? `exit: ${result.code}`
|
|
1490
|
+
: result.signal
|
|
1491
|
+
? `exit: killed by signal ${result.signal} (no exit-status)`
|
|
1492
|
+
: 'exit: unknown (connection closed before the command finished; output may be partial)'
|
|
1493
|
+
const lines = [
|
|
1494
|
+
`session_id: ${resolved.session.id}`,
|
|
1495
|
+
`host_id: ${resolved.session.hostId}`,
|
|
1496
|
+
`reused: ${resolved.reused}`,
|
|
1497
|
+
]
|
|
1498
|
+
if (result.cwd) lines.push(`cwd: ${result.cwd}`)
|
|
1499
|
+
if (result.truncated) lines.push('truncated: true (output limited to max_bytes)')
|
|
1500
|
+
lines.push(exitLine, '--- stdout ---', result.stdout || '(empty)', '--- stderr ---', result.stderr || '(empty)')
|
|
1501
|
+
return { text: lines.join('\n'), isError: !(result.exitKnown && result.code === 0) }
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
if (action === 'read_session') {
|
|
1505
|
+
const resolved = await resolveSession(args, projectPathKey, false, exec.signal)
|
|
1506
|
+
await registry.ensureShell(resolved.session, opOpts)
|
|
1507
|
+
const out = resolved.session.getOutputText()
|
|
1508
|
+
assertSessionStillAuthorized(resolved.session, isHostAuthorized)
|
|
1509
|
+
return ok(`session_id: ${resolved.session.id}\n\n${out || '(empty)'}`)
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
if (action === 'send_input') {
|
|
1513
|
+
const data = requireString(args.data, 'data')
|
|
1514
|
+
const resolved = await resolveSession(args, projectPathKey, false, exec.signal)
|
|
1515
|
+
const stream = await registry.ensureShell(resolved.session, opOpts)
|
|
1516
|
+
stream.write(data)
|
|
1517
|
+
assertSessionStillAuthorized(resolved.session, isHostAuthorized, REVOKED_AFTER_RUN)
|
|
1518
|
+
return ok(`sent ${data.length} chars to ${resolved.session.id}`)
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
if (action === 'resize_session') {
|
|
1522
|
+
const resolved = await resolveSession(args, projectPathKey, false, exec.signal)
|
|
1523
|
+
const stream = await registry.ensureShell(resolved.session, opOpts)
|
|
1524
|
+
const cols = Number(args.cols) || 80
|
|
1525
|
+
const rows = Number(args.rows) || 24
|
|
1526
|
+
if (typeof stream.setWindow === 'function') stream.setWindow(rows, cols, 0, 0)
|
|
1527
|
+
assertSessionStillAuthorized(resolved.session, isHostAuthorized)
|
|
1528
|
+
return ok(`resized ${resolved.session.id} to ${cols}x${rows}`)
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
const sftpActions = new Set([
|
|
1532
|
+
'sftp_list',
|
|
1533
|
+
'sftp_stat',
|
|
1534
|
+
'sftp_read_text',
|
|
1535
|
+
'sftp_write_text',
|
|
1536
|
+
'sftp_mkdir',
|
|
1537
|
+
'sftp_rename',
|
|
1538
|
+
'sftp_delete',
|
|
1539
|
+
'sftp_upload',
|
|
1540
|
+
'sftp_download',
|
|
1541
|
+
])
|
|
1542
|
+
if (sftpActions.has(action)) {
|
|
1543
|
+
const resolved = await resolveSession(args, projectPathKey, true, exec.signal)
|
|
1544
|
+
const sftp = resolved.session.sftp
|
|
1545
|
+
if (!sftp) throw new Error('SFTP not available on session')
|
|
1546
|
+
const remotePath = args.path != null ? String(args.path) : String(args.remote_path ?? '')
|
|
1547
|
+
|
|
1548
|
+
if (action === 'sftp_list') {
|
|
1549
|
+
const list = await sftpReaddir(sftp, remotePath || '.', opOpts)
|
|
1550
|
+
assertSessionStillAuthorized(resolved.session, isHostAuthorized)
|
|
1551
|
+
const lines = list.map((e) => e.longname || e.filename)
|
|
1552
|
+
return ok(`session_id: ${resolved.session.id}\n` + lines.join('\n'))
|
|
1553
|
+
}
|
|
1554
|
+
if (action === 'sftp_stat') {
|
|
1555
|
+
const st = await sftpStat(sftp, requireString(remotePath, 'path'), opOpts)
|
|
1556
|
+
assertSessionStillAuthorized(resolved.session, isHostAuthorized)
|
|
1557
|
+
return ok(
|
|
1558
|
+
textify({
|
|
1559
|
+
path: remotePath,
|
|
1560
|
+
size: st.size,
|
|
1561
|
+
mode: st.mode,
|
|
1562
|
+
isDirectory: st.isDirectory?.() ?? false,
|
|
1563
|
+
isFile: st.isFile?.() ?? false,
|
|
1564
|
+
}),
|
|
1565
|
+
)
|
|
1566
|
+
}
|
|
1567
|
+
if (action === 'sftp_read_text') {
|
|
1568
|
+
const r = await sftpReadFile(
|
|
1569
|
+
sftp,
|
|
1570
|
+
requireString(remotePath, 'path'),
|
|
1571
|
+
clampMaxBytes(args.max_bytes, MAX_BYTES_SFTP_READ_DEFAULT),
|
|
1572
|
+
opOpts,
|
|
1573
|
+
)
|
|
1574
|
+
assertSessionStillAuthorized(resolved.session, isHostAuthorized)
|
|
1575
|
+
return ok(
|
|
1576
|
+
`session_id: ${resolved.session.id}\ntruncated: ${r.truncated}\nsize: ${r.size}\n\n${r.content}`,
|
|
1577
|
+
)
|
|
1578
|
+
}
|
|
1579
|
+
if (action === 'sftp_write_text') {
|
|
1580
|
+
await sftpWriteFile(sftp, requireString(remotePath, 'path'), String(args.content ?? ''), opOpts)
|
|
1581
|
+
assertSessionStillAuthorized(resolved.session, isHostAuthorized, REVOKED_AFTER_RUN)
|
|
1582
|
+
return ok(`wrote ${remotePath}`)
|
|
1583
|
+
}
|
|
1584
|
+
if (action === 'sftp_mkdir') {
|
|
1585
|
+
await sftpMkdir(sftp, requireString(remotePath, 'path'), opOpts)
|
|
1586
|
+
assertSessionStillAuthorized(resolved.session, isHostAuthorized, REVOKED_AFTER_RUN)
|
|
1587
|
+
return ok(`mkdir ${remotePath}`)
|
|
1588
|
+
}
|
|
1589
|
+
if (action === 'sftp_rename') {
|
|
1590
|
+
const from = requireString(args.from_path, 'from_path')
|
|
1591
|
+
const to = requireString(args.to_path, 'to_path')
|
|
1592
|
+
await sftpRename(sftp, from, to, opOpts)
|
|
1593
|
+
assertSessionStillAuthorized(resolved.session, isHostAuthorized, REVOKED_AFTER_RUN)
|
|
1594
|
+
return ok(`renamed ${from} -> ${to}`)
|
|
1595
|
+
}
|
|
1596
|
+
if (action === 'sftp_delete') {
|
|
1597
|
+
try {
|
|
1598
|
+
await sftpUnlink(sftp, requireString(remotePath, 'path'), opOpts)
|
|
1599
|
+
} catch {
|
|
1600
|
+
await sftpRmdir(sftp, remotePath, opOpts)
|
|
1601
|
+
}
|
|
1602
|
+
assertSessionStillAuthorized(resolved.session, isHostAuthorized, REVOKED_AFTER_RUN)
|
|
1603
|
+
return ok(`deleted ${remotePath}`)
|
|
1604
|
+
}
|
|
1605
|
+
if (action === 'sftp_upload') {
|
|
1606
|
+
const local = assertWorkspaceLocal(stringOrDefault(args.local_path, args.path, 'local_path'), projectPathKey)
|
|
1607
|
+
const remote = requireString(args.remote_path ?? args.to_path, 'remote_path')
|
|
1608
|
+
await sftpFastPut(sftp, local, remote, opOpts)
|
|
1609
|
+
assertSessionStillAuthorized(resolved.session, isHostAuthorized, REVOKED_AFTER_RUN)
|
|
1610
|
+
return ok(`uploaded ${local} -> ${remote}`)
|
|
1611
|
+
}
|
|
1612
|
+
if (action === 'sftp_download') {
|
|
1613
|
+
const remote = requireString(args.remote_path ?? args.path, 'remote_path')
|
|
1614
|
+
const local = assertWorkspaceLocal(stringOrDefault(args.local_path, args.to_path, 'local_path'), projectPathKey)
|
|
1615
|
+
mkdirSync(dirname(local), { recursive: true })
|
|
1616
|
+
try {
|
|
1617
|
+
await sftpFastGet(sftp, remote, local, opOpts)
|
|
1618
|
+
} catch (e) {
|
|
1619
|
+
// Mid-transfer revocation (or any failure): never leave a pull
|
|
1620
|
+
// from a revoked host on disk.
|
|
1621
|
+
if (discardRevokedLocalFile(resolved.session, local, isHostAuthorized)) {
|
|
1622
|
+
throw new Error(DOWNLOAD_DISCARDED)
|
|
1623
|
+
}
|
|
1624
|
+
throw e
|
|
1625
|
+
}
|
|
1626
|
+
// Result gate: the transfer may have finished in the same tick as
|
|
1627
|
+
// the revocation — remove the file and refuse delivery.
|
|
1628
|
+
if (discardRevokedLocalFile(resolved.session, local, isHostAuthorized)) {
|
|
1629
|
+
throw new Error(DOWNLOAD_DISCARDED)
|
|
1630
|
+
}
|
|
1631
|
+
return ok(`downloaded ${remote} -> ${local}`)
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
throw new Error('SSHManager.action is invalid: ' + action)
|
|
1636
|
+
} catch (e) {
|
|
1637
|
+
if (e && (e.aborted || e.name === 'AbortError')) return fail('Cancelled')
|
|
1638
|
+
const msg = e && e.message ? e.message : String(e)
|
|
1639
|
+
if (e && e.timedOut) return fail(`timed_out: true\n${msg}`)
|
|
1640
|
+
return fail(msg)
|
|
1641
|
+
}
|
|
1642
|
+
},
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
try {
|
|
1646
|
+
const dispose = ctx.tools.register(tool)
|
|
1647
|
+
sshManagerRegistered = true
|
|
1648
|
+
return () => {
|
|
1649
|
+
sshManagerRegistered = false
|
|
1650
|
+
try {
|
|
1651
|
+
dispose()
|
|
1652
|
+
} catch {}
|
|
1653
|
+
}
|
|
1654
|
+
} catch (error) {
|
|
1655
|
+
sshManagerRegistered = false
|
|
1656
|
+
console.error(
|
|
1657
|
+
'[dsh-ssh-tunnel] SSHManager registration failed:',
|
|
1658
|
+
error && error.message ? error.message : error,
|
|
1659
|
+
)
|
|
1660
|
+
return () => {}
|
|
1661
|
+
}
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
export function apply(ctx) {
|
|
1665
|
+
ensureDataDir()
|
|
1666
|
+
// best-effort migrate once
|
|
1667
|
+
try {
|
|
1668
|
+
migrateOldDraft()
|
|
1669
|
+
} catch (e) {
|
|
1670
|
+
console.warn('[dsh-ssh-tunnel] migrate draft skipped', e && e.message ? e.message : e)
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
const trustedHosts = () => {
|
|
1674
|
+
try {
|
|
1675
|
+
// Not injected (see `inject`): read optionally, or the missing service
|
|
1676
|
+
// would throw instead of degrading to an empty trust list.
|
|
1677
|
+
return ctx.get('webRuntime', true)?.trustedHosts || []
|
|
1678
|
+
} catch {
|
|
1679
|
+
return []
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
ctx.effect(
|
|
1684
|
+
() =>
|
|
1685
|
+
ctx.webServer.register({
|
|
1686
|
+
kind: 'prefix',
|
|
1687
|
+
path: '/dsh-ssh-tunnel/api',
|
|
1688
|
+
handler: async (req, res) => {
|
|
1689
|
+
if (!isTrustedRequest(req, trustedHosts())) {
|
|
1690
|
+
writeJson(res, 403, { ok: false, error: 'forbidden' })
|
|
1691
|
+
return
|
|
1692
|
+
}
|
|
1693
|
+
if (req.method !== 'POST') {
|
|
1694
|
+
writeJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
1695
|
+
return
|
|
1696
|
+
}
|
|
1697
|
+
const pathname = new URL(req.url || '/', 'http://dsh.internal').pathname
|
|
1698
|
+
const prefix = '/dsh-ssh-tunnel/api/'
|
|
1699
|
+
if (!pathname.startsWith(prefix)) {
|
|
1700
|
+
writeJson(res, 404, { ok: false, error: 'not found' })
|
|
1701
|
+
return
|
|
1702
|
+
}
|
|
1703
|
+
const method = pathname.slice(prefix.length)
|
|
1704
|
+
if (!method || method.includes('/')) {
|
|
1705
|
+
writeJson(res, 404, { ok: false, error: 'not found' })
|
|
1706
|
+
return
|
|
1707
|
+
}
|
|
1708
|
+
try {
|
|
1709
|
+
const body = await readJsonBody(req)
|
|
1710
|
+
const result = await handleApi(method, body, ctx)
|
|
1711
|
+
writeJson(res, 200, result)
|
|
1712
|
+
} catch (error) {
|
|
1713
|
+
// Errors carrying a code map to its HTTP status; everything else
|
|
1714
|
+
// stays a server fault instead of a blanket 500.
|
|
1715
|
+
const code = error && typeof error.code === 'string' ? error.code : ''
|
|
1716
|
+
const status = API_ERROR_STATUS[code] || 500
|
|
1717
|
+
writeJson(res, status, {
|
|
1718
|
+
ok: false,
|
|
1719
|
+
error: String(error && error.message ? error.message : error),
|
|
1720
|
+
...(code ? { code } : {}),
|
|
1721
|
+
})
|
|
1722
|
+
}
|
|
1723
|
+
},
|
|
1724
|
+
}),
|
|
1725
|
+
'dsh-ssh-tunnel: api',
|
|
1726
|
+
)
|
|
1727
|
+
|
|
1728
|
+
ctx.effect(
|
|
1729
|
+
() =>
|
|
1730
|
+
ctx.webServer.register({
|
|
1731
|
+
kind: 'prefix',
|
|
1732
|
+
path: '/dsh-ssh-tunnel/vendor',
|
|
1733
|
+
handler: serveVendor,
|
|
1734
|
+
}),
|
|
1735
|
+
'dsh-ssh-tunnel: vendor assets',
|
|
1736
|
+
)
|
|
1737
|
+
|
|
1738
|
+
ctx.effect(() => registerSshManagerTool(ctx), 'dsh-ssh-tunnel: SSHManager tool')
|
|
1739
|
+
}
|