dsh-server-connector 0.2.2 → 0.3.0
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 +88 -0
- package/LICENSE +0 -0
- package/README.md +49 -7
- package/cordis.patch.yml +6 -6
- package/icon.svg +15 -15
- package/lib/audit.js +469 -455
- package/lib/client.js +2389 -1720
- package/lib/config.js +89 -0
- package/lib/index.js +316 -266
- package/lib/known-hosts.js +377 -377
- package/lib/pending.js +766 -753
- package/lib/policy.js +590 -590
- package/lib/pool.js +1496 -1464
- package/lib/registry.js +968 -968
- package/lib/routes.js +14 -13
- package/lib/tools.js +1367 -1357
- package/locale/en.json +6 -6
- package/locale/zh.json +6 -6
- package/package.json +105 -105
package/lib/tools.js
CHANGED
|
@@ -1,1357 +1,1367 @@
|
|
|
1
|
-
// dsh-server-connector — the agent-facing tool surface (design doc §5, §6).
|
|
2
|
-
//
|
|
3
|
-
// Six `srv_*` tools, the system-prompt section that advertises the configured
|
|
4
|
-
// servers (§5.3), and the three gates every execution goes through (§6.1):
|
|
5
|
-
//
|
|
6
|
-
// srv_exec(command)
|
|
7
|
-
// ├─① policy.check(command, server.policy, server.disabledRules)
|
|
8
|
-
// │ block → POLICY_BLOCKED, no connection, no execution
|
|
9
|
-
// │ confirm → ②
|
|
10
|
-
// │ allow → ③
|
|
11
|
-
// ├─② ctx.approval.request({agent, toolName: 'srv_exec', reason})
|
|
12
|
-
// │ allowed-once → ③
|
|
13
|
-
// │ rejected/cancelled/unavailable → POLICY_DENIED_BY_USER
|
|
14
|
-
// └─③ execute through the pool → audit → result
|
|
15
|
-
//
|
|
16
|
-
// This module is the only model-visible face of the plugin. It orchestrates the
|
|
17
|
-
// units below it and owns no storage of its own: the registry (§3.1), the pool
|
|
18
|
-
// (§7), the policy engine (§4) and the audit log (§3.4) are injected.
|
|
19
|
-
//
|
|
20
|
-
// Two deliberate decisions, both visible to the model and documented in the PR:
|
|
21
|
-
//
|
|
22
|
-
// 1. **Policy/approval/transport failures are thrown; a nonzero exit and a
|
|
23
|
-
// timeout are results.** §5.5 lists `EXIT_NONZERO` and `TIMEOUT` among the
|
|
24
|
-
// error types, but throwing would throw away the stdout/stderr the model
|
|
25
|
-
// needs to act on. Both are therefore reported as a typed `code` field
|
|
26
|
-
// (`EXIT_NONZERO` / `TIMEOUT`) on the normal result, whose rendered text
|
|
27
|
-
// leads with the code and a next step. Everything that *prevented* the
|
|
28
|
-
// command from running or from reporting (`POLICY_BLOCKED`,
|
|
29
|
-
// `POLICY_DENIED_BY_USER`, `CONNECT_FAILED`, `HOST_KEY_UNKNOWN`,
|
|
30
|
-
// `AUTH_FAILED`, `NO_CREDENTIAL`, `TIMEOUT` before any output, …) is thrown
|
|
31
|
-
// as a {@link ToolError} whose message starts with the §5.5 code.
|
|
32
|
-
//
|
|
33
|
-
// 2. **Long commands are promoted, not migrated.** `async: true` or a call that
|
|
34
|
-
// outlives `syncTimeoutMs` returns a `ctx.jobs` job id that keeps the very
|
|
35
|
-
// same SSH channel open. A foreground command therefore never runs twice and
|
|
36
|
-
// never loses output it already emitted. Without `ctx.jobs` (§9.4 optional
|
|
37
|
-
// service) execution degrades to a synchronous call bounded by `timeoutMs`.
|
|
38
|
-
|
|
39
|
-
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
40
|
-
|
|
41
|
-
import {
|
|
42
|
-
import {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
'
|
|
48
|
-
'
|
|
49
|
-
'
|
|
50
|
-
'
|
|
51
|
-
'
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
* the model can
|
|
64
|
-
*
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
* @param {
|
|
70
|
-
* @param {string}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
'printf "
|
|
91
|
-
'printf "
|
|
92
|
-
'
|
|
93
|
-
'
|
|
94
|
-
'else echo "sudo=
|
|
95
|
-
'
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
* @
|
|
101
|
-
* @
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
* @param {
|
|
129
|
-
* @
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
const
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
// server
|
|
147
|
-
//
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
overrides.
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
//
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
183
|
-
* @param {
|
|
184
|
-
* @
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
* `
|
|
202
|
-
*
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
*
|
|
211
|
-
*
|
|
212
|
-
* @
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
*
|
|
221
|
-
*
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
*
|
|
225
|
-
*
|
|
226
|
-
*
|
|
227
|
-
* @param {any}
|
|
228
|
-
* @
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
*
|
|
247
|
-
* @
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
*
|
|
257
|
-
* `
|
|
258
|
-
*
|
|
259
|
-
* @
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
const
|
|
264
|
-
const
|
|
265
|
-
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
*
|
|
271
|
-
*
|
|
272
|
-
* @
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
if (result.exitCode ===
|
|
280
|
-
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
*
|
|
286
|
-
*
|
|
287
|
-
*
|
|
288
|
-
* @
|
|
289
|
-
* @property {string}
|
|
290
|
-
* @property {string}
|
|
291
|
-
* @property {
|
|
292
|
-
* @property {
|
|
293
|
-
* @property {string} [
|
|
294
|
-
* @property {
|
|
295
|
-
* @property {
|
|
296
|
-
* @property {
|
|
297
|
-
* @property {
|
|
298
|
-
* @property {string} [
|
|
299
|
-
* @property {
|
|
300
|
-
* @property {
|
|
301
|
-
* @property {
|
|
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
|
-
this.#
|
|
351
|
-
this.#
|
|
352
|
-
this.#
|
|
353
|
-
this.#
|
|
354
|
-
this.#
|
|
355
|
-
|
|
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
|
-
type: '
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
)
|
|
550
|
-
const
|
|
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
|
-
description:
|
|
583
|
-
},
|
|
584
|
-
|
|
585
|
-
type: '
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
},
|
|
589
|
-
|
|
590
|
-
type: '
|
|
591
|
-
description:
|
|
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
|
-
const
|
|
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
|
-
type: '
|
|
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
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
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
|
-
const
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
server,
|
|
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
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
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
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
const
|
|
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
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
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
|
-
* @param {any} exec
|
|
1288
|
-
* @returns {
|
|
1289
|
-
*/
|
|
1290
|
-
#
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
*
|
|
1297
|
-
* @param {
|
|
1298
|
-
* @returns {
|
|
1299
|
-
*/
|
|
1300
|
-
#
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
}
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
const
|
|
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
|
-
|
|
1
|
+
// dsh-server-connector — the agent-facing tool surface (design doc §5, §6).
|
|
2
|
+
//
|
|
3
|
+
// Six `srv_*` tools, the system-prompt section that advertises the configured
|
|
4
|
+
// servers (§5.3), and the three gates every execution goes through (§6.1):
|
|
5
|
+
//
|
|
6
|
+
// srv_exec(command)
|
|
7
|
+
// ├─① policy.check(command, server.policy, server.disabledRules)
|
|
8
|
+
// │ block → POLICY_BLOCKED, no connection, no execution
|
|
9
|
+
// │ confirm → ②
|
|
10
|
+
// │ allow → ③
|
|
11
|
+
// ├─② ctx.approval.request({agent, toolName: 'srv_exec', reason})
|
|
12
|
+
// │ allowed-once → ③
|
|
13
|
+
// │ rejected/cancelled/unavailable → POLICY_DENIED_BY_USER
|
|
14
|
+
// └─③ execute through the pool → audit → result
|
|
15
|
+
//
|
|
16
|
+
// This module is the only model-visible face of the plugin. It orchestrates the
|
|
17
|
+
// units below it and owns no storage of its own: the registry (§3.1), the pool
|
|
18
|
+
// (§7), the policy engine (§4) and the audit log (§3.4) are injected.
|
|
19
|
+
//
|
|
20
|
+
// Two deliberate decisions, both visible to the model and documented in the PR:
|
|
21
|
+
//
|
|
22
|
+
// 1. **Policy/approval/transport failures are thrown; a nonzero exit and a
|
|
23
|
+
// timeout are results.** §5.5 lists `EXIT_NONZERO` and `TIMEOUT` among the
|
|
24
|
+
// error types, but throwing would throw away the stdout/stderr the model
|
|
25
|
+
// needs to act on. Both are therefore reported as a typed `code` field
|
|
26
|
+
// (`EXIT_NONZERO` / `TIMEOUT`) on the normal result, whose rendered text
|
|
27
|
+
// leads with the code and a next step. Everything that *prevented* the
|
|
28
|
+
// command from running or from reporting (`POLICY_BLOCKED`,
|
|
29
|
+
// `POLICY_DENIED_BY_USER`, `CONNECT_FAILED`, `HOST_KEY_UNKNOWN`,
|
|
30
|
+
// `AUTH_FAILED`, `NO_CREDENTIAL`, `TIMEOUT` before any output, …) is thrown
|
|
31
|
+
// as a {@link ToolError} whose message starts with the §5.5 code.
|
|
32
|
+
//
|
|
33
|
+
// 2. **Long commands are promoted, not migrated.** `async: true` or a call that
|
|
34
|
+
// outlives `syncTimeoutMs` returns a `ctx.jobs` job id that keeps the very
|
|
35
|
+
// same SSH channel open. A foreground command therefore never runs twice and
|
|
36
|
+
// never loses output it already emitted. Without `ctx.jobs` (§9.4 optional
|
|
37
|
+
// service) execution degrades to a synchronous call bounded by `timeoutMs`.
|
|
38
|
+
|
|
39
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
40
|
+
|
|
41
|
+
import { readConfig } from './config.js'
|
|
42
|
+
import { RULES } from './policy.js'
|
|
43
|
+
import { check as checkPolicy } from './policy.js'
|
|
44
|
+
|
|
45
|
+
/** The six tool names of doc §5.1, in registration order. */
|
|
46
|
+
export const TOOL_NAMES = Object.freeze([
|
|
47
|
+
'srv_list',
|
|
48
|
+
'srv_connect',
|
|
49
|
+
'srv_exec',
|
|
50
|
+
'srv_job_output',
|
|
51
|
+
'srv_job_kill',
|
|
52
|
+
'srv_policy_check',
|
|
53
|
+
])
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Machine-readable error codes (§5.5). `INVALID_ARGUMENT` is the one addition:
|
|
57
|
+
* it marks a malformed tool argument, which the §5.5 table does not enumerate
|
|
58
|
+
* because it describes transport and policy failures.
|
|
59
|
+
* @typedef {'CONNECT_FAILED'|'HOST_KEY_UNKNOWN'|'AUTH_FAILED'|'POLICY_BLOCKED'|'POLICY_DENIED_BY_USER'|'TIMEOUT'|'EXIT_NONZERO'|'UNKNOWN_SERVER'|'AMBIGUOUS_SERVER'|'SERVER_DISABLED'|'NO_CREDENTIAL'|'INVALID_ARGUMENT'} ToolErrorCode
|
|
60
|
+
*/
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* A tool failure the model can read. The message always starts with the code so
|
|
64
|
+
* the model can branch on it without parsing prose (§5.5: "每种都带一句可执行的
|
|
65
|
+
* 下一步提示").
|
|
66
|
+
*/
|
|
67
|
+
export class ToolError extends Error {
|
|
68
|
+
/**
|
|
69
|
+
* @param {ToolErrorCode} code
|
|
70
|
+
* @param {string} detail - what happened, in one sentence.
|
|
71
|
+
* @param {string} [next] - the actionable next step.
|
|
72
|
+
*/
|
|
73
|
+
constructor(code, detail, next) {
|
|
74
|
+
super(`${code}: ${detail}${next === undefined || next === '' ? '' : ` ${next}`}`)
|
|
75
|
+
this.name = 'ToolError'
|
|
76
|
+
/** @type {ToolErrorCode} */
|
|
77
|
+
this.code = code
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The probe `srv_connect` runs after the handshake: platform, shell and sudo
|
|
83
|
+
* availability in one round trip (§5.1). Every line is `key=value`; an unknown
|
|
84
|
+
* key is ignored, a missing one reports `unknown`. The trailing `true` keeps the
|
|
85
|
+
* exit code 0 so a `grep`-style failure inside the probe is not mistaken for a
|
|
86
|
+
* failed connection.
|
|
87
|
+
* @type {string}
|
|
88
|
+
*/
|
|
89
|
+
export const CONNECT_PROBE = [
|
|
90
|
+
'. /etc/os-release 2>/dev/null; printf "os=%s\\n" "${PRETTY_NAME:-${ID:-unknown}}"',
|
|
91
|
+
'printf "kernel=%s\\n" "$(uname -srm 2>/dev/null || echo unknown)"',
|
|
92
|
+
'printf "shell=%s\\n" "${SHELL:-/bin/sh}"',
|
|
93
|
+
'if command -v sudo >/dev/null 2>&1; then',
|
|
94
|
+
' if sudo -n true >/dev/null 2>&1; then echo "sudo=passwordless"; else echo "sudo=password (needs a password)"; fi',
|
|
95
|
+
'else echo "sudo=absent"; fi',
|
|
96
|
+
'true',
|
|
97
|
+
].join('; ')
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Parse the {@link CONNECT_PROBE} output into the fields the tool returns.
|
|
101
|
+
* @param {string} stdout
|
|
102
|
+
* @returns {{platform: string, kernel: string, shell: string, sudo: string}}
|
|
103
|
+
*/
|
|
104
|
+
export function parseProbe(stdout) {
|
|
105
|
+
/** @type {Record<string, string>} */
|
|
106
|
+
const fields = {}
|
|
107
|
+
for (const line of stdout.split('\n')) {
|
|
108
|
+
const index = line.indexOf('=')
|
|
109
|
+
if (index <= 0) continue
|
|
110
|
+
const key = line.slice(0, index).trim()
|
|
111
|
+
const value = line.slice(index + 1).trim()
|
|
112
|
+
if (key !== '' && value !== '') fields[key] = value
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
platform: fields.os ?? 'unknown',
|
|
116
|
+
kernel: fields.kernel ?? 'unknown',
|
|
117
|
+
shell: fields.shell ?? 'unknown',
|
|
118
|
+
sudo: fields.sudo ?? 'unknown',
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Render the §5.3 system-prompt section for the configured servers.
|
|
124
|
+
*
|
|
125
|
+
* Returns an empty string when nothing is configured: the section then
|
|
126
|
+
* contributes nothing (§5.3: "仅在已配置服务器存在时出现"), and an empty section
|
|
127
|
+
* is how `ctx.systemPrompt.section` spells "nothing to say".
|
|
128
|
+
* @param {import('./registry.js').ServerRecord[]} servers
|
|
129
|
+
* @param {{maxListedServers?: number}} [options]
|
|
130
|
+
* @returns {string}
|
|
131
|
+
*/
|
|
132
|
+
export function renderServerSection(servers, options = {}) {
|
|
133
|
+
if (servers.length === 0) return ''
|
|
134
|
+
const max = options.maxListedServers ?? 8
|
|
135
|
+
const listed = servers.slice(0, max)
|
|
136
|
+
const lines = ['可运维服务器(调用 srv_exec 时必须传 server):']
|
|
137
|
+
for (const server of listed) {
|
|
138
|
+
const workdir = server.defaultWorkDir === null ? '(无默认目录)' : server.defaultWorkDir
|
|
139
|
+
lines.push(
|
|
140
|
+
`- ${server.id} ${server.name} ${server.username}@${server.host}:${server.port} 默认目录 ${workdir}`,
|
|
141
|
+
)
|
|
142
|
+
}
|
|
143
|
+
const rest = servers.length - listed.length
|
|
144
|
+
if (rest > 0) lines.push(`…另有 ${rest} 台,调用 srv_list 查看全部`)
|
|
145
|
+
|
|
146
|
+
// Per-server overrides are what an operator actually configured; when every
|
|
147
|
+
// server inherits the built-in catalog, summarise the catalog instead of
|
|
148
|
+
// claiming an override that does not exist.
|
|
149
|
+
/** @type {Map<string, Set<string>>} */
|
|
150
|
+
const overrides = new Map()
|
|
151
|
+
for (const server of servers) {
|
|
152
|
+
for (const [ruleId, decision] of Object.entries(server.policy)) {
|
|
153
|
+
const key = `${ruleId}=${decision}`
|
|
154
|
+
if (!overrides.has(key)) overrides.set(key, new Set())
|
|
155
|
+
overrides.get(key)?.add(server.id)
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (overrides.size > 0) {
|
|
159
|
+
const summary = [...overrides.keys()].sort().join('、')
|
|
160
|
+
lines.push(`高危策略:${summary}(可用 srv_policy_check 预检)`)
|
|
161
|
+
} else {
|
|
162
|
+
const blocked = RULES.filter((rule) => rule.defaultDecision === 'block').length
|
|
163
|
+
const confirmed = RULES.length - blocked
|
|
164
|
+
lines.push(
|
|
165
|
+
`高危策略:内置 ${RULES.length} 条规则(${blocked} 条 block、${confirmed} 条 confirm),未做逐台覆盖(可用 srv_policy_check 预检)`,
|
|
166
|
+
)
|
|
167
|
+
}
|
|
168
|
+
// Custom rules are counted, not listed (doc §4.4.4): the model needs to know a
|
|
169
|
+
// server is stricter than the catalog, not what its private regexes say.
|
|
170
|
+
const customCount = servers.reduce((total, server) => total + (server.customRules ?? []).length, 0)
|
|
171
|
+
if (customCount > 0) lines.push(`另有 ${customCount} 条自定义规则(可用 srv_policy_check 预检具体命令)`)
|
|
172
|
+
return lines.join('\n')
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Order of the §5.3 section in the assembled system prompt.
|
|
177
|
+
*
|
|
178
|
+
* Newer DSH releases allocate section orders centrally
|
|
179
|
+
* (`ctx.systemPrompt.getSectionOrder('MCP_SERVERS')`); the release candidates
|
|
180
|
+
* this package targets expose only plain numeric orders. Both are supported so
|
|
181
|
+
* the plugin does not pin one harness build: the fallback numbers are the ones
|
|
182
|
+
* `SECTION_ORDERS` assigns (`MCP_SERVERS = 3100`, "other tool guidance").
|
|
183
|
+
* @param {any} ctx
|
|
184
|
+
* @param {keyof typeof FALLBACK_SECTION_ORDERS} name
|
|
185
|
+
* @returns {number}
|
|
186
|
+
*/
|
|
187
|
+
export function systemPromptOrder(ctx, name) {
|
|
188
|
+
const resolveOrder = ctx?.systemPrompt?.getSectionOrder
|
|
189
|
+
if (typeof resolveOrder === 'function') {
|
|
190
|
+
try {
|
|
191
|
+
const order = resolveOrder.call(ctx.systemPrompt, name)
|
|
192
|
+
if (typeof order === 'number' && Number.isFinite(order)) return order
|
|
193
|
+
} catch {
|
|
194
|
+
// Fall through to the literal order below.
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return FALLBACK_SECTION_ORDERS[name]
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* The `SECTION_ORDERS` values this plugin uses, for harness builds without
|
|
202
|
+
* `getSectionOrder`. Keep in sync with `@deepseek-ai/dsh-system-prompt`.
|
|
203
|
+
* @type {{MCP_SERVERS: number}}
|
|
204
|
+
*/
|
|
205
|
+
export const FALLBACK_SECTION_ORDERS = Object.freeze({
|
|
206
|
+
MCP_SERVERS: 3100,
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Whether one approval outcome granted the action (§6.1: only `allowed-once`
|
|
211
|
+
* executes).
|
|
212
|
+
* @param {string} outcome
|
|
213
|
+
* @returns {boolean}
|
|
214
|
+
*/
|
|
215
|
+
export function isGranted(outcome) {
|
|
216
|
+
return outcome === 'allowed-once'
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* The session's effective approval policy, read without prompting anyone.
|
|
221
|
+
*
|
|
222
|
+
* `ApprovalService` computes it as "the session override, else the deployment
|
|
223
|
+
* default". The public surface exposes neither directly (`overrideOf` is
|
|
224
|
+
* declared private, `config` is public), so both are probed defensively: a
|
|
225
|
+
* future shape change degrades to `'ask'`, which only affects the wording of a
|
|
226
|
+
* rejection message, never whether something runs.
|
|
227
|
+
* @param {any} approval - the `ctx.approval` service.
|
|
228
|
+
* @param {any} agent - the calling agent.
|
|
229
|
+
* @returns {'ask'|'never'}
|
|
230
|
+
*/
|
|
231
|
+
export function approvalPolicyOf(approval, agent) {
|
|
232
|
+
const session = agent?.session
|
|
233
|
+
if (session !== undefined && typeof approval?.overrideOf === 'function') {
|
|
234
|
+
try {
|
|
235
|
+
const override = approval.overrideOf(session)
|
|
236
|
+
if (override === 'ask' || override === 'never') return override
|
|
237
|
+
} catch {
|
|
238
|
+
// Fall through to the configured default.
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
const configured = approval?.config?.policy
|
|
242
|
+
return configured === 'never' ? 'never' : 'ask'
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Build the §6.1 approval reason: rule id + server name + the full command.
|
|
247
|
+
* @param {{ruleId: string|null, ruleLabel?: string, server: import('./registry.js').ServerRecord, command: string}} input
|
|
248
|
+
* @returns {string}
|
|
249
|
+
*/
|
|
250
|
+
export function approvalReason(input) {
|
|
251
|
+
const rule = input.ruleId === null ? 'server policy' : `rule ${input.ruleId}${input.ruleLabel ? ` (${input.ruleLabel})` : ''}`
|
|
252
|
+
return `${rule} matched on server ${input.server.name} (${input.server.id}): ${input.command}`
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Render a job-read delta the way the model reads it: stdout in order, then the
|
|
257
|
+
* stderr chunks in one `[stderr]` section, so a promoted call and the
|
|
258
|
+
* `srv_job_output` reads that follow have the same shape.
|
|
259
|
+
* @param {readonly {text: string, channel?: string}[]} chunks
|
|
260
|
+
* @returns {string}
|
|
261
|
+
*/
|
|
262
|
+
export function renderChunks(chunks) {
|
|
263
|
+
const out = chunks.filter((chunk) => chunk.channel !== 'stderr').map((chunk) => chunk.text).join('')
|
|
264
|
+
const err = chunks.filter((chunk) => chunk.channel === 'stderr').map((chunk) => chunk.text).join('')
|
|
265
|
+
const separator = out.length > 0 && !out.endsWith('\n') ? '\n' : ''
|
|
266
|
+
return out + (err.length > 0 ? `${separator}[stderr]\n${err}` : '')
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* One line describing how a finished command ended. Kept separately from the
|
|
271
|
+
* output so a test (and the model) can locate it deterministically.
|
|
272
|
+
* @param {{exitCode: number|null, timedOut?: boolean, durationMs?: number}} result
|
|
273
|
+
* @returns {string}
|
|
274
|
+
*/
|
|
275
|
+
export function statusLine(result) {
|
|
276
|
+
if (result.timedOut === true) {
|
|
277
|
+
return 'TIMEOUT: the remote watchdog killed the process group; raise timeoutMs or run it with async: true.'
|
|
278
|
+
}
|
|
279
|
+
if (result.exitCode === null || result.exitCode === undefined) return 'exit: unknown'
|
|
280
|
+
if (result.exitCode === 0) return 'exit 0'
|
|
281
|
+
return `exit ${result.exitCode} (EXIT_NONZERO)`
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* One `srv_exec` result, canonical shape (doc §5.4). `running` marks a promoted
|
|
286
|
+
* call (poll with `srv_job_output`); `code` carries the §5.5 failure code for a
|
|
287
|
+
* command that did run but did not succeed.
|
|
288
|
+
* @typedef {object} SrvExecValue
|
|
289
|
+
* @property {string} serverId
|
|
290
|
+
* @property {string} serverName
|
|
291
|
+
* @property {string} command
|
|
292
|
+
* @property {'allow'|'confirm'} decision
|
|
293
|
+
* @property {string} [policyRule]
|
|
294
|
+
* @property {string} [jobId]
|
|
295
|
+
* @property {boolean} [running]
|
|
296
|
+
* @property {number} [exitCode]
|
|
297
|
+
* @property {'EXIT_NONZERO'|'TIMEOUT'} [code]
|
|
298
|
+
* @property {string} [stdout]
|
|
299
|
+
* @property {string} [stderr]
|
|
300
|
+
* @property {boolean} [truncated]
|
|
301
|
+
* @property {number} [durationMs]
|
|
302
|
+
* @property {string} [note]
|
|
303
|
+
*/
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* The model-visible tool surface. One instance per plugin process.
|
|
307
|
+
*
|
|
308
|
+
* `jobs` and `approval` are optional (§9.4): without `approval` every `confirm`
|
|
309
|
+
* decision fails closed as `POLICY_DENIED_BY_USER`; without `jobs` every call
|
|
310
|
+
* is synchronous.
|
|
311
|
+
*/
|
|
312
|
+
export class ToolSurface {
|
|
313
|
+
/** @type {import('./registry.js').Registry} */
|
|
314
|
+
#registry
|
|
315
|
+
/** @type {import('./pool.js').Pool} */
|
|
316
|
+
#pool
|
|
317
|
+
/** @type {import('./audit.js').AuditLog|null} */
|
|
318
|
+
#audit
|
|
319
|
+
/** @type {any} */
|
|
320
|
+
#approval
|
|
321
|
+
/** @type {any} */
|
|
322
|
+
#jobs
|
|
323
|
+
/** @type {Partial<import('./index.js').PluginConfig>} */
|
|
324
|
+
#config
|
|
325
|
+
/** @type {{info?: Function, warn?: Function, error?: Function}|null} */
|
|
326
|
+
#logger
|
|
327
|
+
/** @type {import('./registry.js').ServerRecord[]} */
|
|
328
|
+
#servers = []
|
|
329
|
+
/** @type {boolean} */
|
|
330
|
+
#loaded = false
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* @param {{
|
|
334
|
+
* registry: import('./registry.js').Registry,
|
|
335
|
+
* pool: import('./pool.js').Pool,
|
|
336
|
+
* audit?: import('./audit.js').AuditLog|null,
|
|
337
|
+
* approval?: any,
|
|
338
|
+
* jobs?: any,
|
|
339
|
+
* config?: Partial<import('./index.js').PluginConfig>,
|
|
340
|
+
* logger?: {info?: Function, warn?: Function, error?: Function}|null,
|
|
341
|
+
* }} options
|
|
342
|
+
*/
|
|
343
|
+
constructor(options) {
|
|
344
|
+
if (options?.registry === undefined || options.registry === null) {
|
|
345
|
+
throw new TypeError('ToolSurface requires a registry.')
|
|
346
|
+
}
|
|
347
|
+
if (options?.pool === undefined || options.pool === null) {
|
|
348
|
+
throw new TypeError('ToolSurface requires a pool.')
|
|
349
|
+
}
|
|
350
|
+
this.#registry = options.registry
|
|
351
|
+
this.#pool = options.pool
|
|
352
|
+
this.#audit = options.audit ?? null
|
|
353
|
+
this.#approval = options.approval ?? null
|
|
354
|
+
this.#jobs = options.jobs ?? null
|
|
355
|
+
// The row config is kept as handed over, never flattened: its fields are
|
|
356
|
+
// volatile cells the loader mutates in place when the user edits them, so a
|
|
357
|
+
// snapshot here would freeze the defaults for the life of the plugin.
|
|
358
|
+
this.#config = options.config ?? {}
|
|
359
|
+
this.#logger = options.logger ?? null
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* The current §10 values, read at call time.
|
|
364
|
+
*
|
|
365
|
+
* Volatile settings change without remounting this surface, so every read goes
|
|
366
|
+
* through here rather than through a constructor-time snapshot.
|
|
367
|
+
* @returns {import('./index.js').PluginConfig}
|
|
368
|
+
*/
|
|
369
|
+
get settings() {
|
|
370
|
+
return readConfig(this.#config)
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** The cached server list used by the synchronous system-prompt provider. */
|
|
374
|
+
get servers() {
|
|
375
|
+
return this.#servers
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** Whether {@link refresh} has completed at least once. */
|
|
379
|
+
get loaded() {
|
|
380
|
+
return this.#loaded
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Re-read `servers.json` into the cache used by the system-prompt provider
|
|
385
|
+
* (which must be synchronous, `ctx.systemPrompt.section` accepts no promise).
|
|
386
|
+
* Called at startup and after every tool call; phase 5's HTTP routes call it
|
|
387
|
+
* after a mutation so the prompt follows the panel.
|
|
388
|
+
* @returns {Promise<import('./registry.js').ServerRecord[]>}
|
|
389
|
+
*/
|
|
390
|
+
async refresh() {
|
|
391
|
+
this.#servers = await this.#registry.list()
|
|
392
|
+
this.#loaded = true
|
|
393
|
+
return this.#servers
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Register the six tools on `ctx.tools` (§5.1).
|
|
398
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx
|
|
399
|
+
* @returns {void}
|
|
400
|
+
*/
|
|
401
|
+
register(ctx) {
|
|
402
|
+
for (const definition of [
|
|
403
|
+
this.#listTool(),
|
|
404
|
+
this.#connectTool(),
|
|
405
|
+
this.#execTool(),
|
|
406
|
+
this.#jobOutputTool(),
|
|
407
|
+
this.#jobKillTool(),
|
|
408
|
+
this.#policyCheckTool(),
|
|
409
|
+
]) {
|
|
410
|
+
ctx.tools.register(definition)
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Register the §5.3 system-prompt section. The provider is synchronous, so it
|
|
416
|
+
* serves the cache; the cache is refreshed at startup and after every tool
|
|
417
|
+
* call.
|
|
418
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx
|
|
419
|
+
* @returns {void}
|
|
420
|
+
*/
|
|
421
|
+
registerSystemPrompt(ctx) {
|
|
422
|
+
ctx.effect(() =>
|
|
423
|
+
ctx.systemPrompt.section({
|
|
424
|
+
name: 'dsh-server-connector:servers',
|
|
425
|
+
// Next to the MCP server list: both describe systems reachable from this
|
|
426
|
+
// deployment, and neither is session-scoped.
|
|
427
|
+
order: systemPromptOrder(ctx, 'MCP_SERVERS'),
|
|
428
|
+
text: () =>
|
|
429
|
+
renderServerSection(
|
|
430
|
+
// Disabled servers are invisible here too (doc §5.1): the prompt is
|
|
431
|
+
// the model's map of what it may address, so listing a target that
|
|
432
|
+
// every call would reject only invites wasted attempts.
|
|
433
|
+
(this.#loaded ? this.#servers : []).filter((server) => server.enabled !== false),
|
|
434
|
+
{ maxListedServers: this.settings.maxListedServers },
|
|
435
|
+
),
|
|
436
|
+
}),
|
|
437
|
+
)
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// ---------------------------------------------------------------- tools --
|
|
441
|
+
|
|
442
|
+
/** @returns {any} */
|
|
443
|
+
#listTool() {
|
|
444
|
+
const surface = this
|
|
445
|
+
return defineTool({
|
|
446
|
+
name: 'srv_list',
|
|
447
|
+
description:
|
|
448
|
+
'List the servers configured on this machine: id, name, host, default working directory, and whether a connection is currently open. Call this first when unsure which server to address.',
|
|
449
|
+
parameters: {},
|
|
450
|
+
output: {
|
|
451
|
+
schema: {
|
|
452
|
+
type: 'array',
|
|
453
|
+
items: {
|
|
454
|
+
type: 'object',
|
|
455
|
+
additionalProperties: false,
|
|
456
|
+
properties: {
|
|
457
|
+
id: { type: 'string', required: true },
|
|
458
|
+
name: { type: 'string', required: true },
|
|
459
|
+
host: { type: 'string', required: true },
|
|
460
|
+
port: { type: 'number', required: true },
|
|
461
|
+
username: { type: 'string', required: true },
|
|
462
|
+
defaultWorkDir: { type: 'string' },
|
|
463
|
+
connected: { type: 'boolean', required: true },
|
|
464
|
+
hostKeyMode: { type: 'string', enum: ['accept-new', 'verify', 'off'], required: true },
|
|
465
|
+
},
|
|
466
|
+
},
|
|
467
|
+
},
|
|
468
|
+
render: (_args, servers) => [
|
|
469
|
+
{
|
|
470
|
+
type: 'text',
|
|
471
|
+
text:
|
|
472
|
+
servers.length === 0
|
|
473
|
+
? 'No servers are configured yet. Add one in the "服务器" panel of this plugin.'
|
|
474
|
+
: servers
|
|
475
|
+
.map(
|
|
476
|
+
(server) =>
|
|
477
|
+
`${server.id} ${server.name} ${server.username}@${server.host}:${server.port}` +
|
|
478
|
+
` ${server.connected ? 'connected' : 'not connected'}` +
|
|
479
|
+
` ${server.defaultWorkDir === undefined ? '(no default dir)' : `dir ${server.defaultWorkDir}`}`,
|
|
480
|
+
)
|
|
481
|
+
.join('\n'),
|
|
482
|
+
},
|
|
483
|
+
],
|
|
484
|
+
},
|
|
485
|
+
async execute() {
|
|
486
|
+
await surface.refresh()
|
|
487
|
+
const connected = new Set(surface.poolConnectedIds())
|
|
488
|
+
// A disabled server is invisible to the model (doc §5.1): it is not
|
|
489
|
+
// returned here, not listed in the system prompt, and any attempt to use
|
|
490
|
+
// it fails in #resolveServer. Showing it only to reject it would make the
|
|
491
|
+
// model retry a target it cannot reach.
|
|
492
|
+
return surface.servers.filter((server) => server.enabled !== false).map((server) => ({
|
|
493
|
+
id: server.id,
|
|
494
|
+
name: server.name,
|
|
495
|
+
host: server.host,
|
|
496
|
+
port: server.port,
|
|
497
|
+
username: server.username,
|
|
498
|
+
...(server.defaultWorkDir === null ? {} : { defaultWorkDir: server.defaultWorkDir }),
|
|
499
|
+
connected: connected.has(server.id),
|
|
500
|
+
hostKeyMode: server.hostKeyMode,
|
|
501
|
+
}))
|
|
502
|
+
},
|
|
503
|
+
})
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/** @returns {any} */
|
|
507
|
+
#connectTool() {
|
|
508
|
+
const surface = this
|
|
509
|
+
return defineTool({
|
|
510
|
+
name: 'srv_connect',
|
|
511
|
+
description:
|
|
512
|
+
'Open (or reuse) the SSH connection for one server and report what the remote host is: platform, kernel, login shell and sudo availability. Safe to call repeatedly; srv_exec connects on its own.',
|
|
513
|
+
parameters: {
|
|
514
|
+
server: {
|
|
515
|
+
type: 'string',
|
|
516
|
+
required: true,
|
|
517
|
+
description: 'Server id, exact name, or an unambiguous prefix of either (see srv_list).',
|
|
518
|
+
},
|
|
519
|
+
},
|
|
520
|
+
output: {
|
|
521
|
+
schema: {
|
|
522
|
+
type: 'object',
|
|
523
|
+
additionalProperties: false,
|
|
524
|
+
properties: {
|
|
525
|
+
serverId: { type: 'string', required: true },
|
|
526
|
+
serverName: { type: 'string', required: true },
|
|
527
|
+
platform: { type: 'string', required: true },
|
|
528
|
+
kernel: { type: 'string', required: true },
|
|
529
|
+
shell: { type: 'string', required: true },
|
|
530
|
+
sudo: { type: 'string', required: true },
|
|
531
|
+
reconnected: { type: 'boolean', required: true },
|
|
532
|
+
durationMs: { type: 'number', required: true },
|
|
533
|
+
},
|
|
534
|
+
},
|
|
535
|
+
render: (_args, value) => [
|
|
536
|
+
{
|
|
537
|
+
type: 'text',
|
|
538
|
+
text: [
|
|
539
|
+
`${value.serverId} (${value.serverName}) connected in ${value.durationMs} ms`,
|
|
540
|
+
`platform: ${value.platform}`,
|
|
541
|
+
`kernel: ${value.kernel}`,
|
|
542
|
+
`shell: ${value.shell}`,
|
|
543
|
+
`sudo: ${value.sudo}`,
|
|
544
|
+
].join('\n'),
|
|
545
|
+
},
|
|
546
|
+
],
|
|
547
|
+
},
|
|
548
|
+
async execute(args, exec) {
|
|
549
|
+
await surface.refresh()
|
|
550
|
+
const server = await surface.#resolveServer(args.server)
|
|
551
|
+
const startedAt = Date.now()
|
|
552
|
+
const { reconnected } = await surface.#callPool('connect', server, exec)
|
|
553
|
+
const probe = await surface.#callPool(
|
|
554
|
+
'exec',
|
|
555
|
+
server,
|
|
556
|
+
{ command: CONNECT_PROBE, timeoutMs: Math.min(surface.settings.commandTimeoutMs, 15000) },
|
|
557
|
+
exec,
|
|
558
|
+
)
|
|
559
|
+
const parsed = parseProbe(String(probe.stdout ?? ''))
|
|
560
|
+
return {
|
|
561
|
+
serverId: server.id,
|
|
562
|
+
serverName: server.name,
|
|
563
|
+
...parsed,
|
|
564
|
+
reconnected: reconnected !== false,
|
|
565
|
+
durationMs: Date.now() - startedAt,
|
|
566
|
+
}
|
|
567
|
+
},
|
|
568
|
+
})
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/** @returns {any} */
|
|
572
|
+
#execTool() {
|
|
573
|
+
const surface = this
|
|
574
|
+
return defineTool({
|
|
575
|
+
name: 'srv_exec',
|
|
576
|
+
description:
|
|
577
|
+
'Run a shell command on one configured server. High-risk commands are blocked or require the user to confirm first (see the server list in the system prompt, or pre-check with srv_policy_check). Output beyond the configured ceiling is cut and marked TRUNCATED; a command that outlives the sync budget returns a job id to poll with srv_job_output.',
|
|
578
|
+
parameters: {
|
|
579
|
+
server: {
|
|
580
|
+
type: 'string',
|
|
581
|
+
required: true,
|
|
582
|
+
description: 'Server id, exact name, or an unambiguous prefix of either.',
|
|
583
|
+
},
|
|
584
|
+
command: {
|
|
585
|
+
type: 'string',
|
|
586
|
+
required: true,
|
|
587
|
+
description: 'The shell command to run on the remote host.',
|
|
588
|
+
},
|
|
589
|
+
cwd: {
|
|
590
|
+
type: 'string',
|
|
591
|
+
description: "Working directory; defaults to the server's defaultWorkDir.",
|
|
592
|
+
},
|
|
593
|
+
timeoutMs: {
|
|
594
|
+
type: 'number',
|
|
595
|
+
description:
|
|
596
|
+
'Wall-clock budget in milliseconds (default from the plugin config, 60000). On expiry the remote watchdog kills the process group.',
|
|
597
|
+
},
|
|
598
|
+
async: {
|
|
599
|
+
type: 'boolean',
|
|
600
|
+
description: 'Return a job id immediately instead of waiting for the first syncTimeoutMs window.',
|
|
601
|
+
},
|
|
602
|
+
},
|
|
603
|
+
output: {
|
|
604
|
+
schema: {
|
|
605
|
+
type: 'object',
|
|
606
|
+
additionalProperties: false,
|
|
607
|
+
properties: {
|
|
608
|
+
serverId: { type: 'string', required: true },
|
|
609
|
+
serverName: { type: 'string', required: true },
|
|
610
|
+
command: { type: 'string', required: true },
|
|
611
|
+
decision: { type: 'string', enum: ['allow', 'confirm'], required: true },
|
|
612
|
+
policyRule: { type: 'string' },
|
|
613
|
+
jobId: { type: 'string' },
|
|
614
|
+
running: { type: 'boolean' },
|
|
615
|
+
exitCode: { type: 'number' },
|
|
616
|
+
code: { type: 'string', enum: ['EXIT_NONZERO', 'TIMEOUT'] },
|
|
617
|
+
stdout: { type: 'string' },
|
|
618
|
+
stderr: { type: 'string' },
|
|
619
|
+
truncated: { type: 'boolean' },
|
|
620
|
+
durationMs: { type: 'number' },
|
|
621
|
+
note: { type: 'string' },
|
|
622
|
+
},
|
|
623
|
+
},
|
|
624
|
+
render: (_args, value) => {
|
|
625
|
+
if (value.running === true) {
|
|
626
|
+
return [
|
|
627
|
+
{
|
|
628
|
+
type: 'text',
|
|
629
|
+
text:
|
|
630
|
+
`started background job ${value.jobId} on ${value.serverId} (${value.serverName})\n` +
|
|
631
|
+
`command: ${value.command}\n` +
|
|
632
|
+
'Read it with srv_job_output, stop it with srv_job_kill.',
|
|
633
|
+
},
|
|
634
|
+
]
|
|
635
|
+
}
|
|
636
|
+
const header = [
|
|
637
|
+
`${value.serverId} (${value.serverName})`,
|
|
638
|
+
statusLine({ exitCode: value.exitCode ?? null, timedOut: value.code === 'TIMEOUT' }),
|
|
639
|
+
value.durationMs === undefined ? undefined : `${value.durationMs} ms`,
|
|
640
|
+
value.decision === 'confirm' ? 'approved by user' : undefined,
|
|
641
|
+
value.truncated === true ? 'TRUNCATED' : undefined,
|
|
642
|
+
]
|
|
643
|
+
.filter((part) => part !== undefined)
|
|
644
|
+
.join(' ')
|
|
645
|
+
const body = [value.stdout ?? '', value.stderr === undefined ? '' : `[stderr]\n${value.stderr}`]
|
|
646
|
+
.filter((part) => part !== '')
|
|
647
|
+
.join(value.stdout !== undefined && !value.stdout.endsWith('\n') && value.stderr ? '\n' : '')
|
|
648
|
+
return [{ type: 'text', text: `${header}\n${body}${value.note === undefined ? '' : `\n[${value.note}]`}` }]
|
|
649
|
+
},
|
|
650
|
+
},
|
|
651
|
+
async execute(args, exec) {
|
|
652
|
+
return surface.#exec(args, exec)
|
|
653
|
+
},
|
|
654
|
+
presentCall: (args) => ({
|
|
655
|
+
card: 'terminal',
|
|
656
|
+
title: args.command,
|
|
657
|
+
description: `server ${args.server}`,
|
|
658
|
+
}),
|
|
659
|
+
})
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/** @returns {any} */
|
|
663
|
+
#jobOutputTool() {
|
|
664
|
+
const surface = this
|
|
665
|
+
return defineTool({
|
|
666
|
+
name: 'srv_job_output',
|
|
667
|
+
description:
|
|
668
|
+
'Read the output of a running or finished srv_exec job. Returns only what is new since the previous read (or since `offset`), plus the next byte offset to resume from. A job whose ring dropped bytes reports lossy: true with the spill file paths.',
|
|
669
|
+
parameters: {
|
|
670
|
+
jobId: { type: 'string', required: true, description: 'Job id returned by srv_exec.' },
|
|
671
|
+
offset: {
|
|
672
|
+
type: 'number',
|
|
673
|
+
description: 'Absolute byte offset to read from. Omit to continue from the previous read.',
|
|
674
|
+
},
|
|
675
|
+
},
|
|
676
|
+
output: {
|
|
677
|
+
schema: {
|
|
678
|
+
type: 'object',
|
|
679
|
+
additionalProperties: false,
|
|
680
|
+
properties: {
|
|
681
|
+
jobId: { type: 'string', required: true },
|
|
682
|
+
status: { type: 'string', required: true },
|
|
683
|
+
text: { type: 'string', required: true },
|
|
684
|
+
next: { type: 'number', required: true },
|
|
685
|
+
lossy: { type: 'boolean', required: true },
|
|
686
|
+
detail: { type: 'string' },
|
|
687
|
+
spillPaths: { type: 'array', items: { type: 'string' } },
|
|
688
|
+
result: { type: 'string' },
|
|
689
|
+
},
|
|
690
|
+
},
|
|
691
|
+
render: (_args, value) => [
|
|
692
|
+
{
|
|
693
|
+
type: 'text',
|
|
694
|
+
text: [
|
|
695
|
+
`${value.jobId} ${value.status}${value.detail === undefined ? '' : ` (${value.detail})`}`,
|
|
696
|
+
value.lossy ? `[some output was dropped; full output: ${(value.spillPaths ?? []).join(', ') || '(unavailable)'}]` : undefined,
|
|
697
|
+
value.text === '' ? '(no new output)' : value.text,
|
|
698
|
+
`next: ${value.next}`,
|
|
699
|
+
]
|
|
700
|
+
.filter((part) => part !== undefined)
|
|
701
|
+
.join('\n'),
|
|
702
|
+
},
|
|
703
|
+
],
|
|
704
|
+
},
|
|
705
|
+
async execute(args, exec) {
|
|
706
|
+
const jobs = surface.#jobs
|
|
707
|
+
if (jobs === null || jobs === undefined) {
|
|
708
|
+
throw new ToolError(
|
|
709
|
+
'INVALID_ARGUMENT',
|
|
710
|
+
'this deployment has no background-job service, so srv_exec never produces a job.',
|
|
711
|
+
'Run the command without async: true (it then runs synchronously).',
|
|
712
|
+
)
|
|
713
|
+
}
|
|
714
|
+
const id = String(args.jobId)
|
|
715
|
+
// The owner is the calling session, so it must come from this call's
|
|
716
|
+
// `exec`. Passing `undefined` here made every read look unowned, and the
|
|
717
|
+
// registry — which fences owned jobs by session — then rejected it with
|
|
718
|
+
// "belongs to another session" even for the session that started it.
|
|
719
|
+
const caller = surface.#ownerOf(exec)
|
|
720
|
+
if (typeof args.offset === 'number') {
|
|
721
|
+
if (!Number.isFinite(args.offset) || args.offset < 0) {
|
|
722
|
+
throw new ToolError('INVALID_ARGUMENT', 'offset must be a non-negative byte offset.')
|
|
723
|
+
}
|
|
724
|
+
const read = jobs.readAt(id, Math.floor(args.offset), caller)
|
|
725
|
+
const job = jobs.get(id, caller)
|
|
726
|
+
return {
|
|
727
|
+
jobId: id,
|
|
728
|
+
status: job.status,
|
|
729
|
+
text: renderChunks(read.chunks),
|
|
730
|
+
next: read.next,
|
|
731
|
+
lossy: read.lossy === true,
|
|
732
|
+
...(job.detail === undefined ? {} : { detail: job.detail }),
|
|
733
|
+
...(job.output?.spillPaths === undefined ? {} : { spillPaths: job.output.spillPaths }),
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
const read = jobs.read(id, caller)
|
|
737
|
+
return {
|
|
738
|
+
jobId: id,
|
|
739
|
+
status: read.job.status,
|
|
740
|
+
text: renderChunks(read.chunks),
|
|
741
|
+
next: read.job.output.total,
|
|
742
|
+
lossy: read.lossy === true,
|
|
743
|
+
...(read.job.detail === undefined ? {} : { detail: read.job.detail }),
|
|
744
|
+
...(read.job.output.spillPaths === undefined ? {} : { spillPaths: read.job.output.spillPaths }),
|
|
745
|
+
...(read.result === undefined ? {} : { result: read.result }),
|
|
746
|
+
}
|
|
747
|
+
},
|
|
748
|
+
})
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/** @returns {any} */
|
|
752
|
+
#jobKillTool() {
|
|
753
|
+
const surface = this
|
|
754
|
+
return defineTool({
|
|
755
|
+
name: 'srv_job_kill',
|
|
756
|
+
description: 'Cancel a running srv_exec job. A finished job reports already-finished.',
|
|
757
|
+
parameters: {
|
|
758
|
+
jobId: { type: 'string', required: true, description: 'Job id returned by srv_exec.' },
|
|
759
|
+
},
|
|
760
|
+
output: {
|
|
761
|
+
schema: {
|
|
762
|
+
type: 'object',
|
|
763
|
+
additionalProperties: false,
|
|
764
|
+
properties: {
|
|
765
|
+
jobId: { type: 'string', required: true },
|
|
766
|
+
outcome: { type: 'string', enum: ['cancellation-requested', 'already-finished'], required: true },
|
|
767
|
+
status: { type: 'string', required: true },
|
|
768
|
+
},
|
|
769
|
+
},
|
|
770
|
+
render: (_args, value) => [
|
|
771
|
+
{
|
|
772
|
+
type: 'text',
|
|
773
|
+
text:
|
|
774
|
+
value.outcome === 'cancellation-requested'
|
|
775
|
+
? `requested cancellation of job ${value.jobId} (status: ${value.status})`
|
|
776
|
+
: `job ${value.jobId} had already finished (status: ${value.status})`,
|
|
777
|
+
},
|
|
778
|
+
],
|
|
779
|
+
},
|
|
780
|
+
async execute(args, exec) {
|
|
781
|
+
const jobs = surface.#jobs
|
|
782
|
+
if (jobs === null || jobs === undefined) {
|
|
783
|
+
throw new ToolError(
|
|
784
|
+
'INVALID_ARGUMENT',
|
|
785
|
+
'this deployment has no background-job service, so no srv_exec job can exist.',
|
|
786
|
+
'srv_exec ran synchronously; nothing is left running.',
|
|
787
|
+
)
|
|
788
|
+
}
|
|
789
|
+
const id = String(args.jobId)
|
|
790
|
+
// Same owner fence as srv_job_output: the caller is this call's session.
|
|
791
|
+
const caller = surface.#ownerOf(exec)
|
|
792
|
+
const outcome = jobs.kill(id, caller)
|
|
793
|
+
const job = jobs.get(id, caller)
|
|
794
|
+
/** @type {'cancellation-requested'|'already-finished'} */
|
|
795
|
+
const cancellation = outcome === 'requested' ? 'cancellation-requested' : 'already-finished'
|
|
796
|
+
return { jobId: id, outcome: cancellation, status: job.status }
|
|
797
|
+
},
|
|
798
|
+
})
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/** @returns {any} */
|
|
802
|
+
#policyCheckTool() {
|
|
803
|
+
const surface = this
|
|
804
|
+
return defineTool({
|
|
805
|
+
name: 'srv_policy_check',
|
|
806
|
+
description:
|
|
807
|
+
'Decide how srv_exec would treat a command on one server — allow, confirm (the user is asked) or block (never runs) — without connecting or executing anything. Use it before running something the built-in rules might catch.',
|
|
808
|
+
parameters: {
|
|
809
|
+
server: { type: 'string', required: true, description: 'Server id, exact name, or an unambiguous prefix.' },
|
|
810
|
+
command: { type: 'string', required: true, description: 'The command to judge.' },
|
|
811
|
+
},
|
|
812
|
+
output: {
|
|
813
|
+
schema: {
|
|
814
|
+
type: 'object',
|
|
815
|
+
additionalProperties: false,
|
|
816
|
+
properties: {
|
|
817
|
+
serverId: { type: 'string', required: true },
|
|
818
|
+
serverName: { type: 'string', required: true },
|
|
819
|
+
command: { type: 'string', required: true },
|
|
820
|
+
decision: { type: 'string', enum: ['allow', 'confirm', 'block'], required: true },
|
|
821
|
+
ruleId: { type: 'string' },
|
|
822
|
+
ruleLabel: { type: 'string' },
|
|
823
|
+
matches: {
|
|
824
|
+
type: 'array',
|
|
825
|
+
items: {
|
|
826
|
+
type: 'object',
|
|
827
|
+
additionalProperties: false,
|
|
828
|
+
properties: {
|
|
829
|
+
ruleId: { type: 'string', required: true },
|
|
830
|
+
decision: { type: 'string', enum: ['allow', 'confirm', 'block'], required: true },
|
|
831
|
+
},
|
|
832
|
+
},
|
|
833
|
+
},
|
|
834
|
+
},
|
|
835
|
+
},
|
|
836
|
+
render: (_args, value) => [
|
|
837
|
+
{
|
|
838
|
+
type: 'text',
|
|
839
|
+
text:
|
|
840
|
+
`${value.decision.toUpperCase()} on ${value.serverId} (${value.serverName})` +
|
|
841
|
+
`${value.ruleId === undefined ? '' : ` — rule ${value.ruleId}${value.ruleLabel === undefined ? '' : ` (${value.ruleLabel})`}`}\n` +
|
|
842
|
+
`command: ${value.command}` +
|
|
843
|
+
(value.decision === 'block' ? '\nThis command will not run: the rule blocks it. Ask the user to change the server policy if it is really needed.' : ''),
|
|
844
|
+
},
|
|
845
|
+
],
|
|
846
|
+
},
|
|
847
|
+
async execute(args) {
|
|
848
|
+
await surface.refresh()
|
|
849
|
+
const server = await surface.#resolveServer(args.server)
|
|
850
|
+
const command = requireCommand(args.command)
|
|
851
|
+
const verdict = checkPolicy(command, server.policy, server.disabledRules)
|
|
852
|
+
const matched = verdict.matchedRule
|
|
853
|
+
return {
|
|
854
|
+
serverId: server.id,
|
|
855
|
+
serverName: server.name,
|
|
856
|
+
command,
|
|
857
|
+
decision: verdict.decision,
|
|
858
|
+
...(matched === null ? {} : { ruleId: matched.id, ruleLabel: matched.label }),
|
|
859
|
+
matches: verdict.matches.map((match) => ({ ruleId: match.id, decision: match.decision })),
|
|
860
|
+
}
|
|
861
|
+
},
|
|
862
|
+
})
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
// ------------------------------------------------------------- internals --
|
|
866
|
+
|
|
867
|
+
/**
|
|
868
|
+
* Resolve and cache-refresh one `server` argument (§5.2).
|
|
869
|
+
* @param {unknown} query
|
|
870
|
+
* @returns {Promise<import('./registry.js').ServerRecord>}
|
|
871
|
+
*/
|
|
872
|
+
async #resolveServer(query) {
|
|
873
|
+
/** @type {import('./registry.js').ServerRecord} */
|
|
874
|
+
let server
|
|
875
|
+
try {
|
|
876
|
+
server = await this.#registry.resolve(String(query ?? ''))
|
|
877
|
+
} catch (error) {
|
|
878
|
+
const code = /** @type {{code?: string, message?: string}} */ (error).code
|
|
879
|
+
if (code === 'UNKNOWN_SERVER' || code === 'AMBIGUOUS_SERVER' || code === 'INVALID_SERVER') {
|
|
880
|
+
throw new ToolError(
|
|
881
|
+
/** @type {ToolErrorCode} */ (code),
|
|
882
|
+
String(/** @type {{message?: string}} */ (error).message ?? error),
|
|
883
|
+
code === 'UNKNOWN_SERVER' ? 'Call srv_list to see the configured servers.' : undefined,
|
|
884
|
+
)
|
|
885
|
+
}
|
|
886
|
+
throw error
|
|
887
|
+
}
|
|
888
|
+
// The enable switch (doc §5.1). Every srv_* tool resolves its target here,
|
|
889
|
+
// so one check covers srv_exec, srv_connect, srv_policy_check and the job
|
|
890
|
+
// tools alike. A disabled server is still addressable by id/name/prefix —
|
|
891
|
+
// that is how the panel names it — so the check belongs after resolution,
|
|
892
|
+
// not inside it.
|
|
893
|
+
if (server.enabled === false) {
|
|
894
|
+
throw new ToolError(
|
|
895
|
+
'SERVER_DISABLED',
|
|
896
|
+
`server ${server.id} (${server.name}) is disabled.`,
|
|
897
|
+
'Enable it in the "服务器" panel of this plugin, then retry.',
|
|
898
|
+
)
|
|
899
|
+
}
|
|
900
|
+
return server
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
/**
|
|
904
|
+
* Call one pool method and normalise its failures into §5.5 tool errors.
|
|
905
|
+
* @param {'connect'|'exec'|'execStream'|'disconnect'} method
|
|
906
|
+
* @param {...any} args
|
|
907
|
+
* @returns {Promise<any>}
|
|
908
|
+
*/
|
|
909
|
+
async #callPool(method, ...args) {
|
|
910
|
+
try {
|
|
911
|
+
// The pool's methods have unrelated signatures; this dispatcher exists only
|
|
912
|
+
// so every transport failure is normalised in one place.
|
|
913
|
+
const call = /** @type {(...a: any[]) => Promise<any>} */ (/** @type {any} */ (this.#pool)[method]).bind(
|
|
914
|
+
this.#pool,
|
|
915
|
+
)
|
|
916
|
+
return await call(...args)
|
|
917
|
+
} catch (error) {
|
|
918
|
+
throw normalizePoolFailure(error)
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
/**
|
|
923
|
+
* Ids with a live connection, for `srv_list` (§5.1 "是否已连接").
|
|
924
|
+
* @returns {string[]}
|
|
925
|
+
*/
|
|
926
|
+
poolConnectedIds() {
|
|
927
|
+
try {
|
|
928
|
+
return this.#pool.connectedServerIds()
|
|
929
|
+
} catch {
|
|
930
|
+
return []
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
/**
|
|
935
|
+
* The three gates of §6.1, then execution.
|
|
936
|
+
* @param {{server: string, command: string, cwd?: string, timeoutMs?: number, async?: boolean}} args
|
|
937
|
+
* @param {import('@deepseek-ai/dsh-tools').ToolRunContext} exec
|
|
938
|
+
* @returns {Promise<SrvExecValue>}
|
|
939
|
+
*/
|
|
940
|
+
async #exec(args, exec) {
|
|
941
|
+
await this.refresh()
|
|
942
|
+
const server = await this.#resolveServer(args.server)
|
|
943
|
+
const command = requireCommand(args.command)
|
|
944
|
+
const settings = this.settings
|
|
945
|
+
const timeoutMs = positiveInteger(args.timeoutMs, 'timeoutMs') ?? settings.commandTimeoutMs
|
|
946
|
+
const cwd = typeof args.cwd === 'string' && args.cwd.trim() !== '' ? args.cwd : undefined
|
|
947
|
+
const auditBase = {
|
|
948
|
+
sessionId: this.#sessionIdOf(exec),
|
|
949
|
+
serverId: server.id,
|
|
950
|
+
serverName: server.name,
|
|
951
|
+
command,
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
// ① policy. A block never connects and never executes.
|
|
955
|
+
const verdict = checkPolicy(command, server.policy, server.disabledRules)
|
|
956
|
+
if (verdict.decision === 'block') {
|
|
957
|
+
await this.#auditExec({ ...auditBase, decision: 'block', policyRule: verdict.ruleId })
|
|
958
|
+
throw new ToolError(
|
|
959
|
+
'POLICY_BLOCKED',
|
|
960
|
+
`command blocked by rule ${verdict.ruleId ?? 'unknown'}${
|
|
961
|
+
verdict.matchedRule === null ? '' : ` (${verdict.matchedRule.label})`
|
|
962
|
+
} on server ${server.name} (${server.id}): ${command}`,
|
|
963
|
+
'Nothing was sent to the server. Ask the user to allow this rule for that server, or rephrase the command.',
|
|
964
|
+
)
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
// ② approval. Only 'allowed-once' proceeds; everything else fails closed.
|
|
968
|
+
/** @type {boolean|null} */
|
|
969
|
+
let approved = null
|
|
970
|
+
if (verdict.decision === 'confirm') {
|
|
971
|
+
const outcome = await this.#requestApproval({
|
|
972
|
+
exec,
|
|
973
|
+
server,
|
|
974
|
+
command,
|
|
975
|
+
ruleId: verdict.ruleId,
|
|
976
|
+
ruleLabel: verdict.matchedRule?.label,
|
|
977
|
+
})
|
|
978
|
+
if (!isGranted(outcome)) {
|
|
979
|
+
await this.#auditExec({ ...auditBase, decision: 'confirm', policyRule: verdict.ruleId, approved: false })
|
|
980
|
+
throw this.#denialError(outcome, exec, server, command)
|
|
981
|
+
}
|
|
982
|
+
approved = true
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
// ③ execute, then audit.
|
|
986
|
+
const jobs = this.#jobs
|
|
987
|
+
if (jobs === null || jobs === undefined) {
|
|
988
|
+
const result = await this.#runSync({ server, command, cwd, timeoutMs, exec, asyncRequested: args.async === true })
|
|
989
|
+
await this.#auditExec({
|
|
990
|
+
...auditBase,
|
|
991
|
+
decision: verdict.decision,
|
|
992
|
+
policyRule: verdict.ruleId,
|
|
993
|
+
approved,
|
|
994
|
+
exitCode: result.exitCode,
|
|
995
|
+
durationMs: result.durationMs,
|
|
996
|
+
truncated: result.truncated,
|
|
997
|
+
})
|
|
998
|
+
return result
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
const started = this.#startJob({ server, command, cwd, timeoutMs, exec })
|
|
1002
|
+
const owner = this.#ownerOf(exec)
|
|
1003
|
+
if (args.async === true) {
|
|
1004
|
+
/** @type {SrvExecValue} */
|
|
1005
|
+
const startedValue = {
|
|
1006
|
+
serverId: server.id,
|
|
1007
|
+
serverName: server.name,
|
|
1008
|
+
command,
|
|
1009
|
+
// The block branch returned above, so the decision is allow or confirm.
|
|
1010
|
+
decision: /** @type {'allow'|'confirm'} */ (verdict.decision),
|
|
1011
|
+
...(verdict.ruleId === null ? {} : { policyRule: verdict.ruleId }),
|
|
1012
|
+
jobId: started.id,
|
|
1013
|
+
running: true,
|
|
1014
|
+
}
|
|
1015
|
+
return startedValue
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
let view
|
|
1019
|
+
try {
|
|
1020
|
+
view = await jobs.wait(started.id, settings.syncTimeoutMs, owner, exec.signal)
|
|
1021
|
+
} catch (error) {
|
|
1022
|
+
jobs.kill(started.id, owner, 'the srv_exec call was aborted')
|
|
1023
|
+
throw error
|
|
1024
|
+
}
|
|
1025
|
+
this.#log('info', 'dsh-server-connector: srv_exec on %s settled as %s', server.id, view.status)
|
|
1026
|
+
|
|
1027
|
+
if (view.status === 'running' || view.status === 'stopping') {
|
|
1028
|
+
return {
|
|
1029
|
+
serverId: server.id,
|
|
1030
|
+
serverName: server.name,
|
|
1031
|
+
command,
|
|
1032
|
+
decision: verdict.decision,
|
|
1033
|
+
...(verdict.ruleId === null ? {} : { policyRule: verdict.ruleId }),
|
|
1034
|
+
jobId: started.id,
|
|
1035
|
+
running: true,
|
|
1036
|
+
note: `still running after ${settings.syncTimeoutMs} ms; use srv_job_output`,
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
// The call itself collected the terminal state and never handed the id out,
|
|
1041
|
+
// so the record leaves with the call (§12 / jobs start+wait+remove pattern).
|
|
1042
|
+
jobs.remove(started.id, owner)
|
|
1043
|
+
const settled = started.outcome()
|
|
1044
|
+
await this.#auditExec({
|
|
1045
|
+
...auditBase,
|
|
1046
|
+
decision: verdict.decision,
|
|
1047
|
+
policyRule: verdict.ruleId,
|
|
1048
|
+
approved,
|
|
1049
|
+
exitCode: settled?.exitCode ?? null,
|
|
1050
|
+
durationMs: settled?.durationMs,
|
|
1051
|
+
truncated: settled?.truncated ?? false,
|
|
1052
|
+
})
|
|
1053
|
+
return this.#resultOf({ server, command, verdict, approved, result: settled })
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
/**
|
|
1057
|
+
* Ask the user through the DSH approval service (§6.1) and report the closed
|
|
1058
|
+
* outcome. A missing service or a missing agent fails closed as
|
|
1059
|
+
* `'unavailable'` rather than running the command unasked.
|
|
1060
|
+
* @param {{exec: any, server: any, command: string, ruleId: string|null, ruleLabel?: string}} input
|
|
1061
|
+
* @returns {Promise<string>}
|
|
1062
|
+
*/
|
|
1063
|
+
async #requestApproval(input) {
|
|
1064
|
+
const approval = this.#approval
|
|
1065
|
+
const agent = input.exec?.agent
|
|
1066
|
+
if (approval === null || approval === undefined || typeof approval.request !== 'function') {
|
|
1067
|
+
return 'unavailable'
|
|
1068
|
+
}
|
|
1069
|
+
if (agent === undefined) return 'unavailable'
|
|
1070
|
+
try {
|
|
1071
|
+
return await approval.request({
|
|
1072
|
+
agent,
|
|
1073
|
+
toolName: 'srv_exec',
|
|
1074
|
+
...(input.exec.callId === undefined ? {} : { callId: input.exec.callId }),
|
|
1075
|
+
reason: approvalReason({
|
|
1076
|
+
ruleId: input.ruleId,
|
|
1077
|
+
ruleLabel: input.ruleLabel,
|
|
1078
|
+
server: input.server,
|
|
1079
|
+
command: input.command,
|
|
1080
|
+
}),
|
|
1081
|
+
...(input.exec.signal === undefined ? {} : { signal: input.exec.signal }),
|
|
1082
|
+
})
|
|
1083
|
+
} catch (error) {
|
|
1084
|
+
this.#log('warn', 'dsh-server-connector: approval.request failed: %s', String(error))
|
|
1085
|
+
return 'unavailable'
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
/**
|
|
1090
|
+
* The §6.1 rejection error, wording the outcome and — when the session policy
|
|
1091
|
+
* is `never` — saying so explicitly, because that is the case the user can fix
|
|
1092
|
+
* without a prompt ever appearing.
|
|
1093
|
+
* @param {string} outcome
|
|
1094
|
+
* @param {any} exec
|
|
1095
|
+
* @param {import('./registry.js').ServerRecord} server
|
|
1096
|
+
* @param {string} command
|
|
1097
|
+
* @returns {ToolError}
|
|
1098
|
+
*/
|
|
1099
|
+
#denialError(outcome, exec, server, command) {
|
|
1100
|
+
const context = `${server.name} (${server.id}): ${command}`
|
|
1101
|
+
if (outcome === 'rejected' && approvalPolicyOf(this.#approval, exec?.agent) === 'never') {
|
|
1102
|
+
return new ToolError(
|
|
1103
|
+
'POLICY_DENIED_BY_USER',
|
|
1104
|
+
`当前会话审批策略为 never,高危命令无法确认,命令未执行 — ${context}`,
|
|
1105
|
+
'The session approval policy is "never", so confirmation prompts are auto-rejected. Switch the session to the "ask" policy (or run the command manually on the server) and retry.',
|
|
1106
|
+
)
|
|
1107
|
+
}
|
|
1108
|
+
if (outcome === 'rejected') {
|
|
1109
|
+
return new ToolError(
|
|
1110
|
+
'POLICY_DENIED_BY_USER',
|
|
1111
|
+
`the user rejected this command — ${context}`,
|
|
1112
|
+
'Nothing was sent to the server. Ask the user what to do instead, or use srv_policy_check to find an allowed form.',
|
|
1113
|
+
)
|
|
1114
|
+
}
|
|
1115
|
+
if (outcome === 'cancelled') {
|
|
1116
|
+
return new ToolError(
|
|
1117
|
+
'POLICY_DENIED_BY_USER',
|
|
1118
|
+
`the confirmation was cancelled before it was answered — ${context}`,
|
|
1119
|
+
'Nothing was sent to the server. Retry when the user is ready to decide.',
|
|
1120
|
+
)
|
|
1121
|
+
}
|
|
1122
|
+
return new ToolError(
|
|
1123
|
+
'POLICY_DENIED_BY_USER',
|
|
1124
|
+
`the confirmation could not be shown (no approval answerer is available) — ${context}`,
|
|
1125
|
+
'Nothing was sent to the server. Ask the user to run the command or to enable an interactive approval surface.',
|
|
1126
|
+
)
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
/**
|
|
1130
|
+
* Execute without `ctx.jobs` (§9.4: "无 jobs → 强制同步执行 + 硬超时").
|
|
1131
|
+
* @param {{server: any, command: string, cwd?: string, timeoutMs: number, exec: any, asyncRequested: boolean}} input
|
|
1132
|
+
* @returns {Promise<SrvExecValue>}
|
|
1133
|
+
*/
|
|
1134
|
+
async #runSync(input) {
|
|
1135
|
+
const result = await this.#callPool(
|
|
1136
|
+
'exec',
|
|
1137
|
+
input.server,
|
|
1138
|
+
{
|
|
1139
|
+
command: input.command,
|
|
1140
|
+
...(input.cwd === undefined ? {} : { cwd: input.cwd }),
|
|
1141
|
+
timeoutMs: input.timeoutMs,
|
|
1142
|
+
},
|
|
1143
|
+
input.exec,
|
|
1144
|
+
)
|
|
1145
|
+
const verdict = checkPolicy(input.command, input.server.policy, input.server.disabledRules)
|
|
1146
|
+
return this.#resultOf({
|
|
1147
|
+
server: input.server,
|
|
1148
|
+
command: input.command,
|
|
1149
|
+
verdict,
|
|
1150
|
+
approved: null,
|
|
1151
|
+
result,
|
|
1152
|
+
note: input.asyncRequested ? 'this deployment has no background-job service, so the call ran synchronously' : undefined,
|
|
1153
|
+
})
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
/**
|
|
1157
|
+
* Shape one finished execution into the canonical tool result.
|
|
1158
|
+
* @param {{server: any, command: string, verdict: any, approved: boolean|null, result: any, note?: string}} input
|
|
1159
|
+
* @returns {SrvExecValue}
|
|
1160
|
+
*/
|
|
1161
|
+
#resultOf(input) {
|
|
1162
|
+
const result = input.result ?? {}
|
|
1163
|
+
const timedOut = result.timedOut === true
|
|
1164
|
+
const exitCode = typeof result.exitCode === 'number' ? result.exitCode : null
|
|
1165
|
+
/** @type {SrvExecValue} */
|
|
1166
|
+
const value = {
|
|
1167
|
+
serverId: input.server.id,
|
|
1168
|
+
serverName: input.server.name,
|
|
1169
|
+
command: input.command,
|
|
1170
|
+
decision: input.verdict.decision === 'block' ? 'allow' : input.verdict.decision,
|
|
1171
|
+
...(input.verdict.ruleId === null ? {} : { policyRule: input.verdict.ruleId }),
|
|
1172
|
+
...(exitCode === null ? {} : { exitCode }),
|
|
1173
|
+
stdout: String(result.stdout ?? ''),
|
|
1174
|
+
stderr: String(result.stderr ?? ''),
|
|
1175
|
+
truncated: result.truncated === true,
|
|
1176
|
+
durationMs: typeof result.durationMs === 'number' ? result.durationMs : 0,
|
|
1177
|
+
}
|
|
1178
|
+
if (timedOut) value.code = 'TIMEOUT'
|
|
1179
|
+
else if (exitCode !== null && exitCode !== 0) value.code = 'EXIT_NONZERO'
|
|
1180
|
+
if (input.note !== undefined) value.note = input.note
|
|
1181
|
+
return value
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
/**
|
|
1185
|
+
* Register one `srv_exec` as a job and start the SSH channel inside it. The
|
|
1186
|
+
* job owns the stream from its first byte, so a promoted call keeps emitting
|
|
1187
|
+
* into the ring that `srv_job_output` reads (§5.4).
|
|
1188
|
+
* @param {{server: any, command: string, cwd?: string, timeoutMs: number, exec: any}} input
|
|
1189
|
+
* @returns {{id: string, outcome: () => any}}
|
|
1190
|
+
*/
|
|
1191
|
+
#startJob(input) {
|
|
1192
|
+
const jobs = this.#jobs
|
|
1193
|
+
/** @type {any} */
|
|
1194
|
+
let settled = null
|
|
1195
|
+
/** @type {(() => void)|null} */
|
|
1196
|
+
let cancelStream = null
|
|
1197
|
+
const maxOutputChars = this.settings.maxOutputChars
|
|
1198
|
+
const label = `${input.server.id}: ${input.command}`
|
|
1199
|
+
|
|
1200
|
+
/** @type {any} */
|
|
1201
|
+
const spec = {
|
|
1202
|
+
kind: 'ssh',
|
|
1203
|
+
label,
|
|
1204
|
+
...(this.#ownerOf(input.exec) === undefined ? {} : { owner: this.#ownerOf(input.exec) }),
|
|
1205
|
+
...(input.exec?.callId === undefined
|
|
1206
|
+
? {}
|
|
1207
|
+
: { toolCall: { callId: input.exec.callId, rootCallId: input.exec.rootCallId ?? input.exec.callId } }),
|
|
1208
|
+
run: (/** @type {any} */ job) => {
|
|
1209
|
+
/** @type {Promise<any>} */
|
|
1210
|
+
let streaming
|
|
1211
|
+
try {
|
|
1212
|
+
streaming = this.#pool.execStream(
|
|
1213
|
+
input.server,
|
|
1214
|
+
{
|
|
1215
|
+
command: input.command,
|
|
1216
|
+
...(input.cwd === undefined ? {} : { cwd: input.cwd }),
|
|
1217
|
+
timeoutMs: input.timeoutMs,
|
|
1218
|
+
maxOutputChars,
|
|
1219
|
+
onChunk: (/** @type {'stdout'|'stderr'} */ channel, /** @type {string} */ text) => {
|
|
1220
|
+
job.append(text, { channel })
|
|
1221
|
+
},
|
|
1222
|
+
onCancel: (/** @type {() => void} */ cancel) => {
|
|
1223
|
+
cancelStream = cancel
|
|
1224
|
+
},
|
|
1225
|
+
},
|
|
1226
|
+
input.exec?.signal,
|
|
1227
|
+
)
|
|
1228
|
+
} catch (error) {
|
|
1229
|
+
return {
|
|
1230
|
+
cancel: () => {},
|
|
1231
|
+
done: Promise.resolve({ status: 'failed', detail: String(error) }),
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
const done = streaming.then(
|
|
1235
|
+
(/** @type {any} */ result) => {
|
|
1236
|
+
settled = result
|
|
1237
|
+
if (result.truncated === true) job.append('\n…[TRUNCATED]')
|
|
1238
|
+
/** @type {any} */
|
|
1239
|
+
const outcome = result.cancelled === true
|
|
1240
|
+
? { status: 'killed', detail: 'cancelled by srv_job_kill' }
|
|
1241
|
+
: {
|
|
1242
|
+
status: 'completed',
|
|
1243
|
+
detail: result.timedOut === true ? 'TIMEOUT (process group killed)' : `exit code: ${result.exitCode}`,
|
|
1244
|
+
result: JSON.stringify({
|
|
1245
|
+
exitCode: result.exitCode,
|
|
1246
|
+
timedOut: result.timedOut,
|
|
1247
|
+
truncated: result.truncated,
|
|
1248
|
+
durationMs: result.durationMs,
|
|
1249
|
+
}),
|
|
1250
|
+
}
|
|
1251
|
+
return outcome
|
|
1252
|
+
},
|
|
1253
|
+
(/** @type {any} */ error) => ({
|
|
1254
|
+
status: 'failed',
|
|
1255
|
+
detail: normalizePoolFailure(error).message,
|
|
1256
|
+
}),
|
|
1257
|
+
)
|
|
1258
|
+
return {
|
|
1259
|
+
cancel: () => {
|
|
1260
|
+
if (cancelStream !== null) cancelStream()
|
|
1261
|
+
},
|
|
1262
|
+
done,
|
|
1263
|
+
}
|
|
1264
|
+
},
|
|
1265
|
+
}
|
|
1266
|
+
const id = jobs.start(spec)
|
|
1267
|
+
return { id, outcome: () => settled }
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
/**
|
|
1271
|
+
* Write one audit line (§6.2). Audit failures are logged, never surfaced: the
|
|
1272
|
+
* command's outcome must not change because a log file is unwritable.
|
|
1273
|
+
* @param {Record<string, any>} entry
|
|
1274
|
+
* @returns {Promise<void>}
|
|
1275
|
+
*/
|
|
1276
|
+
async #auditExec(entry) {
|
|
1277
|
+
const audit = this.#audit
|
|
1278
|
+
if (audit === null || audit === undefined) return
|
|
1279
|
+
try {
|
|
1280
|
+
await audit.append(entry)
|
|
1281
|
+
} catch (error) {
|
|
1282
|
+
this.#log('error', 'dsh-server-connector: audit append failed: %s', String(error))
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
/**
|
|
1287
|
+
* @param {any} exec
|
|
1288
|
+
* @returns {string|undefined}
|
|
1289
|
+
*/
|
|
1290
|
+
#sessionIdOf(exec) {
|
|
1291
|
+
const id = exec?.agent?.id ?? exec?.agent?.session?.id
|
|
1292
|
+
return typeof id === 'string' ? id : undefined
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
/**
|
|
1296
|
+
* The reading/owning session of a job call, or undefined for an unowned job.
|
|
1297
|
+
* @param {any} exec
|
|
1298
|
+
* @returns {any}
|
|
1299
|
+
*/
|
|
1300
|
+
#ownerOf(exec) {
|
|
1301
|
+
return exec?.agent?.id
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
/**
|
|
1305
|
+
* @param {'info'|'warn'|'error'} level
|
|
1306
|
+
* @param {string} message
|
|
1307
|
+
* @param {...unknown} args
|
|
1308
|
+
* @returns {void}
|
|
1309
|
+
*/
|
|
1310
|
+
#log(level, message, ...args) {
|
|
1311
|
+
const logger = this.#logger
|
|
1312
|
+
if (logger === null || logger === undefined) return
|
|
1313
|
+
const write = logger[level] ?? logger.info
|
|
1314
|
+
if (typeof write === 'function') write.call(logger, message, ...args)
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
/**
|
|
1319
|
+
* Map a transport failure onto a §5.5 tool error, keeping the actionable next
|
|
1320
|
+
* step the pool already composed.
|
|
1321
|
+
* @param {unknown} error
|
|
1322
|
+
* @returns {ToolError}
|
|
1323
|
+
*/
|
|
1324
|
+
export function normalizePoolFailure(error) {
|
|
1325
|
+
if (error instanceof ToolError) return error
|
|
1326
|
+
const code = /** @type {{code?: string}} */ (error).code
|
|
1327
|
+
const known = /** @type {ToolErrorCode[]} */ ([
|
|
1328
|
+
'CONNECT_FAILED',
|
|
1329
|
+
'HOST_KEY_UNKNOWN',
|
|
1330
|
+
'AUTH_FAILED',
|
|
1331
|
+
'TIMEOUT',
|
|
1332
|
+
'NO_CREDENTIAL',
|
|
1333
|
+
'EXIT_NONZERO',
|
|
1334
|
+
'INVALID_ARGUMENT',
|
|
1335
|
+
])
|
|
1336
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
1337
|
+
if (typeof code === 'string' && known.includes(/** @type {ToolErrorCode} */ (code))) {
|
|
1338
|
+
return new ToolError(/** @type {ToolErrorCode} */ (code), message)
|
|
1339
|
+
}
|
|
1340
|
+
return new ToolError('CONNECT_FAILED', message, 'Retry; if it persists, check that sshd is running on the target.')
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
/**
|
|
1344
|
+
* @param {unknown} value
|
|
1345
|
+
* @returns {string}
|
|
1346
|
+
*/
|
|
1347
|
+
function requireCommand(value) {
|
|
1348
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
1349
|
+
throw new ToolError('INVALID_ARGUMENT', 'command must be a non-empty string.', 'Pass the shell command to run.')
|
|
1350
|
+
}
|
|
1351
|
+
return value
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
/**
|
|
1355
|
+
* @param {unknown} value
|
|
1356
|
+
* @param {string} label
|
|
1357
|
+
* @returns {number|undefined}
|
|
1358
|
+
*/
|
|
1359
|
+
function positiveInteger(value, label) {
|
|
1360
|
+
if (value === undefined || value === null) return undefined
|
|
1361
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
|
|
1362
|
+
throw new ToolError('INVALID_ARGUMENT', `${label} must be a positive number of milliseconds.`)
|
|
1363
|
+
}
|
|
1364
|
+
return Math.round(value)
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
export default ToolSurface
|