Files
hermes-agent/tests/pm/test_node_resolution.py
ethernet 66d54ebf51 Merge remote-tracking branch 'origin/main' into ethie/pm-clean
# Conflicts:
#	.github/workflows/docker.yml
#	Dockerfile
#	apps/desktop/src/app/settings/about-settings.tsx
#	docker/stage2-hook.sh
2026-09-24 02:23:44 -04:00

382 lines
16 KiB
Python

"""Node discovery reads PM state; legacy home-local trees are never activated."""
import hashlib
import json
from pathlib import Path
import shutil
import subprocess
import pytest
import hermes_constants
import pm
from pm import paths
from pm.lock import Facts, Lockfile
from pm.package import Runner
from pm.registry import get_package
from pm.store import current_target, tree_digest
_REAL_HERMES_HOME = Path.home() / ".hermes" # Captured before per-test HOME isolation.
def _register_installed_tool(name, executable, companions=()):
executable = Path(executable)
version = subprocess.run([str(executable), "--version"], capture_output=True, text=True,
check=True, timeout=10).stdout.strip().removeprefix("v")
package, target, store = get_package(name), current_target(), paths.store_root()
entry = store / package.store_entry(version, target)
binary = package.binary(entry, target)
assert binary is not None
binary.parent.mkdir(parents=True)
binary.symlink_to(executable)
for companion in companions:
binary.with_name(companion.name).symlink_to(companion)
digest = hashlib.sha256(executable.read_bytes()).hexdigest()
lock = Lockfile(paths.lockfile_path())
lock.set_pin(name, version, {target: {"url": executable.as_uri(), "sha256": digest}})
lock.save()
Facts(paths.facts_path()).record(name, version, entry.name, package.env(entry, target), store,
target=target, artifacts=[digest], digest=tree_digest(entry))
return binary
@pytest.fixture
def node_store(tmp_path, monkeypatch):
node = shutil.which("node")
if node is None:
pytest.skip("requires an already-installed Node executable")
# The fixture hashes its input. Never read the developer's live PM store to
# fabricate a temporary one; CI's external Node still exercises this path.
if Path(node).absolute().is_relative_to(_REAL_HERMES_HOME):
pytest.skip("requires a Node binary outside the real Hermes home")
monkeypatch.setenv("PATH", str(Path(node).parent))
home = tmp_path / "home"
home.mkdir()
store = home / "tools"
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setenv("HERMES_RUNTIME_DIR", str(store))
monkeypatch.setenv("HERMES_DISABLE_LAZY_INSTALLS", "1")
lock_path = tmp_path / "lock.json"
monkeypatch.setattr(paths, "lockfile_path", lambda: lock_path)
binary = _register_installed_tool("node", node)
return home, binary, node
@pytest.mark.platforms("posix")
def test_pm_node_wins_over_legacy_tree_and_composes_child_environment(node_store, monkeypatch):
home, binary, external = node_store
legacy = home / "node" / "bin" / "node"
legacy.parent.mkdir(parents=True)
legacy.symlink_to(external)
monkeypatch.setenv("PATH", str(Path(external).parent))
before = paths.facts_path().read_bytes()
selected = pm.installed_package("node")
assert selected is not None and selected.binary == binary
resolved = hermes_constants.find_node_executable("node")
assert resolved is not None and resolved == str(binary)
base = {"PATH": str(Path(external).parent), "CALLER_VALUE": "preserved"}
environment = hermes_constants.with_hermes_node_path(base)
assert environment == pm.env_for("npm", base_env=base)
assert shutil.which("node", path=environment["PATH"]) == str(binary)
child = subprocess.run(
[resolved, "-p", "process.version"],
env=environment, capture_output=True, text=True, check=True, timeout=10,
)
assert child.stdout.strip().removeprefix("v") == selected.version
assert paths.facts_path().read_bytes() == before
assert legacy.is_file()
assert base == {"PATH": str(Path(external).parent), "CALLER_VALUE": "preserved"}
@pytest.mark.platforms("posix")
def test_passive_discovery_never_installs_or_repairs_a_legacy_tree(tmp_path, monkeypatch):
home = tmp_path / "home"
legacy = home / "node" / "bin" / "node"
legacy.parent.mkdir(parents=True)
legacy.write_text("#!/bin/sh\nexit 1\n", encoding="utf-8")
legacy.chmod(0o755)
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setenv("HERMES_RUNTIME_DIR", str(home / "tools"))
monkeypatch.setenv("HERMES_DISABLE_LAZY_INSTALLS", "1")
monkeypatch.setenv("PATH", "")
before = {p.relative_to(home): p.read_bytes() for p in home.rglob("*") if p.is_file()}
attempts = []
def forbidden_install(*args, **kwargs):
attempts.append((args, kwargs))
raise AssertionError("passive lookup must not provision anything")
monkeypatch.setattr(pm, "ensure", forbidden_install)
assert hermes_constants.find_node_executable("node") is None
assert hermes_constants.find_node_executable("npm") is None
assert hermes_constants.with_hermes_node_path({"PATH": ""}) == {"PATH": ""}
assert attempts == []
assert {p.relative_to(home): p.read_bytes() for p in home.rglob("*") if p.is_file()} == before
assert not (home / "tools").exists()
# A user-owned PATH toolchain remains usable without acquiring a PM one.
external = tmp_path / "external"
external.mkdir()
npm = external / "npm"
npm.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
npm.chmod(0o755)
monkeypatch.setenv("PATH", str(external))
assert hermes_constants.find_node_executable("npm") == str(npm)
assert attempts == []
assert not (home / "tools").exists()
@pytest.mark.platforms("posix")
def test_explicit_node_path_is_not_replaced_by_managed_name(node_store):
_home, _binary, external = node_store
assert hermes_constants.find_node_executable(external) == external
assert hermes_constants.find_node_executable(str(Path(external).with_name("missing-node"))) is None
assert hermes_constants.find_node_executable("not-a-node-command") is None
@pytest.mark.platforms("posix")
def test_npm_and_npx_use_the_paired_pm_entry(node_store, monkeypatch):
home, node, external = node_store
npm = Path(external).with_name("npm")
npx = Path(external).with_name("npx")
if not npm.is_file() or not npx.is_file():
pytest.skip("requires already-installed npm and npx")
binary = _register_installed_tool("npm", npm, [npx])
companion = binary.with_name("npx")
legacy = home / "node" / "bin" / "npm"
legacy.parent.mkdir(parents=True)
legacy.symlink_to(npm)
before = paths.facts_path().read_bytes()
monkeypatch.setenv("PATH", "")
assert hermes_constants.find_node_executable("npm") == str(binary)
assert hermes_constants.find_node_executable("npx") == str(companion)
environment = hermes_constants.with_hermes_node_path({"PATH": ""})
assert shutil.which("npm", path=environment["PATH"]) == str(binary)
assert shutil.which("node", path=environment["PATH"]) == str(node)
for command in ("npm", "npx"):
resolved = hermes_constants.find_node_executable(command)
assert resolved is not None
result = subprocess.run(
[resolved, "--version"], env=environment,
capture_output=True, text=True, check=True, timeout=10,
)
assert result.stdout.strip() == Lockfile(paths.lockfile_path()).version("npm")
assert paths.facts_path().read_bytes() == before
companion.unlink()
assert hermes_constants.find_node_executable("npx") is None
@pytest.mark.platforms("windows")
def test_windows_path_prefers_launchable_npm_cmd(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_RUNTIME_DIR", str(tmp_path / "absent-store"))
monkeypatch.setenv("PATH", str(tmp_path))
for name in ("npm", "npm.ps1", "npm.cmd"):
(tmp_path / name).write_text("@exit /b 0\n", encoding="utf-8")
assert hermes_constants.find_node_executable("npm") == str(tmp_path / "npm.cmd")
@pytest.fixture
def npm_probe(node_store, monkeypatch):
home, node, _external = node_store
store = paths.store_root()
package = get_package("npm")
target = current_target()
entry = store / package.store_entry("1.0.0", target)
binary = package.binary(entry, target)
assert binary is not None
binary.parent.mkdir(parents=True)
binary.write_text(
'#!/usr/bin/env node\n'
'const fs = require("fs"); const path = require("path");\n'
'const i = process.argv.indexOf("--prefix");\n'
'const root = i < 0 ? process.cwd() : process.argv[i + 1];\n'
'fs.mkdirSync(path.join(root, "node_modules/.bin"), {recursive:true});\n'
'fs.writeFileSync(path.join(root, "node_modules/.bin/test-server"), "ready");\n'
'fs.writeFileSync(path.join(root, "called.json"), JSON.stringify({argv: process.argv, env: process.env}));\n',
encoding="utf-8",
)
binary.chmod(0o755)
lock = Lockfile(paths.lockfile_path())
lock.set_pin("npm", "1.0.0", {})
lock.save()
def publish():
Facts(paths.facts_path()).record(
"npm", "1.0.0", entry.name, package.env(entry, target), store,
)
return Runner("npm", pm.env_for("npm"))
monkeypatch.setenv("PATH", "")
return home, node, binary, publish
@pytest.fixture
def npm_consumers(npm_probe, tmp_path, monkeypatch):
from agent.lsp.install import _install_npm
from gateway.config import PlatformConfig
from hermes_cli.main_platform_setup import _whatsapp_install_bridge
from hermes_cli.web_routers.messaging import _ensure_whatsapp_bridge_dependencies
from plugins.platforms.photon import adapter as photon, cli
from plugins.platforms.whatsapp.adapter import WhatsAppAdapter
home, _node, _binary, _publish = npm_probe
bridge = tmp_path / "bridge"
bridge.mkdir()
(bridge / "package.json").write_text('{"name":"test-bridge"}', encoding="utf-8")
adapter = WhatsAppAdapter(PlatformConfig(extra={"bridge_script": str(bridge / "bridge.js")}))
monkeypatch.setattr(photon, "_sidecar_dir", lambda: bridge)
monkeypatch.setattr(cli, "_sidecar_dir", lambda: bridge)
return {
"lsp": (lambda: _install_npm("test-pkg", "test-server"), home / "lsp"),
"whatsapp": (lambda: adapter._ensure_bridge_deps(bridge), bridge),
"photon": (photon._reinstall_sidecar_deps, bridge),
"photon-cli": (cli._install_sidecar, bridge),
"cli": (lambda: _whatsapp_install_bridge(bridge), bridge),
"dashboard": (lambda: _ensure_whatsapp_bridge_dependencies(bridge), bridge),
}
@pytest.mark.platforms("posix")
@pytest.mark.parametrize("consumer", ["lsp", "whatsapp", "photon", "photon-cli", "cli", "dashboard"])
def test_npm_consumers_execute_with_pm_node(npm_probe, npm_consumers, consumer):
_home, node, binary, publish = npm_probe
publish()
call, output_dir = npm_consumers[consumer]
call()
result = json.loads((output_dir / "called.json").read_text())
assert Path(result["argv"][1]) == binary
assert shutil.which("node", path=result["env"]["PATH"]) == str(node)
if consumer == "lsp":
assert result["argv"][2:] == [
"install", "--prefix", str(output_dir), "--silent", "--no-fund", "--no-audit", "test-pkg",
]
@pytest.mark.platforms("posix")
@pytest.mark.parametrize("surface", ["cli", "dashboard", "whatsapp", "photon", "photon-cli"])
def test_missing_npm_acquires_npm_closure_at_install_boundary(npm_probe, npm_consumers, monkeypatch, surface):
_home, node, binary, publish = npm_probe
calls = []
def ensure(name, **kwargs):
calls.append((name, kwargs.get("explicit", False)))
assert name == "npm"
return publish()
monkeypatch.setattr(pm, "ensure", ensure)
call, bridge = npm_consumers[surface]
call()
assert calls == [("npm", surface in {"cli", "dashboard", "photon-cli"})]
result = json.loads((bridge / "called.json").read_text())
assert Path(result["argv"][1]) == binary
assert shutil.which("node", path=result["env"]["PATH"]) == str(node)
@pytest.mark.platforms("posix")
@pytest.mark.parametrize("surface", ["whatsapp", "photon"])
def test_runtime_npm_refusal_never_falls_back_to_literal_npm(npm_probe, npm_consumers, monkeypatch, surface):
call, bridge = npm_consumers[surface]
spawns = []
run = subprocess.run
def forbidden_spawn(*args, **kwargs):
if args[0][0] == "git": # Fatal-status reporting may read the code revision.
return run(*args, **kwargs)
spawns.append(args)
raise AssertionError("refused PM preparation must not spawn npm")
monkeypatch.setattr(subprocess, "run", forbidden_spawn)
call()
assert spawns == []
assert not (bridge / "called.json").exists()
assert pm.installed_package("npm") is None
@pytest.mark.parametrize("allowed", [False, True])
def test_adapter_availability_never_provisions_missing_node(tmp_path, monkeypatch, allowed):
from plugins.platforms.photon import adapter as photon
from plugins.platforms.whatsapp import adapter as whatsapp
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setenv("HERMES_RUNTIME_DIR", str(tmp_path / "missing-tools"))
monkeypatch.setenv("PATH", "")
monkeypatch.delenv("PHOTON_NODE_BIN", raising=False)
monkeypatch.setattr(photon, "_sidecar_dir", lambda: tmp_path)
monkeypatch.setattr(photon, "HTTPX_AVAILABLE", True)
monkeypatch.setattr(pm, "lazy_installs_allowed", lambda: allowed)
installs = []
def forbidden_ensure(*args, **kwargs):
installs.append(args)
raise AssertionError("availability must not install")
monkeypatch.setattr(pm, "ensure", forbidden_ensure)
assert whatsapp.check_whatsapp_requirements() is allowed
assert photon.check_requirements() is allowed
assert installs == []
assert not (tmp_path / "missing-tools").exists()
@pytest.mark.platforms("posix")
def test_dashboard_pairing_prepares_npm_before_node_lookup(npm_probe, tmp_path, monkeypatch):
from gateway.platforms import whatsapp_common
from hermes_cli.web_routers.messaging import _spawn_whatsapp_pairing_process
_home, node, _npm, publish = npm_probe
node_facts = paths.facts_path().read_bytes()
paths.facts_path().unlink()
bridge = tmp_path / "bridge"
bridge.mkdir()
(bridge / "bridge.js").write_text('console.log(JSON.stringify({argv:process.argv, path:process.env.PATH}));\n')
monkeypatch.setattr(whatsapp_common, "resolve_whatsapp_bridge_dir", lambda: bridge)
installs = []
def ensure(name, **kwargs):
installs.append((name, kwargs.get("explicit", False)))
paths.facts_path().write_bytes(node_facts)
return publish()
monkeypatch.setattr(pm, "ensure", ensure)
child = _spawn_whatsapp_pairing_process(tmp_path / "session", "bot")
try:
output, _ = child.communicate(timeout=10)
assert child.returncode == 0
finally:
if child.poll() is None:
child.kill()
child.wait(timeout=10)
assert installs == [("npm", True)]
result = json.loads(output)
assert "--pair-json" in result["argv"]
assert shutil.which("node", path=result["path"]) == str(node)
@pytest.mark.platforms("posix")
def test_lsp_node_server_inherits_pm_runtime_and_preserves_overrides(node_store, tmp_path, monkeypatch):
from agent.lsp.servers import ServerContext, find_server_for_file
_home, node, _external = node_store
script = tmp_path / "language-server"
script.write_text('#!/usr/bin/env node\nconsole.log(JSON.stringify({path:process.env.PATH, flag:process.env.LSP_FLAG}));\n')
script.chmod(0o755)
monkeypatch.setenv("PATH", "")
ctx = ServerContext(
workspace_root=str(tmp_path), install_strategy="off",
binary_overrides={"typescript": [str(script)]},
env_overrides={"typescript": {"LSP_FLAG": "project-value"}},
)
server = find_server_for_file(str(tmp_path / "test.ts"))
assert server is not None
spec = server.build_spawn(str(tmp_path), ctx)
assert spec is not None
child = subprocess.run(spec.command, env=spec.env, capture_output=True, text=True, timeout=10)
assert child.returncode == 0, child.stderr
observed = json.loads(child.stdout)
assert shutil.which("node", path=observed["path"]) == str(node)
assert observed["flag"] == "project-value"
ctx.env_overrides["typescript"]["PATH"] = "/explicit/project/tools"
spec = server.build_spawn(str(tmp_path), ctx)
assert spec is not None and spec.env["PATH"] == "/explicit/project/tools"