Files
hermes-agent/tests/scripts/test_fresh_source_install.py
Maciej Lisiewski 7062229090 chore(js): switch the workspaces from npm to pnpm
pnpm 12.15.0 (native executable) installs every JS workspace from one
pnpm-lock.yaml, imported from package-lock.json with the same resolved
versions. website/, the WhatsApp bridge and the photon sidecar stay separate
npm projects.

Configuration moves from .npmrc and the root manifest into
pnpm-workspace.yaml: the 14-day minimumReleaseAge and its excludes,
overrides, allowBuilds (the old allowScripts set, with strictDepBuilds
failing any unlisted build script), engineStrict, sideEffectsCache: false
(in-place rebuilds must not write into the store) and
verifyDepsBeforeRun: false (a `pnpm run` never installs).

The pnpm version is pinned in pm/lock.json and in root devEngines.
pmOnFail: ignore keeps that pin out of pnpm-lock.yaml: a leading pin
document makes osv-scanner read zero packages. scripts/build/node-deps.mjs
enforces the exact version instead, and every lock reader refuses a
multi-document lock. A root preinstall refuses `npm install`; devEngines
onFail is warn so npx (MCP servers, the tsc linter) keeps working inside
the checkout.

User-machine installs run --frozen-lockfile --trust-lockfile (release age is
checked when locking, in CI, not per user). node-deps removes npm-era
node_modules trees before the first pnpm install. Nix builds the store with
fetchPnpmDeps and a hash file that CI checks.
2026-11-06 04:08:24 +01:00

169 lines
8.8 KiB
Python

"""Whole current installer, real PM worker and local application dependency.
The bootstrap toolchain is prepared from this host's real Python/uv; this is
not a zero-Python download test. PM acquires real tool archives on loopback,
builds its unchanged runtime recipe and the tiny app, then publishes launchers.
"""
import hashlib
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
import sysconfig
import tarfile
import tomllib
import pytest
from pm.store import current_target
from tests.pm._fixtures import _wheel, served as served # noqa: PLC0414 -- the self-alias is load-bearing: it suppresses F811 for the pytest fixture parameter shadowing this import
ROOT = Path(__file__).resolve().parents[2]
@pytest.mark.platforms("linux")
@pytest.mark.parametrize("fault", [None, "missing-wheel", "bad-hash"])
def test_current_installer_publishes_real_dependencies_and_warm_path(tmp_path, served, fault):
uv = shutil.which("uv")
assert uv, "fresh-install acceptance requires real uv"
python = Path(sys._base_executable).resolve()
version = ".".join(map(str, sys.version_info[:3]))
minor = ".".join(map(str, sys.version_info[:2]))
home = tmp_path / "home"
home.mkdir()
managed = tmp_path / "bootstrap-python"
arch = {"x64": "x86_64", "arm64": "aarch64"}[current_target().split("-")[1]]
bootstrap = managed / f"cpython-{version}-linux-{arch}-gnu"
(bootstrap / "bin").mkdir(parents=True)
shutil.copytree(sysconfig.get_path("stdlib"), bootstrap / f"lib/python{minor}",
ignore=shutil.ignore_patterns("site-packages", "__pycache__"))
for path in bootstrap.rglob("*"):
path.chmod(0o755 if path.is_dir() else 0o644)
shutil.copy2(python, bootstrap / f"bin/python{minor}")
(bootstrap / "bin/python3").symlink_to(f"python{minor}")
env = {"PATH": os.environ["PATH"], "HOME": str(home), "LANG": "C.UTF-8",
"HERMES_HOME": str(home / ".hermes"), "UV_PYTHON_INSTALL_DIR": str(managed),
"UV_PYTHON_DOWNLOADS": "never", "UV_CACHE_DIR": str(tmp_path / "cache")}
canary = tmp_path / "ambient-bin"
canary.mkdir()
manager_called = tmp_path / "package-manager-called"
for name in ("npm", "pnpm"):
(canary / name).write_text(f'#!/bin/sh\nprintf {name} >> "{manager_called}"\nexit 99\n', encoding="utf-8")
(canary / name).chmod(0o755)
env["PATH"] = str(canary) + os.pathsep + env["PATH"]
for key in ("SSL_CERT_FILE", "SSL_CERT_DIR", "NIX_SSL_CERT_FILE"):
if key in os.environ:
env[key] = os.environ[key]
def run(argv, *, cwd=tmp_path, expected=0):
result = subprocess.run(argv, cwd=cwd, env=env, capture_output=True, text=True, timeout=180, check=False)
assert result.returncode == expected, result.stdout + result.stderr
return result
# Make the bootstrap layout complete using real uv, including its aliases.
run([uv, "python", "install", "--no-bin", "--no-registry", minor])
source = tmp_path / "fixture source"
source.mkdir()
for name in ("pm", "hermes_cli", "hermes_platform"):
shutil.copytree(ROOT / name, source / name, ignore=shutil.ignore_patterns("__pycache__"))
for name in ("utils.py", "hermes_constants.py", "hermes_dir_acl.py", "hermes_yaml.py", "hermes_bootstrap.py",
"setup-hermes.sh"):
shutil.copy2(ROOT / name, source / name)
# The CLI's user-facing text resolves through the i18n kernel (agent.i18n + the English catalog),
# its config/env layer consults the stdlib-only safe-worker policy, and the update path's wait
# loops use the stdlib-only agent.deadline; the rest of agent/ stays out so the tail cannot grow
# a dependency on the agent runtime.
(source / "agent").mkdir()
for name in ("__init__.py", "jiter_preload.py", "i18n.py", "i18n_layers.py", "i18n_languages.py",
"safe_worker_policy.py", "deadline.py"):
shutil.copy2(ROOT / "agent" / name, source / "agent" / name)
(source / "locales").mkdir()
shutil.copy2(ROOT / "locales/en.yaml", source / "locales/en.yaml")
wheels = source / "wheels"
wheels.mkdir()
_wheel(wheels, "installer_probe", "1.0")
recipe = tomllib.loads((source / "pm/pyproject.toml").read_text())
yaml_dep = next(d for d in recipe["project"]["dependencies"] if d.startswith("ruamel.yaml"))
(source / "pyproject.toml").write_text(
'[project]\nname="installer-fixture"\nversion="1"\nrequires-python=">=3.14"\n'
f'dependencies=["installer-probe==1.0",{json.dumps(yaml_dep)}]\n'
'[project.optional-dependencies]\nall=[]\n[dependency-groups]\ndev=[]\ntest=[]\n'
'[tool.uv]\npackage=false\n'
'[tool.uv.sources]\ninstaller-probe={path="wheels/installer_probe-1.0-py3-none-any.whl"}\n',
encoding="utf-8")
run([uv, "lock", "--python", str(python)], cwd=source)
# Only the application is a fixture; the shell, PM, bootstrap and writer run unchanged.
# Completion still imports the CLI's checkout root during post-install maintenance.
(source / "hermes_cli/main.py").write_text(
"import installer_probe, json, sys\n"
"from pathlib import Path\n"
"PROJECT_ROOT = Path(__file__).resolve().parents[1]\n"
"def main():\n print(json.dumps({'module':installer_probe.__file__, 'argv':sys.argv[1:]}))\n"
"if __name__ == '__main__': main()\n", encoding="utf-8")
docroot, url = served
(source / "pm/artifact-mirror.json").write_text(
json.dumps({"origin": url, "prefix": "mirror/"}, indent=2), encoding="utf-8")
pins = {}
for name, files in {
"python": [(bootstrap, "python")],
"uv": [(Path(uv).resolve(), "uv-dist/uv"), (Path(uv).resolve().with_name("uvx"), "uv-dist/uvx")],
}.items():
archive = docroot / f"{name}.tar.gz"
with tarfile.open(archive, "w:gz", compresslevel=1, dereference=True) as tar:
for path, destination in files:
tar.add(path, arcname=destination)
pins[name] = {"version": version if name == "python" else run([uv, "--version"]).stdout.split()[1],
"artifacts": {current_target(): {"url": f"{url}/{archive.name}",
"sha256": hashlib.sha256(archive.read_bytes()).hexdigest()}}}
if fault == "bad-hash":
pins["python"]["artifacts"][current_target()]["sha256"] = "0" * 64
if fault == "missing-wheel":
next(wheels.glob("*.whl")).unlink()
(source / "pm/lock.json").write_text(json.dumps({"schema": 1, "packages": pins}, indent=2), encoding="utf-8")
run(["git", "init", "-b", "fixture"], cwd=source)
run(["git", "add", "."], cwd=source)
run(["git", "-c", "user.name=Fixture", "-c", "user.email=fixture@example.invalid",
"commit", "-m", "fixture"], cwd=source)
commit = run(["git", "rev-parse", "HEAD"], cwd=source).stdout.strip()
install = tmp_path / "installed source"
env["HERMES_REPO_URL"] = str(source)
command = ["bash", str(ROOT / "scripts/install.sh"), "--dir", str(install),
"--branch", "fixture", "--commit", commit, "--non-interactive", "--json"]
result = run(command, expected=1 if fault else 0)
assert not manager_called.exists()
if fault:
assert not (install / ".hermes-bootstrap-complete").exists()
assert not (home / ".local/bin/hermes").exists()
for facts in (home / ".hermes/installs").glob("*/facts.json"):
assert "venv" not in json.loads(facts.read_text())["packages"]
assert '"stage":"python-deps"' in result.stdout
return
assert '"stage":"python-deps"' in result.stdout
assert json.loads((install / ".hermes-bootstrap-complete").read_text())["pinnedCommit"] == commit
facts = next((home / ".hermes/installs").glob("*/facts.json"))
selection = json.loads(facts.read_text())["packages"]["venv"]
launcher = home / ".local/bin/hermes"
child = json.loads(run([str(launcher), "from elsewhere"]).stdout)
assert child["argv"] == ["from elsewhere"]
assert Path(child["module"]).is_relative_to(Path(selection["environment"]))
assert not (install / "venv").exists()
# The developer setup path publishes through the same writer after real PM.
launcher.unlink()
run(["bash", str(install / "setup-hermes.sh")])
assert json.loads(run([str(launcher), "from setup"]).stdout)["argv"] == ["from setup"]
# Warm path publication must work with all acquisition inputs unavailable.
shutil.rmtree(docroot)
shutil.rmtree(install / "wheels")
env["UV_OFFLINE"] = "1"
# A completed bootstrap can be read-only. Finding it must not reinstall it.
marker = bootstrap / f"lib/python{minor}/EXTERNALLY-MANAGED"
marker.chmod(0o444)
before = facts.read_bytes()
try:
run([*command, "--stage", "products"])
finally:
marker.chmod(0o644)
assert facts.read_bytes() == before
assert json.loads(run([str(launcher), "offline"]).stdout)["argv"] == ["offline"]