"""v1.0.0.6 打包产物验证脚本. 校验三件事: 1. exe + _internal 关键文件存在且非空 2. 关键源码模块**确实被打进了包**(见下方"为什么查 PYZ 而非 .py 文件") 3. 启动 exe --no-gui 模式,HTTP 探 /chat 与 /api/version/check 关于第 2 项——为什么不能直接比对 .py 文件的 hash: PyInstaller 6.x 默认把 Python 模块**编译进 PYZ 归档**(exe 内的 zlib 压缩包), `_internal/` 下根本不会有 `scout/**/*.py`。早期版本此脚本按"包内应存在同名 .py 且 hash 一致"校验,在 6.x 上恒定报 FAILED(3 条 packaged missing), 属于**脚本过时**而非打包缺陷——但它每次都会误导排查。 现在改为:查 PYZ 归档的 TOC 里有没有该模块。非纯 Python 的资源文件 (如 VERSION)仍走文件 hash 比对。 用法: python verify_build.py """ import hashlib import os import socket import subprocess import sys import time import urllib.request ROOT = r"D:\gongniu_project\scout-agent" DIST = os.path.join(ROOT, "dist", "ScoutDesktop") EXE = os.path.join(DIST, "ScoutAgent.exe") INTERNAL = os.path.join(DIST, "_internal") # 关键模块 → 必须出现在包内(模块名用点分,PYZ TOC 查这个) # 覆盖本版修复的关键路径 PYZ_CHECKS = [ "scout.tools.builtin.memory", # MemorySaveTool 写入前查重 "scout.memory.store", # add_async 的 fact_key 透传 "scout.context.fact_key_registry", # 缺则运行时 ImportError "scout.core.platform", # 探测/执行同源(2026-10-06) "scout.engine.self_heal", # decode_output 替换裸 decode "scout.context.skills", # frontmatter 嵌套解析 # pycryptodome(企业微信加解密)—— 纯 Python 包,同样进 PYZ 而非 _internal。 # 1.0.0.6 曾因缺包+spec 未收集丢掉整个 Crypto(1.0.0.5 有 42 文件),用户 # 用到企业微信功能会 ImportError,故列为必检项。 "Crypto.Cipher.AES", ] # 关键非 Python 资源 → 包内文件路径(走 hash 比对) HASH_CHECKS = [ (r"VERSION", r"_internal\VERSION"), ] # PYZ 里最容易漏收的 GUI 依赖(2026-10-06 的 GUI 降级事故) _NATIVE_CHECKS = [ (r"_internal\Microsoft.Web.WebView2.Core.dll", "WebView2 核心程序集"), (r"_internal\Microsoft.Web.WebView2.WinForms.dll", "WebView2 WinForms 程序集"), (r"_internal\WebView2Loader.dll", "WebView2 native 加载器"), (r"_internal\pythonnet\runtime\Python.Runtime.dll", "pythonnet 运行时"), ] def sha256(path): h = hashlib.sha256() with open(path, "rb") as f: for chunk in iter(lambda: f.read(1 << 16), b""): h.update(chunk) return h.hexdigest() def pyz_modules() -> set: """从 exe 的 PYZ 归档里读出所有模块名(判断模块是否真被打进包). PyInstaller 6.x 把 Python 模块编译进 exe 内追加的 PYZ 归档(zlib压缩), `_internal/` 下不会有 .py 文件。用官方 archive reader 读,不自己逆格式 (手写 marshal 解析 PYZ 的 TOC 会在跨版本时炸 —— 试过,报 bad marshal data)。 流程:exe (CArchive) → 抽出 PYZ.pyz 条目 → 当作 ZlibArchive 读其 TOC。 """ import tempfile from PyInstaller.archive.readers import CArchiveReader, ZlibArchiveReader outer = CArchiveReader(EXE) pyz_bytes = outer.extract("PYZ.pyz") tmp = None try: fd, tmp = tempfile.mkstemp(suffix=".pyz") with os.fdopen(fd, "wb") as f: f.write(pyz_bytes) return set(ZlibArchiveReader(tmp).toc) finally: if tmp and os.path.isfile(tmp): os.remove(tmp) def free_port(): s = socket.socket() s.bind(("127.0.0.1", 0)) p = s.getsockname()[1] s.close() return p def main(): failures = [] # 1. 产物存在性 print("=== 1. artifact existence ===") if not os.path.isfile(EXE): failures.append(f"missing exe: {EXE}") print(f"[FAIL] missing exe: {EXE}") else: sz = os.path.getsize(EXE) print(f"[ok] exe size = {sz:,} bytes") if not os.path.isdir(INTERNAL): failures.append(f"missing _internal dir") print(f"[FAIL] missing _internal") else: n = sum(len(files) for _, _, files in os.walk(INTERNAL)) print(f"[ok] _internal file count = {n}") # 2. 模块确实打进包(PYZ TOC)+ 资源文件 hash print("\n=== 2. packaged modules (PYZ TOC) ===") mods = pyz_modules() print(f"[info] PYZ module count = {len(mods)}") for mod in PYZ_CHECKS: if mod in mods: print(f"[ok] in PYZ: {mod}") else: failures.append(f"not in PYZ: {mod}") print(f"[FAIL] not in PYZ: {mod}") print("\n=== 2b. native/GUI runtime deps ===") for rel, desc in _NATIVE_CHECKS: p = os.path.join(DIST, rel) if os.path.isfile(p): print(f"[ok] {desc}: {os.path.getsize(p):,} bytes") else: failures.append(f"missing native dep: {rel}") print(f"[FAIL] {desc} missing: {rel}") print("\n=== 2c. resource hash ===") for src_rel, dst_rel in HASH_CHECKS: src = os.path.join(ROOT, src_rel) dst = os.path.join(DIST, dst_rel) if not os.path.isfile(src): failures.append(f"source missing: {src_rel}") print(f"[FAIL] source missing: {src_rel}") continue if not os.path.isfile(dst): failures.append(f"packaged missing: {dst_rel}") print(f"[FAIL] packaged missing: {dst_rel}") continue h_src = sha256(src) h_dst = sha256(dst) match = h_src == h_dst tag = "ok" if match else "FAIL" if not match: failures.append(f"hash mismatch: {src_rel}") print(f"[{tag}] {src_rel} src={h_src[:12]} dst={h_dst[:12]}") # 3. 启动冒烟 print("\n=== 3. startup smoke (--no-gui) ===") port = free_port() proc = subprocess.Popen( [EXE, "--no-gui", "--host", "127.0.0.1", "--port", str(port)], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, cwd=DIST, ) try: ok = False for _ in range(20): # 最多等 20s time.sleep(1) try: with urllib.request.urlopen( f"http://127.0.0.1:{port}/chat", timeout=2 ) as r: if r.status == 200: print(f"[ok] /chat returned 200 (port {port})") ok = True break except Exception: continue if not ok: failures.append("exe did not respond on /chat within 20s") print("[FAIL] exe did not respond on /chat within 20s") else: # 版本号校验 try: with urllib.request.urlopen( f"http://127.0.0.1:{port}/api/version/check", timeout=3 ) as r: import json data = json.loads(r.read().decode()) v = data.get("current_version") or data.get("version") print(f"[ok] /api/version/check current_version = {v}") if v != "1.0.0.6": failures.append(f"version mismatch: got {v}, want 1.0.0.6") print(f"[FAIL] version mismatch: got {v}, want 1.0.0.6") except Exception as e: failures.append(f"version check failed: {e}") print(f"[FAIL] version check failed: {e}") finally: proc.terminate() try: proc.wait(timeout=5) except subprocess.TimeoutExpired: proc.kill() # 总结 print("\n=== summary ===") if failures: print(f"FAILED ({len(failures)} issues):") for f in failures: print(f" - {f}") sys.exit(1) else: print("ALL CHECKS PASSED") if __name__ == "__main__": main()