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.
Files changed (39) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +11 -290
  3. package/README.zh-CN.md +2 -303
  4. package/THIRD-PARTY-LICENSE.txt +21 -0
  5. package/cordis.patch.yml +1 -6
  6. package/core/dsh_pack/_version.py +1 -0
  7. package/core/dsh_pack/bundle.py +74 -0
  8. package/core/dsh_pack/cli.py +81 -18
  9. package/core/dsh_pack/desktop_verify.py +304 -0
  10. package/core/dsh_pack/diagnostics.py +1170 -0
  11. package/core/dsh_pack/errors.py +16 -1
  12. package/core/dsh_pack/export.py +86 -103
  13. package/core/dsh_pack/export_plan.py +96 -0
  14. package/core/dsh_pack/identity.py +25 -0
  15. package/core/dsh_pack/importer.py +1598 -1347
  16. package/core/dsh_pack/network.py +5 -4
  17. package/core/dsh_pack/pack.py +14 -2
  18. package/core/dsh_pack/portability.py +392 -0
  19. package/core/dsh_pack/preflight.py +63 -16
  20. package/core/dsh_pack/troubleshooting.py +94 -0
  21. package/core/dsh_pack/user_cli.py +16 -0
  22. package/core/dsh_pack/validation.py +64 -4
  23. package/core/dsh_pack/verify.py +1485 -1336
  24. package/core/dsh_pack/windows_verify.py +177 -0
  25. package/desktop-patch-helper.mjs +140 -0
  26. package/lib/client.js +292 -102
  27. package/lib/index.d.ts +7 -1
  28. package/lib/index.js +1121 -229
  29. package/package.json +13 -5
  30. package/restart-helper.mjs +121 -121
  31. package/runtime/python/LICENSE.txt +676 -676
  32. package/runtime/python/__install__.json +81 -81
  33. package/runtime/python/python314._pth +6 -6
  34. package/skills/dsh-crate-troubleshooting/SKILL.md +172 -0
  35. package/skills/dsh-crate-troubleshooting/compatibility.json +11 -0
  36. package/skills/dsh-crate-troubleshooting/reference/diagnostics.generated.md +96 -0
  37. package/skills/dsh-crate-troubleshooting/reference/repair-boundaries.md +60 -0
  38. package/skills/dsh-crate-troubleshooting/reference/stages.generated.md +22 -0
  39. package/verify-desktop.mjs +314 -0
@@ -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 os
13
- import posixpath
14
- import re
15
- import subprocess
16
- import threading
17
- import time
18
- import urllib.error
19
- import urllib.parse
20
- import urllib.request
21
- from .errors import _default_checks
22
- from dataclasses import dataclass, field
23
- from pathlib import Path
24
- from typing import Any, Mapping, Protocol, Sequence
25
-
26
-
27
- VERIFY_STATUSES = ("PASS", "FAIL", "UNTESTED", "DEGRADED")
28
- RUNTIME_STEPS = (
29
- ("boot", "DSH boot"),
30
- ("surface_ready", "Web/headless ready"),
31
- ("new_session", "new session"),
32
- ("basic_response", "basic response"),
33
- ("core_tool", "core tool"),
34
- ("restart", "restart"),
35
- ("restart_surface_ready", "verify after restart"),
36
- )
37
-
38
-
39
- @dataclass(frozen=True)
40
- class SmokeTestContract:
41
- """Explicit plugin smoke tests; no test means UNTESTED, never PASS."""
42
-
43
- plugin: str
44
- tests: tuple[str, ...]
45
- required: bool = True
46
-
47
- @classmethod
48
- def from_mapping(cls, value: Mapping[str, Any]) -> "SmokeTestContract":
49
- plugin = value.get("plugin")
50
- tests = value.get("tests")
51
- required = value.get("required", True)
52
- if not isinstance(plugin, str) or not plugin.strip():
53
- raise ValueError("plugin smoke contract requires a non-empty plugin")
54
- if not isinstance(tests, list) or any(not isinstance(item, str) or not item.strip() for item in tests):
55
- raise ValueError(f"plugin smoke contract tests must be a list of names: {plugin}")
56
- if not isinstance(required, bool):
57
- raise ValueError(f"plugin smoke contract required must be boolean: {plugin}")
58
- return cls(plugin=plugin, tests=tuple(tests), required=required)
59
-
60
- def as_dict(self) -> dict[str, Any]:
61
- return {"plugin": self.plugin, "required": self.required, "tests": list(self.tests)}
62
-
63
-
64
- @dataclass(frozen=True)
65
- class VerifyStep:
66
- name: str
67
- status: str
68
- message: str
69
- required: bool = True
70
- evidence: dict[str, Any] = field(default_factory=dict)
71
- diagnostic: dict[str, Any] | None = None
72
-
73
- def __post_init__(self) -> None:
74
- if self.status not in VERIFY_STATUSES:
75
- raise ValueError(f"unsupported verify status: {self.status}")
76
-
77
- def as_dict(self) -> dict[str, Any]:
78
- value: dict[str, Any] = {
79
- "name": self.name,
80
- "status": self.status,
81
- "message": self.message,
82
- "required": self.required,
83
- "evidence": self.evidence,
84
- }
85
- if self.diagnostic is not None:
86
- value["diagnostic"] = self.diagnostic
87
- return value
88
-
89
-
90
- @dataclass(frozen=True)
91
- class VerifyContext:
92
- dsh_home: Path
93
- profile_name: str
94
- profile_path: Path
95
- mode: str
96
- current_environment: Mapping[str, Any]
97
- available_secret_names: frozenset[str]
98
- metadata_path: Path
99
- previous_steps: Mapping[str, VerifyStep]
100
-
101
-
102
- class VerifyAdapter(Protocol):
103
- """Runtime adapter contract used by ``verify_profile``.
104
-
105
- ``run_step`` must perform the requested real action and return either a
106
- ``VerifyStep`` or a mapping with ``status``, ``message`` and ``evidence``.
107
- ``run_plugin_test`` is optional; if absent, plugin tests are UNTESTED.
108
- """
109
-
110
- def run_step(self, step: str, context: VerifyContext) -> VerifyStep | Mapping[str, Any]:
111
- ...
112
-
113
-
114
- def _step(
115
- name: str,
116
- status: str,
117
- message: str,
118
- *,
119
- required: bool = True,
120
- evidence: Mapping[str, Any] | None = None,
121
- diagnostic: Mapping[str, Any] | None = None,
122
- ) -> VerifyStep:
123
- return VerifyStep(
124
- name=name,
125
- status=status,
126
- message=message,
127
- required=required,
128
- evidence=dict(evidence or {}),
129
- diagnostic=dict(diagnostic) if diagnostic else None,
130
- )
131
-
132
-
133
- def _runtime_diagnostic(
134
- *,
135
- code: str,
136
- stage: str,
137
- message: str,
138
- evidence: Mapping[str, Any],
139
- item: str,
140
- expected: str,
141
- impact: str,
142
- can_continue: bool = False,
143
- ) -> dict[str, Any]:
144
- """Structured diagnostic attached to a failed runtime VerifyStep."""
145
- return {
146
- "code": code,
147
- "stage": stage,
148
- "severity": "BLOCKER",
149
- "item": item,
150
- "expected": expected,
151
- "observed": {"message": message, "code": code},
152
- "evidence": dict(evidence),
153
- "impact": impact,
154
- "canContinue": can_continue,
155
- "suggestedChecks": _default_checks(stage, code),
156
- "message": message,
157
- }
158
-
159
-
160
- def _normalise_observation(name: str, value: VerifyStep | Mapping[str, Any], *, required: bool) -> VerifyStep:
161
- if isinstance(value, VerifyStep):
162
- return value
163
- if not isinstance(value, Mapping):
164
- return _step(
165
- name,
166
- "FAIL",
167
- "Verify adapter returned an invalid observation",
168
- required=required,
169
- evidence={"observedType": type(value).__name__},
170
- )
171
- status = value.get("status")
172
- if status not in VERIFY_STATUSES:
173
- return _step(
174
- name,
175
- "FAIL",
176
- "Verify adapter returned an unsupported status",
177
- required=required,
178
- evidence={"observed": dict(value)},
179
- )
180
- return _step(
181
- name,
182
- status,
183
- str(value.get("message", name)),
184
- required=bool(value.get("required", required)),
185
- evidence=value.get("evidence") if isinstance(value.get("evidence"), Mapping) else {},
186
- )
187
-
188
-
189
- def _json_object(path: Path) -> tuple[dict[str, Any] | None, str | None]:
190
- try:
191
- value = json.loads(path.read_text(encoding="utf-8"))
192
- except (OSError, UnicodeDecodeError, json.JSONDecodeError) as error:
193
- return None, str(error)
194
- if not isinstance(value, dict):
195
- return None, "JSON root must be an object"
196
- return value, None
197
-
198
-
199
- def _package_manifest(profile_path: Path, name: str) -> tuple[dict[str, Any] | None, str | None]:
200
- package_path = profile_path / "node_modules" / Path(*name.split("/")) / "package.json"
201
- value, error = _json_object(package_path)
202
- if error:
203
- return None, f"{package_path}: {error}"
204
- return value, None
205
-
206
-
207
- def _profile_bundles(profile_manifest: Mapping[str, Any]) -> tuple[list[str] | None, str | None]:
208
- dsh = profile_manifest.get("dsh", {})
209
- if dsh is None:
210
- dsh = {}
211
- if not isinstance(dsh, Mapping):
212
- return None, "profile package.json dsh must be an object"
213
- profile = dsh.get("profile", {})
214
- if profile is None:
215
- profile = {}
216
- if not isinstance(profile, Mapping):
217
- return None, "profile package.json dsh.profile must be an object"
218
- bundles = profile.get("bundles", [])
219
- if not isinstance(bundles, list) or any(not isinstance(name, str) or not name for name in bundles):
220
- return None, "profile package.json dsh.profile.bundles must be a list of names"
221
- if len(set(bundles)) != len(bundles):
222
- return None, "profile package.json dsh.profile.bundles contains duplicates"
223
- return list(bundles), None
224
-
225
-
226
- def _resolve_installed_package_dir(anchor_dir: Path, name: str) -> Path | None:
227
- """Node-style node_modules lookup from an anchor directory.
228
-
229
- Mirrors DSH ``packageDirFromAnchor`` (installation anchor, then the profile
230
- directory): probes the profile-local ``node_modules``, the shared
231
- ``profiles/node_modules`` layer, home-level ``node_modules``, then each
232
- ancestor. This matches what the Cordis Loader would import from the same
233
- anchor.
234
- """
235
- parts = Path(*name.split("/"))
236
- current = anchor_dir
237
- while True:
238
- candidate = current / "node_modules" / parts
239
- if (candidate / "package.json").is_file():
240
- return candidate
241
- parent = current.parent
242
- if parent == current:
243
- return None
244
- current = parent
245
- return None
246
-
247
-
248
- def _internal_composition_issues(
249
- profile_path: Path,
250
- dependencies: Mapping[str, Any],
251
- bundles: Sequence[str],
252
- ) -> list[dict[str, Any]]:
253
- """Verify a Profile against its own manifest when no Pack metadata exists.
254
-
255
- Used when the Crate Import metadata directory for this Profile is absent
256
- (the Profile was not created by a Crate Import, or the metadata was
257
- removed). This can never claim a Pack round-trip: it only checks that
258
- declared Bundles are installed with their patch files, and that declared
259
- dependencies resolve. The caller marks the Pack-comparison dimension as
260
- UNTESTED.
261
- """
262
- issues: list[dict[str, Any]] = []
263
- declared = dict(dependencies) if isinstance(dependencies, Mapping) else {}
264
- for name in bundles:
265
- package_dir = _resolve_installed_package_dir(profile_path, name)
266
- if package_dir is None:
267
- issues.append({"code": "BUNDLE_NOT_INSTALLED", "bundle": name, "error": "not resolvable from the profile or shared node_modules layers"})
268
- continue
269
- installed, error = _json_object(package_dir / "package.json")
270
- if installed is None:
271
- issues.append({"code": "BUNDLE_NOT_INSTALLED", "bundle": name, "error": error})
272
- continue
273
- dsh = installed.get("dsh", {})
274
- if not isinstance(dsh, Mapping):
275
- dsh = {}
276
- bundle = dsh.get("bundle", {})
277
- patch = bundle.get("patch") if isinstance(bundle, Mapping) else None
278
- normalized_patch = posixpath.normpath(patch.replace("\\", "/")) if isinstance(patch, str) else ""
279
- safe_patch = (
280
- normalized_patch not in {"", ".", ".."}
281
- and not normalized_patch.startswith("../")
282
- and not normalized_patch.startswith("/")
283
- and ".." not in normalized_patch.split("/")
284
- )
285
- patch_path = package_dir / Path(*normalized_patch.split("/")) if safe_patch else None
286
- if not isinstance(patch, str) or not patch.strip() or patch_path is None or not patch_path.is_file():
287
- issues.append({
288
- "code": "BUNDLE_PATCH_MISSING",
289
- "bundle": name,
290
- "patch": patch,
291
- "path": str(patch_path) if patch_path else None,
292
- })
293
- for name, spec in declared.items():
294
- package_dir = _resolve_installed_package_dir(profile_path, name)
295
- if package_dir is None:
296
- issues.append({
297
- "code": "DEPENDENCY_NOT_INSTALLED",
298
- "plugin": name,
299
- "spec": spec,
300
- "error": "not resolvable from the profile or shared node_modules layers",
301
- })
302
- return issues
303
-
304
-
305
- def _composition_step(options: "VerifyOptions") -> VerifyStep:
306
- profile_path = options.dsh_home / "profiles" / options.profile_name
307
- metadata_path = options.dsh_home / ".dsh-pack" / "imports" / options.profile_name
308
- package_path = profile_path / "package.json"
309
- profile_manifest, profile_error = _json_object(package_path)
310
- if profile_error or profile_manifest is None:
311
- return _step(
312
- "composition",
313
- "FAIL",
314
- "prepared Profile package.json cannot be read",
315
- evidence={"path": str(package_path), "error": profile_error},
316
- )
317
- bundles, bundle_error = _profile_bundles(profile_manifest)
318
- if bundle_error or bundles is None:
319
- return _step("composition", "FAIL", bundle_error or "invalid Bundle composition", evidence={"path": str(package_path)})
320
-
321
- manifest_file = metadata_path / "manifest.json"
322
- lock_file = metadata_path / "plugins.lock.json"
323
- prepared_file = metadata_path / "prepared.json"
324
- metadata_present = metadata_path.is_dir() and any(
325
- candidate.is_file() for candidate in (manifest_file, lock_file, prepared_file)
326
- )
327
- if not metadata_present:
328
- # This Profile was not created by a Crate Import, or the Import
329
- # metadata was removed. The Pack-comparison dimension is UNTESTED:
330
- # only an internal consistency check against the Profile's own
331
- # manifest is possible, never a claimed round-trip.
332
- dependencies = profile_manifest.get("dependencies", {})
333
- if not isinstance(dependencies, Mapping):
334
- return _step(
335
- "composition",
336
- "FAIL",
337
- "Profile dependencies is not an object",
338
- evidence={"path": str(package_path)},
339
- )
340
- internal_issues = _internal_composition_issues(profile_path, dependencies, bundles)
341
- if internal_issues:
342
- return _step(
343
- "composition",
344
- "FAIL",
345
- "Crate Import metadata is absent and the Profile fails its internal consistency check",
346
- evidence={
347
- "metadataPath": str(metadata_path),
348
- "packComparison": "UNTESTED",
349
- "issues": internal_issues,
350
- },
351
- )
352
- return _step(
353
- "composition",
354
- "DEGRADED",
355
- "Crate Import metadata is absent; only internal consistency was checked, Pack round-trip UNTESTED",
356
- evidence={
357
- "metadataPath": str(metadata_path),
358
- "profileBundles": bundles,
359
- "packComparison": "UNTESTED",
360
- },
361
- )
362
- metadata_manifest, manifest_error = _json_object(manifest_file)
363
- lock, lock_error = _json_object(lock_file)
364
- prepared, prepared_error = _json_object(prepared_file)
365
- if manifest_error or lock_error or prepared_error or metadata_manifest is None or lock is None or prepared is None:
366
- return _step(
367
- "composition",
368
- "FAIL",
369
- "prepared Import metadata is incomplete",
370
- evidence={
371
- "metadataPath": str(metadata_path),
372
- "manifestError": manifest_error,
373
- "lockError": lock_error,
374
- "preparedError": prepared_error,
375
- },
376
- )
377
- if prepared.get("status") != "prepared":
378
- return _step(
379
- "composition",
380
- "FAIL",
381
- "Import metadata is not marked prepared",
382
- evidence={"metadataPath": str(metadata_path), "preparedStatus": prepared.get("status")},
383
- )
384
- raw_plugins = lock.get("plugins")
385
- if not isinstance(raw_plugins, list) or any(not isinstance(plugin, Mapping) for plugin in raw_plugins):
386
- return _step("composition", "FAIL", "plugins.lock.json plugins is invalid", evidence={"path": str(metadata_path / "plugins.lock.json")})
387
-
388
- dependencies = profile_manifest.get("dependencies", {})
389
- if not isinstance(dependencies, Mapping):
390
- return _step("composition", "FAIL", "Profile dependencies is not an object", evidence={"path": str(package_path)})
391
-
392
- expected_bundles: list[str] = []
393
- installed_rows: list[dict[str, Any]] = []
394
- issues: list[dict[str, Any]] = []
395
- for index, plugin in enumerate(raw_plugins):
396
- name = plugin.get("name")
397
- if not isinstance(name, str) or not name:
398
- issues.append({"code": "PLUGIN_NAME_MISSING", "index": index})
399
- continue
400
- requested = plugin.get("requested")
401
- requested_specifier = requested.get("specifier") if isinstance(requested, Mapping) else None
402
- resolved = plugin.get("resolved")
403
- resolved_version = resolved.get("version") if isinstance(resolved, Mapping) else None
404
- declared_spec = dependencies.get(name)
405
- if declared_spec is not None and declared_spec != requested_specifier:
406
- issues.append({
407
- "code": "PROFILE_SPEC_MISMATCH",
408
- "plugin": name,
409
- "expected": requested_specifier,
410
- "observed": declared_spec,
411
- })
412
- bundle = plugin.get("bundle")
413
- enabled = isinstance(bundle, Mapping) and bundle.get("enabled") is True
414
- if enabled:
415
- expected_bundles.append(name)
416
- patch = bundle.get("patch")
417
- package_path_for_plugin = profile_path / "node_modules" / Path(*name.split("/"))
418
- normalized_patch = posixpath.normpath(patch.replace("\\", "/")) if isinstance(patch, str) else ""
419
- safe_patch = (
420
- normalized_patch not in {"", ".", ".."}
421
- and not normalized_patch.startswith("../")
422
- and not normalized_patch.startswith("/")
423
- and ".." not in normalized_patch.split("/")
424
- )
425
- patch_path = package_path_for_plugin / Path(*normalized_patch.split("/")) if safe_patch else None
426
- if not isinstance(patch, str) or not patch.strip() or patch_path is None or not patch_path.is_file():
427
- issues.append({
428
- "code": "BUNDLE_PATCH_MISSING",
429
- "plugin": name,
430
- "patch": patch,
431
- "path": str(patch_path) if patch_path else None,
432
- })
433
- installed, installed_error = _package_manifest(profile_path, name)
434
- installed_row = {
435
- "name": name,
436
- "required": plugin.get("required") is True,
437
- "mode": (plugin.get("artifact") or {}).get("mode") if isinstance(plugin.get("artifact"), Mapping) else None,
438
- "expectedVersion": resolved_version,
439
- "installed": installed,
440
- "error": installed_error,
441
- }
442
- installed_rows.append(installed_row)
443
- if installed is None:
444
- issue = {"code": "PLUGIN_NOT_INSTALLED", "plugin": name, "error": installed_error}
445
- if plugin.get("required") is True:
446
- issues.append(issue)
447
- else:
448
- issue["optional"] = True
449
- issues.append(issue)
450
- else:
451
- mismatch = installed.get("name") != name or installed.get("version") != resolved_version
452
- if mismatch:
453
- issues.append({
454
- "code": "PACKAGE_IDENTITY_MISMATCH",
455
- "plugin": name,
456
- "expected": {"name": name, "version": resolved_version},
457
- "observed": {"name": installed.get("name"), "version": installed.get("version")},
458
- })
459
-
460
- if bundles != expected_bundles:
461
- issues.append({"code": "BUNDLE_ORDER_MISMATCH", "expected": expected_bundles, "observed": bundles})
462
-
463
- required_issues = [issue for issue in issues if not issue.get("optional")]
464
- if required_issues:
465
- status = "FAIL"
466
- message = "Profile composition does not match prepared Pack metadata"
467
- elif issues:
468
- status = "DEGRADED"
469
- message = "Profile composition is usable but optional plugin evidence is incomplete"
470
- else:
471
- status = "PASS"
472
- message = "Profile, lock, Bundle order, patches, and installed package identities agree"
473
- return _step(
474
- "composition",
475
- status,
476
- message,
477
- evidence={
478
- "profilePath": str(profile_path),
479
- "metadataPath": str(metadata_path),
480
- "profileBundles": bundles,
481
- "lockBundleOrder": expected_bundles,
482
- "installedPlugins": installed_rows,
483
- "pack": prepared.get("pack"),
484
- "issues": issues,
485
- "requiredSecrets": metadata_manifest.get("requiredSecrets", []),
486
- },
487
- )
488
-
489
-
490
- @dataclass(frozen=True)
491
- class VerifyOptions:
492
- dsh_home: Path
493
- profile_name: str
494
- mode: str = "web"
495
- current_environment: Mapping[str, Any] = field(default_factory=dict)
496
- available_secret_names: frozenset[str] = frozenset()
497
- adapter: VerifyAdapter | None = None
498
- plugin_contracts: tuple[SmokeTestContract, ...] = ()
499
-
500
- def __post_init__(self) -> None:
501
- if self.mode not in {"web", "headless"}:
502
- raise ValueError("VerifyOptions.mode must be 'web' or 'headless'")
503
-
504
-
505
- @dataclass(frozen=True)
506
- class VerifyResult:
507
- status: str
508
- dsh_home: Path
509
- profile_name: str
510
- mode: str
511
- current_environment: Mapping[str, Any]
512
- available_secret_names: frozenset[str]
513
- steps: tuple[VerifyStep, ...]
514
- plugin_contracts: tuple[SmokeTestContract, ...]
515
-
516
- def __post_init__(self) -> None:
517
- if self.status not in VERIFY_STATUSES:
518
- raise ValueError(f"unsupported verify result status: {self.status}")
519
-
520
- def as_dict(self) -> dict[str, Any]:
521
- step_map = {step.name: step.as_dict() for step in self.steps}
522
- plugin_smokes = [
523
- step.as_dict()
524
- for step in self.steps
525
- if step.name.startswith("plugin:")
526
- ]
527
- return {
528
- "status": self.status,
529
- "profile": {
530
- "name": self.profile_name,
531
- "dshHome": str(self.dsh_home),
532
- "path": str(self.dsh_home / "profiles" / self.profile_name),
533
- },
534
- "mode": self.mode,
535
- "environment": {"current": dict(self.current_environment)},
536
- "secrets": {
537
- "availableNames": sorted(self.available_secret_names),
538
- "valuesIncluded": False,
539
- },
540
- # Keep the ordered steps array for compatibility, while exposing
541
- # the stable Phase 5 machine-report fields required by callers.
542
- "composition": step_map.get("composition"),
543
- "boot": step_map.get("boot"),
544
- "ready": step_map.get("surface_ready"),
545
- "session": step_map.get("new_session"),
546
- "basicResponse": step_map.get("basic_response"),
547
- "coreTool": step_map.get("core_tool"),
548
- "pluginSmokes": plugin_smokes,
549
- "restart": step_map.get("restart"),
550
- "restartReady": step_map.get("restart_surface_ready"),
551
- "steps": [step.as_dict() for step in self.steps],
552
- "pluginSmokeContracts": [contract.as_dict() for contract in self.plugin_contracts],
553
- }
554
-
555
-
556
- def _overall_status(steps: Sequence[VerifyStep]) -> str:
557
- required = [step for step in steps if step.required]
558
- optional = [step for step in steps if not step.required]
559
- if any(step.status == "FAIL" for step in required):
560
- return "FAIL"
561
- if any(step.status == "UNTESTED" for step in required):
562
- return "UNTESTED"
563
- if any(step.status in {"DEGRADED", "FAIL"} for step in required):
564
- return "DEGRADED"
565
- if any(step.status != "PASS" for step in optional):
566
- return "DEGRADED"
567
- return "PASS"
568
-
569
-
570
- def _loader_composition_failure(composition: VerifyStep, boot: VerifyStep) -> dict[str, Any] | None:
571
- """Classify a real DSH Loader activation failure as composition failure.
572
-
573
- DSH resolves Bundle patches and activates their Loader entries during boot.
574
- A structurally valid Pack can therefore pass the local file checks while
575
- the effective composition still fails in the real runtime. Keep the
576
- original ``boot=FAIL`` step, but attach the same runtime evidence to the
577
- composition step so a report can answer which Bundle prevented startup.
578
- """
579
-
580
- if boot.status != "FAIL":
581
- return None
582
- evidence = boot.evidence
583
- stderr = str(evidence.get("stderr", ""))
584
- stdout = str(evidence.get("stdout", ""))
585
- combined = f"{stderr}\n{stdout}"
586
- lowered = combined.lower()
587
- markers = (
588
- "plugin tree failed to load",
589
- "did not activate",
590
- "failed to apply loader entry",
591
- "loader activation failed",
592
- )
593
- if not any(marker in lowered for marker in markers):
594
- return None
595
-
596
- failed_bundle: str | None = None
597
- installed_plugins = composition.evidence.get("installedPlugins", [])
598
- if isinstance(installed_plugins, list):
599
- for row in installed_plugins:
600
- if not isinstance(row, Mapping):
601
- continue
602
- name = row.get("name")
603
- if not isinstance(name, str) or not name:
604
- continue
605
- if name in combined:
606
- failed_bundle = name
607
- break
608
- if failed_bundle is None:
609
- match = re.search(r"(?:failed to apply loader entry|did not activate)\s+([^\s:(]+)", combined, re.IGNORECASE)
610
- if match:
611
- failed_bundle = match.group(1)
612
-
613
- observed = "Loader activation failed" if "loader activation failed" in lowered else "DSH Loader activation failed"
614
- return {
615
- "stage": "composition",
616
- "failedBundle": failed_bundle,
617
- "observed": observed,
618
- "stderr": stderr,
619
- "impact": "Imported profile cannot start.",
620
- "bootMessage": boot.message,
621
- }
622
-
623
-
624
- def _adapter_step(
625
- adapter: VerifyAdapter | None,
626
- name: str,
627
- context: VerifyContext,
628
- *,
629
- required: bool = True,
630
- ) -> VerifyStep:
631
- if adapter is None:
632
- return _step(
633
- name,
634
- "UNTESTED",
635
- "No runtime VerifyAdapter was supplied",
636
- required=required,
637
- evidence={"reason": "runtime execution is not implicit"},
638
- )
639
- method = getattr(adapter, "run_step", None)
640
- if not callable(method):
641
- return _step(
642
- name,
643
- "UNTESTED",
644
- "VerifyAdapter has no run_step implementation",
645
- required=required,
646
- evidence={"reason": "adapter contract missing"},
647
- )
648
- try:
649
- return _normalise_observation(name, method(name, context), required=required)
650
- except Exception as error: # adapters are external execution boundaries
651
- return _step(
652
- name,
653
- "FAIL",
654
- f"VerifyAdapter failed during {name}",
655
- required=required,
656
- evidence={"error": str(error), "exception": type(error).__name__},
657
- )
658
-
659
-
660
- def _plugin_step(
661
- adapter: VerifyAdapter | None,
662
- contract: SmokeTestContract,
663
- test_name: str,
664
- context: VerifyContext,
665
- ) -> VerifyStep:
666
- step_name = f"plugin:{contract.plugin}:{test_name}"
667
- if adapter is None or not callable(getattr(adapter, "run_plugin_test", None)):
668
- return _step(
669
- step_name,
670
- "UNTESTED",
671
- "No plugin smoke-test runner was supplied",
672
- required=contract.required,
673
- evidence={"plugin": contract.plugin, "test": test_name},
674
- )
675
- try:
676
- value = adapter.run_plugin_test(contract.plugin, test_name, context) # type: ignore[attr-defined]
677
- observed = _normalise_observation(step_name, value, required=contract.required)
678
- if observed.required != contract.required:
679
- observed = VerifyStep(
680
- name=observed.name,
681
- status=observed.status,
682
- message=observed.message,
683
- required=contract.required,
684
- evidence=observed.evidence,
685
- )
686
- return observed
687
- except Exception as error: # plugin tests are an external execution boundary
688
- return _step(
689
- step_name,
690
- "FAIL",
691
- f"Plugin smoke test failed to execute: {contract.plugin}/{test_name}",
692
- required=contract.required,
693
- evidence={"plugin": contract.plugin, "test": test_name, "error": str(error)},
694
- )
695
-
696
-
697
- def verify_profile(options: VerifyOptions) -> VerifyResult:
698
- """Verify one prepared Profile without changing its files."""
699
-
700
- if not options.dsh_home.is_dir():
701
- result = VerifyResult(
702
- status="FAIL",
703
- dsh_home=options.dsh_home,
704
- profile_name=options.profile_name,
705
- mode=options.mode,
706
- current_environment=options.current_environment,
707
- available_secret_names=options.available_secret_names,
708
- steps=(_step("composition", "FAIL", "DSH_HOME does not exist", evidence={"dshHome": str(options.dsh_home)}),),
709
- plugin_contracts=options.plugin_contracts,
710
- )
711
- return result
712
-
713
- composition = _composition_step(options)
714
- steps: list[VerifyStep] = [composition]
715
- previous: dict[str, VerifyStep] = {composition.name: composition}
716
- context_base = {
717
- "dsh_home": options.dsh_home,
718
- "profile_name": options.profile_name,
719
- "profile_path": options.dsh_home / "profiles" / options.profile_name,
720
- "mode": options.mode,
721
- "current_environment": options.current_environment,
722
- "available_secret_names": options.available_secret_names,
723
- "metadata_path": options.dsh_home / ".dsh-pack" / "imports" / options.profile_name,
724
- }
725
-
726
- for name, label in RUNTIME_STEPS:
727
- blocking_runtime_step = next(
728
- (step.name for step in steps if step.name in {"boot", "restart"} and step.status == "FAIL"),
729
- None,
730
- )
731
- if composition.status == "FAIL":
732
- current = _step(
733
- name,
734
- "UNTESTED",
735
- f"{label} was not run because composition failed",
736
- evidence={"blockedBy": "composition"},
737
- )
738
- elif blocking_runtime_step is not None:
739
- current = _step(
740
- name,
741
- "UNTESTED",
742
- f"{label} was not run because an earlier runtime step failed",
743
- evidence={"blockedBy": blocking_runtime_step},
744
- )
745
- else:
746
- context = VerifyContext(previous_steps=dict(previous), **context_base)
747
- current = _adapter_step(options.adapter, name, context)
748
- steps.append(current)
749
- previous[current.name] = current
750
-
751
- for contract in options.plugin_contracts:
752
- for test_name in contract.tests:
753
- context = VerifyContext(previous_steps=dict(previous), **context_base)
754
- current = _plugin_step(options.adapter, contract, test_name, context)
755
- steps.append(current)
756
- previous[current.name] = current
757
-
758
- boot = next((step for step in steps if step.name == "boot"), None)
759
- runtime_composition_failure = (
760
- _loader_composition_failure(composition, boot)
761
- if boot is not None
762
- else None
763
- )
764
- if runtime_composition_failure is not None:
765
- composition = _step(
766
- "composition",
767
- "FAIL",
768
- "Bundle composition failed during DSH Loader activation",
769
- evidence={
770
- **composition.evidence,
771
- "runtimeFailure": runtime_composition_failure,
772
- },
773
- )
774
- steps[0] = composition
775
-
776
- result = VerifyResult(
777
- status=_overall_status(steps),
778
- dsh_home=options.dsh_home,
779
- profile_name=options.profile_name,
780
- mode=options.mode,
781
- current_environment=options.current_environment,
782
- available_secret_names=options.available_secret_names,
783
- steps=tuple(steps),
784
- plugin_contracts=options.plugin_contracts,
785
- )
786
- close = getattr(options.adapter, "close", None)
787
- if callable(close):
788
- try:
789
- close()
790
- except Exception:
791
- # Runtime cleanup evidence belongs to the adapter; do not replace
792
- # the actual verification result with a cleanup exception.
793
- pass
794
- return result
795
-
796
-
797
- class SubprocessVerifyAdapter:
798
- """Explicit subprocess adapter for a real DSH or test runner.
799
-
800
- Configuration shape::
801
-
802
- {
803
- "mode": "web",
804
- "command": ["node", ".../bin.js", "--profile", "{profile}"],
805
- "cwd": "...",
806
- "timeoutSeconds": 60,
807
- "readyRegex": "dsh web: (?P<url>https?://[^\\s]+)",
808
- "expectedStatus": 200,
809
- "probes": {"new_session": ["..."], "core_tool": ["..."]},
810
- "pluginTests": {"plugin-a": {"loads": ["..."]}}
811
- }
812
-
813
- Commands are arrays, never shell strings. ``{dsh_home}``, ``{profile}``,
814
- ``{profile_path}``, ``{url}``, and ``{pid}`` placeholders are available.
815
- Missing probe commands remain UNTESTED. The adapter records full
816
- stdout/stderr in step evidence and never records environment values.
817
- """
818
-
819
- def __init__(self, config: Mapping[str, Any]) -> None:
820
- self.config = dict(config)
821
- self.mode = self.config.get("mode", "web")
822
- if self.mode not in {"web", "headless"}:
823
- raise ValueError("SubprocessVerifyAdapter mode must be 'web' or 'headless'")
824
- self.process: subprocess.Popen[str] | None = None
825
- self._logs: dict[str, list[str]] = {"stdout": [], "stderr": []}
826
- self._threads: list[threading.Thread] = []
827
- self._url: str | None = None
828
- self._last_headless: dict[str, Any] | None = None
829
-
830
- def _timeout(self) -> float:
831
- value = self.config.get("timeoutSeconds", 60)
832
- return float(value) if isinstance(value, (int, float)) and value > 0 else 60.0
833
-
834
- def _format(self, value: str, context: VerifyContext) -> str:
835
- values = {
836
- "dsh_home": str(context.dsh_home),
837
- "profile": context.profile_name,
838
- "profile_path": str(context.profile_path),
839
- "url": self._url or "",
840
- "pid": str(self.process.pid if self.process is not None else ""),
841
- }
842
- return value.format_map(values)
843
-
844
- def _command(self, key: str, context: VerifyContext) -> list[str] | None:
845
- raw = self.config.get("command") if key in {"boot", "restart"} else (self.config.get("probes", {}) or {}).get(key)
846
- if not isinstance(raw, list) or any(not isinstance(item, str) for item in raw):
847
- return None
848
- return [self._format(item, context) for item in raw]
849
-
850
- def _environment(self, context: VerifyContext) -> dict[str, str]:
851
- env = dict(os.environ) if self.config.get("inheritEnvironment", True) is not False else {}
852
- env["DSH_HOME"] = str(context.dsh_home)
853
- env["DSH_PROFILE"] = context.profile_name
854
- if self._url:
855
- env["DSH_WEB_URL"] = self._url
856
- extra = self.config.get("environment", {})
857
- if isinstance(extra, Mapping):
858
- env.update({str(key): str(value) for key, value in extra.items()})
859
- return env
860
-
861
- @staticmethod
862
- def _drain(stream, target: list[str]) -> None:
863
- try:
864
- for line in stream:
865
- target.append(line)
866
- finally:
867
- stream.close()
868
-
869
- def _logs_evidence(self) -> dict[str, Any]:
870
- return {"stdout": "".join(self._logs["stdout"]), "stderr": "".join(self._logs["stderr"])}
871
-
872
- def _spawn_web(self, context: VerifyContext) -> VerifyStep:
873
- command = self._command("boot", context)
874
- if command is None:
875
- return _step("boot", "UNTESTED", "runner config has no boot command", evidence={"mode": self.mode})
876
- self._logs = {"stdout": [], "stderr": []}
877
- try:
878
- cwd = self.config.get("cwd")
879
- self.process = subprocess.Popen(
880
- command,
881
- cwd=str(cwd) if isinstance(cwd, str) else None,
882
- env=self._environment(context),
883
- stdout=subprocess.PIPE,
884
- stderr=subprocess.PIPE,
885
- text=True,
886
- bufsize=1,
887
- )
888
- assert self.process.stdout is not None and self.process.stderr is not None
889
- self._threads = [
890
- threading.Thread(target=self._drain, args=(self.process.stdout, self._logs["stdout"]), daemon=True),
891
- threading.Thread(target=self._drain, args=(self.process.stderr, self._logs["stderr"]), daemon=True),
892
- ]
893
- for thread in self._threads:
894
- thread.start()
895
- except (OSError, ValueError) as error:
896
- evidence = {"command": command, "error": str(error)}
897
- return _step(
898
- "boot",
899
- "FAIL",
900
- "could not start runtime command",
901
- evidence=evidence,
902
- diagnostic=_runtime_diagnostic(
903
- code="RUNNER_SPAWN_FAILED",
904
- stage="runtime",
905
- message="could not start runtime command",
906
- evidence=evidence,
907
- item="command",
908
- expected="the runtime command starts and prints a ready URL",
909
- impact="DSH runtime could not be started for verification.",
910
- ),
911
- )
912
- pattern = self.config.get("readyRegex", r"dsh web: (?P<url>https?://[^\s]+)")
913
- try:
914
- regex = re.compile(pattern if isinstance(pattern, str) else r"dsh web: (?P<url>https?://[^\s]+)")
915
- except re.error as error:
916
- self.close()
917
- evidence = {"error": str(error)}
918
- return _step(
919
- "boot",
920
- "FAIL",
921
- "runner readyRegex is invalid",
922
- evidence=evidence,
923
- diagnostic=_runtime_diagnostic(
924
- code="RUNNER_READY_REGEX_INVALID",
925
- stage="runtime",
926
- message="runner readyRegex is invalid",
927
- evidence=evidence,
928
- item="readyRegex",
929
- expected="a valid readyRegex that extracts the ready URL",
930
- impact="DSH readiness could not be detected.",
931
- ),
932
- )
933
- deadline = time.monotonic() + self._timeout()
934
- while time.monotonic() < deadline:
935
- combined = "".join(self._logs["stdout"])
936
- match = regex.search(combined)
937
- if match:
938
- self._url = match.groupdict().get("url") or (match.group(1) if match.groups() else match.group(0))
939
- return _step("boot", "PASS", "runtime printed its ready URL", evidence={"pid": self.process.pid, "url": self._url, **self._logs_evidence()})
940
- code = self.process.poll()
941
- if code is not None:
942
- evidence = {"exitCode": code, **self._logs_evidence()}
943
- return _step(
944
- "boot",
945
- "FAIL",
946
- "runtime exited before ready",
947
- evidence=evidence,
948
- diagnostic=_runtime_diagnostic(
949
- code="RUNNER_EXITED_BEFORE_READY",
950
- stage="runtime",
951
- message="runtime exited before ready",
952
- evidence=evidence,
953
- item="runtime",
954
- expected="the runtime process stays alive and prints a ready URL",
955
- impact="The imported Profile cannot boot in the target DSH runtime.",
956
- ),
957
- )
958
- time.sleep(0.05)
959
- self.close()
960
- evidence = {"timeoutSeconds": self._timeout(), **self._logs_evidence()}
961
- return _step(
962
- "boot",
963
- "FAIL",
964
- "runtime did not print a ready URL before timeout",
965
- evidence=evidence,
966
- diagnostic=_runtime_diagnostic(
967
- code="RUNNER_READY_TIMEOUT",
968
- stage="runtime",
969
- message="runtime did not print a ready URL before timeout",
970
- evidence=evidence,
971
- item="runtime",
972
- expected="the runtime prints a ready URL within the configured timeout",
973
- impact="DSH readiness could not be confirmed within the timeout.",
974
- ),
975
- )
976
-
977
- def _run_headless(self, context: VerifyContext, step_name: str = "boot") -> VerifyStep:
978
- command = self._command("boot", context)
979
- if command is None:
980
- return _step(step_name, "UNTESTED", "runner config has no headless command", evidence={"mode": self.mode})
981
- try:
982
- completed = subprocess.run(
983
- command,
984
- cwd=str(self.config["cwd"]) if isinstance(self.config.get("cwd"), str) else None,
985
- env=self._environment(context),
986
- capture_output=True,
987
- text=True,
988
- timeout=self._timeout(),
989
- check=False,
990
- )
991
- except (OSError, subprocess.TimeoutExpired) as error:
992
- evidence = {"command": command, "error": str(error)}
993
- return _step(
994
- step_name,
995
- "FAIL",
996
- "headless runtime command failed to complete",
997
- evidence=evidence,
998
- diagnostic=_runtime_diagnostic(
999
- code="HEADLESS_RUNNER_FAILED",
1000
- stage="runtime",
1001
- message="headless runtime command failed to complete",
1002
- evidence=evidence,
1003
- item="command",
1004
- expected="the headless runtime command completes successfully",
1005
- impact="The imported Profile could not be verified in headless mode.",
1006
- ),
1007
- )
1008
- self._last_headless = {"exitCode": completed.returncode, "stdout": completed.stdout, "stderr": completed.stderr}
1009
- evidence = {"command": command, **self._last_headless}
1010
- if completed.returncode == 0:
1011
- return _step(step_name, "PASS", "headless runtime completed", evidence=evidence)
1012
- return _step(
1013
- step_name,
1014
- "FAIL",
1015
- "headless runtime exited with failure",
1016
- evidence=evidence,
1017
- diagnostic=_runtime_diagnostic(
1018
- code="HEADLESS_RUNNER_FAILED",
1019
- stage="runtime",
1020
- message="headless runtime exited with failure",
1021
- evidence=evidence,
1022
- item="runtime",
1023
- expected="the headless runtime exits with code 0",
1024
- impact="The imported Profile failed in headless runtime.",
1025
- ),
1026
- )
1027
-
1028
- def _probe(self, step: str, context: VerifyContext) -> VerifyStep:
1029
- command = self._command(step, context)
1030
- if command is None:
1031
- return _step(step, "UNTESTED", f"no explicit {step} probe command was supplied", evidence={"mode": self.mode})
1032
- try:
1033
- completed = subprocess.run(
1034
- command,
1035
- cwd=str(self.config["cwd"]) if isinstance(self.config.get("cwd"), str) else None,
1036
- env=self._environment(context),
1037
- capture_output=True,
1038
- text=True,
1039
- timeout=self._timeout(),
1040
- check=False,
1041
- )
1042
- except (OSError, subprocess.TimeoutExpired) as error:
1043
- evidence = {"command": command, "error": str(error)}
1044
- return _step(
1045
- step,
1046
- "FAIL",
1047
- f"{step} probe failed to complete",
1048
- evidence=evidence,
1049
- diagnostic=_runtime_diagnostic(
1050
- code="PROBE_FAILED",
1051
- stage="probe",
1052
- message=f"{step} probe failed to complete",
1053
- evidence=evidence,
1054
- item=step,
1055
- expected=f"the {step} probe command completes successfully",
1056
- impact=f"The {step} runtime capability could not be verified.",
1057
- ),
1058
- )
1059
- evidence = {"command": command, "exitCode": completed.returncode, "stdout": completed.stdout, "stderr": completed.stderr}
1060
- if completed.returncode == 0:
1061
- return _step(step, "PASS", f"{step} probe completed", evidence=evidence)
1062
- return _step(
1063
- step,
1064
- "FAIL",
1065
- f"{step} probe failed",
1066
- evidence=evidence,
1067
- diagnostic=_runtime_diagnostic(
1068
- code="PROBE_FAILED",
1069
- stage="probe",
1070
- message=f"{step} probe failed",
1071
- evidence=evidence,
1072
- item=step,
1073
- expected=f"the {step} probe command exits with code 0",
1074
- impact=f"The {step} runtime capability could not be verified.",
1075
- ),
1076
- )
1077
-
1078
- def _surface_ready(self, context: VerifyContext, step: str = "surface_ready") -> VerifyStep:
1079
- if self.mode == "headless":
1080
- if self._last_headless is None:
1081
- return _step(step, "UNTESTED", "headless boot has not completed", evidence={})
1082
- status = "PASS" if self._last_headless["exitCode"] == 0 else "FAIL"
1083
- return _step(step, status, "headless completion is the ready signal", evidence=dict(self._last_headless))
1084
- if not self._url:
1085
- return _step(step, "UNTESTED", "boot did not provide a Web URL", evidence={})
1086
- ownership = self._port_owner()
1087
- if ownership["checked"] is not True:
1088
- return _step(
1089
- step,
1090
- "UNTESTED",
1091
- "could not verify that the Web port belongs to the test PID",
1092
- evidence={"url": self._url, **ownership},
1093
- )
1094
- if ownership["portBelongsToProcess"] is not True:
1095
- evidence = {"url": self._url, **ownership}
1096
- return _step(
1097
- step,
1098
- "FAIL",
1099
- "Web port does not belong to the test PID",
1100
- evidence=evidence,
1101
- diagnostic=_runtime_diagnostic(
1102
- code="WEB_PORT_NOT_OWNED",
1103
- stage="surface",
1104
- message="Web port does not belong to the test PID",
1105
- evidence=evidence,
1106
- item="port",
1107
- expected="the listening port belongs to the runtime process being verified",
1108
- impact="Another process may occupy the DSH Web port.",
1109
- ),
1110
- )
1111
- try:
1112
- with urllib.request.urlopen(self._url, timeout=self._timeout()) as response:
1113
- code = response.status
1114
- body = response.read()
1115
- except (OSError, urllib.error.URLError) as error:
1116
- evidence = {"url": self._url, "error": str(error), "pid": self.process.pid if self.process else None}
1117
- return _step(
1118
- step,
1119
- "FAIL",
1120
- "Web URL did not return an HTTP response",
1121
- evidence=evidence,
1122
- diagnostic=_runtime_diagnostic(
1123
- code="WEB_HTTP_UNREACHABLE",
1124
- stage="surface",
1125
- message="Web URL did not return an HTTP response",
1126
- evidence=evidence,
1127
- item="url",
1128
- expected="the DSH Web URL returns an HTTP response",
1129
- impact="The DSH Web surface is not reachable.",
1130
- ),
1131
- )
1132
- expected = self.config.get("expectedStatus", 200)
1133
- status = "PASS" if code == expected else "FAIL"
1134
- evidence = {"url": self._url, "pid": self.process.pid if self.process else None, **ownership, "status": code, "expectedStatus": expected, "responseBytes": len(body)}
1135
- if status == "PASS":
1136
- return _step(step, "PASS", "Web HTTP readiness probe completed", evidence=evidence)
1137
- return _step(
1138
- step,
1139
- "FAIL",
1140
- "Web HTTP status did not match expected",
1141
- evidence=evidence,
1142
- diagnostic=_runtime_diagnostic(
1143
- code="WEB_HTTP_STATUS_MISMATCH",
1144
- stage="surface",
1145
- message="Web HTTP status did not match expected",
1146
- evidence=evidence,
1147
- item="url",
1148
- expected=f"the DSH Web URL returns HTTP {expected}",
1149
- impact="The DSH Web surface returned an unexpected HTTP status.",
1150
- ),
1151
- )
1152
-
1153
- def _port_owner(self) -> dict[str, Any]:
1154
- """Check the listening PID for the adapter's URL without third-party dependencies."""
1155
-
1156
- if self.process is None or not self._url:
1157
- return {"checked": False, "portOwnerPid": None, "portBelongsToProcess": False, "ownershipError": "process or URL is unavailable"}
1158
- return self._port_owner_for(self._url, self.process.pid)
1159
-
1160
- @staticmethod
1161
- def _port_owner_for(url: str | None, pid: int | None) -> dict[str, Any]:
1162
- if not url or pid is None:
1163
- return {"checked": False, "portOwnerPid": None, "portBelongsToProcess": False, "ownershipError": "process or URL is unavailable"}
1164
- parsed = urllib.parse.urlparse(url)
1165
- if parsed.port is None:
1166
- return {"checked": False, "portOwnerPid": None, "portBelongsToProcess": False, "ownershipError": "URL has no port"}
1167
- if os.name != "nt":
1168
- return {"checked": False, "portOwnerPid": None, "portBelongsToProcess": False, "ownershipError": "port ownership probe is currently implemented for Windows netstat"}
1169
- try:
1170
- completed = subprocess.run(
1171
- ["netstat", "-ano", "-p", "tcp"],
1172
- capture_output=True,
1173
- text=True,
1174
- timeout=10,
1175
- check=False,
1176
- )
1177
- except (OSError, subprocess.TimeoutExpired) as error:
1178
- return {"checked": False, "portOwnerPid": None, "portBelongsToProcess": False, "ownershipError": str(error)}
1179
- port_owner: int | None = None
1180
- for line in completed.stdout.splitlines():
1181
- fields = line.split()
1182
- if len(fields) < 5 or fields[0].upper() != "TCP" or fields[3].upper() != "LISTENING":
1183
- continue
1184
- port_match = re.search(r":(\d+)$", fields[1])
1185
- if port_match is None or int(port_match.group(1)) != parsed.port:
1186
- continue
1187
- try:
1188
- port_owner = int(fields[4])
1189
- except ValueError:
1190
- continue
1191
- break
1192
- return {
1193
- "checked": True,
1194
- "portOwnerPid": port_owner,
1195
- "portBelongsToProcess": port_owner == pid,
1196
- }
1197
-
1198
- def run_step(self, step: str, context: VerifyContext) -> VerifyStep:
1199
- if step == "boot":
1200
- return self._run_headless(context) if self.mode == "headless" else self._spawn_web(context)
1201
- if step == "surface_ready" or step == "restart_surface_ready":
1202
- return self._surface_ready(context, step)
1203
- if step == "restart":
1204
- stopped = self.close()
1205
- restarted = self._run_headless(context, step) if self.mode == "headless" else self._rename(self._spawn_web(context), step)
1206
- evidence = dict(restarted.evidence)
1207
- evidence["stoppedProcess"] = stopped
1208
- if restarted.status == "PASS" and stopped.get("portReleased") is not True:
1209
- return _step(
1210
- step,
1211
- "FAIL",
1212
- "restart completed without proof that the previous process port was released",
1213
- evidence=evidence,
1214
- diagnostic=_runtime_diagnostic(
1215
- code="RESTART_PORT_NOT_RELEASED",
1216
- stage="restart",
1217
- message="restart completed without proof that the previous process port was released",
1218
- evidence=evidence,
1219
- item="port",
1220
- expected="the previous runtime process releases the Web port before restart",
1221
- impact="The restarted DSH may conflict with a stale process on the same port.",
1222
- ),
1223
- )
1224
- return VerifyStep(
1225
- name=restarted.name,
1226
- status=restarted.status,
1227
- message=restarted.message,
1228
- required=restarted.required,
1229
- evidence=evidence,
1230
- diagnostic=restarted.diagnostic,
1231
- )
1232
- if step in {"new_session", "basic_response", "core_tool"}:
1233
- return self._probe(step, context)
1234
- return _step(step, "UNTESTED", f"unsupported subprocess verify step: {step}", evidence={})
1235
-
1236
- @staticmethod
1237
- def _rename(value: VerifyStep, name: str) -> VerifyStep:
1238
- return VerifyStep(name=name, status=value.status, message=value.message, required=value.required, evidence=value.evidence, diagnostic=value.diagnostic)
1239
-
1240
- def run_plugin_test(self, plugin: str, test_name: str, context: VerifyContext) -> VerifyStep:
1241
- configured = self.config.get("pluginTests", {})
1242
- command = configured.get(plugin, {}).get(test_name) if isinstance(configured, Mapping) and isinstance(configured.get(plugin), Mapping) else None
1243
- if not isinstance(command, list) or any(not isinstance(item, str) for item in command):
1244
- return _step(f"plugin:{plugin}:{test_name}", "UNTESTED", "no explicit plugin smoke command was supplied", required=True, evidence={"plugin": plugin, "test": test_name})
1245
- command = [self._format(item, context) for item in command]
1246
- try:
1247
- 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)
1248
- except (OSError, subprocess.TimeoutExpired) as error:
1249
- evidence = {"command": command, "error": str(error)}
1250
- return _step(
1251
- f"plugin:{plugin}:{test_name}",
1252
- "FAIL",
1253
- "plugin smoke command failed to complete",
1254
- required=True,
1255
- evidence=evidence,
1256
- diagnostic=_runtime_diagnostic(
1257
- code="PLUGIN_SMOKE_FAILED",
1258
- stage="plugin-smoke",
1259
- message="plugin smoke command failed to complete",
1260
- evidence=evidence,
1261
- item=plugin,
1262
- expected=f"the plugin smoke command for {plugin} completes successfully",
1263
- impact=f"Plugin {plugin} smoke test could not be verified.",
1264
- ),
1265
- )
1266
- evidence = {"command": command, "exitCode": completed.returncode, "stdout": completed.stdout, "stderr": completed.stderr}
1267
- if completed.returncode == 0:
1268
- return _step(f"plugin:{plugin}:{test_name}", "PASS", "plugin smoke command completed", required=True, evidence=evidence)
1269
- return _step(
1270
- f"plugin:{plugin}:{test_name}",
1271
- "FAIL",
1272
- "plugin smoke command failed",
1273
- required=True,
1274
- evidence=evidence,
1275
- diagnostic=_runtime_diagnostic(
1276
- code="PLUGIN_SMOKE_FAILED",
1277
- stage="plugin-smoke",
1278
- message="plugin smoke command failed",
1279
- evidence=evidence,
1280
- item=plugin,
1281
- expected=f"the plugin smoke command for {plugin} exits with code 0",
1282
- impact=f"Plugin {plugin} smoke test failed.",
1283
- ),
1284
- )
1285
-
1286
- def close(self) -> dict[str, Any]:
1287
- process = self.process
1288
- url = self._url
1289
- if process is None:
1290
- return {"pid": None, "url": url, "processExited": True, "portReleased": True, "reason": "no persistent process"}
1291
- pid = process.pid
1292
- if process.poll() is None:
1293
- process.terminate()
1294
- try:
1295
- process.wait(timeout=5)
1296
- except subprocess.TimeoutExpired:
1297
- process.kill()
1298
- process.wait(timeout=5)
1299
- exit_code = process.poll()
1300
- port_evidence: dict[str, Any] = {"checked": True, "portOwnerPid": None, "portBelongsToProcess": False}
1301
- port_released = True
1302
- if self.mode == "web" and url:
1303
- deadline = time.monotonic() + 2.0
1304
- while True:
1305
- port_evidence = self._port_owner_for(url, pid)
1306
- if port_evidence.get("checked") is not True:
1307
- port_released = False
1308
- break
1309
- if port_evidence.get("portOwnerPid") is None:
1310
- port_released = True
1311
- break
1312
- if time.monotonic() >= deadline:
1313
- port_released = False
1314
- break
1315
- time.sleep(0.05)
1316
- evidence = {
1317
- "pid": pid,
1318
- "url": url,
1319
- "exitCode": exit_code,
1320
- "processExited": process.poll() is not None,
1321
- "portReleased": port_released,
1322
- "portEvidence": port_evidence,
1323
- }
1324
- self.process = None
1325
- self._url = None
1326
- return evidence
1327
-
1328
-
1329
- def load_smoke_contracts(path: str | os.PathLike[str]) -> tuple[SmokeTestContract, ...]:
1330
- """Load one JSON object or an array of plugin smoke contracts."""
1331
-
1332
- value = json.loads(Path(path).read_text(encoding="utf-8"))
1333
- values = value if isinstance(value, list) else [value]
1334
- if any(not isinstance(item, Mapping) for item in values):
1335
- raise ValueError("plugin contract JSON must contain an object or an array of objects")
1336
- return tuple(SmokeTestContract.from_mapping(item) for item in values)
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)