"""Portable dependency discovery around the byte-preserved Skill runner.""" from __future__ import annotations import copy import importlib.util import json import os from pathlib import Path import struct import sys import tempfile import time PACKAGE = Path(__file__).resolve().parent # -I excludes even the script directory. Only add this installed package, never cwd. sys.path.insert(0, str(PACKAGE)) from dependency_checks import check_function, check_startup, python_runtime def upstream_runner(): name = "artemis_device_test_upstream_runner" spec = importlib.util.spec_from_file_location( name, PACKAGE / "skill" / "scripts" / "tool_runner.py" ) module = importlib.util.module_from_spec(spec) sys.modules[name] = module spec.loader.exec_module(module) return module UPSTREAM = upstream_runner() class DependencyError(ValueError): """Only stable, public error codes belong here.""" def dll_machine(path: Path) -> int: """Read just the DOS/PE headers; discovering a DLL never loads it.""" try: with path.open("rb") as stream: header = stream.read(64) if len(header) != 64 or header[:2] != b"MZ": raise ValueError offset = struct.unpack_from(" None: machine = dll_machine(path) expected = 0x8664 if struct.calcsize("P") == 8 else 0x14C if machine != expected: raise DependencyError("mediainfo_architecture_mismatch") def path_directories(): seen = set() for value in os.environ.get("PATH", "").split(os.pathsep): value = value.strip().strip('"') if not value or value.startswith(("\\\\", "//")): continue path = Path(value) # Ignore empty/relative PATH entries: they must not search the working directory. if not path.is_absolute() or not path.is_dir(): continue resolved = path.resolve() if resolved not in seen: seen.add(resolved) yield resolved def media_library(profile, roots): configured = profile.get("libraries", {}).get("mediainfo") if configured: path = Path(UPSTREAM.configured_local_path(configured, "mediainfo", r"MediaInfo\.dll")) validate_library(path) return str(path), "profile", [] rejected = [] candidates = [ (path, "tool_root") for path in UPSTREAM.root_candidates(roots) if path.name.lower() == "mediainfo.dll" and path.is_file() ] candidates.extend((directory / "MediaInfo.dll", "path") for directory in path_directories()) seen = set() for path, source in candidates: if not path.is_file() or path.resolve() in seen: continue seen.add(path.resolve()) try: validate_library(path) except DependencyError as exc: rejected.append({"path": str(path), "error_code": str(exc)}) continue return str(path.resolve()), source, rejected if rejected: raise DependencyError("mediainfo_no_matching_architecture_or_valid_pe") raise UPSTREAM.ToolMissing("mediainfo") def resolve_dependency(tool_id, profile, roots): """Resolve one dependency only; an unrelated broken dependency cannot block it.""" effective = copy.deepcopy(profile) rejected = [] if tool_id == "mediainfo": path, source, rejected = media_library(profile, roots) effective.setdefault("libraries", {})["mediainfo"] = path elif ( tool_id == "binwalk" and not profile.get("runtimes", {}).get("binwalk_python") and not profile.get("tools", {}).get("binwalk") ): # One documented opt-in directory convention, never an arbitrary Python on PATH. for root in roots: runtime = Path(root) / "binwalk-python" / "Scripts" / "python.exe" if runtime.is_file(): effective.setdefault("runtimes", {})["binwalk_python"] = str(runtime.resolve()) break prefix = UPSTREAM.command_prefix(tool_id, effective, roots) if tool_id != "mediainfo": path = prefix[0] if profile.get("runtimes", {}).get("binwalk_python") and tool_id == "binwalk": source = "profile.runtime" elif profile.get("tools", {}).get(tool_id): source = "profile" elif tool_id == "binwalk" and effective.get("runtimes", {}).get("binwalk_python"): source = "tool_root.binwalk-python" else: source = ( "tool_root" if any(Path(path).is_relative_to(Path(root).resolve()) for root in roots) else "path" ) return ( effective, prefix, {"status": "found", "source": source, "path": path, "rejected": rejected}, ) CAPABILITIES = { "probe_media": ( "ffprobe", ["RTSP connectivity and business expectations require task evidence"], ), "media_info": ("mediainfo", []), "analyze_pcap": ("tshark", []), "capture_packets": ( "dumpcap", ["selected interface, Npcap and capture permission; no capture attempted"], ), "scan_ports": ("nmap", ["target connectivity and requested port states; no scan attempted"]), "scan_firmware": ( "binwalk", ["signature detection is not firmware compatibility or encryption acceptance"], ), "capture_serial": ( "powershell", ["adapter driver, selected COM, baud, permission and exclusive access; no port opened"], ), } def capability_status(tools): by_id = {row["tool_id"]: row for row in tools} result = {} for capability, (tool_id, conditions) in CAPABILITIES.items(): row = by_id[tool_id] discovery, startup, function = ( row[key]["status"] for key in ("discovery", "startup", "functional") ) blocked = ( discovery != "found" or "failed" in {startup, function} or "timed_out" in {startup, function} ) result[capability] = { "dependency": tool_id, "status": "blocked" if blocked else "local_verified" if function == "passed" else "available_unverified", "external_conditions": conditions, } return result def doctor(profile, roots, *, check_versions=False, check_functional=False, timeout=42): started = time.monotonic() deadline = started + max(0.1, timeout - 1) UPSTREAM.validate_profile(profile) rows, prefixes = [], {} catalog = json.loads(UPSTREAM.CATALOG.read_text("utf-8")) for entry in catalog["tools"]: row = dict(entry) tool_id = entry["tool_id"] if tool_id == "mediainfo": row["capability"] = ( "通过 profile/统一目录/PATH 发现匹配位数的本地 MediaInfo.dll;独立子进程固定 API 和白名单字段,不使用 GUI/CLI 替代。" ) if tool_id not in UPSTREAM.EXECUTABLES and tool_id != "mediainfo": row["availability"] = { "mcp": "mcp-check-required", "orchestrator": "orchestrator-context", }.get(entry.get("interface"), "manual-interface") rows.append(row) continue row.update(startup={"status": "not_checked"}, functional={"status": "not_checked"}) try: _, prefix, discovery = resolve_dependency(tool_id, profile, roots) prefixes[tool_id] = prefix row["discovery"] = discovery row["availability"] = ( "library-found" if tool_id == "mediainfo" else "python-module-configured" if tool_id == "binwalk" and len(prefix) > 1 else "executable-found" ) except UPSTREAM.ToolMissing: row.update( availability="missing", discovery={"status": "missing", "error_code": "dependency_not_found"}, ) except (UPSTREAM.ValidationError, DependencyError) as exc: row.update( availability="invalid", discovery={"status": "invalid", "error_code": str(exc)} ) except OSError: row.update( availability="unavailable", discovery={"status": "unavailable", "error_code": "dependency_path_unreadable"}, ) rows.append(row) runtime = python_runtime(deadline, check_versions or check_functional) with tempfile.TemporaryDirectory(prefix="artemis-dependency-check-") as temporary: directory = Path(temporary) # Complete startup checks first, so a slow functional check cannot hide discovery/load results. if check_versions or check_functional: for row in rows: tool_id = row["tool_id"] if tool_id in prefixes: row["startup"] = check_startup(tool_id, prefixes[tool_id], directory, deadline) if check_functional: for row in rows: tool_id = row["tool_id"] if tool_id in prefixes: row["functional"] = ( check_function(tool_id, prefixes[tool_id], directory, deadline) if row["startup"]["status"] == "passed" else {"status": "not_checked", "reason": "startup_not_verified"} ) return { "execution_status": "completed", "test_verdict": "not_evaluated", "tools": rows, "capabilities": capability_status(rows), "python_runtime": runtime, "checks": { "startup_requested": check_versions or check_functional, "functional_requested": check_functional, }, "external_conditions": { "capture_driver": "interface enumeration does not prove packet capture permission", "serial_driver": "port enumeration does not prove adapter driver or communication", "gui_and_websites": "require actual application/window, authorized session and permissions", "android": "requires Artemis device checks in the current task", }, "device_actions_executed": False, "catalog_evidence_is_historical": True, "elapsed_seconds": round(time.monotonic() - started, 3), "note": "Independent local checks; no scan, capture, serial open, download, installation or business verdict. PATH is inherited from the Artemis host process.", } def main(argv=None): arguments = list(sys.argv[1:] if argv is None else argv) functional = "--check-functional" in arguments if functional: arguments.remove("--check-functional") try: args = UPSTREAM.parse_args(arguments) if functional and args.command != "doctor": raise UPSTREAM.ValidationError("functional_check_only_supported_for_doctor") profile = UPSTREAM.load_profile(args.profile) if args.command in {"list", "doctor"}: result = doctor( profile, args.tool_root, check_versions=bool(getattr(args, "check_versions", False) and not args.dry_run), check_functional=functional and not args.dry_run, timeout=UPSTREAM.bounded_int( args.timeout or "42", 1, 3600, "timeout_must_be_1_to_3600_seconds" ), ) else: tool_id = { "probe": "ffprobe", "media-info": "mediainfo", "analyze-pcap": "tshark", "capture": "dumpcap", "scan": "nmap", "firmware-scan": "binwalk", "serial": "powershell", }[args.command] effective, _, discovery = resolve_dependency(tool_id, profile, args.tool_root) plan = UPSTREAM.build_plan(args, effective, os.environ) result = ( {"execution_status": "planned", "test_verdict": "not_evaluated", **plan.public()} if args.dry_run else UPSTREAM.execute(plan) ) result["dependency_discovery"] = discovery code = 0 if result["execution_status"] in {"planned", "completed"} else 1 except UPSTREAM.ToolMissing as exc: result = { "execution_status": "tool_missing", "test_verdict": "not_evaluated", "tool_id": str(exc), "error_code": "prepare_dependency_in_path_tool_root_or_explicit_profile", } code = 3 except (UPSTREAM.ValidationError, DependencyError) as exc: result = { "execution_status": "invalid_arguments", "test_verdict": "not_evaluated", "error_code": str(exc), } code = 2 except (OSError, ValueError): result = { "execution_status": "configuration_error", "test_verdict": "not_evaluated", "error_code": "local_file_unreadable_or_invalid", } code = 2 print(json.dumps(result, ensure_ascii=True, indent=2)) return code if __name__ == "__main__": sys.exit(main())