diff --git a/app/modules/documents/agent_package.py b/app/modules/documents/agent_package.py index 2e520c4..ffae7c6 100644 --- a/app/modules/documents/agent_package.py +++ b/app/modules/documents/agent_package.py @@ -8,6 +8,12 @@ ERP_LOCAL_AGENT_VERSION = "1.3.0" ERP_LOCAL_AGENT_NAME = "ERP Local Agent" RUNTIME_ROOT = Path(__file__).resolve().parent / "local_agent_runtime" +# ZIP metadata is part of the ZIP byte stream. If writestr() is allowed to use +# the current clock time, two packages built from identical source files have +# different SHA256 hashes. Keep update packages reproducible so the SHA256 +# returned by update-manifest is exactly the SHA256 of update-package. +_DETERMINISTIC_ZIP_TIMESTAMP = (2026, 1, 1, 0, 0, 0) + def build_agent_env(*, erp_base_url: str, node_code: str, node_secret: str, storage_root: str, tenant_id=None, branch_id=None, sync_interval_seconds: int = 30, request_timeout_seconds: int = 60, tunnel_enabled: bool = True, tunnel_reconnect_seconds: int = 10) -> str: erp_base_url = (erp_base_url or "").strip().rstrip("/") @@ -38,34 +44,67 @@ def build_agent_env(*, erp_base_url: str, node_code: str, node_secret: str, stor ) +def _zip_info(name: str) -> zipfile.ZipInfo: + info = zipfile.ZipInfo(filename=name, date_time=_DETERMINISTIC_ZIP_TIMESTAMP) + info.compress_type = zipfile.ZIP_DEFLATED + info.create_system = 3 + # Regular file with rw-r--r-- permissions. Stable metadata keeps ZIP bytes + # reproducible on every request and across Linux deployments. + info.external_attr = (0o100644 & 0xFFFF) << 16 + return info + + +def _write_zip_bytes(dst: zipfile.ZipFile, name: str, payload: bytes) -> None: + dst.writestr(_zip_info(name), payload) + + def _build_zip(*, env_text: str | None, include_env: bool, include_admin_readme: bool) -> bytes: if not RUNTIME_ROOT.exists(): raise RuntimeError(f"ERP Local Agent runtime is missing: {RUNTIME_ROOT}") + buffer = io.BytesIO() - with zipfile.ZipFile(buffer, "w", compression=zipfile.ZIP_DEFLATED) as dst: - for path in sorted(RUNTIME_ROOT.rglob("*")): + with zipfile.ZipFile( + buffer, + "w", + compression=zipfile.ZIP_DEFLATED, + compresslevel=9, + ) as dst: + for path in sorted(RUNTIME_ROOT.rglob("*"), key=lambda item: item.as_posix()): if not path.is_file() or "__pycache__" in path.parts: continue name = path.relative_to(RUNTIME_ROOT).as_posix() - dst.writestr(name, path.read_bytes()) + _write_zip_bytes(dst, name, path.read_bytes()) + if include_env and env_text is not None: - dst.writestr(".env", env_text) + _write_zip_bytes(dst, ".env", env_text.encode("utf-8")) + if include_admin_readme: - dst.writestr( - "README_ERP_LOCAL_AGENT.txt", + readme = ( f"ERP Local Agent {ERP_LOCAL_AGENT_VERSION}\n" "Existing storage, WebSocket tunnel, Tally, accounting .act and local dashboard functionality are preserved.\n" "Local dashboard: http://127.0.0.1:8788\n" "The agent checks for updates automatically but installation is always initiated by the local user.\n" - "The existing .env, data, logs, .venv and client storage are preserved during updates.\n", + "The existing .env, data, logs, .venv and client storage are preserved during updates.\n" ) + _write_zip_bytes(dst, "README_ERP_LOCAL_AGENT.txt", readme.encode("utf-8")) + return buffer.getvalue() def build_preconfigured_agent_zip(*, env_text: str, include_admin_readme: bool = False) -> bytes: - return _build_zip(env_text=env_text, include_env=True, include_admin_readme=include_admin_readme) + return _build_zip( + env_text=env_text, + include_env=True, + include_admin_readme=include_admin_readme, + ) def build_agent_update_zip() -> bytes: # Update package deliberately excludes .env, .venv, data, logs and client files. - return _build_zip(env_text=None, include_env=False, include_admin_readme=False) + # The ZIP is deterministic so update-manifest and update-package always agree + # on SHA256 for the same deployed runtime/version. + return _build_zip( + env_text=None, + include_env=False, + include_admin_readme=False, + )