Files
hermes-agent/scripts/bundles/native_prepared.py
ethernet 2efa4ff94f refactor(desktop): prepare dependencies before saving build caches
Dependency acquisition during packaging left native wheels and packager
inputs outside the pre-build cache save. Compose PM and existing providers
into a preparation phase, then require builds to consume admitted inputs.

Share native preparation with PM Bundle. Keep path-bound environments and
signing outputs separate from reusable caches. Use read-only cache tokens
for commit builds and preserve the one-command local build path.

Verify pinned tools through PM, probe PTYs under the prepared Electron,
and supply dmgbuild through a build-only PM package. Resolve bundled tool
stores from their payload manifest so relocation preserves discovery.

Validation: focused Python and JS tests, checkJs, Ruff, Windows checks,
anti-slop, cache relocation, and network-denied Linux AppImage builds.
Relocated runtime smoke passed with NixOS host libraries supplied.
Native Windows/macOS signing and live GitHub cache behavior remain untested.
2026-09-13 14:28:31 -04:00

141 lines
6.2 KiB
Python

"""Job-local native preparation admission; never a portable environment cache."""
from __future__ import annotations
from contextlib import contextmanager
from dataclasses import asdict
import hashlib
import json
import os
import re
import tomllib
from pathlib import Path
from pm.store import current_target, tree_digest
from scripts.build.inputs import AgentInputs, RESOURCE_ENV
def prepared_path(out: Path) -> Path:
return out.with_name(out.name + ".prepared.json")
@contextmanager
def preparation_lock(out: Path):
from hermes_cli.runtime_state import _lock
from pm.filesystem import is_junction
if out != out.resolve():
raise ValueError("symlinked native output")
for name in ("hermes-agent", "tools", "venv", "pm-runtime", "uv-cache", "bin",
"enabled-features.json", "manifest.json"):
path = out / name
if path.is_symlink() or (path.exists() and is_junction(path)):
raise ValueError(f"symlinked native output: {path}")
with out.with_name(out.name + ".prepare.lock").open("a+b") as lock:
if not _lock(lock.fileno(), wait=False):
raise ValueError("native output is already in use")
yield
def _digest(path: Path) -> str:
return tree_digest(path) if path.is_dir() else hashlib.sha256(path.read_bytes()).hexdigest()
def _source_digest(code: Path) -> str:
"""Ignore only assembly-owned products, leaving admitted source immutable."""
project = tomllib.loads((code / "pyproject.toml").read_text(encoding="utf-8-sig"))["project"]
dist = re.sub(r"[-_.]+", "_", project["name"])
generated = {"install-stamp.json", "hermes_cli/tui_dist", "hermes_cli/web_dist",
f"{dist}-{project['version']}.dist-info"}
files = {}
for directory, dirs, names in os.walk(code):
dirs[:] = [name for name in dirs if name != "__pycache__"
and (Path(directory) / name).relative_to(code).as_posix() not in generated]
for name in dirs:
path = Path(directory) / name
if path.is_symlink():
files[path.relative_to(code).as_posix()] = os.readlink(path)
for name in names:
path = Path(directory) / name
relative = path.relative_to(code).as_posix()
if relative not in generated:
files[relative] = os.readlink(path) if path.is_symlink() else _digest(path)
return hashlib.sha256(json.dumps(files, sort_keys=True).encode()).hexdigest()
def _owned(path: Path, out: Path) -> None:
if not path.is_absolute() or not path.exists() or not path.resolve().is_relative_to(out):
raise ValueError(f"missing or escaped prepared path: {path}")
def _check_links(path: Path, out: Path) -> None:
from pm.filesystem import is_junction
for directory, dirs, files in os.walk(path):
for name in dirs + files:
entry = Path(directory) / name
if entry.is_symlink() or is_junction(entry):
_owned(entry, out)
def _input_paths(inputs: AgentInputs, out: Path) -> list[Path]:
expected = {"code": out / "hermes-agent", "project": out / "hermes-agent/pyproject.toml",
"tools": out / "tools", "environment": out / "venv", "pm_runtime": out / "pm-runtime",
"features": out / "enabled-features.json"}
if (any(getattr(inputs, key) != path for key, path in expected.items())
or inputs.placement != "contained" or inputs.repo != "hermes-agent"
or inputs.frontends or inputs.stamp or inputs.command_dir or inputs.env
or inputs.resources != {name: inputs.code / name for name in RESOURCE_ENV}
or not inputs.site_packages.is_relative_to(inputs.environment)):
raise ValueError("native prepared input layout changed")
paths = [inputs.code, inputs.tools, inputs.environment, inputs.pm_runtime,
inputs.python, inputs.features, out / "uv-cache"]
for path in [inputs.project, inputs.site_packages, *inputs.resources.values(), *paths]:
_owned(path, out)
return paths
def publish_prepared(out: Path, source: Path, revision: str, inputs: AgentInputs) -> Path:
from hermes_cli.runtime_state import _atomic_bytes
from pm.paths import lockfile_path
inputs.validate(out)
paths = _input_paths(inputs, out)
digests = {}
for path in paths:
_owned(path, out)
_check_links(path, out)
digests[path.relative_to(out).as_posix()] = _source_digest(path) if path == inputs.code else _digest(path)
data = {"schema": 1, "out": str(out), "source": str(source), "revision": revision,
"lock": _digest(lockfile_path()), "inputs": asdict(inputs), "digests": digests}
prepared = prepared_path(out)
_atomic_bytes(prepared, (json.dumps(data, default=str, indent=2) + "\n").encode())
return prepared
def load_prepared(prepared: Path) -> AgentInputs:
from pm.paths import lockfile_path
try:
data = json.loads(prepared.read_text(encoding="utf-8-sig"))
out = Path(data["out"])
if (prepared.is_symlink() or out != out.resolve() or data["schema"] != 1
or prepared != prepared_path(out) or data["lock"] != _digest(lockfile_path())
or not re.fullmatch(r"[a-f0-9]{40}", data["revision"])):
raise ValueError("native preparation identity changed")
inputs = AgentInputs.from_dict(data["inputs"])
if inputs.target != current_target() or not inputs.ref:
raise ValueError("native preparation target/ref changed")
inputs.validate(out)
required = {path.relative_to(out).as_posix() for path in _input_paths(inputs, out)}
if set(data["digests"]) != required:
raise ValueError("native preparation inventory changed")
for name, digest in data["digests"].items():
path = out / name
_owned(path, out)
_check_links(path, out)
actual = _source_digest(path) if path == inputs.code else _digest(path)
if actual != digest:
raise ValueError(f"prepared bytes changed: {name}")
return inputs
except (OSError, ValueError, KeyError, TypeError) as exc:
raise ValueError(f"invalid native preparation; run preparation again: {exc}") from exc