Files
hermes-agent/pm/packages.py
teknium1 ee0ad5adb2 refactor(pm): expose MUSL_TARGETS publicly; drop the per-URL pin hash cache
packages.py and security_packages.py imported the private _MUSL_TARGETS
across modules; make it a public store constant next to ALL_TARGETS.
The _pin_tool hash memo is an unrelated optimisation, out of scope here.
2026-09-27 03:24:39 -07:00

1158 lines
48 KiB
Python

"""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 <release>+<bundle revision>. 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 "<python>+<release tag>", e.g. "3.14.7+20260901"
pyver, _, tag = version.partition("+")
if not tag:
raise InstallError(self.name, f"version {version!r} needs the +<release> 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 []
@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 tar.bz2
release asset extracts with stdlib tarfile: no self-extractor, no GUI."""
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}/Git-{tag}.{build}-{arch}.tar.bz2"
)
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:
from pm.store import extract_tar
extract_tar(archive, staged, git_msys=True)
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 (dated autobuild tag; 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:
`<name with '-'→'_'>-<revision>`, 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 `<playwright revision>+<chrome version>` — 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",
}