Ctrl K
ComunidadGuías…

verify_build.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""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()