dsh-crate-web 0.1.0 → 0.1.2-preview.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +11 -290
- package/README.zh-CN.md +2 -303
- package/THIRD-PARTY-LICENSE.txt +21 -0
- package/cordis.patch.yml +1 -6
- package/core/dsh_pack/_version.py +1 -0
- package/core/dsh_pack/bundle.py +74 -0
- package/core/dsh_pack/cli.py +81 -18
- package/core/dsh_pack/desktop_verify.py +304 -0
- package/core/dsh_pack/diagnostics.py +1170 -0
- package/core/dsh_pack/errors.py +16 -1
- package/core/dsh_pack/export.py +86 -103
- package/core/dsh_pack/export_plan.py +96 -0
- package/core/dsh_pack/identity.py +25 -0
- package/core/dsh_pack/importer.py +1598 -1347
- package/core/dsh_pack/network.py +5 -4
- package/core/dsh_pack/pack.py +14 -2
- package/core/dsh_pack/portability.py +392 -0
- package/core/dsh_pack/preflight.py +63 -16
- package/core/dsh_pack/troubleshooting.py +94 -0
- package/core/dsh_pack/user_cli.py +16 -0
- package/core/dsh_pack/validation.py +64 -4
- package/core/dsh_pack/verify.py +1485 -1336
- package/core/dsh_pack/windows_verify.py +177 -0
- package/desktop-patch-helper.mjs +140 -0
- package/lib/client.js +292 -102
- package/lib/index.d.ts +7 -1
- package/lib/index.js +1121 -229
- package/package.json +13 -5
- package/restart-helper.mjs +121 -121
- package/runtime/python/LICENSE.txt +676 -676
- package/runtime/python/__install__.json +81 -81
- package/runtime/python/python314._pth +6 -6
- package/skills/dsh-crate-troubleshooting/SKILL.md +172 -0
- package/skills/dsh-crate-troubleshooting/compatibility.json +11 -0
- package/skills/dsh-crate-troubleshooting/reference/diagnostics.generated.md +96 -0
- package/skills/dsh-crate-troubleshooting/reference/repair-boundaries.md +60 -0
- package/skills/dsh-crate-troubleshooting/reference/stages.generated.md +22 -0
- package/verify-desktop.mjs +314 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Ordered Bundle patch checks shared by Export, Preflight, Import and Verify."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import io
|
|
5
|
+
import json
|
|
6
|
+
import tarfile
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Mapping
|
|
9
|
+
|
|
10
|
+
from .errors import ExportError, SchemaValidationError
|
|
11
|
+
from .validation import bundle_patch_paths
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def check_patch_content(content: bytes, label: str) -> None:
|
|
15
|
+
# This validates text/privacy; it deliberately does not interpret YAML or
|
|
16
|
+
# claim the Loader can execute the patch. Runtime composition is separate.
|
|
17
|
+
from .portability import assert_private_values_absent
|
|
18
|
+
assert_private_values_absent(content, label)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def check_patch_declaration(manifest: Mapping[str, Any], expected: Any) -> None:
|
|
22
|
+
dsh = manifest.get("dsh")
|
|
23
|
+
bundle = dsh.get("bundle") if isinstance(dsh, Mapping) else None
|
|
24
|
+
declared = bundle.get("patch") if isinstance(bundle, Mapping) else None
|
|
25
|
+
if bundle_patch_paths(declared) != bundle_patch_paths(expected):
|
|
26
|
+
raise SchemaValidationError("installed Bundle patch declaration differs from Crate (including order)")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def read_bundle_patches(package_root: Path, declaration: Any) -> tuple[tuple[str, bytes], ...]:
|
|
30
|
+
root = package_root.resolve()
|
|
31
|
+
patches = []
|
|
32
|
+
for relative in bundle_patch_paths(declaration):
|
|
33
|
+
path = package_root.joinpath(*relative.split("/"))
|
|
34
|
+
try:
|
|
35
|
+
path.resolve().relative_to(root)
|
|
36
|
+
if not path.is_file() or path.is_symlink():
|
|
37
|
+
raise ValueError("patch is not a regular file")
|
|
38
|
+
content = path.read_bytes()
|
|
39
|
+
except (ValueError, OSError) as error:
|
|
40
|
+
raise ExportError(f"Bundle patch file is missing or escapes its package: {relative}") from error
|
|
41
|
+
check_patch_content(content, relative)
|
|
42
|
+
patches.append((relative, content))
|
|
43
|
+
return tuple(patches)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def check_bundle_artifact(content: bytes, declaration: Any) -> None:
|
|
47
|
+
"""Check every declared patch and its package declaration without extraction."""
|
|
48
|
+
paths = bundle_patch_paths(declaration)
|
|
49
|
+
try:
|
|
50
|
+
with tarfile.open(fileobj=io.BytesIO(content), mode="r:gz") as archive:
|
|
51
|
+
members = archive.getmembers()
|
|
52
|
+
names = [member.name for member in members]
|
|
53
|
+
if len(names) != len(set(names)):
|
|
54
|
+
raise SchemaValidationError("Bundle artifact contains duplicate members")
|
|
55
|
+
package_member = archive.getmember("package/package.json")
|
|
56
|
+
if not package_member.isfile():
|
|
57
|
+
raise SchemaValidationError("Bundle artifact package.json must be a regular file")
|
|
58
|
+
package_file = archive.extractfile(package_member)
|
|
59
|
+
if package_file is None:
|
|
60
|
+
raise SchemaValidationError("Bundle artifact package.json cannot be read")
|
|
61
|
+
manifest = json.loads(package_file.read().decode("utf-8"))
|
|
62
|
+
if not isinstance(manifest, Mapping):
|
|
63
|
+
raise SchemaValidationError("Bundle artifact package.json must be an object")
|
|
64
|
+
check_patch_declaration(manifest, declaration)
|
|
65
|
+
for relative in paths:
|
|
66
|
+
member = archive.getmember("package/" + relative)
|
|
67
|
+
if not member.isfile():
|
|
68
|
+
raise SchemaValidationError(f"Bundle artifact patch must be a regular file: {relative}")
|
|
69
|
+
source = archive.extractfile(member)
|
|
70
|
+
if source is None:
|
|
71
|
+
raise SchemaValidationError(f"Bundle artifact patch cannot be read: {relative}")
|
|
72
|
+
check_patch_content(source.read(), relative)
|
|
73
|
+
except (OSError, tarfile.TarError, KeyError, UnicodeDecodeError, json.JSONDecodeError) as error:
|
|
74
|
+
raise SchemaValidationError("Bundle artifact is invalid or a declared patch is missing") from error
|
package/core/dsh_pack/cli.py
CHANGED
|
@@ -15,6 +15,51 @@ from .preflight import PreflightContext, inspect_pack, render_text
|
|
|
15
15
|
from .verify import SubprocessVerifyAdapter, VerifyOptions, load_smoke_contracts, verify_profile
|
|
16
16
|
|
|
17
17
|
|
|
18
|
+
def _pnpm_command_from_environment() -> tuple[str, ...] | None:
|
|
19
|
+
value = os.environ.get('DSH_PACK_PNPM_COMMAND')
|
|
20
|
+
if not value:
|
|
21
|
+
return None
|
|
22
|
+
try:
|
|
23
|
+
command = json.loads(value)
|
|
24
|
+
except ValueError:
|
|
25
|
+
command = None
|
|
26
|
+
if not isinstance(command, list) or not command or not all(isinstance(item, str) and item and '\x00' not in item for item in command):
|
|
27
|
+
raise PackImportError('DSH_PACK_PNPM_COMMAND must be a nonempty JSON argv array', stage='planning', code='PACKAGE_MANAGER_COMMAND_INVALID')
|
|
28
|
+
return tuple(command)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _host_bundles_from_environment():
|
|
32
|
+
source = os.environ.get('DSH_PACK_HOST_BUNDLES_FILE')
|
|
33
|
+
if not source:
|
|
34
|
+
return None
|
|
35
|
+
try:
|
|
36
|
+
value = json.loads(Path(source).read_text(encoding='utf-8'))
|
|
37
|
+
if value.get('schemaVersion') != 1 or not isinstance(value.get('bundles'), dict):
|
|
38
|
+
raise ValueError('invalid host bundle evidence')
|
|
39
|
+
return value['bundles']
|
|
40
|
+
except (OSError, ValueError, TypeError, AttributeError) as error:
|
|
41
|
+
raise PackImportError('cannot read explicit host Bundle evidence', stage='planning', code='HOST_BUNDLE_EVIDENCE_INVALID') from error
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _patch_adapter_from_environment() -> tuple[str, ...] | None:
|
|
45
|
+
value = os.environ.get('DSH_PACK_PATCH_ADAPTER_COMMAND')
|
|
46
|
+
if not value:
|
|
47
|
+
return None
|
|
48
|
+
try:
|
|
49
|
+
command = json.loads(value)
|
|
50
|
+
if not isinstance(command, list) or not command or not all(isinstance(item, str) and item and '\x00' not in item for item in command):
|
|
51
|
+
raise ValueError('invalid adapter command')
|
|
52
|
+
return tuple(command)
|
|
53
|
+
except (ValueError, TypeError) as error:
|
|
54
|
+
raise PackImportError('DSH_PACK_PATCH_ADAPTER_COMMAND must be an explicit JSON argv array', stage='planning', code='PROFILE_PATCH_ADAPTER_COMMAND_INVALID') from error
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _emit(value: dict, operation: str) -> None:
|
|
58
|
+
from .diagnostics import envelope_fields
|
|
59
|
+
payload = {**envelope_fields(operation=operation, status=str(value.get("status", "UNTESTED"))), **value}
|
|
60
|
+
print(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True))
|
|
61
|
+
|
|
62
|
+
|
|
18
63
|
def _plugin_inventory(values: list[str]) -> dict[str, str | None]:
|
|
19
64
|
inventory: dict[str, str | None] = {}
|
|
20
65
|
for value in values:
|
|
@@ -78,6 +123,9 @@ def _export_parser(subparsers: argparse._SubParsersAction[argparse.ArgumentParse
|
|
|
78
123
|
parser.add_argument("--embed", action="append", default=[], metavar="PLUGIN", help="embed one plugin artifact")
|
|
79
124
|
parser.add_argument("--reference-only", action="append", default=[], metavar="PLUGIN", help="keep one plugin reference-only")
|
|
80
125
|
parser.add_argument("--include-installed-bundles", action="store_true", help="include direct installed Bundle inventory in the exported Profile composition")
|
|
126
|
+
parser.add_argument("--preset", choices=("share", "freeze", "custom"), default="custom")
|
|
127
|
+
parser.add_argument("--data-selection-json", help="JSON array of explicitly selected data paths, kinds and owners")
|
|
128
|
+
parser.add_argument("--plan-only", action="store_true", help="preview export contents without writing a Crate")
|
|
81
129
|
parser.add_argument("--required-secret", action="append", default=[], metavar="NAME", help="record one required Secret name")
|
|
82
130
|
parser.set_defaults(handler=_run_export)
|
|
83
131
|
|
|
@@ -137,7 +185,7 @@ def _run_inspect(args: argparse.Namespace) -> int:
|
|
|
137
185
|
)
|
|
138
186
|
result = inspect_pack(args.pack, context=context)
|
|
139
187
|
if args.json:
|
|
140
|
-
|
|
188
|
+
_emit(result.as_dict(), args.command)
|
|
141
189
|
else:
|
|
142
190
|
print(render_text(result))
|
|
143
191
|
return 1 if result.blockers else 0
|
|
@@ -170,6 +218,8 @@ def _export_payload(result: object) -> dict[str, object]:
|
|
|
170
218
|
"requiredSecrets": data.manifest.get("requiredSecrets", []),
|
|
171
219
|
"plugins": data.plugins_lock.get("plugins", []),
|
|
172
220
|
"artifactPaths": sorted(f"plugins/{name}" for name in data.plugin_artifacts),
|
|
221
|
+
"dataInventory": data.manifest.get("dataInventory", []),
|
|
222
|
+
"completeness": data.manifest.get("completeness", {}),
|
|
173
223
|
}
|
|
174
224
|
|
|
175
225
|
|
|
@@ -178,33 +228,37 @@ def _run_export(args: argparse.Namespace) -> int:
|
|
|
178
228
|
if overlap:
|
|
179
229
|
payload = {"status": "failed", "error": {"message": f"plugin selected for both --embed and --reference-only: {sorted(overlap)}"}}
|
|
180
230
|
if args.json:
|
|
181
|
-
|
|
231
|
+
_emit(payload, args.command)
|
|
182
232
|
else:
|
|
183
233
|
print(f"EXPORT FAIL: {payload['error']['message']}")
|
|
184
234
|
return 2
|
|
185
235
|
plugin_options = {name: PluginExportOptions(mode=EMBEDDED) for name in args.embed}
|
|
186
236
|
plugin_options.update({name: PluginExportOptions(mode=REFERENCE_ONLY) for name in args.reference_only})
|
|
187
237
|
try:
|
|
188
|
-
|
|
189
|
-
args.profile,
|
|
190
|
-
args.output,
|
|
191
|
-
options=ExportOptions(
|
|
238
|
+
options = ExportOptions(
|
|
192
239
|
environment=_export_environment(args),
|
|
193
240
|
required_secrets=tuple(args.required_secret),
|
|
194
241
|
plugin_options=plugin_options,
|
|
195
242
|
include_installation_bundles=args.include_installed_bundles,
|
|
196
|
-
|
|
197
|
-
|
|
243
|
+
export_mode=args.preset,
|
|
244
|
+
data_selection=tuple(json.loads(Path(args.data_selection_json).read_text(encoding="utf-8"))) if args.data_selection_json else (),
|
|
245
|
+
)
|
|
246
|
+
if args.plan_only:
|
|
247
|
+
from .export_plan import build_plan
|
|
248
|
+
payload = build_plan(Path(args.profile), options=options)
|
|
249
|
+
_emit(payload, args.command)
|
|
250
|
+
return 0
|
|
251
|
+
result = export_profile(args.profile, args.output, options=options)
|
|
198
252
|
except (DshPackError, OSError, ValueError, json.JSONDecodeError) as error:
|
|
199
253
|
payload = {"status": "failed", "error": {"message": str(error)}}
|
|
200
254
|
if args.json:
|
|
201
|
-
|
|
255
|
+
_emit(payload, args.command)
|
|
202
256
|
else:
|
|
203
257
|
print(f"EXPORT FAIL: {error}")
|
|
204
258
|
return 2
|
|
205
259
|
payload = _export_payload(result)
|
|
206
260
|
if args.json:
|
|
207
|
-
|
|
261
|
+
_emit(payload, args.command)
|
|
208
262
|
else:
|
|
209
263
|
print(f"EXPORT PASS: {result.path}")
|
|
210
264
|
return 0
|
|
@@ -226,6 +280,9 @@ def _run_import(args: argparse.Namespace) -> int:
|
|
|
226
280
|
try:
|
|
227
281
|
sources = _reference_sources(args.reference_plugin)
|
|
228
282
|
options = ImportOptions(
|
|
283
|
+
pnpm_command=_pnpm_command_from_environment(),
|
|
284
|
+
host_bundles=_host_bundles_from_environment(),
|
|
285
|
+
patch_adapter_command=_patch_adapter_from_environment(),
|
|
229
286
|
current_environment=environment,
|
|
230
287
|
available_secrets=available_secrets,
|
|
231
288
|
reference_plugin_sources=sources,
|
|
@@ -238,7 +295,7 @@ def _run_import(args: argparse.Namespace) -> int:
|
|
|
238
295
|
plan = create_import_plan(args.pack, args.dsh_home, options=options)
|
|
239
296
|
payload = {"status": "plan", "readOnly": True, "plan": plan.as_dict()}
|
|
240
297
|
if args.json:
|
|
241
|
-
|
|
298
|
+
_emit(payload, args.command)
|
|
242
299
|
else:
|
|
243
300
|
print(f"Plan: {plan.profile_name}")
|
|
244
301
|
return 0
|
|
@@ -246,12 +303,12 @@ def _run_import(args: argparse.Namespace) -> int:
|
|
|
246
303
|
except (PackImportError, ValueError, OSError) as error:
|
|
247
304
|
payload = {"status": "failed", "error": error.as_dict() if isinstance(error, PackImportError) else {"message": str(error)}}
|
|
248
305
|
if args.json:
|
|
249
|
-
|
|
306
|
+
_emit(payload, args.command)
|
|
250
307
|
else:
|
|
251
308
|
print(f"Import failed: {payload['error'].get('message', str(error))}")
|
|
252
309
|
return 1
|
|
253
310
|
if args.json:
|
|
254
|
-
|
|
311
|
+
_emit(result.as_dict(), args.command)
|
|
255
312
|
else:
|
|
256
313
|
print(f"Status: {result.status}")
|
|
257
314
|
print(f"Profile: {result.profile_path}")
|
|
@@ -279,13 +336,13 @@ def _run_delete_profile(args: argparse.Namespace) -> int:
|
|
|
279
336
|
except PackImportError as error:
|
|
280
337
|
payload = {"status": "failed", "error": error.as_dict()}
|
|
281
338
|
if args.json:
|
|
282
|
-
|
|
339
|
+
_emit(payload, args.command)
|
|
283
340
|
else:
|
|
284
341
|
print(f"Delete failed: {error}")
|
|
285
342
|
return 1
|
|
286
343
|
payload = result.as_dict()
|
|
287
344
|
if args.json:
|
|
288
|
-
|
|
345
|
+
_emit(payload, args.command)
|
|
289
346
|
else:
|
|
290
347
|
print(f"Deleted Profile: {result.profile_name}")
|
|
291
348
|
return 0
|
|
@@ -332,7 +389,11 @@ def _run_verify(args: argparse.Namespace) -> int:
|
|
|
332
389
|
config = json.loads(Path(args.runner_config).read_text(encoding="utf-8"))
|
|
333
390
|
if not isinstance(config, dict):
|
|
334
391
|
raise ValueError("runner config JSON must contain an object")
|
|
335
|
-
|
|
392
|
+
if config.get('kind') == 'desktop':
|
|
393
|
+
from .desktop_verify import DesktopVerifyAdapter
|
|
394
|
+
adapter = DesktopVerifyAdapter(config)
|
|
395
|
+
else:
|
|
396
|
+
adapter = SubprocessVerifyAdapter(config)
|
|
336
397
|
result = verify_profile(
|
|
337
398
|
VerifyOptions(
|
|
338
399
|
dsh_home=Path(args.dsh_home).resolve(),
|
|
@@ -347,12 +408,12 @@ def _run_verify(args: argparse.Namespace) -> int:
|
|
|
347
408
|
except (OSError, ValueError, json.JSONDecodeError) as error:
|
|
348
409
|
payload = {"status": "FAIL", "error": {"message": str(error)}}
|
|
349
410
|
if args.json:
|
|
350
|
-
|
|
411
|
+
_emit(payload, args.command)
|
|
351
412
|
else:
|
|
352
413
|
print(f"Verify failed: {error}")
|
|
353
414
|
return 1
|
|
354
415
|
if args.json:
|
|
355
|
-
|
|
416
|
+
_emit(result.as_dict(), args.command)
|
|
356
417
|
else:
|
|
357
418
|
print(f"Status: {result.status}")
|
|
358
419
|
for step in result.steps:
|
|
@@ -368,5 +429,7 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|
|
368
429
|
_import_parser(subparsers)
|
|
369
430
|
_delete_parser(subparsers)
|
|
370
431
|
_verify_parser(subparsers)
|
|
432
|
+
from .troubleshooting import configure_parser
|
|
433
|
+
configure_parser(subparsers)
|
|
371
434
|
args = parser.parse_args(argv)
|
|
372
435
|
return args.handler(args)
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
"""随包独立桌面执行器。Core保留步骤顺序、required约束及最终状态裁决。"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import queue
|
|
8
|
+
import re
|
|
9
|
+
import subprocess
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
import uuid
|
|
13
|
+
|
|
14
|
+
from .verify import VerifyStep
|
|
15
|
+
from .windows_verify import OwnedJob, WindowsCounters, process_tree, summarize_resources, tcp_rows
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _write(path, value):
|
|
19
|
+
Path(path).write_text(json.dumps(value, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class DesktopVerifyAdapter:
|
|
23
|
+
"""Only controls processes created by its own stdio controller and Windows Job."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, config):
|
|
26
|
+
if os.name != 'nt' or config.get('mode') != 'web':
|
|
27
|
+
raise ValueError('Bundled desktop Verify supports Windows web mode only')
|
|
28
|
+
if config.get('modelRequests') != 0 or config.get('automaticRetries') != 0:
|
|
29
|
+
raise ValueError('Bundled desktop Verify requires zero model requests and zero retries')
|
|
30
|
+
maximum = config.get('maximumBoots')
|
|
31
|
+
if type(maximum) is not int or not 1 <= maximum <= 2:
|
|
32
|
+
raise ValueError('maximumBoots must be 1 or 2')
|
|
33
|
+
self.config = dict(config)
|
|
34
|
+
for name in ['desktopExecutable', 'controllerScript']:
|
|
35
|
+
path = Path(config[name])
|
|
36
|
+
if not path.is_absolute() or not path.is_file() or path.is_symlink() or path.is_junction():
|
|
37
|
+
raise ValueError(f'{name} must be an absolute regular file')
|
|
38
|
+
self.process = None
|
|
39
|
+
self.job = None
|
|
40
|
+
self.directory = None
|
|
41
|
+
self.owned = {}
|
|
42
|
+
self.endpoints = {}
|
|
43
|
+
self.events = []
|
|
44
|
+
self.rows = []
|
|
45
|
+
self.observation_errors = []
|
|
46
|
+
self.stop = threading.Event()
|
|
47
|
+
self.lock = threading.RLock()
|
|
48
|
+
self.responses = queue.Queue()
|
|
49
|
+
self.closed = None
|
|
50
|
+
self.generation = 0
|
|
51
|
+
self.counter = WindowsCounters()
|
|
52
|
+
|
|
53
|
+
def _bind(self, pid, role):
|
|
54
|
+
with self.lock:
|
|
55
|
+
if pid in self.owned:
|
|
56
|
+
return
|
|
57
|
+
handle, image = self.counter.open(pid)
|
|
58
|
+
executable = Path(image)
|
|
59
|
+
bundled_python = getattr(self, 'child_home', Path(self.config['desktopExecutable']).parent)/'profiles/desktop/node_modules/dsh-crate-web/runtime/python/python.exe'
|
|
60
|
+
if not executable.is_relative_to(Path(self.config['desktopExecutable']).parent) and executable != bundled_python:
|
|
61
|
+
self.counter.k.CloseHandle(handle)
|
|
62
|
+
raise ValueError(f'Verify child executable escaped its declared runtime: pid={pid}, image={image}')
|
|
63
|
+
self.owned[pid] = (handle, image, role)
|
|
64
|
+
|
|
65
|
+
def _event(self, value):
|
|
66
|
+
self.events.append(value)
|
|
67
|
+
if value.get('event') == 'desktop.spawned':
|
|
68
|
+
self.generation = value['generation']
|
|
69
|
+
self.root_pid = value['pid']
|
|
70
|
+
self.child_home = Path(value['home'])
|
|
71
|
+
self._bind(self.root_pid, 'desktop')
|
|
72
|
+
self.endpoints.update({f'cdp-{self.generation}': value['cdpPort'], f'inspector-{self.generation}': value['inspectorPort']})
|
|
73
|
+
|
|
74
|
+
def _read(self):
|
|
75
|
+
try:
|
|
76
|
+
for line in self.process.stdout:
|
|
77
|
+
value = json.loads(line)
|
|
78
|
+
if 'event' in value:
|
|
79
|
+
self._event(value)
|
|
80
|
+
else:
|
|
81
|
+
self.responses.put(value)
|
|
82
|
+
except Exception as error:
|
|
83
|
+
self.responses.put({'error': {'type': type(error).__name__, 'message': str(error)}})
|
|
84
|
+
finally:
|
|
85
|
+
self.responses.put({'error': {'type': 'EOF', 'message': 'Desktop controller pipe closed'}})
|
|
86
|
+
|
|
87
|
+
def _rpc(self, operation, timeout=65):
|
|
88
|
+
if not self.process or self.process.poll() is not None:
|
|
89
|
+
raise RuntimeError('Desktop controller is not running')
|
|
90
|
+
self.process.stdin.write(json.dumps({'operation': operation}) + '\n')
|
|
91
|
+
self.process.stdin.flush()
|
|
92
|
+
try:
|
|
93
|
+
result = self.responses.get(timeout=timeout)
|
|
94
|
+
except queue.Empty:
|
|
95
|
+
raise TimeoutError('Desktop controller deadline: ' + operation) from None
|
|
96
|
+
if 'error' in result:
|
|
97
|
+
error = result['error']
|
|
98
|
+
raise RuntimeError(f"{error['type']}: {error['message']}")
|
|
99
|
+
return result['value']
|
|
100
|
+
|
|
101
|
+
def _sample(self):
|
|
102
|
+
while not self.stop.is_set():
|
|
103
|
+
try:
|
|
104
|
+
# Only the known controller's descendants are observed. Nothing is killed here.
|
|
105
|
+
for pid in process_tree(self.process.pid):
|
|
106
|
+
try:
|
|
107
|
+
self._bind(pid, 'controller' if pid == self.process.pid else 'desktop-descendant')
|
|
108
|
+
except OSError as error:
|
|
109
|
+
if getattr(error, 'winerror', None) not in (87, 6) and error.errno not in (87, 6):
|
|
110
|
+
raise
|
|
111
|
+
except ValueError as error:
|
|
112
|
+
item = {'type': type(error).__name__, 'message': str(error)}
|
|
113
|
+
if item not in self.observation_errors:
|
|
114
|
+
self.observation_errors.append(item)
|
|
115
|
+
with self.lock:
|
|
116
|
+
processes = [self.counter.process(pid, h, role, image) for pid, (h, image, role) in self.owned.items()]
|
|
117
|
+
row = {'atUtc': time.time(), 'monotonicNs': time.monotonic_ns(), 'system': self.counter.system(), 'processes': processes}
|
|
118
|
+
self.rows.append(row)
|
|
119
|
+
with (self.directory/'resources.jsonl').open('a', encoding='utf-8') as stream:
|
|
120
|
+
stream.write(json.dumps(row) + '\n')
|
|
121
|
+
except Exception as error:
|
|
122
|
+
self.observation_errors.append({'type': type(error).__name__, 'message': str(error)})
|
|
123
|
+
self.stop.wait(.2)
|
|
124
|
+
|
|
125
|
+
def _start(self, context):
|
|
126
|
+
parent = context.dsh_home/'.dsh-pack/verify-jobs'
|
|
127
|
+
parent.mkdir(parents=True, exist_ok=True)
|
|
128
|
+
if parent.is_symlink() or parent.is_junction():
|
|
129
|
+
raise ValueError('Verify jobs directory is a reparse link')
|
|
130
|
+
self.directory = parent/uuid.uuid4().hex
|
|
131
|
+
self.directory.mkdir()
|
|
132
|
+
config = {**self.config, 'sourceHome': str(context.dsh_home), 'sourceProfile': context.profile_name,
|
|
133
|
+
'job': str(self.directory), 'observe': True}
|
|
134
|
+
_write(self.directory/'controller-config.json', config)
|
|
135
|
+
env = {k: v for k, v in os.environ.items() if not re.search(r'secret|token|password|credential|authorization|api.?key|^(NODE_|ELECTRON_|PYTHONPATH$|PYTHONHOME$)', k, re.I)}
|
|
136
|
+
env['ELECTRON_RUN_AS_NODE'] = '1'
|
|
137
|
+
self.job = OwnedJob()
|
|
138
|
+
self.error_log = (self.directory/'controller.stderr.log').open('x', encoding='utf-8')
|
|
139
|
+
self.process = subprocess.Popen([config['desktopExecutable'], config['controllerScript'], str(self.directory/'controller-config.json')],
|
|
140
|
+
cwd=self.directory, env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=self.error_log,
|
|
141
|
+
text=True, encoding='utf-8', creationflags=subprocess.CREATE_NO_WINDOW)
|
|
142
|
+
# Controller waits for stdin; no desktop can be created before assignment succeeds.
|
|
143
|
+
try:
|
|
144
|
+
self.job.assign(self.process)
|
|
145
|
+
except Exception:
|
|
146
|
+
self.process.stdin.close()
|
|
147
|
+
self.process.wait(timeout=10)
|
|
148
|
+
raise
|
|
149
|
+
self._bind(self.process.pid, 'controller')
|
|
150
|
+
self.reader = threading.Thread(target=self._read, daemon=True)
|
|
151
|
+
self.reader.start()
|
|
152
|
+
self.sampler = threading.Thread(target=self._sample, daemon=True)
|
|
153
|
+
self.sampler.start()
|
|
154
|
+
|
|
155
|
+
def _surface(self):
|
|
156
|
+
value = self._rpc('surface')
|
|
157
|
+
self.endpoints[f'web-{self.generation}'] = value['webPort']
|
|
158
|
+
table = tcp_rows()
|
|
159
|
+
with self.lock:
|
|
160
|
+
pids = set(self.owned)
|
|
161
|
+
# Snapshot after discovering the listeners, before granting ready.
|
|
162
|
+
pids.update(process_tree(self.process.pid))
|
|
163
|
+
evidence = []
|
|
164
|
+
for name, port in self.endpoints.items():
|
|
165
|
+
if not name.endswith('-' + str(self.generation)):
|
|
166
|
+
continue
|
|
167
|
+
listeners = [row for row in table if row['localPort'] == port and row['state'] == 'LISTEN']
|
|
168
|
+
if not listeners or not all(row['pid'] in pids for row in listeners):
|
|
169
|
+
raise RuntimeError('Owned endpoint listener not confirmed: ' + name)
|
|
170
|
+
for row in listeners:
|
|
171
|
+
self._bind(row['pid'], 'endpoint-owner')
|
|
172
|
+
if name.startswith('web-') and not any(row['pid'] == value['runtimePid'] for row in listeners):
|
|
173
|
+
raise RuntimeError('Authenticated backend PID does not own its Web listener')
|
|
174
|
+
evidence.append({'name': name, 'port': port, 'listeners': listeners})
|
|
175
|
+
return {**value, 'endpoints': evidence}
|
|
176
|
+
|
|
177
|
+
def _new_session(self):
|
|
178
|
+
directory = self.child_home/'sessions'
|
|
179
|
+
before = set(directory.rglob('*.zstd')) if directory.exists() else set()
|
|
180
|
+
action = self._rpc('new_session')
|
|
181
|
+
deadline = time.monotonic() + 12
|
|
182
|
+
while time.monotonic() < deadline:
|
|
183
|
+
paths = set(directory.rglob('*.zstd')) if directory.exists() else set()
|
|
184
|
+
added = paths - before
|
|
185
|
+
selected = action['selectedAfter'][0]
|
|
186
|
+
for path in sorted(p for p in paths if p.parent.name == selected):
|
|
187
|
+
try:
|
|
188
|
+
from compression import zstd
|
|
189
|
+
text = zstd.decompress(path.read_bytes()).decode('utf-8')
|
|
190
|
+
lines = [json.loads(line) for line in text.splitlines() if line.strip()]
|
|
191
|
+
except (OSError, ValueError, EOFError):
|
|
192
|
+
continue # A bounded wait for the one requested write; no second click.
|
|
193
|
+
header = next((row for row in lines if row.get('type') == 'session'), None)
|
|
194
|
+
if not header:
|
|
195
|
+
continue
|
|
196
|
+
cwd = header.get('cwd')
|
|
197
|
+
if not cwd or not Path(cwd).resolve().is_relative_to((self.directory/'documents').resolve()):
|
|
198
|
+
raise RuntimeError('New session workspace escaped Verify isolation')
|
|
199
|
+
if header.get('id') != selected or any(row.get('type') == 'turn/start' for row in lines):
|
|
200
|
+
raise RuntimeError('New session identity or zero-model boundary mismatch')
|
|
201
|
+
workspace = json.loads((self.child_home/'storages/workspace.json').read_text('utf-8'))
|
|
202
|
+
matches = [(key, row) for key, row in workspace['tables']['workspaces'].items() if selected in row['sessionIds']]
|
|
203
|
+
if len(matches) != 1 or Path(matches[0][1]['path']).resolve() != Path(cwd).resolve():
|
|
204
|
+
raise RuntimeError('Selected session does not belong to its persisted workspace')
|
|
205
|
+
return {**action, 'persistedSession': str(path), 'header': header, 'newFiles': len(added),
|
|
206
|
+
'mode': 'created-new-session' if path in added else 'reused-existing-blank', 'workspaceId': matches[0][0]}
|
|
207
|
+
time.sleep(.15)
|
|
208
|
+
raise TimeoutError('Official New session action did not persist an isolated session')
|
|
209
|
+
|
|
210
|
+
def run_step(self, name, context):
|
|
211
|
+
evidence = {'modelRequests': 0, 'automaticRetries': 0}
|
|
212
|
+
if name in ('boot', 'restart') and self.generation >= self.config['maximumBoots']:
|
|
213
|
+
return VerifyStep(name, 'UNTESTED', '桌面启动额度已用完;未关闭当前实例或请求额外启动',
|
|
214
|
+
evidence={**evidence, 'reason': 'desktop-start-budget-exhausted',
|
|
215
|
+
'maximumBoots': self.config['maximumBoots'],
|
|
216
|
+
'observedBoots': self.generation})
|
|
217
|
+
if name == 'boot':
|
|
218
|
+
if self.process is None:
|
|
219
|
+
self._start(context)
|
|
220
|
+
value = self._rpc('boot')
|
|
221
|
+
evidence.update(value)
|
|
222
|
+
evidence['jobDirectory'] = str(self.directory)
|
|
223
|
+
evidence['surface'] = self._surface()
|
|
224
|
+
elif name in ('surface_ready', 'restart_surface_ready'):
|
|
225
|
+
evidence.update(self._surface())
|
|
226
|
+
elif name == 'new_session':
|
|
227
|
+
evidence.update(self._new_session())
|
|
228
|
+
elif name in ('basic_response', 'core_tool'):
|
|
229
|
+
return VerifyStep(name, 'UNTESTED', '模型调用预算为0;未请求模型或依赖模型的工具', evidence=evidence)
|
|
230
|
+
elif name == 'restart':
|
|
231
|
+
evidence['closedGeneration'] = self._rpc('close', 30)
|
|
232
|
+
evidence['newGeneration'] = self._rpc('boot')
|
|
233
|
+
evidence['surface'] = self._surface()
|
|
234
|
+
else:
|
|
235
|
+
return VerifyStep(name, 'UNTESTED', '随包运行器未实现该能力', evidence=evidence)
|
|
236
|
+
return VerifyStep(name, 'PASS', '独立桌面真实动作及所属资源已核查', evidence=evidence)
|
|
237
|
+
|
|
238
|
+
def run_plugin_test(self, plugin, test_name, context):
|
|
239
|
+
return VerifyStep(f'{plugin}:{test_name}', 'UNTESTED', '插件专用能力尚未由随包运行器实际执行',
|
|
240
|
+
evidence={'plugin': plugin, 'test': test_name, 'modelRequests': 0})
|
|
241
|
+
|
|
242
|
+
def close(self):
|
|
243
|
+
if self.closed is not None:
|
|
244
|
+
return self.closed
|
|
245
|
+
if self.process is None:
|
|
246
|
+
self.closed = {'processExited': True, 'portApplicable': False, 'endpointsApplicable': False,
|
|
247
|
+
'reason': 'adapter did not create a process'}
|
|
248
|
+
if self.job:
|
|
249
|
+
self.job.close()
|
|
250
|
+
return self.closed
|
|
251
|
+
errors = []
|
|
252
|
+
close = None
|
|
253
|
+
try:
|
|
254
|
+
close = self._rpc('close', 35)
|
|
255
|
+
except Exception as error:
|
|
256
|
+
errors.append({'stage': 'desktop-close', 'type': type(error).__name__, 'message': str(error)})
|
|
257
|
+
try:
|
|
258
|
+
self.process.stdin.close()
|
|
259
|
+
self.process.wait(timeout=10)
|
|
260
|
+
except Exception as error:
|
|
261
|
+
errors.append({'stage': 'controller-close', 'type': type(error).__name__, 'message': str(error)})
|
|
262
|
+
# Give already-exiting descendants a bounded passive tail before fallback.
|
|
263
|
+
deadline = time.monotonic() + 3
|
|
264
|
+
accounting = self.job.active_processes()
|
|
265
|
+
while accounting['activeProcesses'] and time.monotonic() < deadline:
|
|
266
|
+
time.sleep(.1)
|
|
267
|
+
accounting = self.job.active_processes()
|
|
268
|
+
forced = accounting['activeProcesses'] != 0
|
|
269
|
+
if forced:
|
|
270
|
+
errors.append({'stage': 'owned-job', 'message': 'Owned descendants remained; closed KILL_ON_JOB_CLOSE job', 'before': accounting})
|
|
271
|
+
self.job.close()
|
|
272
|
+
if forced:
|
|
273
|
+
self.process.wait(timeout=5)
|
|
274
|
+
self.stop.set()
|
|
275
|
+
self.sampler.join(timeout=5)
|
|
276
|
+
with self.lock:
|
|
277
|
+
processes = [self.counter.process(pid, h, role, image) for pid, (h, image, role) in self.owned.items()]
|
|
278
|
+
table = tcp_rows()
|
|
279
|
+
active_states = {'LISTEN', 'ESTABLISHED', 'SYN_SENT', 'SYN_RCVD', 'CLOSE_WAIT'}
|
|
280
|
+
pids = set(self.owned)
|
|
281
|
+
endpoints = []
|
|
282
|
+
for name, port in self.endpoints.items():
|
|
283
|
+
remaining = [row for row in table if row['localPort'] == port and row['state'] in active_states]
|
|
284
|
+
endpoints.append({'name': name, 'port': port, 'checked': True, 'released': not remaining, 'remaining': remaining,
|
|
285
|
+
'foreignOwnerRows': [row for row in remaining if row['pid'] not in pids],
|
|
286
|
+
'method': 'passive IPv4+IPv6 TCP owner table; no connection probes'})
|
|
287
|
+
process_exited = all(row['exited'] for row in processes) and accounting['activeProcesses'] == 0
|
|
288
|
+
unexpected_exit = [row for row in processes if row['exited'] and row['exitCode'] != 0]
|
|
289
|
+
if unexpected_exit:
|
|
290
|
+
errors.append({'stage': 'process-exit', 'message': 'Observed owned processes exited nonzero', 'processes': unexpected_exit})
|
|
291
|
+
self.closed = {'processExited': process_exited, 'processes': processes, 'jobAccountingBeforeClose': accounting,
|
|
292
|
+
'portApplicable': bool(endpoints), 'portReleased': all(row['released'] for row in endpoints),
|
|
293
|
+
'portEvidence': {'checked': True, 'method': 'GetExtendedTcpTable IPv4+IPv6'},
|
|
294
|
+
'endpointsApplicable': bool(endpoints), 'endpoints': endpoints, 'errors': errors,
|
|
295
|
+
'normalClose': 'PASS' if not errors and close and close['exitCode'] == 0 and self.process.returncode == 0 and process_exited else 'FAIL',
|
|
296
|
+
'desktopClose': close, 'controllerExitCode': self.process.returncode, 'forcedJobClose': forced,
|
|
297
|
+
'jobDirectory': str(self.directory), 'resourceSamples': len(self.rows), 'observationErrors': self.observation_errors}
|
|
298
|
+
_write(self.directory/'cleanup.json', self.closed)
|
|
299
|
+
_write(self.directory/'resource-summary.json', summarize_resources(self.rows))
|
|
300
|
+
_write(self.directory/'controller-progress.json', self.events)
|
|
301
|
+
self.error_log.close()
|
|
302
|
+
for handle, _, _ in self.owned.values():
|
|
303
|
+
self.counter.k.CloseHandle(handle)
|
|
304
|
+
return self.closed
|