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
@@ -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
@@ -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
- print(json.dumps(result.as_dict(), ensure_ascii=False, indent=2, sort_keys=True))
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
- print(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True))
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
- result = export_profile(
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
- print(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True))
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
- print(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True))
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
- print(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True))
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
- print(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True))
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
- print(json.dumps(result.as_dict(), ensure_ascii=False, indent=2, sort_keys=True))
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
- print(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True))
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
- print(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True))
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
- adapter = SubprocessVerifyAdapter(config)
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
- print(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True))
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
- print(json.dumps(result.as_dict(), ensure_ascii=False, indent=2, sort_keys=True))
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