dsh-crate-web 0.1.0 → 0.1.2-preview.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +11 -290
- package/README.zh-CN.md +2 -303
- package/THIRD-PARTY-LICENSE.txt +21 -0
- package/cordis.patch.yml +1 -6
- package/core/dsh_pack/_version.py +1 -0
- package/core/dsh_pack/bundle.py +74 -0
- package/core/dsh_pack/cli.py +81 -18
- package/core/dsh_pack/desktop_verify.py +304 -0
- package/core/dsh_pack/diagnostics.py +1170 -0
- package/core/dsh_pack/errors.py +16 -1
- package/core/dsh_pack/export.py +86 -103
- package/core/dsh_pack/export_plan.py +96 -0
- package/core/dsh_pack/identity.py +25 -0
- package/core/dsh_pack/importer.py +1598 -1347
- package/core/dsh_pack/network.py +5 -4
- package/core/dsh_pack/pack.py +14 -2
- package/core/dsh_pack/portability.py +392 -0
- package/core/dsh_pack/preflight.py +63 -16
- package/core/dsh_pack/troubleshooting.py +94 -0
- package/core/dsh_pack/user_cli.py +16 -0
- package/core/dsh_pack/validation.py +64 -4
- package/core/dsh_pack/verify.py +1485 -1336
- package/core/dsh_pack/windows_verify.py +177 -0
- package/desktop-patch-helper.mjs +140 -0
- package/lib/client.js +292 -102
- package/lib/index.d.ts +7 -1
- package/lib/index.js +1121 -229
- package/package.json +13 -5
- package/restart-helper.mjs +121 -121
- package/runtime/python/LICENSE.txt +676 -676
- package/runtime/python/__install__.json +81 -81
- package/runtime/python/python314._pth +6 -6
- package/skills/dsh-crate-troubleshooting/SKILL.md +172 -0
- package/skills/dsh-crate-troubleshooting/compatibility.json +11 -0
- package/skills/dsh-crate-troubleshooting/reference/diagnostics.generated.md +96 -0
- package/skills/dsh-crate-troubleshooting/reference/repair-boundaries.md +60 -0
- package/skills/dsh-crate-troubleshooting/reference/stages.generated.md +22 -0
- package/verify-desktop.mjs +314 -0
package/core/dsh_pack/verify.py
CHANGED
|
@@ -1,1336 +1,1485 @@
|
|
|
1
|
-
"""Phase 5 verification for a prepared DSH Profile.
|
|
2
|
-
|
|
3
|
-
The core owns composition checks, status aggregation, evidence shape, and the
|
|
4
|
-
plugin smoke-test contract. Runtime execution is deliberately injected via a
|
|
5
|
-
``VerifyAdapter`` so this module never guesses a DSH command, health endpoint,
|
|
6
|
-
or model interaction. Without an adapter, runtime checks are UNTESTED.
|
|
7
|
-
"""
|
|
8
|
-
|
|
9
|
-
from __future__ import annotations
|
|
10
|
-
|
|
11
|
-
import json
|
|
12
|
-
import
|
|
13
|
-
import
|
|
14
|
-
import
|
|
15
|
-
import
|
|
16
|
-
import
|
|
17
|
-
import
|
|
18
|
-
import
|
|
19
|
-
import
|
|
20
|
-
import
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
from
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
""
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
"
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
"
|
|
156
|
-
"
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
return None,
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
if
|
|
222
|
-
return None, "
|
|
223
|
-
return
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
def
|
|
227
|
-
""
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
""
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
if
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
"
|
|
436
|
-
"
|
|
437
|
-
"
|
|
438
|
-
"
|
|
439
|
-
"
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
"
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
"
|
|
484
|
-
"
|
|
485
|
-
"
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
if
|
|
564
|
-
return "
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
"
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
)
|
|
631
|
-
if
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
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
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
"boot",
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
)
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
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
|
-
|
|
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
|
-
evidence=evidence,
|
|
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
|
-
def
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
if
|
|
1244
|
-
return
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
return
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
)
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
"
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
if
|
|
1290
|
-
return
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
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
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1
|
+
"""Phase 5 verification for a prepared DSH Profile.
|
|
2
|
+
|
|
3
|
+
The core owns composition checks, status aggregation, evidence shape, and the
|
|
4
|
+
plugin smoke-test contract. Runtime execution is deliberately injected via a
|
|
5
|
+
``VerifyAdapter`` so this module never guesses a DSH command, health endpoint,
|
|
6
|
+
or model interaction. Without an adapter, runtime checks are UNTESTED.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import errno
|
|
13
|
+
import ipaddress
|
|
14
|
+
import os
|
|
15
|
+
import posixpath
|
|
16
|
+
import re
|
|
17
|
+
import socket
|
|
18
|
+
import subprocess
|
|
19
|
+
import threading
|
|
20
|
+
import time
|
|
21
|
+
import urllib.error
|
|
22
|
+
import urllib.parse
|
|
23
|
+
import urllib.request
|
|
24
|
+
from .errors import _default_checks
|
|
25
|
+
from .errors import DshPackError
|
|
26
|
+
from .bundle import check_patch_declaration, read_bundle_patches
|
|
27
|
+
from .identity import expected_install_version
|
|
28
|
+
from .pack import sha256_bytes
|
|
29
|
+
from .preflight import _artifact_package_identity
|
|
30
|
+
from dataclasses import dataclass, field, replace
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import Any, Mapping, Protocol, Sequence
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
VERIFY_STATUSES = ("PASS", "FAIL", "UNTESTED", "DEGRADED")
|
|
36
|
+
RUNTIME_STEPS = (
|
|
37
|
+
("boot", "DSH boot"),
|
|
38
|
+
("surface_ready", "Web/headless ready"),
|
|
39
|
+
("new_session", "new session"),
|
|
40
|
+
("basic_response", "basic response"),
|
|
41
|
+
("core_tool", "core tool"),
|
|
42
|
+
("restart", "restart"),
|
|
43
|
+
("restart_surface_ready", "verify after restart"),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class SmokeTestContract:
|
|
49
|
+
"""Explicit plugin smoke tests; no test means UNTESTED, never PASS."""
|
|
50
|
+
|
|
51
|
+
plugin: str
|
|
52
|
+
tests: tuple[str, ...]
|
|
53
|
+
required: bool = True
|
|
54
|
+
|
|
55
|
+
@classmethod
|
|
56
|
+
def from_mapping(cls, value: Mapping[str, Any]) -> "SmokeTestContract":
|
|
57
|
+
plugin = value.get("plugin")
|
|
58
|
+
tests = value.get("tests")
|
|
59
|
+
required = value.get("required", True)
|
|
60
|
+
if not isinstance(plugin, str) or not plugin.strip():
|
|
61
|
+
raise ValueError("plugin smoke contract requires a non-empty plugin")
|
|
62
|
+
if not isinstance(tests, list) or any(not isinstance(item, str) or not item.strip() for item in tests):
|
|
63
|
+
raise ValueError(f"plugin smoke contract tests must be a list of names: {plugin}")
|
|
64
|
+
if not isinstance(required, bool):
|
|
65
|
+
raise ValueError(f"plugin smoke contract required must be boolean: {plugin}")
|
|
66
|
+
return cls(plugin=plugin, tests=tuple(tests), required=required)
|
|
67
|
+
|
|
68
|
+
def as_dict(self) -> dict[str, Any]:
|
|
69
|
+
return {"plugin": self.plugin, "required": self.required, "tests": list(self.tests)}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True)
|
|
73
|
+
class VerifyStep:
|
|
74
|
+
name: str
|
|
75
|
+
status: str
|
|
76
|
+
message: str
|
|
77
|
+
required: bool = True
|
|
78
|
+
evidence: dict[str, Any] = field(default_factory=dict)
|
|
79
|
+
diagnostic: dict[str, Any] | None = None
|
|
80
|
+
|
|
81
|
+
def __post_init__(self) -> None:
|
|
82
|
+
if self.status not in VERIFY_STATUSES:
|
|
83
|
+
raise ValueError(f"unsupported verify status: {self.status}")
|
|
84
|
+
|
|
85
|
+
def as_dict(self) -> dict[str, Any]:
|
|
86
|
+
value: dict[str, Any] = {
|
|
87
|
+
"name": self.name,
|
|
88
|
+
"status": self.status,
|
|
89
|
+
"message": self.message,
|
|
90
|
+
"required": self.required,
|
|
91
|
+
"evidence": self.evidence,
|
|
92
|
+
}
|
|
93
|
+
if self.diagnostic is not None:
|
|
94
|
+
value["diagnostic"] = self.diagnostic
|
|
95
|
+
return value
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass(frozen=True)
|
|
99
|
+
class VerifyContext:
|
|
100
|
+
dsh_home: Path
|
|
101
|
+
profile_name: str
|
|
102
|
+
profile_path: Path
|
|
103
|
+
mode: str
|
|
104
|
+
current_environment: Mapping[str, Any]
|
|
105
|
+
available_secret_names: frozenset[str]
|
|
106
|
+
metadata_path: Path
|
|
107
|
+
previous_steps: Mapping[str, VerifyStep]
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class VerifyAdapter(Protocol):
|
|
111
|
+
"""Runtime adapter contract used by ``verify_profile``.
|
|
112
|
+
|
|
113
|
+
``run_step`` must perform the requested real action and return either a
|
|
114
|
+
``VerifyStep`` or a mapping with ``status``, ``message`` and ``evidence``.
|
|
115
|
+
``run_plugin_test`` is optional; if absent, plugin tests are UNTESTED.
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
def run_step(self, step: str, context: VerifyContext) -> VerifyStep | Mapping[str, Any]:
|
|
119
|
+
...
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _step(
|
|
123
|
+
name: str,
|
|
124
|
+
status: str,
|
|
125
|
+
message: str,
|
|
126
|
+
*,
|
|
127
|
+
required: bool = True,
|
|
128
|
+
evidence: Mapping[str, Any] | None = None,
|
|
129
|
+
diagnostic: Mapping[str, Any] | None = None,
|
|
130
|
+
) -> VerifyStep:
|
|
131
|
+
return VerifyStep(
|
|
132
|
+
name=name,
|
|
133
|
+
status=status,
|
|
134
|
+
message=message,
|
|
135
|
+
required=required,
|
|
136
|
+
evidence=dict(evidence or {}),
|
|
137
|
+
diagnostic=dict(diagnostic) if diagnostic else None,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _runtime_diagnostic(
|
|
142
|
+
*,
|
|
143
|
+
code: str,
|
|
144
|
+
stage: str,
|
|
145
|
+
message: str,
|
|
146
|
+
evidence: Mapping[str, Any],
|
|
147
|
+
item: str,
|
|
148
|
+
expected: str,
|
|
149
|
+
impact: str,
|
|
150
|
+
can_continue: bool = False,
|
|
151
|
+
) -> dict[str, Any]:
|
|
152
|
+
"""Structured diagnostic attached to a failed runtime VerifyStep."""
|
|
153
|
+
from .diagnostics import envelope_fields
|
|
154
|
+
return {
|
|
155
|
+
**envelope_fields(operation="verify", status="FAIL"),
|
|
156
|
+
"code": code,
|
|
157
|
+
"stage": stage,
|
|
158
|
+
"severity": "BLOCKER",
|
|
159
|
+
"item": item,
|
|
160
|
+
"expected": expected,
|
|
161
|
+
"observed": {"message": message, "code": code},
|
|
162
|
+
"evidence": dict(evidence),
|
|
163
|
+
"impact": impact,
|
|
164
|
+
"canContinue": can_continue,
|
|
165
|
+
"suggestedChecks": _default_checks(stage, code),
|
|
166
|
+
"message": message,
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _normalise_observation(name: str, value: VerifyStep | Mapping[str, Any], *, required: bool) -> VerifyStep:
|
|
171
|
+
if isinstance(value, VerifyStep):
|
|
172
|
+
# 步骤身份和必测要求由 Core 决定;适配器标签不能绕过
|
|
173
|
+
# 前置失败,也不能弱化最终状态。保留适配器的原始证据。
|
|
174
|
+
return VerifyStep(
|
|
175
|
+
name=name,
|
|
176
|
+
status=value.status,
|
|
177
|
+
message=value.message,
|
|
178
|
+
required=required,
|
|
179
|
+
evidence=value.evidence,
|
|
180
|
+
diagnostic=value.diagnostic,
|
|
181
|
+
)
|
|
182
|
+
if not isinstance(value, Mapping):
|
|
183
|
+
return _step(
|
|
184
|
+
name,
|
|
185
|
+
"FAIL",
|
|
186
|
+
"Verify adapter returned an invalid observation",
|
|
187
|
+
required=required,
|
|
188
|
+
evidence={"observedType": type(value).__name__},
|
|
189
|
+
)
|
|
190
|
+
status = value.get("status")
|
|
191
|
+
if status not in VERIFY_STATUSES:
|
|
192
|
+
return _step(
|
|
193
|
+
name,
|
|
194
|
+
"FAIL",
|
|
195
|
+
"Verify adapter returned an unsupported status",
|
|
196
|
+
required=required,
|
|
197
|
+
evidence={"observed": dict(value)},
|
|
198
|
+
)
|
|
199
|
+
return _step(
|
|
200
|
+
name,
|
|
201
|
+
status,
|
|
202
|
+
str(value.get("message", name)),
|
|
203
|
+
required=required,
|
|
204
|
+
evidence=value.get("evidence") if isinstance(value.get("evidence"), Mapping) else {},
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _json_object(path: Path) -> tuple[dict[str, Any] | None, str | None]:
|
|
209
|
+
try:
|
|
210
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
211
|
+
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as error:
|
|
212
|
+
return None, str(error)
|
|
213
|
+
if not isinstance(value, dict):
|
|
214
|
+
return None, "JSON root must be an object"
|
|
215
|
+
return value, None
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _package_manifest(profile_path: Path, name: str) -> tuple[dict[str, Any] | None, str | None]:
|
|
219
|
+
package_path = profile_path / "node_modules" / Path(*name.split("/")) / "package.json"
|
|
220
|
+
value, error = _json_object(package_path)
|
|
221
|
+
if error:
|
|
222
|
+
return None, f"{package_path}: {error}"
|
|
223
|
+
return value, None
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _profile_bundles(profile_manifest: Mapping[str, Any]) -> tuple[list[str] | None, str | None]:
|
|
227
|
+
dsh = profile_manifest.get("dsh", {})
|
|
228
|
+
if dsh is None:
|
|
229
|
+
dsh = {}
|
|
230
|
+
if not isinstance(dsh, Mapping):
|
|
231
|
+
return None, "profile package.json dsh must be an object"
|
|
232
|
+
profile = dsh.get("profile", {})
|
|
233
|
+
if profile is None:
|
|
234
|
+
profile = {}
|
|
235
|
+
if not isinstance(profile, Mapping):
|
|
236
|
+
return None, "profile package.json dsh.profile must be an object"
|
|
237
|
+
bundles = profile.get("bundles", [])
|
|
238
|
+
if not isinstance(bundles, list) or any(not isinstance(name, str) or not name for name in bundles):
|
|
239
|
+
return None, "profile package.json dsh.profile.bundles must be a list of names"
|
|
240
|
+
if len(set(bundles)) != len(bundles):
|
|
241
|
+
return None, "profile package.json dsh.profile.bundles contains duplicates"
|
|
242
|
+
return list(bundles), None
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _composition_step(options: "VerifyOptions") -> VerifyStep:
|
|
246
|
+
profile_path = options.dsh_home / "profiles" / options.profile_name
|
|
247
|
+
metadata_path = options.dsh_home / ".dsh-pack" / "imports" / options.profile_name
|
|
248
|
+
package_path = profile_path / "package.json"
|
|
249
|
+
profile_manifest, profile_error = _json_object(package_path)
|
|
250
|
+
if profile_error or profile_manifest is None:
|
|
251
|
+
return _step(
|
|
252
|
+
"composition",
|
|
253
|
+
"FAIL",
|
|
254
|
+
"prepared Profile package.json cannot be read",
|
|
255
|
+
evidence={"path": str(package_path), "error": profile_error},
|
|
256
|
+
)
|
|
257
|
+
bundles, bundle_error = _profile_bundles(profile_manifest)
|
|
258
|
+
if bundle_error or bundles is None:
|
|
259
|
+
return _step("composition", "FAIL", bundle_error or "invalid Bundle composition", evidence={"path": str(package_path)})
|
|
260
|
+
|
|
261
|
+
metadata_manifest, manifest_error = _json_object(metadata_path / "manifest.json")
|
|
262
|
+
lock, lock_error = _json_object(metadata_path / "plugins.lock.json")
|
|
263
|
+
prepared, prepared_error = _json_object(metadata_path / "prepared.json")
|
|
264
|
+
if manifest_error or lock_error or prepared_error or metadata_manifest is None or lock is None or prepared is None:
|
|
265
|
+
return _step(
|
|
266
|
+
"composition",
|
|
267
|
+
"FAIL",
|
|
268
|
+
"prepared Import metadata is incomplete",
|
|
269
|
+
evidence={
|
|
270
|
+
"metadataPath": str(metadata_path),
|
|
271
|
+
"manifestError": manifest_error,
|
|
272
|
+
"lockError": lock_error,
|
|
273
|
+
"preparedError": prepared_error,
|
|
274
|
+
},
|
|
275
|
+
)
|
|
276
|
+
if prepared.get("status") != "prepared":
|
|
277
|
+
return _step(
|
|
278
|
+
"composition",
|
|
279
|
+
"FAIL",
|
|
280
|
+
"Import metadata is not marked prepared",
|
|
281
|
+
evidence={"metadataPath": str(metadata_path), "preparedStatus": prepared.get("status")},
|
|
282
|
+
)
|
|
283
|
+
raw_plugins = lock.get("plugins")
|
|
284
|
+
if not isinstance(raw_plugins, list) or any(not isinstance(plugin, Mapping) for plugin in raw_plugins):
|
|
285
|
+
return _step("composition", "FAIL", "plugins.lock.json plugins is invalid", evidence={"path": str(metadata_path / "plugins.lock.json")})
|
|
286
|
+
|
|
287
|
+
dependencies = profile_manifest.get("dependencies", {})
|
|
288
|
+
if not isinstance(dependencies, Mapping):
|
|
289
|
+
return _step("composition", "FAIL", "Profile dependencies is not an object", evidence={"path": str(package_path)})
|
|
290
|
+
|
|
291
|
+
expected_bundles: list[str] = []
|
|
292
|
+
installed_rows: list[dict[str, Any]] = []
|
|
293
|
+
issues: list[dict[str, Any]] = []
|
|
294
|
+
installation = prepared.get('installation')
|
|
295
|
+
rebuilt = isinstance(installation, Mapping) and installation.get('resolver') == 'pnpm'
|
|
296
|
+
original_dependencies = None
|
|
297
|
+
if rebuilt:
|
|
298
|
+
try:
|
|
299
|
+
original_bytes = (profile_path / '.dsh-crate/original-package.json').read_bytes()
|
|
300
|
+
if sha256_bytes(original_bytes) != installation.get('originalManifestSha256'):
|
|
301
|
+
raise ValueError('original manifest hash mismatch')
|
|
302
|
+
if sha256_bytes((profile_path / 'pnpm-workspace.yaml').read_bytes()) != installation.get('workspaceSha256'):
|
|
303
|
+
raise ValueError('generated installation workspace hash mismatch')
|
|
304
|
+
adaptation = installation.get('profilePatchAdaptation')
|
|
305
|
+
if isinstance(adaptation, Mapping) and adaptation.get('changed') is True:
|
|
306
|
+
if adaptation.get('adapter') != 'official-desktop-composition-equivalence-v1':
|
|
307
|
+
raise ValueError('unsupported Profile patch adaptation identity')
|
|
308
|
+
if sha256_bytes((profile_path / '.dsh-crate/original-profile-config/cordis.patch.yml').read_bytes()) != adaptation.get('originalSha256'):
|
|
309
|
+
raise ValueError('original Profile patch hash mismatch')
|
|
310
|
+
if sha256_bytes((profile_path / 'cordis.patch.yml').read_bytes()) != adaptation.get('runtimeSha256'):
|
|
311
|
+
raise ValueError('adapted runtime Profile patch hash mismatch')
|
|
312
|
+
if not isinstance(adaptation.get('loaderTreeSha256'), str) or not re.fullmatch('[a-f0-9]{64}', adaptation['loaderTreeSha256']):
|
|
313
|
+
raise ValueError('official Loader equivalence evidence is invalid')
|
|
314
|
+
removed = adaptation.get('removedNames')
|
|
315
|
+
if not isinstance(removed, list) or not removed or any(not isinstance(row, Mapping) or set(row) != {'id', 'name'} or not all(isinstance(value, str) and value for value in row.values()) for row in removed):
|
|
316
|
+
raise ValueError('Profile patch adaptation record is invalid')
|
|
317
|
+
original_dependencies = json.loads(original_bytes).get('dependencies', {})
|
|
318
|
+
if not isinstance(original_dependencies, Mapping):
|
|
319
|
+
raise ValueError('original dependencies must be an object')
|
|
320
|
+
modules, error = _json_object(profile_path / 'node_modules/.modules.yaml')
|
|
321
|
+
if error or modules is None or modules.get('nodeLinker') != 'hoisted' or not (profile_path / 'pnpm-lock.yaml').is_file():
|
|
322
|
+
raise ValueError('pnpm installation records are incomplete')
|
|
323
|
+
store = modules.get('virtualStoreDir')
|
|
324
|
+
if not isinstance(store, str) or (profile_path / 'node_modules' / store).resolve() != (profile_path / 'node_modules/.pnpm').resolve():
|
|
325
|
+
raise ValueError('pnpm virtual store does not match the prepared Profile')
|
|
326
|
+
except (OSError, ValueError, TypeError) as error:
|
|
327
|
+
issues.append({'code': 'REBUILD_STATE_INVALID', 'message': str(error)})
|
|
328
|
+
for index, plugin in enumerate(raw_plugins):
|
|
329
|
+
name = plugin.get("name")
|
|
330
|
+
if not isinstance(name, str) or not name:
|
|
331
|
+
issues.append({"code": "PLUGIN_NAME_MISSING", "index": index})
|
|
332
|
+
continue
|
|
333
|
+
requested = plugin.get("requested")
|
|
334
|
+
requested_specifier = requested.get("specifier") if isinstance(requested, Mapping) else None
|
|
335
|
+
resolved_version = expected_install_version(plugin)
|
|
336
|
+
declared_spec = dependencies.get(name)
|
|
337
|
+
installed, installed_error = _package_manifest(profile_path, name)
|
|
338
|
+
prepared_names = {row.get('name') for row in prepared.get('plugins', []) if isinstance(row, Mapping)}
|
|
339
|
+
skipped_optional = plugin.get('required') is not True and name not in prepared_names and installed is None and declared_spec is None
|
|
340
|
+
if rebuilt and not skipped_optional:
|
|
341
|
+
try:
|
|
342
|
+
requests = installation.get('requests', {})
|
|
343
|
+
spec = requests.get(name) if isinstance(requests, Mapping) else None
|
|
344
|
+
match = re.fullmatch(r'file:\./\.dsh-crate/artifacts/([0-9a-f]{64})\.tgz', spec or '')
|
|
345
|
+
if not match or declared_spec != spec:
|
|
346
|
+
raise ValueError('local artifact dependency mapping mismatch')
|
|
347
|
+
if original_dependencies is None or original_dependencies.get(name, 'unknown') != requested_specifier:
|
|
348
|
+
raise ValueError('original Profile SPEC does not match plugins.lock')
|
|
349
|
+
artifact = plugin.get('artifact', {})
|
|
350
|
+
if artifact.get('mode') == 'embedded' and artifact.get('sha256') != match[1]:
|
|
351
|
+
raise ValueError('embedded artifact mapping differs from plugins.lock hash')
|
|
352
|
+
path = profile_path / '.dsh-crate/artifacts' / (match[1] + '.tgz')
|
|
353
|
+
if path.is_symlink() or path.resolve().parent != (profile_path / '.dsh-crate/artifacts').resolve():
|
|
354
|
+
raise ValueError('artifact path escapes the prepared Profile')
|
|
355
|
+
content = path.read_bytes()
|
|
356
|
+
if sha256_bytes(content) != match[1]:
|
|
357
|
+
raise ValueError('local rebuild artifact hash mismatch')
|
|
358
|
+
identity = _artifact_package_identity(content)
|
|
359
|
+
if identity != {'name': name, 'version': resolved_version}:
|
|
360
|
+
raise ValueError('local rebuild artifact identity mismatch')
|
|
361
|
+
except (OSError, ValueError, TypeError) as error:
|
|
362
|
+
issues.append({'code': 'REBUILD_ARTIFACT_INVALID', 'plugin': name, 'message': str(error)})
|
|
363
|
+
elif declared_spec is not None and declared_spec != requested_specifier:
|
|
364
|
+
issues.append({
|
|
365
|
+
"code": "PROFILE_SPEC_MISMATCH",
|
|
366
|
+
"plugin": name,
|
|
367
|
+
"expected": requested_specifier,
|
|
368
|
+
"observed": declared_spec,
|
|
369
|
+
})
|
|
370
|
+
bundle = plugin.get("bundle")
|
|
371
|
+
enabled = isinstance(bundle, Mapping) and bundle.get("enabled") is True
|
|
372
|
+
if enabled:
|
|
373
|
+
expected_bundles.append(name)
|
|
374
|
+
if enabled and not skipped_optional:
|
|
375
|
+
patch = bundle.get("patch")
|
|
376
|
+
package_path_for_plugin = profile_path / "node_modules" / Path(*name.split("/"))
|
|
377
|
+
try:
|
|
378
|
+
read_bundle_patches(package_path_for_plugin, patch)
|
|
379
|
+
if installed is not None:
|
|
380
|
+
check_patch_declaration(installed, patch)
|
|
381
|
+
except DshPackError as error:
|
|
382
|
+
issues.append({
|
|
383
|
+
"code": "BUNDLE_PATCH_INVALID",
|
|
384
|
+
"plugin": name,
|
|
385
|
+
"patch": patch,
|
|
386
|
+
"error": str(error),
|
|
387
|
+
})
|
|
388
|
+
installed_row = {
|
|
389
|
+
"name": name,
|
|
390
|
+
"required": plugin.get("required") is True,
|
|
391
|
+
"mode": (plugin.get("artifact") or {}).get("mode") if isinstance(plugin.get("artifact"), Mapping) else None,
|
|
392
|
+
"expectedVersion": resolved_version,
|
|
393
|
+
"installed": installed,
|
|
394
|
+
"error": installed_error,
|
|
395
|
+
}
|
|
396
|
+
installed_rows.append(installed_row)
|
|
397
|
+
if installed is None:
|
|
398
|
+
issue = {"code": "PLUGIN_NOT_INSTALLED", "plugin": name, "error": installed_error}
|
|
399
|
+
if plugin.get("required") is True:
|
|
400
|
+
issues.append(issue)
|
|
401
|
+
else:
|
|
402
|
+
issue["optional"] = True
|
|
403
|
+
issues.append(issue)
|
|
404
|
+
else:
|
|
405
|
+
mismatch = installed.get("name") != name or installed.get("version") != resolved_version
|
|
406
|
+
if mismatch:
|
|
407
|
+
issues.append({
|
|
408
|
+
"code": "PACKAGE_IDENTITY_MISMATCH",
|
|
409
|
+
"plugin": name,
|
|
410
|
+
"expected": {"name": name, "version": resolved_version},
|
|
411
|
+
"observed": {"name": installed.get("name"), "version": installed.get("version")},
|
|
412
|
+
})
|
|
413
|
+
|
|
414
|
+
if bundles != expected_bundles:
|
|
415
|
+
issues.append({"code": "BUNDLE_ORDER_MISMATCH", "expected": expected_bundles, "observed": bundles})
|
|
416
|
+
|
|
417
|
+
required_issues = [issue for issue in issues if not issue.get("optional")]
|
|
418
|
+
if required_issues:
|
|
419
|
+
status = "FAIL"
|
|
420
|
+
message = "Profile composition does not match prepared Pack metadata"
|
|
421
|
+
elif issues:
|
|
422
|
+
status = "DEGRADED"
|
|
423
|
+
message = "Profile composition is usable but optional plugin evidence is incomplete"
|
|
424
|
+
else:
|
|
425
|
+
status = "PASS"
|
|
426
|
+
message = "Profile, lock, Bundle order, patches, and installed package identities agree"
|
|
427
|
+
return _step(
|
|
428
|
+
"composition",
|
|
429
|
+
status,
|
|
430
|
+
message,
|
|
431
|
+
evidence={
|
|
432
|
+
"profilePath": str(profile_path),
|
|
433
|
+
"metadataPath": str(metadata_path),
|
|
434
|
+
"profileBundles": bundles,
|
|
435
|
+
"lockBundleOrder": expected_bundles,
|
|
436
|
+
"installedPlugins": installed_rows,
|
|
437
|
+
"pack": prepared.get("pack"),
|
|
438
|
+
"issues": issues,
|
|
439
|
+
"requiredSecrets": metadata_manifest.get("requiredSecrets", []),
|
|
440
|
+
},
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
@dataclass(frozen=True)
|
|
445
|
+
class VerifyOptions:
|
|
446
|
+
dsh_home: Path
|
|
447
|
+
profile_name: str
|
|
448
|
+
mode: str = "web"
|
|
449
|
+
current_environment: Mapping[str, Any] = field(default_factory=dict)
|
|
450
|
+
available_secret_names: frozenset[str] = frozenset()
|
|
451
|
+
adapter: VerifyAdapter | None = None
|
|
452
|
+
plugin_contracts: tuple[SmokeTestContract, ...] = ()
|
|
453
|
+
|
|
454
|
+
def __post_init__(self) -> None:
|
|
455
|
+
if self.mode not in {"web", "headless"}:
|
|
456
|
+
raise ValueError("VerifyOptions.mode must be 'web' or 'headless'")
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
@dataclass(frozen=True)
|
|
460
|
+
class VerifyResult:
|
|
461
|
+
status: str
|
|
462
|
+
dsh_home: Path
|
|
463
|
+
profile_name: str
|
|
464
|
+
mode: str
|
|
465
|
+
current_environment: Mapping[str, Any]
|
|
466
|
+
available_secret_names: frozenset[str]
|
|
467
|
+
steps: tuple[VerifyStep, ...]
|
|
468
|
+
plugin_contracts: tuple[SmokeTestContract, ...]
|
|
469
|
+
business_status: str | None = None
|
|
470
|
+
|
|
471
|
+
def __post_init__(self) -> None:
|
|
472
|
+
if self.status not in VERIFY_STATUSES:
|
|
473
|
+
raise ValueError(f"unsupported verify result status: {self.status}")
|
|
474
|
+
|
|
475
|
+
def as_dict(self) -> dict[str, Any]:
|
|
476
|
+
step_map = {step.name: step.as_dict() for step in self.steps}
|
|
477
|
+
plugin_smokes = [
|
|
478
|
+
step.as_dict()
|
|
479
|
+
for step in self.steps
|
|
480
|
+
if step.name.startswith("plugin:")
|
|
481
|
+
]
|
|
482
|
+
return {
|
|
483
|
+
"status": self.status,
|
|
484
|
+
"businessStatus": self.business_status or self.status,
|
|
485
|
+
"cleanup": step_map.get("cleanup"),
|
|
486
|
+
"profile": {
|
|
487
|
+
"name": self.profile_name,
|
|
488
|
+
"dshHome": str(self.dsh_home),
|
|
489
|
+
"path": str(self.dsh_home / "profiles" / self.profile_name),
|
|
490
|
+
},
|
|
491
|
+
"mode": self.mode,
|
|
492
|
+
"environment": {"current": dict(self.current_environment)},
|
|
493
|
+
"secrets": {
|
|
494
|
+
"availableNames": sorted(self.available_secret_names),
|
|
495
|
+
"valuesIncluded": False,
|
|
496
|
+
},
|
|
497
|
+
# Keep the ordered steps array for compatibility, while exposing
|
|
498
|
+
# the stable Phase 5 machine-report fields required by callers.
|
|
499
|
+
"composition": step_map.get("composition"),
|
|
500
|
+
"boot": step_map.get("boot"),
|
|
501
|
+
"ready": step_map.get("surface_ready"),
|
|
502
|
+
"session": step_map.get("new_session"),
|
|
503
|
+
"basicResponse": step_map.get("basic_response"),
|
|
504
|
+
"coreTool": step_map.get("core_tool"),
|
|
505
|
+
"pluginSmokes": plugin_smokes,
|
|
506
|
+
"restart": step_map.get("restart"),
|
|
507
|
+
"restartReady": step_map.get("restart_surface_ready"),
|
|
508
|
+
"steps": [step.as_dict() for step in self.steps],
|
|
509
|
+
"pluginSmokeContracts": [contract.as_dict() for contract in self.plugin_contracts],
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def _overall_status(steps: Sequence[VerifyStep]) -> str:
|
|
514
|
+
required = [step for step in steps if step.required]
|
|
515
|
+
optional = [step for step in steps if not step.required]
|
|
516
|
+
if any(step.status == "FAIL" for step in required):
|
|
517
|
+
return "FAIL"
|
|
518
|
+
if any(step.status == "UNTESTED" for step in required):
|
|
519
|
+
return "UNTESTED"
|
|
520
|
+
if any(step.status in {"DEGRADED", "FAIL"} for step in required):
|
|
521
|
+
return "DEGRADED"
|
|
522
|
+
if any(step.status != "PASS" for step in optional):
|
|
523
|
+
return "DEGRADED"
|
|
524
|
+
return "PASS"
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def _cleanup_status(evidence: Mapping[str, Any]) -> str:
|
|
528
|
+
"""缺失证据不能当作释放成功;此前失败的清理也不能被末次成功遮蔽。"""
|
|
529
|
+
attempts = evidence.get("attempts", [])
|
|
530
|
+
if isinstance(attempts, list) and any(
|
|
531
|
+
isinstance(item, Mapping) and _cleanup_status(item) == "FAIL" for item in attempts
|
|
532
|
+
):
|
|
533
|
+
return "FAIL"
|
|
534
|
+
endpoints = evidence.get("endpoints", [])
|
|
535
|
+
endpoints = endpoints if isinstance(endpoints, list) else []
|
|
536
|
+
if (evidence.get("status") == "FAIL" or evidence.get("errors") or evidence.get("processExited") is False
|
|
537
|
+
or evidence.get("portReleased") is False
|
|
538
|
+
or any(isinstance(item, Mapping) and item.get("released") is False for item in endpoints)):
|
|
539
|
+
return "FAIL"
|
|
540
|
+
if evidence.get("processExited") is not True:
|
|
541
|
+
return "UNTESTED"
|
|
542
|
+
if evidence.get("portApplicable") is not False and evidence.get("portReleased") is not True:
|
|
543
|
+
return "UNTESTED"
|
|
544
|
+
if evidence.get("portApplicable") is not False and (
|
|
545
|
+
not isinstance(evidence.get("portEvidence"), Mapping)
|
|
546
|
+
or evidence["portEvidence"].get("checked") is not True
|
|
547
|
+
):
|
|
548
|
+
return "UNTESTED"
|
|
549
|
+
if evidence.get("endpointsApplicable") is not False and (
|
|
550
|
+
not endpoints or any(not isinstance(item, Mapping) or item.get("checked") is not True
|
|
551
|
+
or item.get("released") is not True for item in endpoints)
|
|
552
|
+
):
|
|
553
|
+
return "UNTESTED"
|
|
554
|
+
if isinstance(attempts, list) and any(
|
|
555
|
+
not isinstance(item, Mapping) or _cleanup_status(item) != "PASS" for item in attempts
|
|
556
|
+
):
|
|
557
|
+
return "UNTESTED"
|
|
558
|
+
return "PASS"
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def _cleanup_step(adapter: VerifyAdapter | None) -> VerifyStep:
|
|
562
|
+
close = getattr(adapter, "close", None)
|
|
563
|
+
if not callable(close):
|
|
564
|
+
return _step("cleanup", "UNTESTED", "未提供可核查的最终清理接口",
|
|
565
|
+
evidence={"processExited": None, "portReleased": None, "endpoints": [],
|
|
566
|
+
"reason": "VerifyAdapter has no close implementation"})
|
|
567
|
+
try:
|
|
568
|
+
value = close()
|
|
569
|
+
except Exception as error:
|
|
570
|
+
evidence = {"processExited": None, "portReleased": None, "endpoints": [],
|
|
571
|
+
"error": str(error), "exception": type(error).__name__}
|
|
572
|
+
return _step("cleanup", "FAIL", "最终清理抛出异常;原业务步骤和诊断已保留", evidence=evidence,
|
|
573
|
+
diagnostic=_runtime_diagnostic(
|
|
574
|
+
code="VERIFY_CLEANUP_EXCEPTION", stage="cleanup", message="最终清理抛出异常",
|
|
575
|
+
evidence=evidence, item="cleanup", expected="清理完成并返回资源释放证据",
|
|
576
|
+
impact="无法确认验证资源已释放;请检查 cleanup 证据中的进程和端点。"))
|
|
577
|
+
evidence = dict(value) if isinstance(value, Mapping) else {
|
|
578
|
+
"processExited": None, "portReleased": None, "endpoints": [],
|
|
579
|
+
"reason": "close returned no resource evidence"}
|
|
580
|
+
status = _cleanup_status(evidence)
|
|
581
|
+
diagnostic = None
|
|
582
|
+
if status != "PASS":
|
|
583
|
+
diagnostic = _runtime_diagnostic(
|
|
584
|
+
code="VERIFY_CLEANUP_FAILED" if status == "FAIL" else "VERIFY_CLEANUP_UNVERIFIED",
|
|
585
|
+
stage="cleanup", message="最终清理失败" if status == "FAIL" else "最终清理证据不足",
|
|
586
|
+
evidence=evidence, item="cleanup", expected="已观察进程、端口和端点均有释放证据",
|
|
587
|
+
impact="原业务步骤和诊断已保留;资源释放状态请单独检查。")
|
|
588
|
+
return _step("cleanup", status,
|
|
589
|
+
{"PASS": "已核查所记录资源的释放", "FAIL": "清理失败;原业务步骤和诊断已保留",
|
|
590
|
+
"UNTESTED": "清理证据不足;未将未知状态判为成功"}[status],
|
|
591
|
+
evidence=evidence, diagnostic=diagnostic)
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def _loader_composition_failure(composition: VerifyStep, boot: VerifyStep) -> dict[str, Any] | None:
|
|
595
|
+
"""Classify a real DSH Loader activation failure as composition failure.
|
|
596
|
+
|
|
597
|
+
DSH resolves Bundle patches and activates their Loader entries during boot.
|
|
598
|
+
A structurally valid Pack can therefore pass the local file checks while
|
|
599
|
+
the effective composition still fails in the real runtime. Keep the
|
|
600
|
+
original ``boot=FAIL`` step, but attach the same runtime evidence to the
|
|
601
|
+
composition step so a report can answer which Bundle prevented startup.
|
|
602
|
+
"""
|
|
603
|
+
|
|
604
|
+
if boot.status != "FAIL":
|
|
605
|
+
return None
|
|
606
|
+
evidence = boot.evidence
|
|
607
|
+
stderr = str(evidence.get("stderr", ""))
|
|
608
|
+
stdout = str(evidence.get("stdout", ""))
|
|
609
|
+
combined = f"{stderr}\n{stdout}"
|
|
610
|
+
lowered = combined.lower()
|
|
611
|
+
markers = (
|
|
612
|
+
"plugin tree failed to load",
|
|
613
|
+
"did not activate",
|
|
614
|
+
"failed to apply loader entry",
|
|
615
|
+
"loader activation failed",
|
|
616
|
+
)
|
|
617
|
+
if not any(marker in lowered for marker in markers):
|
|
618
|
+
return None
|
|
619
|
+
|
|
620
|
+
# 只接受失败行明确命名的条目;全局启动日志和安装顺序不能证明归属。
|
|
621
|
+
# 运行条目可能是未直接列在 Bundle 清单中的包或 subpath,必须保留原名。
|
|
622
|
+
target_pattern = re.compile(
|
|
623
|
+
r"(?P<marker>plugin tree failed to load\s*:\s*|failed to apply loader entry\s+|did not activate\s+)"
|
|
624
|
+
r"[`\"']?(?P<target>(?:@[\w.-]+/)?[\w.-]+(?:/[\w.-]+)*)[`\"']?(?=\s*(?::|$))",
|
|
625
|
+
re.IGNORECASE,
|
|
626
|
+
)
|
|
627
|
+
installed_plugins = composition.evidence.get("installedPlugins", [])
|
|
628
|
+
installed_names = {
|
|
629
|
+
row["name"] for row in installed_plugins
|
|
630
|
+
if isinstance(row, Mapping) and isinstance(row.get("name"), str) and row["name"]
|
|
631
|
+
} if isinstance(installed_plugins, list) else set()
|
|
632
|
+
failed_targets: set[str] = set()
|
|
633
|
+
for line in combined.splitlines():
|
|
634
|
+
if not any(marker in line.lower() for marker in markers):
|
|
635
|
+
continue
|
|
636
|
+
for match in target_pattern.finditer(line):
|
|
637
|
+
target = match.group("target")
|
|
638
|
+
if target.lower() in {"error", "typeerror", "rangeerror", "exception"}:
|
|
639
|
+
continue
|
|
640
|
+
# 泛化的等待失败文案不是 Loader 证据;只有明确包名才可归属。
|
|
641
|
+
if match.group("marker").lower().startswith("did not activate"):
|
|
642
|
+
if not (target.startswith("@") or any(target == name or target.startswith(name + "/") for name in installed_names)):
|
|
643
|
+
continue
|
|
644
|
+
failed_targets.add(target)
|
|
645
|
+
for name in installed_names:
|
|
646
|
+
if re.search(r"(?<![\w@./-])" + re.escape(name) + r"\s+did not activate\b", line, re.IGNORECASE):
|
|
647
|
+
failed_targets.add(name)
|
|
648
|
+
has_loader_failure = any(marker in lowered for marker in markers if marker != "did not activate")
|
|
649
|
+
if not has_loader_failure and not failed_targets:
|
|
650
|
+
return None
|
|
651
|
+
failed_bundle = next(iter(failed_targets)) if len(failed_targets) == 1 else None
|
|
652
|
+
|
|
653
|
+
observed = "Loader activation failed" if "loader activation failed" in lowered else "DSH Loader activation failed"
|
|
654
|
+
return {
|
|
655
|
+
"stage": "composition",
|
|
656
|
+
"failedBundle": failed_bundle,
|
|
657
|
+
"observed": observed,
|
|
658
|
+
"stderr": stderr,
|
|
659
|
+
"impact": "Imported profile cannot start.",
|
|
660
|
+
"bootMessage": boot.message,
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def _adapter_step(
|
|
665
|
+
adapter: VerifyAdapter | None,
|
|
666
|
+
name: str,
|
|
667
|
+
context: VerifyContext,
|
|
668
|
+
*,
|
|
669
|
+
required: bool = True,
|
|
670
|
+
) -> VerifyStep:
|
|
671
|
+
if adapter is None:
|
|
672
|
+
return _step(
|
|
673
|
+
name,
|
|
674
|
+
"UNTESTED",
|
|
675
|
+
"No runtime VerifyAdapter was supplied",
|
|
676
|
+
required=required,
|
|
677
|
+
evidence={"reason": "runtime execution is not implicit"},
|
|
678
|
+
)
|
|
679
|
+
method = getattr(adapter, "run_step", None)
|
|
680
|
+
if not callable(method):
|
|
681
|
+
return _step(
|
|
682
|
+
name,
|
|
683
|
+
"UNTESTED",
|
|
684
|
+
"VerifyAdapter has no run_step implementation",
|
|
685
|
+
required=required,
|
|
686
|
+
evidence={"reason": "adapter contract missing"},
|
|
687
|
+
)
|
|
688
|
+
try:
|
|
689
|
+
return _normalise_observation(name, method(name, context), required=required)
|
|
690
|
+
except Exception as error: # adapters are external execution boundaries
|
|
691
|
+
return _step(
|
|
692
|
+
name,
|
|
693
|
+
"FAIL",
|
|
694
|
+
f"VerifyAdapter failed during {name}",
|
|
695
|
+
required=required,
|
|
696
|
+
evidence={"error": str(error), "exception": type(error).__name__},
|
|
697
|
+
)
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
def _plugin_step(
|
|
701
|
+
adapter: VerifyAdapter | None,
|
|
702
|
+
contract: SmokeTestContract,
|
|
703
|
+
test_name: str,
|
|
704
|
+
context: VerifyContext,
|
|
705
|
+
) -> VerifyStep:
|
|
706
|
+
step_name = f"plugin:{contract.plugin}:{test_name}"
|
|
707
|
+
if adapter is None or not callable(getattr(adapter, "run_plugin_test", None)):
|
|
708
|
+
return _step(
|
|
709
|
+
step_name,
|
|
710
|
+
"UNTESTED",
|
|
711
|
+
"No plugin smoke-test runner was supplied",
|
|
712
|
+
required=contract.required,
|
|
713
|
+
evidence={"plugin": contract.plugin, "test": test_name},
|
|
714
|
+
)
|
|
715
|
+
try:
|
|
716
|
+
value = adapter.run_plugin_test(contract.plugin, test_name, context) # type: ignore[attr-defined]
|
|
717
|
+
observed = _normalise_observation(step_name, value, required=contract.required)
|
|
718
|
+
if observed.required != contract.required:
|
|
719
|
+
observed = VerifyStep(
|
|
720
|
+
name=observed.name,
|
|
721
|
+
status=observed.status,
|
|
722
|
+
message=observed.message,
|
|
723
|
+
required=contract.required,
|
|
724
|
+
evidence=observed.evidence,
|
|
725
|
+
)
|
|
726
|
+
return observed
|
|
727
|
+
except Exception as error: # plugin tests are an external execution boundary
|
|
728
|
+
return _step(
|
|
729
|
+
step_name,
|
|
730
|
+
"FAIL",
|
|
731
|
+
f"Plugin smoke test failed to execute: {contract.plugin}/{test_name}",
|
|
732
|
+
required=contract.required,
|
|
733
|
+
evidence={"plugin": contract.plugin, "test": test_name, "error": str(error)},
|
|
734
|
+
)
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
def _runtime_blocker(steps: list[VerifyStep]) -> str | None:
|
|
738
|
+
"""Preserve the first failure; never probe or restart past missing startup evidence."""
|
|
739
|
+
runtime_names = {name for name, _ in RUNTIME_STEPS}
|
|
740
|
+
prerequisites = {"boot", "surface_ready", "restart", "restart_surface_ready"}
|
|
741
|
+
return next((step.name for step in steps if step.name in runtime_names
|
|
742
|
+
and (step.status == "FAIL" or (step.name in prerequisites
|
|
743
|
+
and step.status not in {"PASS", "DEGRADED"}))), None)
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
def verify_profile(options: VerifyOptions) -> VerifyResult:
|
|
747
|
+
"""Verify one prepared Profile without changing its files."""
|
|
748
|
+
|
|
749
|
+
if not options.dsh_home.is_dir():
|
|
750
|
+
result = VerifyResult(
|
|
751
|
+
status="FAIL",
|
|
752
|
+
dsh_home=options.dsh_home,
|
|
753
|
+
profile_name=options.profile_name,
|
|
754
|
+
mode=options.mode,
|
|
755
|
+
current_environment=options.current_environment,
|
|
756
|
+
available_secret_names=options.available_secret_names,
|
|
757
|
+
steps=(_step("composition", "FAIL", "DSH_HOME does not exist", evidence={"dshHome": str(options.dsh_home)}),),
|
|
758
|
+
plugin_contracts=options.plugin_contracts,
|
|
759
|
+
business_status="FAIL",
|
|
760
|
+
)
|
|
761
|
+
cleanup = _cleanup_step(options.adapter)
|
|
762
|
+
return replace(result, steps=(*result.steps, cleanup))
|
|
763
|
+
|
|
764
|
+
composition = _composition_step(options)
|
|
765
|
+
steps: list[VerifyStep] = [composition]
|
|
766
|
+
previous: dict[str, VerifyStep] = {composition.name: composition}
|
|
767
|
+
context_base = {
|
|
768
|
+
"dsh_home": options.dsh_home,
|
|
769
|
+
"profile_name": options.profile_name,
|
|
770
|
+
"profile_path": options.dsh_home / "profiles" / options.profile_name,
|
|
771
|
+
"mode": options.mode,
|
|
772
|
+
"current_environment": options.current_environment,
|
|
773
|
+
"available_secret_names": options.available_secret_names,
|
|
774
|
+
"metadata_path": options.dsh_home / ".dsh-pack" / "imports" / options.profile_name,
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
for name, label in RUNTIME_STEPS:
|
|
778
|
+
blocking_runtime_step = _runtime_blocker(steps)
|
|
779
|
+
if composition.status == "FAIL":
|
|
780
|
+
current = _step(
|
|
781
|
+
name,
|
|
782
|
+
"UNTESTED",
|
|
783
|
+
f"{label} was not run because composition failed",
|
|
784
|
+
evidence={"blockedBy": "composition"},
|
|
785
|
+
)
|
|
786
|
+
elif blocking_runtime_step is not None:
|
|
787
|
+
current = _step(
|
|
788
|
+
name,
|
|
789
|
+
"UNTESTED",
|
|
790
|
+
f"{label} was not run because an earlier runtime prerequisite did not pass",
|
|
791
|
+
evidence={"blockedBy": blocking_runtime_step},
|
|
792
|
+
)
|
|
793
|
+
else:
|
|
794
|
+
context = VerifyContext(previous_steps=dict(previous), **context_base)
|
|
795
|
+
current = _adapter_step(options.adapter, name, context)
|
|
796
|
+
steps.append(current)
|
|
797
|
+
previous[current.name] = current
|
|
798
|
+
|
|
799
|
+
# The imported lock, rather than the caller's contract list, defines which
|
|
800
|
+
# plugin capabilities must be accounted for. A missing or empty contract
|
|
801
|
+
# is explicit missing evidence, never an implicit successful smoke test.
|
|
802
|
+
imported_plugins = composition.evidence.get("installedPlugins", [])
|
|
803
|
+
imported_required = {
|
|
804
|
+
row["name"]: row.get("required") is True
|
|
805
|
+
for row in imported_plugins
|
|
806
|
+
if isinstance(row, Mapping) and isinstance(row.get("name"), str)
|
|
807
|
+
} if isinstance(imported_plugins, list) else {}
|
|
808
|
+
contracts = [
|
|
809
|
+
SmokeTestContract(
|
|
810
|
+
plugin=contract.plugin,
|
|
811
|
+
tests=tuple(test for test in contract.tests if isinstance(test, str) and test.strip()),
|
|
812
|
+
required=contract.required or imported_required.get(contract.plugin, False),
|
|
813
|
+
)
|
|
814
|
+
for contract in options.plugin_contracts
|
|
815
|
+
]
|
|
816
|
+
covered_plugins = {contract.plugin for contract in contracts}
|
|
817
|
+
contracts.extend(
|
|
818
|
+
SmokeTestContract(plugin=name, tests=(), required=required)
|
|
819
|
+
for name, required in imported_required.items()
|
|
820
|
+
if name not in covered_plugins
|
|
821
|
+
)
|
|
822
|
+
blocking_plugin_step = ("composition" if composition.status not in {"PASS", "DEGRADED"}
|
|
823
|
+
else _runtime_blocker(steps))
|
|
824
|
+
for contract in contracts:
|
|
825
|
+
for test_name in contract.tests or (None,):
|
|
826
|
+
step_name = f"plugin:{contract.plugin}:{test_name or 'untested'}"
|
|
827
|
+
if blocking_plugin_step is not None:
|
|
828
|
+
current = _step(
|
|
829
|
+
step_name, "UNTESTED", "Plugin smoke test was not run because runtime prerequisites did not pass",
|
|
830
|
+
required=contract.required,
|
|
831
|
+
evidence={"plugin": contract.plugin, "test": test_name, "blockedBy": blocking_plugin_step},
|
|
832
|
+
)
|
|
833
|
+
elif test_name is None:
|
|
834
|
+
current = _step(
|
|
835
|
+
step_name, "UNTESTED", "Imported plugin has no non-empty dedicated smoke-test contract",
|
|
836
|
+
required=contract.required,
|
|
837
|
+
evidence={"plugin": contract.plugin, "test": None, "reason": "missing smoke-test contract"},
|
|
838
|
+
)
|
|
839
|
+
else:
|
|
840
|
+
context = VerifyContext(previous_steps=dict(previous), **context_base)
|
|
841
|
+
current = _plugin_step(options.adapter, contract, test_name, context)
|
|
842
|
+
steps.append(current)
|
|
843
|
+
previous[current.name] = current
|
|
844
|
+
|
|
845
|
+
boot = next((step for step in steps if step.name == "boot"), None)
|
|
846
|
+
runtime_composition_failure = (
|
|
847
|
+
_loader_composition_failure(composition, boot)
|
|
848
|
+
if boot is not None
|
|
849
|
+
else None
|
|
850
|
+
)
|
|
851
|
+
if runtime_composition_failure is not None:
|
|
852
|
+
composition = _step(
|
|
853
|
+
"composition",
|
|
854
|
+
"FAIL",
|
|
855
|
+
"Bundle composition failed during DSH Loader activation",
|
|
856
|
+
evidence={
|
|
857
|
+
**composition.evidence,
|
|
858
|
+
"runtimeFailure": runtime_composition_failure,
|
|
859
|
+
},
|
|
860
|
+
)
|
|
861
|
+
steps[0] = composition
|
|
862
|
+
|
|
863
|
+
business_status = _overall_status(steps)
|
|
864
|
+
steps.append(_cleanup_step(options.adapter))
|
|
865
|
+
result = VerifyResult(
|
|
866
|
+
status=_overall_status(steps),
|
|
867
|
+
dsh_home=options.dsh_home,
|
|
868
|
+
profile_name=options.profile_name,
|
|
869
|
+
mode=options.mode,
|
|
870
|
+
current_environment=options.current_environment,
|
|
871
|
+
available_secret_names=options.available_secret_names,
|
|
872
|
+
steps=tuple(steps),
|
|
873
|
+
plugin_contracts=tuple(contracts),
|
|
874
|
+
business_status=business_status,
|
|
875
|
+
)
|
|
876
|
+
return result
|
|
877
|
+
|
|
878
|
+
|
|
879
|
+
class SubprocessVerifyAdapter:
|
|
880
|
+
"""Explicit subprocess adapter for a real DSH or test runner.
|
|
881
|
+
|
|
882
|
+
Configuration shape::
|
|
883
|
+
|
|
884
|
+
{
|
|
885
|
+
"mode": "web",
|
|
886
|
+
"command": ["node", ".../bin.js", "--profile", "{profile}"],
|
|
887
|
+
"cwd": "...",
|
|
888
|
+
"timeoutSeconds": 60,
|
|
889
|
+
"readyRegex": "dsh web: (?P<url>https?://[^\\s]+)",
|
|
890
|
+
"expectedStatus": 200,
|
|
891
|
+
"probes": {"new_session": ["..."], "core_tool": ["..."]},
|
|
892
|
+
"pluginTests": {"plugin-a": {"loads": ["..."]}}
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
Commands are arrays, never shell strings. ``{dsh_home}``, ``{profile}``,
|
|
896
|
+
``{profile_path}``, ``{url}``, and ``{pid}`` placeholders are available.
|
|
897
|
+
Missing probe commands remain UNTESTED. The adapter records full
|
|
898
|
+
stdout/stderr in step evidence and never records environment values.
|
|
899
|
+
"""
|
|
900
|
+
|
|
901
|
+
def __init__(self, config: Mapping[str, Any]) -> None:
|
|
902
|
+
self.config = dict(config)
|
|
903
|
+
self.mode = self.config.get("mode", "web")
|
|
904
|
+
if self.mode not in {"web", "headless"}:
|
|
905
|
+
raise ValueError("SubprocessVerifyAdapter mode must be 'web' or 'headless'")
|
|
906
|
+
self.process: subprocess.Popen[str] | None = None
|
|
907
|
+
self._logs: dict[str, list[str]] = {"stdout": [], "stderr": []}
|
|
908
|
+
self._threads: list[threading.Thread] = []
|
|
909
|
+
self._url: str | None = None
|
|
910
|
+
self._last_headless: dict[str, Any] | None = None
|
|
911
|
+
self._cleanup_attempts: list[dict[str, Any]] = []
|
|
912
|
+
self._last_cleanup: dict[str, Any] | None = None
|
|
913
|
+
|
|
914
|
+
def _timeout(self) -> float:
|
|
915
|
+
value = self.config.get("timeoutSeconds", 60)
|
|
916
|
+
return float(value) if isinstance(value, (int, float)) and value > 0 else 60.0
|
|
917
|
+
|
|
918
|
+
def _format(self, value: str, context: VerifyContext) -> str:
|
|
919
|
+
values = {
|
|
920
|
+
"dsh_home": str(context.dsh_home),
|
|
921
|
+
"profile": context.profile_name,
|
|
922
|
+
"profile_path": str(context.profile_path),
|
|
923
|
+
"url": self._url or "",
|
|
924
|
+
"pid": str(self.process.pid if self.process is not None else ""),
|
|
925
|
+
}
|
|
926
|
+
return value.format_map(values)
|
|
927
|
+
|
|
928
|
+
def _command(self, key: str, context: VerifyContext) -> list[str] | None:
|
|
929
|
+
raw = self.config.get("command") if key in {"boot", "restart"} else (self.config.get("probes", {}) or {}).get(key)
|
|
930
|
+
if not isinstance(raw, list) or any(not isinstance(item, str) for item in raw):
|
|
931
|
+
return None
|
|
932
|
+
return [self._format(item, context) for item in raw]
|
|
933
|
+
|
|
934
|
+
def _environment(self, context: VerifyContext) -> dict[str, str]:
|
|
935
|
+
env = dict(os.environ) if self.config.get("inheritEnvironment", True) is not False else {}
|
|
936
|
+
if self._url:
|
|
937
|
+
env["DSH_WEB_URL"] = self._url
|
|
938
|
+
extra = self.config.get("environment", {})
|
|
939
|
+
if isinstance(extra, Mapping):
|
|
940
|
+
env.update({str(key): str(value) for key, value in extra.items()})
|
|
941
|
+
# Windows environment names are case-insensitive. Remove all inherited
|
|
942
|
+
# and configured aliases before assigning the actual isolated target.
|
|
943
|
+
env = {key: value for key, value in env.items() if key.upper() not in {"DSH_HOME", "DSH_PROFILE"}}
|
|
944
|
+
env["DSH_HOME"] = str(context.dsh_home)
|
|
945
|
+
env["DSH_PROFILE"] = context.profile_name
|
|
946
|
+
return env
|
|
947
|
+
|
|
948
|
+
@staticmethod
|
|
949
|
+
def _drain(stream, target: list[str]) -> None:
|
|
950
|
+
try:
|
|
951
|
+
for line in stream:
|
|
952
|
+
target.append(line)
|
|
953
|
+
finally:
|
|
954
|
+
stream.close()
|
|
955
|
+
|
|
956
|
+
def _logs_evidence(self) -> dict[str, Any]:
|
|
957
|
+
return {"stdout": "".join(self._logs["stdout"]), "stderr": "".join(self._logs["stderr"])}
|
|
958
|
+
|
|
959
|
+
def _spawn_web(self, context: VerifyContext) -> VerifyStep:
|
|
960
|
+
command = self._command("boot", context)
|
|
961
|
+
if command is None:
|
|
962
|
+
return _step("boot", "UNTESTED", "runner config has no boot command", evidence={"mode": self.mode})
|
|
963
|
+
self._logs = {"stdout": [], "stderr": []}
|
|
964
|
+
try:
|
|
965
|
+
cwd = self.config.get("cwd")
|
|
966
|
+
self.process = subprocess.Popen(
|
|
967
|
+
command,
|
|
968
|
+
cwd=str(cwd) if isinstance(cwd, str) else None,
|
|
969
|
+
env=self._environment(context),
|
|
970
|
+
stdout=subprocess.PIPE,
|
|
971
|
+
stderr=subprocess.PIPE,
|
|
972
|
+
text=True,
|
|
973
|
+
bufsize=1,
|
|
974
|
+
)
|
|
975
|
+
assert self.process.stdout is not None and self.process.stderr is not None
|
|
976
|
+
self._threads = [
|
|
977
|
+
threading.Thread(target=self._drain, args=(self.process.stdout, self._logs["stdout"]), daemon=True),
|
|
978
|
+
threading.Thread(target=self._drain, args=(self.process.stderr, self._logs["stderr"]), daemon=True),
|
|
979
|
+
]
|
|
980
|
+
for thread in self._threads:
|
|
981
|
+
thread.start()
|
|
982
|
+
except (OSError, ValueError) as error:
|
|
983
|
+
evidence = {"command": command, "error": str(error)}
|
|
984
|
+
return _step(
|
|
985
|
+
"boot",
|
|
986
|
+
"FAIL",
|
|
987
|
+
"could not start runtime command",
|
|
988
|
+
evidence=evidence,
|
|
989
|
+
diagnostic=_runtime_diagnostic(
|
|
990
|
+
code="RUNNER_SPAWN_FAILED",
|
|
991
|
+
stage="runtime",
|
|
992
|
+
message="could not start runtime command",
|
|
993
|
+
evidence=evidence,
|
|
994
|
+
item="command",
|
|
995
|
+
expected="the runtime command starts and prints a ready URL",
|
|
996
|
+
impact="DSH runtime could not be started for verification.",
|
|
997
|
+
),
|
|
998
|
+
)
|
|
999
|
+
pattern = self.config.get("readyRegex", r"dsh web: (?P<url>https?://[^\s]+)")
|
|
1000
|
+
try:
|
|
1001
|
+
regex = re.compile(pattern if isinstance(pattern, str) else r"dsh web: (?P<url>https?://[^\s]+)")
|
|
1002
|
+
except re.error as error:
|
|
1003
|
+
self.close()
|
|
1004
|
+
evidence = {"error": str(error)}
|
|
1005
|
+
return _step(
|
|
1006
|
+
"boot",
|
|
1007
|
+
"FAIL",
|
|
1008
|
+
"runner readyRegex is invalid",
|
|
1009
|
+
evidence=evidence,
|
|
1010
|
+
diagnostic=_runtime_diagnostic(
|
|
1011
|
+
code="RUNNER_READY_REGEX_INVALID",
|
|
1012
|
+
stage="runtime",
|
|
1013
|
+
message="runner readyRegex is invalid",
|
|
1014
|
+
evidence=evidence,
|
|
1015
|
+
item="readyRegex",
|
|
1016
|
+
expected="a valid readyRegex that extracts the ready URL",
|
|
1017
|
+
impact="DSH readiness could not be detected.",
|
|
1018
|
+
),
|
|
1019
|
+
)
|
|
1020
|
+
deadline = time.monotonic() + self._timeout()
|
|
1021
|
+
while time.monotonic() < deadline:
|
|
1022
|
+
combined = "".join(self._logs["stdout"])
|
|
1023
|
+
match = regex.search(combined)
|
|
1024
|
+
if match:
|
|
1025
|
+
self._url = match.groupdict().get("url") or (match.group(1) if match.groups() else match.group(0))
|
|
1026
|
+
return _step("boot", "PASS", "runtime printed its ready URL", evidence={"pid": self.process.pid, "url": self._url, **self._logs_evidence()})
|
|
1027
|
+
code = self.process.poll()
|
|
1028
|
+
if code is not None:
|
|
1029
|
+
evidence = {"exitCode": code, **self._logs_evidence()}
|
|
1030
|
+
return _step(
|
|
1031
|
+
"boot",
|
|
1032
|
+
"FAIL",
|
|
1033
|
+
"runtime exited before ready",
|
|
1034
|
+
evidence=evidence,
|
|
1035
|
+
diagnostic=_runtime_diagnostic(
|
|
1036
|
+
code="RUNNER_EXITED_BEFORE_READY",
|
|
1037
|
+
stage="runtime",
|
|
1038
|
+
message="runtime exited before ready",
|
|
1039
|
+
evidence=evidence,
|
|
1040
|
+
item="runtime",
|
|
1041
|
+
expected="the runtime process stays alive and prints a ready URL",
|
|
1042
|
+
impact="The imported Profile cannot boot in the target DSH runtime.",
|
|
1043
|
+
),
|
|
1044
|
+
)
|
|
1045
|
+
time.sleep(0.05)
|
|
1046
|
+
self.close()
|
|
1047
|
+
evidence = {"timeoutSeconds": self._timeout(), **self._logs_evidence()}
|
|
1048
|
+
return _step(
|
|
1049
|
+
"boot",
|
|
1050
|
+
"FAIL",
|
|
1051
|
+
"runtime did not print a ready URL before timeout",
|
|
1052
|
+
evidence=evidence,
|
|
1053
|
+
diagnostic=_runtime_diagnostic(
|
|
1054
|
+
code="RUNNER_READY_TIMEOUT",
|
|
1055
|
+
stage="runtime",
|
|
1056
|
+
message="runtime did not print a ready URL before timeout",
|
|
1057
|
+
evidence=evidence,
|
|
1058
|
+
item="runtime",
|
|
1059
|
+
expected="the runtime prints a ready URL within the configured timeout",
|
|
1060
|
+
impact="DSH readiness could not be confirmed within the timeout.",
|
|
1061
|
+
),
|
|
1062
|
+
)
|
|
1063
|
+
|
|
1064
|
+
def _run_headless(self, context: VerifyContext, step_name: str = "boot") -> VerifyStep:
|
|
1065
|
+
command = self._command("boot", context)
|
|
1066
|
+
if command is None:
|
|
1067
|
+
return _step(step_name, "UNTESTED", "runner config has no headless command", evidence={"mode": self.mode})
|
|
1068
|
+
try:
|
|
1069
|
+
completed = subprocess.run(
|
|
1070
|
+
command,
|
|
1071
|
+
cwd=str(self.config["cwd"]) if isinstance(self.config.get("cwd"), str) else None,
|
|
1072
|
+
env=self._environment(context),
|
|
1073
|
+
capture_output=True,
|
|
1074
|
+
text=True,
|
|
1075
|
+
timeout=self._timeout(),
|
|
1076
|
+
check=False,
|
|
1077
|
+
)
|
|
1078
|
+
except (OSError, subprocess.TimeoutExpired) as error:
|
|
1079
|
+
evidence = {"command": command, "error": str(error)}
|
|
1080
|
+
return _step(
|
|
1081
|
+
step_name,
|
|
1082
|
+
"FAIL",
|
|
1083
|
+
"headless runtime command failed to complete",
|
|
1084
|
+
evidence=evidence,
|
|
1085
|
+
diagnostic=_runtime_diagnostic(
|
|
1086
|
+
code="HEADLESS_RUNNER_FAILED",
|
|
1087
|
+
stage="runtime",
|
|
1088
|
+
message="headless runtime command failed to complete",
|
|
1089
|
+
evidence=evidence,
|
|
1090
|
+
item="command",
|
|
1091
|
+
expected="the headless runtime command completes successfully",
|
|
1092
|
+
impact="The imported Profile could not be verified in headless mode.",
|
|
1093
|
+
),
|
|
1094
|
+
)
|
|
1095
|
+
self._last_headless = {"exitCode": completed.returncode, "stdout": completed.stdout, "stderr": completed.stderr}
|
|
1096
|
+
evidence = {"command": command, **self._last_headless}
|
|
1097
|
+
if completed.returncode == 0:
|
|
1098
|
+
return _step(step_name, "PASS", "headless runtime completed", evidence=evidence)
|
|
1099
|
+
return _step(
|
|
1100
|
+
step_name,
|
|
1101
|
+
"FAIL",
|
|
1102
|
+
"headless runtime exited with failure",
|
|
1103
|
+
evidence=evidence,
|
|
1104
|
+
diagnostic=_runtime_diagnostic(
|
|
1105
|
+
code="HEADLESS_RUNNER_FAILED",
|
|
1106
|
+
stage="runtime",
|
|
1107
|
+
message="headless runtime exited with failure",
|
|
1108
|
+
evidence=evidence,
|
|
1109
|
+
item="runtime",
|
|
1110
|
+
expected="the headless runtime exits with code 0",
|
|
1111
|
+
impact="The imported Profile failed in headless runtime.",
|
|
1112
|
+
),
|
|
1113
|
+
)
|
|
1114
|
+
|
|
1115
|
+
def _probe(self, step: str, context: VerifyContext) -> VerifyStep:
|
|
1116
|
+
command = self._command(step, context)
|
|
1117
|
+
if command is None:
|
|
1118
|
+
return _step(step, "UNTESTED", f"no explicit {step} probe command was supplied", evidence={"mode": self.mode})
|
|
1119
|
+
try:
|
|
1120
|
+
completed = subprocess.run(
|
|
1121
|
+
command,
|
|
1122
|
+
cwd=str(self.config["cwd"]) if isinstance(self.config.get("cwd"), str) else None,
|
|
1123
|
+
env=self._environment(context),
|
|
1124
|
+
capture_output=True,
|
|
1125
|
+
text=True,
|
|
1126
|
+
timeout=self._timeout(),
|
|
1127
|
+
check=False,
|
|
1128
|
+
)
|
|
1129
|
+
except (OSError, subprocess.TimeoutExpired) as error:
|
|
1130
|
+
evidence = {"command": command, "error": str(error)}
|
|
1131
|
+
return _step(
|
|
1132
|
+
step,
|
|
1133
|
+
"FAIL",
|
|
1134
|
+
f"{step} probe failed to complete",
|
|
1135
|
+
evidence=evidence,
|
|
1136
|
+
diagnostic=_runtime_diagnostic(
|
|
1137
|
+
code="PROBE_FAILED",
|
|
1138
|
+
stage="probe",
|
|
1139
|
+
message=f"{step} probe failed to complete",
|
|
1140
|
+
evidence=evidence,
|
|
1141
|
+
item=step,
|
|
1142
|
+
expected=f"the {step} probe command completes successfully",
|
|
1143
|
+
impact=f"The {step} runtime capability could not be verified.",
|
|
1144
|
+
),
|
|
1145
|
+
)
|
|
1146
|
+
evidence = {"command": command, "exitCode": completed.returncode, "stdout": completed.stdout, "stderr": completed.stderr}
|
|
1147
|
+
if completed.returncode == 0:
|
|
1148
|
+
return _step(step, "PASS", f"{step} probe completed", evidence=evidence)
|
|
1149
|
+
return _step(
|
|
1150
|
+
step,
|
|
1151
|
+
"FAIL",
|
|
1152
|
+
f"{step} probe failed",
|
|
1153
|
+
evidence=evidence,
|
|
1154
|
+
diagnostic=_runtime_diagnostic(
|
|
1155
|
+
code="PROBE_FAILED",
|
|
1156
|
+
stage="probe",
|
|
1157
|
+
message=f"{step} probe failed",
|
|
1158
|
+
evidence=evidence,
|
|
1159
|
+
item=step,
|
|
1160
|
+
expected=f"the {step} probe command exits with code 0",
|
|
1161
|
+
impact=f"The {step} runtime capability could not be verified.",
|
|
1162
|
+
),
|
|
1163
|
+
)
|
|
1164
|
+
|
|
1165
|
+
def _surface_ready(self, context: VerifyContext, step: str = "surface_ready") -> VerifyStep:
|
|
1166
|
+
if self.mode == "headless":
|
|
1167
|
+
if self._last_headless is None:
|
|
1168
|
+
return _step(step, "UNTESTED", "headless boot has not completed", evidence={})
|
|
1169
|
+
status = "PASS" if self._last_headless["exitCode"] == 0 else "FAIL"
|
|
1170
|
+
return _step(step, status, "headless completion is the ready signal", evidence=dict(self._last_headless))
|
|
1171
|
+
if not self._url:
|
|
1172
|
+
return _step(step, "UNTESTED", "boot did not provide a Web URL", evidence={})
|
|
1173
|
+
ownership = self._port_owner()
|
|
1174
|
+
if ownership["checked"] is not True:
|
|
1175
|
+
return _step(
|
|
1176
|
+
step,
|
|
1177
|
+
"UNTESTED",
|
|
1178
|
+
"could not verify that the Web port belongs to the test PID",
|
|
1179
|
+
evidence={"url": self._url, **ownership},
|
|
1180
|
+
)
|
|
1181
|
+
if ownership["portBelongsToProcess"] is not True:
|
|
1182
|
+
evidence = {"url": self._url, **ownership}
|
|
1183
|
+
return _step(
|
|
1184
|
+
step,
|
|
1185
|
+
"FAIL",
|
|
1186
|
+
"Web port does not belong to the test PID",
|
|
1187
|
+
evidence=evidence,
|
|
1188
|
+
diagnostic=_runtime_diagnostic(
|
|
1189
|
+
code="WEB_PORT_NOT_OWNED",
|
|
1190
|
+
stage="surface",
|
|
1191
|
+
message="Web port does not belong to the test PID",
|
|
1192
|
+
evidence=evidence,
|
|
1193
|
+
item="port",
|
|
1194
|
+
expected="the listening port belongs to the runtime process being verified",
|
|
1195
|
+
impact="Another process may occupy the DSH Web port.",
|
|
1196
|
+
),
|
|
1197
|
+
)
|
|
1198
|
+
try:
|
|
1199
|
+
with urllib.request.urlopen(self._url, timeout=self._timeout()) as response:
|
|
1200
|
+
code = response.status
|
|
1201
|
+
body = response.read()
|
|
1202
|
+
except (OSError, urllib.error.URLError) as error:
|
|
1203
|
+
evidence = {"url": self._url, "error": str(error), "pid": self.process.pid if self.process else None}
|
|
1204
|
+
return _step(
|
|
1205
|
+
step,
|
|
1206
|
+
"FAIL",
|
|
1207
|
+
"Web URL did not return an HTTP response",
|
|
1208
|
+
evidence=evidence,
|
|
1209
|
+
diagnostic=_runtime_diagnostic(
|
|
1210
|
+
code="WEB_HTTP_UNREACHABLE",
|
|
1211
|
+
stage="surface",
|
|
1212
|
+
message="Web URL did not return an HTTP response",
|
|
1213
|
+
evidence=evidence,
|
|
1214
|
+
item="url",
|
|
1215
|
+
expected="the DSH Web URL returns an HTTP response",
|
|
1216
|
+
impact="The DSH Web surface is not reachable.",
|
|
1217
|
+
),
|
|
1218
|
+
)
|
|
1219
|
+
expected = self.config.get("expectedStatus", 200)
|
|
1220
|
+
status = "PASS" if code == expected else "FAIL"
|
|
1221
|
+
evidence = {"url": self._url, "pid": self.process.pid if self.process else None, **ownership, "status": code, "expectedStatus": expected, "responseBytes": len(body)}
|
|
1222
|
+
if status == "PASS":
|
|
1223
|
+
return _step(step, "PASS", "Web HTTP readiness probe completed", evidence=evidence)
|
|
1224
|
+
return _step(
|
|
1225
|
+
step,
|
|
1226
|
+
"FAIL",
|
|
1227
|
+
"Web HTTP status did not match expected",
|
|
1228
|
+
evidence=evidence,
|
|
1229
|
+
diagnostic=_runtime_diagnostic(
|
|
1230
|
+
code="WEB_HTTP_STATUS_MISMATCH",
|
|
1231
|
+
stage="surface",
|
|
1232
|
+
message="Web HTTP status did not match expected",
|
|
1233
|
+
evidence=evidence,
|
|
1234
|
+
item="url",
|
|
1235
|
+
expected=f"the DSH Web URL returns HTTP {expected}",
|
|
1236
|
+
impact="The DSH Web surface returned an unexpected HTTP status.",
|
|
1237
|
+
),
|
|
1238
|
+
)
|
|
1239
|
+
|
|
1240
|
+
def _port_owner(self) -> dict[str, Any]:
|
|
1241
|
+
"""Check the listening PID for the adapter's URL without third-party dependencies."""
|
|
1242
|
+
|
|
1243
|
+
if self.process is None or not self._url:
|
|
1244
|
+
return {"checked": False, "portOwnerPid": None, "portBelongsToProcess": False, "ownershipError": "process or URL is unavailable"}
|
|
1245
|
+
return self._port_owner_for(self._url, self.process.pid)
|
|
1246
|
+
|
|
1247
|
+
@staticmethod
|
|
1248
|
+
def _port_owner_for(url: str | None, pid: int | None) -> dict[str, Any]:
|
|
1249
|
+
if not url or pid is None:
|
|
1250
|
+
return {"checked": False, "portOwnerPid": None, "portBelongsToProcess": False, "ownershipError": "process or URL is unavailable"}
|
|
1251
|
+
parsed = urllib.parse.urlparse(url)
|
|
1252
|
+
if parsed.port is None:
|
|
1253
|
+
return {"checked": False, "portOwnerPid": None, "portBelongsToProcess": False, "ownershipError": "URL has no port"}
|
|
1254
|
+
if os.name != "nt":
|
|
1255
|
+
return {"checked": False, "portOwnerPid": None, "portBelongsToProcess": False, "ownershipError": "port ownership probe is currently implemented for Windows netstat"}
|
|
1256
|
+
try:
|
|
1257
|
+
completed = subprocess.run(
|
|
1258
|
+
["netstat", "-ano", "-p", "tcp"],
|
|
1259
|
+
capture_output=True,
|
|
1260
|
+
text=True,
|
|
1261
|
+
timeout=10,
|
|
1262
|
+
check=False,
|
|
1263
|
+
)
|
|
1264
|
+
except (OSError, subprocess.TimeoutExpired) as error:
|
|
1265
|
+
return {"checked": False, "portOwnerPid": None, "portBelongsToProcess": False, "ownershipError": str(error)}
|
|
1266
|
+
if completed.returncode != 0:
|
|
1267
|
+
return {"checked": False, "portOwnerPid": None, "portBelongsToProcess": False,
|
|
1268
|
+
"ownershipError": "netstat failed", "exitCode": completed.returncode}
|
|
1269
|
+
port_owner: int | None = None
|
|
1270
|
+
for line in completed.stdout.splitlines():
|
|
1271
|
+
fields = line.split()
|
|
1272
|
+
if len(fields) < 5 or fields[0].upper() != "TCP" or fields[3].upper() != "LISTENING":
|
|
1273
|
+
continue
|
|
1274
|
+
port_match = re.search(r":(\d+)$", fields[1])
|
|
1275
|
+
if port_match is None or int(port_match.group(1)) != parsed.port:
|
|
1276
|
+
continue
|
|
1277
|
+
try:
|
|
1278
|
+
port_owner = int(fields[4])
|
|
1279
|
+
except ValueError:
|
|
1280
|
+
continue
|
|
1281
|
+
break
|
|
1282
|
+
return {
|
|
1283
|
+
"checked": True,
|
|
1284
|
+
"portOwnerPid": port_owner,
|
|
1285
|
+
"portBelongsToProcess": port_owner == pid,
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
def run_step(self, step: str, context: VerifyContext) -> VerifyStep:
|
|
1289
|
+
if step == "boot":
|
|
1290
|
+
return self._run_headless(context) if self.mode == "headless" else self._spawn_web(context)
|
|
1291
|
+
if step == "surface_ready" or step == "restart_surface_ready":
|
|
1292
|
+
return self._surface_ready(context, step)
|
|
1293
|
+
if step == "restart":
|
|
1294
|
+
stopped = self.close()
|
|
1295
|
+
restarted = self._run_headless(context, step) if self.mode == "headless" else self._rename(self._spawn_web(context), step)
|
|
1296
|
+
evidence = dict(restarted.evidence)
|
|
1297
|
+
evidence["stoppedProcess"] = stopped
|
|
1298
|
+
# 已完成的headless命令没有Web端口;不适用不是“释放未知”。
|
|
1299
|
+
no_web_port = (self.mode == "headless" and stopped.get("processExited") is True
|
|
1300
|
+
and stopped.get("portApplicable") is False)
|
|
1301
|
+
if restarted.status == "PASS" and stopped.get("portReleased") is not True and not no_web_port:
|
|
1302
|
+
return _step(
|
|
1303
|
+
step,
|
|
1304
|
+
"FAIL",
|
|
1305
|
+
"restart completed without proof that the previous process port was released",
|
|
1306
|
+
evidence=evidence,
|
|
1307
|
+
diagnostic=_runtime_diagnostic(
|
|
1308
|
+
code="RESTART_PORT_NOT_RELEASED",
|
|
1309
|
+
stage="restart",
|
|
1310
|
+
message="restart completed without proof that the previous process port was released",
|
|
1311
|
+
evidence=evidence,
|
|
1312
|
+
item="port",
|
|
1313
|
+
expected="the previous runtime process releases the Web port before restart",
|
|
1314
|
+
impact="The restarted DSH may conflict with a stale process on the same port.",
|
|
1315
|
+
),
|
|
1316
|
+
)
|
|
1317
|
+
return VerifyStep(
|
|
1318
|
+
name=restarted.name,
|
|
1319
|
+
status=restarted.status,
|
|
1320
|
+
message=restarted.message,
|
|
1321
|
+
required=restarted.required,
|
|
1322
|
+
evidence=evidence,
|
|
1323
|
+
diagnostic=restarted.diagnostic,
|
|
1324
|
+
)
|
|
1325
|
+
if step in {"new_session", "basic_response", "core_tool"}:
|
|
1326
|
+
return self._probe(step, context)
|
|
1327
|
+
return _step(step, "UNTESTED", f"unsupported subprocess verify step: {step}", evidence={})
|
|
1328
|
+
|
|
1329
|
+
@staticmethod
|
|
1330
|
+
def _rename(value: VerifyStep, name: str) -> VerifyStep:
|
|
1331
|
+
return VerifyStep(name=name, status=value.status, message=value.message, required=value.required, evidence=value.evidence, diagnostic=value.diagnostic)
|
|
1332
|
+
|
|
1333
|
+
def run_plugin_test(self, plugin: str, test_name: str, context: VerifyContext) -> VerifyStep:
|
|
1334
|
+
configured = self.config.get("pluginTests", {})
|
|
1335
|
+
command = configured.get(plugin, {}).get(test_name) if isinstance(configured, Mapping) and isinstance(configured.get(plugin), Mapping) else None
|
|
1336
|
+
if not isinstance(command, list) or any(not isinstance(item, str) for item in command):
|
|
1337
|
+
return _step(f"plugin:{plugin}:{test_name}", "UNTESTED", "no explicit plugin smoke command was supplied", required=True, evidence={"plugin": plugin, "test": test_name})
|
|
1338
|
+
command = [self._format(item, context) for item in command]
|
|
1339
|
+
try:
|
|
1340
|
+
completed = subprocess.run(command, cwd=str(self.config["cwd"]) if isinstance(self.config.get("cwd"), str) else None, env=self._environment(context), capture_output=True, text=True, timeout=self._timeout(), check=False)
|
|
1341
|
+
except (OSError, subprocess.TimeoutExpired) as error:
|
|
1342
|
+
evidence = {"command": command, "error": str(error)}
|
|
1343
|
+
return _step(
|
|
1344
|
+
f"plugin:{plugin}:{test_name}",
|
|
1345
|
+
"FAIL",
|
|
1346
|
+
"plugin smoke command failed to complete",
|
|
1347
|
+
required=True,
|
|
1348
|
+
evidence=evidence,
|
|
1349
|
+
diagnostic=_runtime_diagnostic(
|
|
1350
|
+
code="PLUGIN_SMOKE_FAILED",
|
|
1351
|
+
stage="plugin-smoke",
|
|
1352
|
+
message="plugin smoke command failed to complete",
|
|
1353
|
+
evidence=evidence,
|
|
1354
|
+
item=plugin,
|
|
1355
|
+
expected=f"the plugin smoke command for {plugin} completes successfully",
|
|
1356
|
+
impact=f"Plugin {plugin} smoke test could not be verified.",
|
|
1357
|
+
),
|
|
1358
|
+
)
|
|
1359
|
+
evidence = {"command": command, "exitCode": completed.returncode, "stdout": completed.stdout, "stderr": completed.stderr}
|
|
1360
|
+
if completed.returncode == 0:
|
|
1361
|
+
return _step(f"plugin:{plugin}:{test_name}", "PASS", "plugin smoke command completed", required=True, evidence=evidence)
|
|
1362
|
+
return _step(
|
|
1363
|
+
f"plugin:{plugin}:{test_name}",
|
|
1364
|
+
"FAIL",
|
|
1365
|
+
"plugin smoke command failed",
|
|
1366
|
+
required=True,
|
|
1367
|
+
evidence=evidence,
|
|
1368
|
+
diagnostic=_runtime_diagnostic(
|
|
1369
|
+
code="PLUGIN_SMOKE_FAILED",
|
|
1370
|
+
stage="plugin-smoke",
|
|
1371
|
+
message="plugin smoke command failed",
|
|
1372
|
+
evidence=evidence,
|
|
1373
|
+
item=plugin,
|
|
1374
|
+
expected=f"the plugin smoke command for {plugin} exits with code 0",
|
|
1375
|
+
impact=f"Plugin {plugin} smoke test failed.",
|
|
1376
|
+
),
|
|
1377
|
+
)
|
|
1378
|
+
|
|
1379
|
+
@staticmethod
|
|
1380
|
+
def _endpoint_release(url: str) -> dict[str, Any]:
|
|
1381
|
+
"""只核查已观察的本地 TCP 端点;拒绝连接才证明监听已释放。"""
|
|
1382
|
+
evidence: dict[str, Any] = {"checked": False, "released": None, "transport": "tcp"}
|
|
1383
|
+
try:
|
|
1384
|
+
parsed = urllib.parse.urlparse(url)
|
|
1385
|
+
address = ipaddress.ip_address(parsed.hostname or "")
|
|
1386
|
+
if not address.is_loopback or parsed.port is None:
|
|
1387
|
+
raise ValueError("only explicit loopback endpoints with a port are checked")
|
|
1388
|
+
evidence.update({"url": f"{parsed.scheme}://{'[' + str(address) + ']' if address.version == 6 else address}:{parsed.port}",
|
|
1389
|
+
"host": str(address), "port": parsed.port})
|
|
1390
|
+
with socket.socket(socket.AF_INET6 if address.version == 6 else socket.AF_INET, socket.SOCK_STREAM) as client:
|
|
1391
|
+
client.settimeout(0.3)
|
|
1392
|
+
code = client.connect_ex((str(address), parsed.port))
|
|
1393
|
+
evidence["socketError"] = code
|
|
1394
|
+
if code == 0:
|
|
1395
|
+
evidence.update(checked=True, released=False)
|
|
1396
|
+
elif code in {errno.ECONNREFUSED, 10061}:
|
|
1397
|
+
evidence.update(checked=True, released=True)
|
|
1398
|
+
else:
|
|
1399
|
+
evidence["reason"] = "连接结果不能证明端点已释放"
|
|
1400
|
+
except (OSError, ValueError) as error:
|
|
1401
|
+
evidence["reason"] = str(error)
|
|
1402
|
+
return evidence
|
|
1403
|
+
|
|
1404
|
+
def close(self) -> dict[str, Any]:
|
|
1405
|
+
process = self.process
|
|
1406
|
+
url = self._url
|
|
1407
|
+
if process is None:
|
|
1408
|
+
if self._last_cleanup is not None:
|
|
1409
|
+
return self._last_cleanup
|
|
1410
|
+
completed = self._last_headless is not None
|
|
1411
|
+
return {"pid": None, "url": None, "processExited": True if completed else None,
|
|
1412
|
+
"exitCode": self._last_headless.get("exitCode") if completed else None,
|
|
1413
|
+
"portReleased": None, "portApplicable": False, "endpointsApplicable": False,
|
|
1414
|
+
"endpoints": [], "attempts": [],
|
|
1415
|
+
"reason": "headless subprocess.run completed" if completed else "no runtime process was observed"}
|
|
1416
|
+
pid = process.pid
|
|
1417
|
+
actions: list[str] = []
|
|
1418
|
+
errors: list[dict[str, str]] = []
|
|
1419
|
+
exit_code = None
|
|
1420
|
+
try:
|
|
1421
|
+
if process.poll() is None:
|
|
1422
|
+
actions.append("terminate")
|
|
1423
|
+
process.terminate()
|
|
1424
|
+
try:
|
|
1425
|
+
process.wait(timeout=5)
|
|
1426
|
+
except subprocess.TimeoutExpired:
|
|
1427
|
+
actions.append("kill")
|
|
1428
|
+
process.kill()
|
|
1429
|
+
process.wait(timeout=5)
|
|
1430
|
+
exit_code = process.poll()
|
|
1431
|
+
except Exception as error:
|
|
1432
|
+
errors.append({"operation": "stop-process", "error": str(error), "exception": type(error).__name__})
|
|
1433
|
+
try:
|
|
1434
|
+
exit_code = process.poll()
|
|
1435
|
+
except Exception as poll_error:
|
|
1436
|
+
errors.append({"operation": "poll-process", "error": str(poll_error), "exception": type(poll_error).__name__})
|
|
1437
|
+
port_evidence: dict[str, Any] = {"checked": False, "reason": "no observed Web URL"}
|
|
1438
|
+
port_released: bool | None = None
|
|
1439
|
+
endpoints: list[dict[str, Any]] = []
|
|
1440
|
+
if self.mode == "web" and url:
|
|
1441
|
+
deadline = time.monotonic() + 2.0
|
|
1442
|
+
while True:
|
|
1443
|
+
try:
|
|
1444
|
+
port_evidence = self._port_owner_for(url, pid)
|
|
1445
|
+
except Exception as error:
|
|
1446
|
+
port_evidence = {"checked": False, "ownershipError": str(error)}
|
|
1447
|
+
endpoints = [{"url": url, **self._endpoint_release(url)}]
|
|
1448
|
+
if port_evidence.get("checked") is not True:
|
|
1449
|
+
port_released = None
|
|
1450
|
+
break
|
|
1451
|
+
port_released = port_evidence.get("portOwnerPid") is None
|
|
1452
|
+
if port_released and endpoints[0].get("released") is True:
|
|
1453
|
+
break
|
|
1454
|
+
if time.monotonic() >= deadline:
|
|
1455
|
+
break
|
|
1456
|
+
time.sleep(0.05)
|
|
1457
|
+
for thread in self._threads:
|
|
1458
|
+
thread.join(timeout=1)
|
|
1459
|
+
evidence = {
|
|
1460
|
+
"pid": pid,
|
|
1461
|
+
"url": url,
|
|
1462
|
+
"exitCode": exit_code,
|
|
1463
|
+
"processExited": True if exit_code is not None else (False if not errors else None),
|
|
1464
|
+
"portReleased": port_released,
|
|
1465
|
+
"portEvidence": port_evidence,
|
|
1466
|
+
"portApplicable": self.mode == "web", "endpointsApplicable": self.mode == "web",
|
|
1467
|
+
"endpoints": endpoints,
|
|
1468
|
+
"endpointCoverage": "observed-web-url-only; Inspector/PTY/unknown descendants are not inferred",
|
|
1469
|
+
"actions": actions, "errors": errors,
|
|
1470
|
+
}
|
|
1471
|
+
self._cleanup_attempts.append(evidence)
|
|
1472
|
+
self._last_cleanup = {**evidence, "attempts": list(self._cleanup_attempts)}
|
|
1473
|
+
self.process = None
|
|
1474
|
+
self._url = None
|
|
1475
|
+
return self._last_cleanup
|
|
1476
|
+
|
|
1477
|
+
|
|
1478
|
+
def load_smoke_contracts(path: str | os.PathLike[str]) -> tuple[SmokeTestContract, ...]:
|
|
1479
|
+
"""Load one JSON object or an array of plugin smoke contracts."""
|
|
1480
|
+
|
|
1481
|
+
value = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
1482
|
+
values = value if isinstance(value, list) else [value]
|
|
1483
|
+
if any(not isinstance(item, Mapping) for item in values):
|
|
1484
|
+
raise ValueError("plugin contract JSON must contain an object or an array of objects")
|
|
1485
|
+
return tuple(SmokeTestContract.from_mapping(item) for item in values)
|