"""Package definitions for the tools hermes manages. Versions and hashes live in pm/lock.json (written by `pm lock`), never here.""" from __future__ import annotations import logging import os import shutil import subprocess import sys import tempfile from pathlib import Path from typing import Optional from pm.package import ( DebPackage, InstallError, Package, StatePackage, _entry_listing, _probe_reason, ) from pm.registry import register from pm.store import ALL_TARGETS, MUSL_TARGETS, Store, current_target, flatten_single_dir, merge_tree from pm.update import ( btbn_index, btbn_versions, github_release_tags, llama_app_bucket_versions, llama_app_latest, martin_riedl_index, martin_riedl_versions, node_latest_versions, npm_dist_tags, pbs_versions, ) LOG = logging.getLogger(__name__) _RUST_TRIPLE = { "win32-x64": "x86_64-pc-windows-msvc", "win32-arm64": "aarch64-pc-windows-msvc", "linux-x64": "x86_64-unknown-linux-gnu", "linux-arm64": "aarch64-unknown-linux-gnu", "linux-x64-musl": "x86_64-unknown-linux-musl", "linux-arm64-musl": "aarch64-unknown-linux-musl", "darwin-x64": "x86_64-apple-darwin", "darwin-arm64": "aarch64-apple-darwin", } _NODE_PLAT = { "win32-x64": "win-x64", "win32-arm64": "win-arm64", "linux-x64": "linux-x64", "linux-arm64": "linux-arm64", "linux-x64-musl": "linux-x64-musl", "linux-arm64-musl": "linux-arm64-musl", "darwin-x64": "darwin-x64", "darwin-arm64": "darwin-arm64", } class BinaryPackage(Package): """A downloaded archive exposing one binary. Covers most tools.""" binary_rel: dict[str, str] = {} flatten = True probe_version = True # argv after the binary for the smoke probe. A package whose binary # rejects the GNU double-dash form (ffmpeg's BtbN autobuild) overrides. probe_args: list[str] = ["--version"] # Run the probe with cwd=binary.parent: dlopen'd backends (llama.cpp's # cudart) resolve their shared libraries from the working directory. probe_cwd = False def _rel(self, target: str) -> Optional[str]: win = target.startswith("win32") return self.binary_rel.get(target) or self.binary_rel.get( "win32" if win else "posix" ) def stage(self, store: Store, staged: Path, version: str, target: str) -> None: if self.flatten: flatten_single_dir(staged) def binary(self, entry: Path, target: str) -> Optional[Path]: rel = self._rel(target) return entry / rel if rel else None def verify(self, entry: Path, target: str) -> str: """Check file/architecture evidence for every target, plus a smoke probe only on the native target. Never execute cross-staged bytes.""" binary = self.binary(entry, target) if binary is None: return "no binary_rel for this target" reason = self._binary_reason(binary, entry, target) if reason: return reason if not self.probe_version or target != current_target(): return "" try: proc = subprocess.run( [str(binary), *self.probe_args], capture_output=True, timeout=60, cwd=str(binary.parent) if self.probe_cwd else None, env=self._probe_env(), ) except OSError as e: return f"could not exec {binary} {' '.join(self.probe_args)}: {e}" except subprocess.TimeoutExpired: return f"{binary} {' '.join(self.probe_args)} timed out after 60s" if proc.returncode != 0: return _probe_reason(binary, proc) return "" def _probe_env(self) -> dict: """Deps' env composed in: npm's shim is `#!/usr/bin/env node` and must find the node it extends on PATH.""" if not self.deps: return dict(os.environ) from pm.install import env_for return env_for(*self.deps) @register class Dmgbuild(BinaryPackage): """Build-only DMG supplier, independently pinned by PM rather than dmg-builder. The lock version is +. Updates are manual: review the official electron-builder-binaries bundle and re-pin both Darwin targets. Keep the paired Python tree intact for the launcher and diagnostic hook. """ name = "dmgbuild" internal = True on_path = False flatten = False probe_version = False binary_rel = {"posix": "dmgbuild"} gaps = {target: "DMG creation requires macOS" for target in ALL_TARGETS if not target.startswith("darwin-")} def fetch_url(self, version: str, target: str) -> str: release, _, revision = version.partition("+") arch = {"darwin-arm64": "arm64", "darwin-x64": "x86_64"}[target] return ( "https://github.com/electron-userland/electron-builder-binaries/releases/download/" f"dmg-builder@{release}/dmgbuild-bundle-{arch}-{revision}.tar.gz" ) def verify(self, entry: Path, target: str) -> str: return super().verify(entry, target) or self._binary_reason(entry / "python/bin/python3", entry, target) class _BionicDebArm: """Shared bionic-arm behavior for the termux tool packages: extract as a .deb on the bionic target (DebPackage's hardened ar+tar), as the binary package otherwise, and never host-exec-probe a bionic binary (it cannot run on the staging host).""" def unpack(self, archive: Path, staged: Path, target: str) -> None: if target == "linux-arm64-bionic": DebPackage.unpack(self, archive, staged, target) else: BinaryPackage.unpack(self, archive, staged, target) def stage(self, store: Store, staged: Path, version: str, target: str) -> None: if target != "linux-arm64-bionic": BinaryPackage.stage(self, store, staged, version, target) def binary(self, entry: Path, target: str) -> Optional[Path]: if target == "linux-arm64-bionic": # File evidence, not exec: the staged .deb's main binary. # Cross-target verify() never probes it; consumers (env PATH, # bundle layout) need the real path. return entry / self.prefix_rel / self.main_rel(target) return BinaryPackage.binary(self, entry, target) def verify(self, entry: Path, target: str) -> str: # MRO order puts BinaryPackage.verify (exec-probe semantics) ahead of # DebPackage.verify (file-evidence semantics); bionic needs the # latter -- one dispatch here replaces the per-class copies. if target == "linux-arm64-bionic": return DebPackage.verify(self, entry, target) return BinaryPackage.verify(self, entry, target) @register class Uv(_BionicDebArm, BinaryPackage, DebPackage): """astral's prebuilt tarballs for glibc/musl/mac/win; the Termux main-repo uv .deb for bionic (termux builds uv from source -- no astral bionic artifact exists). The bionic arm is a runtime tool on the phone (lazy plugin installs) and the wheelhouse's resolver in the build container.""" name = "uv" deps = ("python",) internal = True on_path = False binary_rel = {"win32": "uv.exe", "posix": "uv"} # The staged .deb's main binary: DebPackage.verify checks it. main_bin_rel = "bin/uv" def main_rel(self, target: str) -> str: return self.main_bin_rel deb_package = "uv" def fetch_url(self, version: str, target: str) -> str: if target == "linux-arm64-bionic": return f"https://packages.termux.dev/apt/termux-main/pool/main/u/uv/uv_{version}_aarch64.deb" triple = _RUST_TRIPLE[target] ext = "zip" if target.startswith("win32") else "tar.gz" return f"https://github.com/astral-sh/uv/releases/download/{version}/uv-{triple}.{ext}" def latest_versions(self, target: str, locked=None) -> list[str]: return github_release_tags("astral-sh/uv") @register class Python(_BionicDebArm, BinaryPackage, DebPackage): """The pinned interpreter for launchers and every PM-managed uv command. Optional when provisioning unrelated tools; required by uv's closure. """ name = "python" optional = True probe_version = False binary_rel = {"win32": "python.exe", "posix": "bin/python3"} # The staged .deb's main binary: DebPackage.verify checks it. main_bin_rel = "bin/python3.14" def main_rel(self, target: str) -> str: return self.main_bin_rel # termux-main (official termux repo) python deb. It lags python-build- # standalone by one patch (3.14.6-1 vs 3.14.7), so the bionic row is a # manual pin -- never derived from the node version, and pm update leaves # it alone (no bionic resolver). deb_package = "python" _BIONIC_URL = ( "https://packages.termux.dev/apt/termux-main/pool/main/p/python/" "python_3.14.6-1_aarch64.deb" ) def stage(self, store: Store, staged: Path, version: str, target: str) -> None: super().stage(store, staged, version, target) binary = self.binary(staged, target) if binary is not None and sys.platform == "darwin": from hermes_cli.macos_signing import sign_managed_python sign_managed_python(binary) # python-build-standalone ships the x64 VC runtime (vcruntime140_1.dll) # beside ARM64 Windows Python; it cannot load on ARM64 and would fail # the arch guard. Drop it HERE, before publish: the tree digest is # recorded over the shipped bytes, so a post-publish deletion (as the # old bundle-time drop did) makes doctor's re-hash mismatch. if target == "win32-arm64": (staged / "vcruntime140_1.dll").unlink(missing_ok=True) def fetch_url(self, version: str, target: str) -> str: if target == "linux-arm64-bionic": return self._BIONIC_URL # lock version is "+", e.g. "3.14.7+20260901" pyver, _, tag = version.partition("+") if not tag: raise InstallError(self.name, f"version {version!r} needs the + tag") triple = _RUST_TRIPLE[target] return ( "https://github.com/astral-sh/python-build-standalone/releases/download/" f"{tag}/cpython-{pyver}+{tag}-{triple}-install_only.tar.gz" ) def latest_versions(self, target: str, locked=None) -> list[str]: # Keep the locked minor line. The advertised asset owns patch and build. # Bionic remains a manual pin from a separate supplier. if target == "linux-arm64-bionic" or not locked or "+" not in locked: return [] pyver = locked.partition("+")[0] minor = ".".join(pyver.split(".")[:2]) return pbs_versions(minor, _RUST_TRIPLE[target]) def _uv_lock_digest(path: Path) -> bytes: """sha256 of uv.lock, cached on (mtime_ns, size) — check() runs at every startup and uv.lock is megabyte-class.""" import hashlib stat = path.stat() key = (stat.st_mtime_ns, stat.st_size) cached = _uv_lock_digest_cache.get(path) if cached and cached[0] == key: return cached[1] digest = hashlib.sha256(path.read_bytes()).digest() _uv_lock_digest_cache[path] = (key, digest) return digest _uv_lock_digest_cache: dict[Path, tuple] = {} def uv_cache_dir() -> Path: """The hermes-owned uv cache: machine-scoped and shared (keyed by content — two profiles reuse one cache), anchored to the DEFAULT hermes root like partials_root(). A bundle ships a seeded copy at the payload root (uv-cache/); the first call on a sealed install copies it out to the writable machine cache (the read-only payload can't serve uv's working cache), and a warm `uv sync --offline` from it is near-free (probed: 0.4s vs 1.2s cold) — the blow-away- on-update contract depends on it. uv's default cache location is per-user/platform-opinionated and never used by pm.""" from hermes_constants import get_default_hermes_root machine_cache = get_default_hermes_root() / "cache" / "uv" marker = machine_cache / ".seeded" if not marker.is_file(): # Seed from a shipped bundle cache when present (payload root = # store_root().parent on a sealed install). Record completion ONLY after a clean copy: a # partial seed that marked itself done would never be retried, and every later offline # sync that needs the missing entries fails closed. try: from pm.paths import store_root payload_cache = store_root().parent / "uv-cache" if payload_cache.is_dir(): machine_cache.mkdir(parents=True, exist_ok=True) for entry in payload_cache.iterdir(): if entry.name == ".seeded": continue dest = machine_cache / entry.name if not dest.exists(): ( shutil.copytree(entry, dest) if entry.is_dir() else shutil.copy2(entry, dest) ) except OSError as exc: LOG.warning("uv cache seed incomplete, retrying on the next install: %s", exc) else: try: marker.parent.mkdir(parents=True, exist_ok=True) marker.write_text("1", encoding="utf-8") except OSError: pass return machine_cache @register class Venv(StatePackage): """The project venv: pyproject.toml + uv.lock + enabled extras. Made true by `uv sync --frozen`; uv is its internal dependency.""" name = "venv" deps = ("uv",) def __init__(self, project_root: Path | None = None): self._project_root = project_root def project_root(self) -> Path: from pm.paths import repo_root return repo_root() if self._project_root is None else self._project_root def venv_dir(self) -> Path: from pm.environments import selected_venv return selected_venv(self.project_root()) def expected_stamp(self, extras: list[str], *, plugin_dirs=None) -> str: import hashlib import json from pm.lock import Lockfile from pm.paths import lockfile_path from pm.store import current_target lock = Lockfile(lockfile_path()) target = current_target() python = (lock.version("python"), target, [artifact["sha256"] for artifact in lock.artifacts("python", target)]) h = hashlib.sha256() h.update(_uv_lock_digest(self.project_root() / "uv.lock")) h.update(",".join(sorted(extras)).encode()) h.update(json.dumps(python).encode()) # Plugin members union into the venv — a changed member set must # re-sync even when extras and core lock are unchanged. from pm.workspace import enabled_member_dirs, members_stamp h.update(members_stamp(enabled_member_dirs() if plugin_dirs is None else plugin_dirs).encode()) return h.hexdigest() def apply(self, extras: list[str], *, plugin_dirs=None, repair: bool = False, explicit: bool = False, skip_invalid_secondary: bool = False) -> dict: """Prepare one complete environment; the caller commits its selection. ``skip_invalid_secondary`` is the update's contract: an unreadable secondary profile is left out (the caller reports it) instead of refusing the whole graph. """ import uuid from pm.environments import install_state_dir, runtime_facts_path from pm.environment import managed_environment from pm.lock import Facts from pm.native_build import source_build_environment from pm.workspace import enabled_member_dirs, lock_and_sync project = self.project_root() generation = install_state_dir(project) / "environments" / uuid.uuid4().hex candidate = generation / "venv" environment = managed_environment(candidate, env=source_build_environment(project), explicit=explicit or repair, output=sys.stderr) if not repair: # Inspection may skip a broken secondary profile, but publishing a replacement # graph must not silently evict its recorded members (including passed candidates). # An update does evict them, loudly: it must not fail on another profile's config. from pm.plugins_state import enabled_plugins_ordered enabled_plugins_ordered(skip_invalid_secondary=skip_invalid_secondary) members = [] if repair else (enabled_member_dirs() if plugin_dirs is None else plugin_dirs) try: generation.mkdir(parents=True) (generation / ".lease-managed").touch() environment.create() prior = Facts(runtime_facts_path(project), strict=repair).get("venv") or {} replay = None if repair and ("environment" in prior or "resolved_lock" in prior): if not all(isinstance(prior.get(key), str) and prior[key] for key in ("environment", "resolved_lock")): raise InstallError(self.name, "recorded dependency paths are incomplete; refusing to drop plugins") recorded = Path(prior["resolved_lock"]).resolve() previous = Path(prior["environment"]).resolve().parent generations = install_state_dir(project) / "environments" if (previous.parent != generations.resolve() or recorded != previous / "workspace" / "uv.lock" or not recorded.is_file()): raise InstallError(self.name, "recorded dependency lock is missing; refusing to drop plugins") replay = recorded.parent seed = (Path(prior["resolved_lock"]) if members and prior.get("resolved_lock") else project / "uv.lock") lock_and_sync(members, extras, root=generation / "workspace", seed_lock=seed, frozen=repair or not members, replay=replay, source=project, environment=environment) resolved_lock = generation / "workspace" / "uv.lock" environment.check() if repair: from pm.recovery import validate_environment validate_environment(environment.executable, env=dict(environment.env), cwd=resolved_lock.parent) except BaseException: shutil.rmtree(generation, ignore_errors=True) raise (generation / ".lease-managed").touch() return {"environment": candidate, "resolved_lock": resolved_lock} @register class Nodejs(_BionicDebArm, BinaryPackage, DebPackage): """nodejs.org tarballs for glibc/mac/win, unofficial-builds for musl; the Termux main-repo nodejs .deb for bionic (same major line, termux-built).""" name = "node" binary_rel = {"win32": "node.exe", "posix": "bin/node"} # The staged .deb's main binary: DebPackage.verify checks it. main_bin_rel = "bin/node" def main_rel(self, target: str) -> str: return self.main_bin_rel deb_package = "nodejs" def fetch_url(self, version: str, target: str) -> str: if target == "linux-arm64-bionic": # termux's deb carries a -1 revision after the upstream version return f"https://packages.termux.dev/apt/termux-main/pool/main/n/nodejs/nodejs_{version}-1_aarch64.deb" plat = _NODE_PLAT[target] ext = "zip" if target.startswith("win32") else "tar.xz" base = ( "https://unofficial-builds.nodejs.org/download/release" if target in MUSL_TARGETS else "https://nodejs.org/dist" ) return f"{base}/v{version}/node-v{version}-{plat}.{ext}" def latest_versions(self, target: str, locked=None) -> list[str]: # Keep one Node version across targets. If unofficial musl publication # lags nodejs.org, the later artifact pin/download fails before the # lockfile is written rather than selecting glibc bytes on musl. return node_latest_versions() @register class TermuxDocker(Package): """The termux/termux-docker container image, pinned by registry digest. The image is never downloaded or unpacked by pm -- docker pulls it by digest reference at build time. The lock row exists so the digest is pinned in the single pin authority beside every other third-party artifact: consumers read the digest string from the lock's url field (termux/termux-docker@sha256:...). verify() is presence-shaped: this package stages nothing. """ name = "termux-docker" optional = True # Pure pin: no bytes are staged, so stage_only()/install skip the store # entirely -- the digest's consumers (docker pull) verify it. pin_only = True def missing_reason(self, target: str) -> Optional[str]: return None if target == "linux-arm64-bionic" else "docker image target is linux-arm64-bionic" def fetch_url(self, version: str, target: str) -> str: return f"docker://termux/termux-docker@{version}" def unpack(self, archive: Path, staged: Path, target: str) -> None: raise InstallError(self.name, "a docker image digest is a pin, not a downloadable artifact") def verify(self, entry: Path, target: str) -> str: return "" def npm_env(cache_dir: Path, base_env: Optional[dict] = None) -> dict[str, str]: """Keep ambient Node and npm options out of PM's child process.""" env = { key: value for key, value in (os.environ if base_env is None else base_env).items() if not key.lower().startswith("npm_config_") and key.upper() not in ("NODE_OPTIONS", "NODE_PATH", "NODE_ENV") } env["npm_config_cache"] = str(cache_dir) return env @register class Npm(BinaryPackage): name = "npm" deps = ("node",) binary_rel = {"win32": "npm.cmd", "posix": "bin/npm"} flatten = False probe_version = False url = "https://registry.npmjs.org/npm/-/npm-{version}.tgz" def unpack(self, archive: Path, staged: Path, target: str) -> None: """npm installs itself using the node it extends: a plain unpack resolves the cli from dirname(process.execPath) and finds node's bundled npm instead. --offline pins the bytes to the verified tarball; --ignore-scripts + a sanitized env keep user npm/node config out of the staging.""" if target == "linux-arm64-bionic": from pm.store import extract unpacked = staged / ".unpacked" extract(archive, unpacked) package = unpacked / "package" lib = staged / "lib/node_modules/npm" lib.parent.mkdir(parents=True, exist_ok=True) package.rename(lib) unpacked.rmdir() bindir = staged / "bin" bindir.mkdir() for name in ("npm", "npx"): wrapper = bindir / name wrapper.write_text( "#!/data/data/com.termux/files/usr/bin/sh\n" 'here="$(cd "$(dirname "$0")" && pwd)"\n' f'exec node "$here/../lib/node_modules/npm/bin/{name}-cli.js" "$@"\n', encoding="utf-8", ) wrapper.chmod(0o755) return from pm.install import _installed_location, _lockfile from pm.registry import get_package node = get_package("node") location = _installed_location(node, _lockfile(), target) if location is None: raise InstallError(self.name, "npm extends node, which is not installed") facts, store = location node_fact = facts.get("node") if node_fact is None: raise InstallError(self.name, "npm extends node, which is not installed") node_bin = node.binary(store.entry(node_fact["entry"]), target) if node_bin is None or not node_bin.is_file(): raise InstallError(self.name, "node's entry is missing its binary") win = target.startswith("win32") bundled_cli = ( node_bin.parent / "node_modules" / "npm" / "bin" / "npm-cli.js" if win else node_bin.parent.parent / "lib" / "node_modules" / "npm" / "bin" / "npm-cli.js" ) if not bundled_cli.is_file(): raise InstallError(self.name, "node's entry is missing its bundled npm-cli.js") staged.mkdir(parents=True, exist_ok=True) # npm caches the tarball it installs. The archive's directory is the # store's download entry, which must hold only the archive: a cache # there turns its removal into a tree delete that fails on Windows # while Defender still holds the fresh copy (WinError 145). Cleanup # of this throwaway cache must never fail the install. with tempfile.TemporaryDirectory(prefix="hermes-npm-cache-", ignore_cleanup_errors=True) as cache: proc = subprocess.run( [ str(node_bin), str(bundled_cli), "install", "--global", "--prefix", str(staged), "--offline", "--ignore-scripts", "--no-audit", "--no-fund", str(archive), ], capture_output=True, text=True, timeout=900, env=npm_env(Path(cache)), ) if proc.returncode != 0: raise InstallError( self.name, f"self-install exited {proc.returncode}: {proc.stderr[-400:]}" ) def latest_versions(self, target: str, locked=None) -> list[str]: latest = npm_dist_tags("npm").get("latest") return [latest] if latest else [] # The pinned git artifact is a self-extracting 7z: only hosts where PE images # execute can stage it (tests patch this flag). _HOST_IS_WINDOWS = os.name == "nt" @register class Git(BinaryPackage): """Windows only: Git for Windows carries the bash.exe contract. POSIX uses the system git - a deliberate gap, not an oversight. The pin is the PortableGit self-extracting 7z: it carries its own extractor (stock Windows 10 tar.exe has no bzip2), shows a progress window (-y only drops prompts), and runs the vendor post-install, so the staged tree is post-install output.""" name = "git" optional = True binary_rel = {"win32": "cmd/git.exe"} flatten = False gaps = { "linux-x64": "POSIX uses system git by choice", "linux-arm64": "POSIX uses system git by choice", "linux-x64-musl": "POSIX uses system git by choice", "linux-arm64-musl": "POSIX uses system git by choice", "darwin-x64": "POSIX uses system git by choice", "darwin-arm64": "POSIX uses system git by choice", } def fetch_url(self, version: str, target: str) -> str: tag, build = version.split("+") arch = "arm64" if target.endswith("arm64") else "64-bit" return ( f"https://github.com/git-for-windows/git/releases/download/" f"v{tag}.windows.{build}/PortableGit-{tag}.{build}-{arch}.7z.exe" ) def latest_versions(self, target: str, locked=None) -> list[str]: # Tag is v2.53.0.windows.3; the lock version is 2.53.0+3. out = [] for tag in github_release_tags("git-for-windows/git", strip_prefix="v"): if ".windows." in tag: base, _, build = tag.partition(".windows.") out.append(f"{base}+{build}") return out def unpack(self, archive: Path, staged: Path, target: str) -> None: if not _HOST_IS_WINDOWS: raise InstallError( self.name, "the pinned artifact is a PortableGit self-extracting 7z, which " "only runs on Windows", "stage win32 git on a Windows host; POSIX hosts use the system git", ) shutil.rmtree(staged, ignore_errors=True) staged.mkdir(parents=True) # Execute a scratch copy, never the cached fetch- bytes: an executed # PE can stay handle-held (on-execute AV scan, the RunProgram children) # past pm's download-cleanup retry and fail the install (WinError 32). with tempfile.TemporaryDirectory( prefix=".sfx-", dir=staged.parent, ignore_cleanup_errors=True ) as work: exe = Path(work) / archive.name shutil.copy2(archive, exe) # No pipes: RunProgram children would inherit them and hold run() # open past the stub's exit. Under -y the stub prints nothing anyway. try: proc = subprocess.run( [str(exe), f"-o{staged}", "-y"], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=600, ) except subprocess.TimeoutExpired: raise InstallError( self.name, "PortableGit self-extractor did not finish in 600 s" ) from None if proc.returncode: raise InstallError( self.name, f"PortableGit self-extractor exited {proc.returncode} (it reports " "nothing under -y; usual causes: disk full, path-length limit, " "antivirus lock)", ) def env(self, entry: Path, target: str) -> dict: return {"PATH": [str(entry / "cmd"), str(entry / "usr" / "bin")]} @register class Gh(BinaryPackage): name = "gh" optional = True binary_rel = {"win32": "bin/gh.exe", "posix": "bin/gh"} def fetch_url(self, version: str, target: str) -> str: # GitHub CLI's Linux release matrix is built with CGO_ENABLED=0, # so the generic Linux archive is libc-independent. lookup_target = target.removesuffix("-musl") if target in MUSL_TARGETS else target osname, arch = lookup_target.split("-") plat = {"win32": "windows", "linux": "linux", "darwin": "macOS"}[osname] arch = {"x64": "amd64", "arm64": "arm64"}[arch] ext = "zip" if osname in ("win32", "darwin") else "tar.gz" return ( f"https://github.com/cli/cli/releases/download/v{version}/" f"gh_{version}_{plat}_{arch}.{ext}" ) def latest_versions(self, target: str, locked=None) -> list[str]: return github_release_tags("cli/cli", strip_prefix="v") @register class Ffmpeg(_BionicDebArm, BinaryPackage, DebPackage): """FFmpeg builds for supported targets; BtbN Linux archives require glibc. optional=False: ffmpeg is a required runtime tool where supported. Sealed bundles ship it baked into the payload; every `hermes update` and `hermes pm install` re-ensures it from the new lockfile before the venv sync (pm.client.ensure_tools_for_sync), so a pin bump lands. Windows + Linux: BtbN/FFmpeg-Builds (month-end autobuild tag, kept two years; ships ffprobe too). macOS: ffmpeg.martin-riedl.de (uniform ZIP, published sha256; single-binary — no ffprobe). Linux deliberately does NOT use martin-riedl: that build is compiled without x11grab (confirmed on the pinned 9.0.1 amd64 binary), and x11grab is how screen capture on X11 works.""" name = "ffmpeg" deb_package = "ffmpeg" optional = False gaps = {target: "BtbN Linux builds link glibc dynamically" for target in MUSL_TARGETS} def main_rel(self, target: str) -> str: return "bin/ffmpeg" # macOS (martin-riedl, the only remaining posix stream) and Windows/Linux # (BtbN) have no shared release cadence — they drift in PATCH. The lockfile # version label is major.minor; each target's exact patch lives in its # artifact urls. version_style = "minor" # martin-riedl (macOS) zips are a single `ffmpeg` at the zip root; BtbN # ships bin/ffmpeg (Linux, .tar.xz) and bin/ffmpeg.exe (Windows, .zip) # under one top-level dir that flatten hoists. binary_rel = { "win32": "bin/ffmpeg.exe", "linux-x64": "bin/ffmpeg", "linux-arm64": "bin/ffmpeg", "posix": "ffmpeg", } flatten = True # BtbN autobuild n9.0.1-11-ge47273f4d9 rejects `--version` # ("Unrecognized option '-version'", exit 2880417800); `-version` works # and is accepted by every ffmpeg build. probe_args = ["-version"] def fetch_url(self, version: str, target: str) -> str: if target == "linux-arm64-bionic": return f"https://packages.termux.dev/apt/termux-main/pool/main/f/ffmpeg/ffmpeg_{version}_aarch64.deb" if target in MUSL_TARGETS: raise InstallError(self.name, f"unavailable on {target}: {self.missing_reason(target)}") osname, arch = target.split("-") if osname in ("win32", "linux"): artifact = btbn_index().get(target, {}).get(version) if artifact is not None: tag, asset = artifact return f"https://github.com/BtbN/FFmpeg-Builds/releases/download/{tag}/{asset}" else: epoch = martin_riedl_index().get(target, {}).get(version) if epoch is not None: source_arch = "amd64" if arch == "x64" else arch return f"https://ffmpeg.martin-riedl.de/download/macos/{source_arch}/{epoch}_{version}/ffmpeg.zip" # Existing installs read exact URLs from the lockfile. Re-pinning # must never silently substitute a different version or target. raise InstallError(self.name, f"no advertised {version} artifact for {target}", "retry when the upstream index is available, or keep the existing pin") def latest_versions(self, target: str, locked=None) -> list[str]: if target in MUSL_TARGETS: return [] if target in ("win32-x64", "win32-arm64", "linux-x64", "linux-arm64"): return btbn_versions(target) return martin_riedl_versions(target) @register class Ripgrep(BinaryPackage): name = "ripgrep" binary_rel = {"win32": "rg.exe", "posix": "rg"} # The ARM64 Windows release links the VC++ runtime dynamically (the x64 # one embeds it), and a fresh Windows has no vcruntime140.dll: rg.exe # dies with STATUS_DLL_NOT_FOUND. Our pinned Python ships the ARM64 DLL. deps = ("python",) def stage(self, store: Store, staged: Path, version: str, target: str) -> None: super().stage(store, staged, version, target) if target != "win32-arm64": return from pm.install import _installed_location, _lockfile from pm.registry import get_package python = get_package("python") location = _installed_location(python, _lockfile(), target) fact = location[0].get("python") if location is not None else None if fact is None: raise InstallError(self.name, "ARM64 rg.exe needs vcruntime140.dll from python, which is not installed") runtime = location[1].entry(fact["entry"]) / "vcruntime140.dll" if not runtime.is_file(): raise InstallError(self.name, f"python's entry has no {runtime.name}") # App-local copy: the loader searches the exe's own folder first, and # the entry digest is recorded over it. shutil.copy2(runtime, staged / runtime.name) def verify(self, entry: Path, target: str) -> str: if target == "linux-arm64-bionic": return Package.verify(self, entry, target) return super().verify(entry, target) def fetch_url(self, version: str, target: str) -> str: if target == "linux-arm64-bionic": target = "linux-arm64" # This upstream artifact is a static musl executable. triple = _RUST_TRIPLE[target].replace("-unknown-linux-gnu", "-unknown-linux-musl") ext = "zip" if target.startswith("win32") else "tar.gz" return ( f"https://github.com/BurntSushi/ripgrep/releases/download/{version}/" f"ripgrep-{version}-{triple}.{ext}" ) def latest_versions(self, target: str, locked=None) -> list[str]: return github_release_tags("BurntSushi/ripgrep") @register class CuaDriver(BinaryPackage): name = "cua-driver" optional = True gaps = {target: "cua-driver does not publish a musl build" for target in MUSL_TARGETS} binary_rel = { "darwin-arm64": "CuaDriver.app/Contents/MacOS/cua-driver", "darwin-x64": "CuaDriver.app/Contents/MacOS/cua-driver", "win32": "cua-driver.exe", "posix": "cua-driver", } def fetch_url(self, version: str, target: str) -> str: arch = { "darwin-x64": "darwin-universal", "darwin-arm64": "darwin-universal", "linux-x64": "linux-x86_64", "linux-arm64": "linux-arm64", "win32-x64": "windows-x86_64", "win32-arm64": "windows-arm64", }[target] ext = "zip" if target.startswith("win32") else "tar.gz" # Only the directory archive contains the signed macOS app identity # needed by TCC and private sessions. Other targets' binary archives # already carry their runtime helpers (including Windows UIAccess). variant = "" if target.startswith("darwin") else "-binary" return ( f"https://github.com/trycua/cua/releases/download/cua-driver-rs-v{version}/" f"cua-driver-rs-{version}-{arch}{variant}.{ext}" ) def latest_versions(self, target: str, locked=None) -> list[str]: return github_release_tags("trycua/cua", strip_prefix="cua-driver-rs-v") def stage(self, store: Store, staged: Path, version: str, target: str) -> None: flatten_single_dir(staged) sdk = staged / "sdk" if sdk.is_dir(): shutil.rmtree(sdk, ignore_errors=True) def _probe_env(self) -> dict: """The probe IS a first run: it must not mint telemetry state.""" try: from tools.computer_use.cua_backend import cua_driver_child_env return cua_driver_child_env() except Exception: return dict(os.environ, CUA_DRIVER_RS_TELEMETRY_ENABLED="0") @register class AgentBrowser(BinaryPackage): name = "agent-browser" optional = True # Browser tools find agent-browser only in PM's store or on PATH (no npx # fallback), and their readiness check never installs it, so an install # without it silently loses every browser_* tool. # Default-install it; `install.sh --skip-browser` declines it. default = True deps = ("chromium",) # Termux owns its browser stack (`npm install -g agent-browser`; see # tools/browser_tool_install.py), and PM has no bionic Chromium to drive. gaps = { "linux-arm64-bionic": "Termux installs agent-browser through npm", **{target: "agent-browser has no musl Chromium runtime" for target in MUSL_TARGETS}, } flatten = True probe_version = False url = "https://registry.npmjs.org/agent-browser/-/agent-browser-{version}.tgz" # No win32-arm64 gap: agent-browser ships only win32-x64, and Windows # ARM64 runs it via built-in emulation (its own postinstall falls back # to x64 on arm64). chromium is likewise the x64 build on win32-arm64. emulated_arch_targets = frozenset({"win32-arm64"}) def latest_versions(self, target: str, locked=None) -> list[str]: latest = npm_dist_tags("agent-browser").get("latest") return [latest] if latest else [] def _rel(self, target: str) -> Optional[str]: ext = ".exe" if target.startswith("win32") else "" # Windows ARM64 runs the x64 binary under built-in emulation: # agent-browser ships no native arm64 build (its own postinstall # falls back to x64 on arm64), so the staged name is win32-x64. if target == "win32-arm64": target = "win32-x64" return f"bin/agent-browser-{target}{ext}" def binary(self, entry: Path, target: str) -> Optional[Path]: # The win32-arm64 payload carries the x64 binary (emulated), so # resolve it under the win32-x64 name. return super().binary(entry, "win32-x64" if target == "win32-arm64" else target) def stage(self, store: Store, staged: Path, version: str, target: str) -> None: flatten_single_dir(staged) bin_dir = staged / "bin" if not bin_dir.is_dir(): raise InstallError(self.name, "staged without a bin/ directory") keep = Path(self._rel(target)).name if not (bin_dir / keep).is_file(): raise InstallError(self.name, f"{keep} missing from the staged tarball") for item in bin_dir.iterdir(): if item.is_file() and item.name.startswith("agent-browser-") and item.name != keep: item.unlink() # The npm tarball ships every native binary as 0644; agent-browser's # own postinstall sets the exec bit, and pm runs no postinstall. kept = bin_dir / keep kept.chmod(kept.stat().st_mode | 0o111) @register class Chromium(Package): """Playwright resolves browsers by DIRECTORY NAME under one root: `-`, no target suffix. The env points PLAYWRIGHT_BROWSERS_PATH at the store root itself. The entry carries the INSTALLATION_COMPLETE marker playwright checks. The version is `+` — most targets download from the Chrome-for-Testing CDN by chrome version, and the targets CfT doesn't build (linux-arm64) come from playwright's own mirror by revision. The store entry is named by revision only, which is all playwright's resolver reads.""" name = "chromium" optional = True on_path = False # Neither Chrome-for-Testing nor Playwright's mirror builds for Android. gaps = { "linux-arm64-bionic": "no Chromium build for Android/Termux", **{target: "Playwright/Chrome-for-Testing publishes no musl build" for target in MUSL_TARGETS}, } emulated_arch_targets = frozenset({"win32-arm64"}) _CDN = "https://cdn.playwright.dev" # Chrome-for-Testing platform names; targets absent here fall back to # playwright's dbazure mirror with its own platform names. # win32-arm64 uses the win64 (x64) build: CfT publishes no native # win-arm64 chromium, and Windows ARM64 runs x64 binaries via built-in # emulation — the same choice agent-browser's own postinstall makes. _CFT = { "linux-x64": "linux64", "darwin-x64": "mac-x64", "darwin-arm64": "mac-arm64", "win32-x64": "win64", "win32-arm64": "win64", } _MIRROR = {"linux-arm64": "linux-arm64"} def store_entry(self, version: str, target: str) -> str: revision = version.partition("+")[0] return f"{self.name.replace('-', '_')}-{revision}" def fetch_url(self, version: str, target: str) -> str: revision, _, chrome = version.partition("+") plat = self._CFT.get(target) if plat and chrome: return f"{self._CDN}/builds/cft/{chrome}/{plat}/chrome-{plat}.zip" mirror_plat = self._MIRROR[target] return ( f"{self._CDN}/dbazure/download/playwright/builds/chromium/" f"{revision}/chromium-{mirror_plat}.zip" ) def binary(self, entry: Path, target: str) -> Optional[Path]: # CfT and Playwright's ARM Linux archive use different enclosing # directories. Resolve the executable within the selected entry. names = {"chrome.exe"} if target.startswith("win32") else {"chrome", "chromium", "Google Chrome for Testing", "Chromium"} return next((p for p in sorted(entry.rglob("*")) if p.name in names and p.is_file()), None) def stage(self, store: Store, staged: Path, version: str, target: str) -> None: (staged / "INSTALLATION_COMPLETE").write_text("", encoding="utf-8") def verify(self, entry: Path, target: str) -> str: marker = entry / "INSTALLATION_COMPLETE" if not marker.is_file(): return f"INSTALLATION_COMPLETE missing under {entry}; {_entry_listing(entry)}" binary = self.binary(entry, target) if binary is None: return f"Chromium executable missing under {entry}" return self._binary_reason(binary, entry, target) def env(self, entry: Path, target: str) -> dict: binary = self.binary(entry, target) if binary is None: raise InstallError(self.name, f"Chromium executable missing under {entry}") return { "PLAYWRIGHT_BROWSERS_PATH": str(entry.parent), "AGENT_BROWSER_EXECUTABLE_PATH": str(binary), } class LlamaCpp(BinaryPackage): """One llama.cpp backend build. Backends are dlopen'd plugins, so a usable engine is one archive per (target, backend) — plus, for Windows CUDA, the cudart archive: end users have no CUDA toolkit, and Windows resolves a DLL from the loading executable's own directory, so those DLLs must land beside llama-server.exe rather than in a second entry. Backend is a HARDWARE choice, not a target, so each backend is its own optional package and the runtime asks for the one this machine can use. Version is llama.cpp's rolling release tag without the `b`. """ optional = True on_path = False binary_rel = {"win32": "llama-server.exe", "posix": "llama-server"} flatten = False # --version is llama-server's liveness proof AND the check that the # backend's shared libraries resolve: a CUDA build with no cudart # beside it fails here rather than at first chat. probe_cwd = True backend: str = "" # Release-asset infix per target, or absent where upstream ships none. assets: dict[str, str] = {} @property def gaps(self) -> dict[str, str]: # type: ignore[override] return { target: f"llama.cpp publishes no {self.backend} build for {target}" for target in ALL_TARGETS if target not in self.assets } def _asset_names(self, version: str, target: str) -> list[str]: ext = "zip" if target.startswith("win32") else "tar.gz" return [f"llama-b{version}-bin-{self.assets[target]}.{ext}"] def fetch_urls(self, version: str, target: str) -> list[str]: return [ f"https://github.com/ggml-org/llama.cpp/releases/download/b{version}/{asset}" for asset in self._asset_names(version, target) ] def fetch_url(self, version: str, target: str) -> str: return self.fetch_urls(version, target)[0] def latest_versions(self, target: str, locked=None) -> list[str]: # Resolve from the llama.app installer bucket (the installer's own # updater pointer + version index — no API token, no rate limit): # the `latest` pointer is the authoritative "next version"; the # bucket tree supplies the full candidate list. Artifacts still # come from the llama.cpp GitHub releases (1:1 tag correspondence). latest = llama_app_latest() if latest is not None: return [latest, *llama_app_bucket_versions()] return llama_app_bucket_versions() or github_release_tags( "ggml-org/llama.cpp", strip_prefix="b" ) def known_sha256(self, version: str, url: str) -> Optional[str]: """GitHub's release API serves every asset's digest, so pinning a 280 MB engine costs one API call instead of the download.""" return _github_release_digests("ggml-org/llama.cpp", f"b{version}").get( url.rsplit("/", 1)[-1] ) def stage(self, store: Store, staged: Path, version: str, target: str) -> None: """Some archives nest the binaries under build/bin; hoist them so binary_rel is one path for every target.""" if (staged / self.binary(staged, target).name).is_file(): return found = sorted(staged.rglob(self.binary(staged, target).name)) if not found: raise InstallError(self.name, "archive contains no llama-server") merge_tree(found[0].parent, staged) def _github_release_digests(repo: str, tag: str) -> dict[str, str]: from pm.update import _get_json cached = _release_digest_cache.get((repo, tag)) if cached is not None: return cached url = f"https://api.github.com/repos/{repo}/releases/tags/{tag}" try: release = _get_json(url) except Exception: return {} digests = {} for asset in release.get("assets", []): digest = (asset.get("digest") or "").partition("sha256:")[2] if digest: digests[asset["name"]] = digest _release_digest_cache[(repo, tag)] = digests return digests _release_digest_cache: dict[tuple, dict] = {} @register class LlamaCppCuda(LlamaCpp): """Windows only: upstream publishes no prebuilt Linux CUDA archive at current tags, so NVIDIA Linux users run the vulkan build.""" name = "llamacpp-cuda" backend = "cuda" # CUDA 13.3 verified against 13.1/13.2 drivers; arm64 prebuilts landed # on 13.4 (the only CUDA line upstream builds for win-arm64). assets = { "win32-x64": "win-cuda-13.3-x64", "win32-arm64": "win-cuda-13.4-arm64", } _CUDART = {"win32-x64": "13.3-x64", "win32-arm64": "13.4-arm64"} def _asset_names(self, version: str, target: str) -> list[str]: return super()._asset_names(version, target) + [ f"cudart-llama-bin-win-cuda-{self._CUDART[target]}.zip" ] @register class LlamaCppVulkan(LlamaCpp): name = "llamacpp-vulkan" backend = "vulkan" assets = { "win32-x64": "win-vulkan-x64", "linux-x64": "ubuntu-vulkan-x64", "linux-arm64": "ubuntu-vulkan-arm64", } @register class LlamaCppHip(LlamaCpp): name = "llamacpp-hip" backend = "hip" # Upstream renamed the ROCm archives to 10.0 at b10767 (cff184438e). assets = { "win32-x64": "win-rocm-10.0-x64", "linux-x64": "ubuntu-rocm-10.0-x64", } @register class LlamaCppMetal(LlamaCpp): """macOS archives are unified builds with Metal compiled in.""" name = "llamacpp-metal" backend = "metal" assets = {"darwin-x64": "macos-x64", "darwin-arm64": "macos-arm64"} @register class LlamaCppCpu(LlamaCpp): name = "llamacpp-cpu" backend = "cpu" assets = { "win32-x64": "win-cpu-x64", "win32-arm64": "win-cpu-arm64", "linux-x64": "ubuntu-x64", "linux-arm64": "ubuntu-arm64", "darwin-x64": "macos-x64", "darwin-arm64": "macos-arm64", }