@yanglaofish/dsh-proxy-pro 1.1.0 → 2.0.1
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/docs/LESSONS.md +1217 -994
- package/lib/client.js +67 -16
- package/lib/index.js +901 -831
- package/lib/proxy-core.js +43 -18
- package/package.json +5 -5
package/lib/index.js
CHANGED
|
@@ -1,832 +1,902 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* dsh-proxy-pro — host half (Cordis plugin).
|
|
3
|
-
*
|
|
4
|
-
* Routes the whole runtime through a proxy when enabled:
|
|
5
|
-
* - model requests and plain fetch(): the undici global dispatcher
|
|
6
|
-
* - web_search / web_fetch: the dsh-http-proxy POLICY that dsh-web-fetch-http's
|
|
7
|
-
* proxyRouteFor() reads (the KEY fix the stock dsh-plugin-proxy lacks — see
|
|
8
|
-
* docs/LESSONS.md §2-§3)
|
|
9
|
-
* - spawned tools: HTTP_PROXY / HTTPS_PROXY / ALL_PROXY / NO_PROXY are
|
|
10
|
-
* written into process.env
|
|
11
|
-
*
|
|
12
|
-
* Everything goes through ONE channel: `installProxyFromEnvironment(envLike)`.
|
|
13
|
-
* That call owns the environment, the global dispatcher, and the module-level
|
|
14
|
-
* policy state as one coherent unit, so the plugin never touches undici
|
|
15
|
-
* directly and no dispatcher/instance skew is possible (LESSONS §3: mixed
|
|
16
|
-
* undici instances → UND_ERR_INVALID_ARG).
|
|
17
|
-
*
|
|
18
|
-
* A "never applied yet" guard keeps boot with `enabled: false` a strict no-op
|
|
19
|
-
* on the transport, so dsh-proxy-pro can coexist with dsh-plugin-proxy during
|
|
20
|
-
* migration (NFR-4: only the row whose switch is ON owns the dispatcher).
|
|
21
|
-
*
|
|
22
|
-
* Configuration lives in the `proxy` settings namespace (Settings → 代理管理),
|
|
23
|
-
* layered over the composition entry (cordis.patch.yml).
|
|
24
|
-
*
|
|
25
|
-
* @module dsh-proxy-pro
|
|
26
|
-
*/
|
|
27
|
-
import z from '@deepseek-ai/schemastery'
|
|
28
|
-
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
29
|
-
import { installProxyFromEnvironment, proxyRouteFor } from '@deepseek-ai/dsh-http-proxy'
|
|
30
|
-
import { execFile } from 'node:child_process'
|
|
31
|
-
import { promisify } from 'node:util'
|
|
32
|
-
import {
|
|
33
|
-
classifyProbeStatus,
|
|
34
|
-
classifyTargetFailure,
|
|
35
|
-
combineDualProbe,
|
|
36
|
-
configPayload,
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
const
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
const
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
return
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
*
|
|
275
|
-
*
|
|
276
|
-
*
|
|
277
|
-
*
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
/**
|
|
384
|
-
function
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
*/
|
|
400
|
-
function
|
|
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
|
-
const
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
//
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
const
|
|
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
|
-
const
|
|
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
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
1
|
+
/**
|
|
2
|
+
* dsh-proxy-pro — host half (Cordis plugin).
|
|
3
|
+
*
|
|
4
|
+
* Routes the whole runtime through a proxy when enabled:
|
|
5
|
+
* - model requests and plain fetch(): the undici global dispatcher
|
|
6
|
+
* - web_search / web_fetch: the dsh-http-proxy POLICY that dsh-web-fetch-http's
|
|
7
|
+
* proxyRouteFor() reads (the KEY fix the stock dsh-plugin-proxy lacks — see
|
|
8
|
+
* docs/LESSONS.md §2-§3)
|
|
9
|
+
* - spawned tools: HTTP_PROXY / HTTPS_PROXY / ALL_PROXY / NO_PROXY are
|
|
10
|
+
* written into process.env
|
|
11
|
+
*
|
|
12
|
+
* Everything goes through ONE channel: `installProxyFromEnvironment(envLike)`.
|
|
13
|
+
* That call owns the environment, the global dispatcher, and the module-level
|
|
14
|
+
* policy state as one coherent unit, so the plugin never touches undici
|
|
15
|
+
* directly and no dispatcher/instance skew is possible (LESSONS §3: mixed
|
|
16
|
+
* undici instances → UND_ERR_INVALID_ARG).
|
|
17
|
+
*
|
|
18
|
+
* A "never applied yet" guard keeps boot with `enabled: false` a strict no-op
|
|
19
|
+
* on the transport, so dsh-proxy-pro can coexist with dsh-plugin-proxy during
|
|
20
|
+
* migration (NFR-4: only the row whose switch is ON owns the dispatcher).
|
|
21
|
+
*
|
|
22
|
+
* Configuration lives in the `proxy` settings namespace (Settings → 代理管理),
|
|
23
|
+
* layered over the composition entry (cordis.patch.yml).
|
|
24
|
+
*
|
|
25
|
+
* @module dsh-proxy-pro
|
|
26
|
+
*/
|
|
27
|
+
import z from '@deepseek-ai/schemastery'
|
|
28
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
29
|
+
import { installProxyFromEnvironment, proxyRouteFor } from '@deepseek-ai/dsh-http-proxy'
|
|
30
|
+
import { execFile } from 'node:child_process'
|
|
31
|
+
import { promisify } from 'node:util'
|
|
32
|
+
import {
|
|
33
|
+
classifyProbeStatus,
|
|
34
|
+
classifyTargetFailure,
|
|
35
|
+
combineDualProbe,
|
|
36
|
+
configPayload,
|
|
37
|
+
headNeedsGetRetry,
|
|
38
|
+
keepaliveHint,
|
|
39
|
+
makeSystemProxyReader,
|
|
40
|
+
normalizeProxyUrl,
|
|
41
|
+
readProxyConfig,
|
|
42
|
+
resolveEntryId,
|
|
43
|
+
resolveProxyState,
|
|
44
|
+
summarize,
|
|
45
|
+
systemFactsEqual,
|
|
46
|
+
} from './proxy-core.js'
|
|
47
|
+
import {
|
|
48
|
+
PROXY_CONFIG_PARAMETERS,
|
|
49
|
+
PROXY_SET_PARAMETERS,
|
|
50
|
+
PROXY_STATUS_SCHEMA,
|
|
51
|
+
PROXY_TEST_PARAMETERS,
|
|
52
|
+
PROXY_TEST_SCHEMA,
|
|
53
|
+
} from './tool-schemas.js'
|
|
54
|
+
|
|
55
|
+
const execFileAsync = promisify(execFile)
|
|
56
|
+
|
|
57
|
+
/** Cordis plugin name (also the bundle row id in cordis.patch.yml). */
|
|
58
|
+
const name = 'dsh-proxy-pro'
|
|
59
|
+
|
|
60
|
+
/** Services this plugin must resolve before it applies. */
|
|
61
|
+
const inject = ['tools', 'systemPrompt', 'settings']
|
|
62
|
+
|
|
63
|
+
/** Settings namespace owning the proxy configuration (shared with stock
|
|
64
|
+
* tooling). Plain literal: the legacy settingsNamespace() helper was removed
|
|
65
|
+
* from newer dsh-settings, and 'proxy' is already a valid lowercase-hyphenated
|
|
66
|
+
* namespace identifier. */
|
|
67
|
+
const PROXY_NS = 'proxy'
|
|
68
|
+
|
|
69
|
+
/** Ordered placement of the proxy status section in the system prompt. */
|
|
70
|
+
const SECTION_ORDER = 55
|
|
71
|
+
|
|
72
|
+
/** Base path for the status HTTP API (client polls this). */
|
|
73
|
+
const ROUTE_BASE = '/dsh-proxy-pro/api'
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Mark one Config field live-editable.
|
|
77
|
+
*
|
|
78
|
+
* dsh >= 0.2 only accepts a settings write whose paths sit under a field whose
|
|
79
|
+
* schema node carries `meta.volatile` (`volatileForm`/`isVolatilePath` in
|
|
80
|
+
* dsh-settings); an unmarked schema answers `Plugin entry "…" has no volatile
|
|
81
|
+
* fields` and every toggle bounces straight back (LESSONS §36). The guard keeps
|
|
82
|
+
* the definition loadable on older schemastery builds without `.volatile()`.
|
|
83
|
+
*
|
|
84
|
+
* @param field - a schemastery field.
|
|
85
|
+
* @returns the same field, marked volatile when the API exists.
|
|
86
|
+
*/
|
|
87
|
+
const volatile = (field) => (typeof field?.volatile === 'function' ? field.volatile() : field)
|
|
88
|
+
|
|
89
|
+
/** Composition-row configuration (also the settings schema). */
|
|
90
|
+
const Config = z.object({
|
|
91
|
+
enabled: volatile(z.boolean().default(false)),
|
|
92
|
+
// 'none' kept only for parsing pre-removal settings.yaml values; the UI and
|
|
93
|
+
// proxy_config never produce it anymore (LESSONS §17.3e).
|
|
94
|
+
mode: volatile(z.union([z.const('system'), z.const('custom'), z.const('none')]).default('system')),
|
|
95
|
+
customUrl: volatile(z.string().default('http://127.0.0.1:7890')),
|
|
96
|
+
noProxy: volatile(z.string().default('localhost,127.0.0.1,::1')),
|
|
97
|
+
// Deliberately NOT volatile: the poll interval is read once at apply time,
|
|
98
|
+
// so promising a live edit here would be a lie. It stays a composition knob.
|
|
99
|
+
systemPollMs: z.number().default(30000).min(0),
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
/** Every proxy environment name dsh-http-proxy's resolver can read. */
|
|
103
|
+
const PROXY_ENV_NAMES = [
|
|
104
|
+
'HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'NO_PROXY',
|
|
105
|
+
'http_proxy', 'https_proxy', 'all_proxy', 'no_proxy',
|
|
106
|
+
]
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The virtual environment snapshot for one effective state, or null when the
|
|
110
|
+
* proxy is inactive. installProxyFromEnvironment resolves its policy purely
|
|
111
|
+
* from this (env.get must return `{ value }` or undefined); the lower-case
|
|
112
|
+
* names win in resolution, the upper-case ones are the published form.
|
|
113
|
+
*/
|
|
114
|
+
function proxyEnvMap(effective) {
|
|
115
|
+
if (effective?.active !== true || effective.url === null || effective.url === undefined) return null
|
|
116
|
+
const http = effective.http ?? effective.url
|
|
117
|
+
const https = effective.https ?? effective.url
|
|
118
|
+
const noProxy = effective.noProxy ?? ''
|
|
119
|
+
return {
|
|
120
|
+
HTTP_PROXY: http, HTTPS_PROXY: https, ALL_PROXY: effective.url, NO_PROXY: noProxy,
|
|
121
|
+
http_proxy: http, https_proxy: https, all_proxy: effective.url, no_proxy: noProxy,
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Build the envLike snapshot `installProxyFromEnvironment` expects (LESSONS §4). */
|
|
126
|
+
function makeEnvLike(effective) {
|
|
127
|
+
const map = proxyEnvMap(effective)
|
|
128
|
+
return {
|
|
129
|
+
get: (target) => {
|
|
130
|
+
const value = map === null ? undefined : map[target]
|
|
131
|
+
return value === undefined ? undefined : { value }
|
|
132
|
+
},
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Build the agent-facing status text from the latest sync snapshot. */
|
|
137
|
+
function renderStatusText(snapshot) {
|
|
138
|
+
const effective = snapshot?.effective
|
|
139
|
+
const config = snapshot?.config
|
|
140
|
+
if (effective === undefined || config === undefined) {
|
|
141
|
+
return 'Proxy: not yet initialized.'
|
|
142
|
+
}
|
|
143
|
+
const active = effective.active === true
|
|
144
|
+
const mode = config.mode ?? 'none'
|
|
145
|
+
if (!active) {
|
|
146
|
+
const reason = effective.reason === 'system-proxy-off'
|
|
147
|
+
? 'the Windows system proxy is currently off'
|
|
148
|
+
: effective.reason === 'invalid-custom-url'
|
|
149
|
+
? 'the custom proxy address is invalid'
|
|
150
|
+
: effective.reason === 'mode-none'
|
|
151
|
+
? 'the mode is set to "none"'
|
|
152
|
+
: 'the proxy switch is off'
|
|
153
|
+
return [
|
|
154
|
+
'## Proxy status: OFF',
|
|
155
|
+
'',
|
|
156
|
+
`Network traffic currently goes DIRECT (no proxy): ${reason}.`,
|
|
157
|
+
'If a network operation fails and you suspect it needs a proxy, call `proxy_status` to confirm, then `proxy_set` with enabled=true — or `proxy_config` to change the mode, the address, or the direct/NO_PROXY list.',
|
|
158
|
+
].join('\n')
|
|
159
|
+
}
|
|
160
|
+
const source = mode === 'system' ? 'Windows system proxy' : 'custom address'
|
|
161
|
+
return [
|
|
162
|
+
'## Proxy status: ON',
|
|
163
|
+
'',
|
|
164
|
+
`The whole runtime (model requests, web_search/web_fetch, and all spawned tools) is routed through the proxy: ${effective.url} (${source}).`,
|
|
165
|
+
'',
|
|
166
|
+
`Bypass list (NO_PROXY, traffic goes direct): ${effective.noProxy || '(none)'}.`,
|
|
167
|
+
'',
|
|
168
|
+
'Implications you MUST respect:',
|
|
169
|
+
'- Assume every outbound HTTP(S) request goes through this proxy unless the host is on the bypass list.',
|
|
170
|
+
'- Localhost / 127.0.0.1 / ::1 and anything in the bypass list always go direct — use them for local services.',
|
|
171
|
+
'- If an operation must NOT go through the proxy (an internal network target, a local server, a host the proxy would break), call `proxy_set` with enabled=false first, run the operation, then re-enable with enabled=true.',
|
|
172
|
+
'- To change the configuration itself — switch between the system proxy and a custom address, or edit the direct/NO_PROXY list so some hosts bypass the proxy — call `proxy_config`; it persists the change and applies it immediately.',
|
|
173
|
+
'- Do not silently retry failed proxy attempts with a different tool expecting a different route; check `proxy_status` and the failure reason first.',
|
|
174
|
+
].join('\n')
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// classifyTargetFailure lives in proxy-core.js (pure logic, unit-tested) so the
|
|
178
|
+
// panel's verdict can name the undici cause instead of a bare "fetch failed".
|
|
179
|
+
|
|
180
|
+
// Forced-channel probing uses undici directly. Load lazily and never let a
|
|
181
|
+
// resolution failure block boot (LESSONS §14: boot must not depend on a new
|
|
182
|
+
// bare import).
|
|
183
|
+
let undiciPromise = null
|
|
184
|
+
function getUndici() {
|
|
185
|
+
if (undiciPromise === null) {
|
|
186
|
+
undiciPromise = import('undici').then((m) => m, () => null)
|
|
187
|
+
}
|
|
188
|
+
return undiciPromise
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const PROBE_UA = 'dsh-proxy-pro/0.1'
|
|
192
|
+
const PROBE_TIMEOUT_MS = 6000
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* 一个探测尝试:先 HEAD,HEAD 结论不可信(404/405/501)时再用 GET 复测——
|
|
196
|
+
* 405/501 是「不支持该方法」,404 是网关不给 HEAD 注册路由时的伪装
|
|
197
|
+
* (LESSONS §27:DashScope `HEAD /models` → 404,同路径 GET → 401),只用
|
|
198
|
+
* HEAD 会把健康端点误判成「路径不存在」。`request` 是调用方的 fetch:两条
|
|
199
|
+
* 通道都用 app undici 的 `und.fetch` 配同副本 dispatcher(LESSONS §22/§25
|
|
200
|
+
* —— 绝不要把两份副本混在一起)。
|
|
201
|
+
*
|
|
202
|
+
* @returns classifyProbeStatus / classifyTargetFailure 的判定。
|
|
203
|
+
*/
|
|
204
|
+
async function probeHttp(request, target, dispatcher) {
|
|
205
|
+
const attempt = async (method) => {
|
|
206
|
+
const controller = new AbortController()
|
|
207
|
+
const timer = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS)
|
|
208
|
+
try {
|
|
209
|
+
const init = { method, redirect: 'manual', signal: controller.signal, headers: { 'user-agent': PROBE_UA } }
|
|
210
|
+
if (method === 'GET') init.headers.range = 'bytes=0-0'
|
|
211
|
+
if (dispatcher !== undefined) init.dispatcher = dispatcher
|
|
212
|
+
const res = await request(target, init)
|
|
213
|
+
return { res }
|
|
214
|
+
} catch (error) {
|
|
215
|
+
return { error }
|
|
216
|
+
} finally {
|
|
217
|
+
clearTimeout(timer)
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
const head = await attempt('HEAD')
|
|
221
|
+
if (head.error !== undefined) return classifyTargetFailure(head.error)
|
|
222
|
+
const verdict = classifyProbeStatus(head.res.status)
|
|
223
|
+
if (verdict.kind !== 'http-error' || !headNeedsGetRetry(head.res.status)) return verdict
|
|
224
|
+
const get = await attempt('GET')
|
|
225
|
+
if (get.error !== undefined) return classifyTargetFailure(get.error)
|
|
226
|
+
const retried = classifyProbeStatus(get.res.status)
|
|
227
|
+
const sameStatus = get.res.status === head.res.status
|
|
228
|
+
retried.note = sameStatus
|
|
229
|
+
? `HEAD 与 GET 均返回 ${get.res.status} — 该路径确实不可用。`
|
|
230
|
+
: retried.ok === true
|
|
231
|
+
? `HEAD 返回 ${head.res.status},GET 得到 ${get.res.status} — 以 GET 为准(网关常不给 HEAD 注册路由)。`
|
|
232
|
+
: `HEAD 返回 ${head.res.status},GET 得到 ${get.res.status} — 以 GET 为准。`
|
|
233
|
+
return retried
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Force the probe down one specific channel, bypassing the routing policy:
|
|
238
|
+
* `'proxy'` goes through the configured proxy unconditionally, `'direct'`
|
|
239
|
+
* ignores the proxy entirely. Used by the panel diagnostic's 「使用代理」
|
|
240
|
+
* checkbox so the user can decide whether a host belongs in NO_PROXY.
|
|
241
|
+
*/
|
|
242
|
+
async function forceChannelProbe(url, channel, proxyUrl) {
|
|
243
|
+
const und = await getUndici()
|
|
244
|
+
if (und === null) {
|
|
245
|
+
return { ok: false, verdict: 'unusable', kind: 'unavailable', short: '强制通道不可用(undici 未能加载)',
|
|
246
|
+
why: '宿主进程无法加载 app 侧 undici 包。',
|
|
247
|
+
fix: '取消勾选「使用代理」改用策略通道,或重启 DSH;仍出现请反馈插件版本。' }
|
|
248
|
+
}
|
|
249
|
+
let dispatcher
|
|
250
|
+
try {
|
|
251
|
+
dispatcher = channel === 'direct'
|
|
252
|
+
? new und.Agent({ connections: 1 })
|
|
253
|
+
: new und.ProxyAgent(proxyUrl)
|
|
254
|
+
} catch (error) {
|
|
255
|
+
return { ok: false, verdict: 'unusable', kind: 'bad-proxy', short: `代理地址无效:${String(error?.message ?? error)}`,
|
|
256
|
+
why: '当前生效的代理地址不是有效的 http(s) 代理 URL。',
|
|
257
|
+
fix: '在「代理配置」修复地址(mode = 自定义地址)或清空自定义地址。' }
|
|
258
|
+
}
|
|
259
|
+
try {
|
|
260
|
+
// MUST issue the request with THIS undici copy. The host runs two copies —
|
|
261
|
+
// Node's built-in `fetch` and the app's `undici` package — and a dispatcher
|
|
262
|
+
// built by one handed to the other rejects with UND_ERR_INVALID_ARG
|
|
263
|
+
// ("invalid onRequestStart method"); the panel rendered that as a bare
|
|
264
|
+
// "fetch failed" for BOTH forced channels (LESSONS §22). und.fetch and the
|
|
265
|
+
// Agent/ProxyAgent above come from the same import, so they always agree.
|
|
266
|
+
return await probeHttp((u, i) => und.fetch(u, i), url, dispatcher)
|
|
267
|
+
} finally {
|
|
268
|
+
try { dispatcher.close?.().catch?.(() => {}) } catch { /* best effort */ }
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* 按已安装策略探测,与 web_fetch 走同一条路:用同一份 undici 的 fetch 加
|
|
274
|
+
* 全局 dispatcher。策略通道不能用 Node 内置 `fetch`——全局 dispatcher 由
|
|
275
|
+
* app 的 undici(installProxyFromEnvironment)安装,内置 fetch 拿它必然
|
|
276
|
+
* UND_ERR_INVALID_ARG(LESSONS §25,A 组合;面板当时显示"dispatcher
|
|
277
|
+
* rejected… mixed undici copies")。
|
|
278
|
+
*/
|
|
279
|
+
async function policyProbe(target) {
|
|
280
|
+
const und = await getUndici()
|
|
281
|
+
if (und === null) {
|
|
282
|
+
return { ok: false, verdict: 'unusable', kind: 'unavailable', short: '策略通道不可用(undici 未能加载)',
|
|
283
|
+
why: '宿主进程无法加载 app 侧 undici 包。', fix: '重启 DSH;仍出现请反馈插件版本。' }
|
|
284
|
+
}
|
|
285
|
+
const dispatcher = typeof und.getGlobalDispatcher === 'function' ? und.getGlobalDispatcher() : undefined
|
|
286
|
+
return probeHttp((u, i) => und.fetch(u, i), target, dispatcher)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* 按 web_fetch 的方式探测一个目标,**同时**强制走代理 + 强制直连两个通道,
|
|
291
|
+
* 再把两侧结果综合成一条人话推荐(combineDualProbe)。
|
|
292
|
+
*
|
|
293
|
+
* 单侧探测无法判断被拒是谁干的(目标 or 代理?)——只有两侧对比才能得出
|
|
294
|
+
* 方向性结论(直连通+代理失败 → 问题在代理一侧;代理通+直连失败 → 代理是
|
|
295
|
+
* 唯一通路)。`channel` 保留兼容:'policy'/'proxy'/'direct' 仍是旧的单通道
|
|
296
|
+
* 探测(HTTP API 的历史 useProxy 参数使用),默认 'dual' 走双通道并行。
|
|
297
|
+
*/
|
|
298
|
+
async function probeTarget(url, snapshot, channel = 'dual') {
|
|
299
|
+
let parsed
|
|
300
|
+
try {
|
|
301
|
+
parsed = new URL(url)
|
|
302
|
+
} catch {
|
|
303
|
+
return { url, route: 'invalid', bypassed: false, channel,
|
|
304
|
+
proxy: { ok: false, verdict: 'unusable', kind: 'invalid', short: 'URL 无效 — 需要 http(s)://主机/路径',
|
|
305
|
+
why: '输入不是可解析的绝对 URL。', fix: '用完整形式,如 https://github.com/foo/bar。' },
|
|
306
|
+
direct: { ok: false, verdict: 'unusable', kind: 'invalid', short: 'URL 无效 — 需要 http(s)://主机/路径',
|
|
307
|
+
why: '输入不是可解析的绝对 URL。', fix: '用完整形式,如 https://github.com/foo/bar。' },
|
|
308
|
+
verdict: 'both-bad', short: 'URL 无效', why: '输入不是可解析的绝对 URL。', fix: '用完整形式,如 https://github.com/foo/bar。' }
|
|
309
|
+
}
|
|
310
|
+
let routeName = 'DIRECT'
|
|
311
|
+
try {
|
|
312
|
+
const routeProbe = proxyRouteFor(parsed)
|
|
313
|
+
routeName = routeProbe.proxied ? `PROXIED ${routeProbe.proxy}` : 'DIRECT'
|
|
314
|
+
} catch {
|
|
315
|
+
// proxyRouteFor unavailable in this composition → rely on state snapshot
|
|
316
|
+
}
|
|
317
|
+
const noProxyList = snapshot?.effective?.noProxy ?? ''
|
|
318
|
+
const bypassed = noProxyList.split(',').some((entry) => {
|
|
319
|
+
const host = entry.trim().replace(/^\*?\./, '').toLowerCase()
|
|
320
|
+
return host.length > 0 && (host === parsed.hostname.toLowerCase() || parsed.hostname.toLowerCase().endsWith(`.${host}`))
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
// 兼容旧单通道调用(HTTP API 历史 useProxy 参数)
|
|
324
|
+
if (channel === 'proxy' || channel === 'direct') {
|
|
325
|
+
const proxyUrl = channel === 'proxy' ? (snapshot?.effective?.url ?? '') : ''
|
|
326
|
+
const probe = proxyUrl === '' && channel === 'proxy'
|
|
327
|
+
? { ok: false, verdict: 'unusable', kind: 'bad-proxy', short: '当前快照里没有可用的代理地址',
|
|
328
|
+
why: '代理开关处于关闭(或 Windows 系统代理关闭),没有可强制走代理的对象。',
|
|
329
|
+
fix: '打开代理,或在「代理配置」里修复地址/模式。' }
|
|
330
|
+
: await forceChannelProbe(parsed, channel, proxyUrl)
|
|
331
|
+
return { url, route: routeName, bypassed, channel, probe }
|
|
332
|
+
}
|
|
333
|
+
if (channel === 'policy') {
|
|
334
|
+
const probe = await policyProbe(parsed)
|
|
335
|
+
return { url, route: routeName, bypassed, channel, probe }
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// 双通道并行:强制走代理 + 强制直连,各自独立探测(互不干扰)
|
|
339
|
+
const proxyUrl = snapshot?.effective?.url ?? ''
|
|
340
|
+
const proxyProbe = proxyUrl === ''
|
|
341
|
+
? { ok: false, verdict: 'unusable', kind: 'bad-proxy', short: '当前快照里没有可用的代理地址',
|
|
342
|
+
why: '代理开关处于关闭(或 Windows 系统代理关闭),没有可强制走代理的对象。',
|
|
343
|
+
fix: '打开代理,或在「代理配置」里修复地址/模式。' }
|
|
344
|
+
: await forceChannelProbe(parsed, 'proxy', proxyUrl)
|
|
345
|
+
const directProbe = await forceChannelProbe(parsed, 'direct', '')
|
|
346
|
+
const combined = combineDualProbe(proxyProbe, directProbe)
|
|
347
|
+
return {
|
|
348
|
+
url, route: routeName, bypassed, channel: 'dual',
|
|
349
|
+
proxy: proxyProbe, direct: directProbe,
|
|
350
|
+
verdict: combined.verdict, short: combined.short,
|
|
351
|
+
why: combined.why, fix: combined.fix,
|
|
352
|
+
proxyBadge: combined.proxyBadge, directBadge: combined.directBadge,
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// ---- browser-trust fence (mirrors dsh's /api fence) ------------------------
|
|
357
|
+
// dsh's own RPC channels run every request through isTrustedApiRequest
|
|
358
|
+
// (dsh-client-connection): Host must be loopback (DNS rebinding cannot forge
|
|
359
|
+
// Host), sec-fetch-site=cross-site is refused (browser CSRF marker), and an
|
|
360
|
+
// attached Origin must be same-host. Our panel API is registered directly on
|
|
361
|
+
// ctx.webServer, so we replicate that fence here — the same implementation
|
|
362
|
+
// dsh-skill-manager ships (its lib/index.js L131-172), matching the loopback
|
|
363
|
+
// verdict of the official fence with zero extra dependencies.
|
|
364
|
+
|
|
365
|
+
/** Normalized URL of a Host-header authority (hostname lowercased), or undefined. */
|
|
366
|
+
function parseHostHeader(host) {
|
|
367
|
+
if (typeof host !== 'string' || !host) return undefined
|
|
368
|
+
try {
|
|
369
|
+
return new URL(`http://${host}`).hostname.toLowerCase()
|
|
370
|
+
} catch {
|
|
371
|
+
return undefined
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** localhost | [::1] | any IPv4 in 127/8 — the loopback authority set. */
|
|
376
|
+
function isLoopbackHostname(hostname) {
|
|
377
|
+
if (hostname === 'localhost' || hostname === '[::1]') return true
|
|
378
|
+
const parts = hostname.split('.')
|
|
379
|
+
return parts.length === 4 && parts[0] === '127'
|
|
380
|
+
&& parts.every((p) => /^\d{1,3}$/.test(p) && Number(p) <= 255)
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** True when the Host is loopback and any attached browser markers are same-origin. */
|
|
384
|
+
function isTrustedPanelRequest(headers) {
|
|
385
|
+
const host = typeof headers?.get === 'function' ? headers.get('host') : headers?.host
|
|
386
|
+
const hostname = parseHostHeader(host)
|
|
387
|
+
if (!hostname || !isLoopbackHostname(hostname)) return false
|
|
388
|
+
if (headers?.get && headers.get('sec-fetch-site') === 'cross-site') return false
|
|
389
|
+
if (!headers?.get && headers?.['sec-fetch-site'] === 'cross-site') return false
|
|
390
|
+
const originRaw = typeof headers?.get === 'function' ? headers.get('origin') : headers?.origin
|
|
391
|
+
if (originRaw === undefined || originRaw === null || originRaw === '') return true
|
|
392
|
+
try {
|
|
393
|
+
return new URL(originRaw).hostname.toLowerCase() === hostname
|
|
394
|
+
} catch {
|
|
395
|
+
return false
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** Write 403 and return true when the request fails the fence (handler calls first). */
|
|
400
|
+
function denyIfUntrusted(req, res) {
|
|
401
|
+
if (isTrustedPanelRequest(req.headers)) return false
|
|
402
|
+
try {
|
|
403
|
+
res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' })
|
|
404
|
+
res.end('forbidden')
|
|
405
|
+
} catch {
|
|
406
|
+
/* client gone */
|
|
407
|
+
}
|
|
408
|
+
return true
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Cordis plugin body.
|
|
413
|
+
* @param ctx - registrant context.
|
|
414
|
+
* @param config - validated composition configuration.
|
|
415
|
+
*/
|
|
416
|
+
function apply(ctx, config) {
|
|
417
|
+
const systemProxyReader = makeSystemProxyReader(execFileAsync)
|
|
418
|
+
let source = () => config
|
|
419
|
+
let snapshot = undefined
|
|
420
|
+
let httpProxyPolicy = undefined
|
|
421
|
+
let syncing = null
|
|
422
|
+
let disposed = false
|
|
423
|
+
let haveApplied = false
|
|
424
|
+
let lastAppliedKey = undefined
|
|
425
|
+
let pollTimer = undefined
|
|
426
|
+
let lastSystemFacts = null
|
|
427
|
+
|
|
428
|
+
/** Read all inputs (config + system facts), apply, and record the snapshot. */
|
|
429
|
+
const sync = async () => {
|
|
430
|
+
if (disposed) return
|
|
431
|
+
const resolved = source()
|
|
432
|
+
let systemProxy = null
|
|
433
|
+
// System facts are only consulted in mode 'system': custom mode keeps the
|
|
434
|
+
// configured whitelist alone, so no registry read is needed there (§17.3f).
|
|
435
|
+
if (resolved.mode === 'system' && resolved.enabled === true) {
|
|
436
|
+
try {
|
|
437
|
+
systemProxy = await systemProxyReader()
|
|
438
|
+
lastSystemFacts = systemProxy
|
|
439
|
+
} catch (error) {
|
|
440
|
+
ctx.logger.warn('dsh-proxy-pro: reading the Windows system proxy failed: %s', String(error))
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
const effective = resolveProxyState(resolved, systemProxy)
|
|
444
|
+
const active = effective.active === true && effective.url !== null
|
|
445
|
+
// Idempotency (LESSONS §17): when the effective transport is unchanged we
|
|
446
|
+
// refresh the rich snapshot but MUST NOT rebuild env/dispatcher — sync is
|
|
447
|
+
// a rebuild only when the effective state actually changed. Pollers may
|
|
448
|
+
// call requestSync() freely; the cost of an unchanged pass is a snapshot.
|
|
449
|
+
// http/https are part of the key: a https-only change (same url) must
|
|
450
|
+
// still rebuild so HTTPS_PROXY actually switches (review fix, 2026-09-17).
|
|
451
|
+
const effKey = JSON.stringify([active, effective.url ?? null, effective.http ?? null, effective.https ?? null, effective.noProxy ?? null, effective.reason ?? 'unknown'])
|
|
452
|
+
if (lastAppliedKey !== undefined && lastAppliedKey === effKey) {
|
|
453
|
+
snapshot = { config: resolved, effective, systemProxy, at: new Date().toISOString() }
|
|
454
|
+
ctx.emit('dsh-proxy-pro/status', summarize(snapshot))
|
|
455
|
+
return
|
|
456
|
+
}
|
|
457
|
+
// Guard: while we have never applied anything, OFF stays a transport
|
|
458
|
+
// no-op so boot with the default switch never touches env/dispatcher
|
|
459
|
+
// (coexistence with dsh-plugin-proxy during migration, NFR-4).
|
|
460
|
+
if (!active && !haveApplied) {
|
|
461
|
+
snapshot = { config: resolved, effective, systemProxy, at: new Date().toISOString() }
|
|
462
|
+
ctx.emit('dsh-proxy-pro/status', summarize(snapshot))
|
|
463
|
+
return
|
|
464
|
+
}
|
|
465
|
+
if (active) haveApplied = true
|
|
466
|
+
// Single transport channel: installProxyFromEnvironment owns process.env,
|
|
467
|
+
// the global dispatcher, and the module-level policy that proxyRouteFor()
|
|
468
|
+
// reads. Uninstall the previous policy before (re)installing so the
|
|
469
|
+
// package's active/installed state never goes stale (LESSONS §4).
|
|
470
|
+
try {
|
|
471
|
+
if (httpProxyPolicy !== undefined) {
|
|
472
|
+
// Do NOT await the disposer: it restores dispatcher + env synchronously
|
|
473
|
+
// (setGlobalDispatcher / restoreEnv run before the first await inside),
|
|
474
|
+
// then awaits agent.close() — undici's graceful close drains every
|
|
475
|
+
// keep-alive connection, which with the whole desktop's traffic in
|
|
476
|
+
// flight can block this turn for seconds. Fire it, keep the install
|
|
477
|
+
// serial, and let the pool drain in the background (LESSONS §17.3b).
|
|
478
|
+
const dispose = httpProxyPolicy
|
|
479
|
+
httpProxyPolicy = undefined
|
|
480
|
+
void dispose().catch(() => {})
|
|
481
|
+
}
|
|
482
|
+
const envLike = makeEnvLike(effective)
|
|
483
|
+
httpProxyPolicy = await installProxyFromEnvironment(envLike, (m) => ctx.logger.warn('dsh-proxy-pro: dsh-http-proxy: %s', m))
|
|
484
|
+
lastAppliedKey = effKey
|
|
485
|
+
} catch (error) {
|
|
486
|
+
ctx.logger.warn('dsh-proxy-pro: applying the proxy policy failed: %s', String(error))
|
|
487
|
+
}
|
|
488
|
+
snapshot = { config: resolved, effective, systemProxy, at: new Date().toISOString() }
|
|
489
|
+
ctx.emit('dsh-proxy-pro/status', summarize(snapshot))
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/** Coalesced sync: concurrent triggers share one in-flight pass. */
|
|
493
|
+
const requestSync = () => {
|
|
494
|
+
if (disposed) return Promise.resolve()
|
|
495
|
+
if (syncing !== null) return syncing
|
|
496
|
+
syncing = sync().finally(() => { syncing = null })
|
|
497
|
+
return syncing
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// Configuration source — two settings generations (LESSONS §26, §33):
|
|
501
|
+
// - dsh <= 0.1.x: a per-plugin namespace registered through the settings
|
|
502
|
+
// service (register(ns, schema, {base}) → scope.get()/watch()).
|
|
503
|
+
// - dsh >= 0.2: register()/watch() are gone. The plugin's OWN composition
|
|
504
|
+
// entry IS the settings form (namespace = entry id, schema = this Config),
|
|
505
|
+
// writes go through settings.update(entryId, patch), the live values are
|
|
506
|
+
// the Config's volatile references, and Loader announces a volatile-only
|
|
507
|
+
// commit with `loader/volatile-update`.
|
|
508
|
+
const settingsService = ctx.get('settings')
|
|
509
|
+
const configEditor = ctx.get('configEditor')
|
|
510
|
+
const legacyRegister = typeof settingsService?.register === 'function'
|
|
511
|
+
const entryId = resolveEntryId(ctx, configEditor)
|
|
512
|
+
if (legacyRegister) {
|
|
513
|
+
const proxyScope = settingsService.register(PROXY_NS, Config, { base: config })
|
|
514
|
+
source = () => proxyScope.get()
|
|
515
|
+
proxyScope.watch(() => {
|
|
516
|
+
void requestSync().catch(() => {})
|
|
517
|
+
})
|
|
518
|
+
} else if (typeof settingsService?.update === 'function') {
|
|
519
|
+
// Documented volatile contract (cordis README, dsh-settings README, and the
|
|
520
|
+
// host Service/Event Inspect providers — LESSONS §36/§38): resolved
|
|
521
|
+
// `.volatile()` fields are `Volatile<T>` references read through `.get()`,
|
|
522
|
+
// and a volatile-only edit is committed into that same reference. The
|
|
523
|
+
// catalogued notification is `settings/document-updated(ns, revision)`; the
|
|
524
|
+
// loader also emits its internal `loader/volatile-update` on this fiber, so
|
|
525
|
+
// both are observed (sync is idempotent).
|
|
526
|
+
source = () => readProxyConfig(config)
|
|
527
|
+
ctx.on('settings/document-updated', (ns) => {
|
|
528
|
+
if (ns === entryId) void requestSync().catch(() => {})
|
|
529
|
+
})
|
|
530
|
+
ctx.on('loader/volatile-update', () => {
|
|
531
|
+
void requestSync().catch(() => {})
|
|
532
|
+
})
|
|
533
|
+
}
|
|
534
|
+
// We ship our own settings page (代理配置), so opt out of the auto-generated
|
|
535
|
+
// form — the documented pattern is an effect inside an injected child, so a
|
|
536
|
+
// late-loading Settings service still picks the policy up.
|
|
537
|
+
if (typeof ctx.inject === 'function') {
|
|
538
|
+
ctx.inject(['settings'], (child) => {
|
|
539
|
+
child.effect(() => {
|
|
540
|
+
try {
|
|
541
|
+
return child.settings.configure({ auto: false }, ctx.fiber)
|
|
542
|
+
} catch (error) {
|
|
543
|
+
ctx.logger.warn('dsh-proxy-pro: settings page policy not applied: %s', String(error))
|
|
544
|
+
return () => {}
|
|
545
|
+
}
|
|
546
|
+
}, 'dsh-proxy-pro: settings page policy')
|
|
547
|
+
})
|
|
548
|
+
}
|
|
549
|
+
// Without either generation the plugin still runs read-only on the
|
|
550
|
+
// composition config (source keeps returning it).
|
|
551
|
+
|
|
552
|
+
/** Whether this composition can persist a configuration write at all: a
|
|
553
|
+
* read-only composition must not offer enabled controls in the panel. */
|
|
554
|
+
const settingsWritable = settingsService !== undefined
|
|
555
|
+
&& (legacyRegister || typeof settingsService.update === 'function')
|
|
556
|
+
|
|
557
|
+
/** Persist one config patch through whichever settings generation is present. */
|
|
558
|
+
const writeSettings = async (patch) => {
|
|
559
|
+
if (!settingsWritable) {
|
|
560
|
+
throw new Error('the settings service is unavailable in this composition — change the proxy configuration in the composition instead')
|
|
561
|
+
}
|
|
562
|
+
await settingsService.update(legacyRegister ? PROXY_NS : entryId, patch)
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// ---------- Agent tools ----------
|
|
566
|
+
|
|
567
|
+
ctx.tools.register(defineTool({
|
|
568
|
+
name: 'proxy_status',
|
|
569
|
+
description: 'Show the current proxy state of this runtime: whether traffic goes through a proxy, the effective proxy address, the NO_PROXY bypass list, and the source of the setting. Call this before any network operation where the proxy route matters, and whenever a network operation fails unexpectedly.',
|
|
570
|
+
parameters: {},
|
|
571
|
+
output: {
|
|
572
|
+
schema: PROXY_STATUS_SCHEMA,
|
|
573
|
+
render: (_args, value) => {
|
|
574
|
+
const head = value.active
|
|
575
|
+
? `Proxy ON — ${value.url} (${value.source === 'system' ? 'system' : 'custom'})`
|
|
576
|
+
: `Proxy OFF — direct connection (${value.reason})`
|
|
577
|
+
const lines = [head, `NO_PROXY bypass: ${value.noProxy || '(none)'}`]
|
|
578
|
+
return [{ type: 'text', text: lines.join('\n') }]
|
|
579
|
+
},
|
|
580
|
+
},
|
|
581
|
+
execute: async () => {
|
|
582
|
+
await requestSync()
|
|
583
|
+
return summarize(snapshot)
|
|
584
|
+
},
|
|
585
|
+
presentCall: () => ({
|
|
586
|
+
card: 'generic',
|
|
587
|
+
title: 'Proxy status',
|
|
588
|
+
kind: 'other',
|
|
589
|
+
rawInput: {},
|
|
590
|
+
}),
|
|
591
|
+
}))
|
|
592
|
+
|
|
593
|
+
ctx.tools.register(defineTool({
|
|
594
|
+
name: 'proxy_set',
|
|
595
|
+
description: 'Turn the proxy on or off for the whole runtime. Use enabled=false when an operation must go DIRECT (an internal/intranet target, a local server, or a host the proxy would break), then re-enable with enabled=true afterwards. The change is persisted and takes effect immediately for model requests, web fetches, and spawned tools.',
|
|
596
|
+
parameters: PROXY_SET_PARAMETERS,
|
|
597
|
+
output: {
|
|
598
|
+
schema: PROXY_STATUS_SCHEMA,
|
|
599
|
+
render: (_args, value) => [{
|
|
600
|
+
type: 'text',
|
|
601
|
+
text: value.active
|
|
602
|
+
? `Proxy enabled — ${value.url} (${value.source === 'system' ? 'system' : 'custom'})`
|
|
603
|
+
: `Proxy disabled — traffic now goes direct.`,
|
|
604
|
+
}],
|
|
605
|
+
},
|
|
606
|
+
execute: async (args) => {
|
|
607
|
+
try {
|
|
608
|
+
await writeSettings({ enabled: args.enabled === true })
|
|
609
|
+
} catch (error) {
|
|
610
|
+
throw new Error(`proxy_set: ${error?.message ?? error}`)
|
|
611
|
+
}
|
|
612
|
+
await requestSync()
|
|
613
|
+
return summarize(snapshot)
|
|
614
|
+
},
|
|
615
|
+
presentCall: (args) => ({
|
|
616
|
+
card: 'generic',
|
|
617
|
+
title: `Proxy ${args.enabled === true ? 'on' : 'off'}`,
|
|
618
|
+
kind: 'other',
|
|
619
|
+
rawInput: args,
|
|
620
|
+
}),
|
|
621
|
+
}))
|
|
622
|
+
|
|
623
|
+
ctx.tools.register(defineTool({
|
|
624
|
+
name: 'proxy_test',
|
|
625
|
+
description: 'Diagnose whether a target URL will route through the proxy or direct, and probe actual connectivity. Use when a fetch/git/curl to a host fails unexpectedly: it reports the route web_fetch will take (PROXIED vs DIRECT), whether the host is bypassed by NO_PROXY, a live connectivity probe, and an NTLM keepalive hint when the proxy asks for authentication.',
|
|
626
|
+
parameters: PROXY_TEST_PARAMETERS,
|
|
627
|
+
output: {
|
|
628
|
+
schema: PROXY_TEST_SCHEMA,
|
|
629
|
+
render: (_args, value) => {
|
|
630
|
+
const badge = (b) => (b?.tone === 'ok' ? '🟢' : b?.tone === 'warn' ? '🟡' : '🔴') + ' ' + (b?.text ?? '未知')
|
|
631
|
+
const lines = [
|
|
632
|
+
`路由: ${value.route}`,
|
|
633
|
+
`NO_PROXY 命中: ${value.bypassed}`,
|
|
634
|
+
`状态码: 走代理 ${value.proxy?.status ?? '—'} · 直连 ${value.direct?.status ?? '—'}`,
|
|
635
|
+
`走代理: ${badge(value.proxyBadge)}`,
|
|
636
|
+
`直连: ${badge(value.directBadge)}`,
|
|
637
|
+
`结论: ${value.short ?? '未知'}`,
|
|
638
|
+
]
|
|
639
|
+
if (value.why) lines.push(`可能原因: ${value.why}`)
|
|
640
|
+
if (value.fix) lines.push(`挽救措施: ${value.fix}`)
|
|
641
|
+
if (value.hint) lines.push(`提示: ${value.hint}`)
|
|
642
|
+
return [{ type: 'text', text: lines.join('\n') }]
|
|
643
|
+
},
|
|
644
|
+
},
|
|
645
|
+
execute: async (args) => {
|
|
646
|
+
await requestSync()
|
|
647
|
+
const result = await probeTarget(String(args.url ?? ''), snapshot)
|
|
648
|
+
result.hint = keepaliveHint(`${result.proxy?.short ?? ''} ${result.direct?.short ?? ''}`, snapshot?.effective?.url ?? '')
|
|
649
|
+
return result
|
|
650
|
+
},
|
|
651
|
+
presentCall: (args) => ({
|
|
652
|
+
card: 'generic',
|
|
653
|
+
title: 'Proxy test',
|
|
654
|
+
kind: 'other',
|
|
655
|
+
rawInput: args,
|
|
656
|
+
}),
|
|
657
|
+
}))
|
|
658
|
+
|
|
659
|
+
ctx.tools.register(defineTool({
|
|
660
|
+
name: 'proxy_config',
|
|
661
|
+
description: 'Read or change the proxy configuration: the switch (enabled), the source mode (system / custom / none), the custom proxy address, and the NO_PROXY direct-connection list. Call it with no arguments to read the current configuration, or with any subset of fields to change them. Changes are persisted and take effect immediately for model requests, web fetches, and spawned tools. Use `proxy_set` for a plain on/off toggle, and `proxy_test` to check how one URL routes.',
|
|
662
|
+
parameters: PROXY_CONFIG_PARAMETERS,
|
|
663
|
+
output: {
|
|
664
|
+
schema: PROXY_STATUS_SCHEMA,
|
|
665
|
+
render: (_args, value) => [{
|
|
666
|
+
type: 'text',
|
|
667
|
+
text: value.active
|
|
668
|
+
? `Proxy config — mode=${value.mode}, ${value.url} (${value.source === 'system' ? 'system' : 'custom'}), bypass: ${value.noProxy || '(none)'}`
|
|
669
|
+
: `Proxy config — inactive (${value.reason}); mode=${value.mode}, bypass: ${value.noProxy || '(none)'}`,
|
|
670
|
+
}],
|
|
671
|
+
},
|
|
672
|
+
execute: async (args) => {
|
|
673
|
+
const patch = {}
|
|
674
|
+
if (args.enabled !== undefined) patch.enabled = args.enabled === true
|
|
675
|
+
if (args.mode !== undefined) patch.mode = args.mode
|
|
676
|
+
if (args.customUrl !== undefined) {
|
|
677
|
+
const raw = String(args.customUrl).trim()
|
|
678
|
+
if (raw === '') {
|
|
679
|
+
patch.customUrl = ''
|
|
680
|
+
} else {
|
|
681
|
+
const normalized = normalizeProxyUrl(raw)
|
|
682
|
+
if (normalized === null) {
|
|
683
|
+
throw new Error(`proxy_config: "${args.customUrl}" is not a usable proxy address — expected http(s)://host:port (a bare host:port is also accepted)`)
|
|
684
|
+
}
|
|
685
|
+
patch.customUrl = normalized
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
if (args.noProxy !== undefined) patch.noProxy = String(args.noProxy).trim()
|
|
689
|
+
if (Object.keys(patch).length > 0) await writeSettings(patch)
|
|
690
|
+
await requestSync()
|
|
691
|
+
return summarize(snapshot)
|
|
692
|
+
},
|
|
693
|
+
presentCall: (args) => ({
|
|
694
|
+
card: 'generic',
|
|
695
|
+
title: 'Proxy config',
|
|
696
|
+
kind: 'other',
|
|
697
|
+
rawInput: args,
|
|
698
|
+
}),
|
|
699
|
+
}))
|
|
700
|
+
|
|
701
|
+
// The model must always know whether the proxy is in force.
|
|
702
|
+
ctx.systemPrompt.section({
|
|
703
|
+
name: 'dsh-proxy-pro:status',
|
|
704
|
+
order: SECTION_ORDER,
|
|
705
|
+
text: () => renderStatusText(snapshot),
|
|
706
|
+
})
|
|
707
|
+
|
|
708
|
+
// ---------- Status HTTP API (for the client UI) ----------
|
|
709
|
+
const sendJson = (res, status, body) => {
|
|
710
|
+
try {
|
|
711
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
|
|
712
|
+
res.end(JSON.stringify(body))
|
|
713
|
+
} catch {
|
|
714
|
+
/* client gone */
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
const readJsonBody = async (req, maxBytes = 2 * 1024 * 1024) => {
|
|
718
|
+
const chunks = []
|
|
719
|
+
let size = 0
|
|
720
|
+
for await (const chunk of req) {
|
|
721
|
+
size += chunk.length
|
|
722
|
+
if (size > maxBytes) throw new Error('request body too large')
|
|
723
|
+
chunks.push(chunk)
|
|
724
|
+
}
|
|
725
|
+
const text = Buffer.concat(chunks).toString('utf8')
|
|
726
|
+
if (!text) return {}
|
|
727
|
+
try {
|
|
728
|
+
return JSON.parse(text)
|
|
729
|
+
} catch {
|
|
730
|
+
return {}
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/**
|
|
735
|
+
* Register the status/config HTTP API on the web server.
|
|
736
|
+
*
|
|
737
|
+
* dsh 0.2 mounts `webServer` AFTER plugin rows have applied, so reading
|
|
738
|
+
* `ctx.get('webServer')` once inside apply() returned undefined and this
|
|
739
|
+
* whole block was skipped silently — the panel then reported "宿主未提供
|
|
740
|
+
* 配置接口" while everything else looked healthy (LESSONS §35). The 0.2
|
|
741
|
+
* idiom is `ctx.inject([...], (child) => …)`: it runs as soon as the service
|
|
742
|
+
* exists (immediately when it already does). The direct read stays as the
|
|
743
|
+
* fallback for older cores.
|
|
744
|
+
*/
|
|
745
|
+
let apiRegistered = false
|
|
746
|
+
const registerApi = (webServer) => {
|
|
747
|
+
if (apiRegistered || webServer === undefined || typeof webServer.register !== 'function') return false
|
|
748
|
+
try {
|
|
749
|
+
const unregisterRoute = webServer.register({
|
|
750
|
+
kind: 'prefix',
|
|
751
|
+
path: ROUTE_BASE,
|
|
752
|
+
handler: async (req, res) => {
|
|
753
|
+
if (denyIfUntrusted(req, res)) return
|
|
754
|
+
const url = new URL(req.url ?? '/', 'http://localhost')
|
|
755
|
+
const is = (p, m) => url.pathname === `${ROUTE_BASE}${p}` && req.method === m
|
|
756
|
+
try {
|
|
757
|
+
if (is('/status', 'GET')) {
|
|
758
|
+
// Pull-triggered refresh: requestSync is idempotent — when the
|
|
759
|
+
// effKey is unchanged it only refreshes the snapshot (no
|
|
760
|
+
// dispatcher/env rebuild); only a real system-proxy change
|
|
761
|
+
// reinstalls. So refreshing here is cheap and every pull serves
|
|
762
|
+
// fresh facts. This is safe where the pre-096e431 /status was
|
|
763
|
+
// not: that one reinstalled UNCONDITIONALLY on every pull and
|
|
764
|
+
// dragged the desktop down (LESSONS §17). The 30s host poll
|
|
765
|
+
// still owns unattended following (page-closed changes); this
|
|
766
|
+
// exists only to serve the viewer.
|
|
767
|
+
await requestSync()
|
|
768
|
+
sendJson(res, 200, { ok: true, status: summarize(snapshot) })
|
|
769
|
+
return
|
|
770
|
+
}
|
|
771
|
+
if (is('/route', 'GET')) {
|
|
772
|
+
const target = url.searchParams.get('url') ?? ''
|
|
773
|
+
// useProxy 保留兼容:'1'/'0' 走旧的单通道强制;无参数(新版面板)走双通道
|
|
774
|
+
const useProxy = url.searchParams.get('useProxy')
|
|
775
|
+
const channel = useProxy === '1' ? 'proxy' : useProxy === '0' ? 'direct' : 'dual'
|
|
776
|
+
const result = await probeTarget(target, snapshot, channel)
|
|
777
|
+
result.hint = keepaliveHint(
|
|
778
|
+
`${result.proxy?.short ?? ''} ${result.direct?.short ?? ''}`.trim(),
|
|
779
|
+
snapshot?.effective?.url ?? ''
|
|
780
|
+
)
|
|
781
|
+
sendJson(res, 200, { ok: true, ...result })
|
|
782
|
+
return
|
|
783
|
+
}
|
|
784
|
+
if (is('/toggle', 'POST')) {
|
|
785
|
+
const body = await readJsonBody(req)
|
|
786
|
+
try {
|
|
787
|
+
await writeSettings({ enabled: body.enabled === true })
|
|
788
|
+
} catch (error) {
|
|
789
|
+
sendJson(res, 503, { ok: false, error: String(error?.message ?? error) })
|
|
790
|
+
return
|
|
791
|
+
}
|
|
792
|
+
await requestSync()
|
|
793
|
+
sendJson(res, 200, { ok: true, status: summarize(snapshot) })
|
|
794
|
+
return
|
|
795
|
+
}
|
|
796
|
+
// Editable configuration for the client panel. dsh >= 0.2 removed
|
|
797
|
+
// the browser settings transport (`ctx.settingsScope`), so the panel
|
|
798
|
+
// reads and writes through this API instead (LESSONS §33).
|
|
799
|
+
if (is('/config', 'GET')) {
|
|
800
|
+
sendJson(res, 200, { ok: true, ...configPayload(source(), settingsWritable) })
|
|
801
|
+
return
|
|
802
|
+
}
|
|
803
|
+
if (is('/config', 'POST')) {
|
|
804
|
+
const body = await readJsonBody(req)
|
|
805
|
+
const patch = {}
|
|
806
|
+
if (body.enabled !== undefined) patch.enabled = body.enabled === true
|
|
807
|
+
if (body.mode !== undefined) {
|
|
808
|
+
if (body.mode !== 'system' && body.mode !== 'custom') {
|
|
809
|
+
sendJson(res, 400, { ok: false, error: 'mode must be "system" or "custom"' })
|
|
810
|
+
return
|
|
811
|
+
}
|
|
812
|
+
patch.mode = body.mode
|
|
813
|
+
}
|
|
814
|
+
if (body.customUrl !== undefined) {
|
|
815
|
+
const raw = String(body.customUrl).trim()
|
|
816
|
+
if (raw === '') {
|
|
817
|
+
patch.customUrl = ''
|
|
818
|
+
} else {
|
|
819
|
+
const normalized = normalizeProxyUrl(raw)
|
|
820
|
+
if (normalized === null) {
|
|
821
|
+
sendJson(res, 400, { ok: false, error: `"${body.customUrl}" is not a usable proxy address — expected http(s)://host:port` })
|
|
822
|
+
return
|
|
823
|
+
}
|
|
824
|
+
patch.customUrl = normalized
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
if (body.noProxy !== undefined) patch.noProxy = String(body.noProxy).trim()
|
|
828
|
+
try {
|
|
829
|
+
if (Object.keys(patch).length > 0) await writeSettings(patch)
|
|
830
|
+
} catch (error) {
|
|
831
|
+
sendJson(res, 503, { ok: false, error: String(error?.message ?? error) })
|
|
832
|
+
return
|
|
833
|
+
}
|
|
834
|
+
await requestSync()
|
|
835
|
+
sendJson(res, 200, { ok: true, ...configPayload(source(), settingsWritable) })
|
|
836
|
+
return
|
|
837
|
+
}
|
|
838
|
+
sendJson(res, 404, { ok: false, error: 'not found' })
|
|
839
|
+
} catch (error) {
|
|
840
|
+
sendJson(res, 500, { ok: false, error: String(error?.message ?? error) })
|
|
841
|
+
}
|
|
842
|
+
},
|
|
843
|
+
})
|
|
844
|
+
ctx.effect(() => () => { try { unregisterRoute?.() } catch { /* best effort */ } }, 'dsh-proxy-pro: route cleanup')
|
|
845
|
+
apiRegistered = true
|
|
846
|
+
ctx.logger.info('dsh-proxy-pro: status API registered on %s', ROUTE_BASE)
|
|
847
|
+
return true
|
|
848
|
+
} catch (error) {
|
|
849
|
+
ctx.logger.warn('dsh-proxy-pro: webServer unavailable — status API disabled: %s', String(error))
|
|
850
|
+
return false
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
// Late-bound registration (dsh 0.2 mounts webServer after plugin rows).
|
|
855
|
+
if (typeof ctx.inject === 'function') {
|
|
856
|
+
ctx.inject(['webServer'], (childCtx) => { registerApi(childCtx.webServer) })
|
|
857
|
+
}
|
|
858
|
+
// Older cores where the service is already present at apply time.
|
|
859
|
+
registerApi(ctx.get('webServer'))
|
|
860
|
+
|
|
861
|
+
// Follow the Windows system proxy at runtime (change-only re-apply).
|
|
862
|
+
if (config.systemPollMs > 0) {
|
|
863
|
+
pollTimer = setInterval(async () => {
|
|
864
|
+
if (disposed) return
|
|
865
|
+
const resolved = source()
|
|
866
|
+
if (resolved.mode !== 'system' || resolved.enabled !== true) return
|
|
867
|
+
let facts
|
|
868
|
+
try {
|
|
869
|
+
facts = await systemProxyReader()
|
|
870
|
+
} catch {
|
|
871
|
+
return
|
|
872
|
+
}
|
|
873
|
+
if (lastSystemFacts !== null && systemFactsEqual(lastSystemFacts, facts)) return
|
|
874
|
+
void requestSync().catch(() => {})
|
|
875
|
+
}, config.systemPollMs)
|
|
876
|
+
pollTimer.unref?.()
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
// Tear down: restore env, dispatcher, and dsh-http-proxy policy. If a sync
|
|
880
|
+
// is still in flight (e.g. a status-API request), uninstall chained AFTER it
|
|
881
|
+
// — otherwise a late install would leak a policy that teardown already
|
|
882
|
+
// skipped (review fix, 2026-09-17).
|
|
883
|
+
ctx.effect(() => {
|
|
884
|
+
return () => {
|
|
885
|
+
disposed = true
|
|
886
|
+
if (pollTimer !== undefined) clearInterval(pollTimer)
|
|
887
|
+
const detach = () => {
|
|
888
|
+
if (httpProxyPolicy === undefined) return
|
|
889
|
+
const policy = httpProxyPolicy
|
|
890
|
+
httpProxyPolicy = undefined
|
|
891
|
+
void Promise.resolve(policy()).catch(() => {})
|
|
892
|
+
}
|
|
893
|
+
if (syncing !== null) {
|
|
894
|
+
void Promise.resolve(syncing).catch(() => {}).finally(detach)
|
|
895
|
+
} else {
|
|
896
|
+
detach()
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
}, 'dsh-proxy-pro: cleanup')
|
|
900
|
+
}
|
|
901
|
+
|
|
832
902
|
export { Config, apply, inject, name }
|