First launch of a bundled payload paid a cold-compile stall: the launcher redirects bytecode writes to a user-level cache (signature-breaking on macOS, read-only mount on AppImage/MSIX), so every import compiled from source. Now staging bakes the cache into the payload: - compileall with the payload's OWN staged 3.14 interpreter, unchecked- hash pycs: repack mtimes cannot invalidate them, a stale source can never trigger a rewrite, and read-only pycs mean the macOS signature never observes a change. Dirs stay writable — in-place rebuilds rmtree the tree; asserted coverage plus unchecked-hash means no cache-miss write can target them. - coverage is the perf contract: the bake FAILS if any parseable module lacks a pyc (empirically 0 unparseable files ship, so compileall is strict). Probe suite: py_compile/cache_from_source, PEP 552 flags, multi-root read, stale-source no-rewrite, read-only cache-dir import. - launcher: the baked marker makes configure() leave sys.pycache_prefix UNSET — the prefix relocates reads too and would hide the baked pycs. Payload modules read their source-adjacent cache (Python's default multi-root lookup); plugin/user modules keep caching beside their own sources under HERMES_HOME. Unmarked payloads keep the old redirect. - snapshot(): the sealed payload ships without tests/website/evals/ .github/nix/docker/tests-js (~69MB, 46% of tracked bytes) and without apps/ui-tui/web/scripts — CI prebuilds those products, and is_bundled_payload routes sealed updates to the channel updater, so the rebuild graph never runs in a bundle (linux_desktop_entry degrades to the themed icon). Frontend product staging keeps the full tree. - test_bundle_native now stages the FULL relocatable toolchain (a bare interpreter ELF falls back to its compile-time /install prefix and cannot create a venv), and runs on the real 3.14 for the first time this campaign — the whole battery had been running 3.12 against the 3.14-pinned lock.
189 lines
7.8 KiB
Python
189 lines
7.8 KiB
Python
"""Distribution snapshots, PM facts and portable link sealing."""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tarfile
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
if str(ROOT) not in sys.path:
|
|
sys.path.insert(0, str(ROOT))
|
|
|
|
|
|
# Snapshot dirs the sealed agent payload never reads. Tests, docs and CI
|
|
# definitions never ship; the frontend/desktop sources and build scripts are
|
|
# prebuilt by CI into staged products (AgentInputs.frontends) — a sealed
|
|
# payload never re-enters the source-build graph (is_bundled_payload routes
|
|
# updates to the channel updater), and linux_desktop_entry degrades to the
|
|
# themed icon when apps/desktop/assets is absent. Excluded here means:
|
|
# not packaged, not compiled, not baked. Frontend product staging
|
|
# (scripts/bundles/stage.py) needs its full tree and passes no exclusions.
|
|
INERT_SNAPSHOT_DIRS = (
|
|
"tests", "tests-js", "website", "evals", ".github", "nix", "docker",
|
|
"apps", "ui-tui", "web", "scripts",
|
|
)
|
|
|
|
|
|
def snapshot(repo: Path, ref: str, destination: Path, exclude: tuple[str, ...] = ()) -> None:
|
|
"""Archive a resolved git revision without carrying checkout metadata."""
|
|
repo, destination = repo.resolve(), destination.resolve()
|
|
if repo == destination or repo.is_relative_to(destination):
|
|
raise ValueError("the snapshot destination must not contain the source checkout")
|
|
with tempfile.TemporaryDirectory(prefix="hermes-archive-") as temp:
|
|
archive = Path(temp) / "source.tar"
|
|
pathspecs = [f":(exclude){name}" for name in exclude]
|
|
subprocess.run(
|
|
["git", "archive", "--format=tar", "--output", str(archive), ref, "--", *pathspecs],
|
|
cwd=repo, check=True)
|
|
if destination.exists():
|
|
shutil.rmtree(destination)
|
|
destination.mkdir(parents=True)
|
|
with tarfile.open(archive) as source:
|
|
source.extractall(destination, filter="data")
|
|
|
|
|
|
def _payload_file(root: Path, relative: str) -> Path:
|
|
path = root / relative
|
|
if path.is_symlink() or not path.resolve().is_relative_to(root.resolve()):
|
|
raise ValueError(f"metadata path escapes payload or is symlinked: {path}")
|
|
return path
|
|
|
|
|
|
def _share_metadata(path: Path) -> None:
|
|
# Atomic PM publication stays private; only packaged, non-secret records
|
|
# cross this boundary. Windows installers own their ACL policy.
|
|
if os.name != "nt":
|
|
path.chmod(0o644)
|
|
|
|
|
|
def _discard_build_locks(root: Path) -> None:
|
|
for name in ("venv", "pm-runtime"):
|
|
_payload_file(root, f"{name}/.lock").unlink(missing_ok=True)
|
|
|
|
|
|
def record_tools(root: Path, lock_path: Path, target: str, entries: dict[str, str]) -> None:
|
|
from pm.lock import Facts, Lockfile
|
|
from pm.registry import get_package
|
|
from pm.store import tree_digest
|
|
|
|
store = root / "tools"
|
|
facts, lock = Facts(_payload_file(root, "tools/facts.json")), Lockfile(lock_path)
|
|
for name, entry_name in entries.items():
|
|
entry = store / entry_name
|
|
version, artifacts = lock.version(name), lock.artifacts(name, target)
|
|
if not entry.is_dir() or not version or not artifacts:
|
|
raise ValueError(f"incomplete payload tool: {name}")
|
|
facts.record(name, version, entry_name, get_package(name).env(entry, target), store,
|
|
target=target, artifacts=[a["sha256"] for a in artifacts], digest=tree_digest(entry))
|
|
if entries:
|
|
_share_metadata(facts.path)
|
|
|
|
|
|
def rehash_tools(root: Path) -> int:
|
|
"""Record final tool bytes before the enclosing package is signed."""
|
|
from pm.lock import Facts
|
|
|
|
store = root / "tools"
|
|
facts = Facts(_payload_file(root, "tools/facts.json"), strict=True)
|
|
count = facts.refresh_digests(store)
|
|
_share_metadata(facts.path)
|
|
return count
|
|
|
|
|
|
def seal_pm_runtime(root: Path, python: Path) -> dict:
|
|
"""Record a resident PM runtime without Windows' CWD-bound redirector.
|
|
|
|
Sealed workers execute the base interpreter with -I -S and add only the
|
|
recorded site directory. Its paths remain valid after the payload moves.
|
|
"""
|
|
root, python = root.resolve(), python.resolve()
|
|
if not python.is_relative_to(root) or not python.is_file():
|
|
raise ValueError(f"PM interpreter must belong to the payload: {python}")
|
|
runtime = root / "pm-runtime"
|
|
sites = list(runtime.glob("lib/python*/site-packages")) + list(runtime.glob("Lib/site-packages"))
|
|
if len(sites) != 1:
|
|
raise ValueError(f"PM dependency directory missing or ambiguous: {runtime}")
|
|
marker = {
|
|
"python": Path(os.path.relpath(python, runtime)).as_posix(),
|
|
"sitePackages": sites[0].relative_to(runtime).as_posix(),
|
|
}
|
|
cfg = _payload_file(root, "pm-runtime/pyvenv.cfg")
|
|
marker_path = _payload_file(root, "pm-runtime/pm-runtime.json")
|
|
lines = cfg.read_text(encoding="utf-8-sig").splitlines()
|
|
lines = [line for line in lines if line.partition("=")[0].strip() not in
|
|
{"home", "executable", "base-executable", "base-prefix", "base-exec-prefix", "command"}]
|
|
lines.insert(0, f"home = {os.path.relpath(python.parent, runtime)}")
|
|
cfg.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
# A sealed runtime is not activated, and copied Windows redirectors cannot
|
|
# follow its relative home from arbitrary working directories.
|
|
bindir = runtime / ("Scripts" if os.name == "nt" else "bin")
|
|
for entry in bindir.iterdir():
|
|
if os.name == "nt" or not entry.is_symlink():
|
|
if entry.is_file():
|
|
entry.unlink()
|
|
_relativize_bin_links(root, runtime / "bin")
|
|
marker_path.write_text(json.dumps(marker, indent=2) + "\n", encoding="utf-8")
|
|
_share_metadata(marker_path)
|
|
_discard_build_locks(root)
|
|
return marker
|
|
|
|
|
|
def relativize_links(root: Path) -> int:
|
|
"""Only dependency-venv links move; framework links belong to codesign."""
|
|
root = root.resolve()
|
|
_discard_build_locks(root)
|
|
return sum(_relativize_bin_links(root, root / name / "bin") for name in ("venv", "pm-runtime"))
|
|
|
|
|
|
def _relativize_bin_links(root: Path, directory: Path) -> int:
|
|
count = 0
|
|
if not directory.is_dir():
|
|
return count
|
|
for link in directory.iterdir():
|
|
if not link.is_symlink():
|
|
continue
|
|
target = os.readlink(link)
|
|
if not os.path.isabs(target):
|
|
# Keep sibling chains intact; their absolute store link is rewritten separately.
|
|
if not Path(os.path.abspath(directory / target)).is_relative_to(root):
|
|
raise ValueError(f"link escapes payload: {link} -> {target}")
|
|
continue
|
|
resolved = (directory / target).resolve()
|
|
if not resolved.is_relative_to(root):
|
|
parts = Path(target).parts
|
|
if "tools" not in parts:
|
|
raise ValueError(f"link escapes payload: {link} -> {target}")
|
|
resolved = root.joinpath(*parts[parts.index("tools"):]).resolve()
|
|
if not resolved.is_relative_to(root) or not resolved.exists():
|
|
raise ValueError(f"missing payload link target: {link} -> {target}")
|
|
relative = os.path.relpath(resolved, directory)
|
|
if relative != target:
|
|
link.unlink()
|
|
link.symlink_to(relative)
|
|
count += 1
|
|
for link in directory.iterdir():
|
|
if link.is_symlink() and (not link.resolve().is_relative_to(root) or not link.exists()):
|
|
raise ValueError(f"invalid relative payload link: {link}")
|
|
return count
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("action", choices=["relocate", "rehash"])
|
|
parser.add_argument("payload", type=Path)
|
|
args = parser.parse_args()
|
|
if args.action == "rehash":
|
|
print(f"rehashed {rehash_tools(args.payload)} payload tools")
|
|
else:
|
|
relativize_links(args.payload)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|