mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-11 14:10:50 +02:00
78 lines
4.1 KiB
Python
78 lines
4.1 KiB
Python
"""Build a native closure after provision-hermes verifies source and resolves uv.lock.
|
|
|
|
This runs only during explicit provisioning, never in an agent's execution.
|
|
"""
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import shutil
|
|
import sys
|
|
import sysconfig
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
source, destination, provider = map(Path, sys.argv[1:])
|
|
if destination.exists():
|
|
raise ValueError("Hermes provisioning destination must be new")
|
|
destination.mkdir(parents=True, mode=0o700)
|
|
|
|
|
|
def copy_tree(origin, target, excluded=()):
|
|
def ignore(directory, names):
|
|
return [name for name in names if name in excluded or name == "__pycache__"
|
|
or name.endswith((".pyc", ".pyo"))]
|
|
shutil.copytree(origin, target, symlinks=False, ignore=ignore, dirs_exist_ok=True)
|
|
|
|
|
|
copy_tree(Path(sys.base_prefix), destination / "python", ("include", "share"))
|
|
# uv rewrites sysconfig to its installation home. That path differs between
|
|
# local consumers, root image builds and the Daytona runtime user. Normalize
|
|
# this build metadata so the verified closure is relocatable and reproducible.
|
|
prefixes = {sys.base_prefix, str(Path(sys.base_prefix).resolve()), sysconfig.get_config_var("prefix")}
|
|
for metadata in (destination / "python/lib/python3.12").glob("_sysconfigdata_*.py"):
|
|
text = metadata.read_text()
|
|
for prefix in sorted((p for p in prefixes if isinstance(p, str)), key=len, reverse=True):
|
|
text = text.replace(prefix, "/paperclip-hermes/python")
|
|
metadata.write_text(text)
|
|
if sys.platform == "darwin":
|
|
# uv also rewrites the Mach-O install name. Restore a relative identity and
|
|
# a deterministic ad-hoc signature after changing its load command.
|
|
library = destination / "python/lib/libpython3.12.dylib"
|
|
if not shutil.which("install_name_tool") or not shutil.which("codesign"):
|
|
raise ValueError("Hermes provisioning requires macOS Command Line Tools (install_name_tool and codesign)")
|
|
subprocess.run(["install_name_tool", "-id", "@rpath/libpython3.12.dylib", str(library)], check=True)
|
|
subprocess.run(["codesign", "--force", "--sign", "-", "--identifier", "paperclip.hermes.libpython3.12", "--timestamp=none", str(library)], check=True)
|
|
site = destination / "python/lib/python3.12/site-packages"
|
|
site.mkdir(parents=True, exist_ok=True)
|
|
# Do not retain editable-install locators, virtualenv hooks, or provenance files
|
|
# containing the build machine's absolute paths. Hermes source is loaded below.
|
|
for item in (source / ".venv/lib/python3.12/site-packages").iterdir():
|
|
if item.name.startswith(("__editable__", "_virtualenv", "hermes_agent-")) or item.name == "__pycache__":
|
|
continue
|
|
if item.suffix == ".pth":
|
|
raise ValueError("Unreviewed Python site hook in Hermes dependencies")
|
|
if item.is_dir():
|
|
copy_tree(item, site / item.name)
|
|
elif item.suffix not in (".pyc", ".pyo"):
|
|
shutil.copy2(item, site / item.name)
|
|
copy_tree(source, destination / "app", (".venv", ".git", ".github", "tests", "website", "docs", ".pytest_cache"))
|
|
for name in ("bridge.py", "policy.py", "tool_process.py", "entry.py", "version.json"):
|
|
shutil.copy2(provider / name, destination / name)
|
|
# Installed RECORD and direct_url metadata can contain mutable build locations.
|
|
for item in destination.rglob("*.dist-info/direct_url.json"):
|
|
item.unlink()
|
|
for item in destination.rglob("*.dist-info/RECORD"):
|
|
item.unlink()
|
|
entries = []
|
|
for item in sorted(destination.rglob("*")):
|
|
if not item.is_file():
|
|
continue
|
|
data = item.read_bytes()
|
|
entries.append({"path": item.relative_to(destination).as_posix(), "sha256": hashlib.sha256(data).hexdigest(),
|
|
"size": len(data), "executable": bool(item.stat().st_mode & 0o111)})
|
|
entries.sort(key=lambda entry: entry["path"].encode("utf-16-be"))
|
|
serialized = json.dumps(entries, separators=(",", ":"), ensure_ascii=False).encode()
|
|
digest = hashlib.sha256(serialized).hexdigest()
|
|
(destination / "manifest.json").write_text(json.dumps({"entries": entries}, separators=(",", ":")))
|
|
print(json.dumps({"closureSha256": digest, "files": len(entries), "bytes": sum(e["size"] for e in entries)}))
|