Files
hermes-agent/tests/hermes_cli/test_source_launcher_publication.py
unsupportedpastels a4648c5f58 fix(launchers): fall back to the runtime store only when the tree has none
Ignoring HERMES_RUNTIME_DIR outright at publication broke environments
that keep their whole store beside HERMES_HOME and name it through the
override: the desktop source-backend contract fixture now fails at
`assert launchers` because the tree resolves an empty default store.

Publication now prefers the tree's own recorded store and uses the
inherited runtime directory only when the tree records none, so a
fixture or toolchain store can no longer displace an install's own
interpreter. Adds the #131745 scratch layout through
ensure_install_launchers and the no-own-store fallback as tests.

Co-authored-by: liuhao1024 <sunsky.lau@gmail.com>
2026-10-03 21:44:47 -04:00

682 lines
33 KiB
Python

"""Source launchers keep custom-home and selected-generation state at boot."""
import json
import os
from pathlib import Path
import shlex
import shutil
import subprocess
import sys
import pytest
from hermes_cli import _launchers
from pm.environments import install_state_dir, site_packages
ROOT = Path(__file__).resolve().parents[2]
BOOT_FILES = (
"hermes_bootstrap.py", "hermes_constants.py", "hermes_cli/__init__.py", "hermes_cli/_launchers.py",
"pm/environments.py", "pm/filesystem.py", "pm/paths.py", "hermes_cli/runtime_state.py",
"hermes_cli/_early_recovery.py", "hermes_cli/_parser.py",
"hermes_cli/venv_sync.py", "hermes_cli/steward.py",
"hermes_cli/stderr_timestamp.py",
"scripts/hermes-gateway",
)
def fixture_tree(tmp_path, monkeypatch):
repo = tmp_path / "source 'café repo"
for relative in BOOT_FILES:
destination = repo / relative
destination.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(ROOT / relative, destination)
(repo / "acp_adapter").mkdir()
(repo / "acp_adapter" / "__init__.py").write_text("", encoding="utf-8")
entry = (
"import json, os, sys\n"
"def main():\n"
" import selected_probe\n"
" print(json.dumps({'value': selected_probe.VALUE, 'argv': sys.argv[1:], "
"'home': os.environ.get('HERMES_HOME'), 'exe': sys.executable}))\n"
" return 7\n"
"if __name__ == '__main__':\n sys.exit(main())\n"
)
for path in (repo / "hermes_cli/main.py", repo / "acp_adapter/entry.py"):
path.write_text(entry, encoding="utf-8")
# Windows resolves its default under LOCALAPPDATA, not HOME.
if os.name == "nt":
monkeypatch.setenv("LOCALAPPDATA", str(tmp_path))
home = tmp_path / ("hermes" if os.name == "nt" else ".hermes")
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.delenv("HERMES_RUNTIME_DIR", raising=False)
store = home / "tools"
store.mkdir(parents=True)
interpreter = Path(sys._base_executable).resolve()
(store / "facts.json").write_text(json.dumps({"schema": 1, "packages": {"python": {
"version": "fixture", "entry": str(interpreter.parent if os.name == "nt" else interpreter.parents[1])
}}}), encoding="utf-8")
return repo, home, interpreter
def select_generation(repo, name, value):
selected = install_state_dir(repo) / 'environments' / str(name) / 'venv'
site = site_packages(selected)
site.mkdir(parents=True)
(selected / 'pyvenv.cfg').write_text('home = fixture\n', encoding='utf-8')
(site / 'selected_probe.py').write_text(f'VALUE = {value!r}\n', encoding='utf-8')
(install_state_dir(repo) / 'facts.json').write_text(
json.dumps({'packages': {'venv': {'environment': str(selected)}}}), encoding='utf-8')
return selected
@pytest.mark.platforms("windows", "posix")
@pytest.mark.parametrize("form", ["native", "shell"])
def test_source_launchers_boot_selected_generation_from_custom_home(tmp_path, monkeypatch, form, real_bash):
repo, home, interpreter = fixture_tree(tmp_path, monkeypatch)
out = tmp_path / "commands"
out.mkdir()
# No repo/venv or console script exists. PM selection lives outside the checkout.
launchers = [Path(p) for p in _launchers.ensure_install_launchers(repo, out)]
assert len(launchers) == len(_launchers.ENTRY_POINTS)
if form == "shell":
shell_out = tmp_path / "shell-commands"
shell_out.mkdir()
launchers = [
_launchers._mint_shell_launcher(name, shell_out, interpreter,
_launchers._launcher_script(name, repo, None))
for name in _launchers.ENTRY_POINTS
]
args = ['spaces and café', 'apostrophe\'s', r'one\two', '$HOME; echo no', '']
for number in (1, 2):
select_generation(repo, number, number)
env = dict(os.environ)
env.pop("HERMES_HOME", None)
env.pop("HERMES_RUNTIME_DIR", None)
env["PYTHONHOME"] = str(tmp_path / "foreign-python")
env["PYTHONPATH"] = str(tmp_path / "foreign-deps")
for launcher in launchers:
assert launcher is not None
command = [real_bash, "-s"] if form == "shell" else [str(launcher), *args]
script = "exec " + shlex.join([real_bash, str(launcher), *args]) + "\n" if form == "shell" else None
result = subprocess.run(command, input=script, cwd=tmp_path, env=env,
capture_output=True, text=True, encoding="utf-8", timeout=30)
assert result.returncode == 7, result.stdout + result.stderr
receipt = json.loads(result.stdout)
assert receipt["value"] == number
assert receipt["argv"] == args
assert Path(receipt["home"]) == home
assert Path(receipt["exe"]).samefile(interpreter)
assert not (repo / "venv").exists()
@pytest.mark.platforms("posix")
def test_launcher_resolves_default_home_at_use_not_publication(tmp_path, monkeypatch):
repo, published_home, _ = fixture_tree(tmp_path, monkeypatch)
launcher = Path(_launchers.ensure_install_launchers(repo, tmp_path / "commands")[0])
new_user_home = tmp_path / "second-user"
new_user_home.mkdir()
monkeypatch.setenv("HOME", str(new_user_home))
monkeypatch.setenv("HERMES_HOME", str(new_user_home / ".hermes"))
select_generation(repo, "second", "from-second-user")
env = dict(os.environ)
env.pop("HERMES_HOME")
result = subprocess.run([str(launcher)], cwd=tmp_path, env=env,
capture_output=True, text=True, encoding="utf-8", timeout=30)
assert result.returncode == 7, result.stdout + result.stderr
assert json.loads(result.stdout)["home"] == str(new_user_home / ".hermes")
assert json.loads(result.stdout)["value"] == "from-second-user"
assert published_home != new_user_home / ".hermes"
@pytest.mark.parametrize("publisher", [
pytest.param("boot", marks=pytest.mark.platforms("posix")),
pytest.param("repair", marks=pytest.mark.platforms("posix")),
pytest.param("native", marks=pytest.mark.platforms("windows")),
pytest.param("cmd", marks=pytest.mark.platforms("windows")),
])
def test_profile_publication_preserves_shared_launcher_default_home(tmp_path, monkeypatch, publisher):
from hermes_cli import boot_bootstrap, post_update
repo, home, interpreter = fixture_tree(tmp_path, monkeypatch)
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.setenv("HERMES_INSTALL_ROOT", str(repo))
profile = home / "profiles" / "coder"
profile.mkdir(parents=True)
(home / "active_profile").write_text("default\n", encoding="utf-8")
(repo / "install-stamp.json").write_text(json.dumps({
"commit": "abcdef012345", "updateMechanism": "git", "runtimeDir": str(home / "tools"),
}), encoding="utf-8")
select_generation(repo, "shared", "ready")
out = tmp_path / ".local" / "bin"
if publisher == "cmd":
monkeypatch.setattr(_launchers, "_load_script_maker", lambda: None)
launchers = [Path(p) for p in _launchers.ensure_install_launchers(repo, out)]
assert len(launchers) == len(_launchers.ENTRY_POINTS)
if publisher == "cmd":
assert all(launcher.suffix == ".cmd" for launcher in launchers)
def assert_home(override, expected):
env = dict(os.environ)
env.pop("HERMES_HOME", None)
if override is not None:
env["HERMES_HOME"] = str(override)
for launcher in launchers:
result = subprocess.run([str(launcher)], cwd=tmp_path, env=env,
capture_output=True, text=True, encoding="utf-8", timeout=30)
assert result.returncode == 7, result.stdout + result.stderr
receipt = json.loads(result.stdout)
assert Path(receipt["home"]) == expected
assert receipt["value"] == "ready"
assert Path(receipt["exe"]).samefile(interpreter)
assert_home(None, home)
monkeypatch.setenv("HERMES_HOME", str(profile))
if publisher == "boot":
# Keep the real per-profile boot gate and exposure step, not unrelated
# migrations. A named profile's first boot republishes the shared files.
monkeypatch.setattr(post_update, "BOOT_HOME_STEPS", tuple(
step for step in post_update.BOOT_HOME_STEPS if step[0] == "expose_cli"
))
result = boot_bootstrap.run_boot_bootstrap(repo)
assert result["home"]["expose_cli"]["ok"], result
assert boot_bootstrap.record_path(repo).is_file()
elif publisher == "repair":
assert _launchers.expose_cli(repo, create=False)["ok"]
else:
# Windows exposure is installer-owned. Both transports use this writer.
assert _launchers.ensure_install_launchers(repo, out)
assert_home(None, home)
assert_home(profile, profile)
other_home = tmp_path / "explicit custom home"
other_home.mkdir()
# A custom home is its own dependency root: it sees only generations committed there.
monkeypatch.setenv("HERMES_HOME", str(other_home))
select_generation(repo, "shared", "ready")
assert_home(other_home, other_home)
@pytest.mark.platforms("posix")
@pytest.mark.parametrize("corruption", ["bom", "crlf"])
def test_posix_materializer_publishes_only_executable_shell_launchers(tmp_path, monkeypatch, corruption):
repo, _home, _interpreter = fixture_tree(tmp_path, monkeypatch)
out = tmp_path / "bin"
out.mkdir()
launchers = [Path(p) for p in _launchers.ensure_install_launchers(repo, out)]
assert {p.name for p in launchers} == set(_launchers.ENTRY_POINTS)
assert all(os.access(p, os.X_OK) for p in launchers)
assert set(out.iterdir()) == set(launchers)
local = repo / ".hermes" / "bin"
assert {p.name for p in local.iterdir()} == set(_launchers.ENTRY_POINTS)
launcher = local / "hermes"
expected = launcher.read_bytes()
launcher.write_bytes(b"\xef\xbb\xbf" + expected if corruption == "bom" else expected.replace(b"\n", b"\r\n"))
assert _launchers.ensure_install_launchers(repo, out)
# Shell executables need exact bytes: neither a BOM before #! nor CRLF is
# interchangeable with the generated script, even if text decoding agrees.
assert launcher.read_bytes() == expected
result = subprocess.run([str(out / "hermes"), "--print-runtime-command"],
capture_output=True, text=True, encoding="utf-8", timeout=30)
assert result.returncode == 0, result.stderr
assert Path(json.loads(result.stdout)[0]).samefile(_interpreter)
before = launcher.stat().st_mtime_ns
assert _launchers.ensure_install_launchers(repo, out)
assert launcher.stat().st_mtime_ns == before
def test_materializer_cli_refuses_missing_store_without_publishing(tmp_path, monkeypatch):
repo, home, _interpreter = fixture_tree(tmp_path, monkeypatch)
(home / "tools" / "facts.json").unlink()
orphan = home / "tools" / "python-unrecorded" / ("python.exe" if os.name == "nt" else "bin/python3")
orphan.parent.mkdir(parents=True)
orphan.touch() # uncommitted tool bytes are not an installed interpreter
out = tmp_path / "bin"
result = subprocess.run([sys.executable, "-I", str(repo / "hermes_cli/_launchers.py"), str(out)],
cwd=tmp_path, capture_output=True, text=True, encoding="utf-8", timeout=30)
assert result.returncode == 1, result.stdout + result.stderr
assert "store interpreter" in result.stderr
assert not out.exists() or not list(out.iterdir())
@pytest.mark.platforms("posix")
@pytest.mark.parametrize("legacy_hermes", ["venv-python", "venv-console-script"])
def test_boot_migrates_legacy_conveniences_to_selected_runtime(tmp_path, monkeypatch, legacy_hermes):
repo, home, interpreter = fixture_tree(tmp_path, monkeypatch)
monkeypatch.setattr(Path, "home", lambda: home)
monkeypatch.setenv("HERMES_INSTALL_ROOT", str(repo))
select_generation(repo, 'current', 'migrated')
out = home / ".local" / "bin"
out.mkdir(parents=True)
# Old venv and sibling-ACP wrappers, with an unrelated command sharing bin.
old_wrapper = (f'#!/bin/sh\nexec "{repo}/venv/bin/python" "{repo}/hermes" "$@"\n'
if legacy_hermes == "venv-python" else
f'#!/usr/bin/env bash\nunset PYTHONPATH\nunset PYTHONHOME\n'
f'exec "{repo}/venv/bin/hermes" "$@"\n')
(out / "hermes").write_text(old_wrapper, encoding="utf-8")
(out / "hermes-acp").write_text(
'#!/usr/bin/env bash\n# Hermes Agent — ACP launcher (written by `hermes update`).\n'
f'exec "{out}/hermes" acp "$@"\n', encoding="utf-8")
# Mentions this install only in a comment and runs another checkout's venv.
foreign = f'#!/bin/sh\n# user note about {repo}/venv/bin/hermes\nexec "{tmp_path}/other/venv/bin/hermes" "$@"\n'
(out / "hermes-agent").write_text(foreign, encoding="utf-8")
result = _launchers.expose_cli()
assert result["ok"], result
assert set(result["written"]) == {"hermes", "hermes-acp"}
for name in ("hermes", "hermes-acp"):
run = subprocess.run([str(out / name), "quoted argument"], cwd=tmp_path,
capture_output=True, text=True, timeout=30, encoding="utf-8")
assert run.returncode == 7, run.stderr
receipt = json.loads(run.stdout)
assert receipt["value"] == "migrated"
assert receipt["argv"] == ["quoted argument"]
assert Path(receipt["exe"]).samefile(interpreter)
assert (out / "hermes-agent").read_text(encoding="utf-8-sig") == foreign
before = {p: p.stat().st_mtime_ns for p in out.iterdir()}
assert _launchers.expose_cli()["written"] == []
assert before == {p: p.stat().st_mtime_ns for p in out.iterdir()}
def _command_survives_generation_collection(tmp_path, monkeypatch, surface):
from hermes_cli.runtime_state import collect_generations
repo, home, interpreter = fixture_tree(tmp_path, monkeypatch)
out = tmp_path / "bin"
out.mkdir()
_launchers.ensure_install_launchers(repo, out)
launcher = next(path for path in out.iterdir() if path.stem == "hermes")
args = ["café ' quoted", "", "$HOME; not a shell"]
command = []
for value in ("old", "new"):
selected = select_generation(repo, value, value)
(selected.parent / ".lease-managed").touch()
if value == "old":
if surface == "legacy":
command = [sys.executable, "-I", str(repo / "scripts/hermes-gateway"), "--help"]
args = ["gateway", "--help"]
elif surface == "ssh":
from hermes_cli.windows_ssh_runtime import _resolve_direct_command
command = [*_resolve_direct_command(str(launcher)), *args]
elif surface == "published":
result = subprocess.run([str(launcher), "--print-runtime-command", "--", *args],
capture_output=True, text=True, timeout=30, encoding="utf-8")
assert result.returncode == 0, result.stderr
command = json.loads(result.stdout)
else:
from hermes_cli import gateway
monkeypatch.setattr(gateway, "PROJECT_ROOT", repo)
if surface == "launchd":
import plistlib
from tests.hermes_cli.test_gateway_service import _osascript_exec_argv
unit = gateway.generate_launchd_plist()
# The job runs through osascript (#71206); exec the child it would spawn, minus the
# `>> log 2>> log` tail that only means something to the shell.
command = _osascript_exec_argv(plistlib.loads(unit.encode())["ProgramArguments"])[:-4]
args = ["gateway", "run", "--external-supervisor"]
else:
unit = gateway.generate_systemd_unit()
command = shlex.split(next(line.removeprefix("ExecStart=") for line in unit.splitlines()
if line.startswith("ExecStart=")))
args = ["gateway", "run"]
assert str(selected.parent) not in unit
if surface not in ("legacy", "systemd", "launchd"):
assert Path(command[0]).samefile(interpreter)
assert collect_generations(repo, min_age_seconds=0) == [selected.parent.parent / "old"]
result = subprocess.run(command, cwd=tmp_path, capture_output=True, text=True, timeout=30, encoding="utf-8")
assert result.returncode == 7, result.stderr
receipt = result.stdout
if surface == "launchd":
# stderr_timestamp stamps the child's stdout lines ("YYYY-MM-DD HH:MM:SS,mmm ").
receipt = receipt.split(" ", 2)[2]
assert json.loads(receipt)["value"] == "new"
assert json.loads(receipt)["argv"] == args
@pytest.mark.parametrize("surface", ["published", "systemd", "launchd", "ssh", "legacy"])
@pytest.mark.platforms("posix")
@pytest.mark.spawns_gateway_lookalike
def test_posix_commands_survive_generation_collection(tmp_path, monkeypatch, surface):
_command_survives_generation_collection(tmp_path, monkeypatch, surface)
@pytest.mark.parametrize("surface", ["published", "ssh"])
@pytest.mark.platforms("windows")
def test_windows_commands_survive_generation_collection(tmp_path, monkeypatch, surface):
_command_survives_generation_collection(tmp_path, monkeypatch, surface)
@pytest.mark.platforms("windows")
@pytest.mark.parametrize("launcher_form", ["native", "cmd", "native-with-maker"])
def test_running_source_launcher_can_republish_itself(tmp_path, monkeypatch, launcher_form):
repo, _home, interpreter = fixture_tree(tmp_path, monkeypatch)
selected = select_generation(repo, "selected", "ready")
if launcher_form == "native-with-maker":
import distlib
shutil.copytree(Path(distlib.__file__).parent, site_packages(selected) / "distlib")
entry = repo / "hermes_cli/main.py"
entry.write_text(
"from pathlib import Path\n"
"from hermes_cli._launchers import ensure_install_launchers, ENTRY_POINTS\n"
"def main():\n"
" root = Path(__file__).resolve().parents[1]\n"
" written = ensure_install_launchers(root, root / '.hermes/bin')\n"
" print('published', len(written), len(ENTRY_POINTS), flush=True)\n"
" return 0 if len(written) == len(ENTRY_POINTS) else 1\n",
encoding="utf-8",
)
out = repo / ".hermes/bin"
if launcher_form == "cmd":
monkeypatch.setattr(_launchers, "_load_script_maker", lambda: None)
launchers = _launchers.ensure_install_launchers(repo, out)
assert len(launchers) == len(_launchers.ENTRY_POINTS)
command = next(Path(p) for p in launchers if Path(p).stem == "hermes")
assert command.suffix == (".cmd" if launcher_form == "cmd" else ".exe")
result = subprocess.run([str(command)], cwd=tmp_path, capture_output=True,
text=True, encoding="utf-8", timeout=30)
assert result.returncode == 0, result.stdout + result.stderr
assert "published 2 2" in result.stdout
assert _launchers.ensure_install_launchers(repo, out)
selected_python = _launchers.resolve_store_python(repo)
assert selected_python is not None and selected_python.samefile(interpreter)
@pytest.mark.platforms("windows")
def test_repin_without_distlib_retires_stale_native_launcher(tmp_path, monkeypatch):
repo, home, _interpreter = fixture_tree(tmp_path, monkeypatch)
local = repo / ".hermes/bin"
original = Path(_launchers.ensure_install_launchers(repo, local)[0])
assert original.suffix == ".exe"
new_python = home / "tools" / "repinned" / "python.exe"
new_python.parent.mkdir()
new_python.touch()
(home / "tools/facts.json").write_text(
json.dumps({"packages": {"python": {"entry": "repinned"}}}), encoding="utf-8")
monkeypatch.setattr(_launchers, "_load_script_maker", lambda: None)
result = _launchers.stage_launcher("hermes", repo, local)
assert result == local / "hermes.cmd"
assert not original.exists() # cmd.exe must not run the old exe first
assert result is not None and str(new_python) in result.read_text(encoding="utf-8-sig")
@pytest.mark.platforms("windows")
def test_windows_repair_upgrades_healthy_old_pm_external_launchers(tmp_path, monkeypatch):
from hermes_cli._install_repair import ensure_windows_bin_launchers
repo, home, interpreter = fixture_tree(tmp_path, monkeypatch)
managed = home / "hermes-agent"
shutil.move(repo, managed)
external = home / "bin"
external.mkdir()
for name in _launchers.ENTRY_POINTS:
(external / f"{name}.exe").write_bytes(b"old PM launcher without a venv binding")
assert ensure_windows_bin_launchers(managed, user_path_entries=[])
local = managed / ".hermes" / "bin"
launcher = local / "hermes.exe"
if not launcher.exists():
launcher = local / "hermes.cmd"
result = subprocess.run([str(launcher), "--print-runtime-command"], capture_output=True,
text=True, timeout=30, encoding="utf-8")
assert result.returncode == 0, result.stderr
assert Path(json.loads(result.stdout)[0]).samefile(interpreter)
def test_dashboard_action_boots_selected_dependencies(tmp_path, monkeypatch):
from hermes_cli import web_server, web_server_gateway
repo, home, _ = fixture_tree(tmp_path, monkeypatch)
select_generation(repo, 'current', 'selected')
monkeypatch.setattr(web_server, "PROJECT_ROOT", repo)
monkeypatch.setattr(web_server_gateway, "_ACTION_LOG_DIR", home / "logs")
proc = web_server_gateway._spawn_hermes_action(["--version"], "gateway-restart")
try:
assert proc.wait(timeout=30) == 7
log = (home / "logs" / web_server_gateway._ACTION_LOG_FILES["gateway-restart"]).read_text(encoding="utf-8-sig")
assert json.loads(log.splitlines()[-1])["value"] == "selected"
finally:
if proc.poll() is None:
proc.kill()
proc.wait(timeout=30)
def test_pre_pm_base_dependencies_activate_only_at_boot(tmp_path, monkeypatch):
# The in-tree pre-PM venv is never activated; only a sealed payload's own environment is.
repo, home, _ = fixture_tree(tmp_path, monkeypatch)
environment = repo.parent / "payload-deps"
(repo.parent / "manifest.json").write_text(
json.dumps({"repo": repo.name, "venv": environment.name,
"store": (home / "tools").relative_to(repo.parent).as_posix()}), encoding="utf-8")
site = site_packages(environment)
site.mkdir(parents=True)
editable = tmp_path / "editable"
editable.mkdir()
(editable / "selected_probe.py").write_text("VALUE = 'base-pth'\n", encoding="utf-8")
(site / "member.pth").write_text(str(editable) + "\n", encoding="utf-8")
command = _launchers.runtime_command(repo)
result = subprocess.run(command, cwd=tmp_path, capture_output=True, text=True, timeout=30, encoding="utf-8")
assert result.returncode == 7, result.stderr
assert json.loads(result.stdout)["value"] == "base-pth"
def test_external_interpreter_keeps_its_owned_dependencies(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home"))
monkeypatch.setenv("HERMES_RUNTIME_DIR", str(tmp_path / "empty-store"))
command = _launchers.runtime_command(ROOT, code="import ruamel.yaml; print('external-runtime-ready')")
result = subprocess.run(command, cwd=tmp_path, capture_output=True, text=True, timeout=30, encoding="utf-8")
assert result.returncode == 0, result.stderr
assert result.stdout.strip() == "external-runtime-ready"
@pytest.mark.platforms("posix")
@pytest.mark.spawns_gateway_lookalike
def test_service_survives_python_tool_replacement(tmp_path, monkeypatch):
from hermes_cli import gateway
repo, home, interpreter = fixture_tree(tmp_path, monkeypatch)
monkeypatch.setattr(gateway, "PROJECT_ROOT", repo)
select_generation(repo, "shared", "ready")
store = home / "tools"
for version in ("python-A", "python-B"):
python = store / version / "bin" / "python3"
python.parent.mkdir(parents=True)
python.symlink_to(interpreter)
(store / "facts.json").write_text(json.dumps({"packages": {"python": {"entry": version}}}), encoding="utf-8")
gateway._prepare_service_launcher()
if version == "python-A":
unit = gateway.generate_systemd_unit()
assert str(store / version) not in unit
command = shlex.split(next(line.split("=", 1)[1] for line in unit.splitlines() if line.startswith("ExecStart=")))
shutil.rmtree(store / "python-A")
result = subprocess.run(command, cwd=tmp_path, capture_output=True, text=True, timeout=30, encoding="utf-8")
assert result.returncode == 7, result.stderr
assert json.loads(result.stdout)["value"] == "ready"
@pytest.mark.platforms("posix")
@pytest.mark.parametrize("create", [True, False])
def test_sync_migrates_old_store_wrapper_before_python_collection(tmp_path, monkeypatch, create):
import builtins
from hermes_cli.venv_sync import publish_launchers
repo, home, interpreter = fixture_tree(tmp_path, monkeypatch)
monkeypatch.setattr(Path, "home", lambda: home)
select_generation(repo, "shared", "ready")
out = home / ".local/bin"
out.mkdir(parents=True)
if not create:
original_import = builtins.__import__
def without_config(name, *args, **kwargs):
assert name != "hermes_cli.config", "bootstrap publication imported application config"
return original_import(name, *args, **kwargs)
monkeypatch.setattr(builtins, "__import__", without_config)
store = home / "tools"
for version in ("python-A", "python-B"):
python = store / version / "bin/python3"
python.parent.mkdir(parents=True)
python.symlink_to(interpreter)
(store / "facts.json").write_text(json.dumps({"schema": 1, "packages": {"python": {"entry": version}}}), encoding="utf-8")
if version == "python-A":
_launchers.mint_launcher("hermes", repo, out, python, None)
else:
if create:
publish_launchers(repo)
else:
publish_launchers(repo, create=False)
assert set(out.iterdir()) == {out / "hermes"}
assert not (home / "bin").exists()
assert not (home / "config.yaml").exists()
assert not (home / "skills").exists()
shutil.rmtree(store / "python-A")
result = subprocess.run([str(out / "hermes")], cwd=tmp_path,
capture_output=True, text=True, timeout=30, encoding="utf-8")
assert result.returncode == 7, result.stderr
assert json.loads(result.stdout)["value"] == "ready"
assert Path(json.loads(result.stdout)["exe"]) == store / "python-B/bin/python3"
def test_update_import_probe_uses_selected_dependencies(tmp_path, monkeypatch):
from hermes_cli import update_cmd, update_cmd_validation
repo, _, _ = fixture_tree(tmp_path, monkeypatch)
selected = install_state_dir(repo) / "environments" / "current" / "venv"
site = site_packages(selected)
site.mkdir(parents=True)
(selected / "pyvenv.cfg").write_text("home = fixture\n", encoding="utf-8")
(site / "selected_probe.py").write_text("VALUE = 'selected'\n", encoding="utf-8")
(install_state_dir(repo) / "facts.json").write_text(
json.dumps({"packages": {"venv": {"environment": str(selected)}}}), encoding="utf-8")
(repo / "hermes_integrity_probe.py").write_text("import selected_probe\n", encoding="utf-8")
monkeypatch.setattr(update_cmd, "_UPDATE_CRITICAL_MODULES", ("hermes_integrity_probe",))
assert update_cmd_validation._critical_module_import_failures(repo, report_runtime_errors=True) == {}
@pytest.mark.platforms("posix")
def test_stage_launcher_ignores_an_inherited_runtime_directory(tmp_path, monkeypatch):
# #131745: a persisted launcher must boot the install's own store python.
# An inherited HERMES_RUNTIME_DIR (e2e fixture, desktop toolchain, PM
# subprocess env) names a runtime directory for the RUNNING process, never
# for the artifacts the process publishes — that store can be a scratch
# tree a later cleanup deletes, leaving the launcher dead (exit 127).
repo, home, interpreter = fixture_tree(tmp_path, monkeypatch)
store = home / "tools"
tree_python = store / "python-A" / "bin" / "python3"
tree_python.parent.mkdir(parents=True)
tree_python.symlink_to(interpreter)
(store / "facts.json").write_text(
json.dumps({"packages": {"python": {"entry": "python-A"}}}), encoding="utf-8")
foreign_store = tmp_path / "foreign" / "tools"
foreign_store.mkdir(parents=True)
foreign_python = foreign_store / "python-foreign" / "bin" / "python3"
foreign_python.parent.mkdir(parents=True)
foreign_python.symlink_to(interpreter)
(foreign_store / "facts.json").write_text(
json.dumps({"packages": {"python": {"entry": "python-foreign"}}}), encoding="utf-8")
monkeypatch.setenv("HERMES_RUNTIME_DIR", str(foreign_store))
# Publication callers create the bin dir before staging (see
# ensure_install_launchers); the launcher writer does not invent it.
out = repo / ".hermes" / "bin"
out.mkdir(parents=True, exist_ok=True)
published = _launchers.stage_launcher("hermes", repo, out)
assert published is not None
wrapper = published.read_text(encoding="utf-8-sig")
assert "python-A" in wrapper
assert "python-foreign" not in wrapper
@pytest.mark.platforms("posix")
def test_service_launcher_binds_the_tree_store_despite_inherited_runtime_override(tmp_path, monkeypatch):
from hermes_cli import gateway
repo, home, interpreter = fixture_tree(tmp_path, monkeypatch)
monkeypatch.setattr(gateway, "PROJECT_ROOT", repo)
store = home / "tools"
tree_python = store / "python-A" / "bin" / "python3"
tree_python.parent.mkdir(parents=True)
tree_python.symlink_to(interpreter)
(store / "facts.json").write_text(
json.dumps({"packages": {"python": {"entry": "python-A"}}}), encoding="utf-8")
foreign_store = tmp_path / "foreign" / "tools"
foreign_store.mkdir(parents=True)
foreign_python = foreign_store / "python-foreign" / "bin" / "python3"
foreign_python.parent.mkdir(parents=True)
foreign_python.symlink_to(interpreter)
(foreign_store / "facts.json").write_text(
json.dumps({"packages": {"python": {"entry": "python-foreign"}}}), encoding="utf-8")
monkeypatch.setenv("HERMES_RUNTIME_DIR", str(foreign_store))
gateway._prepare_service_launcher()
wrapper = (repo / ".hermes" / "bin" / "hermes").read_text(encoding="utf-8-sig")
assert "python-A" in wrapper
assert "python-foreign" not in wrapper
@pytest.mark.platforms("posix")
def test_runtime_override_still_selects_the_runtime_python(tmp_path, monkeypatch):
# The publication split must not mute the override for execution paths:
# resolving a python to RUN still honors HERMES_RUNTIME_DIR by default.
repo, home, interpreter = fixture_tree(tmp_path, monkeypatch)
foreign_store = tmp_path / "foreign" / "tools"
foreign_store.mkdir(parents=True)
foreign_python = foreign_store / "python-foreign" / "bin" / "python3"
foreign_python.parent.mkdir(parents=True)
foreign_python.symlink_to(interpreter)
(foreign_store / "facts.json").write_text(
json.dumps({"packages": {"python": {"entry": "python-foreign"}}}), encoding="utf-8")
monkeypatch.setenv("HERMES_RUNTIME_DIR", str(foreign_store))
selected = _launchers.resolve_store_python(repo)
assert selected == foreign_store / "python-foreign" / "bin" / "python3"
def _record_store_python(store: Path, interpreter: Path) -> Path:
python = store / "python-fixture" / "bin" / "python3"
python.parent.mkdir(parents=True)
python.symlink_to(interpreter)
(store / "facts.json").write_text(
json.dumps({"packages": {"python": {"entry": "python-fixture"}}}), encoding="utf-8")
return python
@pytest.mark.platforms("posix")
def test_install_launchers_never_bind_a_fixture_store_under_scratch(tmp_path, monkeypatch):
# #131745's layout: a process aimed at an e2e fixture home under the
# install's own cache/scratch republishes the real install's launchers.
# Idle pruning later deletes that store, and the service exits 127.
repo, home, interpreter = fixture_tree(tmp_path, monkeypatch)
own = _launchers.resolve_store_python(repo)
fixture = home / "cache" / "scratch" / "hermes-e2e-media-overlap-run" / "hermes-home" / "tools"
_record_store_python(fixture, interpreter)
monkeypatch.setenv("HERMES_RUNTIME_DIR", str(fixture))
written = _launchers.ensure_install_launchers(repo, repo / ".hermes" / "bin")
assert len(written) == len(_launchers.WINDOWS_BIN_LAUNCHERS)
for path in written:
wrapper = Path(path).read_text(encoding="utf-8-sig")
assert "hermes-e2e-media-overlap-run" not in wrapper
assert shlex.quote(str(own)) in wrapper
@pytest.mark.platforms("posix")
def test_publication_uses_the_runtime_store_when_the_tree_records_none(tmp_path, monkeypatch):
# The desktop source-backend contract keeps its store beside HERMES_HOME
# and names it through HERMES_RUNTIME_DIR. A tree with no store of its own
# still publishes from that one.
repo, home, interpreter = fixture_tree(tmp_path, monkeypatch)
(home / "tools" / "facts.json").unlink()
runtime = tmp_path / "runtime-tools"
python = _record_store_python(runtime, interpreter)
monkeypatch.setenv("HERMES_RUNTIME_DIR", str(runtime))
written = _launchers.ensure_install_launchers(repo, repo / ".hermes" / "bin")
assert len(written) == len(_launchers.WINDOWS_BIN_LAUNCHERS)
for path in written:
assert shlex.quote(str(python)) in Path(path).read_text(encoding="utf-8-sig")