fix(pm): voice enables audio-io through PM; public plugin-state readers; PM tests isolate the machine home

- voice_mode: `_import_audio` asks `pm.ensure_import("audio-io")` before importing
  sounddevice, so `/voice on` turns the feature on (or reports exactly why PM
  refused: lazy installs off, platform gate, restart needed) instead of printing a
  `python -c "from pm import sync_venv…"` one-liner for the user to run.
- pm.plugins_state: `read_home_selection` and `dependency_homes` are the public
  readers; memory_provider_migration and plugins_cmd stop importing underscored names.
- pm.store: the `sha256_file` shim is gone; the authority test asserts the Store has no
  file hash of its own rather than patching one.
- tests/pm/conftest: `isolated_machine_home` is autouse (Path.home, HOME, USERPROFILE,
  HERMES_HOME all under tmp_path); `@pytest.mark.real_machine_home` opts a module out
  — the four suites that spawn children with a home they build themselves.
- activate / activate.ps1 / Dockerfile followed hermes_cli.runtime_paths to
  pm.environments (the earlier sweep only covered .py).
- pm.registry loads builtins through importlib so a test patching `pm.packages` in
  sys.modules still registers the security packages.
This commit is contained in:
ethernet committed 2026-09-19 00:48:11 -04:00
1 parent 7f6ec2749e
commit a00ca233f8
30 files changed
+105 -79

No files matched your search

+2 -2
View File
@@ -183,8 +183,8 @@ COPY pm/ pm/
# `python3 -m pm.cli install` fails with "No module named
# 'hermes_constants'" without it on the path. Copy the module next to pm.
COPY hermes_constants.py hermes_constants.py
# PM imports the shared stdlib runtime path and locking owners before deps exist.
COPY hermes_cli/__init__.py hermes_cli/runtime_paths.py hermes_cli/runtime_state.py hermes_cli/
# PM imports the shared stdlib locking owner before deps exist.
COPY hermes_cli/__init__.py hermes_cli/runtime_state.py hermes_cli/
COPY scripts/bundles/payload.py scripts/bundles/payload.py
RUN set -eu; \
python3 -c 'from pm import ensure; [ensure(name, explicit=True) for name in ("uv", "chromium", "npm")]'; \
+1 -1
View File
@@ -50,7 +50,7 @@ if [ -z "$_hermes_py" ]; then
fi
# --- emit export lines from `pm env` ---
_hermes_json="$(PYTHONHOME= PYTHONPATH="$_hermes_repo" "$_hermes_py" -m hermes_cli.runtime_paths)"
_hermes_json="$(PYTHONHOME= PYTHONPATH="$_hermes_repo" "$_hermes_py" -m pm.environments)"
if [ -z "$_hermes_json" ] || [ "${_hermes_json#*{}" = "$_hermes_json" ]; then
echo "activate: could not read pm env (run ./setup-hermes.sh first)" >&2
unset _hermes_repo _hermes_os _hermes_arch _hermes_target _hermes_store _hermes_py _hermes_json
+1 -1
View File
@@ -45,7 +45,7 @@ $priorHome = $env:PYTHONHOME
try {
$env:PYTHONPATH = $repo
Remove-Item env:PYTHONHOME -ErrorAction SilentlyContinue
$json = (& $py -m hermes_cli.runtime_paths) -join "`n"
$json = (& $py -m pm.environments) -join "`n"
if ($LASTEXITCODE -ne 0) { throw 'activate: could not read the installed environment' }
$composed = $json | ConvertFrom-Json
} finally {
+3 -10
View File
@@ -78,11 +78,9 @@ class CLIVoiceMixin:
"Run pkg install termux-api and install the Termux:API Android app, "
"then retry /voice on."
)
raise RuntimeError(
"Voice mode requires sounddevice and numpy.\n"
"From the Hermes environment, run: "
"python -c \"from pm import sync_venv; sync_venv(['audio-io'], explicit=True)\". "
"Then restart Hermes.")
# check_voice_requirements already asked PM to enable audio-io; its detail line
# says why that did not happen (lazy installs off, needs restart, platform gate).
raise RuntimeError("Voice mode requires audio capture.\n" + reqs.get("details", ""))
if not reqs.get("stt_available", reqs.get("stt_key_set")):
raise RuntimeError(
"Voice mode requires an STT provider for transcription.\n"
@@ -504,11 +502,6 @@ class CLIVoiceMixin:
if _is_termux_environment():
_cprint(f"\n {_BOLD}Run: pkg install termux-api{_RST}")
_cprint(f" {_DIM}Then install/update the Termux:API Android app for microphone capture{_RST}")
else:
_cprint(f"\n {_BOLD}From the Hermes environment, run: "
"python -c \"from pm import sync_venv; sync_venv(['audio-io'], explicit=True)\""
f"{_RST}")
_cprint(f" {_DIM}Then restart Hermes.{_RST}")
return
with self._voice_lock:
+4 -4
View File
@@ -27,8 +27,8 @@ _attempted: set[str] = set()
def configured_provider(home: Path) -> str:
"""``memory.provider`` of *home*'s effective config, or ``""``."""
from pm.plugins_state import _read_home_config
memory = (_read_home_config(home) or {}).get("memory") or {}
from pm.plugins_state import read_home_selection
memory = (read_home_selection(home) or {}).get("memory") or {}
return str(memory.get("provider") or "").strip()
@@ -93,9 +93,9 @@ def _install_into(home: Path) -> Callable[[str], dict]:
def migrate_all_homes(*, say: Callable[[str], None] = print) -> list[str]:
"""``hermes update`` hook: every profile home sharing this venv. Returns installed plugin names."""
from pm.plugins_state import _all_homes
from pm.plugins_state import dependency_homes
installed: list[str] = []
for home in _all_homes():
for home in dependency_homes():
try:
name = migrate_home(home, install=_install_into(home), say=say)
except Exception as exc:
+2 -2
View File
@@ -1146,10 +1146,10 @@ def _discard_key_and_leaf(names: set, key: str) -> None:
def _set_plugin_enabled(name: str, *, enable: bool, aliases=(), console=None) -> None:
"""Submit the command's delta with the version of the selection it read."""
from pm.plugins_state import _read_home_config
from pm.plugins_state import read_home_selection
expected_config = _plugin_selection_version()
config = _read_home_config(get_hermes_home()) or {}
config = read_home_selection(get_hermes_home()) or {}
plugins = config.get("plugins") or {}
enabled = set(plugins.get("enabled") or ())
disabled = set(plugins.get("disabled") or ())
+10 -8
View File
@@ -16,11 +16,12 @@ def _profiles_root() -> Path:
return dependency_home_root() / "profiles"
def _read_home_config(home: Path) -> Optional[dict[str, Any]]:
"""Read a selection once; only an absent file means an unknown home.
def read_home_selection(home: Path) -> Optional[dict[str, Any]]:
"""The plugin/memory selection a home's config.yaml declares (None: no config yet).
An unreadable selection must not shrink the next dependency generation.
Empty YAML is an explicit empty configuration, as in the CLI loader.
The public reader for anything that must agree with what PM installs for that home.
An unreadable selection raises rather than shrinking the next dependency generation;
empty YAML is an explicit empty configuration, as in the CLI loader.
"""
config_path = home / "config.yaml"
try:
@@ -86,8 +87,9 @@ def _is_directory(path: Path) -> bool:
raise ValueError(f"could not inspect plugin directory: {path}") from exc
def _all_homes() -> list[Path]:
"""Enumerate the complete union or refuse; a partial scan cannot remove members."""
def dependency_homes() -> list[Path]:
"""Every home whose selection feeds the shared venv: the default home plus each profile.
Enumerates the complete union or refuses; a partial scan cannot remove members."""
from pm.environments import dependency_home_root
homes = [dependency_home_root()]
@@ -112,9 +114,9 @@ def enabled_plugins_ordered(*, proposed_home=None, enabled=None, disabled=None,
the union. Admission refuses a conflicting candidate without changing
the active environment or disabling an existing provider."""
out: dict[Path, list[str]] = {}
for home in _all_homes():
for home in dependency_homes():
# ONE parse per home feeds both queries (enabled + provider).
config = _read_home_config(home)
config = read_home_selection(home)
config = config or {}
if proposed_home is not None and home.resolve() == Path(proposed_home).resolve():
config = {**config, "plugins": {"enabled": list(enabled or ()), "disabled": list(disabled or ())}}
+2 -2
View File
@@ -29,8 +29,8 @@ def candidate_members(extra_dirs=(), **selection):
def selection_snapshot() -> dict[Path, bytes | None]:
from pm.plugins_state import _all_homes
return {home / "config.yaml": _bytes(home / "config.yaml") for home in _all_homes()}
from pm.plugins_state import dependency_homes
return {home / "config.yaml": _bytes(home / "config.yaml") for home in dependency_homes()}
def validate_manifest(source: Path) -> dict:
+6 -3
View File
@@ -14,6 +14,9 @@ from pm.package import InstallError, Package
_packages: dict[str, Package] = {}
_BUILTIN_MODULES = ("pm.packages", "pm.security_packages")
def _builtins_loaded() -> dict[str, Package]:
"""Register the built-in definitions on first read, not on ``import pm``.
@@ -22,9 +25,9 @@ def _builtins_loaded() -> dict[str, Package]:
cost every launch ~40ms and break the stripped payloads that ship only the
pre-import files.
"""
if "pm.packages" not in sys.modules:
import pm.packages # noqa: F401 (registers the built-in definitions)
import pm.security_packages # noqa: F401
for module in _BUILTIN_MODULES:
if module not in sys.modules:
importlib.import_module(module)
return _packages
-6
View File
@@ -103,12 +103,6 @@ def current_target() -> str:
return f"linux-{arch}"
def sha256_file(path: Path) -> str:
from pm.downloader import _sha256_file
return _sha256_file(path)
def hash_url(url: str) -> str:
"""sha256 of a url's content, streamed. `pm lock` uses this to pin."""
import hashlib
+17 -5
View File
@@ -4,12 +4,24 @@ from pathlib import Path
import pytest
@pytest.fixture
def isolated_machine_home(tmp_path, monkeypatch):
def pytest_configure(config):
config.addinivalue_line(
"markers", "real_machine_home: keep Path.home() intact (a subprocess or stage_runtime needs the real one)")
@pytest.fixture(autouse=True)
def isolated_machine_home(request, tmp_path, monkeypatch):
"""Machine-scoped PM state (store, caches, profiles root) lands under tmp_path.
Opt in per module with ``pytestmark = pytest.mark.usefixtures("isolated_machine_home")``.
Does not cross a subprocess boundary — tests that spawn set HOME/USERPROFILE themselves.
The root conftest already sandboxes HERMES_HOME; PM additionally keys its machine cache
off the home directory, so that moves too — in this process (Path.home) and in any child
(HOME / USERPROFILE). Opt out with ``@pytest.mark.real_machine_home``.
"""
if request.node.get_closest_marker("real_machine_home"):
return
home = tmp_path / ".hermes"
home.mkdir(exist_ok=True)
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("USERPROFILE", str(tmp_path))
monkeypatch.setenv("HERMES_HOME", str(home))
+3
View File
@@ -12,6 +12,9 @@ from tests.pm.test_activate_scripts import (
_bash, _bash_env, _isolated_checkout, _posix, _powershell, _spawnable_python,
)
# Spawns children with a home it builds itself; the parent's must stay real.
pytestmark = pytest.mark.real_machine_home
def _sync_checkout(tmp_path: Path):
root = _isolated_checkout(tmp_path)
+3
View File
@@ -22,6 +22,9 @@ import tomllib
import pytest
# Spawns children with a home it builds itself; the parent's must stay real.
pytestmark = pytest.mark.real_machine_home
class _ArchiveHandler(SimpleHTTPRequestHandler):
def copyfile(self, source, outputfile):
+1 -1
View File
@@ -43,7 +43,7 @@ def test_home_layout_joins_sibling_union(tmp_path, monkeypatch, layout):
monkeypatch.delenv("HERMES_HOME", raising=False)
else:
monkeypatch.setenv("HERMES_HOME", str(active))
active.mkdir(parents=True)
active.mkdir(parents=True, exist_ok=True)
sibling = root / "profiles/sibling"
_write_enabled(sibling, ["dep-plug"])
member = _make_dep_plugin(sibling / "plugins", "dep-plug")
+2 -4
View File
@@ -42,9 +42,8 @@ def test_features_path_in_bundle_uses_payload_root(rooted):
def test_sync_venv_refuses_outside_frozen_extras(rooted, monkeypatch):
feats.write_features(["web", "acp"])
import sys
ensure_mod = sys.modules["pm.install"]
import pm.install as ensure_mod
from pm.package import InstallError
monkeypatch.setattr(ensure_mod, "lazy_installs_allowed", lambda: False)
@@ -63,12 +62,11 @@ def test_sync_venv_refuses_outside_frozen_extras(rooted, monkeypatch):
def test_sync_venv_allows_frozen_extras_when_lazy_off(rooted, monkeypatch):
feats.write_features(["web"])
import sys
from pm import paths
from pm.lock import Facts
from pm.environments import install_state_dir, runtime_facts_path
ensure_mod = sys.modules["pm.install"]
import pm.install as ensure_mod
# Matching stamp alone cannot certify a vanished environment. Reuse only
# the recorded selection while retaining the disabled acquisition policy.
@@ -33,7 +33,6 @@ def archive(files: dict[str, bytes]) -> bytes:
return output.getvalue()
pytestmark = pytest.mark.usefixtures("isolated_machine_home")
@pytest.fixture(params=["install", "stage"])
+2 -1
View File
@@ -314,7 +314,8 @@ def test_fetch_cache_verification_is_owned_by_downloader(pm_env, monkeypatch, po
if poisoned:
first.write_bytes(b"poisoned")
monkeypatch.setattr(store_mod, "sha256_file", lambda *args: pytest.fail("Store rehashed downloader-verified bytes"))
# Verification is the downloader's alone: the Store carries no file-hash helper to reach for.
assert not any(name.startswith("sha256") for name in vars(store_mod))
with store.scratch() as scratch:
second = store.fetch(url, digest, scratch)
assert second == first
-1
View File
@@ -15,7 +15,6 @@ from pm.lock import Facts
from pm.runtime import runtime_environment
from tests.pm._fixtures import _wheel
pytestmark = pytest.mark.usefixtures("isolated_machine_home")
+2 -2
View File
@@ -30,9 +30,9 @@ def test_pm_runtime_discovers_plugins_without_application_dependencies(tmp_path,
import importlib.util, json, sys
sys.path.insert(0, {str(repo)!r})
from pm.workspace import enabled_member_dirs
from pm.plugins_state import _read_home_config
from pm.plugins_state import read_home_selection
from pathlib import Path
assert _read_home_config(Path({str(home)!r}))["plugins"]["enabled"] == []
assert read_home_selection(Path({str(home)!r}))["plugins"]["enabled"] == []
assert enabled_member_dirs() == []
assert importlib.util.find_spec("openai") is None
assert importlib.util.find_spec("yaml") is None
+3
View File
@@ -25,6 +25,9 @@ from pm.lock import Facts
from pm.package import InstallError
from tests.pm._fixtures import isolated_python # noqa: F401
# Spawns children with a home it builds itself; the parent's must stay real.
pytestmark = pytest.mark.real_machine_home
@pytest.fixture
def source_launch(tmp_path, monkeypatch, isolated_python):
+3 -2
View File
@@ -6,6 +6,7 @@ must defer to recorded-graph recovery, not resolve today's application inputs.
from __future__ import annotations
import importlib
import hashlib
import json
import os
from pathlib import Path
@@ -17,7 +18,7 @@ import pytest
from pm.lock import Facts, Lockfile
from pm.runtime import runtime_environment
from pm.store import current_target, sha256_file, tree_digest
from pm.store import current_target, tree_digest
from tests.pm._fixtures import _wheel
@pytest.fixture(autouse=True)
@@ -127,7 +128,7 @@ def test_bootstrap_repairs_before_dependency_activation(tmp_path, monkeypatch, m
assert not package.verify(directory, target), (
f"startup recovery requires native {target} tools: {binary}"
)
digest = sha256_file(binary)
digest = hashlib.sha256(binary.read_bytes()).hexdigest()
lock.set_pin(name, "fixture", {target: {"url": binary.as_uri(), "sha256": digest}})
facts.record(name, "fixture", str(directory), {}, tools,
target=target, artifacts=[digest], digest=tree_digest(directory))
+1 -3
View File
@@ -1,11 +1,9 @@
"""A bundle reuses shipped bytes and adds missing pinned tools outside its seal."""
import json
from pathlib import Path
import pytest
import pm.paths as paths
from pm.lock import Facts, Lockfile
from pm.lock import Facts
from tests.pm.test_pm_authority import core_env, pm_env, served # noqa: F401 — fixtures
-2
View File
@@ -3,11 +3,9 @@
from __future__ import annotations
import pytest
import pm.packages as pkgs
pytestmark = pytest.mark.usefixtures("isolated_machine_home")
-1
View File
@@ -16,7 +16,6 @@ from pm.package import InstallError
from pm.packages import Python, Uv
from pm.store import current_target
pytestmark = pytest.mark.usefixtures("isolated_machine_home")
@@ -7,10 +7,8 @@ import shutil
import subprocess
import sys
import pytest
pytestmark = pytest.mark.usefixtures("isolated_machine_home")
def test_ambient_uv_config_does_not_affect_pm_venv_sync(tmp_path, monkeypatch):
+3
View File
@@ -21,6 +21,9 @@ from tests.pm._fixtures import (
isolated_python as isolated_python,
)
# Spawns children with a home it builds itself; the parent's must stay real.
pytestmark = pytest.mark.real_machine_home
def test_isolated_worker_preserves_install_error(client, monkeypatch):
engine = importlib.import_module("pm.install")
+2 -2
View File
@@ -368,12 +368,12 @@ def test_stale_enablement_cannot_replace_a_newer_selection(client, tmp_path, mon
config.write_text("plugins: {enabled: [], disabled: []}\n")
from pm import plugins_state
read = plugins_state._read_home_config
read = plugins_state.read_home_selection
def concurrent_commit(home):
stale = read(home)
config.write_text("plugins: {enabled: [first], disabled: []}\n")
return stale
monkeypatch.setattr(plugins_state, "_read_home_config", concurrent_commit)
monkeypatch.setattr(plugins_state, "read_home_selection", concurrent_commit)
with pytest.raises(AdmissionRefused, match="changed"):
pc._set_plugin_enabled("second", enable=True)
assert fast_safe_load(config.read_bytes())["plugins"]["enabled"] == ["first"]
-1
View File
@@ -22,7 +22,6 @@ from pm.environment import managed_environment
from tests.pm.test_environment_build import locked_project # noqa: F401
pytestmark = pytest.mark.usefixtures("isolated_machine_home")
@pytest.fixture
-1
View File
@@ -12,7 +12,6 @@ from tests.pm import _fixtures
from pm.environment import managed_environment
pytestmark = pytest.mark.usefixtures("isolated_machine_home")
def _buildable_source(plugin):
+32 -11
View File
@@ -42,7 +42,19 @@ _TEMP_DIR = os.path.join(tempfile.gettempdir(), "hermes_voice")
# WSL, no PortAudio).
def _import_audio():
"""Lazy-import (sounddevice, numpy); raises ImportError/OSError when unavailable."""
"""Lazy-import (sounddevice, numpy), enabling the ``audio-io`` extra through PM first.
Raises ImportError when the extra cannot be enabled here (lazy installs off, platform
gate, or installed-but-needs-restart) and OSError when PortAudio's shared library is
missing — pip can't fix that one, so it is reported separately.
"""
import pm
if not pm.available("audio-io"):
try:
pm.ensure_import("audio-io")
except pm.InstallError as exc:
raise ImportError(str(exc)) from exc
import sounddevice as sd
import numpy as np
return sd, np
@@ -90,6 +102,16 @@ def _unlink_quietly(path: Optional[str]) -> None:
os.unlink(path)
def _audio_unavailable_reason() -> str:
try:
_import_audio()
except ImportError as exc:
return _voice_capture_install_hint(exc)
except OSError:
return _portaudio_missing_message().splitlines()[0]
return ""
def _audio_available() -> bool:
try:
_import_audio()
@@ -113,12 +135,13 @@ def _default_input_samplerate(sd) -> int:
# ── Environment detection ──
def _voice_capture_install_hint() -> str:
# On Termux PortAudio is a system package a pip install can't provide (#18432); everywhere
# else the audio-io extra goes through PM so the install lands in the venv Hermes runs.
def _voice_capture_install_hint(error: BaseException | None = None) -> str:
"""Why audio capture is unavailable. ``_import_audio`` already tried to enable the
``audio-io`` extra through PM, so the ImportError it raised IS the remediation."""
# On Termux PortAudio is a system package a pip install can't provide (#18432).
if _is_termux_environment():
return "pkg install python-numpy portaudio && python -m pip install sounddevice"
return "python -c \"from pm import sync_venv; sync_venv(['audio-io'], explicit=True)\""
return str(error) if error else "audio-io extra unavailable"
def _portaudio_missing_message() -> str:
@@ -245,9 +268,9 @@ def _probe_audio_libraries(warnings: List[str], notices: List[str], *, has_forwa
try:
sd, _ = _import_audio()
except ImportError:
except ImportError as exc:
return outcome("Termux:API microphone recording available (sounddevice not required)",
f"Audio libraries not installed ({_voice_capture_install_hint()})", import_failed=True)
f"Audio libraries not installed ({_voice_capture_install_hint(exc)})", import_failed=True)
except OSError:
return outcome("Termux:API microphone recording available (PortAudio not required)",
_portaudio_missing_message(), import_failed=True)
@@ -752,9 +775,7 @@ class AudioRecorder(_RecorderBase):
except OSError as e:
raise RuntimeError(_portaudio_missing_message()) from e
except ImportError as e:
raise RuntimeError(
"Voice mode requires sounddevice and numpy.\n"
f"Install with: {_voice_capture_install_hint()}") from e
raise RuntimeError(f"Voice mode requires sounddevice and numpy.\n{_voice_capture_install_hint(e)}") from e
with self._lock:
if self._recording:
return
@@ -1481,7 +1502,7 @@ def check_voice_requirements() -> Dict[str, Any]:
details = [
"Audio capture: OK (Termux:API microphone)" if termux_capture
else "Audio capture: OK" if has_audio
else f"Audio capture: MISSING ({_voice_capture_install_hint()})",
else f"Audio capture: MISSING ({_audio_unavailable_reason()})",
"STT provider: DISABLED in config (stt.enabled: false)" if not stt_enabled
else f"STT provider: {stt_label}" if stt_label
else ("STT provider: MISSING (run `hermes tools` and configure "