dsh-crate-web 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +302 -0
  2. package/README.zh-CN.md +304 -0
  3. package/cordis.patch.yml +11 -0
  4. package/core/dsh_pack/__init__.py +77 -0
  5. package/core/dsh_pack/__main__.py +4 -0
  6. package/core/dsh_pack/cli.py +372 -0
  7. package/core/dsh_pack/errors.py +167 -0
  8. package/core/dsh_pack/export.py +912 -0
  9. package/core/dsh_pack/importer.py +1347 -0
  10. package/core/dsh_pack/network.py +77 -0
  11. package/core/dsh_pack/pack.py +248 -0
  12. package/core/dsh_pack/preflight.py +811 -0
  13. package/core/dsh_pack/user_cli.py +462 -0
  14. package/core/dsh_pack/validation.py +272 -0
  15. package/core/dsh_pack/verify.py +1336 -0
  16. package/lib/client.js +1623 -0
  17. package/lib/index.d.ts +30 -0
  18. package/lib/index.js +1275 -0
  19. package/package.json +73 -0
  20. package/restart-helper.mjs +121 -0
  21. package/runtime/python/LICENSE.txt +676 -0
  22. package/runtime/python/__install__.json +82 -0
  23. package/runtime/python/_asyncio.pyd +0 -0
  24. package/runtime/python/_bz2.pyd +0 -0
  25. package/runtime/python/_ctypes.pyd +0 -0
  26. package/runtime/python/_decimal.pyd +0 -0
  27. package/runtime/python/_elementtree.pyd +0 -0
  28. package/runtime/python/_hashlib.pyd +0 -0
  29. package/runtime/python/_lzma.pyd +0 -0
  30. package/runtime/python/_multiprocessing.pyd +0 -0
  31. package/runtime/python/_overlapped.pyd +0 -0
  32. package/runtime/python/_queue.pyd +0 -0
  33. package/runtime/python/_remote_debugging.pyd +0 -0
  34. package/runtime/python/_socket.pyd +0 -0
  35. package/runtime/python/_sqlite3.pyd +0 -0
  36. package/runtime/python/_ssl.pyd +0 -0
  37. package/runtime/python/_uuid.pyd +0 -0
  38. package/runtime/python/_wmi.pyd +0 -0
  39. package/runtime/python/_zoneinfo.pyd +0 -0
  40. package/runtime/python/_zstd.pyd +0 -0
  41. package/runtime/python/libcrypto-3.dll +0 -0
  42. package/runtime/python/libffi-8.dll +0 -0
  43. package/runtime/python/libssl-3.dll +0 -0
  44. package/runtime/python/pyexpat.pyd +0 -0
  45. package/runtime/python/python.cat +0 -0
  46. package/runtime/python/python.exe +0 -0
  47. package/runtime/python/python3.dll +0 -0
  48. package/runtime/python/python314._pth +6 -0
  49. package/runtime/python/python314.dll +0 -0
  50. package/runtime/python/python314.zip +0 -0
  51. package/runtime/python/pythonw.exe +0 -0
  52. package/runtime/python/select.pyd +0 -0
  53. package/runtime/python/sqlite3.dll +0 -0
  54. package/runtime/python/unicodedata.pyd +0 -0
  55. package/runtime/python/vcruntime140.dll +0 -0
  56. package/runtime/python/vcruntime140_1.dll +0 -0
  57. package/runtime/python/winsound.pyd +0 -0
@@ -0,0 +1,1336 @@
1
+ """Phase 5 verification for a prepared DSH Profile.
2
+
3
+ The core owns composition checks, status aggregation, evidence shape, and the
4
+ plugin smoke-test contract. Runtime execution is deliberately injected via a
5
+ ``VerifyAdapter`` so this module never guesses a DSH command, health endpoint,
6
+ or model interaction. Without an adapter, runtime checks are UNTESTED.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import 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)